Feat/outline thickness and tp weapon hold #22
@@ -94,6 +94,12 @@ var _joint_helpers: Array = []
|
|||||||
var _rig_info: Dictionary = {}
|
var _rig_info: Dictionary = {}
|
||||||
var _spring_mod: SpringBones
|
var _spring_mod: SpringBones
|
||||||
var is_holding_weapon: bool = false
|
var is_holding_weapon: bool = false
|
||||||
|
## Which skin this is, so per-character hold tuning can be looked up. Set by
|
||||||
|
## whoever spawns the model; falls back to the GLB's basename.
|
||||||
|
var skin_id: String = ""
|
||||||
|
## Live hold overrides. Written by debug/weapon_lab.gd while tuning and by
|
||||||
|
## set_weapon() from the saved table otherwise.
|
||||||
|
var hold_tune: Dictionary = {}
|
||||||
|
|
||||||
# Animation blending: locomotion plays full-body through a Transition node;
|
# Animation blending: locomotion plays full-body through a Transition node;
|
||||||
# gameplay one-shots (reload/throw/shoot/hit) play through an
|
# gameplay one-shots (reload/throw/shoot/hit) play through an
|
||||||
@@ -863,6 +869,22 @@ func get_lean_debug() -> float:
|
|||||||
return _cur_fwd
|
return _cur_fwd
|
||||||
|
|
||||||
|
|
||||||
|
## Push a new hold tuning table in and re-seat the weapon with it.
|
||||||
|
##
|
||||||
|
## For debug/weapon_lab.gd: the knobs that live on the pose layer take effect on
|
||||||
|
## the next frame, but weapon SIZE and the grip offset are baked into the
|
||||||
|
## attachment when the weapon is seated, so those need the weapon re-measured.
|
||||||
|
func set_hold_tuning(t: Dictionary) -> void:
|
||||||
|
hold_tune = t
|
||||||
|
if _pose_mod:
|
||||||
|
_pose_mod.tune = t
|
||||||
|
if _weapon_attachment and _weapon_attachment.get_child_count() > 0:
|
||||||
|
var w := _weapon_attachment.get_child(0) as Node3D
|
||||||
|
if w:
|
||||||
|
w.transform = Transform3D.IDENTITY
|
||||||
|
_measure_weapon(w)
|
||||||
|
|
||||||
|
|
||||||
## Whether the locomotion cycle is running backwards, which points the stride
|
## Whether the locomotion cycle is running backwards, which points the stride
|
||||||
## the opposite way to the hips. For debug/travel_dir_check.gd.
|
## the opposite way to the hips. For debug/travel_dir_check.gd.
|
||||||
func stride_reversed_debug() -> bool:
|
func stride_reversed_debug() -> bool:
|
||||||
@@ -893,6 +915,12 @@ func set_weapon(script_path: String) -> void:
|
|||||||
is_holding_weapon = script_path != ""
|
is_holding_weapon = script_path != ""
|
||||||
if script_path == "" or not skeleton:
|
if script_path == "" or not skeleton:
|
||||||
return
|
return
|
||||||
|
# Per-character, per-weapon hold overrides, if any have been tuned. Empty is
|
||||||
|
# the normal case and means "use what the code derives".
|
||||||
|
if hold_tune.is_empty():
|
||||||
|
var sid := skin_id if skin_id != "" else model_path.get_file().get_basename()
|
||||||
|
hold_tune = WeaponHoldTuning.resolve(WeaponHoldTuning.load_all(), sid,
|
||||||
|
script_path.get_file().get_basename())
|
||||||
|
|
||||||
var script = load(script_path)
|
var script = load(script_path)
|
||||||
if not script:
|
if not script:
|
||||||
@@ -1039,15 +1067,25 @@ func _measure_weapon(w: Node3D) -> void:
|
|||||||
|
|
||||||
# Size the gun to the arm that has to hold it, then seat the grip in the fist.
|
# Size the gun to the arm that has to hold it, then seat the grip in the fist.
|
||||||
var k := _weapon_scale(stock_u + fore_u)
|
var k := _weapon_scale(stock_u + fore_u)
|
||||||
|
# A tuned size wins over the derived one. The derivation keeps the handguard
|
||||||
|
# inside the support arm's reach, which is a floor on believability, not a
|
||||||
|
# statement about how big the gun should LOOK.
|
||||||
|
if hold_tune.get("weapon_scale", 0.0) > 0.01:
|
||||||
|
k = float(hold_tune["weapon_scale"])
|
||||||
w.scale = Vector3.ONE * k
|
w.scale = Vector3.ONE * k
|
||||||
w.position -= w.transform.basis * grip
|
w.position -= w.transform.basis * grip
|
||||||
|
|
||||||
_pose_mod.gun_fore = fore_u * k
|
_pose_mod.gun_fore = fore_u * k
|
||||||
|
if hold_tune.get("gun_fore", 0.0) > 0.0001:
|
||||||
|
_pose_mod.gun_fore = float(hold_tune["gun_fore"])
|
||||||
|
|
||||||
# Stock: not authored anywhere, so derive it from the barrel. Half the
|
# Stock: not authored anywhere, so derive it from the barrel. Half the
|
||||||
# grip-to-muzzle distance behind the grip lands the butt in the shoulder
|
# grip-to-muzzle distance behind the grip lands the butt in the shoulder
|
||||||
# pocket for every gun in the set.
|
# pocket for every gun in the set.
|
||||||
_pose_mod.gun_stock = stock_u * k
|
_pose_mod.gun_stock = stock_u * k
|
||||||
|
if hold_tune.get("gun_stock", 0.0) > 0.0001:
|
||||||
|
_pose_mod.gun_stock = float(hold_tune["gun_stock"])
|
||||||
|
_pose_mod.tune = hold_tune
|
||||||
|
|
||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
@@ -1172,6 +1210,25 @@ class ShooterPoseModifier extends SkeletonModifier3D:
|
|||||||
var roles: Dictionary = {}
|
var roles: Dictionary = {}
|
||||||
## "index.L" -> its bone names, knuckle to fingertip, from the same sidecar.
|
## "index.L" -> its bone names, knuckle to fingertip, from the same sidecar.
|
||||||
var fingers: Dictionary = {}
|
var fingers: Dictionary = {}
|
||||||
|
## Per-character hold overrides — see characters/weapon_hold_tuning.gd and
|
||||||
|
## debug/weapon_lab.gd. A dictionary rather than a field per knob so a new
|
||||||
|
## knob needs no plumbing: add it here, read it with _t/_tv, and the lab
|
||||||
|
## picks it up from the same table.
|
||||||
|
var tune: Dictionary = {}
|
||||||
|
## Where the hold put the weapon this frame, in skeleton space. The lab draws
|
||||||
|
## these so the points being tuned are visible rather than inferred.
|
||||||
|
var dbg_grip: Vector3 = Vector3.ZERO
|
||||||
|
var dbg_fore: Vector3 = Vector3.ZERO
|
||||||
|
var dbg_stock: Vector3 = Vector3.ZERO
|
||||||
|
|
||||||
|
func _t(key: String, fallback: float) -> float:
|
||||||
|
return float(tune.get(key, fallback))
|
||||||
|
|
||||||
|
func _tv(key: String, fallback: Vector3) -> Vector3:
|
||||||
|
var v = tune.get(key)
|
||||||
|
# A zero-length vector means "not set" — see WeaponHoldTuning.KNOBS. It
|
||||||
|
# is how the elbow poles keep their hip/ADS blend unless overridden.
|
||||||
|
return v if (v is Vector3 and v.length() > 0.0001) else fallback
|
||||||
var _fing: Dictionary = {} # same, resolved to bone indices
|
var _fing: Dictionary = {} # same, resolved to bone indices
|
||||||
var _curl: Dictionary = {} # "L"/"R" -> curl axis in the rest frame
|
var _curl: Dictionary = {} # "L"/"R" -> curl axis in the rest frame
|
||||||
## "L"/"R" -> Basis(along, palm, curl), the hand's anatomy in the rest pose.
|
## "L"/"R" -> Basis(along, palm, curl), the hand's anatomy in the rest pose.
|
||||||
@@ -1488,12 +1545,14 @@ class ShooterPoseModifier extends SkeletonModifier3D:
|
|||||||
if ua_r < 0:
|
if ua_r < 0:
|
||||||
return
|
return
|
||||||
var breathe := sin(_time * 2.2) * 0.012 + fwd * 0.02
|
var breathe := sin(_time * 2.2) * 0.012 + fwd * 0.02
|
||||||
|
var t_pocket_hip := _tv("pocket_hip", POCKET_HIP)
|
||||||
|
var t_pocket_ads := _tv("pocket_ads", POCKET_ADS)
|
||||||
# ~7 degrees of muzzle rise per shot, stacking a little on full auto.
|
# ~7 degrees of muzzle rise per shot, stacking a little on full auto.
|
||||||
var kick := recoil * 0.12
|
var kick := recoil * 0.12
|
||||||
|
|
||||||
# 1. The gun's line: pitched down at low-ready, on the camera line at
|
# 1. The gun's line: pitched down at low-ready, on the camera line at
|
||||||
# ADS, kicked up by recoil.
|
# ADS, kicked up by recoil.
|
||||||
var gun_pitch := lerpf(GUN_PITCH_HIP, -aim_pitch, ads) - kick + breathe
|
var gun_pitch := lerpf(_t("pitch_hip", GUN_PITCH_HIP), -aim_pitch, ads) - kick + breathe
|
||||||
var aim_dir: Vector3 = (Quaternion(Vector3(1, 0, 0), gun_pitch) \
|
var aim_dir: Vector3 = (Quaternion(Vector3(1, 0, 0), gun_pitch) \
|
||||||
* Vector3(0, 0, 1)).normalized()
|
* Vector3(0, 0, 1)).normalized()
|
||||||
# Gun's "up" — perpendicular to the barrel in the vertical plane, so the
|
# Gun's "up" — perpendicular to the barrel in the vertical plane, so the
|
||||||
@@ -1506,7 +1565,7 @@ class ShooterPoseModifier extends SkeletonModifier3D:
|
|||||||
|
|
||||||
# 2. Anchor the stock at the shoulder, then walk out along the barrel.
|
# 2. Anchor the stock at the shoulder, then walk out along the barrel.
|
||||||
var shoulder := skel.get_bone_global_pose(ua_r).origin
|
var shoulder := skel.get_bone_global_pose(ua_r).origin
|
||||||
var pocket: Vector3 = POCKET_HIP.lerp(POCKET_ADS, ads)
|
var pocket: Vector3 = t_pocket_hip.lerp(t_pocket_ads, ads)
|
||||||
var stock_pos := shoulder + pocket
|
var stock_pos := shoulder + pocket
|
||||||
var grip_pos := stock_pos + aim_dir * gun_stock
|
var grip_pos := stock_pos + aim_dir * gun_stock
|
||||||
# The support hand rides as far out the handguard as it can actually
|
# The support hand rides as far out the handguard as it can actually
|
||||||
@@ -1532,6 +1591,9 @@ class ShooterPoseModifier extends SkeletonModifier3D:
|
|||||||
if fore_dist <= floor_fore:
|
if fore_dist <= floor_fore:
|
||||||
break
|
break
|
||||||
var fore_pos := grip_pos + aim_dir * fore_dist
|
var fore_pos := grip_pos + aim_dir * fore_dist
|
||||||
|
dbg_grip = grip_pos
|
||||||
|
dbg_fore = fore_pos
|
||||||
|
dbg_stock = stock_pos
|
||||||
|
|
||||||
# 3. Support hand goes to the mag well during a reload (under the
|
# 3. Support hand goes to the mag well during a reload (under the
|
||||||
# receiver — the correct side), otherwise to the handguard.
|
# receiver — the correct side), otherwise to the handguard.
|
||||||
@@ -1550,8 +1612,8 @@ class ShooterPoseModifier extends SkeletonModifier3D:
|
|||||||
l_target = mag_well.lerp(fore_pos, (p - 0.80) / 0.20)
|
l_target = mag_well.lerp(fore_pos, (p - 0.80) / 0.20)
|
||||||
|
|
||||||
# 4. Solve both arms onto those points.
|
# 4. Solve both arms onto those points.
|
||||||
var pole_r: Vector3 = POLE_R_HIP.lerp(POLE_R_ADS, ads).normalized()
|
var pole_r: Vector3 = _tv("pole_r", POLE_R_HIP.lerp(POLE_R_ADS, ads)).normalized()
|
||||||
var pole_l: Vector3 = POLE_L_HIP.lerp(POLE_L_ADS, ads).normalized()
|
var pole_l: Vector3 = _tv("pole_l", POLE_L_HIP.lerp(POLE_L_ADS, ads)).normalized()
|
||||||
var g_fa_r := _ik_arm(skel, "DEF-upper_arm.R", "DEF-forearm.R",
|
var g_fa_r := _ik_arm(skel, "DEF-upper_arm.R", "DEF-forearm.R",
|
||||||
"DEF-hand.R", grip_pos, pole_r, _hold_r)
|
"DEF-hand.R", grip_pos, pole_r, _hold_r)
|
||||||
var g_fa_l := _ik_arm(skel, "DEF-upper_arm.L", "DEF-forearm.L",
|
var g_fa_l := _ik_arm(skel, "DEF-upper_arm.L", "DEF-forearm.L",
|
||||||
@@ -1580,7 +1642,8 @@ class ShooterPoseModifier extends SkeletonModifier3D:
|
|||||||
var up_flat := (up_now - aim_dir * up_now.dot(aim_dir))
|
var up_flat := (up_now - aim_dir * up_now.dot(aim_dir))
|
||||||
if up_flat.length_squared() > 0.0001:
|
if up_flat.length_squared() > 0.0001:
|
||||||
var roll := up_flat.normalized().signed_angle_to(gun_up, aim_dir)
|
var roll := up_flat.normalized().signed_angle_to(gun_up, aim_dir)
|
||||||
arc = Quaternion(aim_dir, roll + R_HAND_TWIST) * arc
|
arc = Quaternion(aim_dir, roll + _t("trigger_roll",
|
||||||
|
R_HAND_TWIST)) * arc
|
||||||
_set_global_rot(skel, hand, g_fa_r, arc, _hold_r)
|
_set_global_rot(skel, hand, g_fa_r, arc, _hold_r)
|
||||||
|
|
||||||
# 6. Support hand: WRAP the handguard.
|
# 6. Support hand: WRAP the handguard.
|
||||||
@@ -1605,7 +1668,11 @@ class ShooterPoseModifier extends SkeletonModifier3D:
|
|||||||
if hand_l >= 0:
|
if hand_l >= 0:
|
||||||
# -aim_dir so the hand comes at the handguard from the body side
|
# -aim_dir so the hand comes at the handguard from the body side
|
||||||
# rather than reaching over it backwards.
|
# rather than reaching over it backwards.
|
||||||
var want := Basis(gun_up.cross(-aim_dir).normalized(), gun_up,
|
# `support_roll` rolls the whole target frame about the barrel,
|
||||||
|
# which is the one axis a hand wrapping a cylinder is free in.
|
||||||
|
var roll_q := Quaternion(aim_dir, _t("support_roll", 0.0))
|
||||||
|
var up_r := roll_q * gun_up
|
||||||
|
var want := Basis(up_r.cross(-aim_dir).normalized(), up_r,
|
||||||
-aim_dir)
|
-aim_dir)
|
||||||
var rest: Basis = _hand_frame["L"]
|
var rest: Basis = _hand_frame["L"]
|
||||||
var g_hand := (want * rest.inverse()).get_rotation_quaternion() \
|
var g_hand := (want * rest.inverse()).get_rotation_quaternion() \
|
||||||
@@ -1646,14 +1713,16 @@ class ShooterPoseModifier extends SkeletonModifier3D:
|
|||||||
if bones.is_empty():
|
if bones.is_empty():
|
||||||
continue
|
continue
|
||||||
# The right index rides the trigger; everything else wraps.
|
# The right index rides the trigger; everything else wraps.
|
||||||
var amount: Array = CURL_TRIGGER if (side == "R" and digit == "index") else CURL_WRAP
|
var trigger: bool = side == "R" and digit == "index"
|
||||||
|
var amount: Array = CURL_TRIGGER if trigger else CURL_WRAP
|
||||||
|
var scale: float = _t("curl_trigger", 1.0) if trigger else _t("curl_wrap", 1.0)
|
||||||
for i in bones.size():
|
for i in bones.size():
|
||||||
var a: float = amount[i] if i < amount.size() else amount[-1]
|
var a: float = amount[i] if i < amount.size() else amount[-1]
|
||||||
_add_space(skel, bones[i], Quaternion(axis, a * w))
|
_add_space(skel, bones[i], Quaternion(axis, a * scale * w))
|
||||||
var thumb: PackedInt32Array = _fing.get("thumb." + side, PackedInt32Array())
|
var thumb: PackedInt32Array = _fing.get("thumb." + side, PackedInt32Array())
|
||||||
for i in thumb.size():
|
for i in thumb.size():
|
||||||
var a: float = CURL_THUMB[i] if i < CURL_THUMB.size() else CURL_THUMB[-1]
|
var a: float = CURL_THUMB[i] if i < CURL_THUMB.size() else CURL_THUMB[-1]
|
||||||
_add_space(skel, thumb[i], Quaternion(axis, a * w))
|
_add_space(skel, thumb[i], Quaternion(axis, a * _t("curl_thumb", 1.0) * w))
|
||||||
|
|
||||||
## Straight-arm length of an arm chain, from the rest pose.
|
## Straight-arm length of an arm chain, from the rest pose.
|
||||||
func _arm_reach(skel: Skeleton3D, ua_name: String, fa_name: String,
|
func _arm_reach(skel: Skeleton3D, ua_name: String, fa_name: String,
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
extends Object
|
||||||
|
class_name WeaponHoldTuning
|
||||||
|
|
||||||
|
## Per-character, per-weapon overrides for how a gun is held.
|
||||||
|
##
|
||||||
|
## Every knob in the rifle hold used to be a constant tuned against one rig, and
|
||||||
|
## every one of them was wrong on the next character imported — the mount
|
||||||
|
## rotation, the wrist twist, the weapon size. The ones that CAN be derived from
|
||||||
|
## the skeleton now are. The rest are genuinely art direction: how high the stock
|
||||||
|
## rides, how far the elbow flares, how hard the fingers close. Those want an
|
||||||
|
## artist's eye and a slider, not another guess in code.
|
||||||
|
##
|
||||||
|
## This is where that judgement is stored. debug/weapon_lab.gd writes it;
|
||||||
|
## SkinnedPlayerModel reads it when a weapon is equipped.
|
||||||
|
##
|
||||||
|
## Resolution is layered, most general first, so a single number can be set once
|
||||||
|
## for everything and then contradicted where it matters:
|
||||||
|
##
|
||||||
|
## defaults every character, every weapon
|
||||||
|
## skins.<skin>._all this character, every weapon
|
||||||
|
## skins.<skin>.<weapon> this character, this weapon
|
||||||
|
##
|
||||||
|
## An empty file means "use the built-in defaults", so the game runs perfectly
|
||||||
|
## well with no tuning at all — this only ever adds information.
|
||||||
|
|
||||||
|
const PATH := "res://assets/characters/weapon_holds.json"
|
||||||
|
## Written to the project when running from source; falls back to user:// for an
|
||||||
|
## exported build, where res:// is read-only.
|
||||||
|
const USER_PATH := "user://weapon_holds.json"
|
||||||
|
|
||||||
|
## key -> [label, minimum, maximum, is_vector, default]
|
||||||
|
##
|
||||||
|
## The lab builds its whole UI from this, so adding a knob here is all it takes
|
||||||
|
## to expose one. Ranges are what a plausible answer lives inside, not what the
|
||||||
|
## value can technically be.
|
||||||
|
##
|
||||||
|
## The DEFAULT must match what the code does when nothing is tuned, or the lab
|
||||||
|
## lies: a slider parked at 0 next to a code default of 1.0 means the first touch
|
||||||
|
## of that slider silently switches the behaviour off. Zero means "let the code
|
||||||
|
## decide" only where it is called out below.
|
||||||
|
const KNOBS := [
|
||||||
|
["weapon_scale", "Weapon size (0 = fit to arm)", 0.0, 1.4, false, 0.0],
|
||||||
|
["gun_fore", "Support hand along barrel (0 = auto)", 0.0, 0.50, false, 0.0],
|
||||||
|
["gun_stock", "Grip to buttstock (0 = auto)", 0.0, 0.45, false, 0.0],
|
||||||
|
["pitch_hip", "Muzzle pitch, low ready", -0.6, 0.6, false, 0.16],
|
||||||
|
["support_roll", "Support hand roll", -3.2, 3.2, false, 0.0],
|
||||||
|
["trigger_roll", "Trigger hand roll", -3.2, 3.2, false, 0.0],
|
||||||
|
["curl_wrap", "Finger wrap", 0.0, 2.0, false, 1.0],
|
||||||
|
["curl_trigger", "Trigger finger", 0.0, 2.0, false, 1.0],
|
||||||
|
["curl_thumb", "Thumb", 0.0, 2.0, false, 1.0],
|
||||||
|
["pocket_hip", "Stock pocket, low ready", -0.30, 0.30, true,
|
||||||
|
Vector3(0.03, -0.07, 0.06)],
|
||||||
|
["pocket_ads", "Stock pocket, aiming", -0.30, 0.30, true,
|
||||||
|
Vector3(0.05, 0.01, 0.07)],
|
||||||
|
# Zero means "use the code's own hip/ADS blend" for these two — see _tv in
|
||||||
|
# ShooterPoseModifier, which treats a zero-length vector as unset.
|
||||||
|
["pole_r", "Firing elbow (0 = auto)", -1.5, 1.5, true, Vector3.ZERO],
|
||||||
|
["pole_l", "Support elbow (0 = auto)", -1.5, 1.5, true, Vector3.ZERO],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
## The built-in value for a knob, for a lab that has nothing saved yet.
|
||||||
|
static func default_for(key: String):
|
||||||
|
for spec in KNOBS:
|
||||||
|
if spec[0] == key:
|
||||||
|
return spec[5]
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
static func _read(path: String) -> Dictionary:
|
||||||
|
if not FileAccess.file_exists(path):
|
||||||
|
return {}
|
||||||
|
var parsed = JSON.parse_string(FileAccess.get_file_as_string(path))
|
||||||
|
return parsed if typeof(parsed) == TYPE_DICTIONARY else {}
|
||||||
|
|
||||||
|
|
||||||
|
static func load_all() -> Dictionary:
|
||||||
|
# user:// wins, so a tuning pass made in an exported build is not lost, and
|
||||||
|
# so the lab can be used without a writable project directory.
|
||||||
|
var base := _read(PATH)
|
||||||
|
var over := _read(USER_PATH)
|
||||||
|
if over.is_empty():
|
||||||
|
return base
|
||||||
|
if base.is_empty():
|
||||||
|
return over
|
||||||
|
# Shallow merge is enough: the layers below are merged per key anyway.
|
||||||
|
for k in over:
|
||||||
|
base[k] = over[k]
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
## The resolved knob table for one character holding one weapon.
|
||||||
|
##
|
||||||
|
## Vectors survive the JSON round trip as three-element arrays, so they are
|
||||||
|
## rebuilt here rather than at every read site.
|
||||||
|
static func resolve(all: Dictionary, skin_id: String, weapon_id: String) -> Dictionary:
|
||||||
|
var out := {}
|
||||||
|
var skins: Dictionary = all.get("skins", {})
|
||||||
|
var mine: Dictionary = skins.get(skin_id, {})
|
||||||
|
for layer in [all.get("defaults", {}), mine.get("_all", {}),
|
||||||
|
mine.get(weapon_id, {})]:
|
||||||
|
if typeof(layer) != TYPE_DICTIONARY:
|
||||||
|
continue
|
||||||
|
for k in layer:
|
||||||
|
out[k] = layer[k]
|
||||||
|
for k in out.keys():
|
||||||
|
var v = out[k]
|
||||||
|
if v is Array and v.size() == 3:
|
||||||
|
out[k] = Vector3(float(v[0]), float(v[1]), float(v[2]))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
## Store one character+weapon's knobs and write the file. Returns where it went.
|
||||||
|
static func save(all: Dictionary, skin_id: String, weapon_id: String,
|
||||||
|
knobs: Dictionary) -> String:
|
||||||
|
if not all.has("skins"):
|
||||||
|
all["skins"] = {}
|
||||||
|
if not all["skins"].has(skin_id):
|
||||||
|
all["skins"][skin_id] = {}
|
||||||
|
var flat := {}
|
||||||
|
for k in knobs:
|
||||||
|
var v = knobs[k]
|
||||||
|
flat[k] = [v.x, v.y, v.z] if v is Vector3 else v
|
||||||
|
all["skins"][skin_id][weapon_id] = flat
|
||||||
|
|
||||||
|
var text := JSON.stringify(all, " ")
|
||||||
|
# Prefer the project copy so a tuning pass lands in version control with the
|
||||||
|
# character it belongs to; fall back to user:// when res:// is not writable.
|
||||||
|
var f := FileAccess.open(PATH, FileAccess.WRITE)
|
||||||
|
if f:
|
||||||
|
f.store_string(text)
|
||||||
|
f.close()
|
||||||
|
return PATH
|
||||||
|
f = FileAccess.open(USER_PATH, FileAccess.WRITE)
|
||||||
|
if f:
|
||||||
|
f.store_string(text)
|
||||||
|
f.close()
|
||||||
|
return USER_PATH
|
||||||
|
return "<could not write>"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cxbghp14y3i7v
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
extends Node3D
|
||||||
|
|
||||||
|
## Weapon hold lab — tune how each character holds each gun, in 3D, live.
|
||||||
|
##
|
||||||
|
## godot --path . res://debug/weapon_lab.tscn
|
||||||
|
##
|
||||||
|
## Every knob in the rifle hold started life as a constant tuned against one rig,
|
||||||
|
## and every one of them was wrong on the next character imported. The ones that
|
||||||
|
## CAN be derived from the skeleton now are — the mount rotation, the wrist roll,
|
||||||
|
## the weapon size. What is left is genuinely art direction: how high the stock
|
||||||
|
## rides, how far the elbow flares, how hard the fingers close. Those want an eye
|
||||||
|
## and a slider, not another guess in code.
|
||||||
|
##
|
||||||
|
## Pick a character, pick a weapon, pick a pose, drag the sliders, press Save.
|
||||||
|
## The result lands in assets/characters/weapon_holds.json keyed by character and
|
||||||
|
## weapon, and the game reads it whenever that gun is equipped.
|
||||||
|
##
|
||||||
|
## CONTROLS
|
||||||
|
## left drag orbit wheel zoom
|
||||||
|
## middle drag pan F frame the hands
|
||||||
|
## R reset knobs S save C copy JSON to clipboard
|
||||||
|
##
|
||||||
|
## The three coloured markers are the points being solved for — red is the
|
||||||
|
## trigger grip, green the support hand on the handguard, blue the buttstock. If
|
||||||
|
## a hand is not on its marker the IK could not reach, which is a different
|
||||||
|
## problem from the marker being in the wrong place.
|
||||||
|
|
||||||
|
const POSES := [
|
||||||
|
["Low ready", "ground", 0.0, 0.0],
|
||||||
|
["Aiming", "ground", 0.0, 1.0],
|
||||||
|
["Running", "ground", 9.0, 0.0],
|
||||||
|
["Crouched", "ground", 0.0, 0.0],
|
||||||
|
]
|
||||||
|
|
||||||
|
var _model: SkinnedPlayerModel
|
||||||
|
var _skins: Array = []
|
||||||
|
var _weapons: Array = []
|
||||||
|
var _skin := 0
|
||||||
|
var _weapon := 0
|
||||||
|
var _pose := 0
|
||||||
|
var _knobs: Dictionary = {}
|
||||||
|
var _all: Dictionary = {}
|
||||||
|
var _sliders: Dictionary = {}
|
||||||
|
var _status: Label
|
||||||
|
var _cam: Camera3D
|
||||||
|
var _yaw := 0.6
|
||||||
|
var _pitch := -0.1
|
||||||
|
var _dist := 2.2
|
||||||
|
var _pivot := Vector3(0, 1.25, 0)
|
||||||
|
var _markers: Array = []
|
||||||
|
var _pickers: Dictionary = {}
|
||||||
|
|
||||||
|
|
||||||
|
## Frames to wait before the self-shot below. The model loads asynchronously and
|
||||||
|
## the cloth solver needs a moment to settle, so an immediate capture shows a
|
||||||
|
## half-built character.
|
||||||
|
const SHOT_WARMUP := 40
|
||||||
|
var _shot_path := ""
|
||||||
|
var _frames := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_build_world()
|
||||||
|
_collect_sources()
|
||||||
|
# `-- shot <path> [skin] [weapon]` renders one frame and quits, so the lab
|
||||||
|
# can be checked without a human at the controls.
|
||||||
|
var args := OS.get_cmdline_user_args()
|
||||||
|
if args.size() >= 2 and String(args[0]) == "shot":
|
||||||
|
_shot_path = String(args[1])
|
||||||
|
if args.size() > 2:
|
||||||
|
_skin = maxi(0, _index_of(_skins, String(args[2])))
|
||||||
|
if args.size() > 3:
|
||||||
|
_weapon = maxi(0, _index_of(_weapons, String(args[3])))
|
||||||
|
_build_ui()
|
||||||
|
_reload_model()
|
||||||
|
|
||||||
|
|
||||||
|
func _index_of(list: Array, id: String) -> int:
|
||||||
|
for i in list.size():
|
||||||
|
if String(list[i].id) == id:
|
||||||
|
return i
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
# ── scene ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func _build_world() -> void:
|
||||||
|
var env := WorldEnvironment.new()
|
||||||
|
var e := Environment.new()
|
||||||
|
e.background_mode = Environment.BG_COLOR
|
||||||
|
e.background_color = Color(0.17, 0.18, 0.22)
|
||||||
|
e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||||
|
e.ambient_light_color = Color(0.55, 0.57, 0.65)
|
||||||
|
e.ambient_light_energy = 1.0
|
||||||
|
env.environment = e
|
||||||
|
add_child(env)
|
||||||
|
|
||||||
|
var key := DirectionalLight3D.new()
|
||||||
|
key.rotation_degrees = Vector3(-42, 132, 0)
|
||||||
|
key.light_energy = 1.5
|
||||||
|
add_child(key)
|
||||||
|
var fill := DirectionalLight3D.new()
|
||||||
|
fill.rotation_degrees = Vector3(-18, -40, 0)
|
||||||
|
fill.light_energy = 0.5
|
||||||
|
add_child(fill)
|
||||||
|
|
||||||
|
_cam = Camera3D.new()
|
||||||
|
_cam.fov = 45.0
|
||||||
|
add_child(_cam)
|
||||||
|
_update_camera()
|
||||||
|
|
||||||
|
# Grip / support / stock, so the points under the sliders are visible.
|
||||||
|
for c in [Color(1, 0.3, 0.3), Color(0.3, 1, 0.4), Color(0.4, 0.6, 1)]:
|
||||||
|
var m := MeshInstance3D.new()
|
||||||
|
var sphere := SphereMesh.new()
|
||||||
|
sphere.radius = 0.012
|
||||||
|
sphere.height = 0.024
|
||||||
|
m.mesh = sphere
|
||||||
|
var mat := StandardMaterial3D.new()
|
||||||
|
mat.albedo_color = c
|
||||||
|
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||||
|
# Depth-tested on purpose. Drawn through the body they look like they are
|
||||||
|
# floating in front of the chest when they are in fact behind an arm,
|
||||||
|
# which is exactly the wrong impression for judging whether a hand is on
|
||||||
|
# its target.
|
||||||
|
mat.no_depth_test = false
|
||||||
|
m.material_override = mat
|
||||||
|
add_child(m)
|
||||||
|
_markers.append(m)
|
||||||
|
|
||||||
|
|
||||||
|
func _collect_sources() -> void:
|
||||||
|
for id in SkinManager.skins:
|
||||||
|
var s = SkinManager.skins[id]
|
||||||
|
# GLB-backed skins only; the colour tints have no skeleton to pose.
|
||||||
|
if "model_path" in s and String(s.model_path) != "":
|
||||||
|
_skins.append({"id": id, "name": s.skin_name, "path": s.model_path})
|
||||||
|
_skins.sort_custom(func(a, b): return a.id < b.id)
|
||||||
|
for id in LoadoutManager.weapon_db:
|
||||||
|
var w: Dictionary = LoadoutManager.weapon_db[id]
|
||||||
|
if String(w.get("script", "")) != "":
|
||||||
|
_weapons.append({"id": id, "name": w.get("name", id),
|
||||||
|
"script": w["script"]})
|
||||||
|
_weapons.sort_custom(func(a, b): return a.id < b.id)
|
||||||
|
_all = WeaponHoldTuning.load_all()
|
||||||
|
|
||||||
|
|
||||||
|
func _reload_model() -> void:
|
||||||
|
if _model:
|
||||||
|
_model.queue_free()
|
||||||
|
_model = SkinnedPlayerModel.new()
|
||||||
|
_model.model_path = _skins[_skin].path
|
||||||
|
_model.skin_id = _skins[_skin].id
|
||||||
|
add_child(_model)
|
||||||
|
await get_tree().process_frame
|
||||||
|
_load_knobs()
|
||||||
|
_model.set_weapon(_weapons[_weapon].script)
|
||||||
|
_model.set_hold_tuning(_knobs)
|
||||||
|
_apply_pose()
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_pose() -> void:
|
||||||
|
if not _model or not _model.loaded:
|
||||||
|
return
|
||||||
|
var p: Array = POSES[_pose]
|
||||||
|
_model.update_state(p[1], p[2], _pose == 3)
|
||||||
|
_model.set_locomotion(0.0, 1.0 if p[2] > 0.1 else 0.0, p[3])
|
||||||
|
|
||||||
|
|
||||||
|
func _process(_delta: float) -> void:
|
||||||
|
_apply_pose()
|
||||||
|
if _shot_path != "":
|
||||||
|
_frames += 1
|
||||||
|
if _frames == SHOT_WARMUP:
|
||||||
|
# Frame the hands, which is the only part anyone is judging.
|
||||||
|
if _model and _model._pose_mod:
|
||||||
|
_pivot = _model.skeleton.global_transform * _model._pose_mod.dbg_grip
|
||||||
|
_dist = 0.7
|
||||||
|
_yaw = 1.15
|
||||||
|
_pitch = -0.15
|
||||||
|
_update_camera()
|
||||||
|
elif _frames > SHOT_WARMUP + 2:
|
||||||
|
var img := get_viewport().get_texture().get_image()
|
||||||
|
img.save_png(_shot_path)
|
||||||
|
print("weapon_lab: saved ", _shot_path)
|
||||||
|
get_tree().quit()
|
||||||
|
return
|
||||||
|
if _model and _model._pose_mod and _model.skeleton:
|
||||||
|
var to_world: Transform3D = _model.skeleton.global_transform
|
||||||
|
var pm = _model._pose_mod
|
||||||
|
var pts := [pm.dbg_grip, pm.dbg_fore, pm.dbg_stock]
|
||||||
|
for i in _markers.size():
|
||||||
|
_markers[i].global_position = to_world * pts[i]
|
||||||
|
_markers[i].visible = pts[i] != Vector3.ZERO
|
||||||
|
|
||||||
|
|
||||||
|
# ── knobs ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func _load_knobs() -> void:
|
||||||
|
_knobs = WeaponHoldTuning.resolve(_all, _skins[_skin].id, _weapons[_weapon].id)
|
||||||
|
for k in _sliders:
|
||||||
|
var entry = _sliders[k]
|
||||||
|
var spec: Array = entry.spec
|
||||||
|
if spec[4]:
|
||||||
|
var v: Vector3 = _knobs.get(k, WeaponHoldTuning.default_for(k))
|
||||||
|
entry.x.set_value_no_signal(v.x)
|
||||||
|
entry.y.set_value_no_signal(v.y)
|
||||||
|
entry.z.set_value_no_signal(v.z)
|
||||||
|
else:
|
||||||
|
entry.x.set_value_no_signal(float(_knobs.get(k,
|
||||||
|
WeaponHoldTuning.default_for(k))))
|
||||||
|
_refresh_label(k)
|
||||||
|
|
||||||
|
|
||||||
|
func _knob_changed(key: String) -> void:
|
||||||
|
var entry = _sliders[key]
|
||||||
|
var spec: Array = entry.spec
|
||||||
|
if spec[4]:
|
||||||
|
_knobs[key] = Vector3(entry.x.value, entry.y.value, entry.z.value)
|
||||||
|
else:
|
||||||
|
_knobs[key] = entry.x.value
|
||||||
|
_refresh_label(key)
|
||||||
|
if _model:
|
||||||
|
_model.set_hold_tuning(_knobs)
|
||||||
|
|
||||||
|
|
||||||
|
func _refresh_label(key: String) -> void:
|
||||||
|
var entry = _sliders[key]
|
||||||
|
var spec: Array = entry.spec
|
||||||
|
if spec[4]:
|
||||||
|
entry.label.text = "%s %.3f, %.3f, %.3f" % [spec[1], entry.x.value,
|
||||||
|
entry.y.value, entry.z.value]
|
||||||
|
else:
|
||||||
|
var v: float = entry.x.value
|
||||||
|
entry.label.text = "%s %s" % [spec[1],
|
||||||
|
"auto" if (key == "weapon_scale" and v < 0.01) else "%.3f" % v]
|
||||||
|
|
||||||
|
|
||||||
|
# ── ui ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func _build_ui() -> void:
|
||||||
|
var layer := CanvasLayer.new()
|
||||||
|
add_child(layer)
|
||||||
|
|
||||||
|
var panel := PanelContainer.new()
|
||||||
|
panel.set_anchors_preset(Control.PRESET_LEFT_WIDE)
|
||||||
|
panel.custom_minimum_size = Vector2(430, 0)
|
||||||
|
layer.add_child(panel)
|
||||||
|
|
||||||
|
var scroll := ScrollContainer.new()
|
||||||
|
panel.add_child(scroll)
|
||||||
|
var box := VBoxContainer.new()
|
||||||
|
box.custom_minimum_size = Vector2(410, 0)
|
||||||
|
box.add_theme_constant_override("separation", 2)
|
||||||
|
scroll.add_child(box)
|
||||||
|
|
||||||
|
var title := Label.new()
|
||||||
|
title.text = "WEAPON HOLD LAB"
|
||||||
|
box.add_child(title)
|
||||||
|
|
||||||
|
box.add_child(_picker("Character", _skins, func(i):
|
||||||
|
_skin = i
|
||||||
|
_reload_model()))
|
||||||
|
box.add_child(_picker("Weapon", _weapons, func(i):
|
||||||
|
_weapon = i
|
||||||
|
_load_knobs()
|
||||||
|
if _model:
|
||||||
|
_model.set_weapon(_weapons[_weapon].script)
|
||||||
|
_model.set_hold_tuning(_knobs)))
|
||||||
|
var poses: Array = []
|
||||||
|
for p in POSES:
|
||||||
|
poses.append({"name": p[0]})
|
||||||
|
box.add_child(_picker("Pose", poses, func(i): _pose = i))
|
||||||
|
|
||||||
|
box.add_child(HSeparator.new())
|
||||||
|
for spec in WeaponHoldTuning.KNOBS:
|
||||||
|
box.add_child(_knob_row(spec))
|
||||||
|
|
||||||
|
box.add_child(HSeparator.new())
|
||||||
|
var row := HBoxContainer.new()
|
||||||
|
for b in [["Save (S)", func(): _save()], ["Reset (R)", func(): _reset()],
|
||||||
|
["Copy JSON (C)", func(): _copy()]]:
|
||||||
|
var btn := Button.new()
|
||||||
|
btn.text = b[0]
|
||||||
|
btn.pressed.connect(b[1])
|
||||||
|
row.add_child(btn)
|
||||||
|
box.add_child(row)
|
||||||
|
|
||||||
|
_status = Label.new()
|
||||||
|
_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
_status.custom_minimum_size = Vector2(400, 40)
|
||||||
|
_status.text = "left drag orbit · wheel zoom · middle drag pan · F frame hands"
|
||||||
|
box.add_child(_status)
|
||||||
|
# Reflect whatever the shot arguments or the defaults selected, or the
|
||||||
|
# dropdown says one thing while the scene shows another.
|
||||||
|
_pickers["Character"].select(_skin)
|
||||||
|
_pickers["Weapon"].select(_weapon)
|
||||||
|
_pickers["Pose"].select(_pose)
|
||||||
|
|
||||||
|
|
||||||
|
func _picker(label: String, items: Array, on_pick: Callable) -> Control:
|
||||||
|
var row := HBoxContainer.new()
|
||||||
|
var l := Label.new()
|
||||||
|
l.text = label
|
||||||
|
l.custom_minimum_size = Vector2(90, 0)
|
||||||
|
row.add_child(l)
|
||||||
|
var opt := OptionButton.new()
|
||||||
|
opt.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
for it in items:
|
||||||
|
opt.add_item(String(it.name))
|
||||||
|
opt.item_selected.connect(on_pick)
|
||||||
|
row.add_child(opt)
|
||||||
|
_pickers[label] = opt
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
func _knob_row(spec: Array) -> Control:
|
||||||
|
var key: String = spec[0]
|
||||||
|
var box := VBoxContainer.new()
|
||||||
|
box.add_theme_constant_override("separation", 0)
|
||||||
|
var label := Label.new()
|
||||||
|
box.add_child(label)
|
||||||
|
var entry := {"spec": spec, "label": label}
|
||||||
|
var axes := ["x", "y", "z"] if spec[4] else ["x"]
|
||||||
|
for a in axes:
|
||||||
|
var sl := HSlider.new()
|
||||||
|
sl.min_value = spec[2]
|
||||||
|
sl.max_value = spec[3]
|
||||||
|
sl.step = 0.001
|
||||||
|
sl.custom_minimum_size = Vector2(400, 14)
|
||||||
|
sl.value_changed.connect(func(_v): _knob_changed(key))
|
||||||
|
box.add_child(sl)
|
||||||
|
entry[a] = sl
|
||||||
|
_sliders[key] = entry
|
||||||
|
return box
|
||||||
|
|
||||||
|
|
||||||
|
func _save() -> void:
|
||||||
|
var where := WeaponHoldTuning.save(_all, _skins[_skin].id,
|
||||||
|
_weapons[_weapon].id, _knobs)
|
||||||
|
_status.text = "Saved %s + %s to %s" % [_skins[_skin].id,
|
||||||
|
_weapons[_weapon].id, where]
|
||||||
|
|
||||||
|
|
||||||
|
func _reset() -> void:
|
||||||
|
_knobs.clear()
|
||||||
|
for k in _sliders:
|
||||||
|
var e = _sliders[k]
|
||||||
|
for a in ["x", "y", "z"]:
|
||||||
|
if e.has(a):
|
||||||
|
e[a].set_value_no_signal(0.0)
|
||||||
|
_refresh_label(k)
|
||||||
|
if _model:
|
||||||
|
_model.set_hold_tuning({})
|
||||||
|
_status.text = "Reset to what the code derives"
|
||||||
|
|
||||||
|
|
||||||
|
func _copy() -> void:
|
||||||
|
var flat := {}
|
||||||
|
for k in _knobs:
|
||||||
|
var v = _knobs[k]
|
||||||
|
flat[k] = [v.x, v.y, v.z] if v is Vector3 else v
|
||||||
|
DisplayServer.clipboard_set(JSON.stringify(flat, " "))
|
||||||
|
_status.text = "Copied this character+weapon's knobs to the clipboard"
|
||||||
|
|
||||||
|
|
||||||
|
# ── camera ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func _update_camera() -> void:
|
||||||
|
var b := Basis.from_euler(Vector3(_pitch, _yaw, 0))
|
||||||
|
_cam.global_transform = Transform3D(b, _pivot + b * Vector3(0, 0, _dist))
|
||||||
|
|
||||||
|
|
||||||
|
func _unhandled_input(e: InputEvent) -> void:
|
||||||
|
if e is InputEventMouseMotion:
|
||||||
|
if e.button_mask & MOUSE_BUTTON_MASK_LEFT:
|
||||||
|
_yaw -= e.relative.x * 0.006
|
||||||
|
_pitch = clampf(_pitch - e.relative.y * 0.006, -1.4, 1.4)
|
||||||
|
_update_camera()
|
||||||
|
elif e.button_mask & MOUSE_BUTTON_MASK_MIDDLE:
|
||||||
|
var b := _cam.global_transform.basis
|
||||||
|
_pivot += (b.x * -e.relative.x + b.y * e.relative.y) * _dist * 0.0015
|
||||||
|
_update_camera()
|
||||||
|
elif e is InputEventMouseButton and e.pressed:
|
||||||
|
if e.button_index == MOUSE_BUTTON_WHEEL_UP:
|
||||||
|
_dist = maxf(_dist * 0.9, 0.15)
|
||||||
|
_update_camera()
|
||||||
|
elif e.button_index == MOUSE_BUTTON_WHEEL_DOWN:
|
||||||
|
_dist = minf(_dist * 1.1, 8.0)
|
||||||
|
_update_camera()
|
||||||
|
elif e is InputEventKey and e.pressed and not e.echo:
|
||||||
|
match e.keycode:
|
||||||
|
KEY_F:
|
||||||
|
# Frame the hands, which is what is actually being judged.
|
||||||
|
if _model and _model._pose_mod:
|
||||||
|
_pivot = _model.skeleton.global_transform \
|
||||||
|
* _model._pose_mod.dbg_grip
|
||||||
|
_dist = 0.55
|
||||||
|
_update_camera()
|
||||||
|
KEY_S: _save()
|
||||||
|
KEY_R: _reset()
|
||||||
|
KEY_C: _copy()
|
||||||
|
KEY_ESCAPE: get_tree().quit()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dmqu5ghj4pxl1
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[gd_scene load_steps=2 format=3 uid="uid://cwpnlabweapon01"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://debug/weapon_lab.gd" id="1_lab"]
|
||||||
|
|
||||||
|
[node name="WeaponLab" type="Node3D"]
|
||||||
|
script = ExtResource("1_lab")
|
||||||
Reference in New Issue
Block a user