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 Anchor,
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 # Top-centred 360x440 column. Anchored CENTER_TOP so the board sits near the
70 # top of the viewport at any window size and remains horizontally centred.
71 root = VBoxContainer(name="Root", anchor=Anchor.CENTER_TOP, inset=(0, 20), size=(360, 440))
72 root.separation = 10
73
74 # Title
75 title = Label("Tic Tac Toe", name="Title")
76 title.font_size = 24.0
77 title.text_colour = Colour.WHITE
78 title.alignment = "centre"
79 title.size = Vec2(360, 36)
80 root.add_child(title)
81
82 # Status
83 self.status_label = Label("Player X's turn", name="Status")
84 self.status_label.font_size = 16.0
85 self.status_label.text_colour = (0.7, 0.9, 1.0, 1.0)
86 self.status_label.alignment = "centre"
87 self.status_label.size = Vec2(360, 24)
88 root.add_child(self.status_label)
89
90 # Grid
91 grid = GridContainer(columns=3, name="Grid")
92 grid.size = Vec2(360, 330)
93 grid.separation = 6
94
95 for row in range(3):
96 for col in range(3):
97 btn = Button("", name=f"Cell_{row}_{col}")
98 btn.size = Vec2(110, 100)
99 btn.font_size = 36.0
100 btn.bg_colour = (0.15, 0.15, 0.2, 1.0)
101 btn.hover_colour = (0.25, 0.25, 0.35, 1.0)
102 btn.pressed_colour = (0.1, 0.1, 0.15, 1.0)
103 btn.border_colour = (0.4, 0.4, 0.5, 1.0)
104 r, c = row, col
105 btn.pressed.connect(lambda r=r, c=c: self.make_move(r, c))
106 grid.add_child(btn)
107 self.cells[row][col] = btn
108
109 root.add_child(grid)
110
111 # New Game button
112 self.new_game_btn = Button("New Game", name="NewGame")
113 self.new_game_btn.size = Vec2(360, 35)
114 self.new_game_btn.bg_colour = (0.2, 0.5, 0.3, 1.0)
115 self.new_game_btn.hover_colour = (0.3, 0.6, 0.4, 1.0)
116 self.new_game_btn.pressed.connect(self.reset)
117 root.add_child(self.new_game_btn)
118
119 self.add_child(root)
120
121 def make_move(self, row: int, col: int) -> bool:
122 """Place current player's mark. Returns True if move was valid."""
123 if self.winner is not None or self.board[row][col] is not None:
124 return False
125 self.board[row][col] = self.current_player
126 btn = self.cells[row][col]
127 btn.text = self.current_player
128 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)
129 self._check_winner()
130 if self.winner is None:
131 self.current_player = "O" if self.current_player == "X" else "X"
132 self.status_label.text = f"Player {self.current_player}'s turn"
133 return True
134
135 def _check_winner(self):
136 b = self.board
137 lines = [
138 # Rows
139 [(0, 0), (0, 1), (0, 2)],
140 [(1, 0), (1, 1), (1, 2)],
141 [(2, 0), (2, 1), (2, 2)],
142 # Columns
143 [(0, 0), (1, 0), (2, 0)],
144 [(0, 1), (1, 1), (2, 1)],
145 [(0, 2), (1, 2), (2, 2)],
146 # Diagonals
147 [(0, 0), (1, 1), (2, 2)],
148 [(0, 2), (1, 1), (2, 0)],
149 ]
150 for line in lines:
151 vals = [b[r][c] for r, c in line]
152 if vals[0] is not None and vals[0] == vals[1] == vals[2]:
153 self.winner = vals[0]
154 self.status_label.text = f"Player {self.winner} wins!"
155 self.status_label.text_colour = (0.2, 1.0, 0.4, 1.0)
156 self.game_over.emit()
157 return
158 # Check draw
159 if all(b[r][c] is not None for r in range(3) for c in range(3)):
160 self.winner = "draw"
161 self.status_label.text = "It's a draw!"
162 self.status_label.text_colour = (1.0, 1.0, 0.4, 1.0)
163 self.game_over.emit()
164
165 def reset(self):
166 """Reset the board for a new game."""
167 self.board = [[None] * 3 for _ in range(3)]
168 self.current_player = "X"
169 self.winner = None
170 for row in range(3):
171 for col in range(3):
172 self.cells[row][col].text = ""
173 self.status_label.text = f"Player {self.current_player}'s turn"
174 self.status_label.text_colour = (0.7, 0.9, 1.0, 1.0)
175 self.new_game_btn.disabled = False
176
177
178class TicTacToeApp(Node):
179 """Root node: manages menu and game scenes, tracks scores.
180
181 State machine:
182 menu β game β result β menu (loop)
183 """
184
185 def __init__(self, **kw):
186 super().__init__(**kw)
187 self.name = "TicTacToeApp"
188 self.scores = None
189 self.state = "menu" # "menu" | "game" | "result"
190 self.menu = None
191 self.game: TicTacToeGame | None = None
192 self._result_ui: Node | None = None
193
194 def on_ready(self):
195 self.scores = ScoreBoard()
196 self._show_menu()
197
198 def _show_menu(self):
199 self.state = "menu"
200 self._clear_game()
201 self._clear_result()
202 self.menu = MainMenu(self.scores)
203 self.menu.play_pressed.connect(self._start_game)
204 self.menu.quit_pressed.connect(self._quit)
205 self.add_child(self.menu)
206
207 def _start_game(self):
208 self.state = "game"
209 if self.menu:
210 self.menu.destroy()
211 self.menu = None
212 self.game = TicTacToeGame()
213 self.game.game_over.connect(self._on_game_over)
214 self.add_child(self.game)
215
216 def _on_game_over(self):
217 if not self.game:
218 return
219 self.scores.record(self.game.winner if self.game.winner != "draw" else None)
220 self.state = "result"
221 # The round is over: the overlay owns what happens next, so the board's own
222 # New Game button stops accepting clicks until a fresh game is built.
223 self.game.new_game_btn.disabled = True
224 self._show_result_overlay()
225
226 def _show_result_overlay(self):
227 self._clear_result()
228 self._result_ui = Node(name="ResultOverlay")
229
230 # Centred result overlay: 360x140 centred on the viewport, offset 40px
231 # below centre (an 80px top inset) so it doesn't cover the board's title row.
232 layout = VBoxContainer(name="ResultLayout", anchor=Anchor.CENTER, inset=(0, 80, 0, 0), size=(360, 140))
233 layout.separation = 10
234
235 # Play Again
236 again = Button("Play Again", name="PlayAgain")
237 again.size = Vec2(360, 45)
238 again.font_size = 18.0
239 again.bg_colour = (0.15, 0.35, 0.2, 1.0)
240 again.hover_colour = (0.2, 0.45, 0.3, 1.0)
241 again.pressed_colour = (0.1, 0.25, 0.15, 1.0)
242 again.border_colour = (0.3, 0.6, 0.4, 1.0)
243 again.pressed.connect(self._play_again)
244 layout.add_child(again)
245
246 # Back to Menu
247 back = Button("Back to Menu", name="BackToMenu")
248 back.size = Vec2(360, 40)
249 back.font_size = 16.0
250 back.bg_colour = (0.15, 0.15, 0.2, 1.0)
251 back.hover_colour = (0.2, 0.2, 0.3, 1.0)
252 back.pressed_colour = (0.1, 0.1, 0.15, 1.0)
253 back.border_colour = (0.3, 0.3, 0.4, 1.0)
254 back.pressed.connect(self._show_menu)
255 layout.add_child(back)
256
257 self._result_ui.add_child(layout)
258 self.add_child(self._result_ui)
259
260 def _play_again(self):
261 self._clear_result()
262 self._clear_game()
263 self._start_game()
264
265 def _clear_game(self):
266 if self.game:
267 self.game.destroy()
268 self.game = None
269
270 def _clear_result(self):
271 if self._result_ui:
272 self._result_ui.destroy()
273 self._result_ui = None
274
275 def _quit(self):
276 self.app.quit()
277
278
279if __name__ == "__main__":
280 App(title="Tic Tac Toe", width=SCREEN_W, height=SCREEN_H).run(TicTacToeApp())