You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
224 lines
6.8 KiB
224 lines
6.8 KiB
#!/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() |