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