#!/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 # shark eats fish with gore splats vegan: bool = False # disable 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 # ─── 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]): """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: # \033[2J clears the entire terminal (not just our canvas width), # preventing stale characters when the terminal is wider than self.cols # or after a resize. The full-canvas overwrite below keeps flicker minimal. parts = ["\033[2J\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) # ─── 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 = ''): 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 @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) class HookEntity(Entity): """Fishhook: descends to 75% depth, then retracts.""" 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 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) 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() 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.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): if self.cfg.bloody: self._check_predation() 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=i + 5, cb_args=[0, 0, 0], default_color='c', die_offscreen=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, )) # ── 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, )) # ── 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(8, (self.rows - 9) * self.cols // 200) 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(9, max(10, 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(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), 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=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 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 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 are the y positions at path offsets 0, 12, 24. # With amplitude ±7 and center y=10 the arc spans y=3 (above waves) # to y=10 (just below waterline), crossing the surface in both phases. # Cumulative dy: offset 0→0, offset 12→-6, offset 24→-3 # so start_ys = [10, 10-6, 10-3] = [10, 4, 7] start_ys = [10, 4, 7] 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) # ── 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(9, max(10, 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(9, max(10, 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(9, max(10, 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): sharks = [e for e in self.entities if e.entity_type == 'shark' and e.alive] fish = [e for e in self.entities if e.entity_type == 'fish' and e.alive] for s in sharks: sx1 = int(s.x); sx2 = sx1 + s.width sy1 = int(s.y); sy2 = sy1 + s.height for f in fish: fx = int(f.x) + f.width // 2 fy = int(f.y) + f.height // 2 if sx1 <= fx <= sx2 and sy1 <= fy <= sy2: self.add_splat(fx, fy) f.alive = False # ── 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), 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)') 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, ) rows, cols = term_size() aq = Aquarium(rows, cols, cfg) 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() os.write(fd, (ALT_ON + HIDE_CUR + "\033[2J\033[H").encode()) 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() os.write(fd, frame.encode()) elapsed = time.monotonic() - t0 sleep = TICK - elapsed if sleep > 0: time.sleep(sleep) except KeyboardInterrupt: pass finally: inp.restore() os.write(fd, (SHOW_CUR + ALT_OFF + RESET).encode()) if __name__ == '__main__': main()