Spaces:
Running
Running
| """ | |
| 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 | |
| # ββ TypedDicts βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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'] | |
| # ββ Color constants ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 | |
| # Semantic aliases (same int values as the names above) | |
| PLAYER = 1 | |
| ENEMY = 2 | |
| GOOD = 3 | |
| GOLD = 4 | |
| NEUTRAL = 5 | |
| SPECIAL = 6 | |
| SELECT = 7 | |
| WATER = 8 | |
| # Module-level shortcuts so callers can write CYAN, ENEMY, etc. | |
| # IntEnum members are ints; existing `color: int` signatures still accept these. | |
| 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 | |
| # ββ Input βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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) | |
| """ | |
| # Class-level state β Input owns its own previous-frame snapshot. _poll() | |
| # captures and rotates it; callers don't manage it directly. | |
| _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 | |
| 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) | |
| def up(self) -> bool: return self.pressed(curses.KEY_UP, ord('w'), ord('W')) | |
| def down(self) -> bool: return self.pressed(curses.KEY_DOWN, ord('s'), ord('S')) | |
| def left(self) -> bool: return self.pressed(curses.KEY_LEFT, ord('a'), ord('A')) | |
| def right(self) -> bool: return self.pressed(curses.KEY_RIGHT, ord('d'), ord('D')) | |
| def fire(self) -> bool: return self.pressed(ord('j'), ord('J'), ord('f'), ord('F')) or 1 in self.mouse_pressed | |
| def jump(self) -> bool: return self.pressed(ord(' ')) | |
| def confirm(self) -> bool: return self.pressed(ord('\n'), 10, 13, ord(' ')) | |
| def pause(self) -> bool: return self.pressed(27) | |
| # Frames of "stickiness" applied to recently-seen keys. Terminal key | |
| # auto-repeat is unreliable across emulators β macOS Terminal and iTerm | |
| # default to ~500ms before the first repeat fires, then ~30ms between | |
| # subsequent repeats. At 60 FPS that ~500ms gap is 30 frames of silence | |
| # after the initial press. A grace window shorter than that produces a | |
| # noticeable "press, move one cell, stall for half a second, then start | |
| # moving" stutter. 20 frames (333ms) covers most terminals' initial | |
| # delay without making single taps feel mushy. | |
| _REPEAT_GRACE = 20 | |
| # Class-level age tracker: key -> frames since last seen. | |
| _key_age: dict[int, int] = {} | |
| 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) | |
| # Age all tracked keys by one frame, drop expired ones. | |
| cls._key_age = {k: a + 1 for k, a in cls._key_age.items() | |
| if a + 1 < cls._REPEAT_GRACE} | |
| # Reset age for keys actually seen this frame. | |
| for k in seen_this_frame: | |
| cls._key_age[k] = 0 | |
| # inp.keys = every key still within the grace window. | |
| inp.keys = set(cls._key_age.keys()) | |
| cls._last = inp | |
| return inp | |
| # ββ Bounds-safe primitive ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Shared bounds-safe addstr wrapper used across games. Cheaper than each game | |
| # defining a local `_p()` closure with the same try/except + bounds check. | |
| 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 | |
| # ββ Renderer βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 | |
| # ββ Primitives ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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) | |
| # ββ Shapes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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) | |
| # ββ Progress bars ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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) | |
| # ββ Sprites and menus ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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)) | |
| # ββ Text utilities ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 | |
| # ββ Standard screens βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 # kept for API compatibility; intentionally unused | |
| 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) | |
| # Clear only the panel rectangle (no full-screen scrim) so the game | |
| # stays visible behind the menu and we don't pay for an H*W write. | |
| for r in range(bh): | |
| self.text(by + r, bx, ' ' * bw, color=NEUTRAL) | |
| # Single-line frame | |
| self.box(by, bx, bh, bw, color=SELECT) | |
| # Centered title row + prompt row | |
| 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) | |
| # ββ Scene βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 | |
| # ββ Engine βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _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 # seconds per frame (updated each tick) | |
| # Engine-owned RNG. Games should prefer self.engine.rng over the global | |
| # `random` module so daily challenges and replays can be deterministic. | |
| 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 | |
| # Profiling: when run(profile=True), accumulates per-frame timings. | |
| 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) | |
| # Read terminal size before calling on_enter so scenes can use it | |
| 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() | |
| # Fixed timestep β skip catch-up if we fall behind | |
| 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) # no spiral-of-death | |
| self._tick += 1 | |
| self.H, self.W = scr.getmaxyx() | |
| # Minimum terminal size guard | |
| 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) | |
| # Handle fade transition | |
| 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() | |
| # Result may be: None, "name", ("name", payload), or "quit". | |
| 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 # _loop() only calls us when _fade is set, but narrow for type checker | |
| frac = f['t'] / max(1, f['ticks']) | |
| chars = 'ββββ ' | |
| f['t'] += 1 | |
| current = self._scenes.get(self._current) | |
| if f['phase'] == 'out': | |
| # Draw scene underneath, then overlay fade | |
| 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: # 'in' | |
| 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() | |
| # ββ AnimSprite βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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))] | |
| # ββ Camera βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 | |
| # ββ Particles ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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) | |
| def count(self) -> int: | |
| return len(self._active) | |
| # ββ Timer βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 | |
| def frac(self) -> float: | |
| """Progress 0.0 β 1.0.""" | |
| return min(1.0, self._elapsed / max(1e-9, self.duration)) | |
| def remaining(self) -> float: | |
| return max(0.0, self.duration - self._elapsed) | |
| # ββ Audio βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 | |
| # ββ GameSave βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 # missing / unreadable / corrupt save β caller starts fresh | |
| def delete(self) -> None: | |
| self._path.unlink(missing_ok=True) | |
| def exists(self) -> bool: | |
| return self._path.exists() | |
| # ββ Stars βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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, | |
| )) | |
| # Deep-background landmarks β sparse, very slow, larger glyphs | |
| 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 | |
| # ββ Math utilities βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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) | |
| # ββ Engine helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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: | |
| # 6x6x6 RGB cube: 16 + 36*r + 6*g + b where r,g,b in 0..5 | |
| # Picked for high saturation against a black background. | |
| VIBRANT_CYAN = 51 # #00ffff | |
| VIBRANT_RED = 196 # #ff0000 | |
| VIBRANT_GREEN = 46 # #00ff00 | |
| VIBRANT_YELLOW = 226 # #ffff00 | |
| VIBRANT_WHITE = 231 # #ffffff | |
| VIBRANT_MAGENTA = 201 # #ff00ff | |
| VIBRANT_BLUE = 33 # #0087ff (royal β pure 21 #0000ff is too dim) | |
| VIBRANT_BLACK = 16 # #000000 | |
| 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 | |
| # ββ Frame ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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) | |
| # ββ Build the content block as (text, color, bold) tuples ββββββββββββββ | |
| 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)) | |
| # Strip trailing blank rows so vertical centering doesn't pad on both | |
| # sides of empty space at the end. | |
| while lines and lines[-1][0] == '': | |
| lines.pop() | |
| # ββ Vertical centering βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Inner panel runs rows 3..H-4 inclusive; H-3 is reserved for the | |
| # PRESS SPACE prompt. So the content has (H - 3) - 3 = H - 6 rows. | |
| top_inner = 3 | |
| bot_inner = H - 4 # last writable row above the SPACE prompt | |
| inner_h = max(0, bot_inner - top_inner + 1) | |
| if len(lines) > inner_h: | |
| # Too tall for this terminal β clip trailing lines so the prompt | |
| # still has its row. Better than overrunning the box border. | |
| lines = lines[:inner_h] | |
| start = top_inner + max(0, (inner_h - len(lines)) // 2) | |
| # ββ Horizontal centering of the whole content column βββββββββββββββββββ | |
| # Compute the widest line so the column sits centered as a block, not | |
| # so each line is independently centered (preserves CONTROLS alignment). | |
| 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) | |
| # ββ Blinking prompt (always on its reserved row) βββββββββββββββββββββββ | |
| 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') | |