nodes/root.py¶
Part of Q1K3.
1"""Q1K3Root: the top-level scene node.
2
3Mounts:
4- A static-geometry parent for map blocks
5- An entities parent for player + enemies + projectiles + etc
6- A HUD overlay (Control)
7- A title screen, which owns pointer capture and the paused state
8- A WorldEnvironment for atmosphere
9- DirectionalLight3D ambient sun
10
11Hosts:
12- ``game_time`` (real time accumulator, mirrors upstream `game_time`)
13- ``world`` (MapData collision bitmap)
14- ``player`` (current Player Node3D)
15- ``has_key`` flag
16- Convenience helpers used by entities (spawn_particles, play_sfx, queue_remove)
17"""
18
19from __future__ import annotations
20
21import math
22
23from simvx.core import (
24 AudioClip,
25 AudioPlayer,
26 AudioPlayer3D,
27 DirectionalLight3D,
28 Input,
29 MouseCaptureMode,
30 Node,
31 Node3D,
32 PointerFilter,
33 Vec3,
34 WorldEnvironment,
35)
36
37from . import audio, maps, particle, textures
38from .door import Door
39from .enemy import Enforcer, Grunt, Hound, Ogre, Zombie
40from .hud import HUD
41from .light import TempLight
42from .menu import TitleScreen
43from .pickup import (
44 GrenadeLauncherPickup,
45 GrenadesPickup,
46 HealthPickup,
47 KeyPickup,
48 NailgunPickup,
49 NailsPickup,
50)
51from .player import Player, install_input_actions
52from .prop import Barrel, LevelTrigger, Torch
53from .touch import TouchControls
54from .world import CELL_X, CELL_Y, CELL_Z, MapData, add_block_node
55
56# Spawn tables keyed by the ``kind`` string used in maps.py. Enemies take a
57# patrol direction; props and pickups are constructed from ``(game, pos)`` alone.
58ENEMY_KINDS = {"grunt": Grunt, "enforcer": Enforcer, "ogre": Ogre, "zombie": Zombie, "hound": Hound}
59PROP_KINDS = {"torch": Torch, "level": LevelTrigger}
60PICKUP_KINDS = {
61 "health": HealthPickup,
62 "nailgun": NailgunPickup,
63 "grenadelauncher": GrenadeLauncherPickup,
64 "nails": NailsPickup,
65 "grenades": GrenadesPickup,
66 "key": KeyPickup,
67}
68
69# How long a one-shot spatial SFX node is kept alive before it is reaped.
70SFX_LIFETIME = 2.0
71
72
73def _entity_world_pos(coords: tuple[int, int, int]) -> Vec3:
74 """Convert (x, y, z) cell coords from `maps.py` to world units."""
75 x, y, z = coords
76 return Vec3(x * CELL_X + CELL_X / 2, y * CELL_Y + CELL_Y / 2, z * CELL_Z + CELL_Z / 2)
77
78
79class Q1K3Root(Node):
80 """Top-level scene: hosts world geometry, entities, HUD, environment."""
81
82 #: Set by the ``--test`` capture path to skip the title screen.
83 autostart = False
84
85 def on_ready(self) -> None:
86 # Input actions in root.on_ready: run_headless resets input state, so a
87 # --test mode loses anything registered before it.
88 install_input_actions()
89
90 # Pre-bake textures + SFX (avoids first-fire stutter)
91 textures.warm_all()
92 audio.warm_all()
93
94 # State
95 self.game_time: float = 0.0
96 self.has_key: bool = False
97 self._map_index: int = 0
98 self._jump_to_next_level: bool = False
99 self._dead_player_respawn_at: float | None = None
100
101 # Entity bookkeeping (mirrors upstream lists)
102 self._entities: list[Node3D] = []
103 self._enemies: list[Node3D] = []
104 self._friendlies: list[Node3D] = []
105 self._pending_remove: set[Node3D] = set()
106 # (reap_at, node) for the one-shot spatial SFX players, drained in on_update.
107 self._sfx_to_collect: list[tuple[float, AudioPlayer3D]] = []
108
109 # World environment + sun
110 self._env = WorldEnvironment()
111 self._env.bloom_enabled = True
112 self._env.bloom_threshold = 1.2
113 self._env.bloom_intensity = 0.3
114 self._env.tonemap_exposure = 1.0
115 self.add_child(self._env)
116
117 self._sun = DirectionalLight3D()
118 self._sun.colour = (1.0, 0.95, 0.85)
119 self._sun.intensity = 0.6
120 self._sun.position = Vec3(0, 200, -100)
121 self._sun.look_at(Vec3(0, 0, 0))
122 self.add_child(self._sun)
123
124 # Static geometry + entity containers
125 self._geometry_parent: Node3D = self.add_child(Node3D(name="Geometry"))
126 self._entities_parent: Node3D = self.add_child(Node3D(name="Entities"))
127
128 # SFX player nodes
129 self._sfx_player = AudioPlayer()
130 self.add_child(self._sfx_player)
131
132 # HUD. Passive overlay: it must never claim pointer events from the
133 # look drag or the on-screen controls.
134 self.hud = HUD(self)
135 self.hud.pointer_filter = PointerFilter.IGNORE
136 self.hud.visible = False # revealed by start_game
137 self.add_child(self.hud)
138
139 self.player: Player | None = None
140 self.touch: TouchControls | None = None
141
142 # Build map 1
143 self._init_map(0)
144
145 # Title screen last, so it sits on top of the HUD for hit-testing.
146 # The screenshot capture path skips it and drops straight into play.
147 self.menu = TitleScreen(self)
148 self.add_child(self.menu)
149 if self.autostart:
150 self.start_game(pointer_only=False)
151 else:
152 self.tree.paused = True
153
154 # ------------------------------------------------------------------
155 # Map building
156 # ------------------------------------------------------------------
157
158 def _init_map(self, idx: int) -> None:
159 # Tear down previous geometry/entities
160 for child in list(self._geometry_parent.children):
161 child.parent.remove_child(child)
162 for child in list(self._entities_parent.children):
163 child.parent.remove_child(child)
164 self._entities.clear()
165 self._enemies.clear()
166 self._friendlies.clear()
167 self._pending_remove.clear()
168 self._sfx_to_collect.clear()
169 self.player = None
170 self.has_key = False
171
172 m = maps.all_maps()[idx]
173 self.world = MapData()
174
175 for x, y, z, sx, sy, sz, tex_id in m["blocks"]:
176 add_block_node(self._geometry_parent, self.world, x, y, z, sx, sy, sz, tex_id)
177
178 for kind, coords, p1 in m["entities"]:
179 pos = _entity_world_pos(coords)
180 self._spawn(kind, pos, p1)
181
182 def _spawn(self, kind: str, pos: Vec3, p1: int) -> None:
183 if kind == "player":
184 # Player keeps `_yaw` in JS-frame (0 ↔ +Z forward); the camera
185 # sync adds π when applying the SimVX rotation. Matches upstream
186 # `this._yaw += game_map_index * Math.PI`.
187 self.player = Player(self, pos, yaw=p1 * math.pi)
188 self._register(self.player, friendly=True)
189 elif kind in ENEMY_KINDS:
190 self._register(ENEMY_KINDS[kind](self, pos, patrol_dir=p1), enemy=True)
191 elif kind == "barrel":
192 # Barrels take damage, so they live in the list projectiles test against.
193 self._register(Barrel(self, pos), enemy=True)
194 elif kind == "door":
195 needs_key = self._map_index == 0 and not self.has_key
196 self._register(Door(self, pos, yaw_dir=p1, needs_key=needs_key), enemy=True, friendly=True)
197 elif kind in PROP_KINDS:
198 self._register(PROP_KINDS[kind](self, pos))
199 elif kind in PICKUP_KINDS:
200 self._register(PICKUP_KINDS[kind](self, pos))
201
202 def _register(self, ent: Node3D, *, enemy: bool = False, friendly: bool = False) -> None:
203 """Mount *ent* and file it in the collision lists it belongs to."""
204 self.add_entity(ent)
205 if enemy:
206 self._enemies.append(ent)
207 if friendly:
208 self._friendlies.append(ent)
209
210 # ------------------------------------------------------------------
211 # Per-frame
212 # ------------------------------------------------------------------
213
214 def on_update(self, dt: float) -> None:
215 self.game_time += dt
216
217 if Input.just_pressed("menu"):
218 self.open_menu()
219 return
220
221 # Reap the one-shot spatial SFX nodes whose clip has finished.
222 if self._sfx_to_collect:
223 still_playing = []
224 for reap_at, spatial in self._sfx_to_collect:
225 if reap_at <= self.game_time:
226 self.queue_remove(spatial)
227 else:
228 still_playing.append((reap_at, spatial))
229 self._sfx_to_collect = still_playing
230
231 # Process pending removals (after entity update so we don't mutate the
232 # iteration list mid-frame; entities mark themselves dead via queue_remove).
233 if self._pending_remove:
234 for ent in list(self._pending_remove):
235 if ent in self._entities:
236 self._entities.remove(ent)
237 if ent in self._enemies:
238 self._enemies.remove(ent)
239 if ent in self._friendlies:
240 self._friendlies.remove(ent)
241 if ent.parent is not None:
242 ent.parent.remove_child(ent)
243 self._pending_remove.clear()
244
245 # Death respawn
246 if self._dead_player_respawn_at is not None and self.game_time >= self._dead_player_respawn_at:
247 self._dead_player_respawn_at = None
248 self._init_map(self._map_index)
249
250 if self._jump_to_next_level:
251 self._jump_to_next_level = False
252 self._map_index += 1
253 if self._map_index >= len(maps.all_maps()):
254 self.show_message("THE END: THANKS FOR PLAYING", duration=10.0)
255 self._map_index = 0
256 self._init_map(self._map_index)
257
258 # ------------------------------------------------------------------
259 # Entity helpers (called by Player / Enemy / Projectile / Pickup)
260 # ------------------------------------------------------------------
261
262 def add_entity(self, ent: Node3D) -> None:
263 self._entities_parent.add_child(ent)
264 self._entities.append(ent)
265
266 def queue_remove(self, ent: Node3D) -> None:
267 self._pending_remove.add(ent)
268
269 def enemies_list(self) -> list:
270 return self._enemies
271
272 def friendlies_list(self) -> list:
273 return self._friendlies
274
275 def entities_in_group(self, group: int) -> list:
276 if group == 1:
277 return self._friendlies
278 if group == 2:
279 return self._enemies
280 return []
281
282 def spawn_particles(self, pos: Vec3, count: int, speed: float, lifetime: float, tex_id: int) -> None:
283 particle.spawn_burst(self, pos, count, speed, lifetime, tex_id)
284
285 def spawn_temp_light(self, pos: Vec3, intensity: float, colour, duration: float) -> None:
286 tl = TempLight(self, pos, intensity, colour, duration)
287 self.add_entity(tl)
288
289 def add_dynamic_light(self, pos: Vec3, intensity: float, colour) -> None:
290 # Per-frame transient pulsing; rendered as a TempLight that lives 1 frame.
291 tl = TempLight(self, pos, intensity, colour, duration=0.05)
292 self.add_entity(tl)
293
294 def play_sfx(self, stream: AudioClip) -> None:
295 # No device guard needed: the engine falls back to a silent backend
296 # when no audio device exists, so play() is always safe.
297 self._sfx_player.stop()
298 self._sfx_player.stream = stream
299 self._sfx_player.play()
300
301 def play_sfx_at(self, stream: AudioClip, pos: Vec3) -> None:
302 # Spatial player: short-lived AudioPlayer3D node. Distance + pan
303 # are computed by the audio backend automatically when a Camera3D is
304 # the active listener. on_update reaps it once the clip has finished.
305 spatial = AudioPlayer3D(stream=stream)
306 spatial.position = pos
307 self._entities_parent.add_child(spatial)
308 spatial.play()
309 self._sfx_to_collect.append((self.game_time + SFX_LIFETIME, spatial))
310
311 def show_message(self, text: str, duration: float = 2.0) -> None:
312 if hasattr(self, "hud"):
313 self.hud.show_message(text, duration)
314
315 def next_level(self) -> None:
316 self._jump_to_next_level = True
317
318 def on_player_died(self) -> None:
319 # Auto-respawn after 2s
320 self._dead_player_respawn_at = self.game_time + 2.0
321 self.show_message("YOU DIED", duration=2.5)
322
323 # ------------------------------------------------------------------
324 # Menu / gameplay transitions
325 # ------------------------------------------------------------------
326
327 def start_game(self, *, pointer_only: bool) -> None:
328 """Leave the title screen. Capture the pointer, or show touch controls."""
329 self.menu.visible = False
330 self.hud.visible = True
331 self.hud.set_pointer_mode(pointer_only)
332 if pointer_only:
333 Input.set_mouse_capture_mode(MouseCaptureMode.VISIBLE)
334 if self.touch is None:
335 self.touch = TouchControls()
336 self.add_child(self.touch)
337 self.touch.visible = True
338 else:
339 if self.touch is not None:
340 self.touch.visible = False
341 Input.set_mouse_capture_mode(MouseCaptureMode.CAPTURED)
342 # The click that dismissed the menu must not also fire the weapon.
343 if self.player is not None:
344 self.player._can_shoot_at = self.game_time + 0.3
345 self.tree.paused = False
346
347 def open_menu(self) -> None:
348 """Pause, release the pointer, and put the title screen back up."""
349 Input.set_mouse_capture_mode(MouseCaptureMode.VISIBLE)
350 if self.touch is not None:
351 self.touch.visible = False
352 self.hud.visible = False
353 self.menu.visible = True
354 self.tree.paused = True
355
356 def quit_game(self) -> None:
357 Input.set_mouse_capture_mode(MouseCaptureMode.VISIBLE)
358 self.app.quit()