feat(characters): rig anchors, and a lab that edits any tuning group

The weapon lab could tune how a character HOLDS a gun. It could not tune where
the gun sits in the hand, and that is a different question with a different
scope: a hold is per character and weapon, an anchor is a fact about the hand.

RigAnchors adds it. A hand bone's origin is the WRIST, not the palm, and how far
down the palm a grip should sit — and how the gun rolls in the fingers — depends
on how big that character's hand is and how the artist posed the thumb. It
cannot be derived, it differs per character, and it is small. So it is an offset
that defaults to identity, and identity means exactly what the code derived
before anchors existed: an untuned character is bit-for-bit unchanged.

Deliberately NOT a fixed rotation on the mount. There used to be one, and a bone
attachment is expressed in the BONE's axes, which no two rigs agree on — that
constant is why the hand mount points were wrong on every character. The derived
mount stays derived; the anchor is a nudge on top of it, and nothing here spells
a bone name.

The layering, the JSON round trip and the res://-then-user:// write are now
TuningStore, because none of that was ever specific to weapons and two copies of
it would mean two places for "an exported build's tuning pass is silently
discarded" to come back. WeaponHoldTuning is built on it with its file format
unchanged.

The lab is a rig lab now, and it grew three things:

  ANCHORS   a second knob group. It cost a spec table — everything in the lab is
            written against "which specs, which file, keyed on what" rather than
            twice against the two groups, so anchors got sliders, live preview,
            reset, save and clipboard for free. A third group is a third row in
            GROUPS.
  CLIP      audition one animation on its own. The four pose buttons are the
            states the game drives; watching a whole clip end to end is how you
            see where a retarget went wrong, and there was no way to do it.
  SURFACES  what the importer decided each surface IS, and a click to isolate a
            class. Isolating is how the decision gets CHECKED: click `hair` and
            anything else still standing was misclassified. Hidden by swapping
            in a transparent material rather than hiding the node, because a
            mesh is not one class — Miku's body, face and hair are three
            surfaces of one mesh.

And it takes the game's theme, applied to its own panel rather than only to the
Window, for the same reason the pause menu needed it: a CanvasLayer is not a
Control, so theme inheritance stops at one.

debug/rig_anchor_check.gd asserts the physical consequence rather than the
plumbing — an anchor system is easy to build so that the sliders move, the file
saves, the JSON round-trips and the gun does not budge. All six characters move
their weapon by exactly the offset asked for, measured in the attachment's frame
because a world-space delta on an animating skeleton is mostly the idle
animation, and all six return exactly to the derived mount when it is cleared.

Also fixes BoltRule, whose zigzag was self-intersecting: draw_colored_polygon
triangulates, and a crossing outline fails triangulation and draws nothing at
all except a console full of "Invalid polygon data". Same silhouette, walked as
a closed loop, with the ink edge as a polyline rather than a grown polygon —
growing a concave shape from its centroid reintroduces the crossing.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-27 14:56:03 -04:00
co-authored by Claude Opus 5
parent 26f7c2c622
commit 08ae85b286
16 changed files with 1028 additions and 508 deletions
+90
View File
@@ -0,0 +1,90 @@
extends Object
class_name RigAnchors
## Named attachment points on a character's skeleton, adjustable per character.
##
## An anchor is a bone ROLE plus an offset: "the grip sits here, relative to the
## right hand". The role is resolved from the rig sidecar, so nothing here ever
## spells a bone name — that rule is what let four characters hold a gun at all.
## The offset is the part a human has to decide.
##
## Why an offset is needed even though the code derives a mount:
##
## The third-person weapon is seated at the hand bone's ORIGIN with no
## hand-relative rotation, and the pose layer then aims it by rotating the wrist
## until the gun's forward axis lies on the aim line. That is deliberate and it
## is right — a constant rotation there is expressed in the BONE's axes, no two
## rigs agree on those, and a fixed `(0, 90, -90)` is exactly why the hand mount
## points used to be wrong on every character.
##
## But a hand bone's origin is the WRIST, not the palm. How far down the palm a
## grip should sit, and how the gun should roll in the fingers, is a judgement
## about that character's hand — how big it is, how the fingers were modelled,
## how the artist posed the thumb. It cannot be derived, it differs per
## character, and it is small. So it is an offset, it defaults to zero, and zero
## means "exactly what the code derives" — which is what every character gets
## until someone opens the rig lab and decides otherwise.
const PATH := "res://assets/characters/rig_anchors.json"
## Written to the project when running from source; falls back to user:// for an
## exported build, where res:// is read-only.
const USER_PATH := "user://rig_anchors.json"
## The one subject key. Anchors are per CHARACTER, not per weapon — where a grip
## sits in a palm is a fact about the hand, and re-tuning it for every gun would
## be re-answering the same question. TuningStore is keyed by subject, so this
## names the only one there is.
const SUBJECT := "anchors"
## key -> [label, minimum, maximum, is_vector, default]
##
## The lab builds its whole anchor UI from this, so adding an anchor here is all
## it takes to expose one. Ranges are what a plausible answer lives inside, not
## what the value can technically be: a grip more than 12 cm from the wrist is
## not a grip, it is a mistake, and a slider that can express it only makes the
## useful range harder to hit.
##
## Every default is ZERO, and that is load-bearing — see the note above. A knob
## whose slider sits at 0 next to a code default of something else means the
## first touch of that slider silently changes behaviour.
const KNOBS := [
["grip_offset", "Grip position in the palm (m)", -0.12, 0.12, true, Vector3.ZERO],
["grip_rotation", "Grip roll/pitch/yaw (rad)", -1.6, 1.6, true, Vector3.ZERO],
]
## Which bone role each anchor hangs off. Roles, not names — resolved through
## the sidecar the pipeline writes.
const ANCHOR_BONE := {
"grip_offset": "hand.R",
"grip_rotation": "hand.R",
}
static func default_for(key: String):
return TuningStore.default_for(KNOBS, key)
static func load_all() -> Dictionary:
return TuningStore.read(PATH, USER_PATH)
## The resolved anchor table for one character.
static func resolve(all: Dictionary, skin_id: String) -> Dictionary:
return TuningStore.resolve(all, skin_id, SUBJECT)
static func save(all: Dictionary, skin_id: String, table: Dictionary) -> String:
return TuningStore.write(all, skin_id, SUBJECT, table, PATH, USER_PATH)
## The grip anchor as a transform to seat a weapon with, in hand-bone space.
##
## Identity when nothing is tuned, which is what the code did before anchors
## existed — so a character nobody has opened the lab for is bit-for-bit
## unchanged.
static func grip_transform(table: Dictionary) -> Transform3D:
var pos: Vector3 = table.get("grip_offset", Vector3.ZERO)
var rot: Vector3 = table.get("grip_rotation", Vector3.ZERO)
if pos == Vector3.ZERO and rot == Vector3.ZERO:
return Transform3D.IDENTITY
return Transform3D(Basis.from_euler(rot), pos)
+1
View File
@@ -0,0 +1 @@
uid://ck8e6odry037
+59 -10
View File
@@ -100,9 +100,12 @@ var is_holding_weapon: bool = false
## Which skin this is, so per-character hold tuning can be looked up. Set by
## whoever spawns the model; falls back to the GLB's basename.
var skin_id: String = ""
## Live hold overrides. Written by debug/weapon_lab.gd while tuning and by
## Live hold overrides. Written by debug/rig_lab.gd while tuning and by
## set_weapon() from the saved table otherwise.
var hold_tune: Dictionary = {}
## Live anchor overrides — where the grip sits in the palm, and how the gun
## rolls in the fingers. Same two sources as hold_tune. See RigAnchors.
var anchors: Dictionary = {}
# Animation blending: locomotion plays full-body through a Transition node;
# gameplay one-shots (reload/throw/shoot/hit) play through an
@@ -907,20 +910,55 @@ func get_lean_debug() -> float:
return _cur_fwd
## Every canonical clip this character resolved to something real.
##
## Canonical rather than raw, because the raw names differ per character — one
## rig's "CrouchIdle" is another's "Crouch_Idle_Loop" — and the canonical name is
## what the game asks for. For debug/rig_lab.gd's clip scrubber.
func clip_names_debug() -> Array:
return _resolved_clips.keys()
## Play one clip outright, ignoring the locomotion state machine. Lab only.
func play_clip_debug(canonical: String) -> void:
_play_clip(canonical, true)
## Push a new hold tuning table in and re-seat the weapon with it.
##
## For debug/weapon_lab.gd: the knobs that live on the pose layer take effect on
## For debug/rig_lab.gd: the knobs that live on the pose layer take effect on
## the next frame, but weapon SIZE and the grip offset are baked into the
## attachment when the weapon is seated, so those need the weapon re-measured.
func set_hold_tuning(t: Dictionary) -> void:
hold_tune = t
if _pose_mod:
_pose_mod.tune = t
if _weapon_attachment and _weapon_attachment.get_child_count() > 0:
var w := _weapon_attachment.get_child(0) as Node3D
if w:
w.transform = Transform3D.IDENTITY
_measure_weapon(w)
_reseat_weapon()
## Push a new anchor table in and re-seat the weapon on it.
##
## Separate from set_hold_tuning because the two are separate questions with
## separate scopes — a hold is per character AND weapon, an anchor is per
## character — and the lab edits them on different screens.
func set_anchors(a: Dictionary) -> void:
anchors = a
_reseat_weapon()
## Re-apply the grip anchor and re-measure, after either table changed.
##
## `_measure_weapon` reads the weapon's transform to work out where its grip and
## muzzle are, so the anchor has to be back in place BEFORE it runs — measuring
## from identity and then offsetting would move the gun without moving the
## points the hands are being solved onto.
func _reseat_weapon() -> void:
if _weapon_attachment == null or _weapon_attachment.get_child_count() == 0:
return
var w := _weapon_attachment.get_child(0) as Node3D
if w:
w.transform = RigAnchors.grip_transform(anchors)
_measure_weapon(w)
## Whether the locomotion cycle is running backwards, which points the stride
@@ -955,10 +993,15 @@ func set_weapon(script_path: String) -> void:
return
# Per-character, per-weapon hold overrides, if any have been tuned. Empty is
# the normal case and means "use what the code derives".
var sid := skin_id if skin_id != "" else model_path.get_file().get_basename()
if hold_tune.is_empty():
var sid := skin_id if skin_id != "" else model_path.get_file().get_basename()
hold_tune = WeaponHoldTuning.resolve(WeaponHoldTuning.load_all(), sid,
script_path.get_file().get_basename())
# Anchors are per character, not per weapon — where a grip sits in a palm is
# a fact about the hand — so unlike hold_tune they are not re-read per gun
# unless the lab has pushed a live set in.
if anchors.is_empty():
anchors = RigAnchors.resolve(RigAnchors.load_all(), sid)
var script = load(script_path)
if not script:
@@ -992,7 +1035,13 @@ func set_weapon(script_path: String) -> void:
# rig, and the wrist then absorbs whatever that bone's roll happens to be.
# The grip is placed at the bone's origin below, so the gun sits IN the
# hand rather than at a fixed offset from a differently-oriented bone.
w.transform = Transform3D.IDENTITY
#
# ...with one adjustment on top: the character's own grip ANCHOR. A hand
# bone's origin is the wrist, not the palm, and how far down the palm a
# grip should sit is a fact about that character's hand — how big it is,
# how the fingers were modelled — which cannot be derived. It defaults to
# identity, so a character nobody has tuned behaves exactly as before.
w.transform = RigAnchors.grip_transform(anchors)
if _pose_mod:
_pose_mod.gun_fwd_hand = Vector3(0, 0, -1)
_pose_mod.gun_up_hand = Vector3(0, 1, 0)
@@ -1249,7 +1298,7 @@ class ShooterPoseModifier extends SkeletonModifier3D:
## "index.L" -> its bone names, knuckle to fingertip, from the same sidecar.
var fingers: Dictionary = {}
## Per-character hold overrides — see characters/weapon_hold_tuning.gd and
## debug/weapon_lab.gd. A dictionary rather than a field per knob so a new
## debug/rig_lab.gd. A dictionary rather than a field per knob so a new
## knob needs no plumbing: add it here, read it with _t/_tv, and the lab
## picks it up from the same table.
var tune: Dictionary = {}
+112
View File
@@ -0,0 +1,112 @@
extends Object
class_name TuningStore
## Per-character art direction on disk, layered so a number can be set once for
## everyone and then contradicted exactly where it matters.
##
## defaults every character, every subject
## skins.<skin>._all this character, every subject
## skins.<skin>.<key> this character, this subject
##
## "Subject" is whatever the caller is keying on — a weapon id for how a gun is
## held, an anchor set for where it is held. The store does not care.
##
## This is the shape WeaponHoldTuning arrived at, pulled out so it is not the
## only thing that can have it. Every knob in the rifle hold started as a
## constant tuned against one rig and was wrong on the next character imported;
## the ones that can be derived from the skeleton now are, and what is left is
## genuinely an artist's judgement — how high a stock rides, where in the palm a
## grip sits. Judgement wants a slider and a file, not another guess in code.
##
## An absent or empty file means "use the built-in defaults", so the game runs
## perfectly well with nothing tuned at all. This only ever ADDS information.
## Where a tuning file is read from and written to.
##
## The project copy is preferred on save so a tuning pass lands in version
## control beside the character it belongs to. `user://` is the fallback for an
## exported build, where res:// is read-only — and it WINS on load, so a pass
## made in a shipped build is not silently discarded.
static func read(res_path: String, user_path: String) -> Dictionary:
var base := _read_one(res_path)
var over := _read_one(user_path)
if over.is_empty():
return base
if base.is_empty():
return over
# Shallow is enough: the layers below are merged per key anyway.
for k in over:
base[k] = over[k]
return base
static func _read_one(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
return {}
var parsed = JSON.parse_string(FileAccess.get_file_as_string(path))
return parsed if typeof(parsed) == TYPE_DICTIONARY else {}
## The resolved table for one character and one subject, most general first.
##
## Vectors survive the JSON round trip as three-element arrays and are rebuilt
## here rather than at every read site — a caller that forgot would get an Array
## where it expected a Vector3, which fails somewhere else entirely.
static func resolve(all: Dictionary, skin_id: String, subject: String) -> Dictionary:
var out := {}
var skins: Dictionary = all.get("skins", {})
var mine: Dictionary = skins.get(skin_id, {})
for layer in [all.get("defaults", {}), mine.get("_all", {}),
mine.get(subject, {})]:
if typeof(layer) != TYPE_DICTIONARY:
continue
for k in layer:
out[k] = layer[k]
return revive(out)
## Three-element arrays back into Vector3s, in place. Anything else is left
## alone, so a knob that is genuinely a list of three numbers would need its own
## handling — none is, and one that was would be a Vector3 anyway.
static func revive(table: Dictionary) -> Dictionary:
for k in table.keys():
var v = table[k]
if v is Array and v.size() == 3:
table[k] = Vector3(float(v[0]), float(v[1]), float(v[2]))
return table
static func flatten(table: Dictionary) -> Dictionary:
var flat := {}
for k in table:
var v = table[k]
flat[k] = [v.x, v.y, v.z] if v is Vector3 else v
return flat
## Store one character+subject's table and write the file. Returns where it went.
static func write(all: Dictionary, skin_id: String, subject: String,
table: Dictionary, res_path: String, user_path: String) -> String:
if not all.has("skins"):
all["skins"] = {}
if not all["skins"].has(skin_id):
all["skins"][skin_id] = {}
all["skins"][skin_id][subject] = flatten(table)
var text := JSON.stringify(all, " ")
for path in [res_path, user_path]:
var f := FileAccess.open(path, FileAccess.WRITE)
if f:
f.store_string(text)
f.close()
return path
return "<could not write>"
## The default for a knob, from a spec table shaped
## `[key, label, minimum, maximum, is_vector, default]`.
static func default_for(specs: Array, key: String):
for spec in specs:
if spec[0] == key:
return spec[5]
return 0.0
+1
View File
@@ -0,0 +1 @@
uid://cq378n7o0qnoc
+10 -65
View File
@@ -10,7 +10,7 @@ class_name WeaponHoldTuning
## rides, how far the elbow flares, how hard the fingers close. Those want an
## artist's eye and a slider, not another guess in code.
##
## This is where that judgement is stored. debug/weapon_lab.gd writes it;
## This is where that judgement is stored. debug/rig_lab.gd writes it;
## SkinnedPlayerModel reads it when a weapon is equipped.
##
## Resolution is layered, most general first, so a single number can be set once
@@ -61,79 +61,24 @@ const KNOBS := [
## The built-in value for a knob, for a lab that has nothing saved yet.
static func default_for(key: String):
for spec in KNOBS:
if spec[0] == key:
return spec[5]
return 0.0
static func _read(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
return {}
var parsed = JSON.parse_string(FileAccess.get_file_as_string(path))
return parsed if typeof(parsed) == TYPE_DICTIONARY else {}
return TuningStore.default_for(KNOBS, key)
## The layering, the JSON round trip and the res://-then-user:// write all live
## in TuningStore now, because they are not specific to weapons — rig anchors
## want exactly the same behaviour, and having two copies of it would mean two
## places for "an exported build's tuning pass is silently discarded" to come
## back. The on-disk format is unchanged.
static func load_all() -> Dictionary:
# user:// wins, so a tuning pass made in an exported build is not lost, and
# so the lab can be used without a writable project directory.
var base := _read(PATH)
var over := _read(USER_PATH)
if over.is_empty():
return base
if base.is_empty():
return over
# Shallow merge is enough: the layers below are merged per key anyway.
for k in over:
base[k] = over[k]
return base
return TuningStore.read(PATH, USER_PATH)
## The resolved knob table for one character holding one weapon.
##
## Vectors survive the JSON round trip as three-element arrays, so they are
## rebuilt here rather than at every read site.
static func resolve(all: Dictionary, skin_id: String, weapon_id: String) -> Dictionary:
var out := {}
var skins: Dictionary = all.get("skins", {})
var mine: Dictionary = skins.get(skin_id, {})
for layer in [all.get("defaults", {}), mine.get("_all", {}),
mine.get(weapon_id, {})]:
if typeof(layer) != TYPE_DICTIONARY:
continue
for k in layer:
out[k] = layer[k]
for k in out.keys():
var v = out[k]
if v is Array and v.size() == 3:
out[k] = Vector3(float(v[0]), float(v[1]), float(v[2]))
return out
return TuningStore.resolve(all, skin_id, weapon_id)
## Store one character+weapon's knobs and write the file. Returns where it went.
static func save(all: Dictionary, skin_id: String, weapon_id: String,
knobs: Dictionary) -> String:
if not all.has("skins"):
all["skins"] = {}
if not all["skins"].has(skin_id):
all["skins"][skin_id] = {}
var flat := {}
for k in knobs:
var v = knobs[k]
flat[k] = [v.x, v.y, v.z] if v is Vector3 else v
all["skins"][skin_id][weapon_id] = flat
var text := JSON.stringify(all, " ")
# Prefer the project copy so a tuning pass lands in version control with the
# character it belongs to; fall back to user:// when res:// is not writable.
var f := FileAccess.open(PATH, FileAccess.WRITE)
if f:
f.store_string(text)
f.close()
return PATH
f = FileAccess.open(USER_PATH, FileAccess.WRITE)
if f:
f.store_string(text)
f.close()
return USER_PATH
return "<could not write>"
return TuningStore.write(all, skin_id, weapon_id, knobs, PATH, USER_PATH)