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
+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