SNKRX

Wave-based arena, particles, screenshake, slow-motion, bloom.

▶ Run in browser

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