Created
August 4, 2026 10:37
-
-
Save tarruda/20421dbfa612ebf71ed20361320b1281 to your computer and use it in GitHub Desktop.
DeepSeek V4 0731 Terminal tetris
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| TETRIS — a beautiful, fully-featured Tetris clone for the terminal. | |
| Controls | |
| ──────── | |
| ← / A, D move left / right | |
| ↓ / S soft drop | |
| SPACE hard drop | |
| Z rotate counter-clockwise | |
| X / ↑ / W rotate clockwise | |
| C / H hold piece | |
| P pause | |
| R restart | |
| Q / ESC quit | |
| Features | |
| ──────── | |
| • SRS rotation with wall kicks | |
| • 7-bag randomizer, 4-piece next queue, hold piece | |
| • ghost piece, lock delay (with move resets) | |
| • T-spin detection (single / double / triple / no-clear) | |
| • combos, back-to-back bonuses, perfect-clear bonus | |
| • guideline-style scoring, levels and speed curve | |
| • persistent high score (~/.tetris_highscore) | |
| • 256-color shaded blocks (8-color fallback), Unicode borders | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import curses | |
| import os | |
| import random | |
| import time | |
| VERSION = "1.0.0" | |
| COLS = 10 | |
| VISIBLE = 20 | |
| HIDDEN = 2 | |
| ROWS = VISIBLE + HIDDEN | |
| KINDS = ("I", "O", "T", "S", "Z", "J", "L") | |
| # --------------------------------------------------------------------------- | |
| # Tetromino definitions (Super Rotation System) | |
| # Each state is the set of (x, y) cell offsets inside a 4x4 / 3x3 box. | |
| # --------------------------------------------------------------------------- | |
| SHAPES = { | |
| "I": { | |
| 0: [(0, 1), (1, 1), (2, 1), (3, 1)], | |
| 1: [(2, 0), (2, 1), (2, 2), (2, 3)], | |
| 2: [(0, 2), (1, 2), (2, 2), (3, 2)], | |
| 3: [(1, 0), (1, 1), (1, 2), (1, 3)], | |
| }, | |
| "O": {r: [(1, 0), (2, 0), (1, 1), (2, 1)] for r in range(4)}, | |
| "T": { | |
| 0: [(1, 0), (0, 1), (1, 1), (2, 1)], | |
| 1: [(1, 0), (1, 1), (2, 1), (1, 2)], | |
| 2: [(0, 1), (1, 1), (2, 1), (1, 2)], | |
| 3: [(1, 0), (0, 1), (1, 1), (1, 2)], | |
| }, | |
| "S": { | |
| 0: [(1, 0), (2, 0), (0, 1), (1, 1)], | |
| 1: [(1, 0), (1, 1), (2, 1), (2, 2)], | |
| 2: [(1, 1), (2, 1), (0, 2), (1, 2)], | |
| 3: [(0, 0), (0, 1), (1, 1), (1, 2)], | |
| }, | |
| "Z": { | |
| 0: [(0, 0), (1, 0), (1, 1), (2, 1)], | |
| 1: [(2, 0), (1, 1), (2, 1), (1, 2)], | |
| 2: [(0, 1), (1, 1), (1, 2), (2, 2)], | |
| 3: [(1, 0), (0, 1), (1, 1), (0, 2)], | |
| }, | |
| "J": { | |
| 0: [(0, 0), (0, 1), (1, 1), (2, 1)], | |
| 1: [(1, 0), (2, 0), (1, 1), (1, 2)], | |
| 2: [(0, 1), (1, 1), (2, 1), (2, 2)], | |
| 3: [(1, 0), (1, 1), (0, 2), (1, 2)], | |
| }, | |
| "L": { | |
| 0: [(2, 0), (0, 1), (1, 1), (2, 1)], | |
| 1: [(1, 0), (1, 1), (1, 2), (2, 2)], | |
| 2: [(0, 1), (1, 1), (2, 1), (0, 2)], | |
| 3: [(0, 0), (1, 0), (1, 1), (1, 2)], | |
| }, | |
| } | |
| # SRS wall-kick tables, keyed by (from_rotation, to_rotation). | |
| # Rotations: 0 = spawn, 1 = 90° CW, 2 = 180°, 3 = 90° CCW. | |
| KICKS_JLSTZ = { | |
| (0, 1): [(0, 0), (-1, 0), (-1, 1), (0, -2), (-1, -2)], | |
| (1, 0): [(0, 0), (1, 0), (1, -1), (0, 2), (1, 2)], | |
| (1, 2): [(0, 0), (1, 0), (1, -1), (0, 2), (1, 2)], | |
| (2, 1): [(0, 0), (-1, 0), (-1, 1), (0, -2), (-1, -2)], | |
| (2, 3): [(0, 0), (1, 0), (1, 1), (0, -2), (1, -2)], | |
| (3, 2): [(0, 0), (-1, 0), (-1, -1), (0, 2), (-1, 2)], | |
| (3, 0): [(0, 0), (-1, 0), (-1, -1), (0, 2), (-1, 2)], | |
| (0, 3): [(0, 0), (1, 0), (1, 1), (0, -2), (1, -2)], | |
| } | |
| KICKS_I = { | |
| (0, 1): [(0, 0), (-2, 0), (1, 0), (-2, -1), (1, 2)], | |
| (1, 0): [(0, 0), (2, 0), (-1, 0), (2, 1), (-1, -2)], | |
| (1, 2): [(0, 0), (-1, 0), (2, 0), (-1, 2), (2, -1)], | |
| (2, 1): [(0, 0), (1, 0), (-2, 0), (1, -2), (-2, 1)], | |
| (2, 3): [(0, 0), (2, 0), (-1, 0), (2, 1), (-1, -2)], | |
| (3, 2): [(0, 0), (-2, 0), (1, 0), (-2, -1), (1, 2)], | |
| (3, 0): [(0, 0), (1, 0), (-2, 0), (1, -2), (-2, 1)], | |
| (0, 3): [(0, 0), (-1, 0), (2, 0), (-1, 2), (2, -1)], | |
| } | |
| KICKS_O = {(f, t): [(0, 0)] for f in range(4) for t in range(4)} | |
| def kicks_for(kind: str, frm: int, to: int): | |
| if kind == "I": | |
| table = KICKS_I | |
| elif kind == "O": | |
| table = KICKS_O | |
| else: | |
| table = KICKS_JLSTZ | |
| return table.get((frm, to), [(0, 0)]) | |
| # (bright, base, dark) color indices for 256-color terminals | |
| PALETTE_256 = { | |
| "I": (51, 39, 24), | |
| "O": (228, 220, 172), | |
| "T": (213, 135, 60), | |
| "S": (114, 83, 29), | |
| "Z": (203, 196, 52), | |
| "J": (81, 75, 25), | |
| "L": (215, 208, 130), | |
| } | |
| # 8-color fallback | |
| PALETTE_8 = { | |
| "I": (6, 6, 0), | |
| "O": (3, 3, 0), | |
| "T": (5, 5, 0), | |
| "S": (2, 2, 0), | |
| "Z": (1, 1, 0), | |
| "J": (4, 4, 0), | |
| "L": (3, 3, 0), | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Game logic (pure Python, no curses — easily testable) | |
| # --------------------------------------------------------------------------- | |
| class Tetris: | |
| def __init__(self, seed=None, highscore_file=None): | |
| self.rng = random.Random(seed) | |
| self.highscore_file = highscore_file or self.default_highscore_file() | |
| self.highscore = self._load_highscore() | |
| self.reset() | |
| @staticmethod | |
| def default_highscore_file(): | |
| try: | |
| home = os.path.expanduser("~") | |
| return os.path.join(home, ".tetris_highscore") | |
| except Exception: | |
| return os.path.join( | |
| os.path.dirname(os.path.abspath(__file__)), ".tetris_highscore" | |
| ) | |
| def _load_highscore(self): | |
| try: | |
| with open(self.highscore_file) as f: | |
| return max(0, int(f.read().strip())) | |
| except Exception: | |
| return 0 | |
| def save_highscore(self): | |
| if self.score <= self.highscore: | |
| return | |
| self.highscore = self.score | |
| for path in {self.highscore_file, | |
| os.path.join(os.path.dirname(os.path.abspath(__file__)), | |
| ".tetris_highscore")}: | |
| try: | |
| with open(path, "w") as f: | |
| f.write(str(self.highscore)) | |
| break | |
| except Exception: | |
| continue | |
| # ---- setup ----------------------------------------------------------- | |
| def reset(self): | |
| self.grid = [[0] * COLS for _ in range(ROWS)] # 0 empty, 1..7 piece | |
| self.bag = [] | |
| self.queue = [] | |
| self.held = None | |
| self.can_hold = True | |
| self.active = None | |
| self.ghost_y = 0 | |
| self.score = 0 | |
| self.lines = 0 | |
| self.level = 1 | |
| self.combo = 0 | |
| self.last_bonus = False | |
| self.pieces = 0 | |
| self.tetrises = 0 | |
| self.tspins = 0 | |
| self.perfects = 0 | |
| self.state = "start" # start | play | pause | over | |
| self.landed = False | |
| self.lock_timer = 0.0 | |
| self.lock_resets = 0 | |
| self.last_action = None | |
| self.fall_timer = 0.0 | |
| self.anim_rows = [] | |
| self.anim_t = 0.0 | |
| self._pending_clear = None | |
| self.quit = False | |
| self._refill_queue() | |
| self.spawn() | |
| def restart(self): | |
| self.reset() | |
| self.state = "play" | |
| def _refill_queue(self): | |
| while len(self.queue) < 5: | |
| if not self.bag: | |
| self.bag = list(KINDS) | |
| self.rng.shuffle(self.bag) | |
| self.queue.append(self.bag.pop(0)) | |
| def spawn(self): | |
| self._spawn_kind(self.queue.pop(0)) | |
| self._refill_queue() | |
| def _spawn_kind(self, kind): | |
| self.active = {"kind": kind, "rot": 0, "x": 3, "y": 0, | |
| "cells": SHAPES[kind][0]} | |
| self.landed = False | |
| self.lock_timer = 0.0 | |
| self.lock_resets = 0 | |
| self.last_action = None | |
| self.can_hold = True | |
| self.ghost_y = self.drop_y() | |
| if not self.fits(self.active["cells"], 3, 0): | |
| self.state = "over" | |
| self.save_highscore() | |
| # ---- geometry -------------------------------------------------------- | |
| def fits(self, cells, x, y): | |
| for cx, cy in cells: | |
| gx, gy = x + cx, y + cy | |
| if gx < 0 or gx >= COLS or gy >= ROWS: | |
| return False | |
| if gy >= 0 and self.grid[gy][gx]: | |
| return False | |
| return True | |
| def can_drop(self): | |
| cur = self.active | |
| return self.fits(cur["cells"], cur["x"], cur["y"] + 1) | |
| def drop_y(self): | |
| cur = self.active | |
| y = cur["y"] | |
| while self.fits(cur["cells"], cur["x"], y + 1): | |
| y += 1 | |
| return y | |
| def update_ghost(self): | |
| self.ghost_y = self.drop_y() | |
| # ---- actions --------------------------------------------------------- | |
| def move(self, dx): | |
| if self.state != "play" or not self.active or self.anim_rows: | |
| return | |
| cur = self.active | |
| if self.fits(cur["cells"], cur["x"] + dx, cur["y"]): | |
| cur["x"] += dx | |
| self.last_action = "move" | |
| was_landed = self.landed | |
| if self.can_drop(): | |
| self.landed = False | |
| self.lock_timer = 0.0 | |
| if was_landed: | |
| self.lock_resets += 1 | |
| if self.lock_resets >= 15: | |
| self.lock_now() | |
| return | |
| self.update_ghost() | |
| def rotate(self, direction): | |
| if self.state != "play" or not self.active or self.anim_rows: | |
| return False | |
| cur = self.active | |
| new_rot = (cur["rot"] + direction) % 4 | |
| cells = SHAPES[cur["kind"]][new_rot] | |
| was_landed = self.landed | |
| for dx, dy in kicks_for(cur["kind"], cur["rot"], new_rot): | |
| if self.fits(cells, cur["x"] + dx, cur["y"] + dy): | |
| cur["rot"] = new_rot | |
| cur["cells"] = cells | |
| cur["x"] += dx | |
| cur["y"] += dy | |
| self.last_action = "rotate" | |
| if self.can_drop(): | |
| self.landed = False | |
| self.lock_timer = 0.0 | |
| if was_landed: | |
| self.lock_resets += 1 | |
| if self.lock_resets >= 15: | |
| self.lock_now() | |
| return True | |
| self.update_ghost() | |
| return True | |
| return False | |
| def soft_drop(self): | |
| if self.state != "play" or not self.active or self.anim_rows: | |
| return | |
| cur = self.active | |
| if self.fits(cur["cells"], cur["x"], cur["y"] + 1): | |
| cur["y"] += 1 | |
| self.score += 1 | |
| self.fall_timer = 0.0 | |
| self.update_ghost() | |
| else: | |
| self.lock_now() | |
| def hard_drop(self): | |
| if self.state != "play" or not self.active or self.anim_rows: | |
| return | |
| cur = self.active | |
| target = self.drop_y() | |
| self.score += 2 * (target - cur["y"]) | |
| cur["y"] = target | |
| self.update_ghost() | |
| self.lock_now() | |
| def hold(self): | |
| if (self.state != "play" or not self.active or self.anim_rows | |
| or not self.can_hold): | |
| return | |
| kind = self.active["kind"] | |
| if self.held is None: | |
| self.held = kind | |
| self.spawn() | |
| else: | |
| tmp = self.held | |
| self.held = kind | |
| self._spawn_kind(tmp) | |
| self.can_hold = False | |
| # ---- locking & clears ------------------------------------------------- | |
| def lock_now(self): | |
| if not self.active: | |
| return | |
| cur = self.active | |
| kind = cur["kind"] | |
| for cx, cy in cur["cells"]: | |
| gx, gy = cur["x"] + cx, cur["y"] + cy | |
| if 0 <= gx < COLS and 0 <= gy < ROWS: | |
| self.grid[gy][gx] = KINDS.index(kind) + 1 | |
| self.pieces += 1 | |
| tspin = self.detect_tspin(cur) | |
| if tspin: | |
| self.tspins += 1 | |
| self.active = None | |
| self.landed = False | |
| self.resolve_clear(tspin) | |
| self.spawn() | |
| def detect_tspin(self, cur): | |
| if cur["kind"] != "T" or self.last_action != "rotate": | |
| return False | |
| cx, cy = cur["x"] + 1, cur["y"] + 1 # T pivot at local (1, 1) | |
| corners = [(cx - 1, cy - 1), (cx + 1, cy - 1), | |
| (cx - 1, cy + 1), (cx + 1, cy + 1)] | |
| filled = 0 | |
| for gx, gy in corners: | |
| if gx < 0 or gx >= COLS or gy >= ROWS or \ | |
| (gy >= 0 and self.grid[gy][gx]): | |
| filled += 1 | |
| return filled >= 3 | |
| def resolve_clear(self, tspin): | |
| full = [r for r in range(ROWS) if all(self.grid[r])] | |
| if full: | |
| self.anim_rows = full | |
| self.anim_t = 0.0 | |
| self._pending_clear = tspin | |
| else: | |
| self.combo = 0 | |
| self.last_bonus = False | |
| def finish_clear(self): | |
| if not self.anim_rows: | |
| return | |
| n = len(self.anim_rows) | |
| tspin = self._pending_clear | |
| for r in self.anim_rows: | |
| del self.grid[r] | |
| self.grid.insert(0, [0] * COLS) | |
| self.anim_rows = [] | |
| self._pending_clear = None | |
| self.lines += n | |
| self.level = self.lines // 10 + 1 | |
| self.combo = self.combo + 1 if n else 0 | |
| if tspin: | |
| pts = 400 if n == 0 else (800, 1200, 1600)[min(n, 3) - 1] | |
| elif n == 4: | |
| pts = 800 | |
| self.tetrises += 1 | |
| else: | |
| pts = (0, 100, 300, 500)[n] if n <= 3 else 800 | |
| pts *= self.level | |
| is_bonus = n > 0 and (tspin or n == 4) | |
| if is_bonus and self.last_bonus: | |
| pts = pts * 3 // 2 # back-to-back | |
| self.last_bonus = is_bonus | |
| if n: | |
| self.score += pts + 50 * self.combo * self.level | |
| if n and not any(any(row) for row in self.grid): | |
| self.score += 1000 * self.level # perfect clear | |
| self.perfects += 1 | |
| # ---- timing ---------------------------------------------------------- | |
| def fall_interval(self): | |
| return max(0.03, 0.8 * (0.85 ** (self.level - 1))) | |
| def lock_delay(self): | |
| return max(0.12, 0.5 * (0.9 ** (self.level - 1))) | |
| def update(self, dt): | |
| if self.state != "play": | |
| return | |
| if self.anim_rows: | |
| self.anim_t += dt | |
| if self.anim_t >= 0.12: | |
| self.finish_clear() | |
| return | |
| if not self.active: | |
| return | |
| if self.landed: | |
| self.lock_timer += dt | |
| if self.lock_timer >= self.lock_delay(): | |
| self.lock_now() | |
| return | |
| self.fall_timer += dt | |
| interval = self.fall_interval() | |
| if self.fall_timer >= interval: | |
| self.fall_timer = 0.0 | |
| if self.can_drop(): | |
| self.active["y"] += 1 | |
| self.update_ghost() | |
| else: | |
| self.landed = True | |
| self.lock_timer = 0.0 | |
| # --------------------------------------------------------------------------- | |
| # Rendering | |
| # --------------------------------------------------------------------------- | |
| HOLD_W = 16 | |
| NEXT_W = 20 | |
| GAP = 2 | |
| BOARD_W = 22 # 20 cell chars + 2 borders | |
| TOTAL_W = HOLD_W + GAP + BOARD_W + GAP + NEXT_W # 62 | |
| TOTAL_H = 26 | |
| class Renderer: | |
| def __init__(self, stdscr, ascii_chars=False): | |
| self.stdscr = stdscr | |
| self.ascii = ascii_chars | |
| self._cache = {} | |
| self.colors256 = False | |
| self._init_colors() | |
| if ascii_chars: | |
| self.fill = "#" | |
| self.ghost_ch = "+" | |
| else: | |
| self.fill = "▀" | |
| self.ghost_ch = "▒" | |
| # ---- colors ---------------------------------------------------------- | |
| def _pair(self, fg, bg): | |
| key = (fg, bg) | |
| if key in self._cache: | |
| return self._cache[key] | |
| n = len(self._cache) + 1 | |
| try: | |
| curses.init_pair(n, fg, bg) | |
| except curses.error: | |
| return 0 | |
| self._cache[key] = n | |
| return n | |
| def _init_colors(self): | |
| try: | |
| curses.start_color() | |
| curses.use_default_colors() | |
| self.colors256 = curses.COLORS >= 256 and curses.COLOR_PAIRS >= 40 | |
| except curses.error: | |
| self.colors256 = False | |
| if self.colors256: | |
| bg, panel = 235, 234 | |
| self.c_bg, self.c_panel = bg, panel | |
| self.p_bg = self._pair(bg, bg) | |
| self.p_panel = self._pair(panel, panel) | |
| self.p_frame = self._pair(244, -1) | |
| self.p_text = self._pair(231, -1) | |
| self.p_dim = self._pair(242, -1) | |
| self.p_flash = self._pair(231, 250) | |
| self.piece = {} | |
| for k in KINDS: | |
| bright, base, dark = PALETTE_256[k] | |
| self.piece[k] = { | |
| "fill": self._pair(bright, dark), | |
| "ghost": self._pair(base, -1), | |
| "title": self._pair(bright, -1), | |
| } | |
| else: | |
| self.c_bg = self.c_panel = 0 | |
| self.p_bg = self._pair(0, 0) | |
| self.p_panel = self._pair(0, 0) | |
| self.p_frame = self._pair(7, -1) | |
| self.p_text = self._pair(7, -1) | |
| self.p_dim = self._pair(7, -1) | |
| self.p_flash = self._pair(7, 7) | |
| self.piece = {} | |
| for k in KINDS: | |
| color = PALETTE_8[k][0] | |
| self.piece[k] = { | |
| "fill": self._pair(color, 0), | |
| "ghost": self._pair(color, 0), | |
| "title": self._pair(color, -1), | |
| } | |
| # ---- primitives ------------------------------------------------------ | |
| def put(self, row, col, text, pair=0, attr=0): | |
| h, w = self.stdscr.getmaxyx() | |
| if row < 0 or row >= h or col < 0 or col >= w: | |
| return | |
| if col + len(text) > w: | |
| text = text[:max(0, w - col)] | |
| if not text: | |
| return | |
| attrs = attr | (curses.color_pair(pair) if pair else 0) | |
| try: | |
| self.stdscr.addstr(row, col, text, attrs) | |
| except curses.error: | |
| pass | |
| def box(self, top, left, width, height, double=False): | |
| if self.ascii: | |
| hch, vch, tl, tr, bl, br = "-", "|", "+", "+", "+", "+" | |
| else: | |
| if double: | |
| hch, vch, tl, tr, bl, br = "═", "║", "╔", "╗", "╚", "╝" | |
| else: | |
| hch, vch, tl, tr, bl, br = "─", "│", "┌", "┐", "└", "┘" | |
| self.put(top, left, tl + hch * (width - 2) + tr, self.p_frame) | |
| self.put(top + height - 1, left, bl + hch * (width - 2) + br, | |
| self.p_frame) | |
| for r in range(top + 1, top + height - 1): | |
| self.put(r, left, vch, self.p_frame) | |
| self.put(r, left + width - 1, vch, self.p_frame) | |
| def draw_mini(self, top, left, kind, pair, attr=0): | |
| """Draw a small preview piece. `left` is the inner-area char column.""" | |
| cells = SHAPES[kind][0] | |
| xs = [x for x, _ in cells] | |
| ys = [y for _, y in cells] | |
| minx, miny = min(xs), min(ys) | |
| w = max(xs) - minx + 1 | |
| h = max(ys) - miny + 1 | |
| start_c = left + ((5 - w) // 2) * 2 | |
| start_r = top + (2 - h) // 2 | |
| for x, y in cells: | |
| r = start_r + y - miny | |
| c = start_c + (x - minx) * 2 | |
| self.put(r, c, self.fill * 2, pair, attr) | |
| # ---- whole frame ----------------------------------------------------- | |
| def draw(self, game): | |
| scr = self.stdscr | |
| scr.erase() | |
| h, w = scr.getmaxyx() | |
| if h < TOTAL_H or w < TOTAL_W: | |
| msg = "Terminal too small — resize to at least 80x26" | |
| self.put(h // 2, max(0, (w - len(msg)) // 2), msg, self.p_text, | |
| curses.A_BOLD) | |
| return | |
| x0 = max(0, (w - TOTAL_W) // 2) | |
| self._draw_background(x0) | |
| self._draw_frame(x0) | |
| self._draw_title(x0) | |
| self._draw_hold(game, x0) | |
| self._draw_next(game, x0) | |
| self._draw_board(game, x0) | |
| self._draw_controls(x0) | |
| if game.state == "start": | |
| best = max(game.highscore, game.score) | |
| self.draw_overlay( | |
| "TETRIS", | |
| [f"BEST {best:,}", "PRESS ANY KEY TO START"], | |
| title_pair=self.piece["T"]["title"], | |
| ) | |
| elif game.state == "pause": | |
| self.draw_overlay("PAUSED", ["P to resume · Q to quit"]) | |
| elif game.state == "over": | |
| best = max(game.highscore, game.score) | |
| self.draw_overlay( | |
| "GAME OVER", | |
| [f"SCORE {game.score:,}", f"BEST {best:,}"], | |
| note="R restart · Q quit", | |
| ) | |
| def _draw_background(self, x0): | |
| row = " " * TOTAL_W | |
| for r in range(1, TOTAL_H - 1): | |
| self.put(r, x0, row, self.p_panel) | |
| def _draw_frame(self, x0): | |
| w = TOTAL_W + 2 | |
| if self.ascii: | |
| self.put(0, x0 - 1, "+" + "-" * (w - 2) + "+", self.p_frame) | |
| self.put(TOTAL_H - 1, x0 - 1, "+" + "-" * (w - 2) + "+", | |
| self.p_frame) | |
| v = "|" | |
| else: | |
| self.put(0, x0 - 1, "╔" + "═" * (w - 2) + "╗", self.p_frame) | |
| self.put(TOTAL_H - 1, x0 - 1, "╚" + "═" * (w - 2) + "╝", | |
| self.p_frame) | |
| v = "║" | |
| for r in range(1, TOTAL_H - 1): | |
| self.put(r, x0 - 1, v, self.p_frame) | |
| self.put(r, x0 + TOTAL_W, v, self.p_frame) | |
| def _draw_title(self, x0): | |
| _, w = self.stdscr.getmaxyx() | |
| s = "◆ T E T R I S ◆" | |
| colors = [self.piece[k]["title"] for k in ("I", "O", "T", "S", "Z", "J")] | |
| col = (w - len(s)) // 2 | |
| ci = 0 | |
| for ch in s: | |
| if ch in "TETRIS": | |
| pair = colors[ci % 6] | |
| ci += 1 | |
| elif ch == "◆": | |
| pair = self.p_frame | |
| else: | |
| pair = self.p_dim | |
| self.put(1, col, ch, pair, curses.A_BOLD) | |
| col += 1 | |
| def _draw_hold(self, game, x0): | |
| self.put(2, x0 + 2, "HOLD", self.p_dim) | |
| self.box(3, x0 + 2, 12, 4) | |
| if game.held: | |
| attr = curses.A_DIM if not game.can_hold else 0 | |
| self.draw_mini(4, x0 + 3, game.held, | |
| self.piece[game.held]["fill"], attr) | |
| best = max(game.highscore, game.score) | |
| self.put(8, x0 + 2, "BEST", self.p_dim) | |
| self.put(9, x0 + 2, f"{best:>10,}", self.p_text, curses.A_BOLD) | |
| self.put(11, x0 + 2, "PIECES", self.p_dim) | |
| self.put(12, x0 + 2, f"{game.pieces:>10}", self.p_text) | |
| self.put(14, x0 + 2, "TETRISES", self.p_dim) | |
| self.put(15, x0 + 2, f"{game.tetrises:>10}", self.p_text) | |
| self.put(17, x0 + 2, "T-SPINS", self.p_dim) | |
| self.put(18, x0 + 2, f"{game.tspins:>10}", self.p_text) | |
| self.put(20, x0 + 2, "PERFECTS", self.p_dim) | |
| self.put(21, x0 + 2, f"{game.perfects:>10}", self.p_text) | |
| def _draw_next(self, game, x0): | |
| nleft = x0 + HOLD_W + GAP + BOARD_W + GAP | |
| self.put(2, nleft + 2, "NEXT", self.p_dim) | |
| for i in range(4): | |
| top = 3 + i * 4 | |
| self.box(top, nleft + 2, 12, 4) | |
| if i < len(game.queue): | |
| kind = game.queue[i] | |
| self.draw_mini(top + 1, nleft + 3, kind, | |
| self.piece[kind]["fill"]) | |
| self.put(19, nleft + 2, f"SCORE {game.score:>9,}", self.p_text, | |
| curses.A_BOLD) | |
| self.put(20, nleft + 2, f"LEVEL {game.level:>9}", self.p_text) | |
| self.put(21, nleft + 2, f"LINES {game.lines:>9}", self.p_text) | |
| if game.combo > 1: | |
| combo = f"COMBO x{game.combo}" + (" B2B" if game.last_bonus else "") | |
| else: | |
| combo = "COMBO -" + (" B2B" if game.last_bonus else "") | |
| self.put(22, nleft + 2, combo, self.p_text) | |
| def _draw_board(self, game, x0): | |
| left = x0 + HOLD_W + GAP | |
| inner = left + 1 | |
| self.put(2, left, "┌" + "─" * 20 + "┐", self.p_frame) | |
| self.put(23, left, "└" + "─" * 20 + "┘", self.p_frame) | |
| for r in range(3, 23): | |
| self.put(r, left, "│", self.p_frame) | |
| self.put(r, left + 21, "│", self.p_frame) | |
| anim = set(game.anim_rows) | |
| for r in range(VISIBLE): | |
| row = r + HIDDEN | |
| for c in range(COLS): | |
| col = inner + c * 2 | |
| if row in anim: | |
| self.put(3 + r, col, self.fill * 2, self.p_flash) | |
| elif game.grid[row][c]: | |
| kind = KINDS[game.grid[row][c] - 1] | |
| self.put(3 + r, col, self.fill * 2, | |
| self.piece[kind]["fill"]) | |
| else: | |
| self.put(3 + r, col, " ", self.p_bg) | |
| if game.active and not anim: | |
| kind = game.active["kind"] | |
| # ghost piece | |
| for cx, cy in game.active["cells"]: | |
| gx, gy = game.active["x"] + cx, game.ghost_y + cy | |
| if 0 <= gx < COLS and HIDDEN <= gy < ROWS: | |
| self.put(3 + gy - HIDDEN, inner + gx * 2, | |
| self.ghost_ch * 2, self.piece[kind]["ghost"]) | |
| # active piece | |
| for cx, cy in game.active["cells"]: | |
| gx, gy = game.active["x"] + cx, game.active["y"] + cy | |
| if 0 <= gx < COLS and HIDDEN <= gy < ROWS: | |
| self.put(3 + gy - HIDDEN, inner + gx * 2, | |
| self.fill * 2, self.piece[kind]["fill"]) | |
| def _draw_controls(self, x0): | |
| _, w = self.stdscr.getmaxyx() | |
| s = ("←→ move ↓ soft SPACE hard Z/X rotate " | |
| "C hold P pause R restart Q quit") | |
| self.put(24, max(0, (w - len(s)) // 2), s, self.p_dim) | |
| def draw_overlay(self, title, lines, note=None, title_pair=None): | |
| h, w = self.stdscr.getmaxyx() | |
| box_w = 36 | |
| box_h = 3 + len(lines) + (1 if note else 0) | |
| top = max(1, (TOTAL_H - box_h) // 2 - 2) | |
| left = max(0, (w - box_w) // 2) | |
| self.box(top, left, box_w, box_h, double=True) | |
| self.put(top + 1, left + (box_w - len(title)) // 2, title, | |
| title_pair or self.p_text, curses.A_BOLD) | |
| for i, ln in enumerate(lines): | |
| self.put(top + 2 + i, left + (box_w - len(ln)) // 2, ln, | |
| self.p_dim) | |
| if note: | |
| self.put(top + box_h - 2, left + (box_w - len(note)) // 2, note, | |
| self.p_dim) | |
| # --------------------------------------------------------------------------- | |
| # Input & main loop | |
| # --------------------------------------------------------------------------- | |
| def handle_key(game, key): | |
| if key in (ord("q"), ord("Q"), 27): # ESC | |
| game.quit = True | |
| return | |
| if key in (-1, 0) or key == curses.KEY_RESIZE: | |
| return | |
| if game.state == "start": | |
| game.state = "play" | |
| game.fall_timer = 0.0 | |
| return | |
| if game.state == "pause": | |
| if key in (ord("p"), ord("P")): | |
| game.state = "play" | |
| return | |
| if game.state == "over": | |
| if key in (ord("r"), ord("R")): | |
| game.restart() | |
| return | |
| # play state | |
| if key in (ord("p"), ord("P")): | |
| game.state = "pause" | |
| elif key in (ord("r"), ord("R")): | |
| game.restart() | |
| elif key in (curses.KEY_LEFT, ord("a"), ord("A")): | |
| game.move(-1) | |
| elif key in (curses.KEY_RIGHT, ord("d"), ord("D")): | |
| game.move(1) | |
| elif key in (curses.KEY_DOWN, ord("s"), ord("S")): | |
| game.soft_drop() | |
| elif key in (curses.KEY_UP, ord("w"), ord("W"), ord("x"), ord("X")): | |
| game.rotate(1) | |
| elif key in (ord("z"), ord("Z")): | |
| game.rotate(-1) | |
| elif key == ord(" "): | |
| game.hard_drop() | |
| elif key in (ord("c"), ord("C"), ord("h"), ord("H")): | |
| game.hold() | |
| def parse_args(argv=None): | |
| p = argparse.ArgumentParser( | |
| prog="tetris", | |
| description="A beautiful, fully-featured Tetris clone for the terminal.", | |
| ) | |
| p.add_argument("--seed", type=int, default=None, | |
| help="random seed (for reproducible games)") | |
| p.add_argument("--highscore", default=None, | |
| help="path to high-score file") | |
| p.add_argument("--ascii", action="store_true", | |
| help="render with ASCII characters only") | |
| p.add_argument("--frames", type=int, default=0, | |
| help="quit automatically after N frames (testing)") | |
| p.add_argument("--version", action="version", version=f"tetris {VERSION}") | |
| return p.parse_args(argv) | |
| def main(stdscr): | |
| args = parse_args() | |
| renderer = Renderer(stdscr, ascii_chars=args.ascii) | |
| game = Tetris(seed=args.seed, highscore_file=args.highscore) | |
| stdscr.nodelay(True) | |
| stdscr.keypad(True) | |
| try: | |
| curses.curs_set(0) | |
| except curses.error: | |
| pass | |
| last = time.monotonic() | |
| frame = 0 | |
| while True: | |
| now = time.monotonic() | |
| dt = min(now - last, 0.05) | |
| last = now | |
| key = stdscr.getch() | |
| handle_key(game, key) | |
| if game.quit: | |
| break | |
| game.update(dt) | |
| renderer.draw(game) | |
| stdscr.refresh() | |
| frame += 1 | |
| if args.frames and frame >= args.frames: | |
| break | |
| time.sleep(0.004) | |
| if __name__ == "__main__": | |
| try: | |
| curses.wrapper(main) | |
| except KeyboardInterrupt: | |
| pass |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment