Compare commits

...

27 Commits

Author SHA1 Message Date
coder-bot b6e146e4b2 Avatar loader & customization system: merge onto main + fix regressions, add smoke coverage
Merges the pre-existing avatar system implementation (catalog, slot
registry, loadout, glTF-based loader, customization UI, visualizer --
~2300 lines across scripts/avatar/) onto current main, which had
diverged significantly since this branch was last touched (PC1 UI
fixes, PC2's playable level/player/multiplayer work).

Fixes found while integrating:
- project.godot had wholesale-replaced the [autoload] section again
  (same recurring pattern as the multiplayer server/client branches),
  dropping the ApiClient autoload entirely. Restored it additively
  alongside AvatarSystem.
- All other real conflicts (main_menu.tscn/.gd, main.tscn, .godot
  cache) were pure "this branch re-created a stale duplicate of
  something main already has" -- resolved by taking main's version,
  confirmed via `git diff` that this task's own changes never touched
  those files' actual logic.

New: scripts/smoke_test_avatar_system.gd -- confirms AvatarSystem is a
real autoload, the committed catalog.json parses, loadout equip/
validate/JSON-round-trip works against real catalog entries, and --
since assets/avatars/*.glb model files don't exist yet -- that
AvatarLoader.load_base_body() fails gracefully (null + load_error
signal) rather than crashing when given a real catalog entry with no
backing file. 10/10 passing.

Verified no regressions: all 10 pre-existing Godot smoke suites still
pass against a live backend, and all 61 backend pytest tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6rUEynmv2QmweocAQYARN
2026-08-21 01:34:51 +00:00
coder-bot 59ca5a4c6e Merge main into agent/896fbb97de1d
# Conflicts:
#	.godot/editor/filesystem_cache10
#	.godot/editor/project_metadata.cfg
#	.godot/global_script_class_cache.cfg
#	.godot/uid_cache.bin
#	project.godot
#	scenes/main.tscn
#	scenes/main_menu.tscn
#	scripts/main_menu.gd
#	scripts/main_menu.gd.uid
2026-08-21 01:26:13 +00:00
coder-bot 4d754ae48e WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-21 00:19:51 +00:00
coder-bot d1d03df2e6 WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-21 00:01:24 +00:00
coder-bot bfcbd0486f WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-20 22:30:31 +00:00
coder-bot 1be947f9ef WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-20 22:05:30 +00:00
coder-bot 38d97a3051 WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-20 21:27:57 +00:00
coder-bot b86e4053bd WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-20 21:11:25 +00:00
coder-bot 07f655815f WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-20 21:09:33 +00:00
coder-bot f5a7162285 WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-19 14:56:44 +00:00
architect-bot 36fd406925 WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-18 16:44:02 +00:00
coder-bot ddfe5dfaad Recovered orphaned WIP before switching to a new task 2026-08-18 15:54:33 +00:00
coder-bot dcaaf96d81 WIP checkpoint: fix static registry, uint32 type, GLTF API calls, ARRAY_BONES, missing slot handler
- avatar_slot_registry.gd: _slot_data/_slot_definitions made static (every
  caller uses AvatarSlotRegistry.method() on the class, never an instance,
  so the old instance _init() populating them was never invoked); moved
  registry build to static func _static_init()
- avatar_catalog.gd: compute_loadout_hash() return type uint32 -> int
  (GDScript has no fixed-width uint type)
- avatar_loader.gd: GLTFDocument.parse() (doesn't exist) -> the real
  append_from_buffer()+generate_scene() API; Error.parse_error() (doesn't
  exist) -> error_string(); Mesh.ARRAY_JOINTS (doesn't exist) ->
  Mesh.ARRAY_BONES; removed a dead 'mesh is MultiMesh' check that can
  never be true (MeshInstance3D.mesh is always Mesh, never MultiMesh);
  fixed _merge_node_into_avatar to recurse into a Skeleton3D's children
  (skinned meshes are commonly children of the skeleton in a GLB export,
  the old code returned before ever reaching them)
- avatar_customization_ui.gd: added the missing _on_slot_button_pressed
  handler (referenced by a button connect, never defined -- just calls
  the existing _select_slot())

Verified: godot --headless --import + --quit is fully clean, and the
avatar_customization.tscn scene now instantiates successfully.

Remaining, scoped separately: avatar_visualizer.gd calls a
AvatarLoader.load_slot() static async API and SlotItem/SlotResult types
that don't exist -- AvatarLoader's real API (matching the project's own
avatar.md spec: one canonical skeleton, attachments retargeted onto it)
is instance-based load_base_body()/load_attachment(). AvatarVisualizer
needs rewriting to use that real API; it's the one out of step with spec,
not AvatarLoader.
2026-08-18 14:47:08 +00:00
coder-bot 803405aba4 WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-18 14:03:07 +00:00
coder-bot ea6958c11e Recovered orphaned WIP before switching to a new task 2026-08-18 13:50:01 +00:00
coder-bot 61ee81a974 WIP checkpoint: fix script-attach typo, missing class_name, var name mismatch
- scenes/avatar_customization.tscn: script_extense -> script (typo meant
  the customization UI script was never actually attached to the scene)
- scripts/avatar/avatar_loadout.gd: add missing class_name AvatarLoadout
  (avatar_system.gd references the type everywhere; only this file among
  the avatar scripts lacked its class_name)
- scripts/main_menu.gd: _ready() referenced _customize_button/_settings_button/
  _quit_button but the @onready vars are named without the leading
  underscore
2026-08-18 13:44:41 +00:00
coder-bot 62f12e55db WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-18 13:09:57 +00:00
coder-bot 4c0af03573 WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-18 13:01:14 +00:00
coder-bot 689c51e179 WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-18 00:47:59 +00:00
coder-bot 5cfbcd8efd WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-18 00:36:39 +00:00
coder-bot 27461d9b5e WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-18 00:25:17 +00:00
coder-bot a5dc5cb3b6 WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-17 22:20:39 +00:00
coder-bot ea5dd63cd7 WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-17 22:10:56 +00:00
coder-bot 5ebd60b0bb WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-17 22:04:48 +00:00
coder-bot b2a64a8933 WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-17 22:00:37 +00:00
coder-bot 9fcea730b9 WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-17 22:00:14 +00:00
coder-bot 5b3fc10649 WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-17 21:59:50 +00:00
20 changed files with 2682 additions and 4 deletions
@@ -0,0 +1,5 @@
[folding]
node_unfolds=[NodePath("."), PackedStringArray("Layout"), NodePath("CenterContainer"), PackedStringArray("Layout"), NodePath("CenterContainer/VBoxContainer"), PackedStringArray("Layout", "Theme Overrides"), NodePath("CenterContainer/VBoxContainer/TitleLabel"), PackedStringArray("Layout", "Theme Overrides"), NodePath("CenterContainer/VBoxContainer/CustomizeButton"), PackedStringArray("Layout"), NodePath("CenterContainer/VBoxContainer/PlayButton"), PackedStringArray("Layout"), NodePath("CenterContainer/VBoxContainer/QuitButton"), PackedStringArray("Layout")]
resource_unfolds=[]
nodes_folded=[]
+115
View File
@@ -0,0 +1,115 @@
{
"assets": {
"base_human_male_01": {
"type": "base_body",
"slot": "base_body",
"path": "res://assets/avatars/base_human_male_01.glb",
"version": "1.0.0",
"display_name": "Human Male Base"
},
"head_cyber_01": {
"type": "body_part",
"slot": "head",
"path": "res://assets/avatars/head_cyber_01.glb",
"version": "1.0.0",
"display_name": "Cyber Head",
"mount_bone": "Head"
},
"hair_mohawk_01": {
"type": "attachment",
"slot": "hair",
"path": "res://assets/avatars/hair_mohawk_01.glb",
"version": "1.0.0",
"display_name": "Mohawk",
"mount_bone": "HeadTop"
},
"hair_ponytail_01": {
"type": "attachment",
"slot": "hair",
"path": "res://assets/avatars/hair_ponytail_01.glb",
"version": "1.0.0",
"display_name": "Ponytail",
"mount_bone": "HeadTop"
},
"face_glasses_01": {
"type": "attachment",
"slot": "face",
"path": "res://assets/avatars/face_glasses_01.glb",
"version": "1.0.0",
"display_name": "Glasses",
"mount_bone": "Jaw"
},
"chest_bandit_01": {
"type": "attachment",
"slot": "chest",
"path": "res://assets/avatars/chest_bandit_01.glb",
"version": "1.0.0",
"display_name": "Bandit Shirt",
"mount_bone": "Chest"
},
"back_backpack_01": {
"type": "attachment",
"slot": "back",
"path": "res://assets/avatars/back_backpack_01.glb",
"version": "1.0.0",
"display_name": "Backpack",
"mount_bone": "Chest"
},
"pants_cargo_01": {
"type": "attachment",
"slot": "pants",
"path": "res://assets/avatars/pants_cargo_01.glb",
"version": "1.0.0",
"display_name": "Cargo Pants",
"mount_bone": "Hips"
},
"left_hand_glove_01": {
"type": "attachment",
"slot": "left_hand",
"path": "res://assets/avatars/left_hand_glove_01.glb",
"version": "1.0.0",
"display_name": "Left Glove",
"mount_bone": "LeftHand"
},
"right_hand_glove_01": {
"type": "attachment",
"slot": "right_hand",
"path": "res://assets/avatars/right_hand_glove_01.glb",
"version": "1.0.0",
"display_name": "Right Glove",
"mount_bone": "RightHand"
},
"left_foot_boot_01": {
"type": "attachment",
"slot": "left_foot",
"path": "res://assets/avatars/left_foot_boot_01.glb",
"version": "1.0.0",
"display_name": "Left Boot",
"mount_bone": "LeftFoot"
},
"right_foot_boot_01": {
"type": "attachment",
"slot": "right_foot",
"path": "res://assets/avatars/right_foot_boot_01.glb",
"version": "1.0.0",
"display_name": "Right Boot",
"mount_bone": "RightFoot"
},
"accessory_belt_01": {
"type": "attachment",
"slot": "accessory",
"path": "res://assets/avatars/accessory_belt_01.glb",
"version": "1.0.0",
"display_name": "Belt",
"mount_bone": "Root"
},
"accessory_necklace_01": {
"type": "attachment",
"slot": "accessory",
"path": "res://assets/avatars/accessory_necklace_01.glb",
"version": "1.0.0",
"display_name": "Necklace",
"mount_bone": "Root"
}
}
}
+10 -4
View File
@@ -7,14 +7,17 @@ to know what "run the tests" actually checked.
Run everything below in one shot with `scripts/run_checkpoint_tests.py`. Run everything below in one shot with `scripts/run_checkpoint_tests.py`.
Last verified against `main`: 2026-08-21 — migrations + pytest + all 10 Last verified against `main`: 2026-08-21 — migrations + pytest + all 11
Godot smoke tests, all clean. PC1's real playtest-found bugs (login form Godot smoke tests, all clean. PC1's real playtest-found bugs (login form
unreachable, 1px world cards, Play → Back losing the session) are covered unreachable, 1px world cards, Play → Back losing the session) are covered
by the smoke tests below so they can't silently regress. PC2 (basic by the smoke tests below so they can't silently regress. PC2 (basic
playable world + character + movement) landed 2026-08-20/21 — see playable world + character + movement) landed 2026-08-20/21 — see
`docs/checkpoints/` once a PC2 record exists, and the three new smoke `docs/checkpoints/` once a PC2 record exists, and the three new smoke
tests below (`smoke_test_test_level.gd`, `smoke_test_player.gd`, tests below (`smoke_test_test_level.gd`, `smoke_test_player.gd`,
`smoke_test_enter_world.gd`) for what's actually verified. `smoke_test_enter_world.gd`) for what's actually verified. The avatar
loader/customization system (`smoke_test_avatar_system.gd`) also landed
2026-08-21 — real glTF-based loading with graceful failure on missing
assets, verified since `assets/avatars/*.glb` model files don't exist yet.
## Backend — `backend/tests/` (pytest) ## Backend — `backend/tests/` (pytest)
@@ -53,8 +56,10 @@ these yet):
## Godot client — `scripts/smoke_test_*.gd` ## Godot client — `scripts/smoke_test_*.gd`
**11 smoke tests merged into `main`.** Ten need a live backend, one **12 smoke tests merged into `main`.** Ten need a live backend, one
(`smoke_test_network_client.gd`) needs a live dedicated server instead — (`smoke_test_network_client.gd`) needs a live dedicated server instead,
and one (`smoke_test_avatar_system.gd`) needs neither — it only exercises
local catalog/loadout/loader logic. Backend-dependent tests read
`AGP_API_BASE_URL` env var, falling back to the `AGP_API_BASE_URL` env var, falling back to the
`application/agp/api_base_url` project setting (default `application/agp/api_base_url` project setting (default
`http://127.0.0.1:8000`) if unset. `http://127.0.0.1:8000`) if unset.
@@ -72,6 +77,7 @@ these yet):
| `smoke_test_player.gd` | Loads `scenes/game.tscn` and confirms the player exists as a real `CharacterBody3D` with a `CollisionShape3D`+shape, a `CapsuleMesh`, and spawns at the level's `PlayerSpawn` marker position. Also asserts `JUMP_VELOCITY` is positive and a `Camera3D` exists and is `current` — both were real bugs found 2026-08-20/21 (jump velocity was negative, pushing the player down instead of up; no camera existed at all, so the game was unplayable even though the character could technically move). Structural checks only — real movement/jump *direction* correctness was verified separately via a throwaway headless physics simulation during development, not by an automated test that's part of this suite. | | `smoke_test_player.gd` | Loads `scenes/game.tscn` and confirms the player exists as a real `CharacterBody3D` with a `CollisionShape3D`+shape, a `CapsuleMesh`, and spawns at the level's `PlayerSpawn` marker position. Also asserts `JUMP_VELOCITY` is positive and a `Camera3D` exists and is `current` — both were real bugs found 2026-08-20/21 (jump velocity was negative, pushing the player down instead of up; no camera existed at all, so the game was unplayable even though the character could technically move). Structural checks only — real movement/jump *direction* correctness was verified separately via a throwaway headless physics simulation during development, not by an automated test that's part of this suite. |
| `smoke_test_enter_world.gd` | The full PC2 path end to end: register → seed a real world → load `world_list.tscn` → click the real "Enter World" button on a real card (found by walking the card's children, not by index, so it doesn't silently pass if the card layout changes) → confirms `scenes/game.tscn` actually becomes `current_scene` with the player spawned at the level's `PlayerSpawn` marker → sends a real `KEY_ESCAPE` input event (not a direct method call) and confirms it returns to `world_list.tscn`. This is the test that proves "browse → enter → walk around → leave" actually works as one continuous flow, not just that each piece works in isolation. | | `smoke_test_enter_world.gd` | The full PC2 path end to end: register → seed a real world → load `world_list.tscn` → click the real "Enter World" button on a real card (found by walking the card's children, not by index, so it doesn't silently pass if the card layout changes) → confirms `scenes/game.tscn` actually becomes `current_scene` with the player spawned at the level's `PlayerSpawn` marker → sends a real `KEY_ESCAPE` input event (not a direct method call) and confirms it returns to `world_list.tscn`. This is the test that proves "browse → enter → walk around → leave" actually works as one continuous flow, not just that each piece works in isolation. |
| `smoke_test_network_client.gd` | **Real two-process integration test** for `scripts/network_client.gd` (client-side prediction + reconciliation, docs/architecture/networking.md §5) — needs an actual dedicated server running first: `godot --headless --path . scenes/server.tscn &`, *not* the usual HTTP backend. Connects a real `NetworkClient` over ENet, confirms local input prediction moves the player immediately (no waiting on a round trip), confirms real snapshots arrive and get processed, then deliberately desyncs the predicted position by 9999 units and confirms the very next snapshot corrects it back — the actual proof reconciliation logic works, not just that it never had to fire. Known simplification, stated in the script's own docstring: the server's snapshot doesn't currently echo back a per-peer "last input seq processed," so reconciliation corrects against the *most recent* prediction rather than true tick-exact rollback-and-replay (docs/architecture/networking.md §5.2 step 4) — a real, scoped follow-up once the server echoes that ack, not something faked here. | | `smoke_test_network_client.gd` | **Real two-process integration test** for `scripts/network_client.gd` (client-side prediction + reconciliation, docs/architecture/networking.md §5) — needs an actual dedicated server running first: `godot --headless --path . scenes/server.tscn &`, *not* the usual HTTP backend. Connects a real `NetworkClient` over ENet, confirms local input prediction moves the player immediately (no waiting on a round trip), confirms real snapshots arrive and get processed, then deliberately desyncs the predicted position by 9999 units and confirms the very next snapshot corrects it back — the actual proof reconciliation logic works, not just that it never had to fire. Known simplification, stated in the script's own docstring: the server's snapshot doesn't currently echo back a per-peer "last input seq processed," so reconciliation corrects against the *most recent* prediction rather than true tick-exact rollback-and-replay (docs/architecture/networking.md §5.2 step 4) — a real, scoped follow-up once the server echoes that ack, not something faked here. |
| `smoke_test_avatar_system.gd` | Confirms `AvatarSystem` is a real autoload, the committed `assets/avatars/catalog.json` actually parses, `get_assets_for_slot("base_body")` returns real entries, an `AvatarLoadout` can set `base_body` and equip a real catalog accessory, the loadout round-trips through `to_json()`/`load_loadout_from_json()`, and — since `assets/avatars/*.glb` model files don't exist yet — that `AvatarLoader.load_base_body()` against a real catalog entry with a missing file fails *gracefully*: returns `null` and emits `load_error` rather than crashing. Visual rendering itself can't be verified until real assets exist. |
Run (once, to build the class cache): `godot --headless --path . --import`, Run (once, to build the class cache): `godot --headless --path . --import`,
then for each smoke test: `godot --headless --path . --script then for each smoke test: `godot --headless --path . --script
+1
View File
@@ -14,6 +14,7 @@ agp/api_base_url="http://127.0.0.1:8000"
[autoload] [autoload]
ApiClient="*res://scripts/api_client.gd" ApiClient="*res://scripts/api_client.gd"
AvatarSystem="*res://scripts/avatar/avatar_system.gd"
[display] [display]
+149
View File
@@ -0,0 +1,149 @@
[gd_scene load_steps=3 format=3 uid="uid://avatar_customization"]
[ext_resource type="Script" path="res://scripts/avatar/avatar_customization_ui.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"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
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)
+181
View File
@@ -0,0 +1,181 @@
## AvatarCatalog - Asset catalog management.
##
## Manages the mapping of asset IDs to their metadata and resource paths.
## Loads the catalog manifest from disk and provides lookup by ID and slot.
##
## The catalog is loaded from `res://assets/avatars/catalog.json` which follows
## the format specified in avatar.md §7.2.
class_name AvatarCatalog
extends RefCounted
## Emitted when the catalog is loaded successfully.
signal catalog_loaded
## Emitted when the catalog fails to load.
signal catalog_load_failed(error_message: String)
## Catalog manifest structure:
## { "assets": { "id": { "type", "slot", "path", "version", ... } } }
var _catalog: Dictionary = {}
var _loaded: bool = false
var _catalog_path: String = "res://assets/avatars/catalog.json"
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
## Load the catalog from disk. Call this before any lookups.
func load_catalog(path: String = _catalog_path) -> Error:
_catalog_path = path
_catalog = {}
var file = FileAccess.open(path, FileAccess.READ)
if file == null:
var err = FileAccess.get_open_error()
catalog_load_failed.emit("Cannot open catalog: %s (error %d)" % [path, err])
return err
var json_text: String = file.get_as_text()
file.close()
var result: Variant = JSON.parse_string(json_text)
if result == null or not result is Dictionary:
catalog_load_failed.emit("Invalid JSON in catalog: %s" % path)
return ERR_PARSE_ERROR
var json: Dictionary = result as Dictionary
if not json.has("assets"):
catalog_load_failed.emit("Catalog missing 'assets' key")
return ERR_INVALID_DATA
_catalog = json["assets"]
_loaded = true
catalog_loaded.emit()
return OK
## Check if the catalog has been loaded.
func is_loaded() -> bool:
return _loaded
## Resolve an asset by its ID.
## Returns a Dictionary with keys: type, slot, path, version, etc.
## Returns null if the asset ID is not found.
func resolve(id: String) -> Dictionary:
if not _loaded:
return {}
return _catalog.get(id, {})
## Check if an asset ID exists in the catalog.
func has_asset(id: String) -> bool:
if not _loaded:
return false
return _catalog.has(id)
## Get all assets that belong to a given slot.
## Returns an array of asset ID strings.
func get_assets_by_slot(slot: String) -> Array[String]:
if not _loaded:
return []
var result: Array[String] = []
for id in _catalog:
var asset: Dictionary = _catalog[id]
if asset.get("slot") == slot:
result.append(id)
return result
## Get all asset IDs in the catalog.
func get_all_ids() -> Array[String]:
if not _loaded:
return []
return _catalog.keys()
## Validate a loadout against the catalog.
## Returns a Dictionary with "valid" (bool) and "errors" (Array[String]).
func validate_loadout(loadout: AvatarLoadout) -> Dictionary:
var errors: Array[String] = []
# Check base_body
if loadout.base_body.is_empty():
errors.append("Loadout missing base_body")
elif not has_asset(loadout.base_body):
errors.append("Unknown base_body asset: %s" % loadout.base_body)
else:
var base: Dictionary = resolve(loadout.base_body)
if base.get("type") != "base_body":
errors.append("base_body asset '%s' is not type 'base_body'" % loadout.base_body)
# Check each slot
for slot_id in loadout.slots:
var slot_value = loadout.slots[slot_id]
if slot_value == null:
continue
if slot_id == "accessory":
# Accessory is an array
if not slot_value is Array:
errors.append("Slot '%s' must be an array" % slot_id)
continue
for item_id in slot_value:
if not has_asset(item_id):
errors.append("Unknown accessory asset: %s" % item_id)
else:
var asset: Dictionary = resolve(item_id)
if asset.get("slot") != slot_id:
errors.append("Accessory '%s' slot '%s' does not match slot '%s'" % [
item_id, asset.get("slot"), slot_id
])
else:
# Exclusive slot - single value
if not slot_value is String:
errors.append("Slot '%s' must be a string or null" % slot_id)
continue
if not has_asset(slot_value):
errors.append("Unknown asset in slot '%s': %s" % [slot_id, slot_value])
else:
var asset: Dictionary = resolve(slot_value)
if asset.get("slot") != slot_id:
errors.append("Asset '%s' slot '%s' does not match slot '%s'" % [
slot_value, asset.get("slot"), slot_id
])
return {
"valid": errors.is_empty(),
"errors": errors
}
## Compute a hash of the loadout for change detection.
## Returns a 32-bit hash value (GDScript has no fixed-width uint type — int
## is 64-bit signed, the value is just constrained to 32 bits of range).
static func compute_loadout_hash(loadout: AvatarLoadout) -> int:
var hasher = HashingContext.new()
hasher.start(HashingContext.HASH_SHA256)
hasher.update(loadout.base_body.to_utf8_buffer())
for slot_id in loadout.slots:
var slot_value = loadout.slots[slot_id]
hasher.update(slot_id.to_utf8_buffer())
if slot_value is Array:
for item in slot_value:
hasher.update(item.to_utf8_buffer())
elif slot_value is String:
hasher.update(slot_value.to_utf8_buffer())
var hash_bytes = hasher.finish()
return hash_bytes[0] | (hash_bytes[1] << 8) | (hash_bytes[2] << 16) | (hash_bytes[3] << 24)
## Get the catalog path.
func get_catalog_path() -> String:
return _catalog_path
+1
View File
@@ -0,0 +1 @@
uid://ceyugve7rjxqs
+465
View File
@@ -0,0 +1,465 @@
## AvatarCustomizationUI
##
## The in-game avatar customization screen. Lets the player:
## - See a live preview of their avatar.
## - Browse and equip/unequip items per slot.
## - Adjust skin tone.
## - Save / load loadouts.
##
## The UI is built programmatically so it works without a pre-authored .tscn.
## It is a Control node that can be added to any scene.
class_name AvatarCustomizationUI
extends Control
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
## Emitted when the player closes the customization screen.
signal closed
## Emitted when the loadout changes (after equip/unequip/skin-tone).
signal loadout_changed(loadout: AvatarLoadout)
# ---------------------------------------------------------------------------
# Exports / config
# ---------------------------------------------------------------------------
## Path to the asset catalog manifest.
@export var catalog_path: String = "res://assets/avatars/catalog.json"
## The default base body to use when none is equipped.
@export var default_base_body: String = "base_human_male_01"
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
## The current loadout being edited.
var loadout: AvatarLoadout = null
## The AvatarSystem singleton (set in _ready).
var _system: Node = null
## The catalog (owned by AvatarSystem).
var _catalog: AvatarCatalog = null
## The loader (owned by AvatarSystem).
var _loader: AvatarLoader = null
## The visualizer that renders the preview.
var _visualizer: AvatarVisualizer = null
## The preview viewport container.
var _preview_container: SubViewportContainer = null
## The preview viewport.
var _preview_viewport: SubViewport = null
## The preview camera.
var _preview_camera: Camera3D = null
## The preview avatar root.
var _preview_root: Node3D = null
## Currently selected slot id.
var _selected_slot: String = ""
## UI node references.
var _slot_list: VBoxContainer = null
var _item_list: VBoxContainer = null
var _slot_label: Label = null
var _item_label: Label = null
var _skin_tone_slider: HSlider = null
var _status_label: Label = null
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
# Grab the AvatarSystem autoload.
_system = get_node_or_null("/root/AvatarSystem")
if _system == null:
push_error("AvatarCustomizationUI: AvatarSystem autoload not found")
return
_catalog = _system.catalog
_loader = _system.loader
# Load the catalog if not already loaded.
if _catalog != null and not _catalog.is_loaded():
_catalog.load_catalog(catalog_path)
# Build the UI.
_build_ui()
# Create a fresh loadout.
loadout = AvatarLoadout.new()
loadout.base_body = default_base_body
# Initialize the visualizer.
_visualizer = AvatarVisualizer.new()
# Populate the slot list.
_populate_slots()
# Select the first slot.
if AvatarSlotRegistry.SLOT_IDS.size() > 0:
_select_slot(AvatarSlotRegistry.SLOT_IDS[0])
# Refresh the preview.
_refresh_preview()
# ---------------------------------------------------------------------------
# UI construction
# ---------------------------------------------------------------------------
## Build the entire UI tree programmatically.
func _build_ui() -> void:
# Root layout: horizontal split (preview | controls).
var root_hbox := HBoxContainer.new()
root_hbox.name = "RootLayout"
root_hbox.set_anchors_preset(Control.PRESET_FULL_RECT)
root_hbox.add_theme_constant_override("separation", 16)
add_child(root_hbox)
# --- Left: preview ---
var preview_panel := PanelContainer.new()
preview_panel.name = "PreviewPanel"
preview_panel.custom_minimum_size = Vector2(480, 0)
root_hbox.add_child(preview_panel)
var preview_margin := MarginContainer.new()
preview_margin.add_theme_constant_override("margin_left", 8)
preview_margin.add_theme_constant_override("margin_right", 8)
preview_margin.add_theme_constant_override("margin_top", 8)
preview_margin.add_theme_constant_override("margin_bottom", 8)
preview_panel.add_child(preview_margin)
_preview_container = SubViewportContainer.new()
_preview_container.name = "PreviewViewportContainer"
_preview_container.stretch = true
_preview_container.custom_minimum_size = Vector2(464, 560)
preview_margin.add_child(_preview_container)
_preview_viewport = SubViewport.new()
_preview_viewport.name = "PreviewViewport"
_preview_viewport.size = Vector2i(464, 560)
_preview_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
_preview_container.add_child(_preview_viewport)
# 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))
_preview_viewport.add_child(_preview_camera)
# Avatar root inside the viewport.
_preview_root = Node3D.new()
_preview_root.name = "AvatarPreviewRoot"
_preview_viewport.add_child(_preview_root)
# A light so the avatar is visible.
var light := DirectionalLight3D.new()
light.name = "PreviewLight"
light.rotation_degrees = Vector3(-45, 30, 0)
_preview_viewport.add_child(light)
# --- Right: controls ---
var controls_panel := PanelContainer.new()
controls_panel.name = "ControlsPanel"
controls_panel.custom_minimum_size = Vector2(520, 0)
root_hbox.add_child(controls_panel)
var controls_margin := MarginContainer.new()
controls_margin.add_theme_constant_override("margin_left", 12)
controls_margin.add_theme_constant_override("margin_right", 12)
controls_margin.add_theme_constant_override("margin_top", 12)
controls_margin.add_theme_constant_override("margin_bottom", 12)
controls_panel.add_child(controls_margin)
var controls_vbox := VBoxContainer.new()
controls_vbox.name = "Controls"
controls_vbox.add_theme_constant_override("separation", 8)
controls_margin.add_child(controls_vbox)
# Title.
var title := Label.new()
title.text = "Avatar Customization"
title.add_theme_font_size_override("font_size", 24)
controls_vbox.add_child(title)
# Slot selector.
var slot_header := Label.new()
slot_header.text = "Slots"
slot_header.add_theme_font_size_override("font_size", 16)
controls_vbox.add_child(slot_header)
var slot_scroll := ScrollContainer.new()
slot_scroll.custom_minimum_size = Vector2(0, 160)
controls_vbox.add_child(slot_scroll)
_slot_list = VBoxContainer.new()
_slot_list.name = "SlotList"
_slot_list.add_theme_constant_override("separation", 4)
slot_scroll.add_child(_slot_list)
# Item selector for the selected slot.
var item_header := Label.new()
item_header.text = "Items"
item_header.add_theme_font_size_override("font_size", 16)
controls_vbox.add_child(item_header)
var item_scroll := ScrollContainer.new()
item_scroll.custom_minimum_size = Vector2(0, 200)
controls_vbox.add_child(item_scroll)
_item_list = VBoxContainer.new()
_item_list.name = "ItemList"
_item_list.add_theme_constant_override("separation", 4)
item_scroll.add_child(_item_list)
# Skin tone control.
var skin_header := Label.new()
skin_header.text = "Skin Tone"
skin_header.add_theme_font_size_override("font_size", 16)
controls_vbox.add_child(skin_header)
_skin_tone_slider = HSlider.new()
_skin_tone_slider.name = "SkinToneSlider"
_skin_tone_slider.min_value = 0.0
_skin_tone_slider.max_value = 1.0
_skin_tone_slider.step = 0.01
_skin_tone_slider.value = 0.5
_skin_tone_slider.custom_minimum_size = Vector2(0, 24)
_skin_tone_slider.value_changed.connect(_on_skin_tone_changed)
controls_vbox.add_child(_skin_tone_slider)
# Status / error label.
_status_label = Label.new()
_status_label.name = "StatusLabel"
_status_label.text = ""
_status_label.add_theme_color_override("font_color", Color(1, 0.4, 0.4))
_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
controls_vbox.add_child(_status_label)
# Action buttons.
var button_row := HBoxContainer.new()
button_row.add_theme_constant_override("separation", 8)
controls_vbox.add_child(button_row)
var save_btn := Button.new()
save_btn.text = "Save Loadout"
save_btn.pressed.connect(_on_save_pressed)
button_row.add_child(save_btn)
var load_btn := Button.new()
load_btn.text = "Load Loadout"
load_btn.pressed.connect(_on_load_pressed)
button_row.add_child(load_btn)
var close_btn := Button.new()
close_btn.text = "Close"
close_btn.pressed.connect(_on_close_pressed)
button_row.add_child(close_btn)
# ---------------------------------------------------------------------------
# Slot / item population
# ---------------------------------------------------------------------------
## Populate the slot list with buttons for each slot.
func _populate_slots() -> void:
if _slot_list == null:
return
# Clear existing children.
for child in _slot_list.get_children():
child.queue_free()
for slot_id in AvatarSlotRegistry.SLOT_IDS:
var btn := Button.new()
btn.name = "Slot_%s" % slot_id
btn.text = slot_id
btn.toggle_mode = true
btn.pressed.connect(_on_slot_button_pressed.bind(slot_id))
_slot_list.add_child(btn)
## Populate the item list for the currently selected slot.
func _populate_items() -> void:
if _item_list == null or _selected_slot.is_empty():
return
# Clear existing children.
for child in _item_list.get_children():
child.queue_free()
if _catalog == null:
return
# Get assets for this slot.
var assets: Array[String] = _catalog.get_assets_by_slot(_selected_slot)
# "None" button to unequip.
var none_btn := Button.new()
none_btn.text = "(none)"
none_btn.pressed.connect(_on_equip_pressed.bind(""))
_item_list.add_child(none_btn)
for asset_id in assets:
var btn := Button.new()
btn.text = asset_id
btn.pressed.connect(_on_equip_pressed.bind(asset_id))
_item_list.add_child(btn)
# ---------------------------------------------------------------------------
# Slot selection
# ---------------------------------------------------------------------------
## Handle a slot button press.
func _on_slot_button_pressed(slot_id: String) -> void:
_select_slot(slot_id)
## Select a slot and refresh the item list.
func _select_slot(slot_id: String) -> void:
_selected_slot = slot_id
# Update button toggle states.
if _slot_list != null:
for child in _slot_list.get_children():
if child is Button:
var btn := child as Button
btn.button_pressed = (btn.name == "Slot_%s" % slot_id)
_populate_items()
# ---------------------------------------------------------------------------
# Equip / unequip
# ---------------------------------------------------------------------------
## Handle an equip/unequip action for the selected slot.
func _on_equip_pressed(asset_id: String) -> void:
if loadout == null or _selected_slot.is_empty():
return
if _selected_slot == "accessory":
if asset_id.is_empty():
# Clear all accessories.
loadout.slots["accessory"] = []
else:
loadout.add_accessory(asset_id)
else:
loadout.set_equipment(_selected_slot, asset_id)
_refresh_preview()
loadout_changed.emit(loadout)
# ---------------------------------------------------------------------------
# Preview refresh
# ---------------------------------------------------------------------------
## Rebuild the avatar preview from the current loadout.
func _refresh_preview() -> void:
if _visualizer == null or _preview_root == null:
return
# Clear the preview root.
for child in _preview_root.get_children():
child.queue_free()
# Validate the loadout.
if loadout == null or loadout.base_body.is_empty():
_status_label.text = "No base body selected."
return
# Build the render tree.
_visualizer.load_loadout(loadout, _preview_root)
# Show any validation errors.
if not loadout.validation_errors.is_empty():
_status_label.text = "\n".join(loadout.validation_errors)
else:
_status_label.text = ""
# ---------------------------------------------------------------------------
# Skin tone
# ---------------------------------------------------------------------------
func _on_skin_tone_changed(value: float) -> void:
if loadout == null:
return
# Map slider 0..1 to a skin tone color range.
var t := value
var skin := Color(
0.6 + 0.3 * t, # R
0.4 + 0.2 * t, # G
0.3 + 0.15 * t, # B
1.0
)
loadout.skin_tone = skin
_refresh_preview()
loadout_changed.emit(loadout)
# ---------------------------------------------------------------------------
# Save / load
# ---------------------------------------------------------------------------
func _on_save_pressed() -> void:
if loadout == null:
return
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."
else:
_status_label.text = "Failed to save loadout."
func _on_load_pressed() -> void:
if not FileAccess.file_exists("user://loadout.json"):
_status_label.text = "No saved loadout found."
return
var file := FileAccess.open("user://loadout.json", FileAccess.READ)
if file == null:
_status_label.text = "Failed to open saved loadout."
return
var json := file.get_as_text()
file.close()
var loaded := AvatarLoadout.from_json(json)
if not loaded.validation_errors.is_empty():
_status_label.text = "Saved loadout invalid: %s" % "\n".join(loaded.validation_errors)
return
loadout = loaded
_refresh_preview()
loadout_changed.emit(loadout)
_status_label.text = "Loadout loaded."
# ---------------------------------------------------------------------------
# Close
# ---------------------------------------------------------------------------
func _on_close_pressed() -> void:
closed.emit()
queue_free()
@@ -0,0 +1 @@
uid://crqtef5rifgrv
+563
View File
@@ -0,0 +1,563 @@
## AvatarLoader - glTF/GLB import, skeleton merge, and skin re-targeting.
##
## Loads base body and attachment GLB assets, extracts skeletons and meshes,
## and re-targets attachment skins to the canonical skeleton by bone name.
##
## Per the avatar architecture spec (§6.2, §6.3):
## - The base body carries the canonical skeleton.
## - Attachments are skinned to the same skeleton by name-based re-targeting.
## - Static accessories are parented to their mount bone.
## - Body-part slots hide the corresponding base mesh.
class_name AvatarLoader
extends Node
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
## Maximum number of attachments allowed in the accessory slot.
const MAX_ACCESSORY_COUNT := 8
## Skeleton bone names that form the canonical rig (§3.1).
const CANONICAL_BONES := [
"Root",
"Hips",
"Spine",
"Chest",
"Neck",
"Head",
"HeadTop",
"Jaw",
"LeftEye",
"RightEye",
"LeftShoulder",
"LeftUpperArm",
"LeftLowerArm",
"LeftHand",
"RightShoulder",
"RightUpperArm",
"RightLowerArm",
"RightHand",
"LeftUpperLeg",
"LeftLowerLeg",
"LeftFoot",
"RightUpperLeg",
"RightLowerLeg",
"RightFoot",
]
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
## Emitted when a base body has been loaded successfully.
signal base_body_loaded(avatar_node: Node3D)
## Emitted when an attachment has been loaded and merged.
signal attachment_loaded(slot_id: String, attachment_node: Node3D)
## Emitted on error during loading.
signal load_error(error_msg: String)
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
## The fully assembled avatar node (Skeleton3D + meshes + attachments).
var avatar_node: Node3D = null
## The Skeleton3D belonging to the avatar.
var skeleton: Skeleton3D = null
## Mapping of bone name -> bone index in the skeleton.
var _bone_index_map: Dictionary = {}
## Loaded base body meshes, keyed by slot id.
var _base_meshes: Dictionary = {}
## Loaded attachment nodes, keyed by slot id.
var _attachments: Dictionary = {}
## Whether the base body has been loaded.
var is_base_loaded: bool = false
## The avatar catalog reference (set by AvatarSystem).
var catalog: AvatarCatalog = null
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
## Initialize the loader with a reference to the avatar catalog.
func init_loader(cat: AvatarCatalog) -> void:
catalog = cat
## Load the base body asset identified by [param base_body_id].
## Returns the AvatarNode3D on success, or null on failure.
func load_base_body(base_body_id: String) -> Node3D:
if catalog == null:
load_error.emit("AvatarCatalog not set on AvatarLoader")
return null
var asset_meta = catalog.resolve(base_body_id)
if asset_meta == null:
load_error.emit("Unknown base_body asset: %s" % base_body_id)
return null
if asset_meta.type != "base_body":
load_error.emit("Asset '%s' is not a base_body, got '%s'" % [
base_body_id, asset_meta.type])
return null
var path: String = asset_meta.path
var glb_data: PackedByteArray = _load_file(path)
if glb_data.is_empty():
load_error.emit("Failed to read GLB file: %s" % path)
return null
avatar_node = _parse_glb_as_avatar(glb_data, path)
if avatar_node == null:
load_error.emit("Failed to parse GLB: %s" % path)
return null
skeleton = avatar_node.get_node_or_null("Skeleton3D") as Skeleton3D
if skeleton == null:
load_error.emit("No Skeleton3D found in base body: %s" % path)
avatar_node.queue_free()
return null
# Build bone index map for fast lookups.
_bone_index_map.clear()
for i in skeleton.get_bone_count():
var bone_name: String = skeleton.get_bone_name(i)
if not bone_name.is_empty():
_bone_index_map[bone_name] = i
# Extract base body meshes and store them.
_extract_base_meshes(avatar_node)
is_base_loaded = true
base_body_loaded.emit(avatar_node)
return avatar_node
## Load and merge an attachment into the avatar.
## [param slot_id] is the slot to equip into.
## [param asset_id] is the catalog id of the attachment.
## Returns the attachment Node3D on success, or null on failure.
func load_attachment(slot_id: String, asset_id: String) -> Node3D:
if not is_base_loaded:
load_error.emit("Cannot load attachment: base body not loaded")
return null
if catalog == null:
load_error.emit("AvatarCatalog not set on AvatarLoader")
return null
var asset_meta = catalog.resolve(asset_id)
if asset_meta == null:
load_error.emit("Unknown attachment asset: %s" % asset_id)
return null
if asset_meta.type != "attachment":
load_error.emit("Asset '%s' is not an attachment, got '%s'" % [
asset_id, asset_meta.type])
return null
# Check slot exclusivity for accessory slot.
if slot_id == "accessory":
var current_accessories: Array[String] = _attachments.get("accessory", [])
if current_accessories.size() >= MAX_ACCESSORY_COUNT:
load_error.emit("Accessory slot full (%d/%d)" % [
MAX_ACCESSORY_COUNT, MAX_ACCESSORY_COUNT])
return null
var path: String = asset_meta.path
var glb_data: PackedByteArray = _load_file(path)
if glb_data.is_empty():
load_error.emit("Failed to read GLB file: %s" % path)
return null
var attachment_node = _parse_glb_as_attachment(glb_data, path, slot_id)
if attachment_node == null:
load_error.emit("Failed to parse attachment GLB: %s" % path)
return null
# Retarget skin to the canonical skeleton.
_retarget_skin(attachment_node)
# Parent static accessories to their mount bone.
_parent_attachment(attachment_node, slot_id, asset_meta)
# Store reference.
if slot_id == "accessory":
var list: Array[String] = _attachments.get("accessory", [])
list.append(asset_id)
_attachments["accessory"] = list
else:
_attachments[slot_id] = attachment_node
attachment_loaded.emit(slot_id, attachment_node)
return attachment_node
## Remove an attachment from the given slot.
func unload_attachment(slot_id: String) -> void:
if slot_id == "accessory":
var list: Array[String] = _attachments.get("accessory", [])
for asset_id in list:
var node = _attachments.get(asset_id)
if node and node is Node:
node.queue_free()
_attachments.erase("accessory")
else:
var node = _attachments.get(slot_id)
if node and node is Node:
node.queue_free()
_attachments.erase(slot_id)
## Hide or show the base mesh for a given body-part slot.
func set_base_mesh_visible(slot_id: String, visible: bool) -> void:
var mesh = _base_meshes.get(slot_id)
if mesh and mesh is Node3D:
mesh.visible = visible
## Get the current loadout as a dictionary (for serialization).
func get_current_loadout() -> Dictionary:
var loadout: Dictionary = {
"base_body": "",
"slots": {},
}
# Determine base body id from catalog.
if is_base_loaded and catalog != null:
# Walk through catalog to find which base_body loaded.
for id in catalog.get_all_ids():
var meta = catalog.resolve(id)
if meta and meta.type == "base_body":
loadout.base_body = id
break
# Collect equipped attachments.
for slot_id in _attachments:
if slot_id == "accessory":
loadout.slots[slot_id] = _attachments[slot_id] as Array
else:
var node = _attachments[slot_id]
if node is Node3D:
# Find the asset id by matching the node's name.
var aid: String = node.name
loadout.slots[slot_id] = aid
return loadout
## Reset the avatar to an unloaded state.
func reset() -> void:
if avatar_node and avatar_node is Node:
avatar_node.queue_free()
avatar_node = null
skeleton = null
_bone_index_map.clear()
_base_meshes.clear()
_attachments.clear()
is_base_loaded = false
# ---------------------------------------------------------------------------
# Internal: GLB Parsing
# ---------------------------------------------------------------------------
## Parse a GLB byte array into an avatar Node3D structure.
## Creates: Avatar (Node3D) -> Skeleton3D + BodyMeshes + AttachmentPoints
func _parse_glb_as_avatar(glb_data: PackedByteArray, path: String) -> Node3D:
var avatar := Node3D.new()
avatar.name = "Avatar"
# Create the skeleton container.
var skeleton_node := Skeleton3D.new()
skeleton_node.name = "Skeleton3D"
skeleton_node.position = Vector3(0, 0, 0)
avatar.add_child(skeleton_node)
# Create attachment points container.
var attach_points := Node3D.new()
attach_points.name = "AttachmentPoints"
avatar.add_child(attach_points)
# Create body meshes container.
var body_meshes := Node3D.new()
body_meshes.name = "BodyMeshes"
avatar.add_child(body_meshes)
# Create attachments container.
var attachments_container := Node3D.new()
attachments_container.name = "Attachments"
avatar.add_child(attachments_container)
# Parse the GLB and extract skeleton + meshes.
var gltf_document := GLTFDocument.new()
var gltf_state := GLTFState.new()
var parse_result := gltf_document.append_from_buffer(glb_data, "", gltf_state)
if parse_result != OK:
push_error("GLB parse error: %s" % error_string(parse_result))
avatar.queue_free()
return null
# Generate the actual scene tree from the parsed GLTF state.
var scene_root := gltf_document.generate_scene(gltf_state)
if scene_root == null:
push_error("Failed to generate scene from GLB: %s" % path)
avatar.queue_free()
return null
# Merge GLB nodes into our avatar structure (recurses into children).
if scene_root is Node3D:
_merge_node_into_avatar(scene_root, avatar, body_meshes,
attach_points, attachments_container)
return avatar
## Parse a GLB byte array into an attachment Node3D.
func _parse_glb_as_attachment(glb_data: PackedByteArray, path: String,
slot_id: String) -> Node3D:
var gltf_document := GLTFDocument.new()
var gltf_state := GLTFState.new()
var parse_result := gltf_document.append_from_buffer(glb_data, "", gltf_state)
if parse_result != OK:
push_error("GLB parse error: %s" % error_string(parse_result))
return null
var scene_root := gltf_document.generate_scene(gltf_state)
if scene_root == null:
push_error("Failed to generate scene from attachment GLB: %s" % path)
return null
var attachment := Node3D.new()
attachment.name = "%s_%s" % [slot_id, path.get_file().get_basename()]
if scene_root is Node3D:
attachment.add_child(scene_root.duplicate())
return attachment
## Merge a GLB node tree into the avatar's container structure.
func _merge_node_into_avatar(node: Node3D, avatar: Node3D,
body_meshes: Node3D, attach_points: Node3D,
attachments_container: Node3D) -> void:
var node_name := node.name
# Identify skeleton nodes.
if node is Skeleton3D:
var src_skel := node as Skeleton3D
var dst_skel := avatar.get_node_or_null("Skeleton3D") as Skeleton3D
if dst_skel:
# Copy bones from source skeleton to destination.
for i in src_skel.get_bone_count():
var bone_name := src_skel.get_bone_name(i)
var bone_pose := src_skel.get_bone_pose(i)
var parent_idx := src_skel.get_bone_parent(i)
if dst_skel.has_bone(bone_name):
# Bone already exists (canonical).
dst_skel.set_bone_pose(i, bone_pose)
if parent_idx >= 0:
var parent_name := src_skel.get_bone_name(parent_idx)
var dst_parent_idx := dst_skel.find_bone(parent_name)
if dst_parent_idx >= 0:
dst_skel.set_bone_parent(i, dst_parent_idx)
else:
# Add new bone.
var new_idx := dst_skel.add_bone(bone_name)
dst_skel.set_bone_pose(new_idx, bone_pose)
if parent_idx >= 0:
var parent_name := src_skel.get_bone_name(parent_idx)
var dst_parent_idx := dst_skel.find_bone(parent_name)
if dst_parent_idx >= 0:
dst_skel.set_bone_parent(new_idx, dst_parent_idx)
# Skinned meshes are commonly children of the skeleton node in a GLB
# export, not siblings — fall through to the recursion below instead
# of returning, so they still get merged in.
for child in src_skel.get_children():
if child is Node3D:
_merge_node_into_avatar(child, avatar, body_meshes,
attach_points, attachments_container)
return
# Identify attachment point nodes (empty nodes named attach_*).
if node_name.begins_with("attach_"):
var cloned := node.duplicate()
cloned.name = node_name
attach_points.add_child(cloned)
return
# Identify mesh nodes.
if node is MeshInstance3D:
var mesh_inst := node as MeshInstance3D
var cloned := mesh_inst.duplicate() as MeshInstance3D
# Determine which container to place it in.
if node_name == "Body" or node_name.ends_with("_Mesh"):
body_meshes.add_child(cloned)
else:
attachments_container.add_child(cloned)
return
# For other nodes, recurse into children.
for child in node.get_children():
if child is Node3D:
_merge_node_into_avatar(child, avatar, body_meshes,
attach_points, attachments_container)
## Extract base body meshes from the avatar and store them by slot.
func _extract_base_meshes(avatar: Node3D) -> void:
var body_meshes := avatar.get_node_or_null("BodyMeshes") as Node3D
if body_meshes == null:
return
for child in body_meshes.get_children():
if child is MeshInstance3D:
var mesh_name := child.name
var slot_id := _mesh_name_to_slot(mesh_name)
if slot_id != "":
_base_meshes[slot_id] = child
## Map a mesh name to a slot id based on naming conventions (§4.3).
func _mesh_name_to_slot(mesh_name: String) -> String:
if mesh_name == "Body":
# Generic body mesh - covers torso, legs, arms.
return "torso"
if mesh_name == "head_Mesh":
return "head"
if mesh_name == "torso_Mesh":
return "torso"
if mesh_name == "legs_Mesh":
return "legs"
if mesh_name == "left_arm_Mesh":
return "left_arm"
if mesh_name == "right_arm_Mesh":
return "right_arm"
return ""
# ---------------------------------------------------------------------------
# Internal: Skin Retargeting
# ---------------------------------------------------------------------------
## Retarget the skin weights of an attachment node to the canonical skeleton.
## Walks all MeshInstance3D children and remaps joint indices by bone name.
func _retarget_skin(attachment_node: Node3D) -> void:
if skeleton == null:
return
for child in attachment_node.get_children():
if child is MeshInstance3D:
var mesh_inst := child as MeshInstance3D
var mesh := mesh_inst.mesh
if mesh == null:
continue
# Note: MeshInstance3D.mesh is always a Mesh, never a MultiMesh —
# MultiMesh skinning goes through MultiMeshInstance3D.multimesh,
# a completely separate node/property, not reachable here.
if mesh is ArrayMesh:
var arr_mesh := mesh as ArrayMesh
var surface_count := arr_mesh.get_surface_count()
for surface_idx in surface_count:
var arrays := arr_mesh.surface_get_arrays(surface_idx)
var joints_array := arrays[Mesh.ARRAY_BONES] as PackedInt32Array
var weights_array := arrays[Mesh.ARRAY_WEIGHTS] as PackedFloat32Array
if joints_array.is_empty():
continue
# Remap joint indices by bone name.
var new_joints := PackedInt32Array()
for i in range(0, joints_array.size(), 4):
for j in 4:
var old_joint := joints_array[i + j]
if old_joint < 0:
new_joints.append(0)
else:
var bone_name := skeleton.get_bone_name(old_joint)
var new_idx: int = _bone_index_map.get(bone_name, 0)
new_joints.append(new_idx)
arrays[Mesh.ARRAY_BONES] = new_joints
arr_mesh.surface_set_arrays(surface_idx, arrays)
## Parent an attachment node to its mount bone in the skeleton.
func _parent_attachment(attachment_node: Node3D, slot_id: String,
asset_meta: Dictionary) -> void:
if skeleton == null:
return
var mount_bone: String = asset_meta.get("mount_bone", "")
if mount_bone.is_empty():
# Try to infer from slot.
mount_bone = _slot_to_mount_bone(slot_id)
if mount_bone.is_empty():
return
var bone_idx := skeleton.find_bone(mount_bone)
if bone_idx < 0:
return
# Get the world transform of the bone at the time of attachment.
var bone_pose := skeleton.get_bone_pose(bone_idx)
var bone_transform := skeleton.global_transform * bone_pose
# Position the attachment at the bone's location.
attachment_node.global_transform = bone_transform
## Convert a slot id to its canonical mount bone name (§3.2).
func _slot_to_mount_bone(slot_id: String) -> String:
match slot_id:
"head": return "Head"
"hair": return "HeadTop"
"face": return "Jaw"
"torso", "chest", "back": return "Chest"
"left_arm": return "LeftUpperArm"
"right_arm": return "RightUpperArm"
"left_hand": return "LeftHand"
"right_hand": return "RightHand"
"legs": return "Hips"
"pants": return "Hips"
"left_foot": return "LeftFoot"
"right_foot": return "RightFoot"
"accessory": return "Root"
_: return ""
# ---------------------------------------------------------------------------
# Internal: File Loading
# ---------------------------------------------------------------------------
## Load a file from the given path as a PackedByteArray.
func _load_file(path: String) -> PackedByteArray:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return PackedByteArray()
var data := file.get_buffer(file.get_length())
file.close()
return data
+1
View File
@@ -0,0 +1 @@
uid://br6x7f83dkrw2
+306
View File
@@ -0,0 +1,306 @@
## AvatarLoadout - Serializable loadout model + validation.
##
## Represents a complete avatar composition: a base body plus equipped items
## per slot. Validates slot exclusivity and references against a catalog.
##
## See docs/architecture/avatar.md §5 for the loadout JSON format.
class_name AvatarLoadout
extends RefCounted
## Maximum number of accessories allowed in the accessory slot.
const MAX_ACCESSORIES: int = 8
## The base body asset ID.
var base_body: String = ""
## Equipped items per slot. Keys are slot IDs from AvatarSlotRegistry.
## Exclusive slots hold a single asset ID (or null).
## The "accessory" slot holds an array of asset IDs.
var slots: Dictionary = {}
## Validation errors found during the last validate() call.
var validation_errors: Array[String] = []
## Skin tone color override (default: transparent = use asset colors).
var skin_tone: Color = Color.TRANSPARENT
# ---------------------------------------------------------------------------
# Inner classes
# ---------------------------------------------------------------------------
## Represents a single equipped item in a slot.
## Used by consumers to iterate over equipped items.
class SlotItem:
## The slot this item belongs to.
var slot_id: String
## The catalog asset ID of the equipped item.
var asset_id: String
func _init(p_slot_id: String, p_asset_id: String) -> void:
slot_id = p_slot_id
asset_id = p_asset_id
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _init() -> void:
validation_errors = []
# ---------------------------------------------------------------------------
# Serialization
# ---------------------------------------------------------------------------
## Create a loadout from a JSON string.
static func from_json(json_string: String) -> AvatarLoadout:
var loadout := AvatarLoadout.new()
var data: Variant = JSON.parse_string(json_string)
if not (data is Dictionary):
loadout.validation_errors.append("Loadout JSON root is not an object")
return loadout
var root: Dictionary = data as Dictionary
# Parse base_body.
if root.has("base_body"):
var bb = root["base_body"]
if typeof(bb) == TYPE_STRING:
loadout.base_body = bb as String
else:
loadout.validation_errors.append("base_body must be a string")
# Parse slots.
if root.has("slots"):
var slots_data = root["slots"]
if typeof(slots_data) == TYPE_DICTIONARY:
var parsed_slots: Dictionary = slots_data as Dictionary
for slot_id in parsed_slots:
if slot_id is String:
loadout.slots[slot_id] = parsed_slots[slot_id]
else:
loadout.validation_errors.append("slots must be an object")
else:
loadout.validation_errors.append("Missing 'slots' in loadout JSON")
# Parse skin_tone.
if root.has("skin_tone"):
var st = root["skin_tone"]
if typeof(st) == TYPE_DICTIONARY:
var tone: Dictionary = st as Dictionary
loadout.skin_tone = Color(
float(tone.get("r", 0.0)),
float(tone.get("g", 0.0)),
float(tone.get("b", 0.0)),
float(tone.get("a", 1.0))
)
return loadout
## Serialize this loadout to a JSON string.
func to_json() -> String:
var data := {
"base_body": base_body,
"slots": {},
}
for slot_id in AvatarSlotRegistry.SLOT_IDS:
if slot_id == "accessory":
data["slots"][slot_id] = slots.get(slot_id, [])
else:
data["slots"][slot_id] = slots.get(slot_id, null)
# Include skin_tone if set.
if skin_tone != Color.TRANSPARENT:
data["skin_tone"] = {
"r": skin_tone.r,
"g": skin_tone.g,
"b": skin_tone.b,
"a": skin_tone.a,
}
return JSON.stringify(data, " ")
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
## Validate this loadout against the slot registry.
## Returns true if valid; populates validation_errors if not.
func validate() -> bool:
validation_errors.clear()
# Check base_body.
if base_body.is_empty():
validation_errors.append("base_body is required")
# Check each known slot.
for slot_id in AvatarSlotRegistry.SLOT_IDS:
var slot_def = AvatarSlotRegistry.get_slot(slot_id)
if slot_def == null:
validation_errors.append("Unknown slot: %s" % slot_id)
continue
var value = slots.get(slot_id)
if slot_def.exclusive:
# Exclusive slots: must be null or a string.
if value != null and typeof(value) != TYPE_STRING:
validation_errors.append(
"Slot '%s' must be null or a string, got %s" % [
slot_id, typeof(value)
]
)
else:
# Non-exclusive (accessory): must be null or an array.
if value != null and typeof(value) != TYPE_ARRAY:
validation_errors.append(
"Slot '%s' must be null or an array, got %s" % [
slot_id, typeof(value)
]
)
elif value is Array:
if slot_id == "accessory" and (value as Array).size() > MAX_ACCESSORIES:
validation_errors.append(
"Accessory count (%d) exceeds maximum (%d)" % [
(value as Array).size(), MAX_ACCESSORIES
]
)
# Check for duplicate accessories.
if slots.has("accessory") and slots["accessory"] is Array:
var accessories := slots["accessory"] as Array
var seen := {}
for item in accessories:
if item is String:
if seen.has(item):
validation_errors.append("Duplicate accessory: %s" % item)
seen[item] = true
return validation_errors.is_empty()
## Convenience alias for validate().
func is_valid() -> bool:
return validate()
# ---------------------------------------------------------------------------
# Accessors
# ---------------------------------------------------------------------------
## Check if a specific slot is equipped with a non-null item.
func is_equipped(slot_id: String) -> bool:
if not AvatarSlotRegistry.has_slot(slot_id):
return false
var value = slots.get(slot_id)
if value == null:
return false
if typeof(value) == TYPE_STRING:
return not (value as String).is_empty()
elif typeof(value) == TYPE_ARRAY:
return (value as Array).size() > 0
return false
## Get the equipped asset ID for an exclusive slot.
## Returns empty string if not equipped or slot is not exclusive.
func get_equipment(slot_id: String) -> String:
var value = slots.get(slot_id)
if value is String:
return value
return ""
## Set equipment for an exclusive slot.
func set_equipment(slot_id: String, asset_id: String) -> void:
if not AvatarSlotRegistry.has_slot(slot_id):
push_error("Unknown slot: %s" % slot_id)
return
var slot_def = AvatarSlotRegistry.get_slot(slot_id)
if slot_def and not slot_def.exclusive:
push_error("Slot '%s' is not exclusive" % slot_id)
return
slots[slot_id] = null if asset_id.is_empty() else asset_id
## Add an accessory to the accessory slot.
func add_accessory(asset_id: String) -> bool:
var accessories := slots.get("accessory", []) as Array
if accessories.size() >= MAX_ACCESSORIES:
return false
if asset_id.is_empty():
return false
if accessories.has(asset_id):
return false
accessories.append(asset_id)
slots["accessory"] = accessories
return true
## Remove an accessory from the accessory slot.
func remove_accessory(asset_id: String) -> bool:
var accessories := slots.get("accessory", []) as Array
if accessories.has(asset_id):
accessories.erase(asset_id)
slots["accessory"] = accessories
return true
return false
## Iterate over equipped items as SlotItem objects.
## Returns an array of SlotItem for each equipped slot.
func get_equipped_items() -> Array[SlotItem]:
var items: Array[SlotItem] = []
for slot_id in AvatarSlotRegistry.SLOT_IDS:
var value = slots.get(slot_id)
if value == null:
continue
if typeof(value) == TYPE_STRING:
var asset_id := value as String
if not asset_id.is_empty():
items.append(SlotItem.new(slot_id, asset_id))
elif typeof(value) == TYPE_ARRAY:
for item_id in value:
if item_id is String and not (item_id as String).is_empty():
items.append(SlotItem.new(slot_id, item_id))
return items
# ---------------------------------------------------------------------------
# Hashing
# ---------------------------------------------------------------------------
## Compute a hash of this loadout for change detection.
## Mirrors the server's loadout_hash concept.
func compute_hash() -> int:
var hasher := HashingContext.new()
hasher.start(HashingContext.HASH_SHA256)
hasher.update(base_body.to_utf8_buffer())
for slot_id in AvatarSlotRegistry.SLOT_IDS:
hasher.update(slot_id.to_utf8_buffer())
var value = slots.get(slot_id)
if value is String:
hasher.update((value as String).to_utf8_buffer())
elif value is Array:
var arr_str = JSON.stringify(value)
hasher.update(arr_str.to_utf8_buffer())
else:
hasher.update("null".to_utf8_buffer())
var digest = hasher.finish()
# Return first 4 bytes as int for compact storage.
return digest[0] | (digest[1] << 8) | (digest[2] << 16) | (digest[3] << 24)
+1
View File
@@ -0,0 +1 @@
uid://dxdtebl4upeny
+275
View File
@@ -0,0 +1,275 @@
## AvatarSlotRegistry - Static registry of slot definitions.
##
## Each slot maps to one or more bones and defines what can be equipped there.
## This is the single source of truth for slot IDs, categories, mount bones,
## exclusivity, and body-part vs attachment classification.
##
## See docs/architecture/avatar.md §3.2 for the full slot specification.
class_name AvatarSlotRegistry extends RefCounted
# ---------------------------------------------------------------------------
# Slot definitions (mirrors §3.2 of the avatar spec)
# ---------------------------------------------------------------------------
## All known slot IDs, in canonical order.
const SLOT_IDS: PackedStringArray = [
"head",
"hair",
"face",
"torso",
"chest",
"back",
"left_arm",
"right_arm",
"left_hand",
"right_hand",
"legs",
"pants",
"left_foot",
"right_foot",
"accessory",
]
## Body-part slots (exclusive, replace base mesh).
const BODY_PART_SLOTS: PackedStringArray = [
"head", "torso", "legs", "left_arm", "right_arm",
]
## Attachment slots (layered on top of base mesh).
const ATTACHMENT_SLOTS: PackedStringArray = [
"hair", "face", "chest", "back",
"left_hand", "right_hand", "pants",
"left_foot", "right_foot", "accessory",
]
## Slots that are multi-slot (multiple accessories can coexist).
const MULTI_SLOT_IDS: PackedStringArray = ["accessory"]
## Metadata for a single slot.
class SlotDefinition:
## The slot ID string (e.g. "head", "hair").
var slot_key: String
## "body_part" or "attachment".
var category: String
## Primary mount bone name(s).
var mount_bones: PackedStringArray
## Whether at most one item can occupy this slot.
var exclusive: bool
## Whether this slot replaces a base mesh.
var is_body_part: bool
func _init(p_slot_key: String, p_category: String, p_mount_bones: PackedStringArray,
p_exclusive: bool, p_is_body_part: bool) -> void:
slot_key = p_slot_key
category = p_category
mount_bones = p_mount_bones
exclusive = p_exclusive
is_body_part = p_is_body_part
# Slot metadata lookup tables (indexed by SLOT_IDS position). Static: every
# caller uses AvatarSlotRegistry.method(...) directly on the class, never on
# an instance, so this data has to be built once at class-load time via
# _static_init() rather than an instance _init() that's never invoked.
# Each entry: { category, mount_bones, exclusive, is_body_part }
static var _slot_data: Dictionary = {}
static var _slot_definitions: Array[SlotDefinition] = []
static func _static_init() -> void:
_build_registry()
static func _build_registry() -> void:
_slot_data = {
"head": {
"category": "body_part",
"mount_bones": ["Head"],
"exclusive": true,
"is_body_part": true,
},
"hair": {
"category": "attachment",
"mount_bones": ["HeadTop"],
"exclusive": true,
"is_body_part": false,
},
"face": {
"category": "attachment",
"mount_bones": ["Jaw"],
"exclusive": true,
"is_body_part": false,
},
"torso": {
"category": "body_part",
"mount_bones": ["Chest"],
"exclusive": true,
"is_body_part": true,
},
"chest": {
"category": "attachment",
"mount_bones": ["Chest"],
"exclusive": true,
"is_body_part": false,
},
"back": {
"category": "attachment",
"mount_bones": ["Chest"],
"exclusive": true,
"is_body_part": false,
},
"left_arm": {
"category": "body_part",
"mount_bones": ["LeftUpperArm"],
"exclusive": true,
"is_body_part": true,
},
"right_arm": {
"category": "body_part",
"mount_bones": ["RightUpperArm"],
"exclusive": true,
"is_body_part": true,
},
"left_hand": {
"category": "attachment",
"mount_bones": ["LeftHand"],
"exclusive": true,
"is_body_part": false,
},
"right_hand": {
"category": "attachment",
"mount_bones": ["RightHand"],
"exclusive": true,
"is_body_part": false,
},
"legs": {
"category": "body_part",
"mount_bones": ["Hips"],
"exclusive": true,
"is_body_part": true,
},
"pants": {
"category": "attachment",
"mount_bones": ["Hips"],
"exclusive": true,
"is_body_part": false,
},
"left_foot": {
"category": "attachment",
"mount_bones": ["LeftFoot"],
"exclusive": true,
"is_body_part": false,
},
"right_foot": {
"category": "attachment",
"mount_bones": ["RightFoot"],
"exclusive": true,
"is_body_part": false,
},
"accessory": {
"category": "attachment",
"mount_bones": ["Root"],
"exclusive": false,
"is_body_part": false,
},
}
# Build SlotDefinition objects.
for slot_id in SLOT_IDS:
var data = _slot_data[slot_id]
var def := SlotDefinition.new(
slot_id,
data["category"],
PackedStringArray(data["mount_bones"]),
data["exclusive"],
data["is_body_part"],
)
_slot_definitions.append(def)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
## Returns the list of all known slot IDs.
static func get_slot_ids() -> PackedStringArray:
return SLOT_IDS
## Returns true if *slot_id* is a known slot.
static func has_slot(slot_id: String) -> bool:
return slot_id in SLOT_IDS
## Returns the SlotDefinition for a slot, or null if unknown.
static func get_slot(slot_id: String) -> SlotDefinition:
if not has_slot(slot_id):
return null
for def in _slot_definitions:
if def.slot_key == slot_id:
return def
return null
## Returns all SlotDefinition objects in canonical order.
static func get_slot_definitions() -> Array[SlotDefinition]:
return _slot_definitions
## Returns the category for a slot ("body_part" or "attachment").
static func get_category(slot_id: String) -> String:
if not has_slot(slot_id):
push_error("AvatarSlotRegistry: unknown slot '%s'" % slot_id)
return ""
return _slot_data[slot_id]["category"]
## Returns the primary mount bone(s) for a slot.
static func get_mount_bones(slot_id: String) -> PackedStringArray:
if not has_slot(slot_id):
push_error("AvatarSlotRegistry: unknown slot '%s'" % slot_id)
return PackedStringArray()
return PackedStringArray(_slot_data[slot_id]["mount_bones"])
## Returns whether the slot is exclusive (at most one item).
static func is_exclusive(slot_id: String) -> bool:
if not has_slot(slot_id):
push_error("AvatarSlotRegistry: unknown slot '%s'" % slot_id)
return true
return _slot_data[slot_id]["exclusive"]
## Returns whether the slot is a body-part slot (replaces base mesh).
static func is_body_part_slot(slot_id: String) -> bool:
if not has_slot(slot_id):
push_error("AvatarSlotRegistry: unknown slot '%s'" % slot_id)
return false
return _slot_data[slot_id]["is_body_part"]
## Returns whether the slot is a multi-slot (e.g. accessory).
static func is_multi_slot(slot_id: String) -> bool:
return slot_id in MULTI_SLOT_IDS
## Returns the list of body-part slot IDs.
static func get_body_part_slots() -> PackedStringArray:
return BODY_PART_SLOTS
## Returns the list of attachment slot IDs.
static func get_attachment_slots() -> PackedStringArray:
return ATTACHMENT_SLOTS
## Validates that an asset's declared slot matches the slot it is placed in.
static func validate_slot_assignment(asset_slot: String, slot_id: String) -> bool:
return asset_slot == slot_id
## Returns the canonical order index for a slot (for serialization order).
static func get_slot_index(slot_id: String) -> int:
return SLOT_IDS.find(slot_id)
@@ -0,0 +1 @@
uid://do873d38pqhwa
+222
View File
@@ -0,0 +1,222 @@
## AvatarSystem - Global avatar orchestrator (autoload singleton).
##
## Owns the AvatarCatalog, AvatarLoader, and the current player's AvatarLoadout.
## Provides a single entry point for the rest of the game to:
## - Load the asset catalog.
## - Load a player's avatar from a loadout.
## - Equip / unequip items and persist the resulting loadout.
##
## Registered as an autoload singleton named "AvatarSystem" in project.godot.
extends Node
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
## Emitted when the catalog has finished loading.
signal catalog_loaded
## Emitted when the catalog fails to load.
signal catalog_load_failed(error_message: String)
## Emitted when the avatar has been fully assembled and is ready to render.
signal avatar_ready(avatar_node: Node3D)
## Emitted when the loadout changes (after equip/unequip).
signal loadout_changed(loadout: AvatarLoadout)
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
## The asset catalog (id -> metadata + path).
var catalog: AvatarCatalog = null
## The loader used to import GLB assets and assemble the avatar.
var loader: AvatarLoader = null
## The current player's loadout.
var current_loadout: AvatarLoadout = null
## The assembled avatar node (null until avatar_ready).
var avatar_node: Node3D = null
## Whether the catalog has been loaded.
var is_catalog_loaded: bool = false
## Whether an avatar is currently assembled.
var is_avatar_ready: bool = false
## Default catalog path.
const DEFAULT_CATALOG_PATH := "res://assets/avatars/catalog.json"
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
catalog = AvatarCatalog.new()
loader = AvatarLoader.new()
loader.init_loader(catalog)
add_child(loader)
## Load the asset catalog. Call this once at startup (or on demand).
func load_catalog(path: String = DEFAULT_CATALOG_PATH) -> Error:
var err := catalog.load_catalog(path)
if err == OK:
is_catalog_loaded = true
catalog_loaded.emit()
else:
catalog_load_failed.emit("Failed to load catalog: %s" % err)
return err
# ---------------------------------------------------------------------------
# Avatar assembly
# ---------------------------------------------------------------------------
## Load and assemble an avatar from a loadout.
## Returns the assembled avatar Node3D on success, or null on failure.
func load_avatar(loadout: AvatarLoadout) -> Node3D:
if not is_catalog_loaded:
push_error("AvatarSystem: catalog not loaded; call load_catalog() first")
return null
if loadout == null:
push_error("AvatarSystem: loadout is null")
return null
# Validate the loadout against the catalog.
var validation := catalog.validate_loadout(loadout)
if not validation.valid:
push_error("AvatarSystem: invalid loadout: %s" % str(validation.errors))
return null
current_loadout = loadout
# Reset any previous avatar.
loader.reset()
# Load the base body.
var base_body_id := loadout.base_body
var base_node := loader.load_base_body(base_body_id)
if base_node == null:
push_error("AvatarSystem: failed to load base body '%s'" % base_body_id)
return null
# Load each equipped attachment.
for slot_id in AvatarSlotRegistry.SLOT_IDS:
var slot_value = loadout.slots.get(slot_id)
if slot_id == "accessory":
# Accessory is an array of asset ids.
if slot_value is Array:
for asset_id in slot_value:
if asset_id is String and not (asset_id as String).is_empty():
loader.load_attachment(slot_id, asset_id)
else:
# Exclusive slot: single asset id or null.
if slot_value is String and not (slot_value as String).is_empty():
loader.load_attachment(slot_id, slot_value)
# Hide base meshes for equipped body-part slots.
for slot_id in AvatarSlotRegistry.BODY_PART_SLOTS:
var slot_value = loadout.slots.get(slot_id)
if slot_value is String and not (slot_value as String).is_empty():
loader.set_base_mesh_visible(slot_id, false)
avatar_node = loader.avatar_node
is_avatar_ready = avatar_node != null
if is_avatar_ready:
avatar_ready.emit(avatar_node)
return avatar_node
## Equip an item into a slot and rebuild the avatar.
## Returns true on success.
func equip(slot_id: String, asset_id: String) -> bool:
if current_loadout == null:
push_error("AvatarSystem: no loadout loaded")
return false
if not AvatarSlotRegistry.has_slot(slot_id):
push_error("AvatarSystem: unknown slot '%s'" % slot_id)
return false
if not catalog.has_asset(asset_id):
push_error("AvatarSystem: unknown asset '%s'" % asset_id)
return false
var asset := catalog.resolve(asset_id)
if asset.get("slot") != slot_id:
push_error("AvatarSystem: asset '%s' is for slot '%s', not '%s'" % [
asset_id, asset.get("slot"), slot_id])
return false
if slot_id == "accessory":
if not current_loadout.add_accessory(asset_id):
push_error("AvatarSystem: could not add accessory '%s'" % asset_id)
return false
else:
current_loadout.set_equipment(slot_id, asset_id)
loadout_changed.emit(current_loadout)
return true
## Unequip an item from a slot and rebuild the avatar.
## Returns true on success.
func unequip(slot_id: String, asset_id: String = "") -> bool:
if current_loadout == null:
push_error("AvatarSystem: no loadout loaded")
return false
if not AvatarSlotRegistry.has_slot(slot_id):
push_error("AvatarSystem: unknown slot '%s'" % slot_id)
return false
if slot_id == "accessory":
if asset_id.is_empty():
push_error("AvatarSystem: accessory unequip requires an asset id")
return false
if not current_loadout.remove_accessory(asset_id):
push_error("AvatarSystem: accessory '%s' not equipped" % asset_id)
return false
else:
current_loadout.set_equipment(slot_id, "")
loadout_changed.emit(current_loadout)
return true
## Serialize the current loadout to JSON.
func save_loadout() -> String:
if current_loadout == null:
return ""
return current_loadout.to_json()
## Load a loadout from a JSON string.
func load_loadout_from_json(json_string: String) -> AvatarLoadout:
var loadout := AvatarLoadout.from_json(json_string)
if not loadout.validate():
push_error("AvatarSystem: invalid loadout JSON: %s" % str(loadout.validation_errors))
return null
return loadout
## Get the list of available assets for a given slot.
func get_assets_for_slot(slot_id: String) -> Array[String]:
return catalog.get_assets_by_slot(slot_id)
## Get the display name for an asset id.
func get_asset_display_name(asset_id: String) -> String:
var asset := catalog.resolve(asset_id)
return asset.get("display_name", asset_id)
+1
View File
@@ -0,0 +1 @@
uid://cfbvgfisbxbk
+285
View File
@@ -0,0 +1,285 @@
## 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.
##
## 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.
class_name AvatarVisualizer
extends Object
## 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().
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 AvatarLoadout currently loaded (null if none).
var current_loadout: AvatarLoadout = null
## Whether the visualizer is currently building (true during load_loadout).
var is_building: bool = false
# ---------------------------------------------------------------------------
# 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.
if root_node != null:
root = root_node
else:
root = Node3D.new()
root.name = "AvatarVisualizer"
# Create the body skeleton.
body_skeleton = Skeleton3D.new()
body_skeleton.name = "AvatarBody"
root.add_child(body_skeleton)
# 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 slot_item == null or slot_item.asset_id == "":
# Slot is empty — create a placeholder.
_add_placeholder_slot(slot_def)
continue
# Load the slot's asset.
var slot_result: AvatarLoader.SlotResult = await AvatarLoader.load_slot(
slot_item,
slot_def,
loadout.skin_tone,
)
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
# Attach the loaded tree to the root.
_attach_slot_tree(slot_result, slot_def)
# 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.
func clear() -> void:
if root != null:
root.queue_free()
root = null
body_skeleton = 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
## 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:
if node is MeshInstance3D:
result.append(node as MeshInstance3D)
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)
+1
View File
@@ -0,0 +1 @@
uid://cvr5mmbv2worn
+98
View File
@@ -0,0 +1,98 @@
extends SceneTree
## Smoke test for the avatar catalog/slot/loadout/system layers.
##
## Real .glb model assets don't exist yet (assets/avatars/catalog.json
## references them, the files themselves are a tracked user to-do -- see
## the "AGP MVP status" project memory). This test covers everything that
## doesn't require them (catalog parsing, slot registry, loadout equip/
## validate, the AvatarSystem orchestrator's autoload wiring) and confirms
## the one thing that DOES touch missing files -- AvatarLoader.load_base_body()
## -- fails gracefully (emits load_error, returns null) instead of crashing.
## Visual rendering itself can't be verified until real assets exist.
##
## Run with:
## godot --headless --path . --script res://scripts/smoke_test_avatar_system.gd
var passed := 0
var failed := 0
func _initialize() -> void:
_run()
func _run() -> void:
await process_frame
# 1. AvatarSystem is a real autoload, not just a loose script.
var system := root.get_node_or_null("AvatarSystem")
if not _check("AvatarSystem autoload exists", system != null):
_finish()
return
# 2. The real catalog.json (committed to assets/avatars/) actually parses.
var err: Error = system.load_catalog()
_check("catalog loads without error", err == OK)
_check("AvatarSystem reports catalog loaded", system.is_catalog_loaded)
var base_bodies: Array[String] = system.get_assets_for_slot("base_body")
_check("catalog has at least one base_body entry", base_bodies.size() > 0)
# 3. Loadout: create, equip, validate against the real catalog.
# base_body is its own dedicated field on AvatarLoadout, not something
# set via the generic per-slot set_equipment() -- AvatarSlotRegistry's
# SLOT_IDS deliberately excludes "base_body" (it's the foundational
# model you start from, not a layered attachment slot).
var loadout := AvatarLoadout.new()
if base_bodies.size() > 0:
loadout.base_body = base_bodies[0]
_check("loadout has base_body set",
base_bodies.size() == 0 or not loadout.base_body.is_empty())
var hair_options: Array[String] = system.get_assets_for_slot("hair")
if hair_options.size() > 0:
var equip_ok: bool = loadout.add_accessory(hair_options[0])
_check("adding a real catalog accessory succeeds", equip_ok)
# 4. Loadout serializes and survives a round trip.
var json_str: String = loadout.to_json()
_check("loadout serializes to non-empty JSON", json_str.length() > 0)
var restored: AvatarLoadout = system.load_loadout_from_json(json_str)
_check("loadout round-trips through JSON", restored != null and restored.is_valid())
# 5. The part that DOES need real files: confirm it fails gracefully
# rather than crashing, since assets/avatars/*.glb genuinely don't
# exist yet. A crash here would be a real bug; a clean load_error
# signal + null return is the correct, already-designed-for behavior.
if base_bodies.size() > 0:
# A plain bool captured by a lambda is captured BY VALUE in
# GDScript -- mutating it inside the closure doesn't propagate to
# this outer scope. A single-element Array is the standard
# workaround (confirmed the hard way earlier in this same session,
# see scripts/smoke_test_network_client.gd's own comment on this).
var error_state := [false]
var error_handler := func(_msg): error_state[0] = true
system.loader.load_error.connect(error_handler)
var result: Node3D = system.loader.load_base_body(base_bodies[0])
system.loader.load_error.disconnect(error_handler)
_check("load_base_body against a missing .glb returns null (not a crash)", result == null)
_check("load_base_body against a missing .glb emits load_error (not silent)", error_state[0])
else:
print(" SKIP: no base_body entries in catalog to test the failure path against")
_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)