Wasser-Mechanik nach Original, Slope-Schweben-Fix, Spawn-Pose, HUD v3

Stefans zweite Feedback-Runde:
- Nicht-Schwimmer (Tony & Co.) tauchen nicht mehr unter: die Wasser-
  oberflaeche traegt (14px Eintauchtiefe, springen von dort moeglich,
  Herzverlust laeuft) -- wie im Original-Screenshot. Schwimmer (Smacks)
  loesen nahe der Oberflaeche mit der Sprungtaste einen echten Sprung aus
  und kommen wieder aus dem Wasser raus (vorher unmoeglich).
- Slope-Schweben gefixt: Luft-Snap nur bei echter Durchdringung; Bergauf-
  Toleranz nur fuer bereits Gelandete.
- Start-Sprite-Sequenz beim Levelstart/Charakterwechsel (Tony Arm hoch,
  Smacks Daumen hoch, Frame 22, ~1.1s, durch Eingabe abbrechbar).
- Rennsprungweite: RUN_SPEED 185 -> 200 ("bis hinter die Blume").
- HUD v3 nach Original-Referenz: helle Creme-Rahmen, hoehere Boxen,
  Cerealien-Icon in voller Groesse, leere Herz-Plaetze dunkel.

Verifiziert: Physik-Sims (Wade+Absprung, Smacks-Ausstiegssprung, Decken-
Kollision unter der Bruecke korrekt) + E2E rc=0 + --once exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 11:19:44 +02:00
co-authored by Claude Fable 5
parent 07b347b607
commit 7eb7f6f3b7
2 changed files with 117 additions and 36 deletions
+25
View File
@@ -1480,3 +1480,28 @@ ducken als Smacks, ESC/QUIT rc=0. --once Regression exit 0.
Items einsammeln (FROS_W1-Rotationssprites + ctype 4), Smacks-Blickrichtungs-
Paritaet pruefen, Coco/Toucan-Frames zuordnen (aktuell Tony-Layout
angenommen), Kamera-Hoehe feinjustieren, Gegner, Tuer-/Schluessel-Logik.
## 25.07.2026 (4) -- zweite Feedback-Runde: Wasser-Mechanik, Slopes, Spawn-Pose, HUD v3
Stefans Live-Test (5 Screenshots), alles umgesetzt:
1. **Slope-Schweben gefixt**: in der Luft snappt der Fuss nur noch bei
echter Durchdringung der Diagonale (tol=0); nur wer schon am Boden ist,
steigt mit 8px-Toleranz bergauf. (Nebenfund: der Grashang hat ZWEI
Slope-Tiles uebereinander -- gelaufen wird auf der oberen Diagonale.)
2. **Wasser-Mechanik wie im Original**: Nicht-Schwimmer tauchen NICHT unter
-- die Oberflaeche traegt (Fuss sinkt nur 14px ein, on_ground, man kann
von ihr abspringen), Herzverlust laeuft weiter. Schwimmer (Smacks): nahe
der Oberflaeche loest die Sprungtaste einen ECHTEN Sprung aus (vorher kam
man nicht mehr aus dem Wasser raus, nur schwacher Schwimmstoss).
Smacks-Blickrichtung: passt (Stefan bestaetigt).
3. **Start-Sprite-Sequenz** beim Levelstart/Charakterwechsel: Tony streckt
den Arm hoch (Frame 22), Smacks Daumen hoch (Frame 22); ~1.1s, jede
Eingabe bricht ab.
4. **Rennsprung weiter**: RUN_SPEED 185 -> 200 ("bis hinter die Blume").
5. **HUD v3** nach Original-Screenshot: helle Creme-Rahmen statt Gold,
groessere Boxen (18px), Icon in voller Groesse, leere Herz-Plaetze
bleiben dunkel.
Verifiziert per Physik-Simulationen (Wade-Ebene 686=water_y+14 + Sprung
vy=-345 von dort; Smacks-Ausstiegssprung vy=-360 an offener Wasserstelle;
Smacks unter der Bruecke stoesst korrekt an die Decke) + E2E rc=0 + --once ok.
+91 -35
View File
@@ -33,7 +33,8 @@ HUD_H = 24
TILE = 16
WALK_SPEED = 110.0
RUN_SPEED = 185.0
RUN_SPEED = 200.0 # Stefans Nachmessung: Rennsprung muss bis
# HINTER die Blume tragen -> etwas schneller
GRAVITY = 900.0
# Sprunghoehe ~72px (Stefans Referenz: Tonys Kopf erreicht gerade den Ast
# ueber dem Spawn): v = sqrt(2*g*h) ~= 360.
@@ -45,6 +46,10 @@ RUN_ANIM_FPS = 14.0
IDLE_ANIM_AFTER = 5.0 # Sekunden Stillstand bis zur Idle-Animation
IDLE_ANIM_FPS = 2.5
WATER_DAMAGE_EVERY = 5.0 # im Wasser: alle 5s ein Herz (Stefans Angabe)
SPAWN_ANIM_SECONDS = 1.1 # Start-Sprite beim Levelstart/Charakterwechsel
WATER_WADE_DEPTH = 14 # Nicht-Schwimmer sinken nur bis hierhin ein
# (Original: Tony "springt auf der Wasser-
# oberflaeche", taucht NICHT unter)
SOLID_BIT = 32
SLOPE_CT = 16
@@ -59,6 +64,7 @@ CHAR_DEFS = {
idle_r=0, idle_l=1, jump_r=18, jump_l=19, duck_r=24, duck_l=25,
swim_r=None, swim_l=None,
idle_anim_r=[44, 46, 48], idle_anim_l=[45, 47, 49],
spawn_frame=22, # Arm nach oben gestreckt
item_icon='ITE0', head_index=0,
),
'SMACKS': dict(
@@ -68,6 +74,7 @@ CHAR_DEFS = {
idle_r=0, idle_l=1, jump_r=24, jump_l=25, duck_r=18, duck_l=19,
swim_r=[37, 39, 41, 43], swim_l=[38, 40, 42, 44],
idle_anim_r=[31, 33, 35], idle_anim_l=[32, 34, 36],
spawn_frame=22, # Daumen-hoch-Geste
item_icon='ITE1', head_index=1,
),
'COCO': dict(
@@ -76,6 +83,7 @@ CHAR_DEFS = {
run_r=None, run_l=None,
idle_r=0, idle_l=1, jump_r=18, jump_l=19, duck_r=24, duck_l=25,
swim_r=None, swim_l=None, idle_anim_r=None, idle_anim_l=None,
spawn_frame=None,
item_icon='ITE3', head_index=2,
),
'TOUCAN': dict(
@@ -84,6 +92,7 @@ CHAR_DEFS = {
run_r=None, run_l=None,
idle_r=0, idle_l=1, jump_r=18, jump_l=19, duck_r=24, duck_l=25,
swim_r=None, swim_l=None, idle_anim_r=None, idle_anim_l=None,
spawn_frame=None,
item_icon='ITE2', head_index=3,
),
}
@@ -187,7 +196,6 @@ class Player:
def __init__(self, assets, char, pos):
self.assets = assets
self.set_character(char, pos)
self.vx = 0.0
self.vy = 0.0
self.on_ground = False
@@ -197,6 +205,9 @@ class Player:
self.swimming = False
self.anim_t = 0.0
self.idle_t = 0.0
self.spawn_t = 0.0 # Start-Sprite-Sequenz (Arm hoch/Daumen hoch)
self._grounded_before = False
self.set_character(char, pos)
def set_character(self, char, pos=None):
self.char = char
@@ -206,6 +217,9 @@ class Player:
self.surfs = _frames_to_surfaces(frames, palette)
if pos is not None:
self.x, self.y = float(pos[0]), float(pos[1])
# Start-Sprite-Sequenz beim Levelstart/Charakterwechsel (Stefans
# Beobachtung: Tony streckt die Hand nach oben, Smacks Daumen hoch)
self.spawn_t = SPAWN_ANIM_SECONDS
def rect(self):
return pygame.Rect(int(self.x - self.HITBOX_W / 2),
@@ -213,6 +227,12 @@ class Player:
self.HITBOX_W, self.HITBOX_H)
def update(self, level, dt, keys):
if self.spawn_t > 0:
self.spawn_t -= dt
# Eingabe bricht die Start-Pose sofort ab
if any(keys[k] for k in (pygame.K_LEFT, pygame.K_RIGHT,
pygame.K_UP, pygame.K_DOWN, pygame.K_SPACE)):
self.spawn_t = 0.0
in_water = level.in_water(self.y)
self.swimming = in_water and self.d['can_swim']
self.ducking = self.on_ground and not self.swimming and keys[pygame.K_DOWN]
@@ -239,8 +259,17 @@ class Player:
jump_pressed = keys[pygame.K_SPACE] or keys[pygame.K_UP]
if self.swimming:
if jump_pressed:
wy = level.info.get('water_y')
near_surface = wy is not None and (self.y - wy) < 24
if jump_pressed and near_surface:
# Stefans Bug "kommt nicht mehr aus dem Wasser raus": nahe der
# Oberflaeche wird ein ECHTER Sprung ausgeloest (traegt auf die
# Plattform), nicht nur ein Schwimmstoss.
self.vy = JUMP_VELOCITY
self.swimming = False
elif jump_pressed:
self.vy = min(self.vy, SWIM_KICK)
if self.swimming:
self.vy = min(self.vy + SWIM_GRAVITY * dt, 120.0)
else:
if self.on_ground and not self.ducking and jump_pressed:
@@ -280,17 +309,36 @@ class Player:
ny = (ty + 1) * TILE + self.HITBOX_H + 0.01
self.vy = 0.0
# Schraegen (ctype 16)
# Schraegen (ctype 16). WICHTIG (Stefans Bug "Figuren stehen auf
# Schraegen in der Luft"): in der LUFT nur snappen, wenn der Fuss die
# Diagonale wirklich durchdrungen hat (ny >= surf). Nur wer schon am
# Boden ist (bergauf laufen), darf mit Toleranz nach OBEN aufsteigen.
if self.vy >= 0:
was_grounded = self.on_ground or self._grounded_before
foot_ty = int(ny // TILE)
for ty2 in (foot_ty - 1, foot_ty, foot_ty + 1):
tys = (foot_ty - 1, foot_ty, foot_ty + 1) if was_grounded else (foot_ty, foot_ty + 1)
tol = 8 if was_grounded else 0
for ty2 in tys:
surf = level.slope_surface(self.x, ty2)
if surf is not None and ny >= surf - 10:
if surf is not None and ny >= surf - tol:
ny = surf - 0.01
self.vy = 0.0
self.on_ground = True
break
# Nicht-Schwimmer tauchen NICHT unter (Original, Stefans Screenshot:
# Tony "springt quasi auf der Wasseroberflaeche") -- die Oberflaeche
# traegt wie weicher Boden, die Beine sinken nur WADE_DEPTH tief ein.
# Springen von dort geht (on_ground), Herzverlust laeuft weiter.
wy = level.info.get('water_y')
if wy is not None and not self.d['can_swim']:
wade_max = wy + WATER_WADE_DEPTH
if ny >= wade_max:
ny = wade_max
self.vy = 0.0
self.on_ground = True
self.y = ny
self._grounded_before = self.on_ground
def _pick(self, key_r, key_l):
i = self.d[key_r] if self.facing > 0 else self.d[key_l]
@@ -299,6 +347,8 @@ class Player:
def current_frame_index(self):
d = self.d
right = self.facing > 0
if self.spawn_t > 0 and d.get('spawn_frame') is not None and self.on_ground:
return d['spawn_frame']
if self.swimming and d['swim_r']:
seq = d['swim_r'] if right else d['swim_l']
return seq[int(self.anim_t * 6) % len(seq)] if self.vx else seq[0]
@@ -332,9 +382,12 @@ class Hud:
Herz-Box (2x2-GRID), Charakter-Kopf (WECH, zeigt IMMER die aktuell
gewaehlte Figur) + Leben, Uhr.'''
GOLD = (222, 186, 24)
RED = (150, 14, 14)
DARK = (84, 6, 6)
# Farben nach Stefans Original-HUD-Screenshot: HELLE (creme-weisse)
# Boxrahmen auf dunkelrotem Grund -- nicht das satte Gold der ersten
# Version.
FRAME = (244, 232, 200)
RED = (146, 10, 10)
DARK = (80, 4, 4)
WHITE = (248, 248, 248)
def __init__(self, assets):
@@ -359,43 +412,46 @@ class Hud:
self.heads_small.append(pygame.transform.smoothscale(head, (17, 16)))
def _box(self, canvas, x, y, w, h):
pygame.draw.rect(canvas, self.GOLD, (x - 1, y - 1, w + 2, h + 2))
pygame.draw.rect(canvas, self.RED, (x, y, w, h))
pygame.draw.rect(canvas, self.FRAME, (x - 1, y - 1, w + 2, h + 2),
border_radius=3)
pygame.draw.rect(canvas, self.RED, (x, y, w, h), border_radius=2)
def _text(self, canvas, x, y, text):
canvas.blit(self.font.render(text, outline=(0, 0, 0)), (x, y))
def draw(self, canvas, y0, char_def, cookies, score, hearts, lives, time_left):
'''Layout nach Stefans Original-HUD-Screenshot (Positionen /2 aufs
320er-Raster gemessen): [Kellogg's][Icon+Keks][Item][Score][Herz 2x2]
[Kopf+Leben][Uhr], Boxen fast HUD-hoch mit hellem Rahmen.'''
pygame.draw.rect(canvas, self.DARK, (0, y0, 320, HUD_H))
# Kellogg's-Leiste
pygame.draw.rect(canvas, self.WHITE, (2, y0 + 3, 56, HUD_H - 6), border_radius=6)
canvas.blit(self.kellogg, (5, y0 + 5))
# Keks-Box mit Item-Icon des aktiven Charakters
self._box(canvas, 62, y0 + 4, 46, 16)
box_y, box_h = y0 + 3, 18
text_y = y0 + 9
# Kellogg's-Leiste (weiss, abgerundet)
pygame.draw.rect(canvas, self.WHITE, (2, y0 + 2, 60, HUD_H - 4), border_radius=7)
canvas.blit(self.kellogg, (7, y0 + 6))
# Keks-Box: Cerealien-Icon (16x16, volle Groesse) + Zaehler
self._box(canvas, 66, box_y, 48, box_h)
icon = self.item_icons.get(char_def['item_icon'])
if icon:
canvas.blit(icon, (64, y0 + 4))
self._text(canvas, 82, y0 + 9, f'{cookies:03d}')
# Item-Box
self._box(canvas, 114, y0 + 4, 36, 16)
canvas.blit(icon, (68, y0 + 4))
self._text(canvas, 87, text_y, f'{cookies:03d}')
# Item-Box (leer)
self._box(canvas, 118, box_y, 38, box_h)
# Score
self._box(canvas, 156, y0 + 4, 54, 16)
self._text(canvas, 159, y0 + 9, f'{score:06d}')
# Herz-Box: 2x2-Grid (Referenz-Screenshot)
self._box(canvas, 216, y0 + 2, 26, 20)
for i in range(4):
icon = self.heart_full if i < hearts else self.heart_empty
hx = 219 + (i % 2) * 10
hy = y0 + 3 + (i // 2) * 9
canvas.blit(icon, (hx, hy))
# Charakter-Kopf (immer die gewaehlte Figur) + Leben
self._box(canvas, 248, y0 + 2, 36, 20)
canvas.blit(self.heads_small[char_def['head_index']], (250, y0 + 4))
self._text(canvas, 272, y0 + 9, str(lives))
self._box(canvas, 160, box_y, 56, box_h)
self._text(canvas, 164, text_y, f'{score:06d}')
# Herz-Box: 2x2-Grid; leere Plaetze bleiben dunkel (wie im Original)
self._box(canvas, 220, box_y, 24, box_h)
for i in range(min(hearts, 4)):
canvas.blit(self.heart_full, (222 + (i % 2) * 10, y0 + 4 + (i // 2) * 9))
# Charakter-Kopf (zeigt IMMER die gewaehlte Figur) + Leben
self._box(canvas, 248, box_y, 32, box_h)
canvas.blit(self.heads_small[char_def['head_index']], (249, y0 + 4))
self._text(canvas, 269, text_y, str(lives))
# Uhr
m, s = divmod(max(0, int(time_left)), 60)
self._box(canvas, 288, y0 + 4, 30, 16)
self._text(canvas, 290, y0 + 9, f'{m}:{s:02d}')
self._box(canvas, 284, box_y, 34, box_h)
self._text(canvas, 287, text_y, f'{m}:{s:02d}')
def run_character_select(clock, assets, available, current):