extends Control ## Profile panel controller — full-scene loadout screen. ## Left: profile info + default character selector. ## Right: 3D SubViewport character preview. 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 # 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 # Loadout refs @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 # 3D Preview refs @onready var character_root := %CharacterRoot as Node3D @onready var anim_player: AnimationPlayer # ------------------------------------------------------------------------- # State # ------------------------------------------------------------------------- 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" } var _loadout_index: int = 0 # Index into CHARACTERS var _default_character: String = "Copper" func _ready() -> void: _connect_signals() _load_profile_data() _setup_avatar_grid() _setup_account_settings_ui() _load_loadout() _setup_3d_preview() 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) 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()) 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 # ------------------------------------------------------------------------- func _load_profile_data() -> void: var profile := UserProfileManager.profile var stats := UserProfileManager.stats display_name_input.text = profile.get("display_name", "Guest") 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) 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) if AuthManager.is_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 link_account_btn.visible = false status_label.text = "" func _get_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" # ------------------------------------------------------------------------- # 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) _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 var is_default := char_name == _default_character set_default_btn.text = "✓ DEFAULT" if is_default else "Set as Default" set_default_btn.disabled = is_default _update_3d_preview(char_name) 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)) _refresh_loadout_ui() # Persist to storage _save_loadout_to_profile() # Sync to leaderboard immediately UserProfileManager.submit_to_leaderboard() func _save_loadout_to_profile() -> void: """Save loadout_character field to Nakama profile storage.""" 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 } 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 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) if new_root: anim_player.root_node = new_root.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]) # ------------------------------------------------------------------------- # 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 btn.expand_icon = true else: btn.text = str(i + 1) 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!" else: status_label.text = "Failed to update avatar" # ------------------------------------------------------------------------- # 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" return status_label.text = "Saving..." save_name_btn.disabled = true var success := 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!" emit_signal("profile_updated") await get_tree().create_timer(3.0).timeout status_label.text = "" # ------------------------------------------------------------------------- # 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() ) 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 submit_cred_btn.pressed.connect(func(): status_label.text = "Updating credentials..." var payload = { "current_password": old_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 else: status_label.text = "Credentials updated successfully!" 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() ) ) 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 = "" acc_settings_dialog.popup_centered() func _on_logout_pressed() -> void: AuthManager.logout() 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() # ------------------------------------------------------------------------- # Close / Show # ------------------------------------------------------------------------- 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) _load_profile_data() _load_loadout() _check_admin_visibility() show() if AuthManager.is_guest: _on_link_account_pressed() status_label.text = "Please link an email to save your progress permanently!" 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 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() # ------------------------------------------------------------------------- # Signal handlers # ------------------------------------------------------------------------- func _on_profile_updated() -> void: _load_profile_data() func _on_profile_update_failed(error: String) -> void: status_label.add_theme_color_override("font_color", Color.RED) status_label.text = error await get_tree().create_timer(3.0).timeout status_label.text = ""