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