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