"""Level-Gameplay: Tile-Rendering, Kamera, Spielfiguren, Kollision, HUD. Basis-Referenzen: DOSBox-Aufnahmen (Xvfb+XTEST) + Stefans annotierte Live-Screenshots (25.07.2026). Wichtigste Fakten: - Level 1 = W1L1.MAP (Template-Matching). Viewport 320x176, HUD 320x24. - Kollision: MAP-ctype Bit 32 = solide, ctype 16 = 45-Grad-Schraege. - WASSER existiert (nur mit DOSBox machine=vgaonly sichtbar -- klassischer VGA-Palette-Effekt): dunkles Blau unterhalb der Wasserlinie; wer drin steht, verliert alle 5s ein Herz. SMACKS kann schwimmen (kein Schaden). - Pro Level ist hinterlegt, welche Charaktere waehlbar sind (Level 1: nur TONY und SMACKS) -- Quelle im Original vermutlich .ARE (Format noch nicht geknackt, siehe NOTES), hier v1 als LEVEL_INFO-Konstanten. - Tuer mit Kopf-Symbol: ALT im STEHEN davor oeffnet das Charakter- Auswahlmenue ("MIT WEM MOECHTEST DU SPIELEN?", WECH_1-4 = Koepfe farbig, WECH_5-8 = grau/nicht verfuegbar). ALT beim Laufen = RENNEN (nur Tony). Frame-Zuordnungen (nummerierte Sheets gesichtet): TONY.BOB (53): 0-15 Gehzyklus (gerade=RECHTS), 18/19 Sprung(gestreckt), 24/25 Ducken, 26-33 RENN-"Rad" (4 Phasen x 2), 44-49 Idle-Packung (3x2). SMACKS.BOB (51): 0-15 Gehzyklus, 18/19 Ducken(kauern), 24/25 Sprung, 31-36 Idle-Packung, 37-44 SCHWIMMEN (4 Phasen x 2), 45-47 Wasserblasen. """ 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, 175 HUD_H = 25 # GAMEBAR.PCC ist exakt 320x25 TILE = 16 WALK_SPEED = 110.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. # Nachjustiert (Stefans Referenz am Haus in W1L1): Tonys Kopf muss am # Scheitel den AST (~y505-516) erreichen, Smacks' Kopf das darunter # haengende BLATT (~y511-526) -- gleiche Sprungkraft, verschiedene # Koerpergroessen (56 vs 44 px), wie im Original (-0x18000 fuer alle). # Mit SPRITE_SINK=4 und Diskretisierung braucht das ~88px Scheitelhoehe # (2. Feinjustage von Stefan: "Smacks bis Blatt, Tony bis Ast"). JUMP_VELOCITY = -400.0 # Sprung-WEITE (Stefans Live-DOSBox-Vergleich: "eine Blattlaenge weiter"): # in der Luft laeuft die Figur horizontal schneller als am Boden. # Gemessen: Faktor 1.27 = 114px Sprungweite (92px + ~1 Blatt/Tile + Rundung). AIR_SPEED_FACTOR = 1.27 SWIM_GRAVITY = 240.0 # Smacks im Wasser: traege sinken SWIM_KICK = -140.0 # Schwimmstoss mit Space WALK_ANIM_FPS = 17.5 # Original: delay 4 Ticks @70Hz (Binary-Tabelle) RUN_ANIM_FPS = 17.5 # Rad hat dasselbe delay 4 IDLE_ANIM_AFTER = 4.0 # Sekunden Stillstand bis zur Idle-Animation # (DOSBox-Timeline: Schachtel nach ~4s) IDLE_ANIM_FPS = 2.8 # Original: delay 25 Ticks @70Hz WATER_DAMAGE_EVERY = 3.8 # im Wasser: NACHGEMESSEN am Original (w1l1_run: # Herzverlust alle 13 Frames a 0.29s = 3.77s); # das ERSTE Herz kostet der Eintritt sofort SPAWN_ANIM_SECONDS = 1.1 # Start-Sprite beim Levelstart/Charakterwechsel SPRITE_SINK = 4 # Fuesse sinken optisch in die Blaetter/Gras ein # (Stefans "Figuren schweben ein paar Pixel") WATER_WADE_DEPTH = 14 # Nicht-Schwimmer sinken nur bis hierhin ein # (Original: Tony "springt auf der Wasser- # oberflaeche", taucht NICHT unter) WATER_BOUNCE_V = -110.0 # Auto-Huepfer auf der Wasseroberflaeche # (Endlosschleife, Stefans Beschreibung); # manueller Sprung (voll) geht trotzdem DEATH_ANIM_SECONDS = 2.4 # Tuch faellt, Figur fliegt aus dem Bild JUMP_SQUAT_SECONDS = 0.09 # Absprung-Hocke (DOSBox-Referenz: kurzer # Kauer-Frame im Absprungmoment, dann Streckung) SWIM_ANIM_FPS = 7.0 # Schwimmstoss [10 Ticks] = 7 fps SWIM_UP_SPEED = 85.0 # sanftes Auftauchen mit Pfeil hoch SWIM_DOWN_SPEED = 110.0 # sanftes Abtauchen mit Pfeil runter SWIM_STEER_ACCEL = 420.0 # wie schnell die Tauchrichtung anspricht FLAP_ANIM_FPS = 5.8 # Fluegelschlag [12 Ticks] FLAP_DECAY = 48.0 # jeder Schlag wird schwaecher (Original +0x2000) FLAP_MIN = -70.0 # schwaecher als das -> kein Schlag mehr (Original -0x4000) GLIDE_FALL_MAX = 55.0 # Sinkgeschwindigkeit im Gleitflug IDLE_WAIT_FPS = 2.8 # Idle-Warteanimation [25 Ticks] SOLID_BIT = 32 SLOPE_CT = 16 # --- SLOT-System (komplett aus dem Binary, NOTES Eintrag (18)) ------------- # Jeder BOB-Frame traegt seine SLOT-Nummer + Anker im Header. Regel aus # set_anim (Seg3:0x4cad): sprite = slot; Blickrichtung links -> slot + 50. # Die Slot-Belegung ist fuer ALLE Charaktere gleich; welche Faehigkeiten ein # Charakter hat, ergibt sich daraus, WELCHE Slots sein BOB enthaelt # (nur Tony hat 13-16 = rennen, nur Smacks/Toucan 25-28 = schwimmen, # nur Toucan 29-31 als Richtungs-Paare = fliegen, nur Coco 35-44 = hangeln). # Steh-/Sprung-Posen (Stefans Screenshots + Zuordnung 26.07.2026): # - Am SPAWN (noch nie bewegt) steht das Original FRONTAL: Slot 17. # - Nach Bewegung steht es SEITLICH in Blickrichtung: Slot 0 (Beine # zusammen, zugleich erster Walkframe). # - Slot 8 (BOB 16) = SPRUNG AUFWAERTS, Slot 9 (BOB 18) = FALLEN # (Stefans Zuordnung: "016.png bis oben, 018.png beim Runterfallen"). SLOT_STAND_FRONT = 17 # Slot 10 (BOB 20) = seitliche Steh-Pose (Stefans Fund: "020.png bei Tony # UND Smacks"). Passt: Spawn-Tabelle [70][10,11,10] = stehen/Arm hoch/ # stehen, und die Schachtel-Idle endet mit [...,10x5] = zurueck zum Stehen. SLOT_STAND_SIDE = 10 SLOT_RISE = 8 SLOT_IDLE = SLOT_STAND_SIDE # Fallback-Slot (existiert bei allen Chars) SLOT_JUMP = 9 # Springen/Fallen SLOT_ARMUP = 11 # Arm hoch / Daumen hoch (Spawn-Pose, Tabelle [70][10,11,10]) SLOT_DUCK = 12 SLOT_STAND_ALT = 18 # zweite Frontal-Pose (Blinzeln/Variante) SLOT_DEATH = 19 # Tod (19, Sturz 20) SLOT_SWIM_IDLE = 25 # Treiben im Wasser (erster Schwimm-Frame; 22-24 sind # die "Schachtel essen"-Posen der Idle-Warteanimation) SLOT_GLIDE = 34 # Toucan: Gleiten (Tabelle [4][34,34,34], Flag 0x20) SLOT_PEEK = 45 # In-die-Tuer-gucken (Einzelframe, alle Charaktere) # Fallendes Accessoire beim Tod: die flachen 32x16-Frames (Tony: Halstuch # BOB 40, Smacks: Cappy BOB 29 -- beide frueher von Stefan abgenommen). # Die 16x16-Slots 48/49 sind es NICHT (lila Kloetzchen, Zweck noch unklar). SLOT_DROP1, SLOT_DROP2 = 20, 21 # Animations-Tabellen (Original: [delay Ticks@70Hz][slots...]): ANIM_WALK = [0, 1, 2, 3, 4, 5, 6, 7] # [4] -> 17.5 fps ANIM_RUN = [13, 14, 15, 16] # [4] ANIM_SWIM = [25, 26, 27, 28] # [10] Schwimmstoss ANIM_FLAP = [29, 30, 31, 30] # [12] Toucan-Fluegelschlag ANIM_IDLE_WAIT = [23, 24, 23, 22, 23, 22, 23, 22, 23, 24, 23, 22, 23, 10, 10, 10, 10, 10] # [25] CHAR_DEFS = { 'TONY': dict(bob='TONY', item_icon='ITE0', head_index=0), 'SMACKS': dict(bob='SMACKS', item_icon='ITE1', head_index=1), 'COCO': dict(bob='COCO', item_icon='ITE3', head_index=2), 'TOUCAN': dict(bob='TOUCAN', item_icon='ITE2', head_index=3), } CHAR_ORDER = ['TONY', 'SMACKS', 'COCO', 'TOUCAN'] # Level-Metadaten (v1 als Konstanten -- die Quelle im Original ist vermutlich # das noch nicht geknackte .ARE-Format; dessen Header enthaelt Pixel- # Koordinatenpaare, siehe NOTES). LEVEL_INFO = { 'W1L1': dict( spawn=(17, 40), time=599, chars=['TONY', 'SMACKS'], # Level 1: nur die beiden (Stefan) water_y=42 * TILE, # Wasseroberflaeche unter der Bruecke door_px=(12 * TILE, 37 * TILE, 2 * TILE, 3 * TILE), # Haus-Tuer (x,y,w,h) ), } WATER_TINT = (10, 20, 120, 110) # dunkelblaues Overlay def _frames_to_surfaces(frames, palette): 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: '''MAP+ICO einmal komplett vorrendern; Kamera blittet den Ausschnitt.''' def __init__(self, assets, map_name): container = assets.container self.name = map_name self.info = LEVEL_INFO.get(map_name) world = map_name[:2] 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']) 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()) 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): self.surface.blit(tile_surfs[tile], ((i % self.width) * TILE, (i // self.width) * TILE)) self.surface = self.surface.convert() self.palette = palette # Level ohne handgepflegte LEVEL_INFO (alle ausser W1L1): Spawn # generisch suchen -- erste Spalte mit Boden und Luft darueber. if self.info is None: spawn = (3, 5) for tx in range(2, self.width - 2): for ty in range(2, self.height - 1): if (self.cells[ty * self.width + tx][1] & SOLID_BIT and self.cells[(ty - 1) * self.width + tx][1] == 0 and self.cells[(ty - 2) * self.width + tx][1] == 0): spawn = (tx, ty) break else: continue break self.info = dict(spawn=spawn, time=599, chars=['TONY', 'SMACKS'], water_y=None, door_px=None) # 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 - 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] def solid(self, tx, ty): 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 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') return wy is not None and py >= wy class Player: '''Spielfigur (Tony/Smacks/...): Fusspunkt-basierte AABB-Physik.''' HITBOX_W = 20 HITBOX_H = 52 def __init__(self, assets, char, pos): self.assets = assets self.vx = 0.0 self.vy = 0.0 self.on_ground = False self.facing = 1 self.ducking = False self.running = False 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.force_slot = None # erzwungener Slot (Tuer-Peek, Tod) self.hurt_t = 0.0 # Rest-Blinkzeit nach Schaden self.jump_squat_t = 0.0 # Absprung-Hocke (kurz nach dem Absprung) self.swim_stroke_t = 0.0 # Rest-Dauer der Schwimmstoss-Animation self.swim_move_t = 0.0 # laufende Schwimmbewegung (links/rechts/tauchen) self.flap_strength = 0.0 # Toucan: aktuelle Fluegelschlag-Kraft self.flap_t = 0.0 # Rest-Dauer der Fluegelschlag-Animation self.gliding = False self.hanging = False # Coco: haengt an einem Ast self.jump_lock = False # Sprung gesperrt, bis Taste losgelassen # (Leertaste aus dem Tuer-Menue "leckte" # sonst als Sofortsprung ins Level) self.moved = False # schon bewegt seit Spawn/Charakterwechsel? # (frontale vs. seitliche Steh-Pose) self._grounded_before = False self.set_character(char, pos) def set_character(self, char, pos=None): self.char = char self.d = CHAR_DEFS[char] frames = parse_bob(self.assets.container.entries[self.d['bob'] + '.BOB']) palette = get_bob_palette(self.assets.container.entries, self.d['bob']) surfs = _frames_to_surfaces(frames, palette) # Slot -> (Surface, Anker) direkt aus den BOB-Headern (Original-Daten) self.slots = {fr.slot: (surf, fr.anchor_x, fr.anchor_y) for fr, surf in zip(frames, surfs)} # Faehigkeiten ergeben sich aus den vorhandenen Slots (wie im Original) self.can_run = 13 in self.slots self.can_swim = 25 in self.slots self.can_fly = 29 in self.slots and 79 in self.slots # nur Paare = Toucan self.can_hang = 35 in self.slots # Coco 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 self.moved = False self.idle_t = 0.0 def sprite(self, slot): '''(Surface, ax, ay) fuer einen Slot; Blickrichtung links = +50 (Original-Regel aus set_anim). Einzel-Frames gibt es nur einseitig.''' if self.facing < 0 and slot < 50 and slot + 50 in self.slots: return self.slots[slot + 50] return self.slots.get(slot) or self.slots[SLOT_IDLE] 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): if self.hurt_t > 0: self.hurt_t -= dt if self.jump_squat_t > 0: self.jump_squat_t -= dt if self.spawn_t > 0: self.spawn_t -= dt # Eingabe bricht die Start-Pose sofort ab -- aber NICHT die noch # gehaltene Bestaetigungstaste aus dem Tuer-Menue (jump_lock), # sonst fehlt nach Leertasten-Wechsel die Arm-hoch/Daumen-Pose if not self.jump_lock and 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.can_swim if self.on_ground: self.gliding = False # Ducken geht NICHT im Wasser (Stefans Regel -- auch nicht fuer # Nicht-Schwimmer, die auf der Oberflaeche huepfen) self.ducking = (self.on_ground and not self.swimming and not in_water 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 mods = pygame.key.get_mods() wants_run = bool(mods & (pygame.KMOD_ALT | pygame.KMOD_SHIFT)) self.running = wants_run and self.can_run and move != 0 and not self.swimming speed = RUN_SPEED if self.running else WALK_SPEED if self.swimming: speed *= 0.7 elif not self.on_ground: speed *= AIR_SPEED_FACTOR # Sprungweite (siehe Konstante) self.vx = move * speed if move: self.facing = move self.moved = True self.anim_t += dt * (RUN_ANIM_FPS / WALK_ANIM_FPS if self.running else 1.0) self.idle_t = 0.0 else: self.anim_t = 0.0 self.idle_t += dt if self.swim_stroke_t > 0: self.swim_stroke_t -= dt if self.flap_t > 0: self.flap_t -= dt jump_pressed = keys[pygame.K_SPACE] or keys[pygame.K_UP] jump_edge = jump_pressed and not getattr(self, '_jump_was_down', False) self._jump_was_down = jump_pressed space_edge = keys[pygame.K_SPACE] and not getattr(self, '_space_was_down', False) self._space_was_down = keys[pygame.K_SPACE] if self.jump_lock: if jump_pressed: jump_pressed = jump_edge = space_edge = False else: self.jump_lock = False if self.swimming: wy = level.info.get('water_y') near_surface = wy is not None and (self.y - wy) < 24 self.gliding = False if keys[pygame.K_SPACE] and near_surface: # Stefans Bug "kommt nicht mehr aus dem Wasser raus": nahe der # Oberflaeche loest LEERTASTE einen ECHTEN Sprung aus (traegt # auf die Plattform), nicht nur einen Schwimmstoss. self.vy = JUMP_VELOCITY self.swimming = False self.flap_strength = JUMP_VELOCITY * (5.0 / 6.0) else: if space_edge: # Schwimmstoss (Impuls + Stoss-Animation) self.vy = min(self.vy, SWIM_KICK) self.swim_stroke_t = len(ANIM_SWIM) / SWIM_ANIM_FPS # Sanftes AUF-/ABTAUCHEN mit Hoch/Runter (Stefans Wunsch: # "nach oben tauchen fuehlt sich wie kleines Springen an") -- # die Geschwindigkeit naehert sich weich einem Ziel, statt # Sprung-Impulse zu stapeln. Ohne Eingabe: traeges Absinken. if keys[pygame.K_UP]: target = 0.0 if near_surface else -SWIM_UP_SPEED elif keys[pygame.K_DOWN]: target = SWIM_DOWN_SPEED else: target = 120.0 if self.vy < target: self.vy = min(self.vy + SWIM_GRAVITY * dt, target) else: self.vy = max(self.vy - SWIM_STEER_ACCEL * dt, target) # Schwimmbewegungs-Animation, solange aktiv geschwommen wird if move or keys[pygame.K_UP] or keys[pygame.K_DOWN]: self.swim_move_t += dt else: self.swim_move_t = 0.0 else: # Coco: Hangeln an Aesten. EXPERIMENTELL: Ast-Tiles = ctype-Bit 8 # (kommt in Baum-Leveln vor; der Original-Code prueft das Tile # auf Kopfhoehe -- endgueltige Verifikation per DOSBox steht aus). head_ty = int((self.y - self.HITBOX_H) // TILE) tx = int(self.x // TILE) if (self.can_hang and not self.hanging and not self.on_ground and keys[pygame.K_UP] and level.ctype(tx, head_ty) & 8): self.hanging = True self.y = head_ty * TILE + TILE + self.HITBOX_H - 0.01 if self.hanging: if (not level.ctype(tx, head_ty) & 8 or keys[pygame.K_DOWN] or self.on_ground): self.hanging = False else: self.vy = 0.0 self.vx *= 0.6 # Hangeln ist langsamer als Laufen if self.on_ground and not self.ducking and jump_pressed: self.vy = JUMP_VELOCITY self.on_ground = False self.idle_t = 0.0 self.jump_squat_t = JUMP_SQUAT_SECONDS # Toucan: Fluegelschlag-Kette beginnt mit dem Absprung; jeder # weitere Schlag wird schwaecher (Original: -0x18000, dann je # +0x2000 bis unter -0x4000 -> "fuenfmal mit den Fluegeln") self.flap_strength = JUMP_VELOCITY * (5.0 / 6.0) self.gliding = False elif (self.can_fly and jump_edge and not self.on_ground and not self.gliding): if self.flap_strength >= FLAP_MIN: # zu schwach -> ab jetzt nur noch gleiten (Slot 34) self.gliding = True else: self.vy = self.flap_strength self.flap_strength += FLAP_DECAY self.flap_t = len(ANIM_FLAP) / FLAP_ANIM_FPS self.idle_t = 0.0 if self.hanging: pass # am Ast: keine Schwerkraft elif self.gliding and self.vy > 0 and not keys[pygame.K_DOWN]: # Gleitflug: sanftes Sinken, Pfeil runter bricht ab (Handbuch) self.vy = min(self.vy + GRAVITY * 0.25 * dt, GLIDE_FALL_MAX) else: self.vy = min(self.vy + GRAVITY * dt, 480.0) # 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, 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, wall_bottom + 1)): nx = (tx + 1) * TILE + self.HITBOX_W / 2 + 0.01 self.x = nx # Y + Boden/Decke 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.hanging: pass # am Ast: weder Boden-Snap noch Decken-Kollision elif 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: ty = int((ny - self.HITBOX_H) // 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). 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 and not self.hanging: was_grounded = self.on_ground or self._grounded_before old_ty = int(self.y // TILE) foot_ty = int(ny // TILE) # SWEPT: alle zwischen alter und neuer Position ueberstrichenen # Zeilen pruefen -- bei schnellem Fall (bis 8px/Frame) konnte die # duenne Graskante sonst uebersprungen werden (Stefans "falle # durch den Slope-Boden wenn ich ungluecklich lande"). start = min(old_ty, foot_ty) - (1 if was_grounded else 0) tol = 8 if was_grounded else 0 for ty2 in range(start, foot_ty + 2): surf = level.slope_surface(self.x, ty2) if (surf is not None and ny >= surf - tol and self.y <= surf + tol + 6): 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.can_swim: wade_max = wy + WATER_WADE_DEPTH if ny >= wade_max: ny = wade_max # Auto-Huepfer: die Oberflaeche "federt" -- kleiner Sprung in # Endlosschleife (kein Idle im Wasser). on_ground=True laesst # den naechsten Frame trotzdem einen VOLLEN manuellen Sprung # zu (Rauskommen bleibt moeglich, Steuerung normal). self.vy = WATER_BOUNCE_V self.on_ground = True self.idle_t = 0.0 self.y = ny self._grounded_before = self.on_ground def current_slot(self): if self.force_slot is not None: return self.force_slot if self.spawn_t > 0 and self.on_ground: return SLOT_ARMUP if self.swimming: if self.swim_stroke_t > 0: i = int((len(ANIM_SWIM) / SWIM_ANIM_FPS - self.swim_stroke_t) * SWIM_ANIM_FPS) return ANIM_SWIM[min(i, len(ANIM_SWIM) - 1)] if self.swim_move_t > 0: # Links/Rechts/Tauchen: durchgehende Schwimmbewegung return ANIM_SWIM[int(self.swim_move_t * SWIM_ANIM_FPS) % len(ANIM_SWIM)] return SLOT_SWIM_IDLE if self.hanging: # Coco haengt am Ast (Slots 35-44, Anker-Y 64 haengt ihn unter # die Astkante); beim Hangeln laufen die Frames durch if self.vx: seq = [35, 36, 37, 38, 39, 40, 41, 42, 43, 44] return seq[int(self.anim_t * WALK_ANIM_FPS) % len(seq)] return 35 if self.ducking: return SLOT_DUCK if not self.on_ground: if self.flap_t > 0: i = int((len(ANIM_FLAP) / FLAP_ANIM_FPS - self.flap_t) * FLAP_ANIM_FPS) return ANIM_FLAP[min(i, len(ANIM_FLAP) - 1)] if self.gliding and SLOT_GLIDE in self.slots: return SLOT_GLIDE # aufwaerts = Slot 8, abwaerts = Slot 9 (Stefans Zuordnung); # KEINE Hocke beim Absprung/Aufkommen (Stefan: Duck-Frame # blitzte beim Springen kurz auf -- muss weg) return SLOT_RISE if self.vy < 0 else SLOT_JUMP if self.vx: seq, fps = (ANIM_RUN, RUN_ANIM_FPS) if (self.running and self.can_run) \ else (ANIM_WALK, WALK_ANIM_FPS) return seq[int(self.anim_t * fps) % len(seq)] if self.idle_t >= IDLE_ANIM_AFTER: i = int((self.idle_t - IDLE_ANIM_AFTER) * IDLE_WAIT_FPS) % len(ANIM_IDLE_WAIT) return ANIM_IDLE_WAIT[i] # Stehen = immer die seitliche Pose (Slot 10/BOB 20) -- auch direkt # nach der Arm-hoch-Spawn-Pose (Stefan: 034.png sah "verdreht" aus, # nach dem Spawn muss 020.png kommen) return SLOT_STAND_SIDE def draw(self, screen, cam_x, cam_y): # Schadens-Blinken: nach Herzverlust flackert die Figur kurz # (klassisches Sichtbarkeits-Toggle -- ein eigenes "Aua"-Sprite gibt # es in den BOBs nicht) if self.hurt_t > 0 and int(self.hurt_t * 12) % 2 == 0: return surf, ax, ay = self.sprite(self.current_slot()) # Anker aus dem BOB-Header: (x,y) der Figur = Fusspunkt; SPRITE_SINK # gleicht die Gras-Ueberlappung der Tiles aus (fuer alle Frames gleich) screen.blit(surf, (int(self.x - ax - cam_x), int(self.y - ay - cam_y) + SPRITE_SINK)) class Hud: '''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].''' def __init__(self, assets): from .formats import load_pcc container = assets.container # Die Boxen der GAMEBAR.PCC bleiben WEISS (die Grafik hat das schon # richtig -- Stefans Korrektur); Ziffern/Herzen kommen aus # GAMEBAR.BOB, gerendert mit der GAMEBAR.PCC-PALETTE (damit sind die # Ziffern ROT mit dunkler Umrandung wie im Original -- mit der # bob-Regel-Palette waren sie braun). _, _, _, gb_pal = load_pcc(container.entries['GAMEBAR.PCC']) frames = parse_bob(container.entries['GAMEBAR.BOB']) self.icons = _frames_to_surfaces(frames, gb_pal) self.digits = self.icons[0:10] # 7x13, rot self.heart_full = self.icons[10] self.bar = assets.pcc_surface('GAMEBAR').convert() self.item_icons = {n: intro.make_border_bg_transparent(assets.pcc_surface(n)) for n in ('ITE0', 'ITE1', 'ITE2', 'ITE3')} # Kleine Charakter-Koepfe: WECH-Koepfe pixelig (nearest) verkleinert # + schwarzer 1px-Umriss -- Stefans Feedback: das Original-Icon ist # ein klarer Pixel-Kopf mit schwarzem Rand (kein weichgezeichnetes # Bild). Gleicher Stil wie das Tuerschild in DOORS.BOB. self.heads_small = [] for i in range(1, 5): head = intro.make_border_bg_transparent(assets.pcc_surface(f'WECH_{i}')) small = pygame.transform.scale(head, (15, 14)) outlined = pygame.Surface((17, 16), pygame.SRCALPHA) mask = small.copy() tint = pygame.Surface(mask.get_size(), pygame.SRCALPHA) tint.fill((0, 0, 0, 255)) mask.blit(tint, (0, 0), special_flags=pygame.BLEND_RGBA_MULT) for dx, dy in ((0, 1), (2, 1), (1, 0), (1, 2)): outlined.blit(mask, (dx, dy)) outlined.blit(small, (1, 1)) self.heads_small.append(outlined) def _number(self, canvas, x, y, value, width): s = str(max(0, int(value))).rjust(width, '0')[-width:] for i, ch in enumerate(s): canvas.blit(self.digits[int(ch)], (x + i * 8, y)) def draw(self, canvas, y0, char_def, cookies, score, hearts, lives, time_left): canvas.blit(self.bar, (0, y0)) num_y = y0 + 6 # Box 1 (57-99): Cerealien-Icon des aktiven Charakters + Keks-Zaehler icon = self.item_icons.get(char_def['item_icon']) if icon: canvas.blit(icon, (58, y0 + 4)) self._number(canvas, 75, num_y, cookies, 3) # Box 2 (105-154): Item -- noch leer # Box 3 (160-210): Score self._number(canvas, 161, num_y, score, 6) # Box 4 (216-248): Herzen, 3x2-Grid, max 6 (Original-Screenshot); # leere Plaetze bleiben weiss for i in range(min(hearts, 6)): canvas.blit(self.heart_full, (218 + (i % 3) * 10, y0 + 4 + (i // 3) * 9)) # Box 5 (254-280): Charakter-Kopf (immer die gewaehlte Figur) + Leben canvas.blit(self.heads_small[char_def['head_index']], (254, y0 + 4)) self._number(canvas, 272, num_y, lives, 1) # Box 6 (286-315): Uhr -- der ":" ist Teil der Grafik (auf weiss # sichtbar, Stefans Bestaetigung) m, s = divmod(max(0, int(time_left)), 60) self._number(canvas, 288, num_y, m, 1) self._number(canvas, 300, num_y, s, 2) def run_character_select(clock, assets, available, current): '''Tuer-Menue "MIT WEM MOECHTEST DU SPIELEN?" -- Koepfe WECH_1-4 (farbig) bzw. WECH_5-8 (grau = in diesem Level nicht verfuegbar). Links/Rechts + Return; ESC bricht ab. Rueckgabe: Charaktername, None (abgebrochen) oder 'quit' (Fenster-X).''' from .menu import MenuAssets, BLINK_SECONDS m = MenuAssets(assets) heads_color = [intro.make_border_bg_transparent(assets.pcc_surface(f'WECH_{i}')) for i in range(1, 5)] heads_grey = [intro.make_border_bg_transparent(assets.pcc_surface(f'WECH_{i}')) for i in range(5, 9)] names = CHAR_ORDER sel = [names.index(current) if current in names else 0] start = time.monotonic() while True: 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 None if event.key in (pygame.K_LEFT, pygame.K_UP): sel[0] = (sel[0] - 1) % 4 elif event.key in (pygame.K_RIGHT, pygame.K_DOWN): sel[0] = (sel[0] + 1) % 4 elif event.key in (pygame.K_RETURN, pygame.K_SPACE): if names[sel[0]] in available: return names[sel[0]] t = time.monotonic() - start blink_on = int(t / BLINK_SECONDS) % 2 == 0 canvas = m.menu_bg.copy() title = m.font_small.render('MIT WEM M\xd6CHTEST DU SPIELEN?', outline=(0, 0, 0)) canvas.blit(title, ((320 - title.get_width()) // 2, 48)) for i, name in enumerate(names): x = 52 + i * 60 head = heads_color[i] if name in available else heads_grey[i] if i == sel[0] and not blink_on: pass # Auswahl blinkt else: canvas.blit(head, (x, 74)) label = m.font_small.render(name, outline=(0, 0, 0)) canvas.blit(label, (x + 24 - label.get_width() // 2, 124)) intro._blit_centered(intro.build_letterboxed(canvas)) pygame.display.flip() clock.tick(30) def run_level(clock, assets, audio_paths, map_name='W1L1'): '''Level spielen. ESC -> 'map', Fenster-X -> 'quit'.''' level = Level(assets, map_name) info = level.info spawn_px = (info['spawn'][0] * TILE + 8, info['spawn'][1] * TILE - 0.01) player = Player(assets, info['chars'][0], spawn_px) hud = Hud(assets) door = pygame.Rect(info['door_px']) if info.get('door_px') else None # Tuergrafiken (DOORS.BOB): Frame 0 = OFFENE Tuer (Stefans Korrektur), # Frame 5 = Kopf-Schild einzeln. Ruhezustand = geschlossene Tile-Tuer der # Map + Schild; bei ALT oeffnet sich die Tuer (Frame 0) und nach ~1s # passiert die Tuer-Aktion (hier: Charakterwechsel-Menue). Frames 1-4 # sind die Schluessel-Tueren mit farbigen Knaeufen (spaeter). _door_surfs = _frames_to_surfaces(parse_bob(assets.container.entries['DOORS.BOB']), get_bob_palette(assets.container.entries, 'DOORS')) door_open_sprite = _door_surfs[0] door_sign_sprite = _door_surfs[5] door_open_t = None # None = zu; sonst Sekunden seit ALT-Druck 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 hearts, lives = 6, 3 # 6 Herzen (3x2) -- Original-Screenshot water_t = None # None = aktuell kein Wasserkontakt duck_hold = 0.0 down_gap = 0.0 # Grace gegen Taste/on_ground-Flackern look_down = 0.0 # Kamera-Offset beim gehaltenen Ducken dying = None # dict(t, vy, drop_y, drop_vy) waehrend Tod-Anim bubbles = [] # Smacks' Wasserblasen (dict x,y,vx,t) while True: dt = min(clock.tick(60) / 1000.0, 1 / 20) 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' # Tuer benutzen: ALT im STEHEN vor der Tuer (nicht beim # Rennen -- Stefans Regel) -> Tuer oeffnet sich (Frame 0), # ~1s spaeter kommt die Tuer-Aktion (Charakterwechsel-Menue) if event.key in (pygame.K_LALT, pygame.K_RALT): if (door and door.collidepoint(player.x, player.y - 1) and player.on_ground and abs(player.vx) < 1 and door_open_t is None): door_open_t = 0.0 elif player.swimming and 29 in player.slots: # Smacks: Wasserblase (Handbuch: ALT beim Schwimmen/ # Tauchen). Waechst 29->30->31, treibt vor und steigt. bubbles.append(dict(x=player.x + 14 * player.facing, y=player.y - 26, vx=55.0 * player.facing, t=0.0)) keys = pygame.key.get_pressed() time_left = info['time'] - (time.monotonic() - start) # Zeit abgelaufen -> TIME-OUT-Tafel (Original-Screen, Stefans # Screenshot: Menue-Rahmen + "TIME OUT"), dann Leben -1 und Respawn if dying is None and time_left <= 0: from .menu import MenuAssets m = MenuAssets(assets) panel = m.menu_bg.copy() txt = m.font_big.render('TIME OUT') panel.blit(txt, ((320 - txt.get_width()) // 2, 72)) end = time.monotonic() + 2.5 while time.monotonic() < end: for event in pygame.event.get(): if event.type == pygame.QUIT: return 'quit' if event.type == pygame.KEYDOWN: end = 0 intro._blit_centered(intro.build_letterboxed(panel)) pygame.display.flip() clock.tick(30) lives -= 1 if lives <= 0: return 'map' hearts = 6 player.x, player.y = spawn_px player.vy = 0.0 player.spawn_t = SPAWN_ANIM_SECONDS player.moved = False player.jump_lock = True start = time.monotonic() continue if dying is None: player.update(level, dt, keys) else: # Tod-Animation: Figur fliegt (ohne Kollision) aus dem Bild, # das Halstuch (Tony) bzw. die Kappe (Smacks) flattert herab. dying['t'] += dt dying['vy'] += GRAVITY * 0.6 * dt player.y += dying['vy'] * dt dying['drop_y'] += dying['drop_vy'] * dt dying['drop_vy'] = min(dying['drop_vy'] + 60 * dt, 40.0) if dying['t'] >= DEATH_ANIM_SECONDS: player.force_slot = None dying = None lives -= 1 if lives <= 0: return 'map' hearts = 6 player.x, player.y = spawn_px player.vy = 0.0 player.spawn_t = SPAWN_ANIM_SECONDS player.moved = False start = time.monotonic() # neuer Versuch = frische Levelzeit # Tuer-Sequenz: offen zeigen (Figur schaut rein), nach 1s die Aktion if door_open_t is not None: door_open_t += dt player.force_slot = SLOT_PEEK if door_open_t >= 1.0: door_open_t = None player.force_slot = None choice = run_character_select(clock, assets, info['chars'], player.char) if choice == 'quit': return 'quit' if choice and choice != player.char: player.set_character(choice) else: # auch beim SELBEN Charakter: Aus-der-Tuer-Pose zeigen # (Stefan: Idle hatte "schon gegriffen", Pose fehlte) player.spawn_t = SPAWN_ANIM_SECONDS player.moved = False player.idle_t = 0.0 # Bestaetigungs-Leertaste nicht als Sprung ins Level leaken pygame.event.clear() player.jump_lock = True # Wasser-Schaden fuer Nicht-Schwimmer. Stefans Erinnerung ans # Original: schon der ERSTE Wasserkontakt kostet ein Herz (+Blinken), # danach alle WATER_DAMAGE_EVERY Sekunden ein weiteres. # WICHTIG: can_swim pruefen, nicht player.swimming -- beim # Oberflaechensprung ist swimming kurz False, obwohl die Figur noch # im Wasser steckt (Stefans Bug: "Smacks verliert Leben im Wasser"). if dying is None and level.in_water(player.y) and not player.can_swim: take_heart = water_t is None or water_t >= WATER_DAMAGE_EVERY water_t = 0.0 if take_heart else water_t + dt if take_heart: hearts -= 1 player.hurt_t = 0.9 # Schadens-Blinken if hearts <= 0: # Tod-Animation starten: erschrockener Frame, Flug nach # oben aus dem Bild, Accessoire (Tuch/Kappe) faellt herab player.hurt_t = 0.0 # kein Blink-Toggle waehrend der Anim player.force_slot = SLOT_DEATH dying = dict(t=0.0, vy=-260.0, drop_y=player.y - 40.0, drop_vy=-30.0) else: water_t = None # None = kein Wasserkontakt (naechster kostet sofort) # Kamera: Standard so, dass die Figur bei ~75%% der Hoehe steht # (Stefans Referenz-Screenshot); haelt man DUCKEN gedrueckt, faehrt # die Kamera nach kurzer Zeit sanft ~40px nach unten (Wasser unterm # Abgrund sichtbar -- Original-Verhalten). # Grace-Period: kurze Unterbrechungen (X11-Autorepeat sendet # Release/Press-Paare; on_ground kann 1 Frame flackern) setzen den # Halte-Timer NICHT zurueck -- Stefans "Kamera geht sporadisch # wieder rauf, obwohl ich die Taste nicht losgelassen habe". if keys[pygame.K_DOWN] and player.on_ground and dying is None: duck_hold += dt down_gap = 0.0 else: down_gap += dt if down_gap > 0.2: duck_hold = 0.0 target_down = 40.0 if duck_hold >= 1.2 else 0.0 look_down += (target_down - look_down) * min(1.0, dt * 3.0) cam_x = max(0, min(int(player.x) - VIEW_W // 2, map_w_px - VIEW_W)) cam_y = max(0, min(int(player.y) - int(VIEW_H * 0.75) + int(look_down), map_h_px - VIEW_H)) canvas.blit(level.surface, (0, 0), (cam_x, cam_y, VIEW_W, VIEW_H)) # Tuer: zu = Tile-Tuer + Kopf-Schild; offen (nach ALT) = Frame 0 if door: if door_open_t is not None: canvas.blit(door_open_sprite, (door.x - cam_x, door.y - cam_y)) else: canvas.blit(door_sign_sprite, (door.x - cam_x, door.y - cam_y)) # Wasserblasen: wachsen (29->30->31), treiben vorwaerts, steigen, # platzen an der Oberflaeche. (Gegner-Fang kommt mit den Gegnern.) wy_b = info.get('water_y') for b in bubbles[:]: b['t'] += dt b['x'] += b['vx'] * dt b['vx'] *= 0.96 if b['t'] > 0.5: b['y'] -= 28 * dt if (wy_b is not None and b['y'] < wy_b + 8) or b['t'] > 4.0: bubbles.remove(b) continue slot = 29 if b['t'] < 0.25 else (30 if b['t'] < 0.5 else 31) entry = player.slots.get(slot) if entry: bs = entry[0] canvas.blit(bs, (int(b['x'] - bs.get_width() / 2 - cam_x), int(b['y'] - bs.get_height() / 2 - cam_y))) player.draw(canvas, cam_x, cam_y) if dying is not None and SLOT_DROP1 in player.slots: # fallendes Accessoire (16x16, Slots 48/49 im Wechsel = Flattern) slot = SLOT_DROP1 if int(dying['t'] * 6) % 2 == 0 else SLOT_DROP2 drop = player.slots.get(slot, player.slots[SLOT_DROP1])[0] canvas.blit(drop, (int(player.x - drop.get_width() / 2 - cam_x), int(dying['drop_y'] - cam_y))) # Tuer offen: Level abdunkeln (Original dimmt die Szene waehrend der # Tuer-Interaktion -- DOSBox-Referenz) if door_open_t is not None: dim = pygame.Surface((VIEW_W, VIEW_H), pygame.SRCALPHA) dim.fill((0, 0, 30, 120)) canvas.blit(dim, (0, 0)) # Wasser: dunkles Overlay unterhalb der Wasserlinie (im Original ein # VGA-Palette-Effekt, nur bei machine=vgaonly sichtbar -- Stefans Fund) wy = info.get('water_y') if wy is not None and wy - cam_y < VIEW_H: top = max(0, wy - cam_y) overlay = pygame.Surface((VIEW_W, VIEW_H - top), pygame.SRCALPHA) overlay.fill(WATER_TINT) canvas.blit(overlay, (0, top)) hud.draw(canvas, VIEW_H, player.d, cookies=0, score=0, hearts=hearts, lives=lives, time_left=time_left) intro._blit_centered(intro.build_letterboxed(canvas)) pygame.display.flip()