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]>
85 lines
4.0 KiB
GDScript
85 lines
4.0 KiB
GDScript
extends Object
|
|
class_name WeaponHoldTuning
|
|
|
|
## Per-character, per-weapon overrides for how a gun is held.
|
|
##
|
|
## Every knob in the rifle hold used to be a constant tuned against one rig, and
|
|
## every one of them was wrong on the next character imported — the mount
|
|
## rotation, the wrist twist, the weapon size. The ones that CAN be derived from
|
|
## the skeleton now are. The rest are genuinely art direction: how high the stock
|
|
## 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/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
|
|
## for everything and then contradicted where it matters:
|
|
##
|
|
## defaults every character, every weapon
|
|
## skins.<skin>._all this character, every weapon
|
|
## skins.<skin>.<weapon> this character, this weapon
|
|
##
|
|
## An empty file means "use the built-in defaults", so the game runs perfectly
|
|
## well with no tuning at all — this only ever adds information.
|
|
|
|
const PATH := "res://assets/characters/weapon_holds.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://weapon_holds.json"
|
|
|
|
## key -> [label, minimum, maximum, is_vector, default]
|
|
##
|
|
## The lab builds its whole UI from this, so adding a knob here is all it takes
|
|
## to expose one. Ranges are what a plausible answer lives inside, not what the
|
|
## value can technically be.
|
|
##
|
|
## The DEFAULT must match what the code does when nothing is tuned, or the lab
|
|
## 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 := [
|
|
["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)],
|
|
# Zero means "use the code's own hip/ADS blend" 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],
|
|
]
|
|
|
|
|
|
## The built-in value for a knob, for a lab that has nothing saved yet.
|
|
static func default_for(key: String):
|
|
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:
|
|
return TuningStore.read(PATH, USER_PATH)
|
|
|
|
|
|
## The resolved knob table for one character holding one weapon.
|
|
static func resolve(all: Dictionary, skin_id: String, weapon_id: String) -> Dictionary:
|
|
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:
|
|
return TuningStore.write(all, skin_id, weapon_id, knobs, PATH, USER_PATH)
|