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" # ── 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 `_`, 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)