|
|
|
@@ -0,0 +1,221 @@
|
|
|
|
|
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] ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
"example-hat": {
|
|
|
|
|
"category": "head",
|
|
|
|
|
"character": "Oldpop",
|
|
|
|
|
"slots": [
|
|
|
|
|
{ "mesh": "oldpop-hat1", "mode": "override", "material": "" },
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
# ── [COSTUME] ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
"oldpop-grey-pant": {
|
|
|
|
|
"category": "costume",
|
|
|
|
|
"character": "Oldpop",
|
|
|
|
|
"slots": [
|
|
|
|
|
{ "mesh": "oldpop-body", "mode": "overlay", "material": "res://assets/characters/skins/clothing/bmo_greypants.tres" },
|
|
|
|
|
{ "mesh": "oldpop-bottom1", "mode": "override", "material": "res://assets/characters/skins/clothing/bmo_greypants.tres" },
|
|
|
|
|
{ "mesh": "oldpop-bottom2", "mode": "override", "material": "res://assets/characters/skins/clothing/bmo_greypants.tres" },
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
"oldpop-clothing-original": {
|
|
|
|
|
"category": "costume",
|
|
|
|
|
"character": "Oldpop",
|
|
|
|
|
"slots": [
|
|
|
|
|
{ "mesh": "oldpop-body", "mode": "overlay", "material": "res://assets/characters/skins/clothing/bmo_originalpants.tres" },
|
|
|
|
|
{ "mesh": "oldpop-bottom1", "mode": "override", "material": "res://assets/characters/skins/clothing/bmo_originalpants.tres" },
|
|
|
|
|
{ "mesh": "oldpop-bottom2", "mode": "override", "material": "res://assets/characters/skins/clothing/bmo_originalpants.tres" },
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
"oldpop-red-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] ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
"example-gloves": {
|
|
|
|
|
"category": "glove",
|
|
|
|
|
"character": "Oldpop",
|
|
|
|
|
"slots": [
|
|
|
|
|
{ "mesh": "oldpop-hands", "mode": "override", "material": "" },
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
## [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:
|
|
|
|
|
mn.set_surface_override_material(0, 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:
|
|
|
|
|
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
|