Mr. Rescue¶
Arcade firefighter, fire spread, rescue civilians, procedural building.
📄 Docs onlyUpstream: https://github.com/SimonLarsen/mrrescue
Tags: port tier-2
Mr. Rescue: SimVX port¶
Tier 2 port (#21) of Mr. Rescue by Simon Larsen, an arcade firefighter platformer where you spray water at spreading fires and rescue civilians from a procedurally-stitched 3-storey building.
Run¶
# Interactive
uv run python ported_games/mr_rescue/simvx_port/main.py
# Headless (captures 8 screenshots into screenshots/)
uv run python ported_games/mr_rescue/simvx_port/main.py --test
# Web export
uv run simvx export web ported_games/mr_rescue/simvx_port/main.py \
-o ported_games/mr_rescue/simvx_port/web/index.html
Controls¶
Key |
Action |
|---|---|
Arrow keys / WASD |
Move horizontally, climb ladders, aim gun up/down |
Z / Space |
Jump |
X |
Spray water (drains tank, must cool down if overloaded) |
C |
Grab civilian / Throw carried civilian |
Enter |
Confirm in menus |
Esc |
Quit |
Notes¶
Procedural NumPy textures throughout; no upstream PNG assets are bundled.
Audio is procedural beeps (no upstream WAV/OGG reuse). See ../NOTES.md for
deliberate cuts (bosses, lightmap, joystick UI, music tracks, multiple enemy
types).
License: Port code under the engine’s MIT. Mirrors upstream’s zlib gameplay design; CC-BY-SA 3.0 attribution to Simon Larsen for the original concept and visual reference (no upstream art reused; all textures are procedural).
Source¶
1#!/usr/bin/env python3
2"""Mr. Rescue: Arcade firefighter, fire spread, rescue civilians, procedural building.
3
4# /// simvx
5# tags = ["port", "tier-2"]
6# upstream = "https://github.com/SimonLarsen/mrrescue"
7# web = { width = 1024, height = 800, responsive = true, disabled = true, reason = "Blocked on engine API drift: Draw2D.new_layer removed." }
8# ///
9
10Run interactively::
11
12 uv run python ported_games/mr_rescue/simvx_port/main.py
13
14Headless smoke test (captures 8 stage screenshots)::
15
16 uv run python ported_games/mr_rescue/simvx_port/main.py --test
17
18Web export::
19
20 uv run simvx export web ported_games/mr_rescue/simvx_port/main.py \
21 -o ported_games/mr_rescue/simvx_port/web/index.html
22
23Controls
24--------
25- Arrow keys: move, aim gun, climb ladder
26- Z: jump
27- X: spray water
28- C: grab / throw civilian
29- Enter / Space: confirm in menus
30- Esc: quit
31"""
32
33from __future__ import annotations
34
35import sys
36from pathlib import Path
37
38# Make the port folder importable in --test and direct runs alike.
39_PORT_DIR = Path(__file__).resolve().parent
40if str(_PORT_DIR) not in sys.path:
41 sys.path.insert(0, str(_PORT_DIR))
42
43from simvx.core import (
44 CanvasLayer,
45 Input,
46 InputMap,
47 Key,
48 Node,
49)
50from simvx.core.world_environment import WorldEnvironment
51from simvx.graphics import App
52
53from nodes import colours as C
54from nodes.game import GameScene
55from nodes.hud import HUD
56from nodes.menu import EndScreen, TitleScreen
57
58
59WINDOW_W = 1024
60WINDOW_H = 800
61
62
63# ---------------------------------------------------------------------------- root
64
65class MrRescueRoot(Node):
66 """Phase machine: menu → game → end."""
67
68 def __init__(self, *, seed: int | None = None, **kwargs):
69 super().__init__(name="MrRescueRoot", **kwargs)
70 self.phase = "menu"
71 self.section = 1
72 self._seed = seed
73 self._sub: Node | None = None
74 self._hud: HUD | None = None
75 self._hud_layer: CanvasLayer | None = None
76
77 def on_ready(self):
78 # InputMap MUST live in on_ready (web exporter skips main()).
79 InputMap.add_action("left", [Key.LEFT, Key.A])
80 InputMap.add_action("right", [Key.RIGHT, Key.D])
81 InputMap.add_action("up", [Key.UP, Key.W])
82 InputMap.add_action("down", [Key.DOWN, Key.S])
83 InputMap.add_action("jump", [Key.Z, Key.SPACE])
84 InputMap.add_action("shoot", [Key.X])
85 InputMap.add_action("grab", [Key.C])
86 InputMap.add_action("start", [Key.ENTER, Key.SPACE])
87 InputMap.add_action("quit", [Key.ESCAPE])
88
89 env = self.add_child(WorldEnvironment())
90 env.sky_mode = "disabled"
91 env.tonemap_mode = "aces"
92 env.tonemap_exposure = 1.05
93 env.bloom_enabled = True
94 env.bloom_threshold = 0.85
95 env.bloom_intensity = 0.40
96 env.bloom_soft_knee = 0.5
97
98 self._enter_menu()
99
100 # ----------------------------------------------------------- phase swaps
101
102 def _drop_sub(self):
103 if self._sub is not None:
104 self._sub.destroy()
105 self._sub = None
106 # P6 fix landed but be defensive: null out the camera ref so menu
107 # phases render in screen-space without inheriting a dead camera.
108 if self.tree is not None:
109 self.tree._current_camera_2d = None
110 # Drop existing HUD too; we'll re-add LAST so it draws on top.
111 if self._hud_layer is not None:
112 self._hud_layer.destroy()
113 self._hud_layer = None
114 self._hud = None
115
116 def _ensure_hud_on_top(self):
117 """Re-create the HUDLayer as the last child so it draws on top.
118
119 Node._draw_recursive iterates children in declaration order; the last
120 child draws last (on top of everything else). Node2D auto-reorders
121 CanvasLayers but our root is a plain Node, so we manage it manually.
122 """
123 if self._hud_layer is not None:
124 self._hud_layer.destroy()
125 self._hud_layer = CanvasLayer(name="HUDLayer", layer=10)
126 self.add_child(self._hud_layer)
127 self._hud = HUD(viewport_w=WINDOW_W, viewport_h=WINDOW_H)
128 self._hud_layer.add_child(self._hud)
129
130 def _enter_menu(self):
131 self.phase = "menu"
132 self._drop_sub()
133 self.section = 1
134 ts = TitleScreen(viewport_w=WINDOW_W, viewport_h=WINDOW_H)
135 ts.start.connect(self._enter_game)
136 self.add_child(ts)
137 self._sub = ts
138 self._ensure_hud_on_top()
139
140 def _enter_game(self):
141 self.phase = "game"
142 self._drop_sub()
143 scene = GameScene(
144 viewport_w=WINDOW_W,
145 viewport_h=WINDOW_H,
146 section=self.section,
147 level=1,
148 seed=self._seed,
149 )
150 scene.victory.connect(self._on_victory)
151 scene.failure.connect(self._on_failure)
152 self.add_child(scene)
153 self._sub = scene
154 self._ensure_hud_on_top()
155
156 def _on_victory(self):
157 self._enter_end(victory=True, reason="ALL CIVILIANS RESCUED")
158
159 def _on_failure(self, reason: str):
160 msg = {
161 "casualty": "TOO MANY CIVILIANS LOST",
162 "overheat": "YOUR SUIT OVERHEATED",
163 }.get(reason, "")
164 self._enter_end(victory=False, reason=msg)
165
166 def _enter_end(self, *, victory: bool, reason: str):
167 self.phase = "end"
168 # Snapshot stats before we drop the scene.
169 scene = self._sub
170 score = getattr(scene, "score", 0)
171 total = getattr(scene, "civilians_total", 0)
172 rescued = total - getattr(scene, "casualties", 0) - len(getattr(scene, "civilians", []))
173 rescued = max(0, rescued)
174 self._drop_sub()
175 es = EndScreen(
176 viewport_w=WINDOW_W,
177 viewport_h=WINDOW_H,
178 victory=victory,
179 score=score,
180 rescued=rescued,
181 civilians_total=total,
182 reason=reason,
183 )
184 es.restart.connect(self._enter_menu)
185 self.add_child(es)
186 self._sub = es
187 self._ensure_hud_on_top()
188
189 # ----------------------------------------------------------- update
190
191 def on_process(self, dt: float):
192 if Input.is_action_just_pressed("quit"):
193 if self.app is not None:
194 self.app.quit()
195 return
196
197 # Drive HUD with current scene state.
198 if self._hud is None:
199 return
200 self._hud.visible = (self.phase == "game")
201 if self.phase == "game" and isinstance(self._sub, GameScene):
202 scene: GameScene = self._sub
203 p = scene.player
204 self._hud.set_state(
205 water=p.water,
206 water_max=p.water_capacity,
207 overloaded=p.overloaded,
208 has_reserve=p.has_reserve,
209 temperature=p.temperature,
210 max_temperature=p.max_temperature,
211 casualties=scene.casualties,
212 max_casualties=scene.max_casualties,
213 civilians_remaining=len(scene.civilians),
214 civilians_total=scene.civilians_total,
215 fires_remaining=scene.fires.fire_count(),
216 section=scene.section,
217 score=scene.score,
218 is_dying=p.is_dying,
219 )
220
221
222# --------------------------------------------------------------------- entry
223
224def _run_test():
225 """Headless smoke test: captures 8 stage screenshots."""
226 from simvx.core import InputSimulator
227 from simvx.graphics import save_png
228
229 out_dir = _PORT_DIR / "screenshots"
230 out_dir.mkdir(exist_ok=True)
231
232 app = App(title="Mr. Rescue (test)", width=WINDOW_W, height=WINDOW_H,
233 visible=False, bg_colour=C.BG)
234 sim = InputSimulator()
235 root = MrRescueRoot(seed=42)
236
237 # Frame plan:
238 # 0..40 menu (capture frame 20 = title screen)
239 # 40..80 press ENTER → game loaded (capture 60 = building overview)
240 # 80..150 player runs right + sprays (capture 120 = water spray)
241 # 150..230 player attempts to grab / mid-game (capture 200 = mid-game)
242 # 230..330 chaos: fire spread + civilian states (capture 280 = peak fire)
243 # 330..360 end (capture 350 = end screen via dev-trigger)
244 captures = {
245 20: "01_title.png",
246 60: "02_world_overview.png",
247 120: "03_water_spray.png",
248 180: "04_climb_grab.png",
249 260: "05_combat.png",
250 320: "06_late_game.png",
251 450: "07_end_screen.png",
252 500: "08_post_restart.png",
253 }
254 capture_frames = sorted(captures.keys())
255
256 def _drive(idx, _t):
257 # Frame 30: press ENTER to start the game.
258 if idx == 30:
259 sim.press_key(Key.ENTER)
260 if idx == 32:
261 sim.release_key(Key.ENTER)
262 # Walk RIGHT toward fires + civilians (player starts at x=72, civilians
263 # at x=488 on the same floor, ~3.5s of running at max 160 px/s).
264 if idx == 50:
265 sim.press_key(Key.RIGHT)
266 if idx == 100:
267 sim.press_key(Key.X) # spray water as we run
268 if idx == 200:
269 sim.release_key(Key.X)
270 # Stop and try grabbing civilian.
271 if idx == 280:
272 sim.release_key(Key.RIGHT)
273 sim.press_key(Key.C)
274 if idx == 285:
275 sim.release_key(Key.C)
276 # Spray more fires + walk further (different floor demonstration).
277 if idx == 300:
278 sim.press_key(Key.RIGHT)
279 sim.press_key(Key.X)
280 if idx == 380:
281 sim.release_key(Key.X)
282 sim.release_key(Key.RIGHT)
283 # Try climbing a ladder (the auto-detect uses UP).
284 if idx == 400:
285 sim.press_key(Key.UP)
286 if idx == 410:
287 sim.release_key(Key.UP)
288 # Heat may eventually kill the player. Press ENTER on the end screen.
289 if idx == 470:
290 sim.press_key(Key.ENTER)
291 if idx == 472:
292 sim.release_key(Key.ENTER)
293 return None
294
295 frames_captured = app.run_headless(
296 root,
297 frames=520,
298 on_frame=_drive,
299 capture_frames=capture_frames,
300 )
301 for idx, frame in zip(capture_frames, frames_captured, strict=False):
302 if frame is None:
303 continue
304 # Workaround for known headless alpha-bleed bug.
305 frame[..., 3] = 255
306 path = out_dir / captures[idx]
307 save_png(path, frame)
308 print(f"saved {path}")
309
310
311def main():
312 if "--test" in sys.argv:
313 _run_test()
314 return
315 app = App(title="Mr. Rescue", width=WINDOW_W, height=WINDOW_H,
316 bg_colour=C.BG)
317 app.run(MrRescueRoot())
318
319
320if __name__ == "__main__":
321 main()