From 9f3f7549217718e10fbfcaf6733977f76aabff3f Mon Sep 17 00:00:00 2001 From: rene Date: Sat, 28 Mar 2026 20:43:18 +0100 Subject: [PATCH] asciiquarium-ng: rewrite as faithful 1:1 Python port of Perl original Same sprites, same colors, same entity timing as the Perl source. Replaces Curses with direct ANSI truecolor escapes using the same RGB values as our Homebrew fix (xterm-256 slots 16-231). --- asciiquarium_ng.py | 1806 +++++++++++++++++++++++++++++--------------- 1 file changed, 1181 insertions(+), 625 deletions(-) diff --git a/asciiquarium_ng.py b/asciiquarium_ng.py index 1dffb6e..b74e7b0 100755 --- a/asciiquarium_ng.py +++ b/asciiquarium_ng.py @@ -1,707 +1,1263 @@ #!/usr/bin/env python3 -"""asciiquarium-ng — modernized aquarium animation with true RGB colors.""" +"""asciiquarium-ng: Python port of asciiquarium with true RGB colors. +Requires no external dependencies — pure ANSI escape codes instead of Curses. +""" -import argparse -import math -import os -import random -import signal -import sys -import time -import tty -import termios -import threading -from dataclasses import dataclass, field -from typing import Optional +import os, sys, tty, termios, fcntl, signal, time, random, shutil +from typing import Optional, Callable, List + +# ─── 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(row, col): return f"\033[{row+1};{col+1}H" +def term_size(): s = shutil.get_terminal_size(); return s.lines, s.columns -# ────────────────────────────────────────────────────────────────────────────── -# Terminal helpers -# ────────────────────────────────────────────────────────────────────────────── +# ─── Color palette ─────────────────────────────────────────────────────────── +# RGB values matching our Homebrew init_pair fix (xterm-256 slots 16-231): +# w=231=#ffffff r=196=#ff0000 g=46=#00ff00 b=27=#005fff +# c=51=#00ffff m=201=#ff00ff y=226=#ffff00 k=238=#444444 -def truecolor(r: int, g: int, b: int, bg: bool = False) -> str: - """Return ANSI true-color escape for fg (bg=False) or bg (bg=True).""" - layer = 48 if bg else 38 - return f"\033[{layer};2;{r};{g};{b}m" +_C = { + 'w': _fg(255,255,255), 'W': BOLD + _fg(255,255,255), + 'r': _fg(255,0,0), 'R': BOLD + _fg(255,0,0), + 'g': _fg(0,255,0), 'G': BOLD + _fg(0,255,0), + 'b': _fg(0,95,255), 'B': BOLD + _fg(0,95,255), + 'c': _fg(0,255,255), 'C': BOLD + _fg(0,255,255), + 'm': _fg(255,0,255), 'M': BOLD + _fg(255,0,255), + 'y': _fg(255,255,0), 'Y': BOLD + _fg(255,255,0), + 'k': _fg(68,68,68), 'K': BOLD + _fg(68,68,68), +} + +_NAMED = { + 'white':'w', 'WHITE':'W', + 'cyan':'c', 'CYAN':'C', + 'green':'g', 'GREEN':'G', + 'blue':'b', 'BLUE':'B', + 'red':'r', 'RED':'R', + 'yellow':'y', 'YELLOW':'Y', + 'black':'k', 'BLACK':'k', + 'magenta':'m', 'MAGENTA':'M', +} + +def _esc(key: Optional[str]) -> str: + if key is None: return RESET + if key in _C: return _C[key] + k2 = _NAMED.get(key) + return _C[k2] if k2 else RESET + +def _rand_color(mask_lines: List[str]) -> List[str]: + """Replace digit placeholders 1-9 in color mask with random color letters. + Digit 4 (eye) is always 'W' (white), as in the original rand_color().""" + pool = list('cCrRyYbBgGmM') + mapping = {str(i): random.choice(pool) for i in range(1, 10)} + mapping['4'] = 'W' + return [''.join(mapping.get(ch, ch) for ch in line) for line in mask_lines] -RESET = "\033[0m" -HIDE_CURSOR = "\033[?25l" -SHOW_CURSOR = "\033[?25h" -ALT_SCREEN_ON = "\033[?1049h" -ALT_SCREEN_OFF = "\033[?1049l" -CLEAR = "\033[2J\033[H" - - -def move(row: int, col: int) -> str: - return f"\033[{row+1};{col+1}H" - - -def terminal_size() -> tuple[int, int]: - import shutil - s = shutil.get_terminal_size() - return s.lines, s.columns - - -# ────────────────────────────────────────────────────────────────────────────── -# Color palette -# ────────────────────────────────────────────────────────────────────────────── - -@dataclass(frozen=True) -class RGB: - r: int - g: int - b: int - - def blend(self, other: "RGB", t: float) -> "RGB": - return RGB( - int(self.r + (other.r - self.r) * t), - int(self.g + (other.g - self.g) * t), - int(self.b + (other.b - self.b) * t), - ) - - def fg(self) -> str: - return truecolor(self.r, self.g, self.b, bg=False) - - def bg(self) -> str: - return truecolor(self.r, self.g, self.b, bg=True) - - -# Ocean gradient: deep midnight blue at bottom → teal at mid-depth → dark cyan at surface -OCEAN_TOP = RGB(0, 60, 80) -OCEAN_MID = RGB(0, 40, 70) -OCEAN_BOTTOM = RGB(0, 20, 50) - -# Entity colors -C_WHITE = RGB(255, 255, 255) -C_CYAN = RGB(0, 220, 220) -C_GREEN = RGB(50, 220, 80) -C_YELLOW = RGB(240, 200, 0) -C_ORANGE = RGB(255, 140, 0) -C_RED = RGB(220, 50, 50) -C_MAGENTA = RGB(200, 80, 200) -C_BLUE = RGB(80, 140, 255) -C_GRAY = RGB(160, 160, 160) -C_DARK = RGB(60, 60, 80) -C_SAND = RGB(180, 160, 100) -C_BUBBLE = RGB(150, 200, 230) -C_WAVE = RGB(100, 180, 220) -C_SHIP = RGB(140, 120, 100) -C_SHARK = RGB(120, 130, 145) - - -# ────────────────────────────────────────────────────────────────────────────── -# Canvas: per-cell fg color + character, rendered in one write() -# ────────────────────────────────────────────────────────────────────────────── - -@dataclass -class Cell: - ch: str = " " - fg: Optional[RGB] = None # None → no fg escape (use bg color as text color) - +# ─── Canvas ────────────────────────────────────────────────────────────────── class Canvas: def __init__(self, rows: int, cols: int): self.rows = rows self.cols = cols - self._cells: list[list[Cell]] = [[Cell() for _ in range(cols)] for _ in range(rows)] - self._ocean: list[RGB] = [] - self._build_ocean() - - def _build_ocean(self): - """Precompute ocean background color per row.""" - self._ocean = [] - for r in range(self.rows): - t = r / max(self.rows - 1, 1) - if t < 0.5: - color = OCEAN_TOP.blend(OCEAN_MID, t * 2) - else: - color = OCEAN_MID.blend(OCEAN_BOTTOM, (t - 0.5) * 2) - self._ocean.append(color) + 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 = rows - self.cols = cols - self._cells = [[Cell() for _ in range(cols)] for _ in range(rows)] - self._build_ocean() + 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 row in self._cells: - for cell in row: - cell.ch = " " - cell.fg = None + 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, fg: Optional[RGB]): + def put(self, row: int, col: int, ch: str, ck: Optional[str]): if 0 <= row < self.rows and 0 <= col < self.cols: - self._cells[row][col].ch = ch - self._cells[row][col].fg = fg + self._ch[row][col] = ch + self._ck[row][col] = ck - def put_str(self, row: int, col: int, s: str, fg: Optional[RGB]): - for i, ch in enumerate(s): - self.put(row, col + i, ch, fg) + def put_sprite(self, y: int, x: int, shape: List[str], + mask: Optional[List[str]], default_color: Optional[str]): + """Draw a sprite. Spaces and '?' characters are transparent.""" + rows, cols = self.rows, self.cols + for dr, line in enumerate(shape): + row = y + dr + if row < 0 or row >= rows: + continue + mline = (mask[dr] if dr < len(mask) else '') if mask else '' + for dc, ch in enumerate(line): + if ch in (' ', '?'): + continue + col = x + dc + if 0 <= col < cols: + mk = mline[dc] if dc < len(mline) else ' ' + ck = mk if mk in _C else default_color + self._ch[row][col] = ch + self._ck[row][col] = ck def render(self) -> str: - buf: list[str] = ["\033[H"] # move to top-left without clearing - last_fg: Optional[RGB] = None - last_bg: Optional[RGB] = None - - for r, row in enumerate(self._cells): - ocean_bg = self._ocean[r] - for c, cell in enumerate(row): - # Background - if ocean_bg is not last_bg: - buf.append(ocean_bg.bg()) - last_bg = ocean_bg - # Foreground - fg = cell.fg if cell.fg is not None else ocean_bg - if fg is not last_fg: - buf.append(fg.fg()) - last_fg = fg - buf.append(cell.ch) - # End of row — no newline needed (cursor wraps), but reset bg to avoid - # terminal smearing the last cell's bg color into line padding - if r < self.rows - 1: - buf.append(RESET) - last_fg = None - last_bg = None - - buf.append(RESET) - return "".join(buf) + 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(self.cols): + ck = self._ck[r][c] + if ck is not last_ck: + parts.append(RESET if ck is None else _esc(ck)) + last_ck = ck + parts.append(self._ch[r][c]) + parts.append(RESET) + return ''.join(parts) -# ────────────────────────────────────────────────────────────────────────────── -# Entity base class -# ────────────────────────────────────────────────────────────────────────────── +# ─── Sprite helpers ────────────────────────────────────────────────────────── + +def _p(s: str) -> List[str]: + """Parse a Perl q{} heredoc: strip the mandatory leading blank line.""" + lines = s.split('\n') + if lines and lines[0] == '': + lines = lines[1:] + if lines and lines[-1] == '': + lines = lines[:-1] + return lines + + +# ─── Entity ────────────────────────────────────────────────────────────────── class Entity: - def __init__(self, row: int, col: int, speed: float = 1.0): - self.row = row - self.col = col # float for sub-pixel movement - self.speed = speed # cols per second (negative = left) - self.alive = True + """Animated, moving sprite, faithful to Term::Animation::Entity semantics. - def update(self, dt: float, rows: int, cols: int): - """Advance physics. Set self.alive = False to remove.""" - self.col += self.speed * dt + callback_args matches the Perl convention: + [dx, dy, dz, frame_advance] — simple movement + [path_index, [list_of_steps]] — path-based movement (dolphins) + where each path step is [dx, dy, dz, frame_advance]. + """ - def draw(self, canvas: Canvas): - pass + def __init__(self, + frames: List[List[str]], + masks: Optional[List[List[str]]], + x: float, y: float, + cb_args: list, + default_color: Optional[str], + die_offscreen: bool = True, + death_cb: Optional[Callable] = None, + die_time: Optional[float] = None, + die_frame: Optional[int] = None, + entity_type: str = ''): + self.frames = frames + self.masks = masks + self.x = float(x) + self.y = float(y) + self.cb_args = list(cb_args) + self.default_color = default_color + self.die_offscreen = die_offscreen + self.death_cb = death_cb + self.die_time = die_time + self._die_frame = die_frame + self.entity_type = entity_type + self.curr_frame = 0.0 + self.alive = True + self.height = len(frames[0]) if frames else 0 + self.width = max((len(l) for l in frames[0]), default=0) if frames else 0 - def _offscreen(self, cols: int, width: int) -> bool: - c = int(self.col) - return c + width < 0 or c >= cols + @property + def frame_idx(self) -> int: + n = len(self.frames) + return int(self.curr_frame) % n if n else 0 + def _move(self): + cb = self.cb_args + # Path-based: cb_args = [path_idx, [steps]] + if len(cb) >= 2 and isinstance(cb[1], list): + idx = int(cb[0]) % len(cb[1]) + step = cb[1][idx] + cb[0] = cb[0] + 1 + dx = step[0] if len(step) > 0 else 0 + dy = step[1] if len(step) > 1 else 0 + fadv = step[3] if len(step) > 3 else 0 + else: + dx = cb[0] if len(cb) > 0 else 0 + dy = cb[1] if len(cb) > 1 else 0 + fadv = cb[3] if len(cb) > 3 else 0 -# ────────────────────────────────────────────────────────────────────────────── -# Small fish ><> or <>< -# ────────────────────────────────────────────────────────────────────────────── + self.x += dx + self.y += dy + if fadv: + self.curr_frame = (self.curr_frame + fadv) % len(self.frames) -SMALL_FISH_R = ["><>", "><°>", ">°<>"] # swimming right -SMALL_FISH_L = ["<><", "<°><", "<>°<"] # swimming left + def update(self, aq: 'Aquarium') -> bool: + if not self.alive: + return False -FISH_COLORS = [C_CYAN, C_YELLOW, C_ORANGE, C_GREEN, C_MAGENTA, C_BLUE, C_RED] - - -class SmallFish(Entity): - def __init__(self, row: int, col: int, speed: float, color: RGB): - super().__init__(row, col, speed) - self.color = color - forms = SMALL_FISH_R if speed > 0 else SMALL_FISH_L - self.body = random.choice(forms) - - def update(self, dt: float, rows: int, cols: int): - super().update(dt, rows, cols) - if self._offscreen(cols, len(self.body)): + if self.die_time is not None and time.time() >= self.die_time: self.alive = False + elif self._die_frame is not None: + self._die_frame -= 1 + if self._die_frame <= 0: + self.alive = False + + if self.alive: + self._move() + if self.die_offscreen: + ix, iy = int(self.x), int(self.y) + if (ix >= aq.cols or ix + self.width < 0 or + iy >= aq.rows or iy + self.height < 0): + self.alive = False + + if not self.alive and self.death_cb: + self.death_cb(self, aq) + + return self.alive def draw(self, canvas: Canvas): - canvas.put_str(int(self.row), int(self.col), self.body, self.color) + fi = self.frame_idx + canvas.put_sprite(int(self.y), int(self.x), + self.frames[fi], + self.masks[fi] if self.masks and fi < len(self.masks) else None, + self.default_color) -# ────────────────────────────────────────────────────────────────────────────── -# Big fish >==[}> or <{[==< -# ────────────────────────────────────────────────────────────────────────────── +class HookEntity(Entity): + """Fishhook: descends to 75% depth, then retracts.""" -BIG_FISH_R = [ - [" __ ", ">==[}> ", " ~~ "], -] -BIG_FISH_L = [ - [" __ ", " <{[==<", " ~~ "], -] + def __init__(self, *args, target_y: int, **kwargs): + super().__init__(*args, **kwargs) + self._target_y = target_y + self._retracting = False + + def _move(self): + if not self._retracting: + if int(self.y) >= self._target_y: + self._retracting = True + self.cb_args[1] = -1 # reverse: go up + else: + self.y += 1 + else: + self.y -= 1 + if int(self.y) < -10: + self.alive = False -class BigFish(Entity): - def __init__(self, row: int, col: int, speed: float, color: RGB): - super().__init__(row, col, speed) - self.color = color - self.sprite = random.choice(BIG_FISH_R if speed > 0 else BIG_FISH_L) - - def update(self, dt: float, rows: int, cols: int): - super().update(dt, rows, cols) - w = max(len(l) for l in self.sprite) - if self._offscreen(cols, w): - self.alive = False - - def draw(self, canvas: Canvas): - for dr, line in enumerate(self.sprite): - canvas.put_str(int(self.row) + dr - 1, int(self.col), line, self.color) - - -# ────────────────────────────────────────────────────────────────────────────── -# Shark -# ────────────────────────────────────────────────────────────────────────────── - -SHARK_R = [ - " __ ", - " =-_ =-_ =-_ ==>| ,----( o>", - " ~~-~ ", -] -SHARK_L = [ - " __ ", - " 0 else SHARK_L - - def update(self, dt: float, rows: int, cols: int): - super().update(dt, rows, cols) - w = max(len(l) for l in self.sprite) - if self._offscreen(cols, w): - self.alive = False - - def draw(self, canvas: Canvas): - for dr, line in enumerate(self.sprite): - canvas.put_str(int(self.row) + dr - 1, int(self.col), line, C_SHARK) - - -# ────────────────────────────────────────────────────────────────────────────── -# Jellyfish -# ────────────────────────────────────────────────────────────────────────────── - -JELLY_BODY = [" ,., ", "( o )", "|'|'|", "| | |"] -JELLY_ALT = [" ,., ", "( o )", "| | |", "|'|'|"] - - -class Jellyfish(Entity): - def __init__(self, row: int, col: int, color: RGB): - # Jellyfish drift slowly horizontally and bob vertically - speed = random.uniform(-0.3, 0.3) - super().__init__(row, col, speed) - self.color = color - self.base_row = float(row) - self.bob_phase = random.uniform(0, math.tau) - self.bob_amp = random.uniform(0.5, 1.5) - self.bob_freq = random.uniform(0.3, 0.7) - self.t = 0.0 - self.frame = 0 - self.frame_timer = 0.0 - - def update(self, dt: float, rows: int, cols: int): - self.t += dt - self.col += self.speed * dt - self.row = self.base_row + math.sin(self.bob_phase + self.t * self.bob_freq * math.tau) * self.bob_amp - self.frame_timer += dt - if self.frame_timer > 0.5: - self.frame = 1 - self.frame - self.frame_timer = 0.0 - if self._offscreen(cols, 5): - self.alive = False - - def draw(self, canvas: Canvas): - sprite = JELLY_BODY if self.frame == 0 else JELLY_ALT - for dr, line in enumerate(sprite): - canvas.put_str(int(self.row) + dr, int(self.col), line, self.color) - - -# ────────────────────────────────────────────────────────────────────────────── -# Bubble -# ────────────────────────────────────────────────────────────────────────────── - -class Bubble(Entity): - def __init__(self, row: int, col: int): - super().__init__(row, float(col), speed=0) - self.float_row = float(row) - self.float_speed = random.uniform(0.8, 2.0) # rows per second upward - self.wobble_phase = random.uniform(0, math.tau) - self.wobble_amp = random.uniform(0.3, 0.8) - self.t = 0.0 - - def update(self, dt: float, rows: int, cols: int): - self.t += dt - self.float_row -= self.float_speed * dt - self.col = self.col + math.sin(self.wobble_phase + self.t * 1.5) * self.wobble_amp * dt - self.row = self.float_row - if self.float_row < 1: - self.alive = False - - def draw(self, canvas: Canvas): - canvas.put(int(self.row), int(self.col), "o", C_BUBBLE) - - -# ────────────────────────────────────────────────────────────────────────────── -# Seaweed -# ────────────────────────────────────────────────────────────────────────────── - -class Seaweed(Entity): - """Stationary swaying seaweed at the bottom.""" - def __init__(self, row: int, col: int, height: int): - super().__init__(row, col, speed=0) - self.height = height - self.phase = random.uniform(0, math.tau) - self.t = 0.0 - - def update(self, dt: float, rows: int, cols: int): - self.t += dt - - def draw(self, canvas: Canvas): - for i in range(self.height): - r = int(self.row) - i - offset = int(math.sin(self.phase + self.t * 1.2 + i * 0.4) * 1) - ch = "(" if (i + int(self.t * 2)) % 2 == 0 else ")" - canvas.put(r, int(self.col) + offset, ch, C_GREEN) - - -# ────────────────────────────────────────────────────────────────────────────── -# Ship (on surface) -# ────────────────────────────────────────────────────────────────────────────── - -SHIP_R = [ - " | ", - " | ", - " __|__ ", - " / \\ ", - "/ * * \\", - "~~~~~~~~~~", -] -SHIP_L = [ - " | ", - " | ", - " __|__ ", - " / \\ ", - "/ * * \\", - "~~~~~~~~~~", -] - - -class Ship(Entity): - def __init__(self, col: int, speed: float): - super().__init__(0, col, speed) # row adjusted in draw - self.sprite = SHIP_R if speed > 0 else SHIP_L - - def update(self, dt: float, rows: int, cols: int): - super().update(dt, rows, cols) - self.row = 0 # always at surface - w = max(len(l) for l in self.sprite) - if self._offscreen(cols, w): - self.alive = False - - def draw(self, canvas: Canvas): - for dr, line in enumerate(self.sprite): - canvas.put_str(dr, int(self.col), line, C_SHIP) - - -# ────────────────────────────────────────────────────────────────────────────── -# Waves -# ────────────────────────────────────────────────────────────────────────────── - -class Waves: - """Animated wave band at the top of the screen.""" - def __init__(self): - self.t = 0.0 - self.offset = 0.0 - - def update(self, dt: float): - self.t += dt - self.offset += dt * 4.0 - - def draw(self, canvas: Canvas): - cols = canvas.cols - # Two rows of waves - for c in range(cols): - phase = (c + self.offset) * 0.3 - # Row 0: crests - ch = "~" if math.sin(phase) > 0 else " " - canvas.put(0, c, ch, C_WAVE) - # Row 1: troughs - ch2 = "~" if math.sin(phase + 1.0) > -0.3 else " " - canvas.put(1, c, ch2, C_WAVE.blend(C_BLUE, 0.3)) - - -# ────────────────────────────────────────────────────────────────────────────── -# Castle (static decoration) -# ────────────────────────────────────────────────────────────────────────────── - -CASTLE = [ - " T~~", - " | ", - " /|\\\\", - " T~~ /_|_\\\\", - " | | |", - " /|\\\\ | |", - " /_|_\\\\ | |", - " | | | |", - "===|===|===|===|===", -] - - -class Castle: - def __init__(self, row: int, col: int): - self.row = row - self.col = col - - def draw(self, canvas: Canvas): - for dr, line in enumerate(CASTLE): - canvas.put_str(self.row + dr, self.col, line, C_SAND) - - -# ────────────────────────────────────────────────────────────────────────────── -# Aquarium orchestrator -# ────────────────────────────────────────────────────────────────────────────── +# ─── Aquarium ──────────────────────────────────────────────────────────────── class Aquarium: + def __init__(self, rows: int, cols: int): - self.rows = rows - self.cols = cols - self.canvas = Canvas(rows, cols) - self.entities: list[Entity] = [] - self.waves = Waves() - self.castle: Optional[Castle] = None - self.seaweeds: list[Seaweed] = [] - self.t = 0.0 - self.next_fish = 0.0 - self.next_shark = random.uniform(30, 60) - self.next_jelly = random.uniform(5, 15) - self.next_ship = random.uniform(20, 45) - self._setup_static() - self._initial_population() - self._resize_lock = threading.Lock() - self._needs_resize = False - self._new_size: tuple[int, int] = (rows, cols) + self.rows = rows + self.cols = cols + self.canvas = Canvas(rows, cols) + self.entities: List[Entity] = [] + self._random_fns = [ + self.add_ship, self.add_whale, self.add_monster, + self.add_big_fish, self.add_shark, self.add_fishhook, + self.add_swan, self.add_ducks, self.add_dolphins, + ] + self._setup() - def _setup_static(self): - # Castle at bottom-right - castle_row = self.rows - len(CASTLE) - castle_col = max(0, self.cols - 22) - self.castle = Castle(castle_row, castle_col) + def _setup(self): + self.entities.clear() + self.add_environment() + self.add_castle() + self.add_all_seaweed() + self.add_all_fish() + self.random_object(None) - # Seaweeds scattered at bottom - self.seaweeds = [] - n_weeds = max(3, self.cols // 15) - used_cols = set() - # Avoid castle area - castle_zone = range(castle_col - 2, castle_col + 22) - for _ in range(n_weeds): - for _ in range(20): - c = random.randint(2, self.cols - 4) - if c not in used_cols and c not in castle_zone: - used_cols.add(c) - h = random.randint(3, min(7, self.rows // 4)) - self.seaweeds.append(Seaweed(self.rows - 2, c, h)) - break - - def _initial_population(self): - """Spawn a few fish to start.""" - for _ in range(4): - self._spawn_fish() - for _ in range(2): - self._spawn_bubble_cluster() - - def _fish_row(self) -> int: - return random.randint(3, self.rows - 5) - - def _spawn_fish(self): - going_right = random.random() < 0.5 - speed = random.uniform(4, 10) * (1 if going_right else -1) - col = -10 if going_right else self.cols + 10 - color = random.choice(FISH_COLORS) - if random.random() < 0.3: - self.entities.append(BigFish(self._fish_row(), col, speed * 0.6, color)) - else: - self.entities.append(SmallFish(self._fish_row(), col, speed, color)) - - def _spawn_bubble_cluster(self): - col = random.randint(2, self.cols - 3) - base_row = random.randint(self.rows // 2, self.rows - 3) - for _ in range(random.randint(1, 3)): - self.entities.append(Bubble(base_row + random.randint(-1, 1), - col + random.randint(-1, 1))) - - def _spawn_shark(self): - going_right = random.random() < 0.5 - speed = random.uniform(5, 8) * (1 if going_right else -1) - col = -40 if going_right else self.cols + 40 - self.entities.append(Shark(self._fish_row(), col, speed)) - - def _spawn_jellyfish(self): - col = random.randint(0, self.cols - 6) - row = random.randint(3, self.rows - 8) - color = random.choice([C_CYAN, C_MAGENTA, C_BLUE, C_YELLOW]) - self.entities.append(Jellyfish(row, col, color)) - - def _spawn_ship(self): - going_right = random.random() < 0.5 - speed = random.uniform(2, 5) * (1 if going_right else -1) - col = -12 if going_right else self.cols + 12 - self.entities.append(Ship(col, speed)) - - def signal_resize(self, rows: int, cols: int): - with self._resize_lock: - self._needs_resize = True - self._new_size = (rows, cols) - - def _apply_resize(self): - rows, cols = self._new_size - self.rows = rows - self.cols = cols + def resize(self, rows: int, cols: int): + self.rows, self.cols = rows, cols self.canvas.resize(rows, cols) - self._setup_static() - self._needs_resize = False + self._setup() - def update(self, dt: float): - with self._resize_lock: - if self._needs_resize: - self._apply_resize() + def _add(self, e: Entity): + self.entities.append(e) - self.t += dt - self.waves.update(dt) + def random_object(self, dead): + random.choice(self._random_fns)(dead) - # Spawn timers - self.next_fish -= dt - if self.next_fish <= 0: - self._spawn_fish() - self.next_fish = random.uniform(3, 8) + def step(self): + self.entities = [e for e in self.entities if e.update(self)] - self.next_shark -= dt - if self.next_shark <= 0: - self._spawn_shark() - self.next_shark = random.uniform(25, 55) - - self.next_jelly -= dt - if self.next_jelly <= 0: - self._spawn_jellyfish() - self.next_jelly = random.uniform(8, 20) - - self.next_ship -= dt - if self.next_ship <= 0: - self._spawn_ship() - self.next_ship = random.uniform(20, 45) - - # Occasional bubble clusters - if random.random() < dt * 0.5: - self._spawn_bubble_cluster() - - # Update entities - for e in self.entities: - e.update(dt, self.rows, self.cols) - self.entities = [e for e in self.entities if e.alive] - - for w in self.seaweeds: - w.update(dt, self.rows, self.cols) - - def draw(self) -> str: + def render(self) -> str: self.canvas.clear() - self.waves.draw(self.canvas) - if self.castle: - self.castle.draw(self.canvas) - for w in self.seaweeds: - w.draw(self.canvas) for e in self.entities: e.draw(self.canvas) return self.canvas.render() + # ── Environment (water lines) ───────────────────────────────────────────── -# ────────────────────────────────────────────────────────────────────────────── -# Input handling (non-blocking, raw mode) -# ────────────────────────────────────────────────────────────────────────────── + def add_environment(self): + segments = [ + '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~', + '^^^^ ^^^ ^^^ ^^^ ^^^^ ', + '^^^^ ^^^^ ^^^ ^^ ', + '^^ ^^^^ ^^^ ^^^^^^ ', + ] + repeat = self.cols // len(segments[0]) + 1 + for i, seg in enumerate(segments): + self._add(Entity( + frames=[[seg * repeat]], + masks=None, + x=0, y=i + 5, + cb_args=[0, 0, 0], + default_color='c', + die_offscreen=False, + )) -class InputHandler: + # ── Castle ─────────────────────────────────────────────────────────────── + + def add_castle(self): + shape = _p(r""" + T~~ + | + /^\ + / \ + _ _ _ / \ _ _ _ +[ ]_[ ]_[ ]/ _ _ \[ ]_[ ]_[ ] +|_=__-_ =_|_[ ]_[ ]_|_=-___-__| + | _- = | =_ = _ |= _= | + |= -[] |- = _ = |_-=_[] | + | =_ |= - ___ | =_ = | + |= []- |- /| |\ |=_ =[] | + |- =_ | =| | | | |- = - | + |_______|__|_|_|_|__|_______| +""") + mask = _p(""" + RR + + yyy + y y + y y + y y + + + + yyy + yy yy + y y y y + yyyyyyy +""") + self._add(Entity( + frames=[shape], masks=[mask], + x=self.cols - 32, y=self.rows - 13, + cb_args=[0, 0, 0], + default_color='k', + die_offscreen=False, + )) + + # ── Seaweed ────────────────────────────────────────────────────────────── + + def add_all_seaweed(self): + for _ in range(self.cols // 15): + self.add_seaweed(None) + + def add_seaweed(self, dead): + # Faithful reproduction of Perl seaweed generation: + # two frames built by alternating ( and ) per row + height = random.randint(3, 6) # int(rand(4)) + 3 + raw = ['', ''] + for i in range(1, height + 1): + left = i % 2 + right = 1 - left + raw[left] += '(\n' + raw[right] += ' )\n' + frames = [r.split('\n')[:-1] for r in raw] # strip trailing empty + + x = random.randint(1, self.cols - 3) + y = self.rows - height + anim_speed = random.uniform(0.25, 0.30) + die_t = time.time() + random.randint(8 * 60, 12 * 60) + + self._add(Entity( + frames=frames, masks=None, + x=x, y=y, + cb_args=[0, 0, 0, anim_speed], + default_color='g', + die_offscreen=False, + death_cb=self.add_seaweed, + die_time=die_t, + )) + + # ── Bubbles ────────────────────────────────────────────────────────────── + + def add_bubble(self, fish: Entity): + bx = int(fish.x) + (fish.width if fish.cb_args[0] > 0 else 0) + by = int(fish.y) + fish.height // 2 + self._add(Entity( + frames=[['.'], ['o'], ['O'], ['O'], ['O']], + masks=None, + x=bx, y=by, + cb_args=[0, -1, 0, 0.1], + default_color='C', + die_offscreen=True, + )) + + # ── Fish ───────────────────────────────────────────────────────────────── + + def add_all_fish(self): + count = max(1, (self.rows - 9) * self.cols // 350) + for _ in range(count): + self.add_fish(None) + + def add_fish(self, dead): + # Each tuple: (shape_right, mask_right, shape_left, mask_left) + # Color mask digits: 1=body 2=dorsal 3=flippers 4=eye 5=mouth 6=tail 7=gills + fish_types = [ + (_p(r""" + \ + ...\..., +\ /' \ + >= ( ' > +/ \ / / + `"'"'/'' +"""), _p(""" + 2 + 1112111 +6 11 1 + 66 7 4 5 +6 1 3 1 + 11111311 +"""), _p(r""" + / + ,.../... + / '\ / +< ' ) =< + \ \ / \ + `'\'"'"' +"""), _p(""" + 2 + 1112111 + 1 11 6 +5 4 7 66 + 1 3 1 6 + 11311111 +""")), + (_p(r""" + \ +\ /--\ +>= (o> +/ \__/ + / +"""), _p(""" + 2 +6 1111 +66 745 +6 1111 + 3 +"""), _p(r""" + / + /--\ / +::::::::;;\\\ + ''\\\\\'' ';\ +"""), _p(""" + 222 + 1122211 666 + 4111111111666 +51111111111666 + 113333311 666 +""")), + (_p(""" + __ +><_'> + ' +"""), _p(""" + 11 +61145 + 3 +"""), _p(""" + __ +<'_>< + ` +"""), _p(""" + 11 +54116 + 3 +""")), + (_p(r""" + ..\, +>=' ('> + '''/'' +"""), _p(""" + 1121 +661 745 + 111311 +"""), _p(r""" + ,/.. +<') `=< + ``\``` +"""), _p(""" + 1211 +547 166 + 113111 +""")), + (_p(r""" + \ + / \ +>=_('> + \_/ + / +"""), _p(""" + 2 + 1 1 +661745 + 111 + 3 +"""), _p(r""" + / + / \ +<')_=< + \_/ + \ +"""), _p(""" + 2 + 1 1 +547166 + 111 + 3 +""")), + (_p(r""" + ,\ +>=('> + '/ +"""), _p(""" + 12 +66745 + 13 +"""), _p(r""" + /, +<')=< + \` +"""), _p(""" + 21 +54766 + 31 +""")), + (_p(r""" + __ +\/ o\ +/\__/ +"""), _p(""" + 11 +61 41 +61111 +"""), _p(r""" + __ +/o \/ +\__/\ +"""), _p(""" + 11 +14 16 +11116 +""")), + ] + + fish_num = random.randint(0, len(fish_types) - 1) + going_right = (fish_num % 2 == 0) + speed = random.uniform(0.25, 2.25) * (1 if going_right else -1) + + sr, mr, sl, ml = fish_types[fish_num] + if going_right: + shape = sr + mask = _rand_color(mr) + x = 1 - max(len(l) for l in shape) + else: + shape = sl + mask = _rand_color(ml) + x = self.cols - 2 + + height = len(shape) + y = random.randint(9, max(10, self.rows - height)) + + def on_death(fish, aq): + if random.random() < 0.03: + aq.add_bubble(fish) + aq.add_fish(fish) + + self._add(Entity( + frames=[shape], masks=[mask], + x=x, y=y, + cb_args=[speed, 0, 0], + default_color='c', + die_offscreen=True, + death_cb=on_death, + )) + + # ── Splat ──────────────────────────────────────────────────────────────── + + def add_splat(self, x: int, y: int): + self._add(Entity( + frames=[_p(""" + . + *** + ' +"""), _p(""" + ",*;` + "*,** + *"'~' +"""), _p(""" + , , + " ","' + *" *'" + " ; . +"""), _p(""" +* ' , ' ` +' ` * . ' + ' `' ",' +* ' " * . +" * ', ' +""")], + masks=None, + x=x - 4, y=y - 2, + cb_args=[0, 0, 0, 0.25], + default_color='R', + die_offscreen=False, + die_frame=15, + )) + + # ── Shark ──────────────────────────────────────────────────────────────── + + def add_shark(self, dead=None): + shape_r = _p(r""" + __ + ( `\ + ,??????????????????????????) `\ +;' `.????????????????????????( `\__ + ; `.?????????????__..---'' `~~~~-._ + `. `.____...--'' (b `--._ + > _.-' .(( ._ ) + .`.-`--...__ .-' -.___.....-(|/|/|/|/' + ;.'?????????`. ...----`.___.',,,_______......---' + '???????????'-' +""") + shape_l = _p(r""" + __ + /' ) + /' (??????????????????????????, + __/' )????????????????????????.' `; + _.-~~~~' ``---..__?????????????.' ; + _.--' b) ``--...____.' .' +( _. )). `-._ < + `\|\|\|\|)-.....___.- `-. __...--'-.'. + `---......_______,,,`.___.'----... .'?????????`.; + `-`???????????` +""") + mask_r = _p(""" + + + + cR + + cWWWWWWWW + + +""") + mask_l = _p(""" + + + + + Rc + + WWWWWWWWc + + +""") + going_right = random.random() < 0.5 + speed = 2.0 * (1 if going_right else -1) + x = -53 if going_right else self.cols - 2 + y = random.randint(9, max(10, self.rows - 19)) + + self._add(Entity( + frames=[shape_r if going_right else shape_l], + masks=[mask_r if going_right else mask_l], + x=x, y=y, + cb_args=[speed, 0, 0], + default_color='C', + die_offscreen=True, + death_cb=lambda e, aq: aq.random_object(e), + )) + + # ── Ship ───────────────────────────────────────────────────────────────── + + def add_ship(self, dead=None): + shape_r = _p(r""" + | | | + )_) )_) )_) + )___))___))___)\ + )____)____)_____)\\\ +_____|____|____|____\\\\__ +\ / +""") + shape_l = _p(r""" + | | | + (_( (_( (_( + /(___((___((___( + //(_____(____(____( +__///____|____|____|_____ + \ / +""") + mask_r = _p(""" + y y y + + w + ww +yyyyyyyyyyyyyyyyyyyywwwyy +y y +""") + mask_l = _p(""" + y y y + + w + ww +yywwwyyyyyyyyyyyyyyyyyyyy + y y +""") + going_right = random.random() < 0.5 + speed = 1.0 * (1 if going_right else -1) + x = -24 if going_right else self.cols - 2 + + self._add(Entity( + frames=[shape_r if going_right else shape_l], + masks=[mask_r if going_right else mask_l], + x=x, y=0, + cb_args=[speed, 0, 0], + default_color='W', + die_offscreen=True, + death_cb=lambda e, aq: aq.random_object(e), + )) + + # ── Whale ──────────────────────────────────────────────────────────────── + + def add_whale(self, dead=None): + body_r = """ + .-----: + .' `. +,????/ (o) \\ +\\`._/ ,__) +""" + body_l = """ + :-----. + .' `. + / (o) \\????, +(__, \\_.'/ +""" + mask_r_str = """ + C C + CCCCCCC + C C C + BBBBBBB + BB BB +B B BWB B +BBBBB BBBB +""" + mask_l_str = """ + C C + CCCCCCC + C C C + BBBBBBB + BB BB + B BWB B B +BBBB BBBBB +""" + spouts = [ + "\n\n :\n", + "\n\n :\n :\n", + "\n . .\n -:-\n :\n", + "\n . .\n .-:-.\n :\n", + "\n . .\n'.-:.`\n' : '\n", + "\n .- -.\n; : ;\n", + "\n\n; ;\n", + ] + + going_right = random.random() < 0.5 + speed = 1.0 * (1 if going_right else -1) + spout_align = 11 if not going_right else 1 + body_str = body_r if not going_right else body_l + mask_str = mask_r_str if not going_right else mask_l_str + x = -18 if going_right else self.cols - 2 + + frames, masks = [], [] + # 5 frames without spout + for _ in range(5): + frames.append(_p('\n\n\n' + body_str)) + masks.append(_p(mask_str)) + # 7 frames with animated spout + for spout in spouts: + sp_lines = spout.strip('\n').split('\n') + aligned = '\n'.join(' ' * spout_align + l for l in sp_lines) + frames.append(_p(aligned + '\n' + body_str)) + masks.append(_p(mask_str)) + + self._add(Entity( + frames=frames, masks=masks, + x=x, y=0, + cb_args=[speed, 0, 0, 1], + default_color='W', + die_offscreen=True, + death_cb=lambda e, aq: aq.random_object(e), + )) + + # ── Monster ────────────────────────────────────────────────────────────── + + def add_monster(self, dead=None): + monster_r = [_p(s) for s in [r""" + ____ + __??????????????????????????????????????????/ o \ + / \????????_?????????????????????_???????/ ____ > + _??????| __ |?????/ \????????_????????/ \????| | + | \?????| || |????| |?????/ \?????| |???| | +""", r""" + ____ + __?????????/ o \ + _?????????????????????_???????/ \?????/ ____ > + _???????/ \????????_????????/ \????| __ |???| | + | \?????| |?????/ \?????| |???| || |???| | +""", r""" + ____ + __????????????????????/ o \ + _??????????????????????_???????/ \????????_???????/ ____ > +| \??????????_????????/ \????| __ |?????/ \????| | + \ \???????/ \?????| |???| || |????| |???| | +""", r""" + ____ + __???????????????????????????????/ o \ + _??????????_???????/ \????????_??????????????????/ ____ > + | \???????/ \????| __ |?????/ \????????_??????| | + \ \?????| |???| || |????| |?????/ \????| | +"""]] + monster_l = [_p(s) for s in [r""" + ____ + / o \??????????????????????????????????????????__ +< ____ \???????_?????????????????????_????????/ \ + | |????/ \????????_????????/ \?????| __ |??????_ + | |???| |?????/ \?????| |????| || |?????/ | +""", r""" + ____ + / o \?????????__ +< ____ \?????/ \???????_?????????????????????_ + | |???| __ |????/ \????????_????????/ \???????_ + | |???| || |???| |?????/ \?????| |?????/ | +""", r""" + ____ + / o \????????????????????__ +< ____ \???????_????????/ \???????_??????????????????????_ + | |????/ \?????| __ |????/ \????????_??????????/ | + | |???| |????| || |???| |?????/ \???????/ / +""", r""" + ____ + / o \???????????????????????????????__ +< ____ \??????????????????_????????/ \???????_??????????_ + | |??????_????????/ \?????| __ |????/ \???????/ | + | |????/ \?????| |????| || |???| |?????/ / +"""]] + mask_r = _p(""" + + W + + + +""") + mask_l = _p(""" + + W + + + +""") + + going_right = random.random() < 0.5 + speed = 2.0 * (1 if going_right else -1) + if going_right: + x, shapes, mask = -64, monster_r, mask_r + else: + x, shapes, mask = self.cols - 2, monster_l, mask_l + + self._add(Entity( + frames=shapes, masks=[mask] * 4, + x=x, y=2, + cb_args=[speed, 0, 0, 0.25], + default_color='G', + die_offscreen=True, + death_cb=lambda e, aq: aq.random_object(e), + )) + + # ── Big fish ───────────────────────────────────────────────────────────── + + def add_big_fish(self, dead=None): + shape_r = _p(r""" + ______ +`""-. `````-----.....__ + `. . . `-. + : . . `. + , : . . _ : +: `. : (@) `._ + `. `..' . =`-. .__) + ; . = ~ : .-" + .' .'`. . . =.-' `._ .' +: .' : . .' + ' .' . . . .-' + .'____....----''.'=.' + "" .'.' + ''"'` +""") + shape_l = _p(r""" + ______ + __.....-----''''' .-""' + .-' . . .' + .' . . : + : _ . . : , + _.' (@) : .' : +(__. .-'= . `..' .' + "-. : ~ = . ; + `. _.' `-.= . . .'`. `. + `. . : `. : + `-. . . . `. ` + `.=`.``----....____`. + `.`. "" + '`"`` +""") + mask_r_t = _p(""" + 111111 +11111 11111111111111111 + 11 2 2 111 + 1 2 2 11 + 1 1 2 2 1 1 +1 11 1 1W1 111 + 11 1111 2 1111 1111 + 1 2 1 1 1 111 + 11 1111 2 2 1111 111 11 +1 11 1 2 11 + 1 11 2 2 2 111 + 111111111111111111111 + 11 1111 + 11111 +""") + mask_l_t = _p(""" + 111111 + 11111111111111111 11111 + 111 2 2 11 + 11 2 2 1 + 1 1 2 2 1 1 + 111 1W1 1 11 1 +1111 1111 2 1111 11 + 111 1 1 1 2 1 + 11 111 1111 2 2 1111 11 + 11 2 1 11 1 + 111 2 2 2 11 1 + 111111111111111111111 + 1111 11 + 11111 +""") + going_right = random.random() < 0.5 + speed = 3.0 * (1 if going_right else -1) + if going_right: + x = -34; shape = shape_r; mask = _rand_color(mask_r_t) + else: + x = self.cols - 1; shape = shape_l; mask = _rand_color(mask_l_t) + + y = random.randint(9, max(10, self.rows - 15)) + self._add(Entity( + frames=[shape], masks=[mask], + x=x, y=y, + cb_args=[speed, 0, 0], + default_color='Y', + die_offscreen=True, + death_cb=lambda e, aq: aq.random_object(e), + )) + + # ── Ducks ──────────────────────────────────────────────────────────────── + + def add_ducks(self, dead=None): + ducks_r = [_p(s) for s in [r""" + _??????????_??????????_ +,____(')=??,____(')=??,____(')< + \~~= ')???\~~= ')???\~~= ') +""", r""" + _??????????_??????????_ +,____(')=??,____(')(')____,??=(')____,??=(')____, + (` =~~/????(` =~~/????(` =~~/ +""", r""" + _??????????_??????????_ +=(')____,??>(')____,??=(')____, + (` =~~/????(` =~~/????(` =~~/ +""", r""" + _??????????_??????????_ +=(')____,??=(')____,??>(')____, + (` =~~/????(` =~~/????(` =~~/ +"""]] + mask_r = _p(""" + g g g +wwwwwgcgy wwwwwgcgy wwwwwgcgy + wwww Ww wwww Ww wwww Ww +""") + mask_l = _p(""" + g g g +ygcgwwwww ygcgwwwww ygcgwwwww + wW wwww wW wwww wW wwww +""") + going_right = random.random() < 0.5 + speed = 1.0 * (1 if going_right else -1) + if going_right: + x, shapes, mask = -30, ducks_r, mask_r + else: + x, shapes, mask = self.cols - 2, ducks_l, mask_l + + self._add(Entity( + frames=shapes, masks=[mask] * 3, + x=x, y=5, + cb_args=[speed, 0, 0, 0.25], + default_color='W', + die_offscreen=True, + death_cb=lambda e, aq: aq.random_object(e), + )) + + # ── Dolphins ───────────────────────────────────────────────────────────── + + def add_dolphins(self, dead=None): + dolp_r = [_p(s) for s in [r""" + , + __)\ +(\_.-' a`-. +(/~~````(/~^^` +""", r""" + , +(\__ __)\ +(/~.'' a`-. + ````\)~^^` +"""]] + dolp_l = [_p(s) for s in [r""" + , + _/(__ +.-'a `-._/) +'^^~\)''''~~\) +""", r""" + , + _/(__ __/) +.-'a ``.~\) +'^^~(/'''' +"""]] + mask_r = _p(""" + + + W +""") + mask_l = _p(""" + + + W +""") + going_right = random.random() < 0.5 + spd = 1.0 * (1 if going_right else -1) + dist = 15 * (1 if going_right else -1) + + up = [spd, -0.5, 0, 0.5] + down = [spd, 0.5, 0, 0.5] + glide = [spd, 0.0, 0, 0.5] + path = [up]*14 + [glide]*2 + [down]*14 + [glide]*6 + + shapes = dolp_r if going_right else dolp_l + mask = mask_r if going_right else mask_l + masks = [mask] * 2 + base_x = -13 if going_right else self.cols - 2 + + # Three dolphins offset in their path cycle (offsets 0, 12, 24) + start_ys = [8, 2, 5] + colors = ['b', 'B', 'C'] + for i in range(3): + is_lead = (i == 2) + self._add(Entity( + frames=shapes, masks=masks, + x=base_x - dist * (2 - i), + y=start_ys[i], + cb_args=[i * 12, list(path)], + default_color=colors[i], + die_offscreen=is_lead, + death_cb=(lambda e, aq: aq.random_object(e)) if is_lead else None, + )) + + # ── Swan ───────────────────────────────────────────────────────────────── + + def add_swan(self, dead=None): + swan_r = _p(r""" + ___ +,_ / _,\ +| \ \( \| +| \_ \\ +(_ \_) \ +(\_ ` \ + \ -=~ / +""") + swan_l = _p(r""" + ___ +/,_ \ _, +|/ )/ / | + // _/ | + / ( / _) +/ ` _/) +\ ~=- / +""") + mask_r = _p(""" + + g + yy +""") + mask_l = _p(""" + + g +yy +""") + going_right = random.random() < 0.5 + speed = 1.0 * (1 if going_right else -1) + if going_right: + x, shape, mask = -10, swan_r, mask_r + else: + x, shape, mask = self.cols - 2, swan_l, mask_l + + self._add(Entity( + frames=[shape], masks=[mask], + x=x, y=1, + cb_args=[speed, 0, 0, 0.25], + default_color='W', + die_offscreen=True, + death_cb=lambda e, aq: aq.random_object(e), + )) + + # ── Fishhook ───────────────────────────────────────────────────────────── + + def add_fishhook(self, dead=None): + hook_shape = _p(r""" + o + || + || +/ \ || + \__// + `--' +""") + x = random.randint(10, max(11, self.cols - 20)) + target_y = int(self.rows * 0.75) + + e = HookEntity( + frames=[hook_shape], masks=None, + x=x, y=-4, + cb_args=[0, 1, 0], + default_color='G', + die_offscreen=False, + target_y=target_y, + death_cb=lambda e, aq: aq.random_object(e), + ) + self._add(e) + + +# ─── Input handler ─────────────────────────────────────────────────────────── + +class Input: def __init__(self): - self._fd = sys.stdin.fileno() - self._old_settings = termios.tcgetattr(self._fd) + self._fd = sys.stdin.fileno() + self._old = termios.tcgetattr(self._fd) tty.setraw(self._fd) - import fcntl - import os flags = fcntl.fcntl(self._fd, fcntl.F_GETFL) fcntl.fcntl(self._fd, fcntl.F_SETFL, flags | os.O_NONBLOCK) - def read_key(self) -> Optional[str]: + def key(self) -> Optional[str]: try: - ch = os.read(self._fd, 1) - return ch.decode("utf-8", errors="ignore") - except BlockingIOError: + return os.read(self._fd, 1).decode('utf-8', errors='ignore') + except (BlockingIOError, OSError): return None def restore(self): - termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old_settings) + termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old) -# ────────────────────────────────────────────────────────────────────────────── -# Main loop -# ────────────────────────────────────────────────────────────────────────────── +# ─── Main loop ─────────────────────────────────────────────────────────────── def main(): - parser = argparse.ArgumentParser(description="asciiquarium-ng — animated aquarium with true RGB colors") - parser.add_argument("--fps", type=int, default=30, help="Target frames per second (default: 30)") - parser.add_argument("--no-ship", action="store_true", help="Disable the surface ship") - parser.add_argument("--no-shark", action="store_true", help="Disable the shark") - args = parser.parse_args() + rows, cols = term_size() + aq = Aquarium(rows, cols) + inp = Input() + paused = False + needs_reset = False - frame_time = 1.0 / args.fps - rows, cols = terminal_size() - - inp = InputHandler() - aquarium = Aquarium(rows, cols) - - if args.no_shark: - aquarium.next_shark = float("inf") - if args.no_ship: - aquarium.next_ship = float("inf") - - # SIGWINCH: terminal resize - def on_resize(signum, frame): - r, c = terminal_size() - aquarium.signal_resize(r, c) + def on_resize(sig, frame_): + nonlocal needs_reset + needs_reset = True signal.signal(signal.SIGWINCH, on_resize) - # Enter alternate screen, hide cursor - os.write(sys.stdout.fileno(), (ALT_SCREEN_ON + HIDE_CURSOR + CLEAR).encode()) + fd = sys.stdout.fileno() + os.write(fd, (ALT_ON + HIDE_CUR + "\033[2J\033[H").encode()) try: - last = time.monotonic() + # Match original: halfdelay(1) = 0.1s tick ≈ 10 fps + TICK = 0.1 while True: - now = time.monotonic() - dt = now - last - last = now + t0 = time.monotonic() - # Input - key = inp.read_key() - if key in ("q", "Q", "\x03", "\x1b"): + if needs_reset: + rows, cols = term_size() + aq.resize(rows, cols) + needs_reset = False + + key = inp.key() + if key in ('q', 'Q', '\x03', '\x1b'): break - elif key == "p": - # Pause: wait for another p or q - while True: - k2 = inp.read_key() - if k2 in ("p", "q", "Q", "\x03", "\x1b"): - if k2 in ("q", "Q", "\x03", "\x1b"): - raise KeyboardInterrupt - break - time.sleep(0.05) - last = time.monotonic() - continue + elif key == 'p': + paused = not paused + elif key in ('r', 'R'): + # Full redraw (like KEY_RESIZE in original) + rows, cols = term_size() + aq.resize(rows, cols) - aquarium.update(dt) - frame = aquarium.draw() - os.write(sys.stdout.fileno(), frame.encode()) + if not paused: + aq.step() + frame = aq.render() + os.write(fd, frame.encode()) - # Sleep to hit target fps - elapsed = time.monotonic() - now - sleep = frame_time - elapsed + elapsed = time.monotonic() - t0 + sleep = TICK - elapsed if sleep > 0: time.sleep(sleep) @@ -709,8 +1265,8 @@ def main(): pass finally: inp.restore() - os.write(sys.stdout.fileno(), (SHOW_CURSOR + ALT_SCREEN_OFF + RESET).encode()) + os.write(fd, (SHOW_CUR + ALT_OFF + RESET).encode()) -if __name__ == "__main__": +if __name__ == '__main__': main()