Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e33b91d976 | |||
| fdc43bd5f1 | |||
| 3f7acf857c | |||
| 6648b2bba5 | |||
| 7f753cb1b8 | |||
| 66adcff145 | |||
| 2ffcd9cd3f |
@@ -0,0 +1,194 @@
|
|||||||
|
# PC2 Test Level & Player Spawn — Design Note
|
||||||
|
|
||||||
|
> **Status:** Decided (PC2)
|
||||||
|
> **Owner:** Architecture
|
||||||
|
> **Consumers:** the two PC2 implementation tasks — (1) build the test level,
|
||||||
|
> (2) build the player & spawn it. Both must be able to follow this note
|
||||||
|
> without further design decisions.
|
||||||
|
|
||||||
|
## 1. Decision: static scene, not chunk streaming
|
||||||
|
|
||||||
|
**The PC2 playable test level is a single, hand-authored, static Godot 3D
|
||||||
|
scene.** It is **not** built through the chunk streaming system
|
||||||
|
(`scripts/chunk_stream_manager.gd`, `chunk_scene.gd`, `chunk.gd`,
|
||||||
|
`chunk_data_decoder.gd`).
|
||||||
|
|
||||||
|
This is a deliberate decision, not a default:
|
||||||
|
|
||||||
|
- **The streaming system has never been exercised with real content.** It is a
|
||||||
|
faithful port of the `docs/architecture/world.md` format spec, but nothing in
|
||||||
|
the project produces or serves chunk payloads for it to consume. `scenes/main.tscn`
|
||||||
|
wires `chunk_stream_manager.gd` onto a bare `Node3D` with no `player` reference
|
||||||
|
and no `_server_rpc`, so at runtime it does nothing. There is no server-side
|
||||||
|
chunk authoring/streaming endpoint to integrate against.
|
||||||
|
- **PC2 scope is single-player, local-only, no networking.** The value of chunk
|
||||||
|
streaming is server-authoritative streaming around a moving player across a
|
||||||
|
network boundary. None of those preconditions exist in PC2, so exercising the
|
||||||
|
pipeline here would mean building a chunk *authoring + serving* path first —
|
||||||
|
clearly out of checkpoint scope.
|
||||||
|
- **A static scene is the lowest-risk way to get "spawn a player in a real 3D
|
||||||
|
level and walk around" working**, which is the actual PC2 outcome being tested.
|
||||||
|
|
||||||
|
Chunk streaming is **not removed and not broken by this decision** — it stays as
|
||||||
|
an optional, later milestone. The static level is the PC2 proof of the *player*
|
||||||
|
and *camera/physics* half of the problem; chunk streaming remains the later proof
|
||||||
|
of the *world data* half.
|
||||||
|
|
||||||
|
## 2. Where things live
|
||||||
|
|
||||||
|
| Artifact | Path | Owned by |
|
||||||
|
|---|---|---|
|
||||||
|
| Test level scene | `scenes/worlds/test_level.tscn` | level task |
|
||||||
|
| Player scene | `scenes/player.tscn` | player task |
|
||||||
|
| Player controller script | `scripts/player.gd` | player task |
|
||||||
|
| Game composition scene | `scenes/game.tscn` | player task |
|
||||||
|
|
||||||
|
- **`scenes/worlds/`** is the convention for all level scenes (it does not exist
|
||||||
|
yet; the level task creates it). Future levels follow `scenes/worlds/<name>.tscn`.
|
||||||
|
- `scenes/player.tscn` is **reusable** across levels — it must not hard-code any
|
||||||
|
reference to `test_level`; it discovers the spawn point at runtime (see §3).
|
||||||
|
|
||||||
|
## 3. Player spawn convention
|
||||||
|
|
||||||
|
The level scene **must** contain a `Marker3D` named exactly **`PlayerSpawn`**.
|
||||||
|
That node's world transform is the spawn point.
|
||||||
|
|
||||||
|
- `Marker3D` is a plain `Node3D` that shows a cross gizmo in the editor and
|
||||||
|
carries no gameplay behavior — it is the established Godot idiom for "position
|
||||||
|
hint." (Confirmed present in Godot 4.6.2.)
|
||||||
|
- The marker's **position** is authoritative; its rotation is ignored by the
|
||||||
|
player (spawn yaw is the player's concern, default forward is fine).
|
||||||
|
- Coordinate system: **Y-up, units = meters, 1 unit = 1 m.** The marker must sit
|
||||||
|
at least **1.0 m above the ground's top surface** (recommended `y = 1.0` where
|
||||||
|
the ground top is `y = 0`) so the player capsule does not spawn intersecting
|
||||||
|
the floor and get popped out by the physics solver.
|
||||||
|
- There is exactly **one** `PlayerSpawn` per level. (If a level ever needs
|
||||||
|
multiple spawns, that is a later convention — not PC2.)
|
||||||
|
|
||||||
|
**Lookup rule (implemented by `player.gd`, not by the level):**
|
||||||
|
|
||||||
|
```
|
||||||
|
# in player.gd _ready()
|
||||||
|
var spawn := get_tree().current_scene.find_child("PlayerSpawn", true, false)
|
||||||
|
if spawn is Node3D:
|
||||||
|
global_position = spawn.global_position
|
||||||
|
else:
|
||||||
|
push_warning("player.gd: no PlayerSpawn marker found; spawning at origin")
|
||||||
|
global_position = Vector3(0, 1.0, 0) # fallback, do not crash
|
||||||
|
```
|
||||||
|
|
||||||
|
The lookup runs from the current scene root so it finds the marker whether it is
|
||||||
|
a direct child of the level instance or nested under a `Level` wrapper. The
|
||||||
|
fallback keeps the player runnable in a scene that forgot its marker, but a
|
||||||
|
**missing `PlayerSpawn` is a level bug** and the level task's smoke test must fail
|
||||||
|
on it (see §6).
|
||||||
|
|
||||||
|
## 4. Composition (who puts level + player together)
|
||||||
|
|
||||||
|
`scenes/game.tscn` is the PC2 playable entry point:
|
||||||
|
|
||||||
|
```
|
||||||
|
Game (Node3D)
|
||||||
|
├── Level (instance of res://scenes/worlds/test_level.tscn)
|
||||||
|
└── Player (instance of res://scenes/player.tscn)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `Game` is a plain `Node3D` with **no script** — composition is done by scene
|
||||||
|
instantiation, not code, so there is nothing to keep in sync.
|
||||||
|
- The **level scene is self-contained and has zero dependency on the player** —
|
||||||
|
it loads and is smoke-testable on its own. The level task does not instance or
|
||||||
|
reference `player.tscn`.
|
||||||
|
- The **player scene is self-contained and has zero dependency on any specific
|
||||||
|
level** — it only requires that the *current scene* contains a `PlayerSpawn`.
|
||||||
|
- Ownership of `game.tscn` goes to the **player task**, which depends on the
|
||||||
|
level task having landed `test_level.tscn` first (chain with `depends_on`).
|
||||||
|
|
||||||
|
## 5. Level content requirements (level task)
|
||||||
|
|
||||||
|
`scenes/worlds/test_level.tscn`, root node `TestLevel` (`Node3D`). Required
|
||||||
|
children:
|
||||||
|
|
||||||
|
| Node | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `Ground` | `StaticBody3D` | `BoxShape3D` (e.g. `40 × 1 × 40`) centered at `(0, -0.5, 0)` so the **top face is `y = 0`**; matching `MeshInstance3D` (`BoxMesh`) so it is visible and collidable. Use a `StaticBody3D` rather than a bare `PlaneMesh` so collision is explicit and reliable. |
|
||||||
|
| `Props` | `Node3D` | 3–5 simple props as `StaticBody3D` + `MeshInstance3D` using built-in primitives only (`BoxMesh`, `CylinderMesh`, `SphereMesh`). `assets/` is empty and PC2 must not require imported content. Positioned on the ground surface; scale freely. |
|
||||||
|
| `Sun` | `DirectionalLight3D` | Pitch it downward (~ -45°), enable shadows if the compatibility renderer allows; shadows are optional. |
|
||||||
|
| `WorldEnvironment` | `WorldEnvironment` | Ambient light and/or a simple background color. Keep it plain — the project uses `gl_compatibility`. |
|
||||||
|
| `PlayerSpawn` | `Marker3D` | Per §3. Recommended `(0, 1.0, 0)`. |
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- **Do not** add any `script` to the level. It is pure content. Any per-level
|
||||||
|
behavior belongs to the player (or a future game-mode script), not the level.
|
||||||
|
- Keep the whole level on the XZ plane around the origin; nothing off at extreme
|
||||||
|
coordinates, no thin walls the capsule can tunnel through.
|
||||||
|
|
||||||
|
## 6. Player contract (player task)
|
||||||
|
|
||||||
|
`scenes/player.tscn`, root `Player` (`CharacterBody3D`) + `scripts/player.gd`.
|
||||||
|
|
||||||
|
Minimum for PC2:
|
||||||
|
|
||||||
|
- **Body:** `CharacterBody3D` with a `CollisionShape3D` using `CapsuleShape3D`
|
||||||
|
(~`radius 0.3`, ~`height 1.8`) and a matching `CapsuleMesh` for visibility.
|
||||||
|
- **Camera:** a `Camera3D` child. First-person (camera at capsule head height) is
|
||||||
|
the lowest-risk choice; third-person is fine too but is more work.
|
||||||
|
- **Spawn:** per §3 — read `PlayerSpawn` from the current scene, fall back
|
||||||
|
gracefully.
|
||||||
|
- **Movement:** gravity + `move_and_slide()` so the player stands on `Ground`.
|
||||||
|
WASD/arrow locomotion is the hard requirement. Mouse-look is recommended but
|
||||||
|
may be split into its own task if the controller grows.
|
||||||
|
- **No networking, no avatar UI, no chunk code** — PC2 is local-only. Do not add
|
||||||
|
`MultiplayerAPI`, `ChunkStreamManager`, or any avatar customization here.
|
||||||
|
|
||||||
|
The player must work when instanced under `Game` alongside a level; it must not
|
||||||
|
assume it is the only node in the scene.
|
||||||
|
|
||||||
|
## 7. Entry point / how a human plays it
|
||||||
|
|
||||||
|
- `scenes/game.tscn` is the PC2 playable scene. For playtesting, load it directly
|
||||||
|
(e.g. `godot --path . scenes/game.tscn`), or temporarily set
|
||||||
|
`application/run/main_scene` to it.
|
||||||
|
- **Wiring "Play → Join a world" in the existing `world_list` UI is out of scope
|
||||||
|
for this note** and out of scope for the level/player tasks. The current menu
|
||||||
|
flow ends at browse-worlds (PC1). If a checkpoint wants a clickable path into
|
||||||
|
the test level, that navigation wiring is its own small task — do not fold it
|
||||||
|
into level or player.
|
||||||
|
|
||||||
|
## 8. Validation (both tasks must run this before "done")
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# one-time, first checkout only — builds the class/import cache
|
||||||
|
godot --headless --path . --import
|
||||||
|
|
||||||
|
# then, to catch parse/load errors:
|
||||||
|
godot --headless --path . --quit
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Level task:** additionally load `test_level.tscn` headlessly and assert it
|
||||||
|
(a) loads without error and (b) contains a `Marker3D` named `PlayerSpawn`
|
||||||
|
positioned ≥ 1.0 m above the ground top. A smoke script following the existing
|
||||||
|
`scripts/smoke_test_*.gd` pattern is the right vehicle.
|
||||||
|
- **Player task:** a smoke test that instantiates `game.tscn`, waits a frame,
|
||||||
|
and asserts the player's global position equals the `PlayerSpawn` position
|
||||||
|
(within a small epsilon). This is the check that proves the spawn convention
|
||||||
|
actually wired through — not just "the scene loads."
|
||||||
|
|
||||||
|
## 9. Explicitly out of scope for PC2
|
||||||
|
|
||||||
|
- Networking / multiplayer / prediction (single-player, local-only).
|
||||||
|
- Chunk streaming integration and server-side chunk authoring/serving.
|
||||||
|
- Avatar customization UI and glTF avatar loading.
|
||||||
|
- Join-a-world navigation from the world list.
|
||||||
|
- Any content moderation / age-gating logic in the level.
|
||||||
|
|
||||||
|
## 10. Summary
|
||||||
|
|
||||||
|
| Concern | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Level construction | Static hand-authored Godot 3D scene (chunk streaming deferred, not removed) |
|
||||||
|
| Level location | `scenes/worlds/test_level.tscn` |
|
||||||
|
| Spawn convention | `Marker3D` named `PlayerSpawn`, ≥ 1.0 m above ground, looked up by the player |
|
||||||
|
| Player | `scenes/player.tscn` (`CharacterBody3D`) + `scripts/player.gd`, reusable across levels |
|
||||||
|
| Composition | `scenes/game.tscn` instances `Level` + `Player`; no script |
|
||||||
|
| Entry point | Load `scenes/game.tscn` directly; UI join-wiring deferred |
|
||||||
Binary file not shown.
@@ -0,0 +1,120 @@
|
|||||||
|
[gd_scene load_steps=1 format=3 uid="uid://c49a36fc7db_test_level"]
|
||||||
|
|
||||||
|
[sub_resource type="BoxShape3D" id="BoxShape3D_ground"]
|
||||||
|
size = Vector3(40, 1, 40)
|
||||||
|
|
||||||
|
[sub_resource type="BoxMesh" id="BoxMesh_ground"]
|
||||||
|
size = Vector3(40, 1, 40)
|
||||||
|
|
||||||
|
[sub_resource type="BoxShape3D" id="BoxShape3D_prop1"]
|
||||||
|
size = Vector3(2, 3, 2)
|
||||||
|
|
||||||
|
[sub_resource type="BoxMesh" id="BoxMesh_prop1"]
|
||||||
|
size = Vector3(2, 3, 2)
|
||||||
|
|
||||||
|
[sub_resource type="BoxShape3D" id="BoxShape3D_prop2"]
|
||||||
|
size = Vector3(3, 1, 3)
|
||||||
|
|
||||||
|
[sub_resource type="BoxMesh" id="BoxMesh_prop2"]
|
||||||
|
size = Vector3(3, 1, 3)
|
||||||
|
|
||||||
|
[sub_resource type="CylinderShape3D" id="CylinderShape3D_prop3"]
|
||||||
|
top_radius = 1.0
|
||||||
|
height = 4.0
|
||||||
|
|
||||||
|
[sub_resource type="CylinderMesh" id="CylinderMesh_prop3"]
|
||||||
|
top_radius = 1.0
|
||||||
|
bottom_radius = 1.0
|
||||||
|
height = 4.0
|
||||||
|
subdivisions_top_radius = 8
|
||||||
|
|
||||||
|
[sub_resource type="SphereShape3D" id="SphereShape3D_prop4"]
|
||||||
|
radius = 1.0
|
||||||
|
|
||||||
|
[sub_resource type="SphereMesh" id="SphereMesh_prop4"]
|
||||||
|
radius = 1.0
|
||||||
|
subdivisions_ring = 12
|
||||||
|
subdivisions_height = 12
|
||||||
|
|
||||||
|
[sub_resource type="SphereShape3D" id="SphereShape3D_prop5"]
|
||||||
|
radius = 0.5
|
||||||
|
|
||||||
|
[sub_resource type="SphereMesh" id="SphereMesh_prop5"]
|
||||||
|
radius = 0.5
|
||||||
|
subdivisions_ring = 8
|
||||||
|
subdivisions_height = 8
|
||||||
|
|
||||||
|
[sub_resource type="Environment" id="WorldEnvironment_env"]
|
||||||
|
background_mode = 1
|
||||||
|
background_color = Color(0.4, 0.6, 0.8, 1)
|
||||||
|
ambient_light_source = 1
|
||||||
|
ambient_light_energy = 0.4
|
||||||
|
ambient_light_sky_energy = 1.0
|
||||||
|
|
||||||
|
[node name="TestLevel" type="Node3D"]
|
||||||
|
|
||||||
|
[node name="Ground" type="StaticBody3D" parent="."]
|
||||||
|
position = Vector3(0, -0.5, 0)
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="Ground"]
|
||||||
|
shape = SubResource("BoxShape3D_ground")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="Ground"]
|
||||||
|
mesh = SubResource("BoxMesh_ground")
|
||||||
|
|
||||||
|
[node name="Props" type="Node3D" parent="."]
|
||||||
|
|
||||||
|
[node name="Prop1_Box" type="StaticBody3D" parent="Props"]
|
||||||
|
position = Vector3(8, 1.5, 8)
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop1_Box"]
|
||||||
|
shape = SubResource("BoxShape3D_prop1")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop1_Box"]
|
||||||
|
mesh = SubResource("BoxMesh_prop1")
|
||||||
|
|
||||||
|
[node name="Prop2_Pedestal" type="StaticBody3D" parent="Props"]
|
||||||
|
position = Vector3(-8, 0.5, 6)
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop2_Pedestal"]
|
||||||
|
shape = SubResource("BoxShape3D_prop2")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop2_Pedestal"]
|
||||||
|
mesh = SubResource("BoxMesh_prop2")
|
||||||
|
|
||||||
|
[node name="Prop3_Column" type="StaticBody3D" parent="Props"]
|
||||||
|
position = Vector3(6, 2.0, -8)
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop3_Column"]
|
||||||
|
shape = SubResource("CylinderShape3D_prop3")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop3_Column"]
|
||||||
|
mesh = SubResource("CylinderMesh_prop3")
|
||||||
|
|
||||||
|
[node name="Prop4_Sphere" type="StaticBody3D" parent="Props"]
|
||||||
|
position = Vector3(-6, 1.0, -6)
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop4_Sphere"]
|
||||||
|
shape = SubResource("SphereShape3D_prop4")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop4_Sphere"]
|
||||||
|
mesh = SubResource("SphereMesh_prop4")
|
||||||
|
|
||||||
|
[node name="Prop5_SmallSphere" type="StaticBody3D" parent="Props"]
|
||||||
|
position = Vector3(0, 0.5, 12)
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop5_SmallSphere"]
|
||||||
|
shape = SubResource("SphereShape3D_prop5")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop5_SmallSphere"]
|
||||||
|
mesh = SubResource("SphereMesh_prop5")
|
||||||
|
|
||||||
|
[node name="Sun" type="DirectionalLight3D" parent="."]
|
||||||
|
rotation = Vector3(-0.785398, 0, 0)
|
||||||
|
shadow_enabled = true
|
||||||
|
|
||||||
|
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
|
||||||
|
environment = SubResource("WorldEnvironment_env")
|
||||||
|
|
||||||
|
[node name="PlayerSpawn" type="Marker3D" parent="."]
|
||||||
|
position = Vector3(0, 1.5, 0)
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Headless smoke test for the PC2 test level scene.
|
||||||
|
##
|
||||||
|
## Run with:
|
||||||
|
## godot --headless --path . --script res://scripts/smoke_test_test_level.gd
|
||||||
|
##
|
||||||
|
## Loads the test level scene, asserts:
|
||||||
|
## (a) it loads without error,
|
||||||
|
## (b) it contains a Marker3D named "PlayerSpawn",
|
||||||
|
## (c) PlayerSpawn is positioned >= 1.0 m above the ground top surface.
|
||||||
|
## Exits 0 on success, 1 on failure.
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run()
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
var level_path: String = "res://scenes/worlds/test_level.tscn"
|
||||||
|
|
||||||
|
# Load the scene file
|
||||||
|
var level_resource: PackedScene = load(level_path)
|
||||||
|
if level_resource == null:
|
||||||
|
printerr("SMOKE RESULT: FAIL \u2014 could not load ", level_path)
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
print("SMOKE: test_level.tscn loaded successfully")
|
||||||
|
|
||||||
|
# Change the running scene to our test level
|
||||||
|
var err: int = change_scene_to_packed(level_resource)
|
||||||
|
if err != OK:
|
||||||
|
printerr("SMOKE RESULT: FAIL \u2014 change_scene_to_packed returned ", err)
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Wait for the scene_changed signal so current_scene is set
|
||||||
|
await scene_changed
|
||||||
|
|
||||||
|
var level_instance: Node3D = current_scene as Node3D
|
||||||
|
if level_instance == null:
|
||||||
|
printerr("SMOKE RESULT: FAIL \u2014 current_scene is not Node3D")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Give the tree one frame to fully initialize
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
# (b) Find the PlayerSpawn marker
|
||||||
|
var spawn_marker: Node3D = level_instance.find_child("PlayerSpawn", true, false) as Node3D
|
||||||
|
if spawn_marker == null:
|
||||||
|
printerr("SMOKE RESULT: FAIL \u2014 no Node3D named 'PlayerSpawn' found in test level")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
print("SMOKE: found PlayerSpawn at ", spawn_marker.global_position)
|
||||||
|
|
||||||
|
# (c) Verify PlayerSpawn is >= 1.0 m above ground top
|
||||||
|
# The ground top surface is at y = 0 (Ground StaticBody3D centered at y = -0.5
|
||||||
|
# with height 1.0, so top face is at y = 0).
|
||||||
|
var spawn_y: float = spawn_marker.global_position.y
|
||||||
|
if spawn_y < 1.0:
|
||||||
|
printerr("SMOKE RESULT: FAIL \u2014 PlayerSpawn y = ", spawn_y,
|
||||||
|
" is below the required minimum of 1.0 m above ground top (y=0)")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
print("SMOKE: PlayerSpawn y = ", spawn_y, " >= 1.0 m \u2014 PASS")
|
||||||
|
|
||||||
|
# Additional check: verify Ground StaticBody3D exists
|
||||||
|
var ground: StaticBody3D = level_instance.find_child("Ground", true, false) as StaticBody3D
|
||||||
|
if ground == null:
|
||||||
|
printerr("SMOKE RESULT: FAIL \u2014 no StaticBody3D named 'Ground' found")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
print("SMOKE: found Ground StaticBody3D")
|
||||||
|
|
||||||
|
# Verify Ground top face is at y = 0
|
||||||
|
var ground_shape: CollisionShape3D = ground.find_child("CollisionShape3D", true, false) as CollisionShape3D
|
||||||
|
if ground_shape != null and ground_shape.shape is BoxShape3D:
|
||||||
|
var box_shape: BoxShape3D = ground_shape.shape as BoxShape3D
|
||||||
|
var ground_top: float = ground.global_position.y + box_shape.size.y / 2.0
|
||||||
|
if abs(ground_top - 0.0) > 0.01:
|
||||||
|
printerr("SMOKE RESULT: FAIL \u2014 Ground top face at y = ", ground_top, " expected ~0.0")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
print("SMOKE: Ground top face at y = ", ground_top, " \u2014 PASS")
|
||||||
|
|
||||||
|
# Verify props exist (3-5 StaticBody3D children under Props node)
|
||||||
|
var props_node: Node3D = level_instance.find_child("Props", true, false) as Node3D
|
||||||
|
if props_node != null:
|
||||||
|
var prop_count: int = 0
|
||||||
|
for child in props_node.get_children():
|
||||||
|
if child is StaticBody3D:
|
||||||
|
prop_count += 1
|
||||||
|
if prop_count < 3:
|
||||||
|
printerr("SMOKE RESULT: FAIL \u2014 only ", prop_count, " props found, need >= 3")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
print("SMOKE: found ", prop_count, " props \u2014 PASS")
|
||||||
|
else:
|
||||||
|
printerr("SMOKE RESULT: FAIL \u2014 no 'Props' Node3D found")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Verify lighting exists
|
||||||
|
var sun: DirectionalLight3D = level_instance.find_child("Sun", true, false) as DirectionalLight3D
|
||||||
|
if sun == null:
|
||||||
|
printerr("SMOKE RESULT: FAIL \u2014 no DirectionalLight3D named 'Sun' found")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
print("SMOKE: found Sun DirectionalLight3D")
|
||||||
|
|
||||||
|
# Verify WorldEnvironment exists
|
||||||
|
var env: WorldEnvironment = level_instance.find_child("WorldEnvironment", true, false) as WorldEnvironment
|
||||||
|
if env == null:
|
||||||
|
printerr("SMOKE RESULT: FAIL \u2014 no WorldEnvironment named 'WorldEnvironment' found")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
print("SMOKE: found WorldEnvironment")
|
||||||
|
|
||||||
|
# All checks passed
|
||||||
|
print("SMOKE RESULT: PASS")
|
||||||
|
quit(0)
|
||||||
Reference in New Issue
Block a user