nodes/harness.pyΒΆ

Part of GDQuest Open RPG.

  1"""Headless screenshot capture: walks the player through the full demo loop.
  2
  3Run it from the port directory with ``python -m nodes.harness``.
  4
  5Input is driven through the public ``InputSimulator``, so every press travels
  6the same path a real keyboard takes.
  7
  8Captures 8 stages:
  9    00_title.png         title card
 10    01_overworld.png     overworld idle
 11    02_dialogue.png      NPC dialogue showing
 12    03_encounter.png     encounter dialogue ("*roars*") just before battle
 13    04_battle_player.png battle, player turn (action menu)
 14    05_battle_target.png battle, target cursor visible
 15    06_battle_enemy.png  battle mid-execute
 16    07_victory.png       VICTORY banner
 17"""
 18
 19from __future__ import annotations
 20
 21from collections.abc import Callable
 22from pathlib import Path
 23
 24from simvx.core.input.controls import DeviceButton, Key
 25from simvx.core.testing import InputSimulator
 26from simvx.graphics import App, save_png
 27
 28from .settings import HEIGHT, TITLE, WIDTH
 29
 30
 31def run_headless_capture() -> None:
 32    # Import here to avoid circular import at module load time
 33    from main import RPGRoot
 34
 35    out_dir = Path(__file__).resolve().parent.parent / "screenshots"
 36    out_dir.mkdir(exist_ok=True)
 37
 38    app = App(width=WIDTH, height=HEIGHT, title=TITLE, visible=False)
 39    root = RPGRoot()
 40    sim = InputSimulator(root)
 41
 42    captures: dict[int, str] = {}
 43    actions: dict[int, Callable[[], None]] = {}
 44
 45    def schedule_tap(at: int, key: DeviceButton, hold: int = 1) -> None:
 46        actions[at] = lambda: sim.press_key(key)
 47        actions[at + hold] = lambda: sim.release_key(key)
 48
 49    # Title card, then start the game.
 50    captures[10] = "00_title.png"
 51    schedule_tap(20, Key.ENTER, hold=2)
 52
 53    # Warm-up: let the overworld settle before the first walk.
 54    captures[40] = "01_overworld.png"
 55
 56    # Walk left to cell (6, 8); player starts at (15, 8). 9 left presses
 57    # (so we stop one cell to the right of the monk at (5, 8) and can interact).
 58    f = 60
 59    for _ in range(9):
 60        schedule_tap(f, Key.A, hold=1)
 61        f += 16  # 16 frames > 15-frame step duration to ensure step completes
 62    # By now player at (6, 8) facing left. Interact with (5, 8).
 63    schedule_tap(f + 30, Key.E, hold=1)
 64    captures[f + 70] = "02_dialogue.png"
 65
 66    # Advance dialogue 4 times (3 lines + 1 to close)
 67    f += 90
 68    for _ in range(5):
 69        schedule_tap(f, Key.SPACE, hold=2)
 70        f += 30
 71
 72    # Walk right to encounter trigger at (15, 8). We're at (6, 8) -> 9 right steps.
 73    for _ in range(9):
 74        schedule_tap(f, Key.D, hold=1)
 75        f += 16
 76    # Capture encounter dialog right after arrival (before any SPACE press).
 77    captures[f + 30] = "03_encounter.png"
 78
 79    # Encounter dialogue auto-shows; advance past it.
 80    f += 60
 81    for _ in range(3):
 82        schedule_tap(f, Key.SPACE, hold=2)
 83        f += 30
 84
 85    # Battle scene loaded. Wait for fade and spawn (~30 frames), then SELECT.
 86    f += 60
 87    captures[f] = "04_battle_player.png"
 88
 89    # Press DOWN once to move menu cursor to second action (e.g., wizard heal),
 90    # but for the first player (knight) only one action exists -> ENTER.
 91    schedule_tap(f + 8, Key.ENTER, hold=2)
 92    f += 30
 93    captures[f] = "05_battle_target.png"
 94
 95    # Confirm target. Then for wizard + squirrel, just ENTER through.
 96    schedule_tap(f + 8, Key.ENTER, hold=2)
 97    f += 25
 98    schedule_tap(f, Key.ENTER, hold=2)  # wizard action
 99    f += 20
100    schedule_tap(f, Key.ENTER, hold=2)  # wizard target
101    f += 25
102    schedule_tap(f, Key.ENTER, hold=2)  # squirrel action
103    f += 20
104    schedule_tap(f, Key.ENTER, hold=2)  # squirrel target
105    f += 30
106    captures[f] = "06_battle_enemy.png"
107
108    # Let actions resolve, repeat round if needed. Each ENTER advances either
109    # an action menu or a target cursor. The battle should resolve within ~80
110    # presses for 2 enemies vs 3 players.
111    start_battle_loop = f
112    for _ in range(80):
113        schedule_tap(f, Key.ENTER, hold=2)
114        f += 12
115    # Try capturing at multiple offsets to catch the VICTORY banner.
116    captures[start_battle_loop + 12 * 25] = "07_victory.png"
117
118    total_frames = f + 40
119
120    def on_frame(idx: int, t: float) -> None:
121        fn = actions.get(idx)
122        if fn is not None:
123            fn()
124
125    def capture_fn(idx: int) -> bool:
126        return idx in captures
127
128    frames = app.run_headless(
129        root,
130        frames=total_frames,
131        on_frame=on_frame,
132        capture_fn=capture_fn,
133    )
134
135    capture_indices = sorted(captures.keys())
136    saved = []
137    for fi, img in zip(capture_indices, frames, strict=False):
138        out = out_dir / captures[fi]
139        save_png(img, out)
140        saved.append(str(out))
141        print(f"saved {out}")
142    print(f"{len(saved)} screenshots written")
143
144
145if __name__ == "__main__":
146    run_headless_capture()