feat: skin update
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cnp6a3r8bt0ol
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://d3wlsx2lbdlge
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user