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