macbook-setup/pacman.py

533 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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