extends Control ## Main menu with login/register UI wired to the backend auth API. ## ## The AuthPanel is hidden by default. When the user clicks the LoginButton or ## RegisterButton (AuthButtonsRow, above the panel), the auth panel slides in. ## The Login and Register buttons on the AuthButtonsRow directly set the panel's ## mode, so the user can switch between Login and Register at any time while ## the panel is open. ## ## On successful auth the token is stored via ApiClient, AuthButtonsRow hides, ## the welcome label shows the username, and the Play button becomes enabled. ## The auth form fields are hidden, leaving only the welcome message visible. ## ## When returning from another scene (e.g. world_list), _ready() checks whether ## ApiClient already holds a valid token. If so, it restores the authenticated ## UI state (Play enabled, welcome message visible) instead of forcing the user ## back to the login form. ## Emitted once _ready()'s session check (has_token() + the awaited ## get_me() call) has actually resolved, whichever way -- lets a test (or ## anything else) know the UI state is trustworthy now, rather than ## guessing from a frame count or from UI properties that are ambiguous ## mid-await (e.g. AuthButtonsRow.visible is true both before the check ## resolves AND in a genuine logged-out end state). signal auth_state_ready # ─── UI references ──────────────────────────────────────────────────────────── var _auth_buttons_row: HBoxContainer var _login_button: Button var _register_button: Button var _auth_panel: PanelContainer var _auth_mode_label: Label var _username_edit: LineEdit var _password_edit: LineEdit var _email_edit: LineEdit var _over_18_check: CheckBox var _auth_button: Button var _status_label: Label var _welcome_label: Label var _discover_button: Button var _play_button: Button var _customize_button: Button var _social_button: Button var _chat_button: Button var _marketplace_button: Button var _wallet_button: Button var _groups_button: Button var _selling_button: Button var _quit_button: Button var _is_register_mode: bool = false var _is_authenticating: bool = false var _sign_out_button: Button = null # ─── Lifecycle ──────────────────────────────────────────────────────────────── static func _build_label() -> String: var f := FileAccess.open("res://assets/build_info.json", FileAccess.READ) if f == null: return "dev" var d = JSON.parse_string(f.get_as_text()) if d is Dictionary: if d.has("build_label"): var c: String = str(d.get("commit", "")) if not c.is_empty(): return "%s (%s)" % [d.get("build_label"), c] return str(d.get("build_label")) var v: String = str(d.get("version", "")) if not v.is_empty(): return "MVP Build %s (%s)" % [v, d.get("commit", "?")] return "%s %s" % [d.get("checkpoint", "?"), d.get("commit", "?")] return "dev" static func _update_fs_button(btn: Button) -> void: if DisplayServer.get_name() == "headless": btn.text = "⛶ Fullscreen (F11)" return var m := DisplayServer.window_get_mode() if m == DisplayServer.WINDOW_MODE_FULLSCREEN or m == DisplayServer.WINDOW_MODE_EXCLUSIVE_FULLSCREEN: btn.text = "🗗 Windowed (F11)" else: btn.text = "⛶ Fullscreen (F11)" static func _update_sound_button(btn: Button) -> void: if btn == null or not is_instance_valid(btn): return var main_loop = Engine.get_main_loop() var audio_mgr: Node = main_loop.root.get_node_or_null("AudioManager") if main_loop is SceneTree else null var enabled: bool = audio_mgr.is_sound_enabled() if audio_mgr and audio_mgr.has_method("is_sound_enabled") else true if enabled: btn.text = "🔊 Sound: ON" else: btn.text = "🔇 Sound: OFF" func _ready() -> void: var v := Label.new() var bl := _build_label() v.text = bl if bl.begins_with("MVP Build") or bl == "dev" else ("build %s" % bl) v.add_theme_color_override("font_color", Color(1, 1, 1, 0.7)) v.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.85)) v.add_theme_constant_override("outline_size", 4) v.add_theme_font_size_override("font_size", 12) # Full-width strip pinned to the bottom, text right-aligned. A bare # BOTTOM_RIGHT preset + .position on a zero-size Label collapses the rect # and nothing renders -- which is why this never showed up. v.set_anchors_preset(Control.PRESET_BOTTOM_WIDE) v.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT v.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM v.offset_left = 8 v.offset_right = -10 v.offset_top = -28 v.offset_bottom = -6 v.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(v) var fs_btn := Button.new() fs_btn.set_anchors_preset(Control.PRESET_TOP_RIGHT) fs_btn.offset_left = -160 fs_btn.offset_top = 10 fs_btn.offset_right = -10 fs_btn.offset_bottom = 38 fs_btn.add_theme_font_size_override("font_size", 12) _update_fs_button(fs_btn) fs_btn.pressed.connect(func(): Session.toggle_fullscreen() _update_fs_button(fs_btn) ) add_child(fs_btn) var ctrl_btn := Button.new() ctrl_btn.name = "ControlsButton" ctrl_btn.set_anchors_preset(Control.PRESET_TOP_RIGHT) ctrl_btn.offset_left = -300 ctrl_btn.offset_top = 10 ctrl_btn.offset_right = -170 ctrl_btn.offset_bottom = 38 ctrl_btn.text = "⌨️ Controls" ctrl_btn.add_theme_font_size_override("font_size", 12) ctrl_btn.pressed.connect(_on_controls_button_pressed) add_child(ctrl_btn) var sound_btn := Button.new() sound_btn.name = "SoundButton" sound_btn.set_anchors_preset(Control.PRESET_TOP_RIGHT) sound_btn.offset_left = -440 sound_btn.offset_top = 10 sound_btn.offset_right = -310 sound_btn.offset_bottom = 38 sound_btn.add_theme_font_size_override("font_size", 12) _update_sound_button(sound_btn) sound_btn.pressed.connect(func(): var audio_mgr = get_node_or_null("/root/AudioManager") if audio_mgr and audio_mgr.has_method("toggle_sound"): audio_mgr.toggle_sound() _update_sound_button(sound_btn) ) var audio_singleton = get_node_or_null("/root/AudioManager") if audio_singleton and audio_singleton.has_signal("sound_toggled"): var on_toggled = func(_enabled: bool): if is_instance_valid(sound_btn): _update_sound_button(sound_btn) audio_singleton.sound_toggled.connect(on_toggled) sound_btn.tree_exiting.connect(func(): if is_instance_valid(audio_singleton) and audio_singleton.sound_toggled.is_connected(on_toggled): audio_singleton.sound_toggled.disconnect(on_toggled) ) add_child(sound_btn) # Cache node references. _auth_buttons_row = $CenterContainer/VBoxContainer/AuthButtonsRow _login_button = $CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton _register_button = $CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton _auth_panel = $CenterContainer/VBoxContainer/AuthPanel _auth_mode_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthModeLabel _username_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit _password_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit _email_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit if _email_edit and not _email_edit.text_submitted.is_connected(_on_auth_button_pressed): _email_edit.text_submitted.connect(_on_auth_button_pressed) _over_18_check = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/Over18Check _auth_button = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton _status_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthStatusLabel _welcome_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel _discover_button = $CenterContainer/VBoxContainer.get_node_or_null("DiscoverButton") _play_button = $CenterContainer/VBoxContainer/PlayButton _customize_button = $CenterContainer/VBoxContainer/CustomizeButton _social_button = $CenterContainer/VBoxContainer.get_node_or_null("SocialButton") _chat_button = $CenterContainer/VBoxContainer.get_node_or_null("ChatButton") _marketplace_button = $CenterContainer/VBoxContainer.get_node_or_null("MarketplaceButton") _wallet_button = $CenterContainer/VBoxContainer.get_node_or_null("WalletButton") _groups_button = $CenterContainer/VBoxContainer.get_node_or_null("GroupsButton") _selling_button = $CenterContainer/VBoxContainer.get_node_or_null("SellingButton") _quit_button = $CenterContainer/VBoxContainer/QuitButton # Default to navigation being gated. When a token exists, the async session # check below may take several frames (a real get_me() round-trip); # keeping the button disabled until validation succeeds prevents a fast # click from navigating to world_list with an expired/invalid token. _set_nav_enabled(false) # Check whether a valid session already exists (e.g. user logged in, # navigated to world_list, then clicked Back). If so, restore the # authenticated UI state instead of forcing the login form again. if ApiClient.has_token(): var me_result: Dictionary = await ApiClient.get_me() if me_result.ok and me_result.data is Dictionary: var username: String = str((me_result.data as Dictionary).get("username", "")) if username != "": _show_authenticated(username) await _prime_avatar() auth_state_ready.emit() return # Token missing or invalid — fall through to logged-out state. _reset_to_logged_out() auth_state_ready.emit() func _set_nav_enabled(enabled: bool) -> void: var buttons = [ _discover_button, _play_button, _customize_button, _social_button, _chat_button, _marketplace_button, _wallet_button, _groups_button, _selling_button ] for b in buttons: if b != null and is_instance_valid(b): b.disabled = not enabled ## Restore the UI to the authenticated state (Play enabled, welcome message ## visible, auth buttons hidden). AuthPanel itself stays visible -- the ## welcome label lives inside it, so hiding the panel hides the label too ## even when the label's own `visible` is true (real bug, 2026-08-20: a ## review caught the panel being hidden here while the label claimed to be ## visible, which is a no-op UI-wise since Control.visible doesn't override ## an ancestor's hidden state). func _show_authenticated(username: String) -> void: _auth_panel.visible = true _status_label.visible = false _welcome_label.text = "Welcome, %s!" % username _welcome_label.visible = true # Hide the same full set of form fields _on_auth_complete() hides right # after a fresh login/register -- this path (session restore on scene # reload, e.g. after Play -> Back) used to only hide EmailEdit/ # Over18Check, leaving UsernameEdit/PasswordEdit/AuthButton/ # AuthModeLabel visible on top of the welcome message. Real bug: Play # was enabled and the welcome label was correct, so it wasn't caught by # checks for those alone -- the login fields being back on screen is # what a user actually sees as "it's making me log in again." _username_edit.visible = false _password_edit.visible = false _email_edit.visible = false _over_18_check.visible = false _auth_button.visible = false _auth_mode_label.visible = false _set_nav_enabled(true) _auth_buttons_row.visible = false if _sign_out_button == null or not is_instance_valid(_sign_out_button): _sign_out_button = Button.new() _sign_out_button.name = "SignOutButton" _sign_out_button.text = "Sign Out" _sign_out_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER _sign_out_button.add_theme_font_size_override("font_size", 12) _sign_out_button.pressed.connect(_on_sign_out_pressed) $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox.add_child(_sign_out_button) _sign_out_button.visible = true print("Restored authenticated session for: ", username) ## Reset the UI to the logged-out state (Play disabled, auth panel hidden, ## login/register buttons visible). func _reset_to_logged_out() -> void: _auth_panel.visible = false _status_label.visible = false _welcome_label.visible = false if _sign_out_button != null and is_instance_valid(_sign_out_button): _sign_out_button.visible = false _email_edit.visible = false _over_18_check.visible = false _over_18_check.button_pressed = true _set_nav_enabled(false) _update_auth_mode_ui() func _on_sign_out_pressed() -> void: ApiClient.clear_token() _reset_to_logged_out() _auth_buttons_row.visible = true _clear_status() # ─── Navigation ─────────────────────────────────────────────────────────────── func _on_discover_button_pressed() -> void: if _discover_button != null and _discover_button.disabled: return get_tree().change_scene_to_file("res://scenes/discovery_ui.tscn") func _on_play_button_pressed() -> void: if _play_button.disabled: return get_tree().change_scene_to_file("res://scenes/world_list.tscn") func _on_customize_button_pressed() -> void: if _customize_button.disabled: return get_tree().change_scene_to_file("res://scenes/avatar_customization.tscn") func _on_social_button_pressed() -> void: if _social_button != null and _social_button.disabled: return get_tree().change_scene_to_file("res://scenes/friends_presence_ui.tscn") func _on_chat_button_pressed() -> void: if _chat_button != null and _chat_button.disabled: return get_tree().change_scene_to_file("res://scenes/chat_ui.tscn") func _on_marketplace_button_pressed() -> void: if _marketplace_button != null and _marketplace_button.disabled: return get_tree().change_scene_to_file("res://scenes/marketplace_ui.tscn") func _on_wallet_button_pressed() -> void: if _wallet_button != null and _wallet_button.disabled: return get_tree().change_scene_to_file("res://scenes/wallet_ui.tscn") func _on_groups_button_pressed() -> void: if _groups_button != null and _groups_button.disabled: return get_tree().change_scene_to_file("res://scenes/groups_ui.tscn") func _on_selling_button_pressed() -> void: if _selling_button != null and _selling_button.disabled: return get_tree().change_scene_to_file("res://scenes/selling_payout_ui.tscn") func _on_quit_button_pressed() -> void: get_tree().quit() # ─── Auth panel toggle ──────────────────────────────────────────────────────── func _on_login_button_pressed() -> void: """Show the auth panel in login mode, called from AuthButtonsRow/LoginButton.""" _auth_panel.visible = true _is_register_mode = false _update_auth_mode_ui() _clear_status() _username_edit.grab_focus() func _on_register_button_pressed() -> void: """Show the auth panel in register mode, called from AuthButtonsRow/RegisterButton.""" _auth_panel.visible = true _is_register_mode = true _update_auth_mode_ui() _clear_status() _username_edit.grab_focus() func _update_auth_mode_ui() -> void: """Update labels and field visibility based on login vs register mode.""" if _is_register_mode: _auth_mode_label.text = "Register" _auth_button.text = "Register" _email_edit.visible = true _over_18_check.visible = true else: _auth_mode_label.text = "Login" _auth_button.text = "Login" _email_edit.visible = false _over_18_check.visible = false # ─── Auth submission ────────────────────────────────────────────────────────── func _on_auth_button_pressed(_text: String = "") -> void: """Handle login or register button press.""" if _is_authenticating: return # Validate inputs. var username := _username_edit.text.strip_edges() var password := _password_edit.text if username == "": _show_status("Username is required.") return if password == "": _show_status("Password is required.") return if _is_register_mode: var email := _email_edit.text.strip_edges() if email == "": _show_status("Email is required.") return if not _over_18_check.button_pressed: _show_status("You must confirm you are 18 or older to register.") return _auth_button.disabled = true _auth_button.text = "Registering..." _is_authenticating = true _clear_status() var result := await ApiClient.register(username, email, password, true) await _on_auth_complete(result) else: _auth_button.disabled = true _auth_button.text = "Logging in..." _is_authenticating = true _clear_status() var result := await ApiClient.login(username, password) await _on_auth_complete(result) func _on_auth_complete(result: Dictionary) -> void: """Handle the result of a login or register call.""" _auth_button.disabled = false _auth_button.text = "Register" if _is_register_mode else "Login" _is_authenticating = false if result.ok: # Update the UI to the authenticated state first, synchronously, so # the player sees "Welcome" + an enabled Play immediately -- then # prime the avatar (a catalog load + a loadout fetch) in the # background. A slow or failing prime must never gate getting in. _username_edit.visible = false _password_edit.visible = false _email_edit.visible = false _over_18_check.visible = false _auth_button.visible = false _auth_mode_label.visible = false _status_label.visible = false _welcome_label.text = "Welcome, %s!" % _username_edit.text.strip_edges() _welcome_label.visible = true _set_nav_enabled(true) _auth_buttons_row.visible = false print("Auth successful. Token stored.") await _prime_avatar() else: # Failure — show backend error detail. _show_status(result.detail) # ─── Avatar priming ─────────────────────────────────────────────────────────── ## After a successful auth, load the asset catalog and the player's saved ## loadout so the in-world player renders as their avatar. Best-effort: a ## failure here just means the player falls back to the default capsule, it ## never blocks getting into the game. func _prime_avatar() -> void: if not Engine.has_singleton("AvatarSystem") and get_node_or_null("/root/AvatarSystem") == null: return var avatars = get_node_or_null("/root/AvatarSystem") if avatars == null: return if not await avatars.ensure_catalog(): push_warning("main_menu: avatar catalog failed to load; using fallback look") return var res: Dictionary = await ApiClient.get_avatar_loadout() if res.ok and res.data is Dictionary and (res.data as Dictionary).has("data"): avatars.set_loadout_from_dict((res.data as Dictionary)["data"]) elif FileAccess.file_exists("user://loadout.json"): var f := FileAccess.open("user://loadout.json", FileAccess.READ) if f != null: var txt := f.get_as_text() f.close() var dict: Variant = JSON.parse_string(txt) if dict is Dictionary: avatars.set_loadout_from_dict(dict as Dictionary) elif not avatars.has_loadout(): # No saved loadout reachable -- fall back to the catalog's first base body. var bases: Array = avatars.get_assets_for_slot("base_body") if bases.size() > 0: avatars.set_loadout_from_dict({"base_body": bases[0], "slots": {}}) # ─── Status helpers ─────────────────────────────────────────────────────────── func _show_status(message: String) -> void: _status_label.text = message _status_label.visible = true func _clear_status() -> void: _status_label.text = "" _status_label.visible = false func _unhandled_input(event: InputEvent) -> void: if event.is_action_pressed("ui_cancel"): if has_node("ControlsModal"): get_node("ControlsModal").queue_free() get_viewport().set_input_as_handled() return if _auth_panel != null and _auth_panel.visible and not ApiClient.has_token(): _auth_panel.visible = false _auth_buttons_row.visible = true _clear_status() get_viewport().set_input_as_handled() func _on_controls_button_pressed() -> void: if has_node("ControlsModal"): return var modal := preload("res://scripts/controls_modal.gd").new() add_child(modal)