Mr. Rescue¶
Arcade firefighter, fire spread, rescue civilians, procedural building.
▶ Run in browserUpstream: 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 |
Space |
Jump |
Shift |
Spray water (drains tank, must cool down if overloaded) |
E |
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 }
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 / WASD: move, aim gun, climb ladder
26- Space: jump
27- Shift: spray water
28- E: 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 nodes import colours as C
44from nodes.game import GameScene
45from nodes.menu import EndScreen, TitleScreen
46
47from simvx.core import (
48 Input,
49 InputMap,
50 Key,
51 Node,
52)
53from simvx.core.world_environment import WorldEnvironment
54from simvx.graphics import App
55
56WINDOW_W = 1024
57WINDOW_H = 800
58
59
60# ---------------------------------------------------------------------------- root
61
62class MrRescueRoot(Node):
63 """Phase machine: menu → game → end."""
64
65 def __init__(self, *, seed: int | None = None, **kwargs):
66 super().__init__(name="MrRescueRoot", **kwargs)
67 self.phase = "menu"
68 self.section = 1
69 self._seed = seed
70 self._sub: Node | None = None
71 self._menu_sfx = None
72
73 def on_ready(self):
74 # InputMap MUST live in on_ready (web exporter skips main()).
75 InputMap.add_action("left", [Key.LEFT, Key.A])
76 InputMap.add_action("right", [Key.RIGHT, Key.D])
77 InputMap.add_action("up", [Key.UP, Key.W])
78 InputMap.add_action("down", [Key.DOWN, Key.S])
79 InputMap.add_action("jump", [Key.SPACE])
80 InputMap.add_action("shoot", [Key.LEFT_SHIFT, Key.RIGHT_SHIFT])
81 InputMap.add_action("grab", [Key.E])
82 InputMap.add_action("start", [Key.ENTER, Key.SPACE])
83 InputMap.add_action("quit", [Key.ESCAPE])
84
85 env = self.add_child(WorldEnvironment())
86 env.sky_mode = "disabled"
87 env.tonemap_mode = "aces"
88 env.tonemap_exposure = 1.05
89 env.bloom_enabled = True
90 env.bloom_threshold = 0.78
91 env.bloom_intensity = 0.52
92 env.bloom_soft_knee = 0.5
93
94 # One-shot menu confirm cue (gameplay SFX live on the GameScene's bank).
95 try:
96 from nodes.audio import get_sfx
97
98 from simvx.core import AudioPlayer
99 self._menu_sfx = self.add_child(AudioPlayer(stream=get_sfx("menu_confirm"), bus="SFX"))
100 except Exception: # noqa: BLE001 -- audio is non-essential
101 self._menu_sfx = None
102
103 # The HUD is owned by the GameScene (in its own CanvasLayer) so it is
104 # created and destroyed with the game phase -- clean lifecycle with no HUD
105 # state to reset between phases.
106 self._enter_menu()
107
108 # ----------------------------------------------------------- phase swaps
109
110 def _drop_sub(self):
111 if self._sub is not None:
112 self._sub.destroy()
113 self._sub = None
114 # P6 fix landed but be defensive: null out the camera ref so menu
115 # phases render in screen-space without inheriting a dead camera.
116 if self.tree is not None:
117 self.tree._current_camera_2d = None
118
119 def _enter_menu(self):
120 self.phase = "menu"
121 self._drop_sub()
122 self.section = 1
123 ts = TitleScreen()
124 ts.start.connect(self._enter_game)
125 self.add_child(ts)
126 self._sub = ts
127
128 def _play_menu_sfx(self):
129 if self._menu_sfx is not None:
130 try:
131 self._menu_sfx.play()
132 except Exception: # noqa: BLE001
133 pass
134
135 def _restart(self):
136 self._play_menu_sfx()
137 self._enter_menu()
138
139 def _enter_game(self):
140 self.phase = "game"
141 self._play_menu_sfx()
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
155 def _on_victory(self):
156 self._enter_end(victory=True, reason="ALL CIVILIANS RESCUED")
157
158 def _on_failure(self, reason: str):
159 msg = {
160 "casualty": "TOO MANY CIVILIANS LOST",
161 "overheat": "YOUR SUIT OVERHEATED",
162 }.get(reason, "")
163 self._enter_end(victory=False, reason=msg)
164
165 def _enter_end(self, *, victory: bool, reason: str):
166 self.phase = "end"
167 # Snapshot stats before we drop the scene.
168 scene = self._sub
169 score = getattr(scene, "score", 0)
170 total = getattr(scene, "civilians_total", 0)
171 casualties = getattr(scene, "casualties", 0)
172 rescued = max(0, total - casualties - len(getattr(scene, "civilians", [])))
173 self._drop_sub()
174 es = EndScreen(
175 victory=victory,
176 score=score,
177 rescued=rescued,
178 civilians_total=total,
179 casualties=casualties,
180 reason=reason,
181 )
182 es.restart.connect(self._restart)
183 self.add_child(es)
184 self._sub = es
185
186 # ----------------------------------------------------------- update
187
188 def on_update(self, dt: float):
189 if Input.is_action_just_pressed("quit"):
190 if self.app is not None:
191 self.app.quit()
192 return
193 # The GameScene now drives its own HUD (it owns it), so the root only
194 # handles the global quit + phase machine.
195
196
197# --------------------------------------------------------------------- entry
198
199def _run_test():
200 """Headless smoke test: captures 8 stage screenshots."""
201 from simvx.core import InputSimulator
202 from simvx.graphics import save_png
203
204 out_dir = _PORT_DIR / "screenshots"
205 out_dir.mkdir(exist_ok=True)
206
207 app = App(title="Mr. Rescue (test)", width=WINDOW_W, height=WINDOW_H,
208 visible=False, bg_colour=C.BG)
209 sim = InputSimulator()
210 root = MrRescueRoot(seed=42)
211
212 # Frame plan:
213 # 0..40 menu (capture frame 20 = title screen)
214 # 40..80 press ENTER → game loaded (capture 60 = building overview)
215 # 80..150 player runs right + sprays (capture 120 = water spray)
216 # 150..230 player attempts to grab / mid-game (capture 200 = mid-game)
217 # 230..330 chaos: fire spread + civilian states (capture 280 = peak fire)
218 # 330..360 end (capture 350 = end screen via dev-trigger)
219 captures = {
220 20: "01_title.png",
221 60: "02_world_overview.png",
222 120: "03_water_spray.png",
223 180: "04_climb_grab.png",
224 260: "05_combat.png",
225 320: "06_late_game.png",
226 450: "07_end_screen.png",
227 500: "08_post_restart.png",
228 }
229 capture_frames = sorted(captures.keys())
230
231 def _drive(idx, _t):
232 # Frame 30: press ENTER to start the game.
233 if idx == 30:
234 sim.press_key(Key.ENTER)
235 if idx == 32:
236 sim.release_key(Key.ENTER)
237 # Walk RIGHT toward fires + civilians (player starts at x=72, civilians
238 # at x=488 on the same floor, ~3.5s of running at max 160 px/s).
239 if idx == 50:
240 sim.press_key(Key.RIGHT)
241 if idx == 100:
242 sim.press_key(Key.LEFT_SHIFT) # spray water as we run
243 if idx == 200:
244 sim.release_key(Key.LEFT_SHIFT)
245 # Stop and try grabbing civilian.
246 if idx == 280:
247 sim.release_key(Key.RIGHT)
248 sim.press_key(Key.E)
249 if idx == 285:
250 sim.release_key(Key.E)
251 # Spray more fires + walk further (different floor demonstration).
252 if idx == 300:
253 sim.press_key(Key.RIGHT)
254 sim.press_key(Key.LEFT_SHIFT)
255 if idx == 380:
256 sim.release_key(Key.LEFT_SHIFT)
257 sim.release_key(Key.RIGHT)
258 # Try climbing a ladder (the auto-detect uses UP).
259 if idx == 400:
260 sim.press_key(Key.UP)
261 if idx == 410:
262 sim.release_key(Key.UP)
263 # Force the overheat end-state so the end screen is captured (test-only
264 # state poke; real play reaches this through fire heat).
265 if idx == 430 and hasattr(root._sub, "player"):
266 p = root._sub.player
267 p.temperature = p.max_temperature + 1.0
268 # Press ENTER on the end screen to return to the title.
269 if idx == 470:
270 sim.press_key(Key.ENTER)
271 if idx == 472:
272 sim.release_key(Key.ENTER)
273 return None
274
275 frames_captured = app.run_headless(
276 root,
277 frames=520,
278 on_frame=_drive,
279 capture_frames=capture_frames,
280 )
281 for idx, frame in zip(capture_frames, frames_captured, strict=False):
282 if frame is None:
283 continue
284 # Workaround for known headless alpha-bleed bug.
285 frame[..., 3] = 255
286 path = out_dir / captures[idx]
287 save_png(path, frame)
288 print(f"saved {path}")
289
290
291def main():
292 if "--test" in sys.argv:
293 _run_test()
294 return
295 app = App(title="Mr. Rescue", width=WINDOW_W, height=WINDOW_H,
296 bg_colour=C.BG)
297 app.run(MrRescueRoot())
298
299
300if __name__ == "__main__":
301 main()