"""Level-Gameplay: Tile-Rendering, Kamera, Tony-Steuerung, Kollision, HUD. Erster spielbarer Gameplay-Slice (Level 1 = W1L1.MAP, per Template-Matching des DOSBox-Gameplay-Screenshots gegen die gerenderten Maps verifiziert -- NICHT W1L0, das ist vermutlich ein Bonus-/Sonderlevel). Referenz-Fakten aus der DOSBox-Aufnahme (25.07.2026): - Auf der Weltkarte startet SPACE das Level (Return tut dort nichts, Pfeiltasten bewegen keinen sichtbaren Cursor -- am Spielstart ist nur Level 1 verfuegbar). Kurzer Schwarzblende-Uebergang, dann steht das Level sofort (kein "Get Ready"-Screen). - Viewport: 320x176 (oben), HUD-Balken unten 320x24 (y=176..200). - HUD: Kellogg's-Schriftzug, Keks-Zaehler (000), Item-Box, Score (000000), 4 Herzen, Tony-Kopf mit Lebenszahl (3), Uhr zaehlt von 10:00 runter (9:59 ~1.5s nach Levelstart beobachtet). - Tony spawnt im Bereich um Tile ~17 (Kamera-Match bei Map-Pixel 128,496), steht auf der "Saeulen"-Plattform (Zeile 40). Kollision (aus dem 7-Bit-Typfeld der MAP-Zellen, empirisch an W1L1): Bit 32 = solide (40 = Boden-Oberkante 32|8, 32 = Fuellung darunter, 36/44/41 = Varianten). 0 = Luft, 4 = Item-Marker, 16 = Special (Tuer?). v1 nutzt nur `ctype & 32` als Wand/Boden. Tony-Sprite (TONY.BOB, 53 Frames a 32x56): 8-Phasen-Gehzyklus, GERADE Frame-Indizes = Blick links, UNGERADE = rechts (0..15). Reihe 3 (16..23) Arme hoch (Sprung), Reihe 4 (24..31) Ducken/Rollen. Musik: ONGAME2.TFX buendelt 14 Songs -- Song 0 als v1-Annahme fuer Level 1 (nach Gehoer noch nicht zugeordnet; wie beim Factor5-Sound: Stefan hoert, wir korrigieren den Index bei Bedarf). """ import sys import time 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 # sichtbarer Levelausschnitt HUD_H = 24 # HUD-Balken unten (320x24) TILE = 16 # Physik (v1 -- nach Spielgefuehl gegen die DOSBox-Referenz grob abgestimmt; # Stefans Live-Test 25.07.: "fuehlt sich gut an") WALK_SPEED = 110.0 # px/s RUN_SPEED = 185.0 # px/s -- Tonys Faehigkeit: RENNEN (ALT halten, # wie im Original lt. Anleitung; LSHIFT geht auch) GRAVITY = 900.0 # px/s^2 JUMP_VELOCITY = -300.0 # px/s (Sprunghoehe ~50px = gut 3 Tiles) WALK_ANIM_FPS = 12.0 # 9:59 statt 10:00: die Original-Anzeige stand ~1.5s nach Levelstart schon auf # 9:59, und 4-stellig ("9:59") passt sicher in die Uhr-Box (320px-Zeile). LEVEL_TIME_SECONDS = 599 SOLID_BIT = 32 SLOPE_CT = 16 # ctype 16 = Schraegen-Tile (Grashaenge) -- # als blaue Diagonale in der ctype-Visualisierung # exakt auf dem Hang der DOSBox-Referenz gefunden # ("faellt durch"-Bug von Stefans Live-Test) # TONY.BOB-Frame-Zuordnung. KORREKTUR (Stefans Live-Test 25.07.2026: "Figur # laeuft rueckwaerts, Richtung stimmt"): GERADE Indizes sind RECHTS, # UNGERADE LINKS -- genau andersrum als zuerst angenommen. IDLE_R, IDLE_L = 0, 1 WALK_R = [0, 2, 4, 6, 8, 10, 12, 14] WALK_L = [1, 3, 5, 7, 9, 11, 13, 15] JUMP_R, JUMP_L = 16, 17 DUCK_R, DUCK_L = 24, 25 def _frames_to_surfaces(frames, palette): '''BOB-Frames (Palette-Indizes, -1 = transparent) -> pygame-Surfaces.''' out = [] for fr in frames: surf = pygame.Surface((fr.width, fr.height), pygame.SRCALPHA) for y in range(fr.height): for x in range(fr.width): idx = fr.pixels[y * fr.width + x] if 0 <= idx <= 255: r, g, b = palette[idx] surf.set_at((x, y), (r, g, b, 255)) out.append(surf) return out class Level: '''Laedt eine MAP (+ zugehoeriges Welt-ICO/-Palette) und rendert sie einmalig komplett auf eine grosse Surface -- die Kamera blittet daraus den sichtbaren Ausschnitt (W1L1: 3488x752 px, als Vollbild-Surface problemlos).''' def __init__(self, assets, map_name): container = assets.container self.name = map_name world = map_name[:2] # 'W1' self.width, self.height, self.cells = parse_map(container.entries[map_name + '.MAP']) palette = get_world_palette(container.entries, world) tiles = parse_ico(container.entries[world + '.ICO']) # Tiles einmalig zu Surfaces tile_surfs = [] for grid in tiles: s = pygame.Surface((TILE, TILE)) for y in range(TILE): for x in range(TILE): idx = grid[y * TILE + x] r, g, b = palette[idx & 0xFF] s.set_at((x, y), (r, g, b)) tile_surfs.append(s.convert()) # ganze Map vorrendern self.surface = pygame.Surface((self.width * TILE, self.height * TILE)) self.surface.fill((0, 0, 0)) for i, (tile, _ct) in enumerate(self.cells): if tile < len(tile_surfs): x = (i % self.width) * TILE y = (i // self.width) * TILE self.surface.blit(tile_surfs[tile], (x, y)) self.surface = self.surface.convert() self.palette = palette def ctype(self, tx, ty): if tx < 0 or ty < 0 or tx >= self.width or ty >= self.height: return SOLID_BIT # ausserhalb = solide (haelt Tony im Level) return self.cells[ty * self.width + tx][1] def solid(self, tx, ty): return bool(self.ctype(tx, ty) & SOLID_BIT) def slope_surface(self, px, ty): '''Boden-y der 45-Grad-Schraege im Slope-Tile (ctype 16) der Zeile ty an Pixel-x px -- oder None, wenn dort kein Slope-Tile liegt. Steigungsrichtung per Nachbar-Heuristik: schliesst rechts-oberhalb ein weiteres Slope-/Solid-Tile an, steigt die Diagonale nach rechts (Flaeche faellt nach links ab), sonst gespiegelt.''' tx = int(px // TILE) if self.ctype(tx, ty) != SLOPE_CT: return None fx = px - tx * TILE # 0..15 im 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 # links unten -> rechts oben if rising_left and not rising_right: return ty * TILE + fx # links oben -> rechts unten return (ty + 1) * TILE - 1 - fx # Default: nach rechts steigend class Tony: '''Spielfigur: Position (px, Fusspunkt-basiert), einfache AABB-Physik gegen das Tile-Grid.''' HITBOX_W = 20 HITBOX_H = 52 def __init__(self, assets, spawn_px): container = assets.container frames = parse_bob(container.entries['TONY.BOB']) palette = get_bob_palette(container.entries, 'TONY') self.surfs = _frames_to_surfaces(frames, palette) self.x, self.y = float(spawn_px[0]), float(spawn_px[1]) # Fussmitte self.vx = 0.0 self.vy = 0.0 self.on_ground = False self.facing = 1 # 1 = rechts, -1 = links self.ducking = False self.anim_t = 0.0 def rect(self): return pygame.Rect(int(self.x - self.HITBOX_W / 2), int(self.y - self.HITBOX_H), self.HITBOX_W, self.HITBOX_H) def update(self, level, dt, keys): self.ducking = self.on_ground and keys[pygame.K_DOWN] move = 0 if not self.ducking: if keys[pygame.K_LEFT]: move -= 1 if keys[pygame.K_RIGHT]: move += 1 # Tonys Faehigkeit: RENNEN -- ALT halten (wie im Original laut # Anleitung "MIT ALT AKTIVIERST DU DIE FAEHIGKEITEN"), LSHIFT als # moderne Alternative. (Alt+Enter bleibt Vollbild -- kein Konflikt, # das faengt die Eventschleife ab.) mods = pygame.key.get_mods() running = bool(mods & (pygame.KMOD_ALT | pygame.KMOD_SHIFT)) self.vx = move * (RUN_SPEED if running else WALK_SPEED) if move: self.facing = move self.anim_t += dt * (1.5 if running else 1.0) else: self.anim_t = 0.0 if self.on_ground and not self.ducking and (keys[pygame.K_SPACE] or keys[pygame.K_UP]): self.vy = JUMP_VELOCITY self.on_ground = False self.vy = min(self.vy + GRAVITY * dt, 480.0) # X-Bewegung + Wandkollision nx = self.x + self.vx * dt r = self.rect() if self.vx > 0: edge = nx + self.HITBOX_W / 2 tx = int(edge // TILE) if any(level.solid(tx, ty) for ty in range(r.top // TILE, (r.bottom - 1) // TILE + 1)): nx = tx * TILE - self.HITBOX_W / 2 - 0.01 elif self.vx < 0: edge = nx - self.HITBOX_W / 2 tx = int(edge // TILE) if any(level.solid(tx, ty) for ty in range(r.top // TILE, (r.bottom - 1) // TILE + 1)): nx = (tx + 1) * TILE + self.HITBOX_W / 2 + 0.01 self.x = nx # Y-Bewegung + Boden-/Deckenkollision ny = self.y + self.vy * dt left_tx = int((self.x - self.HITBOX_W / 2) // TILE) right_tx = int((self.x + self.HITBOX_W / 2 - 1) // TILE) if self.vy >= 0: ty = int(ny // TILE) if any(level.solid(tx, ty) for tx in range(left_tx, right_tx + 1)): ny = ty * TILE - 0.01 self.vy = 0.0 self.on_ground = True else: self.on_ground = False else: top = ny - self.HITBOX_H ty = int(top // TILE) if any(level.solid(tx, ty) for tx in range(left_tx, right_tx + 1)): ny = (ty + 1) * TILE + self.HITBOX_H + 0.01 self.vy = 0.0 # Schraegen (ctype 16): Boden folgt der 45-Grad-Diagonale. Am # FUSSMITTELPUNKT geprueft (nicht an den Hitbox-Kanten -- so "schwebt" # Tony nicht an der Hangkante). Zeile darueber mitpruefen, damit er # beim Bergauflaufen ins naechsthoehere Slope-Tile aufsteigt; Snap nur # bei fallender/stehender Bewegung und kleiner Distanz (kein # Herunterziehen mitten im Sprung). if self.vy >= 0: foot_ty = int(ny // TILE) for ty2 in (foot_ty - 1, foot_ty, foot_ty + 1): surf = level.slope_surface(self.x, ty2) if surf is not None and ny >= surf - 10: ny = surf - 0.01 self.vy = 0.0 self.on_ground = True break self.y = ny def current_frame(self): right = self.facing > 0 if self.ducking: i = DUCK_R if right else DUCK_L elif not self.on_ground: i = JUMP_R if right else JUMP_L elif self.vx: seq = WALK_R if right else WALK_L i = seq[int(self.anim_t * WALK_ANIM_FPS) % len(seq)] else: i = IDLE_R if right else IDLE_L if i < len(self.surfs): return self.surfs[i] return self.surfs[0] def draw(self, screen, cam_x, cam_y): surf = self.current_frame() # Fusspunkt-zentriert x = int(self.x - surf.get_width() / 2 - cam_x) y = int(self.y - surf.get_height() - cam_y) screen.blit(surf, (x, y)) class Hud: '''HUD-Balken (320x24) -- v1-Nachbildung des Original-Layouts. Panel (dunkelrote Boxen mit Goldrand) selbst gezeichnet; Zahlen in der kleinen FON-Schrift mit Outline (die Original-HUD-Ziffern sind noch keinem Asset sauber zugeordnet -- die GAMEBAR.BOB-Ziffern rendern mit der bob-Regel- Palette dunkelrot statt weiss, vermutlich andere Laufzeit-Palette). Die GAMEBAR-Schluessel-/Herz-Icons (Frames 10-16) liegen fuer den spaeteren Feinschliff schon in self.icons bereit.''' GOLD = (222, 186, 24) RED = (150, 14, 14) DARK = (84, 6, 6) WHITE = (248, 248, 248) def __init__(self, assets): from .menu import BitmapFont, _mapping_from_rows container = assets.container frames = parse_bob(container.entries['GAMEBAR.BOB']) palette = get_bob_palette(container.entries, 'GAMEBAR') self.icons = _frames_to_surfaces(frames, palette) self.heart_full = self.icons[10] # 9x8, pink/rot self.heart_empty = self.icons[11] self.font = BitmapFont(assets.pcc_surface('FON_1B'), 9, 7, _mapping_from_rows(['1234567890;:!"%/()=?,.-\x7f#']), spacing=8) # Kellogg's-Schriftzug fuer die weisse HUD-Leiste links (Stefans # Feedback: "die weisse Kellogg's-Leiste fehlt"): aus KELLOGGS.PCC # gecroppt (Schriftzug ohne "praesentiert") und klein skaliert. logo = assets.pcc_surface('KELLOGGS').subsurface((36, 26, 252, 66)) self.kellogg = pygame.transform.smoothscale(logo, (50, 13)) 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)) def _text(self, canvas, x, y, text): surf = self.font.render(text, outline=(0, 0, 0)) canvas.blit(surf, (x, y)) def draw(self, canvas, y0, cookies, score, hearts, lives, time_left): pygame.draw.rect(canvas, self.DARK, (0, y0, 320, HUD_H)) pad_y = y0 + 8 # Kellogg's-Leiste (weiss, abgerundet) ganz links pygame.draw.rect(canvas, self.WHITE, (2, y0 + 3, 58, HUD_H - 6), border_radius=6) canvas.blit(self.kellogg, (6, y0 + 5)) # Keks-Zaehler self._box(canvas, 66, pad_y - 3, 32, 14) self._text(canvas, 70, pad_y, f'{cookies:03d}') # Item-Box (leer) self._box(canvas, 104, pad_y - 3, 38, 14) # Score self._box(canvas, 148, pad_y - 3, 56, 14) self._text(canvas, 152, pad_y, f'{score:06d}') # Herzen (echte GAMEBAR-Sprites) for i in range(4): icon = self.heart_full if i < hearts else self.heart_empty canvas.blit(icon, (210 + i * 11, pad_y - 1)) # Leben self._box(canvas, 259, pad_y - 3, 18, 14) self._text(canvas, 264, pad_y, str(lives)) # Uhr m:ss (Format 4-stellig, Startzeit 9:59 -> passt immer) m, s = divmod(max(0, int(time_left)), 60) self._box(canvas, 283, pad_y - 3, 35, 14) self._text(canvas, 286, pad_y, f'{m}:{s:02d}') def run_level(clock, assets, audio_paths, map_name='W1L1', spawn_tile=(17, 40)): '''Spielt ein Level. ESC -> zurueck zur Karte ('map'), Fenster-X -> 'quit'. v1: Laufen/Springen/Ducken + Kollision + Kamera + HUD + Levelmusik. Noch ohne Gegner/Items/Fahigkeiten -- die folgen schrittweise.''' level = Level(assets, map_name) tony = Tony(assets, (spawn_tile[0] * TILE + 8, spawn_tile[1] * TILE - 0.01)) hud = Hud(assets) # Levelmusik: ONGAME2 Song 0 (v1-Annahme, siehe Docstring). Loopt. if pygame.mixer.get_init(): path = audio_paths.get(('ONGAME2', None)) if path: try: pygame.mixer.music.load(path) pygame.mixer.music.play(-1) except Exception as exc: print(f'[level] Levelmusik konnte nicht geladen werden: {exc}', file=sys.stderr) canvas = pygame.Surface((320, 200)) start = time.monotonic() map_w_px = level.width * TILE map_h_px = level.height * TILE while True: dt = clock.tick(60) / 1000.0 dt = min(dt, 1 / 20) # Physik-Schutz bei Rucklern for event in pygame.event.get(): if event.type == pygame.QUIT: return 'quit' if event.type == pygame.KEYDOWN: if event.key == pygame.K_RETURN and (event.mod & pygame.KMOD_ALT): intro.toggle_fullscreen() continue if event.key == pygame.K_ESCAPE: return 'map' # wie im Original: ESC = zurueck zur Karte keys = pygame.key.get_pressed() tony.update(level, dt, keys) # Kamera: Tony zentriert, an Levelraender geklemmt cam_x = max(0, min(int(tony.x) - VIEW_W // 2, map_w_px - VIEW_W)) cam_y = max(0, min(int(tony.y) - VIEW_H * 2 // 3, map_h_px - VIEW_H)) canvas.blit(level.surface, (0, 0), (cam_x, cam_y, VIEW_W, VIEW_H)) tony.draw(canvas, cam_x, cam_y) time_left = LEVEL_TIME_SECONDS - (time.monotonic() - start) hud.draw(canvas, VIEW_H, cookies=0, score=0, hearts=4, lives=3, time_left=time_left) intro._blit_centered(intro.build_letterboxed(canvas)) pygame.display.flip()