feat(rig lab): wrists in three axes, and sliders that belong to the pose on screen
Three things the lab could not do. WRISTS. Each hand had one scalar, a twist about the barrel. That is the only axis a hand wrapping a cylinder is free in ONCE the arc onto the barrel is solved — which is true of the support hand, was never true of the trigger hand, and in neither case left a way to cock a wrist forward or break it inward. Both now take pitch, yaw and roll, applied in the GUN's frame so the three sliders mean the same thing whether the muzzle is down at low ready or level down the sights. Zero is exactly the old behaviour, since the roll term defaulted to zero too. HAND POINTS. `gun_stock` and `gun_fore` are the distances along the weapon at which each hand sits, and they were labelled by what they measure rather than by whose hand it is. They now say TRIGGER and SUPPORT, next to the off-barrel shifts for the same two hands, so the four controls that place a hand read as four controls that place a hand. POSES. The hold's knobs are now per pose, and the lab shows one pose's at a time. Half of them mean something different at low ready than down the sights; showing both sets at once meant every slider on screen was for one of two poses with nothing saying which. Selecting a pose rebuilds the panel. Two poses, not four, and deliberately: 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 — and the heading says so, rather than letting someone tune "Running" and wonder why standing still changed. Offering four independent tunings would be inventing a capability the code does not have, and the fourth would silently do nothing. `pitch` is the case that forced the design: down the sights the muzzle follows the CAMERA, so there is nothing there to tune. It exists at low ready and nowhere else, and a spec table where a knob names the poses it applies to is what lets that be said instead of shipping a control that does nothing. hold_pose_check asserts both halves — that no pose shows another's knobs, that aiming offers no muzzle pitch, that the heading names the hold being edited, and that all twelve wrist axes turn the hand they name. Its first version reported every wrist axis as moving the hand by 0.0 degrees, which is precisely the answer it would have given if the wrists had never been implemented: it read `get_bone_pose_rotation` from a SceneTree script, and Godot restores every bone's local pose after the modifier pass. The repo has a reference section about exactly this and it still cost a cycle. Measured through a PoseProbe, every axis turns its hand ~20 degrees. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b1c8bab714
commit
a97ccca13c
@@ -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)
|
||||
Reference in New Issue
Block a user