From 06d1865accaf5fa26e068b097598f6dffcf5683d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 19 Oct 2022 16:47:25 +0100 Subject: [PATCH] Add 5x5 as an example *evil grin* --- examples/five_by_five.css | 84 +++++++++++++ examples/five_by_five.md | 17 +++ examples/five_by_five.py | 254 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 355 insertions(+) create mode 100644 examples/five_by_five.css create mode 100644 examples/five_by_five.md create mode 100644 examples/five_by_five.py diff --git a/examples/five_by_five.css b/examples/five_by_five.css new file mode 100644 index 000000000..6bebda34d --- /dev/null +++ b/examples/five_by_five.css @@ -0,0 +1,84 @@ +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; +} + +GameCell:hover { + background: $panel-lighten-1; + border: round $panel; +} + +GameCell.on { + background: $secondary; + border: round $secondary-darken-1; +} + +GameCell.on: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 000000000..6fcc887bb --- /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 000000000..f00bec989 --- /dev/null +++ b/examples/five_by_five.py @@ -0,0 +1,254 @@ +"""Simple version of 5x5, developed for/with Textual.""" + +from pathlib import Path +from typing import Final, cast + +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 rich.markdown import Markdown + + +class Help(Screen): + """The help screen for the application.""" + + #: Bindings for the help screen. + BINDINGS = [("esc,space,q,h,question_mark", "app.pop_screen", "Close")] + + def compose(self) -> ComposeResult: + """Compose the game's help.""" + 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.""" + 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 turned on. + on = reactive(0) + + def compose(self) -> ComposeResult: + """Compose 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.""" + self.query_one("#moves", Static).update(f"Moves: {moves}") + + def watch_on(self, on: int): + """Watch the on-count reactive and update when it changes.""" + self.query_one("#progress", Static).update(f"On: {on}") + + +class GameCell(Button): + """Individual playable cell in the game.""" + + @staticmethod + def at(row: int, col: int) -> str: + return f"cell-{row}-{col}" + + def __init__(self, row: int, col: int) -> None: + """Initialise the game cell.""" + super().__init__("", id=self.at(row, col)) + + +class GameGrid(Widget): + """The main playable grid of game cells.""" + + def compose(self) -> ComposeResult: + """Compose 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 = [ + ("n", "reset", "New Game"), + ("h,question_mark", "app.push_screen('help')", "Help"), + ("q", "quit", "Quit"), + ] + + @property + def on_cells(self) -> DOMQuery[GameCell]: + """The collection of cells that are currently turned on. + + :type: DOMQuery[GameCell] + """ + return cast(DOMQuery[GameCell], self.query("GameCell.on")) + + @property + def on_count(self) -> int: + """The number of cells that are turned on. + + :type: int + """ + return len(self.on_cells) + + @property + def all_on(self) -> bool: + """Are all the cells turned on? + + :type: bool + """ + return self.on_count == self.SIZE * self.SIZE + + def game_playable(self, playable: bool) -> None: + """Mark the game as playable, or not. + + :param bool playable: Should the game currently be playable? + """ + for cell in self.query(GameCell): + cell.disabled = not playable + + def new_game(self) -> None: + """Start a new game.""" + self.query_one(GameHeader).moves = 0 + self.on_cells.remove_class("on") + self.query_one(WinnerMessage).hide() + self.game_playable(True) + self.toggle_cells( + self.query_one(f"#{GameCell.at(self.SIZE // 2,self.SIZE // 2 )}", GameCell) + ) + + def compose(self) -> ComposeResult: + """Compose the application 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 on bounds. + + :param int row: The row of the cell to toggle. + :param int col: The column of the cell to toggle. + + 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. + """ + if 0 <= row <= (self.SIZE - 1) and 0 <= col <= (self.SIZE - 1): + self.query_one(f"#{GameCell.at(row, col)}", GameCell).toggle_class("on") + + def toggle_cells(self, cell: GameCell) -> None: + """Toggle a 5x5 pattern around the given cell. + + :param GameCell cell: The cell to toggle the cells around. + """ + # Abusing the ID as a data- attribute too (or a cargo instance + # variable if you're old enough to have worked with Clipper). + # Textual doesn't have anything like it at the moment: + # + # https://twitter.com/davepdotorg/status/1555822341170597888 + # + # but given the reply it may do at some point. + if cell.id: + row, col = map(int, cell.id.split("-")[1:]) + self.toggle_cell(row - 1, col) + self.toggle_cell(row + 1, col) + self.toggle_cell(row, col) + self.toggle_cell(row, col - 1) + self.toggle_cell(row, col + 1) + self.query_one(GameHeader).on = self.on_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. + """ + self.toggle_cells(cell) + self.query_one(GameHeader).moves += 1 + if self.all_on: + 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.""" + self.make_move_on(cast(GameCell, event.button)) + + def action_reset(self) -> None: + """Reset the game.""" + self.new_game() + + def on_mount(self) -> None: + """Get the game started when we first mount.""" + self.new_game() + + +class FiveByFive(App[None]): + """Main 5x5 application class.""" + + #: The name of the stylesheet for the app. + CSS_PATH = Path(__file__).with_suffix(".css") + + #: The pre-loaded screens for the application. + SCREENS = {"help": Help()} + + #: App-level bindings. + BINDINGS = [("d", "app.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()