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:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user