Files
tony-remake/game/level.py
T
duffyduckandClaude Fable 5 68be969e0d WELTKARTE mit Levelauswahl -- Pfad/Punkte/Namen live aus dem Binary
game/worldmap.py: parst Kartenpfad, Levelpunkt-Positionen und die 22
Levelnamen zur Laufzeit aus START.EXE (NE-Datensegment, Anker-Suche,
Fallback W1L1). Links/Rechts laesst die Kartenfigur (Mini-Kopf KARTE4-7
je Charakter) die Original-Wegpunkte ablaufen, der aktive Levelpunkt
blinkt orange/blau (KARTE0/2 -- Blinksequenz stand exakt so in der
Binary-Animationstabelle), Space/Return startet das Level, ESC -> Menue.
Kartenposition bleibt ueber Levelbesuche erhalten.

Level ohne handgepflegte LEVEL_INFO erhalten einen generischen Spawn
(erste Bodenstelle) -- damit sind ALLE 22 Level anwaehlbar und spielbar
(Basis-Physik; Wasser/Tueren dort folgen mit den echten Leveldaten).

E2E: Karte -> laufen -> W1L3 spielen -> zurueck -> W1L2 -> QUIT rc=0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 14:18:30 +02:00

780 lines
36 KiB
Python

"""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.
JUMP_VELOCITY = -360.0
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 = 5.0 # Sekunden Stillstand bis zur Idle-Animation
IDLE_ANIM_FPS = 2.8 # Original: delay 25 Ticks @70Hz
WATER_DAMAGE_EVERY = 1.5 # im Wasser: alle 1.5s ein Herz (Stefan: 5s war arg lang)
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)
SOLID_BIT = 32
SLOPE_CT = 16
# 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],
# 16/17 = echte Steh-Frames (0/1 sind Schrittphasen -- "letzter
# Lauf-Frame bleibt stehen"-Bug)
idle_r=16, idle_l=17, jump_r=18, jump_l=19, duck_r=24, duck_l=25,
swim_r=None, swim_l=None,
# ECHTE Sequenz aus der Binary-Animationstabelle (@143966, x2):
idle_anim_r=[46, 48, 46, 44, 46, 44, 46, 44, 46, 48, 46, 44, 46, 20, 20, 20, 20, 20],
idle_anim_l=[47, 49, 47, 45, 47, 45, 47, 45, 47, 49, 47, 45, 47, 21, 21, 21, 21, 21],
spawn_frame=22, # Arm nach oben gestreckt
death_frame=38, drop_frame=40, # erschrocken; fallendes HALSTUCH
peek_frame=50, # in die Tuer reinschauen (Rueckenansicht -- Stefans Fund)
item_icon='ITE0', head_index=0,
),
'SMACKS': dict(
bob='SMACKS', can_run=False, can_swim=True,
# Zyklus OHNE 12-15 (das sind Steh-/Uebergangsframes)
walk_r=[0, 2, 4, 6, 8, 10], walk_l=[1, 3, 5, 7, 9, 11],
run_r=None, run_l=None,
# 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
death_frame=26, drop_frame=29, # erschrocken; fallende KAPPE
peek_frame=48, # Reinschau-Frame (letzter vor den Platzhaltern)
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,
spawn_frame=None,
death_frame=None, drop_frame=None, peek_frame=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,
spawn_frame=None,
death_frame=None, drop_frame=None, peek_frame=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):
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_frame = None # erzwungener Frame (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._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'])
self.surfs = _frames_to_surfaces(frames, palette)
if pos is not None:
self.x, self.y = float(pos[0]), float(pos[1])
# Start-Sprite-Sequenz beim Levelstart/Charakterwechsel (Stefans
# Beobachtung: Tony streckt die Hand nach oben, Smacks Daumen hoch)
self.spawn_t = SPAWN_ANIM_SECONDS
def rect(self):
return pygame.Rect(int(self.x - self.HITBOX_W / 2),
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
if any(keys[k] for k in (pygame.K_LEFT, pygame.K_RIGHT,
pygame.K_UP, pygame.K_DOWN, pygame.K_SPACE)):
self.spawn_t = 0.0
in_water = level.in_water(self.y)
self.swimming = in_water and self.d['can_swim']
# 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.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 * (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
jump_pressed = keys[pygame.K_SPACE] or keys[pygame.K_UP]
if self.swimming:
wy = level.info.get('water_y')
near_surface = wy is not None and (self.y - wy) < 24
if jump_pressed and near_surface:
# Stefans Bug "kommt nicht mehr aus dem Wasser raus": nahe der
# Oberflaeche wird ein ECHTER Sprung ausgeloest (traegt auf die
# Plattform), nicht nur ein Schwimmstoss.
self.vy = JUMP_VELOCITY
self.swimming = False
elif jump_pressed:
self.vy = min(self.vy, SWIM_KICK)
if self.swimming:
self.vy = min(self.vy + SWIM_GRAVITY * dt, 120.0)
else:
if self.on_ground and not self.ducking and jump_pressed:
self.vy = JUMP_VELOCITY
self.on_ground = False
self.idle_t = 0.0
self.jump_squat_t = JUMP_SQUAT_SECONDS
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.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:
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.d['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 _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.force_frame is not None:
return self.force_frame
if self.spawn_t > 0 and d.get('spawn_frame') is not None and self.on_ground:
return d['spawn_frame']
if self.swimming and d['swim_r']:
seq = d['swim_r'] if right else d['swim_l']
return seq[int(self.anim_t * 6) % len(seq)] if self.vx else seq[0]
if self.ducking:
return self._pick('duck_r', 'duck_l')
if not self.on_ground:
if self.jump_squat_t > 0:
return self._pick('duck_r', 'duck_l') # Absprung-Hocke
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):
# 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
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) + 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 = 0.0
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
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
keys = pygame.key.get_pressed()
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_frame = 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
# Tuer-Sequenz: offen zeigen (Figur schaut rein), nach 1s die Aktion
if door_open_t is not None:
door_open_t += dt
if player.d.get('peek_frame') is not None:
player.force_frame = player.d['peek_frame']
if door_open_t >= 1.0:
door_open_t = None
player.force_frame = 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)
# Wasser-Schaden (alle 5s ein Herz), Schwimmer sind sicher
if dying is None and level.in_water(player.y) and not player.swimming:
water_t += dt
if water_t >= WATER_DAMAGE_EVERY:
water_t = 0.0
hearts -= 1
player.hurt_t = 0.9 # Schadens-Blinken
if hearts <= 0 and player.d.get('death_frame') is not None:
# Tod-Animation starten: erschrockener Frame, Flug nach
# oben aus dem Bild, Tuch/Kappe faellt herunter
player.hurt_t = 0.0 # kein Blink-Toggle waehrend der Anim
player.force_frame = player.d['death_frame']
dying = dict(t=0.0, vy=-260.0,
drop_y=player.y - 40.0, drop_vy=-30.0)
elif hearts <= 0:
lives -= 1
hearts = 6
player.x, player.y = spawn_px
player.vy = 0.0
if lives <= 0:
return 'map'
else:
water_t = 0.0
# 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))
player.draw(canvas, cam_x, cam_y)
if dying is not None and player.d.get('drop_frame') is not None:
drop = player.surfs[player.d['drop_frame']]
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))
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()