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
@@ -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
|
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
|
to isolate it — that is how the classifier gets checked: click `hair` and
|
||||||
anything else still standing was misclassified.
|
anything else still standing was misclassified.
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ Two related traps:
|
|||||||
| `rig_anchor_check.gd` | a grip anchor physically moves the weapon, and clears | 0 failures |
|
| `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_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 |
|
| `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 | — |
|
| `anim_capture.gd` / `orbit_capture.gd` | renders, for looking | — |
|
||||||
| `roster_capture.gd` | one photo of every character, from the picker | — |
|
| `roster_capture.gd` | one photo of every character, from the picker | — |
|
||||||
| `ui_capture.gd` | one photo of every menu screen | — |
|
| `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,
|
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
|
the ADS blend and the recoil kick exactly, because all three move the basis
|
||||||
and the anchor together.
|
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
|
- `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
|
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
|
renders — the fallback catches it — and quietly loses its per-class art
|
||||||
|
|||||||
@@ -1318,9 +1318,32 @@ class ShooterPoseModifier extends SkeletonModifier3D:
|
|||||||
|
|
||||||
func _tv(key: String, fallback: Vector3) -> Vector3:
|
func _tv(key: String, fallback: Vector3) -> Vector3:
|
||||||
var v = tune.get(key)
|
var v = tune.get(key)
|
||||||
# A zero-length vector means "not set" — see WeaponHoldTuning.KNOBS. It
|
# A zero-length vector means "not set" — see WeaponHoldTuning. It is how
|
||||||
# is how the elbow poles keep their hip/ADS blend unless overridden.
|
# the elbow poles keep their hip/ADS blend unless overridden.
|
||||||
return v if (v is Vector3 and v.length() > 0.0001) else fallback
|
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 `<stem>_hip` and `<stem>_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 _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.
|
||||||
@@ -1637,13 +1660,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.
|
||||||
|
#
|
||||||
|
# `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 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()
|
||||||
@@ -1657,7 +1681,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 = t_pocket_hip.lerp(t_pocket_ads, ads)
|
var pocket: Vector3 = _tvp("pocket", POCKET_HIP, POCKET_ADS)
|
||||||
var stock_pos := shoulder + pocket
|
var stock_pos := shoulder + pocket
|
||||||
# The gun's own frame: across, up, along the barrel. The hand anchors are
|
# 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
|
# 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)
|
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 = _tv("pole_r", POLE_R_HIP.lerp(POLE_R_ADS, ads)).normalized()
|
var pole_r: Vector3 = _tvp("pole_r", POLE_R_HIP, POLE_R_ADS).normalized()
|
||||||
var pole_l: Vector3 = _tv("pole_l", POLE_L_HIP.lerp(POLE_L_ADS, 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",
|
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",
|
||||||
@@ -1742,8 +1766,13 @@ 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 + _t("trigger_roll",
|
arc = Quaternion(aim_dir, roll + R_HAND_TWIST) * arc
|
||||||
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)
|
_set_global_rot(skel, hand, g_fa_r, arc, _hold_r)
|
||||||
|
|
||||||
# 6. Support hand: WRAP the handguard.
|
# 6. Support hand: WRAP the handguard.
|
||||||
@@ -1768,12 +1797,17 @@ 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.
|
||||||
# `support_roll` rolls the whole target frame about the barrel,
|
var want := Basis(gun_up.cross(-aim_dir).normalized(), gun_up,
|
||||||
# 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)
|
||||||
|
# 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 rest: Basis = _hand_frame["L"]
|
||||||
var g_hand := (want * rest.inverse()).get_rotation_quaternion() \
|
var g_hand := (want * rest.inverse()).get_rotation_quaternion() \
|
||||||
* skel.get_bone_global_rest(hand_l).basis.get_rotation_quaternion()
|
* skel.get_bone_global_rest(hand_l).basis.get_rotation_quaternion()
|
||||||
|
|||||||
@@ -28,6 +28,30 @@ const PATH := "res://assets/characters/weapon_holds.json"
|
|||||||
## exported build, where res:// is read-only.
|
## exported build, where res:// is read-only.
|
||||||
const USER_PATH := "user://weapon_holds.json"
|
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]
|
## key -> [label, minimum, maximum, is_vector, default]
|
||||||
##
|
##
|
||||||
## The lab builds its whole UI from this, so adding a knob here is all it takes
|
## 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
|
## 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
|
## of that slider silently switches the behaviour off. Zero means "let the code
|
||||||
## decide" only where it is called out below.
|
## 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],
|
["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", "TRIGGER hand along the weapon, from the butt (0 = auto)",
|
||||||
["gun_stock", "Grip to buttstock (0 = auto)", 0.0, 0.45, false, 0.0],
|
0.0, 0.45, false, 0.0],
|
||||||
["pitch_hip", "Muzzle pitch, low ready", -0.6, 0.6, false, 0.16],
|
["gun_fore", "SUPPORT hand along the weapon, from the grip (0 = auto)",
|
||||||
["support_roll", "Support hand roll", -3.2, 3.2, false, 0.0],
|
0.0, 0.50, 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)],
|
|
||||||
# The two hand anchors, off the barrel line.
|
# The two hand anchors, off the barrel line.
|
||||||
#
|
#
|
||||||
# `gun_stock` and `gun_fore` above are DISTANCES ALONG the barrel, and for a
|
# `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
|
# 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
|
# what lets the reach solver slide the support hand back down the handguard
|
||||||
# without also undoing a deliberate sideways nudge.
|
# 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],
|
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],
|
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.
|
# ShooterPoseModifier, which treats a zero-length vector as unset.
|
||||||
["pole_r", "Firing elbow (0 = auto)", -1.5, 1.5, true, Vector3.ZERO],
|
["pole_r", "Firing elbow (0 = auto)", -1.5, 1.5, true, {
|
||||||
["pole_l", "Support elbow (0 = auto)", -1.5, 1.5, true, Vector3.ZERO],
|
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.
|
## 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):
|
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
|
## The layering, the JSON round trip and the res://-then-user:// write all live
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bw4ojr7c451pq
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://d3m2g70t1tkho
|
||||||
@@ -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)
|
||||||
+113
-27
@@ -83,8 +83,13 @@ var _pose := 0
|
|||||||
## group -> resolved knob table, and group -> the whole file it came from.
|
## group -> resolved knob table, and group -> the whole file it came from.
|
||||||
var _knobs: Dictionary = {"hold": {}, "anchors": {}}
|
var _knobs: Dictionary = {"hold": {}, "anchors": {}}
|
||||||
var _all: 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": {}}
|
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
|
## "" 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.
|
## being played on its own so a single animation can be watched end to end.
|
||||||
var _clip := ""
|
var _clip := ""
|
||||||
@@ -124,6 +129,10 @@ func _ready() -> void:
|
|||||||
_skin = maxi(0, _index_of(_skins, String(args[2])))
|
_skin = maxi(0, _index_of(_skins, String(args[2])))
|
||||||
if args.size() > 3:
|
if args.size() > 3:
|
||||||
_weapon = maxi(0, _index_of(_weapons, String(args[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()
|
_build_ui()
|
||||||
_reload_model()
|
_reload_model()
|
||||||
|
|
||||||
@@ -337,7 +346,7 @@ var _drag_last := Vector3.ZERO
|
|||||||
## pose is actually showing. Dragging at low ready must not silently rewrite the
|
## pose is actually showing. Dragging at low ready must not silently rewrite the
|
||||||
## aiming pocket.
|
## aiming pocket.
|
||||||
func _stock_knob() -> String:
|
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:
|
func _knob_for(marker: int) -> String:
|
||||||
@@ -410,8 +419,12 @@ func _drag_to(mouse: Vector2) -> void:
|
|||||||
clampf(cur.z + delta.z, lo, hi)))
|
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:
|
func _spec_for(group: String, key: String) -> Array:
|
||||||
for spec in _specs(group):
|
for spec in _all_specs(group):
|
||||||
if spec[0] == key:
|
if spec[0] == key:
|
||||||
return spec
|
return spec
|
||||||
return ["", "", -1.0, 1.0, true, Vector3.ZERO]
|
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 full editor — sliders, live preview, reset, save, clipboard — for the cost of
|
||||||
# a spec table.
|
# 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:
|
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):
|
func _default_for(group: String, key: String):
|
||||||
@@ -451,6 +483,13 @@ func _default_for(group: String, key: String):
|
|||||||
else WeaponHoldTuning.default_for(key)
|
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:
|
func _load_knobs() -> void:
|
||||||
for group in GROUPS:
|
for group in GROUPS:
|
||||||
if group == "anchors":
|
if group == "anchors":
|
||||||
@@ -458,17 +497,60 @@ func _load_knobs() -> void:
|
|||||||
else:
|
else:
|
||||||
_knobs[group] = WeaponHoldTuning.resolve(_all[group],
|
_knobs[group] = WeaponHoldTuning.resolve(_all[group],
|
||||||
_skins[_skin].id, _weapons[_weapon].id)
|
_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]:
|
for k in _sliders[group]:
|
||||||
var entry = _sliders[group][k]
|
var entry = _sliders[group][k]
|
||||||
var spec: Array = entry.spec
|
var v = _knobs[group].get(k, _default_for(group, k))
|
||||||
if spec[4]:
|
if entry.spec[4]:
|
||||||
var v: Vector3 = _knobs[group].get(k, _default_for(group, k))
|
if not (v is Vector3):
|
||||||
|
v = Vector3.ZERO
|
||||||
entry.x.set_value_no_signal(v.x)
|
entry.x.set_value_no_signal(v.x)
|
||||||
entry.y.set_value_no_signal(v.y)
|
entry.y.set_value_no_signal(v.y)
|
||||||
entry.z.set_value_no_signal(v.z)
|
entry.z.set_value_no_signal(v.z)
|
||||||
else:
|
else:
|
||||||
entry.x.set_value_no_signal(float(_knobs[group].get(k,
|
entry.x.set_value_no_signal(float(v))
|
||||||
_default_for(group, k))))
|
|
||||||
_refresh_label(group, k)
|
_refresh_label(group, k)
|
||||||
|
|
||||||
|
|
||||||
@@ -544,7 +626,10 @@ func _build_ui() -> void:
|
|||||||
var poses: Array = []
|
var poses: Array = []
|
||||||
for p in POSES:
|
for p in POSES:
|
||||||
poses.append({"name": p[0]})
|
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
|
# 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
|
# 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.
|
# animation end to end and see where a retarget went wrong.
|
||||||
@@ -555,10 +640,17 @@ func _build_ui() -> void:
|
|||||||
|
|
||||||
for group in GROUPS:
|
for group in GROUPS:
|
||||||
box.add_child(UITheme.divider(0.5))
|
box.add_child(UITheme.divider(0.5))
|
||||||
box.add_child(UITheme.heading(GROUPS[group]["title"], 18))
|
var head := UITheme.heading("", 18)
|
||||||
for spec in _specs(group):
|
_headings[group] = head
|
||||||
box.add_child(_knob_row(group, spec))
|
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))
|
box.add_child(_group_buttons(group))
|
||||||
|
_rebuild_knobs()
|
||||||
|
|
||||||
box.add_child(UITheme.divider(0.7))
|
box.add_child(UITheme.divider(0.7))
|
||||||
box.add_child(UITheme.heading("SURFACES · click to isolate", 18))
|
box.add_child(UITheme.heading("SURFACES · click to isolate", 18))
|
||||||
@@ -639,23 +731,17 @@ func _save(group: String) -> void:
|
|||||||
_weapons[_weapon].id, where]
|
_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:
|
func _reset(group: String) -> void:
|
||||||
_knobs[group] = {}
|
_knobs[group] = {}
|
||||||
for k in _sliders[group]:
|
_sync_sliders()
|
||||||
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)
|
|
||||||
_push(group)
|
_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:
|
func _copy(group: String) -> void:
|
||||||
|
|||||||
Reference in New Issue
Block a user