#!/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()