PC3 Phase 1.4-1.6: avatars wired into the game end to end

- api_client.gd: get_avatar_loadout() / save_avatar_loadout()
- main_menu.gd: on successful auth (fresh or session-restore), load the
  catalog + fetch the saved loadout into AvatarSystem so the in-world
  player renders as the player's avatar. Best-effort: a slow/failed prime
  never gates getting in (UI goes authenticated first, prime runs after).
  New "Customize Avatar" button (enabled only when signed in).
- avatar_system.gd: local-player convenience API -- ensure_catalog(),
  set_loadout_from_dict() (from the backend's GET shape), has_loadout(),
  build_current_avatar().
- player.gd: swaps the placeholder capsule for the assembled avatar
  (feet-aligned to the capsule bottom) when a loadout is primed; silent
  fallback to the capsule otherwise. Camera stays first-person for now
  (third-person is a noted follow-up).
- avatar_visualizer.gd: rewrote it -- the old version referenced
  AvatarLoader.SlotResult / load_slot() which never existed, so it didn't
  compile and took avatar_customization_ui.gd down with it. Now a thin
  wrapper over AvatarLoader for the preview.
- avatar_customization_ui.gd: seeds from the live loadout, saves through to
  AvatarSystem + PUT /api/avatar/loadout (local file kept as fallback),
  fixed a Camera3D.look_at() before-in-tree crash.
- scenes/avatar_customization.tscn rebuilt (old one used the Godot-3
  `ViewportContainer` node type) + avatar_customization_screen.gd wrapper
  that returns to the menu on close.
- smoke_test_avatar_in_world.gd: primed loadout -> game.tscn -> player
  shows the assembled avatar, capsule hidden, rig + bone mounts present.

Verified: cold-cache smokes green (player 15/15, avatar_system 14/14,
avatar_in_world 10/10, main_menu_ui 13/13); against a live backend
main_menu_full_flow 12/12, main_menu_back 20/20, world_list 4/4,
enter_world 9/9, e2e 4/4; plus a manual register->default-loadout->
customize->save->re-fetch-persisted round trip.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
This commit is contained in:
2026-09-05 21:50:41 +00:00
parent af85048deb
commit e59cf158e5
10 changed files with 333 additions and 390 deletions
+45 -3
View File
@@ -40,6 +40,7 @@ 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
@@ -63,6 +64,7 @@ func _ready() -> void:
_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
@@ -70,6 +72,7 @@ func _ready() -> void:
# 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
@@ -80,6 +83,7 @@ func _ready() -> void:
var username: String = str((me_result.data as Dictionary).get("username", ""))
if username != "":
_show_authenticated(username)
await _prime_avatar()
auth_state_ready.emit()
return
@@ -115,6 +119,7 @@ func _show_authenticated(username: String) -> void:
_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)
@@ -129,6 +134,7 @@ func _reset_to_logged_out() -> void:
_over_18_check.visible = false
_over_18_check.button_pressed = false
_play_button.disabled = true # Gate play behind successful auth.
_customize_button.disabled = true
_update_auth_mode_ui()
@@ -142,6 +148,12 @@ func _on_play_button_pressed() -> void:
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()
@@ -214,7 +226,7 @@ func _on_auth_button_pressed() -> void:
_clear_status()
var result := await ApiClient.register(username, email, password, true)
_on_auth_complete(result)
await _on_auth_complete(result)
else:
_auth_button.disabled = true
_auth_button.text = "Logging in..."
@@ -222,7 +234,7 @@ func _on_auth_button_pressed() -> void:
_clear_status()
var result := await ApiClient.login(username, password)
_on_auth_complete(result)
await _on_auth_complete(result)
func _on_auth_complete(result: Dictionary) -> void:
@@ -232,7 +244,10 @@ func _on_auth_complete(result: Dictionary) -> void:
_is_authenticating = false
if result.ok:
# Success — hide the auth form fields, show only the welcome label.
# 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
@@ -243,13 +258,40 @@ func _on_auth_complete(result: Dictionary) -> void:
_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: