ai-station/.venv/lib/python3.12/site-packages/textual/demo/game.py

590 lines
19 KiB
Python

"""
An implementation of the "Sliding Tile" puzzle.
Textual isn't a game engine exactly, but it wasn't hard to build this.
"""
from __future__ import annotations
from asyncio import sleep
from collections import defaultdict
from dataclasses import dataclass
from itertools import product
from random import choice
from time import monotonic
from rich.console import ConsoleRenderable
from rich.syntax import Syntax
from textual import containers, events, on, work
from textual._loop import loop_last
from textual.app import ComposeResult
from textual.binding import Binding
from textual.demo.page import PageScreen
from textual.geometry import Offset, Size
from textual.reactive import reactive
from textual.screen import ModalScreen, Screen
from textual.timer import Timer
from textual.widgets import Button, Digits, Footer, Markdown, Select, Static
@dataclass
class NewGame:
"""A dataclass to report the desired game type."""
language: str
code: str
size: tuple[int, int]
PYTHON_CODE = '''\
class SpatialMap(Generic[ValueType]):
"""A spatial map allows for data to be associated with rectangular regions
in Euclidean space, and efficiently queried.
When the SpatialMap is populated, a reference to each value is placed into one or
more buckets associated with a regular grid that covers 2D space.
The SpatialMap is able to quickly retrieve the values under a given "window" region
by combining the values in the grid squares under the visible area.
"""
def __init__(self, grid_width: int = 100, grid_height: int = 20) -> None:
"""Create a spatial map with the given grid size.
Args:
grid_width: Width of a grid square.
grid_height: Height of a grid square.
"""
self._grid_size = (grid_width, grid_height)
self.total_region = Region()
self._map: defaultdict[GridCoordinate, list[ValueType]] = defaultdict(list)
self._fixed: list[ValueType] = []
def _region_to_grid_coordinates(self, region: Region) -> Iterable[GridCoordinate]:
"""Get the grid squares under a region.
Args:
region: A region.
Returns:
Iterable of grid coordinates (tuple of 2 values).
"""
# (x1, y1) is the coordinate of the top left cell
# (x2, y2) is the coordinate of the bottom right cell
x1, y1, width, height = region
x2 = x1 + width - 1
y2 = y1 + height - 1
grid_width, grid_height = self._grid_size
return product(
range(x1 // grid_width, x2 // grid_width + 1),
range(y1 // grid_height, y2 // grid_height + 1),
)
'''
XML_CODE = """\
<?xml version="1.0" encoding="UTF-8"?>
<movies>
<movie>
<title>Back to the Future</title> <year>1985</year> <director>Robert Zemeckis</director>
<genre>Science Fiction</genre> <rating>PG</rating>
<cast>
<actor> <name>Michael J. Fox</name> <role>Marty McFly</role> </actor>
<actor> <name>Christopher Lloyd</name> <role>Dr. Emmett Brown</role> </actor>
</cast>
</movie>
<movie>
<title>The Breakfast Club</title> <year>1985</year> <director>John Hughes</director>
<genre>Drama</genre> <rating>R</rating>
<cast>
<actor> <name>Emilio Estevez</name> <role>Andrew Clark</role> </actor>
<actor> <name>Molly Ringwald</name> <role>Claire Standish</role> </actor>
</cast>
</movie>
<movie>
<title>Ghostbusters</title> <year>1984</year> <director>Ivan Reitman</director>
<genre>Comedy</genre> <rating>PG</rating>
<cast>
<actor> <name>Bill Murray</name> <role>Dr. Peter Venkman</role> </actor>
<actor> <name>Dan Aykroyd</name> <role>Dr. Raymond Stantz</role> </actor>
</cast>
</movie>
<movie>
<title>Die Hard</title> <year>1988</year> <director>John McTiernan</director>
<genre>Action</genre> <rating>R</rating>
<cast>
<actor> <name>Bruce Willis</name> <role>John McClane</role> </actor>
<actor> <name>Alan Rickman</name> <role>Hans Gruber</role> </actor>
</cast>
</movie>
<movie>
<title>E.T. the Extra-Terrestrial</title> <year>1982</year> <director>Steven Spielberg</director>
<genre>Science Fiction</genre> <rating>PG</rating>
<cast>
<actor> <name>Henry Thomas</name> <role>Elliott</role> </actor>
<actor> <name>Drew Barrymore</name> <role>Gertie</role> </actor>
</cast>
</movie>
</movies>"""
BF_CODE = """\
[life.b -- John Horton Conway's Game of Life
(c) 2021 Daniel B. Cristofani
]
>>>->+>+++++>(++++++++++)[[>>>+<<<-]>+++++>+>>+[<<+>>>>>+<<<-]<-]>>>>[
[>>>+>+<<<<-]+++>>+[<+>>>+>+<<<-]>>[>[[>>>+<<<-]<]<<++>+>>>>>>-]<-
]+++>+>[[-]<+<[>+++++++++++++++++<-]<+]>>[
[+++++++++.-------->>>]+[-<<<]>>>[>>,----------[>]<]<<[
<<<[
>--[<->>+>-<<-]<[[>>>]+>-[+>>+>-]+[<<<]<-]>++>[<+>-]
>[[>>>]+[<<<]>>>-]+[->>>]<-[++>]>[------<]>+++[<<<]>
]<
]>[
-[+>>+>-]+>>+>>>+>[<<<]>->+>[
>[->+>+++>>++[>>>]+++<<<++<<<++[>>>]>>>]<<<[>[>>>]+>>>]
<<<<<<<[<<++<+[-<<<+]->++>>>++>>>++<<<<]<<<+[-<<<+]+>->>->>
]<<+<<+<<<+<<-[+<+<<-]+<+[
->+>[-<-<<[<<<]>[>>[>>>]<<+<[<<<]>-]]
<[<[<[<<<]>+>>[>>>]<<-]<[<<<]]>>>->>>[>>>]+>
]>+[-<<[-]<]-[
[>>>]<[<<[<<<]>>>>>+>[>>>]<-]>>>[>[>>>]<<<<+>[<<<]>>-]>
]<<<<<<[---<-----[-[-[<->>+++<+++++++[-]]]]<+<+]>
]>>
]
[This program simulates the Game of Life cellular automaton.
Type e.g. "be" to toggle the fifth cell in the second row, "q" to quit,
or a bare linefeed to advance one generation.
Grid wraps toroidally. Board size in parentheses in first line (2-166 work).
This program is licensed under a Creative Commons Attribution-ShareAlike 4.0
International License (http://creativecommons.org/licenses/by-sa/4.0/).]
"""
LEVELS = {"Python": PYTHON_CODE, "XML": XML_CODE, "BF": BF_CODE}
class Tile(containers.Vertical):
"""An individual tile in the puzzle.
A Tile is a container with a static inside it.
The static contains the code (as a Rich Syntax object), scrolled so the
relevant portion is visible.
"""
DEFAULT_CSS = """
Tile {
position: absolute;
Static {
width: auto;
height: auto;
&:hover { tint: $primary 30%; }
}
&#blank { visibility: hidden; }
}
"""
position: reactive[Offset] = reactive(Offset)
def __init__(
self,
renderable: ConsoleRenderable,
tile: int | None,
size: Size,
position: Offset,
) -> None:
self.renderable = renderable
self.tile = tile
self.tile_size = size
self.start_position = position
super().__init__(id="blank" if tile is None else f"tile{self.tile}")
self.set_reactive(Tile.position, position)
def compose(self) -> ComposeResult:
static = Static(
self.renderable,
classes="tile",
name="blank" if self.tile is None else str(self.tile),
)
assert self.parent is not None
static.styles.width = self.parent.styles.width
static.styles.height = self.parent.styles.height
yield static
def on_mount(self) -> None:
if self.tile is not None:
width, height = self.tile_size
self.styles.width = width
self.styles.height = height
column, row = self.position
self.set_scroll(column * width, row * height)
self.offset = self.position * self.tile_size
def watch_position(self, position: Offset) -> None:
"""The 'position' is in tile coordinate.
When it changes we animate it to the cell coordinates."""
self.animate("offset", position * self.tile_size, duration=0.2)
class GameDialog(containers.VerticalGroup):
"""A dialog to ask the user for the initial game parameters."""
DEFAULT_CSS = """
GameDialog {
background: $boost;
border: thick $primary-muted;
padding: 0 2;
width: 50;
#values {
width: 1fr;
Select { margin: 1 0;}
}
Button {
margin: 0 1 1 1;
width: 1fr;
}
}
"""
def compose(self) -> ComposeResult:
with containers.VerticalGroup(id="values"):
yield Select.from_values(
LEVELS.keys(),
prompt="Language",
value="Python",
id="language",
allow_blank=False,
)
yield Select(
[
("Easy (3x3)", (3, 3)),
("Medium (4x4)", (4, 4)),
("Hard (5x5)", (5, 5)),
],
prompt="Level",
value=(4, 4),
id="level",
allow_blank=False,
)
yield Button("Start", variant="primary")
@on(Button.Pressed)
def on_button_pressed(self) -> None:
language = self.query_one("#language", Select).selection
level = self.query_one("#level", Select).selection
assert language is not None and level is not None
self.screen.dismiss(NewGame(language, LEVELS[language], level))
class GameDialogScreen(ModalScreen):
"""Modal screen containing the dialog."""
CSS = """
GameDialogScreen {
align: center middle;
}
"""
BINDINGS = [("escape", "dismiss")]
def compose(self) -> ComposeResult:
yield GameDialog()
class Game(containers.Vertical, can_focus=True):
"""Widget for the game board."""
ALLOW_MAXIMIZE = False
DEFAULT_CSS = """
Game {
visibility: hidden;
align: center middle;
hatch: right $panel;
border: heavy transparent;
&:focus {
border: heavy $success;
}
#grid {
border: heavy $primary;
hatch: right $panel;
box-sizing: content-box;
}
Digits {
width: auto;
color: $foreground;
}
}
"""
BINDINGS = [
Binding("up", "move('up')", "up", priority=True),
Binding("down", "move('down')", "down", priority=True),
Binding("left", "move('left')", "left", priority=True),
Binding("right", "move('right')", "right", priority=True),
]
state = reactive("waiting")
play_start_time: reactive[float] = reactive(monotonic)
play_time = reactive(0.0, init=False)
code = reactive("")
dimensions = reactive(Size(3, 3))
code = reactive("")
language = reactive("")
def __init__(
self,
code: str,
language: str,
dimensions: tuple[int, int],
tile_size: tuple[int, int],
) -> None:
self.set_reactive(Game.code, code)
self.set_reactive(Game.language, language)
self.locations: defaultdict[Offset, int | None] = defaultdict(None)
super().__init__()
self.dimensions = Size(*dimensions)
self.tile_size = Size(*tile_size)
self.play_timer: Timer | None = None
def check_win(self) -> bool:
return all(tile.start_position == tile.position for tile in self.query(Tile))
def watch_dimensions(self, dimensions: Size) -> None:
self.locations.clear()
tile_width, tile_height = dimensions
for last, tile_no in loop_last(range(0, tile_width * tile_height)):
position = Offset(*divmod(tile_no, tile_width))
self.locations[position] = None if last else tile_no
def compose(self) -> ComposeResult:
syntax = Syntax(
self.code,
self.language.lower(),
indent_guides=True,
line_numbers=True,
theme="material",
)
tile_width, tile_height = self.dimensions
self.state = "waiting"
yield Digits("")
with containers.HorizontalGroup(id="grid") as grid:
grid.styles.width = tile_width * self.tile_size[0]
grid.styles.height = tile_height * self.tile_size[1]
for row, column in product(range(tile_width), range(tile_height)):
position = Offset(row, column)
tile_no = self.locations[position]
yield Tile(syntax, tile_no, self.tile_size, position)
if self.language:
self.call_after_refresh(self.shuffle)
def update_clock(self) -> None:
if self.state == "playing":
elapsed = monotonic() - self.play_start_time
self.play_time = elapsed
def watch_play_time(self, play_time: float) -> None:
minutes, seconds = divmod(play_time, 60)
hours, minutes = divmod(minutes, 60)
self.query_one(Digits).update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:04.1f}")
def watch_state(self, old_state: str, new_state: str) -> None:
if self.play_timer is not None:
self.play_timer.stop()
if new_state == "playing":
self.play_start_time = monotonic()
self.play_timer = self.set_interval(1 / 10, self.update_clock)
def get_tile(self, tile: int | None) -> Tile:
"""Get a tile (int) or the blank (None)."""
return self.query_one("#blank" if tile is None else f"#tile{tile}", Tile)
def get_tile_at(self, position: Offset) -> Tile:
"""Get a tile at the given position, or raise an IndexError."""
if position not in self.locations:
raise IndexError("No tile")
return self.get_tile(self.locations[position])
def move_tile(self, tile_no: int | None) -> None:
"""Move a tile to the blank.
Note: this doesn't do any validation of legal moves.
"""
tile = self.get_tile(tile_no)
blank = self.get_tile(None)
blank_position = blank.position
self.locations[tile.position] = None
blank.position = tile.position
self.locations[blank_position] = tile_no
tile.position = blank_position
if self.state == "playing" and self.check_win():
self.state = "won"
self.notify("You won!", title="Sliding Tile Puzzle")
def can_move(self, tile: int) -> bool:
"""Check if a tile may move."""
blank_position = self.get_tile(None).position
tile_position = self.get_tile(tile).position
return blank_position in (
tile_position + (1, 0),
tile_position - (1, 0),
tile_position + (0, 1),
tile_position - (0, 1),
)
def action_move(self, direction: str) -> None:
if self.state != "playing":
self.app.bell()
return
blank = self.get_tile(None).position
if direction == "up":
position = blank + (0, +1)
elif direction == "down":
position = blank + (0, -1)
elif direction == "left":
position = blank + (+1, 0)
elif direction == "right":
position = blank + (-1, 0)
try:
tile = self.get_tile_at(position)
except IndexError:
return
self.move_tile(tile.tile)
def get_legal_moves(self) -> set[Offset]:
"""Get the positions of all tiles that can move."""
blank = self.get_tile(None).position
moves: list[Offset] = []
DIRECTIONS = [(-1, 0), (+1, -0), (0, -1), (0, +1)]
moves = [
blank + direction
for direction in DIRECTIONS
if (blank + direction) in self.locations
]
return {self.get_tile_at(position).position for position in moves}
@work(exclusive=True)
async def shuffle(self, shuffles: int = 150) -> None:
"""A worker to do the shuffling."""
self.visible = True
if self.play_timer is not None:
self.play_timer.stop()
self.query_one("#grid").border_title = "[reverse bold] SHUFFLING - Please Wait "
self.state = "shuffling"
previous_move: Offset = Offset(-1, -1)
for _ in range(shuffles):
legal_moves = self.get_legal_moves()
legal_moves.discard(previous_move)
previous_move = self.get_tile(None).position
move_position = choice(list(legal_moves))
move_tile = self.get_tile_at(move_position)
self.move_tile(move_tile.tile)
await sleep(0.05)
self.query_one("#grid").border_title = ""
self.state = "playing"
@on(events.Click, ".tile")
def on_tile_clicked(self, event: events.Click) -> None:
assert event.widget is not None
tile = int(event.widget.name or 0)
if self.state != "playing" or not self.can_move(tile):
self.app.bell()
return
self.move_tile(tile)
class GameInstructions(containers.VerticalGroup):
DEFAULT_CSS = """\
GameInstructions {
layer: instructions;
width: 60;
background: $panel;
border: thick $primary-darken-2;
Markdown {
background: $panel;
}
}
"""
INSTRUCTIONS = """\
# Instructions
This is an implementation of the *sliding tile puzzle*.
The board consists of a number of tiles and a blank space.
After shuffling, the goal is to restore the original "image" by moving a square either horizontally or vertically into the blank space.
This version is like the physical game, but rather than an image, you need to restore code.
"""
def compose(self) -> ComposeResult:
yield Markdown(self.INSTRUCTIONS)
with containers.Center():
yield Button("New Game", action="screen.new_game", variant="success")
class GameScreen(PageScreen):
"""The screen containing the game."""
DEFAULT_CSS = """
GameScreen{
#container {
align: center middle;
layers: instructions game;
}
}
"""
BINDINGS = [("n", "new_game", "New Game")]
def compose(self) -> ComposeResult:
with containers.Vertical(id="container"):
yield GameInstructions()
yield Game("\n" * 100, "", dimensions=(4, 4), tile_size=(16, 8))
yield Footer()
def action_shuffle(self) -> None:
self.query_one(Game).shuffle()
def action_new_game(self) -> None:
self.app.push_screen(GameDialogScreen(), callback=self.new_game)
async def new_game(self, new_game: NewGame | None) -> None:
if new_game is None:
return
self.query_one(GameInstructions).display = False
game = self.query_one(Game)
game.state = "waiting"
game.code = new_game.code
game.language = new_game.language
game.dimensions = Size(*new_game.size)
await game.recompose()
game.focus()
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
if action == "shuffle" and self.query_one(Game).state == "waiting":
return None
return True
if __name__ == "__main__":
from textual.app import App
class GameApp(App):
def get_default_screen(self) -> Screen:
return GameScreen()
app = GameApp()
app.run()