Files
tony-remake/game/level.py
T
duffyduckandClaude Fable 5 973e290cf6 LEVEL 1 SPIELBAR: game/level.py -- Tiles, Kamera, Tony-Physik, HUD, Musik
Weltkarte: SPACE startet Level 1 (Original-Verhalten per DOSBox-Referenz:
Return tut dort nichts, kein sichtbarer Level-Cursor am Spielstart).

Level 1 = W1L1.MAP -- per Template-Matching des DOSBox-Gameplay-Screenshots
gegen die gerenderten Maps identifiziert (W1L0 ist NICHT Level 1).

game/level.py:
- Level: MAP+ICO+Weltpalette laden, komplette Map einmalig vorrendern,
  Kamera blittet 320x176-Ausschnitt (HUD 320x24 darunter, wie Original).
- Kollision: MAP-ctype Bit 32 = solide (40=Boden, 32=Fuellung; empirisch
  an der Spawn-Plattform verifiziert).
- Tony: TONY.BOB (53 Frames; 8-Phasen-Gehzyklus, gerade=links/ungerade=
  rechts), AABB-Physik gegen das Tile-Grid, laufen/springen/ducken,
  Fusspunkt-basiert. Physikwerte v1 nach Gefuehl (Feinschliff nach Test).
- HUD-Nachbildung (Keks/Item/Score/Herzen/Leben/Uhr 10:00-Countdown) mit
  FON_1B-Ziffern; GAMEBAR-Icons geladen (Original-Ziffern-Palette unklar).
- Levelmusik: ONGAME2 Song 0 als v1-ANNAHME (nach Gehoer korrigieren, wie
  beim Factor5-Sound); wird im Audio-Preload mitgerendert.
- ESC -> zurueck zur Karte (Titelmusik loopt wieder), Fenster-X -> Ende.

Verifiziert: E2E unter Xvfb mit gehaltenen XTEST-Tasten -- laufen (Kamera
scrollt), springen, Plattformkante runterfallen, unterm Brueckenbogen
zurueck, ESC->Karte->Menue->QUIT rc=0. Level-Standbild deckungsgleich zur
DOSBox-Referenz. --once-Regression exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 01:17:44 +02:00

336 lines
13 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,
# Feinschliff nach Stefans Live-Test)
WALK_SPEED = 110.0 # px/s
GRAVITY = 900.0 # px/s^2
JUMP_VELOCITY = -300.0 # px/s (Sprunghoehe ~50px = gut 3 Tiles)
WALK_ANIM_FPS = 12.0
LEVEL_TIME_SECONDS = 10 * 60 # Uhr im Original zaehlt von 10:00 runter
SOLID_BIT = 32
# TONY.BOB-Frame-Zuordnung (siehe Modul-Docstring)
IDLE_L, IDLE_R = 0, 1
WALK_L = [0, 2, 4, 6, 8, 10, 12, 14]
WALK_R = [1, 3, 5, 7, 9, 11, 13, 15]
JUMP_L, JUMP_R = 16, 17
DUCK_L, DUCK_R = 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)
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
self.vx = move * WALK_SPEED
if move:
self.facing = move
self.anim_t += dt
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
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)
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.font = BitmapFont(assets.pcc_surface('FON_1B'), 9, 7,
_mapping_from_rows(['1234567890;:!"%/()=?,.-\x7f#']),
spacing=8)
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 + 7
# Keks-Zaehler
self._box(canvas, 30, pad_y - 3, 34, 15)
self._text(canvas, 34, pad_y, f'{cookies:03d}')
# Item-Box (leer)
self._box(canvas, 92, pad_y - 3, 40, 15)
# Score
self._box(canvas, 148, pad_y - 3, 58, 15)
self._text(canvas, 152, pad_y, f'{score:06d}')
# Herzen
for i in range(4):
col = (230, 40, 60) if i < hearts else (70, 10, 20)
hx = 222 + i * 11
pygame.draw.circle(canvas, col, (hx + 3, pad_y + 3), 3)
pygame.draw.circle(canvas, col, (hx + 8, pad_y + 3), 3)
pygame.draw.polygon(canvas, col, [(hx, pad_y + 4), (hx + 5, pad_y + 10), (hx + 11, pad_y + 4)])
# Leben
self._box(canvas, 272, pad_y - 3, 13, 15)
self._text(canvas, 275, pad_y, str(lives))
# Uhr m:ss
m, s = divmod(max(0, int(time_left)), 60)
self._box(canvas, 290, pad_y - 3, 29, 15)
self._text(canvas, 292, 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()