1 changed files with 224 additions and 0 deletions
@ -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() |
||||||
Loading…
Reference in new issue