diff --git a/asciiquarium_ng.py b/asciiquarium_ng.py index 86a3045..47efc8e 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 # shark eats fish with gore splats - vegan: bool = False # disable predators (shark, big_fish, fishhook) + bloody: bool = False # massacre: all predators eat fish with blood splats + vegan: bool = False # peaceful: no predators (shark, big_fish, fishhook) any_key: bool = False # any key exits # ─── Terminal control ──────────────────────────────────────────────────────── @@ -32,6 +32,17 @@ 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): @@ -100,23 +111,40 @@ 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]): - """Draw a sprite. Spaces and '?' characters are transparent.""" + 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. + """ 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 in (' ', '?'): + if ch == '?': 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 + 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 def render(self) -> str: # \033[H homes the cursor; every cell is rewritten so \033[2J is not @@ -141,6 +169,10 @@ class Canvas: 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) @@ -178,7 +210,8 @@ class Entity: death_cb: Optional[Callable] = None, die_time: Optional[float] = None, die_frame: Optional[int] = None, - entity_type: str = ''): + entity_type: str = '', + opaque: bool = True): self.frames = frames self.masks = masks self.x = float(x) @@ -190,6 +223,7 @@ 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 @@ -249,29 +283,10 @@ 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.default_color, + self.opaque) -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.""" @@ -310,7 +325,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) + self.wl = max(3, rows // 3 - 3) self.entities: List[Entity] = [] fns = [self.add_whale, self.add_monster, self.add_swan, @@ -342,7 +357,7 @@ class Aquarium: def resize(self, rows: int, cols: int): self.rows, self.cols = rows, cols - self.wl = max(3, rows // 3) + self.wl = max(3, rows // 3 - 3) self.canvas.resize(rows, cols) self._cs_state = 0 self._cs_portcullis = None @@ -355,8 +370,8 @@ class Aquarium: random.choice(self._random_fns)(dead) def step(self): - if self.cfg.bloody: - self._check_predation() + self._check_predation() + self._check_fishing() self.entities = [e for e in self.entities if e.update(self)] self._update_crab_scene() @@ -384,6 +399,7 @@ class Aquarium: cb_args=[0, 0, 0], default_color='c', die_offscreen=False, + opaque=False, )) # ── Castle ─────────────────────────────────────────────────────────────── @@ -425,6 +441,7 @@ class Aquarium: cb_args=[0, 0, 0], default_color='k', die_offscreen=False, + opaque=False, )) # ── Seaweed ────────────────────────────────────────────────────────────── @@ -458,6 +475,7 @@ class Aquarium: die_offscreen=False, death_cb=lambda e, aq: aq.add_seaweed(e), die_time=die_t, + opaque=False, )) # ── Bubbles ────────────────────────────────────────────────────────────── @@ -478,7 +496,7 @@ class Aquarium: def add_all_fish(self): underwater_rows = max(1, self.rows - (self.wl + 4)) - count = max(15, underwater_rows * self.cols // 120) + count = max(6, underwater_rows * self.cols // 320) for _ in range(count): self.add_fish(None) @@ -838,7 +856,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=0, + x=x, y=self.wl - 5, cb_args=[speed, 0, 0], default_color='W', die_offscreen=True, @@ -918,7 +936,7 @@ BBBB BBBBB self._add(Entity( frames=frames, masks=masks, - x=x, y=0, + x=x, y=self.wl - 7, cb_args=[speed, 0, 0, 1], default_color='W', die_offscreen=True, @@ -1002,7 +1020,7 @@ BBBB BBBBB self._add(Entity( frames=shapes, masks=[mask] * 4, - x=x, y=2, + x=x, y=self.wl - 4, cb_args=[speed, 0, 0, 0.25], default_color='G', die_offscreen=True, @@ -1091,6 +1109,7 @@ BBBB BBBBB default_color='Y', die_offscreen=True, death_cb=lambda e, aq: aq.random_object(e), + entity_type='big_fish', )) # ── Ducks ──────────────────────────────────────────────────────────────── @@ -1256,7 +1275,7 @@ yy self._add(Entity( frames=[shape], masks=[mask], - x=x, y=1, + x=x, y=self.wl - 6, cb_args=[speed, 0, 0, 0.25], default_color='W', die_offscreen=True, @@ -1266,7 +1285,7 @@ yy # ── Fishhook ───────────────────────────────────────────────────────────── def add_fishhook(self, dead=None): - hook_shape = _p(r""" + hook_part = _p(r""" o || || @@ -1274,18 +1293,36 @@ yy \__// `--' """) - x = random.randint(10, max(11, self.cols - 20)) - target_y = int(self.rows * 0.75) + 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 - e = HookEntity( - frames=[hook_shape], masks=None, - x=x, y=-4, - cb_args=[0, 1, 0], - default_color='G', + 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 die_offscreen=False, - target_y=target_y, + die_frame=int(len(frames) / fadv), 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) ─────────────────────────────────────────────── @@ -1442,19 +1479,63 @@ yccw # ── 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 + # 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 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) + if px1 <= fx <= px2 and py1 <= fy <= py2: + if p.entity_type == 'shark' or self.cfg.bloody: + 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) ──────────────────────────────────────── @@ -1497,7 +1578,7 @@ yccw mask = [' yWy ', ' '] c = CrabEntity( frames=[crab_f0, crab_f1], masks=[mask, mask], - x=float(gx), y=float(gy), + x=float(gx), y=float(gy + 1), cb_args=[-1, 0, 0, 0.25], default_color='y', die_offscreen=False, @@ -1560,6 +1641,8 @@ def main(): 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( @@ -1584,8 +1667,31 @@ def main(): fd = sys.stdout.fileno() # Activate alternate screen first, THEN read terminal size so the # size reflects the actual pane dimensions (important in split panes). - os.write(fd, (ALT_ON + HIDE_CUR + "\033[2J\033[H").encode()) + # \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) try: @@ -1620,7 +1726,7 @@ def main(): f" ents={len(aq.entities)}" f" fish_y=[{','.join(fish_ys)}] ") frame += f"\033[1;1H\033[0;7m{dbg}\033[m" - os.write(fd, frame.encode()) + _write_all(fd, frame.encode()) elapsed = time.monotonic() - t0 sleep = TICK - elapsed @@ -1631,7 +1737,7 @@ def main(): pass finally: inp.restore() - os.write(fd, (SHOW_CUR + ALT_OFF + RESET).encode()) + _write_all(fd, (SHOW_CUR + ALT_OFF + RESET).encode()) if __name__ == '__main__':