nodes/harness.pyΒΆ

Part of GDQuest Open RPG.

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