diff --git a/.claude/skills/character-pipeline/SKILL.md b/.claude/skills/character-pipeline/SKILL.md index 4bd7c6e..2a4e8c1 100644 --- a/.claude/skills/character-pipeline/SKILL.md +++ b/.claude/skills/character-pipeline/SKILL.md @@ -132,7 +132,21 @@ godot --path . res://debug/rig_lab.tscn ``` Pick a character, a weapon, a pose or a single clip. Drag sliders for the HOLD -(character + weapon) and the ANCHORS (character), and save. Click a surface class +(character + weapon) and the ANCHORS (character), and save. + +**The HOLD is per pose.** The runtime blends between exactly two holds, on +`ads`, so the lab offers two: low ready and aiming. Selecting a pose rebuilds +the hold sliders to that pose's — you never see a control belonging to the pose +you are not adjusting. Running and Crouched use the low-ready hold, and the +heading says so rather than letting someone tune "Running" and wonder why +standing still changed. `pitch` exists at low ready only: down the sights the +muzzle follows the camera, so there is nothing there to tune, and a slider that +does nothing is worse than a missing one. + +Knobs that describe the WEAPON and the hands on it — where each hand sits along +it and off its barrel line, the finger curls, the weapon size — are shared, +because shouldering a gun does not move the hand along it. Both wrists take +pitch, yaw and roll in the gun's own frame, per pose. Click a surface class to isolate it — that is how the classifier gets checked: click `hair` and anything else still standing was misclassified. diff --git a/.claude/skills/character-pipeline/references/verification.md b/.claude/skills/character-pipeline/references/verification.md index 8c38fdf..042f879 100644 --- a/.claude/skills/character-pipeline/references/verification.md +++ b/.claude/skills/character-pipeline/references/verification.md @@ -46,6 +46,7 @@ Two related traps: | `rig_anchor_check.gd` | a grip anchor physically moves the weapon, and clears | 0 failures | | `anchor_shift_check.gd` | the hand anchors move in the GUN's frame, both poses | 0 failures | | `anchor_drag_check.gd` | dragging a marker writes the knob the mouse asked for | 0 failures | +| `hold_pose_check.gd` | the lab shows only the selected pose's knobs; every wrist axis turns its hand | 0 failures | | `anim_capture.gd` / `orbit_capture.gd` | renders, for looking | — | | `roster_capture.gd` | one photo of every character, from the picker | — | | `ui_capture.gd` | one photo of every menu screen | — | @@ -88,6 +89,11 @@ Three of these exist because the obvious check passes on a broken system. it hangs off and rotating into the current gun basis cancels the breathing, the ADS blend and the recoil kick exactly, because all three move the basis and the anchor together. +- `hold_pose_check` measures the wrists through a `PoseProbe`, and had to learn + it the same way everything else did: reading `get_bone_pose_rotation` from the + SceneTree reported every wrist axis as turning the hand by **0.0 degrees** — + the identical answer it would give if the wrists had never been implemented. + See READ THIS FIRST. That trap is still the most expensive one in this repo. - `surface_class_check` FAILS on a surface that falls through to the heuristic instead of resolving from the table. A model whose names stopped matching still renders — the fallback catches it — and quietly loses its per-class art diff --git a/characters/skinned_player_model.gd b/characters/skinned_player_model.gd index 07e5d6a..a9f00c2 100644 --- a/characters/skinned_player_model.gd +++ b/characters/skinned_player_model.gd @@ -1318,9 +1318,32 @@ class ShooterPoseModifier extends SkeletonModifier3D: 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. + # A zero-length vector means "not set" — see WeaponHoldTuning. 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 + + ## A per-pose scalar knob, blended by `ads` the same way the hold itself is. + ## + ## Stored as `_hip` and `_ads` — the convention `pocket_hip` and + ## `pocket_ads` already used, now that every knob which ought to differ + ## between the two holds can. + func _tp(stem: String, d_hip: float, d_ads: float) -> float: + return lerpf(_t(stem + "_hip", d_hip), _t(stem + "_ads", d_ads), ads) + + func _tvp(stem: String, d_hip: Vector3, d_ads: Vector3) -> Vector3: + return _tv(stem + "_hip", d_hip).lerp(_tv(stem + "_ads", d_ads), ads) + + ## The wrist offset for one hand, as a rotation in the GUN's frame. + ## + ## Pitch about the weapon's across-axis, yaw about its up, roll about the + ## barrel — so the three sliders mean the same thing whether the muzzle is + ## down at low ready or level down the sights. Identity when untuned, which + ## is exactly what the hold did before there was anything but a roll. + func _wrist(stem: String, side: Vector3, up: Vector3, fwd: Vector3) -> Quaternion: + var w := _tvp(stem, Vector3.ZERO, Vector3.ZERO) + if w == Vector3.ZERO: + return Quaternion.IDENTITY + return Quaternion(side, w.x) * Quaternion(up, w.y) * Quaternion(fwd, w.z) var _fing: Dictionary = {} # same, resolved to bone indices 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. @@ -1637,13 +1660,14 @@ class ShooterPoseModifier extends SkeletonModifier3D: if ua_r < 0: return 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. var kick := recoil * 0.12 # 1. The gun's line: pitched down at low-ready, on the camera line at # ADS, kicked up by recoil. + # + # `pitch` is a low-ready knob only, and deliberately: down the sights + # the muzzle follows the CAMERA, so there is nothing there to tune. 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) \ * Vector3(0, 0, 1)).normalized() @@ -1657,7 +1681,7 @@ class ShooterPoseModifier extends SkeletonModifier3D: # 2. Anchor the stock at the shoulder, then walk out along the barrel. var shoulder := skel.get_bone_global_pose(ua_r).origin - var pocket: Vector3 = t_pocket_hip.lerp(t_pocket_ads, ads) + var pocket: Vector3 = _tvp("pocket", POCKET_HIP, POCKET_ADS) var stock_pos := shoulder + pocket # The gun's own frame: across, up, along the barrel. The hand anchors are # nudged in THIS rather than in skeleton space so a sideways offset stays @@ -1712,8 +1736,8 @@ class ShooterPoseModifier extends SkeletonModifier3D: l_target = mag_well.lerp(fore_pos, (p - 0.80) / 0.20) # 4. Solve both arms onto those points. - var pole_r: Vector3 = _tv("pole_r", POLE_R_HIP.lerp(POLE_R_ADS, ads)).normalized() - var pole_l: Vector3 = _tv("pole_l", POLE_L_HIP.lerp(POLE_L_ADS, ads)).normalized() + var pole_r: Vector3 = _tvp("pole_r", POLE_R_HIP, POLE_R_ADS).normalized() + var pole_l: Vector3 = _tvp("pole_l", POLE_L_HIP, POLE_L_ADS).normalized() var g_fa_r := _ik_arm(skel, "DEF-upper_arm.R", "DEF-forearm.R", "DEF-hand.R", grip_pos, pole_r, _hold_r) var g_fa_l := _ik_arm(skel, "DEF-upper_arm.L", "DEF-forearm.L", @@ -1742,8 +1766,13 @@ class ShooterPoseModifier extends SkeletonModifier3D: var up_flat := (up_now - aim_dir * up_now.dot(aim_dir)) if up_flat.length_squared() > 0.0001: var roll := up_flat.normalized().signed_angle_to(gun_up, aim_dir) - arc = Quaternion(aim_dir, roll + _t("trigger_roll", - R_HAND_TWIST)) * arc + arc = Quaternion(aim_dir, roll + R_HAND_TWIST) * arc + # The artist's wrist, on top of the solved one. Three axes in the + # gun's frame rather than the single twist this used to take — + # the barrel has to lie on the aim line, which fixes two of the + # hand's three freedoms, but nothing fixes how far the wrist is + # cocked or broken, and those were unreachable. + arc = _wrist("wrist_r", side, gun_up, aim_dir) * arc _set_global_rot(skel, hand, g_fa_r, arc, _hold_r) # 6. Support hand: WRAP the handguard. @@ -1768,12 +1797,17 @@ class ShooterPoseModifier extends SkeletonModifier3D: if hand_l >= 0: # -aim_dir so the hand comes at the handguard from the body side # rather than reaching over it backwards. - # `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, + var want := Basis(gun_up.cross(-aim_dir).normalized(), gun_up, -aim_dir) + # Then the artist's wrist. Rolling about the barrel is the one + # axis a hand wrapping a cylinder is genuinely free in, and it + # used to be the only one offered — which left no way to cock the + # wrist forward or break it inward, and those are most of what + # separates a convincing support hand from a mannequin's. + # Rotating the whole frame is equivalent to the old roll for the + # roll component, since -aim_dir is unchanged by a rotation + # about aim_dir. + want = Basis(_wrist("wrist_l", side, gun_up, aim_dir)) * want var rest: Basis = _hand_frame["L"] var g_hand := (want * rest.inverse()).get_rotation_quaternion() \ * skel.get_bone_global_rest(hand_l).basis.get_rotation_quaternion() diff --git a/characters/weapon_hold_tuning.gd b/characters/weapon_hold_tuning.gd index 39951d3..25fb7f8 100644 --- a/characters/weapon_hold_tuning.gd +++ b/characters/weapon_hold_tuning.gd @@ -28,6 +28,30 @@ const PATH := "res://assets/characters/weapon_holds.json" ## exported build, where res:// is read-only. const USER_PATH := "user://weapon_holds.json" +# ── The pose axis ──────────────────────────────────────────────────────────── +# +# Half of these knobs mean something different at low ready than they do down +# the sights, and half do not. Where a hand sits ON the weapon is a fact about +# the gun and the character's hands; how the weapon is carried is a fact about +# what they are doing with it. +# +# The runtime blends between exactly TWO holds, on `ads` — there is no third. +# "Running" and "Crouched" in the lab are locomotion states that still use the +# low-ready hold, because that is all `_apply_rifle_hold` can express. Offering +# four independent pose tunings would be inventing a capability the code does +# not have, and the fourth would silently do nothing. +# +# So: two poses, and a knob names the ones it exists for. +const POSE_HIP := "hip" +const POSE_ADS := "ads" +const POSE_NAMES := {POSE_HIP: "low ready", POSE_ADS: "aiming"} + + +## Which pose a given `ads` blend is being tuned as. +static func pose_for_ads(ads: float) -> String: + return POSE_ADS if ads > 0.5 else POSE_HIP + + ## key -> [label, minimum, maximum, is_vector, default] ## ## The lab builds its whole UI from this, so adding a knob here is all it takes @@ -38,20 +62,15 @@ const USER_PATH := "user://weapon_holds.json" ## 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 := [ +## +## These are the pose-INDEPENDENT ones. They describe the weapon and the hands +## on it, which do not change when the character shoulders the gun. +const SHARED_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)], + ["gun_stock", "TRIGGER hand along the weapon, from the butt (0 = auto)", + 0.0, 0.45, false, 0.0], + ["gun_fore", "SUPPORT hand along the weapon, from the grip (0 = auto)", + 0.0, 0.50, false, 0.0], # The two hand anchors, off the barrel line. # # `gun_stock` and `gun_fore` above are DISTANCES ALONG the barrel, and for a @@ -67,20 +86,91 @@ const KNOBS := [ # is redundant but harmless, and keeping the along-axis distances separate is # what lets the reach solver slide the support hand back down the handguard # without also undoing a deliberate sideways nudge. - ["grip_shift", "Trigger-hand anchor, off the barrel line", -0.15, 0.15, true, + ["grip_shift", "TRIGGER hand, off the barrel line", -0.15, 0.15, true, Vector3.ZERO], - ["fore_shift", "Support-hand anchor, off the barrel line", -0.15, 0.15, true, + ["fore_shift", "SUPPORT hand, off the barrel line", -0.15, 0.15, true, Vector3.ZERO], - # Zero means "use the code's own hip/ADS blend" for these two — see _tv in + ["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], +] + +## stem -> [label, minimum, maximum, is_vector, {pose: default}] +## +## Stored and read as `_`, which is the convention `pocket_hip` and +## `pocket_ads` already used — generalised so every knob that ought to differ +## between the two holds can. +## +## A pose ABSENT from the defaults dictionary means the knob does not exist +## there, and the lab will not show it. `pitch` is the case that forces this: +## down the sights the muzzle follows the camera, so there is nothing to tune, +## and a "muzzle pitch, aiming" slider would be a control that does nothing. +const POSE_KNOBS := [ + ["pocket", "Stock pocket", -0.30, 0.30, true, { + POSE_HIP: Vector3(0.03, -0.07, 0.06), + POSE_ADS: Vector3(0.05, 0.01, 0.07)}], + ["pitch", "Muzzle pitch", -0.6, 0.6, false, {POSE_HIP: 0.16}], + # Full wrist orientation, not just a roll. + # + # These were one scalar each, a twist about the barrel, because that is the + # only axis a hand wrapping a cylinder is free in ONCE the arc onto the + # barrel has been solved. That is true of the support hand and it was never + # true of the trigger hand, and even for the support hand it left no way to + # cock a wrist forward or break it inward — which is most of what separates a + # convincing rifle hold from a mannequin's. + # + # Pitch, yaw and roll, applied in the GUN's frame (about across, up, and the + # barrel) so the axes mean the same thing at any weapon pitch. Zero is + # exactly the old behaviour, since the roll term was zero by default too. + ["wrist_r", "TRIGGER wrist — pitch / yaw / roll", -1.6, 1.6, true, { + POSE_HIP: Vector3.ZERO, POSE_ADS: Vector3.ZERO}], + ["wrist_l", "SUPPORT wrist — pitch / yaw / roll", -1.6, 1.6, true, { + POSE_HIP: Vector3.ZERO, POSE_ADS: Vector3.ZERO}], + # Zero means "use the code's own default" 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], + ["pole_r", "Firing elbow (0 = auto)", -1.5, 1.5, true, { + POSE_HIP: Vector3.ZERO, POSE_ADS: Vector3.ZERO}], + ["pole_l", "Support elbow (0 = auto)", -1.5, 1.5, true, { + POSE_HIP: Vector3.ZERO, POSE_ADS: Vector3.ZERO}], ] +## The spec table for one pose: the shared knobs, plus that pose's own, with +## their keys already suffixed. +## +## This is what the lab builds its sliders from, so a knob that does not apply +## to the pose being adjusted is not merely disabled — it is not there. +static func knobs_for(pose: String) -> Array: + var out: Array = SHARED_KNOBS.duplicate() + for spec in POSE_KNOBS: + var defaults: Dictionary = spec[5] + if not defaults.has(pose): + continue + out.append(["%s_%s" % [spec[0], pose], + "%s, %s" % [spec[1], POSE_NAMES[pose]], + spec[2], spec[3], spec[4], defaults[pose]]) + return out + + +## Every knob across every pose. For anything that has to reason about the whole +## table rather than about one screen of it — resetting, saving, and the checks. +static func all_knobs() -> Array: + var out: Array = SHARED_KNOBS.duplicate() + for spec in POSE_KNOBS: + var defaults: Dictionary = spec[5] + for pose in defaults: + out.append(["%s_%s" % [spec[0], pose], + "%s, %s" % [spec[1], POSE_NAMES[pose]], + spec[2], spec[3], spec[4], defaults[pose]]) + return out + + ## The built-in value for a knob, for a lab that has nothing saved yet. +## +## Across ALL poses, not just the one on screen: a reset or a save has to know +## what `pocket_ads` defaults to even while low ready is being adjusted. static func default_for(key: String): - return TuningStore.default_for(KNOBS, key) + return TuningStore.default_for(all_knobs(), key) ## The layering, the JSON round trip and the res://-then-user:// write all live diff --git a/debug/anchor_drag_check.gd.uid b/debug/anchor_drag_check.gd.uid new file mode 100644 index 0000000..24fc878 --- /dev/null +++ b/debug/anchor_drag_check.gd.uid @@ -0,0 +1 @@ +uid://bw4ojr7c451pq diff --git a/debug/anchor_shift_check.gd.uid b/debug/anchor_shift_check.gd.uid new file mode 100644 index 0000000..82e21d2 --- /dev/null +++ b/debug/anchor_shift_check.gd.uid @@ -0,0 +1 @@ +uid://d3m2g70t1tkho diff --git a/debug/hold_pose_check.gd b/debug/hold_pose_check.gd new file mode 100644 index 0000000..e8a1ca7 --- /dev/null +++ b/debug/hold_pose_check.gd @@ -0,0 +1,184 @@ +extends SceneTree + +## Two things about the per-pose hold. +## +## 1. THE SLIDERS ON SCREEN BELONG TO THE POSE ON SCREEN. Half the hold's knobs +## mean something different at low ready than down the sights, and showing +## both sets at once meant every slider was for one of two poses with nothing +## saying which — and `pitch` does not exist down the sights at all, because +## there the muzzle follows the camera. A control that does nothing is worse +## than a missing one. +## +## 2. THE WRISTS ROTATE. They were one scalar each, a twist about the barrel, +## which is the only axis a hand wrapping a cylinder is free in ONCE the arc +## onto the barrel is solved — true of the support hand, never true of the +## trigger hand, and in neither case a way to cock a wrist forward or break it +## inward. Now three axes, in the gun's frame. This asserts each axis moves +## the hand it names, and that the two poses hold separate values. +## +## godot --path . -s res://debug/hold_pose_check.gd + +const LAB := "res://debug/rig_lab.tscn" +## Big enough to read past the pose layer's smoothing, small enough that the IK +## does not give up and drop the hold. +const TWIST := 0.35 + +var _fails := 0 +var _probe: PoseProbe = null + + +## Snapshot the pose from INSIDE the modifier pass. +## +## Godot restores every bone's local pose after `SkeletonModifier3D` runs, so +## reading `get_bone_pose_rotation` from a SceneTree script recomputes the +## globals from the ANIMATION alone — the shooter hold is simply not in what you +## measure. The first version of this check did that and reported every wrist +## axis as moving the hand by 0.0 degrees, which is the same answer it would +## give if the wrists had never been implemented. +## +## The same trap, and the same fix, as `cloth_clip_check` and `travel_dir_check`. +class PoseProbe extends SkeletonModifier3D: + var pose: Array = [] + + func _process_modification() -> void: + var skel := get_skeleton() + if skel == null: + return + pose.resize(skel.get_bone_count()) + for i in skel.get_bone_count(): + pose[i] = skel.get_bone_global_pose(i) + + +func _init() -> void: + await process_frame + var lab: Node = load(LAB).instantiate() + root.add_child(lab) + for _i in 200: + await process_frame + if lab._model == null or lab._model._pose_mod == null: + _expect(false, "the lab built a character with a pose layer") + _done() + return + + _check_scoping(lab) + await _check_wrists(lab) + _done() + + +# ── 1. slider scoping ──────────────────────────────────────────────────────── + +func _check_scoping(lab: Node) -> void: + var shared := {} + for spec in WeaponHoldTuning.SHARED_KNOBS: + shared[spec[0]] = true + + for i in lab.POSES.size(): + lab._pose = i + lab._rebuild_knobs() + var pose: String = lab._hold_pose() + var other: String = "ads" if pose == "hip" else "hip" + var keys: Array = lab._sliders["hold"].keys() + + var strays: PackedStringArray = [] + for k in keys: + if shared.has(k): + continue + if not String(k).ends_with("_" + pose): + strays.append(k) + _expect(strays.is_empty(), + "'%s' shows only %s knobs%s" % [lab.POSES[i][0], pose, + "" if strays.is_empty() else " — strays: " + ", ".join(strays)]) + + # Specifically: the OTHER pose's stock pocket must not be on screen. It + # is the knob most likely to be edited by accident, because both poses + # have one and they look identical in a list. + _expect(not lab._sliders["hold"].has("pocket_" + other), + "'%s' does not show the %s stock pocket" % [lab.POSES[i][0], other]) + + # ...and the heading says which hold is being edited. + var head: String = lab._heading_for("hold") + _expect(head.to_lower().contains( + WeaponHoldTuning.POSE_NAMES[pose]), + "'%s' heading names the hold: %s" % [lab.POSES[i][0], head]) + + # `pitch` exists at low ready and NOWHERE else. + lab._pose = 0 + lab._rebuild_knobs() + _expect(lab._sliders["hold"].has("pitch_hip"), "low ready offers a muzzle pitch") + lab._pose = 1 + lab._rebuild_knobs() + _expect(not lab._sliders["hold"].has("pitch_ads"), + "aiming offers no muzzle pitch — down the sights it follows the camera") + + +# ── 2. the wrists ──────────────────────────────────────────────────────────── + +func _check_wrists(lab: Node) -> void: + var model = lab._model + var pm = model._pose_mod + var skel: Skeleton3D = model.skeleton + var hands := {"wrist_r": "hand.R", "wrist_l": "hand.L"} + # AFTER the hold, so what it sees is what the hold produced. + _probe = PoseProbe.new() + _probe.name = "WristProbe" + skel.add_child(_probe) + for _i in 10: + await process_frame + + for pose in ["hip", "ads"]: + lab._pose = 0 if pose == "hip" else 1 + lab._rebuild_knobs() + for stem in hands: + var bone: int = _role_bone(model, hands[stem]) + if bone < 0: + _expect(false, "'%s' resolves to a bone" % hands[stem]) + continue + for axis in 3: + var v := Vector3.ZERO + v[axis] = TWIST + model.set_hold_tuning({}) + await _settle(lab, pose) + var before: Quaternion = _probe_rot(bone) + model.set_hold_tuning({"%s_%s" % [stem, pose]: v}) + await _settle(lab, pose) + var after: Quaternion = _probe_rot(bone) + var moved := rad_to_deg(before.angle_to(after)) + # Not compared to an exact angle: the wrist is applied as a + # global-space target and blended in by the hold weight, so the + # LOCAL rotation that lands on the bone is not the knob. That it + # moved, and moved substantially, is the claim. + _expect(moved > 3.0, + "%s %s axis %d turned the hand %.1f deg" % [stem, pose, axis, moved]) + model.set_hold_tuning({}) + + +func _settle(lab: Node, pose: String) -> void: + lab._model.update_state("ground", 0.0, false) + lab._model.set_locomotion(0.0, 0.0, 1.0 if pose == "ads" else 0.0) + for _i in 60: + await process_frame + + +func _probe_rot(bone: int) -> Quaternion: + if _probe == null or bone >= _probe.pose.size(): + return Quaternion.IDENTITY + var t: Transform3D = _probe.pose[bone] + return t.basis.get_rotation_quaternion() + + +func _role_bone(model, role: String) -> int: + var name: String = model._rig_info.get("roles", {}).get(role, "") + return model.skeleton.find_bone(name) if name != "" else -1 + + +func _expect(ok: bool, what: String) -> void: + if ok: + print(" OK: %s" % what) + else: + print(" FAIL: %s" % what) + _fails += 1 + + +func _done() -> void: + print("\n=== HOLD POSES ===\nFailures: %d" % _fails) + quit(1 if _fails > 0 else 0) diff --git a/debug/rig_lab.gd b/debug/rig_lab.gd index f9cd51c..1c1414d 100644 --- a/debug/rig_lab.gd +++ b/debug/rig_lab.gd @@ -83,8 +83,13 @@ var _pose := 0 ## group -> resolved knob table, and group -> the whole file it came from. var _knobs: Dictionary = {"hold": {}, "anchors": {}} var _all: Dictionary = {"hold": {}, "anchors": {}} -## group -> key -> {spec, label, x, y, z} +## group -> key -> {spec, label, x, y, z}. Only ever holds the knobs currently +## ON SCREEN, which for the hold means only the selected pose's. var _sliders: Dictionary = {"hold": {}, "anchors": {}} +## group -> the container its slider rows live in, and its heading, so the hold +## can be rebuilt when the pose changes without rebuilding the whole panel. +var _knob_boxes: Dictionary = {} +var _headings: Dictionary = {} ## "" means the pose buttons drive the model; anything else is a canonical clip ## being played on its own so a single animation can be watched end to end. var _clip := "" @@ -124,6 +129,10 @@ func _ready() -> void: _skin = maxi(0, _index_of(_skins, String(args[2]))) if args.size() > 3: _weapon = maxi(0, _index_of(_weapons, String(args[3]))) + if args.size() > 4: + # Which pose to photograph — the panel differs per pose now, so a + # shot of only the default one says nothing about the others. + _pose = clampi(int(args[4]), 0, POSES.size() - 1) _build_ui() _reload_model() @@ -337,7 +346,7 @@ var _drag_last := Vector3.ZERO ## pose is actually showing. Dragging at low ready must not silently rewrite the ## aiming pocket. func _stock_knob() -> String: - return "pocket_ads" if POSES[_pose][3] > 0.5 else "pocket_hip" + return "pocket_%s" % _hold_pose() func _knob_for(marker: int) -> String: @@ -410,8 +419,12 @@ func _drag_to(mouse: Vector2) -> void: clampf(cur.z + delta.z, lo, hi))) +## The spec for a key, searched across every pose rather than the screenful on +## show. A drag writes the selected pose's key, which is on screen; a reset +## touches both. Looking only at `_specs` would silently hand back the +## catch-all below for anything belonging to the other pose. func _spec_for(group: String, key: String) -> Array: - for spec in _specs(group): + for spec in _all_specs(group): if spec[0] == key: return spec return ["", "", -1.0, 1.0, true, Vector3.ZERO] @@ -442,8 +455,27 @@ func _set_knob(group: String, key: String, value) -> void: # a full editor — sliders, live preview, reset, save, clipboard — for the cost of # a spec table. +## The spec table for a group, for the pose CURRENTLY being adjusted. +## +## The hold's table is a function of the pose: half its knobs mean something +## different at low ready than they do down the sights, and one of them — +## muzzle pitch — does not exist down the sights at all, because there the +## muzzle follows the camera. Showing all of them at once meant every slider on +## screen was for one of two poses and nothing said which. func _specs(group: String) -> Array: - return RigAnchors.KNOBS if group == "anchors" else WeaponHoldTuning.KNOBS + if group == "anchors": + return RigAnchors.KNOBS + return WeaponHoldTuning.knobs_for(_hold_pose()) + + +## Which hold the selected pose is tuning. +## +## The runtime blends between exactly TWO holds, on `ads`. "Running" and +## "Crouched" are locomotion states that still use the low-ready hold, so they +## edit the same numbers as "Low ready" — and the heading says so, rather than +## letting someone tune "Running" and wonder why standing still changed. +func _hold_pose() -> String: + return WeaponHoldTuning.pose_for_ads(POSES[_pose][3]) func _default_for(group: String, key: String): @@ -451,6 +483,13 @@ func _default_for(group: String, key: String): else WeaponHoldTuning.default_for(key) +## Every knob of a group across BOTH poses. Reset works on the whole table, not +## just the screenful on show — resetting while low ready is selected must not +## leave the aiming pocket half-tuned and invisible. +func _all_specs(group: String) -> Array: + return RigAnchors.KNOBS if group == "anchors" else WeaponHoldTuning.all_knobs() + + func _load_knobs() -> void: for group in GROUPS: if group == "anchors": @@ -458,17 +497,60 @@ func _load_knobs() -> void: else: _knobs[group] = WeaponHoldTuning.resolve(_all[group], _skins[_skin].id, _weapons[_weapon].id) + _sync_sliders() + + +## Build the slider rows for each group, for the pose being adjusted. +## +## Called on startup and whenever the pose changes. Nothing about the underlying +## VALUES changes here — `_knobs` holds every pose's numbers at once, and Save +## writes all of them. This only decides which are on screen, so that every +## slider you can see belongs to the pose in the picker above it. +func _rebuild_knobs() -> void: + for group in GROUPS: + var rows: VBoxContainer = _knob_boxes.get(group) + if rows == null: + continue + for c in rows.get_children(): + rows.remove_child(c) + c.queue_free() + _sliders[group].clear() + for spec in _specs(group): + rows.add_child(_knob_row(group, spec)) + var head: Label = _headings.get(group) + if head: + head.text = _heading_for(group) + _sync_sliders() + + +func _heading_for(group: String) -> String: + if group != "hold": + return GROUPS[group]["title"] + var pose := _hold_pose() + var shown: String = WeaponHoldTuning.POSE_NAMES[pose] + # Say outright when the selected pose is not its own hold. Running and + # Crouched use the low-ready numbers, and someone tuning "Running" and + # finding standing still had changed too would rightly call that a bug. + var via := "" + if POSES[_pose][0].to_lower() != shown: + via = " (%s uses the %s hold)" % [POSES[_pose][0], shown] + return "HOLD · %s · this character, this weapon%s" % [shown.to_upper(), via] + + +## Push the stored values into whatever sliders are currently on screen. +func _sync_sliders() -> void: + for group in GROUPS: for k in _sliders[group]: var entry = _sliders[group][k] - var spec: Array = entry.spec - if spec[4]: - var v: Vector3 = _knobs[group].get(k, _default_for(group, k)) + var v = _knobs[group].get(k, _default_for(group, k)) + if entry.spec[4]: + if not (v is Vector3): + v = Vector3.ZERO 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[group].get(k, - _default_for(group, k)))) + entry.x.set_value_no_signal(float(v)) _refresh_label(group, k) @@ -544,7 +626,10 @@ func _build_ui() -> void: 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(_picker("Pose", poses, func(i): + _pose = i + # The hold's knobs ARE the pose's knobs, so changing pose rebuilds them. + _rebuild_knobs())) # Clip scrubber. The four poses above are the states the game drives; this # plays one clip on its own, which is the only way to watch a whole # animation end to end and see where a retarget went wrong. @@ -555,10 +640,17 @@ func _build_ui() -> void: for group in GROUPS: box.add_child(UITheme.divider(0.5)) - box.add_child(UITheme.heading(GROUPS[group]["title"], 18)) - for spec in _specs(group): - box.add_child(_knob_row(group, spec)) + var head := UITheme.heading("", 18) + _headings[group] = head + box.add_child(head) + # The hold's rows are rebuilt whenever the pose changes, so they live in + # their own container rather than loose in the panel. + var rows := VBoxContainer.new() + rows.add_theme_constant_override("separation", 2) + _knob_boxes[group] = rows + box.add_child(rows) box.add_child(_group_buttons(group)) + _rebuild_knobs() box.add_child(UITheme.divider(0.7)) box.add_child(UITheme.heading("SURFACES · click to isolate", 18)) @@ -639,23 +731,17 @@ func _save(group: String) -> void: _weapons[_weapon].id, where] +## Clear a whole group — every pose, not just the one on screen. +## +## The sliders then take their DEFAULTS, not zero. Those are the same thing for +## every anchor and for most of the hold, but not for all of it: a slider parked +## at 0 next to a code default of 1.0 means reset quietly switched that behaviour +## off rather than restoring it. func _reset(group: String) -> void: _knobs[group] = {} - for k in _sliders[group]: - var e = _sliders[group][k] - for a in ["x", "y", "z"]: - if e.has(a): - # Back to the DEFAULT, not to zero. Those are the same thing for - # every anchor and for most of the hold, but not for all of it — - # a slider parked at 0 next to a code default of 1.0 means reset - # quietly switched that behaviour off rather than restoring it. - var d = _default_for(group, k) - var v: float = (d[a] if d is Vector3 else float(d)) \ - if e.spec[4] else float(d) - e[a].set_value_no_signal(v) - _refresh_label(group, k) + _sync_sliders() _push(group) - _status.text = "Reset %s to what the code derives" % group + _status.text = "Reset every %s knob, both poses, to what the code derives" % group func _copy(group: String) -> void: