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()