nodes/game.py¶

Part of Mr. Rescue.

  1"""Active gameplay scene: wires together grid, fires, player, civilians, enemies.
  2
  3Owns the camera-transformed world, plus its own CanvasLayer for the HUD so the
  4bars render in screen space and are created and destroyed with the scene.
  5
  6Phase transitions emitted via signals:
  7- ``victory``  : all civilians rescued, last fire put out
  8- ``failure``  : player died OR casualty cap reached
  9"""
 10
 11from __future__ import annotations
 12
 13import math
 14import random
 15
 16from simvx.core import Camera2D, CanvasLayer, Node, Node2D, Signal, Sprite2D, UpdateMode, Vec2
 17
 18from . import colours as C
 19from . import textures
 20from .audio import SfxBank
 21from .building import generate_layout, make_tile_grid
 22from .civilian import Civilian
 23from .enemy import FireBug
 24from .fire import FireGrid
 25from .hud import HUD, HUD_H, TOP_H
 26from .particles import Particles2D
 27from .player import Player
 28from .tile_grid import TILE_SIZE
 29
 30
 31class _FloatingText(Node2D):
 32    """World-space score popups that drift up and fade (e.g. '+250')."""
 33
 34    # ``on_draw`` recomputes each popup's rising ``y`` + fading alpha from the
 35    # non-Property ``_items`` list every tick, so it genuinely redraws every
 36    # frame: declare it dynamic so a camera scroll no longer re-triggers it.
 37    dynamic = True
 38
 39    def __init__(self, **kwargs):
 40        super().__init__(**kwargs)
 41        self._items: list[dict] = []
 42
 43    def add(self, text: str, pos: Vec2, colour, *, life: float = 0.9, rise: float = 36.0):
 44        self._items.append(
 45            {"text": text, "x": float(pos.x), "y": float(pos.y), "t": 0.0, "life": life, "rise": rise, "colour": colour}
 46        )
 47
 48    def on_update(self, dt: float):
 49        if dt <= 0:
 50            return
 51        for it in self._items:
 52            it["t"] += dt
 53        self._items = [it for it in self._items if it["t"] < it["life"]]
 54
 55    def on_draw(self, renderer):
 56        for it in self._items:
 57            f = it["t"] / it["life"]
 58            alpha = max(0.0, 1.0 - f)
 59            y = it["y"] - it["rise"] * f
 60            r, g, b = it["colour"][0], it["colour"][1], it["colour"][2]
 61            tw = renderer.text_width(it["text"], 1)
 62            renderer.draw_text(it["text"], (it["x"] - tw / 2, y), scale=1, colour=(r, g, b, alpha))
 63
 64
 65class GameScene(Node):
 66    """One section / building. Restart by replacing this scene."""
 67
 68    victory = Signal()
 69    failure = Signal(str)  # reason: "casualty" | "overheat"
 70    score_changed = Signal(int)
 71    civilian_rescued = Signal()
 72
 73    def __init__(
 74        self, *, viewport_w: int, viewport_h: int, section: int = 1, level: int = 1, seed: int | None = None, **kwargs
 75    ):
 76        super().__init__(**kwargs)
 77        self.viewport_w = viewport_w
 78        self.viewport_h = viewport_h
 79        self.section = section
 80        self.level = level
 81        self.score = 0
 82        self.casualties = 0
 83        self.max_casualties = max(1, 6 - level)
 84
 85        # Audio (procedural SFX + ambient bed); self-contained + crash-safe.
 86        self.sfx = self.add_child(SfxBank())
 87
 88        # Containers / world ----------------------------------------
 89        self._world = Node2D(name="World")
 90        self.add_child(self._world)
 91
 92        # Parallax night backdrop -- added first so it draws behind the building
 93        # and shows through the windows. Positioned each frame in on_update.
 94        self._bg_night = Sprite2D(texture=textures.get("night_backdrop"), width=1800, height=1300, filter="nearest")
 95        self._world.add_child(self._bg_night)
 96        self._bg_skyline = Sprite2D(texture=textures.get("skyline"), width=1800, height=380, filter="nearest")
 97        self._world.add_child(self._bg_skyline)
 98
 99        # Build the layout.
100        plan = generate_layout(section=section, seed=seed)
101        self.plan = plan
102        self.grid = make_tile_grid(plan)
103        self._world.add_child(self.grid)
104
105        # Fires ------------------------------------------------------
106        self.fires = FireGrid(grid=self.grid, section=section, seed=seed)
107        self.fires.seed(plan.fire_seeds)
108        self._world.add_child(self.fires)
109        self.initial_fire_count = self.fires.fire_count()
110
111        # Particles --------------------------------------------------
112        self.particles = Particles2D(capacity=600)
113        self._world.add_child(self.particles)
114
115        # Score popups (drawn above particles, below the HUD).
116        self._fx = self._world.add_child(_FloatingText())
117
118        # Player -----------------------------------------------------
119        self.player = Player(
120            position=Vec2(plan.player_start[0], plan.player_start[1]),
121            level=level,
122        )
123        self._world.add_child(self.player)
124
125        # Civilians --------------------------------------------------
126        self.civilians: list[Civilian] = []
127        for i, (cx, cy) in enumerate(plan.civilian_spawns):
128            civ = Civilian(position=Vec2(cx, cy), outfit=i % 4)
129            self._world.add_child(civ)
130            civ.set_world(grid=self.grid, fires=self.fires)
131            civ.rescued.connect(self._on_civ_rescued)
132            civ.died.connect(self._on_civ_died)
133            self.civilians.append(civ)
134        self.civilians_total = len(self.civilians)
135
136        # Enemies ----------------------------------------------------
137        self.enemies: list[FireBug] = []
138        for ex, ey in plan.enemy_spawns:
139            bug = FireBug(position=Vec2(ex, ey))
140            self._world.add_child(bug)
141            bug.set_world(grid=self.grid, fires=self.fires)
142            bug.killed.connect(self._on_enemy_killed)
143            bug.fire_spawned.connect(self._on_fire_spawned_by_enemy)
144            self.enemies.append(bug)
145
146        # Wire the player to the world it queries, then to the juice it drives.
147        self.player.set_world(
148            grid=self.grid,
149            civilians=self.civilians,
150            enemies=self.enemies,
151            fires=self.fires,
152        )
153        self.player.water_hit.connect(self._on_water_hit)
154        self.player.sprayed.connect(self._on_spray_tick)
155        self.player.wall_splashed.connect(self._on_wall_splash)
156        self.player.jumped.connect(self._on_jumped)
157        self.player.climbed.connect(self._on_climbed)
158        self.player.tank_emptied.connect(self._on_tank_emptied)
159        self.player.died.connect(self._on_player_died)
160
161        # Camera follows player ------------------------------------
162        self.camera = Camera2D(
163            position=Vec2(plan.player_start[0], plan.player_start[1]),
164            zoom=3.0,
165        )
166        self._world.add_child(self.camera)
167        # Note: we set camera.position manually each frame so we control
168        # clamping + screenshake. ``target`` is left None.
169
170        # HUD on its own CanvasLayer so it draws above the world in screen-space,
171        # created and destroyed together with this scene.
172        self._hud_layer = self.add_child(CanvasLayer(name="HUDLayer", layer=10))
173        self._hud = self._hud_layer.add_child(HUD())
174
175        self._screenshake_t = 0.0
176        self._screenshake_amp = 0.0
177        self._hitstop_t = 0.0
178        self._heat_flash = 0.0
179        self._t = 0.0
180
181    def on_ready(self):
182        self.sfx.start_music()
183
184    def _drive_hud(self):
185        p = self.player
186        self._hud.set_state(
187            water=p.water,
188            water_max=p.water_capacity,
189            overloaded=p.overloaded,
190            temperature=p.temperature,
191            max_temperature=p.max_temperature,
192            casualties=self.casualties,
193            max_casualties=self.max_casualties,
194            civilians_remaining=len(self.civilians),
195            civilians_total=self.civilians_total,
196            fires_remaining=self.fires.fire_count(),
197            section=self.section,
198            score=self.score,
199            is_dying=p.is_dying,
200            heat_flash=self._heat_flash,
201        )
202
203    def on_exit_tree(self):
204        self.sfx.stop_music()
205
206    # ----------------------------------------------------------- input feedback
207
208    def _on_jumped(self):
209        self._screenshake(0.05)
210        self.sfx.play("jump")
211
212    def _on_climbed(self):
213        self.sfx.play("climb")
214
215    def _on_tank_emptied(self):
216        self.sfx.play("overheat")
217
218    def _on_spray_tick(self, muzzle: Vec2, direction: Vec2):
219        # A couple of mist droplets streaming off the muzzle each frame.
220        ang = math.atan2(direction.y, direction.x)
221        self.particles.emit_burst(
222            (muzzle.x, muzzle.y),
223            count=2,
224            speed=120,
225            speed_var=40,
226            life=0.18,
227            scale0=1.6,
228            colour=(0.7, 0.9, 1.0, 0.8),
229            drag=5,
230            cone=0.35,
231            direction=ang,
232        )
233        self.sfx.set_spraying(True)
234
235    def _on_wall_splash(self, pos: Vec2, direction: Vec2):
236        # Splash back off the wall the beam hits.
237        ang = math.atan2(-direction.y, -direction.x)
238        self.particles.emit_burst(
239            (pos.x, pos.y),
240            count=5,
241            speed=90,
242            speed_var=30,
243            life=0.25,
244            scale0=2.0,
245            colour=(0.75, 0.92, 1.0, 0.9),
246            drag=6,
247            cone=0.8,
248            direction=ang,
249        )
250
251    # ----------------------------------------------------------- helpers
252
253    def _on_water_hit(self, pos: Vec2, kind: str):
254        # Splash particles
255        self.particles.emit_burst(
256            (pos.x, pos.y),
257            count=6,
258            speed=70,
259            speed_var=20,
260            life=0.25,
261            scale0=2.0,
262            colour=(0.7, 0.9, 1.0, 1.0),
263            drag=4,
264        )
265        if kind == "extinguish":
266            self.score += 20
267            self.score_changed.emit(self.score)
268            # White flash + smoke puff at the doused fire.
269            self.particles.emit_burst(
270                (pos.x, pos.y),
271                count=4,
272                speed=60,
273                speed_var=20,
274                life=0.12,
275                scale0=4.0,
276                colour=(1.0, 1.0, 0.95, 0.9),
277                drag=8,
278            )
279            self.particles.emit_burst(
280                (pos.x, pos.y),
281                count=10,
282                speed=40,
283                speed_var=10,
284                life=0.6,
285                scale0=3.0,
286                colour=(0.2, 0.2, 0.22, 0.7),
287                drag=2,
288            )
289            self.sfx.play("extinguish")
290
291    def _on_civ_rescued(self, civ):
292        self.score += 250
293        self.civilian_rescued.emit()
294        self.score_changed.emit(self.score)
295        if civ in self.civilians:
296            self.civilians.remove(civ)
297        # The win moment gets the biggest juice: gold sparkle + expanding ring.
298        self.particles.emit_burst(
299            (civ.position.x, civ.position.y - 12),
300            count=18,
301            speed=120,
302            speed_var=40,
303            life=0.6,
304            scale0=2.8,
305            colour=(1.0, 1.0, 0.5, 1.0),
306            drag=3,
307        )
308        self.particles.emit_burst(
309            (civ.position.x, civ.position.y - 12),
310            count=20,
311            speed=200,
312            speed_var=20,
313            life=0.5,
314            scale0=2.0,
315            colour=C.YELLOW,
316            drag=1,
317        )
318        self._fx.add("+250", Vec2(civ.position.x, civ.position.y - 24), C.YELLOW)
319        self.sfx.play("rescue")
320        self._hitstop_t = 0.09
321        self._screenshake(0.25, amp=4.0)
322        self._check_victory()
323
324    def _on_civ_died(self, civ):
325        self.casualties += 1
326        if civ in self.civilians:
327            self.civilians.remove(civ)
328        self.particles.emit_burst(
329            (civ.position.x, civ.position.y - 8),
330            count=20,
331            speed=70,
332            speed_var=30,
333            life=0.8,
334            scale0=3.0,
335            colour=(0.25, 0.22, 0.18, 0.9),
336            drag=2,
337        )
338        self._fx.add("LOST", Vec2(civ.position.x, civ.position.y - 20), C.RED)
339        self.sfx.play("civ_die")
340        self._hitstop_t = 0.06
341        self._screenshake(0.18, amp=2.5)
342        if self.casualties >= self.max_casualties:
343            self.failure.emit("casualty")
344
345    def _on_enemy_killed(self, enemy):
346        self.score += 100
347        self.score_changed.emit(self.score)
348        if enemy in self.enemies:
349            self.enemies.remove(enemy)
350        self._fx.add("+100", Vec2(enemy.position.x, enemy.position.y - 18), C.HUD_FG)
351        self.sfx.play("enemy_kill")
352        self.particles.emit_burst(
353            (enemy.position.x, enemy.position.y - 10),
354            count=18,
355            speed=100,
356            speed_var=40,
357            life=0.5,
358            scale0=2.5,
359            colour=(1.0, 0.4, 0.2, 1.0),
360            drag=3,
361        )
362        self._hitstop_t = 0.06
363        self._screenshake(0.12, amp=2.0)
364
365    def _on_fire_spawned_by_enemy(self, pos: Vec2):
366        self._screenshake(0.06, amp=1.0)
367        self.particles.emit_burst(
368            (pos.x, pos.y),
369            count=8,
370            speed=60,
371            speed_var=20,
372            life=0.5,
373            scale0=2.5,
374            colour=(1.0, 0.5, 0.1, 1.0),
375            drag=2,
376        )
377
378    def _on_player_died(self):
379        self.failure.emit("overheat")
380
381    # ----------------------------------------------------------- per-frame
382
383    def _screen_size(self) -> tuple[int, int]:
384        if self.tree is not None:
385            w, h = self.tree.screen_size
386            return int(w), int(h)
387        return self.viewport_w, self.viewport_h
388
389    def on_update(self, dt: float):
390        self._t += dt
391
392        # Real hitstop: freeze the whole gameplay subtree for a few ms on big
393        # impacts so they punch. GameScene itself is the PARENT of `_world`, so
394        # it keeps ticking and can release the freeze; only the children pause.
395        if self._hitstop_t > 0:
396            self._hitstop_t = max(0.0, self._hitstop_t - dt)
397            self._world.update_mode = UpdateMode.DISABLED if self._hitstop_t > 0 else UpdateMode.INHERIT
398
399        # Carry: pull civilian to the player's back position.
400        if self.player.grabbed is not None and self.player.grabbed.alive:
401            self.player.grabbed.position = Vec2(
402                self.player.position.x,
403                self.player.position.y - 16,
404            )
405
406        # Rescue check: civilian carried out onto the roof. The roof line is the
407        # map's top row, which is exactly where the topmost ladder ends, so this
408        # fires the moment the climb runs out of rungs with a civilian aboard.
409        if self.player.grabbed is not None and self.player.position.y <= TILE_SIZE:
410            civ = self.player.hand_over_carried()
411            civ.rescue()
412            # Player drops back down the shaft into the building.
413            self.player.position = Vec2(self.player.position.x, TILE_SIZE + 8)
414
415        # Stop the spray loop SFX when not actively shooting.
416        if not self.player.shooting:
417            self.sfx.set_spraying(False)
418
419        # ---- Camera follow (resolution-independent + HUD letterbox) ----
420        vw, vh = self._screen_size()
421        map_w_px = self.grid.w * TILE_SIZE
422        map_h_px = self.grid.h * TILE_SIZE
423        zoom = float(self.camera.zoom) or 1.0
424        half_view_x = vw / (2 * zoom)
425        half_view_y = vh / (2 * zoom)
426        target_x = self.player.position.x
427        target_y = self.player.position.y - 40
428        target_x = max(half_view_x, min(map_w_px - half_view_x, target_x))
429        # Reserve the top/bottom HUD strips: let the camera overscroll by each
430        # strip's height (in world units) so building edges sit *under* the bars
431        # and the player is never drawn behind the bottom bar.
432        top_pad = TOP_H / zoom
433        bot_pad = HUD_H / zoom
434        target_y = max(half_view_y - top_pad, min(map_h_px - half_view_y + bot_pad, target_y))
435
436        # Apply screenshake.
437        shake_x = shake_y = 0.0
438        if self._screenshake_t > 0:
439            self._screenshake_t -= dt
440            amp = self._screenshake_amp * (self._screenshake_t / 0.2)
441            shake_x = (random.random() - 0.5) * amp
442            shake_y = (random.random() - 0.5) * amp
443        cam = Vec2(target_x + shake_x, target_y + shake_y)
444        self.camera.position = cam
445
446        # Parallax backdrop: drift the night/skyline layers slower than the
447        # camera so they read as distant depth behind the windows.
448        self._bg_night.position = Vec2(cam.x * 0.92, cam.y * 0.94)
449        self._bg_skyline.position = Vec2(cam.x * 0.75, cam.y * 0.86 + 60)
450
451        # Heat flash overlay alpha tracks player heat.
452        self._heat_flash = max(0.0, min(1.0, self.player.heat))
453
454        # Drive the HUD from live scene state.
455        self._drive_hud()
456
457    def _screenshake(self, duration: float, amp: float = 2.0):
458        self._screenshake_t = max(self._screenshake_t, duration)
459        self._screenshake_amp = max(self._screenshake_amp, amp)
460
461    # ----------------------------------------------------------- victory
462
463    def _check_victory(self):
464        # Victory = all civilians rescued (none remaining + player above top).
465        # We let the runner advance; actual win gating handled by root.
466        if not self.civilians and self.fires.fire_count() == 0:
467            self.victory.emit()