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
+3
View File
@@ -99,6 +99,9 @@ func _physics_process(delta):
return
# Only run on server/authority (Authority 1)
# Guard against peer being torn down (e.g. after host quits a solo match)
if not multiplayer.has_multiplayer_peer():
return
if not multiplayer.is_server():
return
+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
+536
View File
@@ -0,0 +1,536 @@
@tool
extends Control
## Skin Catalog Editor — run this scene in the Godot editor to manage all skins.
##
## USAGE:
## Open scenes/tools/skin_catalog_editor.tscn in the editor, then press F6 (Run Current Scene).
## Edit skins in the form, then click "💾 Save & Generate" to rewrite:
## • scripts/managers/skin_manager.gd (SKIN_CATALOG block)
## • server/nakama/tekton_admin.js (SHOP_CATALOG_DEFS block)
const DATA_PATH := "res://assets/data/skin_catalog_data.json"
const SKIN_MANAGER_PATH := "res://scripts/managers/skin_manager.gd"
const ADMIN_JS_PATH := "res://server/nakama/tekton_admin.js"
const CATEGORIES := ["head", "costume", "glove", "accessory"]
const RARITIES := ["Common", "Rare", "Epic", "Legendary"]
const MODES := ["override", "overlay"]
# Sentinel markers — must match what's in the target files
const BEGIN_SKIN := "## [BEGIN_SKIN_CATALOG]"
const END_SKIN := "## [END_SKIN_CATALOG]"
const BEGIN_SHOP := "// [BEGIN_SHOP_CATALOG_DEFS]"
const END_SHOP := "// [END_SHOP_CATALOG_DEFS]"
# ─── State ───────────────────────────────────────────────────────────────────
var _data: Array = []
var _selected_idx: int = -1
var _dirty: bool = false
# ─── UI refs (built in code) ─────────────────────────────────────────────────
var _skin_list_vbox: VBoxContainer
var _status_label: Label
var _form_panel: PanelContainer
var _no_sel_label: Label
var _form_item_id: LineEdit
var _form_name: LineEdit
var _form_character: LineEdit
var _form_gold: SpinBox
var _form_star: SpinBox
var _form_category: OptionButton
var _form_rarity: OptionButton
var _slots_vbox: VBoxContainer
var _delete_btn: Button
var _save_btn: Button
# ─────────────────────────────────────────────────────────────────────────────
func _ready() -> void:
_build_ui()
_load_data()
_refresh_list()
# ─────────────────────────────────────────────────────────────────────────────
# UI Construction
# ─────────────────────────────────────────────────────────────────────────────
func _build_ui() -> void:
anchor_right = 1.0
anchor_bottom = 1.0
# Root VBox
var root := VBoxContainer.new()
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(root)
# ── Top bar ──────────────────────────────────────────────────────────────
var top := HBoxContainer.new()
top.custom_minimum_size.y = 38
root.add_child(top)
var title := Label.new()
title.text = " 🎨 Skin Catalog Editor"
title.add_theme_font_size_override("font_size", 16)
title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
top.add_child(title)
var add_btn := Button.new()
add_btn.text = " New Skin"
add_btn.pressed.connect(_on_add_pressed)
top.add_child(add_btn)
_delete_btn = Button.new()
_delete_btn.text = "✕ Delete"
_delete_btn.disabled = true
_delete_btn.pressed.connect(_on_delete_pressed)
top.add_child(_delete_btn)
_save_btn = Button.new()
_save_btn.text = "💾 Save & Generate"
_save_btn.pressed.connect(_on_save_pressed)
top.add_child(_save_btn)
# ── Main split: list | form ───────────────────────────────────────────────
var hsplit := HSplitContainer.new()
hsplit.size_flags_vertical = Control.SIZE_EXPAND_FILL
hsplit.split_offset = 230
root.add_child(hsplit)
# LEFT — scrollable skin list
var list_scroll := ScrollContainer.new()
list_scroll.custom_minimum_size.x = 210
list_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
hsplit.add_child(list_scroll)
_skin_list_vbox = VBoxContainer.new()
_skin_list_vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
list_scroll.add_child(_skin_list_vbox)
# RIGHT — form in a scroll container
var form_scroll := ScrollContainer.new()
form_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
form_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
hsplit.add_child(form_scroll)
var form_root := VBoxContainer.new()
form_root.size_flags_horizontal = Control.SIZE_EXPAND_FILL
form_scroll.add_child(form_root)
_no_sel_label = Label.new()
_no_sel_label.text = "\n\n← Select a skin from the list to edit it."
_no_sel_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
form_root.add_child(_no_sel_label)
_form_panel = PanelContainer.new()
_form_panel.visible = false
_form_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
form_root.add_child(_form_panel)
var form_vbox := VBoxContainer.new()
form_vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_form_panel.add_child(form_vbox)
# Form fields
form_vbox.add_child(_section_label("── Item Info ───────────────────────────"))
_form_item_id = _field(form_vbox, "Item ID", "e.g. oldpop_hat1")
_form_name = _field(form_vbox, "Display Name", "e.g. Oldpop Hat I")
_form_character = _field(form_vbox, "Character (node)", "e.g. Oldpop, Masbro, Bob")
_form_gold = _spinbox(form_vbox, "Gold Price", 0, 99999)
_form_star = _spinbox(form_vbox, "Star Price", 0, 99999)
_form_category = _option(form_vbox, "Category", CATEGORIES)
_form_rarity = _option(form_vbox, "Rarity", RARITIES)
# ── Slots section ─────────────────────────────────────────────────────────
var slots_hdr := HBoxContainer.new()
form_vbox.add_child(slots_hdr)
var slots_title := Label.new()
slots_title.text = "── Material Slots ──────────────────────────"
slots_title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
slots_hdr.add_child(slots_title)
var add_slot_btn := Button.new()
add_slot_btn.text = " Add Slot"
add_slot_btn.pressed.connect(_on_add_slot_pressed)
slots_hdr.add_child(add_slot_btn)
var slot_header_row := HBoxContainer.new()
form_vbox.add_child(slot_header_row)
for col_text in ["Mesh Node Name", "Mode", "Material Path (res://...)", ""]:
var lbl := Label.new()
lbl.text = col_text
lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
slot_header_row.add_child(lbl)
_slots_vbox = VBoxContainer.new()
_slots_vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
form_vbox.add_child(_slots_vbox)
# ── Status bar ────────────────────────────────────────────────────────────
_status_label = Label.new()
_status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
root.add_child(_status_label)
func _section_label(text: String) -> Label:
var lbl := Label.new()
lbl.text = text
return lbl
func _field(parent: VBoxContainer, label_text: String, hint: String = "") -> LineEdit:
var row := HBoxContainer.new()
parent.add_child(row)
var lbl := Label.new()
lbl.text = label_text + ":"
lbl.custom_minimum_size.x = 150
row.add_child(lbl)
var edit := LineEdit.new()
edit.placeholder_text = hint
edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(edit)
return edit
func _spinbox(parent: VBoxContainer, label_text: String, mn: int, mx: int) -> SpinBox:
var row := HBoxContainer.new()
parent.add_child(row)
var lbl := Label.new()
lbl.text = label_text + ":"
lbl.custom_minimum_size.x = 150
row.add_child(lbl)
var spin := SpinBox.new()
spin.min_value = mn
spin.max_value = mx
spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(spin)
return spin
func _option(parent: VBoxContainer, label_text: String, items: Array) -> OptionButton:
var row := HBoxContainer.new()
parent.add_child(row)
var lbl := Label.new()
lbl.text = label_text + ":"
lbl.custom_minimum_size.x = 150
row.add_child(lbl)
var opt := OptionButton.new()
for item in items:
opt.add_item(item)
opt.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(opt)
return opt
# ─────────────────────────────────────────────────────────────────────────────
# Data I/O
# ─────────────────────────────────────────────────────────────────────────────
func _load_data() -> void:
if not FileAccess.file_exists(DATA_PATH):
_data = []
_set_status("No data file found — starting fresh.", Color.YELLOW)
return
var f := FileAccess.open(DATA_PATH, FileAccess.READ)
var parsed = JSON.parse_string(f.get_as_text())
f.close()
_data = parsed.get("skins", []) if parsed is Dictionary else []
_set_status("Loaded %d skin(s) from %s" % [_data.size(), DATA_PATH], Color.WHITE)
func _save_json() -> void:
var f := FileAccess.open(DATA_PATH, FileAccess.WRITE)
f.store_string(JSON.stringify({"skins": _data}, "\t"))
f.close()
# ─────────────────────────────────────────────────────────────────────────────
# List
# ─────────────────────────────────────────────────────────────────────────────
func _refresh_list() -> void:
for c in _skin_list_vbox.get_children():
c.queue_free()
for i in _data.size():
var entry: Dictionary = _data[i]
var btn := Button.new()
var cat: String = entry.get("category", "?")
var iid: String = entry.get("item_id", "?")
btn.text = "[%s]\n%s" % [cat, iid]
btn.alignment = HORIZONTAL_ALIGNMENT_LEFT
btn.toggle_mode = true
btn.button_pressed = (i == _selected_idx)
btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
btn.pressed.connect(_on_list_item_pressed.bind(i))
_skin_list_vbox.add_child(btn)
func _on_list_item_pressed(idx: int) -> void:
if _selected_idx >= 0:
_commit_form()
_selected_idx = idx
_refresh_list()
_populate_form()
# ─────────────────────────────────────────────────────────────────────────────
# Form
# ─────────────────────────────────────────────────────────────────────────────
func _populate_form() -> void:
if _selected_idx < 0 or _selected_idx >= _data.size():
_form_panel.visible = false
_no_sel_label.visible = true
_delete_btn.disabled = true
return
_form_panel.visible = true
_no_sel_label.visible = false
_delete_btn.disabled = false
var e: Dictionary = _data[_selected_idx]
_form_item_id.text = e.get("item_id", "")
_form_name.text = e.get("name", "")
_form_character.text = e.get("character", "")
_form_gold.value = e.get("gold", 0)
_form_star.value = e.get("star", 0)
var cat_idx := CATEGORIES.find(e.get("category", "head"))
_form_category.selected = max(0, cat_idx)
var rar_idx := RARITIES.find(e.get("rarity", "Common"))
_form_rarity.selected = max(0, rar_idx)
_rebuild_slot_rows(e.get("slots", []))
func _rebuild_slot_rows(slots: Array) -> void:
for c in _slots_vbox.get_children():
c.queue_free()
for i in slots.size():
_add_slot_row(i, slots[i])
func _add_slot_row(idx: int, slot: Dictionary) -> void:
var row := HBoxContainer.new()
_slots_vbox.add_child(row)
var mesh_edit := LineEdit.new()
mesh_edit.placeholder_text = "mesh node name"
mesh_edit.text = slot.get("mesh", "")
mesh_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
mesh_edit.tooltip_text = "MeshInstance3D node name, e.g. oldpop-hat1"
row.add_child(mesh_edit)
var mode_opt := OptionButton.new()
for m in MODES:
mode_opt.add_item(m)
mode_opt.selected = max(0, MODES.find(slot.get("mode", "override")))
row.add_child(mode_opt)
var mat_edit := LineEdit.new()
mat_edit.placeholder_text = "res://... (empty = skip)"
mat_edit.text = slot.get("material", "")
mat_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
mat_edit.tooltip_text = "Full res:// path to .tres material file"
row.add_child(mat_edit)
var del_btn := Button.new()
del_btn.text = ""
del_btn.custom_minimum_size.x = 30
del_btn.pressed.connect(func():
_commit_form()
var slots: Array = _data[_selected_idx].get("slots", [])
slots.remove_at(idx)
_data[_selected_idx]["slots"] = slots
_rebuild_slot_rows(slots)
)
row.add_child(del_btn)
func _commit_form() -> void:
if _selected_idx < 0 or _selected_idx >= _data.size():
return
var e: Dictionary = _data[_selected_idx]
e["item_id"] = _form_item_id.text.strip_edges()
e["name"] = _form_name.text.strip_edges()
e["character"] = _form_character.text.strip_edges()
e["gold"] = int(_form_gold.value)
e["star"] = int(_form_star.value)
e["category"] = CATEGORIES[_form_category.selected]
e["rarity"] = RARITIES[_form_rarity.selected]
# Read slots
var slots: Array = []
for row in _slots_vbox.get_children():
if not row is HBoxContainer:
continue
var ch := row.get_children()
if ch.size() < 3:
continue
slots.append({
"mesh": (ch[0] as LineEdit).text.strip_edges(),
"mode": MODES[(ch[1] as OptionButton).selected],
"material": (ch[2] as LineEdit).text.strip_edges(),
})
e["slots"] = slots
# ─────────────────────────────────────────────────────────────────────────────
# Button handlers
# ─────────────────────────────────────────────────────────────────────────────
func _on_add_pressed() -> void:
if _selected_idx >= 0:
_commit_form()
_data.append({
"item_id": "new_skin_%d" % _data.size(),
"name": "New Skin",
"category": "head",
"character": "",
"gold": 0,
"star": 0,
"rarity": "Common",
"slots": [],
})
_selected_idx = _data.size() - 1
_refresh_list()
_populate_form()
_set_status("New skin created. Fill in the form and Save & Generate.", Color.YELLOW)
func _on_delete_pressed() -> void:
if _selected_idx < 0:
return
var removed: String = _data[_selected_idx].get("item_id", "?")
_data.remove_at(_selected_idx)
_selected_idx = min(_selected_idx, _data.size() - 1)
_refresh_list()
_populate_form()
_set_status("Deleted: " + removed, Color.YELLOW)
func _on_add_slot_pressed() -> void:
if _selected_idx < 0:
return
_commit_form()
var slots: Array = _data[_selected_idx].get("slots", [])
slots.append({"mesh": "", "mode": "override", "material": ""})
_data[_selected_idx]["slots"] = slots
_rebuild_slot_rows(slots)
func _on_save_pressed() -> void:
if _selected_idx >= 0:
_commit_form()
_save_json()
var err_gd := _generate_skin_manager()
var err_js := _generate_admin_js()
if err_gd == OK and err_js == OK:
_set_status("✓ skin_manager.gd and tekton_admin.js updated successfully!", Color(0.4, 1.0, 0.4))
else:
_set_status("⚠ Some files could not be updated — check the Output log.", Color.YELLOW)
# ─────────────────────────────────────────────────────────────────────────────
# Code Generation — skin_manager.gd
# ─────────────────────────────────────────────────────────────────────────────
func _generate_skin_manager() -> int:
if not FileAccess.file_exists(SKIN_MANAGER_PATH):
push_error("[SkinCatalogEditor] File not found: " + SKIN_MANAGER_PATH)
return ERR_FILE_NOT_FOUND
var f := FileAccess.open(SKIN_MANAGER_PATH, FileAccess.READ)
var src: String = f.get_as_text()
f.close()
var b := src.find(BEGIN_SKIN)
var e := src.find(END_SKIN)
if b == -1 or e == -1:
push_error("[SkinCatalogEditor] Sentinel markers not found in skin_manager.gd")
return ERR_INVALID_DATA
var lines: PackedStringArray = []
lines.append(BEGIN_SKIN)
lines.append("const SKIN_CATALOG: Dictionary = {")
lines.append("")
# Group by category
var by_cat: Dictionary = {}
for entry: Dictionary in _data:
var cat: String = entry.get("category", "head")
if not by_cat.has(cat):
by_cat[cat] = []
by_cat[cat].append(entry)
for cat in CATEGORIES:
if not by_cat.has(cat):
continue
lines.append("\t# \u2500\u2500 [%s] \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" % cat.to_upper())
for entry: Dictionary in by_cat[cat]:
lines.append("\t\"%s\": {" % entry["item_id"])
lines.append("\t\t\"category\": \"%s\"," % entry["category"])
lines.append("\t\t\"character\": \"%s\"," % entry.get("character", ""))
lines.append("\t\t\"slots\": [")
for slot: Dictionary in entry.get("slots", []):
lines.append("\t\t\t{ \"mesh\": \"%s\", \"mode\": \"%s\", \"material\": \"%s\" }," % [
slot.get("mesh", ""), slot.get("mode", "override"), slot.get("material", "")
])
lines.append("\t\t]")
lines.append("\t},")
lines.append("")
lines.append("}")
lines.append(END_SKIN)
var block: String = "\n".join(lines)
var new_src: String = src.substr(0, b) + block + src.substr(e + END_SKIN.length())
var fw := FileAccess.open(SKIN_MANAGER_PATH, FileAccess.WRITE)
fw.store_string(new_src)
fw.close()
return OK
# ─────────────────────────────────────────────────────────────────────────────
# Code Generation — tekton_admin.js
# ─────────────────────────────────────────────────────────────────────────────
func _generate_admin_js() -> int:
if not FileAccess.file_exists(ADMIN_JS_PATH):
push_error("[SkinCatalogEditor] File not found: " + ADMIN_JS_PATH)
return ERR_FILE_NOT_FOUND
var f := FileAccess.open(ADMIN_JS_PATH, FileAccess.READ)
var src: String = f.get_as_text()
f.close()
var b := src.find(BEGIN_SHOP)
var e := src.find(END_SHOP)
if b == -1 or e == -1:
push_error("[SkinCatalogEditor] Sentinel markers not found in tekton_admin.js")
return ERR_INVALID_DATA
var lines: PackedStringArray = []
lines.append(BEGIN_SHOP)
lines.append("var SHOP_CATALOG_DEFS = [")
var prev_cat := ""
for entry: Dictionary in _data:
var cat: String = entry.get("category", "head")
if cat != prev_cat:
lines.append(" // \u2500\u2500 %s \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" % cat.to_upper())
prev_cat = cat
var char_val: String = entry.get("character", "")
var char_part: String = (", character: \"%s\"" % char_val) if not char_val.is_empty() else ""
lines.append(" { id: \"%s\", name: \"%s\", category: \"%s\", gold: %d, star: %d, rarity: \"%s\"%s }," % [
entry.get("item_id", ""),
entry.get("name", ""),
cat,
entry.get("gold", 0),
entry.get("star", 0),
entry.get("rarity", "Common"),
char_part,
])
lines.append("];")
lines.append(END_SHOP)
var block: String = "\n".join(lines)
var new_src: String = src.substr(0, b) + block + src.substr(e + END_SHOP.length())
var fw := FileAccess.open(ADMIN_JS_PATH, FileAccess.WRITE)
fw.store_string(new_src)
fw.close()
return OK
# ─────────────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────────────
func _set_status(msg: String, color: Color = Color.WHITE) -> void:
if not _status_label:
return
_status_label.add_theme_color_override("font_color", color)
_status_label.text = msg
+1
View File
@@ -0,0 +1 @@
uid://d3wlsx2lbdlge
+107 -19
View File
@@ -54,6 +54,7 @@ signal profile_updated
# Back button + popups
@onready var back_btn := %BackBtn as Button
@onready var topup_gold_btn := %TopupGoldBtn as Button
@onready var avatar_popup := %AvatarSelectionPopup as PopupPanel
@onready var avatar_grid := %GridContainer as GridContainer
@onready var acc_settings_dialog := %AccountSettingsDialog as AcceptDialog
@@ -89,14 +90,30 @@ const RARITY_COLORS: Dictionary = {
"Legendary": Color(1.00, 0.70, 0.10, 1)
}
## Client-side item catalog: item_id -> { name, rarity, star_value }
## Add new entries here whenever a new item is added to the shop.
const ITEM_CATALOG: Dictionary = {
"head_hat1": {"name": "Cap", "rarity": "Common", "star_value": 50},
"head_crown": {"name": "Crown", "rarity": "Epic", "star_value": 1500},
"costume_red": {"name": "Red Suit", "rarity": "Rare", "star_value": 200},
"costume_gold": {"name": "Gold Suit", "rarity": "Epic", "star_value": 1000},
"glove_leather": {"name": "Leather Gloves", "rarity": "Common", "star_value": 50},
"acc_glasses": {"name": "Sunglasses", "rarity": "Rare", "star_value": 300},
# ── Generic items ──────────────────────────────────────────────────────
"head_hat1": {"name": "Cap", "rarity": "Common", "star_value": 50},
"head_crown": {"name": "Crown", "rarity": "Epic", "star_value": 1500},
"costume_red": {"name": "Red Suit", "rarity": "Rare", "star_value": 200},
"costume_gold": {"name": "Gold Suit", "rarity": "Epic", "star_value": 1000},
"glove_leather": {"name": "Leather Gloves", "rarity": "Common", "star_value": 50},
"acc_glasses": {"name": "Sunglasses", "rarity": "Rare", "star_value": 300},
# ── Oldpop (Copper) — Hat ─────────────────────────────────────────────
"oldpop_hat1": {"name": "Oldpop Hat I", "rarity": "Common", "star_value": 100},
"oldpop_hat2": {"name": "Oldpop Hat II", "rarity": "Rare", "star_value": 250},
"oldpop_hat3": {"name": "Oldpop Hat III", "rarity": "Epic", "star_value": 800},
# ── Oldpop (Copper) — Costume ─────────────────────────────────────────
"oldpop_body": {"name": "Oldpop Body", "rarity": "Rare", "star_value": 200},
"oldpop_arms": {"name": "Oldpop Arms", "rarity": "Common", "star_value": 100},
"oldpop-grey-pant": {"name": "Grey Pants", "rarity": "Common", "star_value": 80},
"oldpop-clothing-original": {"name": "Original Pants", "rarity": "Common", "star_value": 80},
# kept for backward compat with older purchases
"oldpop_clothing_original": {"name": "Original Pants", "rarity": "Common", "star_value": 80},
# ── Oldpop (Copper) — Gloves ──────────────────────────────────────────
"oldpop_gloves": {"name": "Oldpop Gloves", "rarity": "Common", "star_value": 60},
}
const ITEMS_PER_PAGE: int = 9
# ─────────────────────────────────────────────────────────────
@@ -149,6 +166,7 @@ func _connect_signals() -> void:
char_left_btn.pressed.connect(func(): _cycle_loadout_char(-1))
char_right_btn.pressed.connect(func(): _cycle_loadout_char(1))
set_default_btn.pressed.connect(_on_set_default_pressed)
topup_gold_btn.pressed.connect(_on_topup_gold_pressed)
# Category tabs
head_tab_btn.pressed.connect(func(): _on_category_tab_pressed("head"))
@@ -237,10 +255,30 @@ func _highlight_active_tab() -> void:
func _rebuild_category_items() -> void:
_category_items.clear()
var prefix := _current_category + "_"
var prefix := _current_category + "_"
# Resolve the current character's node name (e.g. "Copper" → "Oldpop")
var current_char_display: String = CHARACTERS[_loadout_index]
var current_char_node: String = CHAR_NODE_MAP.get(current_char_display, current_char_display)
for item_id: String in UserProfileManager.inventory:
if item_id.begins_with(prefix):
_category_items.append(item_id)
# Look up the skin data from SkinManager first (handles all id formats)
var skin_data: Dictionary = SkinManager.SKIN_CATALOG.get(item_id, {})
if not skin_data.is_empty():
# Only show items that match the current category
if skin_data.get("category", "") != _current_category:
continue
# Only show items that belong to this character (or have no character restriction)
var item_char: String = skin_data.get("character", "")
if item_char.is_empty() or item_char == current_char_node:
if not _category_items.has(item_id):
_category_items.append(item_id)
else:
# Fallback: generic prefix-based match (e.g. "head_hat1" under "head" tab)
if item_id.begins_with(prefix):
if not _category_items.has(item_id):
_category_items.append(item_id)
# ─────────────────────────────────────────────────────────────
# Item grid
@@ -291,8 +329,12 @@ func _show_item_info(item_id: String) -> void:
item_price_label.text = str(sv) if sv > 0 else ""
var equipped: String = UserProfileManager.loadout.get(_current_category, "")
equip_btn.text = "✓ Equipped" if equipped == item_id else "Equip"
equip_btn.disabled = (equipped == item_id)
if equipped == item_id:
equip_btn.text = "Unequip"
equip_btn.disabled = false
else:
equip_btn.text = "Equip"
equip_btn.disabled = false
dismantle_btn.disabled = false
func _clear_item_info() -> void:
@@ -308,13 +350,28 @@ func _clear_item_info() -> void:
# ─────────────────────────────────────────────────────────────
func _on_equip_pressed() -> void:
if _selected_item_id.is_empty(): return
var ok: bool = await UserProfileManager.update_loadout(_current_category, _selected_item_id)
if ok:
_set_status("Equipped: " + _selected_item_id, Color(0.4, 1.0, 0.4))
_populate_item_grid()
_show_item_info(_selected_item_id)
var equipped: String = UserProfileManager.loadout.get(_current_category, "")
if equipped == _selected_item_id:
# ── UNEQUIP ──────────────────────────────────────────────
var ok: bool = await UserProfileManager.update_loadout(_current_category, "")
if ok:
_set_status("Unequipped: " + _selected_item_id, Color(1.0, 0.7, 0.3))
SkinManager.apply_loadout(character_root, UserProfileManager.loadout)
_populate_item_grid()
_show_item_info(_selected_item_id)
else:
_set_status("Failed to unequip.", Color.RED)
else:
_set_status("Failed to equip item.", Color.RED)
# ── EQUIP ────────────────────────────────────────────────
var ok: bool = await UserProfileManager.update_loadout(_current_category, _selected_item_id)
if ok:
_set_status("Equipped: " + _selected_item_id, Color(0.4, 1.0, 0.4))
SkinManager.apply_loadout(character_root, UserProfileManager.loadout)
_populate_item_grid()
_show_item_info(_selected_item_id)
else:
_set_status("Failed to equip item.", Color.RED)
func _on_dismantle_pressed() -> void:
if _selected_item_id.is_empty(): return
@@ -413,6 +470,8 @@ func _update_3d_preview(character_name: String) -> void:
anim_player.play("animation-pack/idle")
elif anim_player.get_animation_list().size() > 0:
anim_player.play(anim_player.get_animation_list()[0])
# Apply equipped skins on the newly-visible character
SkinManager.apply_loadout(character_root, UserProfileManager.loadout)
# ─────────────────────────────────────────────────────────────
# Drag-to-rotate
@@ -570,6 +629,17 @@ func _on_logout_pressed() -> void:
func _on_admin_panel_pressed() -> void:
AdminManager.toggle_admin_panel()
func _on_topup_gold_pressed() -> void:
_set_status("Topping up gold...", Color.WHITE)
topup_gold_btn.disabled = true
var ok: bool = await UserProfileManager.admin_topup_gold()
topup_gold_btn.disabled = false
if ok:
gold_label.text = str(UserProfileManager.wallet.get("gold", 0))
_set_status("Top-up successful!", Color(0.4, 1.0, 0.4))
else:
_set_status("Top-up failed.", Color.RED)
# ─────────────────────────────────────────────────────────────
# Show / Close
# ─────────────────────────────────────────────────────────────
@@ -582,16 +652,33 @@ func show_panel() -> void:
_set_status("Loading profile...", Color.YELLOW)
_load_profile_data()
_load_loadout()
_rebuild_category_items()
_populate_item_grid()
_check_admin_visibility()
show()
if AuthManager.is_guest:
_on_link_account_pressed()
_set_status("Link an email to save progress permanently!", Color.YELLOW)
# Reload inventory from server to guarantee fresh data, then refresh the grid
_set_status("Refreshing inventory...", Color.WHITE)
await UserProfileManager.load_inventory()
# Auto-select the first tab that actually has items (avoids confusing empty grid)
var found_tab := false
for cat in ["head", "costume", "glove", "accessory"]:
_current_category = cat
_rebuild_category_items()
if not _category_items.is_empty():
found_tab = true
break
if not found_tab:
_current_category = "head"
_rebuild_category_items()
_current_page = 0
_populate_item_grid()
_highlight_active_tab()
_set_status("", Color.WHITE)
func _check_admin_visibility() -> void:
admin_panel_btn.hide()
topup_gold_btn.hide()
if not NakamaManager.client or not NakamaManager.session: return
var account = await NakamaManager.client.get_account_async(NakamaManager.session)
if account.is_exception(): return
@@ -599,6 +686,7 @@ func _check_admin_visibility() -> void:
var meta = JSON.parse_string(raw)
if meta is Dictionary and meta.get("role", "") in ["owner", "admin"]:
admin_panel_btn.show()
topup_gold_btn.show()
# ─────────────────────────────────────────────────────────────
# Helpers
+67 -30
View File
@@ -27,7 +27,22 @@ signal closed
# --- State ---
var current_category: String = "head"
var current_char_idx: int = 0
# Node names inside the GLB scene (CharacterRoot children)
var available_chars: Array[String] = ["Bob", "Masbro", "Gatot", "Oldpop"]
# Display name shown in the label -> node name in GLB
const DISPLAY_TO_NODE: Dictionary = {
"Copper": "Oldpop",
"Dabro": "Masbro",
"Pip": "Bob",
"Gatot": "Gatot",
}
# Reverse: node name -> display name shown to player
const NODE_TO_DISPLAY: Dictionary = {
"Oldpop": "Copper",
"Masbro": "Dabro",
"Bob": "Pip",
"Gatot": "Gatot",
}
# Drag tracking
var _is_dragging: bool = false
@@ -81,12 +96,13 @@ func _ready() -> void:
# Local 3D preview
# -----------------------------------------------------------------------
func _setup_3d_preview() -> void:
# Attempt to match the user's currently saved loadout character
var def_char: String = UserProfileManager.profile.get("loadout_character", "Bob")
var idx = available_chars.find(def_char)
# loadout_character stores display names (e.g. "Copper") — convert to node name first
var def_raw: String = UserProfileManager.profile.get("loadout_character", "")
var def_node: String = DISPLAY_TO_NODE.get(def_raw, def_raw) # "Copper" -> "Oldpop"
var idx: int = available_chars.find(def_node)
if idx != -1:
current_char_idx = idx
_update_char_name_label()
_update_preview_char()
@@ -101,31 +117,30 @@ func _on_next_char() -> void:
_update_preview_char()
func _update_char_name_label() -> void:
char_name_label.text = available_chars[current_char_idx]
var node_name: String = available_chars[current_char_idx]
# Show the player-facing display name (e.g. "Copper" instead of "Oldpop")
char_name_label.text = NODE_TO_DISPLAY.get(node_name, node_name)
func _update_preview_char() -> void:
if not character_root: return
var target_node_name = available_chars[current_char_idx]
var active_char_node: Node3D = null
var target_node_name := available_chars[current_char_idx]
for child in character_root.get_children():
if child is Node3D:
child.visible = (child.name == target_node_name)
if child.name == target_node_name:
active_char_node = child
var active_char_node := character_root.get_node_or_null(target_node_name) as Node3D
if active_char_node and anim_player:
anim_player.root_node = active_char_node.get_path()
if anim_player.has_animation("animation-pack/idle"):
anim_player.play("animation-pack/idle")
elif anim_player.get_animation_list().size() > 0:
anim_player.play(anim_player.get_animation_list()[0])
if active_char_node:
var p = preload("res://scenes/player.gd").new()
p.apply_loadout(active_char_node)
p.free()
# Apply the player's current loadout materials
SkinManager.apply_loadout(character_root, UserProfileManager.loadout)
# -----------------------------------------------------------------------
# Drag-to-rotate
@@ -236,9 +251,12 @@ func _make_star_card(pack: Dictionary) -> Control:
func _make_cosmetic_card(item: Dictionary) -> Control:
var card: Control = template_cosmetic_card.duplicate()
card.visible = true
var item_id: String = item.get("id", "")
var already_owned: bool = UserProfileManager.inventory.has(item_id)
var name_lbl: Label = card.find_child("NameLabel", true, false) as Label
if name_lbl: name_lbl.text = item.get("name", item.get("id", "?"))
if name_lbl: name_lbl.text = item.get("name", item_id)
var rarity: String = item.get("rarity", "Common")
var rarity_lbl: Label = card.find_child("RarityLabel", true, false) as Label
@@ -264,10 +282,18 @@ func _make_cosmetic_card(item: Dictionary) -> Control:
if try_btn: try_btn.pressed.connect(_on_try_pressed.bind(item))
var buy_btn: Button = card.find_child("BuyBtn", true, false) as Button
if buy_btn: buy_btn.pressed.connect(_on_buy_cosmetic_pressed.bind(item))
if buy_btn:
if already_owned:
buy_btn.text = "✓ Owned"
buy_btn.disabled = true
# Dim the entire card to signal it's already purchased
card.modulate = Color(0.55, 0.55, 0.55, 0.85)
else:
buy_btn.pressed.connect(_on_buy_cosmetic_pressed.bind(item))
return card
# -----------------------------------------------------------------------
# Wallet refresh
# -----------------------------------------------------------------------
@@ -281,25 +307,27 @@ func _refresh_wallet() -> void:
# -----------------------------------------------------------------------
# Button callbacks
# -----------------------------------------------------------------------
# Tracks a revert callable from SkinManager.preview_skin
var _preview_revert: Callable = Callable()
func _on_try_pressed(item: Dictionary) -> void:
status_label.text = "Previewing: " + item.get("name", item.get("id", "?"))
# Auto-switch character if the catalog item targets a specific one.
# Auto-switch to the character this skin belongs to (if specified)
if item.has("character"):
var char_name: String = item.get("character")
var idx: int = available_chars.find(char_name)
if idx != -1 and current_char_idx != idx:
current_char_idx = idx
_update_char_name_label()
# Inject into loadout temporarily to preview it without saving
var prev: String = UserProfileManager.loadout.get(current_category, "")
UserProfileManager.loadout[current_category] = item.id
_update_preview_char()
# Revert immediately, so jumping to next character drops preview.
UserProfileManager.loadout[current_category] = prev
_update_preview_char()
# Revert any previous preview first
if _preview_revert.is_valid():
_preview_revert.call()
# Live material preview — SkinManager records a revert snapshot automatically
_preview_revert = SkinManager.preview_skin(character_root, item.get("id", ""))
func _on_buy_gold_pressed(pack: Dictionary) -> void:
status_label.text = "Processing purchase..."
@@ -335,7 +363,16 @@ func _on_buy_cosmetic_pressed(item: Dictionary) -> void:
status_label.text = ("Purchased: " + item.get("name", item.id)) if success else "Purchase failed."
if success:
_refresh_wallet()
# Refresh preview to show newly purchased skin's materials
if _preview_revert.is_valid():
_preview_revert.call()
_preview_revert = Callable()
SkinManager.apply_loadout(character_root, UserProfileManager.loadout)
func _on_close() -> void:
# Clean up any open preview when closing the shop
if _preview_revert.is_valid():
_preview_revert.call()
_preview_revert = Callable()
hide()
emit_signal("closed")