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