Features: Opacity, Angelhaken, Predation, wl-relative Positionen, Resize-Fix

- _write_all(): Partial-Write-Fix (O_NONBLOCK stdin → stdout in iTerm2 PTY)
- Canvas.put_sprite(): opaque-Modus — nur Interior-Spaces werden schwarz gemalt,
  Leading/Trailing-Spaces bleiben transparent (kein schwarzer Block hinter Tieren)
- Entity: opaque=True als Default; Wellen/Schloss/Seegras explizit opaque=False
- wl = max(3, rows//3 - 3): Wasserlinie 3 Zeilen höher
- Fischanzahl: max(6, underwater_rows * cols // 320)
- Oberflächenobjekte (Schiff, Wal, Nessi, Schwan) mit wl-relativem y — bleiben
  bei Resize auf der Wasseroberfläche
- add_fishhook(): neu als Frame-Animation (Schnur wächst/schrumpft per Frame);
  weißes Schnur-Mask + gelber Haken (default_color y)
- _check_fishing(): Haken erkennt Fisch-Kollision, springt zur Rückzugsphase,
  injiziert Fisch-Zeile in verbleibende Frames → Fisch hängt am Haken
- _check_predation(): Hai frisst immer (mit Blut); --bloody aktiviert big_fish
  als weiteren Räuber; --vegan = keine Räuber
- HookEntity entfernt (ersetzt durch Frame-Animation)
- big_fish: entity_type gesetzt für Kollisionserkennung
- Krabbe: y = gy+1 (eine Zeile tiefer)
- Canvas.render(): \033[K am Ende jeder Zeile — löscht letzte Spalte ohne
  Pending-Wrap; behebt Stale-Content nach Resize
- \033[r beim Start: Scroll-Region-Reset
- --ruler Flag für Diagnose
This commit is contained in:
rene 2026-03-29 18:39:12 +02:00
parent d87088eff0
commit 0396ec83c6

View file

@ -15,8 +15,8 @@ class Config:
speed: float = 1.0 # global speed multiplier (TICK = 0.1 / speed) speed: float = 1.0 # global speed multiplier (TICK = 0.1 / speed)
no_shark: bool = False no_shark: bool = False
no_ship: bool = False no_ship: bool = False
bloody: bool = False # shark eats fish with gore splats bloody: bool = False # massacre: all predators eat fish with blood splats
vegan: bool = False # disable predators (shark, big_fish, fishhook) vegan: bool = False # peaceful: no predators (shark, big_fish, fishhook)
any_key: bool = False # any key exits any_key: bool = False # any key exits
# ─── Terminal control ──────────────────────────────────────────────────────── # ─── 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 _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 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 ─────────────────────────────────────────────────────────── # ─── Color palette ───────────────────────────────────────────────────────────
# RGB values matching our Homebrew init_pair fix (xterm-256 slots 16-231): # RGB values matching our Homebrew init_pair fix (xterm-256 slots 16-231):
@ -100,19 +111,36 @@ class Canvas:
self._ck[row][col] = ck self._ck[row][col] = ck
def put_sprite(self, y: int, x: int, shape: List[str], def put_sprite(self, y: int, x: int, shape: List[str],
mask: Optional[List[str]], default_color: Optional[str]): mask: Optional[List[str]], default_color: Optional[str],
"""Draw a sprite. Spaces and '?' characters are transparent.""" 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 rows, cols = self.rows, self.cols
for dr, line in enumerate(shape): for dr, line in enumerate(shape):
row = y + dr row = y + dr
if row < 0 or row >= rows: if row < 0 or row >= rows:
continue continue
mline = (mask[dr] if dr < len(mask) else '') if mask else '' 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): for dc, ch in enumerate(line):
if ch in (' ', '?'): if ch == '?':
continue continue
col = x + dc col = x + dc
if 0 <= col < cols: 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 ' ' mk = mline[dc] if dc < len(mline) else ' '
ck = mk if mk in _C else default_color ck = mk if mk in _C else default_color
self._ch[row][col] = ch self._ch[row][col] = ch
@ -141,6 +169,10 @@ class Canvas:
parts.append(RESET if ck is None else _esc(ck)) parts.append(RESET if ck is None else _esc(ck))
last_ck = ck last_ck = ck
parts.append(self._ch[r][c]) 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) parts.append(RESET)
return ''.join(parts) return ''.join(parts)
@ -178,7 +210,8 @@ class Entity:
death_cb: Optional[Callable] = None, death_cb: Optional[Callable] = None,
die_time: Optional[float] = None, die_time: Optional[float] = None,
die_frame: Optional[int] = None, die_frame: Optional[int] = None,
entity_type: str = ''): entity_type: str = '',
opaque: bool = True):
self.frames = frames self.frames = frames
self.masks = masks self.masks = masks
self.x = float(x) self.x = float(x)
@ -190,6 +223,7 @@ class Entity:
self.die_time = die_time self.die_time = die_time
self._die_frame = die_frame self._die_frame = die_frame
self.entity_type = entity_type self.entity_type = entity_type
self.opaque = opaque
self.curr_frame = 0.0 self.curr_frame = 0.0
self.alive = True self.alive = True
self.height = len(frames[0]) if frames else 0 self.height = len(frames[0]) if frames else 0
@ -249,29 +283,10 @@ class Entity:
canvas.put_sprite(int(self.y), int(self.x), canvas.put_sprite(int(self.y), int(self.x),
self.frames[fi], self.frames[fi],
self.masks[fi] if self.masks and fi < len(self.masks) else None, 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): class CrabEntity(Entity):
"""Crab that walks out from the castle gate and then returns.""" """Crab that walks out from the castle gate and then returns."""
@ -310,7 +325,7 @@ class Aquarium:
self.canvas = Canvas(rows, cols) self.canvas = Canvas(rows, cols)
# wl = first row of the wave area, proportional like Perl's int($lines/3). # 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). # 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] = [] self.entities: List[Entity] = []
fns = [self.add_whale, self.add_monster, self.add_swan, fns = [self.add_whale, self.add_monster, self.add_swan,
@ -342,7 +357,7 @@ class Aquarium:
def resize(self, rows: int, cols: int): def resize(self, rows: int, cols: int):
self.rows, self.cols = rows, cols 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.canvas.resize(rows, cols)
self._cs_state = 0 self._cs_state = 0
self._cs_portcullis = None self._cs_portcullis = None
@ -355,8 +370,8 @@ class Aquarium:
random.choice(self._random_fns)(dead) random.choice(self._random_fns)(dead)
def step(self): 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.entities = [e for e in self.entities if e.update(self)]
self._update_crab_scene() self._update_crab_scene()
@ -384,6 +399,7 @@ class Aquarium:
cb_args=[0, 0, 0], cb_args=[0, 0, 0],
default_color='c', default_color='c',
die_offscreen=False, die_offscreen=False,
opaque=False,
)) ))
# ── Castle ─────────────────────────────────────────────────────────────── # ── Castle ───────────────────────────────────────────────────────────────
@ -425,6 +441,7 @@ class Aquarium:
cb_args=[0, 0, 0], cb_args=[0, 0, 0],
default_color='k', default_color='k',
die_offscreen=False, die_offscreen=False,
opaque=False,
)) ))
# ── Seaweed ────────────────────────────────────────────────────────────── # ── Seaweed ──────────────────────────────────────────────────────────────
@ -458,6 +475,7 @@ class Aquarium:
die_offscreen=False, die_offscreen=False,
death_cb=lambda e, aq: aq.add_seaweed(e), death_cb=lambda e, aq: aq.add_seaweed(e),
die_time=die_t, die_time=die_t,
opaque=False,
)) ))
# ── Bubbles ────────────────────────────────────────────────────────────── # ── Bubbles ──────────────────────────────────────────────────────────────
@ -478,7 +496,7 @@ class Aquarium:
def add_all_fish(self): def add_all_fish(self):
underwater_rows = max(1, self.rows - (self.wl + 4)) 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): for _ in range(count):
self.add_fish(None) self.add_fish(None)
@ -838,7 +856,7 @@ yywwwyyyyyyyyyyyyyyyyyyyy
self._add(Entity( self._add(Entity(
frames=[shape_r if going_right else shape_l], frames=[shape_r if going_right else shape_l],
masks=[mask_r if going_right else mask_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], cb_args=[speed, 0, 0],
default_color='W', default_color='W',
die_offscreen=True, die_offscreen=True,
@ -918,7 +936,7 @@ BBBB BBBBB
self._add(Entity( self._add(Entity(
frames=frames, masks=masks, frames=frames, masks=masks,
x=x, y=0, x=x, y=self.wl - 7,
cb_args=[speed, 0, 0, 1], cb_args=[speed, 0, 0, 1],
default_color='W', default_color='W',
die_offscreen=True, die_offscreen=True,
@ -1002,7 +1020,7 @@ BBBB BBBBB
self._add(Entity( self._add(Entity(
frames=shapes, masks=[mask] * 4, frames=shapes, masks=[mask] * 4,
x=x, y=2, x=x, y=self.wl - 4,
cb_args=[speed, 0, 0, 0.25], cb_args=[speed, 0, 0, 0.25],
default_color='G', default_color='G',
die_offscreen=True, die_offscreen=True,
@ -1091,6 +1109,7 @@ BBBB BBBBB
default_color='Y', default_color='Y',
die_offscreen=True, die_offscreen=True,
death_cb=lambda e, aq: aq.random_object(e), death_cb=lambda e, aq: aq.random_object(e),
entity_type='big_fish',
)) ))
# ── Ducks ──────────────────────────────────────────────────────────────── # ── Ducks ────────────────────────────────────────────────────────────────
@ -1256,7 +1275,7 @@ yy
self._add(Entity( self._add(Entity(
frames=[shape], masks=[mask], frames=[shape], masks=[mask],
x=x, y=1, x=x, y=self.wl - 6,
cb_args=[speed, 0, 0, 0.25], cb_args=[speed, 0, 0, 0.25],
default_color='W', default_color='W',
die_offscreen=True, die_offscreen=True,
@ -1266,7 +1285,7 @@ yy
# ── Fishhook ───────────────────────────────────────────────────────────── # ── Fishhook ─────────────────────────────────────────────────────────────
def add_fishhook(self, dead=None): def add_fishhook(self, dead=None):
hook_shape = _p(r""" hook_part = _p(r"""
o o
|| ||
|| ||
@ -1274,18 +1293,36 @@ 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)) x = random.randint(10, max(11, self.cols - 20))
target_y = int(self.rows * 0.75) 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( hook_mask_lines = [''] * len(hook_part) # hook chars → default_color
frames=[hook_shape], masks=None, frames, fmasks = [], []
x=x, y=-4, for d in range(descent + 1):
cb_args=[0, 1, 0], n = start_d + d
default_color='G', 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, die_offscreen=False,
target_y=target_y, die_frame=int(len(frames) / fadv),
death_cb=lambda e, aq: aq.random_object(e), 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) self._add(e)
# ── Seahorse (Seepferdchen) ─────────────────────────────────────────────── # ── Seahorse (Seepferdchen) ───────────────────────────────────────────────
@ -1442,19 +1479,63 @@ yccw
# ── Predation (--bloody) ───────────────────────────────────────────────── # ── Predation (--bloody) ─────────────────────────────────────────────────
def _check_predation(self): def _check_predation(self):
sharks = [e for e in self.entities # Shark always eats fish; in bloody mode big_fish joins the massacre.
if e.entity_type == 'shark' and e.alive] 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 fish = [e for e in self.entities
if e.entity_type == 'fish' and e.alive] if e.entity_type == 'fish' and e.alive]
for s in sharks: for p in predators:
sx1 = int(s.x); sx2 = sx1 + s.width px1 = int(p.x); px2 = px1 + p.width
sy1 = int(s.y); sy2 = sy1 + s.height py1 = int(p.y); py2 = py1 + p.height
for f in fish: for f in fish:
fx = int(f.x) + f.width // 2 fx = int(f.x) + f.width // 2
fy = int(f.y) + f.height // 2 fy = int(f.y) + f.height // 2
if sx1 <= fx <= sx2 and sy1 <= fy <= sy2: if px1 <= fx <= px2 and py1 <= fy <= py2:
if p.entity_type == 'shark' or self.cfg.bloody:
self.add_splat(fx, fy) self.add_splat(fx, fy)
f.alive = False 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) ──────────────────────────────────────── # ── Crab scene (portcullis + crab) ────────────────────────────────────────
@ -1497,7 +1578,7 @@ yccw
mask = [' yWy ', ' '] mask = [' yWy ', ' ']
c = CrabEntity( c = CrabEntity(
frames=[crab_f0, crab_f1], masks=[mask, mask], 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], cb_args=[-1, 0, 0, 0.25],
default_color='y', default_color='y',
die_offscreen=False, die_offscreen=False,
@ -1560,6 +1641,8 @@ def main():
help='any key press exits (default: q/Q/Esc/Ctrl-C)') help='any key press exits (default: q/Q/Esc/Ctrl-C)')
parser.add_argument('--debug', action='store_true', parser.add_argument('--debug', action='store_true',
help='show terminal size / entity count overlay') 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() args = parser.parse_args()
cfg = Config( cfg = Config(
@ -1584,8 +1667,31 @@ def main():
fd = sys.stdout.fileno() fd = sys.stdout.fileno()
# Activate alternate screen first, THEN read terminal size so the # Activate alternate screen first, THEN read terminal size so the
# size reflects the actual pane dimensions (important in split panes). # 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() 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) aq = Aquarium(rows, cols, cfg)
try: try:
@ -1620,7 +1726,7 @@ def main():
f" ents={len(aq.entities)}" f" ents={len(aq.entities)}"
f" fish_y=[{','.join(fish_ys)}] ") f" fish_y=[{','.join(fish_ys)}] ")
frame += f"\033[1;1H\033[0;7m{dbg}\033[m" 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 elapsed = time.monotonic() - t0
sleep = TICK - elapsed sleep = TICK - elapsed
@ -1631,7 +1737,7 @@ def main():
pass pass
finally: finally:
inp.restore() inp.restore()
os.write(fd, (SHOW_CUR + ALT_OFF + RESET).encode()) _write_all(fd, (SHOW_CUR + ALT_OFF + RESET).encode())
if __name__ == '__main__': if __name__ == '__main__':