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.._all this character, every weapon ## skins.. 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)], # 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 anchor, off the barrel line", -0.15, 0.15, true, Vector3.ZERO], ["fore_shift", "Support-hand anchor, off the barrel line", -0.15, 0.15, true, Vector3.ZERO], # 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)