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 _dob_edit: LineEdit var _auth_button: Button var _status_label: Label var _welcome_label: Label var _play_button: Button var _customize_button: Button var _quit_button: Button var _is_register_mode: bool = false var _is_authenticating: bool = false # ─── 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: return "%s %s" % [d.get("checkpoint", "?"), d.get("commit", "?")] return "dev" func _ready() -> void: var v := Label.new() v.text = "build %s" % _build_label() 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) # 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 _dob_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DobEdit _auth_button = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton _status_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthStatusLabel _welcome_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel _play_button = $CenterContainer/VBoxContainer/PlayButton _customize_button = $CenterContainer/VBoxContainer/CustomizeButton _quit_button = $CenterContainer/VBoxContainer/QuitButton # Default to Play 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. _play_button.disabled = true _customize_button.disabled = true # 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() ## 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 _dob_edit.visible = false _auth_button.visible = false _auth_mode_label.visible = false _play_button.disabled = false _customize_button.disabled = false _auth_buttons_row.visible = false 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 _email_edit.visible = false _dob_edit.visible = false _dob_edit.text = "" _play_button.disabled = true # Gate play behind successful auth. _customize_button.disabled = true _update_auth_mode_ui() # ─── Play / Quit ────────────────────────────────────────────────────────────── func _on_play_button_pressed() -> void: if _play_button.disabled: return # Browse worlds is the first real screen past login -- actual gameplay # (joining a world) doesn't exist yet, browsing does. 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_quit_button_pressed() -> void: get_tree().quit() ## Loose client-side check -- YYYY-MM-DD, a real calendar date, not in the ## future. The backend does the authoritative age computation. func _is_valid_dob(dob: String) -> bool: var m := RegEx.new() m.compile("^\\d{4}-\\d{2}-\\d{2}$") if m.search(dob) == null: return false var parts := dob.split("-") var d := {"year": int(parts[0]), "month": int(parts[1]), "day": int(parts[2]), "hour": 0, "minute": 0, "second": 0} var unix := Time.get_unix_time_from_datetime_dict(d) return unix > 0 and unix < Time.get_unix_time_from_system() # ─── 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 _dob_edit.visible = true else: _auth_mode_label.text = "Login" _auth_button.text = "Login" _email_edit.visible = false _dob_edit.visible = false # ─── Auth submission ────────────────────────────────────────────────────────── func _on_auth_button_pressed() -> 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 var dob := _dob_edit.text.strip_edges() if not _is_valid_dob(dob): _show_status("Enter your date of birth as YYYY-MM-DD.") return _auth_button.disabled = true _auth_button.text = "Registering..." _is_authenticating = true _clear_status() var result := await ApiClient.register(username, email, password, dob) 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 _dob_edit.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 _play_button.disabled = false _customize_button.disabled = false _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"]) else: # 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