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
+113
-27
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user