diff --git a/examples/five_by_five.css b/examples/five_by_five.css new file mode 100644 index 0000000000..8901d777af --- /dev/null +++ b/examples/five_by_five.css @@ -0,0 +1,88 @@ +$animation-type: linear; +$animatin-speed: 175ms; + +Game { + align: center middle; + layers: gameplay messages; +} + +GameGrid { + layout: grid; + grid-size: 5 5; + layer: gameplay; +} + +GameHeader { + background: $primary-background; + color: $text; + height: 1; + dock: top; + layer: gameplay; +} + +GameHeader #app-title { + width: 60%; +} + +GameHeader #moves { + width: 20%; +} + +GameHeader #progress { + width: 20%; +} + +Footer { + height: 1; + dock: bottom; + layer: gameplay; +} + +GameCell { + width: 100%; + height: 100%; + background: $surface; + border: round $surface-darken-1; + transition: background $animatin-speed $animation-type, color $animatin-speed $animation-type; +} + +GameCell:hover { + background: $panel-lighten-1; + border: round $panel; +} + +GameCell.filled { + background: $secondary; + border: round $secondary-darken-1; +} + +GameCell.filled:hover { + background: $secondary-lighten-1; + border: round $secondary; +} + +WinnerMessage { + width: 50%; + height: 25%; + layer: messages; + visibility: hidden; + content-align: center middle; + text-align: center; + background: $success; + color: $text; + border: round; + padding: 2; +} + +.visible { + visibility: visible; +} + +Help { + background: $primary; + color: $text; + border: round $primary-lighten-3; + padding: 2; +} + +/* five_by_five.css ends here */ diff --git a/examples/five_by_five.md b/examples/five_by_five.md new file mode 100644 index 0000000000..6fcc887bba --- /dev/null +++ b/examples/five_by_five.md @@ -0,0 +1,17 @@ +# 5x5 + +## Introduction + +An annoying puzzle for the terminal, built with +[Textual](https://www.textualize.io/). + +## Objective + +The object of the game is to fill all of the squares. When you click on a +square, it, and the squares above, below and to the sides will be toggled. + +It is possible to solve the puzzle in as few as 14 moves. + +Good luck! + +[//]: # (README.md ends here) diff --git a/examples/five_by_five.py b/examples/five_by_five.py new file mode 100644 index 0000000000..4c6004aef9 --- /dev/null +++ b/examples/five_by_five.py @@ -0,0 +1,330 @@ +"""Simple version of 5x5, developed for/with Textual.""" + +from pathlib import Path +from typing import cast +import sys + +if sys.version_info >= (3, 8): + from typing import Final +else: + from typing_extensions import Final + +from textual.containers import Horizontal +from textual.app import App, ComposeResult +from textual.screen import Screen +from textual.widget import Widget +from textual.widgets import Footer, Button, Static +from textual.css.query import DOMQuery +from textual.reactive import reactive +from textual.binding import Binding + +from rich.markdown import Markdown + + +class Help(Screen): + """The help screen for the application.""" + + #: Bindings for the help screen. + BINDINGS = [("escape,space,q,question_mark", "pop_screen", "Close")] + + def compose(self) -> ComposeResult: + """Compose the game's help. + + Returns: + ComposeResult: The result of composing the help screen. + """ + yield Static(Markdown(Path(__file__).with_suffix(".md").read_text())) + + +class WinnerMessage(Static): + """Widget to tell the user they have won.""" + + #: The minimum number of moves you can solve the puzzle in. + MIN_MOVES: Final = 14 + + @staticmethod + def _plural(value: int) -> str: + return "" if value == 1 else "s" + + def show(self, moves: int) -> None: + """Show the winner message. + + Args: + moves (int): The number of moves required to win. + """ + self.update( + "W I N N E R !\n\n\n" + f"You solved the puzzle in {moves} move{self._plural(moves)}." + + ( + ( + f" It is possible to solve the puzzle in {self.MIN_MOVES}, " + f"you were {moves - self.MIN_MOVES} move{self._plural(moves - self.MIN_MOVES)} over." + ) + if moves > self.MIN_MOVES + else " Well done! That's the minimum number of moves to solve the puzzle!" + ) + ) + self.add_class("visible") + + def hide(self) -> None: + """Hide the winner message.""" + self.remove_class("visible") + + +class GameHeader(Widget): + """Header for the game. + + Comprises of the title (``#app-title``), the number of moves ``#moves`` + and the count of how many cells are turned on (``#progress``). + """ + + #: Keep track of how many moves the player has made. + moves = reactive(0) + + #: Keep track of how many cells are filled. + filled = reactive(0) + + def compose(self) -> ComposeResult: + """Compose the game header. + + Returns: + ComposeResult: The result of composing the game header. + """ + yield Horizontal( + Static(self.app.title, id="app-title"), + Static(id="moves"), + Static(id="progress"), + ) + + def watch_moves(self, moves: int): + """Watch the moves reactive and update when it changes. + + Args: + moves (int): The number of moves made. + """ + self.query_one("#moves", Static).update(f"Moves: {moves}") + + def watch_filled(self, filled: int): + """Watch the on-count reactive and update when it changes. + + Args: + filled (int): The number of cells that are currently on. + """ + self.query_one("#progress", Static).update(f"Filled: {filled}") + + +class GameCell(Button): + """Individual playable cell in the game.""" + + @staticmethod + def at(row: int, col: int) -> str: + """Get the ID of the cell at the given location. + + Args: + row (int): The row of the cell. + col (int): The column of the cell. + + Returns: + str: A string ID for the cell. + """ + return f"cell-{row}-{col}" + + def __init__(self, row: int, col: int) -> None: + """Initialise the game cell. + + Args: + row (int): The row of the cell. + col (int): The column of the cell. + + """ + super().__init__("", id=self.at(row, col)) + self.row = row + self.col = col + + +class GameGrid(Widget): + """The main playable grid of game cells.""" + + def compose(self) -> ComposeResult: + """Compose the game grid. + + Returns: + ComposeResult: The result of composing the game grid. + """ + for row in range(Game.SIZE): + for col in range(Game.SIZE): + yield GameCell(row, col) + + +class Game(Screen): + """Main 5x5 game grid screen.""" + + #: The size of the game grid. Clue's in the name really. + SIZE = 5 + + #: The bindings for the main game grid. + BINDINGS = [ + Binding("n", "new_game", "New Game"), + Binding("question_mark", "push_screen('help')", "Help", key_display="?"), + Binding("q", "quit", "Quit"), + Binding("up,w,k", "navigate(-1,0)", "Move Up", False), + Binding("down,s,j", "navigate(1,0)", "Move Down", False), + Binding("left,a,h", "navigate(0,-1)", "Move Left", False), + Binding("right,d,l", "navigate(0,1)", "Move Right", False), + Binding("space", "move", "Toggle", False), + ] + + @property + def filled_cells(self) -> DOMQuery[GameCell]: + """DOMQuery[GameCell]: The collection of cells that are currently turned on.""" + return cast(DOMQuery[GameCell], self.query("GameCell.filled")) + + @property + def filled_count(self) -> int: + """int: The number of cells that are currently filled.""" + return len(self.filled_cells) + + @property + def all_filled(self) -> bool: + """bool: Are all the cells filled?""" + return self.filled_count == self.SIZE * self.SIZE + + def game_playable(self, playable: bool) -> None: + """Mark the game as playable, or not. + + Args: + playable (bool): Should the game currently be playable? + """ + for cell in self.query(GameCell): + cell.disabled = not playable + + def cell(self, row: int, col: int) -> GameCell: + """Get the cell at a given location. + + Args: + row (int): The row of the cell to get. + col (int): The column of the cell to get. + + Returns: + GameCell: The cell at that location. + """ + return self.query_one(f"#{GameCell.at(row,col)}", GameCell) + + def compose(self) -> ComposeResult: + """Compose the game screen. + + Returns: + ComposeResult: The result of composing the game screen. + """ + yield GameHeader() + yield GameGrid() + yield Footer() + yield WinnerMessage() + + def toggle_cell(self, row: int, col: int) -> None: + """Toggle an individual cell, but only if it's in bounds. + + If the row and column would place the cell out of bounds for the + game grid, this function call is a no-op. That is, it's safe to call + it with an invalid cell coordinate. + + Args: + row (int): The row of the cell to toggle. + col (int): The column of the cell to toggle. + """ + if 0 <= row <= (self.SIZE - 1) and 0 <= col <= (self.SIZE - 1): + self.cell(row, col).toggle_class("filled") + + _PATTERN: Final = (-1, 1, 0, 0, 0) + + def toggle_cells(self, cell: GameCell) -> None: + """Toggle a 5x5 pattern around the given cell. + + Args: + cell (GameCell): The cell to toggle the cells around. + """ + for row, col in zip(self._PATTERN, reversed(self._PATTERN)): + self.toggle_cell(cell.row + row, cell.col + col) + self.query_one(GameHeader).filled = self.filled_count + + def make_move_on(self, cell: GameCell) -> None: + """Make a move on the given cell. + + All relevant cells around the given cell are toggled as per the + game's rules. + + Args: + cell (GameCell): The cell to make a move on + """ + self.toggle_cells(cell) + self.query_one(GameHeader).moves += 1 + if self.all_filled: + self.query_one(WinnerMessage).show(self.query_one(GameHeader).moves) + self.game_playable(False) + + def on_button_pressed(self, event: GameCell.Pressed) -> None: + """React to a press of a button on the game grid. + + Args: + event (GameCell.Pressed): The event to react to. + """ + self.make_move_on(cast(GameCell, event.button)) + + def action_new_game(self) -> None: + """Start a new game.""" + self.query_one(GameHeader).moves = 0 + self.filled_cells.remove_class("filled") + self.query_one(WinnerMessage).hide() + middle = self.cell(self.SIZE // 2, self.SIZE // 2) + self.toggle_cells(middle) + self.set_focus(middle) + self.game_playable(True) + + def action_navigate(self, row: int, col: int) -> None: + """Navigate to a new cell by the given offsets. + + Args: + row (int): The row of the cell to navigate to. + col (int): The column of the cell to navigate to. + """ + if isinstance(self.focused, GameCell): + self.set_focus( + self.cell( + (self.focused.row + row) % self.SIZE, + (self.focused.col + col) % self.SIZE, + ) + ) + + def action_move(self) -> None: + """Make a move on the current cell.""" + if isinstance(self.focused, GameCell): + self.focused.press() + + def on_mount(self) -> None: + """Get the game started when we first mount.""" + self.action_new_game() + + +class FiveByFive(App[None]): + """Main 5x5 application class.""" + + #: The name of the stylesheet for the app. + CSS_PATH = "five_by_five.css" + + #: The pre-loaded screens for the application. + SCREENS = {"help": Help()} + + #: App-level bindings. + BINDINGS = [("D", "toggle_dark", "Toggle Dark Mode")] + + def __init__(self) -> None: + """Constructor.""" + super().__init__(title="5x5 -- A little annoying puzzle") + + def on_mount(self) -> None: + """Set up the application on startup.""" + self.push_screen(Game()) + + +if __name__ == "__main__": + FiveByFive().run()