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