asciiquarium/asciiquarium_ng.py
rene 9f3f754921 asciiquarium-ng: rewrite as faithful 1:1 Python port of Perl original
Same sprites, same colors, same entity timing as the Perl source.
Replaces Curses with direct ANSI truecolor escapes using the same
RGB values as our Homebrew fix (xterm-256 slots 16-231).
2026-03-28 20:43:18 +01:00

1272 lines
36 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 = 1 - max(len(l) for l in shape)
else:
shape = sl
mask = _rand_color(ml)
x = self.cols - 2
height = len(shape)
y = random.randint(9, max(10, self.rows - height))
def on_death(fish, aq):
if random.random() < 0.03:
aq.add_bubble(fish)
aq.add_fish(fish)
self._add(Entity(
frames=[shape], masks=[mask],
x=x, y=y,
cb_args=[speed, 0, 0],
default_color='c',
die_offscreen=True,
death_cb=on_death,
))
# ── Splat ────────────────────────────────────────────────────────────────
def add_splat(self, x: int, y: int):
self._add(Entity(
frames=[_p("""
.
***
'
"""), _p("""
",*;`
"*,**
*"'~'
"""), _p("""
, ,
" ","'
*" *'"
" ; .
"""), _p("""
* ' , ' `
' ` * . '
' `' ",'
* ' " * .
" * ', '
""")],
masks=None,
x=x - 4, y=y - 2,
cb_args=[0, 0, 0, 0.25],
default_color='R',
die_offscreen=False,
die_frame=15,
))
# ── Shark ────────────────────────────────────────────────────────────────
def add_shark(self, dead=None):
shape_r = _p(r"""
__
( `\
,??????????????????????????) `\
;' `.????????????????????????( `\__
; `.?????????????__..---'' `~~~~-._
`. `.____...--'' (b `--._
> _.-' .(( ._ )
.`.-`--...__ .-' -.___.....-(|/|/|/|/'
;.'?????????`. ...----`.___.',,,_______......---'
'???????????'-'
""")
shape_l = _p(r"""
__
/' )
/' (??????????????????????????,
__/' )????????????????????????.' `;
_.-~~~~' ``---..__?????????????.' ;
_.--' b) ``--...____.' .'
( _. )). `-._ <
`\|\|\|\|)-.....___.- `-. __...--'-.'.
`---......_______,,,`.___.'----... .'?????????`.;
`-`???????????`
""")
mask_r = _p("""
cR
cWWWWWWWW
""")
mask_l = _p("""
Rc
WWWWWWWWc
""")
going_right = random.random() < 0.5
speed = 2.0 * (1 if going_right else -1)
x = -53 if going_right else self.cols - 2
y = random.randint(9, max(10, self.rows - 19))
self._add(Entity(
frames=[shape_r if going_right else shape_l],
masks=[mask_r if going_right else mask_l],
x=x, y=y,
cb_args=[speed, 0, 0],
default_color='C',
die_offscreen=True,
death_cb=lambda e, aq: aq.random_object(e),
))
# ── Ship ─────────────────────────────────────────────────────────────────
def add_ship(self, dead=None):
shape_r = _p(r"""
| | |
)_) )_) )_)
)___))___))___)\
)____)____)_____)\\\
_____|____|____|____\\\\__
\ /
""")
shape_l = _p(r"""
| | |
(_( (_( (_(
/(___((___((___(
//(_____(____(____(
__///____|____|____|_____
\ /
""")
mask_r = _p("""
y y y
w
ww
yyyyyyyyyyyyyyyyyyyywwwyy
y y
""")
mask_l = _p("""
y y y
w
ww
yywwwyyyyyyyyyyyyyyyyyyyy
y y
""")
going_right = random.random() < 0.5
speed = 1.0 * (1 if going_right else -1)
x = -24 if going_right else self.cols - 2
self._add(Entity(
frames=[shape_r if going_right else shape_l],
masks=[mask_r if going_right else mask_l],
x=x, y=0,
cb_args=[speed, 0, 0],
default_color='W',
die_offscreen=True,
death_cb=lambda e, aq: aq.random_object(e),
))
# ── Whale ────────────────────────────────────────────────────────────────
def add_whale(self, dead=None):
body_r = """
.-----:
.' `.
,????/ (o) \\
\\`._/ ,__)
"""
body_l = """
:-----.
.' `.
/ (o) \\????,
(__, \\_.'/
"""
mask_r_str = """
C C
CCCCCCC
C C C
BBBBBBB
BB BB
B B BWB B
BBBBB BBBB
"""
mask_l_str = """
C C
CCCCCCC
C C C
BBBBBBB
BB BB
B BWB B B
BBBB BBBBB
"""
spouts = [
"\n\n :\n",
"\n\n :\n :\n",
"\n . .\n -:-\n :\n",
"\n . .\n .-:-.\n :\n",
"\n . .\n'.-:.`\n' : '\n",
"\n .- -.\n; : ;\n",
"\n\n; ;\n",
]
going_right = random.random() < 0.5
speed = 1.0 * (1 if going_right else -1)
spout_align = 11 if not going_right else 1
body_str = body_r if not going_right else body_l
mask_str = mask_r_str if not going_right else mask_l_str
x = -18 if going_right else self.cols - 2
frames, masks = [], []
# 5 frames without spout
for _ in range(5):
frames.append(_p('\n\n\n' + body_str))
masks.append(_p(mask_str))
# 7 frames with animated spout
for spout in spouts:
sp_lines = spout.strip('\n').split('\n')
aligned = '\n'.join(' ' * spout_align + l for l in sp_lines)
frames.append(_p(aligned + '\n' + body_str))
masks.append(_p(mask_str))
self._add(Entity(
frames=frames, masks=masks,
x=x, y=0,
cb_args=[speed, 0, 0, 1],
default_color='W',
die_offscreen=True,
death_cb=lambda e, aq: aq.random_object(e),
))
# ── Monster ──────────────────────────────────────────────────────────────
def add_monster(self, dead=None):
monster_r = [_p(s) for s in [r"""
____
__??????????????????????????????????????????/ o \
/ \????????_?????????????????????_???????/ ____ >
_??????| __ |?????/ \????????_????????/ \????| |
| \?????| || |????| |?????/ \?????| |???| |
""", r"""
____
__?????????/ o \
_?????????????????????_???????/ \?????/ ____ >
_???????/ \????????_????????/ \????| __ |???| |
| \?????| |?????/ \?????| |???| || |???| |
""", r"""
____
__????????????????????/ o \
_??????????????????????_???????/ \????????_???????/ ____ >
| \??????????_????????/ \????| __ |?????/ \????| |
\ \???????/ \?????| |???| || |????| |???| |
""", r"""
____
__???????????????????????????????/ o \
_??????????_???????/ \????????_??????????????????/ ____ >
| \???????/ \????| __ |?????/ \????????_??????| |
\ \?????| |???| || |????| |?????/ \????| |
"""]]
monster_l = [_p(s) for s in [r"""
____
/ o \??????????????????????????????????????????__
< ____ \???????_?????????????????????_????????/ \
| |????/ \????????_????????/ \?????| __ |??????_
| |???| |?????/ \?????| |????| || |?????/ |
""", r"""
____
/ o \?????????__
< ____ \?????/ \???????_?????????????????????_
| |???| __ |????/ \????????_????????/ \???????_
| |???| || |???| |?????/ \?????| |?????/ |
""", r"""
____
/ o \????????????????????__
< ____ \???????_????????/ \???????_??????????????????????_
| |????/ \?????| __ |????/ \????????_??????????/ |
| |???| |????| || |???| |?????/ \???????/ /
""", r"""
____
/ o \???????????????????????????????__
< ____ \??????????????????_????????/ \???????_??????????_
| |??????_????????/ \?????| __ |????/ \???????/ |
| |????/ \?????| |????| || |???| |?????/ /
"""]]
mask_r = _p("""
W
""")
mask_l = _p("""
W
""")
going_right = random.random() < 0.5
speed = 2.0 * (1 if going_right else -1)
if going_right:
x, shapes, mask = -64, monster_r, mask_r
else:
x, shapes, mask = self.cols - 2, monster_l, mask_l
self._add(Entity(
frames=shapes, masks=[mask] * 4,
x=x, y=2,
cb_args=[speed, 0, 0, 0.25],
default_color='G',
die_offscreen=True,
death_cb=lambda e, aq: aq.random_object(e),
))
# ── Big fish ─────────────────────────────────────────────────────────────
def add_big_fish(self, dead=None):
shape_r = _p(r"""
______
`""-. `````-----.....__
`. . . `-.
: . . `.
, : . . _ :
: `. : (@) `._
`. `..' . =`-. .__)
; . = ~ : .-"
.' .'`. . . =.-' `._ .'
: .' : . .'
' .' . . . .-'
.'____....----''.'=.'
"" .'.'
''"'`
""")
shape_l = _p(r"""
______
__.....-----''''' .-""'
.-' . . .'
.' . . :
: _ . . : ,
_.' (@) : .' :
(__. .-'= . `..' .'
"-. : ~ = . ;
`. _.' `-.= . . .'`. `.
`. . : `. :
`-. . . . `. `
`.=`.``----....____`.
`.`. ""
'`"``
""")
mask_r_t = _p("""
111111
11111 11111111111111111
11 2 2 111
1 2 2 11
1 1 2 2 1 1
1 11 1 1W1 111
11 1111 2 1111 1111
1 2 1 1 1 111
11 1111 2 2 1111 111 11
1 11 1 2 11
1 11 2 2 2 111
111111111111111111111
11 1111
11111
""")
mask_l_t = _p("""
111111
11111111111111111 11111
111 2 2 11
11 2 2 1
1 1 2 2 1 1
111 1W1 1 11 1
1111 1111 2 1111 11
111 1 1 1 2 1
11 111 1111 2 2 1111 11
11 2 1 11 1
111 2 2 2 11 1
111111111111111111111
1111 11
11111
""")
going_right = random.random() < 0.5
speed = 3.0 * (1 if going_right else -1)
if going_right:
x = -34; shape = shape_r; mask = _rand_color(mask_r_t)
else:
x = self.cols - 1; shape = shape_l; mask = _rand_color(mask_l_t)
y = random.randint(9, max(10, self.rows - 15))
self._add(Entity(
frames=[shape], masks=[mask],
x=x, y=y,
cb_args=[speed, 0, 0],
default_color='Y',
die_offscreen=True,
death_cb=lambda e, aq: aq.random_object(e),
))
# ── Ducks ────────────────────────────────────────────────────────────────
def add_ducks(self, dead=None):
ducks_r = [_p(s) for s in [r"""
_??????????_??????????_
,____(')=??,____(')=??,____(')<
\~~= ')???\~~= ')???\~~= ')
""", r"""
_??????????_??????????_
,____(')=??,____(')<??,____(')=
\~~= ')???\~~= ')???\~~= ')
""", r"""
_??????????_??????????_
,____(')<??,____(')=??,____(')=
\~~= ')???\~~= ')???\~~= ')
"""]]
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()