harness.pyΒΆ
Part of Tower Defence.
1"""Scripted-input harness for the Tower Defence port.
2
3Drives the game through multiple waves using simulated mouse + keyboard.
4Captures one screenshot per gameplay stage for visual verification.
5
6Stages:
7 01_menu -- title screen
8 02_world -- enter game, empty board
9 03_basic_placed -- one BASIC turret on the path
10 04_three_towers -- BASIC + SLOW + SNIPER all placed
11 05_wave_running -- wave 1 mid-way (enemies spawning + dying)
12 06_upgraded -- BASIC turret upgraded, range visible
13 07_late_wave -- late in wave 1
14 08_wave2_start -- wave 1 cleared, wave 2 begins
15 09_wave3_start -- wave 2 cleared, wave 3 begins
16 10_late_game -- mid wave 3 with multiple kills
17
18Run from the simvx repo root:
19 uv run python examples/ports/tower_defence_tut/harness.py
20"""
21
22from __future__ import annotations
23
24import sys
25from collections.abc import Callable
26from pathlib import Path
27
28_PORT_DIR = Path(__file__).parent
29if str(_PORT_DIR) not in sys.path:
30 sys.path.insert(0, str(_PORT_DIR))
31
32from main import WINDOW_HEIGHT, WINDOW_WIDTH, TowerDefenceRoot # noqa: E402
33from nodes.td_data import TILE_SIZE # noqa: E402
34
35from simvx.core import DeviceButton, Key, Mouse # noqa: E402
36from simvx.core.testing import InputSimulator # noqa: E402
37from simvx.graphics import App, save_png # noqa: E402
38
39OUT = _PORT_DIR / "screenshots"
40
41
42def tile_centre(tx: int, ty: int) -> tuple[float, float]:
43 return ((tx + 0.5) * TILE_SIZE, (ty + 0.5) * TILE_SIZE)
44
45
46def panel_pos(rect: tuple[float, float, float, float]) -> tuple[float, float]:
47 x, y, w, h = rect
48 return (x + w / 2, y + h / 2)
49
50
51def capture_sequence() -> None:
52 OUT.mkdir(exist_ok=True)
53 app = App(
54 width=WINDOW_WIDTH,
55 height=WINDOW_HEIGHT,
56 title="Tower Defence Harness",
57 visible=False,
58 )
59
60 captures: dict[int, str] = {}
61 actions: dict[int, Callable[[], None]] = {}
62
63 # Every action goes through the public input simulator, so the game sees the
64 # same state + events a real mouse and keyboard produce. Press and release are
65 # scheduled on separate frames: the game polls ``is_..._just_pressed``, so the
66 # press has to survive one full frame.
67
68 def click_at(x: float, y: float):
69 return lambda: sim.press_mouse(Mouse.LEFT, (x, y))
70
71 def release():
72 return lambda: sim.release_mouse(Mouse.LEFT)
73
74 def press_key(k: DeviceButton):
75 return lambda: sim.press_key(k)
76
77 def release_key(k: DeviceButton):
78 return lambda: sim.release_key(k)
79
80 from nodes.td_world import TowerDefenceWorld
81
82 # ------------------------------------------------------------------
83 # Stage 1: Menu
84 # ------------------------------------------------------------------
85 captures[30] = "stage_01_menu.png"
86
87 # Stage 2: enter game
88 actions[40] = press_key(Key.ENTER)
89 actions[42] = release_key(Key.ENTER)
90 captures[60] = "stage_02_world.png"
91
92 # ------------------------------------------------------------------
93 # Stage 3: place BASIC turret near the spawn corridor
94 # ------------------------------------------------------------------
95 bx, by = panel_pos(TowerDefenceWorld.BTN_BUY)
96 actions[70] = click_at(bx, by)
97 actions[72] = release()
98 cx, cy = tile_centre(11, 1) # adjacent to the W-running path on row 2
99 actions[80] = click_at(cx, cy)
100 actions[82] = release()
101 captures[90] = "stage_03_basic_placed.png"
102
103 # ------------------------------------------------------------------
104 # Stage 4: place SLOW + SNIPER along the path
105 # ------------------------------------------------------------------
106 nx, ny = panel_pos(TowerDefenceWorld.BTN_TYPE_NEXT)
107 actions[100] = click_at(nx, ny) # cycle to slow
108 actions[102] = release()
109 cx, cy = tile_centre(7, 4)
110 actions[110] = click_at(cx, cy)
111 actions[112] = release()
112
113 actions[120] = click_at(nx, ny) # cycle to sniper
114 actions[122] = release()
115 cx, cy = tile_centre(8, 8)
116 actions[130] = click_at(cx, cy)
117 actions[132] = release()
118
119 cx, cy = panel_pos(TowerDefenceWorld.BTN_CANCEL)
120 actions[140] = click_at(cx, cy)
121 actions[142] = release()
122 captures[150] = "stage_04_three_towers.png"
123
124 # ------------------------------------------------------------------
125 # Stage 5: begin wave 1 + engage fast-forward so the harness completes
126 # several waves in a tractable run length.
127 # ------------------------------------------------------------------
128 bx, by = panel_pos(TowerDefenceWorld.BTN_BEGIN)
129 actions[160] = click_at(bx, by)
130 actions[162] = release()
131 fx, fy = panel_pos(TowerDefenceWorld.BTN_FAST)
132 actions[170] = click_at(fx, fy)
133 actions[172] = release()
134 captures[300] = "stage_05_wave_running.png"
135
136 # ------------------------------------------------------------------
137 # Stage 6: select + upgrade BASIC turret mid-wave
138 # ------------------------------------------------------------------
139 cx, cy = tile_centre(11, 1)
140 actions[420] = click_at(cx, cy)
141 actions[422] = release()
142 actions[440] = press_key(Key.U)
143 actions[442] = release_key(Key.U)
144 captures[470] = "stage_06_upgraded.png"
145
146 # ------------------------------------------------------------------
147 # Stage 7: late wave 1
148 # ------------------------------------------------------------------
149 captures[700] = "stage_07_late_wave.png"
150
151 # ------------------------------------------------------------------
152 # Stage 8: wave 1 done -> begin wave 2
153 # ------------------------------------------------------------------
154 bx, by = panel_pos(TowerDefenceWorld.BTN_BEGIN)
155 actions[1100] = click_at(bx, by)
156 actions[1102] = release()
157 captures[1200] = "stage_08_wave2_start.png"
158
159 # Mid-wave-2: deselect + buy another basic with the level-up money.
160 actions[1250] = click_at(*panel_pos(TowerDefenceWorld.BTN_TYPE_PREV)) # back to slow
161 actions[1252] = release()
162 actions[1260] = click_at(*panel_pos(TowerDefenceWorld.BTN_TYPE_PREV)) # back to basic
163 actions[1262] = release()
164 actions[1280] = click_at(*panel_pos(TowerDefenceWorld.BTN_BUY))
165 actions[1282] = release()
166 cx, cy = tile_centre(0, 7) # grass adjacent to S-running path on col 1
167 actions[1300] = click_at(cx, cy)
168 actions[1302] = release()
169 actions[1310] = click_at(*panel_pos(TowerDefenceWorld.BTN_CANCEL))
170 actions[1312] = release()
171
172 # ------------------------------------------------------------------
173 # Stage 9: wave 2 done -> begin wave 3
174 # ------------------------------------------------------------------
175 bx, by = panel_pos(TowerDefenceWorld.BTN_BEGIN)
176 actions[2300] = click_at(bx, by)
177 actions[2302] = release()
178 captures[2400] = "stage_09_wave3_start.png"
179 captures[2700] = "stage_10_late_game.png"
180
181 total_frames = 2900
182
183 def on_frame(idx: int, t: float):
184 fn = actions.get(idx)
185 if fn is not None:
186 fn()
187
188 def capture_fn(idx: int) -> bool:
189 return idx in captures
190
191 root = TowerDefenceRoot()
192 sim = InputSimulator(root)
193 frames = app.run_headless(
194 root,
195 frames=total_frames,
196 on_frame=on_frame,
197 capture_fn=capture_fn,
198 )
199
200 capture_indices = sorted(captures.keys())
201 for fi, img in zip(capture_indices, frames, strict=False):
202 out = OUT / captures[fi]
203 save_png(img, out)
204 print(f"saved {out}")
205 print(f"{len(capture_indices)} screenshots written")
206
207
208if __name__ == "__main__":
209 capture_sequence()