From 5184df290c6c436995bad00d0c261351443e15f3 Mon Sep 17 00:00:00 2001 From: Matteo Benedetto Date: Wed, 17 Jun 2026 15:00:26 +0200 Subject: [PATCH] Add Tetris implementation using Textual TUI --- tetris_textual.py | 224 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 tetris_textual.py diff --git a/tetris_textual.py b/tetris_textual.py new file mode 100644 index 0000000..2ee5c31 --- /dev/null +++ b/tetris_textual.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +"""Implementazione minimale di Tetris con Textual (TUI moderna).""" + +import random +from dataclasses import dataclass + +from textual.app import App, ComposeResult +from textual.containers import Horizontal, Vertical +from textual.widgets import Static, Footer, Header +from textual.binding import Binding +from textual.timer import Timer + +# Tetromini classici come liste di (x, y) relative al centro +SHAPES = { + "I": [(0, -1), (0, 0), (0, 1), (0, 2)], + "O": [(0, 0), (0, 1), (1, 0), (1, 1)], + "T": [(-1, 0), (0, 0), (1, 0), (0, 1)], + "S": [(-1, 0), (0, 0), (0, 1), (1, 1)], + "Z": [(-1, 1), (0, 1), (0, 0), (1, 0)], + "J": [(-1, -1), (-1, 0), (0, 0), (1, 0)], + "L": [(-1, 0), (0, 0), (1, 0), (1, -1)], +} + +COLORS = { + "I": "cyan", + "O": "yellow", + "T": "magenta", + "S": "green", + "Z": "red", + "J": "blue", + "L": "orange3", +} + +BOARD_WIDTH = 10 +BOARD_HEIGHT = 20 + + +@dataclass +class Piece: + cells: list[tuple[int, int]] + x: int + y: int + kind: str + + @property + def color(self) -> str: + return COLORS[self.kind] + + def translated(self, dx: int, dy: int) -> "Piece": + return Piece(self.cells, self.x + dx, self.y + dy, self.kind) + + def rotated(self) -> "Piece": + # Rotazione 90 gradi orario: (x, y) -> (-y, x) + rotated_cells = [(-y, x) for x, y in self.cells] + return Piece(rotated_cells, self.x, self.y, self.kind) + + def absolute_positions(self) -> list[tuple[int, int]]: + return [(self.x + cx, self.y + cy) for cx, cy in self.cells] + + +class TetrisApp(App): + CSS = """ + Screen { align: center middle; } + #board { + width: 22; + height: 22; + border: solid green; + padding: 0; + } + #side { + width: 24; + height: 22; + padding: 1 2; + } + """ + + BINDINGS = [ + Binding("left", "move(-1)", "Left"), + Binding("right", "move(1)", "Right"), + Binding("down", "drop", "Down"), + Binding("up", "rotate", "Rotate"), + Binding("space", "rotate", "Rotate"), + Binding("q", "quit", "Quit"), + Binding("p", "pause", "Pause"), + ] + + def __init__(self) -> None: + super().__init__() + self.grid: list[list[str | None]] = [[None] * BOARD_WIDTH for _ in range(BOARD_HEIGHT)] + self.current_piece: Piece = self._spawn_piece() + self.score = 0 + self.game_over = False + self.paused = False + self.tick_timer: Timer | None = None + + def compose(self) -> ComposeResult: + yield Header() + with Horizontal(): + yield Static(id="board") + with Vertical(id="side"): + yield Static("Score: 0\n\nControls:\n← → move\n↓ soft drop\n↑/space rotate\np pause\nq quit", id="info") + yield Footer() + + def on_mount(self) -> None: + self.tick_timer = self.set_interval(0.6, self._tick) + self._render() + + def _spawn_piece(self) -> Piece: + kind = random.choice(list(SHAPES.keys())) + return Piece(SHAPES[kind][:], BOARD_WIDTH // 2, 0, kind) + + def _is_valid(self, piece: Piece) -> bool: + for x, y in piece.absolute_positions(): + if x < 0 or x >= BOARD_WIDTH or y >= BOARD_HEIGHT: + return False + if y >= 0 and self.grid[y][x] is not None: + return False + return True + + def _lock_piece(self) -> None: + for x, y in self.current_piece.absolute_positions(): + if 0 <= y < BOARD_HEIGHT and 0 <= x < BOARD_WIDTH: + self.grid[y][x] = self.current_piece.color + self._clear_lines() + self.current_piece = self._spawn_piece() + if not self._is_valid(self.current_piece): + self.game_over = True + if self.tick_timer: + self.tick_timer.stop() + self.query_one("#info", Static).update("GAME OVER\n\nScore: " + str(self.score)) + + def _clear_lines(self) -> None: + new_grid = [row for row in self.grid if any(cell is None for cell in row)] + cleared = BOARD_HEIGHT - len(new_grid) + for _ in range(cleared): + new_grid.insert(0, [None] * BOARD_WIDTH) + self.grid = new_grid + self.score += cleared * 100 + + def _tick(self) -> None: + if self.game_over or self.paused: + return + moved = self.current_piece.translated(0, 1) + if self._is_valid(moved): + self.current_piece = moved + else: + self._lock_piece() + self._render() + + def action_move(self, delta: int) -> None: + if self.game_over or self.paused: + return + moved = self.current_piece.translated(delta, 0) + if self._is_valid(moved): + self.current_piece = moved + self._render() + + def action_drop(self) -> None: + if self.game_over or self.paused: + return + moved = self.current_piece.translated(0, 1) + if self._is_valid(moved): + self.current_piece = moved + self.score += 1 + else: + self._lock_piece() + self._render() + + def action_rotate(self) -> None: + if self.game_over or self.paused: + return + rotated = self.current_piece.rotated() + # Wall kick minimale + for dx in (0, 1, -1, 2, -2): + candidate = rotated.translated(dx, 0) + if self._is_valid(candidate): + self.current_piece = candidate + self._render() + return + + def action_pause(self) -> None: + self.paused = not self.paused + self._render() + + def _render(self) -> None: + board_widget = self.query_one("#board", Static) + lines = [] + # Bordo superiore + lines.append("+" + "-" * (BOARD_WIDTH * 2) + "+") + + for y in range(BOARD_HEIGHT): + row_str = "|" + for x in range(BOARD_WIDTH): + # Pezzo attivo + occupied = False + for px, py in self.current_piece.absolute_positions(): + if px == x and py == y: + row_str += f"[{self.current_piece.color}]#[/]" + occupied = True + break + if not occupied: + cell = self.grid[y][x] + if cell: + row_str += f"[{cell}]#[/]" + else: + row_str += " ." + row_str += "|" + lines.append(row_str) + + # Bordo inferiore + lines.append("+" + "-" * (BOARD_WIDTH * 2) + "+") + board_widget.update("\n".join(lines)) + + info_widget = self.query_one("#info", Static) + status = "PAUSED" if self.paused else "" + info_widget.update( + f"Score: {self.score}\n{status}\n\n" + "Controls:\n← → move\n↓ soft drop\n↑/space rotate\np pause\nq quit" + ) + + +if __name__ == "__main__": + app = TetrisApp() + app.run() \ No newline at end of file