From b1fc00fa6a19ee84d9948a9ba340e2f0c4e3f3e5 Mon Sep 17 00:00:00 2001 From: David Leutgeb Date: Mon, 29 Sep 2025 23:45:47 +0200 Subject: [PATCH] First release of the simple testris game. --- tetris.py | 548 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 548 insertions(+) create mode 100644 tetris.py diff --git a/tetris.py b/tetris.py new file mode 100644 index 0000000..b38b012 --- /dev/null +++ b/tetris.py @@ -0,0 +1,548 @@ +#!/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("", lambda e: self.on_left()) + root.bind("", lambda e: self.on_right()) + root.bind("", lambda e: self.on_down()) + root.bind("", lambda e: self.on_rotate()) + root.bind("", lambda e: self.on_hard_drop()) + root.bind("", lambda e: self.on_hold()) + root.bind("", 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())