feat: overhaul UI main and profile

This commit is contained in:
2026-04-15 16:26:49 +08:00
parent 01661a56ba
commit f10d777c90
16 changed files with 1888 additions and 710 deletions
+69 -1
View File
@@ -9,6 +9,9 @@ signal avatar_changed(url: String)
# Profile data
var profile: Dictionary = {}
var stats: Dictionary = {}
var wallet: Dictionary = {"gold": 0, "star": 0}
var inventory: Array = []
var loadout: Dictionary = {"head": "", "costume": "", "glove": "", "accessory": ""}
var is_profile_loaded: bool = false
# Nakama storage collection names
@@ -47,6 +50,8 @@ func load_profile() -> Dictionary:
# Reset state first to ensure no old account data carries over
profile = {}
stats = {}
wallet = {"gold": 0, "star": 0}
inventory = []
is_profile_loaded = false
if not NakamaManager.session:
@@ -84,6 +89,18 @@ func load_profile() -> Dictionary:
var stored_data = JSON.parse_string(storage_result.objects[0].value)
if stored_data:
profile.merge(stored_data, true)
if stored_data.has("loadout"):
loadout = stored_data["loadout"]
# Parse Wallet
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)
# Load Inventory
await load_inventory()
# Load stats
await load_stats()
@@ -98,6 +115,21 @@ func load_profile() -> Dictionary:
return profile
func load_inventory() -> void:
if not NakamaManager.session: return
inventory.clear()
var result = await NakamaManager.client.list_storage_objects_async(
NakamaManager.session,
"inventory",
NakamaManager.session.user_id,
100
)
if not result.is_exception() and result.objects:
for obj in result.objects:
inventory.append(obj.key)
func load_stats() -> Dictionary:
# Reset stats first to ensure fresh data for new logins
stats = {}
@@ -223,7 +255,8 @@ func _save_profile_data() -> bool:
"avatar_index": profile.get("avatar_index", 0),
"bio": profile.get("bio", ""),
"country": profile.get("country", ""),
"language": profile.get("language", "en")
"language": profile.get("language", "en"),
"loadout": loadout
}
var write_obj := NakamaWriteStorageObject.new(
@@ -247,6 +280,41 @@ func _save_profile_data() -> bool:
emit_signal("profile_updated")
return true
func update_loadout(category: String, item_id: String) -> bool:
if not loadout.has(category):
return false
loadout[category] = item_id
return await _save_profile_data()
func purchase_item(item_id: String, price_gold: int, price_star: int, category: String) -> bool:
if not NakamaManager.session: return false
var payload = JSON.stringify({
"item_id": item_id,
"price_gold": price_gold,
"price_star": price_star,
"category": category
})
var result = await NakamaManager.client.rpc_async(
NakamaManager.session,
"purchase_item",
payload
)
if result.is_exception():
push_error("[UserProfileManager] Purchase failed: ", result.get_exception().message)
return false
# Update local cache
if price_gold > 0: wallet["gold"] -= price_gold
if price_star > 0: wallet["star"] -= price_star
if not inventory.has(item_id):
inventory.append(item_id)
emit_signal("profile_updated")
return true
# =============================================================================
# Stats Management
# =============================================================================
+443 -248
View File
@@ -1,163 +1,369 @@
extends Control
## Profile panel controller — full-scene loadout screen.
## Left: profile info + default character selector.
## Right: 3D SubViewport character preview.
## Profile panel — 3-column layout.
## Left : profile card, currencies, stats, account buttons.
## Center: category tabs overlay, 3D rotatable preview, char-selector overlay.
## Right : item info card, 3×3 owned-item grid, pagination.
signal closed
signal profile_updated
# -------------------------------------------------------------------------
# UI References
# -------------------------------------------------------------------------
@onready var back_btn := %BackBtn as Button
@onready var avatar_display := %AvatarDisplay as TextureRect
@onready var change_avatar_btn := %ChangeAvatarBtn as Button
@onready var display_name_input := %DisplayNameInput as LineEdit
@onready var save_name_btn := %SaveNameBtn as Button
@onready var games_played_label := %GamesPlayed as Label
@onready var win_rate_label := %WinRate as Label
@onready var high_score_label := %HighScore as Label
@onready var account_type_label := %AccountType as Label
@onready var link_account_btn := %LinkAccountBtn as Button
@onready var admin_panel_btn := %AdminPanelBtn as Button
@onready var logout_btn := %LogoutBtn as Button
@onready var status_label := %StatusLabel as Label
@onready var avatar_popup := %AvatarSelectionPopup as PopupPanel
@onready var avatar_grid := %GridContainer as GridContainer
# ─────────────────────────────────────────────────────────────
# UI references (all resolved via %UniqueName — must match .tscn)
# ─────────────────────────────────────────────────────────────
# Account Settings refs
@onready var acc_settings_dialog := %AccountSettingsDialog as AcceptDialog
@onready var old_pass_input := %OldPassInput as LineEdit
@onready var new_email_input := %NewEmailInput as LineEdit
@onready var new_pass_input := %NewPassInput as LineEdit
@onready var submit_cred_btn := %SubmitCredBtn as Button
@onready var tz_dropdown := %TzDropdown as OptionButton
@onready var save_tz_btn := %SaveTzBtn as Button
@onready var reset_stats_btn := %ResetStatsBtn as Button
# Left column
@onready var avatar_display := %AvatarDisplay as TextureRect
@onready var display_name_input := %DisplayNameInput as LineEdit
@onready var save_name_btn := %SaveNameBtn as Button
@onready var change_avatar_btn := %ChangeAvatarBtn as Button
@onready var account_type_label := %AccountType as Label
@onready var link_account_btn := %LinkAccountBtn as Button
@onready var gold_label := %GoldLabel as Label
@onready var star_label := %StarLabel as Label
@onready var games_played_label := %GamesPlayed as Label
@onready var win_rate_label := %WinRate as Label
@onready var high_score_label := %HighScore as Label
@onready var acc_settings_btn := %AccSettingsBtn as Button
@onready var admin_panel_btn := %AdminPanelBtn as Button
@onready var logout_btn := %LogoutBtn as Button
@onready var status_label := %StatusLabel as Label
# Loadout refs
@onready var char_left_btn := %CharLeftBtn as Button
@onready var char_right_btn := %CharRightBtn as Button
# Center column (overlays inside CenterWrapper)
@onready var head_tab_btn := %HeadTabBtn as Button
@onready var costume_tab_btn := %CostumeTabBtn as Button
@onready var glove_tab_btn := %GloveTabBtn as Button
@onready var acc_tab_btn := %AccTabBtn as Button
@onready var drag_zone := %DragZone as Control
@onready var character_root := %CharacterRoot as Node3D
@onready var char_left_btn := %CharLeftBtn as Button
@onready var char_right_btn := %CharRightBtn as Button
@onready var loadout_char_name := %LoadoutCharName as Label
@onready var set_default_btn := %SetDefaultBtn as Button
@onready var set_default_btn := %SetDefaultBtn as Button
@onready var anim_player := %AnimationPlayer as AnimationPlayer
# Right column
@onready var item_preview := %ItemPreview as TextureRect
@onready var item_name_label := %ItemNameLabel as Label
@onready var item_rarity_label := %ItemRarityLabel as Label
@onready var item_price_label := %ItemPriceLabel as Label
@onready var equip_btn := %EquipBtn as Button
@onready var dismantle_btn := %DismantleBtn as Button
@onready var item_grid := %ItemGrid as GridContainer
@onready var prev_page_btn := %PrevPageBtn as Button
@onready var page_label := %PageLabel as Label
@onready var next_page_btn := %NextPageBtn as Button
# 3D Preview refs
@onready var character_root := %CharacterRoot as Node3D
@onready var anim_player: AnimationPlayer
# Back button + popups
@onready var back_btn := %BackBtn as Button
@onready var avatar_popup := %AvatarSelectionPopup as PopupPanel
@onready var avatar_grid := %GridContainer as GridContainer
@onready var acc_settings_dialog := %AccountSettingsDialog as AcceptDialog
@onready var old_pass_input := %OldPassInput as LineEdit
@onready var new_email_input := %NewEmailInput as LineEdit
@onready var new_pass_input := %NewPassInput as LineEdit
@onready var submit_cred_btn := %SubmitCredBtn as Button
@onready var tz_dropdown := %TzDropdown as OptionButton
@onready var save_tz_btn := %SaveTzBtn as Button
@onready var reset_stats_btn := %ResetStatsBtn as Button
# -------------------------------------------------------------------------
# State
# -------------------------------------------------------------------------
# New static popups
@onready var dismantle_dialog := %DismantleConfirmDialog as ConfirmationDialog
@onready var link_email_dialog := %LinkEmailDialog as AcceptDialog
@onready var link_email_input := %LinkEmailInput as LineEdit
@onready var link_pass_input := %LinkPassInput as LineEdit
@onready var reset_stats_dialog := %ResetStatsConfirmDialog as ConfirmationDialog
# ─────────────────────────────────────────────────────────────
# Constants
# ─────────────────────────────────────────────────────────────
const CHARACTERS: Array[String] = ["Copper", "Dabro", "Gatot", "Pip"]
# Maps game character name -> GLB node name in the SubViewport
# Must match the mapping in player.gd's set_character()
const CHAR_NODE_MAP: Dictionary = {
"Copper": "Oldpop",
"Dabro": "Masbro",
"Gatot": "Gatot",
"Pip": "Bob"
"Dabro": "Masbro",
"Gatot": "Gatot",
"Pip": "Bob"
}
const RARITY_COLORS: Dictionary = {
"Common": Color(0.80, 0.80, 0.80, 1),
"Rare": Color(0.30, 0.60, 1.00, 1),
"Epic": Color(0.70, 0.30, 1.00, 1),
"Legendary": Color(1.00, 0.70, 0.10, 1)
}
## Client-side item catalog: item_id -> { name, rarity, star_value }
const ITEM_CATALOG: Dictionary = {
"head_hat1": {"name": "Cap", "rarity": "Common", "star_value": 50},
"head_crown": {"name": "Crown", "rarity": "Epic", "star_value": 1500},
"costume_red": {"name": "Red Suit", "rarity": "Rare", "star_value": 200},
"costume_gold": {"name": "Gold Suit", "rarity": "Epic", "star_value": 1000},
"glove_leather": {"name": "Leather Gloves", "rarity": "Common", "star_value": 50},
"acc_glasses": {"name": "Sunglasses", "rarity": "Rare", "star_value": 300},
}
const ITEMS_PER_PAGE: int = 9
var _loadout_index: int = 0 # Index into CHARACTERS
# ─────────────────────────────────────────────────────────────
# State
# ─────────────────────────────────────────────────────────────
var _loadout_index: int = 0
var _default_character: String = "Copper"
var _current_category: String = "head"
var _category_items: Array = []
var _current_page: int = 0
var _selected_item_id: String = ""
var _item_slots: Array[Button] = []
# Drag-to-rotate
var _drag_active: bool = false
var _drag_start_x: float = 0.0
var _model_yaw: float = 0.0
# ─────────────────────────────────────────────────────────────
# Lifecycle
# ─────────────────────────────────────────────────────────────
func _ready() -> void:
_build_slot_refs()
_connect_signals()
_load_profile_data()
_setup_avatar_grid()
_setup_account_settings_ui()
_load_profile_data()
_load_loadout()
_setup_3d_preview()
_on_category_tab_pressed("head")
func _build_slot_refs() -> void:
_item_slots.clear()
for child in item_grid.get_children():
if child is Button:
_item_slots.append(child as Button)
# ─────────────────────────────────────────────────────────────
# Signal wiring
# ─────────────────────────────────────────────────────────────
func _connect_signals() -> void:
back_btn.pressed.connect(_on_close_pressed)
change_avatar_btn.pressed.connect(_on_change_avatar_pressed)
save_name_btn.pressed.connect(_on_save_name_pressed)
link_account_btn.pressed.connect(_on_link_account_pressed)
acc_settings_btn.pressed.connect(_open_account_settings)
admin_panel_btn.pressed.connect(_on_admin_panel_pressed)
logout_btn.pressed.connect(_on_logout_pressed)
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)
UserProfileManager.profile_loaded.connect(func(p): _on_profile_updated())
# Category tabs
head_tab_btn.pressed.connect(func(): _on_category_tab_pressed("head"))
costume_tab_btn.pressed.connect(func(): _on_category_tab_pressed("costume"))
glove_tab_btn.pressed.connect(func(): _on_category_tab_pressed("glove"))
acc_tab_btn.pressed.connect(func(): _on_category_tab_pressed("accessory"))
# Item grid slot buttons
for i in _item_slots.size():
_item_slots[i].pressed.connect(_on_slot_pressed.bind(i))
equip_btn.pressed.connect(_on_equip_pressed)
dismantle_btn.pressed.connect(_on_dismantle_pressed)
dismantle_dialog.confirmed.connect(_on_dismantle_confirmed)
link_email_dialog.confirmed.connect(_on_link_account_confirmed)
reset_stats_dialog.confirmed.connect(_on_reset_stats_confirmed)
prev_page_btn.pressed.connect(_on_prev_page)
next_page_btn.pressed.connect(_on_next_page)
drag_zone.gui_input.connect(_on_drag_input)
if UserProfileManager.profile_loaded.connect(func(_p): _on_profile_updated()) != OK:
push_warning("[ProfilePanel] Could not connect profile_loaded")
UserProfileManager.profile_updated.connect(_on_profile_updated)
UserProfileManager.profile_update_failed.connect(_on_profile_update_failed)
# Dynamically inject Account Settings button
var acc_settings_btn = Button.new()
acc_settings_btn.text = "Account Settings"
acc_settings_btn.custom_minimum_size = Vector2(0, 44)
acc_settings_btn.add_theme_font_override("font", load("res://assets/fonts/Nougat-ExtraBlack.ttf"))
acc_settings_btn.pressed.connect(_open_account_settings)
# Insert it before Logout button
var logout_idx = logout_btn.get_index()
logout_btn.get_parent().add_child(acc_settings_btn)
logout_btn.get_parent().move_child(acc_settings_btn, logout_idx)
# -------------------------------------------------------------------------
# Profile
# -------------------------------------------------------------------------
# ─────────────────────────────────────────────────────────────
# Profile data
# ─────────────────────────────────────────────────────────────
func _load_profile_data() -> void:
var profile := UserProfileManager.profile
var prof := UserProfileManager.profile
var stats := UserProfileManager.stats
display_name_input.text = profile.get("display_name", "Guest")
display_name_input.text = prof.get("display_name", "")
display_name_input.max_length = 6
var avatar_url: String = UserProfileManager.get_avatar_url()
if ResourceLoader.exists(avatar_url):
avatar_display.texture = load(avatar_url)
var url: String = UserProfileManager.get_avatar_url()
if ResourceLoader.exists(url):
avatar_display.texture = load(url) as Texture2D
games_played_label.text = "Games Played: %d" % stats.get("games_played", 0)
win_rate_label.text = "Win Rate: %.1f%%" % UserProfileManager.get_win_rate()
high_score_label.text = "High Score: %d" % stats.get("high_score", 0)
win_rate_label.text = "Win Rate: %.1f%%" % UserProfileManager.get_win_rate()
high_score_label.text = "High Score: %d" % stats.get("high_score", 0)
gold_label.text = str(UserProfileManager.wallet.get("gold", 0))
star_label.text = str(UserProfileManager.wallet.get("star", 0))
if AuthManager.is_guest:
account_type_label.text = "Account: Guest"
account_type_label.text = "Account : Guest"
link_account_btn.visible = true
link_account_btn.text = "Link Email (Keep Progress)"
else:
var mode_name := _get_auth_mode_name(AuthManager.auth_mode)
account_type_label.text = "Account: %s" % mode_name
account_type_label.text = "Account : %s" % _auth_mode_name(AuthManager.auth_mode)
link_account_btn.visible = false
status_label.text = ""
func _get_auth_mode_name(mode: int) -> String:
func _auth_mode_name(mode: int) -> String:
match mode:
AuthManager.AuthMode.EMAIL:
return "Email"
AuthManager.AuthMode.GOOGLE:
return "Google"
AuthManager.AuthMode.APPLE:
return "Apple"
AuthManager.AuthMode.FACEBOOK:
return "Facebook"
_:
return "Guest"
AuthManager.AuthMode.EMAIL: return "Email"
AuthManager.AuthMode.GOOGLE: return "Google"
AuthManager.AuthMode.APPLE: return "Apple"
AuthManager.AuthMode.FACEBOOK: return "Facebook"
_: return "Guest"
# -------------------------------------------------------------------------
# Loadout
# -------------------------------------------------------------------------
# ─────────────────────────────────────────────────────────────
# Category tabs
# ─────────────────────────────────────────────────────────────
func _on_category_tab_pressed(category: String) -> void:
_current_category = category
_current_page = 0
_selected_item_id = ""
_clear_item_info()
_rebuild_category_items()
_populate_item_grid()
_highlight_active_tab()
func _highlight_active_tab() -> void:
var map := {
"head": head_tab_btn,
"costume": costume_tab_btn,
"glove": glove_tab_btn,
"accessory": acc_tab_btn
}
for cat: String in map:
(map[cat] as Button).modulate = Color(1.3, 1.3, 0.4, 1) if cat == _current_category else Color.WHITE
func _rebuild_category_items() -> void:
_category_items.clear()
var prefix := _current_category + "_"
for item_id: String in UserProfileManager.inventory:
if item_id.begins_with(prefix):
_category_items.append(item_id)
# ─────────────────────────────────────────────────────────────
# Item grid
# ─────────────────────────────────────────────────────────────
func _populate_item_grid() -> void:
var start: int = _current_page * ITEMS_PER_PAGE
var total: int = _category_items.size()
var total_pages: int = max(1, ceili(float(total) / float(ITEMS_PER_PAGE)))
page_label.text = "%d / %d" % [_current_page + 1, total_pages]
prev_page_btn.disabled = (_current_page == 0)
next_page_btn.disabled = ((_current_page + 1) >= total_pages)
var equipped: String = UserProfileManager.loadout.get(_current_category, "")
for i in _item_slots.size():
var slot: Button = _item_slots[i]
var idx: int = start + i
if idx < total:
var item_id: String = _category_items[idx]
var info: Dictionary = ITEM_CATALOG.get(item_id, {})
slot.text = info.get("name", item_id)
slot.tooltip_text = item_id
slot.modulate = Color(0.4, 1.0, 0.4, 1) if item_id == equipped else Color.WHITE
else:
slot.text = ""
slot.tooltip_text = ""
slot.modulate = Color.WHITE
func _on_slot_pressed(slot_index: int) -> void:
var idx: int = _current_page * ITEMS_PER_PAGE + slot_index
if idx >= _category_items.size():
return
_selected_item_id = _category_items[idx]
_show_item_info(_selected_item_id)
func _show_item_info(item_id: String) -> void:
var info: Dictionary = ITEM_CATALOG.get(item_id, {})
item_name_label.text = info.get("name", item_id)
var rarity: String = info.get("rarity", "Common")
item_rarity_label.text = rarity
item_rarity_label.add_theme_color_override(
"font_color", RARITY_COLORS.get(rarity, Color.WHITE)
)
var sv: int = info.get("star_value", 0)
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)
dismantle_btn.disabled = false
func _clear_item_info() -> void:
item_name_label.text = "Select an item"
item_rarity_label.text = ""
item_price_label.text = ""
equip_btn.text = "Equip"
equip_btn.disabled = true
dismantle_btn.disabled = true
# ─────────────────────────────────────────────────────────────
# Item actions
# ─────────────────────────────────────────────────────────────
func _on_equip_pressed() -> void:
if _selected_item_id.is_empty(): return
var ok: bool = await UserProfileManager.update_loadout(_current_category, _selected_item_id)
if ok:
_set_status("Equipped: " + _selected_item_id, Color(0.4, 1.0, 0.4))
_populate_item_grid()
_show_item_info(_selected_item_id)
else:
_set_status("Failed to equip item.", Color.RED)
func _on_dismantle_pressed() -> void:
if _selected_item_id.is_empty(): return
dismantle_dialog.dialog_text = "Dismantle '%s'?\nThis will remove it from your inventory." % _selected_item_id
dismantle_dialog.popup_centered()
func _on_dismantle_confirmed() -> void:
UserProfileManager.inventory.erase(_selected_item_id)
if UserProfileManager.loadout.get(_current_category, "") == _selected_item_id:
UserProfileManager.update_loadout(_current_category, "")
_selected_item_id = ""
_clear_item_info()
_rebuild_category_items()
_populate_item_grid()
_set_status("Item dismantled.", Color(1, 0.5, 0.3))
# ─────────────────────────────────────────────────────────────
# Pagination
# ─────────────────────────────────────────────────────────────
func _on_prev_page() -> void:
if _current_page > 0:
_current_page -= 1
_populate_item_grid()
func _on_next_page() -> void:
var total_pages: int = max(1, ceili(float(_category_items.size()) / float(ITEMS_PER_PAGE)))
if _current_page + 1 < total_pages:
_current_page += 1
_populate_item_grid()
# ─────────────────────────────────────────────────────────────
# Character loadout
# ─────────────────────────────────────────────────────────────
func _load_loadout() -> void:
"""Load the saved default character from profile storage."""
var saved = UserProfileManager.profile.get("loadout_character", "Copper")
var idx = CHARACTERS.find(saved)
_loadout_index = max(idx, 0)
var saved: String = UserProfileManager.profile.get("loadout_character", "Copper")
var idx: int = CHARACTERS.find(saved)
_loadout_index = max(idx, 0)
_default_character = CHARACTERS[_loadout_index]
_refresh_loadout_ui()
func _cycle_loadout_char(direction: int) -> void:
_loadout_index = wrapi(_loadout_index + direction, 0, CHARACTERS.size())
_refresh_loadout_ui()
_update_3d_preview(CHARACTERS[_loadout_index])
func _refresh_loadout_ui() -> void:
var char_name := CHARACTERS[_loadout_index]
loadout_char_name.text = char_name
loadout_char_name.text = char_name
var is_default := char_name == _default_character
set_default_btn.text = "✓ DEFAULT" if is_default else "Set as Default"
set_default_btn.text = "✓ DEFAULT" if is_default else "Set as Default"
set_default_btn.disabled = is_default
_update_3d_preview(char_name)
@@ -165,50 +371,42 @@ func _on_set_default_pressed() -> void:
var char_name := CHARACTERS[_loadout_index]
_default_character = char_name
UserProfileManager.profile["loadout_character"] = char_name
# Also apply immediately to LobbyManager
if LobbyManager.available_characters.has(char_name):
LobbyManager.local_character_index = LobbyManager.available_characters.find(char_name)
status_label.text = "Loadout set to: " + char_name
status_label.add_theme_color_override("font_color", Color(0.4, 0.8, 0.4))
_set_status("Default set to: " + char_name, Color(0.4, 0.9, 0.4))
_refresh_loadout_ui()
# Persist to storage
_save_loadout_to_profile()
# Sync to leaderboard immediately
UserProfileManager.submit_to_leaderboard()
# Notify lobby (and any other listeners) to refresh their 3D preview
UserProfileManager.profile_updated.emit()
func _save_loadout_to_profile() -> void:
"""Save loadout_character field to Nakama profile storage."""
if not NakamaManager.session:
return
if not NakamaManager.session: return
var data := {
"avatar_index": UserProfileManager.profile.get("avatar_index", 0),
"bio": UserProfileManager.profile.get("bio", ""),
"country": UserProfileManager.profile.get("country", ""),
"language": UserProfileManager.profile.get("language", "en"),
"loadout_character": _default_character
"avatar_index": UserProfileManager.profile.get("avatar_index", 0),
"bio": UserProfileManager.profile.get("bio", ""),
"country": UserProfileManager.profile.get("country", ""),
"language": UserProfileManager.profile.get("language", "en"),
"loadout_character": _default_character,
"loadout": UserProfileManager.loadout
}
var write_obj := NakamaWriteStorageObject.new(
"profiles", "profile", 2, 1, JSON.stringify(data), ""
)
var write_obj := NakamaWriteStorageObject.new("profiles", "profile", 2, 1, JSON.stringify(data), "")
await NakamaManager.client.write_storage_objects_async(NakamaManager.session, [write_obj])
# -------------------------------------------------------------------------
# ─────────────────────────────────────────────────────────────
# 3D Preview
# -------------------------------------------------------------------------
# ─────────────────────────────────────────────────────────────
func _setup_3d_preview() -> void:
anim_player = character_root.get_node_or_null("AnimationPlayer")
_update_3d_preview(_default_character)
func _update_3d_preview(character_name: String) -> void:
if not character_root:
return
if not character_root: return
var node_name: String = CHAR_NODE_MAP.get(character_name, "Masbro")
for child in character_root.get_children():
if child is Node3D:
child.visible = (child.name == node_name)
# Update AnimationPlayer root
if anim_player:
var new_root := character_root.get_node_or_null(node_name)
var new_root: Node3D = character_root.get_node_or_null(node_name) as Node3D
if new_root:
anim_player.root_node = new_root.get_path()
if anim_player.has_animation("animation-pack/idle"):
@@ -216,149 +414,153 @@ func _update_3d_preview(character_name: String) -> void:
elif anim_player.get_animation_list().size() > 0:
anim_player.play(anim_player.get_animation_list()[0])
# -------------------------------------------------------------------------
# ─────────────────────────────────────────────────────────────
# Drag-to-rotate
# ─────────────────────────────────────────────────────────────
func _on_drag_input(event: InputEvent) -> void:
if event is InputEventMouseButton:
var mb := event as InputEventMouseButton
if mb.button_index == MOUSE_BUTTON_LEFT:
_drag_active = mb.pressed
_drag_start_x = mb.position.x
elif event is InputEventMouseMotion and _drag_active:
var mm := event as InputEventMouseMotion
var delta: float = mm.position.x - _drag_start_x
_drag_start_x = mm.position.x
_model_yaw += delta * 0.8
if character_root:
character_root.rotation_degrees.y = _model_yaw
# ─────────────────────────────────────────────────────────────
# Avatar
# -------------------------------------------------------------------------
# ─────────────────────────────────────────────────────────────
func _setup_avatar_grid() -> void:
for child in avatar_grid.get_children():
child.queue_free()
for i in range(UserProfileManager.AVATARS.size()):
var avatar_path: String = UserProfileManager.AVATARS[i]
var btn := Button.new()
btn.custom_minimum_size = Vector2(64, 64)
if ResourceLoader.exists(avatar_path):
var tex := load(avatar_path) as Texture2D
btn.icon = tex
# Assume .tscn has precisely instantiated Button nodes in AvatarGrid
var children = avatar_grid.get_children()
for i in range(min(children.size(), UserProfileManager.AVATARS.size())):
var btn = children[i] as Button
var path: String = UserProfileManager.AVATARS[i]
if ResourceLoader.exists(path):
btn.icon = load(path) as Texture2D
btn.expand_icon = true
else:
btn.text = str(i + 1)
# Prevent double connection if re-initializing
if btn.pressed.is_connected(_on_avatar_selected):
btn.pressed.disconnect(_on_avatar_selected)
btn.pressed.connect(_on_avatar_selected.bind(i))
avatar_grid.add_child(btn)
func _on_change_avatar_pressed() -> void:
avatar_popup.popup_centered()
func _on_avatar_selected(index: int) -> void:
avatar_popup.hide()
status_label.text = "Saving avatar..."
var success := await UserProfileManager.update_avatar(index)
if success:
var avatar_url: String = UserProfileManager.get_avatar_url()
if ResourceLoader.exists(avatar_url):
avatar_display.texture = load(avatar_url)
status_label.text = "Avatar updated!"
_set_status("Saving avatar...", Color.WHITE)
var ok: bool = await UserProfileManager.update_avatar(index)
if ok:
var url: String = UserProfileManager.get_avatar_url()
if ResourceLoader.exists(url):
avatar_display.texture = load(url) as Texture2D
_set_status("Avatar updated!", Color(0.4, 1.0, 0.4))
else:
status_label.text = "Failed to update avatar"
_set_status("Failed to update avatar.", Color.RED)
# -------------------------------------------------------------------------
# Name
# -------------------------------------------------------------------------
# ─────────────────────────────────────────────────────────────
# Display name
# ─────────────────────────────────────────────────────────────
func _on_save_name_pressed() -> void:
var new_name := display_name_input.text.strip_edges()
if new_name.is_empty():
status_label.text = "Name cannot be empty"
_set_status("Name cannot be empty.", Color.RED)
return
status_label.text = "Saving..."
_set_status("Saving...", Color.WHITE)
save_name_btn.disabled = true
var success := await UserProfileManager.update_display_name(new_name)
var ok: bool = await UserProfileManager.update_display_name(new_name)
save_name_btn.disabled = false
if success:
status_label.add_theme_color_override("font_color", Color.GREEN)
status_label.text = "Name updated!"
if ok:
_set_status("Name updated!", Color(0.4, 1.0, 0.4))
emit_signal("profile_updated")
await get_tree().create_timer(3.0).timeout
status_label.text = ""
else:
_set_status("Failed to update name.", Color.RED)
# -------------------------------------------------------------------------
# ─────────────────────────────────────────────────────────────
# Account
# -------------------------------------------------------------------------
# ─────────────────────────────────────────────────────────────
func _on_link_account_pressed() -> void:
var dialog := AcceptDialog.new()
dialog.title = "Link Email"
dialog.dialog_text = "Enter your email and password to link this guest account.\nYour progress will be preserved!"
var vbox := VBoxContainer.new()
var email_input := LineEdit.new()
email_input.placeholder_text = "Email"
var password_input := LineEdit.new()
password_input.placeholder_text = "Password"
password_input.secret = true
vbox.add_child(email_input)
vbox.add_child(password_input)
dialog.add_child(vbox)
add_child(dialog)
dialog.popup_centered()
dialog.confirmed.connect(func():
var email := email_input.text.strip_edges()
var password := password_input.text
if email.is_empty() or password.is_empty():
status_label.text = "Please fill in all fields"
return
status_label.text = "Linking account..."
var success := await AuthManager.link_email(email, password)
if success:
status_label.text = "Account linked successfully!"
link_account_btn.visible = false
account_type_label.text = "Account: Email"
else:
status_label.text = "Failed to link account"
dialog.queue_free()
link_email_input.text = ""
link_pass_input.text = ""
link_email_dialog.popup_centered()
func _on_link_account_confirmed() -> void:
var e := link_email_input.text.strip_edges()
var p := link_pass_input.text
if e.is_empty() or p.is_empty():
_set_status("Fill in all fields.", Color.RED); return
_set_status("Linking account...", Color.WHITE)
var ok: bool = await AuthManager.link_email(e, p)
_set_status(
"Account linked!" if ok else "Failed to link account.",
Color(0.4, 1.0, 0.4) if ok else Color.RED
)
if ok:
link_account_btn.visible = false
account_type_label.text = "Account: Email"
func _setup_account_settings_ui() -> void:
# Populate Timezone dropdown
tz_dropdown.clear()
for i in range(-12, 15):
var prefix = "+" if i >= 0 else ""
tz_dropdown.add_item("GMT " + prefix + str(i))
# Connect buttons
tz_dropdown.add_item("GMT %s%d" % ["+" if i >= 0 else "", i])
submit_cred_btn.pressed.connect(func():
status_label.text = "Updating credentials..."
var payload = {
_set_status("Updating credentials...", Color.WHITE)
var payload := {
"current_password": old_pass_input.text,
"new_email": new_email_input.text,
"new_password": new_pass_input.text
"new_email": new_email_input.text,
"new_password": new_pass_input.text
}
var result = await NakamaManager.client.rpc_async(NakamaManager.session, "change_credentials", JSON.stringify(payload))
if result.is_exception():
status_label.text = "Failed: " + result.get_exception().message
var r = await NakamaManager.client.rpc_async(
NakamaManager.session, "change_credentials", JSON.stringify(payload)
)
if r.is_exception():
_set_status("Error: " + r.get_exception().message, Color.RED)
else:
status_label.text = "Credentials updated successfully!"
_set_status("Credentials updated!", Color(0.4, 1.0, 0.4))
acc_settings_dialog.hide()
)
save_tz_btn.pressed.connect(func():
var selected_text = tz_dropdown.get_item_text(tz_dropdown.selected)
var res = await NakamaManager.client.update_account_async(NakamaManager.session, null, null, null, null, null, selected_text)
if res.is_exception():
status_label.text = "TZ Failed: " + res.get_exception().message
else:
status_label.text = "Timezone saved!"
)
reset_stats_btn.pressed.connect(func():
var conf = ConfirmationDialog.new()
conf.dialog_text = "Are you SURE you want to irreversibly wipe all your stats to 0?"
acc_settings_dialog.add_child(conf)
conf.popup_centered()
conf.confirmed.connect(func():
var r = await NakamaManager.client.rpc_async(NakamaManager.session, "reset_stats", "{}")
if not r.is_exception():
UserProfileManager.stats = {
"games_played": 0, "games_won": 0, "games_lost": 0, "total_score": 0, "high_score": 0, "play_time_minutes": 0
}
_load_profile_data()
status_label.text = "Stats wiped completely."
conf.queue_free()
acc_settings_dialog.hide()
var sel := tz_dropdown.get_item_text(tz_dropdown.selected)
var r = await NakamaManager.client.update_account_async(
NakamaManager.session, null, null, null, null, null, sel
)
_set_status(
"Timezone saved!" if not r.is_exception() else "TZ error: " + r.get_exception().message,
Color(0.4, 1.0, 0.4) if not r.is_exception() else Color.RED
)
)
reset_stats_btn.pressed.connect(func():
reset_stats_dialog.popup_centered()
)
func _on_reset_stats_confirmed() -> void:
var r = await NakamaManager.client.rpc_async(NakamaManager.session, "reset_stats", "{}")
if not r.is_exception():
UserProfileManager.stats = {
"games_played": 0, "games_won": 0, "games_lost": 0,
"total_score": 0, "high_score": 0, "play_time_minutes": 0
}
_load_profile_data()
_set_status("Stats wiped.", Color(1, 0.5, 0.3))
acc_settings_dialog.hide()
func _open_account_settings() -> void:
old_pass_input.visible = not AuthManager.is_guest
old_pass_input.text = ""
new_email_input.text = ""
new_pass_input.text = ""
old_pass_input.text = ""; new_email_input.text = ""; new_pass_input.text = ""
acc_settings_dialog.popup_centered()
func _on_logout_pressed() -> void:
@@ -366,58 +568,51 @@ func _on_logout_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/ui/login_screen.tscn")
func _on_admin_panel_pressed() -> void:
if has_node("/root/AdminManager"):
get_node("/root/AdminManager").toggle_admin_panel()
else:
AdminManager.toggle_admin_panel()
AdminManager.toggle_admin_panel()
# -------------------------------------------------------------------------
# Close / Show
# -------------------------------------------------------------------------
# ─────────────────────────────────────────────────────────────
# Show / Close
# ─────────────────────────────────────────────────────────────
func _on_close_pressed() -> void:
hide()
emit_signal("closed")
func show_panel() -> void:
if not UserProfileManager.is_profile_loaded:
status_label.text = "Loading profile from server..."
status_label.add_theme_color_override("font_color", Color.YELLOW)
_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()
status_label.text = "Please link an email to save your progress permanently!"
_set_status("Link an email to save progress permanently!", Color.YELLOW)
func _check_admin_visibility() -> void:
admin_panel_btn.hide()
if not NakamaManager.client or not NakamaManager.session:
return
# Use native Nakama account API to check user metadata for admin role
if not NakamaManager.client or not NakamaManager.session: return
var account = await NakamaManager.client.get_account_async(NakamaManager.session)
if account.is_exception():
return
# Check account metadata for role
var metadata_str: String = account.user.metadata if account.user.metadata else "{}"
var metadata = JSON.parse_string(metadata_str)
if metadata and metadata is Dictionary:
var role = metadata.get("role", "")
if role in ["owner", "admin"]:
admin_panel_btn.show()
if account.is_exception(): return
var raw: String = account.user.metadata if account.user.metadata else "{}"
var meta = JSON.parse_string(raw)
if meta is Dictionary and meta.get("role", "") in ["owner", "admin"]:
admin_panel_btn.show()
# ─────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────
func _set_status(msg: String, color: Color = Color.WHITE) -> void:
status_label.add_theme_color_override("font_color", color)
status_label.text = msg
# -------------------------------------------------------------------------
# Signal handlers
# -------------------------------------------------------------------------
func _on_profile_updated() -> void:
_load_profile_data()
_rebuild_category_items()
_populate_item_grid()
func _on_profile_update_failed(error: String) -> void:
status_label.add_theme_color_override("font_color", Color.RED)
status_label.text = error
_set_status(error, Color.RED)
await get_tree().create_timer(3.0).timeout
status_label.text = ""
+91
View File
@@ -0,0 +1,91 @@
extends Control
signal closed
@onready var tab_container = $Panel/VBoxContainer/TabContainer
@onready var head_grid = $Panel/VBoxContainer/TabContainer/Head/GridContainer
@onready var costume_grid = $Panel/VBoxContainer/TabContainer/Costume/GridContainer
@onready var glove_grid = $Panel/VBoxContainer/TabContainer/Glove/GridContainer
@onready var accessory_grid = $Panel/VBoxContainer/TabContainer/Accessory/GridContainer
@onready var status_label = $Panel/VBoxContainer/StatusLabel
@onready var back_btn = $Panel/VBoxContainer/Header/BackBtn
var shop_items = {
"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}
]
}
func _ready():
back_btn.pressed.connect(_on_close)
_build_shop()
func show_panel():
show()
_refresh_shop()
func _build_shop():
_populate_grid(head_grid, shop_items["head"])
_populate_grid(costume_grid, shop_items["costume"])
_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():
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():
# 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):
if UserProfileManager.inventory.has(item.id):
status_label.text = "Already owned: " + item.name
return
var price_gold = item.gold
var price_star = item.star
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 + "..."
# Determine category
var category = ""
if shop_items["head"].has(item): category = "head"
elif shop_items["costume"].has(item): category = "costume"
elif shop_items["glove"].has(item): category = "glove"
elif shop_items["accessory"].has(item): category = "accessory"
var success = await UserProfileManager.purchase_item(item.id, price_gold, price_star, category)
if success:
status_label.text = "Successfully purchased: " + item.name
_refresh_shop()
else:
status_label.text = "Failed to purchase. Backend error."
func _on_close():
hide()
emit_signal("closed")
+1
View File
@@ -0,0 +1 @@
uid://w0ddjofws4ib