feat: update shop

This commit is contained in:
2026-04-17 00:17:37 +08:00
parent f10d777c90
commit ff0a2e0f41
5 changed files with 914 additions and 130 deletions
+75 -3
View File
@@ -521,21 +521,93 @@ func _apply_outline_recursive(node: Node):
for child in node.get_children(): for child in node.get_children():
_apply_outline_recursive(child) _apply_outline_recursive(child)
const COSMETIC_MAPPING = {
"head_hat1": {
"hide": ["helm_default", "helm1", "helm2", "helm3"],
"show": ["head_hat1"],
"materials": {}
},
"head_crown": {
"hide": ["helm_default", "helm1", "helm2", "helm3"],
"show": ["head_crown"],
"materials": {}
},
"costume_red": {
"hide": [],
"show": [],
"materials": {
"body": "res://assets/materials/body_red.tres",
"arm": "res://assets/materials/body_red.tres"
}
},
"costume_gold": {
"hide": [],
"show": [],
"materials": {
"body": "res://assets/materials/body_gold.tres",
"arm": "res://assets/materials/body_gold.tres"
}
},
"glove_leather": {
"hide": ["hands", "glove_default"],
"show": ["glove_leather"],
"materials": {}
},
"acc_glasses": {
"hide": [],
"show": ["acc_glasses"],
"materials": {}
}
}
func apply_loadout(character_node: Node3D) -> void: func apply_loadout(character_node: Node3D) -> void:
"""Apply equipped cosmetics from UserProfileManager.loadout onto the active character model. """Apply equipped cosmetics from UserProfileManager.loadout onto the active character model.
Items are expected as child nodes named matching the item ID (e.g. 'head_hat1', 'acc_glasses'). It uses COSMETIC_MAPPING to dynamically swap visibility and materials of internal meshes."""
All nodes in the cosmetic category groups are hidden first, then the equipped one is shown."""
if not has_node("/root/UserProfileManager"): if not has_node("/root/UserProfileManager"):
return return
var loadout: Dictionary = UserProfileManager.loadout var loadout: Dictionary = UserProfileManager.loadout
var all_meshes = _get_all_mesh_instances(character_node)
for category in ["head", "costume", "glove", "accessory"]: for category in ["head", "costume", "glove", "accessory"]:
var equipped: String = loadout.get(category, "") var equipped: String = loadout.get(category, "")
# Fallback basic logic for direct children
for child in character_node.get_children(): for child in character_node.get_children():
# Only manage nodes that start with the category prefix
if child.name.begins_with(category): if child.name.begins_with(category):
child.visible = (child.name == equipped) 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 = []
if node is MeshInstance3D or node.get_class() == "SkinnedMeshInstance3D":
result.append(node)
for child in node.get_children():
result.append_array(_get_all_mesh_instances(child))
return result
@rpc("any_peer", "call_local", "reliable") @rpc("any_peer", "call_local", "reliable")
func sync_character(character_name: String) -> void: func sync_character(character_name: String) -> void:
+419 -57
View File
@@ -1,93 +1,455 @@
[gd_scene format=3 uid="uid://c018oue81jm44"] [gd_scene format=3 uid="uid://c018oue81jm44"]
[ext_resource type="Script" path="res://scripts/ui/shop_panel.gd" id="1"] [ext_resource type="Script" uid="uid://w0ddjofws4ib" path="res://scripts/ui/shop_panel.gd" id="1"]
[ext_resource type="Theme" uid="uid://da337sh5qxi0s" path="res://assets/themes/ui_theme.tres" id="1_jr3vq"]
[ext_resource type="Texture2D" uid="uid://2d1ks5pmblc7" path="res://assets/graphics/main_menu/bg_back.png" id="3_qjhny"]
[ext_resource type="FontFile" uid="uid://xnjx058n4tsw" path="res://assets/fonts/Nougat-ExtraBlack.ttf" id="3_udh10"]
[ext_resource type="PackedScene" uid="uid://ejeamn0pyey4" path="res://assets/characters/Bob.glb" id="4_bob"]
[ext_resource type="PackedScene" uid="uid://d4cul3w3wem5w" path="res://assets/characters/Gatot.glb" id="4_gatot"]
[ext_resource type="PackedScene" uid="uid://1vk0mjnwkngi" path="res://assets/characters/Masbro.glb" id="4_masbro"]
[ext_resource type="PackedScene" uid="uid://bmln7v6v5kvxg" path="res://assets/characters/Oldpop.glb" id="4_oldpop"]
[ext_resource type="AnimationLibrary" uid="uid://c3pyopnwibckj" path="res://assets/characters/animations/animation-pack.res" id="5_animlib"]
[node name="ShopPanel" type="Control"] [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bg"]
bg_color = Color(0.08, 0.09, 0.12, 1)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_card"]
content_margin_left = 12.0
content_margin_top = 12.0
content_margin_right = 12.0
content_margin_bottom = 12.0
bg_color = Color(0.91, 0.86, 0.61, 1)
border_width_left = 4
border_width_top = 4
border_width_right = 4
border_width_bottom = 4
border_color = Color(0.72, 0.52, 0.1, 1)
corner_radius_top_left = 10
corner_radius_top_right = 10
corner_radius_bottom_right = 10
corner_radius_bottom_left = 10
[node name="ShopPanel" type="Control" unique_id=1967851868]
layout_mode = 3 layout_mode = 3
anchors_preset = 15 anchors_preset = 15
anchor_right = 1.0 anchor_right = 1.0
anchor_bottom = 1.0 anchor_bottom = 1.0
grow_horizontal = 2 grow_horizontal = 2
grow_vertical = 2 grow_vertical = 2
theme = ExtResource("1_jr3vq")
script = ExtResource("1") script = ExtResource("1")
[node name="ColorRect" type="ColorRect" parent="."] [node name="Background" type="Panel" parent="." unique_id=2035775033]
layout_mode = 1 layout_mode = 1
anchors_preset = 15 anchors_preset = 15
anchor_right = 1.0 anchor_right = 1.0
anchor_bottom = 1.0 anchor_bottom = 1.0
grow_horizontal = 2 grow_horizontal = 2
grow_vertical = 2 grow_vertical = 2
color = Color(0.12, 0.12, 0.12, 0.95) theme_override_styles/panel = SubResource("StyleBoxFlat_bg")
[node name="Panel" type="PanelContainer" parent="."] [node name="Background2" type="TextureRect" parent="." unique_id=1682487151]
modulate = Color(1, 1, 1, 0.28235295)
layout_mode = 1 layout_mode = 1
anchors_preset = 8 anchors_preset = 15
anchor_left = 0.5 anchor_right = 1.0
anchor_top = 0.5 anchor_bottom = 1.0
anchor_right = 0.5 grow_horizontal = 2
anchor_bottom = 0.5 grow_vertical = 2
offset_left = -300.0 texture = ExtResource("3_qjhny")
offset_top = -250.0 expand_mode = 2
offset_right = 300.0
offset_bottom = 250.0 [node name="MainMargin" type="MarginContainer" parent="." unique_id=1416392345]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/margin_left = 30
theme_override_constants/margin_top = 30
theme_override_constants/margin_right = 30
theme_override_constants/margin_bottom = 30
[node name="MainVBox" type="VBoxContainer" parent="MainMargin" unique_id=298123419]
layout_mode = 2
theme_override_constants/separation = 20
[node name="TopBar" type="HBoxContainer" parent="MainMargin/MainVBox" unique_id=1421665563]
layout_mode = 2
[node name="Wallet" type="HBoxContainer" parent="MainMargin/MainVBox/TopBar" unique_id=382704727]
layout_mode = 2
theme_override_constants/separation = 15
[node name="StarPanel" type="PanelContainer" parent="MainMargin/MainVBox/TopBar/Wallet" unique_id=1049443997]
custom_minimum_size = Vector2(150, 50)
layout_mode = 2
theme = ExtResource("1_jr3vq")
[node name="StarLabel" type="Label" parent="MainMargin/MainVBox/TopBar/Wallet/StarPanel" unique_id=593135182]
unique_name_in_owner = true
layout_mode = 2
theme_override_colors/font_color = Color(0, 0, 0, 1)
theme_override_fonts/font = ExtResource("3_udh10")
theme_override_font_sizes/font_size = 18
text = "✦ 0"
horizontal_alignment = 1
[node name="GoldPanel" type="PanelContainer" parent="MainMargin/MainVBox/TopBar/Wallet" unique_id=132289]
custom_minimum_size = Vector2(150, 50)
layout_mode = 2
theme = ExtResource("1_jr3vq")
[node name="GoldLabel" type="Label" parent="MainMargin/MainVBox/TopBar/Wallet/GoldPanel" unique_id=1638818024]
unique_name_in_owner = true
layout_mode = 2
theme_override_colors/font_color = Color(0, 0, 0, 1)
theme_override_fonts/font = ExtResource("3_udh10")
theme_override_font_sizes/font_size = 18
text = "⭐ 0"
horizontal_alignment = 1
[node name="Spacer" type="Control" parent="MainMargin/MainVBox/TopBar" unique_id=1578035202]
layout_mode = 2
size_flags_horizontal = 3
[node name="TabsHBox" type="HBoxContainer" parent="MainMargin/MainVBox/TopBar" unique_id=805731541]
layout_mode = 2
theme_override_constants/separation = 10
[node name="TabHead" type="Button" parent="MainMargin/MainVBox/TopBar/TabsHBox" unique_id=212253926]
unique_name_in_owner = true
custom_minimum_size = Vector2(60, 50)
layout_mode = 2
theme_override_fonts/font = ExtResource("3_udh10")
text = "HAT"
[node name="TabCostume" type="Button" parent="MainMargin/MainVBox/TopBar/TabsHBox" unique_id=1953478397]
unique_name_in_owner = true
custom_minimum_size = Vector2(60, 50)
layout_mode = 2
theme_override_fonts/font = ExtResource("3_udh10")
text = "CLOTHING"
[node name="TabGlove" type="Button" parent="MainMargin/MainVBox/TopBar/TabsHBox" unique_id=1900195629]
unique_name_in_owner = true
custom_minimum_size = Vector2(60, 50)
layout_mode = 2
theme_override_fonts/font = ExtResource("3_udh10")
text = "GLOVES"
[node name="TabAccessory" type="Button" parent="MainMargin/MainVBox/TopBar/TabsHBox" unique_id=228390814]
unique_name_in_owner = true
custom_minimum_size = Vector2(60, 50)
layout_mode = 2
theme_override_fonts/font = ExtResource("3_udh10")
text = "COSTUME"
[node name="TabGold" type="Button" parent="MainMargin/MainVBox/TopBar/TabsHBox" unique_id=1246468211]
unique_name_in_owner = true
custom_minimum_size = Vector2(60, 50)
layout_mode = 2
theme_override_fonts/font = ExtResource("3_udh10")
text = "GET GOLD"
[node name="TabStar" type="Button" parent="MainMargin/MainVBox/TopBar/TabsHBox" unique_id=378819506]
unique_name_in_owner = true
custom_minimum_size = Vector2(60, 50)
layout_mode = 2
theme_override_fonts/font = ExtResource("3_udh10")
text = "GET STAR"
[node name="ContentHBox" type="HBoxContainer" parent="MainMargin/MainVBox" unique_id=908636948]
layout_mode = 2
size_flags_vertical = 3
theme_override_constants/separation = 30
[node name="LeftSidebar" type="VBoxContainer" parent="MainMargin/MainVBox/ContentHBox" unique_id=1308912288]
custom_minimum_size = Vector2(200, 0)
layout_mode = 2
theme_override_constants/separation = 20
[node name="Banner1" type="Button" parent="MainMargin/MainVBox/ContentHBox/LeftSidebar" unique_id=785866623]
custom_minimum_size = Vector2(0, 100)
layout_mode = 2
theme_override_fonts/font = ExtResource("3_udh10")
disabled = true
text = "Banner Slot"
[node name="Banner2" type="Button" parent="MainMargin/MainVBox/ContentHBox/LeftSidebar" unique_id=1108522673]
custom_minimum_size = Vector2(0, 100)
layout_mode = 2
theme_override_fonts/font = ExtResource("3_udh10")
disabled = true
text = "Banner Slot"
[node name="Banner3" type="Button" parent="MainMargin/MainVBox/ContentHBox/LeftSidebar" unique_id=1479545458]
custom_minimum_size = Vector2(0, 100)
layout_mode = 2
theme_override_fonts/font = ExtResource("3_udh10")
disabled = true
text = "Banner Slot"
[node name="Spacer" type="Control" parent="MainMargin/MainVBox/ContentHBox/LeftSidebar" unique_id=1808260178]
layout_mode = 2
size_flags_vertical = 3
[node name="BackBtn" type="Button" parent="MainMargin/MainVBox/ContentHBox/LeftSidebar" unique_id=1287390655]
unique_name_in_owner = true
custom_minimum_size = Vector2(80, 60)
layout_mode = 2
size_flags_horizontal = 0
text = "←"
[node name="CenterScroll" type="ScrollContainer" parent="MainMargin/MainVBox/ContentHBox" unique_id=953911053]
layout_mode = 2
size_flags_horizontal = 3
horizontal_scroll_mode = 0
[node name="ItemGrid" type="GridContainer" parent="MainMargin/MainVBox/ContentHBox/CenterScroll" unique_id=1867424471]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/h_separation = 20
theme_override_constants/v_separation = 20
columns = 2
[node name="Divider" type="ColorRect" parent="MainMargin/MainVBox/ContentHBox" unique_id=221156668]
custom_minimum_size = Vector2(8, 0)
layout_direction = 2
layout_mode = 2
color = Color(0.85, 0.72, 0.35, 1)
[node name="RightPanel" type="VBoxContainer" parent="MainMargin/MainVBox/ContentHBox" unique_id=472452762]
custom_minimum_size = Vector2(350, 0)
layout_mode = 2
theme_override_constants/separation = 15
[node name="SubViewportContainer" type="SubViewportContainer" parent="MainMargin/MainVBox/ContentHBox/RightPanel" unique_id=916953792]
layout_mode = 2
size_flags_vertical = 3
stretch = true
[node name="SubViewport" type="SubViewport" parent="MainMargin/MainVBox/ContentHBox/RightPanel/SubViewportContainer" unique_id=1223822678]
transparent_bg = true
handle_input_locally = false
size = Vector2i(350, 450)
render_target_update_mode = 4
[node name="Camera3D" type="Camera3D" parent="MainMargin/MainVBox/ContentHBox/RightPanel/SubViewportContainer/SubViewport" unique_id=24602145]
transform = Transform3D(1, 0, 0, 0, 0.9659259, 0.25881898, 0, -0.25881898, 0.9659259, 0, 0.5795684, 1.7936413)
fov = 55.0
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="MainMargin/MainVBox/ContentHBox/RightPanel/SubViewportContainer/SubViewport" unique_id=72453875]
transform = Transform3D(0.866025, -0.433013, 0.25, 0, 0.5, 0.866025, -0.5, -0.75, 0.433013, 0, 0, 0)
[node name="CharacterRoot" type="Node3D" parent="MainMargin/MainVBox/ContentHBox/RightPanel/SubViewportContainer/SubViewport" unique_id=222752607]
unique_name_in_owner = true
[node name="Bob" parent="MainMargin/MainVBox/ContentHBox/RightPanel/SubViewportContainer/SubViewport/CharacterRoot" unique_id=1096946192 instance=ExtResource("4_bob")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.484, 0)
visible = false
[node name="Gatot" parent="MainMargin/MainVBox/ContentHBox/RightPanel/SubViewportContainer/SubViewport/CharacterRoot" unique_id=49313654 instance=ExtResource("4_gatot")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.485, 0)
visible = false
[node name="Masbro" parent="MainMargin/MainVBox/ContentHBox/RightPanel/SubViewportContainer/SubViewport/CharacterRoot" unique_id=1131149145 instance=ExtResource("4_masbro")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.485, 0)
visible = false
[node name="Oldpop" parent="MainMargin/MainVBox/ContentHBox/RightPanel/SubViewportContainer/SubViewport/CharacterRoot" unique_id=1364644723 instance=ExtResource("4_oldpop")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.485, 0)
[node name="AnimationPlayer" type="AnimationPlayer" parent="MainMargin/MainVBox/ContentHBox/RightPanel/SubViewportContainer/SubViewport/CharacterRoot" unique_id=245373446]
unique_name_in_owner = true
root_node = NodePath("../Oldpop")
libraries/animation-pack = ExtResource("5_animlib")
autoplay = &"animation-pack/idle"
[node name="CharSelector" type="HBoxContainer" parent="MainMargin/MainVBox/ContentHBox/RightPanel" unique_id=1613160498]
layout_mode = 2
theme_override_constants/separation = 15
alignment = 1
[node name="PrevBtn" type="Button" parent="MainMargin/MainVBox/ContentHBox/RightPanel/CharSelector" unique_id=754085432]
unique_name_in_owner = true
custom_minimum_size = Vector2(40, 40)
layout_mode = 2
text = "<"
[node name="CharName" type="Label" parent="MainMargin/MainVBox/ContentHBox/RightPanel/CharSelector" unique_id=1410054360]
unique_name_in_owner = true
custom_minimum_size = Vector2(100, 0)
layout_mode = 2
theme_override_fonts/font = ExtResource("3_udh10")
text = "Masbro"
horizontal_alignment = 1
[node name="NextBtn" type="Button" parent="MainMargin/MainVBox/ContentHBox/RightPanel/CharSelector" unique_id=1382801465]
unique_name_in_owner = true
custom_minimum_size = Vector2(40, 40)
layout_mode = 2
text = ">"
[node name="Label" type="Label" parent="MainMargin/MainVBox/ContentHBox/RightPanel" unique_id=2080778318]
layout_mode = 2
theme_override_fonts/font = ExtResource("3_udh10")
text = "Drag to rotate"
horizontal_alignment = 1
[node name="StatusLabel" type="Label" parent="MainMargin/MainVBox" unique_id=1749562194]
unique_name_in_owner = true
layout_mode = 2
horizontal_alignment = 1
[node name="Templates" type="Control" parent="." unique_id=842512015]
visible = false
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2 grow_horizontal = 2
grow_vertical = 2 grow_vertical = 2
[node name="VBoxContainer" type="VBoxContainer" parent="Panel"] [node name="GoldCard" type="PanelContainer" parent="Templates" unique_id=376422212]
layout_mode = 2 unique_name_in_owner = true
custom_minimum_size = Vector2(319, 150)
layout_mode = 0
offset_right = 319.0
offset_bottom = 150.0
theme_override_styles/panel = SubResource("StyleBoxFlat_card")
[node name="Header" type="HBoxContainer" parent="Panel/VBoxContainer"] [node name="HBox" type="HBoxContainer" parent="Templates/GoldCard" unique_id=2018445885]
layout_mode = 2 layout_mode = 2
alignment = 1 theme_override_constants/separation = 14
[node name="BackBtn" type="Button" parent="Panel/VBoxContainer/Header"] [node name="Icon" type="ColorRect" parent="Templates/GoldCard/HBox" unique_id=1987319256]
custom_minimum_size = Vector2(125, 125)
layout_mode = 2 layout_mode = 2
text = "Back" color = Color(0.8, 0.7, 0.4, 1)
[node name="Title" type="Label" parent="Panel/VBoxContainer/Header"] [node name="VBox" type="VBoxContainer" parent="Templates/GoldCard/HBox" unique_id=1510531971]
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3
text = "SHOP" alignment = 1
[node name="AmountLabel" type="Label" parent="Templates/GoldCard/HBox/VBox" unique_id=581128094]
layout_mode = 2
theme_override_colors/font_color = Color(0.15, 0.1, 0.05, 1)
theme_override_font_sizes/font_size = 18
text = "⭐ 100"
horizontal_alignment = 2
[node name="BonusLabel" type="Label" parent="Templates/GoldCard/HBox/VBox" unique_id=31159053]
layout_mode = 2
theme_override_colors/font_color = Color(0.9, 0.45, 0.1, 1)
text = "+50"
horizontal_alignment = 2
[node name="PriceLabel" type="Label" parent="Templates/GoldCard/HBox/VBox" unique_id=2125761085]
layout_mode = 2
theme_override_colors/font_color = Color(0.15, 0.55, 0.2, 1)
text = "$ 0.99"
horizontal_alignment = 1 horizontal_alignment = 1
[node name="TabContainer" type="TabContainer" parent="Panel/VBoxContainer"] [node name="BuyBtn" type="Button" parent="Templates/GoldCard/HBox/VBox" unique_id=417610366]
layout_mode = 2
text = "Buy"
[node name="StarCard" type="PanelContainer" parent="Templates" unique_id=1034862492]
unique_name_in_owner = true
custom_minimum_size = Vector2(319, 150)
layout_mode = 0
offset_right = 319.0
offset_bottom = 150.0
theme_override_styles/panel = SubResource("StyleBoxFlat_card")
[node name="HBox" type="HBoxContainer" parent="Templates/StarCard" unique_id=1343953464]
layout_mode = 2
theme_override_constants/separation = 14
[node name="Icon" type="ColorRect" parent="Templates/StarCard/HBox" unique_id=914113873]
custom_minimum_size = Vector2(125, 125)
layout_mode = 2
color = Color(0.65, 0.5, 0.9, 1)
[node name="VBox" type="VBoxContainer" parent="Templates/StarCard/HBox" unique_id=1883407781]
layout_mode = 2
size_flags_horizontal = 3
alignment = 1
[node name="AmountLabel" type="Label" parent="Templates/StarCard/HBox/VBox" unique_id=1276779551]
layout_mode = 2
theme_override_colors/font_color = Color(0.15, 0.1, 0.05, 1)
theme_override_font_sizes/font_size = 18
text = "✦ 100"
horizontal_alignment = 2
[node name="HSeparator" type="Control" parent="Templates/StarCard/HBox/VBox" unique_id=1627096153]
clip_contents = true
layout_mode = 2 layout_mode = 2
size_flags_vertical = 3 size_flags_vertical = 3
current_tab = 0
[node name="Head" type="MarginContainer" parent="Panel/VBoxContainer/TabContainer"] [node name="CostLabel" type="Label" parent="Templates/StarCard/HBox/VBox" unique_id=1937011106]
layout_mode = 2 layout_mode = 2
theme_override_colors/font_color = Color(0.55, 0.35, 0.05, 1)
[node name="GridContainer" type="GridContainer" parent="Panel/VBoxContainer/TabContainer/Head"] text = "⭐ 500 Gold"
layout_mode = 2
columns = 3
[node name="Costume" type="MarginContainer" parent="Panel/VBoxContainer/TabContainer"]
visible = false
layout_mode = 2
[node name="GridContainer" type="GridContainer" parent="Panel/VBoxContainer/TabContainer/Costume"]
layout_mode = 2
columns = 3
[node name="Glove" type="MarginContainer" parent="Panel/VBoxContainer/TabContainer"]
visible = false
layout_mode = 2
[node name="GridContainer" type="GridContainer" parent="Panel/VBoxContainer/TabContainer/Glove"]
layout_mode = 2
columns = 3
[node name="Accessory" type="MarginContainer" parent="Panel/VBoxContainer/TabContainer"]
visible = false
layout_mode = 2
[node name="GridContainer" type="GridContainer" parent="Panel/VBoxContainer/TabContainer/Accessory"]
layout_mode = 2
columns = 3
[node name="StatusLabel" type="Label" parent="Panel/VBoxContainer"]
layout_mode = 2
text = "Welcome to the Store!"
horizontal_alignment = 1 horizontal_alignment = 1
[node name="BuyBtn" type="Button" parent="Templates/StarCard/HBox/VBox" unique_id=1523983544]
layout_mode = 2
text = "Convert"
[node name="CosmeticCard" type="PanelContainer" parent="Templates" unique_id=1937229989]
unique_name_in_owner = true
custom_minimum_size = Vector2(319, 150)
layout_mode = 0
offset_right = 319.0
offset_bottom = 150.0
theme_override_styles/panel = SubResource("StyleBoxFlat_card")
[node name="HBox" type="HBoxContainer" parent="Templates/CosmeticCard" unique_id=1527720461]
layout_mode = 2
theme_override_constants/separation = 14
[node name="Icon" type="ColorRect" parent="Templates/CosmeticCard/HBox" unique_id=1136185378]
custom_minimum_size = Vector2(125, 125)
layout_mode = 2
color = Color(0.8, 0.7, 0.4, 1)
[node name="VBox" type="VBoxContainer" parent="Templates/CosmeticCard/HBox" unique_id=1048501562]
layout_mode = 2
size_flags_horizontal = 3
alignment = 1
[node name="NameLabel" type="Label" parent="Templates/CosmeticCard/HBox/VBox" unique_id=958903842]
layout_mode = 2
theme_override_colors/font_color = Color(0.1, 0.08, 0.04, 1)
text = "Item Name"
[node name="RarityLabel" type="Label" parent="Templates/CosmeticCard/HBox/VBox" unique_id=882234879]
layout_mode = 2
theme_override_colors/font_color = Color(0.5, 0.5, 0.5, 1)
text = "Common"
[node name="PriceLabel" type="Label" parent="Templates/CosmeticCard/HBox/VBox" unique_id=489381936]
layout_mode = 2
size_flags_vertical = 6
theme_override_colors/font_color = Color(0.15, 0.1, 0.05, 1)
text = "⭐ 0 ✦ 0"
horizontal_alignment = 1
[node name="BtnRow" type="HBoxContainer" parent="Templates/CosmeticCard/HBox/VBox" unique_id=1035177281]
layout_mode = 2
[node name="TryBtn" type="Button" parent="Templates/CosmeticCard/HBox/VBox/BtnRow" unique_id=273726845]
layout_mode = 2
size_flags_horizontal = 3
text = "Try"
[node name="BuyBtn" type="Button" parent="Templates/CosmeticCard/HBox/VBox/BtnRow" unique_id=1486650007]
layout_mode = 2
size_flags_horizontal = 3
text = "Buy"
+51 -1
View File
@@ -12,6 +12,7 @@ var stats: Dictionary = {}
var wallet: Dictionary = {"gold": 0, "star": 0} var wallet: Dictionary = {"gold": 0, "star": 0}
var inventory: Array = [] var inventory: Array = []
var loadout: Dictionary = {"head": "", "costume": "", "glove": "", "accessory": ""} var loadout: Dictionary = {"head": "", "costume": "", "glove": "", "accessory": ""}
var shop_catalog: Dictionary = {}
var is_profile_loaded: bool = false var is_profile_loaded: bool = false
# Nakama storage collection names # Nakama storage collection names
@@ -107,7 +108,7 @@ func load_profile() -> Dictionary:
is_profile_loaded = true is_profile_loaded = true
emit_signal("profile_loaded", profile) emit_signal("profile_loaded", profile)
print("[UserProfileManager] Profile loaded: ", profile.display_name) print("[UserProfileManager] Profile loaded: ", profile.get("display_name", "Unknown"))
# Auto-sync existing score to native Nakama leaderboard in background # Auto-sync existing score to native Nakama leaderboard in background
if stats.get("high_score", 0) > 0 and NakamaManager.session and not AuthManager.is_guest: if stats.get("high_score", 0) > 0 and NakamaManager.session and not AuthManager.is_guest:
@@ -315,6 +316,55 @@ func purchase_item(item_id: String, price_gold: int, price_star: int, category:
emit_signal("profile_updated") emit_signal("profile_updated")
return true return true
func fetch_shop_catalog() -> void:
if not NakamaManager.session: return
var result = await NakamaManager.client.rpc_async(
NakamaManager.session,
"get_shop_catalog",
"{}"
)
if result.is_exception():
push_error("[UserProfileManager] Failed to fetch shop catalog: ", result.get_exception().message)
return
var payload: Dictionary = JSON.parse_string(result.payload)
if payload and payload.has("catalog"):
shop_catalog = payload.catalog
emit_signal("profile_updated")
func buy_currency(package_id: String) -> bool:
if not NakamaManager.session: return false
var payload = JSON.stringify({
"package_id": package_id
})
var result = await NakamaManager.client.rpc_async(
NakamaManager.session,
"buy_currency",
payload
)
if result.is_exception():
push_error("[UserProfileManager] Failed to buy currency: ", result.get_exception().message)
return false
await _reload_wallet()
return true
func _reload_wallet() -> void:
if not NakamaManager.session: return
var account = await NakamaManager.client.get_account_async(NakamaManager.session)
if account.is_exception(): return
if account.wallet:
var w_data = JSON.parse_string(account.wallet)
if w_data:
wallet["gold"] = w_data.get("gold", 0)
wallet["star"] = w_data.get("star", 0)
emit_signal("profile_updated")
# ============================================================================= # =============================================================================
# Stats Management # Stats Management
# ============================================================================= # =============================================================================
+319 -69
View File
@@ -2,90 +2,340 @@ extends Control
signal closed signal closed
@onready var tab_container = $Panel/VBoxContainer/TabContainer # --- Node References (% = unique name, path-independent) ---
@onready var head_grid = $Panel/VBoxContainer/TabContainer/Head/GridContainer @onready var star_label: Label = %StarLabel
@onready var costume_grid = $Panel/VBoxContainer/TabContainer/Costume/GridContainer @onready var gold_label: Label = %GoldLabel
@onready var glove_grid = $Panel/VBoxContainer/TabContainer/Glove/GridContainer @onready var item_grid: GridContainer = %ItemGrid
@onready var accessory_grid = $Panel/VBoxContainer/TabContainer/Accessory/GridContainer @onready var back_btn: Button = %BackBtn
@onready var status_label = $Panel/VBoxContainer/StatusLabel @onready var status_label: Label = %StatusLabel
@onready var back_btn = $Panel/VBoxContainer/Header/BackBtn
var shop_items = { # Tabs
"head": [ @onready var tab_head: Button = %TabHead
{"id": "head_hat1", "name": "Cap", "gold": 100, "star": 0}, @onready var tab_costume: Button = %TabCostume
{"id": "head_crown", "name": "Crown", "gold": 0, "star": 50} @onready var tab_glove: Button = %TabGlove
], @onready var tab_acc: Button = %TabAccessory
"costume": [ @onready var tab_gold: Button = %TabGold
{"id": "costume_red", "name": "Red Suit", "gold": 200, "star": 0}, @onready var tab_star: Button = %TabStar
{"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}
]
}
func _ready(): # 3D Preview
@onready var character_root: Node3D = %CharacterRoot
@onready var anim_player: AnimationPlayer = %AnimationPlayer
@onready var prev_btn: Button = %PrevBtn
@onready var next_btn: Button = %NextBtn
@onready var char_name_label: Label = %CharName
# --- State ---
var current_category: String = "head"
var current_char_idx: int = 0
var available_chars: Array[String] = ["Bob", "Masbro", "Gatot", "Oldpop"]
# Drag tracking
var _is_dragging: bool = false
var _last_mouse_x: float = 0.0
# -----------------------------------------------------------------------
# Gold-pack catalog: { id, label, amount, bonus, usd }
# Matches the mockup tiers: 100 / 500+50 / 1000+150 / 2000+400 / 5000+1250 / 10000+3000
# -----------------------------------------------------------------------
const GOLD_PACKS: Array = [
{"id": "gold_100", "label": "100", "amount": 100, "bonus": 0, "usd": 0.99},
{"id": "gold_500", "label": "500", "amount": 500, "bonus": 50, "usd": 4.99},
{"id": "gold_1000", "label": "1000", "amount": 1000, "bonus": 150, "usd": 9.99},
{"id": "gold_2000", "label": "2000", "amount": 2000, "bonus": 400, "usd": 19.99},
{"id": "gold_5000", "label": "5000", "amount": 5000, "bonus": 1250, "usd": 49.99},
{"id": "gold_10000", "label": "10000", "amount": 10000, "bonus": 3000, "usd": 99.99},
]
# Star-to-gold conversion rates (spent in gold, received in star)
const STAR_PACKS: Array = [
{"id": "star_100", "label": "100 Star", "amount": 100, "gold_cost": 500},
{"id": "star_250", "label": "250 Star", "amount": 250, "gold_cost": 1100},
{"id": "star_600", "label": "600 Star", "amount": 600, "gold_cost": 2500},
]
# -----------------------------------------------------------------------
# _ready
# -----------------------------------------------------------------------
func _ready() -> void:
back_btn.pressed.connect(_on_close) back_btn.pressed.connect(_on_close)
tab_head.pressed.connect(_on_tab_selected.bind("head"))
tab_costume.pressed.connect(_on_tab_selected.bind("costume"))
tab_glove.pressed.connect(_on_tab_selected.bind("glove"))
tab_acc.pressed.connect(_on_tab_selected.bind("accessory"))
tab_gold.pressed.connect(_on_tab_selected.bind("gold_packs"))
tab_star.pressed.connect(_on_tab_selected.bind("star_packs"))
prev_btn.pressed.connect(_on_prev_char)
next_btn.pressed.connect(_on_next_char)
if UserProfileManager.profile_updated.connect(_refresh_wallet) != OK:
pass
_setup_3d_preview()
if UserProfileManager.shop_catalog.is_empty():
_fetch_and_build()
else:
_build_shop()
# -----------------------------------------------------------------------
# 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)
if idx != -1:
current_char_idx = idx
_update_char_name_label()
_update_preview_char()
func _on_prev_char() -> void:
current_char_idx = (current_char_idx - 1 + available_chars.size()) % available_chars.size()
_update_char_name_label()
_update_preview_char()
func _on_next_char() -> void:
current_char_idx = (current_char_idx + 1) % available_chars.size()
_update_char_name_label()
_update_preview_char()
func _update_char_name_label() -> void:
char_name_label.text = available_chars[current_char_idx]
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
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
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()
# -----------------------------------------------------------------------
# Drag-to-rotate
# -----------------------------------------------------------------------
func _input(event: InputEvent) -> void:
if not visible:
return
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
_is_dragging = event.pressed
if event.pressed:
_last_mouse_x = event.position.x
elif event is InputEventMouseMotion and _is_dragging and character_root:
var delta: float = event.position.x - _last_mouse_x
character_root.rotation_degrees.y += delta * 0.5
_last_mouse_x = event.position.x
# -----------------------------------------------------------------------
# Public entry point called by the parent scene
# -----------------------------------------------------------------------
func show_panel() -> void:
show()
_setup_3d_preview()
if UserProfileManager.shop_catalog.is_empty():
_fetch_and_build()
else:
_refresh_wallet()
func _fetch_and_build() -> void:
status_label.text = "Loading catalog..."
await UserProfileManager.fetch_shop_catalog()
_build_shop() _build_shop()
func show_panel(): func _build_shop() -> void:
show() _refresh_wallet()
_refresh_shop() _populate_current_tab()
func _build_shop(): func _on_tab_selected(category: String) -> void:
_populate_grid(head_grid, shop_items["head"]) current_category = category
_populate_grid(costume_grid, shop_items["costume"]) _populate_current_tab()
_populate_grid(glove_grid, shop_items["glove"])
_populate_grid(accessory_grid, shop_items["accessory"])
func _populate_grid(grid, items): # -----------------------------------------------------------------------
for child in grid.get_children(): # Grid population — builds cards dynamically from localized templates
# -----------------------------------------------------------------------
func _populate_current_tab() -> void:
for child in item_grid.get_children():
child.queue_free() child.queue_free()
for item in items:
var btn = Button.new()
btn.custom_minimum_size = Vector2(120, 80)
btn.add_theme_font_size_override("font_size", 14)
btn.text = "%s\nGold: %d\nStar: %d" % [item.name, item.gold, item.star]
btn.pressed.connect(_on_buy_pressed.bind(item))
grid.add_child(btn)
func _refresh_shop(): var catalog := UserProfileManager.shop_catalog
# Visual update to show which items are owned
# (For simplicity, not disabling buttons directly, relying on backend check)
status_label.text = "Welcome to the Shop!"
func _on_buy_pressed(item: Dictionary): match current_category:
if UserProfileManager.inventory.has(item.id): "gold_packs":
status_label.text = "Already owned: " + item.name for pack in GOLD_PACKS:
return item_grid.add_child(_make_gold_card(pack))
"star_packs":
for pack in STAR_PACKS:
item_grid.add_child(_make_star_card(pack))
_:
var items: Array = catalog.get(current_category, [])
for item in items:
item_grid.add_child(_make_cosmetic_card(item))
# -----------------------------------------------------------------------
# Card builders — instantiated from scene templates
# -----------------------------------------------------------------------
@onready var template_gold_card: Control = %GoldCard
@onready var template_star_card: Control = %StarCard
@onready var template_cosmetic_card: Control = %CosmeticCard
func _make_gold_card(pack: Dictionary) -> Control:
var card: Control = template_gold_card.duplicate()
card.visible = true
var price_gold = item.gold var amount_lbl: Label = card.find_child("AmountLabel", true, false) as Label
var price_star = item.star if amount_lbl: amount_lbl.text = "%d" % pack.amount
if UserProfileManager.wallet.get("gold", 0) < price_gold or UserProfileManager.wallet.get("star", 0) < price_star:
status_label.text = "Not enough currency for " + item.name
return
status_label.text = "Purchasing " + item.name + "..." var bonus_lbl: Label = card.find_child("BonusLabel", true, false) as Label
if bonus_lbl:
if pack.bonus > 0:
bonus_lbl.text = "+%d" % pack.bonus
bonus_lbl.show()
else:
bonus_lbl.hide()
var price_lbl: Label = card.find_child("PriceLabel", true, false) as Label
if price_lbl: price_lbl.text = "$ %.2f" % pack.usd
var buy_btn: Button = card.find_child("BuyBtn", true, false) as Button
if buy_btn: buy_btn.pressed.connect(_on_buy_gold_pressed.bind(pack))
return card
func _make_star_card(pack: Dictionary) -> Control:
var card: Control = template_star_card.duplicate()
card.visible = true
# Determine category var amount_lbl: Label = card.find_child("AmountLabel", true, false) as Label
var category = "" if amount_lbl: amount_lbl.text = "%d" % pack.amount
if shop_items["head"].has(item): category = "head"
elif shop_items["costume"].has(item): category = "costume" var cost_lbl: Label = card.find_child("CostLabel", true, false) as Label
elif shop_items["glove"].has(item): category = "glove" if cost_lbl: cost_lbl.text = "Cost: ⭐ %d Gold" % pack.gold_cost
elif shop_items["accessory"].has(item): category = "accessory"
var buy_btn: Button = card.find_child("BuyBtn", true, false) as Button
if buy_btn: buy_btn.pressed.connect(_on_buy_star_pressed.bind(pack))
return card
var success = await UserProfileManager.purchase_item(item.id, price_gold, price_star, category) func _make_cosmetic_card(item: Dictionary) -> Control:
var card: Control = template_cosmetic_card.duplicate()
card.visible = true
var name_lbl: Label = card.find_child("NameLabel", true, false) as Label
if name_lbl: name_lbl.text = item.get("name", item.get("id", "?"))
var rarity: String = item.get("rarity", "Common")
var rarity_lbl: Label = card.find_child("RarityLabel", true, false) as Label
if rarity_lbl:
rarity_lbl.text = rarity
var rarity_col: Color = {
"Common": Color(0.50, 0.50, 0.50),
"Rare": Color(0.20, 0.50, 0.90),
"Epic": Color(0.60, 0.20, 0.80),
"Legendary": Color(0.90, 0.60, 0.10),
}.get(rarity, Color(0.50, 0.50, 0.50))
rarity_lbl.add_theme_color_override("font_color", rarity_col)
var price_lbl: Label = card.find_child("PriceLabel", true, false) as Label
if price_lbl:
var g: int = int(item.get("gold", 0))
var s: int = int(item.get("star", 0))
if g > 0 and s > 0: price_lbl.text = "%d%d" % [g, s]
elif g > 0: price_lbl.text = "%d" % g
else: price_lbl.text = "%d" % s
var try_btn: Button = card.find_child("TryBtn", true, false) as Button
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))
return card
# -----------------------------------------------------------------------
# Wallet refresh
# -----------------------------------------------------------------------
func _refresh_wallet() -> void:
var g: int = UserProfileManager.wallet.get("gold", 0)
var s: int = UserProfileManager.wallet.get("star", 0)
gold_label.text = "%d" % g
star_label.text = "%d" % s
status_label.text = ""
# -----------------------------------------------------------------------
# Button callbacks
# -----------------------------------------------------------------------
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.
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
func _on_buy_gold_pressed(pack: Dictionary) -> void:
status_label.text = "Processing purchase..."
var success: bool = await UserProfileManager.buy_currency(pack.id)
status_label.text = "Purchased %s Gold!" % pack.label if success else "Purchase failed."
if success: if success:
status_label.text = "Successfully purchased: " + item.name _refresh_wallet()
_refresh_shop()
else:
status_label.text = "Failed to purchase. Backend error."
func _on_close(): func _on_buy_star_pressed(pack: Dictionary) -> void:
var cost: int = pack.gold_cost
if UserProfileManager.wallet.get("gold", 0) < cost:
status_label.text = "Not enough Gold. Need ⭐ %d." % cost
return
status_label.text = "Converting..."
var success: bool = await UserProfileManager.buy_currency(pack.id)
status_label.text = "Received ✦ %d Star!" % pack.amount if success else "Conversion failed."
if success:
_refresh_wallet()
func _on_buy_cosmetic_pressed(item: Dictionary) -> void:
if UserProfileManager.inventory.has(item.id):
status_label.text = "Already owned: " + item.get("name", item.id)
return
var price_gold: int = item.get("gold", 0)
var price_star: int = item.get("star", 0)
if UserProfileManager.wallet.get("gold", 0) < price_gold \
or UserProfileManager.wallet.get("star", 0) < price_star:
status_label.text = "Not enough currency."
return
status_label.text = "Purchasing..."
var success: bool = await UserProfileManager.purchase_item(
item.id, price_gold, price_star, current_category)
status_label.text = ("Purchased: " + item.get("name", item.id)) if success else "Purchase failed."
if success:
_refresh_wallet()
func _on_close() -> void:
hide() hide()
emit_signal("closed") emit_signal("closed")
+50
View File
@@ -25,6 +25,8 @@ function InitModule(ctx, logger, nk, initializer) {
// Store RPCs // Store RPCs
initializer.registerRpc("purchase_item", rpcPurchaseItem); initializer.registerRpc("purchase_item", rpcPurchaseItem);
initializer.registerRpc("get_shop_catalog", rpcGetShopCatalog);
initializer.registerRpc("buy_currency", rpcBuyCurrency);
// Leaderboard RPCs // Leaderboard RPCs
initializer.registerRpc("get_leaderboard_stats", rpcGetLeaderboardStats); initializer.registerRpc("get_leaderboard_stats", rpcGetLeaderboardStats);
@@ -384,6 +386,54 @@ function rpcAdminSetUserRole(ctx, logger, nk, payload) {
// Store / Economy RPCs // Store / Economy RPCs
// ============================================================================= // =============================================================================
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 });
}
function rpcBuyCurrency(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("Not authenticated");
var request = JSON.parse(payload);
var packageId = request.package_id;
var changeset = { "gold": 0, "star": 0 };
if (packageId === "gold_pack_1") changeset["gold"] = 1000;
else if (packageId === "gold_pack_2") changeset["gold"] = 5000;
else if (packageId === "star_pack_1") changeset["star"] = 100;
else if (packageId === "star_pack_2") changeset["star"] = 500;
else throw new Error("Invalid package ID");
try {
nk.walletUpdate(ctx.userId, changeset, {}, true);
logger.info("User " + ctx.userId + " bought currency package " + packageId);
return JSON.stringify({ success: true, package_id: packageId });
} catch (e) {
logger.error("Currency purchase failed: " + e.message);
throw new Error("Currency purchase failed: " + e.message);
}
}
function rpcPurchaseItem(ctx, logger, nk, payload) { function rpcPurchaseItem(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("Not authenticated"); if (!ctx.userId) throw new Error("Not authenticated");