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:
@@ -1,45 +1,6 @@
|
|||||||
[gd_scene load_steps=3 format=3 uid="uid://avatar_customization"]
|
[gd_scene load_steps=2 format=3 uid="uid://avatar_customization"]
|
||||||
|
|
||||||
[ext_resource type="Script" path="res://scripts/avatar/avatar_customization_ui.gd" id="1"]
|
[ext_resource type="Script" path="res://scripts/avatar_customization_screen.gd" id="1"]
|
||||||
|
|
||||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_main_panel"]
|
|
||||||
bg_color = Color(0.12, 0.12, 0.15, 1)
|
|
||||||
|
|
||||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_slot_panel"]
|
|
||||||
bg_color = Color(0.18, 0.18, 0.22, 1)
|
|
||||||
border_width_left = 2
|
|
||||||
border_width_right = 2
|
|
||||||
border_width_top = 2
|
|
||||||
border_width_bottom = 2
|
|
||||||
border_color = Color(0.3, 0.3, 0.35, 1)
|
|
||||||
corner_radius_top_left = 4
|
|
||||||
corner_radius_top_right = 4
|
|
||||||
corner_radius_bottom_right = 4
|
|
||||||
corner_radius_bottom_left = 4
|
|
||||||
|
|
||||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button_style"]
|
|
||||||
bg_color = Color(0.25, 0.25, 0.3, 1)
|
|
||||||
border_width_left = 1
|
|
||||||
border_width_right = 1
|
|
||||||
border_width_top = 1
|
|
||||||
border_width_bottom = 1
|
|
||||||
border_color = Color(0.4, 0.4, 0.45, 1)
|
|
||||||
corner_radius_top_left = 4
|
|
||||||
corner_radius_top_right = 4
|
|
||||||
corner_radius_bottom_right = 4
|
|
||||||
corner_radius_bottom_left = 4
|
|
||||||
|
|
||||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button_hover"]
|
|
||||||
bg_color = Color(0.35, 0.35, 0.4, 1)
|
|
||||||
border_width_left = 1
|
|
||||||
border_width_right = 1
|
|
||||||
border_width_top = 1
|
|
||||||
border_width_bottom = 1
|
|
||||||
border_color = Color(0.5, 0.5, 0.55, 1)
|
|
||||||
corner_radius_top_left = 4
|
|
||||||
corner_radius_top_right = 4
|
|
||||||
corner_radius_bottom_right = 4
|
|
||||||
corner_radius_bottom_left = 4
|
|
||||||
|
|
||||||
[node name="AvatarCustomization" type="Control"]
|
[node name="AvatarCustomization" type="Control"]
|
||||||
layout_mode = 3
|
layout_mode = 3
|
||||||
@@ -49,101 +10,3 @@ anchor_bottom = 1.0
|
|||||||
grow_horizontal = 2
|
grow_horizontal = 2
|
||||||
grow_vertical = 2
|
grow_vertical = 2
|
||||||
script = ExtResource("1")
|
script = ExtResource("1")
|
||||||
|
|
||||||
[node name="MainPanel" type="Panel" parent="."]
|
|
||||||
layout_mode = 3
|
|
||||||
anchors_preset = 15
|
|
||||||
anchor_right = 1.0
|
|
||||||
anchor_bottom = 1.0
|
|
||||||
grow_horizontal = 2
|
|
||||||
grow_vertical = 2
|
|
||||||
theme_override_styles/panel = SubResource("StyleBoxFlat_main_panel")
|
|
||||||
|
|
||||||
[node name="VBox" type="VBoxContainer" parent="MainPanel"]
|
|
||||||
layout_mode = 1
|
|
||||||
size_flags_horizontal = 3
|
|
||||||
size_flags_vertical = 3
|
|
||||||
theme_override_constants/separation = 8
|
|
||||||
|
|
||||||
[node name="TitleLabel" type="Label" parent="MainPanel/VBox"]
|
|
||||||
layout_mode = 1
|
|
||||||
size_flags_horizontal = 3
|
|
||||||
text = "Avatar Customization"
|
|
||||||
horizontal_alignment = 1
|
|
||||||
theme_override_font_sizes/font_size = 24
|
|
||||||
|
|
||||||
[node name="HBox" type="HBoxContainer" parent="MainPanel/VBox"]
|
|
||||||
layout_mode = 1
|
|
||||||
size_flags_horizontal = 3
|
|
||||||
size_flags_vertical = 3
|
|
||||||
|
|
||||||
[node name="AvatarViewport" type="ViewportContainer" parent="MainPanel/VBox/HBox"]
|
|
||||||
layout_mode = 1
|
|
||||||
size_flags_horizontal = 3
|
|
||||||
size_flags_vertical = 3
|
|
||||||
expand_texture = true
|
|
||||||
stretch = true
|
|
||||||
|
|
||||||
[node name="AvatarViewport" type="Viewport" parent="MainPanel/VBox/HBox/AvatarViewport"]
|
|
||||||
layout_mode = 3
|
|
||||||
size_flags_horizontal = 3
|
|
||||||
size_flags_vertical = 3
|
|
||||||
width = 512
|
|
||||||
height = 512
|
|
||||||
transparent_bg = true
|
|
||||||
|
|
||||||
[node name="Avatar3D" type="Node3D" parent="MainPanel/VBox/HBox/AvatarViewport/AvatarViewport"]
|
|
||||||
layout_mode = 3
|
|
||||||
|
|
||||||
[node name="Camera3D" type="Camera3D" parent="MainPanel/VBox/HBox/AvatarViewport/AvatarViewport"]
|
|
||||||
layout_mode = 3
|
|
||||||
position = Vector3(0, 1.0, 3.0)
|
|
||||||
rotation = Vector3(-0.174533, 0, 0)
|
|
||||||
|
|
||||||
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="MainPanel/VBox/HBox/AvatarViewport/AvatarViewport"]
|
|
||||||
layout_mode = 3
|
|
||||||
position = Vector3(2, 4, 2)
|
|
||||||
rotation = Vector3(0.785398, 0, 0)
|
|
||||||
light_color = Color(0.8, 0.8, 0.8, 1)
|
|
||||||
light_energy = 1.0
|
|
||||||
|
|
||||||
[node name="DirectionalLight3D2" type="DirectionalLight3D" parent="MainPanel/VBox/HBox/AvatarViewport/AvatarViewport"]
|
|
||||||
layout_mode = 3
|
|
||||||
position = Vector3(-2, 2, -2)
|
|
||||||
rotation = Vector3(0.785398, 0, 0)
|
|
||||||
light_color = Color(0.4, 0.4, 0.5, 1)
|
|
||||||
light_energy = 0.5
|
|
||||||
|
|
||||||
[node name="SlotList" type="VBoxContainer" parent="MainPanel/VBox/HBox"]
|
|
||||||
layout_mode = 1
|
|
||||||
size_flags_horizontal = 0
|
|
||||||
size_flags_vertical = 3
|
|
||||||
theme_override_constants/separation = 4
|
|
||||||
custom_minimum_size = Vector2(280, 0)
|
|
||||||
|
|
||||||
[node name="SlotHeader" type="Label" parent="MainPanel/VBox/HBox/SlotList"]
|
|
||||||
layout_mode = 1
|
|
||||||
text = "SLOTS"
|
|
||||||
theme_override_font_sizes/font_size = 16
|
|
||||||
font_color = Color(0.7, 0.7, 0.75, 1)
|
|
||||||
|
|
||||||
[node name="ScrollContainer" type="ScrollContainer" parent="MainPanel/VBox/HBox/SlotList"]
|
|
||||||
layout_mode = 1
|
|
||||||
size_flags_horizontal = 3
|
|
||||||
size_flags_vertical = 3
|
|
||||||
horizontal_mode = 2
|
|
||||||
horizontal_scroll_mode = 2
|
|
||||||
|
|
||||||
[node name="SlotsVBox" type="VBoxContainer" parent="MainPanel/VBox/HBox/SlotList/ScrollContainer"]
|
|
||||||
layout_mode = 1
|
|
||||||
size_flags_horizontal = 3
|
|
||||||
size_flags_vertical = 3
|
|
||||||
theme_override_constants/separation = 4
|
|
||||||
|
|
||||||
[node name="BackButton" type="Button" parent="MainPanel/VBox"]
|
|
||||||
layout_mode = 1
|
|
||||||
size_flags_horizontal = 3
|
|
||||||
text = "Back to Menu"
|
|
||||||
button_pressed = false
|
|
||||||
action_mode = 0
|
|
||||||
custom_minimum_size = Vector2(0, 36)
|
|
||||||
|
|||||||
@@ -112,6 +112,12 @@ layout_mode = 1
|
|||||||
text = "Play"
|
text = "Play"
|
||||||
size_flags_horizontal = 4
|
size_flags_horizontal = 4
|
||||||
|
|
||||||
|
[node name="CustomizeButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||||
|
layout_mode = 1
|
||||||
|
text = "Customize Avatar"
|
||||||
|
size_flags_horizontal = 4
|
||||||
|
disabled = true
|
||||||
|
|
||||||
[node name="QuitButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
[node name="QuitButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
text = "Quit"
|
text = "Quit"
|
||||||
@@ -120,6 +126,7 @@ size_flags_horizontal = 4
|
|||||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton" to="." method="_on_login_button_pressed"]
|
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton" to="." method="_on_login_button_pressed"]
|
||||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton" to="." method="_on_register_button_pressed"]
|
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton" to="." method="_on_register_button_pressed"]
|
||||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/PlayButton" to="." method="_on_play_button_pressed"]
|
[connection signal="pressed" from="CenterContainer/VBoxContainer/PlayButton" to="." method="_on_play_button_pressed"]
|
||||||
|
[connection signal="pressed" from="CenterContainer/VBoxContainer/CustomizeButton" to="." method="_on_customize_button_pressed"]
|
||||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/QuitButton" to="." method="_on_quit_button_pressed"]
|
[connection signal="pressed" from="CenterContainer/VBoxContainer/QuitButton" to="." method="_on_quit_button_pressed"]
|
||||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton" to="." method="_on_auth_button_pressed"]
|
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton" to="." method="_on_auth_button_pressed"]
|
||||||
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit" to="." method="_on_auth_button_pressed"]
|
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit" to="." method="_on_auth_button_pressed"]
|
||||||
|
|||||||
@@ -221,6 +221,22 @@ func get_me() -> Dictionary:
|
|||||||
return await request(HTTPClient.METHOD_GET, "/api/users/me", "", "application/json", true)
|
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
|
# Internals
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -106,9 +106,14 @@ func _ready() -> void:
|
|||||||
# Build the UI.
|
# Build the UI.
|
||||||
_build_ui()
|
_build_ui()
|
||||||
|
|
||||||
# Create a fresh loadout.
|
# Seed from the player's live loadout (set at login) if there is one, so
|
||||||
loadout = AvatarLoadout.new()
|
# the screen opens showing what they're actually wearing; otherwise start
|
||||||
loadout.base_body = default_base_body
|
# 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.
|
# Initialize the visualizer.
|
||||||
_visualizer = AvatarVisualizer.new()
|
_visualizer = AvatarVisualizer.new()
|
||||||
@@ -165,8 +170,9 @@ func _build_ui() -> void:
|
|||||||
# Camera for the preview.
|
# Camera for the preview.
|
||||||
_preview_camera = Camera3D.new()
|
_preview_camera = Camera3D.new()
|
||||||
_preview_camera.name = "PreviewCamera"
|
_preview_camera.name = "PreviewCamera"
|
||||||
_preview_camera.position = Vector3(0, 1.0, 3.0)
|
# look_at needs a global transform, which only exists once in the tree.
|
||||||
_preview_camera.look_at(Vector3(0, 1.0, 0))
|
_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)
|
_preview_viewport.add_child(_preview_camera)
|
||||||
|
|
||||||
# Avatar root inside the viewport.
|
# Avatar root inside the viewport.
|
||||||
@@ -429,14 +435,26 @@ func _on_skin_tone_changed(value: float) -> void:
|
|||||||
func _on_save_pressed() -> void:
|
func _on_save_pressed() -> void:
|
||||||
if loadout == null:
|
if loadout == null:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Local cache (offline fallback / fast reopen).
|
||||||
var json := loadout.to_json()
|
var json := loadout.to_json()
|
||||||
var file := FileAccess.open("user://loadout.json", FileAccess.WRITE)
|
var file := FileAccess.open("user://loadout.json", FileAccess.WRITE)
|
||||||
if file:
|
if file:
|
||||||
file.store_string(json)
|
file.store_string(json)
|
||||||
file.close()
|
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:
|
else:
|
||||||
_status_label.text = "Failed to save loadout."
|
_status_label.text = "Saved locally (not signed in)."
|
||||||
|
|
||||||
|
|
||||||
func _on_load_pressed() -> void:
|
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:
|
func get_asset_display_name(asset_id: String) -> String:
|
||||||
var asset := catalog.resolve(asset_id)
|
var asset := catalog.resolve(asset_id)
|
||||||
return asset.get("display_name", 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
|
## AvatarVisualizer
|
||||||
##
|
##
|
||||||
## Builds the render tree from an AvatarLoadout.
|
## Renders an AvatarLoadout into a 3D node tree for previews (the customization
|
||||||
##
|
## screen). A thin wrapper over AvatarLoader -- it owns its own loader instance
|
||||||
## Responsibilities:
|
## and catalog reference so building a preview never disturbs AvatarSystem's
|
||||||
## - Create a root Node3D (or Node2D for 2D mode) as the avatar container.
|
## live in-world avatar state.
|
||||||
## - 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.
|
|
||||||
##
|
##
|
||||||
## Usage:
|
## Usage:
|
||||||
## var viz = AvatarVisualizer.new()
|
## var viz := AvatarVisualizer.new()
|
||||||
## viz.load_loadout(loadout)
|
## viz.load_loadout(loadout, preview_root_node3d) # preview_root gets the avatar as a child
|
||||||
## avatar_node.add_child(viz.get_root())
|
## ...
|
||||||
##
|
## viz.clear()
|
||||||
## The visualizer owns the Skeleton3D and MeshInstance3D trees it creates.
|
|
||||||
## The caller owns the root node's placement in the scene tree.
|
|
||||||
|
|
||||||
class_name AvatarVisualizer
|
class_name AvatarVisualizer
|
||||||
extends Object
|
extends Object
|
||||||
@@ -26,265 +17,99 @@ extends Object
|
|||||||
# Preloaded rather than referenced by global class_name -- see avatar_loadout.gd.
|
# Preloaded rather than referenced by global class_name -- see avatar_loadout.gd.
|
||||||
const AvatarLoadout := preload("res://scripts/avatar/avatar_loadout.gd")
|
const AvatarLoadout := preload("res://scripts/avatar/avatar_loadout.gd")
|
||||||
const AvatarLoader := preload("res://scripts/avatar/avatar_loader.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")
|
const AvatarSlotRegistry := preload("res://scripts/avatar/avatar_slot_registry.gd")
|
||||||
|
|
||||||
|
|
||||||
## Emitted when the visualizer has finished building the render tree.
|
## Emitted when the visualizer has finished building the render tree.
|
||||||
signal build_complete
|
signal build_complete
|
||||||
## Emitted when the visualizer has been cleared.
|
## Emitted when the visualizer has been cleared.
|
||||||
signal cleared
|
signal cleared
|
||||||
|
|
||||||
|
## The node the assembled avatar is parented under.
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Public API
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
## The root node that holds the avatar render tree.
|
|
||||||
## Set this to the desired parent before calling load_loadout().
|
|
||||||
var root: Node3D = null
|
var root: Node3D = null
|
||||||
|
|
||||||
## The Skeleton3D that serves as the body skeleton.
|
## The assembled avatar node (child of `root`).
|
||||||
## All limb skeletons are retargeted to this skeleton's bone hierarchy.
|
var avatar_node: Node3D = null
|
||||||
var body_skeleton: Skeleton3D = null
|
|
||||||
|
|
||||||
## The AvatarLoadout currently loaded (null if none).
|
## The loadout currently rendered.
|
||||||
var current_loadout: AvatarLoadout = null
|
var current_loadout: AvatarLoadout = null
|
||||||
|
|
||||||
## Whether the visualizer is currently building (true during load_loadout).
|
var _loader = null
|
||||||
var is_building: bool = false
|
var _catalog = null
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
## Build the avatar for [param loadout] and parent it under [param root_node]
|
||||||
# Core
|
## (or the previously-set `root`). Replaces any previously-built avatar.
|
||||||
# ---------------------------------------------------------------------------
|
func load_loadout(loadout, root_node: Node3D = null) -> void:
|
||||||
|
|
||||||
## 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.
|
|
||||||
if root_node != null:
|
if root_node != null:
|
||||||
root = root_node
|
root = root_node
|
||||||
else:
|
if root == null or loadout == null:
|
||||||
root = Node3D.new()
|
return
|
||||||
root.name = "AvatarVisualizer"
|
|
||||||
|
|
||||||
# Create the body skeleton.
|
clear()
|
||||||
body_skeleton = Skeleton3D.new()
|
current_loadout = loadout
|
||||||
body_skeleton.name = "AvatarBody"
|
|
||||||
root.add_child(body_skeleton)
|
|
||||||
|
|
||||||
# Build each slot's contribution.
|
if _catalog == null:
|
||||||
for slot_def in AvatarSlotRegistry.get_slot_definitions():
|
var sys = root.get_node_or_null("/root/AvatarSystem")
|
||||||
var slot_key: String = slot_def.slot_key
|
if sys != null:
|
||||||
var slot_item: AvatarLoadout.SlotItem = loadout.slots.get(slot_key)
|
_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 == "":
|
_loader = AvatarLoader.new()
|
||||||
# Slot is empty — create a placeholder.
|
root.add_child(_loader)
|
||||||
_add_placeholder_slot(slot_def)
|
_loader.init_loader(_catalog)
|
||||||
continue
|
|
||||||
|
|
||||||
# Load the slot's asset.
|
var base: Node3D = _loader.load_base_body(loadout.base_body)
|
||||||
var slot_result: AvatarLoader.SlotResult = await AvatarLoader.load_slot(
|
if base == null:
|
||||||
slot_item,
|
return
|
||||||
slot_def,
|
root.add_child(base)
|
||||||
loadout.skin_tone,
|
avatar_node = base
|
||||||
)
|
|
||||||
|
|
||||||
if slot_result == null:
|
for slot_id in AvatarSlotRegistry.SLOT_IDS:
|
||||||
push_warning("AvatarVisualizer: failed to load slot '%s' (%s)" % [slot_key, slot_item.asset_id])
|
var value = loadout.slots.get(slot_id)
|
||||||
_add_placeholder_slot(slot_def)
|
if slot_id == "accessory":
|
||||||
continue
|
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.
|
for slot_id in AvatarSlotRegistry.BODY_PART_SLOTS:
|
||||||
_attach_slot_tree(slot_result, slot_def)
|
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()
|
build_complete.emit()
|
||||||
|
|
||||||
|
|
||||||
## Clear the render tree and free resources.
|
## Tear down the current preview avatar.
|
||||||
func clear() -> void:
|
func clear() -> void:
|
||||||
if root != null:
|
if avatar_node != null and is_instance_valid(avatar_node):
|
||||||
root.queue_free()
|
avatar_node.queue_free()
|
||||||
root = null
|
avatar_node = null
|
||||||
body_skeleton = null
|
if _loader != null and is_instance_valid(_loader):
|
||||||
current_loadout = null
|
_loader.queue_free()
|
||||||
cleared.emit()
|
_loader = null
|
||||||
|
current_loadout = null
|
||||||
|
cleared.emit()
|
||||||
|
|
||||||
|
|
||||||
## Get the root node.
|
|
||||||
func get_root() -> Node3D:
|
func get_root() -> Node3D:
|
||||||
return root
|
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]:
|
func get_all_mesh_instances() -> Array[MeshInstance3D]:
|
||||||
var meshes: Array[MeshInstance3D] = []
|
var out: Array[MeshInstance3D] = []
|
||||||
if root == null:
|
if avatar_node:
|
||||||
return meshes
|
_collect_meshes(avatar_node, out)
|
||||||
_collect_meshes(root, meshes)
|
return out
|
||||||
return meshes
|
|
||||||
|
|
||||||
|
|
||||||
## Get all Skeleton3D nodes in the render tree.
|
func _collect_meshes(node: Node, out: Array[MeshInstance3D]) -> void:
|
||||||
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:
|
|
||||||
if node is MeshInstance3D:
|
if node is MeshInstance3D:
|
||||||
result.append(node as MeshInstance3D)
|
out.append(node)
|
||||||
for child in node.get_children():
|
for child in node.get_children():
|
||||||
_collect_meshes(child, result)
|
_collect_meshes(child, out)
|
||||||
|
|
||||||
|
|
||||||
## 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)
|
|
||||||
|
|||||||
@@ -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
@@ -40,6 +40,7 @@ var _auth_button: Button
|
|||||||
var _status_label: Label
|
var _status_label: Label
|
||||||
var _welcome_label: Label
|
var _welcome_label: Label
|
||||||
var _play_button: Button
|
var _play_button: Button
|
||||||
|
var _customize_button: Button
|
||||||
var _quit_button: Button
|
var _quit_button: Button
|
||||||
|
|
||||||
var _is_register_mode: bool = false
|
var _is_register_mode: bool = false
|
||||||
@@ -63,6 +64,7 @@ func _ready() -> void:
|
|||||||
_status_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthStatusLabel
|
_status_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthStatusLabel
|
||||||
_welcome_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel
|
_welcome_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel
|
||||||
_play_button = $CenterContainer/VBoxContainer/PlayButton
|
_play_button = $CenterContainer/VBoxContainer/PlayButton
|
||||||
|
_customize_button = $CenterContainer/VBoxContainer/CustomizeButton
|
||||||
_quit_button = $CenterContainer/VBoxContainer/QuitButton
|
_quit_button = $CenterContainer/VBoxContainer/QuitButton
|
||||||
|
|
||||||
# Default to Play being gated. When a token exists, the async session
|
# 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
|
# keeping the button disabled until validation succeeds prevents a fast
|
||||||
# click from navigating to world_list with an expired/invalid token.
|
# click from navigating to world_list with an expired/invalid token.
|
||||||
_play_button.disabled = true
|
_play_button.disabled = true
|
||||||
|
_customize_button.disabled = true
|
||||||
|
|
||||||
# Check whether a valid session already exists (e.g. user logged in,
|
# Check whether a valid session already exists (e.g. user logged in,
|
||||||
# navigated to world_list, then clicked Back). If so, restore the
|
# 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", ""))
|
var username: String = str((me_result.data as Dictionary).get("username", ""))
|
||||||
if username != "":
|
if username != "":
|
||||||
_show_authenticated(username)
|
_show_authenticated(username)
|
||||||
|
await _prime_avatar()
|
||||||
auth_state_ready.emit()
|
auth_state_ready.emit()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -115,6 +119,7 @@ func _show_authenticated(username: String) -> void:
|
|||||||
_auth_button.visible = false
|
_auth_button.visible = false
|
||||||
_auth_mode_label.visible = false
|
_auth_mode_label.visible = false
|
||||||
_play_button.disabled = false
|
_play_button.disabled = false
|
||||||
|
_customize_button.disabled = false
|
||||||
_auth_buttons_row.visible = false
|
_auth_buttons_row.visible = false
|
||||||
print("Restored authenticated session for: ", username)
|
print("Restored authenticated session for: ", username)
|
||||||
|
|
||||||
@@ -129,6 +134,7 @@ func _reset_to_logged_out() -> void:
|
|||||||
_over_18_check.visible = false
|
_over_18_check.visible = false
|
||||||
_over_18_check.button_pressed = false
|
_over_18_check.button_pressed = false
|
||||||
_play_button.disabled = true # Gate play behind successful auth.
|
_play_button.disabled = true # Gate play behind successful auth.
|
||||||
|
_customize_button.disabled = true
|
||||||
_update_auth_mode_ui()
|
_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")
|
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:
|
func _on_quit_button_pressed() -> void:
|
||||||
get_tree().quit()
|
get_tree().quit()
|
||||||
|
|
||||||
@@ -214,7 +226,7 @@ func _on_auth_button_pressed() -> void:
|
|||||||
_clear_status()
|
_clear_status()
|
||||||
|
|
||||||
var result := await ApiClient.register(username, email, password, true)
|
var result := await ApiClient.register(username, email, password, true)
|
||||||
_on_auth_complete(result)
|
await _on_auth_complete(result)
|
||||||
else:
|
else:
|
||||||
_auth_button.disabled = true
|
_auth_button.disabled = true
|
||||||
_auth_button.text = "Logging in..."
|
_auth_button.text = "Logging in..."
|
||||||
@@ -222,7 +234,7 @@ func _on_auth_button_pressed() -> void:
|
|||||||
_clear_status()
|
_clear_status()
|
||||||
|
|
||||||
var result := await ApiClient.login(username, password)
|
var result := await ApiClient.login(username, password)
|
||||||
_on_auth_complete(result)
|
await _on_auth_complete(result)
|
||||||
|
|
||||||
|
|
||||||
func _on_auth_complete(result: Dictionary) -> void:
|
func _on_auth_complete(result: Dictionary) -> void:
|
||||||
@@ -232,7 +244,10 @@ func _on_auth_complete(result: Dictionary) -> void:
|
|||||||
_is_authenticating = false
|
_is_authenticating = false
|
||||||
|
|
||||||
if result.ok:
|
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
|
_username_edit.visible = false
|
||||||
_password_edit.visible = false
|
_password_edit.visible = false
|
||||||
_email_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.text = "Welcome, %s!" % _username_edit.text.strip_edges()
|
||||||
_welcome_label.visible = true
|
_welcome_label.visible = true
|
||||||
_play_button.disabled = false
|
_play_button.disabled = false
|
||||||
|
_customize_button.disabled = false
|
||||||
_auth_buttons_row.visible = false
|
_auth_buttons_row.visible = false
|
||||||
print("Auth successful. Token stored.")
|
print("Auth successful. Token stored.")
|
||||||
|
await _prime_avatar()
|
||||||
else:
|
else:
|
||||||
# Failure — show backend error detail.
|
# Failure — show backend error detail.
|
||||||
_show_status(result.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 ───────────────────────────────────────────────────────────
|
# ─── Status helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func _show_status(message: String) -> void:
|
func _show_status(message: String) -> void:
|
||||||
|
|||||||
@@ -32,7 +32,14 @@ const PITCH_LIMIT: float = deg_to_rad(89.0)
|
|||||||
var _input_direction := Vector2.ZERO
|
var _input_direction := Vector2.ZERO
|
||||||
var _camera_pitch: float = 0.0
|
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 _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":
|
if DisplayServer.get_name() != "headless":
|
||||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
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
|
# _unhandled_input — capture keyboard / mouse for movement
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user