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 Key, MouseButton # 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 sim = InputSimulator()
68
69 def click_at(x: float, y: float):
70 return lambda: sim.press_mouse(MouseButton.LEFT, (x, y))
71
72 def release():
73 return lambda: sim.release_mouse(MouseButton.LEFT)
74
75 def press_key(k: Key):
76 return lambda: sim.press_key(k)
77
78 def release_key(k: Key):
79 return lambda: sim.release_key(k)
80
81 from nodes.td_world import TowerDefenceWorld
82
83 # ------------------------------------------------------------------
84 # Stage 1: Menu
85 # ------------------------------------------------------------------
86 captures[30] = "stage_01_menu.png"
87
88 # Stage 2: enter game
89 actions[40] = press_key(Key.ENTER)
90 actions[42] = release_key(Key.ENTER)
91 captures[60] = "stage_02_world.png"
92
93 # ------------------------------------------------------------------
94 # Stage 3: place BASIC turret near the spawn corridor
95 # ------------------------------------------------------------------
96 bx, by = panel_pos(TowerDefenceWorld.BTN_BUY)
97 actions[70] = click_at(bx, by)
98 actions[72] = release()
99 cx, cy = tile_centre(11, 1) # adjacent to the W-running path on row 2
100 actions[80] = click_at(cx, cy)
101 actions[82] = release()
102 captures[90] = "stage_03_basic_placed.png"
103
104 # ------------------------------------------------------------------
105 # Stage 4: place SLOW + SNIPER along the path
106 # ------------------------------------------------------------------
107 nx, ny = panel_pos(TowerDefenceWorld.BTN_TYPE_NEXT)
108 actions[100] = click_at(nx, ny) # cycle to slow
109 actions[102] = release()
110 cx, cy = tile_centre(7, 4)
111 actions[110] = click_at(cx, cy)
112 actions[112] = release()
113
114 actions[120] = click_at(nx, ny) # cycle to sniper
115 actions[122] = release()
116 cx, cy = tile_centre(8, 8)
117 actions[130] = click_at(cx, cy)
118 actions[132] = release()
119
120 cx, cy = panel_pos(TowerDefenceWorld.BTN_CANCEL)
121 actions[140] = click_at(cx, cy)
122 actions[142] = release()
123 captures[150] = "stage_04_three_towers.png"
124
125 # ------------------------------------------------------------------
126 # Stage 5: begin wave 1 + engage fast-forward so the harness completes
127 # several waves in a tractable run length.
128 # ------------------------------------------------------------------
129 bx, by = panel_pos(TowerDefenceWorld.BTN_BEGIN)
130 actions[160] = click_at(bx, by)
131 actions[162] = release()
132 fx, fy = panel_pos(TowerDefenceWorld.BTN_FAST)
133 actions[170] = click_at(fx, fy)
134 actions[172] = release()
135 captures[300] = "stage_05_wave_running.png"
136
137 # ------------------------------------------------------------------
138 # Stage 6: select + upgrade BASIC turret mid-wave
139 # ------------------------------------------------------------------
140 cx, cy = tile_centre(11, 1)
141 actions[420] = click_at(cx, cy)
142 actions[422] = release()
143 actions[440] = press_key(Key.U)
144 actions[442] = release_key(Key.U)
145 captures[470] = "stage_06_upgraded.png"
146
147 # ------------------------------------------------------------------
148 # Stage 7: late wave 1
149 # ------------------------------------------------------------------
150 captures[700] = "stage_07_late_wave.png"
151
152 # ------------------------------------------------------------------
153 # Stage 8: wave 1 done -> begin wave 2
154 # ------------------------------------------------------------------
155 bx, by = panel_pos(TowerDefenceWorld.BTN_BEGIN)
156 actions[1100] = click_at(bx, by)
157 actions[1102] = release()
158 captures[1200] = "stage_08_wave2_start.png"
159
160 # Mid-wave-2: deselect + buy another basic with the level-up money.
161 actions[1250] = click_at(*panel_pos(TowerDefenceWorld.BTN_TYPE_PREV)) # back to slow
162 actions[1252] = release()
163 actions[1260] = click_at(*panel_pos(TowerDefenceWorld.BTN_TYPE_PREV)) # back to basic
164 actions[1262] = release()
165 actions[1280] = click_at(*panel_pos(TowerDefenceWorld.BTN_BUY))
166 actions[1282] = release()
167 cx, cy = tile_centre(0, 7) # grass adjacent to S-running path on col 1
168 actions[1300] = click_at(cx, cy)
169 actions[1302] = release()
170 actions[1310] = click_at(*panel_pos(TowerDefenceWorld.BTN_CANCEL))
171 actions[1312] = release()
172
173 # ------------------------------------------------------------------
174 # Stage 9: wave 2 done -> begin wave 3
175 # ------------------------------------------------------------------
176 bx, by = panel_pos(TowerDefenceWorld.BTN_BEGIN)
177 actions[2300] = click_at(bx, by)
178 actions[2302] = release()
179 captures[2400] = "stage_09_wave3_start.png"
180 captures[2700] = "stage_10_late_game.png"
181
182 total_frames = 2900
183
184 def on_frame(idx: int, t: float):
185 fn = actions.get(idx)
186 if fn is not None:
187 fn()
188
189 def capture_fn(idx: int) -> bool:
190 return idx in captures
191
192 frames = app.run_headless(
193 TowerDefenceRoot(),
194 frames=total_frames,
195 on_frame=on_frame,
196 capture_fn=capture_fn,
197 )
198
199 capture_indices = sorted(captures.keys())
200 for fi, img in zip(capture_indices, frames, strict=False):
201 out = OUT / captures[fi]
202 save_png(img, out)
203 print(f"saved {out}")
204 print(f"{len(capture_indices)} screenshots written")
205
206
207if __name__ == "__main__":
208 capture_sequence()