diff --git a/asciiquarium_ng.py b/asciiquarium_ng.py index 47efc8e..7391e7d 100755 --- a/asciiquarium_ng.py +++ b/asciiquarium_ng.py @@ -15,8 +15,8 @@ class Config: speed: float = 1.0 # global speed multiplier (TICK = 0.1 / speed) no_shark: bool = False no_ship: bool = False - bloody: bool = False # massacre: all predators eat fish with blood splats - vegan: bool = False # peaceful: no predators (shark, big_fish, fishhook) + 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 ──────────────────────────────────────────────────────── @@ -32,17 +32,6 @@ 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 -def _write_all(fd: int, data: bytes) -> None: - """Write all bytes to fd, retrying on partial writes (non-blocking TTY).""" - mv = memoryview(data) - offset = 0 - while offset < len(mv): - try: - n = os.write(fd, mv[offset:]) - offset += n - except BlockingIOError: - time.sleep(0.001) - # ─── Color palette ─────────────────────────────────────────────────────────── # RGB values matching our Homebrew init_pair fix (xterm-256 slots 16-231): @@ -111,68 +100,43 @@ class Canvas: self._ck[row][col] = ck def put_sprite(self, y: int, x: int, shape: List[str], - mask: Optional[List[str]], default_color: Optional[str], - opaque: bool = False): - """Draw a sprite. - '?' is always transparent. - ' ' is transparent when opaque=False (background decorations). - When opaque=True: only INTERIOR spaces (between the first and last - visible char of each line) are drawn as black background. - Leading/trailing spaces stay transparent so shapes don't leave - black blocks outside their visible outline. - """ + 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 '' - if opaque: - nsp = [i for i, c in enumerate(line) if c not in (' ', '?')] - interior_start = nsp[0] if nsp else -1 - interior_end = nsp[-1] if nsp else -1 for dc, ch in enumerate(line): - if ch == '?': + if ch in (' ', '?'): continue col = x + dc if 0 <= col < cols: - if ch == ' ': - if opaque and interior_start <= dc <= interior_end: - self._ch[row][col] = ' ' - self._ck[row][col] = None - else: - 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 + mk = mline[dc] if dc < len(mline) else ' ' + ck = mk if mk in _C else default_color + self._ch[row][col] = ch + self._ck[row][col] = ck def render(self) -> str: - # \033[H homes the cursor; every cell is rewritten so \033[2J is not - # needed (it would push content into the scrollback on every frame). - # - # The last column of EVERY row is intentionally skipped (not just the - # last row). Writing to the last column triggers "pending-wrap" state. - # In iTerm2, cursor-position commands (_go) do NOT clear pending-wrap, - # so the first character of the next row triggers a wrap, placing it one - # row too low. This cascades: row r+1 lands at r+2, row r+2 at r+3, … - # Content below the waterline shifts off-screen; skipped rows show stale - # characters. Staying one column short prevents pending-wrap entirely. - col_limit = self.cols - 1 + # \033[H homes the cursor; every cell is rewritten below so \033[2J is + # not needed and would push content into the scrollback on every frame. + # The very last cell (bottom-right corner) is intentionally skipped: + # writing to it would trigger auto-wrap and scroll the terminal. + last_row = self.rows - 1 + last_col = self.cols - 1 parts = ["\033[H", RESET] sentinel = object() last_ck: object = sentinel for r in range(self.rows): parts.append(_go(r, 0)) + col_limit = last_col if r == last_row else self.cols for c in range(col_limit): 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]) - # Erase the last column with the default background (no cursor - # advance → no pending-wrap; handles content left by wider frames). - parts.append(RESET + "\033[K") - last_ck = sentinel parts.append(RESET) return ''.join(parts) @@ -210,8 +174,7 @@ class Entity: death_cb: Optional[Callable] = None, die_time: Optional[float] = None, die_frame: Optional[int] = None, - entity_type: str = '', - opaque: bool = True): + entity_type: str = ''): self.frames = frames self.masks = masks self.x = float(x) @@ -223,7 +186,6 @@ class Entity: self.die_time = die_time self._die_frame = die_frame self.entity_type = entity_type - self.opaque = opaque self.curr_frame = 0.0 self.alive = True self.height = len(frames[0]) if frames else 0 @@ -283,10 +245,29 @@ class Entity: 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, - self.opaque) + 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.""" @@ -325,7 +306,7 @@ class Aquarium: self.canvas = Canvas(rows, cols) # wl = first row of the wave area, proportional like Perl's int($lines/3). # wl+4 is the first fully-underwater row (4 wave rows below wl). - self.wl = max(3, rows // 3 - 3) + self.wl = max(3, rows // 3) self.entities: List[Entity] = [] fns = [self.add_whale, self.add_monster, self.add_swan, @@ -357,7 +338,7 @@ class Aquarium: def resize(self, rows: int, cols: int): self.rows, self.cols = rows, cols - self.wl = max(3, rows // 3 - 3) + self.wl = max(3, rows // 3) self.canvas.resize(rows, cols) self._cs_state = 0 self._cs_portcullis = None @@ -370,8 +351,8 @@ class Aquarium: random.choice(self._random_fns)(dead) def step(self): - self._check_predation() - self._check_fishing() + if self.cfg.bloody: + self._check_predation() self.entities = [e for e in self.entities if e.update(self)] self._update_crab_scene() @@ -399,7 +380,6 @@ class Aquarium: cb_args=[0, 0, 0], default_color='c', die_offscreen=False, - opaque=False, )) # ── Castle ─────────────────────────────────────────────────────────────── @@ -441,7 +421,6 @@ class Aquarium: cb_args=[0, 0, 0], default_color='k', die_offscreen=False, - opaque=False, )) # ── Seaweed ────────────────────────────────────────────────────────────── @@ -475,7 +454,6 @@ class Aquarium: die_offscreen=False, death_cb=lambda e, aq: aq.add_seaweed(e), die_time=die_t, - opaque=False, )) # ── Bubbles ────────────────────────────────────────────────────────────── @@ -495,8 +473,7 @@ class Aquarium: # ── Fish ───────────────────────────────────────────────────────────────── def add_all_fish(self): - underwater_rows = max(1, self.rows - (self.wl + 4)) - count = max(6, underwater_rows * self.cols // 320) + count = max(8, (self.rows - 9) * self.cols // 200) for _ in range(count): self.add_fish(None) @@ -856,7 +833,7 @@ yywwwyyyyyyyyyyyyyyyyyyyy self._add(Entity( frames=[shape_r if going_right else shape_l], masks=[mask_r if going_right else mask_l], - x=x, y=self.wl - 5, + x=x, y=0, cb_args=[speed, 0, 0], default_color='W', die_offscreen=True, @@ -882,7 +859,6 @@ yywwwyyyyyyyyyyyyyyyyyyyy C C CCCCCCC C C C - BBBBBBB BB BB B B BWB B @@ -892,7 +868,6 @@ BBBBB BBBB C C CCCCCCC C C C - BBBBBBB BB BB B BWB B B @@ -915,28 +890,21 @@ BBBB BBBBB mask_str = mask_r_str if going_right else mask_l_str x = -18 if going_right else self.cols - 2 - # 4 body lines; all frames are 8 rows: 3 spout + 1 separator + 4 body. - # This keeps the body at the same rows (4-7) in every frame so the - # whale doesn't jump, and the mask aligns correctly. - body_lines = _p(body_str) - mask_lines = _p(mask_str) - frames, masks = [], [] - # 5 frames without spout: 4 empty rows + 4 body rows + # 5 frames without spout for _ in range(5): - frames.append(['', '', '', ''] + body_lines) - masks.append(mask_lines) - # 7 frames with animated spout: spout padded to 3 rows, then separator, then body + 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_align + l for l in spout.strip('\n').split('\n')] - while len(sp_lines) < 3: - sp_lines = [''] + sp_lines - frames.append(sp_lines + [''] + body_lines) - masks.append(mask_lines) + 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=self.wl - 7, + x=x, y=0, cb_args=[speed, 0, 0, 1], default_color='W', die_offscreen=True, @@ -1020,7 +988,7 @@ BBBB BBBBB self._add(Entity( frames=shapes, masks=[mask] * 4, - x=x, y=self.wl - 4, + x=x, y=2, cb_args=[speed, 0, 0, 0.25], default_color='G', die_offscreen=True, @@ -1109,7 +1077,6 @@ BBBB BBBBB default_color='Y', die_offscreen=True, death_cb=lambda e, aq: aq.random_object(e), - entity_type='big_fish', )) # ── Ducks ──────────────────────────────────────────────────────────────── @@ -1275,7 +1242,7 @@ yy self._add(Entity( frames=[shape], masks=[mask], - x=x, y=self.wl - 6, + x=x, y=1, cb_args=[speed, 0, 0, 0.25], default_color='W', die_offscreen=True, @@ -1285,7 +1252,7 @@ yy # ── Fishhook ───────────────────────────────────────────────────────────── def add_fishhook(self, dead=None): - hook_part = _p(r""" + hook_shape = _p(r""" o || || @@ -1293,36 +1260,18 @@ yy \__// `--' """) - cord = ' |' # '|' at col 7, aligns with 'o' - cord_mask = ' w' # cord is white; hook uses default_color ('y') - x = random.randint(10, max(11, self.cols - 20)) - start_d = self.wl - max_d = int(self.rows * 0.75) - descent = max(1, max_d - start_d) - fadv = 0.3 # ~1 row per 3 ticks ≈ 0.3 s/row + x = random.randint(10, max(11, self.cols - 20)) + target_y = int(self.rows * 0.75) - hook_mask_lines = [''] * len(hook_part) # hook chars → default_color - frames, fmasks = [], [] - for d in range(descent + 1): - n = start_d + d - frames.append([cord] * n + hook_part) - fmasks.append([cord_mask] * n + hook_mask_lines) - for d in range(descent - 1, -1, -1): - n = start_d + d - frames.append([cord] * n + hook_part) - fmasks.append([cord_mask] * n + hook_mask_lines) - - e = Entity( - frames=frames, masks=fmasks, - x=x, y=0, - cb_args=[0, 0, 0, fadv], - default_color='y', # hook = yellow metal + e = HookEntity( + frames=[hook_shape], masks=None, + x=x, y=-4, + cb_args=[0, 1, 0], + default_color='G', die_offscreen=False, - die_frame=int(len(frames) / fadv), + target_y=target_y, death_cb=lambda e, aq: aq.random_object(e), - entity_type='fishhook', ) - e._hook_descent = descent # needed for early retraction on catch self._add(e) # ── Seahorse (Seepferdchen) ─────────────────────────────────────────────── @@ -1479,63 +1428,19 @@ yccw # ── Predation (--bloody) ───────────────────────────────────────────────── def _check_predation(self): - # Shark always eats fish; in bloody mode big_fish joins the massacre. - predator_types = {'shark'} - if self.cfg.bloody: - predator_types.add('big_fish') - predators = [e for e in self.entities - if e.entity_type in predator_types and e.alive] - fish = [e for e in self.entities - if e.entity_type == 'fish' and e.alive] - for p in predators: - px1 = int(p.x); px2 = px1 + p.width - py1 = int(p.y); py2 = py1 + p.height + 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 px1 <= fx <= px2 and py1 <= fy <= py2: - if p.entity_type == 'shark' or self.cfg.bloody: - self.add_splat(fx, fy) + if sx1 <= fx <= sx2 and sy1 <= fy <= sy2: + self.add_splat(fx, fy) f.alive = False - if f.death_cb: - f.death_cb(f, self) - - def _check_fishing(self): - """Fishhook catches fish; caught fish is dragged upward with the hook.""" - HOOK_PART_HEIGHT = 6 - hooks = [e for e in self.entities - if e.entity_type == 'fishhook' and e.alive] - fish = [e for e in self.entities - if e.entity_type == 'fish' and e.alive] - for h in hooks: - fi = h.frame_idx - cord_n = len(h.frames[fi]) - HOOK_PART_HEIGHT - hook_row = int(h.y) + cord_n # row of 'o' - hook_col = int(h.x) + 7 # 'o' at offset 7 - for f in fish: - fy1 = int(f.y); fy2 = fy1 + f.height - fx1 = int(f.x); fx2 = fx1 + f.width - if fy1 <= hook_row <= fy2 and fx1 <= hook_col <= fx2: - f.alive = False - if f.death_cb: - f.death_cb(f, self) - # Jump hook to retraction phase immediately - descent = getattr(h, '_hook_descent', None) - if descent is not None and int(h.curr_frame) <= descent: - h.curr_frame = float(descent) - # Inject fish line into all remaining hook frames so it - # is dragged upward locked to the hook (no separate entity). - fish_raw = (f.frames[f.frame_idx] or ['><>'])[0] or '><>' - fish_stripped = fish_raw.strip() or '><>' - # Centre the fish under the hook's 'o' (col offset 7 in the frame) - pad = max(0, 7 - len(fish_stripped) // 2) - fish_line = ' ' * pad + fish_stripped - fi = int(h.curr_frame) - for i in range(fi, len(h.frames)): - h.frames[i].append(fish_line) - if h.masks and i < len(h.masks) and h.masks[i] is not None: - h.masks[i].append('') - break # one fish per hook per tick # ── Crab scene (portcullis + crab) ──────────────────────────────────────── @@ -1578,7 +1483,7 @@ yccw mask = [' yWy ', ' '] c = CrabEntity( frames=[crab_f0, crab_f1], masks=[mask, mask], - x=float(gx), y=float(gy + 1), + x=float(gx), y=float(gy), cb_args=[-1, 0, 0, 0.25], default_color='y', die_offscreen=False, @@ -1639,10 +1544,6 @@ def main(): 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)') - parser.add_argument('--debug', action='store_true', - help='show terminal size / entity count overlay') - parser.add_argument('--ruler', action='store_true', - help='draw numbered row ruler and exit (diagnostic)') args = parser.parse_args() cfg = Config( @@ -1654,6 +1555,8 @@ def main(): any_key = args.any_key, ) + rows, cols = term_size() + aq = Aquarium(rows, cols, cfg) inp = Input() paused = False needs_reset = False @@ -1665,34 +1568,7 @@ def main(): signal.signal(signal.SIGWINCH, on_resize) fd = sys.stdout.fileno() - # Activate alternate screen first, THEN read terminal size so the - # size reflects the actual pane dimensions (important in split panes). - # \033[r resets any inherited scroll region to the full screen. - _write_all(fd, (ALT_ON + HIDE_CUR + "\033[r" + "\033[2J\033[H").encode()) - rows, cols = term_size() - - if args.ruler: - # Draw numbered ruler for every row to verify actual terminal dimensions. - # The last visible row number tells us the real screen height. - buf = ["\033[H"] - for r in range(rows): - label = f"row {r:3d} (1-idx={r+1}) cols={cols}" - if r == rows - 1: - label = f"\033[7m LAST: row {r} / rows={rows} cols={cols} -- press any key \033[m" - buf.append(f"\033[{r+1};1H\033[0m{label}") - _write_all(fd, ''.join(buf).encode()) - try: - while True: - k = inp.key() - if k is not None: - break - time.sleep(0.05) - finally: - inp.restore() - _write_all(fd, (ALT_OFF + SHOW_CUR).encode()) - return - - aq = Aquarium(rows, cols, cfg) + os.write(fd, (ALT_ON + HIDE_CUR + "\033[2J\033[H").encode()) try: TICK = 0.1 / cfg.speed @@ -1719,14 +1595,7 @@ def main(): if not paused: aq.step() frame = aq.render() - if args.debug: - fish_ys = [f"{int(e.y)}" for e in aq.entities - if e.entity_type == 'fish'][:8] - dbg = (f" rows={aq.rows} cols={aq.cols} wl={aq.wl}" - f" ents={len(aq.entities)}" - f" fish_y=[{','.join(fish_ys)}] ") - frame += f"\033[1;1H\033[0;7m{dbg}\033[m" - _write_all(fd, frame.encode()) + os.write(fd, frame.encode()) elapsed = time.monotonic() - t0 sleep = TICK - elapsed @@ -1737,7 +1606,7 @@ def main(): pass finally: inp.restore() - _write_all(fd, (SHOW_CUR + ALT_OFF + RESET).encode()) + os.write(fd, (SHOW_CUR + ALT_OFF + RESET).encode()) if __name__ == '__main__':