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