game.pyΒΆ
Part of Tic Tac Toe.
1"""Tic Tac Toe -- UI Widget Game
2
3A classic two-player Tic Tac Toe built entirely with SimVX UI widgets.
4Demonstrates buttons, grids, labels, signals, and game state management
5without any custom draw code.
6
7## What You Will Learn
8
9- **UI widgets** -- Build interfaces with `Button`, `Label`, `GridContainer`, `VBoxContainer`
10- **Signal connections** -- Wire button presses to game logic with `btn.pressed.connect()`
11- **GridContainer** -- Automatic grid layout for the 3x3 board
12- **Dynamic UI updates** -- Change button text, colours, and label content at runtime
13- **Game reset** -- Clear and reinitialise UI state
14
15## Controls
16
17Click any empty cell to place X or O. "New Game" clears the board while a round is
18in play; once a round ends, `TicTacToeApp` shows the result overlay instead.
19
20## How It Works
21
22`TicTacToeGame` builds the UI in `on_ready()`:
23
241. A `VBoxContainer` holds the title, status label, game grid, and reset button
252. A `GridContainer` with `columns=3` contains 9 `Button` widgets for the cells
263. Each button's `pressed` signal connects to `make_move(row, col)` using a lambda
274. `make_move()` places the current player's mark, updates the button's text and
28 colour, then calls `_check_winner()` to scan rows, columns, and diagonals
295. A win or draw updates the status label and emits the `game_over` signal
30"""
31
32from menu import MainMenu, ScoreBoard
33
34from simvx.core import (
35 AnchorPreset,
36 Button,
37 Colour,
38 GridContainer,
39 Label,
40 Node,
41 Signal,
42 VBoxContainer,
43)
44from simvx.core.math.types import Vec2
45from simvx.graphics import App
46
47# Window size, shared by main.py, web.py, and demo.py so a resize needs one edit.
48SCREEN_W = 400
49SCREEN_H = 550
50
51
52class TicTacToeGame(Node):
53 """Complete Tic Tac Toe game with UI."""
54
55 def __init__(self, **kwargs):
56 super().__init__(**kwargs)
57 self.name = "TicTacToeGame"
58 self.board: list[list[str | None]] = [[None] * 3 for _ in range(3)]
59 self.current_player = "X"
60 self.winner: str | None = None
61 self.game_over = Signal()
62
63 # UI references (set in ready)
64 self.cells: list[list[Button | None]] = [[None] * 3 for _ in range(3)]
65 self.status_label: Label | None = None
66 self.new_game_btn: Button | None = None
67
68 def on_ready(self):
69 root = VBoxContainer(name="Root")
70 # Top-centred 360x440 column. Anchored CENTER_TOP so the board sits near the
71 # top of the viewport at any window size and remains horizontally centred.
72 root.set_anchor_preset(AnchorPreset.CENTER_TOP)
73 root.margin_left = -180
74 root.margin_right = 180
75 root.margin_top = 20
76 root.margin_bottom = 460
77 root.size = Vec2(360, 440)
78 root.separation = 10
79
80 # Title
81 title = Label("Tic Tac Toe", name="Title")
82 title.font_size = 24.0
83 title.text_colour = Colour.WHITE
84 title.alignment = "center"
85 title.size = Vec2(360, 36)
86 root.add_child(title)
87
88 # Status
89 self.status_label = Label("Player X's turn", name="Status")
90 self.status_label.font_size = 16.0
91 self.status_label.text_colour = (0.7, 0.9, 1.0, 1.0)
92 self.status_label.alignment = "center"
93 self.status_label.size = Vec2(360, 24)
94 root.add_child(self.status_label)
95
96 # Grid
97 grid = GridContainer(columns=3, name="Grid")
98 grid.size = Vec2(360, 330)
99 grid.separation = 6
100
101 for row in range(3):
102 for col in range(3):
103 btn = Button("", name=f"Cell_{row}_{col}")
104 btn.size = Vec2(110, 100)
105 btn.font_size = 36.0
106 btn.bg_colour = (0.15, 0.15, 0.2, 1.0)
107 btn.hover_colour = (0.25, 0.25, 0.35, 1.0)
108 btn.pressed_colour = (0.1, 0.1, 0.15, 1.0)
109 btn.border_colour = (0.4, 0.4, 0.5, 1.0)
110 r, c = row, col
111 btn.pressed.connect(lambda r=r, c=c: self.make_move(r, c))
112 grid.add_child(btn)
113 self.cells[row][col] = btn
114
115 root.add_child(grid)
116
117 # New Game button
118 self.new_game_btn = Button("New Game", name="NewGame")
119 self.new_game_btn.size = Vec2(360, 35)
120 self.new_game_btn.bg_colour = (0.2, 0.5, 0.3, 1.0)
121 self.new_game_btn.hover_colour = (0.3, 0.6, 0.4, 1.0)
122 self.new_game_btn.pressed.connect(self.reset)
123 root.add_child(self.new_game_btn)
124
125 self.add_child(root)
126
127 def make_move(self, row: int, col: int) -> bool:
128 """Place current player's mark. Returns True if move was valid."""
129 if self.winner is not None or self.board[row][col] is not None:
130 return False
131 self.board[row][col] = self.current_player
132 btn = self.cells[row][col]
133 btn.text = self.current_player
134 btn.text_colour = (0.3, 0.8, 1.0, 1.0) if self.current_player == "X" else (1.0, 0.4, 0.4, 1.0)
135 self._check_winner()
136 if self.winner is None:
137 self.current_player = "O" if self.current_player == "X" else "X"
138 self.status_label.text = f"Player {self.current_player}'s turn"
139 return True
140
141 def _check_winner(self):
142 b = self.board
143 lines = [
144 # Rows
145 [(0, 0), (0, 1), (0, 2)],
146 [(1, 0), (1, 1), (1, 2)],
147 [(2, 0), (2, 1), (2, 2)],
148 # Columns
149 [(0, 0), (1, 0), (2, 0)],
150 [(0, 1), (1, 1), (2, 1)],
151 [(0, 2), (1, 2), (2, 2)],
152 # Diagonals
153 [(0, 0), (1, 1), (2, 2)],
154 [(0, 2), (1, 1), (2, 0)],
155 ]
156 for line in lines:
157 vals = [b[r][c] for r, c in line]
158 if vals[0] is not None and vals[0] == vals[1] == vals[2]:
159 self.winner = vals[0]
160 self.status_label.text = f"Player {self.winner} wins!"
161 self.status_label.text_colour = (0.2, 1.0, 0.4, 1.0)
162 self.game_over()
163 return
164 # Check draw
165 if all(b[r][c] is not None for r in range(3) for c in range(3)):
166 self.winner = "draw"
167 self.status_label.text = "It's a draw!"
168 self.status_label.text_colour = (1.0, 1.0, 0.4, 1.0)
169 self.game_over()
170
171 def reset(self):
172 """Reset the board for a new game."""
173 self.board = [[None] * 3 for _ in range(3)]
174 self.current_player = "X"
175 self.winner = None
176 for row in range(3):
177 for col in range(3):
178 self.cells[row][col].text = ""
179 self.status_label.text = f"Player {self.current_player}'s turn"
180 self.status_label.text_colour = (0.7, 0.9, 1.0, 1.0)
181 self.new_game_btn.disabled = False
182
183
184class TicTacToeApp(Node):
185 """Root node: manages menu and game scenes, tracks scores.
186
187 State machine:
188 menu β game β result β menu (loop)
189 """
190
191 def __init__(self, **kw):
192 super().__init__(**kw)
193 self.name = "TicTacToeApp"
194 self.scores = None
195 self.state = "menu" # "menu" | "game" | "result"
196 self.menu = None
197 self.game: TicTacToeGame | None = None
198 self._result_ui: Node | None = None
199
200 def on_ready(self):
201 self.scores = ScoreBoard()
202 self._show_menu()
203
204 def _show_menu(self):
205 self.state = "menu"
206 self._clear_game()
207 self._clear_result()
208 self.menu = MainMenu(self.scores)
209 self.menu.play_pressed.connect(self._start_game)
210 self.menu.quit_pressed.connect(self._quit)
211 self.add_child(self.menu)
212
213 def _start_game(self):
214 self.state = "game"
215 if self.menu:
216 self.menu.destroy()
217 self.menu = None
218 self.game = TicTacToeGame()
219 self.game.game_over.connect(self._on_game_over)
220 self.add_child(self.game)
221
222 def _on_game_over(self):
223 if not self.game:
224 return
225 self.scores.record(self.game.winner if self.game.winner != "draw" else None)
226 self.state = "result"
227 # The round is over: the overlay owns what happens next, so the board's own
228 # New Game button stops accepting clicks until a fresh game is built.
229 self.game.new_game_btn.disabled = True
230 self._show_result_overlay()
231
232 def _show_result_overlay(self):
233 self._clear_result()
234 self._result_ui = Node(name="ResultOverlay")
235
236 layout = VBoxContainer(name="ResultLayout")
237 # Centred result overlay: 360x140 centred on the viewport, offset slightly
238 # below centre so it doesn't cover the board's title row.
239 layout.set_anchor_preset(AnchorPreset.CENTER)
240 layout.margin_left = -180
241 layout.margin_right = 180
242 layout.margin_top = -30
243 layout.margin_bottom = 110
244 layout.size = Vec2(360, 140)
245 layout.separation = 10
246
247 # Play Again
248 again = Button("Play Again", name="PlayAgain")
249 again.size = Vec2(360, 45)
250 again.font_size = 18.0
251 again.bg_colour = (0.15, 0.35, 0.2, 1.0)
252 again.hover_colour = (0.2, 0.45, 0.3, 1.0)
253 again.pressed_colour = (0.1, 0.25, 0.15, 1.0)
254 again.border_colour = (0.3, 0.6, 0.4, 1.0)
255 again.pressed.connect(self._play_again)
256 layout.add_child(again)
257
258 # Back to Menu
259 back = Button("Back to Menu", name="BackToMenu")
260 back.size = Vec2(360, 40)
261 back.font_size = 16.0
262 back.bg_colour = (0.15, 0.15, 0.2, 1.0)
263 back.hover_colour = (0.2, 0.2, 0.3, 1.0)
264 back.pressed_colour = (0.1, 0.1, 0.15, 1.0)
265 back.border_colour = (0.3, 0.3, 0.4, 1.0)
266 back.pressed.connect(self._show_menu)
267 layout.add_child(back)
268
269 self._result_ui.add_child(layout)
270 self.add_child(self._result_ui)
271
272 def _play_again(self):
273 self._clear_result()
274 self._clear_game()
275 self._start_game()
276
277 def _clear_game(self):
278 if self.game:
279 self.game.destroy()
280 self.game = None
281
282 def _clear_result(self):
283 if self._result_ui:
284 self._result_ui.destroy()
285 self._result_ui = None
286
287 def _quit(self):
288 self.app.quit()
289
290
291if __name__ == "__main__":
292 App(title="Tic Tac Toe", width=SCREEN_W, height=SCREEN_H).run(TicTacToeApp())