demo.py¶
Part of Tic Tac Toe.
1"""
2Tic Tac Toe: Scripted demo with menu, gameplay, and verification.
3
4Demonstrates the full game flow: menu with score tracking, two-player
5gameplay with UI widgets, and result overlay with Play Again / Back to Menu.
6
7Run modes:
8 uv run python examples/demos/tictactoe/demo.py # Visual demo
9 uv run python examples/demos/tictactoe/demo.py --test # Headless test (exit 0/1)
10 uv run python examples/demos/tictactoe/demo.py --speed 3 # Faster visual demo
11"""
12
13import argparse
14import sys
15from dataclasses import dataclass
16
17from simvx.core import Node
18from simvx.core.scripted_demo import Assert, Click, DemoRunner, Narrate, Wait
19
20# ============================================================================
21# Cell coordinate helpers: computed from TicTacToeGame layout geometry
22# ============================================================================
23
24# Layout constants (must match game.py)
25_VBOX_POS = (20, 20)
26_VBOX_SEP = 10
27_TITLE_H = 36
28_STATUS_H = 24
29_GRID_SEP = 6
30_GRID_W = 360
31_GRID_COLS = 3
32_CELL_H = 100
33_NEWGAME_H = 35
34
35_GRID_Y = _VBOX_POS[1] + _TITLE_H + _VBOX_SEP + _STATUS_H + _VBOX_SEP
36_CELL_W = (_GRID_W - _GRID_SEP * (_GRID_COLS - 1)) / _GRID_COLS # 116
37
38
39def cell_center(row: int, col: int) -> tuple[float, float]:
40 """Compute pixel center of a cell button given grid row/col."""
41 x = _VBOX_POS[0] + col * (_CELL_W + _GRID_SEP) + _CELL_W / 2
42 y = _GRID_Y + row * (_CELL_H + _GRID_SEP) + _CELL_H / 2
43 return (x, y)
44
45
46_GRID_H = 330 # As set in game.py
47_NEWGAME_LOCAL_Y = _TITLE_H + _VBOX_SEP + _STATUS_H + _VBOX_SEP + _GRID_H + _VBOX_SEP # 420
48
49
50def new_game_center() -> tuple[float, float]:
51 """Compute pixel center of the New Game button."""
52 global_y = _VBOX_POS[1] + _NEWGAME_LOCAL_Y + _NEWGAME_H / 2
53 return (_VBOX_POS[0] + _GRID_W / 2, global_y)
54
55
56# ============================================================================
57# Helpers
58# ============================================================================
59
60
61def _app(root: Node):
62 """Get the TicTacToeApp from the root."""
63 from game import TicTacToeApp
64
65 for c in root.children:
66 if isinstance(c, TicTacToeApp):
67 return c
68 raise RuntimeError("TicTacToeApp not found")
69
70
71def _widget(root: Node, path: str):
72 """Resolve a widget below the app by node path, or None if it is not on screen."""
73 try:
74 return _app(root)[path]
75 except (KeyError, ValueError):
76 return None
77
78
79# ============================================================================
80# Node-targeted click step
81# ============================================================================
82
83
84@dataclass
85class ClickWidget:
86 """Click a widget addressed by node path.
87
88 Board cells sit at fixed offsets, so they are clicked by coordinate. The menu and
89 the result overlay are anchor-centred and move with the window, so their buttons
90 are located from the live layout instead of from mirrored geometry.
91 """
92
93 path: str
94
95
96def _handle_click_widget(runner: DemoRunner, step: ClickWidget, dt: float) -> None:
97 """Resolve the widget's screen rect, then hand the step to the runner's own Click."""
98 widget = _widget(runner.parent, step.path)
99 if widget is None:
100 raise AssertionError(f"ClickWidget: no widget at {step.path!r}")
101 x, y, w, h = widget.get_global_rect()
102 runner._steps[runner.current_step_index] = Click(x + w / 2, y + h / 2)
103 runner._step_time = 0.0
104
105
106DemoRunner.register_step_handler(ClickWidget, _handle_click_widget)
107
108
109# ============================================================================
110# Widget paths
111# ============================================================================
112
113_PLAY_BTN = "MainMenu/MenuLayout/PlayBtn"
114_SCORE_LABEL = "MainMenu/MenuLayout/Scores"
115_NEW_GAME_BTN = "TicTacToeGame/Root/NewGame"
116_PLAY_AGAIN_BTN = "ResultOverlay/ResultLayout/PlayAgain"
117_BACK_TO_MENU_BTN = "ResultOverlay/ResultLayout/BackToMenu"
118
119
120# ============================================================================
121# Demo steps
122# ============================================================================
123
124
125def build_steps() -> list:
126 """Build the full demo: menu → X wins → menu → draw → Play Again → X wins → menu."""
127 steps: list = []
128
129 # ── Phase 1: Menu ──────────────────────────────────────────────────
130 steps.append(Wait(0.3))
131 steps.append(Narrate("Welcome to Tic Tac Toe!", duration=2.0))
132
133 # Verify menu is showing
134 steps.append(Assert(lambda r: _app(r).state == "menu", "Should be in menu state"))
135 steps.append(Assert(lambda r: _app(r).menu is not None, "Menu node exists"))
136
137 def _check_menu_structure(root):
138 title = _widget(root, "MainMenu/MenuLayout/Title")
139 play = _widget(root, _PLAY_BTN)
140 return title is not None and play is not None and title.text == "TIC TAC TOE" and play.text == "Play"
141
142 steps.append(Assert(_check_menu_structure, "Menu has Title and Play button"))
143 steps.append(Assert(lambda r: _app(r).scores.total == 0, "Initial score total is 0"))
144
145 steps.append(Narrate("Scores start at zero. Let's play!", duration=1.5))
146
147 # Click Play
148 steps.append(ClickWidget(_PLAY_BTN))
149 steps.append(Wait(0.3))
150
151 # ── Phase 2: Game 1: X wins (center column) ──────────────────────
152 steps.append(Assert(lambda r: _app(r).state == "game", "Should be in game state"))
153 steps.append(Assert(lambda r: _app(r).game is not None, "Game exists"))
154
155 steps.append(Narrate("X goes first!", duration=1.0))
156
157 # X plays center (1,1)
158 cx, cy = cell_center(1, 1)
159 steps.append(Narrate("X plays center.", duration=0.8))
160 steps.append(Click(cx, cy))
161 steps.append(Wait(0.1))
162 steps.append(Assert(lambda r: _app(r).game.board[1][1] == "X", "Center should be X"))
163
164 # O plays top-left (0,0)
165 cx, cy = cell_center(0, 0)
166 steps.append(Narrate("O plays top-left.", duration=0.8))
167 steps.append(Click(cx, cy))
168 steps.append(Wait(0.1))
169 steps.append(Assert(lambda r: _app(r).game.board[0][0] == "O", "Top-left should be O"))
170
171 # X plays (0,1)
172 cx, cy = cell_center(0, 1)
173 steps.append(Narrate("X plays top-center.", duration=0.8))
174 steps.append(Click(cx, cy))
175 steps.append(Wait(0.1))
176 steps.append(Assert(lambda r: _app(r).game.board[0][1] == "X", "Top-center should be X"))
177
178 # O plays (2,2)
179 cx, cy = cell_center(2, 2)
180 steps.append(Narrate("O plays bottom-right.", duration=0.8))
181 steps.append(Click(cx, cy))
182 steps.append(Wait(0.1))
183 steps.append(Assert(lambda r: _app(r).game.board[2][2] == "O", "Bottom-right should be O"))
184
185 # X plays (2,1) → X wins column 1
186 cx, cy = cell_center(2, 1)
187 steps.append(Narrate("X plays bottom-center: wins column!", duration=1.5))
188 steps.append(Click(cx, cy))
189 steps.append(Wait(0.1))
190 steps.append(Assert(lambda r: _app(r).game.board[2][1] == "X", "Bottom-center should be X"))
191 steps.append(Assert(lambda r: _app(r).game.winner == "X", "X should have won"))
192
193 # Score updated
194 steps.append(Assert(lambda r: _app(r).scores.x_wins == 1, "X has 1 win"))
195 steps.append(Assert(lambda r: _app(r).scores.total == 1, "Total games == 1"))
196
197 # Result overlay showing
198 steps.append(Assert(lambda r: _app(r).state == "result", "In result state"))
199 steps.append(Assert(lambda r: _widget(r, _PLAY_AGAIN_BTN) is not None, "Result overlay exists"))
200
201 # The finished board must not accept a restart behind the overlay.
202 steps.append(Narrate("The board locks: only the overlay can start the next round.", duration=1.5))
203 steps.append(Assert(lambda r: _app(r).game.new_game_btn.disabled, "New Game is locked during the result"))
204 steps.append(ClickWidget(_NEW_GAME_BTN))
205 steps.append(Wait(0.1))
206 steps.append(Assert(lambda r: _app(r).state == "result", "Still in result state after clicking New Game"))
207 steps.append(Assert(lambda r: _app(r).game.board[1][1] == "X", "Finished board is untouched"))
208
209 steps.append(Narrate("X wins! Back to menu to check scores...", duration=1.5))
210
211 # Go back to menu
212 steps.append(ClickWidget(_BACK_TO_MENU_BTN))
213 steps.append(Wait(0.3))
214
215 # Verify menu shows updated scores
216 steps.append(Assert(lambda r: _app(r).state == "menu", "Back in menu"))
217
218 def _check_updated_scores(root):
219 label = _widget(root, _SCORE_LABEL)
220 return label is not None and "X: 1" in label.text
221
222 steps.append(Assert(_check_updated_scores, "Menu shows X: 1"))
223
224 # ── Phase 3: Game 2: Draw ────────────────────────────────────────
225 steps.append(Narrate("Let's play again: this time to a draw!", duration=1.5))
226
227 steps.append(ClickWidget(_PLAY_BTN))
228 steps.append(Wait(0.3))
229
230 steps.append(Assert(lambda r: _app(r).state == "game", "In game state for game 2"))
231
232 # Draw sequence
233 draw_moves = [
234 (0, 0, "X"),
235 (1, 1, "O"),
236 (2, 0, "X"),
237 (1, 0, "O"),
238 (1, 2, "X"),
239 (0, 2, "O"),
240 (0, 1, "X"),
241 (2, 1, "O"),
242 (2, 2, "X"),
243 ]
244
245 steps.append(Narrate("Playing all 9 moves...", duration=1.0))
246
247 for row, col, player in draw_moves:
248 cx, cy = cell_center(row, col)
249 steps.append(Click(cx, cy))
250 steps.append(Wait(0.05))
251
252 def _chk(r, rr=row, cc=col, pp=player):
253 game = _app(r).game
254 return game and game.board[rr][cc] == pp
255
256 steps.append(Assert(_chk, f"Cell ({row},{col}) should be {player}"))
257
258 steps.append(Wait(0.1))
259 steps.append(Assert(lambda r: _app(r).game.winner == "draw", "Game should be a draw"))
260 steps.append(Assert(lambda r: _app(r).scores.draws == 1, "1 draw recorded"))
261 steps.append(Assert(lambda r: _app(r).scores.total == 2, "Total games == 2"))
262
263 # Use Play Again from result overlay
264 steps.append(Narrate("Testing Play Again...", duration=1.0))
265
266 steps.append(ClickWidget(_PLAY_AGAIN_BTN))
267 steps.append(Wait(0.3))
268
269 steps.append(Assert(lambda r: _app(r).state == "game", "In game state for game 3"))
270 steps.append(Assert(lambda r: _widget(r, _PLAY_AGAIN_BTN) is None, "Result overlay cleared"))
271
272 def _check_empty_board(root):
273 game = _app(root).game
274 return game and all(game.board[r][c] is None for r in range(3) for c in range(3))
275
276 steps.append(Assert(_check_empty_board, "All cells empty after Play Again"))
277 steps.append(Assert(lambda r: not _app(r).game.new_game_btn.disabled, "New Game is live again"))
278
279 # ── Phase 4: Game 3: X wins the top row, then back to the menu ────
280 steps.append(Narrate("One more round, then back to the menu.", duration=1.2))
281
282 for row, col in [(0, 0), (1, 0), (0, 1), (1, 1), (0, 2)]:
283 cx, cy = cell_center(row, col)
284 steps.append(Click(cx, cy))
285 steps.append(Wait(0.05))
286
287 steps.append(Assert(lambda r: _app(r).game.winner == "X", "X wins the top row"))
288 steps.append(Assert(lambda r: _app(r).state == "result", "Result state after game 3"))
289
290 steps.append(ClickWidget(_BACK_TO_MENU_BTN))
291 steps.append(Wait(0.3))
292
293 steps.append(Assert(lambda r: _app(r).state == "menu", "Final menu state"))
294
295 def _check_final_scores(root):
296 s = _app(root).scores
297 return s.x_wins == 2 and s.draws == 1 and s.total == 3
298
299 steps.append(Assert(_check_final_scores, "Final scores: X=2, Draws=1, Total=3"))
300
301 def _check_final_label(root):
302 label = _widget(root, _SCORE_LABEL)
303 return label is not None and "X: 2" in label.text
304
305 steps.append(Assert(_check_final_label, "Menu shows X: 2"))
306 steps.append(Narrate("All tests passed! Demo complete.", duration=2.0))
307
308 return steps
309
310
311# ============================================================================
312# Headless test runner
313# ============================================================================
314
315
316def run_headless(speed: float = 10.0) -> bool:
317 """Run the demo headlessly via SceneRunner. Returns True on success."""
318 from game import SCREEN_H, SCREEN_W, TicTacToeApp
319
320 root = Node(name="DemoRoot")
321 root.add_child(TicTacToeApp())
322 return DemoRunner.run_headless(
323 root,
324 build_steps(),
325 speed=speed,
326 screen_size=(SCREEN_W, SCREEN_H),
327 delay_between_steps=0.0,
328 )
329
330
331# ============================================================================
332# Visual demo (with App)
333# ============================================================================
334
335
336def run_visual(speed: float = 1.0, backend: str | None = None):
337 """Run the demo visually with the Vulkan backend."""
338 from game import SCREEN_H, SCREEN_W, TicTacToeApp
339
340 from simvx.graphics import App
341
342 root = Node(name="DemoRoot")
343 root.add_child(TicTacToeApp())
344 steps = build_steps()
345 runner = DemoRunner(steps, test_mode=False, speed=speed)
346 root.add_child(runner)
347
348 App(title="Tic Tac Toe Demo", width=SCREEN_W, height=SCREEN_H, backend=backend).run(root)
349
350
351# ============================================================================
352# Entry point
353# ============================================================================
354
355if __name__ == "__main__":
356 parser = argparse.ArgumentParser(description="Tic Tac Toe scripted demo")
357 parser.add_argument("--test", action="store_true", help="Headless test mode (exit 0/1)")
358 parser.add_argument("--speed", type=float, default=None, help="Playback speed multiplier")
359 parser.add_argument("--backend", type=str, default=None, choices=["glfw", "sdl3"], help="Windowing backend")
360 args = parser.parse_args()
361
362 if args.test:
363 speed = args.speed if args.speed is not None else 10.0
364 ok = run_headless(speed)
365 sys.exit(0 if ok else 1)
366 else:
367 speed = args.speed if args.speed is not None else 1.0
368 run_visual(speed, backend=args.backend)