import json import os import random from pathlib import Path import pygame COLS, ROWS = 10, 20 CELL = 28 BOARD_X, BOARD_Y = 24, 64 WIDTH, HEIGHT = 640, 692 PANEL_X = 330 BG = (15, 18, 29) WELL = (22, 27, 41) GRID = (34, 40, 57) TEXT = (229, 234, 245) MUTED = (149, 161, 184) ACCENT = (103, 224, 204) COLORS = { "I": (65, 204, 230), "J": (84, 126, 235), "L": (246, 163, 65), "O": (240, 215, 76), "S": (99, 210, 133), "T": (178, 111, 230), "Z": (236, 101, 115), } BASE_SHAPES = { "I": ("....", "IIII", "....", "...."), "J": ("J..", "JJJ", "..."), "L": ("..L", "LLL", "..."), "O": ("OO", "OO"), "S": (".SS", "SS.", "..."), "T": (".T.", "TTT", "..."), "Z": ("ZZ.", ".ZZ", "..."), } def rotate_matrix(matrix): return tuple("".join(row) for row in zip(*matrix[::-1])) SHAPES = {} for kind, base in BASE_SHAPES.items(): rotations = [base] for _ in range(3): rotations.append(rotate_matrix(rotations[-1])) SHAPES[kind] = rotations # Clockwise SRS offsets, expressed with positive y pointing upward. JLSTZ_KICKS = { 0: ((0, 0), (-1, 0), (-1, 1), (0, -2), (-1, -2)), 1: ((0, 0), (1, 0), (1, -1), (0, 2), (1, 2)), 2: ((0, 0), (1, 0), (1, 1), (0, -2), (1, -2)), 3: ((0, 0), (-1, 0), (-1, -1), (0, 2), (-1, 2)), } I_KICKS = { 0: ((0, 0), (-2, 0), (1, 0), (-2, -1), (1, 2)), 1: ((0, 0), (-1, 0), (2, 0), (-1, 2), (2, -1)), 2: ((0, 0), (2, 0), (-1, 0), (2, 1), (-1, -2)), 3: ((0, 0), (1, 0), (-2, 0), (1, -2), (-2, 1)), } class ScoreStore: def __init__(self): self.path = Path.cwd() / "high_scores.json" self.entries = [] self.error = "" self.load() def load(self): try: data = json.loads(self.path.read_text(encoding="utf-8")) if isinstance(data, list): for entry in data: if not isinstance(entry, dict): continue name = entry.get("initials", entry.get("name", "")) score = entry.get("score") if ( isinstance(name, str) and isinstance(score, int) and not isinstance(score, bool) and score >= 0 ): self.entries.append({ "initials": name.strip()[:12] or "PLAYER", "score": score, }) self.entries.sort(key=lambda item: item["score"], reverse=True) self.entries = self.entries[:5] except FileNotFoundError: pass except (OSError, ValueError, TypeError): self.error = "Could not load previous scores." @staticmethod def atomic_write(path, data): temporary = path.with_name(path.name + ".tmp") with temporary.open("w", encoding="utf-8") as handle: json.dump(data, handle, indent=2) handle.write("\n") os.replace(temporary, path) def record(self, name, score): entry = {"initials": name.strip()[:12] or "PLAYER", "score": int(score)} self.entries.append(entry) self.entries.sort(key=lambda item: item["score"], reverse=True) self.entries = self.entries[:5] failures = [] for path, data in ( (self.path, self.entries), (Path("/tmp/alpaca_score.json"), entry), ): try: self.atomic_write(path, data) except OSError: failures.append(path.name) self.error = "Save failed: " + ", ".join(failures) if failures else "" if self.error: print(self.error) class Tetris: def __init__(self, scores): self.scores = scores self.reset() @property def level(self): return self.lines // 10 + 1 def reset(self): self.board = [[None for _ in range(COLS)] for _ in range(ROWS)] self.score = 0 self.lines = 0 self.state = "playing" self.initials = "" self.saved = False self.queue = [] self.bag = [] self.clear_rows = [] self.flash_time = 0 self.repeat_direction = 0 self.repeat_time = 0 pygame.key.stop_text_input() self.spawn() def take_piece(self): if not self.bag: self.bag = list(BASE_SHAPES) random.shuffle(self.bag) return self.bag.pop() def spawn(self): while len(self.queue) < 4: self.queue.append(self.take_piece()) self.kind = self.queue.pop(0) self.rotation = 0 self.x = (COLS - len(SHAPES[self.kind][0])) // 2 self.y = -1 self.fall_time = 0 self.lock_time = 0 self.lock_resets = 0 if not self.valid(): self.finish() def cells(self, x=None, y=None, rotation=None): x = self.x if x is None else x y = self.y if y is None else y rotation = self.rotation if rotation is None else rotation matrix = SHAPES[self.kind][rotation] for row, values in enumerate(matrix): for col, value in enumerate(values): if value != ".": yield x + col, y + row def valid(self, x=None, y=None, rotation=None): for col, row in self.cells(x, y, rotation): if col < 0 or col >= COLS or row >= ROWS: return False if row >= 0 and self.board[row][col] is not None: return False return True def grounded(self): return not self.valid(y=self.y + 1) def reset_lock(self, was_grounded): if was_grounded and self.lock_resets < 15: self.lock_time = 0 self.lock_resets += 1 def move(self, dx): was_grounded = self.grounded() if self.valid(x=self.x + dx): self.x += dx self.reset_lock(was_grounded) def rotate(self): if self.kind == "O": return was_grounded = self.grounded() target = (self.rotation + 1) % 4 kicks = I_KICKS if self.kind == "I" else JLSTZ_KICKS for dx, dy in kicks[self.rotation]: if self.valid(self.x + dx, self.y - dy, target): self.x += dx self.y -= dy self.rotation = target self.reset_lock(was_grounded) return def hard_drop(self): distance = 0 while self.valid(y=self.y + 1): self.y += 1 distance += 1 self.score += distance * 2 self.lock_piece() def lock_piece(self): blocks = list(self.cells()) if any(row < 0 for _, row in blocks): self.finish() return for col, row in blocks: self.board[row][col] = self.kind full = [ row for row in range(ROWS) if all(value is not None for value in self.board[row]) ] if full: self.score += (0, 100, 300, 500, 800)[len(full)] * self.level self.lines += len(full) self.clear_rows = full self.flash_time = 0.22 self.state = "clearing" else: self.spawn() def finish(self): self.state = "entering" self.initials = "" pygame.key.start_text_input() def submit_score(self): if not self.saved: self.scores.record(self.initials, self.score) self.saved = True self.state = "gameover" pygame.key.stop_text_input() def update(self, dt): if self.state == "clearing": self.flash_time -= dt if self.flash_time <= 0: remaining = [ row for index, row in enumerate(self.board) if index not in self.clear_rows ] self.board = [ [None for _ in range(COLS)] for _ in self.clear_rows ] + remaining self.clear_rows = [] self.state = "playing" self.spawn() return if self.state != "playing": return keys = pygame.key.get_pressed() direction = int(keys[pygame.K_RIGHT]) - int(keys[pygame.K_LEFT]) if direction != self.repeat_direction: self.repeat_direction = direction self.repeat_time = 0.16 elif direction: self.repeat_time -= dt while self.repeat_time <= 0: self.move(direction) self.repeat_time += 0.045 interval = max(0.065, 0.8 * (0.8 ** (self.level - 1))) soft_drop = keys[pygame.K_DOWN] if soft_drop: interval = min(interval, 0.035) self.fall_time += dt while self.fall_time >= interval: self.fall_time -= interval if self.valid(y=self.y + 1): self.y += 1 self.lock_time = 0 if soft_drop: self.score += 1 else: self.fall_time = 0 break if self.grounded(): self.lock_time += dt if self.lock_time >= 0.45: self.lock_piece() else: self.lock_time = 0 def handle_event(self, event): if self.state == "entering": if event.type == pygame.TEXTINPUT: addition = "".join( char for char in event.text if char.isprintable() ) self.initials = (self.initials + addition)[:12] elif event.type == pygame.KEYDOWN: if event.key == pygame.K_BACKSPACE: self.initials = self.initials[:-1] elif event.key == pygame.K_RETURN: self.submit_score() return if event.type != pygame.KEYDOWN: return if self.state == "gameover": if event.key in (pygame.K_RETURN, pygame.K_r): self.reset() elif self.state == "playing": if event.key == pygame.K_LEFT: self.move(-1) self.repeat_direction = -1 self.repeat_time = 0.16 elif event.key == pygame.K_RIGHT: self.move(1) self.repeat_direction = 1 self.repeat_time = 0.16 elif event.key == pygame.K_UP: self.rotate() elif event.key == pygame.K_SPACE: self.hard_drop() def main(): pygame.init() screen = pygame.display.set_mode((WIDTH, HEIGHT)) pygame.display.set_caption("Tetris - Seven Shapes") clock = pygame.time.Clock() fonts = { "small": pygame.font.Font(None, 21), "normal": pygame.font.Font(None, 27), "large": pygame.font.Font(None, 38), "title": pygame.font.Font(None, 43), } scores = ScoreStore() game = Tetris(scores) def text(value, x, y, size="normal", color=TEXT, center=False): surface = fonts[size].render(str(value), True, color) rectangle = surface.get_rect() if center: rectangle.midtop = (x, y) else: rectangle.topleft = (x, y) screen.blit(surface, rectangle) def block(col, row, kind, ghost=False): if row < 0: return rectangle = pygame.Rect( BOARD_X + col * CELL + 1, BOARD_Y + row * CELL + 1, CELL - 2, CELL - 2, ) color = COLORS[kind] if ghost: pygame.draw.rect(screen, color, rectangle, 2, border_radius=3) else: pygame.draw.rect(screen, color, rectangle, border_radius=3) highlight = tuple(min(255, component + 35) for component in color) pygame.draw.line( screen, highlight, (rectangle.left + 3, rectangle.top + 3), (rectangle.right - 4, rectangle.top + 3), 2 ) def draw(): screen.fill(BG) text("TETRIS", BOARD_X, 16, "title", ACCENT) text("SEVEN SHAPES", PANEL_X, 27, "small", MUTED) well_rect = pygame.Rect(BOARD_X, BOARD_Y, COLS * CELL, ROWS * CELL) pygame.draw.rect(screen, WELL, well_rect) for col in range(COLS + 1): x = BOARD_X + col * CELL pygame.draw.line(screen, GRID, (x, BOARD_Y), (x, well_rect.bottom)) for row in range(ROWS + 1): y = BOARD_Y + row * CELL pygame.draw.line(screen, GRID, (BOARD_X, y), (well_rect.right, y)) for row in range(ROWS): for col in range(COLS): kind = game.board[row][col] if kind is not None: block(col, row, kind) if game.state == "playing": ghost_y = game.y while game.valid(y=ghost_y + 1): ghost_y += 1 for col, row in game.cells(y=ghost_y): block(col, row, game.kind, True) for col, row in game.cells(): block(col, row, game.kind) if game.state == "clearing": alpha = int(220 * max(0, game.flash_time) / 0.22) flash = pygame.Surface((COLS * CELL, CELL), pygame.SRCALPHA) flash.fill((255, 255, 255, alpha)) for row in game.clear_rows: screen.blit(flash, (BOARD_X, BOARD_Y + row * CELL)) pygame.draw.rect(screen, MUTED, well_rect, 2) text("SCORE", PANEL_X, 66, "small", MUTED) text(f"{game.score:,}", PANEL_X, 88, "large") text(f"LEVEL {game.level}", PANEL_X, 136) text(f"LINES {game.lines}", PANEL_X, 166) text("NEXT", PANEL_X, 212, "small", MUTED) for index, kind in enumerate(game.queue[:3]): size = 17 base_x = PANEL_X + index * 91 base_y = 243 occupied = [ (col, row) for row, values in enumerate(SHAPES[kind][0]) for col, value in enumerate(values) if value != "." ] min_col = min(col for col, _ in occupied) min_row = min(row for _, row in occupied) for col, row in occupied: rectangle = pygame.Rect( base_x + (col - min_col) * size, base_y + (row - min_row) * size, size - 2, size - 2, ) pygame.draw.rect( screen, COLORS[kind], rectangle, border_radius=2 ) text("HIGH SCORES - TOP 5", PANEL_X, 310, "normal", ACCENT) for index in range(5): y = 349 + index * 34 if index < len(scores.entries): entry = scores.entries[index] name = entry["initials"] value = f'{entry["score"]:,}' else: name, value = "---", "-" text(f"{index + 1}. {name}", PANEL_X, y, "small") surface = fonts["small"].render(value, True, TEXT) screen.blit(surface, (WIDTH - 22 - surface.get_width(), y)) text("LEFT / RIGHT Move", PANEL_X, 537, "small", MUTED) text("UP Rotate clockwise", PANEL_X, 561, "small", MUTED) text("DOWN Soft drop (+1)", PANEL_X, 585, "small", MUTED) text("SPACE Hard drop (+2)", PANEL_X, 609, "small", MUTED) text("Rows: 100 / 300 / 500 / 800 x level", BOARD_X, 645, "small", MUTED) if scores.error: text("Score file error - see console.", BOARD_X, 669, "small", (250, 130, 130)) if game.state in ("entering", "gameover"): veil = pygame.Surface(well_rect.size, pygame.SRCALPHA) veil.fill((8, 11, 20, 218)) screen.blit(veil, well_rect.topleft) center = BOARD_X + COLS * CELL // 2 text("GAME OVER", center, 221, "large", ACCENT, True) text(f"Score: {game.score:,}", center, 268, center=True) if game.state == "entering": text("Enter name / initials:", center, 322, "normal", TEXT, True) field = pygame.Rect(BOARD_X + 20, 358, COLS * CELL - 40, 42) pygame.draw.rect(screen, WELL, field, border_radius=5) pygame.draw.rect(screen, ACCENT, field, 1, border_radius=5) cursor = "|" if pygame.time.get_ticks() % 1000 < 500 else "" text(game.initials + cursor, center, 369, "normal", TEXT, True) text("ENTER to save your score", center, 426, "small", MUTED, True) text("Blank name becomes PLAYER", center, 451, "small", MUTED, True) else: message = "Score saved!" if not scores.error else "Save error" text(message, center, 330, "normal", TEXT, True) text("ENTER or R to play again", center, 381, "small", MUTED, True) text("New game starts at zero", center, 408, "small", MUTED, True) pygame.display.flip() running = True try: while running: dt = min(clock.tick(60) / 1000.0, 0.05) for event in pygame.event.get(): if event.type == pygame.QUIT: if not game.saved: game.submit_score() running = False break game.handle_event(event) if running: game.update(dt) draw() finally: pygame.quit() if __name__ == "__main__": main()