Files
Papay-Shooter/weapons/weapon_hold_profiles.gd
Nicholas ButzkeandClaude Opus 5 a13ae50f95 feat(rig): every weapon gets its own hold, so the silhouette names the gun
`_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]>
2026-07-28 12:05:43 -04:00

293 lines
12 KiB
GDScript

extends Object
class_name WeaponHoldProfiles
## How each weapon in the set is HELD — one archetype per class of weapon,
## rather than one rifle hold for all twelve.
##
## ── The problem this exists to fix ───────────────────────────────────────────
##
## `ShooterPoseModifier._apply_rifle_hold` did exactly what its name says, to
## everything. A knife, a rocket launcher and an AK were all solved as a rifle:
## stock in the shoulder pocket, support hand out along the barrel, muzzle on the
## aim line. In third person every character therefore stood in the same pose
## regardless of what they were carrying, and the ONLY thing distinguishing a
## sniper from a shotgun was the ~30 cm of gun mesh in their hands — which at the
## distance an enemy is usually seen is nothing.
##
## That matters beyond looking wrong. In a shooter the reason a character's pose
## is readable at range is that it is the fastest available answer to "what is
## about to happen to me". A shouldered tube means take cover; a blade held low
## means they have to close the distance; a rifle at low ready means they have not
## seen you yet. A single hold throws all of that away.
##
## It is also the specific thing HoYoverse's team say they chase in Zenless Zone
## Zero: characters read by SILHOUETTE first, and their designers deliberately
## refuse to settle on one construction method because a single method limits how
## distinguishable the results can be. The same argument applies one level down,
## to how a character holds a thing.
##
## ── What a profile controls ─────────────────────────────────────────────────
##
## A style is not a bundle of slider values. Three of the differences below
## cannot be expressed as a number on the existing rifle solve at all, and those
## are the ones that make the silhouette:
##
## support where the off hand goes, and how it is ORIENTED there —
## wrapped round a handguard, cupped under a pistol grip, hooked
## under a tube, or released entirely so the animation owns it
## 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 (a cheek weld) or
## leans away to clear a tube
##
## Everything else — the pocket offsets, the muzzle pitch at low ready, the elbow
## poles, the finger curls — is an ordinary knob, and the profile just supplies a
## better DEFAULT for that weapon than one global constant could.
##
## ── Layering ────────────────────────────────────────────────────────────────
##
## This is a defaults layer, underneath everything an artist has tuned:
##
## code constants the rifle solve's own fallbacks
## THIS FILE per weapon: what kind of thing it is
## weapon_holds.json defaults -> skins.<skin>._all -> skins.<skin>.<weapon>
##
## So a character with tuning saved from the rig lab is completely unaffected —
## aria's hand-tuned AK-47 hold still wins on every knob it sets — and a weapon
## nobody has tuned stops being held like an AK.
# ── The styles ───────────────────────────────────────────────────────────────
const RIFLE := "rifle"
const SMG := "smg"
const SNIPER := "sniper"
const SHOTGUN := "shotgun"
const LAUNCHER := "launcher"
const PISTOL := "pistol"
const BLADE := "blade"
# ── Where the support hand goes, and how it is turned ────────────────────────
## Wrapped round a handguard, fingers closing ACROSS the barrel. The rifle case.
const SUPPORT_BARREL := "barrel"
## Cupped under and around the firing fist. Two hands together, no shoulder
## contact — a pistol, or any weapon light enough to be held out in front.
const SUPPORT_CUPPED := "cupped"
## Hooked UNDER a tube from below, palm up, well forward of the shoulder. The
## launcher case, where there is no handguard to wrap and the weight is carried
## rather than aimed.
const SUPPORT_TUBE := "tube"
## No support hand at all. The animation keeps the arm, which is what a character
## carrying a blade should look like — the off hand swings with the run cycle.
const SUPPORT_FREE := "free"
## Style -> the structural rules for it.
##
## `pocket_hip` / `pocket_ads` are where the weapon's REAR sits, relative to the
## right shoulder joint, in skeleton space (x across, y up, z forward). They are
## the single most important number here: it is what decides whether a weapon
## reads as shouldered, carried, or held out.
##
## `cheek` is how far the head comes down and across to meet the stock, 0..1,
## applied only as the character shoulders the weapon. Negative leans the head
## AWAY, which is what a tube over the shoulder requires.
##
## `pitch_hip` is the muzzle's droop at low ready, in radians. A launcher's tube
## rides nose-UP because that is how you carry something you do not want pointed
## at your own feet; a blade points forward and in.
const STYLES := {
RIFLE: {
"support": SUPPORT_BARREL,
"pocket_hip": Vector3(0.03, -0.07, 0.06),
"pocket_ads": Vector3(0.05, 0.01, 0.07),
"pitch_hip": 0.16,
"cheek": 0.18,
"pole_r_hip": Vector3(-0.55, -0.85, -0.20),
"pole_l_hip": Vector3(0.45, -0.90, -0.10),
"curl_wrap": 1.0,
"curl_trigger": 1.0,
},
# Compact: the whole weapon is carried closer in, the support hand cannot go
# far because there is not much gun in front of the grip, and the firing elbow
# tucks rather than flares. This is what stops an MP7 from being posed as a
# short rifle with the support arm reaching for a handguard that ended.
SMG: {
"support": SUPPORT_BARREL,
"pocket_hip": Vector3(0.04, -0.10, 0.03),
"pocket_ads": Vector3(0.05, 0.00, 0.05),
"pitch_hip": 0.24,
"cheek": 0.10,
"pole_r_hip": Vector3(-0.40, -0.95, -0.15),
"pole_l_hip": Vector3(0.35, -0.95, -0.05),
"gun_fore": 0.17,
"curl_wrap": 1.05,
"curl_trigger": 1.0,
},
# The cheek weld IS the sniper silhouette. The head comes down onto the stock,
# the pocket sits high and tight so the optic lands at eye height, and the
# support hand goes far out because a long barrel gives it somewhere to go.
SNIPER: {
"support": SUPPORT_BARREL,
# Rides HIGH. A scoped rifle is held so the optic meets the eye, not so
# the butt meets the pocket, and raising the whole weapon is what makes
# that read — pushing the support hand further out does NOT, because the
# reach solver slides it back down the handguard until the arm can get
# there, so a longer `gun_fore` on these stylised arms lands the off hand
# in exactly the same place as a rifle's. Measured: 0.388 vs 0.387 m.
"pocket_hip": Vector3(0.015, 0.005, 0.045),
"pocket_ads": Vector3(0.040, 0.075, 0.055),
"pitch_hip": 0.12,
"cheek": 0.85,
"pole_r_hip": Vector3(-0.72, -0.55, -0.22),
"pole_l_hip": Vector3(0.22, -1.05, -0.02),
"gun_fore": 0.34,
"curl_wrap": 1.0,
"curl_trigger": 0.85,
},
# Held lower and squarer than a rifle, with the support hand back on the
# forend rather than out at the muzzle, and a hard wrap — a shotgun is gripped,
# not balanced.
SHOTGUN: {
"support": SUPPORT_BARREL,
# Carried low and FORWARD, the opposite of the sniper's high tuck. A
# shotgun is pointed rather than aimed and the elbows go wide, because
# the recoil comes back through them.
#
# Low and INBOARD does not work, however tempting the symmetry: +x is
# toward the character's centreline, so dropping the pocket and pushing
# it across at the same time swings the barrel through the chest. It is
# visible immediately in debug/hold_capture.gd and in no assertion —
# the hands were still exactly where they had been asked to go.
"pocket_hip": Vector3(0.030, -0.130, 0.080),
"pocket_ads": Vector3(0.045, -0.040, 0.075),
"pitch_hip": 0.30,
"cheek": 0.22,
"pole_r_hip": Vector3(-0.62, -0.80, -0.22),
"pole_l_hip": Vector3(0.58, -0.72, -0.16),
"gun_fore": 0.22,
"curl_wrap": 1.2,
"curl_trigger": 1.0,
},
# The tube goes ON the shoulder, not into it, and the head leans AWAY to clear
# it. The support hand hooks under from below, well forward, because there is
# nothing to wrap and the job of that arm is to carry weight.
LAUNCHER: {
"support": SUPPORT_TUBE,
"pocket_hip": Vector3(0.055, 0.06, 0.01),
"pocket_ads": Vector3(0.065, 0.105, 0.03),
"pitch_hip": -0.16,
"cheek": -0.30,
"pole_r_hip": Vector3(-0.85, -0.45, -0.10),
"pole_l_hip": Vector3(0.25, -0.85, 0.10),
"gun_stock": 0.30,
"gun_fore": 0.30,
"curl_wrap": 1.0,
"curl_trigger": 0.9,
},
# No shoulder contact at all. The pocket is pushed forward and outboard of the
# joint, which puts both arms out in front of the chest, and the support hand
# cups the firing fist instead of reaching for a barrel.
PISTOL: {
"support": SUPPORT_CUPPED,
"pocket_hip": Vector3(0.02, -0.16, 0.16),
"pocket_ads": Vector3(0.00, -0.04, 0.30),
"pitch_hip": 0.30,
"cheek": 0.05,
"pole_r_hip": Vector3(-0.45, -0.95, -0.05),
"pole_l_hip": Vector3(0.45, -0.95, -0.05),
"gun_stock": 0.03,
"gun_fore": 0.05,
"weapon_scale": 1.0,
"curl_wrap": 1.15,
"curl_trigger": 0.9,
},
# One hand. The off arm is RELEASED back to the animation, so it swings with
# the run cycle instead of gripping a handguard that does not exist — which is
# most of what makes a knife read as a knife at any distance.
BLADE: {
"support": SUPPORT_FREE,
"pocket_hip": Vector3(0.09, -0.20, 0.10),
"pocket_ads": Vector3(0.05, -0.06, 0.22),
"pitch_hip": 0.55,
"cheek": 0.0,
"pole_r_hip": Vector3(-0.35, -1.00, -0.10),
"pole_l_hip": Vector3(0.45, -0.90, -0.10),
"gun_stock": 0.02,
"gun_fore": 0.0,
"weapon_scale": 1.0,
# A blade is held in a full fist — there is no trigger to keep a finger
# straight along, and an extended index on a knife handle looks like a
# mistake rather than like discipline.
"curl_wrap": 1.25,
"curl_trigger": 1.25,
},
}
## Weapon id (the script's basename) -> style.
##
## Keyed on the basename rather than on the class, so this table does not have to
## load twelve weapon scripts to be read, and so a weapon that has not been
## written yet can be listed here the moment its file exists.
const WEAPON_STYLES := {
"ak47": RIFLE,
"m4": RIFLE,
"plasma_gun": RIFLE,
"mp7": SMG,
"nail_gun": SMG,
"dmr": SNIPER,
"awp": SNIPER,
"double_barrel_shotgun": SHOTGUN,
"rocket_launcher": LAUNCHER,
"rocket_swarm": LAUNCHER,
"mortar": LAUNCHER,
"knife": BLADE,
}
## What an unlisted weapon is held as. A rifle is the safe assumption: it is the
## only style that reaches for a handguard, and a weapon with no handguard held
## as a rifle looks odd, where a rifle held as anything else looks broken.
const FALLBACK := RIFLE
## The style name for a weapon id or script path.
static func style_for(weapon: String) -> String:
var id := _id_of(weapon)
return WEAPON_STYLES.get(id, FALLBACK)
## The default knob table for a weapon — the style's values, ready to be merged
## under whatever the JSON tuning says.
##
## Returns a COPY, because the caller merges the artist's values into it and a
## shared dictionary would accumulate one character's tuning into every other's.
static func knobs_for(weapon: String) -> Dictionary:
var style: Dictionary = STYLES.get(style_for(weapon), STYLES[FALLBACK])
var out := style.duplicate(true)
# `support` and `cheek` are structural, not knobs — they are read straight
# off the style by the pose layer and must not end up in the tuning table,
# where the rig lab would offer sliders for them.
out.erase("support")
out.erase("cheek")
return out
## How the off hand is used for this weapon. See the SUPPORT_* constants.
static func support_for(weapon: String) -> String:
var style: Dictionary = STYLES.get(style_for(weapon), STYLES[FALLBACK])
return style.get("support", SUPPORT_BARREL)
## How far the head comes to the stock, 0..1, negative to lean away.
static func cheek_for(weapon: String) -> float:
var style: Dictionary = STYLES.get(style_for(weapon), STYLES[FALLBACK])
return float(style.get("cheek", 0.0))
## A weapon id from either an id or a `res://weapons/<id>.gd` path.
static func _id_of(weapon: String) -> String:
if weapon.ends_with(".gd"):
return weapon.get_file().get_basename()
return weapon