asciiquarium/asciiquarium_ng.py
rene 1f053822ff Krabbe: ASCII-only, weiter sichtbar; Taucher (Scuba-Diver) neu
- Krabben-Zeichen von '°' (non-ASCII, wide-char-Problem) auf '.' umgestellt
- steps_out 14→22: Krabbe läuft weiter aus dem Schloss heraus
- add_diver(): Scuba-Taucher mit 2-Frame-Animation, Sauerstoffblasen beim Tod
2026-03-29 10:19:34 +02:00

1597 lines
46 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, 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:
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
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=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,
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"""
_??????????_??????????_
,____(')<??,____(')=??,____(')=
\~~= ')???\~~= ')???\~~= ')
"""]]
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)
# ── 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()