SNKRX¶
Wave-based arena, particles, screenshake, slow-motion, bloom.
▶ Run in browserUpstream: https://github.com/Luminware/SNKRX
Tags: port tier-1
SNKRX (SimVX port)¶
Snake auto-battler arena roguelite. SimVX port of a327ex/SNKRX.
Run¶
# from /home/fezzik/dev/simvx
uv run python ported_games/snkrx/simvx_port/main.py
Headless smoke test (writes screenshots/frame_30.png, etc.):
uv run python ported_games/snkrx/simvx_port/main.py --test
Scripted 9-stage capture (writes screenshots/stage_*.png):
uv run python ported_games/snkrx/simvx_port/harness.py
Web export:
uv run simvx export web ported_games/snkrx/simvx_port/main.py \
-o ported_games/snkrx/simvx_port/web/index.html
Controls¶
MOUSE / A D: steer the snake head (mouse aim, or A/D rotate)
SPACE / ENTER: confirm in menus, advance buy screen
R: reroll (buy screen, costs 2 gold)
S: skip (buy screen)
ESC: quit
Acceptance scope¶
5 waves with full juice (particles, screenshake, slow-mo, bloom) plus a build/upgrade screen between waves with three classes (Warrior / Archer / Mage). Boss spawns at level 5.
See ../NOTES.md for engine friction, deliberate cuts, and architecture
notes.
Source¶
1#!/usr/bin/env python3
2"""SNKRX: Wave-based arena, particles, screenshake, slow-motion, bloom.
3
4# /// simvx
5# tags = ["port", "tier-1"]
6# upstream = "https://github.com/Luminware/SNKRX"
7# web = { width = 1280, height = 720, responsive = true }
8# ///
9
10Run interactively::
11
12 uv run python ported_games/snkrx/simvx_port/main.py
13
14Headless smoke test (captures frame 30 / 60 / 120)::
15
16 uv run python ported_games/snkrx/simvx_port/main.py --test
17
18Web export (PEP 723 declares no extra deps)::
19
20 uv run simvx export web ported_games/snkrx/simvx_port/main.py -o ported_games/snkrx/simvx_port/web/index.html
21
22Controls
23--------
24- A / D or LEFT / RIGHT: rotate the snake head
25- Mouse: aim toward cursor (snake tracks)
26- ENTER / SPACE: confirm in menus, advance buy screen
27- R: reroll (buy screen, costs 2 gold)
28- S: skip (buy screen)
29- ESC: quit
30"""
31
32from __future__ import annotations
33
34import math
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 MouseButton,
49 Node,
50 Vec2,
51)
52from simvx.core.world_environment import WorldEnvironment
53from simvx.graphics import App
54
55from nodes.arena import ARENA_H, ARENA_W, Arena
56from nodes.buy_screen import BuyScreen
57from nodes.colours import BG
58from nodes.hud import HUD
59from nodes.menu import TitleScreen
60
61
62WINDOW_W = 1280
63WINDOW_H = 720
64TOTAL_WAVES = 5 # ship at least 5 levels per the tier brief
65
66
67# ---------------------------------------------------------------------------- root
68
69class SNKRXRoot(Node):
70 """Top-level scene: owns InputMap, the active sub-scene, and the WorldEnvironment.
71
72 Sub-scene swaps happen in-place via ``_set_phase`` so we don't tear down
73 the WorldEnvironment between rounds.
74 """
75
76 def __init__(self, **kwargs):
77 super().__init__(name="SNKRXRoot", **kwargs)
78 self.phase: str = "menu" # menu | arena | buy | gameover | victory
79 self.wave = 1
80 self.gold = 0
81 self.kills = 0
82 self.build: list[tuple[str, int]] = [
83 ("warrior", 1),
84 ("archer", 1),
85 ("mage", 1),
86 ]
87 self._sub: Node | None = None
88 # HUD lives in a CanvasLayer (layer=10) so it renders in screen-space,
89 # above the camera-transformed gameplay scene.
90 self._hud_layer = CanvasLayer(name="HUDLayer", layer=10)
91 self.add_child(self._hud_layer)
92 self._hud = HUD()
93 self._hud_layer.add_child(self._hud)
94
95 def on_ready(self):
96 # InputMap MUST live in on_ready (web exporter skips main()).
97 InputMap.add_action("start", [Key.ENTER, Key.SPACE])
98 InputMap.add_action("left", [Key.A, Key.LEFT])
99 InputMap.add_action("right", [Key.D, Key.RIGHT])
100 InputMap.add_action("quit", [Key.ESCAPE])
101 InputMap.add_action("skip", [Key.S])
102 InputMap.add_action("reroll", [Key.R])
103
104 # Bloom + tonemap for the neon SNKRX feel.
105 # Sky is disabled so the 2D scene gets a true dark background.
106 env = self.add_child(WorldEnvironment())
107 env.sky_mode = "disabled"
108 env.tonemap_mode = "aces"
109 env.tonemap_exposure = 1.0
110 env.bloom_enabled = True
111 env.bloom_threshold = 0.85
112 env.bloom_intensity = 0.55
113 env.bloom_soft_knee = 0.4
114
115 self._enter_menu()
116
117 # ------------------------------------------------------------ phase swaps
118
119 def _drop_sub(self):
120 if self._sub is not None:
121 self._sub.destroy()
122 self._sub = None
123 # Workaround for engine bug: Camera2D doesn't clear `_current_camera_2d`
124 # on destroy, so non-arena phases inherit the dead camera and render
125 # mis-translated. See simvx/BUGS.md "Camera2D leaks _current_camera_2d
126 # reference on destroy". Delete this whole block once that ships.
127 if self.tree is not None:
128 self.tree._current_camera_2d = None
129
130 def _enter_menu(self):
131 self.phase = "menu"
132 self._drop_sub()
133 self.wave = 1
134 self.gold = 0
135 self.kills = 0
136 self.build = [("warrior", 1), ("archer", 1), ("mage", 1)]
137 ts = TitleScreen()
138 ts.start.connect(self._enter_arena)
139 self.add_child(ts)
140 self._sub = ts
141
142 def _enter_arena(self):
143 self.phase = "arena"
144 self._drop_sub()
145 arena = Arena(level=self.wave, build=self.build)
146 arena.finished.connect(self._on_arena_finished)
147 arena.failed.connect(self._on_arena_failed)
148 self.add_child(arena)
149 self._sub = arena
150
151 def _on_arena_finished(self, xp_gained: int, gold_gained: int):
152 self.gold += gold_gained
153 self.kills += getattr(self._sub, "kills", 0)
154 self.wave += 1
155 if self.wave > TOTAL_WAVES:
156 self._enter_victory()
157 else:
158 self._enter_buy()
159
160 def _on_arena_failed(self):
161 self._enter_gameover()
162
163 def _enter_buy(self):
164 self.phase = "buy"
165 self._drop_sub()
166 bs = BuyScreen(level=self.wave - 1, gold=self.gold, build=self.build)
167 bs.chosen.connect(self._on_buy_chosen)
168 bs.skipped.connect(self._enter_arena)
169 self.add_child(bs)
170 self._sub = bs
171
172 def _on_buy_chosen(self, klass: str, lvl: int):
173 if len(self.build) < 8:
174 self.build.append((klass, lvl))
175 # Subtract gold from the BuyScreen's running total
176 self.gold = self._sub.gold if self._sub else self.gold
177 self._enter_arena()
178
179 def _enter_gameover(self):
180 self.phase = "gameover"
181 self._drop_sub()
182 # Reuse TitleScreen-style overlay would be ideal, keep simple here
183 self._sub = _EndScreen(victory=False, wave=self.wave, kills=self.kills, gold=self.gold)
184 self._sub.start.connect(self._enter_menu)
185 self.add_child(self._sub)
186
187 def _enter_victory(self):
188 self.phase = "victory"
189 self._drop_sub()
190 self._sub = _EndScreen(victory=True, wave=self.wave - 1, kills=self.kills, gold=self.gold)
191 self._sub.start.connect(self._enter_menu)
192 self.add_child(self._sub)
193
194 # ---------------------------------------------------------------- updates
195
196 def on_process(self, dt: float):
197 # Global ESC → quit. Easy to relocate to per-screen later.
198 if Input.is_action_just_pressed("quit"):
199 if self.app is not None:
200 self.app.quit()
201 return
202
203 # HUD only renders during arena phase
204 self._hud.visible_hud = (self.phase == "arena")
205
206 # Arena phase: forward steering input + populate HUD
207 if self.phase == "arena" and isinstance(self._sub, Arena):
208 arena: Arena = self._sub # type: ignore[assignment]
209 # Mouse aim if the cursor moved this frame
210 mp = Input.mouse_position
211 if Input.is_mouse_button_pressed(MouseButton.LEFT) or self._mouse_moved(mp):
212 arena.snake.aim_at(Vec2(float(mp.x), float(mp.y)))
213 else:
214 # Keyboard fallback steering
215 arena.snake.aim_at(None)
216 steer = 0.0
217 if Input.is_action_pressed("left"):
218 steer -= 1.0
219 if Input.is_action_pressed("right"):
220 steer += 1.0
221 arena.snake.steer(steer)
222
223 # HUD state
224 head = arena.snake.head if arena.snake.units else None
225 self._hud.set_state(
226 wave=self.wave,
227 total_waves=TOTAL_WAVES,
228 kills=arena.kills,
229 gold=self.gold + arena.gold_gained,
230 snake_hp=sum(u.hp for u in arena.snake.units if u.alive) if arena.snake.units else 0,
231 snake_max_hp=sum(u.max_hp for u in arena.snake.units) if arena.snake.units else 1,
232 time_scale=arena.time_scale,
233 )
234 else:
235 # Hide HUD outside arena by zeroing
236 self._hud.set_state(
237 wave=self.wave, total_waves=TOTAL_WAVES,
238 kills=0, gold=self.gold,
239 snake_hp=0, snake_max_hp=1, time_scale=1.0,
240 )
241
242 _last_mouse: tuple[float, float] = (0.0, 0.0)
243
244 def _mouse_moved(self, mp) -> bool:
245 x, y = float(mp.x), float(mp.y)
246 moved = (x, y) != self._last_mouse
247 self._last_mouse = (x, y)
248 return moved
249
250
251# ---------------------------------------------------------------- end screens
252
253class _EndScreen(Node):
254 """Game-over / victory scene with continue prompt."""
255
256 from simvx.core import Signal as _S
257 start = _S()
258
259 def __init__(self, *, victory: bool, wave: int, kills: int, gold: int, **kwargs):
260 super().__init__(name="EndScreen", **kwargs)
261 self.victory = victory
262 self.wave = wave
263 self.kills = kills
264 self.gold = gold
265 self._t = 0.0
266
267 def on_process(self, dt: float):
268 self._t += dt
269 if Input.is_action_just_pressed("start"):
270 self.start.emit()
271
272 def on_draw(self, renderer):
273 from nodes.colours import BG, FG, GREEN, GREY, RED, YELLOW
274 w = WINDOW_W
275 h = WINDOW_H
276 renderer.draw_rect((0, 0), (w, h), colour=BG, filled=True)
277 title = "VICTORY" if self.victory else "DEFEAT"
278 c = GREEN if self.victory else RED
279 tw = renderer.text_width(title, 12)
280 renderer.draw_text(title, (w // 2 - tw // 2, 200), scale=12, colour=c)
281
282 for i, line in enumerate([
283 f"WAVES SURVIVED {self.wave}",
284 f"KILLS {self.kills}",
285 f"GOLD {self.gold}",
286 ]):
287 lw = renderer.text_width(line, 3)
288 renderer.draw_text(line, (w // 2 - lw // 2, 380 + i * 50), scale=3, colour=FG)
289
290 if int(self._t * 2) % 2 == 0:
291 prompt = "ENTER RESTART"
292 pw = renderer.text_width(prompt, 3)
293 renderer.draw_text(prompt, (w // 2 - pw // 2, 580), scale=3, colour=YELLOW)
294
295
296# --------------------------------------------------------------------- entry
297
298def _capture(captures, frames):
299 out_dir = _PORT_DIR / "screenshots"
300 out_dir.mkdir(exist_ok=True)
301 from simvx.graphics import save_png
302 for idx, frame in zip(frames, captures, strict=False):
303 path = out_dir / f"frame_{idx}.png"
304 save_png(path, frame)
305 print(f"saved {path}")
306
307
308def _run_test():
309 """Headless smoke test: auto-advance into the arena; capture frames 30/60/120."""
310 from simvx.core import InputSimulator
311
312 app = App(title="SNKRX (test)", width=WINDOW_W, height=WINDOW_H, visible=False, bg_colour=BG)
313 sim = InputSimulator()
314 root = SNKRXRoot()
315
316 def _drive(idx, _t):
317 # Auto-press Enter after a few frames so capture frame 60 / 120 show
318 # actual arena gameplay rather than just the title screen.
319 if idx == 20:
320 sim.press_key(Key.ENTER)
321 if idx == 22:
322 sim.release_key(Key.ENTER)
323 return None
324
325 capture_frames = [30, 60, 120]
326 captures = app.run_headless(
327 root,
328 frames=130,
329 on_frame=_drive,
330 capture_frames=capture_frames,
331 )
332 _capture(captures, capture_frames)
333
334
335def main():
336 if "--test" in sys.argv:
337 _run_test()
338 return
339 app = App(title="SNKRX", width=WINDOW_W, height=WINDOW_H, bg_colour=BG)
340 app.run(SNKRXRoot())
341
342
343if __name__ == "__main__":
344 main()