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()