extends Node ## Autoload: manages all available player skins. ## ## Skins come from two places: ## 1. Built-in signature characters (registered below). These are the ## authored Sakura/Akiba defaults and may layer equipment over a GLB rig. ## 2. assets/characters/skins/skins.json — written automatically by ## tools/pipeline.py for every imported character. No code changes needed ## to add a new model: run the pipeline, restart the game, it's there. ## ## The selected skin persists to user://skin_selection.cfg and is synced to ## other players via PlayerMovementController.synced_skin_id. signal skin_changed(skin_id: String) const SKINS_JSON := "res://assets/characters/skins/skins.json" const LOCAL_SKINS_JSON := "res://assets/characters/skins/skins.local.json" const SELECTION_CFG := "user://skin_selection.cfg" var skins: Dictionary = {} var active_skin_id: String = "default" func _ready() -> void: _register_builtin_skins() _load_skins_json() _load_skin_registry(LOCAL_SKINS_JSON, false) _load_selection() # Fold the old global character choice into legacy loadouts, then derive the # live skin from the active loadout from this point onward. var loadout_manager := get_node_or_null("/root/LoadoutManager") if loadout_manager: loadout_manager.migrate_legacy_skin(active_skin_id) var loadout_skin: String = loadout_manager.get_active_skin_id() if skins.has(loadout_skin) and skins[loadout_skin].is_unlocked: active_skin_id = loadout_skin print("SkinManager: %d skins available" % skins.size()) func _register_builtin_skins() -> void: _add_mecha_skin("default", "Sakura Vanguard", "HD anime shrine-runner in a petal-armored vector-thrust frame", "res://assets/characters/skins/sakura_pilot_hd.glb", "sakura", Color("efe7e8"), Color("e86f98"), Color("343a61"), Color("ffabc8")) _add_mecha_skin("akiba_pulse", "Akiba Pulse", "HD anime district pilot in a graphite neon cyber-mecha frame", "res://assets/characters/skins/akiba_pilot_hd.glb", "akiba", Color("272c3e"), Color("20c9d8"), Color("171a28"), Color("ff4eb8")) func _add_mecha_skin(id: String, display_name: String, description: String, model: String, theme: String, sleeve: Color, accent: Color, glove: Color, nail: Color) -> void: var skin := PlayerSkin.new() skin.skin_name = display_name skin.description = description skin.model_path = model skin.set("mecha_theme", theme) skin.viewmodel_sleeve_color = sleeve skin.viewmodel_accent_color = accent skin.viewmodel_glove_color = glove skin.viewmodel_nail_color = nail skin.viewmodel_hand_style = "fingerless" skin.viewmodel_sleeve_ratio = 0.72 skin.is_unlocked = true skins[id] = skin func _add_color_skin(id: String, display_name: String, description: String, tint: Color) -> void: var skin := PlayerSkin.new() skin.skin_name = display_name skin.description = description skin.color_tint = tint skin.viewmodel_sleeve_color = tint.darkened(0.18) skin.viewmodel_accent_color = tint.lightened(0.22) skin.viewmodel_glove_color = tint.darkened(0.48) skin.viewmodel_nail_color = tint.lightened(0.34) skin.viewmodel_hand_style = "fingerless" skin.is_unlocked = true skins[id] = skin func _load_skins_json() -> void: _load_skin_registry(SKINS_JSON, true) func _load_skin_registry(path: String, warn_if_malformed: bool) -> void: if not FileAccess.file_exists(path): return var file := FileAccess.open(path, FileAccess.READ) if not file: return var data = JSON.parse_string(file.get_as_text()) file.close() if not data is Dictionary or not data.has("skins"): if warn_if_malformed: push_warning("SkinManager: %s is malformed" % path) return for entry in data["skins"]: if not entry is Dictionary or not entry.has("id"): continue var skin := PlayerSkin.new() skin.skin_name = entry.get("name", entry["id"]) skin.description = entry.get("description", "") skin.model_path = entry.get("model", "") skin.set("mecha_theme", entry.get("mecha_theme", "")) skin.is_unlocked = entry.get("unlocked", true) var viewmodel = entry.get("viewmodel", {}) if viewmodel is Dictionary: skin.viewmodel_sleeve_color = _json_color( viewmodel.get("sleeve", ""), skin.viewmodel_sleeve_color) skin.viewmodel_accent_color = _json_color( viewmodel.get("accent", ""), skin.viewmodel_accent_color) skin.viewmodel_skin_color = _json_color( viewmodel.get("skin", ""), skin.viewmodel_skin_color) skin.viewmodel_glove_color = _json_color( viewmodel.get("glove", ""), skin.viewmodel_glove_color) skin.viewmodel_nail_color = _json_color( viewmodel.get("nail", ""), skin.viewmodel_nail_color) var hand_style: String = str(viewmodel.get( "hand_style", skin.viewmodel_hand_style)) if hand_style in ["bare", "fingerless", "glove", "android"]: skin.viewmodel_hand_style = hand_style skin.viewmodel_sleeve_ratio = clampf( float(viewmodel.get("sleeve_ratio", skin.viewmodel_sleeve_ratio)), 0.2, 0.9) skins[entry["id"]] = skin func _json_color(value: Variant, fallback: Color) -> Color: if value is String and not value.is_empty(): return Color.from_string(value, fallback) return fallback # ── Selection ───────────────────────────────────────────────────────────────── func get_skin(skin_id: String) -> PlayerSkin: return skins.get(skin_id, skins["default"]) func get_active_skin() -> PlayerSkin: return get_skin(active_skin_id) func set_active_skin(skin_id: String) -> void: if not skins.has(skin_id) or not skins[skin_id].is_unlocked: return active_skin_id = skin_id _save_selection() skin_changed.emit(skin_id) func get_skin_ids() -> Array: return skins.keys() func _save_selection() -> void: var cfg := ConfigFile.new() cfg.set_value("skin", "active", active_skin_id) cfg.save(SELECTION_CFG) func _load_selection() -> void: var cfg := ConfigFile.new() if cfg.load(SELECTION_CFG) == OK: var saved: String = cfg.get_value("skin", "active", "default") if skins.has(saved): active_skin_id = saved