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
@@ -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 `<stem>_<pose>`, 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
|
||||
|
||||
Reference in New Issue
Block a user