harness.pyΒΆ
Part of Clear Code Zelda.
1#!/usr/bin/env python3
2"""Scripted input harness for the Zelda port: 15 stages, one screenshot each.
3
4Walks, slashes grass, attacks enemies, swaps weapon and spell, casts, and
5drives the upgrade screen, so a manual review is a matter of flipping through
6the captures.
7
8Run from the repo root:
9 uv run python examples/ports/clear_code_zelda/harness.py
10"""
11
12from __future__ import annotations
13
14import sys
15from collections.abc import Callable
16from pathlib import Path
17
18_PORT_DIR = Path(__file__).resolve().parent
19if str(_PORT_DIR) not in sys.path:
20 sys.path.insert(0, str(_PORT_DIR))
21
22from main import ZeldaRoot # noqa: E402
23from settings import HEIGHT, WIDTH # noqa: E402
24
25from simvx.core import Key, Node # noqa: E402
26from simvx.core.testing import InputSimulator # noqa: E402
27from simvx.graphics import App, save_png # noqa: E402
28
29
30def harness():
31 out = _PORT_DIR / "screenshots"
32 out.mkdir(exist_ok=True)
33
34 app = App(width=WIDTH, height=HEIGHT, title="Zelda harness", visible=False)
35 sim = InputSimulator()
36 root = ZeldaRoot(show_menu=False)
37
38 stages = [
39 # name, frames-to-advance, prep callback
40 ("01_initial", 30, lambda: None),
41 ("02_walk_down", 60, lambda: sim.press_key(Key.S)),
42 ("03_release_walk", 5, lambda: sim.release_key(Key.S)),
43 ("04_slash_grass", 20, lambda: sim.tap_key(Key.SPACE)),
44 ("05_attack_enemy", 5, lambda: sim.tap_key(Key.SPACE)), # capture mid-swing
45 ("05b_attack_settle", 25, lambda: None),
46 ("06_attack_enemy_2", 5, lambda: sim.tap_key(Key.SPACE)),
47 ("06b_attack_settle", 25, lambda: None),
48 ("07_swap_weapon", 20, lambda: sim.tap_key(Key.Q)),
49 ("08_swap_magic", 20, lambda: sim.tap_key(Key.E)),
50 ("09_cast_heal", 30, lambda: sim.tap_key(Key.LEFT_CONTROL)),
51 ("10_open_upgrade", 20, lambda: sim.tap_key(Key.M)),
52 ("11_upgrade_right", 25, lambda: sim.tap_key(Key.D)),
53 ("12_upgrade_select", 25, lambda: sim.tap_key(Key.SPACE)),
54 ("13_close_upgrade", 20, lambda: sim.tap_key(Key.M)),
55 ]
56
57 # First frame of each stage runs that stage's prep; the capture lands on the
58 # last frame of the stage, so the stage's effects are still on screen.
59 stage_starts: dict[int, Callable[[], None]] = {}
60 target_frames = []
61 cumulative = 0
62 for _name, count, prep in stages:
63 stage_starts[cumulative] = prep
64 cumulative += count
65 target_frames.append(cumulative - 1)
66
67 # A few buffer frames so the very last capture lands cleanly.
68 total = cumulative + 5
69 frame_counter = {"n": 0}
70
71 class HarnessDriver(Node):
72 """Fires each stage's prep on the first frame of that stage."""
73
74 def on_update(self, dt):
75 # `tap_key` queues its release for the next frame boundary, and this
76 # loop is not SceneRunner, so the queue has to be drained by hand:
77 # a key left held never produces another just-pressed edge.
78 InputSimulator.flush_pending_releases()
79 prep = stage_starts.get(frame_counter["n"])
80 if prep is not None:
81 prep()
82 frame_counter["n"] += 1
83
84 root.add_child(HarnessDriver())
85
86 frames = app.run_headless(root, frames=total, capture_frames=target_frames)
87 for (name, _count, _prep), frame in zip(stages, frames, strict=False):
88 path = out / f"harness_{name}.png"
89 save_png(frame, str(path))
90 print(f"saved {path}")
91
92
93if __name__ == "__main__":
94 harness()