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:
parent
d87088eff0
commit
0396ec83c6
1 changed files with 168 additions and 62 deletions
|
|
@ -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,19 +111,36 @@ 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:
|
||||
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
|
||||
|
|
@ -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_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
|
|||
\__//
|
||||
`--'
|
||||
""")
|
||||
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))
|
||||
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(
|
||||
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]
|
||||
# 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 s in sharks:
|
||||
sx1 = int(s.x); sx2 = sx1 + s.width
|
||||
sy1 = int(s.y); sy2 = sy1 + s.height
|
||||
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:
|
||||
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__':
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue