@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/economy.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/economy.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 economy.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 — economy.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 economy.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)