Files
Papay-Shooter/characters/weapon_hold_tuning.gd
T
Nicholas ButzkeandClaude Opus 5 33d07b3717 feat(tools): a weapon hold lab for tuning each character's grip in 3D
I have been guessing at numbers that want an eye on them. This is the
environment to set them instead.

  godot --path . res://debug/weapon_lab.tscn

Pick a character, pick a weapon, pick a pose, drag sliders, press Save.
Thirteen knobs — weapon size, support-hand distance along the barrel, grip
to buttstock, muzzle pitch at low ready, both wrist rolls, three finger
curl amounts, the stock pocket for low-ready and for aiming, and both elbow
poles. Three markers show the points being solved for: red trigger grip,
green support hand, blue buttstock. If a hand is not ON its marker the IK
could not reach it, which is a different problem from the marker being in
the wrong place, and the two used to be indistinguishable.

Results land in assets/characters/weapon_holds.json, resolved in layers so
a number can be set once and 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 what the code derives", so the game runs exactly
as before until something is actually tuned. The knobs that CAN be derived
from the skeleton still are — mount rotation, wrist frame, weapon size —
and their sliders read "auto" at zero rather than silently overriding.

Two things this had to get right to be honest rather than merely present.
Slider defaults come from the same table as the code defaults, because a
slider parked at 0 beside a code default of 1.0 means the first touch of
that slider silently switches finger curl off. And the markers are
depth-tested: drawn through the body they look like they are floating in
front of the chest when they are really behind an arm, which is the exact
wrong impression for judging whether a hand is on its target.

`-- shot <path> [skin] [weapon]` renders one framed close-up and quits, so
the lab can be checked without a human at the controls — which is how both
of those bugs were caught.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 21:15:53 -04:00

140 lines
5.3 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/weapon_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):
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 {}
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
## 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
## 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>"