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
@@ -0,0 +1,7 @@
[gd_resource type="StandardMaterial3D" format=3 uid="uid://ghpalciewcxf"]
[ext_resource type="Texture2D" uid="uid://p1ihslqnrxjq" path="res://assets/characters/skins/clothing/oldpop_body_greypants.png" id="1_o4mmw"]
[resource]
transparency = 1
albedo_texture = ExtResource("1_o4mmw")
@@ -0,0 +1,7 @@
[gd_resource type="StandardMaterial3D" format=3 uid="uid://bwhs3jw8vhvlp"]
[ext_resource type="Texture2D" uid="uid://b2a16sgrudj5q" path="res://assets/characters/skins/clothing/oldpop_body_originalpants.png" id="1_vfhdr"]
[resource]
transparency = 1
albedo_texture = ExtResource("1_vfhdr")
@@ -0,0 +1,7 @@
[gd_resource type="StandardMaterial3D" format=3 uid="uid://g8olk8cxjbmt"]
[ext_resource type="Texture2D" uid="uid://b3wychpmdlheb" path="res://assets/characters/skins/clothing/oldpop_body_redpants.png" id="1_ko8ty"]
[resource]
transparency = 1
albedo_texture = ExtResource("1_ko8ty")
@@ -0,0 +1,7 @@
[gd_resource type="StandardMaterial3D" format=3 uid="uid://bvwyinggho1f4"]
[ext_resource type="Texture2D" uid="uid://biiy6hk1l75ek" path="res://assets/characters/skins/clothing/oldpop_body_yellowpants.png" id="1_0pbr2"]
[resource]
transparency = 1
albedo_texture = ExtResource("1_0pbr2")
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -0,0 +1,42 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://p1ihslqnrxjq"
path.s3tc="res://.godot/imported/oldpop_body_greypants.png-6ad64e5720f512b94685c7cf02d5741c.s3tc.ctex"
path.etc2="res://.godot/imported/oldpop_body_greypants.png-6ad64e5720f512b94685c7cf02d5741c.etc2.ctex"
metadata={
"imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true
}
[deps]
source_file="res://assets/characters/skins/clothing/oldpop_body_greypants.png"
dest_files=["res://.godot/imported/oldpop_body_greypants.png-6ad64e5720f512b94685c7cf02d5741c.s3tc.ctex", "res://.godot/imported/oldpop_body_greypants.png-6ad64e5720f512b94685c7cf02d5741c.etc2.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

@@ -0,0 +1,42 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b2a16sgrudj5q"
path.s3tc="res://.godot/imported/oldpop_body_originalpants.png-525ea0a1ebb85e7724ba72a992ec08c8.s3tc.ctex"
path.etc2="res://.godot/imported/oldpop_body_originalpants.png-525ea0a1ebb85e7724ba72a992ec08c8.etc2.ctex"
metadata={
"imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true
}
[deps]
source_file="res://assets/characters/skins/clothing/oldpop_body_originalpants.png"
dest_files=["res://.godot/imported/oldpop_body_originalpants.png-525ea0a1ebb85e7724ba72a992ec08c8.s3tc.ctex", "res://.godot/imported/oldpop_body_originalpants.png-525ea0a1ebb85e7724ba72a992ec08c8.etc2.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

@@ -0,0 +1,42 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b3wychpmdlheb"
path.s3tc="res://.godot/imported/oldpop_body_redpants.png-24c7db3db51f38090f8d8a94ee62515b.s3tc.ctex"
path.etc2="res://.godot/imported/oldpop_body_redpants.png-24c7db3db51f38090f8d8a94ee62515b.etc2.ctex"
metadata={
"imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true
}
[deps]
source_file="res://assets/characters/skins/clothing/oldpop_body_redpants.png"
dest_files=["res://.godot/imported/oldpop_body_redpants.png-24c7db3db51f38090f8d8a94ee62515b.s3tc.ctex", "res://.godot/imported/oldpop_body_redpants.png-24c7db3db51f38090f8d8a94ee62515b.etc2.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

@@ -0,0 +1,42 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://biiy6hk1l75ek"
path.s3tc="res://.godot/imported/oldpop_body_yellowpants.png-c05c4454bb6f53b6d47600adb163d718.s3tc.ctex"
path.etc2="res://.godot/imported/oldpop_body_yellowpants.png-c05c4454bb6f53b6d47600adb163d718.etc2.ctex"
metadata={
"imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true
}
[deps]
source_file="res://assets/characters/skins/clothing/oldpop_body_yellowpants.png"
dest_files=["res://.godot/imported/oldpop_body_yellowpants.png-c05c4454bb6f53b6d47600adb163d718.s3tc.ctex", "res://.godot/imported/oldpop_body_yellowpants.png-c05c4454bb6f53b6d47600adb163d718.etc2.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0
+114
View File
@@ -0,0 +1,114 @@
{
"skins": [
{
"category": "head",
"character": "Oldpop",
"gold": 0,
"item_id": "example-hat",
"name": "Example Hat",
"rarity": "Common",
"slots": [
{
"material": "",
"mesh": "oldpop-hat1",
"mode": "override"
}
],
"star": 0
},
{
"category": "costume",
"character": "Oldpop",
"gold": 200,
"item_id": "oldpop-grey-pant",
"name": "Copper Grey Pant",
"rarity": "Common",
"slots": [
{
"material": "res://assets/characters/skins/clothing/bmo_greypants.tres",
"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"
}
],
"star": 0
},
{
"category": "costume",
"character": "Oldpop",
"gold": 0,
"item_id": "oldpop-clothing-original",
"name": "Copper Original Pants",
"rarity": "Common",
"slots": [
{
"material": "res://assets/characters/skins/clothing/bmo_originalpants.tres",
"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"
}
],
"star": 0
},
{
"category": "glove",
"character": "Oldpop",
"gold": 150,
"item_id": "example-gloves",
"name": "Example Gloves",
"rarity": "Common",
"slots": [
{
"material": "",
"mesh": "oldpop-hands",
"mode": "override"
}
],
"star": 0
},
{
"category": "costume",
"character": "Oldpop",
"gold": 200,
"item_id": "oldpop-red-pant",
"name": "Copper Red Pant",
"rarity": "Rare",
"slots": [
{
"material": "res://assets/characters/skins/clothing/bmo_redpants.tres",
"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"
}
],
"star": 0
}
]
}
+1
View File
@@ -38,6 +38,7 @@ SfxManager="*res://scripts/managers/sfx_manager.gd"
NameGenerator="*res://scripts/generators/name_generator.gd"
MusicManager="*res://scripts/managers/music_manager.gd"
AdminManager="*res://scripts/managers/admin_manager.gd"
SkinManager="*res://scripts/managers/skin_manager.gd"
[display]
+3
View File
@@ -297,6 +297,9 @@ func _setup_3d_preview() -> void:
elif anim_player.get_animation_list().size() > 0:
anim_player.play(anim_player.get_animation_list()[0])
# Apply equipped cosmetics so the lobby preview shows the current loadout
SkinManager.apply_loadout(character_root, UserProfileManager.loadout)
func _load_character_textures() -> void:
"""Load character preview textures."""
var characters = {
+25 -41
View File
@@ -180,6 +180,7 @@ var _is_highlighting: bool = false
@onready var vfx_stunned: AnimatedSprite3D = $receiver_skill_stunned
var _selected_character: String = "Masbro"
var _player_loadout: Dictionary = {} ## Synced from the owning client via sync_loadout RPC
var selected_character: String:
get: return _selected_character
set(value):
@@ -501,8 +502,9 @@ func set_character(character_name: String) -> void:
if active_character:
_apply_outline_recursive(active_character)
# Apply cosmetic loadout to active model (local auth only)
if active_character and is_multiplayer_authority():
# Apply cosmetic loadout to active model.
# Uses _player_loadout (synced by owning client) so ALL peers see the correct skin.
if active_character:
apply_loadout(active_character)
func _apply_outline_recursive(node: Node):
@@ -561,45 +563,17 @@ const COSMETIC_MAPPING = {
}
func apply_loadout(character_node: Node3D) -> void:
"""Apply equipped cosmetics from UserProfileManager.loadout onto the active character model.
It uses COSMETIC_MAPPING to dynamically swap visibility and materials of internal meshes."""
if not Engine.get_main_loop().root.has_node("UserProfileManager"):
return
"""Apply equipped cosmetics via SkinManager — the single source of truth.
Uses _player_loadout (synced from the owning client via sync_loadout RPC)
so all peers render the same skin regardless of their own loadout."""
# Fall back to UserProfileManager for the local player before network sync arrives
var loadout: Dictionary = _player_loadout if not _player_loadout.is_empty() \
else UserProfileManager.loadout
# Pass 'self' (the player CharacterBody3D) as character_root because SkinManager
# looks for a CHILD node named e.g. "Oldpop" inside character_root.
# self.$Oldpop, self.$Bob etc. are direct children, matching SKIN_CATALOG "character" keys.
SkinManager.apply_loadout(self, loadout)
var loadout: Dictionary = UserProfileManager.loadout
var all_meshes = _get_all_mesh_instances(character_node)
for category in ["head", "costume", "glove", "accessory"]:
var equipped: String = loadout.get(category, "")
# Fallback basic logic for direct children
for child in character_node.get_children():
if child.name.begins_with(category):
child.visible = (child.name == equipped)
# Advanced mapping logic for deep node part swapping and materials
if equipped and COSMETIC_MAPPING.has(equipped):
var mapping = COSMETIC_MAPPING[equipped]
# Visibility overrides
var to_hide = mapping.get("hide", [])
var to_show = mapping.get("show", [])
for mesh in all_meshes:
if mesh.name in to_hide:
mesh.visible = false
if mesh.name in to_show:
mesh.visible = true
# Material overrides
var mats = mapping.get("materials", {})
for mesh_name in mats:
var mat_path = mats[mesh_name]
for mesh in all_meshes:
if mesh.name == mesh_name:
if ResourceLoader.exists(mat_path):
var new_mat = load(mat_path)
mesh.material_override = new_mat
func _get_all_mesh_instances(node: Node) -> Array:
var result = []
@@ -614,6 +588,13 @@ func sync_character(character_name: String) -> void:
"""Sync character selection across all clients."""
set_character(character_name)
@rpc("any_peer", "call_local", "reliable")
func sync_loadout(loadout_data: Dictionary) -> void:
"""Sync the player's skin loadout to all peers so everyone sees the correct skin."""
_player_loadout = loadout_data
# Pass 'self' as character_root — SkinManager will find $Oldpop/$Bob/etc. by name
SkinManager.apply_loadout(self, _player_loadout)
func _setup_character() -> void:
"""Initialize character based on LobbyManager selection or defaults."""
# Bots self-assign characters based on ID in _ready()
@@ -634,9 +615,12 @@ func _setup_character() -> void:
set_character(character_name)
# If this is our local player, also sync to other clients for late joiners
# If this is our local player, sync character AND loadout to all peers
if is_multiplayer_authority() and can_rpc():
# Populate _player_loadout from UserProfileManager and broadcast
_player_loadout = UserProfileManager.loadout.duplicate()
rpc("sync_character", character_name)
rpc("sync_loadout", _player_loadout)
# =============================================================================
# Animation Functions
+10
View File
@@ -0,0 +1,10 @@
[gd_scene format=3 uid="uid://cxctemsxkw6p8"]
[ext_resource type="Script" uid="uid://d3wlsx2lbdlge" path="res://scripts/tools/skin_catalog_editor.gd" id="1_editor"]
[node name="SkinCatalogEditor" type="Control" unique_id=1992555523]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("1_editor")
+17
View File
@@ -249,6 +249,14 @@ theme_override_colors/font_color = Color(1, 0.4, 0.4, 1)
theme_override_fonts/font = ExtResource("3_font")
text = "Server Admin"
[node name="TopupGoldBtn" type="Button" parent="MainMargin/MainHBox/LeftCol" unique_id=1155262038]
unique_name_in_owner = true
visible = false
layout_mode = 2
theme_override_colors/font_color = Color(0.4, 1, 0.4, 1)
theme_override_fonts/font = ExtResource("3_font")
text = "⭐ Top-up Gold"
[node name="LogoutBtn" type="Button" parent="MainMargin/MainHBox/LeftCol" unique_id=612591334]
unique_name_in_owner = true
layout_mode = 2
@@ -559,54 +567,63 @@ custom_minimum_size = Vector2(88, 88)
layout_mode = 2
size_flags_horizontal = 2
size_flags_vertical = 2
autowrap_mode = 1
[node name="Slot1" type="Button" parent="MainMargin/MainHBox/RightCol/ItemGridCard/Margin/VBox/ItemGrid" unique_id=1570438748]
custom_minimum_size = Vector2(88, 88)
layout_mode = 2
size_flags_horizontal = 2
size_flags_vertical = 2
autowrap_mode = 1
[node name="Slot2" type="Button" parent="MainMargin/MainHBox/RightCol/ItemGridCard/Margin/VBox/ItemGrid" unique_id=1730218687]
custom_minimum_size = Vector2(88, 88)
layout_mode = 2
size_flags_horizontal = 2
size_flags_vertical = 2
autowrap_mode = 1
[node name="Slot3" type="Button" parent="MainMargin/MainHBox/RightCol/ItemGridCard/Margin/VBox/ItemGrid" unique_id=533222646]
custom_minimum_size = Vector2(88, 88)
layout_mode = 2
size_flags_horizontal = 2
size_flags_vertical = 2
autowrap_mode = 1
[node name="Slot4" type="Button" parent="MainMargin/MainHBox/RightCol/ItemGridCard/Margin/VBox/ItemGrid" unique_id=1359133451]
custom_minimum_size = Vector2(88, 88)
layout_mode = 2
size_flags_horizontal = 2
size_flags_vertical = 2
autowrap_mode = 1
[node name="Slot5" type="Button" parent="MainMargin/MainHBox/RightCol/ItemGridCard/Margin/VBox/ItemGrid" unique_id=212812652]
custom_minimum_size = Vector2(88, 88)
layout_mode = 2
size_flags_horizontal = 2
size_flags_vertical = 2
autowrap_mode = 1
[node name="Slot6" type="Button" parent="MainMargin/MainHBox/RightCol/ItemGridCard/Margin/VBox/ItemGrid" unique_id=464526955]
custom_minimum_size = Vector2(88, 88)
layout_mode = 2
size_flags_horizontal = 2
size_flags_vertical = 2
autowrap_mode = 1
[node name="Slot7" type="Button" parent="MainMargin/MainHBox/RightCol/ItemGridCard/Margin/VBox/ItemGrid" unique_id=87751984]
custom_minimum_size = Vector2(88, 88)
layout_mode = 2
size_flags_horizontal = 2
size_flags_vertical = 2
autowrap_mode = 1
[node name="Slot8" type="Button" parent="MainMargin/MainHBox/RightCol/ItemGridCard/Margin/VBox/ItemGrid" unique_id=1866702419]
custom_minimum_size = Vector2(88, 88)
layout_mode = 2
size_flags_horizontal = 2
size_flags_vertical = 2
autowrap_mode = 1
[node name="Pagination" type="HBoxContainer" parent="MainMargin/MainHBox/RightCol/ItemGridCard/Margin/VBox" unique_id=1821323993]
layout_mode = 2
+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
+26
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,6 +139,7 @@ func load_inventory() -> void:
if not result.is_exception() and result.objects:
for obj in result.objects:
if not inventory.has(obj.key):
inventory.append(obj.key)
func load_stats() -> Dictionary:
@@ -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
+93 -5
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 = {
# ── 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"))
@@ -238,9 +256,29 @@ func _highlight_active_tab() -> void:
func _rebuild_category_items() -> void:
_category_items.clear()
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):
# 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,9 +350,24 @@ func _clear_item_info() -> void:
# ─────────────────────────────────────────────────────────────
func _on_equip_pressed() -> void:
if _selected_item_id.is_empty(): return
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:
# ── 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:
@@ -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
+59 -22
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,9 +96,10 @@ 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
@@ -101,19 +117,20 @@ 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()
@@ -122,10 +139,8 @@ func _update_preview_char() -> void:
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
@@ -237,8 +252,11 @@ 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
# 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")
+61 -19
View File
@@ -18,6 +18,7 @@ function InitModule(ctx, logger, nk, initializer) {
initializer.registerRpc("admin_set_user_role", rpcAdminSetUserRole);
initializer.registerRpc("admin_list_users", rpcAdminListUsers);
initializer.registerRpc("admin_delete_users", rpcAdminDeleteUsers);
initializer.registerRpc("admin_topup_gold", rpcAdminTopupGold);
// User management RPCs
initializer.registerRpc("get_user_profile", rpcGetUserProfile);
@@ -52,6 +53,54 @@ function InitModule(ctx, logger, nk, initializer) {
var ADMIN_ROLES = ["admin", "moderator", "owner"];
// =============================================================================
// Shop Catalog Definitions
// =============================================================================
// To add a new item: append ONE entry to SHOP_CATALOG_DEFS.
// Fields:
// id (String) — must match item_id in game inventory + SkinManager
// name (String) — display name shown in shop
// category (String) — "head" | "costume" | "glove" | "accessory"
// gold (Number) — gold price (0 = not sold for gold)
// star (Number) — star price (0 = not sold for star)
// rarity (String) — "Common" | "Rare" | "Epic" | "Legendary"
// character (String) — (optional) which character the skin targets, e.g. "Oldpop"
// [BEGIN_SHOP_CATALOG_DEFS]
var SHOP_CATALOG_DEFS = [
// ── HEAD ────────────────────────────────────────────────────────────
{ id: "example-hat", name: "Example Hat", category: "head", gold: 0, star: 0, rarity: "Common", character: "Oldpop" },
// ── COSTUME ────────────────────────────────────────────────────────────
{ id: "oldpop-grey-pant", name: "Copper Grey Pant", category: "costume", gold: 200, star: 0, rarity: "Common", character: "Oldpop" },
{ id: "oldpop-clothing-original", name: "Copper Original Pants", category: "costume", gold: 0, star: 0, rarity: "Common", character: "Oldpop" },
// ── GLOVE ────────────────────────────────────────────────────────────
{ id: "example-gloves", name: "Example Gloves", category: "glove", gold: 150, star: 0, rarity: "Common", character: "Oldpop" },
// ── COSTUME ────────────────────────────────────────────────────────────
{ id: "oldpop-red-pant", name: "Copper Red Pant", category: "costume", gold: 200, star: 0, rarity: "Rare", character: "Oldpop" },
];
// [END_SHOP_CATALOG_DEFS]
/** Groups SHOP_CATALOG_DEFS by category for the shop RPC response. */
function buildShopCatalog() {
var catalog = {};
for (var i = 0; i < SHOP_CATALOG_DEFS.length; i++) {
var def = SHOP_CATALOG_DEFS[i];
var cat = def.category;
if (!catalog[cat]) catalog[cat] = [];
var entry = {
id: def.id,
name: def.name,
gold: def.gold || 0,
star: def.star || 0,
rarity: def.rarity || "Common"
};
if (def.character) entry.character = def.character;
catalog[cat].push(entry);
}
return catalog;
}
function isAdmin(ctx, nk) {
if (!ctx.userId) return false;
@@ -388,26 +437,19 @@ function rpcAdminSetUserRole(ctx, logger, nk, payload) {
function rpcGetShopCatalog(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("Not authenticated");
return JSON.stringify({ catalog: buildShopCatalog() });
}
// We could read this from storage, but for now we hardcode it to keep it simple and authoritative
var catalog = {
"head": [
{"id": "head_hat1", "name": "Cap", "gold": 100, "star": 0},
{"id": "head_crown", "name": "Crown", "gold": 0, "star": 50}
],
"costume": [
{"id": "costume_red", "name": "Red Suit", "gold": 200, "star": 0},
{"id": "costume_gold", "name": "Gold Suit", "gold": 0, "star": 100}
],
"glove": [
{"id": "glove_leather", "name": "Leather Gloves", "gold": 50, "star": 0}
],
"accessory": [
{"id": "acc_glasses", "name": "Sunglasses", "gold": 80, "star": 0}
]
};
return JSON.stringify({ catalog: catalog });
function rpcAdminTopupGold(ctx, logger, nk, payload) {
requireAdmin(ctx, nk);
try {
nk.walletUpdate(ctx.userId, { "gold": 999999 }, {}, true);
logger.info("Admin gold top-up applied for user " + ctx.userId);
return JSON.stringify({ success: true, gold_added: 999999 });
} catch (e) {
logger.error("Top-up failed: " + e);
throw new Error("Top-up failed: " + e);
}
}
function rpcBuyCurrency(ctx, logger, nk, payload) {