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