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