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
+16
View File
@@ -221,6 +221,22 @@ func get_me() -> Dictionary:
return await request(HTTPClient.METHOD_GET, "/api/users/me", "", "application/json", true)
## Fetch the current user's saved avatar loadout (requires auth).
## On success, result.data is {"data": <loadout dict>, "is_default": bool} --
## `is_default` true means the user has never customised and this is the
## server default.
func get_avatar_loadout() -> Dictionary:
return await request(HTTPClient.METHOD_GET, "/api/avatar/loadout", "", "application/json", true)
## Save (replace) the current user's avatar loadout (requires auth).
## @param loadout A dict in the AvatarLoadout.to_json() shape
## ({"base_body": String, "slots": {...}, "skin_tone"?: {...}}).
func save_avatar_loadout(loadout: Dictionary) -> Dictionary:
var body := JSON.stringify({"data": loadout})
return await request(HTTPClient.METHOD_PUT, "/api/avatar/loadout", body, "application/json", true)
# ---------------------------------------------------------------------------
# Internals
# ---------------------------------------------------------------------------
+25 -7
View File
@@ -106,9 +106,14 @@ func _ready() -> void:
# Build the UI.
_build_ui()
# Create a fresh loadout.
loadout = AvatarLoadout.new()
loadout.base_body = default_base_body
# Seed from the player's live loadout (set at login) if there is one, so
# the screen opens showing what they're actually wearing; otherwise start
# from a bare default.
if _system != null and _system.current_loadout != null:
loadout = AvatarLoadout.from_json(_system.current_loadout.to_json())
if loadout == null or not loadout.validate():
loadout = AvatarLoadout.new()
loadout.base_body = default_base_body
# Initialize the visualizer.
_visualizer = AvatarVisualizer.new()
@@ -165,8 +170,9 @@ func _build_ui() -> void:
# Camera for the preview.
_preview_camera = Camera3D.new()
_preview_camera.name = "PreviewCamera"
_preview_camera.position = Vector3(0, 1.0, 3.0)
_preview_camera.look_at(Vector3(0, 1.0, 0))
# look_at needs a global transform, which only exists once in the tree.
_preview_camera.look_at_from_position(
Vector3(0, 1.0, 3.0), Vector3(0, 1.0, 0), Vector3.UP)
_preview_viewport.add_child(_preview_camera)
# Avatar root inside the viewport.
@@ -429,14 +435,26 @@ func _on_skin_tone_changed(value: float) -> void:
func _on_save_pressed() -> void:
if loadout == null:
return
# Local cache (offline fallback / fast reopen).
var json := loadout.to_json()
var file := FileAccess.open("user://loadout.json", FileAccess.WRITE)
if file:
file.store_string(json)
file.close()
_status_label.text = "Loadout saved."
# Apply to the live in-world avatar immediately.
if _system != null:
_system.set_loadout_from_dict(JSON.parse_string(json))
# Persist to the backend if we're signed in.
var api := get_node_or_null("/root/ApiClient")
if api != null and api.has_token():
_status_label.text = "Saving…"
var res: Dictionary = await api.save_avatar_loadout(JSON.parse_string(json))
_status_label.text = "Saved." if res.get("ok", false) else "Saved locally (backend: %s)" % res.get("detail", "error")
else:
_status_label.text = "Failed to save loadout."
_status_label.text = "Saved locally (not signed in)."
func _on_load_pressed() -> void:
+43
View File
@@ -230,3 +230,46 @@ func get_assets_for_slot(slot_id: String) -> Array[String]:
func get_asset_display_name(asset_id: String) -> String:
var asset := catalog.resolve(asset_id)
return asset.get("display_name", asset_id)
# ---------------------------------------------------------------------------
# Local-player convenience API
#
# The rest of the game (the main menu, the in-world player) only needs three
# things: load the catalog once, hand us the player's loadout (as the dict the
# backend returns), and ask for an assembled avatar node to drop into a scene.
# ---------------------------------------------------------------------------
## True once a loadout has been handed to us (from the backend or a default).
func has_loadout() -> bool:
return current_loadout != null
## Ensure the catalog is loaded (idempotent). Safe to await repeatedly.
func ensure_catalog() -> bool:
if is_catalog_loaded:
return true
return load_catalog() == OK
## Set the current player's loadout from the dict shape the backend's
## GET /api/avatar/loadout returns (i.e. AvatarLoadout.to_json() parsed).
## Returns true if it validated and was stored.
func set_loadout_from_dict(loadout_dict: Dictionary) -> bool:
var lo := AvatarLoadout.from_json(JSON.stringify(loadout_dict))
if not lo.validate():
push_warning("AvatarSystem: rejecting invalid loadout dict: %s"
% str(lo.validation_errors))
return false
current_loadout = lo
loadout_changed.emit(current_loadout)
return true
## Assemble a fresh avatar Node3D from the current loadout, ready to parent
## into a scene (e.g. onto the in-world player). Returns null if there's no
## loadout or the catalog isn't loaded or assembly failed.
func build_current_avatar() -> Node3D:
if current_loadout == null or not is_catalog_loaded:
return null
return load_avatar(current_loadout)
+66 -241
View File
@@ -1,24 +1,15 @@
## AvatarVisualizer
##
## Builds the render tree from an AvatarLoadout.
##
## Responsibilities:
## - Create a root Node3D (or Node2D for 2D mode) as the avatar container.
## - For each slot in the loadout, call AvatarLoader.load_slot() and attach
## the resulting Skeleton3D / MeshInstance3D tree to the root.
## - Apply skin-tone overrides to all MeshInstance3D nodes.
## - Apply a single Skeleton3D root (the "body" skeleton) and retarget all
## limb skeletons to it using the bone-map from AvatarSlotRegistry.
## - Expose `get_root()` for the caller to attach to the scene tree.
## - Expose `clear()` to destroy the tree and free resources.
## Renders an AvatarLoadout into a 3D node tree for previews (the customization
## screen). A thin wrapper over AvatarLoader -- it owns its own loader instance
## and catalog reference so building a preview never disturbs AvatarSystem's
## live in-world avatar state.
##
## Usage:
## var viz = AvatarVisualizer.new()
## viz.load_loadout(loadout)
## avatar_node.add_child(viz.get_root())
##
## The visualizer owns the Skeleton3D and MeshInstance3D trees it creates.
## The caller owns the root node's placement in the scene tree.
## var viz := AvatarVisualizer.new()
## viz.load_loadout(loadout, preview_root_node3d) # preview_root gets the avatar as a child
## ...
## viz.clear()
class_name AvatarVisualizer
extends Object
@@ -26,265 +17,99 @@ extends Object
# Preloaded rather than referenced by global class_name -- see avatar_loadout.gd.
const AvatarLoadout := preload("res://scripts/avatar/avatar_loadout.gd")
const AvatarLoader := preload("res://scripts/avatar/avatar_loader.gd")
const AvatarCatalog := preload("res://scripts/avatar/avatar_catalog.gd")
const AvatarSlotRegistry := preload("res://scripts/avatar/avatar_slot_registry.gd")
## Emitted when the visualizer has finished building the render tree.
signal build_complete
## Emitted when the visualizer has been cleared.
signal cleared
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
## The root node that holds the avatar render tree.
## Set this to the desired parent before calling load_loadout().
## The node the assembled avatar is parented under.
var root: Node3D = null
## The Skeleton3D that serves as the body skeleton.
## All limb skeletons are retargeted to this skeleton's bone hierarchy.
var body_skeleton: Skeleton3D = null
## The assembled avatar node (child of `root`).
var avatar_node: Node3D = null
## The AvatarLoadout currently loaded (null if none).
## The loadout currently rendered.
var current_loadout: AvatarLoadout = null
## Whether the visualizer is currently building (true during load_loadout).
var is_building: bool = false
var _loader = null
var _catalog = null
# ---------------------------------------------------------------------------
# Core
# ---------------------------------------------------------------------------
## Load an AvatarLoadout and build the render tree.
##
## If [param root] is null, a new Node3D is created and assigned.
## If [param loadout] is null or invalid, the current tree is cleared.
func load_loadout(loadout: AvatarLoadout, root_node: Node3D = null) -> void:
# Clear any previous tree.
clear()
if loadout == null or not loadout.is_valid():
build_complete.emit()
return
current_loadout = loadout
is_building = true
# Create root if not provided.
## Build the avatar for [param loadout] and parent it under [param root_node]
## (or the previously-set `root`). Replaces any previously-built avatar.
func load_loadout(loadout, root_node: Node3D = null) -> void:
if root_node != null:
root = root_node
else:
root = Node3D.new()
root.name = "AvatarVisualizer"
if root == null or loadout == null:
return
# Create the body skeleton.
body_skeleton = Skeleton3D.new()
body_skeleton.name = "AvatarBody"
root.add_child(body_skeleton)
clear()
current_loadout = loadout
# Build each slot's contribution.
for slot_def in AvatarSlotRegistry.get_slot_definitions():
var slot_key: String = slot_def.slot_key
var slot_item: AvatarLoadout.SlotItem = loadout.slots.get(slot_key)
if _catalog == null:
var sys = root.get_node_or_null("/root/AvatarSystem")
if sys != null:
_catalog = sys.catalog
if _catalog == null:
_catalog = AvatarCatalog.new()
_catalog.load_catalog("res://assets/avatars/catalog.json")
if slot_item == null or slot_item.asset_id == "":
# Slot is empty — create a placeholder.
_add_placeholder_slot(slot_def)
continue
_loader = AvatarLoader.new()
root.add_child(_loader)
_loader.init_loader(_catalog)
# Load the slot's asset.
var slot_result: AvatarLoader.SlotResult = await AvatarLoader.load_slot(
slot_item,
slot_def,
loadout.skin_tone,
)
var base: Node3D = _loader.load_base_body(loadout.base_body)
if base == null:
return
root.add_child(base)
avatar_node = base
if slot_result == null:
push_warning("AvatarVisualizer: failed to load slot '%s' (%s)" % [slot_key, slot_item.asset_id])
_add_placeholder_slot(slot_def)
continue
for slot_id in AvatarSlotRegistry.SLOT_IDS:
var value = loadout.slots.get(slot_id)
if slot_id == "accessory":
if value is Array:
for aid in value:
if aid is String and not (aid as String).is_empty():
_loader.load_attachment(slot_id, aid)
elif value is String and not (value as String).is_empty():
_loader.load_attachment(slot_id, value)
# Attach the loaded tree to the root.
_attach_slot_tree(slot_result, slot_def)
for slot_id in AvatarSlotRegistry.BODY_PART_SLOTS:
var value = loadout.slots.get(slot_id)
if value is String and not (value as String).is_empty():
_loader.set_base_mesh_visible(slot_id, false)
# Retarget limb skeletons to the body skeleton.
_retarget_limbs()
# Apply skin-tone override to all meshes.
_apply_skin_tone(loadout.skin_tone)
is_building = false
build_complete.emit()
## Clear the render tree and free resources.
## Tear down the current preview avatar.
func clear() -> void:
if root != null:
root.queue_free()
root = null
body_skeleton = null
current_loadout = null
cleared.emit()
if avatar_node != null and is_instance_valid(avatar_node):
avatar_node.queue_free()
avatar_node = null
if _loader != null and is_instance_valid(_loader):
_loader.queue_free()
_loader = null
current_loadout = null
cleared.emit()
## Get the root node.
func get_root() -> Node3D:
return root
## Get a specific slot's Skeleton3D (if it has one).
func get_slot_skeleton(slot_key: String) -> Skeleton3D:
if body_skeleton == null:
return null
var bones := body_skeleton.get_bone_count()
for i in range(bones):
var bone_name: String = body_skeleton.get_bone_name(i)
if bone_name == slot_key:
return body_skeleton
return null
## Get all MeshInstance3D nodes in the render tree.
func get_all_mesh_instances() -> Array[MeshInstance3D]:
var meshes: Array[MeshInstance3D] = []
if root == null:
return meshes
_collect_meshes(root, meshes)
return meshes
var out: Array[MeshInstance3D] = []
if avatar_node:
_collect_meshes(avatar_node, out)
return out
## Get all Skeleton3D nodes in the render tree.
func get_all_skeletons() -> Array[Skeleton3D]:
var skeletons: Array[Skeleton3D] = []
if root == null:
return skeletons
_collect_skeletons(root, skeletons)
return skeletons
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
## Add a placeholder (empty) skeleton for a slot that has no asset.
func _add_placeholder_slot(slot_def: AvatarSlotRegistry.SlotDefinition) -> void:
if body_skeleton == null:
return
var bone_name: String = slot_def.slot_key
var bone_id: int = body_skeleton.add_bone(bone_name)
# Set the bone's rest pose to identity (placeholder).
var rest := Transform3D()
rest.origin = slot_def.default_offset
body_skeleton.set_bone_rest(bone_id, rest)
# Set parent to root (bone 0 is the root bone).
body_skeleton.set_bone_parent(bone_id, 0)
## Attach a loaded slot's tree to the root.
func _attach_slot_tree(result: AvatarLoader.SlotResult, slot_def: AvatarSlotRegistry.SlotDefinition) -> void:
if result.skeleton == null:
return
# Add the skeleton as a child of the body skeleton.
# The skeleton's bones will be merged into the body skeleton during retargeting.
result.skeleton.name = "%s_skeleton" % slot_def.slot_key
result.skeleton.visible = true
root.add_child(result.skeleton)
## Retarget all limb skeletons to the body skeleton.
func _retarget_limbs() -> void:
if body_skeleton == null:
return
# Collect all child Skeleton3D nodes (limb skeletons).
var limb_skeletons: Array[Skeleton3D] = []
for child in root.get_children():
if child is Skeleton3D and child != body_skeleton:
limb_skeletons.append(child as Skeleton3D)
# For each limb skeleton, merge its bones into the body skeleton.
for limb in limb_skeletons:
_merge_skeleton_into_body(limb)
limb.queue_free()
## Merge a limb skeleton's bones into the body skeleton.
func _merge_skeleton_into_body(limb: Skeleton3D) -> void:
if limb == null or body_skeleton == null:
return
var limb_bone_count: int = limb.get_bone_count()
var start_id: int = body_skeleton.get_bone_count()
for i in range(limb_bone_count):
var bone_name: String = limb.get_bone_name(i)
var bone_id: int = body_skeleton.add_bone(bone_name)
# Copy the rest transform.
var rest: Transform3D = limb.get_bone_rest(i)
body_skeleton.set_bone_rest(bone_id, rest)
# Copy the parent.
var parent_id: int = limb.get_bone_parent(i)
if parent_id >= 0:
# Map parent ID to body skeleton.
if parent_id == 0:
# Root of limb skeleton -> parent is the body root.
body_skeleton.set_bone_parent(bone_id, 0)
else:
# Offset by start_id to map to body skeleton bones.
body_skeleton.set_bone_parent(bone_id, start_id + parent_id)
else:
body_skeleton.set_bone_parent(bone_id, 0)
# Copy custom bone data (if any).
var custom_data_count: int = limb.get_bone_custom_data_count(i)
for j in range(custom_data_count):
var data: Variant = limb.get_bone_custom_data(i, j)
body_skeleton.set_bone_custom_data(bone_id, j, data)
# Copy mesh connections.
var mesh_count: int = limb.get_bone_mesh_count(i)
for m in range(mesh_count):
var mesh: MeshInstance3D = limb.get_bone_mesh(i, m)
if mesh != null:
# Re-parent mesh to body skeleton.
mesh.skeleton = body_skeleton
mesh.skeleton_path = body_skeleton.get_path()
body_skeleton.add_bone_mesh(bone_id, mesh)
## Apply skin-tone override to all MeshInstance3D nodes.
func _apply_skin_tone(skin_tone: Color) -> void:
if skin_tone == Color.TRANSPARENT:
return
var meshes: Array[MeshInstance3D] = get_all_mesh_instances()
for mesh_instance in meshes:
if mesh_instance.material_override == null:
mesh_instance.material_override = StandardMaterial3D.new()
var mat: StandardMaterial3D = mesh_instance.material_override
mat.albedo_color = skin_tone
## Recursively collect all MeshInstance3D nodes.
func _collect_meshes(node: Node, result: Array[MeshInstance3D]) -> void:
func _collect_meshes(node: Node, out: Array[MeshInstance3D]) -> void:
if node is MeshInstance3D:
result.append(node as MeshInstance3D)
out.append(node)
for child in node.get_children():
_collect_meshes(child, result)
## Recursively collect all Skeleton3D nodes.
func _collect_skeletons(node: Node, result: Array[Skeleton3D]) -> void:
if node is Skeleton3D:
result.append(node as Skeleton3D)
for child in node.get_children():
_collect_skeletons(child, result)
_collect_meshes(child, out)
+20
View File
@@ -0,0 +1,20 @@
extends Control
## Full-screen host for the AvatarCustomizationUI.
##
## The UI itself (scripts/avatar/avatar_customization_ui.gd) builds its own
## layout programmatically and emits `closed` when the player backs out; this
## thin wrapper is what makes it a navigable scene reachable from the main menu.
const AvatarCustomizationUI := preload("res://scripts/avatar/avatar_customization_ui.gd")
func _ready() -> void:
var ui := AvatarCustomizationUI.new()
ui.name = "AvatarCustomizationUI"
ui.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(ui)
ui.closed.connect(_on_closed)
func _on_closed() -> void:
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
+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:
+29
View File
@@ -32,7 +32,14 @@ const PITCH_LIMIT: float = deg_to_rad(89.0)
var _input_direction := Vector2.ZERO
var _camera_pitch: float = 0.0
## Vertical offset applied to the assembled avatar so its feet (origin y=0)
## line up with the bottom of the player's collision capsule.
const AVATAR_FEET_OFFSET: float = -0.8
@onready var _camera: Camera3D = $Camera3D
@onready var _capsule_mesh: MeshInstance3D = $CapsuleMesh
var _avatar_view: Node3D = null
# ---------------------------------------------------------------------------
@@ -67,6 +74,28 @@ func _ready() -> void:
if DisplayServer.get_name() != "headless":
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
_apply_avatar()
## Replace the placeholder capsule with the player's assembled avatar, if the
## AvatarSystem has a loadout primed (set at login by main_menu.gd). Falls back
## to the capsule silently when there's no avatar to show -- e.g. running this
## scene directly in a test, or an offline/failed catalog load.
## TODO(PC3): a third-person camera so the local player actually sees this;
## for now it's mainly for other players (Phase 2) and looking down.
func _apply_avatar() -> void:
var avatars := get_node_or_null("/root/AvatarSystem")
if avatars == null or not avatars.has_method("build_current_avatar"):
return
var avatar: Node3D = avatars.build_current_avatar()
if avatar == null:
return
avatar.position = Vector3(0.0, AVATAR_FEET_OFFSET, 0.0)
add_child(avatar)
_avatar_view = avatar
if _capsule_mesh:
_capsule_mesh.visible = false
# ---------------------------------------------------------------------------
# _unhandled_input — capture keyboard / mouse for movement
+80
View File
@@ -0,0 +1,80 @@
extends SceneTree
## Smoke test: a primed avatar loadout ends up rendered on the in-world player.
##
## Primes AvatarSystem directly (no backend needed -- main_menu.gd is what
## fetches the loadout over HTTP in the real flow) then loads scenes/game.tscn
## and checks the Player swapped its placeholder capsule for the assembled
## avatar.
##
## Run: godot --headless --path . --script res://scripts/smoke_test_avatar_in_world.gd
var passed := 0
var failed := 0
func _initialize() -> void:
_run()
func _run() -> void:
await process_frame
var avatars := root.get_node_or_null("AvatarSystem")
if not _check("AvatarSystem autoload exists", avatars != null):
return _finish()
_check("catalog loads", await avatars.ensure_catalog())
var bases: Array = avatars.get_assets_for_slot("base_body")
if not _check("catalog has a base body", bases.size() > 0):
return _finish()
var ok: bool = avatars.set_loadout_from_dict({
"base_body": bases[0],
"slots": {"hair": "hair_ponytail_01", "chest": "chest_bandit_01"},
})
_check("loadout primes from a backend-shaped dict", ok and avatars.has_loadout())
var err := change_scene_to_file("res://scenes/game.tscn")
_check("game.tscn loads", err == OK)
# Give _ready() (scene swap + avatar assembly) a few frames to settle.
for i in 6:
await process_frame
var game := current_scene
var player: Node = game.get_node_or_null("Player") if game else null
if not _check("player exists in the world", player != null):
return _finish()
var avatar := player.get_node_or_null("Avatar")
_check("player rendered the assembled avatar (not the capsule)", avatar != null)
var capsule = player.get_node_or_null("CapsuleMesh")
_check("placeholder capsule mesh is hidden", capsule != null and not capsule.visible)
if avatar:
var skel := avatar.get_node_or_null("Skeleton3D") as Skeleton3D
_check("avatar carries the canonical rig", skel != null and skel.get_bone_count() == 24)
var mounts := 0
if skel:
for c in skel.get_children():
if c is BoneAttachment3D:
mounts += 1
_check("equipped hair + chest are bone-mounted", mounts >= 2)
_finish()
func _check(description: String, condition: bool) -> bool:
if condition:
print(" PASS: ", description)
passed += 1
else:
printerr(" FAIL: ", description)
failed += 1
return condition
func _finish() -> void:
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
quit(0 if failed == 0 else 1)