From 07b347b6076c5d4b71e3abc7816a58898d585bbb Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sat, 25 Jul 2026 10:49:09 +0200 Subject: [PATCH] Charakter-System, Wasser, Renn/Idle-Animationen, echtes HUD Stefans Feedback-Paket (7 annotierte Screenshots): - Player-Klasse mit CHAR_DEFS: Tony (RENNEN mit ALT -- echte Rad-Animation, Frames 26-33) und Smacks (SCHWIMMEN, Frames 37-44; kein Rennen) spielbar; Coco/Toucan vorbereitet. Idle-Packungs-Animation nach 5s Stillstand (Tony 44-49, Smacks 31-36). Sprung-Frames 18/19 (gestreckt). - Sprunghoehe kalibriert (-360, ~72px: "Tony kommt mit dem Kopf an den Ast"). - WASSER (Stefans Fund: nur bei DOSBox machine=vgaonly sichtbar): water_y pro Level + dunkelblaues Overlay; Nicht-Schwimmer verlieren alle 5s ein Herz, 0 Herzen -> Respawn/Leben-1, 0 Leben -> Karte. Smacks schwimmt schadlos (traege Wasserphysik, Space = Schwimmstoss). - TUER mit Kopf-Symbol (Level-1-Haus): ALT im STEHEN oeffnet das Charakter-Auswahlmenue "MIT WEM MOECHTEST DU SPIELEN?" -- WECH_1-4 = farbige Koepfe, WECH_5-8 = grau (nicht verfuegbar; Level 1: nur Tony+Smacks). Auswahl blinkt, Wechsel uebernimmt die Position. - HUD nach Referenz-Screenshot: Keks-Box mit Cerealien-Icon (ITE0-3) des aktiven Charakters, Herz-Box als 2x2-Grid, Charakter-Kopf-Box (zeigt die gewaehlte Figur) + Leben, Uhr. - Level-Metadaten (Charaktere/Wasserlinie/Tuer/Spawn) v1 als LEVEL_INFO- Konstanten -- Original-Quelle ist vermutlich das .ARE-Format (Header enthaelt BE-Pixelkoordinaten, Analyse in NOTES.md, noch offen). Verifiziert: E2E (Xvfb+XTEST) -- Tuer->Menue->Smacks, HUD wechselt auf Smacks-Kopf+Puff-Icon, Wasser-Overlay sichtbar, ESC/QUIT rc=0; --once ok. Co-Authored-By: Claude Fable 5 --- NOTES.md | 42 ++++ dosbox/dosbox.conf | 2 + game/level.py | 505 +++++++++++++++++++++++++++++---------------- 3 files changed, 369 insertions(+), 180 deletions(-) diff --git a/NOTES.md b/NOTES.md index 4f7018a..17df0ec 100644 --- a/NOTES.md +++ b/NOTES.md @@ -1438,3 +1438,45 @@ Stefans annotierter Screenshot (4 Punkte), alle behoben: Physik-Feel laut Stefan gut. Levelmusik-Song prueft er nach Gehoer. Offen bleiben: Tueren oeffnen (ALT an Tuer, Schluessel-Logik), Items, Gegner, Karten-Animationen. + +## 25.07.2026 (3) -- Stefans grosses Feedback-Paket: Charaktere, Wasser, Animationen, HUD + +Referenz: 7 annotierte Screenshots von Stefan + DOSBox (machine=vgaonly -- +Stefans Fund: das WASSER wird nur bei vgaonly gerendert, VGA-Palette-Effekt!). + +**Asset-Funde:** +- WECH_1-4.PCC = die 4 Charakter-Koepfe FARBIG, WECH_5-8 = GRAU (= im Level + nicht verfuegbar) -- exakt das Tuer-Auswahlmenue "MIT WEM MOECHTEST DU + SPIELEN?". ITE0-3.PCC = die 4 Cerealien-HUD-Icons (Flake/Puff/Loop/Choco). + FROS/SMAC/COCO/LOOP_W1-3.ICO = SAMMEL-Cerealien-Animationsphasen pro Welt + (fuer Items spaeter). TONY-Frames: 18/19=Sprung, 24/25=Ducken, 26-33= + RENN-"Rad" (Stefans Fuesse-wie-ein-Rad), 44-49=Idle-Packung. SMACKS: 51 + Frames, 18/19=Ducken, 24/25=Sprung, 31-36=Idle-Packung, 37-44=SCHWIMMEN, + 45-47=Wasserblasen. +- ARE-Format angeschaut (W1L1: 6690B, 3 Regionen; Header ab Byte 8 enthaelt + Big-Endian-Pixelkoordinaten-Paare, u.a. (256,592)~Spawn-Kopfposition) -- + noch NICHT geknackt, v1 nutzt LEVEL_INFO-Konstanten. TODO. + +**Implementiert (game/level.py):** +- Player-Klasse mit CHAR_DEFS (Tony/Smacks/Coco/Toucan): Tony rennt (ALT/ + Shift, Rad-Animation), Smacks schwimmt (kein Wasserschaden, traege + Physik, Space=Schwimmstoss), Idle-Packungs-Animation nach 5s Stillstand, + Sprung-Frames korrigiert (18/19 gestreckt statt 16/17). +- Sprunghoehe kalibriert: -360 (~72px ~ Stefans "Kopf an den Ast"). +- WASSER: water_y pro Level, dunkelblaues Overlay, Nicht-Schwimmer verlieren + alle 5s ein Herz; 0 Herzen -> Leben-1 + Respawn; 0 Leben -> Karte. +- TUER (Level-1-Haus, Tiles 12-13/37-39): Kopf-Symbol drueber; ALT im STEHEN + (nicht beim Rennen) oeffnet das Charakter-Menue (WECH-Koepfe, nicht + verfuegbare grau, Auswahl blinkt), Wechsel uebernimmt Position. +- HUD nach Referenz: Keks-Box MIT Cerealien-Icon des aktiven Charakters, + Herz-Box als 2x2-GRID, Charakter-Kopf-Box (zeigt IMMER die gewaehlte + Figur) + Leben, Uhr. + +**Verifiziert:** E2E (Xvfb+XTEST): Tuer -> Menue -> Smacks gewaehlt -> HUD +zeigt Smacks-Kopf+Puff-Icon, Wasser-Overlay sichtbar, laufen/springen/ +ducken als Smacks, ESC/QUIT rc=0. --once Regression exit 0. + +**Offen:** ARE knacken (Charaktere/Wasser/Tueren/Spawns pro Level echt), +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. diff --git a/dosbox/dosbox.conf b/dosbox/dosbox.conf index bf11d02..2c599ff 100644 --- a/dosbox/dosbox.conf +++ b/dosbox/dosbox.conf @@ -1,3 +1,5 @@ +[dosbox] +machine = vgaonly [sdl] fullscreen=false output=surface diff --git a/game/level.py b/game/level.py index c3797fd..c67fa76 100644 --- a/game/level.py +++ b/game/level.py @@ -1,33 +1,24 @@ -"""Level-Gameplay: Tile-Rendering, Kamera, Tony-Steuerung, Kollision, HUD. +"""Level-Gameplay: Tile-Rendering, Kamera, Spielfiguren, 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). +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). -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). +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 @@ -37,41 +28,83 @@ 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) +VIEW_W, VIEW_H = 320, 176 +HUD_H = 24 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_SPEED = 110.0 +RUN_SPEED = 185.0 +GRAVITY = 900.0 +# Sprunghoehe ~72px (Stefans Referenz: Tonys Kopf erreicht gerade den Ast +# ueber dem Spawn): v = sqrt(2*g*h) ~= 360. +JUMP_VELOCITY = -360.0 +SWIM_GRAVITY = 240.0 # Smacks im Wasser: traege sinken +SWIM_KICK = -140.0 # Schwimmstoss mit Space 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 +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) 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) +SLOPE_CT = 16 -# 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 +# Charakter-Definitionen. Richtungs-Paritaet: gerade Frames = rechts +# (an Tony per Live-Test bestaetigt; bei Smacks analog angenommen). +CHAR_DEFS = { + 'TONY': dict( + bob='TONY', can_run=True, can_swim=False, + walk_r=[0, 2, 4, 6, 8, 10, 12, 14], walk_l=[1, 3, 5, 7, 9, 11, 13, 15], + run_r=[26, 28, 30, 32], run_l=[27, 29, 31, 33], + 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], + item_icon='ITE0', head_index=0, + ), + 'SMACKS': dict( + 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, + swim_r=[37, 39, 41, 43], swim_l=[38, 40, 42, 44], + idle_anim_r=[31, 33, 35], idle_anim_l=[32, 34, 36], + item_icon='ITE1', head_index=1, + ), + 'COCO': dict( + bob='COCO', can_run=False, can_swim=False, + 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=18, jump_l=19, duck_r=24, duck_l=25, + swim_r=None, swim_l=None, idle_anim_r=None, idle_anim_l=None, + item_icon='ITE3', head_index=2, + ), + 'TOUCAN': dict( + bob='TOUCAN', can_run=False, can_swim=False, + 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=18, jump_l=19, duck_r=24, duck_l=25, + swim_r=None, swim_l=None, idle_anim_r=None, idle_anim_l=None, + 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): - '''BOB-Frames (Palette-Indizes, -1 = transparent) -> pygame-Surfaces.''' out = [] for fr in frames: surf = pygame.Surface((fr.width, fr.height), pygame.SRCALPHA) @@ -86,20 +119,19 @@ def _frames_to_surfaces(frames, palette): 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).''' + '''MAP+ICO einmal komplett vorrendern; Kamera blittet den Ausschnitt.''' def __init__(self, assets, map_name): container = assets.container self.name = map_name - world = map_name[:2] # 'W1' + self.info = LEVEL_INFO.get(map_name, dict(spawn=(2, 2), time=599, + chars=['TONY'], water_y=None, + door_px=None)) + 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']) - # Tiles einmalig zu Surfaces tile_surfs = [] for grid in tiles: s = pygame.Surface((TILE, TILE)) @@ -110,66 +142,70 @@ class Level: 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.blit(tile_surfs[tile], ((i % self.width) * TILE, + (i // self.width) * TILE)) 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 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): - '''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 + 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 # links unten -> rechts oben + return (ty + 1) * TILE - 1 - fx 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 + return ty * TILE + fx + return (ty + 1) * TILE - 1 - fx + + def in_water(self, py): + wy = self.info.get('water_y') + return wy is not None and py >= wy -class Tony: - '''Spielfigur: Position (px, Fusspunkt-basiert), einfache AABB-Physik - gegen das Tile-Grid.''' +class Player: + '''Spielfigur (Tony/Smacks/...): Fusspunkt-basierte AABB-Physik.''' 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 + 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 - self.facing = 1 # 1 = rechts, -1 = links + self.facing = 1 self.ducking = False + self.running = False + self.swimming = False self.anim_t = 0.0 + self.idle_t = 0.0 + + 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']) + self.surfs = _frames_to_surfaces(frames, palette) + if pos is not None: + self.x, self.y = float(pos[0]), float(pos[1]) def rect(self): return pygame.Rect(int(self.x - self.HITBOX_W / 2), @@ -177,48 +213,56 @@ class Tony: self.HITBOX_W, self.HITBOX_H) def update(self, level, dt, keys): - self.ducking = self.on_ground and keys[pygame.K_DOWN] + 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] 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) + wants_run = bool(mods & (pygame.KMOD_ALT | pygame.KMOD_SHIFT)) + self.running = wants_run and self.d['can_run'] and move != 0 and not self.swimming + speed = RUN_SPEED if self.running else WALK_SPEED + if self.swimming: + speed *= 0.7 + self.vx = move * speed if move: self.facing = move - self.anim_t += dt * (1.5 if running else 1.0) + 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.on_ground and not self.ducking and (keys[pygame.K_SPACE] or keys[pygame.K_UP]): - self.vy = JUMP_VELOCITY - self.on_ground = False + jump_pressed = keys[pygame.K_SPACE] or keys[pygame.K_UP] + if self.swimming: + if jump_pressed: + self.vy = min(self.vy, SWIM_KICK) + self.vy = min(self.vy + SWIM_GRAVITY * dt, 120.0) + else: + 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.vy = min(self.vy + GRAVITY * dt, 480.0) - self.vy = min(self.vy + GRAVITY * dt, 480.0) - - # X-Bewegung + Wandkollision + # X + Wandkollision nx = self.x + self.vx * dt r = self.rect() if self.vx > 0: - edge = nx + self.HITBOX_W / 2 - tx = int(edge // TILE) + 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)): nx = tx * TILE - self.HITBOX_W / 2 - 0.01 elif self.vx < 0: - edge = nx - self.HITBOX_W / 2 - tx = int(edge // TILE) + 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)): nx = (tx + 1) * TILE + self.HITBOX_W / 2 + 0.01 self.x = nx - # Y-Bewegung + Boden-/Deckenkollision + # 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) @@ -231,18 +275,12 @@ class Tony: else: self.on_ground = False else: - top = ny - self.HITBOX_H - ty = int(top // TILE) + 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): 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). + # Schraegen (ctype 16) if self.vy >= 0: foot_ty = int(ny // TILE) for ty2 in (foot_ty - 1, foot_ty, foot_ty + 1): @@ -254,42 +292,49 @@ class Tony: break self.y = ny - def current_frame(self): + def _pick(self, key_r, key_l): + i = self.d[key_r] if self.facing > 0 else self.d[key_l] + return i + + def current_frame_index(self): + d = self.d right = self.facing > 0 + 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] 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] + return self._pick('duck_r', 'duck_l') + if not self.on_ground: + return self._pick('jump_r', 'jump_l') + if self.vx: + if self.running and d['run_r']: + seq = d['run_r'] if right else d['run_l'] + fps = RUN_ANIM_FPS + else: + seq = d['walk_r'] if right else d['walk_l'] + fps = WALK_ANIM_FPS + return seq[int(self.anim_t * fps) % len(seq)] + if self.idle_t >= IDLE_ANIM_AFTER and d['idle_anim_r']: + seq = d['idle_anim_r'] if right else d['idle_anim_l'] + return seq[int((self.idle_t - IDLE_ANIM_AFTER) * IDLE_ANIM_FPS) % len(seq)] + return self._pick('idle_r', 'idle_l') 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)) + i = self.current_frame_index() + surf = self.surfs[i] if i < len(self.surfs) else self.surfs[0] + screen.blit(surf, (int(self.x - surf.get_width() / 2 - cam_x), + int(self.y - surf.get_height() - cam_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.''' + '''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.''' GOLD = (222, 186, 24) RED = (150, 14, 14) DARK = (84, 6, 6) - WHITE = (248, 248, 248) def __init__(self, assets): @@ -298,62 +343,122 @@ class Hud: 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_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) - # 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)) + # Item-Icons (Cerealien) + Charakter-Koepfe klein + self.item_icons = {n: intro.make_border_bg_transparent(assets.pcc_surface(n)) + for n in ('ITE0', 'ITE1', 'ITE2', 'ITE3')} + self.heads_small = [] + for i in range(1, 5): + 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.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)) + canvas.blit(self.font.render(text, outline=(0, 0, 0)), (x, y)) - def draw(self, canvas, y0, cookies, score, hearts, lives, time_left): + def draw(self, canvas, y0, char_def, 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) + # 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) + 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) # Score - self._box(canvas, 148, pad_y - 3, 56, 14) - self._text(canvas, 152, pad_y, f'{score:06d}') - # Herzen (echte GAMEBAR-Sprites) + 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 - 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) + 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)) + # Uhr 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}') + self._box(canvas, 288, y0 + 4, 30, 16) + self._text(canvas, 290, y0 + 9, 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.''' +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) - tony = Tony(assets, (spawn_tile[0] * TILE + 8, spawn_tile[1] * TILE - 0.01)) + 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 + door_head = pygame.transform.smoothscale( + intro.make_border_bg_transparent(assets.pcc_surface('WECH_1')), (16, 15)) - # Levelmusik: ONGAME2 Song 0 (v1-Annahme, siehe Docstring). Loopt. if pygame.mixer.get_init(): path = audio_paths.get(('ONGAME2', None)) if path: @@ -367,10 +472,11 @@ def run_level(clock, assets, audio_paths, map_name='W1L1', spawn_tile=(17, 40)): start = time.monotonic() map_w_px = level.width * TILE map_h_px = level.height * TILE + hearts, lives = 4, 3 + water_t = 0.0 while True: - dt = clock.tick(60) / 1000.0 - dt = min(dt, 1 / 20) # Physik-Schutz bei Rucklern + dt = min(clock.tick(60) / 1000.0, 1 / 20) for event in pygame.event.get(): if event.type == pygame.QUIT: return 'quit' @@ -379,19 +485,58 @@ def run_level(clock, assets, audio_paths, map_name='W1L1', spawn_tile=(17, 40)): intro.toggle_fullscreen() continue if event.key == pygame.K_ESCAPE: - return 'map' # wie im Original: ESC = zurueck zur Karte + return 'map' + # Tuer benutzen: ALT im STEHEN vor der Tuer (nicht beim + # Rennen -- Stefans Regel) -> 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): + 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) keys = pygame.key.get_pressed() - tony.update(level, dt, keys) + player.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)) + # Wasser-Schaden (alle 5s ein Herz), Schwimmer sind sicher + if level.in_water(player.y) and not player.swimming: + water_t += dt + if water_t >= WATER_DAMAGE_EVERY: + water_t = 0.0 + hearts -= 1 + if hearts <= 0: + lives -= 1 + hearts = 4 + player.x, player.y = spawn_px + player.vy = 0.0 + if lives <= 0: + return 'map' + else: + water_t = 0.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) - 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) + # Tuer-Symbol (Kopf ueber dem Tuerfenster -- Charakterwechsel-Hinweis) + if door: + canvas.blit(door_head, (door.x + door.w // 2 - 8 - cam_x, door.y - cam_y)) + player.draw(canvas, cam_x, cam_y) + # 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)) + + time_left = info['time'] - (time.monotonic() - start) + 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()