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