diff --git a/dino.py b/dino.py new file mode 100644 index 0000000..0d5b58f --- /dev/null +++ b/dino.py @@ -0,0 +1,461 @@ +#!/usr/bin/env python3 +"""dino: Chrome-style endless runner for the terminal. +SPACE / UP = jump. Press any key to start or restart. Q / Ctrl-C = quit. +No external dependencies -- pure ANSI escape codes, no Curses. +""" + +import os, sys, tty, termios, fcntl, signal, time, random, shutil, argparse +from typing import List, Optional + +# ── Terminal control ────────────────────────────────────────────────────────── +RESET = "\033[0m" +BOLD = "\033[1m" +HIDE_CUR = "\033[?25l" +SHOW_CUR = "\033[?25h" +ALT_ON = "\033[?1049h" +ALT_OFF = "\033[?1049l" + +def _fg(r, g, b): return f"\033[38;2;{r};{g};{b}m" +def _go(r, c): return f"\033[{r+1};{c+1}H" +def term_size(): s = shutil.get_terminal_size(); return s.lines, s.columns + +def _write_all(fd: int, data: bytes) -> None: + mv = memoryview(data) + off = 0 + while off < len(mv): + try: + off += os.write(fd, mv[off:]) + except BlockingIOError: + time.sleep(0.001) + +# ── Colors ──────────────────────────────────────────────────────────────────── +C_DINO = _fg(200, 100, 55) # Claude terracotta orange +C_DEAD = _fg(220, 60, 60) +C_CACTUS = _fg( 50, 170, 60) +C_BIRD = _fg( 80, 130, 220) +C_GROUND = _fg(160, 140, 90) +C_CLOUD = _fg(180, 200, 220) +C_SCORE = _fg(200, 200, 200) +C_HI = _fg(255, 220, 80) +C_TITLE = _fg(255, 255, 255) +C_SUB = _fg(150, 150, 150) + +# ── Sprites ─────────────────────────────────────────────────────────────────── +# Each sprite: list of strings. Spaces are transparent. + +# Claude mascot (6 wide, 4 tall) +# Body: solid block, two eye-holes (spaces = transparent = dark bg) +# Legs: two stumps, alternating spread / together +DINO_RUN = [ + # frame 0 – legs spread + ["██████", + "█ ██ █", # eyes as transparent gaps + "██████", + " █ █ "], + # frame 1 – legs together + ["██████", + "█ ██ █", + "██████", + " ██ "], +] +DINO_JUMP = [ + "██████", + "█ ██ █", + "██████", + " ", +] +DINO_DEAD = [ + "██████", + "█x██x█", # x eyes + "██████", + " ", +] +DINO_W = 6 +DINO_H = 4 + +# Cacti (variable width, 4-5 tall) +CACTI = [ + # small single (3 wide, 4 tall) + [" ^ ", + "###", + " # ", + " # "], + # tall single with left arm (4 wide, 5 tall) + [" ^ ", + "/## ", + " ## ", + " ## ", + " ## "], + # double (6 wide, 5 tall) + [" ^ ^ ", + "## ## ", + " #### ", + " ## ", + " ## "], + # triple (5 wide, 4 tall) + ["^ ^ ^", + "#####", + " ### ", + " # "], +] + +# Birds / pterodactyls (5 wide, 2 tall, 2 animation frames) +BIRDS = [ + # frame 0: wings up + ["\\-/", + " "], + # frame 1: wings level + [" v ", + "/ \\"], +] +BIRD_W = 3 +BIRD_H = 2 + +# Clouds (8 wide, 2 tall) +CLOUD = [ + " .--. ", + " / \\", +] +CLOUD_W = 8 +CLOUD_H = 2 + + +# ── Canvas ──────────────────────────────────────────────────────────────────── +class Canvas: + def __init__(self, rows: int, cols: int): + self.rows, self.cols = rows, cols + self._ch: List[List[str]] = [[' '] * cols for _ in range(rows)] + self._ck: List[List[Optional[str]]] = [[None] * cols for _ in range(rows)] + + def resize(self, rows: int, cols: int): + self.rows, self.cols = rows, cols + self._ch = [[' '] * cols for _ in range(rows)] + self._ck = [[None] * cols for _ in range(rows)] + + def clear(self): + for r in range(self.rows): + self._ch[r] = [' '] * self.cols + self._ck[r] = [None] * self.cols + + def put(self, row: int, col: int, ch: str, ck: Optional[str]): + if 0 <= row < self.rows and 0 <= col < self.cols: + self._ch[row][col] = ch + self._ck[row][col] = ck + + def put_str(self, row: int, col: int, s: str, ck: Optional[str]): + for i, ch in enumerate(s): + self.put(row, col + i, ch, ck) + + def put_sprite(self, y: int, x: int, lines: List[str], ck: Optional[str]): + for dy, line in enumerate(lines): + for dx, ch in enumerate(line): + if ch != ' ': + self.put(y + dy, x + dx, ch, ck) + + def render(self) -> str: + col_limit = self.cols - 1 + parts = ["\033[H", RESET] + sentinel = object() + last_ck: object = sentinel + for r in range(self.rows): + parts.append(_go(r, 0)) + for c in range(col_limit): + ck = self._ck[r][c] + if ck is not last_ck: + parts.append(RESET if ck is None else ck) + last_ck = ck + parts.append(self._ch[r][c]) + parts.append(RESET + "\033[K") + last_ck = sentinel + parts.append(RESET) + return ''.join(parts) + + +# ── Input ───────────────────────────────────────────────────────────────────── +class Input: + def __init__(self): + self.fd = sys.stdin.fileno() + self._old = termios.tcgetattr(self.fd) + tty.setraw(self.fd) + fl = fcntl.fcntl(self.fd, fcntl.F_GETFL) + fcntl.fcntl(self.fd, fcntl.F_SETFL, fl | os.O_NONBLOCK) + + def read(self) -> bytes: + try: + return os.read(self.fd, 32) + except (BlockingIOError, OSError): + return b'' + + def restore(self): + termios.tcsetattr(self.fd, termios.TCSADRAIN, self._old) + + +# ── Obstacle ────────────────────────────────────────────────────────────────── +GRAVITY = 0.55 # acceleration (chars/tick²) — tuned for ~20 fps +JUMP_VEL = -3.0 # initial upward velocity (negative = up) +DINO_COL = 6 # dino's fixed horizontal position + + +class Obstacle: + def __init__(self, x: float, ground_row: int, rows: int): + self.x = x + if random.random() < 0.25: + # Bird – flies at a random height + self.kind = 'bird' + self.sprite = BIRDS + self.w = BIRD_W + self.h = BIRD_H + height = random.choice([1, 2, 4]) + self.row = ground_row - DINO_H + 1 - height + self.color = C_BIRD + else: + # Cactus + self.kind = 'cactus' + idx = random.randint(0, len(CACTI) - 1) + sp = CACTI[idx] + self.sprite = [sp] # wrap in list so index works like birds + self.w = max(len(l) for l in sp) + self.h = len(sp) + self.row = ground_row - self.h + 1 + self.color = C_CACTUS + self._frame = 0.0 + + def current_frame(self) -> List[str]: + if self.kind == 'bird': + return self.sprite[int(self._frame) % len(self.sprite)] + return self.sprite[0] + + def update(self, speed: float): + self.x -= speed + fadv = 0.12 if self.kind == 'bird' else 0 + self._frame = (self._frame + fadv) % max(len(self.sprite), 1) + + +# ── Game ────────────────────────────────────────────────────────────────────── +class DinoGame: + def __init__(self): + rows, cols = term_size() + self.rows, self.cols = rows, cols + self.canvas = Canvas(rows, cols) + self.highscore = 0.0 + self.state = 'title' # 'title' | 'running' | 'dead' + self._setup() + + def _setup(self): + rows, cols = self.rows, self.cols + self.ground_row = rows - 3 + self.dino_y = float(self.ground_row - DINO_H + 1) + self.dino_vy = 0.0 + self.on_ground = True + self.score = 0.0 + self.speed = 0.35 + self.obstacles: List[Obstacle] = [] + self.clouds: List[List[float]] = [] # [x, y] + self._frame = 0.0 + self._next_obs = random.randint(30, 55) + self._death_anim = 0 # frames of death flash + # Seed some clouds + for _ in range(3): + cx = float(random.randint(10, cols - CLOUD_W - 2)) + cy = float(random.randint(1, max(2, self.ground_row - DINO_H - 5))) + self.clouds.append([cx, cy]) + + def resize(self, rows: int, cols: int): + self.rows, self.cols = rows, cols + self.canvas.resize(rows, cols) + was_running = self.state == 'running' + self._setup() + if was_running: + self.state = 'running' + + def jump(self): + if self.on_ground and self.state == 'running': + self.dino_vy = JUMP_VEL + self.on_ground = False + + def step(self): + if self.state != 'running': + return + + # Score & speed ramp + self.score += 0.12 + self.speed = 0.35 + self.score * 0.00035 + + # Dino physics + if not self.on_ground: + self.dino_vy += GRAVITY * 0.28 + self.dino_y += self.dino_vy * 0.28 + floor = float(self.ground_row - DINO_H + 1) + if self.dino_y >= floor: + self.dino_y = floor + self.dino_vy = 0.0 + self.on_ground = True + + # Leg animation (only while running on ground) + if self.on_ground: + self._frame = (self._frame + 0.18) % 2 + + # Spawn obstacles + self._next_obs -= 1 + if self._next_obs <= 0: + self.obstacles.append( + Obstacle(float(self.cols - 2), self.ground_row, self.rows)) + gap = max(18, int(50 - self.score / 60)) + self._next_obs = random.randint(gap, gap + 22) + + # Move obstacles + for obs in self.obstacles: + obs.update(self.speed) + self.obstacles = [o for o in self.obstacles if o.x + o.w > 0] + + # Move clouds (slow parallax) + for cl in self.clouds: + cl[0] -= 0.08 + self.clouds = [c for c in self.clouds if c[0] + CLOUD_W > 0] + if len(self.clouds) < 4 and random.random() < 0.015: + cy = float(random.randint(1, max(2, self.ground_row - DINO_H - 5))) + self.clouds.append([float(self.cols - CLOUD_W - 1), cy]) + + # Collision detection (AABB with 1-char margin) + dx = DINO_COL + dy = int(self.dino_y) + for obs in self.obstacles: + ox = int(obs.x) + oy = obs.row + if (dx + DINO_W - 1 > ox + 1 and + dx + 1 < ox + obs.w - 1 and + dy + DINO_H - 1 > oy + 1 and + dy + 1 < oy + obs.h - 1): + self.state = 'dead' + if self.score > self.highscore: + self.highscore = self.score + + def render(self) -> str: + rows, cols = self.rows, self.cols + canvas = self.canvas + canvas.clear() + gr = self.ground_row + + # Ground line + ground_str = ('_.-' * ((cols // 3) + 2))[:cols - 1] + canvas.put_str(gr, 0, ground_str, C_GROUND) + + # Clouds + for (cx, cy) in self.clouds: + canvas.put_sprite(int(cy), int(cx), CLOUD, C_CLOUD) + + # ── Title screen ────────────────────────────────────────────────────── + if self.state == 'title': + # Draw a static dino on the ground + canvas.put_sprite(gr - DINO_H, cols // 2 - 2, DINO_RUN[0], C_DINO) + + title = "DINO RUNNER" + sub = "SPACE / UP to start Q to quit" + canvas.put_str(rows // 2 - 2, (cols - len(title)) // 2, title, C_TITLE) + canvas.put_str(rows // 2, (cols - len(sub)) // 2, sub, C_SUB) + + # ── Running / Dead ──────────────────────────────────────────────────── + else: + # Obstacles + for obs in self.obstacles: + canvas.put_sprite(obs.row, int(obs.x), obs.current_frame(), obs.color) + + # Dino + if self.state == 'dead': + dino_sp = DINO_DEAD + dino_c = C_DEAD + elif not self.on_ground: + dino_sp = DINO_JUMP + dino_c = C_DINO + else: + dino_sp = DINO_RUN[int(self._frame) % 2] + dino_c = C_DINO + canvas.put_sprite(int(self.dino_y), DINO_COL, dino_sp, dino_c) + + # Score row + score_str = f"SCORE {int(self.score):05d}" + hi_str = f"BEST {int(self.highscore):05d}" + canvas.put_str(0, cols - len(score_str) - 2, score_str, C_SCORE) + canvas.put_str(0, cols - len(score_str) - len(hi_str) - 4, + hi_str, C_HI) + + if self.state == 'dead': + msg = "GAME OVER" + sub = "SPACE to restart Q to quit" + canvas.put_str(rows // 2 - 1, + (cols - len(msg)) // 2, msg, C_DEAD) + canvas.put_str(rows // 2 + 1, + (cols - len(sub)) // 2, sub, C_SUB) + + return canvas.render() + + +# ── Entry point ─────────────────────────────────────────────────────────────── +def main(): + ap = argparse.ArgumentParser(description='Terminal dino runner game') + ap.add_argument('--speed', type=float, default=1.0, + help='Starting speed multiplier (default 1.0)') + args = ap.parse_args() + + TICK = 0.05 # 20 fps + + fd_out = sys.stdout.fileno() + inp = Input() + game = DinoGame() + game.speed *= args.speed + resize_flag = False + + # Key bytes that mean "jump / start / restart" + ACTION_KEYS = {b' ', b'\r', b'\x1b[A', b'\x1b[B'} # space, enter, up, down + + def on_resize(sig, frame): + nonlocal resize_flag + resize_flag = True + signal.signal(signal.SIGWINCH, on_resize) + + _write_all(fd_out, (ALT_ON + HIDE_CUR).encode()) + try: + while True: + t0 = time.monotonic() + + if resize_flag: + resize_flag = False + r, c = term_size() + game.resize(r, c) + + raw = inp.read() + if raw: + # Quit on Q, q, Ctrl-C, Escape + if any(b in raw for b in (b'q', b'Q', b'\x03', b'\x1b\x1b')): + break + # Action: start / jump / restart + acted = (b' ' in raw or b'\r' in raw + or b'\x1b[A' in raw or b'\x00' in raw) + # Arrow up is ESC [ A (3 bytes); detect as subsequence + if b'\x1b' in raw and b'A' in raw: + acted = True + if acted: + if game.state == 'title': + game.state = 'running' + elif game.state == 'dead': + game._setup() + game.state = 'running' + else: + game.jump() + + game.step() + _write_all(fd_out, game.render().encode()) + + elapsed = time.monotonic() - t0 + rem = TICK - elapsed + if rem > 0: + time.sleep(rem) + finally: + _write_all(fd_out, (ALT_OFF + SHOW_CUR + RESET).encode()) + inp.restore() + if game.score > 0: + print(f"\nScore: {int(game.score)} Best: {int(game.highscore)}") + + +if __name__ == '__main__': + main() diff --git a/pacman.py b/pacman.py new file mode 100644 index 0000000..f5cb8f0 --- /dev/null +++ b/pacman.py @@ -0,0 +1,533 @@ +#!/usr/bin/env python3 +"""pacman: Pac-Man ASCII screensaver with maze. Press any key to exit. +No external dependencies -- pure ANSI escape codes, no Curses. +""" + +import os, sys, tty, termios, fcntl, signal, time, random, shutil, argparse +from typing import List, Optional, Set, Tuple + +# ── Terminal control ────────────────────────────────────────────────────────── +RESET = "\033[0m" +HIDE_CUR = "\033[?25l" +SHOW_CUR = "\033[?25h" +ALT_ON = "\033[?1049h" +ALT_OFF = "\033[?1049l" + +def _fg(r, g, b): return f"\033[38;2;{r};{g};{b}m" +def _go(r, c): return f"\033[{r+1};{c+1}H" +def term_size(): s = shutil.get_terminal_size(); return s.lines, s.columns + +def _write_all(fd: int, data: bytes) -> None: + mv = memoryview(data) + off = 0 + while off < len(mv): + try: + off += os.write(fd, mv[off:]) + except BlockingIOError: + time.sleep(0.001) + +# ── Colors ──────────────────────────────────────────────────────────────────── +C_WALL = _fg( 30, 60, 255) # classic Pac-Man blue +C_PAC = _fg(255, 255, 0) # yellow +C_DOT = _fg(255, 220, 180) # cream +C_PILL = _fg(255, 220, 180) +C_SCORE = _fg(180, 180, 255) +C_SCARED = _fg( 20, 20, 200) +C_SCARE2 = _fg(200, 200, 255) # flashing near end + +GHOST_COLORS = [ + _fg(255, 40, 40), # Blinky – red + _fg(255, 170, 200), # Pinky – pink + _fg( 30, 210, 255), # Inky – cyan + _fg(255, 175, 80), # Clyde – orange +] + +# ── Maze definition ─────────────────────────────────────────────────────────── +# 28 cols × 14 rows. # = wall, . = dot, * = power pellet +MAZE = [ + "############################", + "#............##............#", + "#.####.#####.##.#####.####.#", + "#*####.#####.##.#####.####*#", + "#.####.#####.##.#####.####.#", + "#..........................#", + "#.####.##.########.##.####.#", + "#......##....##....##......#", + "#.####.##.########.##.####.#", + "#..........................#", + "#.####.#####.##.#####.####.#", + "#*####.#####.##.#####.####*#", + "#............##............#", + "############################", +] +MAZE_H = len(MAZE) # 14 +MAZE_W = len(MAZE[0]) # 28 + +# Box-drawing lookup: (N, S, W, E) → character +# Each wall cell gets the char that shows which sides connect to other walls. +_BOX: dict = { + (False,False,False,False): '·', + (True, False,False,False): '╵', + (False,True, False,False): '╷', + (True, True, False,False): '│', + (False,False,True, False): '╴', + (False,False,False,True ): '╶', + (False,False,True, True ): '─', + (True, False,True, False): '┘', + (True, False,False,True ): '└', + (False,True, True, False): '┐', + (False,True, False,True ): '┌', + (True, True, True, False): '┤', + (True, True, False,True ): '├', + (True, False,True, True ): '┴', + (False,True, True, True ): '┬', + (True, True, True, True ): '┼', +} + +# ── Movement helpers ────────────────────────────────────────────────────────── +DIRS = ['R', 'L', 'U', 'D'] +DELTA = {'R': (0, 1), 'L': (0, -1), 'U': (-1, 0), 'D': (1, 0)} + +def is_wall(mr: int, mc: int) -> bool: + if 0 <= mr < MAZE_H and 0 <= mc < MAZE_W: + return MAZE[mr][mc] == '#' + return True + +def valid_dirs(mr: int, mc: int, exclude: Optional[str] = None) -> List[str]: + return [d for d in DIRS + if d != exclude + and not is_wall(mr + DELTA[d][0], mc + DELTA[d][1])] + +def try_move(mr: int, mc: int, d: str) -> Tuple[int, int, bool]: + dr, dc = DELTA[d] + nmr, nmc = mr + dr, mc + dc + if is_wall(nmr, nmc): + return mr, mc, False + return nmr, nmc, True + +# ── Sprite generators ───────────────────────────────────────────────────────── +def make_pac(sw: int, sh: int, direction: str, frame: int) -> List[str]: + """Pac-Man sprite sw×sh using /\\ for the mouth wedge. + frame 0/1 = open, frame 2 = closed (full block). + + At sw=4, sh=4 (user's design): + RIGHT open LEFT open UP open DOWN open + #### #### #/\\# #### + # \\ / # #### #### + # / \\ # #### #\\/# + #### #### #### #### + """ + W = sw + mid = max(0, sw - 2) + arc = '#' * W + opn = (frame <= 1) + + def right() -> List[str]: + rows = [arc] * sh + if opn: + if sh == 1: + rows[0] = '#' * (W - 1) + '>' + else: + hi = max(0, sh // 3) + lo = min(sh - 1, 2 * sh // 3) + if hi == lo and sh > 1: + lo = min(hi + 1, sh - 1) + rows[hi] = '#' + ' ' * mid + '\\' + rows[lo] = '#' + ' ' * mid + '/' + return rows + + def left() -> List[str]: + rows = [arc] * sh + if opn: + if sh == 1: + rows[0] = '<' + '#' * (W - 1) + else: + hi = max(0, sh // 3) + lo = min(sh - 1, 2 * sh // 3) + if hi == lo and sh > 1: + lo = min(hi + 1, sh - 1) + rows[hi] = '/' + ' ' * mid + '#' + rows[lo] = '\\' + ' ' * mid + '#' + return rows + + def up() -> List[str]: + rows = [arc] * sh + if opn: + mc = W // 2 + r = list(arc) + if mc > 0: r[mc - 1] = '/' + if mc < W: r[mc] = '\\' + for i in range(mc - 1): r[i] = ' ' + for i in range(mc + 1, W): r[i] = ' ' + rows[0] = ''.join(r) + return rows + + def down() -> List[str]: + rows = [arc] * sh + if opn: + mc = W // 2 + r = list(arc) + if mc > 0: r[mc - 1] = '\\' + if mc < W: r[mc] = '/' + for i in range(mc - 1): r[i] = ' ' + for i in range(mc + 1, W): r[i] = ' ' + rows[-1] = ''.join(r) + return rows + + return {'R': right, 'L': left, 'U': up, 'D': down}[direction]() + + +def make_ghost(sw: int, sh: int, scared: bool, frame: int) -> List[str]: + """Ghost sprite sw×sh chars.""" + mid = sw - 2 + + if sh == 1 or mid < 0: + return [('=' if scared else '&') * max(1, sw)] + + eye_str = ('= =' if mid >= 5 else '==' if mid >= 2 else '=') if scared else \ + ('o o' if mid >= 5 else 'oo' if mid >= 2 else 'o') + eyes = eye_str.center(mid)[:mid] + + top = '/' + '-' * mid + '\\' + body = '|' + eyes + '|' + pat = '/\\' if frame == 0 else '\\/' + skirt = (pat * (sw // 2 + 2))[:sw] + + if sh == 2: + return [top, body] + else: + return [top, body, skirt] + + +# ── Canvas ──────────────────────────────────────────────────────────────────── +class Canvas: + def __init__(self, rows: int, cols: int): + self.rows, self.cols = rows, cols + self._ch: List[List[str]] = [[' '] * cols for _ in range(rows)] + self._ck: List[List[Optional[str]]] = [[None] * cols for _ in range(rows)] + + def resize(self, rows: int, cols: int): + self.rows, self.cols = rows, cols + self._ch = [[' '] * cols for _ in range(rows)] + self._ck = [[None] * cols for _ in range(rows)] + + def clear(self): + for r in range(self.rows): + self._ch[r] = [' '] * self.cols + self._ck[r] = [None] * self.cols + + def put(self, row: int, col: int, ch: str, ck: Optional[str]): + if 0 <= row < self.rows and 0 <= col < self.cols: + self._ch[row][col] = ch + self._ck[row][col] = ck + + def put_str(self, row: int, col: int, s: str, ck: Optional[str]): + for i, ch in enumerate(s): + self.put(row, col + i, ch, ck) + + def fill_rect(self, row: int, col: int, h: int, w: int, ch: str, ck: Optional[str]): + for dr in range(h): + for dc in range(w): + self.put(row + dr, col + dc, ch, ck) + + def put_sprite_solid(self, y: int, x: int, lines: List[str], ck: Optional[str]): + """Draw sprite; interior spaces are erased (opaque body).""" + for dy, line in enumerate(lines): + vis = [i for i, c in enumerate(line) if c not in (' ', '?')] + if not vis: + continue + lo, hi = vis[0], vis[-1] + for dx, ch in enumerate(line): + if ch in (' ', '?'): + if lo < dx < hi: + self.put(y + dy, x + dx, ' ', None) + else: + self.put(y + dy, x + dx, ch, ck) + + def render(self) -> str: + col_limit = self.cols - 1 + parts = ["\033[H", RESET] + sentinel = object() + last_ck: object = sentinel + for r in range(self.rows): + parts.append(_go(r, 0)) + for c in range(col_limit): + ck = self._ck[r][c] + if ck is not last_ck: + parts.append(RESET if ck is None else ck) + last_ck = ck + parts.append(self._ch[r][c]) + parts.append(RESET + "\033[K") + last_ck = sentinel + parts.append(RESET) + return ''.join(parts) + + +# ── Input ───────────────────────────────────────────────────────────────────── +class Input: + def __init__(self): + self.fd = sys.stdin.fileno() + self._old = termios.tcgetattr(self.fd) + tty.setraw(self.fd) + fl = fcntl.fcntl(self.fd, fcntl.F_GETFL) + fcntl.fcntl(self.fd, fcntl.F_SETFL, fl | os.O_NONBLOCK) + + def read(self) -> bytes: + try: + return os.read(self.fd, 32) + except (BlockingIOError, OSError): + return b'' + + def restore(self): + termios.tcsetattr(self.fd, termios.TCSADRAIN, self._old) + + +# ── Entities ────────────────────────────────────────────────────────────────── +class Ghost: + def __init__(self, mr: int, mc: int, color: str): + self.mr, self.mc = mr, mc + self.color = color + self.dir = random.choice(valid_dirs(mr, mc) or ['R']) + self.frame = 0 + self.scared_ticks = 0 + + def step(self): + nmr, nmc, ok = try_move(self.mr, self.mc, self.dir) + if ok: + self.mr, self.mc = nmr, nmc + # Turn randomly or when blocked + if not ok or random.random() < 0.07: + vd = valid_dirs(self.mr, self.mc) + if vd: + self.dir = random.choice(vd) + self.frame = (self.frame + 1) % 2 + if self.scared_ticks > 0: + self.scared_ticks -= 1 + + +class Pacman: + def __init__(self, mr: int, mc: int): + self.mr, self.mc = mr, mc + self.dir = 'R' + self.frame = 0 + self._fanim = 1 # mouth open/close direction + + def step(self, dots: Set[Tuple[int, int]], + pills: Set[Tuple[int, int]]) -> Tuple[int, int]: + """Move one step. Returns (score_delta, scared_bonus).""" + self.frame += self._fanim + if self.frame >= 2: + self._fanim = -1 + elif self.frame <= 0: + self._fanim = 1 + + nmr, nmc, ok = try_move(self.mr, self.mc, self.dir) + if ok: + self.mr, self.mc = nmr, nmc + if not ok or random.random() < 0.05: + vd = valid_dirs(self.mr, self.mc) + if vd: + self.dir = random.choice(vd) + + score, scared = 0, 0 + pos = (self.mr, self.mc) + if pos in dots: + dots.discard(pos) + score = 10 + if pos in pills: + pills.discard(pos) + score = 50 + scared = 150 + return score, scared + + +# ── Game ────────────────────────────────────────────────────────────────────── +class PacmanGame: + def __init__(self): + rows, cols = term_size() + self.rows, self.cols = rows, cols + self.canvas = Canvas(rows, cols) + self.score = 0 + self.level = 1 + self._scale() + self._init_level() + + def _scale(self): + rows, cols = self.rows, self.cols + self.sw = max(2, min(8, (cols - 4) // MAZE_W)) + self.sh = max(1, min(4, (rows - 4) // MAZE_H)) + self.mleft = (cols - MAZE_W * self.sw) // 2 + self.mtop = (rows - MAZE_H * self.sh) // 2 + 1 # +1 for status line + + def _init_level(self): + self.dots: Set[Tuple[int, int]] = set() + self.pills: Set[Tuple[int, int]] = set() + for mr in range(MAZE_H): + for mc in range(MAZE_W): + ch = MAZE[mr][mc] + if ch == '.': + self.dots.add((mr, mc)) + elif ch == '*': + self.pills.add((mr, mc)) + + open_cells = [(mr, mc) for mr in range(MAZE_H) + for mc in range(MAZE_W) if MAZE[mr][mc] != '#'] + + # Pac-Man near center + cr, cc = MAZE_H // 2, MAZE_W // 2 + start = min(open_cells, key=lambda p: abs(p[0] - cr) + abs(p[1] - cc)) + self.pac = Pacman(start[0], start[1]) + + # Ghosts spread out + spots = random.sample(open_cells, min(4, len(open_cells))) + self.ghosts = [Ghost(mr, mc, GHOST_COLORS[i % 4]) + for i, (mr, mc) in enumerate(spots)] + self.scared_ticks = 0 + self._ptick = 0 + self._gtick = 0 + + def resize(self, rows: int, cols: int): + self.rows, self.cols = rows, cols + self.canvas.resize(rows, cols) + self._scale() + self._init_level() + + def step(self): + self._ptick += 1 + if self._ptick >= 2: + self._ptick = 0 + sd, sb = self.pac.step(self.dots, self.pills) + self.score += sd + if sb: + self.scared_ticks = sb + for g in self.ghosts: + g.scared_ticks = sb + + if self.scared_ticks > 0: + self.scared_ticks -= 1 + + self._gtick += 1 + if self._gtick >= 3: + self._gtick = 0 + for g in self.ghosts: + g.step() + + if not self.dots and not self.pills: + self.level += 1 + self._init_level() + + def _tpos(self, mr: int, mc: int) -> Tuple[int, int]: + return self.mtop + mr * self.sh, self.mleft + mc * self.sw + + def render(self) -> str: + sw, sh = self.sw, self.sh + canvas = self.canvas + canvas.clear() + + # Status line + status = f" SCORE: {self.score:>6} LEVEL: {self.level} dots: {len(self.dots)+len(self.pills)} " + canvas.put_str(0, 0, status, C_SCORE) + + # Maze: walls, dots, pills + for mr in range(MAZE_H): + for mc in range(MAZE_W): + ty, tx = self._tpos(mr, mc) + cell = MAZE[mr][mc] + if cell == '#': + n = is_wall(mr - 1, mc) + s = is_wall(mr + 1, mc) + w_n = is_wall(mr, mc - 1) + e_n = is_wall(mr, mc + 1) + ne = is_wall(mr - 1, mc + 1) + nw = is_wall(mr - 1, mc - 1) + se = is_wall(mr + 1, mc + 1) + sw_d = is_wall(mr + 1, mc - 1) + + top, bot = ty, ty + sh - 1 + lft, rgt = tx, tx + sw - 1 + + # Boundary lines only where wall meets corridor + if not n: + for dx in range(sw): canvas.put(top, tx + dx, '─', C_WALL) + if not s: + for dx in range(sw): canvas.put(bot, tx + dx, '─', C_WALL) + if not w_n: + for dy in range(sh): canvas.put(ty + dy, lft, '│', C_WALL) + if not e_n: + for dy in range(sh): canvas.put(ty + dy, rgt, '│', C_WALL) + + # Outer corners (convex: two boundary edges meet) + if not n and not w_n: canvas.put(top, lft, '┌', C_WALL) + if not n and not e_n: canvas.put(top, rgt, '┐', C_WALL) + if not s and not w_n: canvas.put(bot, lft, '└', C_WALL) + if not s and not e_n: canvas.put(bot, rgt, '┘', C_WALL) + + # Inner corners (concave: both direct neighbours are walls, diagonal is corridor) + if n and w_n and not nw: canvas.put(top, lft, '┘', C_WALL) + if n and e_n and not ne: canvas.put(top, rgt, '└', C_WALL) + if s and w_n and not sw_d: canvas.put(bot, lft, '┐', C_WALL) + if s and e_n and not se: canvas.put(bot, rgt, '┌', C_WALL) + elif (mr, mc) in self.dots: + canvas.put(ty + sh // 2, tx + sw // 2, '\u2022', C_DOT) + elif (mr, mc) in self.pills: + canvas.put(ty + sh // 2, tx + sw // 2, '\u25cf', C_PILL) + + # Ghosts + for g in self.ghosts: + ty, tx = self._tpos(g.mr, g.mc) + scared = g.scared_ticks > 0 + color = (C_SCARE2 if (g.scared_ticks // 5) % 2 == 0 + else C_SCARED) if scared else g.color + sp = make_ghost(sw, sh, scared, g.frame) + canvas.put_sprite_solid(ty, tx, sp, color) + + # Pac-Man + ty, tx = self._tpos(self.pac.mr, self.pac.mc) + sp = make_pac(sw, sh, self.pac.dir, min(self.pac.frame, 2)) + canvas.put_sprite_solid(ty, tx, sp, C_PAC) + + return canvas.render() + + +# ── Entry point ─────────────────────────────────────────────────────────────── +def main(): + ap = argparse.ArgumentParser(description='Pac-Man ASCII screensaver') + ap.add_argument('--speed', type=float, default=1.0, + help='Speed multiplier (default 1.0)') + args = ap.parse_args() + + TICK = 0.08 / args.speed + + fd_out = sys.stdout.fileno() + inp = Input() + game = PacmanGame() + resize_flag = False + + def on_resize(sig, frame): + nonlocal resize_flag + resize_flag = True + signal.signal(signal.SIGWINCH, on_resize) + + _write_all(fd_out, (ALT_ON + HIDE_CUR).encode()) + try: + while True: + t0 = time.monotonic() + if resize_flag: + resize_flag = False + r, c = term_size() + game.resize(r, c) + if inp.read(): + break + game.step() + _write_all(fd_out, game.render().encode()) + rem = TICK - (time.monotonic() - t0) + if rem > 0: + time.sleep(rem) + finally: + _write_all(fd_out, (ALT_OFF + SHOW_CUR + RESET).encode()) + inp.restore() + print(f"\nFinal score: {game.score} (Level {game.level})") + + +if __name__ == '__main__': + main()