461 lines
16 KiB
Python
461 lines
16 KiB
Python
#!/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()
|