feat: skin update

This commit is contained in:
2026-04-20 19:32:52 +08:00
parent b492dc99b6
commit f2e14f20f3
28 changed files with 1396 additions and 113 deletions
+4 -1
View File
@@ -49,7 +49,10 @@ func after_action_completed():
clear_highlights()
# Only update UI if this is the LOCAL HUMAN PLAYER
# Bots are owned by the host (authority match) but shouldn't trigger UI updates
# Guard against stale callbacks after peer teardown (host quitting solo match)
if not multiplayer.has_multiplayer_peer():
player._is_processing_action = false
return
if multiplayer.get_unique_id() == player.get_multiplayer_authority():
# Sync playerboard (Bots DO need to sync their board logic, just not update local UI)
if player.is_multiplayer_authority() and player.has_method("can_rpc") and player.can_rpc():
+221
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://cnp6a3r8bt0ol
+27 -1
View File
@@ -1,4 +1,14 @@
extends Node
# ---------------
# Old vs New Name
# ---------------
# Masbro is Dabro
# Bob is Pip
# Gatot is Gatot
# Oldpop is Copper
# ---------------
## UserProfileManager - Manages user profile data with Nakama storage
signal profile_loaded(profile: Dictionary)
@@ -129,7 +139,8 @@ func load_inventory() -> void:
if not result.is_exception() and result.objects:
for obj in result.objects:
inventory.append(obj.key)
if not inventory.has(obj.key):
inventory.append(obj.key)
func load_stats() -> Dictionary:
# Reset stats first to ensure fresh data for new logins
@@ -334,6 +345,21 @@ func fetch_shop_catalog() -> void:
shop_catalog = payload.catalog
emit_signal("profile_updated")
## Admin-only: grants a large amount of gold via a server-authoritative RPC.
## The Nakama function requireAdmin() on the server prevents non-admin abuse.
func admin_topup_gold() -> bool:
if not NakamaManager.session: return false
var result = await NakamaManager.client.rpc_async(
NakamaManager.session,
"admin_topup_gold",
"{}"
)
if result.is_exception():
push_error("[UserProfileManager] Topup failed: ", result.get_exception().message)
return false
await _reload_wallet()
return true
func buy_currency(package_id: String) -> bool:
if not NakamaManager.session: return false