Feat/outline thickness and tp weapon hold #22
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 `<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 _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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
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