#!/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()