asciiquarium-ng: initial Python implementation with true RGB colors

Single-file animation engine: Canvas with per-cell true-color, ocean
gradient background, waves, castle, seaweed, fish, shark, jellyfish,
ship, bubbles. No Curses dependency — pure ANSI escape codes.
This commit is contained in:
rene 2026-03-28 20:24:30 +01:00
commit cbfa1745b7

716
asciiquarium_ng.py Executable file
View file

@ -0,0 +1,716 @@
#!/usr/bin/env python3
"""asciiquarium-ng — modernized aquarium animation with true RGB colors."""
import argparse
import math
import os
import random
import signal
import sys
import time
import tty
import termios
import threading
from dataclasses import dataclass, field
from typing import Optional
# ──────────────────────────────────────────────────────────────────────────────
# Terminal helpers
# ──────────────────────────────────────────────────────────────────────────────
def truecolor(r: int, g: int, b: int, bg: bool = False) -> str:
"""Return ANSI true-color escape for fg (bg=False) or bg (bg=True)."""
layer = 48 if bg else 38
return f"\033[{layer};2;{r};{g};{b}m"
RESET = "\033[0m"
HIDE_CURSOR = "\033[?25l"
SHOW_CURSOR = "\033[?25h"
ALT_SCREEN_ON = "\033[?1049h"
ALT_SCREEN_OFF = "\033[?1049l"
CLEAR = "\033[2J\033[H"
def move(row: int, col: int) -> str:
return f"\033[{row+1};{col+1}H"
def terminal_size() -> tuple[int, int]:
import shutil
s = shutil.get_terminal_size()
return s.lines, s.columns
# ──────────────────────────────────────────────────────────────────────────────
# Color palette
# ──────────────────────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class RGB:
r: int
g: int
b: int
def blend(self, other: "RGB", t: float) -> "RGB":
return RGB(
int(self.r + (other.r - self.r) * t),
int(self.g + (other.g - self.g) * t),
int(self.b + (other.b - self.b) * t),
)
def fg(self) -> str:
return truecolor(self.r, self.g, self.b, bg=False)
def bg(self) -> str:
return truecolor(self.r, self.g, self.b, bg=True)
# Ocean gradient: deep midnight blue at bottom → teal at mid-depth → dark cyan at surface
OCEAN_TOP = RGB(0, 60, 80)
OCEAN_MID = RGB(0, 40, 70)
OCEAN_BOTTOM = RGB(0, 20, 50)
# Entity colors
C_WHITE = RGB(255, 255, 255)
C_CYAN = RGB(0, 220, 220)
C_GREEN = RGB(50, 220, 80)
C_YELLOW = RGB(240, 200, 0)
C_ORANGE = RGB(255, 140, 0)
C_RED = RGB(220, 50, 50)
C_MAGENTA = RGB(200, 80, 200)
C_BLUE = RGB(80, 140, 255)
C_GRAY = RGB(160, 160, 160)
C_DARK = RGB(60, 60, 80)
C_SAND = RGB(180, 160, 100)
C_BUBBLE = RGB(150, 200, 230)
C_WAVE = RGB(100, 180, 220)
C_SHIP = RGB(140, 120, 100)
C_SHARK = RGB(120, 130, 145)
# ──────────────────────────────────────────────────────────────────────────────
# Canvas: per-cell fg color + character, rendered in one write()
# ──────────────────────────────────────────────────────────────────────────────
@dataclass
class Cell:
ch: str = " "
fg: Optional[RGB] = None # None → no fg escape (use bg color as text color)
class Canvas:
def __init__(self, rows: int, cols: int):
self.rows = rows
self.cols = cols
self._cells: list[list[Cell]] = [[Cell() for _ in range(cols)] for _ in range(rows)]
self._ocean: list[RGB] = []
self._build_ocean()
def _build_ocean(self):
"""Precompute ocean background color per row."""
self._ocean = []
for r in range(self.rows):
t = r / max(self.rows - 1, 1)
if t < 0.5:
color = OCEAN_TOP.blend(OCEAN_MID, t * 2)
else:
color = OCEAN_MID.blend(OCEAN_BOTTOM, (t - 0.5) * 2)
self._ocean.append(color)
def resize(self, rows: int, cols: int):
self.rows = rows
self.cols = cols
self._cells = [[Cell() for _ in range(cols)] for _ in range(rows)]
self._build_ocean()
def clear(self):
for row in self._cells:
for cell in row:
cell.ch = " "
cell.fg = None
def put(self, row: int, col: int, ch: str, fg: Optional[RGB]):
if 0 <= row < self.rows and 0 <= col < self.cols:
self._cells[row][col].ch = ch
self._cells[row][col].fg = fg
def put_str(self, row: int, col: int, s: str, fg: Optional[RGB]):
for i, ch in enumerate(s):
self.put(row, col + i, ch, fg)
def render(self) -> str:
buf: list[str] = ["\033[H"] # move to top-left without clearing
last_fg: Optional[RGB] = None
last_bg: Optional[RGB] = None
for r, row in enumerate(self._cells):
ocean_bg = self._ocean[r]
for c, cell in enumerate(row):
# Background
if ocean_bg is not last_bg:
buf.append(ocean_bg.bg())
last_bg = ocean_bg
# Foreground
fg = cell.fg if cell.fg is not None else ocean_bg
if fg is not last_fg:
buf.append(fg.fg())
last_fg = fg
buf.append(cell.ch)
# End of row — no newline needed (cursor wraps), but reset bg to avoid
# terminal smearing the last cell's bg color into line padding
if r < self.rows - 1:
buf.append(RESET)
last_fg = None
last_bg = None
buf.append(RESET)
return "".join(buf)
# ──────────────────────────────────────────────────────────────────────────────
# Entity base class
# ──────────────────────────────────────────────────────────────────────────────
class Entity:
def __init__(self, row: int, col: int, speed: float = 1.0):
self.row = row
self.col = col # float for sub-pixel movement
self.speed = speed # cols per second (negative = left)
self.alive = True
def update(self, dt: float, rows: int, cols: int):
"""Advance physics. Set self.alive = False to remove."""
self.col += self.speed * dt
def draw(self, canvas: Canvas):
pass
def _offscreen(self, cols: int, width: int) -> bool:
c = int(self.col)
return c + width < 0 or c >= cols
# ──────────────────────────────────────────────────────────────────────────────
# Small fish ><> or <><
# ──────────────────────────────────────────────────────────────────────────────
SMALL_FISH_R = ["><>", "><°>", ">°<>"] # swimming right
SMALL_FISH_L = ["<><", "<°><", "<>°<"] # swimming left
FISH_COLORS = [C_CYAN, C_YELLOW, C_ORANGE, C_GREEN, C_MAGENTA, C_BLUE, C_RED]
class SmallFish(Entity):
def __init__(self, row: int, col: int, speed: float, color: RGB):
super().__init__(row, col, speed)
self.color = color
forms = SMALL_FISH_R if speed > 0 else SMALL_FISH_L
self.body = random.choice(forms)
def update(self, dt: float, rows: int, cols: int):
super().update(dt, rows, cols)
if self._offscreen(cols, len(self.body)):
self.alive = False
def draw(self, canvas: Canvas):
canvas.put_str(int(self.row), int(self.col), self.body, self.color)
# ──────────────────────────────────────────────────────────────────────────────
# Big fish >==[}> or <{[==<
# ──────────────────────────────────────────────────────────────────────────────
BIG_FISH_R = [
[" __ ", ">==[}> ", " ~~ "],
]
BIG_FISH_L = [
[" __ ", " <{[==<", " ~~ "],
]
class BigFish(Entity):
def __init__(self, row: int, col: int, speed: float, color: RGB):
super().__init__(row, col, speed)
self.color = color
self.sprite = random.choice(BIG_FISH_R if speed > 0 else BIG_FISH_L)
def update(self, dt: float, rows: int, cols: int):
super().update(dt, rows, cols)
w = max(len(l) for l in self.sprite)
if self._offscreen(cols, w):
self.alive = False
def draw(self, canvas: Canvas):
for dr, line in enumerate(self.sprite):
canvas.put_str(int(self.row) + dr - 1, int(self.col), line, self.color)
# ──────────────────────────────────────────────────────────────────────────────
# Shark
# ──────────────────────────────────────────────────────────────────────────────
SHARK_R = [
" __ ",
" =-_ =-_ =-_ ==>| ,----( o>",
" ~~-~ ",
]
SHARK_L = [
" __ ",
"<o )----, |<== _=- _=- _=- ",
" ~-~~ ",
]
class Shark(Entity):
def __init__(self, row: int, col: int, speed: float):
super().__init__(row, col, speed)
self.sprite = SHARK_R if speed > 0 else SHARK_L
def update(self, dt: float, rows: int, cols: int):
super().update(dt, rows, cols)
w = max(len(l) for l in self.sprite)
if self._offscreen(cols, w):
self.alive = False
def draw(self, canvas: Canvas):
for dr, line in enumerate(self.sprite):
canvas.put_str(int(self.row) + dr - 1, int(self.col), line, C_SHARK)
# ──────────────────────────────────────────────────────────────────────────────
# Jellyfish
# ──────────────────────────────────────────────────────────────────────────────
JELLY_BODY = [" ,., ", "( o )", "|'|'|", "| | |"]
JELLY_ALT = [" ,., ", "( o )", "| | |", "|'|'|"]
class Jellyfish(Entity):
def __init__(self, row: int, col: int, color: RGB):
# Jellyfish drift slowly horizontally and bob vertically
speed = random.uniform(-0.3, 0.3)
super().__init__(row, col, speed)
self.color = color
self.base_row = float(row)
self.bob_phase = random.uniform(0, math.tau)
self.bob_amp = random.uniform(0.5, 1.5)
self.bob_freq = random.uniform(0.3, 0.7)
self.t = 0.0
self.frame = 0
self.frame_timer = 0.0
def update(self, dt: float, rows: int, cols: int):
self.t += dt
self.col += self.speed * dt
self.row = self.base_row + math.sin(self.bob_phase + self.t * self.bob_freq * math.tau) * self.bob_amp
self.frame_timer += dt
if self.frame_timer > 0.5:
self.frame = 1 - self.frame
self.frame_timer = 0.0
if self._offscreen(cols, 5):
self.alive = False
def draw(self, canvas: Canvas):
sprite = JELLY_BODY if self.frame == 0 else JELLY_ALT
for dr, line in enumerate(sprite):
canvas.put_str(int(self.row) + dr, int(self.col), line, self.color)
# ──────────────────────────────────────────────────────────────────────────────
# Bubble
# ──────────────────────────────────────────────────────────────────────────────
class Bubble(Entity):
def __init__(self, row: int, col: int):
super().__init__(row, float(col), speed=0)
self.float_row = float(row)
self.float_speed = random.uniform(0.8, 2.0) # rows per second upward
self.wobble_phase = random.uniform(0, math.tau)
self.wobble_amp = random.uniform(0.3, 0.8)
self.t = 0.0
def update(self, dt: float, rows: int, cols: int):
self.t += dt
self.float_row -= self.float_speed * dt
self.col = self.col + math.sin(self.wobble_phase + self.t * 1.5) * self.wobble_amp * dt
self.row = self.float_row
if self.float_row < 1:
self.alive = False
def draw(self, canvas: Canvas):
canvas.put(int(self.row), int(self.col), "o", C_BUBBLE)
# ──────────────────────────────────────────────────────────────────────────────
# Seaweed
# ──────────────────────────────────────────────────────────────────────────────
class Seaweed(Entity):
"""Stationary swaying seaweed at the bottom."""
def __init__(self, row: int, col: int, height: int):
super().__init__(row, col, speed=0)
self.height = height
self.phase = random.uniform(0, math.tau)
self.t = 0.0
def update(self, dt: float, rows: int, cols: int):
self.t += dt
def draw(self, canvas: Canvas):
for i in range(self.height):
r = int(self.row) - i
offset = int(math.sin(self.phase + self.t * 1.2 + i * 0.4) * 1)
ch = "(" if (i + int(self.t * 2)) % 2 == 0 else ")"
canvas.put(r, int(self.col) + offset, ch, C_GREEN)
# ──────────────────────────────────────────────────────────────────────────────
# Ship (on surface)
# ──────────────────────────────────────────────────────────────────────────────
SHIP_R = [
" | ",
" | ",
" __|__ ",
" / \\ ",
"/ * * \\",
"~~~~~~~~~~",
]
SHIP_L = [
" | ",
" | ",
" __|__ ",
" / \\ ",
"/ * * \\",
"~~~~~~~~~~",
]
class Ship(Entity):
def __init__(self, col: int, speed: float):
super().__init__(0, col, speed) # row adjusted in draw
self.sprite = SHIP_R if speed > 0 else SHIP_L
def update(self, dt: float, rows: int, cols: int):
super().update(dt, rows, cols)
self.row = 0 # always at surface
w = max(len(l) for l in self.sprite)
if self._offscreen(cols, w):
self.alive = False
def draw(self, canvas: Canvas):
for dr, line in enumerate(self.sprite):
canvas.put_str(dr, int(self.col), line, C_SHIP)
# ──────────────────────────────────────────────────────────────────────────────
# Waves
# ──────────────────────────────────────────────────────────────────────────────
class Waves:
"""Animated wave band at the top of the screen."""
def __init__(self):
self.t = 0.0
self.offset = 0.0
def update(self, dt: float):
self.t += dt
self.offset += dt * 4.0
def draw(self, canvas: Canvas):
cols = canvas.cols
# Two rows of waves
for c in range(cols):
phase = (c + self.offset) * 0.3
# Row 0: crests
ch = "~" if math.sin(phase) > 0 else " "
canvas.put(0, c, ch, C_WAVE)
# Row 1: troughs
ch2 = "~" if math.sin(phase + 1.0) > -0.3 else " "
canvas.put(1, c, ch2, C_WAVE.blend(C_BLUE, 0.3))
# ──────────────────────────────────────────────────────────────────────────────
# Castle (static decoration)
# ──────────────────────────────────────────────────────────────────────────────
CASTLE = [
" T~~",
" | ",
" /|\\\\",
" T~~ /_|_\\\\",
" | | |",
" /|\\\\ | |",
" /_|_\\\\ | |",
" | | | |",
"===|===|===|===|===",
]
class Castle:
def __init__(self, row: int, col: int):
self.row = row
self.col = col
def draw(self, canvas: Canvas):
for dr, line in enumerate(CASTLE):
canvas.put_str(self.row + dr, self.col, line, C_SAND)
# ──────────────────────────────────────────────────────────────────────────────
# Aquarium orchestrator
# ──────────────────────────────────────────────────────────────────────────────
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.waves = Waves()
self.castle: Optional[Castle] = None
self.seaweeds: list[Seaweed] = []
self.t = 0.0
self.next_fish = 0.0
self.next_shark = random.uniform(30, 60)
self.next_jelly = random.uniform(5, 15)
self.next_ship = random.uniform(20, 45)
self._setup_static()
self._initial_population()
self._resize_lock = threading.Lock()
self._needs_resize = False
self._new_size: tuple[int, int] = (rows, cols)
def _setup_static(self):
# Castle at bottom-right
castle_row = self.rows - len(CASTLE)
castle_col = max(0, self.cols - 22)
self.castle = Castle(castle_row, castle_col)
# Seaweeds scattered at bottom
self.seaweeds = []
n_weeds = max(3, self.cols // 15)
used_cols = set()
# Avoid castle area
castle_zone = range(castle_col - 2, castle_col + 22)
for _ in range(n_weeds):
for _ in range(20):
c = random.randint(2, self.cols - 4)
if c not in used_cols and c not in castle_zone:
used_cols.add(c)
h = random.randint(3, min(7, self.rows // 4))
self.seaweeds.append(Seaweed(self.rows - 2, c, h))
break
def _initial_population(self):
"""Spawn a few fish to start."""
for _ in range(4):
self._spawn_fish()
for _ in range(2):
self._spawn_bubble_cluster()
def _fish_row(self) -> int:
return random.randint(3, self.rows - 5)
def _spawn_fish(self):
going_right = random.random() < 0.5
speed = random.uniform(4, 10) * (1 if going_right else -1)
col = -10 if going_right else self.cols + 10
color = random.choice(FISH_COLORS)
if random.random() < 0.3:
self.entities.append(BigFish(self._fish_row(), col, speed * 0.6, color))
else:
self.entities.append(SmallFish(self._fish_row(), col, speed, color))
def _spawn_bubble_cluster(self):
col = random.randint(2, self.cols - 3)
base_row = random.randint(self.rows // 2, self.rows - 3)
for _ in range(random.randint(1, 3)):
self.entities.append(Bubble(base_row + random.randint(-1, 1),
col + random.randint(-1, 1)))
def _spawn_shark(self):
going_right = random.random() < 0.5
speed = random.uniform(5, 8) * (1 if going_right else -1)
col = -40 if going_right else self.cols + 40
self.entities.append(Shark(self._fish_row(), col, speed))
def _spawn_jellyfish(self):
col = random.randint(0, self.cols - 6)
row = random.randint(3, self.rows - 8)
color = random.choice([C_CYAN, C_MAGENTA, C_BLUE, C_YELLOW])
self.entities.append(Jellyfish(row, col, color))
def _spawn_ship(self):
going_right = random.random() < 0.5
speed = random.uniform(2, 5) * (1 if going_right else -1)
col = -12 if going_right else self.cols + 12
self.entities.append(Ship(col, speed))
def signal_resize(self, rows: int, cols: int):
with self._resize_lock:
self._needs_resize = True
self._new_size = (rows, cols)
def _apply_resize(self):
rows, cols = self._new_size
self.rows = rows
self.cols = cols
self.canvas.resize(rows, cols)
self._setup_static()
self._needs_resize = False
def update(self, dt: float):
with self._resize_lock:
if self._needs_resize:
self._apply_resize()
self.t += dt
self.waves.update(dt)
# Spawn timers
self.next_fish -= dt
if self.next_fish <= 0:
self._spawn_fish()
self.next_fish = random.uniform(3, 8)
self.next_shark -= dt
if self.next_shark <= 0:
self._spawn_shark()
self.next_shark = random.uniform(25, 55)
self.next_jelly -= dt
if self.next_jelly <= 0:
self._spawn_jellyfish()
self.next_jelly = random.uniform(8, 20)
self.next_ship -= dt
if self.next_ship <= 0:
self._spawn_ship()
self.next_ship = random.uniform(20, 45)
# Occasional bubble clusters
if random.random() < dt * 0.5:
self._spawn_bubble_cluster()
# Update entities
for e in self.entities:
e.update(dt, self.rows, self.cols)
self.entities = [e for e in self.entities if e.alive]
for w in self.seaweeds:
w.update(dt, self.rows, self.cols)
def draw(self) -> str:
self.canvas.clear()
self.waves.draw(self.canvas)
if self.castle:
self.castle.draw(self.canvas)
for w in self.seaweeds:
w.draw(self.canvas)
for e in self.entities:
e.draw(self.canvas)
return self.canvas.render()
# ──────────────────────────────────────────────────────────────────────────────
# Input handling (non-blocking, raw mode)
# ──────────────────────────────────────────────────────────────────────────────
class InputHandler:
def __init__(self):
self._fd = sys.stdin.fileno()
self._old_settings = termios.tcgetattr(self._fd)
tty.setraw(self._fd)
import fcntl
import os
flags = fcntl.fcntl(self._fd, fcntl.F_GETFL)
fcntl.fcntl(self._fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
def read_key(self) -> Optional[str]:
try:
ch = os.read(self._fd, 1)
return ch.decode("utf-8", errors="ignore")
except BlockingIOError:
return None
def restore(self):
termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old_settings)
# ──────────────────────────────────────────────────────────────────────────────
# Main loop
# ──────────────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="asciiquarium-ng — animated aquarium with true RGB colors")
parser.add_argument("--fps", type=int, default=30, help="Target frames per second (default: 30)")
parser.add_argument("--no-ship", action="store_true", help="Disable the surface ship")
parser.add_argument("--no-shark", action="store_true", help="Disable the shark")
args = parser.parse_args()
frame_time = 1.0 / args.fps
rows, cols = terminal_size()
inp = InputHandler()
aquarium = Aquarium(rows, cols)
if args.no_shark:
aquarium.next_shark = float("inf")
if args.no_ship:
aquarium.next_ship = float("inf")
# SIGWINCH: terminal resize
def on_resize(signum, frame):
r, c = terminal_size()
aquarium.signal_resize(r, c)
signal.signal(signal.SIGWINCH, on_resize)
# Enter alternate screen, hide cursor
os.write(sys.stdout.fileno(), (ALT_SCREEN_ON + HIDE_CURSOR + CLEAR).encode())
try:
last = time.monotonic()
while True:
now = time.monotonic()
dt = now - last
last = now
# Input
key = inp.read_key()
if key in ("q", "Q", "\x03", "\x1b"):
break
elif key == "p":
# Pause: wait for another p or q
while True:
k2 = inp.read_key()
if k2 in ("p", "q", "Q", "\x03", "\x1b"):
if k2 in ("q", "Q", "\x03", "\x1b"):
raise KeyboardInterrupt
break
time.sleep(0.05)
last = time.monotonic()
continue
aquarium.update(dt)
frame = aquarium.draw()
os.write(sys.stdout.fileno(), frame.encode())
# Sleep to hit target fps
elapsed = time.monotonic() - now
sleep = frame_time - elapsed
if sleep > 0:
time.sleep(sleep)
except KeyboardInterrupt:
pass
finally:
inp.restore()
os.write(sys.stdout.fileno(), (SHOW_CURSOR + ALT_SCREEN_OFF + RESET).encode())
if __name__ == "__main__":
main()