macbook-setup/dino.py

461 lines
16 KiB
Python
Raw Permalink 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
"""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()