extends Object class_name TuningStore ## Per-character art direction on disk, layered so a number can be set once for ## everyone and then contradicted exactly where it matters. ## ## defaults every character, every subject ## skins.._all this character, every subject ## skins.. this character, this subject ## ## "Subject" is whatever the caller is keying on — a weapon id for how a gun is ## held, an anchor set for where it is held. The store does not care. ## ## This is the shape WeaponHoldTuning arrived at, pulled out so it is not the ## only thing that can have it. Every knob in the rifle hold started as a ## constant tuned against one rig and was wrong on the next character imported; ## the ones that can be derived from the skeleton now are, and what is left is ## genuinely an artist's judgement — how high a stock rides, where in the palm a ## grip sits. Judgement wants a slider and a file, not another guess in code. ## ## An absent or empty file means "use the built-in defaults", so the game runs ## perfectly well with nothing tuned at all. This only ever ADDS information. ## Where a tuning file is read from and written to. ## ## The project copy is preferred on save so a tuning pass lands in version ## control beside the character it belongs to. `user://` is the fallback for an ## exported build, where res:// is read-only — and it WINS on load, so a pass ## made in a shipped build is not silently discarded. static func read(res_path: String, user_path: String) -> Dictionary: var base := _read_one(res_path) var over := _read_one(user_path) if over.is_empty(): return base if base.is_empty(): return over # Shallow is enough: the layers below are merged per key anyway. for k in over: base[k] = over[k] return base static func _read_one(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 {} ## The resolved table for one character and one subject, most general first. ## ## Vectors survive the JSON round trip as three-element arrays and are rebuilt ## here rather than at every read site — a caller that forgot would get an Array ## where it expected a Vector3, which fails somewhere else entirely. static func resolve(all: Dictionary, skin_id: String, subject: 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(subject, {})]: if typeof(layer) != TYPE_DICTIONARY: continue for k in layer: out[k] = layer[k] return revive(out) ## Three-element arrays back into Vector3s, in place. Anything else is left ## alone, so a knob that is genuinely a list of three numbers would need its own ## handling — none is, and one that was would be a Vector3 anyway. static func revive(table: Dictionary) -> Dictionary: for k in table.keys(): var v = table[k] if v is Array and v.size() == 3: table[k] = Vector3(float(v[0]), float(v[1]), float(v[2])) return table static func flatten(table: Dictionary) -> Dictionary: var flat := {} for k in table: var v = table[k] flat[k] = [v.x, v.y, v.z] if v is Vector3 else v return flat ## Store one character+subject's table and write the file. Returns where it went. static func write(all: Dictionary, skin_id: String, subject: String, table: Dictionary, res_path: String, user_path: String) -> String: if not all.has("skins"): all["skins"] = {} if not all["skins"].has(skin_id): all["skins"][skin_id] = {} all["skins"][skin_id][subject] = flatten(table) var text := JSON.stringify(all, " ") for path in [res_path, user_path]: var f := FileAccess.open(path, FileAccess.WRITE) if f: f.store_string(text) f.close() return path return "" ## The default for a knob, from a spec table shaped ## `[key, label, minimum, maximum, is_vector, default]`. static func default_for(specs: Array, key: String): for spec in specs: if spec[0] == key: return spec[5] return 0.0