asciiquarium/asciiquarium_ng.py
rene 7319d26129 fix whale direction and populate fish on-screen at startup
Whale body/spout-alignment logic was inverted (not going_right → wrong
direction). Initial fish population now starts distributed on-screen
instead of all off-screen edges, so the aquarium is immediately filled.
2026-03-29 09:26:18 +02:00

1280 lines
37 KiB
Python
Executable file

#!/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
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
# ─── 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:
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)
# ─── 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
# ─── 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._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(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._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.entities = [e for e in self.entities if e.update(self)]
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=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"""
/
/--\ /
<o) =<
\__/ \
\
"""), _p("""
2
1111 6
547 66
1111 6
3
""")),
(_p(r"""
\:.
\;, ,;\\\,,
\\\;;:::::::o
///;;::::::::<
/;` ``/////``
"""), _p("""
222
666 1122211
6661111111114
66611111111115
666 113333311
"""), _p(r"""
.:/
,,///;, ,;/
o:::::::;;///
>::::::::;;\\\
''\\\\\'' ';\
"""), _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,
))
# ── 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 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"""
_??????????_??????????_
,____(')<??,____(')=??,____(')=
\~~= ')???\~~= ')???\~~= ')
"""]]
ducks_l = [_p(s) for s in [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 = 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():
rows, cols = term_size()
aq = Aquarium(rows, cols)
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:
# Match original: halfdelay(1) = 0.1s tick ≈ 10 fps
TICK = 0.1
while True:
t0 = time.monotonic()
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':
paused = not paused
elif key in ('r', 'R'):
# Full redraw (like KEY_RESIZE in original)
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()