Files
PyTetris/tetris.py

549 lines
17 KiB
Python

#!/usr/bin/env python3
"""
Simple Tetris using tkinter
How to run:
python3 tetris.py
Controls:
Left/Right: move piece
Down: soft drop
Up: rotate
Space: hard drop
P: pause/resume
R: restart
This is a compact, single-file implementation intended for learning/demo purposes.
"""
from __future__ import annotations
import random
import sys
import time
# Try to import tkinter robustly and allow graceful degradation if unavailable
try:
import tkinter as tk # type: ignore
except Exception as _e1: # pragma: no cover - environment dependent
try:
# Python 2 fallback (some environments may still alias it)
import Tkinter as tk # type: ignore
except Exception as _e2: # pragma: no cover - environment dependent
tk = None # type: ignore
_tk_import_error = _e1
from dataclasses import dataclass
from typing import List, Tuple, Optional
# Game configuration
COLS = 10
ROWS = 20
CELL = 30 # pixel size of a cell
BORDER = 10
PREVIEW_COLS = 6
PREVIEW_ROWS = 6
# Speed settings (milliseconds per tick). Level increases speed.
BASE_TICK = 700
LEVEL_SPEEDUP = 50 # ms reduced per level
MIN_TICK = 90
# Scoring
LINE_SCORES = {1: 100, 2: 300, 3: 500, 4: 800}
SOFT_DROP_SCORE = 1
HARD_DROP_SCORE_PER_CELL = 2
LINES_PER_LEVEL = 10
# Colors for pieces and board
BG_COLOR = "#111417"
GRID_COLOR = "#1a1f24"
TEXT_COLOR = "#e6edf3"
GHOST_COLOR = "#3a3f46"
PIECE_COLORS = {
'I': "#00c0f2",
'J': "#4169e1",
'L': "#ff8c00",
'O': "#f0e68c",
'S': "#32cd32",
'T': "#ba55d3",
'Z': "#ff4d4d",
}
# Tetromino definitions as rotation states (list of (x,y) offsets)
TETROMINOES = {
'I': [
[(0, 1), (1, 1), (2, 1), (3, 1)],
[(2, 0), (2, 1), (2, 2), (2, 3)],
[(0, 2), (1, 2), (2, 2), (3, 2)],
[(1, 0), (1, 1), (1, 2), (1, 3)],
],
'J': [
[(0, 0), (0, 1), (1, 1), (2, 1)],
[(1, 0), (2, 0), (1, 1), (1, 2)],
[(0, 1), (1, 1), (2, 1), (2, 2)],
[(1, 0), (1, 1), (0, 2), (1, 2)],
],
'L': [
[(2, 0), (0, 1), (1, 1), (2, 1)],
[(1, 0), (1, 1), (1, 2), (2, 2)],
[(0, 1), (1, 1), (2, 1), (0, 2)],
[(0, 0), (1, 0), (1, 1), (1, 2)],
],
'O': [
[(1, 0), (2, 0), (1, 1), (2, 1)],
[(1, 0), (2, 0), (1, 1), (2, 1)],
[(1, 0), (2, 0), (1, 1), (2, 1)],
[(1, 0), (2, 0), (1, 1), (2, 1)],
],
'S': [
[(1, 0), (2, 0), (0, 1), (1, 1)],
[(1, 0), (1, 1), (2, 1), (2, 2)],
[(1, 1), (2, 1), (0, 2), (1, 2)],
[(0, 0), (0, 1), (1, 1), (1, 2)],
],
'T': [
[(1, 0), (0, 1), (1, 1), (2, 1)],
[(1, 0), (1, 1), (2, 1), (1, 2)],
[(0, 1), (1, 1), (2, 1), (1, 2)],
[(1, 0), (0, 1), (1, 1), (1, 2)],
],
'Z': [
[(0, 0), (1, 0), (1, 1), (2, 1)],
[(2, 0), (1, 1), (2, 1), (1, 2)],
[(0, 1), (1, 1), (1, 2), (2, 2)],
[(1, 0), (0, 1), (1, 1), (0, 2)],
],
}
@dataclass
class Piece:
kind: str
x: int
y: int
rot: int
def cells(self) -> List[Tuple[int, int]]:
return [(self.x + dx, self.y + dy) for (dx, dy) in TETROMINOES[self.kind][self.rot]]
def rotated(self, dr: int) -> "Piece":
return Piece(self.kind, self.x, self.y, (self.rot + dr) % 4)
def moved(self, dx: int, dy: int) -> "Piece":
return Piece(self.kind, self.x + dx, self.y + dy, self.rot)
class Board:
def __init__(self, cols=COLS, rows=ROWS):
self.cols = cols
self.rows = rows
self.grid: List[List[Optional[str]]] = [[None for _ in range(cols)] for _ in range(rows)]
self.rng = random.Random()
self.bag: List[str] = []
self.score = 0
self.lines_cleared = 0
self.level = 1
self.current: Optional[Piece] = None
self.next_kind: str = self._draw_from_bag()
self.hold_kind: Optional[str] = None
self.hold_used = False
def reset(self):
self.grid = [[None for _ in range(self.cols)] for _ in range(self.rows)]
self.score = 0
self.lines_cleared = 0
self.level = 1
self.bag = []
self.current = None
self.next_kind = self._draw_from_bag()
self.hold_kind = None
self.hold_used = False
def _draw_from_bag(self) -> str:
if not self.bag:
self.bag = list(TETROMINOES.keys())
self.rng.shuffle(self.bag)
return self.bag.pop()
def spawn(self) -> bool:
kind = self.next_kind
self.next_kind = self._draw_from_bag()
# spawn near top center
piece = Piece(kind, x=self.cols // 2 - 2, y=0, rot=0)
# Adjust spawn y to avoid immediate collision for tall shapes
if not self.valid(piece):
piece = piece.moved(0, -1)
if not self.valid(piece):
return False
self.current = piece
self.hold_used = False
return True
def valid(self, piece: Piece) -> bool:
for x, y in piece.cells():
if x < 0 or x >= self.cols or y >= self.rows:
return False
if y >= 0 and self.grid[y][x] is not None:
return False
return True
def lock_piece(self):
assert self.current is not None
for x, y in self.current.cells():
if 0 <= y < self.rows and 0 <= x < self.cols:
self.grid[y][x] = self.current.kind
cleared = self.clear_lines()
self.lines_cleared += cleared
if cleared:
self.score += LINE_SCORES.get(cleared, 0) * self.level
# Level up every LINES_PER_LEVEL
new_level = 1 + self.lines_cleared // LINES_PER_LEVEL
if new_level > self.level:
self.level = new_level
self.current = None
def clear_lines(self) -> int:
new_rows = [row for row in self.grid if any(cell is None for cell in row)]
cleared = self.rows - len(new_rows)
while len(new_rows) < self.rows:
new_rows.insert(0, [None for _ in range(self.cols)])
self.grid = new_rows
return cleared
def hard_drop_distance(self, piece: Optional[Piece] = None) -> int:
if piece is None:
piece = self.current
if piece is None:
return 0
dist = 0
probe = piece
while self.valid(probe.moved(0, 1)):
probe = probe.moved(0, 1)
dist += 1
return dist
def ghost_cells(self) -> List[Tuple[int, int]]:
if not self.current:
return []
d = self.hard_drop_distance(self.current)
temp = self.current.moved(0, d)
return temp.cells()
def tick_down(self) -> bool:
if self.current is None:
return self.spawn()
nxt = self.current.moved(0, 1)
if self.valid(nxt):
self.current = nxt
return True
# cannot move down -> lock
self.lock_piece()
return self.spawn()
def move(self, dx: int):
if not self.current:
return
nxt = self.current.moved(dx, 0)
if self.valid(nxt):
self.current = nxt
def soft_drop(self) -> bool:
if not self.current:
return False
nxt = self.current.moved(0, 1)
if self.valid(nxt):
self.current = nxt
self.score += SOFT_DROP_SCORE
return True
return False
def hard_drop(self):
if not self.current:
return
d = self.hard_drop_distance(self.current)
if d > 0:
self.score += d * HARD_DROP_SCORE_PER_CELL
self.current = self.current.moved(0, d)
self.lock_piece()
self.spawn()
def rotate(self, dr: int):
if not self.current:
return
cand = self.current.rotated(dr)
# simple wall kicks: try shifting left/right/up a little
for dx, dy in [(0, 0), (-1, 0), (1, 0), (-2, 0), (2, 0), (0, -1)]:
test = Piece(cand.kind, cand.x + dx, cand.y + dy, cand.rot)
if self.valid(test):
self.current = test
return
def hold(self):
if self.current is None or self.hold_used:
return
kind = self.current.kind
if self.hold_kind is None:
self.hold_kind = kind
self.current = None
self.spawn()
else:
self.current = Piece(self.hold_kind, x=self.cols // 2 - 2, y=0, rot=0)
self.hold_kind = kind
if not self.valid(self.current):
# try one row up
self.current = self.current.moved(0, -1)
if not self.valid(self.current):
# cannot place -> game will end on next tick
pass
self.hold_used = True
class TetrisApp:
def __init__(self, root: tk.Tk):
self.root = root
root.title("Tetris (tkinter)")
root.configure(bg=BG_COLOR)
self.board = Board()
# layout
side_w = PREVIEW_COLS * CELL
width = BORDER * 3 + COLS * CELL + side_w
height = BORDER * 2 + ROWS * CELL
container = tk.Frame(root, bg=BG_COLOR)
container.pack(padx=10, pady=10)
left = tk.Frame(container, bg=BG_COLOR)
left.grid(row=0, column=0, sticky="n")
right = tk.Frame(container, bg=BG_COLOR)
right.grid(row=0, column=1, sticky="n", padx=(10, 0))
self.canvas = tk.Canvas(left, width=COLS * CELL, height=ROWS * CELL,
bg=BG_COLOR, highlightthickness=0)
self.canvas.pack()
self.info_lbl = tk.Label(right, text="Score: 0\nLines: 0\nLevel: 1",
font=("Consolas", 14), fg=TEXT_COLOR, bg=BG_COLOR, justify="left")
self.info_lbl.pack(anchor="w")
self.next_lbl = tk.Label(right, text="Next:", font=("Consolas", 12), fg=TEXT_COLOR, bg=BG_COLOR)
self.next_lbl.pack(anchor="w", pady=(10, 0))
self.preview = tk.Canvas(right, width=side_w, height=PREVIEW_ROWS * CELL,
bg=BG_COLOR, highlightthickness=0)
self.preview.pack()
self.hold_lbl = tk.Label(right, text="Hold:", font=("Consolas", 12), fg=TEXT_COLOR, bg=BG_COLOR)
self.hold_lbl.pack(anchor="w", pady=(10, 0))
self.hold_view = tk.Canvas(right, width=side_w, height=3 * CELL,
bg=BG_COLOR, highlightthickness=0)
self.hold_view.pack()
self.help_lbl = tk.Label(right, text="Arrows to move/rotate\nSpace: Hard drop\nShift/H: Hold\nP: Pause R: Restart",
font=("Consolas", 10), fg=TEXT_COLOR, bg=BG_COLOR, justify="left")
self.help_lbl.pack(anchor="w", pady=(10, 0))
# game state
self.paused = False
self.game_over = False
self.last_tick_time = time.time()
# key bindings
root.bind("<Left>", lambda e: self.on_left())
root.bind("<Right>", lambda e: self.on_right())
root.bind("<Down>", lambda e: self.on_down())
root.bind("<Up>", lambda e: self.on_rotate())
root.bind("<space>", lambda e: self.on_hard_drop())
root.bind("<Shift_L>", lambda e: self.on_hold())
root.bind("<Shift_R>", lambda e: self.on_hold())
root.bind("h", lambda e: self.on_hold())
root.bind("H", lambda e: self.on_hold())
root.bind("p", lambda e: self.toggle_pause())
root.bind("P", lambda e: self.toggle_pause())
root.bind("r", lambda e: self.restart())
root.bind("R", lambda e: self.restart())
# start
if not self.board.spawn():
self.game_over = True
self.schedule_tick()
self.redraw()
def tick_interval(self) -> int:
ms = max(MIN_TICK, BASE_TICK - (self.board.level - 1) * LEVEL_SPEEDUP)
return ms
def schedule_tick(self):
self.root.after(self.tick_interval(), self.game_loop)
def game_loop(self):
if not self.paused and not self.game_over:
alive = self.board.tick_down()
if not alive:
self.game_over = True
self.redraw()
self.schedule_tick()
def on_left(self):
if self.paused or self.game_over:
return
self.board.move(-1)
self.redraw()
def on_right(self):
if self.paused or self.game_over:
return
self.board.move(1)
self.redraw()
def on_down(self):
if self.paused or self.game_over:
return
moved = self.board.soft_drop()
if not moved:
# lock will occur on tick
pass
self.redraw()
def on_rotate(self):
if self.paused or self.game_over:
return
self.board.rotate(1)
self.redraw()
def on_hard_drop(self):
if self.paused or self.game_over:
return
self.board.hard_drop()
self.redraw()
def on_hold(self):
if self.paused or self.game_over:
return
self.board.hold()
self.redraw()
def toggle_pause(self):
if self.game_over:
return
self.paused = not self.paused
self.redraw()
def restart(self):
self.board.reset()
self.game_over = False
if not self.board.spawn():
self.game_over = True
self.redraw()
def redraw(self):
self.canvas.delete("all")
self.preview.delete("all")
self.hold_view.delete("all")
# draw grid background
for r in range(ROWS):
for c in range(COLS):
x0 = c * CELL
y0 = r * CELL
x1 = x0 + CELL
y1 = y0 + CELL
self.canvas.create_rectangle(x0, y0, x1, y1, outline=GRID_COLOR, fill=BG_COLOR)
# draw landed blocks
for y in range(ROWS):
for x in range(COLS):
kind = self.board.grid[y][x]
if kind:
self.draw_cell(self.canvas, x, y, PIECE_COLORS[kind])
# ghost piece
for gx, gy in self.board.ghost_cells():
if gy >= 0:
self.draw_cell(self.canvas, gx, gy, GHOST_COLOR, outline="")
# current piece
if self.board.current:
for x, y in self.board.current.cells():
if y >= 0:
self.draw_cell(self.canvas, x, y, PIECE_COLORS[self.board.current.kind])
# overlays
if self.paused:
self.canvas.create_text(COLS * CELL // 2, ROWS * CELL // 2, text="PAUSED",
fill=TEXT_COLOR, font=("Consolas", 24, "bold"))
if self.game_over:
self.canvas.create_text(COLS * CELL // 2, ROWS * CELL // 2 - 12, text="GAME OVER",
fill=TEXT_COLOR, font=("Consolas", 24, "bold"))
self.canvas.create_text(COLS * CELL // 2, ROWS * CELL // 2 + 16, text="Press R to restart",
fill=TEXT_COLOR, font=("Consolas", 12))
# info
self.info_lbl.config(text=f"Score: {self.board.score}\nLines: {self.board.lines_cleared}\nLevel: {self.board.level}")
# next preview
self.draw_preview(self.preview, self.board.next_kind)
# hold preview
if self.board.hold_kind:
self.draw_preview(self.hold_view, self.board.hold_kind)
def draw_cell(self, canvas: tk.Canvas, x: int, y: int, color: str, outline: str = "#0c0f12"):
x0 = x * CELL + 1
y0 = y * CELL + 1
x1 = x0 + CELL - 2
y1 = y0 + CELL - 2
canvas.create_rectangle(x0, y0, x1, y1, outline=outline, fill=color)
# simple 3D shading
canvas.create_line(x0, y0, x1, y0, fill="#ffffff")
canvas.create_line(x0, y0, x0, y1, fill="#ffffff")
canvas.create_line(x0, y1, x1, y1, fill="#000000")
canvas.create_line(x1, y0, x1, y1, fill="#000000")
def draw_preview(self, canvas: tk.Canvas, kind: str):
canvas.delete("all")
cells = TETROMINOES[kind][0]
xs = [x for x, y in cells]
ys = [y for x, y in cells]
minx, maxx = min(xs), max(xs)
miny, maxy = min(ys), max(ys)
w = (maxx - minx + 1)
h = (maxy - miny + 1)
# center in preview area
offset_x = (canvas.winfo_width() // CELL - w) // 2
offset_y = (canvas.winfo_height() // CELL - h) // 2
for dx, dy in cells:
x = dx - minx + offset_x
y = dy - miny + offset_y
x0 = x * CELL + 1
y0 = y * CELL + 1
x1 = x0 + CELL - 2
y1 = y0 + CELL - 2
canvas.create_rectangle(x0, y0, x1, y1, outline="#0c0f12", fill=PIECE_COLORS[kind])
def main() -> int:
if tk is None:
msg = (
"Error: tkinter is not available in this Python environment.\n"
"This game requires a Tk GUI.\n\n"
"How to install tkinter:\n"
"- Debian/Ubuntu: sudo apt-get install python3-tk\n"
"- Fedora: sudo dnf install python3-tkinter\n"
"- Arch: sudo pacman -S tk\n"
"- macOS (recommended): Install Python from python.org which includes Tk.\n"
" Homebrew users: brew install python-tk@3.12 (or matching version).\n"
"- Windows: tkinter comes with the official Python installer.\n"
)
print(msg, file=sys.stderr)
return 1
root = tk.Tk()
app = TetrisApp(root)
try:
root.mainloop()
except KeyboardInterrupt:
# allow graceful exit in some environments
try:
root.destroy()
except Exception:
pass
return 0
return 0
if __name__ == "__main__":
sys.exit(main())