From 6c3d6ea7a72c2e7a02d59a41e68c85ad73248300 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sat, 25 Jul 2026 11:39:21 +0200 Subject: [PATCH] Echte GAMEBAR.PCC als HUD, pixelgenaue Slopes, Step-up, Wasser-Grund Stefans dritte Feedback-Runde: - HUD nutzt jetzt die ECHTE GAMEBAR.PCC aus PCKELL.DAT (Stefans Fund): Kellogg's-Leiste, Boxrahmen und Uhr-":" kommen aus der Original-Grafik; weisse Box-Innenflaechen werden beim Laden dunkelrot gefuellt (macht die Original-Engine zur Laufzeit ebenso). Inhalte an den gemessenen Box- Positionen; HUD_H=25 (Grafikhoehe), VIEW_H=175. - Slopes PIXELGENAU: Laufflaeche = oberster Nicht-Himmel-Pixel der Tile- Grafik pro Spalte (die Haenge sind ~1:2, nicht 45 Grad -- Ursache des restlichen Schwebens). Sky-Index vom haeufigsten Luft-Tile. Reine Himmel-Spalten tragen nicht. - STEP_UP=6: Mini-Stufen (Plateau-Kante ueber Hangende) werden beim Laufen ueberwunden; hohe Waende blocken weiterhin. - Unterste Map-Zeile immer solide (Grafik-Bodenstreifen ohne ctypes) -- kein Steckenbleiben mehr beim Tieftauchen. - Smacks-Steh-Frames 12/13 statt 0/1 (Anhalte-Sprite-Bug). Verifiziert: Slope-Drop exakt auf Grafikkante, Plateau-Aufstieg, Wand- Integritaet, Smacks-Idle, --once exit 0, E2E rc=0. Co-Authored-By: Claude Fable 5 --- NOTES.md | 30 ++++++++++ game/level.py | 160 ++++++++++++++++++++++++++++++-------------------- 2 files changed, 126 insertions(+), 64 deletions(-) diff --git a/NOTES.md b/NOTES.md index ec1d236..e48a67f 100644 --- a/NOTES.md +++ b/NOTES.md @@ -1505,3 +1505,33 @@ Stefans Live-Test (5 Screenshots), alles umgesetzt: 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. + +## 25.07.2026 (5) -- dritte Feedback-Runde: echte GAMEBAR, Pixel-Slopes, Diverses + +1. **GAMEBAR.PCC ist die echte HUD-Grafik** (320x25, aus PCKELL.DAT -- + Stefans Fund via preview/png_out/GAMEBAR.png): Kellogg's-Leiste, alle 6 + Boxrahmen, sogar der Uhr-":". Die weissen Box-Innenflaechen fuellt die + Engine zur Laufzeit dunkelrot -> wir ersetzen sie beim Laden (Pixel-Loop + rechts der Kellogg's-Leiste). HUD zeichnet nur noch Inhalte an die + gemessenen Box-Positionen (57/105/160/216/254/286). HUD_H jetzt 25, + VIEW_H 175. (Mit W1-Palette statt eigener wird die Grafik magenta -- + getestet und verworfen.) +2. **Slopes jetzt PIXELGENAU**: pro Slope-Tile (ctype 16) und Spalte wird + der oberste Nicht-Himmel-Pixel der Tile-Grafik als Laufflaeche genommen + (slope_profiles; Sky-Index = haeufigster Pixel des haeufigsten Luft-Tiles + -- NICHT der ersten Luft-Zelle, das war Tile 0/Randfueller). Die Haenge + sind tatsaechlich ~1:2 geneigt, nicht 45 Grad -- daher das restliche + "Schweben" beim Drueberlaufen. Tiles mit reiner Himmel-Spalte tragen + nicht (die oberen der gestapelten Hang-Tiles inkl. Tile 1+ctype16). + Drop-Test: Landung exakt auf Grafikkante (626.0 = Kante 626). +3. **STEP_UP=6**: die untersten 6px der Hitbox blocken nicht an Waenden -- + der 4px-Rest an der Plateau-Kante wird hochgestept (hohe Waende blocken + weiter ueber die oberen Hitbox-Zeilen, verifiziert). +4. **Wasser-Grund**: unterste Map-Zeile ist jetzt immer solide (der gruene + Bodenstreifen hat keine ctypes -- Smacks hing beim Tieftauchen in der + Grafik). Smacks-Grund jetzt 736 = Streifen-Oberkante. +5. **Smacks-Steh-Frames 12/13** (0/1 sind Schrittphasen -- "beim Anhalten + lief die Animation weiter"). + +Verifiziert: Slope-Drop pixelgenau, Plateau-Aufstieg + Weiterlauf, Wand- +Integritaet, Smacks idle=12, --once exit 0, E2E rc=0. diff --git a/game/level.py b/game/level.py index 9f832f1..6a52041 100644 --- a/game/level.py +++ b/game/level.py @@ -28,8 +28,8 @@ import pygame from . import intro from .formats import parse_map, parse_ico, parse_bob, get_world_palette, get_bob_palette -VIEW_W, VIEW_H = 320, 176 -HUD_H = 24 +VIEW_W, VIEW_H = 320, 175 +HUD_H = 25 # GAMEBAR.PCC ist exakt 320x25 TILE = 16 WALK_SPEED = 110.0 @@ -71,7 +71,9 @@ CHAR_DEFS = { bob='SMACKS', can_run=False, can_swim=True, walk_r=[0, 2, 4, 6, 8, 10, 12, 14], walk_l=[1, 3, 5, 7, 9, 11, 13, 15], run_r=None, run_l=None, - idle_r=0, idle_l=1, jump_r=24, jump_l=25, duck_r=18, duck_l=19, + # Idle 12/13: Smacks' echte Steh-Frames (0/1 sind Schrittphasen -- + # Stefans Bug "beim Anhalten wird noch das Laufen-Sprite angezeigt") + idle_r=12, idle_l=13, 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 @@ -160,8 +162,39 @@ class Level: self.surface = self.surface.convert() self.palette = palette + # PIXELGENAUE Slope-Laufflaechen (Stefans "steht in der Luft"-Bug, + # 2. Runde): statt einer 45-Grad-Annahme wird pro Slope-Tile (ctype + # 16) und Spalte der oberste Nicht-Himmel-Pixel der TILE-GRAFIK als + # Bodenhoehe genommen -- die Figur laeuft exakt auf der Graskante. + # Spalten, die komplett Himmel sind (z.B. im oberen von zwei + # gestapelten Hang-Tiles), tragen nicht (Profil=TILE -> kein Boden). + # Sky-Index: haeufigster Pixelwert des HAEUFIGSTEN Luft-Tiles (nicht + # der ersten Luft-Zelle -- die kann ein Randfueller-Tile sein). + from collections import Counter + air_tiles = Counter(t for t, ct in self.cells if ct == 0 and t < len(tiles)) + sky = None + if air_tiles: + common_tile = air_tiles.most_common(1)[0][0] + sky = Counter(tiles[common_tile]).most_common(1)[0][0] + self.slope_profiles = {} + for tile, ct in self.cells: + if ct == SLOPE_CT and tile < len(tiles) and tile not in self.slope_profiles: + grid = tiles[tile] + prof = [] + for x in range(TILE): + top = TILE + for y in range(TILE): + if grid[y * TILE + x] != sky: + top = y + break + prof.append(top) + self.slope_profiles[tile] = prof + def ctype(self, tx, ty): - if tx < 0 or ty < 0 or tx >= self.width or ty >= self.height: + if tx < 0 or ty < 0 or tx >= self.width or ty >= self.height - 1: + # Rand ist solide; die UNTERSTE Zeile ebenfalls (dort liegt der + # Grafik-Bodenstreifen ohne eigene ctype-Werte -- Stefans Bug + # "beim Tieftauchen haengt die Figur im Boden"). return SOLID_BIT return self.cells[ty * self.width + tx][1] @@ -169,19 +202,20 @@ class Level: return bool(self.ctype(tx, ty) & SOLID_BIT) def slope_surface(self, px, ty): + '''Pixelgenaue Bodenhoehe im Slope-Tile (ctype 16) an Pixel-x px -- + oberster Nicht-Himmel-Pixel der Tile-Grafik (siehe slope_profiles). + None, wenn dort kein Slope-Tile liegt oder die Spalte reiner Himmel + ist (dann traegt erst ein tieferes Tile).''' tx = int(px // TILE) if self.ctype(tx, ty) != SLOPE_CT: return None - fx = px - tx * TILE - rising_right = (self.ctype(tx + 1, ty - 1) == SLOPE_CT or - self.solid(tx + 1, ty - 1) or self.solid(tx + 1, ty)) - rising_left = (self.ctype(tx - 1, ty - 1) == SLOPE_CT or - self.solid(tx - 1, ty - 1) or self.solid(tx - 1, ty)) - if rising_right and not rising_left: - return (ty + 1) * TILE - 1 - fx - if rising_left and not rising_right: - return ty * TILE + fx - return (ty + 1) * TILE - 1 - fx + prof = self.slope_profiles.get(self.cells[ty * self.width + tx][0]) + if prof is None: + return (ty + 1) * TILE - 1 + top = prof[min(TILE - 1, max(0, int(px - tx * TILE)))] + if top >= TILE: + return None + return ty * TILE + top def in_water(self, py): wy = self.info.get('water_y') @@ -278,16 +312,20 @@ class Player: self.idle_t = 0.0 self.vy = min(self.vy + GRAVITY * dt, 480.0) - # X + Wandkollision + # X + Wandkollision. STEP_UP: die untersten Pixel der Hitbox blocken + # nicht -- kleine Stufen (z.B. der 4px-Rest an der Plateau-Kante ueber + # einem Hang) werden durchlaufen und vom Boden-Code hochgehoben. + STEP_UP = 6 nx = self.x + self.vx * dt r = self.rect() + wall_bottom = (r.bottom - 1 - (STEP_UP if self.on_ground else 0)) // TILE if self.vx > 0: tx = int((nx + self.HITBOX_W / 2) // TILE) - if any(level.solid(tx, ty) for ty in range(r.top // TILE, (r.bottom - 1) // TILE + 1)): + if any(level.solid(tx, ty) for ty in range(r.top // TILE, wall_bottom + 1)): nx = tx * TILE - self.HITBOX_W / 2 - 0.01 elif self.vx < 0: tx = int((nx - self.HITBOX_W / 2) // TILE) - if any(level.solid(tx, ty) for ty in range(r.top // TILE, (r.bottom - 1) // TILE + 1)): + if any(level.solid(tx, ty) for ty in range(r.top // TILE, wall_bottom + 1)): nx = (tx + 1) * TILE + self.HITBOX_W / 2 + 0.01 self.x = nx @@ -377,18 +415,15 @@ class Player: class Hud: - '''HUD-Balken nach Stefans Referenz-Screenshot: Kellogg's-Leiste, - Keks-Box MIT Item-Icon (ITE0-3 je Charakter), Item-Box, Score-Box, - Herz-Box (2x2-GRID), Charakter-Kopf (WECH, zeigt IMMER die aktuell - gewaehlte Figur) + Leben, Uhr.''' + '''HUD auf Basis der ECHTEN GAMEBAR.PCC (320x25, liegt im PCKELL.DAT -- + Stefans Fund). Die Grafik liefert Kellogg's-Leiste, Boxrahmen und sogar + den ":" der Uhr; die weissen Box-Innenflaechen fuellt die Original-Engine + zur Laufzeit dunkelrot (wir ebenso, per Pixel-Ersetzung rechts der + Kellogg's-Leiste). Inhalte an den gemessenen Box-Positionen: + [57-99 Icon+Kekse][105-154 Item][160-210 Score][216-248 Herzen 2x2] + [254-280 Kopf+Leben][286-315 Uhr].''' - # 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) + BOX_FILL = (146, 10, 10) def __init__(self, assets): from .menu import BitmapFont, _mapping_from_rows @@ -397,13 +432,25 @@ class Hud: palette = get_bob_palette(container.entries, 'GAMEBAR') self.icons = _frames_to_surfaces(frames, palette) self.heart_full = self.icons[10] - self.heart_empty = self.icons[11] self.font = BitmapFont(assets.pcc_surface('FON_1B'), 9, 7, _mapping_from_rows(['1234567890;:!"%/()=?,.-\x7f#']), spacing=8) - logo = assets.pcc_surface('KELLOGGS').subsurface((36, 26, 252, 66)) - self.kellogg = pygame.transform.smoothscale(logo, (50, 13)) - # Item-Icons (Cerealien) + Charakter-Koepfe klein + # GAMEBAR.PCC vorbereiten: Innenflaechen der 6 Boxen dunkelrot, + # der Grafik-":" (Uhr-Box) gelb wie die Ziffern. + bar = assets.pcc_surface('GAMEBAR').convert() + import pygame.surfarray as sa + arr = sa.pixels3d(bar) + w = arr.shape[0] + for x in range(56, w): + col = arr[x] + for y in range(col.shape[0]): + r, g, b = col[y] + if r > 200 and g > 200 and b > 200: + col[y] = self.BOX_FILL + elif 292 <= x <= 302 and r > 150 and g < 100: + col[y] = (255, 222, 0) # ":"-Punkte der Uhr + del arr + self.bar = bar self.item_icons = {n: intro.make_border_bg_transparent(assets.pcc_surface(n)) for n in ('ITE0', 'ITE1', 'ITE2', 'ITE3')} self.heads_small = [] @@ -411,47 +458,32 @@ class Hud: head = intro.make_border_bg_transparent(assets.pcc_surface(f'WECH_{i}')) self.heads_small.append(pygame.transform.smoothscale(head, (17, 16))) - def _box(self, canvas, 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)) - box_y, box_h = y0 + 3, 18 + canvas.blit(self.bar, (0, y0)) 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) + # Box 1: Cerealien-Icon des aktiven Charakters + Keks-Zaehler icon = self.item_icons.get(char_def['item_icon']) if icon: - 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, 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) + canvas.blit(icon, (59, y0 + 4)) + self._text(canvas, 76, text_y, f'{cookies:03d}') + # Box 2 (105-154): Item -- noch leer + # Box 3: Score + self._text(canvas, 161, text_y, f'{score:06d}') + # Box 4: Herzen 2x2 (leere Plaetze bleiben dunkel) 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 + canvas.blit(self.heart_full, (221 + (i % 2) * 11, y0 + 4 + (i // 2) * 9)) + # Box 5: Charakter-Kopf (immer die gewaehlte Figur) + Leben + canvas.blit(self.heads_small[char_def['head_index']], (254, y0 + 4)) + self._text(canvas, 272, text_y, str(lives)) + # Box 6: Uhr (der Grafik-":" ist rot auf dunkelrot kaum sichtbar -- + # eigener gelber ":" per Font drueber) m, s = divmod(max(0, int(time_left)), 60) - self._box(canvas, 284, box_y, 34, box_h) - self._text(canvas, 287, text_y, f'{m}:{s:02d}') + self._text(canvas, 288, text_y, str(m)) + self._text(canvas, 294, text_y, ':') + self._text(canvas, 301, text_y, f'{s:02d}') def run_character_select(clock, assets, available, current):