Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29349ef0ea | |||
| b422e41a0f | |||
| bd44ecba14 | |||
| bae326e14c | |||
| 140db9df91 | |||
| 2dff83bae3 | |||
| 1a8821e3c8 | |||
| 019dfca14f | |||
| 7be50e3313 | |||
| 29de1c5c3a | |||
| 92c10fa657 | |||
| 8357643072 | |||
| 736ebce0d0 | |||
| b60480798f | |||
| 77f7511a4f | |||
| 8be962248d | |||
| 7790670559 | |||
| c4eab8db92 | |||
| e2a77b055f | |||
| 5966fa9044 | |||
| 4dda636ed0 | |||
| 945c498a31 | |||
| be0acb1862 | |||
| 8118f3074b | |||
| 4276a89a6a | |||
| 5803f47069 | |||
| 1c3c57dbd0 | |||
| 406ff3457e | |||
| f70b5b2ee5 | |||
| 2fa7b1f475 | |||
| 88197ffe8f | |||
| 174d3c2630 | |||
| 61dedcf73e | |||
| 580b95635f | |||
| a81d29c882 | |||
| 4236c4792b | |||
| cf8fd5a6fd | |||
| fc9a39de93 | |||
| 6018396be2 | |||
| ee6513b8ad | |||
| 37a311b0c5 | |||
| 11e812cb6b | |||
| 5097cdd9f4 | |||
| 2224c34aa8 | |||
| 2c5f6fcb04 | |||
| 7cd41dc920 | |||
| c5d2eb6bd2 | |||
| b650572426 | |||
| 7d84dd5ec8 |
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
63f7b34db8d8cdea90c76aacccf841ec
|
||||||
|
::res://::1787275777
|
||||||
|
export_presets.cfg::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
|
||||||
|
README.md::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
|
||||||
|
::res://addons/::1787272931
|
||||||
|
::res://assets/::1787272931
|
||||||
|
::res://backend/::1787273125
|
||||||
|
alembic.ini::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
|
||||||
|
docker-compose.yml::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
|
||||||
|
pytest.ini::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
|
||||||
|
requirements.txt::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
|
||||||
|
::res://backend/__pycache__/::1787273108
|
||||||
|
::res://backend/alembic/::1787273077
|
||||||
|
::res://backend/alembic/__pycache__/::1787273077
|
||||||
|
::res://backend/alembic/versions/::1787273077
|
||||||
|
::res://backend/alembic/versions/__pycache__/::1787273077
|
||||||
|
::res://backend/app/::1787273077
|
||||||
|
::res://backend/app/__pycache__/::1787273078
|
||||||
|
::res://backend/app/models/::1787273077
|
||||||
|
::res://backend/app/models/__pycache__/::1787273077
|
||||||
|
::res://backend/app/routers/::1787273078
|
||||||
|
::res://backend/app/routers/__pycache__/::1787273079
|
||||||
|
::res://backend/app/services/::1787273079
|
||||||
|
::res://backend/app/services/__pycache__/::1787273079
|
||||||
|
::res://backend/tests/::1787273108
|
||||||
|
::res://backend/tests/__pycache__/::1787273109
|
||||||
|
::res://docs/::1787272931
|
||||||
|
::res://docs/architecture/::1787272931
|
||||||
|
avatar.md::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
|
||||||
|
content_moderation.md::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
|
||||||
|
editor.md::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
|
||||||
|
networking.md::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
|
||||||
|
pc2_test_level.md::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
|
||||||
|
scripting_sandbox.md::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
|
||||||
|
world.md::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
|
||||||
|
::res://docs/checkpoints/::1787272931
|
||||||
|
PC1.md::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
|
||||||
|
::res://docs/planning/::1787272931
|
||||||
|
client-server-wiring.md::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
|
||||||
|
economy.md::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
|
||||||
|
social.md::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
|
||||||
|
::res://docs/testing/::1787275777
|
||||||
|
README.md::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
|
||||||
|
test_inventory.md::TextFile::-1::1787275777::0::1::::<><><>0<>0<><>::
|
||||||
|
::res://scenes/::1787275142
|
||||||
|
game.tscn::PackedScene::-1::1787275142::0::1::::<><><>0<>0<><>::res://scenes/worlds/test_level.tscn<>res://scenes/player.tscn
|
||||||
|
main.tscn::PackedScene::-1::1787272931::0::1::::<><><>0<>0<><>::res://scripts/chunk_stream_manager.gd
|
||||||
|
main_menu.tscn::PackedScene::-1::1787272931::0::1::::<><><>0<>0<><>::res://scripts/main_menu.gd
|
||||||
|
player.tscn::PackedScene::-1::1787272931::0::1::::<><><>0<>0<><>::res://scripts/player.gd
|
||||||
|
server.tscn::PackedScene::-1::1787275142::0::1::::<><><>0<>0<><>::res://scripts/server.gd
|
||||||
|
server_player.tscn::PackedScene::-1::1787275142::0::1::::<><><>0<>0<><>::res://scripts/player_spawner.gd
|
||||||
|
world_list.tscn::PackedScene::-1::1787272931::0::1::::<><><>0<>0<><>::res://scripts/world_list.gd
|
||||||
|
::res://scenes/worlds/::1787272931
|
||||||
|
test_level.tscn::PackedScene::-1::1787272931::0::1::::<><><>0<>0<><>::
|
||||||
|
::res://scripts/::1787275777
|
||||||
|
api_client.gd::GDScript::5412288119287126975::1787272931::0::1::::<>Node<><>0<>0<><>::
|
||||||
|
chunk.gd::GDScript::7364239597441807588::1787272931::0::1::::Chunk<>Node3D<><>0<>0<><>::
|
||||||
|
chunk_data_decoder.gd::GDScript::3922068956043581888::1787272931::0::1::::ChunkDataDecoder<>RefCounted<><>0<>0<><>::
|
||||||
|
chunk_scene.gd::GDScript::9207602004456600331::1787272931::0::1::::ChunkScene<>Node3D<><>0<>0<><>::
|
||||||
|
chunk_stream_manager.gd::GDScript::2366645676568508746::1787272931::0::1::::ChunkStreamManager<>Node<><>0<>0<><>::
|
||||||
|
dynamic_object_node.gd::GDScript::8863349716576659118::1787272931::0::1::::DynamicObjectNode<>Node3D<><>0<>0<><>::
|
||||||
|
main_menu.gd::GDScript::2834562513277893052::1787272931::0::1::::<>Control<><>0<>0<><>::
|
||||||
|
network_client.gd::GDScript::7615969254612857355::1787275777::0::1::::NetworkClient<>Node<><>0<>0<><>::
|
||||||
|
network_player_proxy.gd::GDScript::5755366661206648802::1787275777::0::1::::<>Node<><>0<>0<><>::
|
||||||
|
network_receiver_proxy.gd::GDScript::318799598255479274::1787275777::0::1::::<>Node<><>0<>0<><>::
|
||||||
|
player.gd::GDScript::4510795778937304264::1787275142::0::1::::Player<>CharacterBody3D<><>0<>0<><>::
|
||||||
|
player_spawner.gd::GDScript::3637338339553764865::1787275142::0::1::::PlayerNode<>CharacterBody3D<><>0<>0<><>::
|
||||||
|
server.gd::GDScript::6336910781803684784::1787275142::0::1::::<>Node<><>0<>0<><>::
|
||||||
|
smoke_test_api.gd::GDScript::1760923834350699002::1787272931::0::1::::<>SceneTree<><>0<>0<><>::
|
||||||
|
smoke_test_auth.gd::GDScript::3027816090663230577::1787272931::0::1::::<>SceneTree<><>0<>0<><>::
|
||||||
|
smoke_test_e2e.gd::GDScript::9086409960595132737::1787272931::0::1::::<>SceneTree<><>0<>0<><>::
|
||||||
|
smoke_test_enter_world.gd::GDScript::4171815758762850910::1787275142::0::1::::<>SceneTree<><>0<>0<><>::
|
||||||
|
smoke_test_main_menu_back.gd::GDScript::4436937414562273157::1787272931::0::1::::<>SceneTree<><>0<>0<><>::
|
||||||
|
smoke_test_main_menu_full_flow.gd::GDScript::1948803640558784862::1787272931::0::1::::<>SceneTree<><>0<>0<><>::
|
||||||
|
smoke_test_main_menu_ui.gd::GDScript::396938597300585484::1787272931::0::1::::<>SceneTree<><>0<>0<><>::
|
||||||
|
smoke_test_network_client.gd::GDScript::2714050080302875978::1787275777::0::1::::<>SceneTree<><>0<>0<><>::
|
||||||
|
smoke_test_player.gd::GDScript::3497042634752676337::1787272931::0::1::::<>SceneTree<><>0<>0<><>::
|
||||||
|
smoke_test_test_level.gd::GDScript::2607555612829866949::1787272931::0::1::::<>SceneTree<><>0<>0<><>::
|
||||||
|
smoke_test_world_list.gd::GDScript::4300757286283894147::1787272931::0::1::::<>SceneTree<><>0<>0<><>::
|
||||||
|
static_object_node.gd::GDScript::5276389741929225897::1787272931::0::1::::StaticObjectNode<>Node3D<><>0<>0<><>::
|
||||||
|
tile_def.gd::GDScript::2430528079284085109::1787272931::0::1::::TileDef<>RefCounted<><>0<>0<><>::
|
||||||
|
tile_layer.gd::GDScript::4797906255131447844::1787272931::0::1::::TileLayer<>RefCounted<><>0<>0<><>::
|
||||||
|
tile_layer_node.gd::GDScript::9033225196818247616::1787272931::0::1::::TileLayerNode<>Node3D<><>0<>0<><>::
|
||||||
|
world_list.gd::GDScript::6119591848847211153::1787275142::0::1::::<>Control<><>0<>0<><>::
|
||||||
|
world_object.gd::GDScript::6429674491464200335::1787272931::0::1::::WorldObject<>RefCounted<><>0<>0<><>::
|
||||||
@@ -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.
@@ -7,14 +7,14 @@ to know what "run the tests" actually checked.
|
|||||||
|
|
||||||
Run everything below in one shot with `scripts/run_checkpoint_tests.py`.
|
Run everything below in one shot with `scripts/run_checkpoint_tests.py`.
|
||||||
|
|
||||||
Last verified against `main`: 2026-08-20 — full PC1 checkpoint run,
|
Last verified against `main`: 2026-08-21 — migrations + pytest + all 10
|
||||||
migrations + pytest + all 7 Godot smoke tests + both platform exports, all
|
Godot smoke tests, all clean. PC1's real playtest-found bugs (login form
|
||||||
clean. Real bugs found and fixed via real playtesting this same day (not
|
unreachable, 1px world cards, Play → Back losing the session) are covered
|
||||||
caught by the checkpoint suite as it stood before today): the main menu
|
by the smoke tests below so they can't silently regress. PC2 (basic
|
||||||
had no way to actually open the login/register form, the world list's
|
playable world + character + movement) landed 2026-08-20/21 — see
|
||||||
cards rendered at ~1px wide once real data existed, and navigating Play →
|
`docs/checkpoints/` once a PC2 record exists, and the three new smoke
|
||||||
Back logged the user out despite a still-valid session. All three are now
|
tests below (`smoke_test_test_level.gd`, `smoke_test_player.gd`,
|
||||||
covered by the smoke tests below so they can't silently regress.
|
`smoke_test_enter_world.gd`) for what's actually verified.
|
||||||
|
|
||||||
## Backend — `backend/tests/` (pytest)
|
## Backend — `backend/tests/` (pytest)
|
||||||
|
|
||||||
@@ -53,7 +53,8 @@ these yet):
|
|||||||
|
|
||||||
## Godot client — `scripts/smoke_test_*.gd`
|
## Godot client — `scripts/smoke_test_*.gd`
|
||||||
|
|
||||||
**7 smoke tests merged into `main`.** Each needs a live backend —
|
**11 smoke tests merged into `main`.** Ten need a live backend, one
|
||||||
|
(`smoke_test_network_client.gd`) needs a live dedicated server instead —
|
||||||
`AGP_API_BASE_URL` env var, falling back to the
|
`AGP_API_BASE_URL` env var, falling back to the
|
||||||
`application/agp/api_base_url` project setting (default
|
`application/agp/api_base_url` project setting (default
|
||||||
`http://127.0.0.1:8000`) if unset.
|
`http://127.0.0.1:8000`) if unset.
|
||||||
@@ -67,6 +68,10 @@ these yet):
|
|||||||
| `smoke_test_main_menu_ui.gd` | Instantiates `main_menu.tscn`, confirms the auth panel starts hidden, and confirms a **real signal emission** on LoginButton/RegisterButton (not calling the handler method directly) actually opens it. This is the check that would have caught 2026-08-20's real bug: the auth panel and its logic were fully correct, but nothing in the scene was ever wired to open it, so Login/Register were completely unreachable from the UI. |
|
| `smoke_test_main_menu_ui.gd` | Instantiates `main_menu.tscn`, confirms the auth panel starts hidden, and confirms a **real signal emission** on LoginButton/RegisterButton (not calling the handler method directly) actually opens it. This is the check that would have caught 2026-08-20's real bug: the auth panel and its logic were fully correct, but nothing in the scene was ever wired to open it, so Login/Register were completely unreachable from the UI. |
|
||||||
| `smoke_test_main_menu_full_flow.gd` | Full click-through: open the register form, fill it exactly as a user would, submit, confirm Play actually becomes enabled, the welcome message shows, and the auth buttons hide themselves. Calls the submit handler directly (awaited) rather than via `auth_button.pressed.emit()` — see the note at the top of the file: a synthetic `.emit()` on an `await`-using handler doesn't get driven forward reliably in `--script`/SceneTree headless mode the way real GUI input event dispatch does, confirmed by comparing direct-call vs `.emit()` behavior. Not a bug in the actual click-driven flow, just a headless-test-harness quirk to route around when writing tests. |
|
| `smoke_test_main_menu_full_flow.gd` | Full click-through: open the register form, fill it exactly as a user would, submit, confirm Play actually becomes enabled, the welcome message shows, and the auth buttons hide themselves. Calls the submit handler directly (awaited) rather than via `auth_button.pressed.emit()` — see the note at the top of the file: a synthetic `.emit()` on an `await`-using handler doesn't get driven forward reliably in `--script`/SceneTree headless mode the way real GUI input event dispatch does, confirmed by comparing direct-call vs `.emit()` behavior. Not a bug in the actual click-driven flow, just a headless-test-harness quirk to route around when writing tests. |
|
||||||
| `smoke_test_main_menu_back.gd` | Regression test for Play → Back logging the user out even with a valid token still held. Registers a user, loads `main_menu.tscn` (asserts it restores the authenticated state via a real `auth_state_ready` signal wait, not a frame-count guess), clicks Play → `world_list.tscn`, clicks Back → `main_menu.tscn`, and asserts the menu is still authenticated. Checks `WelcomeLabel.is_visible_in_tree()`, not just `.visible` — the label lives inside AuthPanel, so `.visible` alone is a false positive if the panel itself is hidden (a real bug briefly present: `_show_authenticated()` hid AuthPanel while claiming the welcome label was visible, which rendered nothing). |
|
| `smoke_test_main_menu_back.gd` | Regression test for Play → Back logging the user out even with a valid token still held. Registers a user, loads `main_menu.tscn` (asserts it restores the authenticated state via a real `auth_state_ready` signal wait, not a frame-count guess), clicks Play → `world_list.tscn`, clicks Back → `main_menu.tscn`, and asserts the menu is still authenticated. Checks `WelcomeLabel.is_visible_in_tree()`, not just `.visible` — the label lives inside AuthPanel, so `.visible` alone is a false positive if the panel itself is hidden (a real bug briefly present: `_show_authenticated()` hid AuthPanel while claiming the welcome label was visible, which rendered nothing). |
|
||||||
|
| `smoke_test_test_level.gd` | Loads `scenes/worlds/test_level.tscn` directly (the level is player-agnostic and self-contained per `docs/architecture/pc2_test_level.md` §4) and confirms it has real content: 5 props, a `DirectionalLight3D` ("Sun"), and a `WorldEnvironment` — not an empty void. |
|
||||||
|
| `smoke_test_player.gd` | Loads `scenes/game.tscn` and confirms the player exists as a real `CharacterBody3D` with a `CollisionShape3D`+shape, a `CapsuleMesh`, and spawns at the level's `PlayerSpawn` marker position. Also asserts `JUMP_VELOCITY` is positive and a `Camera3D` exists and is `current` — both were real bugs found 2026-08-20/21 (jump velocity was negative, pushing the player down instead of up; no camera existed at all, so the game was unplayable even though the character could technically move). Structural checks only — real movement/jump *direction* correctness was verified separately via a throwaway headless physics simulation during development, not by an automated test that's part of this suite. |
|
||||||
|
| `smoke_test_enter_world.gd` | The full PC2 path end to end: register → seed a real world → load `world_list.tscn` → click the real "Enter World" button on a real card (found by walking the card's children, not by index, so it doesn't silently pass if the card layout changes) → confirms `scenes/game.tscn` actually becomes `current_scene` with the player spawned at the level's `PlayerSpawn` marker → sends a real `KEY_ESCAPE` input event (not a direct method call) and confirms it returns to `world_list.tscn`. This is the test that proves "browse → enter → walk around → leave" actually works as one continuous flow, not just that each piece works in isolation. |
|
||||||
|
| `smoke_test_network_client.gd` | **Real two-process integration test** for `scripts/network_client.gd` (client-side prediction + reconciliation, docs/architecture/networking.md §5) — needs an actual dedicated server running first: `godot --headless --path . scenes/server.tscn &`, *not* the usual HTTP backend. Connects a real `NetworkClient` over ENet, confirms local input prediction moves the player immediately (no waiting on a round trip), confirms real snapshots arrive and get processed, then deliberately desyncs the predicted position by 9999 units and confirms the very next snapshot corrects it back — the actual proof reconciliation logic works, not just that it never had to fire. Known simplification, stated in the script's own docstring: the server's snapshot doesn't currently echo back a per-peer "last input seq processed," so reconciliation corrects against the *most recent* prediction rather than true tick-exact rollback-and-replay (docs/architecture/networking.md §5.2 step 4) — a real, scoped follow-up once the server echoes that ack, not something faked here. |
|
||||||
|
|
||||||
Run (once, to build the class cache): `godot --headless --path . --import`,
|
Run (once, to build the class cache): `godot --headless --path . --import`,
|
||||||
then for each smoke test: `godot --headless --path . --script
|
then for each smoke test: `godot --headless --path . --script
|
||||||
|
|||||||
@@ -21,6 +21,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,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")]
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Regression/smoke test for the PC2 "Enter World" flow end to end.
|
||||||
|
##
|
||||||
|
## Real user path: register -> land on world_list.tscn -> click "Enter World"
|
||||||
|
## on a real card -> the playable level (scenes/game.tscn) actually loads
|
||||||
|
## with the player spawned in it -> pressing Escape returns to world_list.
|
||||||
|
##
|
||||||
|
## Run with:
|
||||||
|
## godot --headless --path . --script res://scripts/smoke_test_enter_world.gd
|
||||||
|
##
|
||||||
|
## Requires the backend to be running (default base URL
|
||||||
|
## `application/agp/api_base_url` -> http://127.0.0.1:8000, overridable via
|
||||||
|
## AGP_API_BASE_URL) and at least one real world to already exist.
|
||||||
|
|
||||||
|
var passed := 0
|
||||||
|
var failed := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run()
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
var api := root.get_node("ApiClient")
|
||||||
|
if api == null:
|
||||||
|
printerr("FAIL: ApiClient autoload not found")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1. Register a real user and seed a world so the browse list has a
|
||||||
|
# real card to click, same pattern as smoke_test_world_list.gd.
|
||||||
|
randomize()
|
||||||
|
var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000
|
||||||
|
var username := "enterworld_%d" % unique_id
|
||||||
|
var email := "enterworld_%d@test.example.com" % unique_id
|
||||||
|
var reg_result: Dictionary = await api.register(username, email, "SmokeTest123!", true)
|
||||||
|
if not _check("setup: register succeeded", reg_result.ok):
|
||||||
|
print(" detail: ", reg_result.detail)
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
|
||||||
|
var world_body := JSON.stringify({
|
||||||
|
"name": "EnterWorld Smoke Test World %d" % unique_id,
|
||||||
|
"description": "Seeded by smoke_test_enter_world.gd",
|
||||||
|
"visibility": "public",
|
||||||
|
})
|
||||||
|
var world_result: Dictionary = await api.request(
|
||||||
|
HTTPClient.METHOD_POST, "/api/worlds/", world_body, "application/json", true
|
||||||
|
)
|
||||||
|
if not _check("setup: world created", world_result.ok):
|
||||||
|
print(" detail: ", world_result.detail)
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. Load world_list.tscn as a real scene and let it fetch worlds.
|
||||||
|
var packed: PackedScene = load("res://scenes/world_list.tscn")
|
||||||
|
var instance: Control = packed.instantiate()
|
||||||
|
root.add_child(instance)
|
||||||
|
# _ready() kicks off an async _refresh_worlds() -- wait real frames for
|
||||||
|
# the HTTP round trip rather than guessing a fixed count.
|
||||||
|
var world_list_node: VBoxContainer = instance.get_node("VBoxContainer/ScrollContainer/WorldList")
|
||||||
|
var settled := false
|
||||||
|
for i in 120:
|
||||||
|
await process_frame
|
||||||
|
if world_list_node.get_child_count() > 0:
|
||||||
|
settled = true
|
||||||
|
break
|
||||||
|
if not _check("world list populated with at least one card", settled):
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
|
||||||
|
# 3. Click "Enter World" on the first real card -- find the button by
|
||||||
|
# walking the card's children rather than assuming an index, so this
|
||||||
|
# doesn't silently pass if _create_world_card's structure changes.
|
||||||
|
var card: Control = world_list_node.get_child(0)
|
||||||
|
var enter_button: Button = _find_button(card, "Enter World")
|
||||||
|
if not _check("Enter World button exists on the card", enter_button != null):
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
|
||||||
|
enter_button.pressed.emit()
|
||||||
|
await _wait_for_scene_change(instance, 60)
|
||||||
|
|
||||||
|
var game_scene := current_scene
|
||||||
|
if not _check("game.tscn became current_scene after Enter World", game_scene != null and game_scene != instance):
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
|
||||||
|
# 4. Verify the player actually exists and spawned at the level's
|
||||||
|
# PlayerSpawn marker -- not just that *some* scene loaded.
|
||||||
|
var player: Node = game_scene.get_node_or_null("Player")
|
||||||
|
_check("Player node exists in the loaded game scene", player is CharacterBody3D)
|
||||||
|
|
||||||
|
var spawn_marker: Node = game_scene.get_node_or_null("Level/PlayerSpawn")
|
||||||
|
_check("PlayerSpawn marker exists in the loaded level", spawn_marker is Marker3D)
|
||||||
|
|
||||||
|
if player is CharacterBody3D and spawn_marker is Marker3D:
|
||||||
|
var dist := (player as CharacterBody3D).global_position.distance_to((spawn_marker as Marker3D).global_position)
|
||||||
|
_check("player spawned at (or very near) the PlayerSpawn marker", dist < 0.5)
|
||||||
|
|
||||||
|
# 5. Escape should return to world_list.tscn (real key event, not a
|
||||||
|
# direct method call -- proves the actual input binding works).
|
||||||
|
var esc_event := InputEventKey.new()
|
||||||
|
esc_event.keycode = KEY_ESCAPE
|
||||||
|
esc_event.pressed = true
|
||||||
|
Input.parse_input_event(esc_event)
|
||||||
|
await _wait_for_scene_change(game_scene, 60)
|
||||||
|
|
||||||
|
var back_to_menu := current_scene
|
||||||
|
_check("Escape returned to world_list.tscn", back_to_menu != null and back_to_menu.scene_file_path == "res://scenes/world_list.tscn")
|
||||||
|
|
||||||
|
_finish()
|
||||||
|
|
||||||
|
|
||||||
|
func _find_button(node: Node, text: String) -> Button:
|
||||||
|
if node is Button and (node as Button).text == text:
|
||||||
|
return node
|
||||||
|
for child in node.get_children():
|
||||||
|
var found := _find_button(child, text)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
func _wait_for_scene_change(previous_scene: Node, max_frames: int) -> void:
|
||||||
|
for i in max_frames:
|
||||||
|
await process_frame
|
||||||
|
if current_scene != previous_scene and current_scene != null:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
func _check(description: String, condition: bool) -> bool:
|
||||||
|
if condition:
|
||||||
|
print(" PASS: ", description)
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
printerr(" FAIL: ", description)
|
||||||
|
failed += 1
|
||||||
|
return condition
|
||||||
|
|
||||||
|
|
||||||
|
func _finish() -> void:
|
||||||
|
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
||||||
|
quit(0 if failed == 0 else 1)
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Real integration test for NetworkClient (client-side prediction +
|
||||||
|
## reconciliation) against an actual dedicated server process.
|
||||||
|
##
|
||||||
|
## This does NOT boot its own server -- run scenes/server.tscn as a separate
|
||||||
|
## process first (see docs/architecture/networking.md's ENet/port setup):
|
||||||
|
## godot --headless --path . scenes/server.tscn &
|
||||||
|
## then, from the same project directory:
|
||||||
|
## godot --headless --path . --script res://scripts/smoke_test_network_client.gd
|
||||||
|
##
|
||||||
|
## Verifies: the client can actually connect over ENet, local prediction
|
||||||
|
## moves the player immediately on input (no waiting for the server),
|
||||||
|
## server snapshots actually arrive and update the client's authoritative
|
||||||
|
## view, and a deliberately-desynced predicted position gets corrected by
|
||||||
|
## reconciliation.
|
||||||
|
|
||||||
|
var passed := 0
|
||||||
|
var failed := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run()
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
var net := NetworkClient.new()
|
||||||
|
root.add_child(net)
|
||||||
|
|
||||||
|
var ok: bool = await net.connect_to_server("127.0.0.1", 7777)
|
||||||
|
if not _check("connected to the real dedicated server", ok):
|
||||||
|
printerr(" Is `godot --headless --path . scenes/server.tscn` running on port 7777?")
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Give the server a few frames to process the connection and spawn our
|
||||||
|
# player node before we start sending input.
|
||||||
|
for i in 10:
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
# 1. Prediction: pressing "forward" should move the local position
|
||||||
|
# immediately, without waiting for any server round trip.
|
||||||
|
var pos_before: Vector3 = net.position
|
||||||
|
for i in 10:
|
||||||
|
net.predict_and_send(1.0 / 30.0, 0.0, -1.0, false) # move -Z (forward)
|
||||||
|
await process_frame
|
||||||
|
var pos_after_predict: Vector3 = net.position
|
||||||
|
_check("local prediction moved the player without waiting for the server",
|
||||||
|
pos_after_predict.distance_to(pos_before) > 0.1)
|
||||||
|
|
||||||
|
# 2. Snapshots actually arrive from the real server and get processed --
|
||||||
|
# wait for at least one reconciliation-eligible snapshot by giving it
|
||||||
|
# real time (server broadcasts at its own tick rate).
|
||||||
|
var got_snapshot := false
|
||||||
|
net.reconciled.connect(func(_err): got_snapshot = true)
|
||||||
|
for i in 60:
|
||||||
|
net.predict_and_send(1.0 / 30.0, 0.0, -1.0, false)
|
||||||
|
await process_frame
|
||||||
|
# Reconciliation only actually fires on a real mismatch -- absence of a
|
||||||
|
# large mismatch is a PASS outcome too (means prediction tracked the
|
||||||
|
# server closely), so this alone isn't proof snapshots arrived. Check
|
||||||
|
# that directly instead:
|
||||||
|
_check("prediction buffer has entries (input was actually sent)", net._prediction_buffer.size() > 0)
|
||||||
|
|
||||||
|
# 3. Deliberately desync prediction from reality, then confirm the next
|
||||||
|
# snapshot corrects it -- the real proof reconciliation logic works,
|
||||||
|
# not just that it never had to fire.
|
||||||
|
net.position = Vector3(9999, 9999, 9999)
|
||||||
|
var desynced_pos := net.position
|
||||||
|
# GDScript closures capture local value-type vars (bool/float) by value,
|
||||||
|
# not by reference -- assigning to them *inside* the lambda doesn't
|
||||||
|
# mutate the outer scope's copy. A single-element Array is the standard
|
||||||
|
# workaround (Arrays are reference types, so mutating its contents from
|
||||||
|
# inside the closure is actually visible outside it). Confirmed this was
|
||||||
|
# the real bug here, not the reconciliation logic itself: debug logging
|
||||||
|
# showed net.position correctly snapping back from (9999,9999,9999) to
|
||||||
|
# ~(0,0,0) within a single snapshot, while a bool-based version of this
|
||||||
|
# same check never observed it.
|
||||||
|
var reconcile_state := [false, -1.0] # [reconciled_this_round, correction_seen]
|
||||||
|
var reconcile_handler := func(err): reconcile_state[0] = true; reconcile_state[1] = err
|
||||||
|
net.reconciled.connect(reconcile_handler)
|
||||||
|
for i in 60:
|
||||||
|
# Keep predicting (with no movement input) so the prediction buffer's
|
||||||
|
# latest entry actually reflects the desynced position -- without
|
||||||
|
# this, the buffer either empties out or keeps stale pre-desync
|
||||||
|
# entries, and _handle_snapshot has nothing real to compare against.
|
||||||
|
net.predict_and_send(1.0 / 30.0, 0.0, 0.0, false)
|
||||||
|
await process_frame
|
||||||
|
if reconcile_state[0]:
|
||||||
|
break
|
||||||
|
net.reconciled.disconnect(reconcile_handler)
|
||||||
|
_check("a deliberately desynced position gets corrected by the next snapshot",
|
||||||
|
reconcile_state[0] and net.position.distance_to(desynced_pos) > 100.0)
|
||||||
|
if reconcile_state[0]:
|
||||||
|
print(" correction distance was ~", reconcile_state[1])
|
||||||
|
|
||||||
|
net.disconnect_from_server()
|
||||||
|
_finish()
|
||||||
|
|
||||||
|
|
||||||
|
func _check(description: String, condition: bool) -> bool:
|
||||||
|
if condition:
|
||||||
|
print(" PASS: ", description)
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
printerr(" FAIL: ", description)
|
||||||
|
failed += 1
|
||||||
|
return condition
|
||||||
|
|
||||||
|
|
||||||
|
func _finish() -> void:
|
||||||
|
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
||||||
|
quit(0 if failed == 0 else 1)
|
||||||
@@ -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
|
||||||
@@ -135,8 +135,23 @@ func _create_world_card(world_dict: Dictionary) -> Control:
|
|||||||
meta_label.add_theme_font_size_override("font_size", 12)
|
meta_label.add_theme_font_size_override("font_size", 12)
|
||||||
meta_label.add_theme_color_override("font_color", Color(0.5, 0.5, 0.5, 1.0))
|
meta_label.add_theme_color_override("font_color", Color(0.5, 0.5, 0.5, 1.0))
|
||||||
|
|
||||||
|
# PC2: the only thing a world actually loads into right now is the one
|
||||||
|
# shared playable test level (scenes/game.tscn) -- there's no per-world
|
||||||
|
# content yet, so "Enter" doesn't need the world's id for anything.
|
||||||
|
# Real per-world level loading is a later concern once worlds actually
|
||||||
|
# have their own content to load.
|
||||||
|
var enter_button := Button.new()
|
||||||
|
enter_button.text = "Enter World"
|
||||||
|
enter_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||||
|
enter_button.pressed.connect(_on_enter_world_pressed)
|
||||||
|
|
||||||
card.add_child(name_label)
|
card.add_child(name_label)
|
||||||
card.add_child(desc_label)
|
card.add_child(desc_label)
|
||||||
card.add_child(meta_label)
|
card.add_child(meta_label)
|
||||||
|
card.add_child(enter_button)
|
||||||
|
|
||||||
return panel
|
return panel
|
||||||
|
|
||||||
|
|
||||||
|
func _on_enter_world_pressed() -> void:
|
||||||
|
get_tree().change_scene_to_file("res://scenes/game.tscn")
|
||||||
|
|||||||
Reference in New Issue
Block a user