Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b6e146e4b2 | |||
| 59ca5a4c6e | |||
| b422e41a0f | |||
| bd44ecba14 | |||
| bae326e14c | |||
| 140db9df91 | |||
| 2dff83bae3 | |||
| 1a8821e3c8 | |||
| 019dfca14f | |||
| 7be50e3313 | |||
| 29de1c5c3a | |||
| 92c10fa657 | |||
| 8357643072 | |||
| 736ebce0d0 | |||
| b60480798f | |||
| 77f7511a4f | |||
| c4eab8db92 | |||
| 4d754ae48e | |||
| d1d03df2e6 | |||
| 4dda636ed0 | |||
| 4276a89a6a | |||
| 1c3c57dbd0 | |||
| bfcbd0486f | |||
| 1be947f9ef | |||
| 88197ffe8f | |||
| 38d97a3051 | |||
| 174d3c2630 | |||
| b86e4053bd | |||
| 07f655815f | |||
| f5a7162285 | |||
| 61dedcf73e | |||
| 580b95635f | |||
| a81d29c882 | |||
| 4236c4792b | |||
| cf8fd5a6fd | |||
| fc9a39de93 | |||
| 36fd406925 | |||
| ddfe5dfaad | |||
| dcaaf96d81 | |||
| 803405aba4 | |||
| ea6958c11e | |||
| 61ee81a974 | |||
| 62f12e55db | |||
| 4c0af03573 | |||
| 689c51e179 | |||
| 5cfbcd8efd | |||
| 27461d9b5e | |||
| a5dc5cb3b6 | |||
| ea5dd63cd7 | |||
| 5ebd60b0bb | |||
| b2a64a8933 | |||
| 9fcea730b9 | |||
| 5b3fc10649 | |||
| 6018396be2 | |||
| ee6513b8ad | |||
| 37a311b0c5 | |||
| 11e812cb6b | |||
| 5097cdd9f4 | |||
| 2224c34aa8 | |||
| 2c5f6fcb04 | |||
| 7cd41dc920 | |||
| c5d2eb6bd2 | |||
| b650572426 | |||
| 7d84dd5ec8 |
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
63f7b34db8d8cdea90c76aacccf841ec
|
||||||
|
::res://::1787275036
|
||||||
|
export_presets.cfg::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||||
|
README.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||||
|
::res://addons/::1787272930
|
||||||
|
::res://assets/::1787272930
|
||||||
|
::res://backend/::1787272930
|
||||||
|
alembic.ini::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||||
|
docker-compose.yml::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||||
|
pytest.ini::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||||
|
requirements.txt::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||||
|
::res://backend/alembic/::1787272930
|
||||||
|
::res://backend/alembic/versions/::1787272930
|
||||||
|
::res://backend/app/::1787272930
|
||||||
|
::res://backend/app/models/::1787272930
|
||||||
|
::res://backend/app/routers/::1787272930
|
||||||
|
::res://backend/app/services/::1787272930
|
||||||
|
::res://backend/tests/::1787272930
|
||||||
|
::res://docs/::1787272930
|
||||||
|
::res://docs/architecture/::1787272930
|
||||||
|
avatar.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||||
|
content_moderation.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||||
|
editor.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||||
|
networking.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||||
|
pc2_test_level.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||||
|
scripting_sandbox.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||||
|
world.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||||
|
::res://docs/checkpoints/::1787272930
|
||||||
|
PC1.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||||
|
::res://docs/planning/::1787272930
|
||||||
|
client-server-wiring.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||||
|
economy.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||||
|
social.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||||
|
::res://docs/testing/::1787275036
|
||||||
|
README.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||||
|
test_inventory.md::TextFile::-1::1787275036::0::1::::<><><>0<>0<><>::
|
||||||
|
::res://scenes/::1787275036
|
||||||
|
game.tscn::PackedScene::-1::1787272930::0::1::::<><><>0<>0<><>::res://scenes/worlds/test_level.tscn<>res://scenes/player.tscn
|
||||||
|
main.tscn::PackedScene::-1::1787272930::0::1::::<><><>0<>0<><>::res://scripts/chunk_stream_manager.gd
|
||||||
|
main_menu.tscn::PackedScene::-1::1787272930::0::1::::<><><>0<>0<><>::res://scripts/main_menu.gd
|
||||||
|
player.tscn::PackedScene::-1::1787272930::0::1::::<><><>0<>0<><>::res://scripts/player.gd
|
||||||
|
server.tscn::PackedScene::-1::1787275036::0::1::::<><><>0<>0<><>::res://scripts/server.gd
|
||||||
|
server_player.tscn::PackedScene::-1::1787275036::0::1::::<><><>0<>0<><>::res://scripts/player_spawner.gd
|
||||||
|
world_list.tscn::PackedScene::-1::1787272930::0::1::::<><><>0<>0<><>::res://scripts/world_list.gd
|
||||||
|
::res://scenes/worlds/::1787272930
|
||||||
|
test_level.tscn::PackedScene::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||||
|
::res://scripts/::1787275098
|
||||||
|
api_client.gd::GDScript::5412288119287126975::1787272930::0::1::::<>Node<><>0<>0<><>::
|
||||||
|
chunk.gd::GDScript::7364239597441807588::1787272930::0::1::::Chunk<>Node3D<><>0<>0<><>::
|
||||||
|
chunk_data_decoder.gd::GDScript::3922068956043581888::1787272930::0::1::::ChunkDataDecoder<>RefCounted<><>0<>0<><>::
|
||||||
|
chunk_scene.gd::GDScript::9207602004456600331::1787272930::0::1::::ChunkScene<>Node3D<><>0<>0<><>::
|
||||||
|
chunk_stream_manager.gd::GDScript::2366645676568508746::1787272930::0::1::::ChunkStreamManager<>Node<><>0<>0<><>::
|
||||||
|
dynamic_object_node.gd::GDScript::8863349716576659118::1787272930::0::1::::DynamicObjectNode<>Node3D<><>0<>0<><>::
|
||||||
|
main_menu.gd::GDScript::2834562513277893052::1787272930::0::1::::<>Control<><>0<>0<><>::
|
||||||
|
network_client.gd::GDScript::5408296350353838185::1787275098::0::1::::NetworkClient<>Node<><>0<>0<><>::
|
||||||
|
network_player_proxy.gd::GDScript::4787436336771232797::1787275036::0::1::::<>Node<><>0<>0<><>::
|
||||||
|
network_receiver_proxy.gd::GDScript::6742760436861380520::1787275036::0::1::::<>Node<><>0<>0<><>::
|
||||||
|
player.gd::GDScript::4510795778937304264::1787275036::0::1::::Player<>CharacterBody3D<><>0<>0<><>::
|
||||||
|
player_spawner.gd::GDScript::3637338339553764865::1787275036::0::1::::PlayerNode<>CharacterBody3D<><>0<>0<><>::
|
||||||
|
server.gd::GDScript::6336910781803684784::1787275036::0::1::::<>Node<><>0<>0<><>::
|
||||||
|
smoke_test_api.gd::GDScript::1760923834350699002::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
|
||||||
|
smoke_test_auth.gd::GDScript::3027816090663230577::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
|
||||||
|
smoke_test_e2e.gd::GDScript::9086409960595132737::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
|
||||||
|
smoke_test_enter_world.gd::GDScript::4171815758762850910::1787275036::0::1::::<>SceneTree<><>0<>0<><>::
|
||||||
|
smoke_test_main_menu_back.gd::GDScript::4436937414562273157::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
|
||||||
|
smoke_test_main_menu_full_flow.gd::GDScript::1948803640558784862::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
|
||||||
|
smoke_test_main_menu_ui.gd::GDScript::396938597300585484::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
|
||||||
|
smoke_test_network_client.gd::GDScript::3109386529184855644::1787275067::0::1::::<>SceneTree<><>0<>0<><>::
|
||||||
|
smoke_test_player.gd::GDScript::3497042634752676337::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
|
||||||
|
smoke_test_test_level.gd::GDScript::2607555612829866949::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
|
||||||
|
smoke_test_world_list.gd::GDScript::4300757286283894147::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
|
||||||
|
static_object_node.gd::GDScript::5276389741929225897::1787272930::0::1::::StaticObjectNode<>Node3D<><>0<>0<><>::
|
||||||
|
tile_def.gd::GDScript::2430528079284085109::1787272930::0::1::::TileDef<>RefCounted<><>0<>0<><>::
|
||||||
|
tile_layer.gd::GDScript::4797906255131447844::1787272930::0::1::::TileLayer<>RefCounted<><>0<>0<><>::
|
||||||
|
tile_layer_node.gd::GDScript::9033225196818247616::1787272930::0::1::::TileLayerNode<>Node3D<><>0<>0<><>::
|
||||||
|
world_list.gd::GDScript::6119591848847211153::1787275036::0::1::::<>Control<><>0<>0<><>::
|
||||||
|
world_object.gd::GDScript::6429674491464200335::1787272930::0::1::::WorldObject<>RefCounted<><>0<>0<><>::
|
||||||
@@ -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=[]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[game_view]
|
||||||
|
|
||||||
|
select_mode=0
|
||||||
|
|
||||||
|
[editor_metadata]
|
||||||
|
|
||||||
|
executable_path="/home/daddy/godot/godot"
|
||||||
|
|
||||||
|
[recent_files]
|
||||||
|
|
||||||
|
scenes=["res://scenes/main_menu.tscn", "res://scenes/server.tscn"]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[folding]
|
||||||
|
|
||||||
|
node_unfolds=[]
|
||||||
|
resource_unfolds=[]
|
||||||
|
nodes_folded=[]
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
list=[{
|
||||||
|
"base": &"Node3D",
|
||||||
|
"class": &"Chunk",
|
||||||
|
"icon": "",
|
||||||
|
"is_abstract": false,
|
||||||
|
"is_tool": false,
|
||||||
|
"language": &"GDScript",
|
||||||
|
"path": "res://scripts/chunk.gd"
|
||||||
|
}, {
|
||||||
|
"base": &"RefCounted",
|
||||||
|
"class": &"ChunkDataDecoder",
|
||||||
|
"icon": "",
|
||||||
|
"is_abstract": false,
|
||||||
|
"is_tool": false,
|
||||||
|
"language": &"GDScript",
|
||||||
|
"path": "res://scripts/chunk_data_decoder.gd"
|
||||||
|
}, {
|
||||||
|
"base": &"Node3D",
|
||||||
|
"class": &"ChunkScene",
|
||||||
|
"icon": "",
|
||||||
|
"is_abstract": false,
|
||||||
|
"is_tool": false,
|
||||||
|
"language": &"GDScript",
|
||||||
|
"path": "res://scripts/chunk_scene.gd"
|
||||||
|
}, {
|
||||||
|
"base": &"Node",
|
||||||
|
"class": &"ChunkStreamManager",
|
||||||
|
"icon": "",
|
||||||
|
"is_abstract": false,
|
||||||
|
"is_tool": false,
|
||||||
|
"language": &"GDScript",
|
||||||
|
"path": "res://scripts/chunk_stream_manager.gd"
|
||||||
|
}, {
|
||||||
|
"base": &"Node3D",
|
||||||
|
"class": &"DynamicObjectNode",
|
||||||
|
"icon": "",
|
||||||
|
"is_abstract": false,
|
||||||
|
"is_tool": false,
|
||||||
|
"language": &"GDScript",
|
||||||
|
"path": "res://scripts/dynamic_object_node.gd"
|
||||||
|
}, {
|
||||||
|
"base": &"Node",
|
||||||
|
"class": &"NetworkClient",
|
||||||
|
"icon": "",
|
||||||
|
"is_abstract": false,
|
||||||
|
"is_tool": false,
|
||||||
|
"language": &"GDScript",
|
||||||
|
"path": "res://scripts/network_client.gd"
|
||||||
|
}, {
|
||||||
|
"base": &"CharacterBody3D",
|
||||||
|
"class": &"Player",
|
||||||
|
"icon": "",
|
||||||
|
"is_abstract": false,
|
||||||
|
"is_tool": false,
|
||||||
|
"language": &"GDScript",
|
||||||
|
"path": "res://scripts/player.gd"
|
||||||
|
}, {
|
||||||
|
"base": &"CharacterBody3D",
|
||||||
|
"class": &"PlayerNode",
|
||||||
|
"icon": "",
|
||||||
|
"is_abstract": false,
|
||||||
|
"is_tool": false,
|
||||||
|
"language": &"GDScript",
|
||||||
|
"path": "res://scripts/player_spawner.gd"
|
||||||
|
}, {
|
||||||
|
"base": &"Node3D",
|
||||||
|
"class": &"StaticObjectNode",
|
||||||
|
"icon": "",
|
||||||
|
"is_abstract": false,
|
||||||
|
"is_tool": false,
|
||||||
|
"language": &"GDScript",
|
||||||
|
"path": "res://scripts/static_object_node.gd"
|
||||||
|
}, {
|
||||||
|
"base": &"RefCounted",
|
||||||
|
"class": &"TileDef",
|
||||||
|
"icon": "",
|
||||||
|
"is_abstract": false,
|
||||||
|
"is_tool": false,
|
||||||
|
"language": &"GDScript",
|
||||||
|
"path": "res://scripts/tile_def.gd"
|
||||||
|
}, {
|
||||||
|
"base": &"RefCounted",
|
||||||
|
"class": &"TileLayer",
|
||||||
|
"icon": "",
|
||||||
|
"is_abstract": false,
|
||||||
|
"is_tool": false,
|
||||||
|
"language": &"GDScript",
|
||||||
|
"path": "res://scripts/tile_layer.gd"
|
||||||
|
}, {
|
||||||
|
"base": &"Node3D",
|
||||||
|
"class": &"TileLayerNode",
|
||||||
|
"icon": "",
|
||||||
|
"is_abstract": false,
|
||||||
|
"is_tool": false,
|
||||||
|
"language": &"GDScript",
|
||||||
|
"path": "res://scripts/tile_layer_node.gd"
|
||||||
|
}, {
|
||||||
|
"base": &"RefCounted",
|
||||||
|
"class": &"WorldObject",
|
||||||
|
"icon": "",
|
||||||
|
"is_abstract": false,
|
||||||
|
"is_tool": false,
|
||||||
|
"language": &"GDScript",
|
||||||
|
"path": "res://scripts/world_object.gd"
|
||||||
|
}]
|
||||||
Binary file not shown.
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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-20 — full PC1 checkpoint run,
|
Last verified against `main`: 2026-08-21 — migrations + pytest + all 11
|
||||||
migrations + pytest + all 7 Godot smoke tests + both platform exports, all
|
Godot smoke tests, all clean. PC1's real playtest-found bugs (login form
|
||||||
clean. Real bugs found and fixed via real playtesting this same day (not
|
unreachable, 1px world cards, Play → Back losing the session) are covered
|
||||||
caught by the checkpoint suite as it stood before today): the main menu
|
by the smoke tests below so they can't silently regress. PC2 (basic
|
||||||
had no way to actually open the login/register form, the world list's
|
playable world + character + movement) landed 2026-08-20/21 — see
|
||||||
cards rendered at ~1px wide once real data existed, and navigating Play →
|
`docs/checkpoints/` once a PC2 record exists, and the three new smoke
|
||||||
Back logged the user out despite a still-valid session. All three are now
|
tests below (`smoke_test_test_level.gd`, `smoke_test_player.gd`,
|
||||||
covered by the smoke tests below so they can't silently regress.
|
`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,7 +56,10 @@ these yet):
|
|||||||
|
|
||||||
## Godot client — `scripts/smoke_test_*.gd`
|
## Godot client — `scripts/smoke_test_*.gd`
|
||||||
|
|
||||||
**7 smoke tests merged into `main`.** Each needs a live backend —
|
**12 smoke tests merged into `main`.** Ten need a live backend, one
|
||||||
|
(`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.
|
||||||
@@ -67,6 +73,11 @@ these yet):
|
|||||||
| `smoke_test_main_menu_ui.gd` | Instantiates `main_menu.tscn`, confirms the auth panel starts hidden, and confirms a **real signal emission** on LoginButton/RegisterButton (not calling the handler method directly) actually opens it. This is the check that would have caught 2026-08-20's real bug: the auth panel and its logic were fully correct, but nothing in the scene was ever wired to open it, so Login/Register were completely unreachable from the UI. |
|
| `smoke_test_main_menu_ui.gd` | Instantiates `main_menu.tscn`, confirms the auth panel starts hidden, and confirms a **real signal emission** on LoginButton/RegisterButton (not calling the handler method directly) actually opens it. This is the check that would have caught 2026-08-20's real bug: the auth panel and its logic were fully correct, but nothing in the scene was ever wired to open it, so Login/Register were completely unreachable from the UI. |
|
||||||
| `smoke_test_main_menu_full_flow.gd` | Full click-through: open the register form, fill it exactly as a user would, submit, confirm Play actually becomes enabled, the welcome message shows, and the auth buttons hide themselves. Calls the submit handler directly (awaited) rather than via `auth_button.pressed.emit()` — see the note at the top of the file: a synthetic `.emit()` on an `await`-using handler doesn't get driven forward reliably in `--script`/SceneTree headless mode the way real GUI input event dispatch does, confirmed by comparing direct-call vs `.emit()` behavior. Not a bug in the actual click-driven flow, just a headless-test-harness quirk to route around when writing tests. |
|
| `smoke_test_main_menu_full_flow.gd` | Full click-through: open the register form, fill it exactly as a user would, submit, confirm Play actually becomes enabled, the welcome message shows, and the auth buttons hide themselves. Calls the submit handler directly (awaited) rather than via `auth_button.pressed.emit()` — see the note at the top of the file: a synthetic `.emit()` on an `await`-using handler doesn't get driven forward reliably in `--script`/SceneTree headless mode the way real GUI input event dispatch does, confirmed by comparing direct-call vs `.emit()` behavior. Not a bug in the actual click-driven flow, just a headless-test-harness quirk to route around when writing tests. |
|
||||||
| `smoke_test_main_menu_back.gd` | Regression test for Play → Back logging the user out even with a valid token still held. Registers a user, loads `main_menu.tscn` (asserts it restores the authenticated state via a real `auth_state_ready` signal wait, not a frame-count guess), clicks Play → `world_list.tscn`, clicks Back → `main_menu.tscn`, and asserts the menu is still authenticated. Checks `WelcomeLabel.is_visible_in_tree()`, not just `.visible` — the label lives inside AuthPanel, so `.visible` alone is a false positive if the panel itself is hidden (a real bug briefly present: `_show_authenticated()` hid AuthPanel while claiming the welcome label was visible, which rendered nothing). |
|
| `smoke_test_main_menu_back.gd` | Regression test for Play → Back logging the user out even with a valid token still held. Registers a user, loads `main_menu.tscn` (asserts it restores the authenticated state via a real `auth_state_ready` signal wait, not a frame-count guess), clicks Play → `world_list.tscn`, clicks Back → `main_menu.tscn`, and asserts the menu is still authenticated. Checks `WelcomeLabel.is_visible_in_tree()`, not just `.visible` — the label lives inside AuthPanel, so `.visible` alone is a false positive if the panel itself is hidden (a real bug briefly present: `_show_authenticated()` hid AuthPanel while claiming the welcome label was visible, which rendered nothing). |
|
||||||
|
| `smoke_test_test_level.gd` | Loads `scenes/worlds/test_level.tscn` directly (the level is player-agnostic and self-contained per `docs/architecture/pc2_test_level.md` §4) and confirms it has real content: 5 props, a `DirectionalLight3D` ("Sun"), and a `WorldEnvironment` — not an empty void. |
|
||||||
|
| `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_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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
@@ -21,6 +22,29 @@ window/size/viewport_width=1920
|
|||||||
window/size/viewport_height=1080
|
window/size/viewport_height=1080
|
||||||
window/stretch/mode="canvas_items"
|
window/stretch/mode="canvas_items"
|
||||||
|
|
||||||
|
[input]
|
||||||
|
|
||||||
|
move_forward={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
move_left={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
move_right={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
move_down={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
[rendering]
|
[rendering]
|
||||||
|
|
||||||
renderer/rendering_method="gl_compatibility"
|
renderer/rendering_method="gl_compatibility"
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/server.gd" id="1_server"]
|
||||||
|
|
||||||
|
[node name="Server" type="Node"]
|
||||||
|
script = ExtResource("1_server")
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/player_spawner.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="Player" type="CharacterBody3D"]
|
||||||
|
script = ExtResource("1")
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://ceyugve7rjxqs
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://br6x7f83dkrw2
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dxdtebl4upeny
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cfbvgfisbxbk
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cvr5mmbv2worn
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
class_name NetworkClient
|
||||||
|
extends Node
|
||||||
|
## Client-side networked player: input prediction + server reconciliation.
|
||||||
|
##
|
||||||
|
## Implements docs/architecture/networking.md §5 (Prediction & Reconciliation)
|
||||||
|
## for the *local* player only -- remote-entity interpolation (§5.3) and full
|
||||||
|
## delta-compressed multi-entity snapshots (§6) are explicitly out of scope
|
||||||
|
## for this first pass; the server already only ever has one player in PC2's
|
||||||
|
## single-peer testing, and this task's own description scopes it to "the
|
||||||
|
## player controller built in the dependency task" (singular).
|
||||||
|
##
|
||||||
|
## RPC path-matching, the part that makes this actually work:
|
||||||
|
## Godot's high-level rpc_id() resolves the *receiving* node by matching the
|
||||||
|
## *sending* node's NodePath on the caller's side. The server (scripts/
|
||||||
|
## server.gd) is the SceneTree root node named "Server" when the dedicated
|
||||||
|
## server runs scenes/server.tscn as its main scene, and it spawns each
|
||||||
|
## connected player as a *child* of itself named "Player_<peer_id>"
|
||||||
|
## (scripts/server.gd's _on_peer_connected). For an RPC sent by the client to
|
||||||
|
## actually land on the right node, the client needs a LOCAL node tree at the
|
||||||
|
## exact same paths -- /root/Server for _receive_snapshot (called by the
|
||||||
|
## server on itself), and /root/Server/Player_<own_peer_id> for
|
||||||
|
## rpc_request_input (a real method on the server's PlayerNode). Those local
|
||||||
|
## nodes don't need to run any server logic -- they only need to exist at the
|
||||||
|
## right path so Godot's RPC routing finds them. connect_to_server() builds
|
||||||
|
## that structure once the peer id is known (only available after the ENet
|
||||||
|
## handshake completes).
|
||||||
|
##
|
||||||
|
## Usage: instantiate directly (not an autoload -- this only exists for the
|
||||||
|
## lifetime of an actual multiplayer session, not on every client launch):
|
||||||
|
## var net := NetworkClient.new()
|
||||||
|
## get_tree().root.add_child(net)
|
||||||
|
## var ok := await net.connect_to_server("127.0.0.1", 7777)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Movement constants -- MUST match scripts/player_spawner.gd exactly, per
|
||||||
|
# docs/architecture/networking.md §5.1 ("the same physics constants as the
|
||||||
|
# server"). A mismatch here doesn't break anything outright, it just makes
|
||||||
|
# reconciliation corrections more frequent/visible.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const WALK_SPEED: float = 5.0
|
||||||
|
const JUMP_VELOCITY: float = 4.5
|
||||||
|
const GRAVITY: float = 9.8
|
||||||
|
|
||||||
|
## How far predicted and authoritative position can drift before snapping
|
||||||
|
## rather than trusting the prediction was "close enough." Per §5.2.
|
||||||
|
const RECONCILE_ERROR_THRESHOLD: float = 0.5
|
||||||
|
|
||||||
|
## How many past predicted states to keep for reconciliation replay. Real
|
||||||
|
## sizing per §5.4 is RTT-adaptive; a fixed generous buffer is the
|
||||||
|
## appropriately-scoped first pass here -- 120 ticks at a 30Hz send rate is
|
||||||
|
## 4 seconds of history, comfortably more than any realistic LAN/local RTT.
|
||||||
|
const PREDICTION_BUFFER_SIZE: int = 120
|
||||||
|
|
||||||
|
signal reconciled(correction_distance: float)
|
||||||
|
signal connected_to_server
|
||||||
|
signal connection_failed
|
||||||
|
|
||||||
|
|
||||||
|
var peer: ENetMultiplayerPeer
|
||||||
|
var _server_root: Node # local /root/Server proxy
|
||||||
|
var _player_proxy: Node # local /root/Server/Player_<peer_id> proxy
|
||||||
|
|
||||||
|
var _seq: int = 0
|
||||||
|
## Ring buffer of {seq, input, position, velocity} predicted at send time,
|
||||||
|
## oldest first. Trimmed as the server acknowledges ticks via snapshots.
|
||||||
|
var _prediction_buffer: Array[Dictionary] = []
|
||||||
|
|
||||||
|
var position: Vector3 = Vector3.ZERO
|
||||||
|
var velocity: Vector3 = Vector3.ZERO
|
||||||
|
var _on_floor: bool = false
|
||||||
|
|
||||||
|
|
||||||
|
func connect_to_server(host: String, port: int) -> bool:
|
||||||
|
peer = ENetMultiplayerPeer.new()
|
||||||
|
var err := peer.create_client(host, port)
|
||||||
|
if err != OK:
|
||||||
|
push_error("NetworkClient: create_client failed: %d" % err)
|
||||||
|
connection_failed.emit()
|
||||||
|
return false
|
||||||
|
|
||||||
|
multiplayer.multiplayer_peer = peer
|
||||||
|
multiplayer.connected_to_server.connect(_on_connected)
|
||||||
|
multiplayer.connection_failed.connect(func():
|
||||||
|
connection_failed.emit()
|
||||||
|
)
|
||||||
|
|
||||||
|
for i in 300: # ~5s at 60fps
|
||||||
|
await get_tree().process_frame
|
||||||
|
var status := peer.get_connection_status()
|
||||||
|
if status == MultiplayerPeer.CONNECTION_CONNECTED:
|
||||||
|
return true
|
||||||
|
elif status == MultiplayerPeer.CONNECTION_DISCONNECTED:
|
||||||
|
connection_failed.emit()
|
||||||
|
return false
|
||||||
|
connection_failed.emit()
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _on_connected() -> void:
|
||||||
|
var my_id := multiplayer.get_unique_id()
|
||||||
|
|
||||||
|
# Build the local proxy tree at the exact paths the server's own RPC
|
||||||
|
# calls target -- see the class docstring for why this has to exist.
|
||||||
|
_server_root = Node.new()
|
||||||
|
_server_root.name = "Server"
|
||||||
|
_server_root.set_script(preload("res://scripts/network_receiver_proxy.gd"))
|
||||||
|
get_tree().root.add_child(_server_root)
|
||||||
|
_server_root.set("owner_client", self)
|
||||||
|
|
||||||
|
_player_proxy = Node.new()
|
||||||
|
_player_proxy.name = "Player_%d" % my_id
|
||||||
|
_player_proxy.set_script(preload("res://scripts/network_player_proxy.gd"))
|
||||||
|
_server_root.add_child(_player_proxy)
|
||||||
|
|
||||||
|
connected_to_server.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func disconnect_from_server() -> void:
|
||||||
|
if peer:
|
||||||
|
peer.close()
|
||||||
|
if _server_root and is_instance_valid(_server_root):
|
||||||
|
_server_root.queue_free()
|
||||||
|
multiplayer.multiplayer_peer = null
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Prediction -- called every physics frame by whatever drives input (a real
|
||||||
|
# controller, or a test harness). Applies input locally immediately (so it
|
||||||
|
# renders with zero perceived latency) and sends it to the server, per §5.1.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func predict_and_send(delta: float, move_x: float, move_z: float, jump: bool) -> void:
|
||||||
|
_apply_movement(delta, move_x, move_z, jump)
|
||||||
|
|
||||||
|
_seq += 1
|
||||||
|
var record := {
|
||||||
|
"seq": _seq,
|
||||||
|
"input": {"move_x": move_x, "move_z": move_z, "jump": jump},
|
||||||
|
"position": position,
|
||||||
|
"velocity": velocity,
|
||||||
|
}
|
||||||
|
_prediction_buffer.append(record)
|
||||||
|
if _prediction_buffer.size() > PREDICTION_BUFFER_SIZE:
|
||||||
|
_prediction_buffer.pop_front()
|
||||||
|
|
||||||
|
if _player_proxy and is_instance_valid(_player_proxy):
|
||||||
|
_player_proxy.rpc_id(1, "rpc_request_input", record["input"], _seq)
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_movement(delta: float, move_x: float, move_z: float, jump: bool) -> void:
|
||||||
|
# Mirrors scripts/player_spawner.gd's _process_input + gravity/jump,
|
||||||
|
# intentionally without its ground-friction smoothing -- friction is a
|
||||||
|
# server-authoritative-feel detail, not something worth risking a
|
||||||
|
# prediction/server mismatch over in this first pass. Position still
|
||||||
|
# reconciles against the server's real (frictioned) result every
|
||||||
|
# snapshot, so this doesn't silently drift.
|
||||||
|
if not _on_floor:
|
||||||
|
velocity.y -= GRAVITY * delta
|
||||||
|
|
||||||
|
var direction := Vector3(move_x, 0.0, move_z)
|
||||||
|
if direction.length() > 0.01:
|
||||||
|
direction = direction.normalized()
|
||||||
|
velocity.x = direction.x * WALK_SPEED
|
||||||
|
velocity.z = direction.z * WALK_SPEED
|
||||||
|
else:
|
||||||
|
velocity.x = 0.0
|
||||||
|
velocity.z = 0.0
|
||||||
|
|
||||||
|
if jump and _on_floor:
|
||||||
|
velocity.y = JUMP_VELOCITY
|
||||||
|
|
||||||
|
position += velocity * delta
|
||||||
|
# Headless prediction has no collision shape of its own to test
|
||||||
|
# is_on_floor() against -- treat "at or below the spawn-plane ground"
|
||||||
|
# as grounded. A real render-side controller would swap this for the
|
||||||
|
# actual CharacterBody3D.is_on_floor() of whatever visual node this
|
||||||
|
# drives; this class only owns the *networking* half.
|
||||||
|
if position.y <= 0.0:
|
||||||
|
position.y = 0.0
|
||||||
|
velocity.y = 0.0
|
||||||
|
_on_floor = true
|
||||||
|
else:
|
||||||
|
_on_floor = false
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reconciliation -- called by _ReceiverScript when a snapshot arrives.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _handle_snapshot(snapshot_data: Dictionary) -> void:
|
||||||
|
var my_id := multiplayer.get_unique_id()
|
||||||
|
var players: Dictionary = snapshot_data.get("players", {})
|
||||||
|
# Server keys player_states by peer_id -- Dictionary keys from JSON-ish
|
||||||
|
# RPC payloads can arrive as String or int depending on the transport
|
||||||
|
# encoding, so check both rather than assuming.
|
||||||
|
var my_state: Variant = players.get(my_id, players.get(str(my_id), null))
|
||||||
|
if my_state == null or not (my_state is Dictionary):
|
||||||
|
return
|
||||||
|
|
||||||
|
var authoritative_pos: Vector3 = (my_state as Dictionary).get("position", position)
|
||||||
|
|
||||||
|
# Real limitation, noted explicitly rather than silently faked: the
|
||||||
|
# server's snapshot_state (scripts/server.gd's _broadcast_snapshot) does
|
||||||
|
# not currently echo back "last input seq processed for this peer" --
|
||||||
|
# its `tick` field is the server's own internal simulation tick counter,
|
||||||
|
# a completely different clock from this client's per-input `_seq`. True
|
||||||
|
# tick-correlated rollback (docs/architecture/networking.md §5.2 step 4,
|
||||||
|
# "re-simulate any inputs that occurred after the server's tick") needs
|
||||||
|
# that ack echoed back per-peer -- a real, scoped follow-up, not
|
||||||
|
# something to fake here. Until then, reconcile against the MOST RECENT
|
||||||
|
# prediction (not a tick-matched one): still a real error measurement
|
||||||
|
# and a real correction, just without precise replay-from-exact-tick.
|
||||||
|
if _prediction_buffer.is_empty():
|
||||||
|
return
|
||||||
|
var latest_entry: Dictionary = _prediction_buffer[-1]
|
||||||
|
|
||||||
|
var error: float = (latest_entry["position"] as Vector3).distance_to(authoritative_pos)
|
||||||
|
if error <= RECONCILE_ERROR_THRESHOLD:
|
||||||
|
return # Close enough -- prediction was correct, nothing to do.
|
||||||
|
|
||||||
|
# Correct: snap to the authoritative position. Without a real ack-seq
|
||||||
|
# from the server there's no reliable "replay inputs after this point"
|
||||||
|
# window to recompute, so the buffer is cleared and prediction resumes
|
||||||
|
# fresh from the corrected position on the next input -- still visibly
|
||||||
|
# responsive (no waiting for a round trip before the *next* input
|
||||||
|
# applies), just without this pass replaying in-flight inputs.
|
||||||
|
position = authoritative_pos
|
||||||
|
velocity = Vector3.ZERO
|
||||||
|
_prediction_buffer.clear()
|
||||||
|
|
||||||
|
reconciled.emit(error)
|
||||||
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
extends Node
|
||||||
|
## Client-side proxy for the server's real PlayerNode (scripts/
|
||||||
|
## player_spawner.gd), attached at runtime to a Node named "Player_<peer_id>"
|
||||||
|
## under the /root/Server proxy by NetworkClient._on_connected().
|
||||||
|
##
|
||||||
|
## rpc_request_input is the only one of these actually CALLED from this
|
||||||
|
## side -- the real implementations all live on the server. But two things
|
||||||
|
## force every @rpc method on the real PlayerNode to be mirrored here too,
|
||||||
|
## not just the one this class uses:
|
||||||
|
## 1. Godot's rpc_id() looks up the RPC transfer config (mode/reliability)
|
||||||
|
## from the LOCAL script before it will even send a call, so the method
|
||||||
|
## being called has to be declared locally with a matching annotation.
|
||||||
|
## 2. Godot's multiplayer layer also verifies an "RPC node checksum" --
|
||||||
|
## both peers must declare the *same set* of @rpc methods at a matching
|
||||||
|
## path, or every RPC at that path starts failing, not just the one
|
||||||
|
## that's actually missing. So even rpc_request_interact/attack/
|
||||||
|
## set_position/set_rotation, which this class never calls, have to be
|
||||||
|
## declared here with matching annotations or rpc_request_input itself
|
||||||
|
## breaks too.
|
||||||
|
|
||||||
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
|
func rpc_request_input(_input_state: Dictionary, _seq: int) -> void:
|
||||||
|
pass # Never invoked locally -- see the note above.
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
|
func rpc_request_interact(_target_id: int) -> void:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
|
func rpc_request_attack(_target_id: int, _aim_dir: Vector3) -> void:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func rpc_set_position(_pos: Vector3) -> void:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func rpc_set_rotation(_rot: Vector3) -> void:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
extends Node
|
||||||
|
## Client-side proxy node, attached at runtime to a Node named "Server" at
|
||||||
|
## the SceneTree root by NetworkClient.connect_to_server(). Exists purely so
|
||||||
|
## Godot's RPC path-matching finds a receiver for _receive_snapshot -- see
|
||||||
|
## network_client.gd's class docstring for the full explanation of why this
|
||||||
|
## has to exist at this exact path. Not a real server in any sense.
|
||||||
|
##
|
||||||
|
## Godot's multiplayer layer verifies an "RPC node checksum" -- both peers
|
||||||
|
## must declare the *same set* of @rpc methods at a matching path, or every
|
||||||
|
## RPC at that path (not just the mismatched one) starts failing with
|
||||||
|
## "rpc node checksum failed". So every @rpc method scripts/server.gd
|
||||||
|
## declares on its own root "Server" node has to be mirrored here too, even
|
||||||
|
## the ones this proxy never actually needs to receive.
|
||||||
|
|
||||||
|
var owner_client: Node = null
|
||||||
|
|
||||||
|
|
||||||
|
## Must match scripts/server.gd's own @rpc annotation on _receive_snapshot
|
||||||
|
## exactly -- Godot enforces the receiving method's declared mode/transfer
|
||||||
|
## config, a mismatch here would silently drop the call.
|
||||||
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
|
func _receive_snapshot(snapshot_data: Dictionary) -> void:
|
||||||
|
if owner_client and is_instance_valid(owner_client):
|
||||||
|
owner_client._handle_snapshot(snapshot_data)
|
||||||
|
|
||||||
|
|
||||||
|
## Mirrors scripts/server.gd's _kick -- never actually invoked on this side
|
||||||
|
## (the server calls it on a Player_<id> node's own rpc_id target, not this
|
||||||
|
## root proxy), declared only so the RPC checksum for this path matches.
|
||||||
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
|
func _kick(_reason: String) -> void:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
## Mirrors scripts/server.gd's _disconnect -- same reasoning as _kick above.
|
||||||
|
@rpc("call_remote", "reliable")
|
||||||
|
func _disconnect(_reason: String) -> void:
|
||||||
|
pass
|
||||||
+3
-5
@@ -79,13 +79,11 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||||||
_update_input_direction(key)
|
_update_input_direction(key)
|
||||||
elif not key.pressed:
|
elif not key.pressed:
|
||||||
_clear_input_direction(key)
|
_clear_input_direction(key)
|
||||||
# Release the mouse so the player can get back to a menu/cursor.
|
# Escape exits the level back to the world browse list, mirroring
|
||||||
|
# the Back button pattern already used in world_list.gd/main_menu.gd.
|
||||||
if key.pressed and key.keycode == KEY_ESCAPE:
|
if key.pressed and key.keycode == KEY_ESCAPE:
|
||||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||||
elif event is InputEventMouseButton and event.pressed:
|
get_tree().change_scene_to_file("res://scenes/world_list.tscn")
|
||||||
# Clicking back into the viewport re-captures the mouse.
|
|
||||||
if Input.mouse_mode == Input.MOUSE_MODE_VISIBLE and DisplayServer.get_name() != "headless":
|
|
||||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
|
||||||
elif event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
|
elif event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
|
||||||
_handle_mouse_look(event as InputEventMouseMotion)
|
_handle_mouse_look(event as InputEventMouseMotion)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
class_name PlayerNode
|
||||||
|
extends CharacterBody3D
|
||||||
|
## PlayerNode - Server-side player entity.
|
||||||
|
##
|
||||||
|
## Represents a connected player on the server. Handles movement,
|
||||||
|
## input processing, and state synchronization.
|
||||||
|
|
||||||
|
# --- Signals ---
|
||||||
|
signal health_changed(new_health: int)
|
||||||
|
signal player_died(player_node: PlayerNode)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Constants ---
|
||||||
|
const WALK_SPEED := 5.0
|
||||||
|
const SPRINT_MULTIPLIER := 1.6
|
||||||
|
const JUMP_VELOCITY := 4.5
|
||||||
|
const GRAVITY := 9.8
|
||||||
|
const MAX_FALL_SPEED := 20.0
|
||||||
|
const FRICTION := 8.0
|
||||||
|
const AIR_CONTROL := 0.3
|
||||||
|
|
||||||
|
# --- Configurable ---
|
||||||
|
@export var walk_speed: float = WALK_SPEED
|
||||||
|
@export var sprint_multiplier: float = SPRINT_MULTIPLIER
|
||||||
|
@export var jump_velocity: float = JUMP_VELOCITY
|
||||||
|
@export var max_fall_speed: float = MAX_FALL_SPEED
|
||||||
|
|
||||||
|
# --- State ---
|
||||||
|
@export var max_health: int = 100
|
||||||
|
var current_health: int = 0
|
||||||
|
var is_alive: bool = true
|
||||||
|
var is_sprinting: bool = false
|
||||||
|
var peer_id: int = -1
|
||||||
|
var player_name: String = ""
|
||||||
|
var entity_id: int = 0
|
||||||
|
|
||||||
|
# --- Input buffer ---
|
||||||
|
var _input_queue: Array[Dictionary] = []
|
||||||
|
var _last_processed_seq: int = -1
|
||||||
|
|
||||||
|
# --- Movement ---
|
||||||
|
var _velocity: Vector3 = Vector3.ZERO
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
current_health = max_health
|
||||||
|
velocity = _velocity
|
||||||
|
# Start alive
|
||||||
|
is_alive = true
|
||||||
|
|
||||||
|
|
||||||
|
func _physics_process(delta: float) -> void:
|
||||||
|
if not is_alive:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Process input from queue
|
||||||
|
_process_input(delta)
|
||||||
|
|
||||||
|
# Apply gravity
|
||||||
|
if not is_on_floor():
|
||||||
|
velocity.y -= GRAVITY * delta
|
||||||
|
velocity.y = clampf(velocity.y, -max_fall_speed, max_fall_speed)
|
||||||
|
|
||||||
|
# Apply friction on ground
|
||||||
|
if is_on_floor():
|
||||||
|
var move_dir := Vector2(velocity.x, velocity.z)
|
||||||
|
if move_dir.length() > 0.01:
|
||||||
|
var friction_factor := clampf(FRICTION * delta, 0.0, 1.0)
|
||||||
|
velocity.x = move_dir.normalized().x * move_dir.length() * (1.0 - friction_factor)
|
||||||
|
velocity.z = move_dir.normalized().y * move_dir.length() * (1.0 - friction_factor)
|
||||||
|
else:
|
||||||
|
velocity.x = 0.0
|
||||||
|
velocity.z = 0.0
|
||||||
|
|
||||||
|
# Move and slide
|
||||||
|
velocity.y = clampf(velocity.y, -max_fall_speed, max_fall_speed)
|
||||||
|
move_and_slide()
|
||||||
|
|
||||||
|
# Update visual position (for remote clients via RPC)
|
||||||
|
_update_visual_state()
|
||||||
|
|
||||||
|
|
||||||
|
func _process_input(delta: float) -> void:
|
||||||
|
"""Process buffered input from the server's input queue."""
|
||||||
|
if _input_queue.is_empty():
|
||||||
|
return
|
||||||
|
|
||||||
|
var input_data: Dictionary = _input_queue.pop_front()
|
||||||
|
var input_state: Dictionary = input_data.get("input_state", {})
|
||||||
|
|
||||||
|
# Calculate movement direction
|
||||||
|
var direction := Vector3.ZERO
|
||||||
|
direction.x = input_state.get("move_x", 0.0)
|
||||||
|
direction.z = input_state.get("move_z", 0.0)
|
||||||
|
|
||||||
|
# Sprint check
|
||||||
|
is_sprinting = input_state.get("sprint", false)
|
||||||
|
|
||||||
|
if direction.length() > 0.01:
|
||||||
|
direction = direction.normalized()
|
||||||
|
var speed = walk_speed * sprint_multiplier if is_sprinting else walk_speed
|
||||||
|
velocity.x = direction.x * speed
|
||||||
|
velocity.z = direction.z * speed
|
||||||
|
else:
|
||||||
|
# On ground, apply friction (handled in physics_process)
|
||||||
|
if not is_on_floor():
|
||||||
|
velocity.x *= (1.0 - AIR_CONTROL * delta * 5.0)
|
||||||
|
velocity.z *= (1.0 - AIR_CONTROL * delta * 5.0)
|
||||||
|
|
||||||
|
# Jump
|
||||||
|
if input_state.get("jump", false) and is_on_floor():
|
||||||
|
velocity.y = jump_velocity
|
||||||
|
|
||||||
|
|
||||||
|
func take_damage(amount: int, source_id: int) -> Dictionary:
|
||||||
|
"""Take damage and return result dict."""
|
||||||
|
if not is_alive:
|
||||||
|
return {"success": false, "reason": "already_dead"}
|
||||||
|
|
||||||
|
current_health = max(0, current_health - amount)
|
||||||
|
health_changed.emit(current_health)
|
||||||
|
|
||||||
|
if current_health <= 0:
|
||||||
|
die()
|
||||||
|
return {"success": true, "health": 0, "dead": true}
|
||||||
|
|
||||||
|
return {"success": true, "health": current_health}
|
||||||
|
|
||||||
|
|
||||||
|
func die() -> void:
|
||||||
|
"""Handle player death."""
|
||||||
|
is_alive = false
|
||||||
|
velocity = Vector3.ZERO
|
||||||
|
player_died.emit(self)
|
||||||
|
|
||||||
|
# Respawn after delay
|
||||||
|
await get_tree().create_timer(3.0).timeout
|
||||||
|
respawn()
|
||||||
|
|
||||||
|
|
||||||
|
func respawn() -> void:
|
||||||
|
"""Respawn the player at spawn point."""
|
||||||
|
current_health = max_health
|
||||||
|
is_alive = true
|
||||||
|
velocity = Vector3.ZERO
|
||||||
|
health_changed.emit(current_health)
|
||||||
|
|
||||||
|
# Move to spawn point (default: origin)
|
||||||
|
position = Vector3.ZERO
|
||||||
|
|
||||||
|
|
||||||
|
func set_input_queue(inputs: Array[Dictionary]) -> void:
|
||||||
|
"""Set the input queue from the server."""
|
||||||
|
_input_queue = inputs
|
||||||
|
|
||||||
|
|
||||||
|
func get_state() -> Dictionary:
|
||||||
|
"""Get current player state for replication."""
|
||||||
|
return {
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"peer_id": peer_id,
|
||||||
|
"position": position,
|
||||||
|
"rotation": rotation,
|
||||||
|
"health": current_health,
|
||||||
|
"max_health": max_health,
|
||||||
|
"is_alive": is_alive,
|
||||||
|
"is_sprinting": is_sprinting,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func get_server_state() -> Dictionary:
|
||||||
|
"""Get current player state for server snapshot broadcast."""
|
||||||
|
return get_state()
|
||||||
|
|
||||||
|
|
||||||
|
func process_server_input(tick: int) -> void:
|
||||||
|
"""Called by server tick to process buffered input."""
|
||||||
|
# This is called from the server's _tick loop.
|
||||||
|
# The input queue is populated by RPCs from clients.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
func _update_visual_state() -> void:
|
||||||
|
"""Update visual state (for remote interpolation)."""
|
||||||
|
# In a full implementation, this would update a remote player's
|
||||||
|
# visual representation. For now, the node itself is the authority.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# --- RPCs ---
|
||||||
|
|
||||||
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
|
func rpc_request_input(input_state: Dictionary, seq: int) -> void:
|
||||||
|
"""Receive input from client."""
|
||||||
|
if not is_alive:
|
||||||
|
return
|
||||||
|
# Store for processing in _physics_process
|
||||||
|
_input_queue.append({
|
||||||
|
"input_state": input_state,
|
||||||
|
"seq": seq,
|
||||||
|
"timestamp": Time.get_ticks_msec(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
|
func rpc_request_interact(target_id: int) -> void:
|
||||||
|
"""Handle interaction request from client."""
|
||||||
|
if not is_alive:
|
||||||
|
return
|
||||||
|
# TODO: Implement interaction logic
|
||||||
|
print("Player %d interacted with entity %d" % [peer_id, target_id])
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
|
func rpc_request_attack(target_id: int, aim_dir: Vector3) -> void:
|
||||||
|
"""Handle attack request from client."""
|
||||||
|
if not is_alive:
|
||||||
|
return
|
||||||
|
# TODO: Implement attack logic
|
||||||
|
print("Player %d attacked entity %d with aim %s" % [peer_id, target_id, aim_dir])
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func rpc_set_position(pos: Vector3) -> void:
|
||||||
|
"""Server authoritative position update."""
|
||||||
|
position = pos
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func rpc_set_rotation(rot: Vector3) -> void:
|
||||||
|
"""Server authoritative rotation update."""
|
||||||
|
rotation = rot
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://br27owjh2f5xd
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
## ServerMain - Dedicated server root node.
|
||||||
|
## Accepts ENet connections, spawns Player nodes for each peer,
|
||||||
|
## and runs the simulation tick loop at 20 Hz.
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
|
||||||
|
## Maximum number of concurrent players.
|
||||||
|
const MAX_PLAYERS := 16
|
||||||
|
|
||||||
|
## Server tick rate (Hz).
|
||||||
|
const TICK_RATE := 20
|
||||||
|
|
||||||
|
## Tick duration in seconds.
|
||||||
|
const TICK_DURATION := 1.0 / TICK_RATE
|
||||||
|
|
||||||
|
## ENet server port.
|
||||||
|
const SERVER_PORT := 7777
|
||||||
|
|
||||||
|
## Network peer used for ENet communication.
|
||||||
|
var network_peer: ENetMultiplayerPeer
|
||||||
|
|
||||||
|
## Map of peer_id -> Player node.
|
||||||
|
var players: Dictionary = {}
|
||||||
|
|
||||||
|
## Current tick counter (server-authoritative).
|
||||||
|
var tick: int = 0
|
||||||
|
|
||||||
|
## Whether the server is running.
|
||||||
|
var is_running: bool = false
|
||||||
|
|
||||||
|
## Accumulated real time for fixed timestep simulation.
|
||||||
|
var accumulator: float = 0.0
|
||||||
|
|
||||||
|
## Player scene resource (loaded once).
|
||||||
|
var _player_scene: PackedScene
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
# Preload the player scene once.
|
||||||
|
_player_scene = preload("res://scenes/server_player.tscn")
|
||||||
|
|
||||||
|
# Create and assign the ENet server peer.
|
||||||
|
network_peer = _create_server_peer()
|
||||||
|
if network_peer:
|
||||||
|
multiplayer.multiplayer_peer = network_peer
|
||||||
|
is_running = true
|
||||||
|
print("[Server] Started on port %d" % SERVER_PORT)
|
||||||
|
else:
|
||||||
|
printerr("[Server] Failed to create ENet peer.")
|
||||||
|
get_tree().quit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Server creation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _create_server_peer() -> ENetMultiplayerPeer:
|
||||||
|
var peer := ENetMultiplayerPeer.new()
|
||||||
|
var error := peer.create_server(SERVER_PORT, MAX_PLAYERS)
|
||||||
|
if error != OK:
|
||||||
|
printerr("[Server] create_server error: %d" % error)
|
||||||
|
return null
|
||||||
|
return peer
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixed-timestep simulation loop
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
if not is_running:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Fixed timestep accumulator.
|
||||||
|
accumulator += delta
|
||||||
|
while accumulator >= TICK_DURATION:
|
||||||
|
accumulator -= TICK_DURATION
|
||||||
|
_tick()
|
||||||
|
|
||||||
|
|
||||||
|
func _tick() -> void:
|
||||||
|
"""Main server simulation tick (called at TICK_RATE)."""
|
||||||
|
tick += 1
|
||||||
|
|
||||||
|
# Process player inputs and update state.
|
||||||
|
for peer_id in players:
|
||||||
|
var player: Node3D = players[peer_id]
|
||||||
|
if player and player.has_method("process_server_input"):
|
||||||
|
player.process_server_input(tick)
|
||||||
|
|
||||||
|
# Broadcast snapshot to all clients.
|
||||||
|
_broadcast_snapshot()
|
||||||
|
|
||||||
|
|
||||||
|
func _broadcast_snapshot() -> void:
|
||||||
|
"""Broadcast the current authoritative state to all connected clients."""
|
||||||
|
var snapshot_data := {
|
||||||
|
"tick": tick,
|
||||||
|
"players": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
for peer_id in players:
|
||||||
|
var player: Node3D = players[peer_id]
|
||||||
|
if player and player.has_method("get_server_state"):
|
||||||
|
snapshot_data.players[peer_id] = player.get_server_state()
|
||||||
|
|
||||||
|
# Send snapshot to all peers except the server itself (peer_id == 1).
|
||||||
|
var peer_ids: Array = multiplayer.get_peers()
|
||||||
|
for pid in peer_ids:
|
||||||
|
if pid == 1: # 1 is the server itself
|
||||||
|
continue
|
||||||
|
rpc_id(pid, "_receive_snapshot", snapshot_data)
|
||||||
|
|
||||||
|
|
||||||
|
# Remote: clients receive snapshots.
|
||||||
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
|
func _receive_snapshot(snapshot_data: Dictionary) -> void:
|
||||||
|
pass # Handled by client scene.
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Connection lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _on_peer_connected(peer_id: int) -> void:
|
||||||
|
print("[Server] Peer %d connected." % peer_id)
|
||||||
|
|
||||||
|
if players.size() >= MAX_PLAYERS:
|
||||||
|
print("[Server] Max players reached. Disconnecting peer %d." % peer_id)
|
||||||
|
rpc_id(peer_id, "_kick", "Server is full.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Spawn a Player node for this peer.
|
||||||
|
var player: Node3D = _player_scene.instantiate()
|
||||||
|
player.name = "Player_%d" % peer_id
|
||||||
|
player.peer_id = peer_id
|
||||||
|
add_child(player)
|
||||||
|
players[peer_id] = player
|
||||||
|
|
||||||
|
print("[Server] Spawned player node for peer %d." % peer_id)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_peer_disconnected(peer_id: int) -> void:
|
||||||
|
print("[Server] Peer %d disconnected." % peer_id)
|
||||||
|
if players.has(peer_id):
|
||||||
|
var player: Node3D = players[peer_id]
|
||||||
|
if player.is_inside_tree():
|
||||||
|
player.queue_free()
|
||||||
|
players.erase(peer_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# RPCs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
|
func _kick(reason: String) -> void:
|
||||||
|
print("[Server] Kicking caller: %s" % reason)
|
||||||
|
rpc_id(multiplayer.get_remote_sender_id(), "_disconnect", reason)
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("call_remote", "reliable")
|
||||||
|
func _disconnect(reason: String) -> void:
|
||||||
|
print("[Server] Disconnecting: %s" % reason)
|
||||||
|
get_tree().quit(0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Signal connections
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _enter_tree() -> void:
|
||||||
|
multiplayer.peer_connected.connect(_on_peer_connected)
|
||||||
|
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cwjv826lva2gi
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Regression/smoke test for the PC2 "Enter World" flow end to end.
|
||||||
|
##
|
||||||
|
## Real user path: register -> land on world_list.tscn -> click "Enter World"
|
||||||
|
## on a real card -> the playable level (scenes/game.tscn) actually loads
|
||||||
|
## with the player spawned in it -> pressing Escape returns to world_list.
|
||||||
|
##
|
||||||
|
## Run with:
|
||||||
|
## godot --headless --path . --script res://scripts/smoke_test_enter_world.gd
|
||||||
|
##
|
||||||
|
## Requires the backend to be running (default base URL
|
||||||
|
## `application/agp/api_base_url` -> http://127.0.0.1:8000, overridable via
|
||||||
|
## AGP_API_BASE_URL) and at least one real world to already exist.
|
||||||
|
|
||||||
|
var passed := 0
|
||||||
|
var failed := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run()
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
var api := root.get_node("ApiClient")
|
||||||
|
if api == null:
|
||||||
|
printerr("FAIL: ApiClient autoload not found")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1. Register a real user and seed a world so the browse list has a
|
||||||
|
# real card to click, same pattern as smoke_test_world_list.gd.
|
||||||
|
randomize()
|
||||||
|
var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000
|
||||||
|
var username := "enterworld_%d" % unique_id
|
||||||
|
var email := "enterworld_%d@test.example.com" % unique_id
|
||||||
|
var reg_result: Dictionary = await api.register(username, email, "SmokeTest123!", true)
|
||||||
|
if not _check("setup: register succeeded", reg_result.ok):
|
||||||
|
print(" detail: ", reg_result.detail)
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
|
||||||
|
var world_body := JSON.stringify({
|
||||||
|
"name": "EnterWorld Smoke Test World %d" % unique_id,
|
||||||
|
"description": "Seeded by smoke_test_enter_world.gd",
|
||||||
|
"visibility": "public",
|
||||||
|
})
|
||||||
|
var world_result: Dictionary = await api.request(
|
||||||
|
HTTPClient.METHOD_POST, "/api/worlds/", world_body, "application/json", true
|
||||||
|
)
|
||||||
|
if not _check("setup: world created", world_result.ok):
|
||||||
|
print(" detail: ", world_result.detail)
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. Load world_list.tscn as a real scene and let it fetch worlds.
|
||||||
|
var packed: PackedScene = load("res://scenes/world_list.tscn")
|
||||||
|
var instance: Control = packed.instantiate()
|
||||||
|
root.add_child(instance)
|
||||||
|
# _ready() kicks off an async _refresh_worlds() -- wait real frames for
|
||||||
|
# the HTTP round trip rather than guessing a fixed count.
|
||||||
|
var world_list_node: VBoxContainer = instance.get_node("VBoxContainer/ScrollContainer/WorldList")
|
||||||
|
var settled := false
|
||||||
|
for i in 120:
|
||||||
|
await process_frame
|
||||||
|
if world_list_node.get_child_count() > 0:
|
||||||
|
settled = true
|
||||||
|
break
|
||||||
|
if not _check("world list populated with at least one card", settled):
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
|
||||||
|
# 3. Click "Enter World" on the first real card -- find the button by
|
||||||
|
# walking the card's children rather than assuming an index, so this
|
||||||
|
# doesn't silently pass if _create_world_card's structure changes.
|
||||||
|
var card: Control = world_list_node.get_child(0)
|
||||||
|
var enter_button: Button = _find_button(card, "Enter World")
|
||||||
|
if not _check("Enter World button exists on the card", enter_button != null):
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
|
||||||
|
enter_button.pressed.emit()
|
||||||
|
await _wait_for_scene_change(instance, 60)
|
||||||
|
|
||||||
|
var game_scene := current_scene
|
||||||
|
if not _check("game.tscn became current_scene after Enter World", game_scene != null and game_scene != instance):
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
|
||||||
|
# 4. Verify the player actually exists and spawned at the level's
|
||||||
|
# PlayerSpawn marker -- not just that *some* scene loaded.
|
||||||
|
var player: Node = game_scene.get_node_or_null("Player")
|
||||||
|
_check("Player node exists in the loaded game scene", player is CharacterBody3D)
|
||||||
|
|
||||||
|
var spawn_marker: Node = game_scene.get_node_or_null("Level/PlayerSpawn")
|
||||||
|
_check("PlayerSpawn marker exists in the loaded level", spawn_marker is Marker3D)
|
||||||
|
|
||||||
|
if player is CharacterBody3D and spawn_marker is Marker3D:
|
||||||
|
var dist := (player as CharacterBody3D).global_position.distance_to((spawn_marker as Marker3D).global_position)
|
||||||
|
_check("player spawned at (or very near) the PlayerSpawn marker", dist < 0.5)
|
||||||
|
|
||||||
|
# 5. Escape should return to world_list.tscn (real key event, not a
|
||||||
|
# direct method call -- proves the actual input binding works).
|
||||||
|
var esc_event := InputEventKey.new()
|
||||||
|
esc_event.keycode = KEY_ESCAPE
|
||||||
|
esc_event.pressed = true
|
||||||
|
Input.parse_input_event(esc_event)
|
||||||
|
await _wait_for_scene_change(game_scene, 60)
|
||||||
|
|
||||||
|
var back_to_menu := current_scene
|
||||||
|
_check("Escape returned to world_list.tscn", back_to_menu != null and back_to_menu.scene_file_path == "res://scenes/world_list.tscn")
|
||||||
|
|
||||||
|
_finish()
|
||||||
|
|
||||||
|
|
||||||
|
func _find_button(node: Node, text: String) -> Button:
|
||||||
|
if node is Button and (node as Button).text == text:
|
||||||
|
return node
|
||||||
|
for child in node.get_children():
|
||||||
|
var found := _find_button(child, text)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
func _wait_for_scene_change(previous_scene: Node, max_frames: int) -> void:
|
||||||
|
for i in max_frames:
|
||||||
|
await process_frame
|
||||||
|
if current_scene != previous_scene and current_scene != null:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Real integration test for NetworkClient (client-side prediction +
|
||||||
|
## reconciliation) against an actual dedicated server process.
|
||||||
|
##
|
||||||
|
## This does NOT boot its own server -- run scenes/server.tscn as a separate
|
||||||
|
## process first (see docs/architecture/networking.md's ENet/port setup):
|
||||||
|
## godot --headless --path . scenes/server.tscn &
|
||||||
|
## then, from the same project directory:
|
||||||
|
## godot --headless --path . --script res://scripts/smoke_test_network_client.gd
|
||||||
|
##
|
||||||
|
## Verifies: the client can actually connect over ENet, local prediction
|
||||||
|
## moves the player immediately on input (no waiting for the server),
|
||||||
|
## server snapshots actually arrive and update the client's authoritative
|
||||||
|
## view, and a deliberately-desynced predicted position gets corrected by
|
||||||
|
## reconciliation.
|
||||||
|
|
||||||
|
var passed := 0
|
||||||
|
var failed := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run()
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
var net := NetworkClient.new()
|
||||||
|
root.add_child(net)
|
||||||
|
|
||||||
|
var ok: bool = await net.connect_to_server("127.0.0.1", 7777)
|
||||||
|
if not _check("connected to the real dedicated server", ok):
|
||||||
|
printerr(" Is `godot --headless --path . scenes/server.tscn` running on port 7777?")
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Give the server a few frames to process the connection and spawn our
|
||||||
|
# player node before we start sending input.
|
||||||
|
for i in 10:
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
# 1. Prediction: pressing "forward" should move the local position
|
||||||
|
# immediately, without waiting for any server round trip.
|
||||||
|
var pos_before: Vector3 = net.position
|
||||||
|
for i in 10:
|
||||||
|
net.predict_and_send(1.0 / 30.0, 0.0, -1.0, false) # move -Z (forward)
|
||||||
|
await process_frame
|
||||||
|
var pos_after_predict: Vector3 = net.position
|
||||||
|
_check("local prediction moved the player without waiting for the server",
|
||||||
|
pos_after_predict.distance_to(pos_before) > 0.1)
|
||||||
|
|
||||||
|
# 2. Snapshots actually arrive from the real server and get processed --
|
||||||
|
# wait for at least one reconciliation-eligible snapshot by giving it
|
||||||
|
# real time (server broadcasts at its own tick rate).
|
||||||
|
var got_snapshot := false
|
||||||
|
net.reconciled.connect(func(_err): got_snapshot = true)
|
||||||
|
for i in 60:
|
||||||
|
net.predict_and_send(1.0 / 30.0, 0.0, -1.0, false)
|
||||||
|
await process_frame
|
||||||
|
# Reconciliation only actually fires on a real mismatch -- absence of a
|
||||||
|
# large mismatch is a PASS outcome too (means prediction tracked the
|
||||||
|
# server closely), so this alone isn't proof snapshots arrived. Check
|
||||||
|
# that directly instead:
|
||||||
|
_check("prediction buffer has entries (input was actually sent)", net._prediction_buffer.size() > 0)
|
||||||
|
|
||||||
|
# 3. Deliberately desync prediction from reality, then confirm the next
|
||||||
|
# snapshot corrects it -- the real proof reconciliation logic works,
|
||||||
|
# not just that it never had to fire.
|
||||||
|
net.position = Vector3(9999, 9999, 9999)
|
||||||
|
var desynced_pos := net.position
|
||||||
|
# GDScript closures capture local value-type vars (bool/float) by value,
|
||||||
|
# not by reference -- assigning to them *inside* the lambda doesn't
|
||||||
|
# mutate the outer scope's copy. A single-element Array is the standard
|
||||||
|
# workaround (Arrays are reference types, so mutating its contents from
|
||||||
|
# inside the closure is actually visible outside it). Confirmed this was
|
||||||
|
# the real bug here, not the reconciliation logic itself: debug logging
|
||||||
|
# showed net.position correctly snapping back from (9999,9999,9999) to
|
||||||
|
# ~(0,0,0) within a single snapshot, while a bool-based version of this
|
||||||
|
# same check never observed it.
|
||||||
|
var reconcile_state := [false, -1.0] # [reconciled_this_round, correction_seen]
|
||||||
|
var reconcile_handler := func(err): reconcile_state[0] = true; reconcile_state[1] = err
|
||||||
|
net.reconciled.connect(reconcile_handler)
|
||||||
|
for i in 60:
|
||||||
|
# Keep predicting (with no movement input) so the prediction buffer's
|
||||||
|
# latest entry actually reflects the desynced position -- without
|
||||||
|
# this, the buffer either empties out or keeps stale pre-desync
|
||||||
|
# entries, and _handle_snapshot has nothing real to compare against.
|
||||||
|
net.predict_and_send(1.0 / 30.0, 0.0, 0.0, false)
|
||||||
|
await process_frame
|
||||||
|
if reconcile_state[0]:
|
||||||
|
break
|
||||||
|
net.reconciled.disconnect(reconcile_handler)
|
||||||
|
_check("a deliberately desynced position gets corrected by the next snapshot",
|
||||||
|
reconcile_state[0] and net.position.distance_to(desynced_pos) > 100.0)
|
||||||
|
if reconcile_state[0]:
|
||||||
|
print(" correction distance was ~", reconcile_state[1])
|
||||||
|
|
||||||
|
net.disconnect_from_server()
|
||||||
|
_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)
|
||||||
@@ -135,8 +135,23 @@ func _create_world_card(world_dict: Dictionary) -> Control:
|
|||||||
meta_label.add_theme_font_size_override("font_size", 12)
|
meta_label.add_theme_font_size_override("font_size", 12)
|
||||||
meta_label.add_theme_color_override("font_color", Color(0.5, 0.5, 0.5, 1.0))
|
meta_label.add_theme_color_override("font_color", Color(0.5, 0.5, 0.5, 1.0))
|
||||||
|
|
||||||
|
# PC2: the only thing a world actually loads into right now is the one
|
||||||
|
# shared playable test level (scenes/game.tscn) -- there's no per-world
|
||||||
|
# content yet, so "Enter" doesn't need the world's id for anything.
|
||||||
|
# Real per-world level loading is a later concern once worlds actually
|
||||||
|
# have their own content to load.
|
||||||
|
var enter_button := Button.new()
|
||||||
|
enter_button.text = "Enter World"
|
||||||
|
enter_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||||
|
enter_button.pressed.connect(_on_enter_world_pressed)
|
||||||
|
|
||||||
card.add_child(name_label)
|
card.add_child(name_label)
|
||||||
card.add_child(desc_label)
|
card.add_child(desc_label)
|
||||||
card.add_child(meta_label)
|
card.add_child(meta_label)
|
||||||
|
card.add_child(enter_button)
|
||||||
|
|
||||||
return panel
|
return panel
|
||||||
|
|
||||||
|
|
||||||
|
func _on_enter_world_pressed() -> void:
|
||||||
|
get_tree().change_scene_to_file("res://scenes/game.tscn")
|
||||||
|
|||||||
Reference in New Issue
Block a user