Files
tekton/scripts/tools/skin_catalog_editor.gd
T

594 lines
23 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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 _duplicate_btn: Button
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)
_duplicate_btn = Button.new()
_duplicate_btn.text = "📋 Duplicate"
_duplicate_btn.disabled = true
_duplicate_btn.pressed.connect(_on_duplicate_pressed)
top.add_child(_duplicate_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 := SkinListItem.new()
btn.index = i
btn.editor = self
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
_duplicate_btn.disabled = true
_delete_btn.disabled = true
return
_form_panel.visible = true
_no_sel_label.visible = false
_duplicate_btn.disabled = 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_duplicate_pressed() -> void:
if _selected_idx < 0:
return
_commit_form()
var original = _data[_selected_idx]
var duplicate = original.duplicate(true)
duplicate["item_id"] += "_copy"
_data.insert(_selected_idx + 1, duplicate)
_selected_idx += 1
_refresh_list()
_populate_form()
_set_status("Skin duplicated.", 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 move_item(from_idx: int, to_idx: int) -> void:
if from_idx == to_idx:
return
_commit_form()
var item = _data[from_idx]
_data.remove_at(from_idx)
_data.insert(to_idx, item)
_selected_idx = to_idx
_refresh_list()
_populate_form()
_set_status("Reordered: %s" % item.get("item_id", "?"), Color.WHITE)
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
# ─── Inner Classes ───────────────────────────────────────────────────────────
class SkinListItem extends Button:
var index: int
var editor: Control
func _get_drag_data(_at_position: Vector2) -> Variant:
var preview := Button.new()
preview.text = text
preview.modulate.a = 0.5
set_drag_preview(preview)
return index
func _can_drop_data(_at_position: Vector2, data: Variant) -> bool:
return typeof(data) == TYPE_INT
func _drop_data(_at_position: Vector2, data: Variant) -> void:
editor.move_item(data, index)