diff --git a/assets/characters/skins/clothing/bmo_greypants.tres b/assets/characters/skins/clothing/bmo_greypants.tres new file mode 100644 index 0000000..2428186 --- /dev/null +++ b/assets/characters/skins/clothing/bmo_greypants.tres @@ -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") diff --git a/assets/characters/skins/clothing/bmo_originalpants.tres b/assets/characters/skins/clothing/bmo_originalpants.tres new file mode 100644 index 0000000..7d9372c --- /dev/null +++ b/assets/characters/skins/clothing/bmo_originalpants.tres @@ -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") diff --git a/assets/characters/skins/clothing/bmo_redpants.tres b/assets/characters/skins/clothing/bmo_redpants.tres new file mode 100644 index 0000000..7abd231 --- /dev/null +++ b/assets/characters/skins/clothing/bmo_redpants.tres @@ -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") diff --git a/assets/characters/skins/clothing/bmo_yellowpants.tres b/assets/characters/skins/clothing/bmo_yellowpants.tres new file mode 100644 index 0000000..a1e0c7c --- /dev/null +++ b/assets/characters/skins/clothing/bmo_yellowpants.tres @@ -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") diff --git a/assets/characters/skins/clothing/oldpop_body_greypants.png b/assets/characters/skins/clothing/oldpop_body_greypants.png new file mode 100644 index 0000000..0c773bb Binary files /dev/null and b/assets/characters/skins/clothing/oldpop_body_greypants.png differ diff --git a/assets/characters/skins/clothing/oldpop_body_greypants.png.import b/assets/characters/skins/clothing/oldpop_body_greypants.png.import new file mode 100644 index 0000000..044910f --- /dev/null +++ b/assets/characters/skins/clothing/oldpop_body_greypants.png.import @@ -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 diff --git a/assets/characters/skins/clothing/oldpop_body_originalpants.png b/assets/characters/skins/clothing/oldpop_body_originalpants.png new file mode 100644 index 0000000..f88fd1a Binary files /dev/null and b/assets/characters/skins/clothing/oldpop_body_originalpants.png differ diff --git a/assets/characters/skins/clothing/oldpop_body_originalpants.png.import b/assets/characters/skins/clothing/oldpop_body_originalpants.png.import new file mode 100644 index 0000000..06e0eb3 --- /dev/null +++ b/assets/characters/skins/clothing/oldpop_body_originalpants.png.import @@ -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 diff --git a/assets/characters/skins/clothing/oldpop_body_redpants.png b/assets/characters/skins/clothing/oldpop_body_redpants.png new file mode 100644 index 0000000..ab9f08d Binary files /dev/null and b/assets/characters/skins/clothing/oldpop_body_redpants.png differ diff --git a/assets/characters/skins/clothing/oldpop_body_redpants.png.import b/assets/characters/skins/clothing/oldpop_body_redpants.png.import new file mode 100644 index 0000000..0a4d4b1 --- /dev/null +++ b/assets/characters/skins/clothing/oldpop_body_redpants.png.import @@ -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 diff --git a/assets/characters/skins/clothing/oldpop_body_yellowpants.png b/assets/characters/skins/clothing/oldpop_body_yellowpants.png new file mode 100644 index 0000000..b4d0a4e Binary files /dev/null and b/assets/characters/skins/clothing/oldpop_body_yellowpants.png differ diff --git a/assets/characters/skins/clothing/oldpop_body_yellowpants.png.import b/assets/characters/skins/clothing/oldpop_body_yellowpants.png.import new file mode 100644 index 0000000..47bac91 --- /dev/null +++ b/assets/characters/skins/clothing/oldpop_body_yellowpants.png.import @@ -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 diff --git a/assets/data/skin_catalog_data.json b/assets/data/skin_catalog_data.json new file mode 100644 index 0000000..2ddaa02 --- /dev/null +++ b/assets/data/skin_catalog_data.json @@ -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 + } + ] +} \ No newline at end of file diff --git a/project.godot b/project.godot index 513e682..78d65be 100644 --- a/project.godot +++ b/project.godot @@ -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] diff --git a/scenes/lobby.gd b/scenes/lobby.gd index 207c497..9a929a4 100644 --- a/scenes/lobby.gd +++ b/scenes/lobby.gd @@ -296,6 +296,9 @@ func _setup_3d_preview() -> 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 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.""" diff --git a/scenes/player.gd b/scenes/player.gd index 93fa4b3..6d4b51d 100644 --- a/scenes/player.gd +++ b/scenes/player.gd @@ -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 - - 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 + """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) + 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 diff --git a/scenes/tools/skin_catalog_editor.tscn b/scenes/tools/skin_catalog_editor.tscn new file mode 100644 index 0000000..a714aaa --- /dev/null +++ b/scenes/tools/skin_catalog_editor.tscn @@ -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") diff --git a/scenes/ui/profile_panel.tscn b/scenes/ui/profile_panel.tscn index 539b345..d03b85b 100644 --- a/scenes/ui/profile_panel.tscn +++ b/scenes/ui/profile_panel.tscn @@ -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 diff --git a/scripts/bot_controller.gd b/scripts/bot_controller.gd index adec48c..0440829 100644 --- a/scripts/bot_controller.gd +++ b/scripts/bot_controller.gd @@ -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 diff --git a/scripts/managers/player_action_manager.gd b/scripts/managers/player_action_manager.gd index 2b45d23..b0d3f60 100644 --- a/scripts/managers/player_action_manager.gd +++ b/scripts/managers/player_action_manager.gd @@ -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(): diff --git a/scripts/managers/skin_manager.gd b/scripts/managers/skin_manager.gd new file mode 100644 index 0000000..d39de7b --- /dev/null +++ b/scripts/managers/skin_manager.gd @@ -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 diff --git a/scripts/managers/skin_manager.gd.uid b/scripts/managers/skin_manager.gd.uid new file mode 100644 index 0000000..3cbece4 --- /dev/null +++ b/scripts/managers/skin_manager.gd.uid @@ -0,0 +1 @@ +uid://cnp6a3r8bt0ol diff --git a/scripts/managers/user_profile_manager.gd b/scripts/managers/user_profile_manager.gd index 347bc70..ebb7d85 100644 --- a/scripts/managers/user_profile_manager.gd +++ b/scripts/managers/user_profile_manager.gd @@ -1,4 +1,14 @@ extends Node + +# --------------- +# Old vs New Name +# --------------- +# Masbro is Dabro +# Bob is Pip +# Gatot is Gatot +# Oldpop is Copper +# --------------- + ## UserProfileManager - Manages user profile data with Nakama storage signal profile_loaded(profile: Dictionary) @@ -129,7 +139,8 @@ func load_inventory() -> void: if not result.is_exception() and result.objects: for obj in result.objects: - inventory.append(obj.key) + if not inventory.has(obj.key): + inventory.append(obj.key) func load_stats() -> Dictionary: # Reset stats first to ensure fresh data for new logins @@ -334,6 +345,21 @@ func fetch_shop_catalog() -> void: shop_catalog = payload.catalog emit_signal("profile_updated") +## Admin-only: grants a large amount of gold via a server-authoritative RPC. +## The Nakama function requireAdmin() on the server prevents non-admin abuse. +func admin_topup_gold() -> bool: + if not NakamaManager.session: return false + var result = await NakamaManager.client.rpc_async( + NakamaManager.session, + "admin_topup_gold", + "{}" + ) + if result.is_exception(): + push_error("[UserProfileManager] Topup failed: ", result.get_exception().message) + return false + await _reload_wallet() + return true + func buy_currency(package_id: String) -> bool: if not NakamaManager.session: return false diff --git a/scripts/tools/skin_catalog_editor.gd b/scripts/tools/skin_catalog_editor.gd new file mode 100644 index 0000000..dd985ea --- /dev/null +++ b/scripts/tools/skin_catalog_editor.gd @@ -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 diff --git a/scripts/tools/skin_catalog_editor.gd.uid b/scripts/tools/skin_catalog_editor.gd.uid new file mode 100644 index 0000000..7ef50de --- /dev/null +++ b/scripts/tools/skin_catalog_editor.gd.uid @@ -0,0 +1 @@ +uid://d3wlsx2lbdlge diff --git a/scripts/ui/profile_panel.gd b/scripts/ui/profile_panel.gd index 8567fb6..7b820e7 100644 --- a/scripts/ui/profile_panel.gd +++ b/scripts/ui/profile_panel.gd @@ -54,6 +54,7 @@ signal profile_updated # Back button + popups @onready var back_btn := %BackBtn as Button +@onready var topup_gold_btn := %TopupGoldBtn as Button @onready var avatar_popup := %AvatarSelectionPopup as PopupPanel @onready var avatar_grid := %GridContainer as GridContainer @onready var acc_settings_dialog := %AccountSettingsDialog as AcceptDialog @@ -89,14 +90,30 @@ const RARITY_COLORS: Dictionary = { "Legendary": Color(1.00, 0.70, 0.10, 1) } ## Client-side item catalog: item_id -> { name, rarity, star_value } +## Add new entries here whenever a new item is added to the shop. const ITEM_CATALOG: Dictionary = { - "head_hat1": {"name": "Cap", "rarity": "Common", "star_value": 50}, - "head_crown": {"name": "Crown", "rarity": "Epic", "star_value": 1500}, - "costume_red": {"name": "Red Suit", "rarity": "Rare", "star_value": 200}, - "costume_gold": {"name": "Gold Suit", "rarity": "Epic", "star_value": 1000}, - "glove_leather": {"name": "Leather Gloves", "rarity": "Common", "star_value": 50}, - "acc_glasses": {"name": "Sunglasses", "rarity": "Rare", "star_value": 300}, + # ── Generic items ────────────────────────────────────────────────────── + "head_hat1": {"name": "Cap", "rarity": "Common", "star_value": 50}, + "head_crown": {"name": "Crown", "rarity": "Epic", "star_value": 1500}, + "costume_red": {"name": "Red Suit", "rarity": "Rare", "star_value": 200}, + "costume_gold": {"name": "Gold Suit", "rarity": "Epic", "star_value": 1000}, + "glove_leather": {"name": "Leather Gloves", "rarity": "Common", "star_value": 50}, + "acc_glasses": {"name": "Sunglasses", "rarity": "Rare", "star_value": 300}, + # ── Oldpop (Copper) — Hat ───────────────────────────────────────────── + "oldpop_hat1": {"name": "Oldpop Hat I", "rarity": "Common", "star_value": 100}, + "oldpop_hat2": {"name": "Oldpop Hat II", "rarity": "Rare", "star_value": 250}, + "oldpop_hat3": {"name": "Oldpop Hat III", "rarity": "Epic", "star_value": 800}, + # ── Oldpop (Copper) — Costume ───────────────────────────────────────── + "oldpop_body": {"name": "Oldpop Body", "rarity": "Rare", "star_value": 200}, + "oldpop_arms": {"name": "Oldpop Arms", "rarity": "Common", "star_value": 100}, + "oldpop-grey-pant": {"name": "Grey Pants", "rarity": "Common", "star_value": 80}, + "oldpop-clothing-original": {"name": "Original Pants", "rarity": "Common", "star_value": 80}, + # kept for backward compat with older purchases + "oldpop_clothing_original": {"name": "Original Pants", "rarity": "Common", "star_value": 80}, + # ── Oldpop (Copper) — Gloves ────────────────────────────────────────── + "oldpop_gloves": {"name": "Oldpop Gloves", "rarity": "Common", "star_value": 60}, } + const ITEMS_PER_PAGE: int = 9 # ───────────────────────────────────────────────────────────── @@ -149,6 +166,7 @@ func _connect_signals() -> void: char_left_btn.pressed.connect(func(): _cycle_loadout_char(-1)) char_right_btn.pressed.connect(func(): _cycle_loadout_char(1)) set_default_btn.pressed.connect(_on_set_default_pressed) + topup_gold_btn.pressed.connect(_on_topup_gold_pressed) # Category tabs head_tab_btn.pressed.connect(func(): _on_category_tab_pressed("head")) @@ -237,10 +255,30 @@ func _highlight_active_tab() -> void: func _rebuild_category_items() -> void: _category_items.clear() - var prefix := _current_category + "_" + var prefix := _current_category + "_" + # Resolve the current character's node name (e.g. "Copper" → "Oldpop") + var current_char_display: String = CHARACTERS[_loadout_index] + var current_char_node: String = CHAR_NODE_MAP.get(current_char_display, current_char_display) + for item_id: String in UserProfileManager.inventory: - if item_id.begins_with(prefix): - _category_items.append(item_id) + # Look up the skin data from SkinManager first (handles all id formats) + var skin_data: Dictionary = SkinManager.SKIN_CATALOG.get(item_id, {}) + + if not skin_data.is_empty(): + # Only show items that match the current category + if skin_data.get("category", "") != _current_category: + continue + # Only show items that belong to this character (or have no character restriction) + var item_char: String = skin_data.get("character", "") + if item_char.is_empty() or item_char == current_char_node: + if not _category_items.has(item_id): + _category_items.append(item_id) + else: + # Fallback: generic prefix-based match (e.g. "head_hat1" under "head" tab) + if item_id.begins_with(prefix): + if not _category_items.has(item_id): + _category_items.append(item_id) + # ───────────────────────────────────────────────────────────── # Item grid @@ -291,8 +329,12 @@ func _show_item_info(item_id: String) -> void: item_price_label.text = str(sv) if sv > 0 else "" var equipped: String = UserProfileManager.loadout.get(_current_category, "") - equip_btn.text = "✓ Equipped" if equipped == item_id else "Equip" - equip_btn.disabled = (equipped == item_id) + if equipped == item_id: + equip_btn.text = "Unequip" + equip_btn.disabled = false + else: + equip_btn.text = "Equip" + equip_btn.disabled = false dismantle_btn.disabled = false func _clear_item_info() -> void: @@ -308,13 +350,28 @@ func _clear_item_info() -> void: # ───────────────────────────────────────────────────────────── func _on_equip_pressed() -> void: if _selected_item_id.is_empty(): return - var ok: bool = await UserProfileManager.update_loadout(_current_category, _selected_item_id) - if ok: - _set_status("Equipped: " + _selected_item_id, Color(0.4, 1.0, 0.4)) - _populate_item_grid() - _show_item_info(_selected_item_id) + var equipped: String = UserProfileManager.loadout.get(_current_category, "") + + if equipped == _selected_item_id: + # ── UNEQUIP ────────────────────────────────────────────── + var ok: bool = await UserProfileManager.update_loadout(_current_category, "") + if ok: + _set_status("Unequipped: " + _selected_item_id, Color(1.0, 0.7, 0.3)) + SkinManager.apply_loadout(character_root, UserProfileManager.loadout) + _populate_item_grid() + _show_item_info(_selected_item_id) + else: + _set_status("Failed to unequip.", Color.RED) else: - _set_status("Failed to equip item.", Color.RED) + # ── EQUIP ──────────────────────────────────────────────── + var ok: bool = await UserProfileManager.update_loadout(_current_category, _selected_item_id) + if ok: + _set_status("Equipped: " + _selected_item_id, Color(0.4, 1.0, 0.4)) + SkinManager.apply_loadout(character_root, UserProfileManager.loadout) + _populate_item_grid() + _show_item_info(_selected_item_id) + else: + _set_status("Failed to equip item.", Color.RED) func _on_dismantle_pressed() -> void: if _selected_item_id.is_empty(): return @@ -413,6 +470,8 @@ func _update_3d_preview(character_name: String) -> void: anim_player.play("animation-pack/idle") elif anim_player.get_animation_list().size() > 0: anim_player.play(anim_player.get_animation_list()[0]) + # Apply equipped skins on the newly-visible character + SkinManager.apply_loadout(character_root, UserProfileManager.loadout) # ───────────────────────────────────────────────────────────── # Drag-to-rotate @@ -570,6 +629,17 @@ func _on_logout_pressed() -> void: func _on_admin_panel_pressed() -> void: AdminManager.toggle_admin_panel() +func _on_topup_gold_pressed() -> void: + _set_status("Topping up gold...", Color.WHITE) + topup_gold_btn.disabled = true + var ok: bool = await UserProfileManager.admin_topup_gold() + topup_gold_btn.disabled = false + if ok: + gold_label.text = str(UserProfileManager.wallet.get("gold", 0)) + _set_status("Top-up successful!", Color(0.4, 1.0, 0.4)) + else: + _set_status("Top-up failed.", Color.RED) + # ───────────────────────────────────────────────────────────── # Show / Close # ───────────────────────────────────────────────────────────── @@ -582,16 +652,33 @@ func show_panel() -> void: _set_status("Loading profile...", Color.YELLOW) _load_profile_data() _load_loadout() - _rebuild_category_items() - _populate_item_grid() _check_admin_visibility() show() if AuthManager.is_guest: _on_link_account_pressed() _set_status("Link an email to save progress permanently!", Color.YELLOW) + # Reload inventory from server to guarantee fresh data, then refresh the grid + _set_status("Refreshing inventory...", Color.WHITE) + await UserProfileManager.load_inventory() + # Auto-select the first tab that actually has items (avoids confusing empty grid) + var found_tab := false + for cat in ["head", "costume", "glove", "accessory"]: + _current_category = cat + _rebuild_category_items() + if not _category_items.is_empty(): + found_tab = true + break + if not found_tab: + _current_category = "head" + _rebuild_category_items() + _current_page = 0 + _populate_item_grid() + _highlight_active_tab() + _set_status("", Color.WHITE) func _check_admin_visibility() -> void: admin_panel_btn.hide() + topup_gold_btn.hide() if not NakamaManager.client or not NakamaManager.session: return var account = await NakamaManager.client.get_account_async(NakamaManager.session) if account.is_exception(): return @@ -599,6 +686,7 @@ func _check_admin_visibility() -> void: var meta = JSON.parse_string(raw) if meta is Dictionary and meta.get("role", "") in ["owner", "admin"]: admin_panel_btn.show() + topup_gold_btn.show() # ───────────────────────────────────────────────────────────── # Helpers diff --git a/scripts/ui/shop_panel.gd b/scripts/ui/shop_panel.gd index d9cbfec..b153f8f 100644 --- a/scripts/ui/shop_panel.gd +++ b/scripts/ui/shop_panel.gd @@ -27,7 +27,22 @@ signal closed # --- State --- var current_category: String = "head" var current_char_idx: int = 0 +# Node names inside the GLB scene (CharacterRoot children) var available_chars: Array[String] = ["Bob", "Masbro", "Gatot", "Oldpop"] +# Display name shown in the label -> node name in GLB +const DISPLAY_TO_NODE: Dictionary = { + "Copper": "Oldpop", + "Dabro": "Masbro", + "Pip": "Bob", + "Gatot": "Gatot", +} +# Reverse: node name -> display name shown to player +const NODE_TO_DISPLAY: Dictionary = { + "Oldpop": "Copper", + "Masbro": "Dabro", + "Bob": "Pip", + "Gatot": "Gatot", +} # Drag tracking var _is_dragging: bool = false @@ -81,12 +96,13 @@ func _ready() -> void: # Local 3D preview # ----------------------------------------------------------------------- func _setup_3d_preview() -> void: - # Attempt to match the user's currently saved loadout character - var def_char: String = UserProfileManager.profile.get("loadout_character", "Bob") - var idx = available_chars.find(def_char) + # loadout_character stores display names (e.g. "Copper") — convert to node name first + var def_raw: String = UserProfileManager.profile.get("loadout_character", "") + var def_node: String = DISPLAY_TO_NODE.get(def_raw, def_raw) # "Copper" -> "Oldpop" + var idx: int = available_chars.find(def_node) if idx != -1: current_char_idx = idx - + _update_char_name_label() _update_preview_char() @@ -101,31 +117,30 @@ func _on_next_char() -> void: _update_preview_char() func _update_char_name_label() -> void: - char_name_label.text = available_chars[current_char_idx] + var node_name: String = available_chars[current_char_idx] + # Show the player-facing display name (e.g. "Copper" instead of "Oldpop") + char_name_label.text = NODE_TO_DISPLAY.get(node_name, node_name) func _update_preview_char() -> void: if not character_root: return - - var target_node_name = available_chars[current_char_idx] - var active_char_node: Node3D = null - + + var target_node_name := available_chars[current_char_idx] + for child in character_root.get_children(): if child is Node3D: child.visible = (child.name == target_node_name) - if child.name == target_node_name: - active_char_node = child - + + var active_char_node := character_root.get_node_or_null(target_node_name) as Node3D + if active_char_node and anim_player: anim_player.root_node = active_char_node.get_path() if anim_player.has_animation("animation-pack/idle"): anim_player.play("animation-pack/idle") elif anim_player.get_animation_list().size() > 0: anim_player.play(anim_player.get_animation_list()[0]) - - if active_char_node: - var p = preload("res://scenes/player.gd").new() - p.apply_loadout(active_char_node) - p.free() + + # Apply the player's current loadout materials + SkinManager.apply_loadout(character_root, UserProfileManager.loadout) # ----------------------------------------------------------------------- # Drag-to-rotate @@ -236,9 +251,12 @@ func _make_star_card(pack: Dictionary) -> Control: func _make_cosmetic_card(item: Dictionary) -> Control: var card: Control = template_cosmetic_card.duplicate() card.visible = true - + + var item_id: String = item.get("id", "") + var already_owned: bool = UserProfileManager.inventory.has(item_id) + var name_lbl: Label = card.find_child("NameLabel", true, false) as Label - if name_lbl: name_lbl.text = item.get("name", item.get("id", "?")) + if name_lbl: name_lbl.text = item.get("name", item_id) var rarity: String = item.get("rarity", "Common") var rarity_lbl: Label = card.find_child("RarityLabel", true, false) as Label @@ -264,10 +282,18 @@ func _make_cosmetic_card(item: Dictionary) -> Control: if try_btn: try_btn.pressed.connect(_on_try_pressed.bind(item)) var buy_btn: Button = card.find_child("BuyBtn", true, false) as Button - if buy_btn: buy_btn.pressed.connect(_on_buy_cosmetic_pressed.bind(item)) + if buy_btn: + if already_owned: + buy_btn.text = "✓ Owned" + buy_btn.disabled = true + # Dim the entire card to signal it's already purchased + card.modulate = Color(0.55, 0.55, 0.55, 0.85) + else: + buy_btn.pressed.connect(_on_buy_cosmetic_pressed.bind(item)) return card + # ----------------------------------------------------------------------- # Wallet refresh # ----------------------------------------------------------------------- @@ -281,25 +307,27 @@ func _refresh_wallet() -> void: # ----------------------------------------------------------------------- # Button callbacks # ----------------------------------------------------------------------- +# Tracks a revert callable from SkinManager.preview_skin +var _preview_revert: Callable = Callable() + func _on_try_pressed(item: Dictionary) -> void: status_label.text = "Previewing: " + item.get("name", item.get("id", "?")) - # Auto-switch character if the catalog item targets a specific one. + # Auto-switch to the character this skin belongs to (if specified) if item.has("character"): var char_name: String = item.get("character") var idx: int = available_chars.find(char_name) if idx != -1 and current_char_idx != idx: current_char_idx = idx _update_char_name_label() - - # Inject into loadout temporarily to preview it without saving - var prev: String = UserProfileManager.loadout.get(current_category, "") - UserProfileManager.loadout[current_category] = item.id - - _update_preview_char() - - # Revert immediately, so jumping to next character drops preview. - UserProfileManager.loadout[current_category] = prev + _update_preview_char() + + # Revert any previous preview first + if _preview_revert.is_valid(): + _preview_revert.call() + + # Live material preview — SkinManager records a revert snapshot automatically + _preview_revert = SkinManager.preview_skin(character_root, item.get("id", "")) func _on_buy_gold_pressed(pack: Dictionary) -> void: status_label.text = "Processing purchase..." @@ -335,7 +363,16 @@ func _on_buy_cosmetic_pressed(item: Dictionary) -> void: status_label.text = ("Purchased: " + item.get("name", item.id)) if success else "Purchase failed." if success: _refresh_wallet() + # Refresh preview to show newly purchased skin's materials + if _preview_revert.is_valid(): + _preview_revert.call() + _preview_revert = Callable() + SkinManager.apply_loadout(character_root, UserProfileManager.loadout) func _on_close() -> void: + # Clean up any open preview when closing the shop + if _preview_revert.is_valid(): + _preview_revert.call() + _preview_revert = Callable() hide() emit_signal("closed") diff --git a/server/nakama/tekton_admin.js b/server/nakama/tekton_admin.js index 2d5efb4..e9ddad1 100644 --- a/server/nakama/tekton_admin.js +++ b/server/nakama/tekton_admin.js @@ -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"); - - // 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 }); + return JSON.stringify({ catalog: buildShopCatalog() }); +} + +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) {