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.._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)], # 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 ""