Compare commits

..

1 Commits

Author SHA1 Message Date
coder-bot b2131a4dd0 WIP checkpoint: Client-side player controller: movement + camera 2026-08-20 20:26:27 +00:00
28 changed files with 9 additions and 1966 deletions
-1
View File
@@ -1 +0,0 @@
-85
View File
@@ -1,85 +0,0 @@
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<><>::
-11
View File
@@ -1,11 +0,0 @@
[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"]
@@ -1,5 +0,0 @@
[folding]
node_unfolds=[]
resource_unfolds=[]
nodes_folded=[]
-105
View File
@@ -1,105 +0,0 @@
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.
-194
View File
@@ -1,194 +0,0 @@
# 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 |
+9 -14
View File
@@ -7,14 +7,14 @@ to know what "run the tests" actually checked.
Run everything below in one shot with `scripts/run_checkpoint_tests.py`.
Last verified against `main`: 2026-08-21 — migrations + pytest + all 10
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.
Last verified against `main`: 2026-08-20 — full PC1 checkpoint run,
migrations + pytest + all 7 Godot smoke tests + both platform exports, all
clean. Real bugs found and fixed via real playtesting this same day (not
caught by the checkpoint suite as it stood before today): the main menu
had no way to actually open the login/register form, the world list's
cards rendered at ~1px wide once real data existed, and navigating Play →
Back logged the user out despite a still-valid session. All three are now
covered by the smoke tests below so they can't silently regress.
## Backend — `backend/tests/` (pytest)
@@ -53,8 +53,7 @@ these yet):
## Godot client — `scripts/smoke_test_*.gd`
**11 smoke tests merged into `main`.** Ten need a live backend, one
(`smoke_test_network_client.gd`) needs a live dedicated server instead —
**7 smoke tests merged into `main`.** Each needs a live backend —
`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.
@@ -68,10 +67,6 @@ 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_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. |
Run (once, to build the class cache): `godot --headless --path . --import`,
then for each smoke test: `godot --headless --path . --script
BIN
View File
Binary file not shown.
-23
View File
@@ -21,29 +21,6 @@ window/size/viewport_width=1920
window/size/viewport_height=1080
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]
renderer/rendering_method="gl_compatibility"
-10
View File
@@ -1,10 +0,0 @@
[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")]
-24
View File
@@ -1,24 +0,0 @@
[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
-6
View File
@@ -1,6 +0,0 @@
[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")
-6
View File
@@ -1,6 +0,0 @@
[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")
-120
View File
@@ -1,120 +0,0 @@
[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)
-234
View File
@@ -1,234 +0,0 @@
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)
-43
View File
@@ -1,43 +0,0 @@
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
-38
View File
@@ -1,38 +0,0 @@
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
-146
View File
@@ -1,146 +0,0 @@
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()
-232
View File
@@ -1,232 +0,0 @@
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
-1
View File
@@ -1 +0,0 @@
uid://br27owjh2f5xd
-174
View File
@@ -1,174 +0,0 @@
## 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)
-1
View File
@@ -1 +0,0 @@
uid://cwjv826lva2gi
-146
View File
@@ -1,146 +0,0 @@
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)
-114
View File
@@ -1,114 +0,0 @@
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)
-93
View File
@@ -1,93 +0,0 @@
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
-125
View File
@@ -1,125 +0,0 @@
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)
-15
View File
@@ -135,23 +135,8 @@ func _create_world_card(world_dict: Dictionary) -> Control:
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))
# 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(desc_label)
card.add_child(meta_label)
card.add_child(enter_button)
return panel
func _on_enter_world_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/game.tscn")