extends Node ## SkinManager — Applies cosmetic material overrides / overlays to 3D character nodes. ## ## HOW TO ADD A NEW SKIN (edit this file only): ## Append an entry to SKIN_CATALOG below. Format: ## ## "item_id": { ## "category": "head", # head | costume | glove | accessory ## "character": "Oldpop", # node name under CharacterRoot ## "slots": [ ## { "mesh": "oldpop-hat1", "mode": "override", "material": "res://..." }, ## # mode: "override" → set_surface_override_material(0, mat) ## # mode: "overlay" → material_overlay (transparent layer over base) ## # Leave "material" as "" if the .tres file isn't ready yet — slot is skipped. ## ] ## }, # To add/edit/delete skins, open the editor tool: # scenes/tools/skin_catalog_editor.tscn (run it in the editor) # Or edit SKIN_CATALOG directly below — then click "Save & Generate" in the editor to sync. ## [BEGIN_SKIN_CATALOG] const SKIN_CATALOG: Dictionary = { # ── [HEAD] ────────────────────────────────────────────────────────────────────── "oldpop-blue-hat": { "category": "head", "character": "Oldpop", "slots": [ { "mesh": "oldpop-hat1", "mode": "override", "material": "res://assets/characters/skins/hat/oldpop_mat_hat_blue.tres" }, ] }, "oldpop-green-hat": { "category": "head", "character": "Oldpop", "slots": [ { "mesh": "oldpop-hat1", "mode": "override", "material": "res://assets/characters/skins/hat/oldpop_mat_hat_green.tres" }, ] }, "oldpop-red-hat": { "category": "head", "character": "Oldpop", "slots": [ { "mesh": "oldpop-hat1", "mode": "override", "material": "res://assets/characters/skins/hat/oldpop_mat_hat_red.tres" }, ] }, "oldpop-yellow-hat": { "category": "head", "character": "Oldpop", "slots": [ { "mesh": "oldpop-hat1", "mode": "override", "material": "res://assets/characters/skins/hat/oldpop_mat_hat_yellow.tres" }, ] }, # ── [COSTUME] ────────────────────────────────────────────────────────────────────── "oldpop-og-pant": { "category": "costume", "character": "Oldpop", "slots": [ { "mesh": "oldpop-body", "mode": "overlay", "material": "res://assets/characters/skins/clothing/oldpop_mat_cloth_ori_pant.tres" }, { "mesh": "oldpop-bottom1", "mode": "override", "material": "res://assets/characters/skins/clothing/oldpop_mat_cloth_ori_pant.tres" }, { "mesh": "oldpop-bottom2", "mode": "override", "material": "res://assets/characters/skins/clothing/oldpop_mat_cloth_ori_pant.tres" }, ] }, "oldpop-grey-pant": { "category": "costume", "character": "Oldpop", "slots": [ { "mesh": "oldpop-body", "mode": "overlay", "material": "res://assets/characters/skins/clothing/oldpop_mat_cloth_grey_pant.tres" }, { "mesh": "oldpop-bottom1", "mode": "override", "material": "res://assets/characters/skins/clothing/oldpop_mat_cloth_grey_pant.tres" }, { "mesh": "oldpop-bottom2", "mode": "override", "material": "res://assets/characters/skins/clothing/oldpop_mat_cloth_grey_pant.tres" }, ] }, "oldpop-red-pant": { "category": "costume", "character": "Oldpop", "slots": [ { "mesh": "oldpop-body", "mode": "overlay", "material": "res://assets/characters/skins/clothing/oldpop_mat_cloth_red_pant.tres" }, { "mesh": "oldpop-bottom1", "mode": "override", "material": "res://assets/characters/skins/clothing/oldpop_mat_cloth_red_pant.tres" }, { "mesh": "oldpop-bottom2", "mode": "override", "material": "res://assets/characters/skins/clothing/oldpop_mat_cloth_red_pant.tres" }, ] }, "oldpop-yellow-pant": { "category": "costume", "character": "Oldpop", "slots": [ { "mesh": "oldpop-body", "mode": "overlay", "material": "res://assets/characters/skins/clothing/bmo_redpants.tres" }, { "mesh": "oldpop-bottom1", "mode": "override", "material": "res://assets/characters/skins/clothing/bmo_redpants.tres" }, { "mesh": "oldpop-bottom2", "mode": "override", "material": "res://assets/characters/skins/clothing/bmo_redpants.tres" }, ] }, # ── [GLOVE] ────────────────────────────────────────────────────────────────────── "oldpop-blue-gloves": { "category": "glove", "character": "Oldpop", "slots": [ { "mesh": "oldpop-hands", "mode": "override", "material": "res://assets/characters/skins/gloves/oldpop_mat_gloves_blue.tres" }, ] }, "oldpop-green-gloves": { "category": "glove", "character": "Oldpop", "slots": [ { "mesh": "oldpop-hands", "mode": "override", "material": "res://assets/characters/skins/gloves/oldpop_mat_gloves_green.tres" }, ] }, "oldpop-red-gloves": { "category": "glove", "character": "Oldpop", "slots": [ { "mesh": "oldpop-hands", "mode": "override", "material": "res://assets/characters/skins/gloves/oldpop_mat_gloves_red.tres" }, ] }, "oldpop-yellow-gloves": { "category": "glove", "character": "Oldpop", "slots": [ { "mesh": "oldpop-hands", "mode": "override", "material": "res://assets/characters/skins/gloves/oldpop_mat_gloves_yellow.tres" }, ] }, } ## [END_SKIN_CATALOG] # ───────────────────────────────────────────────────────────────────────────── # Cached materials (loaded once, reused) # ───────────────────────────────────────────────────────────────────────────── var _mat_cache: Dictionary = {} # ───────────────────────────────────────────────────────────────────────────── # Public API # ───────────────────────────────────────────────────────────────────────────── ## Apply a single skin item onto the character model. func apply_skin(character_root: Node3D, item_id: String) -> void: var skin: Dictionary = SKIN_CATALOG.get(item_id, {}) if skin.is_empty(): push_warning("[SkinManager] Unknown item_id: " + item_id) return _apply_skin_data(character_root, skin) ## Apply every equipped item from a loadout dict { category: item_id }. ## Clears all known skin slots first so unequipped items are visually removed. func apply_loadout(character_root: Node3D, loadout: Dictionary) -> void: if not character_root: return # Clear all slots before applying — handles unequip correctly on all targets _clear_all_skins(character_root) for _category: String in loadout: var item_id: String = loadout.get(_category, "") if not item_id.is_empty(): apply_skin(character_root, item_id) ## Clear all material overrides/overlays for a given category. func clear_category(character_root: Node3D, category: String) -> void: if not character_root: return for item_id: String in SKIN_CATALOG: var skin: Dictionary = SKIN_CATALOG[item_id] if skin.get("category", "") != category: continue var char_node := _get_char_node(character_root, skin.get("character", "")) if char_node: for slot: Dictionary in skin.get("slots", []): _clear_slot(char_node, slot) ## Clear every skin slot across all categories. Used by apply_loadout. func _clear_all_skins(character_root: Node3D) -> void: if not character_root: return for item_id: String in SKIN_CATALOG: var skin: Dictionary = SKIN_CATALOG[item_id] var char_node := _get_char_node(character_root, skin.get("character", "")) if char_node: for slot: Dictionary in skin.get("slots", []): _clear_slot(char_node, slot) ## Preview a skin temporarily. Returns a Callable that reverts the change. ## var revert = SkinManager.preview_skin(root, "oldpop_hat1") ## revert.call() # when done func preview_skin(character_root: Node3D, item_id: String) -> Callable: var skin: Dictionary = SKIN_CATALOG.get(item_id, {}) if skin.is_empty(): return func(): pass var char_node := _get_char_node(character_root, skin.get("character", "")) if not char_node: return func(): pass # Snapshot existing materials before applying preview var snapshots: Array[Dictionary] = [] for slot: Dictionary in skin.get("slots", []): var mn := char_node.find_child(slot.get("mesh", ""), true, false) as MeshInstance3D if not mn: continue var mode: String = slot.get("mode", "override") snapshots.append({ "mesh_node": mn, "mode": mode, "prev": mn.material_overlay if mode == "overlay" else mn.get_surface_override_material(0), }) _apply_skin_data(character_root, skin) return func(): for snap: Dictionary in snapshots: var mn := snap["mesh_node"] as MeshInstance3D if not mn: continue if snap["mode"] == "overlay": mn.material_overlay = snap["prev"] else: mn.set_surface_override_material(0, snap["prev"]) # ───────────────────────────────────────────────────────────────────────────── # Internals # ───────────────────────────────────────────────────────────────────────────── func _apply_skin_data(character_root: Node3D, skin: Dictionary) -> void: var char_node := _get_char_node(character_root, skin.get("character", "")) if not char_node: push_warning("[SkinManager] Character node not found: " + skin.get("character", "?")) return for slot: Dictionary in skin.get("slots", []): var mesh_name: String = slot.get("mesh", "") var mode: String = slot.get("mode", "override") var mat_path: String = slot.get("material", "") if mesh_name.is_empty() or mat_path.is_empty(): continue # Material not provided yet — skip gracefully var mat: Material = _load_material(mat_path) if not mat: push_warning("[SkinManager] Material not found: " + mat_path) continue var mn := char_node.find_child(mesh_name, true, false) as MeshInstance3D if not mn: push_warning("[SkinManager] Mesh '%s' not found in '%s'" % [mesh_name, char_node.name]) continue if mode == "overlay": mn.material_overlay = mat else: # PRESERVE OUTLINE SHADER: Check if existing material has a next_pass (outline shader) var existing_mat = mn.get_surface_override_material(0) var preserved_next_pass = null if existing_mat and existing_mat.next_pass: preserved_next_pass = existing_mat.next_pass # Apply the skin material var skin_mat = mat.duplicate() if mat else null if skin_mat and preserved_next_pass: skin_mat.next_pass = preserved_next_pass mn.set_surface_override_material(0, skin_mat) func _clear_slot(char_node: Node3D, slot: Dictionary) -> void: var mesh_name: String = slot.get("mesh", "") var mode: String = slot.get("mode", "override") if mesh_name.is_empty(): return var mn := char_node.find_child(mesh_name, true, false) as MeshInstance3D if not mn: return if mode == "overlay": mn.material_overlay = null else: # PRESERVE OUTLINE SHADER: When clearing, check if we need to restore outline var existing_mat = mn.get_surface_override_material(0) if existing_mat and existing_mat.next_pass: # Has outline - restore base material with outline preserved var base_mat = mn.get_active_material(0) if base_mat: var restored_mat = base_mat.duplicate() restored_mat.next_pass = existing_mat.next_pass mn.set_surface_override_material(0, restored_mat) else: # No base material, just clear but this shouldn't happen normally mn.set_surface_override_material(0, null) else: mn.set_surface_override_material(0, null) func _get_char_node(character_root: Node3D, char_name: String) -> Node3D: if char_name.is_empty() or not character_root: return null return character_root.get_node_or_null(char_name) as Node3D func _load_material(path: String) -> Material: if _mat_cache.has(path): return _mat_cache[path] if not ResourceLoader.exists(path): return null var mat := ResourceLoader.load(path) as Material _mat_cache[path] = mat return mat