549 lines
17 KiB
Python
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())
|