Files
Papay-Shooter/characters/skin_manager.gd
T
2026-08-09 01:31:44 -04:00

171 lines
6.0 KiB
GDScript

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