mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
277 lines
8.5 KiB
Python
277 lines
8.5 KiB
Python
"""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 = [("escape,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))
|
|
self.row = row
|
|
self.col = 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"),
|
|
("up,w", "navigate(-1,0)", "Move Up"),
|
|
("down,s", "navigate(1,0)", "Move Down"),
|
|
("left,a", "navigate(0,-1)", "Move Left"),
|
|
("right,d", "navigate(0,1)", "Move Right"),
|
|
("space", "move", "Toggle"),
|
|
]
|
|
|
|
@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 cell(self, row: int, col: int) -> GameCell:
|
|
"""Get the cell at a given location.
|
|
|
|
:param int row: The row of the cell to get.
|
|
:param int col: The column of the cell to get.
|
|
:returns: The cell at that location.
|
|
:rtype: GameCell
|
|
"""
|
|
return self.query_one(f"#{GameCell.at(row,col)}", GameCell)
|
|
|
|
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()
|
|
middle = self.cell(self.SIZE // 2, self.SIZE // 2)
|
|
self.toggle_cells(middle)
|
|
self.set_focus(middle)
|
|
self.game_playable(True)
|
|
|
|
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.cell(row, col).toggle_class("on")
|
|
|
|
_PATTERN: Final = (-1, 1, 0, 0, 0)
|
|
|
|
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.
|
|
"""
|
|
for row, col in zip(self._PATTERN, reversed(self._PATTERN)):
|
|
self.toggle_cell(cell.row + row, cell.col + col)
|
|
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 action_navigate(self, row: int, col: int) -> None:
|
|
"""Navigate to a new cell by the given offsets."""
|
|
if self.focused and 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 self.focused and isinstance(self.focused, GameCell):
|
|
self.focused.press()
|
|
|
|
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()
|