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 # 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() _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_updated.connect(_on_profile_updated) UserProfileManager.profile_update_failed.connect(_on_profile_update_failed) # ------------------------------------------------------------------------- # 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 = 14 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() 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 _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: _load_profile_data() _load_loadout() _check_admin_visibility() show() 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 = ""