| """ |
| Claudcade Engine v2 |
| βββββββββββββββββββ |
| A terminal game engine built on Python's curses library. |
| No dependencies beyond the standard library. macOS, Linux, WSL. |
| |
| Quick start |
| ----------- |
| from claudcade_engine import Engine, Scene, Input, Renderer |
| from claudcade_engine import PLAYER, ENEMY, GOOD, GOLD, NEUTRAL |
| |
| class Game(Scene): |
| def on_enter(self): |
| self.score = 0 |
| |
| def update(self, inp: Input, tick: int, dt: float) -> str | None: |
| if inp.pause: |
| return "quit" |
| if inp.just_pressed(ord('f')): # single-frame fire |
| self.score += 1 |
| |
| def draw(self, r: Renderer, tick: int): |
| r.header("MY GAME", left=f"SCORE {self.score}") |
| r.center(r.H // 2, "Hello terminal!", PLAYER, bold=True) |
| |
| Engine("My Game", fps=30).scene("game", Game()).run("game") |
| |
| Color constants |
| --------------- |
| Semantic aliases (preferred): |
| PLAYER ENEMY GOOD GOLD NEUTRAL SPECIAL SELECT WATER |
| |
| Raw color names (same values): |
| CYAN RED GREEN YELLOW WHITE MAGENTA HIGHLIGHT BLUE |
| |
| Drawing coordinates |
| ------------------- |
| Renderer methods take (row, col) β row 0 is the TOP of the terminal. |
| Entity.x / Entity.y are (col, row) in screen space. |
| Use r.at(entity, sprite) to avoid manual conversion. |
| |
| Delta time |
| ---------- |
| update() receives dt (seconds since last frame, typically 0.033 at 30 fps). |
| Write all movement as: entity.x += speed * dt * 30 (30 = "at 30fps" reference) |
| """ |
|
|
| from __future__ import annotations |
|
|
| import curses |
| import enum |
| import json |
| import math |
| import random |
| import time |
| from pathlib import Path |
| from typing import Callable, Literal, TypedDict |
|
|
| |
|
|
| class StarDict(TypedDict): |
| x: float |
| y: float |
| spd: float |
| ch: str |
| cp: int |
|
|
|
|
| class _ParticleDict(TypedDict): |
| x: float |
| y: float |
| vx: float |
| vy: float |
| life: int |
| max_life: int |
| char: str |
| color: int |
|
|
|
|
| class BulletDict(TypedDict, total=False): |
| wx: float |
| y: float |
| vx: float |
| owner: str |
| dead: bool |
|
|
|
|
| class PlatformDict(TypedDict): |
| x: float |
| y: float |
| w: float |
|
|
|
|
| class ParticleDict(TypedDict): |
| x: float |
| y: float |
| life: int |
| type: str |
|
|
|
|
| class _FadeState(TypedDict): |
| to: str |
| ticks: int |
| t: int |
| phase: Literal['out', 'in'] |
|
|
|
|
| |
|
|
| class Color(enum.IntEnum): |
| """Color-pair indices β match setup_colors() init order. Members are also |
| accessible by semantic name (PLAYER == CYAN, ENEMY == RED, etc.).""" |
| CYAN = 1 |
| RED = 2 |
| GREEN = 3 |
| YELLOW = 4 |
| WHITE = 5 |
| MAGENTA = 6 |
| HIGHLIGHT = 7 |
| BLUE = 8 |
| |
| PLAYER = 1 |
| ENEMY = 2 |
| GOOD = 3 |
| GOLD = 4 |
| NEUTRAL = 5 |
| SPECIAL = 6 |
| SELECT = 7 |
| WATER = 8 |
|
|
| |
| |
| CYAN = Color.CYAN |
| RED = Color.RED |
| GREEN = Color.GREEN |
| YELLOW = Color.YELLOW |
| WHITE = Color.WHITE |
| MAGENTA = Color.MAGENTA |
| HIGHLIGHT = Color.HIGHLIGHT |
| BLUE = Color.BLUE |
| PLAYER = Color.PLAYER |
| ENEMY = Color.ENEMY |
| GOOD = Color.GOOD |
| GOLD = Color.GOLD |
| NEUTRAL = Color.NEUTRAL |
| SPECIAL = Color.SPECIAL |
| SELECT = Color.SELECT |
| WATER = Color.WATER |
|
|
|
|
| |
|
|
| class Input: |
| """ |
| Keyboard and mouse state for the current frame. |
| |
| Common aliases: |
| inp.up / down / left / right WASD or arrow keys |
| inp.fire J, F, or mouse click |
| inp.jump SPACE |
| inp.confirm ENTER or SPACE |
| inp.pause ESC |
| |
| Single-frame detection: |
| inp.just_pressed(ord('j')) True only on first frame key is down |
| inp.just_released(ord('j')) True only on first frame key is up |
| |
| Raw check: |
| inp.pressed(ord('x')) True every frame key is held |
| |
| Mouse: |
| inp.mouse_click True on left-button press/click |
| inp.mouse_pressed set of buttons pressed this frame |
| (1=left, 2=middle, 3=right, 4=scroll) |
| inp.mouse_row, inp.mouse_col last reported mouse position (or -1) |
| """ |
|
|
| |
| |
| _last: Input | None = None |
|
|
| def __init__(self) -> None: |
| self.keys: set[int] = set() |
| self.mouse_pressed: set[int] = set() |
| self.mouse_row: int = -1 |
| self.mouse_col: int = -1 |
| self._prev: Input | None = None |
|
|
| @property |
| def mouse_click(self) -> bool: |
| """True if the left mouse button was pressed this frame.""" |
| return 1 in self.mouse_pressed |
|
|
| def pressed(self, *keys: int) -> bool: |
| """True every frame any of the given keys are held.""" |
| return any(k in self.keys for k in keys) |
|
|
| def just_pressed(self, *keys: int) -> bool: |
| """True only on the first frame any of the given keys go down.""" |
| if self._prev is None: |
| return self.pressed(*keys) |
| return any(k in self.keys and k not in self._prev.keys for k in keys) |
|
|
| def just_released(self, *keys: int) -> bool: |
| """True only on the first frame any of the given keys come up.""" |
| if self._prev is None: |
| return False |
| return any(k not in self.keys and k in self._prev.keys for k in keys) |
|
|
| @property |
| def up(self) -> bool: return self.pressed(curses.KEY_UP, ord('w'), ord('W')) |
| @property |
| def down(self) -> bool: return self.pressed(curses.KEY_DOWN, ord('s'), ord('S')) |
| @property |
| def left(self) -> bool: return self.pressed(curses.KEY_LEFT, ord('a'), ord('A')) |
| @property |
| def right(self) -> bool: return self.pressed(curses.KEY_RIGHT, ord('d'), ord('D')) |
| @property |
| def fire(self) -> bool: return self.pressed(ord('j'), ord('J'), ord('f'), ord('F')) or 1 in self.mouse_pressed |
| @property |
| def jump(self) -> bool: return self.pressed(ord(' ')) |
| @property |
| def confirm(self) -> bool: return self.pressed(ord('\n'), 10, 13, ord(' ')) |
| @property |
| def pause(self) -> bool: return self.pressed(27) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| _REPEAT_GRACE = 20 |
|
|
| |
| _key_age: dict[int, int] = {} |
|
|
| @classmethod |
| def _poll(cls, scr: curses.window) -> Input: |
| """Poll one frame of input. Rotates class-level _last so just_pressed / |
| just_released always have the prior frame to compare against. Recently- |
| seen keys remain in inp.keys for _REPEAT_GRACE frames to compensate |
| for unreliable terminal auto-repeat.""" |
| inp = cls() |
| inp._prev = cls._last |
| seen_this_frame: set[int] = set() |
| while True: |
| k = scr.getch() |
| if k == -1: |
| break |
| if k == curses.KEY_MOUSE: |
| try: |
| _, mx, my, _, bst = curses.getmouse() |
| inp.mouse_row, inp.mouse_col = my, mx |
| if bst & (curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED): |
| inp.mouse_pressed.add(1) |
| if bst & (curses.BUTTON2_PRESSED | curses.BUTTON2_CLICKED): |
| inp.mouse_pressed.add(2) |
| if bst & (curses.BUTTON3_PRESSED | curses.BUTTON3_CLICKED): |
| inp.mouse_pressed.add(3) |
| except curses.error: |
| pass |
| else: |
| seen_this_frame.add(k) |
|
|
| |
| cls._key_age = {k: a + 1 for k, a in cls._key_age.items() |
| if a + 1 < cls._REPEAT_GRACE} |
| |
| for k in seen_this_frame: |
| cls._key_age[k] = 0 |
| |
| inp.keys = set(cls._key_age.keys()) |
|
|
| cls._last = inp |
| return inp |
|
|
|
|
| |
| |
| |
|
|
| def at_safe(scr: curses.window, H: int, W: int, row: int, col: int, s: str, attr: int = 0) -> None: |
| """Bounds-safe addstr: silently no-ops outside the terminal.""" |
| try: |
| if 0 <= row < H - 1 and 0 <= col < W: |
| scr.addstr(row, col, s[:max(0, W - col)], attr) |
| except curses.error: |
| pass |
|
|
|
|
| |
|
|
| class Renderer: |
| """ |
| All drawing for one frame. Coordinates are (row, col), row 0 = top. |
| |
| Every method is bounds-safe β drawing outside the terminal is silently ignored. |
| |
| Color arguments accept module-level constants (PLAYER, ENEMY, GOOD, etc.) |
| or the raw pair integers 1-8. |
| """ |
|
|
| def __init__(self, scr: curses.window, H: int, W: int) -> None: |
| self._scr = scr |
| self.H = H |
| self.W = W |
|
|
| |
|
|
| def text(self, row: int, col: int, s: str, |
| color: int = NEUTRAL, bold: bool = False, |
| dim: bool = False, reverse: bool = False, |
| blink: bool = False) -> None: |
| """Draw a string at (row, col). Silently clipped at terminal edges.""" |
| attr = curses.color_pair(color) |
| if bold: attr |= curses.A_BOLD |
| if dim: attr |= curses.A_DIM |
| if reverse: attr |= curses.A_REVERSE |
| if blink: attr |= curses.A_BLINK |
| try: |
| if 0 <= row < self.H - 1 and 0 <= col < self.W: |
| self._scr.addstr(row, col, s[:max(0, self.W - col)], attr) |
| except curses.error: |
| pass |
|
|
| def center(self, row: int, s: str, |
| color: int = NEUTRAL, bold: bool = False, |
| dim: bool = False) -> None: |
| """Draw s centered horizontally on row.""" |
| self.text(row, max(0, (self.W - len(s)) // 2), s, color, bold, dim) |
|
|
| def fill(self, row: int, col: int, h: int, w: int, |
| char: str = ' ', color: int = NEUTRAL) -> None: |
| """Fill a rectangle with a character β useful for clearing regions.""" |
| line = (char * w)[:w] |
| for r in range(row, min(row + h, self.H - 1)): |
| self.text(r, col, line, color) |
|
|
| |
|
|
| def box(self, row: int, col: int, h: int, w: int, |
| color: int = NEUTRAL, title: str = "", |
| double: bool = True) -> None: |
| """Draw a border box, optionally with a centered title. |
| |
| double=True βββββ (default) |
| double=False βββββ |
| """ |
| tl, tr, bl, br, hz, vt = ( |
| ('β', 'β', 'β', 'β', 'β', 'β') if double else |
| ('β', 'β', 'β', 'β', 'β', 'β') |
| ) |
| self.text(row, col, tl + hz * (w - 2) + tr, color, bold=True) |
| self.text(row + h-1, col, bl + hz * (w - 2) + br, color, bold=True) |
| for r in range(1, h - 1): |
| self.text(row + r, col, vt, color) |
| self.text(row + r, col + w - 1, vt, color) |
| if title: |
| t = f' {title} ' |
| self.text(row, col + max(0, (w - len(t)) // 2), t, color, bold=True) |
|
|
| def outer_border(self, color: int = NEUTRAL) -> None: |
| """Draw the full outer terminal border.""" |
| self.box(0, 0, self.H - 1, self.W, color) |
|
|
| |
|
|
| def bar(self, row: int, col: int, |
| value: float, maximum: float, width: int, |
| fill_color: int = GOOD, empty_color: int = NEUTRAL, |
| label: str = "") -> None: |
| """Horizontal progress bar. ββββββββββββ Optionally suffixed with label.""" |
| ratio = max(0.0, min(1.0, value / maximum if maximum > 0 else 0.0)) |
| filled = int(width * ratio) |
| self.text(row, col, 'β' * filled, fill_color, bold=True) |
| self.text(row, col + filled, 'β' * (width - filled), empty_color) |
| if label: |
| self.text(row, col + width + 1, label, NEUTRAL) |
|
|
| def vbar(self, top_row: int, col: int, height: int, |
| value: float, maximum: float, |
| fill_color: int = GOOD, empty_color: int = NEUTRAL) -> None: |
| """Vertical progress bar β top=empty, bottom=full.""" |
| ratio = max(0.0, min(1.0, value / maximum if maximum > 0 else 0.0)) |
| filled = int(height * ratio) |
| empty = height - filled |
| for i in range(empty): |
| self.text(top_row + i, col, 'β', empty_color) |
| for i in range(filled): |
| self.text(top_row + empty + i, col, 'β', fill_color, bold=True) |
|
|
| |
|
|
| def sprite(self, row: int, col: int, lines: list[str], |
| color: int = NEUTRAL, bold: bool = True) -> None: |
| """Draw a multi-line ASCII sprite.""" |
| for i, line in enumerate(lines): |
| self.text(row + i, col, line, color, bold) |
|
|
| def menu(self, row: int, col: int, |
| options: list[str], cursor: int, |
| color_selected: int = SELECT, |
| color_normal: int = NEUTRAL, |
| width: int = 20, |
| cursor_char: str = 'βΈ') -> None: |
| """Vertical menu with a cursor indicator. |
| |
| Cursor wraps automatically. Selected item is highlighted. |
| """ |
| cursor = cursor % max(1, len(options)) |
| for i, opt in enumerate(options): |
| prefix = f'{cursor_char} ' if i == cursor else ' ' |
| label = (prefix + opt).ljust(width)[:width] |
| self.text(row + i, col, label, |
| color_selected if i == cursor else color_normal, |
| bold=(i == cursor)) |
|
|
| |
|
|
| def wrapped_text(self, row: int, col: int, text: str, width: int, |
| color: int = NEUTRAL, bold: bool = False) -> int: |
| """Word-wrap text into a fixed column width. Returns number of rows used.""" |
| words = text.split() |
| lines: list[str] = [] |
| current = '' |
| for word in words: |
| test = (current + ' ' + word).strip() |
| if len(test) <= width: |
| current = test |
| else: |
| if current: |
| lines.append(current) |
| current = word[:width] |
| if current: |
| lines.append(current) |
| for i, line in enumerate(lines): |
| self.text(row + i, col, line, color, bold) |
| return len(lines) |
|
|
| def dialog(self, row: int, col: int, width: int, |
| speaker: str, text: str, |
| tick: int = 0, |
| speaker_color: int = PLAYER, |
| portrait: list[str] | None = None, |
| prompt: str = 'βΌ') -> None: |
| """Standard RPG dialog box: speaker name tag, optional portrait, wrapped text. |
| |
| The advance prompt blinks using tick. Prompt appears at bottom-right of box. |
| """ |
| port_w = max((len(l) for l in portrait), default=0) + 2 if portrait else 0 |
| text_col = col + 2 + port_w |
| text_w = width - port_w - 4 |
| lines = max(3, len(self._wrap(text, text_w))) |
| h = lines + 4 |
|
|
| self.box(row, col, h, width, NEUTRAL, double=False) |
| tag = f' {speaker} ' |
| self.text(row, col + 2, tag, speaker_color, bold=True) |
|
|
| if portrait: |
| for i, line in enumerate(portrait[:h - 2]): |
| self.text(row + 1 + i, col + 2, line, speaker_color) |
|
|
| self.wrapped_text(row + 1, text_col, text, text_w, NEUTRAL) |
|
|
| if (tick // 15) % 2 == 0: |
| self.text(row + h - 2, col + width - 4, prompt, GOLD, bold=True) |
|
|
| def _wrap(self, text: str, width: int) -> list[str]: |
| """Internal word-wrap helper.""" |
| words, lines, current = text.split(), [], '' |
| for word in words: |
| test = (current + ' ' + word).strip() |
| if len(test) <= width: |
| current = test |
| else: |
| if current: |
| lines.append(current) |
| current = word[:width] |
| if current: |
| lines.append(current) |
| return lines |
|
|
| |
|
|
| def header(self, game_name: str, |
| left: str = "", right: str = "", |
| color: int = GOLD) -> None: |
| """Standard 3-row HUD header (rows 0β2).""" |
| self.text(0, 0, 'β' + 'β' * (self.W - 2) + 'β', NEUTRAL, bold=True) |
| self.text(1, 0, 'β', NEUTRAL, bold=True) |
| self.text(1, self.W - 1, 'β', NEUTRAL, bold=True) |
| self.text(1, 1, 'βββ', NEUTRAL, dim=True) |
| self.text(1, self.W - 4, 'βββ', NEUTRAL, dim=True) |
| self.text(1, 5, f'β
{game_name} β
', color, bold=True) |
| if left: |
| self.text(1, 5 + len(game_name) + 5, left, ENEMY, bold=True) |
| if right: |
| self.text(1, self.W - len(right) - 4, right, GOOD) |
| self.text(2, 0, 'β ' + 'β' * (self.W - 2) + 'β£', NEUTRAL, bold=True) |
|
|
| def footer(self, controls: str, color: int = NEUTRAL) -> None: |
| """Controls hint in the bottom two rows (rows H-3 and H-2).""" |
| self.text(self.H - 3, 0, 'β ' + 'β' * (self.W - 2) + 'β£', NEUTRAL, bold=True) |
| self.text(self.H - 2, 1, controls.center(self.W - 2)[:self.W - 2], color) |
|
|
| def pause_overlay(self, game_name: str, controls: list[str]) -> None: |
| """Minimal pause panel β just `PAUSED` + Resume/Quit prompts. |
| |
| Earlier versions painted a full-screen β scrim and rendered a |
| boxed CONTROLS table on every frame, which cost ~6000+ curses |
| writes per frame and read as overdesigned. The `controls` |
| argument is preserved for caller-compat but is no longer drawn. |
| """ |
| del controls |
|
|
| title = f' {game_name} Β· PAUSED ' |
| prompt = '[ R ] Resume [ Q ] Quit' |
|
|
| bw = max(len(title), len(prompt)) + 6 |
| bh = 5 |
| by = max(1, (self.H - bh) // 2) |
| bx = max(1, (self.W - bw) // 2) |
|
|
| |
| |
| for r in range(bh): |
| self.text(by + r, bx, ' ' * bw, color=NEUTRAL) |
|
|
| |
| self.box(by, bx, bh, bw, color=SELECT) |
|
|
| |
| self.text(by + 1, bx + (bw - len(title)) // 2, title, color=GOLD, bold=True) |
| self.text(by + 3, bx + (bw - len(prompt)) // 2, prompt, color=NEUTRAL, bold=True) |
|
|
| def gameover_screen(self, title: str = "GAME OVER", |
| score_line: str = "", player_label: str = "", |
| rank: int | None = None, tick: int = 0, |
| prompt: str = "[ SPACE ] Play again [ ESC ] Quit", |
| title_color: int = ENEMY) -> None: |
| """Standard game-over or victory screen.""" |
| self.outer_border() |
| mr = self.H // 2 |
| self.center(mr - 2, f' {title} ', title_color, bold=True) |
| if score_line: |
| self.center(mr, f' {score_line} ', GOLD, bold=True) |
| if player_label: |
| self.center(mr + 1, f' {player_label} ', PLAYER, bold=True) |
| if rank is not None: |
| self.center(mr + 2, f' Global rank: #{rank} ', GOOD, bold=True) |
| elif player_label: |
| self.center(mr + 2, ' Submitting score... ', NEUTRAL, dim=True) |
| if (tick // 15) % 2 == 0: |
| self.center(mr + 4, prompt, NEUTRAL) |
|
|
| def stars(self, star_list: list[StarDict]) -> None: |
| """Draw a parallax star field generated by make_stars().""" |
| for s in star_list: |
| r, c = int(s['y']), int(s['x']) |
| if 0 < r < self.H - 1 and 0 < c < self.W - 1: |
| self.text(r, c, s['ch'], s['cp'], dim=True) |
|
|
|
|
| |
|
|
| class Scene: |
| """ |
| One screen of a game (title, gameplay, pause, game over, β¦). |
| |
| Subclass and implement: |
| on_enter(self) called once when scene activates; |
| self.payload holds any data passed |
| from the previous scene |
| update(self, inp, tick, dt) game logic; return either: |
| - None to stay |
| - "scene_name" to switch |
| - ("scene_name", payload) to switch |
| with data the next scene reads |
| via self.payload |
| - "quit" to exit |
| draw(self, r, tick) render via the Renderer |
| |
| Access engine, size, and delta-time: |
| self.engine.H / self.engine.W terminal dimensions |
| self.engine.switch("gameover") programmatic scene switch |
| self.engine.fade_to("gameover") scene switch with fade transition |
| """ |
|
|
| engine: Engine |
| payload: object | None = None |
|
|
| def on_enter(self) -> None: pass |
|
|
| def update(self, inp: Input, tick: int, dt: float) -> str | tuple[str, object] | None: |
| return None |
|
|
| def draw(self, r: Renderer, tick: int) -> None: pass |
|
|
|
|
| |
|
|
| _MIN_H = 18 |
| _MIN_W = 50 |
|
|
|
|
| class Engine: |
| """ |
| The game loop. |
| |
| Example |
| ------- |
| engine = ( |
| Engine("Snake", fps=15) |
| .scene("menu", MenuScene()) |
| .scene("game", GameScene()) |
| .scene("gameover", GameOverScene()) |
| ) |
| engine.run("menu") |
| """ |
|
|
| def __init__(self, title: str, fps: int = 30, seed: int | None = None) -> None: |
| self.title = title |
| self.fps = fps |
| self.H = 0 |
| self.W = 0 |
| self.dt = 1.0 / fps |
| |
| |
| self.rng = random.Random(seed) |
| self._seed = seed |
| self._scenes: dict[str, Scene] = {} |
| self._current = "" |
| self._scr: curses.window | None = None |
| self._tick = 0 |
| self._fade: _FadeState | None = None |
| |
| self._profile_data: list[tuple[float, float]] | None = None |
|
|
| def seed(self, seed: int) -> None: |
| """Reseed the engine RNG. Useful for daily challenges.""" |
| self._seed = seed |
| self.rng = random.Random(seed) |
|
|
| def scene(self, name: str, s: Scene) -> Engine: |
| """Register a scene. Returns self for chaining.""" |
| s.engine = self |
| self._scenes[name] = s |
| return self |
|
|
| def switch(self, name: str, payload: object | None = None) -> None: |
| """Switch to a named scene immediately. The next scene's `self.payload` |
| is set before on_enter() runs. Warns to stderr on unknown name.""" |
| if name not in self._scenes: |
| import sys |
| print( |
| f'[Engine] Unknown scene {repr(name)}. ' |
| f'Registered: {list(self._scenes)}', |
| file=sys.stderr, |
| ) |
| return |
| self._current = name |
| self._scenes[name].payload = payload |
| self._scenes[name].on_enter() |
|
|
| def fade_to(self, name: str, ticks: int = 12) -> None: |
| """Switch to a scene with a brief fade-out / fade-in transition.""" |
| self._fade = {'to': name, 'ticks': ticks, 't': 0, 'phase': 'out'} |
|
|
| def run(self, initial: str, profile: bool = False) -> None: |
| """Start the game loop, blocking until the game exits. |
| |
| profile=True records per-frame update/draw timings and prints a |
| summary on exit. Useful for finding hot paths. |
| |
| Any exception inside the loop tears down curses cleanly and prints the |
| traceback to stderr so the terminal isn't left in raw mode. |
| """ |
| import sys |
| import traceback |
| if profile: |
| self._profile_data = [] |
| try: |
| curses.wrapper(self._loop, initial) |
| except Exception as exc: |
| try: curses.endwin() |
| except curses.error: pass |
| print(f'\n[{self.title} crashed] {exc}', file=sys.stderr) |
| traceback.print_exc(file=sys.stderr) |
| if self._profile_data: |
| self._print_profile() |
| print('\n [ back in Claude β type anything to chat ]\n') |
|
|
| def _print_profile(self) -> None: |
| """Summarise recorded per-frame timings.""" |
| data = self._profile_data or [] |
| if not data: |
| return |
| n = len(data) |
| ups = sorted(u for u, _ in data) |
| drs = sorted(d for _, d in data) |
| def pct(a: list[float], p: float) -> float: |
| return a[min(len(a) - 1, int(len(a) * p))] |
| print(f'\nββ {self.title} profile ({n} frames at {self.fps} fps) ββ') |
| print(f' update avg={sum(ups)/n*1000:6.2f}ms ' |
| f'p50={pct(ups,0.5)*1000:6.2f}ms ' |
| f'p95={pct(ups,0.95)*1000:6.2f}ms ' |
| f'max={max(ups)*1000:6.2f}ms') |
| print(f' draw avg={sum(drs)/n*1000:6.2f}ms ' |
| f'p50={pct(drs,0.5)*1000:6.2f}ms ' |
| f'p95={pct(drs,0.95)*1000:6.2f}ms ' |
| f'max={max(drs)*1000:6.2f}ms') |
| budget = 1.0 / self.fps * 1000 |
| over = sum(1 for u, d in data if (u + d) * 1000 > budget) |
| print(f' budget {budget:.1f}ms; {over} frame(s) over ({over * 100 // n}%)') |
|
|
| def _loop(self, scr: curses.window, initial: str) -> None: |
| self._scr = scr |
| _init_curses(scr) |
|
|
| |
| self.H, self.W = scr.getmaxyx() |
| self._tick = 0 |
| self.switch(initial) |
|
|
| nxt = time.perf_counter() |
| prev = time.perf_counter() |
|
|
| while True: |
| now = time.perf_counter() |
|
|
| |
| if now < nxt: |
| time.sleep(max(0.0, nxt - now - 0.001)) |
| continue |
| self.dt = now - prev |
| prev = now |
| nxt = max(nxt + 1.0 / self.fps, now) |
| self._tick += 1 |
| self.H, self.W = scr.getmaxyx() |
|
|
| |
| if self.H < _MIN_H or self.W < _MIN_W: |
| scr.erase() |
| msg = f' Resize terminal: need {_MIN_W}x{_MIN_H}, have {self.W}x{self.H} ' |
| try: |
| scr.addstr(0, 0, msg[:self.W]) |
| except curses.error: |
| pass |
| scr.refresh() |
| continue |
|
|
| inp = Input._poll(scr) |
|
|
| |
| if self._fade: |
| self._tick_fade(scr, inp) |
| continue |
|
|
| current = self._scenes.get(self._current) |
| if current is None: |
| break |
|
|
| t0 = time.perf_counter() |
| result = current.update(inp, self._tick, self.dt) |
| t1 = time.perf_counter() |
|
|
| |
| next_name: str | None = None |
| payload: object | None = None |
| if isinstance(result, tuple) and len(result) == 2: |
| next_name, payload = result |
| elif isinstance(result, str): |
| next_name = result |
|
|
| if next_name == 'quit': |
| break |
| if next_name and next_name != self._current: |
| self.switch(next_name, payload) |
|
|
| scr.erase() |
| self._scenes[self._current].draw( |
| Renderer(scr, self.H, self.W), self._tick |
| ) |
| scr.refresh() |
| t2 = time.perf_counter() |
| if self._profile_data is not None: |
| self._profile_data.append((t1 - t0, t2 - t1)) |
|
|
| def _tick_fade(self, scr: curses.window, inp: Input) -> None: |
| f = self._fade |
| if f is None: |
| return |
| frac = f['t'] / max(1, f['ticks']) |
| chars = 'ββββ ' |
| f['t'] += 1 |
|
|
| current = self._scenes.get(self._current) |
|
|
| if f['phase'] == 'out': |
| |
| scr.erase() |
| if current: |
| current.draw(Renderer(scr, self.H, self.W), self._tick) |
| overlay = chars[min(4, int(frac * 5))] |
| for r in range(self.H - 1): |
| try: |
| scr.addstr(r, 0, overlay * self.W, |
| curses.color_pair(NEUTRAL) | curses.A_DIM) |
| except curses.error: |
| pass |
| if f['t'] >= f['ticks']: |
| self.switch(f['to']) |
| f['t'] = 0 |
| f['phase'] = 'in' |
|
|
| else: |
| frac2 = 1.0 - frac |
| scr.erase() |
| current2 = self._scenes.get(self._current) |
| if current2: |
| current2.draw(Renderer(scr, self.H, self.W), self._tick) |
| overlay = chars[min(4, int(frac2 * 5))] |
| for r in range(self.H - 1): |
| try: |
| scr.addstr(r, 0, overlay * self.W, |
| curses.color_pair(NEUTRAL) | curses.A_DIM) |
| except curses.error: |
| pass |
| if f['t'] >= f['ticks']: |
| self._fade = None |
|
|
| scr.refresh() |
|
|
|
|
| |
|
|
| class AnimSprite: |
| """ |
| Frame-cycling sprite with named states. |
| |
| Example |
| ------- |
| states = { |
| 'idle': [ ['(o)', '|=|', '/ \\'] ], |
| 'run': [ ['(_o)', '/|\\', '/ \\'], |
| ['(_o)', '\\|/', '/ \\'] ], |
| } |
| sprite = AnimSprite(states, ticks_per_frame=6) |
| sprite.set_state('run') |
| sprite.tick() |
| r.sprite(row, col, sprite.current()) |
| |
| Callbacks |
| --------- |
| sprite.set_state('die', on_complete=lambda: self.remove()) |
| """ |
|
|
| def __init__(self, states: dict[str, list[list[str]]], ticks_per_frame: int = 8) -> None: |
| self.states = states |
| self.ticks_per_frame = ticks_per_frame |
| self.state = next(iter(states)) |
| self._frame = 0 |
| self._timer = 0 |
| self._on_complete: Callable[[], None] | None = None |
|
|
| def set_state(self, state: str, |
| on_complete: Callable[[], None] | None = None, |
| force: bool = False) -> None: |
| """Switch animation state. |
| |
| force=True restarts the animation even if already in this state. |
| on_complete fires once when the last frame plays. |
| """ |
| if (state != self.state or force) and state in self.states: |
| self.state = state |
| self._frame = 0 |
| self._timer = 0 |
| self._on_complete = on_complete |
|
|
| def tick(self) -> None: |
| self._timer += 1 |
| if self._timer >= self.ticks_per_frame: |
| self._timer = 0 |
| frames = self.states.get(self.state, [[]]) |
| n = max(1, len(frames)) |
| next_f = self._frame + 1 |
| if next_f >= n: |
| if self._on_complete: |
| cb = self._on_complete |
| self._on_complete = None |
| cb() |
| self._frame = next_f % n |
|
|
| def current(self) -> list[str]: |
| frames = self.states.get(self.state, [[' ']]) |
| return frames[self._frame % max(1, len(frames))] |
|
|
|
|
| |
|
|
| class Camera: |
| """ |
| Smooth-follow camera for scrolling worlds. |
| |
| Coordinate convention: x increases right, y increases DOWN (terminal default). |
| Set y_up=True in world_to_screen for platformer games where y=0 is the floor. |
| |
| Example |
| ------- |
| cam = Camera() |
| cam.set_bounds(0, world_width) |
| cam.follow(player.x, player.y) # each frame |
| row, col = cam.world_to_screen(e.x, e.y, W, H) |
| """ |
|
|
| def __init__(self) -> None: |
| self.x = 0.0 |
| self.y = 0.0 |
| self._min_x = -1e9 |
| self._max_x = 1e9 |
| self._min_y = -1e9 |
| self._max_y = 1e9 |
|
|
| def set_bounds(self, min_x: float, max_x: float, |
| min_y: float = -1e9, max_y: float = 1e9) -> None: |
| """Prevent the camera from scrolling outside the world.""" |
| self._min_x = min_x |
| self._max_x = max_x |
| self._min_y = min_y |
| self._max_y = max_y |
|
|
| def follow(self, tx: float, ty: float, lerp: float = 0.12) -> None: |
| """Lerp toward target. lerp=1.0 is instant, 0.05 is very smooth.""" |
| self.x = clamp(self.x + (tx - self.x) * lerp, self._min_x, self._max_x) |
| self.y = clamp(self.y + (ty - self.y) * lerp, self._min_y, self._max_y) |
|
|
| def world_to_screen(self, wx: float, wy: float, W: int, H: int, |
| y_up: bool = False) -> tuple[int, int]: |
| """Convert world position to terminal (row, col). |
| |
| y_up=False Y increases downward (default terminal β good for top-down games) |
| y_up=True Y increases upward (platformer convention β Y=0 is the floor) |
| """ |
| col = int(wx - self.x + W // 3) |
| if y_up: |
| row = int(H - 2 - (wy - self.y)) |
| else: |
| row = int(wy - self.y) |
| return row, col |
|
|
| def on_screen(self, wx: float, wy: float, W: int, H: int, |
| margin: int = 4) -> bool: |
| """True if the world position is currently visible.""" |
| row, col = self.world_to_screen(wx, wy, W, H) |
| return -margin <= row < H + margin and -margin <= col < W + margin |
|
|
|
|
| |
|
|
| class Particles: |
| """ |
| Moving particle system for explosions, sparks, debris, and effects. |
| |
| Example |
| ------- |
| particles = Particles() |
| particles.explode(col, row, count=8, color=GOLD) |
| particles.spawn(col, row, vx=-0.5, vy=-1.0, char='β
', color=PLAYER) |
| # each frame: |
| particles.update() |
| particles.draw(r) |
| """ |
|
|
| def __init__(self) -> None: |
| self._active: list[_ParticleDict] = [] |
|
|
| def spawn(self, col: float, row: float, |
| vx: float = 0.0, vy: float = 0.0, |
| char: str = '*', color: int = GOLD, |
| life: int = 8) -> None: |
| """Spawn one particle with velocity and lifetime.""" |
| self._active.append(_ParticleDict( |
| x=float(col), y=float(row), |
| vx=vx, vy=vy, |
| life=life, max_life=life, |
| char=char, color=color, |
| )) |
|
|
| def explode(self, col: int, row: int, |
| count: int = 14, color: int = GOLD, |
| chars: str = 'β¦β§β
Β·βββ*+') -> None: |
| """Radial burst of particles in random directions.""" |
| for _ in range(count): |
| angle = random.uniform(0, 2 * math.pi) |
| speed = random.uniform(0.3, 1.4) |
| self.spawn( |
| col, row, |
| vx=math.cos(angle) * speed * 2, |
| vy=math.sin(angle) * speed, |
| char=random.choice(chars), |
| color=color, |
| life=random.randint(4, 12), |
| ) |
|
|
| def burst(self, col: int, row: int, |
| direction: tuple[float, float], |
| spread: float = 0.8, count: int = 5, |
| color: int = GOLD) -> None: |
| """Directional burst (e.g., exhaust trail, hit sparks).""" |
| dx, dy = direction |
| angle = math.atan2(dy, dx) |
| for _ in range(count): |
| a = angle + random.uniform(-spread, spread) |
| s = random.uniform(0.4, 1.2) |
| self.spawn(col, row, |
| vx=math.cos(a) * s * 2, |
| vy=math.sin(a) * s, |
| char=random.choice('Β·ββ¦β’'), |
| color=color, |
| life=random.randint(3, 8)) |
|
|
| def update(self) -> None: |
| """Advance all particles by one frame. Call once per frame.""" |
| for p in self._active: |
| p['x'] += p['vx'] |
| p['y'] += p['vy'] |
| p['life'] -= 1 |
| self._active = [p for p in self._active if p['life'] > 0] |
|
|
| def draw(self, r: Renderer) -> None: |
| """Draw all live particles. Call after update().""" |
| for p in self._active: |
| fade = p['life'] / max(1, p['max_life']) |
| r.text(int(p['y']), int(p['x']), |
| p['char'], p['color'], dim=fade < 0.35) |
|
|
| @property |
| def count(self) -> int: |
| return len(self._active) |
|
|
|
|
| |
|
|
| class Timer: |
| """ |
| Countdown timer for game events. |
| |
| Example |
| ------- |
| self.invincible = Timer(2.0) # 2-second invincibility |
| |
| # in update(): |
| if self.invincible.tick(dt): |
| self.can_take_damage = True |
| |
| # check progress: |
| frac = self.invincible.frac # 0.0 β 1.0 |
| done = self.invincible.done |
| """ |
|
|
| def __init__(self, duration: float, auto_reset: bool = False) -> None: |
| self.duration = duration |
| self.auto_reset = auto_reset |
| self._elapsed = 0.0 |
| self.done = False |
|
|
| def tick(self, dt: float) -> bool: |
| """Advance the timer. Returns True the frame it completes.""" |
| if self.done and not self.auto_reset: |
| return False |
| self._elapsed += dt |
| if self._elapsed >= self.duration: |
| self._elapsed = 0.0 if self.auto_reset else self.duration |
| self.done = not self.auto_reset |
| return True |
| return False |
|
|
| def reset(self, duration: float | None = None) -> None: |
| """Restart the timer, optionally with a new duration.""" |
| if duration is not None: |
| self.duration = duration |
| self._elapsed = 0.0 |
| self.done = False |
|
|
| @property |
| def frac(self) -> float: |
| """Progress 0.0 β 1.0.""" |
| return min(1.0, self._elapsed / max(1e-9, self.duration)) |
|
|
| @property |
| def remaining(self) -> float: |
| return max(0.0, self.duration - self._elapsed) |
|
|
|
|
| |
|
|
| class Audio: |
| """ |
| Minimal audio. Two surfaces: |
| beep() / flash() Always works (curses primitives). |
| play(path) Background a system sound command if available. |
| |
| play() degrades gracefully β missing files or unsupported platforms are |
| no-ops, never raise. Currently looks for afplay (macOS) / aplay (ALSA) / |
| paplay (PulseAudio). |
| |
| Example |
| ------- |
| audio = Audio() |
| audio.beep() # short curses bell |
| audio.play('assets/hit.wav') # background; missing file is a no-op |
| """ |
|
|
| def __init__(self, enabled: bool = True) -> None: |
| import shutil |
| import subprocess |
| self.enabled = enabled |
| self._subprocess = subprocess |
| self._cmd: str | None = None |
| for candidate in ('afplay', 'paplay', 'aplay'): |
| if shutil.which(candidate): |
| self._cmd = candidate |
| break |
|
|
| def beep(self) -> None: |
| """Short audible bell via curses.""" |
| if not self.enabled: |
| return |
| try: |
| curses.beep() |
| except curses.error: |
| pass |
|
|
| def flash(self) -> None: |
| """Visible screen flash via curses (silent alternative to beep).""" |
| if not self.enabled: |
| return |
| try: |
| curses.flash() |
| except curses.error: |
| pass |
|
|
| def play(self, path: str | Path) -> None: |
| """Background-play an audio file. No-op if disabled, no command found, |
| or the file doesn't exist. Never blocks the game loop.""" |
| if not self.enabled or self._cmd is None: |
| return |
| p = Path(path) if not isinstance(path, Path) else path |
| if not p.exists(): |
| return |
| try: |
| self._subprocess.Popen( |
| [self._cmd, str(p)], |
| stdout=self._subprocess.DEVNULL, |
| stderr=self._subprocess.DEVNULL, |
| ) |
| except OSError: |
| pass |
|
|
|
|
| |
|
|
| class GameSave: |
| """ |
| Simple JSON save file stored in the user's home directory. |
| |
| Example |
| ------- |
| save = GameSave('claudemon') |
| save.save({'team': [...], 'gold': 500}) |
| data = save.load() # returns dict or None |
| save.delete() |
| """ |
|
|
| def __init__(self, name: str) -> None: |
| self._path = Path.home() / f'.claudcade_{name}_save.json' |
|
|
| def save(self, data: dict[str, object]) -> None: |
| self._path.write_text(json.dumps(data, indent=2)) |
|
|
| def load(self) -> dict[str, object] | None: |
| if not self._path.exists(): |
| return None |
| try: |
| result = json.loads(self._path.read_text()) |
| return result if isinstance(result, dict) else None |
| except (OSError, json.JSONDecodeError): |
| return None |
|
|
| def delete(self) -> None: |
| self._path.unlink(missing_ok=True) |
|
|
| @property |
| def exists(self) -> bool: |
| return self._path.exists() |
|
|
|
|
| |
|
|
| def make_stars(H: int, W: int, count: int = 80, |
| deep: int = 0) -> list[StarDict]: |
| """Generate a parallax star field. Pass to Renderer.stars() and scroll_stars(). |
| |
| `deep` adds a small number of slow-scrolling far-background landmarks |
| (planets, moons) that give a stronger sense of depth. |
| """ |
| result: list[StarDict] = [] |
| for _ in range(count): |
| spd = random.choice([0.25, 0.5, 1.0, 2.0]) |
| ch = 'β' if spd < 0.5 else ('Β·' if spd < 1.0 else ('+' if spd < 1.8 else 'β¦')) |
| cp = NEUTRAL if spd < 1.0 else (PLAYER if spd < 1.5 else GOLD) |
| result.append(StarDict( |
| x=float(random.randint(1, max(1, W - 2))), |
| y=float(random.randint(1, max(1, H - 5))), |
| spd=spd, ch=ch, cp=cp, |
| )) |
| |
| for _ in range(deep): |
| spd = 0.08 |
| ch = random.choice(['O', 'o', 'Β°', 'β―']) |
| cp = random.choice([SPECIAL, WATER, NEUTRAL]) |
| result.append(StarDict( |
| x=float(random.randint(1, max(1, W - 2))), |
| y=float(random.randint(1, max(1, H - 5))), |
| spd=spd, ch=ch, cp=cp, |
| )) |
| return result |
|
|
|
|
| def scroll_stars(stars: list[StarDict], W: int, direction: float = -1.0) -> None: |
| """Scroll stars horizontally. Call once per frame. direction=-1 scrolls left.""" |
| for s in stars: |
| s['x'] += s['spd'] * direction |
| if s['x'] < 1: |
| s['x'] = float(W - 2) |
| elif s['x'] >= W - 1: |
| s['x'] = 1.0 |
|
|
|
|
| |
|
|
| def distance(ax: float, ay: float, bx: float, by: float) -> float: |
| return math.hypot(bx - ax, by - ay) |
|
|
|
|
| def clamp(value: float, lo: float, hi: float) -> float: |
| return max(lo, min(hi, value)) |
|
|
|
|
| def lerp(a: float, b: float, t: float) -> float: |
| return a + (b - a) * t |
|
|
|
|
| def ease_in(t: float) -> float: |
| """Quadratic ease-in. t in [0, 1].""" |
| return t * t |
|
|
|
|
| def ease_out(t: float) -> float: |
| """Quadratic ease-out. t in [0, 1].""" |
| return 1.0 - (1.0 - t) ** 2 |
|
|
|
|
| def ease_in_out(t: float) -> float: |
| """Smooth step. t in [0, 1].""" |
| return t * t * (3.0 - 2.0 * t) |
|
|
|
|
| def sign(x: float) -> float: |
| return 0.0 if x == 0 else (1.0 if x > 0 else -1.0) |
|
|
|
|
| |
|
|
| def setup_colors() -> None: |
| """Initialise the standard Claudecade color pairs. Call once inside curses.wrapper. |
| |
| On 256-color terminals (Ghostty, iTerm2, modern xterm, etc.) we bind to |
| fixed indices in the 6x6x6 RGB cube so the arcade palette stays vibrant |
| regardless of the user's terminal theme. The named curses colors |
| (COLOR_CYAN etc.) are re-mapped by themes β Ghostty's default in |
| particular renders them pastel, which is what the arcade is *not*. |
| |
| On 8-color terminals we fall back to the named pairs so legacy |
| setups still get something readable. |
| """ |
| curses.start_color() |
| curses.use_default_colors() |
|
|
| if curses.COLORS >= 256: |
| |
| |
| VIBRANT_CYAN = 51 |
| VIBRANT_RED = 196 |
| VIBRANT_GREEN = 46 |
| VIBRANT_YELLOW = 226 |
| VIBRANT_WHITE = 231 |
| VIBRANT_MAGENTA = 201 |
| VIBRANT_BLUE = 33 |
| VIBRANT_BLACK = 16 |
|
|
| curses.init_pair(CYAN, VIBRANT_CYAN, -1) |
| curses.init_pair(RED, VIBRANT_RED, -1) |
| curses.init_pair(GREEN, VIBRANT_GREEN, -1) |
| curses.init_pair(YELLOW, VIBRANT_YELLOW, -1) |
| curses.init_pair(WHITE, VIBRANT_WHITE, -1) |
| curses.init_pair(MAGENTA, VIBRANT_MAGENTA, -1) |
| curses.init_pair(HIGHLIGHT, VIBRANT_BLACK, VIBRANT_WHITE) |
| curses.init_pair(BLUE, VIBRANT_BLUE, -1) |
| else: |
| curses.init_pair(CYAN, curses.COLOR_CYAN, -1) |
| curses.init_pair(RED, curses.COLOR_RED, -1) |
| curses.init_pair(GREEN, curses.COLOR_GREEN, -1) |
| curses.init_pair(YELLOW, curses.COLOR_YELLOW, -1) |
| curses.init_pair(WHITE, curses.COLOR_WHITE, -1) |
| curses.init_pair(MAGENTA, curses.COLOR_MAGENTA, -1) |
| curses.init_pair(HIGHLIGHT, curses.COLOR_BLACK, curses.COLOR_WHITE) |
| curses.init_pair(BLUE, curses.COLOR_BLUE, -1) |
|
|
|
|
| def _init_curses(scr: curses.window) -> None: |
| curses.cbreak() |
| curses.noecho() |
| scr.keypad(True) |
| scr.nodelay(True) |
| curses.curs_set(0) |
| setup_colors() |
| try: |
| curses.mousemask(curses.ALL_MOUSE_EVENTS) |
| except curses.error: |
| pass |
|
|
|
|
| def draw_how_to_play(scr: curses.window, H: int, W: int, tick: int, |
| goal: list[str], |
| tips: list[str], |
| controls: list[str] | None = None) -> None: |
| """Standard HOW TO PLAY screen shared by all games. |
| |
| Content is assembled into a single list, vertically centered inside the |
| panel, and horizontally centered within the box. The PRESS SPACE prompt |
| is reserved its own row at the bottom; the content block above it can |
| never overlap the prompt or run off the box (it scales down or clips |
| earlier sections if the terminal is too short). |
| """ |
| P = curses.color_pair |
| BW = min(70, W - 4) |
| lm = (W - BW) // 2 |
|
|
| def _put(r: int, c: int, s: str, a: int = 0) -> None: |
| try: |
| if 0 <= r < H - 1 and 0 <= c < W: |
| scr.addstr(r, c, s[:max(0, W - c)], a) |
| except curses.error: |
| pass |
|
|
| |
| scr.erase() |
| _put(0, lm, 'β' + 'β' * (BW - 2) + 'β', P(NEUTRAL) | curses.A_BOLD) |
| _put(H-1, lm, 'β' + 'β' * (BW - 2) + 'β', P(NEUTRAL) | curses.A_BOLD) |
| for r in range(1, H - 1): |
| _put(r, lm, 'β', P(NEUTRAL) | curses.A_BOLD) |
| _put(r, lm + BW - 1, 'β', P(NEUTRAL) | curses.A_BOLD) |
|
|
| title = 'H O W T O P L A Y' |
| hdr = 'βββ' + title.center(BW - 8) + 'βββ' |
| _put(1, lm + 1, hdr, P(GOLD) | curses.A_BOLD) |
| _put(2, lm, 'β ' + 'β' * (BW - 2) + 'β£', P(NEUTRAL) | curses.A_BOLD) |
|
|
| |
| Line = tuple[str, int, bool] |
| lines: list[Line] = [] |
| lines.append(('GOAL', GOLD, True)) |
| lines.append(('ββββ', NEUTRAL, False)) |
| for line in goal: |
| lines.append((line, NEUTRAL, False)) |
| lines.append(('', NEUTRAL, False)) |
|
|
| if controls is not None: |
| lines.append(('CONTROLS', GOLD, True)) |
| lines.append(('ββββββββ', NEUTRAL, False)) |
| for ctrl in controls: |
| lines.append((ctrl, NEUTRAL, False)) |
| lines.append(('', NEUTRAL, False)) |
|
|
| lines.append(('TIPS', GOLD, True)) |
| lines.append(('ββββ', NEUTRAL, False)) |
| for line in tips: |
| lines.append((line, NEUTRAL, False)) |
|
|
| |
| |
| while lines and lines[-1][0] == '': |
| lines.pop() |
|
|
| |
| |
| |
| top_inner = 3 |
| bot_inner = H - 4 |
| inner_h = max(0, bot_inner - top_inner + 1) |
| if len(lines) > inner_h: |
| |
| |
| lines = lines[:inner_h] |
| start = top_inner + max(0, (inner_h - len(lines)) // 2) |
|
|
| |
| |
| |
| col_w = max((len(text) for text, _, _ in lines), default=0) |
| col_w = min(col_w, BW - 4) |
| col_x = lm + (BW - col_w) // 2 |
|
|
| for i, (text, color, bold) in enumerate(lines): |
| attr = P(color) | (curses.A_BOLD if bold else 0) |
| _put(start + i, col_x, text[:col_w], attr) |
|
|
| |
| if (tick // 15) % 2 == 0: |
| msg = 'PRESS SPACE TO START' |
| _put(H - 3, lm + (BW - len(msg)) // 2, msg, P(GOLD) | curses.A_BOLD) |
| scr.refresh() |
|
|
|
|
| def run_game(main_fn: Callable[[curses.window], None], game_name: str = '') -> None: |
| """Standard curses.wrapper + crash handler. Use at the bottom of every game file. |
| |
| from claudcade_engine import run_game |
| run_game(main, 'C-TYPE') |
| """ |
| import sys |
| import traceback |
| try: |
| curses.wrapper(main_fn) |
| except Exception as e: |
| try: |
| curses.endwin() |
| except curses.error: |
| pass |
| label = f'[{game_name} crashed] ' if game_name else '' |
| print(f'\n{label}{e}', file=sys.stderr) |
| traceback.print_exc(file=sys.stderr) |
| print('\n [ back in Claude β type anything to chat ]\n') |
|
|