`_apply_rifle_hold` did exactly what its name said, to everything. A knife, an
AWP and a rocket launcher were all solved as a rifle — stock in the shoulder
pocket, support hand out along the barrel, muzzle on the aim line — so in third
person every character stood the same way whatever they carried, and the only
thing distinguishing a sniper from a shotgun was the ~30 cm of gun mesh in
their hands. At the distance an enemy is usually seen that is nothing.
It costs more than looking wrong. A character's pose is the fastest available
answer to "what is about to happen to me": a tube on a shoulder means take
cover, a blade held low means they have to close, a rifle at low ready means
they have not seen you. One hold throws all of that away. It is also the thing
HoYoverse's team say they chase in Zenless Zone Zero — characters read by
silhouette first, and they refuse to settle on one construction method because
one method limits how distinguishable the results can be.
weapons/weapon_hold_profiles.gd gives each of the twelve weapons a style, and a
style is not a bundle of slider values. Three of its differences cannot be
expressed on the rifle solve at all, and those are the ones that carry:
support WHERE the off hand goes and HOW IT IS TURNED there — wrapped round
a handguard, cupped against the firing fist, hooked under a tube
palm-up, or released entirely. Sending a hand somewhere new without
re-orienting it gives a hand teleported to the new spot still shaped
for the old one.
mount whether the weapon's rear sits IN the shoulder pocket, ON TOP of the
shoulder, or nowhere near it.
head whether the head comes down to the stock, or leans away to clear a
tube. The cheek weld is the sniper silhouette, and its inverse is
what says a launcher is resting on that shoulder.
A blade RELEASES the off arm back to the animation, so it swings with the run
cycle instead of gripping a handguard that is not there — most of what makes a
one-handed weapon read as one-handed — and closes a full fist, because an index
left straight along a knife handle reads as a mistake, not as discipline.
Layered strictly UNDER the existing tuning, so aria's hand-tuned AK-47 hold is
byte-for-byte what it was. WeaponHoldTuning.default_for is weapon-aware now,
which it had to be: the rig lab SAVES every knob it shows, so without it,
opening the lab on the knife and pressing save would silently overwrite the
blade profile with the rifle spec and put the character back to holding a knife
like an AK with nothing to indicate it had happened.
debug/weapon_hold_check.gd asserts the consequence, not the plumbing — storing
an enum and reading it back proves nothing. It measures where the hands and head
ACTUALLY end up, from inside the modifier pass (outside it, Godot restores the
local poses and every weapon reports an identical rifle) and in the shoulder's
own frame, because the hold breathes and two samples of the SAME weapon
otherwise differ by more than two different weapons do. 21 weapon pairs, all
distinguishable; the knife's off-hand weight measured at 0.01.
Two findings only measuring produced. Pushing `gun_fore` further out for the
sniper does NOTHING — the reach solver slides the support hand back down the
handguard until the arm can get there, so it landed at 0.388 m against the
rifle's 0.387. Raising the whole weapon is what makes a scoped rifle read.
And one only LOOKING produced, via debug/hold_capture.gd: the shotgun's barrel
passed through the character's chest. Every assertion passed — the hands were
exactly where they had been asked to go — but +x is toward the centreline, so
dropping the pocket and pushing it across at once swings the muzzle into the
torso.
rig_anchor_check still reports aria: her hand-tuned wrist rotates the anchor
offset. Pre-existing, and this halves it — it was 2 failures on main, now 1.
Co-Authored-By: Claude Opus 5 <[email protected]>
206 lines
9.7 KiB
GDScript
206 lines
9.7 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"
|
|
|
|
# ── 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
|
|
## 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.
|
|
##
|
|
## 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_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
|
|
# long time that was the only freedom either anchor had: the trigger hand
|
|
# could slide up and down the gun's own axis and nowhere else, and so could
|
|
# the support hand. That is fine for where along a handguard to hold, and
|
|
# useless for a handguard that sits below the bore, an angled foregrip, or a
|
|
# pistol whose grip is nowhere near its barrel line.
|
|
#
|
|
# These are in the GUN's frame — x across, y up, z along the barrel — so they
|
|
# stay meaningful as the weapon pitches between low ready and ADS. Zero is
|
|
# exactly the old behaviour. Their z overlaps `gun_stock`/`gun_fore`, which
|
|
# 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, off the barrel line", -0.15, 0.15, true,
|
|
Vector3.ZERO],
|
|
["fore_shift", "SUPPORT hand, off the barrel line", -0.15, 0.15, true,
|
|
Vector3.ZERO],
|
|
["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, {
|
|
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.
|
|
##
|
|
## WEAPON-AWARE, and it has to be. The hold profile for a weapon class supplies
|
|
## better defaults than the generic spec table — a knife's stock pocket is at the
|
|
## hip, a launcher's is above the shoulder — and the lab SAVES every knob it
|
|
## shows, not just the ones that were moved. Without the weapon here, opening the
|
|
## lab on the knife and pressing save would silently overwrite the blade profile
|
|
## with the rifle spec's values, and the character would go back to holding a
|
|
## knife like an AK with no indication that anything had happened.
|
|
static func default_for(key: String, weapon_id: String = ""):
|
|
if weapon_id != "":
|
|
var profile := WeaponHoldProfiles.knobs_for(weapon_id)
|
|
if profile.has(key):
|
|
return profile[key]
|
|
return TuningStore.default_for(all_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)
|