Compare commits
104 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b6e146e4b2 | |||
| 59ca5a4c6e | |||
| b422e41a0f | |||
| bd44ecba14 | |||
| bae326e14c | |||
| 140db9df91 | |||
| 2dff83bae3 | |||
| 1a8821e3c8 | |||
| 019dfca14f | |||
| 7be50e3313 | |||
| 29de1c5c3a | |||
| 92c10fa657 | |||
| 8357643072 | |||
| 736ebce0d0 | |||
| b60480798f | |||
| 77f7511a4f | |||
| 8be962248d | |||
| 7790670559 | |||
| c4eab8db92 | |||
| 4d754ae48e | |||
| e2a77b055f | |||
| 5966fa9044 | |||
| d1d03df2e6 | |||
| 4dda636ed0 | |||
| 945c498a31 | |||
| be0acb1862 | |||
| 8118f3074b | |||
| 4276a89a6a | |||
| 5803f47069 | |||
| e33b91d976 | |||
| 1c3c57dbd0 | |||
| fdc43bd5f1 | |||
| 406ff3457e | |||
| 3f7acf857c | |||
| 6648b2bba5 | |||
| bfcbd0486f | |||
| f70b5b2ee5 | |||
| 2fa7b1f475 | |||
| 7f753cb1b8 | |||
| 1be947f9ef | |||
| 66adcff145 | |||
| 88197ffe8f | |||
| 38d97a3051 | |||
| 174d3c2630 | |||
| b86e4053bd | |||
| 07f655815f | |||
| 2ffcd9cd3f | |||
| aeccc78143 | |||
| 1c64dd4967 | |||
| 4dd6c7c8af | |||
| e90b394f03 | |||
| 33b0c3f1fb | |||
| 2b88fc476e | |||
| 0ecc16d963 | |||
| 4ec71b87b9 | |||
| dc0c133af8 | |||
| 8f1a1110e2 | |||
| 0f7531008f | |||
| 12fef51020 | |||
| af50819a16 | |||
| 16bb87c6b0 | |||
| 8fc9d378e4 | |||
| 3679e4dbc4 | |||
| c33b10ac46 | |||
| 8c54129bcd | |||
| 409e78baa5 | |||
| 1cc81979a4 | |||
| f8e5de9293 | |||
| afc2211d70 | |||
| 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-20
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
|||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
from pydantic import BaseModel, EmailStr, field_validator
|
from pydantic import BaseModel, EmailStr
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -39,17 +39,7 @@ class RegisterRequest(BaseModel):
|
|||||||
username: str
|
username: str
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
password: str
|
password: str
|
||||||
date_of_birth: str # ISO format date string (YYYY-MM-DD)
|
over_18: bool # Simple boolean confirmation instead of date_of_birth
|
||||||
|
|
||||||
@field_validator("date_of_birth")
|
|
||||||
@classmethod
|
|
||||||
def validate_date_of_birth(cls, v: str) -> str:
|
|
||||||
"""Validate that the date string is a valid date in YYYY-MM-DD format."""
|
|
||||||
try:
|
|
||||||
datetime.strptime(v, "%Y-%m-%d")
|
|
||||||
except ValueError:
|
|
||||||
raise ValueError("date_of_birth must be in YYYY-MM-DD format")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class UserResponse(BaseModel):
|
class UserResponse(BaseModel):
|
||||||
@@ -118,6 +108,16 @@ def hash_password(password: str) -> str:
|
|||||||
def calculate_age(birth_date: date, today: date | None = None) -> int:
|
def calculate_age(birth_date: date, today: date | None = None) -> int:
|
||||||
"""Calculate age in years from a birth date.
|
"""Calculate age in years from a birth date.
|
||||||
|
|
||||||
|
Not called by /register anymore (that endpoint now just trusts the
|
||||||
|
client's over_18 boolean, see RegisterRequest) -- kept as a real,
|
||||||
|
tested utility since date-of-birth storage/verification is coming
|
||||||
|
back in a future pass (real age math, not just a client-side claim,
|
||||||
|
the user explicitly wants to revisit this design). Removing it broke
|
||||||
|
backend/tests/test_auth_age_gate.py's collection entirely (ImportError
|
||||||
|
on `from app.routers.auth import calculate_age`) for zero benefit --
|
||||||
|
restoring it is the lower-risk path over rewriting or deleting 17
|
||||||
|
tests for logic this codebase will need again.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
birth_date: The person's date of birth.
|
birth_date: The person's date of birth.
|
||||||
today: The reference date. Defaults to today's date (UTC).
|
today: The reference date. Defaults to today's date (UTC).
|
||||||
@@ -155,16 +155,12 @@ def _issue_refresh_token(user_id: int, db: AsyncSession) -> str:
|
|||||||
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||||
"""Register a new user and return access + refresh tokens.
|
"""Register a new user and return access + refresh tokens.
|
||||||
|
|
||||||
Requires date_of_birth and rejects users under 18.
|
Requires over_18 boolean confirmation; rejects users who are not 18+.
|
||||||
"""
|
"""
|
||||||
# Parse and validate date_of_birth
|
if not req.over_18:
|
||||||
dob = datetime.strptime(req.date_of_birth, "%Y-%m-%d").date()
|
|
||||||
age = calculate_age(dob)
|
|
||||||
|
|
||||||
if age < 18:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"Must be 18 or older to register (provided date of birth: {req.date_of_birth})",
|
detail="Must be 18 or older to register",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if username already exists
|
# Check if username already exists
|
||||||
@@ -187,7 +183,7 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
|||||||
username=req.username,
|
username=req.username,
|
||||||
email=req.email,
|
email=req.email,
|
||||||
hashed_password=hash_password(req.password),
|
hashed_password=hash_password(req.password),
|
||||||
date_of_birth=dob,
|
date_of_birth=None, # No longer collecting DOB
|
||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
await db.flush() # flush to get user.id before creating refresh token
|
await db.flush() # flush to get user.id before creating refresh token
|
||||||
|
|||||||
@@ -57,38 +57,38 @@ class TestCalculateAge:
|
|||||||
assert calculate_age(dob, today=date(2024, 12, 31)) == 24
|
assert calculate_age(dob, today=date(2024, 12, 31)) == 24
|
||||||
|
|
||||||
|
|
||||||
class TestRegisterRequestDateValidation:
|
class TestRegisterRequestOver18Field:
|
||||||
"""Tests for the RegisterRequest date_of_birth field validator."""
|
"""Tests for RegisterRequest's over_18 field.
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
Replaces TestRegisterRequestDateValidation -- date_of_birth and its
|
||||||
"dob",
|
field_validator were removed from RegisterRequest 2026-08-20 (interim
|
||||||
[
|
simplification: a plain client-side over_18 confirmation instead of
|
||||||
"2000-01-01",
|
collecting/validating an actual date of birth; see calculate_age's
|
||||||
"1990-12-31",
|
own docstring in app/routers/auth.py for why that function is still
|
||||||
"2004-02-29", # leap day
|
here even though this endpoint no longer calls it). There's no date
|
||||||
],
|
format left to validate, so the equivalent coverage is just: the
|
||||||
)
|
field exists, is a real bool, and is required."""
|
||||||
def test_valid_dates(self, dob):
|
|
||||||
"""Valid YYYY-MM-DD dates pass validation."""
|
def test_over_18_true_accepted(self):
|
||||||
from app.routers.auth import RegisterRequest
|
from app.routers.auth import RegisterRequest
|
||||||
|
|
||||||
req = RegisterRequest(username="u", email="u@example.com", password="p", date_of_birth=dob)
|
req = RegisterRequest(username="u", email="u@example.com", password="p", over_18=True)
|
||||||
assert req.date_of_birth == dob
|
assert req.over_18 is True
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
def test_over_18_false_accepted(self):
|
||||||
"dob",
|
"""False is a valid value at the schema level -- the endpoint,
|
||||||
[
|
not the schema, is what rejects it (see
|
||||||
"01-01-2000",
|
TestRegister::test_register_rejects_not_over_18 in
|
||||||
"2000/01/01",
|
test_auth_endpoints.py)."""
|
||||||
"2000-13-01",
|
|
||||||
"2000-00-10",
|
|
||||||
"not-a-date",
|
|
||||||
"",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_invalid_dates_rejected(self, dob):
|
|
||||||
"""Invalid dates are rejected by the validator."""
|
|
||||||
from app.routers.auth import RegisterRequest
|
from app.routers.auth import RegisterRequest
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
req = RegisterRequest(username="u", email="u@example.com", password="p", over_18=False)
|
||||||
RegisterRequest(username="u", email="u@example.com", password="p", date_of_birth=dob)
|
assert req.over_18 is False
|
||||||
|
|
||||||
|
def test_over_18_missing_rejected(self):
|
||||||
|
"""over_18 has no default -- omitting it entirely is a validation error."""
|
||||||
|
from app.routers.auth import RegisterRequest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
RegisterRequest(username="u", email="u@example.com", password="p")
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ class TestRegister:
|
|||||||
"username": "newuser",
|
"username": "newuser",
|
||||||
"email": "new@example.com",
|
"email": "new@example.com",
|
||||||
"password": "secret123",
|
"password": "secret123",
|
||||||
"date_of_birth": "2000-01-01",
|
"over_18": True,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
@@ -81,8 +81,14 @@ class TestRegister:
|
|||||||
assert "access_token" in data
|
assert "access_token" in data
|
||||||
assert data["token_type"] == "bearer"
|
assert data["token_type"] == "bearer"
|
||||||
|
|
||||||
def test_register_rejects_underage(self):
|
def test_register_rejects_not_over_18(self):
|
||||||
"""Users under 18 get a 400 with a detail message."""
|
"""over_18=false gets a 400 with a detail message.
|
||||||
|
|
||||||
|
The interim design (2026-08-20) trusts a client-side boolean
|
||||||
|
confirmation instead of collecting/validating an actual date of
|
||||||
|
birth -- this only checks that the flag is respected, not real
|
||||||
|
age math (there's no date to verify against anymore). Real DOB
|
||||||
|
collection/verification may come back in a future pass."""
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
"/api/auth/register",
|
"/api/auth/register",
|
||||||
@@ -90,7 +96,7 @@ class TestRegister:
|
|||||||
"username": "kid",
|
"username": "kid",
|
||||||
"email": "kid@example.com",
|
"email": "kid@example.com",
|
||||||
"password": "secret123",
|
"password": "secret123",
|
||||||
"date_of_birth": "2010-06-15", # ~15 years old
|
"over_18": False,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
@@ -106,7 +112,7 @@ class TestRegister:
|
|||||||
"username": "duplicate",
|
"username": "duplicate",
|
||||||
"email": "dup1@example.com",
|
"email": "dup1@example.com",
|
||||||
"password": "secret123",
|
"password": "secret123",
|
||||||
"date_of_birth": "2000-01-01",
|
"over_18": True,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
# Second registration with same username, different email
|
# Second registration with same username, different email
|
||||||
@@ -116,7 +122,7 @@ class TestRegister:
|
|||||||
"username": "duplicate",
|
"username": "duplicate",
|
||||||
"email": "dup2@example.com",
|
"email": "dup2@example.com",
|
||||||
"password": "secret123",
|
"password": "secret123",
|
||||||
"date_of_birth": "2000-01-01",
|
"over_18": True,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 409
|
assert resp.status_code == 409
|
||||||
@@ -131,7 +137,7 @@ class TestRegister:
|
|||||||
"username": "user1",
|
"username": "user1",
|
||||||
"email": "same@example.com",
|
"email": "same@example.com",
|
||||||
"password": "secret123",
|
"password": "secret123",
|
||||||
"date_of_birth": "2000-01-01",
|
"over_18": True,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
@@ -140,14 +146,19 @@ class TestRegister:
|
|||||||
"username": "user2",
|
"username": "user2",
|
||||||
"email": "same@example.com",
|
"email": "same@example.com",
|
||||||
"password": "secret123",
|
"password": "secret123",
|
||||||
"date_of_birth": "2000-01-01",
|
"over_18": True,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 409
|
assert resp.status_code == 409
|
||||||
assert "Email already registered" in resp.json()["detail"]
|
assert "Email already registered" in resp.json()["detail"]
|
||||||
|
|
||||||
def test_register_invalid_date_format(self):
|
def test_register_missing_over_18(self):
|
||||||
"""Invalid date format returns 422 validation error."""
|
"""Missing the required over_18 field returns 422.
|
||||||
|
|
||||||
|
Replaces the old test_register_invalid_date_format -- there's no
|
||||||
|
date format to get wrong anymore (no date field at all), so the
|
||||||
|
closest equivalent "malformed request" case is the required
|
||||||
|
over_18 boolean being absent entirely."""
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
"/api/auth/register",
|
"/api/auth/register",
|
||||||
@@ -155,25 +166,10 @@ class TestRegister:
|
|||||||
"username": "user",
|
"username": "user",
|
||||||
"email": "user@example.com",
|
"email": "user@example.com",
|
||||||
"password": "secret123",
|
"password": "secret123",
|
||||||
"date_of_birth": "01-01-2000", # wrong format
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 422
|
assert resp.status_code == 422
|
||||||
|
|
||||||
def test_register_exactly_18(self):
|
|
||||||
"""A user who just turned 18 can register."""
|
|
||||||
client = TestClient(app)
|
|
||||||
resp = client.post(
|
|
||||||
"/api/auth/register",
|
|
||||||
json={
|
|
||||||
"username": "just18",
|
|
||||||
"email": "just18@example.com",
|
|
||||||
"password": "secret123",
|
|
||||||
"date_of_birth": "2007-06-15",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 201
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Login endpoint
|
# Login endpoint
|
||||||
@@ -193,7 +189,7 @@ class TestLogin:
|
|||||||
"username": username,
|
"username": username,
|
||||||
"email": email,
|
"email": email,
|
||||||
"password": password,
|
"password": password,
|
||||||
"date_of_birth": "2000-01-01",
|
"over_18": True,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ def _register_user(client: TestClient, username: str) -> str:
|
|||||||
"username": username,
|
"username": username,
|
||||||
"email": f"{username}@example.com",
|
"email": f"{username}@example.com",
|
||||||
"password": "secret123",
|
"password": "secret123",
|
||||||
"date_of_birth": "2000-01-01",
|
"over_18": True,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ class TestRefresh:
|
|||||||
"username": username,
|
"username": username,
|
||||||
"email": email,
|
"email": email,
|
||||||
"password": password,
|
"password": password,
|
||||||
"date_of_birth": "2000-01-01",
|
"over_18": True,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
@@ -143,7 +143,7 @@ class TestRefresh:
|
|||||||
"username": "refep_loginrefuser",
|
"username": "refep_loginrefuser",
|
||||||
"email": "loginref@example.com",
|
"email": "loginref@example.com",
|
||||||
"password": "secret123",
|
"password": "secret123",
|
||||||
"date_of_birth": "2000-01-01",
|
"over_18": True,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
# Login to get a refresh token
|
# Login to get a refresh token
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# PC2 Test Level & Player Spawn — Design Note
|
||||||
|
|
||||||
|
> **Status:** Decided (PC2)
|
||||||
|
> **Owner:** Architecture
|
||||||
|
> **Consumers:** the two PC2 implementation tasks — (1) build the test level,
|
||||||
|
> (2) build the player & spawn it. Both must be able to follow this note
|
||||||
|
> without further design decisions.
|
||||||
|
|
||||||
|
## 1. Decision: static scene, not chunk streaming
|
||||||
|
|
||||||
|
**The PC2 playable test level is a single, hand-authored, static Godot 3D
|
||||||
|
scene.** It is **not** built through the chunk streaming system
|
||||||
|
(`scripts/chunk_stream_manager.gd`, `chunk_scene.gd`, `chunk.gd`,
|
||||||
|
`chunk_data_decoder.gd`).
|
||||||
|
|
||||||
|
This is a deliberate decision, not a default:
|
||||||
|
|
||||||
|
- **The streaming system has never been exercised with real content.** It is a
|
||||||
|
faithful port of the `docs/architecture/world.md` format spec, but nothing in
|
||||||
|
the project produces or serves chunk payloads for it to consume. `scenes/main.tscn`
|
||||||
|
wires `chunk_stream_manager.gd` onto a bare `Node3D` with no `player` reference
|
||||||
|
and no `_server_rpc`, so at runtime it does nothing. There is no server-side
|
||||||
|
chunk authoring/streaming endpoint to integrate against.
|
||||||
|
- **PC2 scope is single-player, local-only, no networking.** The value of chunk
|
||||||
|
streaming is server-authoritative streaming around a moving player across a
|
||||||
|
network boundary. None of those preconditions exist in PC2, so exercising the
|
||||||
|
pipeline here would mean building a chunk *authoring + serving* path first —
|
||||||
|
clearly out of checkpoint scope.
|
||||||
|
- **A static scene is the lowest-risk way to get "spawn a player in a real 3D
|
||||||
|
level and walk around" working**, which is the actual PC2 outcome being tested.
|
||||||
|
|
||||||
|
Chunk streaming is **not removed and not broken by this decision** — it stays as
|
||||||
|
an optional, later milestone. The static level is the PC2 proof of the *player*
|
||||||
|
and *camera/physics* half of the problem; chunk streaming remains the later proof
|
||||||
|
of the *world data* half.
|
||||||
|
|
||||||
|
## 2. Where things live
|
||||||
|
|
||||||
|
| Artifact | Path | Owned by |
|
||||||
|
|---|---|---|
|
||||||
|
| Test level scene | `scenes/worlds/test_level.tscn` | level task |
|
||||||
|
| Player scene | `scenes/player.tscn` | player task |
|
||||||
|
| Player controller script | `scripts/player.gd` | player task |
|
||||||
|
| Game composition scene | `scenes/game.tscn` | player task |
|
||||||
|
|
||||||
|
- **`scenes/worlds/`** is the convention for all level scenes (it does not exist
|
||||||
|
yet; the level task creates it). Future levels follow `scenes/worlds/<name>.tscn`.
|
||||||
|
- `scenes/player.tscn` is **reusable** across levels — it must not hard-code any
|
||||||
|
reference to `test_level`; it discovers the spawn point at runtime (see §3).
|
||||||
|
|
||||||
|
## 3. Player spawn convention
|
||||||
|
|
||||||
|
The level scene **must** contain a `Marker3D` named exactly **`PlayerSpawn`**.
|
||||||
|
That node's world transform is the spawn point.
|
||||||
|
|
||||||
|
- `Marker3D` is a plain `Node3D` that shows a cross gizmo in the editor and
|
||||||
|
carries no gameplay behavior — it is the established Godot idiom for "position
|
||||||
|
hint." (Confirmed present in Godot 4.6.2.)
|
||||||
|
- The marker's **position** is authoritative; its rotation is ignored by the
|
||||||
|
player (spawn yaw is the player's concern, default forward is fine).
|
||||||
|
- Coordinate system: **Y-up, units = meters, 1 unit = 1 m.** The marker must sit
|
||||||
|
at least **1.0 m above the ground's top surface** (recommended `y = 1.0` where
|
||||||
|
the ground top is `y = 0`) so the player capsule does not spawn intersecting
|
||||||
|
the floor and get popped out by the physics solver.
|
||||||
|
- There is exactly **one** `PlayerSpawn` per level. (If a level ever needs
|
||||||
|
multiple spawns, that is a later convention — not PC2.)
|
||||||
|
|
||||||
|
**Lookup rule (implemented by `player.gd`, not by the level):**
|
||||||
|
|
||||||
|
```
|
||||||
|
# in player.gd _ready()
|
||||||
|
var spawn := get_tree().current_scene.find_child("PlayerSpawn", true, false)
|
||||||
|
if spawn is Node3D:
|
||||||
|
global_position = spawn.global_position
|
||||||
|
else:
|
||||||
|
push_warning("player.gd: no PlayerSpawn marker found; spawning at origin")
|
||||||
|
global_position = Vector3(0, 1.0, 0) # fallback, do not crash
|
||||||
|
```
|
||||||
|
|
||||||
|
The lookup runs from the current scene root so it finds the marker whether it is
|
||||||
|
a direct child of the level instance or nested under a `Level` wrapper. The
|
||||||
|
fallback keeps the player runnable in a scene that forgot its marker, but a
|
||||||
|
**missing `PlayerSpawn` is a level bug** and the level task's smoke test must fail
|
||||||
|
on it (see §6).
|
||||||
|
|
||||||
|
## 4. Composition (who puts level + player together)
|
||||||
|
|
||||||
|
`scenes/game.tscn` is the PC2 playable entry point:
|
||||||
|
|
||||||
|
```
|
||||||
|
Game (Node3D)
|
||||||
|
├── Level (instance of res://scenes/worlds/test_level.tscn)
|
||||||
|
└── Player (instance of res://scenes/player.tscn)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `Game` is a plain `Node3D` with **no script** — composition is done by scene
|
||||||
|
instantiation, not code, so there is nothing to keep in sync.
|
||||||
|
- The **level scene is self-contained and has zero dependency on the player** —
|
||||||
|
it loads and is smoke-testable on its own. The level task does not instance or
|
||||||
|
reference `player.tscn`.
|
||||||
|
- The **player scene is self-contained and has zero dependency on any specific
|
||||||
|
level** — it only requires that the *current scene* contains a `PlayerSpawn`.
|
||||||
|
- Ownership of `game.tscn` goes to the **player task**, which depends on the
|
||||||
|
level task having landed `test_level.tscn` first (chain with `depends_on`).
|
||||||
|
|
||||||
|
## 5. Level content requirements (level task)
|
||||||
|
|
||||||
|
`scenes/worlds/test_level.tscn`, root node `TestLevel` (`Node3D`). Required
|
||||||
|
children:
|
||||||
|
|
||||||
|
| Node | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `Ground` | `StaticBody3D` | `BoxShape3D` (e.g. `40 × 1 × 40`) centered at `(0, -0.5, 0)` so the **top face is `y = 0`**; matching `MeshInstance3D` (`BoxMesh`) so it is visible and collidable. Use a `StaticBody3D` rather than a bare `PlaneMesh` so collision is explicit and reliable. |
|
||||||
|
| `Props` | `Node3D` | 3–5 simple props as `StaticBody3D` + `MeshInstance3D` using built-in primitives only (`BoxMesh`, `CylinderMesh`, `SphereMesh`). `assets/` is empty and PC2 must not require imported content. Positioned on the ground surface; scale freely. |
|
||||||
|
| `Sun` | `DirectionalLight3D` | Pitch it downward (~ -45°), enable shadows if the compatibility renderer allows; shadows are optional. |
|
||||||
|
| `WorldEnvironment` | `WorldEnvironment` | Ambient light and/or a simple background color. Keep it plain — the project uses `gl_compatibility`. |
|
||||||
|
| `PlayerSpawn` | `Marker3D` | Per §3. Recommended `(0, 1.0, 0)`. |
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- **Do not** add any `script` to the level. It is pure content. Any per-level
|
||||||
|
behavior belongs to the player (or a future game-mode script), not the level.
|
||||||
|
- Keep the whole level on the XZ plane around the origin; nothing off at extreme
|
||||||
|
coordinates, no thin walls the capsule can tunnel through.
|
||||||
|
|
||||||
|
## 6. Player contract (player task)
|
||||||
|
|
||||||
|
`scenes/player.tscn`, root `Player` (`CharacterBody3D`) + `scripts/player.gd`.
|
||||||
|
|
||||||
|
Minimum for PC2:
|
||||||
|
|
||||||
|
- **Body:** `CharacterBody3D` with a `CollisionShape3D` using `CapsuleShape3D`
|
||||||
|
(~`radius 0.3`, ~`height 1.8`) and a matching `CapsuleMesh` for visibility.
|
||||||
|
- **Camera:** a `Camera3D` child. First-person (camera at capsule head height) is
|
||||||
|
the lowest-risk choice; third-person is fine too but is more work.
|
||||||
|
- **Spawn:** per §3 — read `PlayerSpawn` from the current scene, fall back
|
||||||
|
gracefully.
|
||||||
|
- **Movement:** gravity + `move_and_slide()` so the player stands on `Ground`.
|
||||||
|
WASD/arrow locomotion is the hard requirement. Mouse-look is recommended but
|
||||||
|
may be split into its own task if the controller grows.
|
||||||
|
- **No networking, no avatar UI, no chunk code** — PC2 is local-only. Do not add
|
||||||
|
`MultiplayerAPI`, `ChunkStreamManager`, or any avatar customization here.
|
||||||
|
|
||||||
|
The player must work when instanced under `Game` alongside a level; it must not
|
||||||
|
assume it is the only node in the scene.
|
||||||
|
|
||||||
|
## 7. Entry point / how a human plays it
|
||||||
|
|
||||||
|
- `scenes/game.tscn` is the PC2 playable scene. For playtesting, load it directly
|
||||||
|
(e.g. `godot --path . scenes/game.tscn`), or temporarily set
|
||||||
|
`application/run/main_scene` to it.
|
||||||
|
- **Wiring "Play → Join a world" in the existing `world_list` UI is out of scope
|
||||||
|
for this note** and out of scope for the level/player tasks. The current menu
|
||||||
|
flow ends at browse-worlds (PC1). If a checkpoint wants a clickable path into
|
||||||
|
the test level, that navigation wiring is its own small task — do not fold it
|
||||||
|
into level or player.
|
||||||
|
|
||||||
|
## 8. Validation (both tasks must run this before "done")
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# one-time, first checkout only — builds the class/import cache
|
||||||
|
godot --headless --path . --import
|
||||||
|
|
||||||
|
# then, to catch parse/load errors:
|
||||||
|
godot --headless --path . --quit
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Level task:** additionally load `test_level.tscn` headlessly and assert it
|
||||||
|
(a) loads without error and (b) contains a `Marker3D` named `PlayerSpawn`
|
||||||
|
positioned ≥ 1.0 m above the ground top. A smoke script following the existing
|
||||||
|
`scripts/smoke_test_*.gd` pattern is the right vehicle.
|
||||||
|
- **Player task:** a smoke test that instantiates `game.tscn`, waits a frame,
|
||||||
|
and asserts the player's global position equals the `PlayerSpawn` position
|
||||||
|
(within a small epsilon). This is the check that proves the spawn convention
|
||||||
|
actually wired through — not just "the scene loads."
|
||||||
|
|
||||||
|
## 9. Explicitly out of scope for PC2
|
||||||
|
|
||||||
|
- Networking / multiplayer / prediction (single-player, local-only).
|
||||||
|
- Chunk streaming integration and server-side chunk authoring/serving.
|
||||||
|
- Avatar customization UI and glTF avatar loading.
|
||||||
|
- Join-a-world navigation from the world list.
|
||||||
|
- Any content moderation / age-gating logic in the level.
|
||||||
|
|
||||||
|
## 10. Summary
|
||||||
|
|
||||||
|
| Concern | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Level construction | Static hand-authored Godot 3D scene (chunk streaming deferred, not removed) |
|
||||||
|
| Level location | `scenes/worlds/test_level.tscn` |
|
||||||
|
| Spawn convention | `Marker3D` named `PlayerSpawn`, ≥ 1.0 m above ground, looked up by the player |
|
||||||
|
| Player | `scenes/player.tscn` (`CharacterBody3D`) + `scripts/player.gd`, reusable across levels |
|
||||||
|
| Composition | `scenes/game.tscn` instances `Level` + `Player`; no script |
|
||||||
|
| Entry point | Load `scenes/game.tscn` directly; UI join-wiring deferred |
|
||||||
+19
-31
@@ -37,45 +37,33 @@ the real scope of PC1; don't expect more when playtesting.
|
|||||||
|
|
||||||
## Manual playtest: backend on cloudbison, client on pinkbison
|
## Manual playtest: backend on cloudbison, client on pinkbison
|
||||||
|
|
||||||
Cloudbison's LAN IP: `192.168.58.138`. Pinkbison's: `192.168.58.124`.
|
As of 2026-08-20 this is dashboard-controlled — no terminal commands, no
|
||||||
|
env vars, no editing files. Foolproof enough to hand to someone else.
|
||||||
|
|
||||||
### 1. On cloudbison — start a real (persistent, not throwaway) backend
|
### 1. On cloudbison — the AGP dashboard
|
||||||
|
|
||||||
```bash
|
Open the dashboard (`http://cloudbison:7070`, or its Tailscale address) and
|
||||||
cd ~
|
go to **PC1** in the nav bar (`/checkpoint/PC1`). Click **Start Backend** —
|
||||||
git clone http://localhost:3000/daddy/agp-game.git agp-game-playtest
|
it pulls the latest `main`, stands up Postgres, runs migrations, and starts
|
||||||
cd agp-game-playtest/backend
|
the API server, all in one step. The page shows the LAN address it's
|
||||||
docker compose up -d postgres
|
reachable at once it's up (under a minute).
|
||||||
docker compose exec -T postgres pg_isready -U agp_user -d agp_db # wait for this to succeed
|
|
||||||
|
|
||||||
export DATABASE_URL="postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db"
|
When you're done playtesting, click **Stop & Clean Up** on the same page —
|
||||||
export JWT_ALGORITHM="RS256"
|
it tears down everything that step started (API server, Postgres
|
||||||
/home/daddy/agp/venv/bin/python3 -m alembic upgrade head
|
container, its volume and network). Nothing is left running or lying
|
||||||
nohup /home/daddy/agp/venv/bin/python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8000 > /tmp/agp-backend.log 2>&1 &
|
around afterward, even if the dashboard itself gets restarted in between.
|
||||||
disown
|
|
||||||
```
|
|
||||||
|
|
||||||
`--host 0.0.0.0` matters — the default `127.0.0.1` only listens on loopback
|
### 2. On pinkbison — download and run the .exe
|
||||||
and pinkbison won't be able to reach it. Verify: `curl
|
|
||||||
http://localhost:8000/api/health` should return `{"status":"ok",...}`.
|
|
||||||
|
|
||||||
### 2. On pinkbison — get the game and point it at cloudbison
|
Same dashboard page: click **Build .exe** the first time (or **Rebuild** to
|
||||||
|
pick up newer changes) — this pulls the latest `main` and exports a Windows
|
||||||
Copy `export/windows/agp-game.exe` (or `export/linux/agp-game.x86_64`,
|
binary with cloudbison's backend address already baked in. Click
|
||||||
whichever matches pinkbison's OS) from cloudbison, or clone the repo there
|
**Download agp-game.exe**, copy it to pinkbison, and just run it. No
|
||||||
and run `scripts/build.sh` locally. Launch with the backend URL as an env
|
environment variable, no config file, no build step on pinkbison at all.
|
||||||
var so you don't have to rebuild to change it:
|
|
||||||
|
|
||||||
- Windows (PowerShell): `$env:AGP_API_BASE_URL="http://192.168.58.138:8000"; .\agp-game.exe`
|
|
||||||
- Linux: `AGP_API_BASE_URL=http://192.168.58.138:8000 ./agp-game.x86_64`
|
|
||||||
|
|
||||||
### 3. Tear down when done
|
### 3. Tear down when done
|
||||||
|
|
||||||
```bash
|
**Stop & Clean Up** on the dashboard page. That's it.
|
||||||
# on cloudbison
|
|
||||||
pkill -f "uvicorn app.main:app"
|
|
||||||
cd ~/agp-game-playtest/backend && docker compose down -v
|
|
||||||
```
|
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
|
|||||||
@@ -7,72 +7,111 @@ 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-19 (commit `1c75367`).
|
Last verified against `main`: 2026-08-21 — migrations + pytest + all 11
|
||||||
|
Godot smoke tests, all clean. PC1's real playtest-found bugs (login form
|
||||||
|
unreachable, 1px world cards, Play → Back losing the session) are covered
|
||||||
|
by the smoke tests below so they can't silently regress. PC2 (basic
|
||||||
|
playable world + character + movement) landed 2026-08-20/21 — see
|
||||||
|
`docs/checkpoints/` once a PC2 record exists, and the three new smoke
|
||||||
|
tests below (`smoke_test_test_level.gd`, `smoke_test_player.gd`,
|
||||||
|
`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)
|
||||||
|
|
||||||
Run: `cd backend && python -m pytest -v` (needs `DATABASE_URL` pointed at a
|
Run: `cd backend && python -m pytest -v` (needs `DATABASE_URL` pointed at a
|
||||||
reachable Postgres — see the runner script; most of these tests don't
|
reachable Postgres — see the runner script).
|
||||||
actually touch the DB, but the app's import chain requires a valid URL to
|
|
||||||
construct).
|
|
||||||
|
|
||||||
**Current total: 38 tests, all passing on a clean `main` checkout.**
|
**Current total: 61 tests, all passing on a clean `main` checkout.**
|
||||||
|
|
||||||
| File | Tests | What it covers |
|
| File | Tests | What it covers |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `test_auth_age_gate.py` | 10 | `calculate_age()` date-math correctness (exact boundary, leap years, birthday-not-yet-this-year) + `RegisterRequest` date-of-birth validation (parametrized valid/invalid date rejection). Backs the 18+ registration gate (PR #23). |
|
| `test_auth_age_gate.py` | 11 | `calculate_age()` date-math correctness (exact boundary, leap years, birthday-not-yet-this-year — kept as a tested utility for a future real-DOB pass even though `/register` no longer calls it) + `RegisterRequest.over_18` field tests (true accepted, false accepted, missing rejected). 2026-08-20: the register flow was simplified from collecting/validating a date of birth to a plain client-side "I am 18 or older" confirmation, so the old date-validation parametrized cases were replaced. Backs the 18+ registration gate (PR #23, revised PC1). |
|
||||||
| `test_gltf_metadata.py` | 21 | glTF/GLB binary + JSON parsing: magic-byte detection, chunk-padding handling, mesh/animation/texture-reference metadata extraction, malformed-input rejection. Backs the asset-import pipeline (PR #27). |
|
| `test_gltf_metadata.py` | 21 | glTF/GLB binary + JSON parsing: magic-byte detection, chunk-padding handling, mesh/animation/texture-reference metadata extraction, malformed-input rejection. Backs the asset-import pipeline (PR #27). |
|
||||||
|
| `test_auth_endpoints.py` | 10 | **Integration tests** for `POST /api/auth/register` and `POST /api/auth/login` against the real FastAPI app + real Postgres. Covers: successful register (201), duplicate username (409), duplicate email (409), `over_18=false` rejection (400), missing `over_18` field (422), successful login with correct credentials (200), wrong password (401), nonexistent user (401), form-encoded login (OAuth2), and JWT validity of returned tokens. |
|
||||||
|
| `test_refresh_endpoint.py` | 8 | **Integration tests** for `POST /api/auth/refresh`. Covers: successful refresh (200, new access token, same refresh token returned), refresh produces different access tokens on repeated calls, invalid refresh token (401), empty body (422), missing field (422), refresh token from login also works, returned access token is valid JWT decodable with public key, `access_token_expires_minutes` in response. |
|
||||||
|
| `test_refresh_token_jti.py` | 6 | Unit tests for refresh token uniqueness (jti claim) and model column length. Covers: two calls with same sub produce different tokens (jti uniqueness), jti is valid UUID, rapid repeated calls all produce unique jtis, jti survives decode, `RefreshToken.token` column is `String(512)`, column type is `String`. |
|
||||||
|
| `test_auth_expired_token.py` | 5 | **Integration tests** for expired-token rejection across auth endpoints. Covers: expired access JWT rejected by `GET /api/auth/me` (401), garbage access token rejected by `/me` (401), missing bearer token returns 401, expired refresh token (DB `expires_at` in past) rejected by `/refresh` (401), expired refresh JWT (past `exp` claim) rejected before DB lookup (401). |
|
||||||
|
|
||||||
**What these are, honestly:** pure unit tests of standalone functions
|
**What these are, honestly:** a mix of pure unit tests (`test_auth_age_gate.py`,
|
||||||
(`calculate_age`, the glTF parser, Pydantic validators). None of them spin
|
`test_gltf_metadata.py`, `test_refresh_token_jti.py`) and real integration tests
|
||||||
up the FastAPI app or hit a real endpoint — they run in ~1s and don't
|
(`test_auth_endpoints.py`, `test_refresh_endpoint.py`, `test_auth_expired_token.py`)
|
||||||
actually need Postgres reachable at all (the import chain just needs a
|
that hit the FastAPI app against a real Postgres database via `TestClient`.
|
||||||
syntactically valid `DATABASE_URL`, nothing connects during collection).
|
The integration tests exercise the full stack: Pydantic validation → DB →
|
||||||
|
JWT → response. The suite is also re-runnable without manual cleanup —
|
||||||
|
`backend/tests/conftest.py` has a session-scoped autouse fixture that
|
||||||
|
truncates all app tables once before the suite runs.
|
||||||
|
|
||||||
**Known coverage gaps** (real gaps, not placeholders — nothing enforces filling
|
**Known coverage gaps** (real gaps, not placeholders — nothing enforces filling
|
||||||
these yet):
|
these yet):
|
||||||
- No integration/endpoint tests at all — `/register`, `/login`, `/refresh`,
|
- No endpoint tests for `GET /api/worlds`, `GET /api/assets/*`, or any
|
||||||
`/worlds`, `/assets/*` have zero tests that actually call them through
|
world/asset CRUD routes — only auth endpoints have integration coverage
|
||||||
the FastAPI app (e.g. via `httpx.AsyncClient` + `ASGITransport`) against a
|
(`GET /api/worlds` is covered indirectly via the Godot smoke tests below).
|
||||||
real (or test) database. Everything currently shipped works because it
|
- No tests for the moderation schema/migration beyond "the migration runs."
|
||||||
was manually verified at merge time (checkpoint review), not because a
|
- No tests for the `GET /api/auth/me` endpoint's response body (only its
|
||||||
test asserts it.
|
auth-header validation is covered via expired-token tests).
|
||||||
- No tests for the RS256 auth upgrade (key generation, refresh-token
|
- No tests for session management (create/validate/revoke sessions).
|
||||||
issuance/persistence) — PR #30, pending review as of this writing,
|
|
||||||
manually verified (correct key usage, all 38 existing tests still pass)
|
|
||||||
but not covered by an automated test of its own yet.
|
|
||||||
- No tests for the moderation schema/migration (in progress as of this
|
|
||||||
writing) beyond "the migration runs."
|
|
||||||
|
|
||||||
## Godot client — `scripts/smoke_test_*.gd`
|
## Godot client — `scripts/smoke_test_*.gd`
|
||||||
|
|
||||||
**Nothing merged into `main` yet as of this writing** — `sandbox_probe/test_script.gd`
|
**12 smoke tests merged into `main`.** Ten need a live backend, one
|
||||||
is the only Godot script on `main` right now, and it's **not a real
|
(`smoke_test_network_client.gd`) needs a live dedicated server instead,
|
||||||
test** (a leftover manual probe, returns a hardcoded string, asserts
|
and one (`smoke_test_avatar_system.gd`) needs neither — it only exercises
|
||||||
nothing). Don't count it toward coverage; delete or repurpose it if anyone
|
local catalog/loadout/loader logic. Backend-dependent tests read
|
||||||
actually needs a sandbox probe again.
|
`AGP_API_BASE_URL` env var, falling back to the
|
||||||
|
`application/agp/api_base_url` project setting (default
|
||||||
|
`http://127.0.0.1:8000`) if unset.
|
||||||
|
|
||||||
`scripts/smoke_test_api.gd` (PC1, PR #29, pending review as of this writing)
|
| File | What it covers |
|
||||||
will be the first real one once merged — `ApiClient` autoload's
|
|---|---|
|
||||||
`health_check()` against a **live backend** (set `AGP_API_BASE_URL` to point
|
| `smoke_test_api.gd` | Exercises `ApiClient.health_check()` against a live backend. Verifies the real HTTP request path (`HTTPRequest` node lifecycle, JSON parse, result-dictionary contract). Exits 1 on failure. |
|
||||||
at one), verified by hand end-to-end before opening the PR: exercises the
|
| `smoke_test_auth.gd` | Tests the full auth flow via `ApiClient` autoload: register unique user (201), duplicate username (409), `over_18=false` registration (400), correct login (200), wrong password (401), nonexistent user (401). |
|
||||||
real HTTP request path (`HTTPRequest` node lifecycle, JSON parse, the
|
| `smoke_test_e2e.gd` | End-to-end flow: health check → register → login → worlds list. Verifies the full request chain with a fresh user, including token storage and `get_worlds()` with auth header. |
|
||||||
result-dictionary contract), not just that the script compiles. Exits 1 on
|
| `smoke_test_world_list.gd` | Registers a user, **seeds a real world via `POST /api/worlds/`** (without this, `run_checkpoint_tests.py`'s always-fresh DB means the populated-card render path never actually runs in CI, only the empty-list path would), instantiates `world_list.tscn` as a real scene, waits for `_refresh_worlds()` to complete, confirms it reached the populated (not empty) state, and asserts **each rendered card's real computed width is >= 200px**. That last check is the one that matters: 2026-08-20, a real bug shipped where every card existed in the tree with a non-zero child count and no crash, but rendered at 1.0px wide (`ScrollContainer`'s `size_flags_horizontal`/`vertical` used the raw integer `4` = `Control.SIZE_SHRINK_CENTER` instead of `3` = `SIZE_EXPAND_FILL`) — invisible on screen, and a child-count-only check would never have caught it. Confirmed by deliberately reintroducing the bug: card width check correctly fails at 25px. |
|
||||||
failure, so it's CI/script-friendly.
|
| `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_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 merged): `godot --headless --path . --import` once (builds the
|
Run (once, to build the class cache): `godot --headless --path . --import`,
|
||||||
class cache — skip this and every `class_name`-declared class falsely
|
then for each smoke test: `godot --headless --path . --script
|
||||||
errors as undeclared), then for each `scripts/smoke_test_*.gd`:
|
res://scripts/smoke_test_<name>.gd` — **no `--quit`**, it kills these before
|
||||||
`godot --headless --path . --script res://scripts/<name>.gd`
|
their awaited HTTP calls finish.
|
||||||
|
|
||||||
**Known gap once it lands:** only `health_check()` is exercised end-to-end.
|
**Known gaps:**
|
||||||
`login`, `register`, `get_worlds`, `get_me` are implemented (see
|
- No smoke tests for chunk loading/streaming (`chunk.gd`, `chunk_scene.gd`).
|
||||||
`scripts/api_client.gd`) but have no smoke coverage yet — each would need a
|
- No smoke tests for dynamic/static object nodes.
|
||||||
real user/world fixture in the target backend to test against meaningfully.
|
- All smoke tests require a live backend; no offline/unit smoke tests exist.
|
||||||
|
- `smoke_test_world_list.gd` and `smoke_test_main_menu_*.gd` are the only
|
||||||
|
ones that exercise a real `.tscn` scene instantiation — others only test
|
||||||
|
the `ApiClient` autoload directly.
|
||||||
|
- `get_me()` (`GET /auth/me`) is implemented in `scripts/api_client.gd` but
|
||||||
|
has no smoke coverage yet.
|
||||||
|
- The Login path (as opposed to Register) through `main_menu.gd` isn't
|
||||||
|
separately smoke-tested — it shares the same `_on_auth_button_pressed()`/
|
||||||
|
`_on_auth_complete()` code path already exercised for Register, but that's
|
||||||
|
inference, not a direct check.
|
||||||
|
|
||||||
|
## Client export
|
||||||
|
|
||||||
|
`scripts/build.sh` produces real Linux (`agp-game.x86_64`) and Windows
|
||||||
|
(`agp-game.exe`) binaries via `--export-release`. Verified 2026-08-20: both
|
||||||
|
exports complete clean from a fresh checkout and produce valid binaries
|
||||||
|
(confirmed via `file` — real ELF64/PE32+, not empty or truncated). Neither
|
||||||
|
export is exercised by an automated GUI test (headless Godot can't drive a
|
||||||
|
window) — actually launching and clicking through the exported binary is a
|
||||||
|
manual step, see `docs/checkpoints/PC1.md` for playtest instructions.
|
||||||
|
|
||||||
## What "full coverage" should eventually mean here
|
## What "full coverage" should eventually mean here
|
||||||
|
|
||||||
Not defined precisely yet — the honest target as of 2026-08-19 is just
|
Not defined precisely yet — the honest target as of 2026-08-20 is
|
||||||
"more than pure-function unit tests." As real integration/endpoint tests get
|
"integration tests for every endpoint + smoke tests for every major scene."
|
||||||
added, update the tables above rather than letting this doc drift stale;
|
As real integration/endpoint tests get added, update the tables above
|
||||||
a wrong inventory is worse than no inventory.
|
rather than letting this doc drift stale; a wrong inventory is worse than
|
||||||
|
no inventory.
|
||||||
|
|||||||
+2
-2
@@ -21,7 +21,7 @@ script_export_mode=2
|
|||||||
custom_template/debug=""
|
custom_template/debug=""
|
||||||
custom_template/release=""
|
custom_template/release=""
|
||||||
debug/export_console_wrapper=1
|
debug/export_console_wrapper=1
|
||||||
binary_format/embed_pck=false
|
binary_format/embed_pck=true
|
||||||
texture_format/bptc=true
|
texture_format/bptc=true
|
||||||
texture_format/s3tc=true
|
texture_format/s3tc=true
|
||||||
texture_format/etc=false
|
texture_format/etc=false
|
||||||
@@ -52,7 +52,7 @@ script_export_mode=2
|
|||||||
custom_template/debug=""
|
custom_template/debug=""
|
||||||
custom_template/release=""
|
custom_template/release=""
|
||||||
debug/export_console_wrapper=1
|
debug/export_console_wrapper=1
|
||||||
binary_format/embed_pck=false
|
binary_format/embed_pck=true
|
||||||
texture_format/bptc=true
|
texture_format/bptc=true
|
||||||
texture_format/s3tc=true
|
texture_format/s3tc=true
|
||||||
texture_format/etc=false
|
texture_format/etc=false
|
||||||
|
|||||||
@@ -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,10 @@
|
|||||||
|
[gd_scene load_steps=3 format=3 uid="uid://game_scene_pc2"]
|
||||||
|
|
||||||
|
[ext_resource type="PackedScene" uid="uid://c49a36fc7db_test_level" path="res://scenes/worlds/test_level.tscn" id="1_level"]
|
||||||
|
[ext_resource type="PackedScene" uid="uid://player_scene_001" path="res://scenes/player.tscn" id="2_player"]
|
||||||
|
|
||||||
|
[node name="Game" type="Node3D"]
|
||||||
|
|
||||||
|
[node name="Level" parent="." instance=ExtResource("1_level")]
|
||||||
|
|
||||||
|
[node name="Player" parent="." instance=ExtResource("2_player")]
|
||||||
+19
-7
@@ -29,6 +29,21 @@ text = "AGP Game"
|
|||||||
horizontal_alignment = 1
|
horizontal_alignment = 1
|
||||||
theme_override_font_sizes/font_size = 72
|
theme_override_font_sizes/font_size = 72
|
||||||
|
|
||||||
|
[node name="AuthButtonsRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||||
|
layout_mode = 1
|
||||||
|
theme_override_constants/separation = 16
|
||||||
|
alignment = 1
|
||||||
|
|
||||||
|
[node name="LoginButton" type="Button" parent="CenterContainer/VBoxContainer/AuthButtonsRow"]
|
||||||
|
layout_mode = 1
|
||||||
|
text = "Login"
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
|
[node name="RegisterButton" type="Button" parent="CenterContainer/VBoxContainer/AuthButtonsRow"]
|
||||||
|
layout_mode = 1
|
||||||
|
text = "Register"
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
[node name="AuthPanel" type="PanelContainer" parent="CenterContainer/VBoxContainer"]
|
[node name="AuthPanel" type="PanelContainer" parent="CenterContainer/VBoxContainer"]
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
visible = false
|
visible = false
|
||||||
@@ -68,16 +83,12 @@ visible = false
|
|||||||
placeholder_text = "Email"
|
placeholder_text = "Email"
|
||||||
size_flags_horizontal = 3
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
[node name="DOBEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
[node name="Over18Check" type="CheckBox" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
visible = false
|
visible = false
|
||||||
placeholder_text = "Date of Birth (YYYY-MM-DD)"
|
text = "I am 18 or older"
|
||||||
size_flags_horizontal = 3
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
[node name="ModeToggle" type="CheckButton" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
|
||||||
layout_mode = 1
|
|
||||||
text = "Register"
|
|
||||||
|
|
||||||
[node name="AuthButton" type="Button" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
[node name="AuthButton" type="Button" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
text = "Login"
|
text = "Login"
|
||||||
@@ -106,9 +117,10 @@ layout_mode = 1
|
|||||||
text = "Quit"
|
text = "Quit"
|
||||||
size_flags_horizontal = 4
|
size_flags_horizontal = 4
|
||||||
|
|
||||||
|
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton" to="." method="_on_login_button_pressed"]
|
||||||
|
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton" to="." method="_on_register_button_pressed"]
|
||||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/PlayButton" to="." method="_on_play_button_pressed"]
|
[connection signal="pressed" from="CenterContainer/VBoxContainer/PlayButton" to="." method="_on_play_button_pressed"]
|
||||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/QuitButton" to="." method="_on_quit_button_pressed"]
|
[connection signal="pressed" from="CenterContainer/VBoxContainer/QuitButton" to="." method="_on_quit_button_pressed"]
|
||||||
[connection signal="toggled" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/ModeToggle" to="." method="_on_mode_toggle_toggled"]
|
|
||||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton" to="." method="_on_auth_button_pressed"]
|
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton" to="." method="_on_auth_button_pressed"]
|
||||||
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit" to="." method="_on_auth_button_pressed"]
|
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit" to="." method="_on_auth_button_pressed"]
|
||||||
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit" to="." method="_on_auth_button_pressed"]
|
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit" to="." method="_on_auth_button_pressed"]
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
[gd_scene load_steps=3 format=3 uid="uid://player_scene_001"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/player.gd" id="1_player"]
|
||||||
|
|
||||||
|
[sub_resource type="CapsuleMesh" id="CapsuleMesh1"]
|
||||||
|
radius = 0.35
|
||||||
|
height = 1.6
|
||||||
|
|
||||||
|
[sub_resource type="CapsuleShape3D" id="CapsuleShape1"]
|
||||||
|
radius = 0.35
|
||||||
|
height = 1.6
|
||||||
|
|
||||||
|
[node name="Player" type="CharacterBody3D"]
|
||||||
|
script = ExtResource("1_player")
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
|
||||||
|
shape = SubResource("CapsuleShape1")
|
||||||
|
|
||||||
|
[node name="CapsuleMesh" type="MeshInstance3D" parent="."]
|
||||||
|
mesh = SubResource("CapsuleMesh1")
|
||||||
|
|
||||||
|
[node name="Camera3D" type="Camera3D" parent="."]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.6, 0)
|
||||||
|
current = true
|
||||||
@@ -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")
|
||||||
+11
-9
@@ -11,14 +11,17 @@ grow_vertical = 2
|
|||||||
script = ExtResource("1_wl")
|
script = ExtResource("1_wl")
|
||||||
|
|
||||||
[node name="VBoxContainer" type="VBoxContainer" parent="."]
|
[node name="VBoxContainer" type="VBoxContainer" parent="."]
|
||||||
layout_mode = 3
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
theme_override_constants/separation = 8
|
theme_override_constants/separation = 8
|
||||||
size_flags_horizontal = 4
|
|
||||||
size_flags_vertical = 4
|
|
||||||
|
|
||||||
[node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer"]
|
[node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer"]
|
||||||
layout_mode = 3
|
layout_mode = 3
|
||||||
size_flags_horizontal = 4
|
size_flags_horizontal = 3
|
||||||
theme_override_constants/separation = 16
|
theme_override_constants/separation = 16
|
||||||
|
|
||||||
[node name="BackButton" type="Button" parent="VBoxContainer/HeaderHBox"]
|
[node name="BackButton" type="Button" parent="VBoxContainer/HeaderHBox"]
|
||||||
@@ -30,7 +33,7 @@ size_flags_horizontal = 0
|
|||||||
layout_mode = 3
|
layout_mode = 3
|
||||||
text = "Browse Worlds"
|
text = "Browse Worlds"
|
||||||
horizontal_alignment = 1
|
horizontal_alignment = 1
|
||||||
size_flags_horizontal = 4
|
size_flags_horizontal = 3
|
||||||
theme_override_font_sizes/font_size = 28
|
theme_override_font_sizes/font_size = 28
|
||||||
|
|
||||||
[node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"]
|
[node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"]
|
||||||
@@ -40,15 +43,14 @@ size_flags_horizontal = 0
|
|||||||
|
|
||||||
[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer"]
|
[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer"]
|
||||||
layout_mode = 3
|
layout_mode = 3
|
||||||
size_flags_horizontal = 4
|
size_flags_horizontal = 3
|
||||||
size_flags_vertical = 4
|
size_flags_vertical = 3
|
||||||
horizontal_mode = 2
|
horizontal_mode = 2
|
||||||
vertical_mode = 2
|
vertical_mode = 2
|
||||||
|
|
||||||
[node name="WorldList" type="VBoxContainer" parent="VBoxContainer/ScrollContainer"]
|
[node name="WorldList" type="VBoxContainer" parent="VBoxContainer/ScrollContainer"]
|
||||||
layout_mode = 3
|
layout_mode = 3
|
||||||
size_flags_horizontal = 4
|
size_flags_horizontal = 3
|
||||||
size_flags_vertical = 4
|
|
||||||
theme_override_constants/separation = 8
|
theme_override_constants/separation = 8
|
||||||
|
|
||||||
[node name="LoadingLabel" type="Label" parent="VBoxContainer"]
|
[node name="LoadingLabel" type="Label" parent="VBoxContainer"]
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
[gd_scene load_steps=1 format=3 uid="uid://c49a36fc7db_test_level"]
|
||||||
|
|
||||||
|
[sub_resource type="BoxShape3D" id="BoxShape3D_ground"]
|
||||||
|
size = Vector3(40, 1, 40)
|
||||||
|
|
||||||
|
[sub_resource type="BoxMesh" id="BoxMesh_ground"]
|
||||||
|
size = Vector3(40, 1, 40)
|
||||||
|
|
||||||
|
[sub_resource type="BoxShape3D" id="BoxShape3D_prop1"]
|
||||||
|
size = Vector3(2, 3, 2)
|
||||||
|
|
||||||
|
[sub_resource type="BoxMesh" id="BoxMesh_prop1"]
|
||||||
|
size = Vector3(2, 3, 2)
|
||||||
|
|
||||||
|
[sub_resource type="BoxShape3D" id="BoxShape3D_prop2"]
|
||||||
|
size = Vector3(3, 1, 3)
|
||||||
|
|
||||||
|
[sub_resource type="BoxMesh" id="BoxMesh_prop2"]
|
||||||
|
size = Vector3(3, 1, 3)
|
||||||
|
|
||||||
|
[sub_resource type="CylinderShape3D" id="CylinderShape3D_prop3"]
|
||||||
|
top_radius = 1.0
|
||||||
|
height = 4.0
|
||||||
|
|
||||||
|
[sub_resource type="CylinderMesh" id="CylinderMesh_prop3"]
|
||||||
|
top_radius = 1.0
|
||||||
|
bottom_radius = 1.0
|
||||||
|
height = 4.0
|
||||||
|
subdivisions_top_radius = 8
|
||||||
|
|
||||||
|
[sub_resource type="SphereShape3D" id="SphereShape3D_prop4"]
|
||||||
|
radius = 1.0
|
||||||
|
|
||||||
|
[sub_resource type="SphereMesh" id="SphereMesh_prop4"]
|
||||||
|
radius = 1.0
|
||||||
|
subdivisions_ring = 12
|
||||||
|
subdivisions_height = 12
|
||||||
|
|
||||||
|
[sub_resource type="SphereShape3D" id="SphereShape3D_prop5"]
|
||||||
|
radius = 0.5
|
||||||
|
|
||||||
|
[sub_resource type="SphereMesh" id="SphereMesh_prop5"]
|
||||||
|
radius = 0.5
|
||||||
|
subdivisions_ring = 8
|
||||||
|
subdivisions_height = 8
|
||||||
|
|
||||||
|
[sub_resource type="Environment" id="WorldEnvironment_env"]
|
||||||
|
background_mode = 1
|
||||||
|
background_color = Color(0.4, 0.6, 0.8, 1)
|
||||||
|
ambient_light_source = 1
|
||||||
|
ambient_light_energy = 0.4
|
||||||
|
ambient_light_sky_energy = 1.0
|
||||||
|
|
||||||
|
[node name="TestLevel" type="Node3D"]
|
||||||
|
|
||||||
|
[node name="Ground" type="StaticBody3D" parent="."]
|
||||||
|
position = Vector3(0, -0.5, 0)
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="Ground"]
|
||||||
|
shape = SubResource("BoxShape3D_ground")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="Ground"]
|
||||||
|
mesh = SubResource("BoxMesh_ground")
|
||||||
|
|
||||||
|
[node name="Props" type="Node3D" parent="."]
|
||||||
|
|
||||||
|
[node name="Prop1_Box" type="StaticBody3D" parent="Props"]
|
||||||
|
position = Vector3(8, 1.5, 8)
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop1_Box"]
|
||||||
|
shape = SubResource("BoxShape3D_prop1")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop1_Box"]
|
||||||
|
mesh = SubResource("BoxMesh_prop1")
|
||||||
|
|
||||||
|
[node name="Prop2_Pedestal" type="StaticBody3D" parent="Props"]
|
||||||
|
position = Vector3(-8, 0.5, 6)
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop2_Pedestal"]
|
||||||
|
shape = SubResource("BoxShape3D_prop2")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop2_Pedestal"]
|
||||||
|
mesh = SubResource("BoxMesh_prop2")
|
||||||
|
|
||||||
|
[node name="Prop3_Column" type="StaticBody3D" parent="Props"]
|
||||||
|
position = Vector3(6, 2.0, -8)
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop3_Column"]
|
||||||
|
shape = SubResource("CylinderShape3D_prop3")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop3_Column"]
|
||||||
|
mesh = SubResource("CylinderMesh_prop3")
|
||||||
|
|
||||||
|
[node name="Prop4_Sphere" type="StaticBody3D" parent="Props"]
|
||||||
|
position = Vector3(-6, 1.0, -6)
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop4_Sphere"]
|
||||||
|
shape = SubResource("SphereShape3D_prop4")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop4_Sphere"]
|
||||||
|
mesh = SubResource("SphereMesh_prop4")
|
||||||
|
|
||||||
|
[node name="Prop5_SmallSphere" type="StaticBody3D" parent="Props"]
|
||||||
|
position = Vector3(0, 0.5, 12)
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop5_SmallSphere"]
|
||||||
|
shape = SubResource("SphereShape3D_prop5")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop5_SmallSphere"]
|
||||||
|
mesh = SubResource("SphereMesh_prop5")
|
||||||
|
|
||||||
|
[node name="Sun" type="DirectionalLight3D" parent="."]
|
||||||
|
rotation = Vector3(-0.785398, 0, 0)
|
||||||
|
shadow_enabled = true
|
||||||
|
|
||||||
|
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
|
||||||
|
environment = SubResource("WorldEnvironment_env")
|
||||||
|
|
||||||
|
[node name="PlayerSpawn" type="Marker3D" parent="."]
|
||||||
|
position = Vector3(0, 1.5, 0)
|
||||||
@@ -178,18 +178,18 @@ func login(username: String, password: String) -> Dictionary:
|
|||||||
|
|
||||||
|
|
||||||
## Register a new user. On success stores the returned access token.
|
## Register a new user. On success stores the returned access token.
|
||||||
## @param date_of_birth ISO date string "YYYY-MM-DD".
|
## @param over_18 Must be true; the backend rejects registration otherwise.
|
||||||
func register(
|
func register(
|
||||||
username: String,
|
username: String,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
password: String,
|
||||||
date_of_birth: String
|
over_18: bool
|
||||||
) -> Dictionary:
|
) -> Dictionary:
|
||||||
var payload := {
|
var payload := {
|
||||||
"username": username,
|
"username": username,
|
||||||
"email": email,
|
"email": email,
|
||||||
"password": password,
|
"password": password,
|
||||||
"date_of_birth": date_of_birth,
|
"over_18": over_18,
|
||||||
}
|
}
|
||||||
var result := await request(
|
var result := await request(
|
||||||
HTTPClient.METHOD_POST,
|
HTTPClient.METHOD_POST,
|
||||||
|
|||||||
@@ -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
|
||||||
+115
-65
@@ -1,24 +1,41 @@
|
|||||||
extends Control
|
extends Control
|
||||||
## Main menu with login/register UI wired to the backend auth API.
|
## Main menu with login/register UI wired to the backend auth API.
|
||||||
##
|
##
|
||||||
## The AuthPanel is hidden by default. When the user clicks "Login" or "Register"
|
## The AuthPanel is hidden by default. When the user clicks the LoginButton or
|
||||||
## (the existing Play/Quit buttons), the auth panel slides in. The ModeToggle
|
## RegisterButton (AuthButtonsRow, above the panel), the auth panel slides in.
|
||||||
## switches between Login and Register modes, showing/hiding the email and DOB
|
## The Login and Register buttons on the AuthButtonsRow directly set the panel's
|
||||||
## fields accordingly.
|
## mode, so the user can switch between Login and Register at any time while
|
||||||
|
## the panel is open.
|
||||||
##
|
##
|
||||||
## On successful auth the token is stored via ApiClient, the welcome label
|
## On successful auth the token is stored via ApiClient, AuthButtonsRow hides,
|
||||||
## shows the username, and the Play button becomes enabled.
|
## the welcome label shows the username, and the Play button becomes enabled.
|
||||||
|
## The auth form fields are hidden, leaving only the welcome message visible.
|
||||||
|
##
|
||||||
|
## When returning from another scene (e.g. world_list), _ready() checks whether
|
||||||
|
## ApiClient already holds a valid token. If so, it restores the authenticated
|
||||||
|
## UI state (Play enabled, welcome message visible) instead of forcing the user
|
||||||
|
## back to the login form.
|
||||||
|
|
||||||
|
|
||||||
# ── UI references ────────────────────────────────────────────────────────────
|
## Emitted once _ready()'s session check (has_token() + the awaited
|
||||||
|
## get_me() call) has actually resolved, whichever way -- lets a test (or
|
||||||
|
## anything else) know the UI state is trustworthy now, rather than
|
||||||
|
## guessing from a frame count or from UI properties that are ambiguous
|
||||||
|
## mid-await (e.g. AuthButtonsRow.visible is true both before the check
|
||||||
|
## resolves AND in a genuine logged-out end state).
|
||||||
|
signal auth_state_ready
|
||||||
|
|
||||||
|
# ─── UI references ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var _auth_buttons_row: HBoxContainer
|
||||||
|
var _login_button: Button
|
||||||
|
var _register_button: Button
|
||||||
var _auth_panel: PanelContainer
|
var _auth_panel: PanelContainer
|
||||||
var _auth_mode_label: Label
|
var _auth_mode_label: Label
|
||||||
var _username_edit: LineEdit
|
var _username_edit: LineEdit
|
||||||
var _password_edit: LineEdit
|
var _password_edit: LineEdit
|
||||||
var _email_edit: LineEdit
|
var _email_edit: LineEdit
|
||||||
var _dob_edit: LineEdit
|
var _over_18_check: CheckBox
|
||||||
var _mode_toggle: CheckButton
|
|
||||||
var _auth_button: Button
|
var _auth_button: Button
|
||||||
var _status_label: Label
|
var _status_label: Label
|
||||||
var _welcome_label: Label
|
var _welcome_label: Label
|
||||||
@@ -29,34 +46,93 @@ var _is_register_mode: bool = false
|
|||||||
var _is_authenticating: bool = false
|
var _is_authenticating: bool = false
|
||||||
|
|
||||||
|
|
||||||
# ── Lifecycle ────────────────────────────────────────────────────────────────
|
# ─── Lifecycle ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
# Cache node references.
|
# Cache node references.
|
||||||
|
_auth_buttons_row = $CenterContainer/VBoxContainer/AuthButtonsRow
|
||||||
|
_login_button = $CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton
|
||||||
|
_register_button = $CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton
|
||||||
_auth_panel = $CenterContainer/VBoxContainer/AuthPanel
|
_auth_panel = $CenterContainer/VBoxContainer/AuthPanel
|
||||||
_auth_mode_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthModeLabel
|
_auth_mode_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthModeLabel
|
||||||
_username_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit
|
_username_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit
|
||||||
_password_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit
|
_password_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit
|
||||||
_email_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit
|
_email_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit
|
||||||
_dob_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DOBEdit
|
_over_18_check = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/Over18Check
|
||||||
_mode_toggle = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/ModeToggle
|
|
||||||
_auth_button = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton
|
_auth_button = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton
|
||||||
_status_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthStatusLabel
|
_status_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthStatusLabel
|
||||||
_welcome_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel
|
_welcome_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel
|
||||||
_play_button = $CenterContainer/VBoxContainer/PlayButton
|
_play_button = $CenterContainer/VBoxContainer/PlayButton
|
||||||
_quit_button = $CenterContainer/VBoxContainer/QuitButton
|
_quit_button = $CenterContainer/VBoxContainer/QuitButton
|
||||||
|
|
||||||
# Initial state.
|
# Default to Play being gated. When a token exists, the async session
|
||||||
|
# check below may take several frames (a real get_me() round-trip);
|
||||||
|
# keeping the button disabled until validation succeeds prevents a fast
|
||||||
|
# click from navigating to world_list with an expired/invalid token.
|
||||||
|
_play_button.disabled = true
|
||||||
|
|
||||||
|
# Check whether a valid session already exists (e.g. user logged in,
|
||||||
|
# navigated to world_list, then clicked Back). If so, restore the
|
||||||
|
# authenticated UI state instead of forcing the login form again.
|
||||||
|
if ApiClient.has_token():
|
||||||
|
var me_result: Dictionary = await ApiClient.get_me()
|
||||||
|
if me_result.ok and me_result.data is Dictionary:
|
||||||
|
var username: String = str((me_result.data as Dictionary).get("username", ""))
|
||||||
|
if username != "":
|
||||||
|
_show_authenticated(username)
|
||||||
|
auth_state_ready.emit()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Token missing or invalid — fall through to logged-out state.
|
||||||
|
_reset_to_logged_out()
|
||||||
|
auth_state_ready.emit()
|
||||||
|
|
||||||
|
|
||||||
|
## Restore the UI to the authenticated state (Play enabled, welcome message
|
||||||
|
## visible, auth buttons hidden). AuthPanel itself stays visible -- the
|
||||||
|
## welcome label lives inside it, so hiding the panel hides the label too
|
||||||
|
## even when the label's own `visible` is true (real bug, 2026-08-20: a
|
||||||
|
## review caught the panel being hidden here while the label claimed to be
|
||||||
|
## visible, which is a no-op UI-wise since Control.visible doesn't override
|
||||||
|
## an ancestor's hidden state).
|
||||||
|
func _show_authenticated(username: String) -> void:
|
||||||
|
_auth_panel.visible = true
|
||||||
|
_status_label.visible = false
|
||||||
|
_welcome_label.text = "Welcome, %s!" % username
|
||||||
|
_welcome_label.visible = true
|
||||||
|
# Hide the same full set of form fields _on_auth_complete() hides right
|
||||||
|
# after a fresh login/register -- this path (session restore on scene
|
||||||
|
# reload, e.g. after Play -> Back) used to only hide EmailEdit/
|
||||||
|
# Over18Check, leaving UsernameEdit/PasswordEdit/AuthButton/
|
||||||
|
# AuthModeLabel visible on top of the welcome message. Real bug: Play
|
||||||
|
# was enabled and the welcome label was correct, so it wasn't caught by
|
||||||
|
# checks for those alone -- the login fields being back on screen is
|
||||||
|
# what a user actually sees as "it's making me log in again."
|
||||||
|
_username_edit.visible = false
|
||||||
|
_password_edit.visible = false
|
||||||
|
_email_edit.visible = false
|
||||||
|
_over_18_check.visible = false
|
||||||
|
_auth_button.visible = false
|
||||||
|
_auth_mode_label.visible = false
|
||||||
|
_play_button.disabled = false
|
||||||
|
_auth_buttons_row.visible = false
|
||||||
|
print("Restored authenticated session for: ", username)
|
||||||
|
|
||||||
|
|
||||||
|
## Reset the UI to the logged-out state (Play disabled, auth panel hidden,
|
||||||
|
## login/register buttons visible).
|
||||||
|
func _reset_to_logged_out() -> void:
|
||||||
_auth_panel.visible = false
|
_auth_panel.visible = false
|
||||||
_status_label.visible = false
|
_status_label.visible = false
|
||||||
_welcome_label.visible = false
|
_welcome_label.visible = false
|
||||||
_email_edit.visible = false
|
_email_edit.visible = false
|
||||||
_dob_edit.visible = false
|
_over_18_check.visible = false
|
||||||
|
_over_18_check.button_pressed = false
|
||||||
_play_button.disabled = true # Gate play behind successful auth.
|
_play_button.disabled = true # Gate play behind successful auth.
|
||||||
_update_auth_mode_ui()
|
_update_auth_mode_ui()
|
||||||
|
|
||||||
|
|
||||||
# ── Play / Quit ──────────────────────────────────────────────────────────────
|
# ─── Play / Quit ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func _on_play_button_pressed() -> void:
|
func _on_play_button_pressed() -> void:
|
||||||
if _play_button.disabled:
|
if _play_button.disabled:
|
||||||
@@ -70,29 +146,24 @@ func _on_quit_button_pressed() -> void:
|
|||||||
get_tree().quit()
|
get_tree().quit()
|
||||||
|
|
||||||
|
|
||||||
# ── Auth panel toggle ────────────────────────────────────────────────────────
|
# ─── Auth panel toggle ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func _on_login_button_pressed() -> void:
|
func _on_login_button_pressed() -> void:
|
||||||
"""Show the auth panel (called from a future Login button on the main menu)."""
|
"""Show the auth panel in login mode, called from AuthButtonsRow/LoginButton."""
|
||||||
_auth_panel.visible = true
|
_auth_panel.visible = true
|
||||||
_auth_panel.grab_focus()
|
_is_register_mode = false
|
||||||
|
_update_auth_mode_ui()
|
||||||
|
_clear_status()
|
||||||
|
_username_edit.grab_focus()
|
||||||
|
|
||||||
|
|
||||||
func _on_register_button_pressed() -> void:
|
func _on_register_button_pressed() -> void:
|
||||||
"""Show the auth panel in register mode."""
|
"""Show the auth panel in register mode, called from AuthButtonsRow/RegisterButton."""
|
||||||
_auth_panel.visible = true
|
_auth_panel.visible = true
|
||||||
_is_register_mode = true
|
_is_register_mode = true
|
||||||
_mode_toggle.button_pressed = true
|
|
||||||
_update_auth_mode_ui()
|
|
||||||
_clear_status()
|
|
||||||
|
|
||||||
|
|
||||||
# ── Mode toggle (Login / Register) ───────────────────────────────────────────
|
|
||||||
|
|
||||||
func _on_mode_toggle_toggled(button_pressed: bool) -> void:
|
|
||||||
_is_register_mode = button_pressed
|
|
||||||
_update_auth_mode_ui()
|
_update_auth_mode_ui()
|
||||||
_clear_status()
|
_clear_status()
|
||||||
|
_username_edit.grab_focus()
|
||||||
|
|
||||||
|
|
||||||
func _update_auth_mode_ui() -> void:
|
func _update_auth_mode_ui() -> void:
|
||||||
@@ -101,15 +172,15 @@ func _update_auth_mode_ui() -> void:
|
|||||||
_auth_mode_label.text = "Register"
|
_auth_mode_label.text = "Register"
|
||||||
_auth_button.text = "Register"
|
_auth_button.text = "Register"
|
||||||
_email_edit.visible = true
|
_email_edit.visible = true
|
||||||
_dob_edit.visible = true
|
_over_18_check.visible = true
|
||||||
else:
|
else:
|
||||||
_auth_mode_label.text = "Login"
|
_auth_mode_label.text = "Login"
|
||||||
_auth_button.text = "Login"
|
_auth_button.text = "Login"
|
||||||
_email_edit.visible = false
|
_email_edit.visible = false
|
||||||
_dob_edit.visible = false
|
_over_18_check.visible = false
|
||||||
|
|
||||||
|
|
||||||
# ── Auth submission ──────────────────────────────────────────────────────────
|
# ─── Auth submission ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func _on_auth_button_pressed() -> void:
|
func _on_auth_button_pressed() -> void:
|
||||||
"""Handle login or register button press."""
|
"""Handle login or register button press."""
|
||||||
@@ -129,17 +200,12 @@ func _on_auth_button_pressed() -> void:
|
|||||||
|
|
||||||
if _is_register_mode:
|
if _is_register_mode:
|
||||||
var email := _email_edit.text.strip_edges()
|
var email := _email_edit.text.strip_edges()
|
||||||
var dob := _dob_edit.text.strip_edges()
|
|
||||||
|
|
||||||
if email == "":
|
if email == "":
|
||||||
_show_status("Email is required.")
|
_show_status("Email is required.")
|
||||||
return
|
return
|
||||||
if dob == "":
|
if not _over_18_check.button_pressed:
|
||||||
_show_status("Date of birth is required.")
|
_show_status("You must confirm you are 18 or older to register.")
|
||||||
return
|
|
||||||
# Basic YYYY-MM-DD format check.
|
|
||||||
if not _is_valid_date(dob):
|
|
||||||
_show_status("Date of birth must be in YYYY-MM-DD format.")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
_auth_button.disabled = true
|
_auth_button.disabled = true
|
||||||
@@ -147,7 +213,7 @@ func _on_auth_button_pressed() -> void:
|
|||||||
_is_authenticating = true
|
_is_authenticating = true
|
||||||
_clear_status()
|
_clear_status()
|
||||||
|
|
||||||
var result := await ApiClient.register(username, email, password, dob)
|
var result := await ApiClient.register(username, email, password, true)
|
||||||
_on_auth_complete(result)
|
_on_auth_complete(result)
|
||||||
else:
|
else:
|
||||||
_auth_button.disabled = true
|
_auth_button.disabled = true
|
||||||
@@ -166,18 +232,25 @@ func _on_auth_complete(result: Dictionary) -> void:
|
|||||||
_is_authenticating = false
|
_is_authenticating = false
|
||||||
|
|
||||||
if result.ok:
|
if result.ok:
|
||||||
# Success — show welcome, enable Play.
|
# Success — hide the auth form fields, show only the welcome label.
|
||||||
|
_username_edit.visible = false
|
||||||
|
_password_edit.visible = false
|
||||||
|
_email_edit.visible = false
|
||||||
|
_over_18_check.visible = false
|
||||||
|
_auth_button.visible = false
|
||||||
|
_auth_mode_label.visible = false
|
||||||
|
_status_label.visible = false
|
||||||
_welcome_label.text = "Welcome, %s!" % _username_edit.text.strip_edges()
|
_welcome_label.text = "Welcome, %s!" % _username_edit.text.strip_edges()
|
||||||
_welcome_label.visible = true
|
_welcome_label.visible = true
|
||||||
_status_label.visible = false
|
|
||||||
_play_button.disabled = false
|
_play_button.disabled = false
|
||||||
|
_auth_buttons_row.visible = false
|
||||||
print("Auth successful. Token stored.")
|
print("Auth successful. Token stored.")
|
||||||
else:
|
else:
|
||||||
# Failure — show backend error detail.
|
# Failure — show backend error detail.
|
||||||
_show_status(result.detail)
|
_show_status(result.detail)
|
||||||
|
|
||||||
|
|
||||||
# ── Status helpers ───────────────────────────────────────────────────────────
|
# ─── Status helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func _show_status(message: String) -> void:
|
func _show_status(message: String) -> void:
|
||||||
_status_label.text = message
|
_status_label.text = message
|
||||||
@@ -187,26 +260,3 @@ func _show_status(message: String) -> void:
|
|||||||
func _clear_status() -> void:
|
func _clear_status() -> void:
|
||||||
_status_label.text = ""
|
_status_label.text = ""
|
||||||
_status_label.visible = false
|
_status_label.visible = false
|
||||||
|
|
||||||
|
|
||||||
# ── Validation ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func _is_valid_date(date_str: String) -> bool:
|
|
||||||
"""Check if a string looks like a valid YYYY-MM-DD date."""
|
|
||||||
if date_str.length() != 10:
|
|
||||||
return false
|
|
||||||
var parts := date_str.split("-")
|
|
||||||
if parts.size() != 3:
|
|
||||||
return false
|
|
||||||
var year := parts[0].to_int()
|
|
||||||
var month := parts[1].to_int()
|
|
||||||
var day := parts[2].to_int()
|
|
||||||
if month < 1 or month > 12:
|
|
||||||
return false
|
|
||||||
if day < 1 or day > 31:
|
|
||||||
return false
|
|
||||||
# Rough month-day validation.
|
|
||||||
var days_in_month := [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
|
||||||
if day > days_in_month[month]:
|
|
||||||
return false
|
|
||||||
return true
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
class_name Player
|
||||||
|
extends CharacterBody3D
|
||||||
|
## The player character controller.
|
||||||
|
##
|
||||||
|
## A simple CharacterBody3D with WASD/arrow locomotion, gravity, jump, and a
|
||||||
|
## first-person Camera3D with mouse-look. Reads the spawn point from the
|
||||||
|
## current scene's `PlayerSpawn` Marker3D (see docs/architecture/pc2_test_level.md §3).
|
||||||
|
##
|
||||||
|
## No networking, no chunk code, no avatar UI — PC2 is local-only.
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Movement constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const WALK_SPEED: float = 5.0
|
||||||
|
const JUMP_VELOCITY: float = 4.5
|
||||||
|
const GRAVITY: float = 9.8
|
||||||
|
|
||||||
|
## Mouse-look sensitivity (radians per pixel of relative motion).
|
||||||
|
const MOUSE_SENSITIVITY: float = 0.003
|
||||||
|
|
||||||
|
## Camera pitch is clamped so the player can't flip past looking straight
|
||||||
|
## up/down (which would otherwise let mouse-look invert on itself).
|
||||||
|
const PITCH_LIMIT: float = deg_to_rad(89.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Input state
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
var _input_direction := Vector2.ZERO
|
||||||
|
var _camera_pitch: float = 0.0
|
||||||
|
|
||||||
|
@onready var _camera: Camera3D = $Camera3D
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _ready — spawn at PlayerSpawn marker, capture the mouse for look
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
# Per §3 of pc2_test_level.md: look up PlayerSpawn from the current
|
||||||
|
# scene root. The lookup walks the full subtree (recursive=true) so
|
||||||
|
# it finds the marker whether it is a direct child of the level or
|
||||||
|
# nested deeper.
|
||||||
|
#
|
||||||
|
# current_scene is null in headless --script mode and in any scene
|
||||||
|
# before the main scene is fully swapped in, so guard it rather than
|
||||||
|
# blindly dereferencing.
|
||||||
|
var current_scene: Node = get_tree().current_scene
|
||||||
|
var spawn: Node = null
|
||||||
|
if current_scene != null:
|
||||||
|
spawn = current_scene.find_child("PlayerSpawn", true, false)
|
||||||
|
|
||||||
|
if spawn is Node3D:
|
||||||
|
global_position = (spawn as Node3D).global_position
|
||||||
|
# Align player forward with spawn yaw (optional polish).
|
||||||
|
var spawn_yaw := (spawn as Node3D).rotation.y
|
||||||
|
rotation.y = spawn_yaw
|
||||||
|
else:
|
||||||
|
push_warning("player.gd: no PlayerSpawn marker found; spawning at origin")
|
||||||
|
global_position = Vector3(0, 1.0, 0)
|
||||||
|
|
||||||
|
# Capture the mouse for FPS-style look. Headless/test runs have no real
|
||||||
|
# window to capture input for, so guard against that rather than error.
|
||||||
|
if DisplayServer.get_name() != "headless":
|
||||||
|
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _unhandled_input — capture keyboard / mouse for movement
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
|
if event is InputEventKey:
|
||||||
|
var key := event as InputEventKey
|
||||||
|
if key.pressed and not key.echo:
|
||||||
|
_update_input_direction(key)
|
||||||
|
elif not key.pressed:
|
||||||
|
_clear_input_direction(key)
|
||||||
|
# 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:
|
||||||
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||||
|
get_tree().change_scene_to_file("res://scenes/world_list.tscn")
|
||||||
|
elif event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
|
||||||
|
_handle_mouse_look(event as InputEventMouseMotion)
|
||||||
|
|
||||||
|
|
||||||
|
func _update_input_direction(key: InputEventKey) -> void:
|
||||||
|
match key.keycode:
|
||||||
|
KEY_W, KEY_UP:
|
||||||
|
_input_direction.y += 1.0
|
||||||
|
KEY_S, KEY_DOWN:
|
||||||
|
_input_direction.y -= 1.0
|
||||||
|
KEY_A, KEY_LEFT:
|
||||||
|
_input_direction.x -= 1.0
|
||||||
|
KEY_D, KEY_RIGHT:
|
||||||
|
_input_direction.x += 1.0
|
||||||
|
|
||||||
|
|
||||||
|
func _clear_input_direction(key: InputEventKey) -> void:
|
||||||
|
match key.keycode:
|
||||||
|
KEY_W, KEY_UP:
|
||||||
|
_input_direction.y -= 1.0
|
||||||
|
KEY_S, KEY_DOWN:
|
||||||
|
_input_direction.y += 1.0
|
||||||
|
KEY_A, KEY_LEFT:
|
||||||
|
_input_direction.x += 1.0
|
||||||
|
KEY_D, KEY_RIGHT:
|
||||||
|
_input_direction.x -= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
func _handle_mouse_look(event: InputEventMouseMotion) -> void:
|
||||||
|
# Yaw rotates the whole body (so movement direction turns with the
|
||||||
|
# look direction); pitch only tilts the camera, so the capsule itself
|
||||||
|
# never tips over.
|
||||||
|
rotation.y -= event.relative.x * MOUSE_SENSITIVITY
|
||||||
|
_camera_pitch = clampf(_camera_pitch - event.relative.y * MOUSE_SENSITIVITY, -PITCH_LIMIT, PITCH_LIMIT)
|
||||||
|
_camera.rotation.x = _camera_pitch
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Physics — gravity, jump, move_and_slide
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _physics_process(delta: float) -> void:
|
||||||
|
# Apply gravity.
|
||||||
|
if not is_on_floor():
|
||||||
|
velocity.y -= GRAVITY * delta
|
||||||
|
|
||||||
|
# Jump.
|
||||||
|
if is_on_floor() and Input.is_action_just_pressed("ui_accept"):
|
||||||
|
velocity.y = JUMP_VELOCITY
|
||||||
|
|
||||||
|
# Build movement direction from input. Godot's local "forward" is -Z, not
|
||||||
|
# +Z, so W (input.y = +1) must use -basis.z -- using +basis.z here would
|
||||||
|
# move the player backward when pressing forward (confirmed by reading
|
||||||
|
# Camera3D/Node3D's own convention: a node looks down its local -Z).
|
||||||
|
var direction := (_input_direction.x * transform.basis.x -
|
||||||
|
_input_direction.y * transform.basis.z).normalized()
|
||||||
|
velocity.x = direction.x * WALK_SPEED
|
||||||
|
velocity.z = direction.z * WALK_SPEED
|
||||||
|
|
||||||
|
move_and_slide()
|
||||||
@@ -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
|
||||||
+11
-12
@@ -10,7 +10,7 @@ extends SceneTree
|
|||||||
## Tests:
|
## Tests:
|
||||||
## 1. Register a unique user → 201 + token stored.
|
## 1. Register a unique user → 201 + token stored.
|
||||||
## 2. Register the same user again → 409 "Username already registered".
|
## 2. Register the same user again → 409 "Username already registered".
|
||||||
## 3. Register an under-18 user → 400 "Must be 18 or older".
|
## 3. Register with over_18=false → 400 "Must be 18 or older".
|
||||||
## 4. Login with correct credentials → 200 + token stored.
|
## 4. Login with correct credentials → 200 + token stored.
|
||||||
## 5. Login with wrong password → 401 "Incorrect username or password".
|
## 5. Login with wrong password → 401 "Incorrect username or password".
|
||||||
## 6. Login with non-existent user → 401 "Incorrect username or password".
|
## 6. Login with non-existent user → 401 "Incorrect username or password".
|
||||||
@@ -21,8 +21,6 @@ const API_BASE := "http://127.0.0.1:8000"
|
|||||||
const TEST_USERNAME := "smoke_test_user_%d"
|
const TEST_USERNAME := "smoke_test_user_%d"
|
||||||
const TEST_EMAIL := "smoke_%d@test.example.com"
|
const TEST_EMAIL := "smoke_%d@test.example.com"
|
||||||
const TEST_PASSWORD := "SmokeTest123!"
|
const TEST_PASSWORD := "SmokeTest123!"
|
||||||
const TEST_DOB_ADULT := "2000-01-01"
|
|
||||||
const TEST_DOB_CHILD := "2020-01-01" # ~5 years old
|
|
||||||
|
|
||||||
|
|
||||||
func _initialize() -> void:
|
func _initialize() -> void:
|
||||||
@@ -40,12 +38,13 @@ func _run() -> void:
|
|||||||
var failed: int = 0
|
var failed: int = 0
|
||||||
|
|
||||||
# --- Test 1: Register a unique user ---
|
# --- Test 1: Register a unique user ---
|
||||||
var unique_id := Time.get_ticks_msec()
|
randomize()
|
||||||
|
var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000
|
||||||
var username := "smoke_%d" % unique_id
|
var username := "smoke_%d" % unique_id
|
||||||
var email := "smoke_%d@test.example.com" % unique_id
|
var email := "smoke_%d@test.example.com" % unique_id
|
||||||
|
|
||||||
print("\n=== Test 1: Register unique user ===")
|
print("\n=== Test 1: Register unique user ===")
|
||||||
var result := await api.register(username, email, TEST_PASSWORD, TEST_DOB_ADULT)
|
var result := await api.register(username, email, TEST_PASSWORD, true)
|
||||||
if _check("register returns ok", result.ok) \
|
if _check("register returns ok", result.ok) \
|
||||||
and _check("register status is 201", result.status == 201) \
|
and _check("register status is 201", result.status == 201) \
|
||||||
and _check("register stores token", api.has_token()):
|
and _check("register stores token", api.has_token()):
|
||||||
@@ -56,7 +55,7 @@ func _run() -> void:
|
|||||||
|
|
||||||
# --- Test 2: Register same user again (duplicate) ---
|
# --- Test 2: Register same user again (duplicate) ---
|
||||||
print("\n=== Test 2: Duplicate username ===")
|
print("\n=== Test 2: Duplicate username ===")
|
||||||
result = await api.register(username, email, TEST_PASSWORD, TEST_DOB_ADULT)
|
result = await api.register(username, email, TEST_PASSWORD, true)
|
||||||
if _check("duplicate register not ok", not result.ok) \
|
if _check("duplicate register not ok", not result.ok) \
|
||||||
and _check("duplicate status is 409", result.status == 409) \
|
and _check("duplicate status is 409", result.status == 409) \
|
||||||
and _check("duplicate detail mentions username",
|
and _check("duplicate detail mentions username",
|
||||||
@@ -66,14 +65,14 @@ func _run() -> void:
|
|||||||
failed += 1
|
failed += 1
|
||||||
print(" detail: ", result.detail)
|
print(" detail: ", result.detail)
|
||||||
|
|
||||||
# --- Test 3: Register under-18 user ---
|
# --- Test 3: Register with over_18=false ---
|
||||||
print("\n=== Test 3: Under-18 registration ===")
|
print("\n=== Test 3: over_18=false registration ===")
|
||||||
var child_username := "child_%d" % unique_id
|
var child_username := "child_%d" % unique_id
|
||||||
var child_email := "child_%d@test.example.com" % unique_id
|
var child_email := "child_%d@test.example.com" % unique_id
|
||||||
result = await api.register(child_username, child_email, TEST_PASSWORD, TEST_DOB_CHILD)
|
result = await api.register(child_username, child_email, TEST_PASSWORD, false)
|
||||||
if _check("under-18 register not ok", not result.ok) \
|
if _check("over_18=false register not ok", not result.ok) \
|
||||||
and _check("under-18 status is 400", result.status == 400) \
|
and _check("over_18=false status is 400", result.status == 400) \
|
||||||
and _check("under-18 detail mentions age",
|
and _check("over_18=false detail mentions age",
|
||||||
"18" in result.detail):
|
"18" in result.detail):
|
||||||
passed += 1
|
passed += 1
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -23,7 +23,6 @@ extends SceneTree
|
|||||||
const TEST_USERNAME_BASE := "smoke_test_user"
|
const TEST_USERNAME_BASE := "smoke_test_user"
|
||||||
const TEST_EMAIL := "smoke@test.com"
|
const TEST_EMAIL := "smoke@test.com"
|
||||||
const TEST_PASSWORD := "TestPass123"
|
const TEST_PASSWORD := "TestPass123"
|
||||||
const TEST_DOB := "2000-01-01"
|
|
||||||
|
|
||||||
|
|
||||||
func _initialize() -> void:
|
func _initialize() -> void:
|
||||||
@@ -70,7 +69,7 @@ func _run() -> void:
|
|||||||
|
|
||||||
# --- Step 2: Register -----------------------------------------------------
|
# --- Step 2: Register -----------------------------------------------------
|
||||||
print("\n=== Step 2: Register (POST /api/auth/register) ===")
|
print("\n=== Step 2: Register (POST /api/auth/register) ===")
|
||||||
var reg: Dictionary = await api.register(username, email, TEST_PASSWORD, TEST_DOB)
|
var reg: Dictionary = await api.register(username, email, TEST_PASSWORD, true)
|
||||||
var reg_token := _extract_token(reg)
|
var reg_token := _extract_token(reg)
|
||||||
var reg_ok: bool = (
|
var reg_ok: bool = (
|
||||||
reg.ok
|
reg.ok
|
||||||
|
|||||||
@@ -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,170 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Regression test for the Play -> Back navigation bug.
|
||||||
|
##
|
||||||
|
## The reported bug: after logging in and clicking Play, the user lands on
|
||||||
|
## world_list.tscn; clicking Back returns to main_menu.tscn, but the menu
|
||||||
|
## reset to a fully logged-out state (Play disabled, login form showing)
|
||||||
|
## even though ApiClient still held a valid token. main_menu.gd's _ready()
|
||||||
|
## used to unconditionally reset the UI on every scene load.
|
||||||
|
##
|
||||||
|
## This test drives the real navigation flow (change_scene_to_file) against
|
||||||
|
## a live backend:
|
||||||
|
## 1. Register a unique user so ApiClient holds a valid token.
|
||||||
|
## 2. Load main_menu.tscn and assert it restores the authenticated state.
|
||||||
|
## 3. Click Play -> world_list.tscn.
|
||||||
|
## 4. Click Back -> main_menu.tscn.
|
||||||
|
## 5. Assert the menu is STILL authenticated (Play enabled, welcome label
|
||||||
|
## actually visible in the tree, login/register buttons hidden).
|
||||||
|
##
|
||||||
|
## Run with:
|
||||||
|
## godot --headless --path . --script res://scripts/smoke_test_main_menu_back.gd
|
||||||
|
##
|
||||||
|
## Requires the backend to be running (default base URL
|
||||||
|
## `application/agp/api_base_url` -> http://127.0.0.1:8000).
|
||||||
|
|
||||||
|
const USERNAME := "backnav_%d"
|
||||||
|
const EMAIL := "backnav_%d@test.example.com"
|
||||||
|
const PASSWORD := "BackNav123!"
|
||||||
|
|
||||||
|
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. Establish a real, valid session exactly the way a user would.
|
||||||
|
randomize()
|
||||||
|
var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000
|
||||||
|
var username := USERNAME % unique_id
|
||||||
|
var email := EMAIL % unique_id
|
||||||
|
print("\n=== Setup: register ", username, " ===")
|
||||||
|
var register_result: Dictionary = await api.register(username, email, PASSWORD, true)
|
||||||
|
if not _check("register succeeds", register_result.ok):
|
||||||
|
print(" detail: ", register_result.detail)
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
_check("ApiClient stores token after register", api.has_token())
|
||||||
|
|
||||||
|
# 2. First visit to the main menu: should already show the authenticated
|
||||||
|
# state (no login form, Play enabled).
|
||||||
|
change_scene_to_file("res://scenes/main_menu.tscn")
|
||||||
|
# change_scene_to_file defers the actual swap -- a couple frames is
|
||||||
|
# plenty for the scene object itself to exist (unlike the real
|
||||||
|
# get_me() network round-trip afterward, which is what auth_state_ready
|
||||||
|
# actually waits for below).
|
||||||
|
for i in 5:
|
||||||
|
await process_frame
|
||||||
|
if current_scene is Control:
|
||||||
|
break
|
||||||
|
|
||||||
|
var menu: Control = current_scene
|
||||||
|
if menu == null or not menu is Control:
|
||||||
|
printerr("FAIL: main_menu.tscn did not become current_scene")
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
await menu.auth_state_ready
|
||||||
|
_check("first main menu visit restores authenticated state", _is_authenticated(menu))
|
||||||
|
|
||||||
|
# 3. Play -> world_list.tscn (real button press).
|
||||||
|
var play_button: Button = menu.get_node("CenterContainer/VBoxContainer/PlayButton")
|
||||||
|
play_button.pressed.emit()
|
||||||
|
await _wait_for_scene_change(menu)
|
||||||
|
|
||||||
|
var world_list_scene := current_scene
|
||||||
|
if world_list_scene == null or world_list_scene == menu:
|
||||||
|
printerr("FAIL: world_list.tscn did not become current_scene after Play")
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
|
||||||
|
# 4. Back -> main_menu.tscn (real button press).
|
||||||
|
var back_button: Button = world_list_scene.get_node("VBoxContainer/HeaderHBox/BackButton")
|
||||||
|
back_button.pressed.emit()
|
||||||
|
await _wait_for_scene_change(world_list_scene)
|
||||||
|
|
||||||
|
menu = current_scene
|
||||||
|
if menu == null or not menu is Control:
|
||||||
|
printerr("FAIL: main_menu.tscn did not become current_scene after Back")
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
|
||||||
|
# The new main_menu instance's own _ready() is *also* awaiting a real
|
||||||
|
# get_me() call -- _wait_for_scene_change only confirms the scene
|
||||||
|
# object exists, not that its authenticated-state fields have been
|
||||||
|
# set yet. Wait for the real signal, not a guess.
|
||||||
|
await menu.auth_state_ready
|
||||||
|
|
||||||
|
# 5. THE regression assertion: still logged in after returning.
|
||||||
|
_check("main menu stays authenticated after Play -> Back", _is_authenticated(menu))
|
||||||
|
|
||||||
|
_finish()
|
||||||
|
|
||||||
|
|
||||||
|
func _is_authenticated(menu: Control) -> bool:
|
||||||
|
var play_button: Button = menu.get_node("CenterContainer/VBoxContainer/PlayButton")
|
||||||
|
var welcome_label: Label = menu.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel")
|
||||||
|
var auth_buttons_row: HBoxContainer = menu.get_node("CenterContainer/VBoxContainer/AuthButtonsRow")
|
||||||
|
var auth_panel: PanelContainer = menu.get_node("CenterContainer/VBoxContainer/AuthPanel")
|
||||||
|
var username_edit: LineEdit = menu.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit")
|
||||||
|
var password_edit: LineEdit = menu.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit")
|
||||||
|
var auth_button: Button = menu.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton")
|
||||||
|
var auth_mode_label: Label = menu.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthModeLabel")
|
||||||
|
|
||||||
|
_check("Play enabled", not play_button.disabled)
|
||||||
|
# WelcomeLabel lives inside AuthPanel, so `.visible` alone is a
|
||||||
|
# false-positive: the label can have visible=true while its parent is
|
||||||
|
# hidden and it renders as nothing. `is_visible_in_tree()` is the
|
||||||
|
# check that proves the user can actually see the welcome message.
|
||||||
|
_check("Welcome label visible in tree", welcome_label.is_visible_in_tree())
|
||||||
|
_check("Auth buttons row hidden", not auth_buttons_row.visible)
|
||||||
|
_check("Auth panel visible (it contains the welcome label)", auth_panel.visible)
|
||||||
|
# Real bug, 2026-08-20: the session-restore path (this one, after
|
||||||
|
# Play -> Back) hid EmailEdit/Over18Check but never the rest of the
|
||||||
|
# login form -- Play enabled + welcome label visible were both still
|
||||||
|
# true, so a check that stopped there passed while the user was
|
||||||
|
# looking at the login fields again on top of the welcome message.
|
||||||
|
_check("Username field hidden", not username_edit.visible)
|
||||||
|
_check("Password field hidden", not password_edit.visible)
|
||||||
|
_check("Auth button hidden", not auth_button.visible)
|
||||||
|
_check("Auth mode label hidden", not auth_mode_label.visible)
|
||||||
|
|
||||||
|
return (not play_button.disabled) \
|
||||||
|
and welcome_label.is_visible_in_tree() \
|
||||||
|
and not auth_buttons_row.visible \
|
||||||
|
and auth_panel.visible \
|
||||||
|
and not username_edit.visible \
|
||||||
|
and not password_edit.visible \
|
||||||
|
and not auth_button.visible \
|
||||||
|
and not auth_mode_label.visible
|
||||||
|
|
||||||
|
|
||||||
|
func _wait_for_scene_change(previous_scene: Node) -> void:
|
||||||
|
for i in 20:
|
||||||
|
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,86 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Full click-through simulation of the real user flow: press Register,
|
||||||
|
## fill the form exactly as a human would, submit, confirm Play actually
|
||||||
|
## becomes clickable. Also verifies that after successful auth the auth
|
||||||
|
## form fields are hidden, leaving only the welcome message visible.
|
||||||
|
|
||||||
|
var passed := 0
|
||||||
|
var failed := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run()
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
var packed: PackedScene = load("res://scenes/main_menu.tscn")
|
||||||
|
var instance: Control = packed.instantiate()
|
||||||
|
root.add_child(instance)
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
var register_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton")
|
||||||
|
var auth_buttons_row: HBoxContainer = instance.get_node("CenterContainer/VBoxContainer/AuthButtonsRow")
|
||||||
|
var username_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit")
|
||||||
|
var password_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit")
|
||||||
|
var email_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit")
|
||||||
|
var over_18_check: CheckBox = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/Over18Check")
|
||||||
|
var auth_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton")
|
||||||
|
var auth_mode_label: Label = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthModeLabel")
|
||||||
|
var welcome_label: Label = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel")
|
||||||
|
var play_button: Button = instance.get_node("CenterContainer/VBoxContainer/PlayButton")
|
||||||
|
|
||||||
|
_check("Play starts disabled", play_button.disabled)
|
||||||
|
|
||||||
|
# Click Register -- a real signal emission is fine here since
|
||||||
|
# _on_register_button_pressed() is synchronous (no await), same as a
|
||||||
|
# real click's GUI event dispatch would drive it.
|
||||||
|
register_button.pressed.emit()
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
_check("auth panel opened in register mode", instance.get_node("CenterContainer/VBoxContainer/AuthPanel").visible)
|
||||||
|
|
||||||
|
# Fill the form exactly as a user would type into it.
|
||||||
|
var unique_id := int(Time.get_unix_time_from_system() * 1000)
|
||||||
|
username_edit.text = "mainmenu_e2e_%d" % unique_id
|
||||||
|
password_edit.text = "TestPass123!"
|
||||||
|
email_edit.text = "mainmenu_e2e_%d@test.example.com" % unique_id
|
||||||
|
over_18_check.button_pressed = true
|
||||||
|
|
||||||
|
# Call the submit handler directly (awaited) rather than via
|
||||||
|
# auth_button.pressed.emit() -- a synthetic .emit() in --script/
|
||||||
|
# SceneTree headless mode doesn't drive an await-using handler's
|
||||||
|
# coroutine forward the same way real GUI input event dispatch does
|
||||||
|
# (confirmed via a direct-call vs emit() comparison: the direct call
|
||||||
|
# always resolves, the synthetic emit sometimes doesn't advance past
|
||||||
|
# its own await point even after 50 real process_frame waits). This
|
||||||
|
# is a headless-mode test-harness limitation, not a bug in the actual
|
||||||
|
# click-driven flow.
|
||||||
|
await instance._on_auth_button_pressed()
|
||||||
|
|
||||||
|
_check("Play became enabled after real register submission", not play_button.disabled)
|
||||||
|
_check("Welcome label visible in tree", welcome_label.is_visible_in_tree())
|
||||||
|
_check("Auth buttons row hid itself after success", not auth_buttons_row.visible)
|
||||||
|
|
||||||
|
# Verify the auth form fields are hidden after successful auth,
|
||||||
|
# leaving only the welcome message visible within the panel.
|
||||||
|
_check("Username edit hidden after auth", not username_edit.visible)
|
||||||
|
_check("Password edit hidden after auth", not password_edit.visible)
|
||||||
|
_check("Email edit hidden after auth", not email_edit.visible)
|
||||||
|
_check("Over18 checkbox hidden after auth", not over_18_check.visible)
|
||||||
|
_check("Auth button hidden after auth", not auth_button.visible)
|
||||||
|
_check("Auth mode label hidden after auth", not auth_mode_label.visible)
|
||||||
|
_check("Welcome label contains username", welcome_label.text.begins_with("Welcome, mainmenu_e2e_"))
|
||||||
|
|
||||||
|
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
||||||
|
quit(0 if failed == 0 else 1)
|
||||||
|
|
||||||
|
|
||||||
|
func _check(description: String, condition: bool) -> void:
|
||||||
|
if condition:
|
||||||
|
print(" PASS: ", description)
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
printerr(" FAIL: ", description)
|
||||||
|
failed += 1
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Verifies the main menu's Login/Register buttons correctly open the auth
|
||||||
|
## panel and toggle between Login and Register modes via signal emission.
|
||||||
|
##
|
||||||
|
## Key tests:
|
||||||
|
## - AuthPanel starts hidden.
|
||||||
|
## - LoginButton press opens the panel in Login mode.
|
||||||
|
## - RegisterButton press opens the panel in Register mode.
|
||||||
|
## - Clicking Login while in Register mode switches back to Login mode.
|
||||||
|
## - Clicking Register while in Login mode switches to Register mode.
|
||||||
|
## - The ModeToggle CheckButton no longer exists in the scene.
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run()
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
var passed := 0
|
||||||
|
var failed := 0
|
||||||
|
|
||||||
|
var packed: PackedScene = load("res://scenes/main_menu.tscn")
|
||||||
|
var instance: Control = packed.instantiate()
|
||||||
|
root.add_child(instance)
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
var login_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton")
|
||||||
|
var register_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton")
|
||||||
|
var auth_panel: PanelContainer = instance.get_node("CenterContainer/VBoxContainer/AuthPanel")
|
||||||
|
var auth_mode_label: Label = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthModeLabel")
|
||||||
|
var email_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit")
|
||||||
|
var over_18_check: CheckBox = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/Over18Check")
|
||||||
|
|
||||||
|
# --- Test 1: auth panel starts hidden ---
|
||||||
|
if _check("auth panel starts hidden", not auth_panel.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# --- Test 2: LoginButton press opens panel in Login mode ---
|
||||||
|
login_button.pressed.emit()
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
if _check("auth panel visible after LoginButton press", auth_panel.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if _check("Login mode label after LoginButton press", auth_mode_label.text == "Login"):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if _check("Email field hidden in Login mode", not email_edit.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if _check("Over18 checkbox hidden in Login mode", not over_18_check.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# --- Test 3: RegisterButton press switches to Register mode ---
|
||||||
|
register_button.pressed.emit()
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
if _check("auth panel still visible after RegisterButton press", auth_panel.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if _check("Register mode label after RegisterButton press", auth_mode_label.text == "Register"):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if _check("Email field visible in Register mode", email_edit.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if _check("Over18 checkbox visible in Register mode", over_18_check.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# --- Test 4: LoginButton press switches back to Login mode ---
|
||||||
|
login_button.pressed.emit()
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
if _check("Login mode label after second LoginButton press", auth_mode_label.text == "Login"):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if _check("Email field hidden after switching back to Login", not email_edit.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if _check("Over18 checkbox hidden after switching back to Login", not over_18_check.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# --- Test 5: ModeToggle CheckButton no longer exists ---
|
||||||
|
var mode_toggle_path = "CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/ModeToggle"
|
||||||
|
var has_mode_toggle = instance.has_node(mode_toggle_path)
|
||||||
|
if _check("ModeToggle CheckButton removed from scene", not has_mode_toggle):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
||||||
|
quit(0 if failed == 0 else 1)
|
||||||
|
|
||||||
|
|
||||||
|
func _check(description: String, condition: bool) -> bool:
|
||||||
|
if condition:
|
||||||
|
print(" PASS: ", description)
|
||||||
|
else:
|
||||||
|
printerr(" FAIL: ", description)
|
||||||
|
return condition
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Smoke test for the player character scene and its spawn behavior.
|
||||||
|
##
|
||||||
|
## Run with:
|
||||||
|
## godot --headless --path . --script res://scripts/smoke_test_player.gd
|
||||||
|
##
|
||||||
|
## Loads the test level, verifies the player is present, spawned at the
|
||||||
|
## PlayerSpawn marker, and has the expected CharacterBody3D structure.
|
||||||
|
|
||||||
|
var _passed := 0
|
||||||
|
var _failed := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
print("SMOKE: starting player smoke test")
|
||||||
|
_run()
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
await process_frame
|
||||||
|
print("SMOKE: loading game scene")
|
||||||
|
|
||||||
|
# Load scenes/game.tscn -- the real PC2 composition point (Level + Player
|
||||||
|
# as siblings, per docs/architecture/pc2_test_level.md §4), not the level
|
||||||
|
# scene directly. Use the same SceneTree API as the real game so
|
||||||
|
# `current_scene` is populated before the scene is added and its
|
||||||
|
# _ready() callbacks fire. That ordering is what the player's spawn
|
||||||
|
# lookup (get_tree().current_scene) depends on.
|
||||||
|
var packed: PackedScene = load("res://scenes/game.tscn")
|
||||||
|
var result := change_scene_to_packed(packed)
|
||||||
|
if result != OK:
|
||||||
|
printerr("SMOKE: failed to change scene, error ", result)
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
await scene_changed
|
||||||
|
var game: Node3D = current_scene as Node3D
|
||||||
|
print("SMOKE: game scene ready")
|
||||||
|
|
||||||
|
# 1. Verify the player node exists in the scene tree.
|
||||||
|
var player: Node = game.get_node("Player")
|
||||||
|
_check("Player node exists in test level", player != null)
|
||||||
|
_check("Player is a CharacterBody3D", player is CharacterBody3D)
|
||||||
|
|
||||||
|
# 2. Verify the player has the expected child structure.
|
||||||
|
var collision: Node = player.get_node("CollisionShape3D")
|
||||||
|
_check("Player has CollisionShape3D", collision != null)
|
||||||
|
_check("CollisionShape3D has a shape",
|
||||||
|
collision is CollisionShape3D and (collision as CollisionShape3D).shape != null)
|
||||||
|
|
||||||
|
var mesh: Node = player.get_node("CapsuleMesh")
|
||||||
|
_check("Player has CapsuleMesh", mesh != null)
|
||||||
|
_check("CapsuleMesh has a mesh",
|
||||||
|
mesh is MeshInstance3D and (mesh as MeshInstance3D).mesh != null)
|
||||||
|
|
||||||
|
# 3. Verify the player spawned at the PlayerSpawn marker position.
|
||||||
|
var spawn_marker: Marker3D = game.get_node("Level/PlayerSpawn")
|
||||||
|
_check("PlayerSpawn marker exists", spawn_marker != null)
|
||||||
|
|
||||||
|
# The player's _ready() should have moved it to the spawn marker's
|
||||||
|
# global position. Compare global positions directly (not local) since
|
||||||
|
# Player and Level sit at different depths under Game.
|
||||||
|
var expected_pos: Vector3 = spawn_marker.global_position
|
||||||
|
_check("Player spawned at PlayerSpawn position",
|
||||||
|
player.global_position == expected_pos)
|
||||||
|
|
||||||
|
# 4. Verify the player script class_name is correct and has the
|
||||||
|
# movement constants defined.
|
||||||
|
var script: GDScript = player.get_script() as GDScript
|
||||||
|
_check("Player has a script attached", script != null)
|
||||||
|
var constants: Dictionary = script.get_script_constant_map()
|
||||||
|
_check("Player has WALK_SPEED constant", constants.has("WALK_SPEED"))
|
||||||
|
_check("Player has GRAVITY constant", constants.has("GRAVITY"))
|
||||||
|
_check("Player has JUMP_VELOCITY constant", constants.has("JUMP_VELOCITY"))
|
||||||
|
_check("JUMP_VELOCITY is positive (upward)", float(constants.get("JUMP_VELOCITY", -1.0)) > 0.0)
|
||||||
|
|
||||||
|
# 5. Verify the player has a Camera3D so the game is actually viewable
|
||||||
|
# from the player's perspective, not just a walking capsule with no view.
|
||||||
|
var camera: Node = player.get_node_or_null("Camera3D")
|
||||||
|
_check("Player has a Camera3D", camera is Camera3D)
|
||||||
|
_check("Camera3D is current", camera is Camera3D and (camera as Camera3D).current)
|
||||||
|
|
||||||
|
print("\n=== SMOKE RESULT: %d/%d passed ===" % [_passed, _passed + _failed])
|
||||||
|
quit(0 if _failed == 0 else 1)
|
||||||
|
|
||||||
|
|
||||||
|
func _check(description: String, condition: bool) -> void:
|
||||||
|
if condition:
|
||||||
|
print(" PASS: ", description)
|
||||||
|
_passed += 1
|
||||||
|
else:
|
||||||
|
printerr(" FAIL: ", description)
|
||||||
|
_failed += 1
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Headless smoke test for the PC2 test level scene.
|
||||||
|
##
|
||||||
|
## Run with:
|
||||||
|
## godot --headless --path . --script res://scripts/smoke_test_test_level.gd
|
||||||
|
##
|
||||||
|
## Loads the test level scene, asserts:
|
||||||
|
## (a) it loads without error,
|
||||||
|
## (b) it contains a Marker3D named "PlayerSpawn",
|
||||||
|
## (c) PlayerSpawn is positioned >= 1.0 m above the ground top surface.
|
||||||
|
## Exits 0 on success, 1 on failure.
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run()
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
var level_path: String = "res://scenes/worlds/test_level.tscn"
|
||||||
|
|
||||||
|
# Load the scene file
|
||||||
|
var level_resource: PackedScene = load(level_path)
|
||||||
|
if level_resource == null:
|
||||||
|
printerr("SMOKE RESULT: FAIL \u2014 could not load ", level_path)
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
print("SMOKE: test_level.tscn loaded successfully")
|
||||||
|
|
||||||
|
# Change the running scene to our test level
|
||||||
|
var err: int = change_scene_to_packed(level_resource)
|
||||||
|
if err != OK:
|
||||||
|
printerr("SMOKE RESULT: FAIL \u2014 change_scene_to_packed returned ", err)
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Wait for the scene_changed signal so current_scene is set
|
||||||
|
await scene_changed
|
||||||
|
|
||||||
|
var level_instance: Node3D = current_scene as Node3D
|
||||||
|
if level_instance == null:
|
||||||
|
printerr("SMOKE RESULT: FAIL \u2014 current_scene is not Node3D")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Give the tree one frame to fully initialize
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
# (b) Find the PlayerSpawn marker
|
||||||
|
var spawn_marker: Node3D = level_instance.find_child("PlayerSpawn", true, false) as Node3D
|
||||||
|
if spawn_marker == null:
|
||||||
|
printerr("SMOKE RESULT: FAIL \u2014 no Node3D named 'PlayerSpawn' found in test level")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
print("SMOKE: found PlayerSpawn at ", spawn_marker.global_position)
|
||||||
|
|
||||||
|
# (c) Verify PlayerSpawn is >= 1.0 m above ground top
|
||||||
|
# The ground top surface is at y = 0 (Ground StaticBody3D centered at y = -0.5
|
||||||
|
# with height 1.0, so top face is at y = 0).
|
||||||
|
var spawn_y: float = spawn_marker.global_position.y
|
||||||
|
if spawn_y < 1.0:
|
||||||
|
printerr("SMOKE RESULT: FAIL \u2014 PlayerSpawn y = ", spawn_y,
|
||||||
|
" is below the required minimum of 1.0 m above ground top (y=0)")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
print("SMOKE: PlayerSpawn y = ", spawn_y, " >= 1.0 m \u2014 PASS")
|
||||||
|
|
||||||
|
# Additional check: verify Ground StaticBody3D exists
|
||||||
|
var ground: StaticBody3D = level_instance.find_child("Ground", true, false) as StaticBody3D
|
||||||
|
if ground == null:
|
||||||
|
printerr("SMOKE RESULT: FAIL \u2014 no StaticBody3D named 'Ground' found")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
print("SMOKE: found Ground StaticBody3D")
|
||||||
|
|
||||||
|
# Verify Ground top face is at y = 0
|
||||||
|
var ground_shape: CollisionShape3D = ground.find_child("CollisionShape3D", true, false) as CollisionShape3D
|
||||||
|
if ground_shape != null and ground_shape.shape is BoxShape3D:
|
||||||
|
var box_shape: BoxShape3D = ground_shape.shape as BoxShape3D
|
||||||
|
var ground_top: float = ground.global_position.y + box_shape.size.y / 2.0
|
||||||
|
if abs(ground_top - 0.0) > 0.01:
|
||||||
|
printerr("SMOKE RESULT: FAIL \u2014 Ground top face at y = ", ground_top, " expected ~0.0")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
print("SMOKE: Ground top face at y = ", ground_top, " \u2014 PASS")
|
||||||
|
|
||||||
|
# Verify props exist (3-5 StaticBody3D children under Props node)
|
||||||
|
var props_node: Node3D = level_instance.find_child("Props", true, false) as Node3D
|
||||||
|
if props_node != null:
|
||||||
|
var prop_count: int = 0
|
||||||
|
for child in props_node.get_children():
|
||||||
|
if child is StaticBody3D:
|
||||||
|
prop_count += 1
|
||||||
|
if prop_count < 3:
|
||||||
|
printerr("SMOKE RESULT: FAIL \u2014 only ", prop_count, " props found, need >= 3")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
print("SMOKE: found ", prop_count, " props \u2014 PASS")
|
||||||
|
else:
|
||||||
|
printerr("SMOKE RESULT: FAIL \u2014 no 'Props' Node3D found")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Verify lighting exists
|
||||||
|
var sun: DirectionalLight3D = level_instance.find_child("Sun", true, false) as DirectionalLight3D
|
||||||
|
if sun == null:
|
||||||
|
printerr("SMOKE RESULT: FAIL \u2014 no DirectionalLight3D named 'Sun' found")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
print("SMOKE: found Sun DirectionalLight3D")
|
||||||
|
|
||||||
|
# Verify WorldEnvironment exists
|
||||||
|
var env: WorldEnvironment = level_instance.find_child("WorldEnvironment", true, false) as WorldEnvironment
|
||||||
|
if env == null:
|
||||||
|
printerr("SMOKE RESULT: FAIL \u2014 no WorldEnvironment named 'WorldEnvironment' found")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
print("SMOKE: found WorldEnvironment")
|
||||||
|
|
||||||
|
# All checks passed
|
||||||
|
print("SMOKE RESULT: PASS")
|
||||||
|
quit(0)
|
||||||
@@ -16,15 +16,27 @@ extends SceneTree
|
|||||||
##
|
##
|
||||||
## Tests:
|
## Tests:
|
||||||
## 1. Register + log in a fresh user (so get_worlds() has a valid token).
|
## 1. Register + log in a fresh user (so get_worlds() has a valid token).
|
||||||
## 2. Instantiate world_list.tscn, add to tree -- @onready resolution
|
## 2. POST a real world via the API so the populated-card path actually
|
||||||
|
## gets exercised -- run_checkpoint_tests.py always starts from a
|
||||||
|
## genuinely empty DB, so without this the card-rendering code below
|
||||||
|
## would never run in CI at all, only the empty-list path would.
|
||||||
|
## 3. Instantiate world_list.tscn, add to tree -- @onready resolution
|
||||||
## must not error.
|
## must not error.
|
||||||
## 3. Wait for _refresh_worlds()'s awaited request to complete.
|
## 4. Wait for _refresh_worlds()'s awaited request to complete.
|
||||||
## 4. Confirm the screen reached a real end state (world cards, or the
|
## 5. Confirm it actually reached the populated (not empty) state, and
|
||||||
## empty-list message -- either is fine, a crash/stuck-loading isn't).
|
## that the rendered card has a real width -- not just non-zero
|
||||||
|
## child_count. 2026-08-20: a real bug shipped where every card
|
||||||
|
## technically existed in the tree (child_count > 0, no error, no
|
||||||
|
## crash) but rendered at 1.0px wide because size_flags_horizontal/
|
||||||
|
## vertical used the raw integer 4 (Control.SIZE_SHRINK_CENTER)
|
||||||
|
## instead of 3 (Control.SIZE_EXPAND_FILL) on ScrollContainer -- a
|
||||||
|
## child-count check alone would never catch that, only a real size
|
||||||
|
## assertion does.
|
||||||
##
|
##
|
||||||
## Exits 0 on full pass, 1 on any failure.
|
## Exits 0 on full pass, 1 on any failure.
|
||||||
|
|
||||||
const WORLD_LIST_SCENE := "res://scenes/world_list.tscn"
|
const WORLD_LIST_SCENE := "res://scenes/world_list.tscn"
|
||||||
|
const MIN_SANE_CARD_WIDTH := 200.0 # a real screen is at minimum a few hundred px wide
|
||||||
|
|
||||||
|
|
||||||
func _initialize() -> void:
|
func _initialize() -> void:
|
||||||
@@ -46,12 +58,25 @@ func _run() -> void:
|
|||||||
var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000
|
var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000
|
||||||
var username := "worldlist_smoke_%d" % unique_id
|
var username := "worldlist_smoke_%d" % unique_id
|
||||||
var email := "worldlist_smoke_%d@test.example.com" % unique_id
|
var email := "worldlist_smoke_%d@test.example.com" % unique_id
|
||||||
var reg_result: Dictionary = await api.register(username, email, "SmokeTest123!", "2000-01-01")
|
var reg_result: Dictionary = await api.register(username, email, "SmokeTest123!", true)
|
||||||
if not _check("setup: register succeeded", reg_result.ok):
|
if not _check("setup: register succeeded", reg_result.ok):
|
||||||
print(" detail: ", reg_result.detail)
|
print(" detail: ", reg_result.detail)
|
||||||
quit(1)
|
quit(1)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# --- Seed a real world so the populated-card path is actually exercised ---
|
||||||
|
print("\n=== Setup: seed a real world ===")
|
||||||
|
var world_body := JSON.stringify({
|
||||||
|
"name": "Smoke Test World %d" % unique_id,
|
||||||
|
"description": "Seeded by smoke_test_world_list.gd to exercise the populated-card render path.",
|
||||||
|
"visibility": "public",
|
||||||
|
})
|
||||||
|
var create_result: Dictionary = await api.request(HTTPClient.METHOD_POST, "/api/worlds/", world_body, "application/json", true)
|
||||||
|
if not _check("setup: world created", create_result.ok):
|
||||||
|
print(" detail: ", create_result.detail)
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
# --- Test 1: Instantiate the real scene ---
|
# --- Test 1: Instantiate the real scene ---
|
||||||
print("\n=== Test 1: Instantiate world_list.tscn ===")
|
print("\n=== Test 1: Instantiate world_list.tscn ===")
|
||||||
var packed_scene: PackedScene = load(WORLD_LIST_SCENE)
|
var packed_scene: PackedScene = load(WORLD_LIST_SCENE)
|
||||||
@@ -99,10 +124,31 @@ func _run() -> void:
|
|||||||
|
|
||||||
if error_label.visible:
|
if error_label.visible:
|
||||||
printerr(" Note: ended in error state: ", error_label.text)
|
printerr(" Note: ended in error state: ", error_label.text)
|
||||||
elif world_list_node.visible:
|
|
||||||
print(" Note: rendered %d world card(s)" % world_list_node.get_child_count())
|
|
||||||
elif empty_label.visible:
|
elif empty_label.visible:
|
||||||
print(" Note: ended in empty-list state")
|
# We just seeded a world -- ending up empty means GET /api/worlds
|
||||||
|
# isn't actually seeing what POST /api/worlds/ just created.
|
||||||
|
_check("ended in populated state, not empty (a world was just seeded)", false)
|
||||||
|
failed += 1
|
||||||
|
elif world_list_node.visible:
|
||||||
|
var card_count := world_list_node.get_child_count()
|
||||||
|
print(" Note: rendered %d world card(s)" % card_count)
|
||||||
|
|
||||||
|
if _check("at least one card rendered", card_count > 0):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# The actual bug this test exists to catch: a card can be present
|
||||||
|
# in the tree, not crash, and still be visually nothing because
|
||||||
|
# it rendered at ~1px wide. Check every card's real computed size,
|
||||||
|
# not just that it exists.
|
||||||
|
for i in range(card_count):
|
||||||
|
var card: Control = world_list_node.get_child(i)
|
||||||
|
var wide_enough := card.size.x >= MIN_SANE_CARD_WIDTH
|
||||||
|
if _check("card %d has a real width (%.0fpx >= %.0fpx)" % [i, card.size.x, MIN_SANE_CARD_WIDTH], wide_enough):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
# --- Summary ---
|
# --- Summary ---
|
||||||
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
||||||
|
|||||||
+33
-11
@@ -88,10 +88,26 @@ func _refresh_worlds() -> void:
|
|||||||
|
|
||||||
## Create a visual card for a single world entry.
|
## Create a visual card for a single world entry.
|
||||||
func _create_world_card(world_dict: Dictionary) -> Control:
|
func _create_world_card(world_dict: Dictionary) -> Control:
|
||||||
|
# PanelContainer for the background, not a ColorRect moved to child index
|
||||||
|
# 0 of a VBoxContainer -- a VBoxContainer lays out ALL its children in a
|
||||||
|
# vertical sequence, it has no concept of "float this one child behind
|
||||||
|
# the others as an overlay". That version rendered as an empty dark bar
|
||||||
|
# sitting above the text, not a background behind it.
|
||||||
|
var panel := PanelContainer.new()
|
||||||
|
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
panel.custom_minimum_size = Vector2(0, 80)
|
||||||
|
var style := StyleBoxFlat.new()
|
||||||
|
style.bg_color = Color(0.15, 0.15, 0.15, 1.0)
|
||||||
|
style.content_margin_left = 12
|
||||||
|
style.content_margin_right = 12
|
||||||
|
style.content_margin_top = 8
|
||||||
|
style.content_margin_bottom = 8
|
||||||
|
panel.add_theme_stylebox_override("panel", style)
|
||||||
|
|
||||||
var card := VBoxContainer.new()
|
var card := VBoxContainer.new()
|
||||||
card.size_flags_horizontal = Control.SIZE_FILL
|
card.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
card.custom_minimum_size = Vector2(0, 80)
|
|
||||||
card.add_theme_constant_override("separation", 4)
|
card.add_theme_constant_override("separation", 4)
|
||||||
|
panel.add_child(card)
|
||||||
|
|
||||||
var name_label := Label.new()
|
var name_label := Label.new()
|
||||||
name_label.text = world_dict.get("name", "Unnamed World")
|
name_label.text = world_dict.get("name", "Unnamed World")
|
||||||
@@ -119,17 +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)
|
||||||
|
|
||||||
# Subtle background for the card
|
return panel
|
||||||
var bg := ColorRect.new()
|
|
||||||
bg.color = Color(0.15, 0.15, 0.15, 1.0)
|
|
||||||
bg.size_flags_horizontal = Control.SIZE_FILL
|
|
||||||
bg.size_flags_vertical = Control.SIZE_FILL
|
|
||||||
bg.custom_minimum_size = Vector2(0, 80)
|
|
||||||
card.add_child(bg)
|
|
||||||
card.move_child(bg, 0)
|
|
||||||
|
|
||||||
return card
|
|
||||||
|
func _on_enter_world_pressed() -> void:
|
||||||
|
get_tree().change_scene_to_file("res://scenes/game.tscn")
|
||||||
|
|||||||
Reference in New Issue
Block a user