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