#!/usr/bin/env python3 """asciiquarium-ng: Python port of asciiquarium with true RGB colors. Requires no external dependencies — pure ANSI escape codes instead of Curses. """ import os, sys, tty, termios, fcntl, signal, time, random, shutil, argparse from dataclasses import dataclass from typing import Optional, Callable, List # ─── Configuration ─────────────────────────────────────────────────────────── @dataclass class Config: speed: float = 1.0 # global speed multiplier (TICK = 0.1 / speed) no_shark: bool = False no_ship: bool = False bloody: bool = False # massacre: all predators eat fish with blood splats vegan: bool = False # peaceful: no predators (shark, big_fish, fishhook) any_key: bool = False # any key exits # ─── 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 def _write_all(fd: int, data: bytes) -> None: """Write all bytes to fd, retrying on partial writes (non-blocking TTY).""" mv = memoryview(data) offset = 0 while offset < len(mv): try: n = os.write(fd, mv[offset:]) offset += n except BlockingIOError: time.sleep(0.001) # ─── 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 _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] # ─── Canvas ────────────────────────────────────────────────────────────────── class Canvas: def __init__(self, rows: int, cols: int): self.rows = rows self.cols = cols self._ch: List[List[str]] = [[' '] * cols for _ in range(rows)] self._ck: List[List[Optional[str]]] = [[None] * cols for _ in range(rows)] def resize(self, rows: int, cols: int): self.rows, self.cols = rows, cols self._ch = [[' '] * cols for _ in range(rows)] self._ck = [[None] * cols for _ in range(rows)] def clear(self): for r in range(self.rows): self._ch[r] = [' '] * self.cols self._ck[r] = [None] * self.cols def put(self, row: int, col: int, ch: str, ck: Optional[str]): if 0 <= row < self.rows and 0 <= col < self.cols: self._ch[row][col] = ch self._ck[row][col] = ck def put_sprite(self, y: int, x: int, shape: List[str], mask: Optional[List[str]], default_color: Optional[str], opaque: bool = False): """Draw a sprite. '?' is always transparent. ' ' is transparent when opaque=False (background decorations). When opaque=True: only INTERIOR spaces (between the first and last visible char of each line) are drawn as black background. Leading/trailing spaces stay transparent so shapes don't leave black blocks outside their visible outline. """ 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 '' if opaque: nsp = [i for i, c in enumerate(line) if c not in (' ', '?')] interior_start = nsp[0] if nsp else -1 interior_end = nsp[-1] if nsp else -1 for dc, ch in enumerate(line): if ch == '?': continue col = x + dc if 0 <= col < cols: if ch == ' ': if opaque and interior_start <= dc <= interior_end: self._ch[row][col] = ' ' self._ck[row][col] = None else: 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: # \033[H homes the cursor; every cell is rewritten so \033[2J is not # needed (it would push content into the scrollback on every frame). # # The last column of EVERY row is intentionally skipped (not just the # last row). Writing to the last column triggers "pending-wrap" state. # In iTerm2, cursor-position commands (_go) do NOT clear pending-wrap, # so the first character of the next row triggers a wrap, placing it one # row too low. This cascades: row r+1 lands at r+2, row r+2 at r+3, … # Content below the waterline shifts off-screen; skipped rows show stale # characters. Staying one column short prevents pending-wrap entirely. col_limit = self.cols - 1 parts = ["\033[H", RESET] sentinel = object() last_ck: object = sentinel for r in range(self.rows): parts.append(_go(r, 0)) for c in range(col_limit): ck = self._ck[r][c] if ck is not last_ck: parts.append(RESET if ck is None else _esc(ck)) last_ck = ck parts.append(self._ch[r][c]) # Erase the last column with the default background (no cursor # advance → no pending-wrap; handles content left by wider frames). parts.append(RESET + "\033[K") last_ck = sentinel parts.append(RESET) return ''.join(parts) # ─── 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: """Animated, moving sprite, faithful to Term::Animation::Entity semantics. 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 __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 = '', opaque: bool = True): 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.opaque = opaque 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 @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 self.x += dx self.y += dy if fadv: self.curr_frame = (self.curr_frame + fadv) % len(self.frames) def update(self, aq: 'Aquarium') -> bool: if not self.alive: return False 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): 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, self.opaque) class CrabEntity(Entity): """Crab that walks out from the castle gate and then returns.""" def __init__(self, *args, steps_out: int, **kwargs): super().__init__(*args, **kwargs) self._steps_out = steps_out self._step_count = 0 self._returning = False self._start_x = self.x self._orig_dx = self.cb_args[0] def _move(self): if not self._returning: self._step_count += 1 if self._step_count >= self._steps_out: self._returning = True self.cb_args[0] = -self._orig_dx else: dx = self.cb_args[0] if (dx > 0 and self.x >= self._start_x) or \ (dx < 0 and self.x <= self._start_x): self.alive = False return super()._move() # ─── Aquarium ──────────────────────────────────────────────────────────────── class Aquarium: def __init__(self, rows: int, cols: int, cfg: Optional['Config'] = None): self.rows = rows self.cols = cols self.cfg = cfg or Config() self.canvas = Canvas(rows, cols) # wl = first row of the wave area, proportional like Perl's int($lines/3). # wl+4 is the first fully-underwater row (4 wave rows below wl). self.wl = max(3, rows // 3 - 3) self.entities: List[Entity] = [] fns = [self.add_whale, self.add_monster, self.add_swan, self.add_ducks, self.add_dolphins, self.add_seahorse, self.add_squid, self.add_diver] if not self.cfg.no_ship: fns.append(self.add_ship) if not self.cfg.no_shark and not self.cfg.vegan: fns.append(self.add_shark) if not self.cfg.vegan: fns += [self.add_big_fish, self.add_fishhook] self._random_fns = fns # Crab scene state machine self._cs_state: int = 0 # 0=idle 1=opening 2=out 3=closing self._cs_next: float = time.time() + random.uniform(90, 150) self._cs_portcullis: Optional[Entity] = None self._setup() def _setup(self): self.entities.clear() self.add_environment() if self.rows >= 20: self.add_castle() self.add_all_seaweed() self.add_all_fish() self.random_object(None) def resize(self, rows: int, cols: int): self.rows, self.cols = rows, cols self.wl = max(3, rows // 3 - 3) self.canvas.resize(rows, cols) self._cs_state = 0 self._cs_portcullis = None self._setup() def _add(self, e: Entity): self.entities.append(e) def random_object(self, dead): random.choice(self._random_fns)(dead) def step(self): self._check_predation() self._check_fishing() self.entities = [e for e in self.entities if e.update(self)] self._update_crab_scene() def render(self) -> str: self.canvas.clear() for e in self.entities: e.draw(self.canvas) return self.canvas.render() # ── Environment (water lines) ───────────────────────────────────────────── 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=self.wl + i, cb_args=[0, 0, 0], default_color='c', die_offscreen=False, opaque=False, )) # ── 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, opaque=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=lambda e, aq: aq.add_seaweed(e), die_time=die_t, opaque=False, )) # ── 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): underwater_rows = max(1, self.rows - (self.wl + 4)) count = max(6, underwater_rows * self.cols // 320) 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_offscreen = 1 - max(len(l) for l in shape) else: shape = sl mask = _rand_color(ml) x_offscreen = self.cols - 2 height = len(shape) width = max(len(l) for l in shape) y = random.randint(self.wl + 4, max(self.wl + 5, self.rows - height)) # Initial population (dead=None): start already on-screen so the # aquarium is immediately populated. Respawned fish enter from edges. if dead is None: x = random.randint(0, max(0, self.cols - width)) else: x = x_offscreen 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, entity_type='fish', )) # ── 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(self.wl + 4, max(self.wl + 5, 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), entity_type='shark', )) # ── 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=self.wl - 5, 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 going_right else 1 body_str = body_r if going_right else body_l mask_str = mask_r_str if going_right else mask_l_str x = -18 if going_right else self.cols - 2 # 4 body lines; all frames are 8 rows: 3 spout + 1 separator + 4 body. # This keeps the body at the same rows (4-7) in every frame so the # whale doesn't jump, and the mask aligns correctly. body_lines = _p(body_str) mask_lines = _p(mask_str) frames, masks = [], [] # 5 frames without spout: 4 empty rows + 4 body rows for _ in range(5): frames.append(['', '', '', ''] + body_lines) masks.append(mask_lines) # 7 frames with animated spout: spout padded to 3 rows, then separator, then body for spout in spouts: sp_lines = [' ' * spout_align + l for l in spout.strip('\n').split('\n')] while len(sp_lines) < 3: sp_lines = [''] + sp_lines frames.append(sp_lines + [''] + body_lines) masks.append(mask_lines) self._add(Entity( frames=frames, masks=masks, x=x, y=self.wl - 7, 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=self.wl - 4, 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(self.wl + 4, max(self.wl + 5, 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), entity_type='big_fish', )) # ── 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=self.wl, 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). # Relative to wl so the arc crosses the surface at any terminal size. # Amplitude ±7: range [wl+5-7, wl+5] = [wl-2, wl+5] # → briefly above waves and ~2 rows underwater. # Cumulative dy at offsets 0/12/24: 0/-6/-3 start_ys = [self.wl + 5, self.wl - 1, self.wl + 2] 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=self.wl - 6, 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_part = _p(r""" o || || / \ || \__// `--' """) cord = ' |' # '|' at col 7, aligns with 'o' cord_mask = ' w' # cord is white; hook uses default_color ('y') x = random.randint(10, max(11, self.cols - 20)) start_d = self.wl max_d = int(self.rows * 0.75) descent = max(1, max_d - start_d) fadv = 0.3 # ~1 row per 3 ticks ≈ 0.3 s/row hook_mask_lines = [''] * len(hook_part) # hook chars → default_color frames, fmasks = [], [] for d in range(descent + 1): n = start_d + d frames.append([cord] * n + hook_part) fmasks.append([cord_mask] * n + hook_mask_lines) for d in range(descent - 1, -1, -1): n = start_d + d frames.append([cord] * n + hook_part) fmasks.append([cord_mask] * n + hook_mask_lines) e = Entity( frames=frames, masks=fmasks, x=x, y=0, cb_args=[0, 0, 0, fadv], default_color='y', # hook = yellow metal die_offscreen=False, die_frame=int(len(frames) / fadv), death_cb=lambda e, aq: aq.random_object(e), entity_type='fishhook', ) e._hook_descent = descent # needed for early retraction on catch self._add(e) # ── Seahorse (Seepferdchen) ─────────────────────────────────────────────── def add_seahorse(self, dead=None): frame0 = _p(r""" } {( |) | (| |) _|_ ( ) """) frame1 = _p(r""" } {( |) | (| |( _|_ ( ) """) mask0 = _p(""" y yy y g gy g ggg ygyyy """) mask1 = mask0 going_right = random.random() < 0.5 speed = random.uniform(0.3, 0.8) * (1 if going_right else -1) x = -5 if going_right else self.cols - 2 y = random.randint(self.wl + 4, max(self.wl + 5, self.rows - 10)) self._add(Entity( frames=[frame0, frame1], masks=[mask0, mask1], x=x, y=y, cb_args=[speed, 0, 0, 0.3], default_color='y', die_offscreen=True, death_cb=lambda e, aq: aq.random_object(e), )) # ── Squid / Octopus (Tintenfisch) ──────────────────────────────────────── def add_squid(self, dead=None): frame_r0 = _p(r""" __ /oo\ ( ) \ / /\/\ /\ /\ """) frame_r1 = _p(r""" __ /oo\ ( ) \ / /\/\ \/ \/ """) frame_l0 = _p(r""" __ /oo\ ( ) \ / /\/\ /\ /\ """) frame_l1 = frame_r1 mask_r = _p(""" mm mWWm mmmmmm mmmm mmmm mmmmmm """) going_right = random.random() < 0.5 speed = random.uniform(0.5, 1.5) * (1 if going_right else -1) x = -8 if going_right else self.cols - 2 y = random.randint(self.wl + 4, max(self.wl + 5, self.rows - 8)) frames = [frame_r0, frame_r1] if going_right else [frame_l0, frame_l1] self._add(Entity( frames=frames, masks=[mask_r, mask_r], x=x, y=y, cb_args=[speed, 0, 0, 0.3], default_color='m', die_offscreen=True, death_cb=lambda e, aq: aq.random_object(e), )) # ── Diver (Taucher) ────────────────────────────────────────────────────── def add_diver(self, dead=None): diver_r0 = _p(r""" O \-|> /\ """) diver_r1 = _p(r""" O /-|> \/ """) diver_l0 = _p(r""" O <|-/ /\ """) diver_l1 = _p(r""" O <|-\ \/ """) mask_r = _p(""" y wccy gg """) mask_l = _p(""" y yccw gg """) going_right = random.random() < 0.5 speed = random.uniform(0.5, 1.0) * (1 if going_right else -1) if going_right: x, frames, mask = -6, [diver_r0, diver_r1], mask_r else: x, frames, mask = self.cols - 2, [diver_l0, diver_l1], mask_l y = random.randint(self.wl + 4, max(self.wl + 5, self.rows - 5)) def on_death(e, aq): aq.add_bubble(e) aq.random_object(e) self._add(Entity( frames=frames, masks=[mask, mask], x=x, y=y, cb_args=[speed, 0, 0, 0.2], default_color='c', die_offscreen=True, death_cb=on_death, )) # ── Predation (--bloody) ───────────────────────────────────────────────── def _check_predation(self): # Shark always eats fish; in bloody mode big_fish joins the massacre. predator_types = {'shark'} if self.cfg.bloody: predator_types.add('big_fish') predators = [e for e in self.entities if e.entity_type in predator_types and e.alive] fish = [e for e in self.entities if e.entity_type == 'fish' and e.alive] for p in predators: px1 = int(p.x); px2 = px1 + p.width py1 = int(p.y); py2 = py1 + p.height for f in fish: fx = int(f.x) + f.width // 2 fy = int(f.y) + f.height // 2 if px1 <= fx <= px2 and py1 <= fy <= py2: if p.entity_type == 'shark' or self.cfg.bloody: self.add_splat(fx, fy) f.alive = False if f.death_cb: f.death_cb(f, self) def _check_fishing(self): """Fishhook catches fish; caught fish is dragged upward with the hook.""" HOOK_PART_HEIGHT = 6 hooks = [e for e in self.entities if e.entity_type == 'fishhook' and e.alive] fish = [e for e in self.entities if e.entity_type == 'fish' and e.alive] for h in hooks: fi = h.frame_idx cord_n = len(h.frames[fi]) - HOOK_PART_HEIGHT hook_row = int(h.y) + cord_n # row of 'o' hook_col = int(h.x) + 7 # 'o' at offset 7 for f in fish: fy1 = int(f.y); fy2 = fy1 + f.height fx1 = int(f.x); fx2 = fx1 + f.width if fy1 <= hook_row <= fy2 and fx1 <= hook_col <= fx2: f.alive = False if f.death_cb: f.death_cb(f, self) # Jump hook to retraction phase immediately descent = getattr(h, '_hook_descent', None) if descent is not None and int(h.curr_frame) <= descent: h.curr_frame = float(descent) # Inject fish line into all remaining hook frames so it # is dragged upward locked to the hook (no separate entity). fish_raw = (f.frames[f.frame_idx] or ['><>'])[0] or '><>' fish_stripped = fish_raw.strip() or '><>' # Centre the fish under the hook's 'o' (col offset 7 in the frame) pad = max(0, 7 - len(fish_stripped) // 2) fish_line = ' ' * pad + fish_stripped fi = int(h.curr_frame) for i in range(fi, len(h.frames)): h.frames[i].append(fish_line) if h.masks and i < len(h.masks) and h.masks[i] is not None: h.masks[i].append('') break # one fish per hook per tick # ── Crab scene (portcullis + crab) ──────────────────────────────────────── def _gate_pos(self): """Absolute (x, y) of the top-left of the portcullis opening.""" return self.cols - 32 + 12, self.rows - 13 + 10 def _portcullis_entity(self, opening: bool) -> Entity: gx, gy = self._gate_pos() f_closed = ['|_|_|_|', '|#|#|#|'] f_half = ['|_|_|_|', '| |'] f_open = [' ', ' '] if opening: frames = ([f_closed] * 3 + [f_half] * 3 + [f_open] * 2) else: frames = ([f_open] * 2 + [f_half] * 3 + [f_closed] * 3) return Entity( frames=frames, masks=None, x=gx, y=gy, cb_args=[0, 0, 0, 1], default_color='y', die_offscreen=False, die_frame=len(frames), ) def _update_crab_scene(self): st = self._cs_state if st == 0: # idle if time.time() >= self._cs_next: e = self._portcullis_entity(opening=True) self._cs_portcullis = e self._add(e) self._cs_state = 1 elif st == 1: # opening if self._cs_portcullis and not self._cs_portcullis.alive: gx, gy = self._gate_pos() # ASCII-only crab to avoid wide-char rendering issues crab_f0 = ['(._.)', '/| |\\'] crab_f1 = ['(._.)', '\\| |/'] mask = [' yWy ', ' '] c = CrabEntity( frames=[crab_f0, crab_f1], masks=[mask, mask], x=float(gx), y=float(gy + 1), cb_args=[-1, 0, 0, 0.25], default_color='y', die_offscreen=False, steps_out=22, # walk further so crab is clearly visible entity_type='crab', ) self._add(c) self._cs_portcullis = None self._cs_state = 2 elif st == 2: # crab out if not any(e.entity_type == 'crab' for e in self.entities): e = self._portcullis_entity(opening=False) self._cs_portcullis = e self._add(e) self._cs_state = 3 elif st == 3: # closing if self._cs_portcullis and not self._cs_portcullis.alive: self._cs_portcullis = None self._cs_state = 0 self._cs_next = time.time() + random.uniform(120, 240) # ─── Input handler ─────────────────────────────────────────────────────────── class Input: def __init__(self): self._fd = sys.stdin.fileno() self._old = termios.tcgetattr(self._fd) tty.setraw(self._fd) flags = fcntl.fcntl(self._fd, fcntl.F_GETFL) fcntl.fcntl(self._fd, fcntl.F_SETFL, flags | os.O_NONBLOCK) def key(self) -> Optional[str]: try: 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) # ─── Main loop ─────────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser( prog='asciiquarium', description='Aquarium animation in ASCII art (Python port)') parser.add_argument('--speed', type=float, default=1.0, metavar='N', help='global speed multiplier (default 1.0)') parser.add_argument('--no-shark', action='store_true', help='no sharks') parser.add_argument('--no-ship', action='store_true', help='no ships') parser.add_argument('--bloody', action='store_true', help='sharks eat fish with gory splats') parser.add_argument('--vegan', action='store_true', help='disable all predators (shark, big fish, fishhook)') parser.add_argument('--any-key', action='store_true', help='any key press exits (default: q/Q/Esc/Ctrl-C)') parser.add_argument('--debug', action='store_true', help='show terminal size / entity count overlay') parser.add_argument('--ruler', action='store_true', help='draw numbered row ruler and exit (diagnostic)') args = parser.parse_args() cfg = Config( speed = max(0.1, min(10.0, args.speed)), no_shark = args.no_shark, no_ship = args.no_ship, bloody = args.bloody, vegan = args.vegan, any_key = args.any_key, ) inp = Input() paused = False needs_reset = False def on_resize(sig, frame_): nonlocal needs_reset needs_reset = True signal.signal(signal.SIGWINCH, on_resize) fd = sys.stdout.fileno() # Activate alternate screen first, THEN read terminal size so the # size reflects the actual pane dimensions (important in split panes). # \033[r resets any inherited scroll region to the full screen. _write_all(fd, (ALT_ON + HIDE_CUR + "\033[r" + "\033[2J\033[H").encode()) rows, cols = term_size() if args.ruler: # Draw numbered ruler for every row to verify actual terminal dimensions. # The last visible row number tells us the real screen height. buf = ["\033[H"] for r in range(rows): label = f"row {r:3d} (1-idx={r+1}) cols={cols}" if r == rows - 1: label = f"\033[7m LAST: row {r} / rows={rows} cols={cols} -- press any key \033[m" buf.append(f"\033[{r+1};1H\033[0m{label}") _write_all(fd, ''.join(buf).encode()) try: while True: k = inp.key() if k is not None: break time.sleep(0.05) finally: inp.restore() _write_all(fd, (ALT_OFF + SHOW_CUR).encode()) return aq = Aquarium(rows, cols, cfg) try: TICK = 0.1 / cfg.speed while True: t0 = time.monotonic() if needs_reset: rows, cols = term_size() aq.resize(rows, cols) needs_reset = False key = inp.key() if key is not None: if cfg.any_key: break if key in ('q', 'Q', '\x03', '\x1b'): break elif key == 'p': paused = not paused elif key in ('r', 'R'): rows, cols = term_size() aq.resize(rows, cols) if not paused: aq.step() frame = aq.render() if args.debug: fish_ys = [f"{int(e.y)}" for e in aq.entities if e.entity_type == 'fish'][:8] dbg = (f" rows={aq.rows} cols={aq.cols} wl={aq.wl}" f" ents={len(aq.entities)}" f" fish_y=[{','.join(fish_ys)}] ") frame += f"\033[1;1H\033[0;7m{dbg}\033[m" _write_all(fd, frame.encode()) elapsed = time.monotonic() - t0 sleep = TICK - elapsed if sleep > 0: time.sleep(sleep) except KeyboardInterrupt: pass finally: inp.restore() _write_all(fd, (SHOW_CUR + ALT_OFF + RESET).encode()) if __name__ == '__main__': main()