Files
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

436 lines
16 KiB
Python

"""Hauptmenue, Optionsmenue, Anleitung, Highscore-Tafel und Weltkarte.
Alles gegen echte DOSBox-Referenzaufnahmen (24.07.2026, Xvfb + XTEST-Tasten)
nachgebaut -- Ablauf im Original:
Titelbild --ESC--> Hauptmenue (faedet ein) mit Eintraegen
HIGHSCORES / ANLEITUNG / OPTIONEN / START / QUIT (rote FON_2-Schrift,
Default-Auswahl START). Der SELEKTIERTE Eintrag BLINKT (das ist der
Cursor -- kein Pfeil o.ae.). Links/rechts der Box winken die Charakter-
Gruppen PARTY1/PARTY2. Unter der Box laeuft ein Credits-Ticker in
kleiner gelber Schrift (FON_1/FON_1B) mit wechselnden Texten
("FREEGAMES #3", "REALISATION RAUSER ADVERTAINMENT GMBH REUTLINGEN",
"PRODUZIERT VON FACTOR 5", "ANIMATIONEN UND ILLUSTRATIONEN HARTWIG
NIEDERGASSEL").
OPTIONEN -> eigene Tafel: MUSIK EIN/AUS, EFFEKTE EIN/AUS, EXIT.
ANLEITUNG -> gelbe Text-Tafel (Joystick-Steuerung + Faehigkeiten).
HIGHSCORES -> Tabelle NAME/SCORE/ITEMS/LEV mit den Original-Defaults
(MATTHIAS 100000 ... BORIS 2500).
START -> Weltkarte (KARTE.PCC -- kompletter Screen inkl. Rahmen und
Charakteren; Animationen KARTE0-9 spaeter).
Screens sind 320x200 (wie die Original-Videomodi) und werden wie die
Intro-Screens letterboxed auf 320x240 -> Fenster skaliert.
Fonts (Zellen-Grids per Spaltenanalyse der PCC-Assets bestimmt):
FON_1 (320x7): 9x7-Zellen, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + AE OE UE ss
FON_1B (320x7): 9x7-Zellen, '1234567890;:!"%/()=?,.-' + Block + '#'
FON_2 (320x45): 16x15-Zellen, 3 Zeilen:
'ABCDEFGHIJKLMNOPQRST' / 'UVWXYZ.,:!?-' / '1234567890'
"""
import sys
import time
import pygame
from . import intro
# ---------------------------------------------------------------------------
# Bitmap-Fonts aus den FON_*.PCC-Assets
# ---------------------------------------------------------------------------
class BitmapFont:
'''Fixed-Cell-Bitmapfont aus einer PCC-Surface. Schwarz (0,0,0) ist
Hintergrund und wird transparent. mapping: dict Zeichen -> (col, row).'''
def __init__(self, surface, cell_w, cell_h, mapping, spacing=None):
surface = surface.convert_alpha()
# Schwarz -> transparent (PCC-Hintergrund)
arr = pygame.PixelArray(surface)
arr.replace((0, 0, 0, 255), (0, 0, 0, 0))
del arr
self.cell_w, self.cell_h = cell_w, cell_h
self.spacing = spacing if spacing is not None else cell_w
self.glyphs = {}
for ch, (col, row) in mapping.items():
rect = pygame.Rect(col * cell_w, row * cell_h, cell_w, cell_h)
if rect.right <= surface.get_width() and rect.bottom <= surface.get_height():
self.glyphs[ch] = surface.subsurface(rect)
def measure(self, text):
return len(text) * self.spacing, self.cell_h
def render(self, text, outline=None):
'''Rendert `text` auf eine neue Surface (Alpha). outline=(r,g,b):
1px-Umrandung in dieser Farbe (wie die gelbe Original-Schrift, die
ueberall mit schwarzem Rand gezeichnet wird).'''
w, h = self.measure(text)
pad = 1 if outline else 0
surf = pygame.Surface((w + 2 * pad, h + 2 * pad), pygame.SRCALPHA)
if outline:
# Umriss: Text 4x versetzt in outline-Farbe, dann normal drueber
mask = pygame.Surface((w, h), pygame.SRCALPHA)
x = 0
for ch in text.upper():
g = self.glyphs.get(ch)
if g:
mask.blit(g, (x, 0))
x += self.spacing
tinted = mask.copy()
tint = pygame.Surface(mask.get_size(), pygame.SRCALPHA)
tint.fill(outline + (255,))
tinted.blit(tint, (0, 0), special_flags=pygame.BLEND_RGBA_MULT)
# RGB durch outline-Farbe ersetzen, Alpha der Glyphen behalten
for dx, dy in ((0, 1), (2, 1), (1, 0), (1, 2), (0, 0), (2, 2), (0, 2), (2, 0)):
surf.blit(tinted, (dx, dy))
x = pad
for ch in text.upper():
g = self.glyphs.get(ch)
if g:
surf.blit(g, (x, pad))
x += self.spacing
return surf
def _mapping_from_rows(rows):
m = {}
for row, chars in enumerate(rows):
for col, ch in enumerate(chars):
m[ch] = (col, row)
return m
class MenuAssets:
'''Laedt und cached alle Menue-relevanten Surfaces + Fonts.'''
def __init__(self, assets):
self.assets = assets
self.menu_bg = assets.pcc_surface('MENU') # 320x200 Komplett-Screen
self.karte = assets.pcc_surface('KARTE') # 320x200 Weltkarte
# PARTY-Sprites haben (wie die Rauser-Assets) einen PAPIERWEISSEN
# Hintergrund und schwarze Konturen -- NICHT schwarz freistellen
# (das loescht die Outlines!), sondern die vom Rand aus
# zusammenhaengende weisse Flaeche per Flood-Fill transparent machen.
self.party1 = intro.make_border_bg_transparent(assets.pcc_surface('PARTY1'))
self.party2 = intro.make_border_bg_transparent(assets.pcc_surface('PARTY2'))
# kleine gelbe Schrift: FON_1 (Buchstaben) + FON_1B (Ziffern/Zeichen)
# -- ein gemeinsamer Font, beide Surfaces uebereinandergelegt waere
# falsch (gleiche Zeilenlage), daher zwei Fonts mit gleichem Grid und
# ein kombinierter render-Wrapper.
# Zellenraster ist 9px, die Engine rendert aber mit 8px Vorschub --
# nur so passen die 32-Zeichen-Zeilen der Anleitung in die Box
# (32 x 8 = 256px bei ~264px Box-Innenbreite, vgl. Referenz).
f1 = BitmapFont(assets.pcc_surface('FON_1'), 9, 7,
_mapping_from_rows(['ABCDEFGHIJKLMNOPQRSTUVWXYZ\xc4\xd6\xdc\xdf']),
spacing=8)
f1b = BitmapFont(assets.pcc_surface('FON_1B'), 9, 7,
_mapping_from_rows(['1234567890;:!"%/()=?,.-\x7f#']),
spacing=8)
# Glyphen zusammenfuehren (disjunkte Zeichensaetze, gleiches Grid)
f1.glyphs.update(f1b.glyphs)
self.font_small = f1
self.font_big = BitmapFont(assets.pcc_surface('FON_2'), 16, 15,
_mapping_from_rows(['ABCDEFGHIJKLMNOPQRST',
'UVWXYZ.,:!?-',
'1234567890']))
@staticmethod
def _transparent(surface):
s = surface.convert_alpha()
arr = pygame.PixelArray(s)
arr.replace((0, 0, 0, 255), (0, 0, 0, 0))
del arr
return s
YELLOW_OUTLINE = (0, 0, 0)
TICKER_TEXTS = [
'FREEGAMES #3',
'REALISATION RAUSER ADVERTAINMENT GMBH REUTLINGEN',
'PRODUZIERT VON FACTOR 5',
'ANIMATIONEN UND ILLUSTRATIONEN HARTWIG NIEDERGASSEL',
]
TICKER_SECONDS = 4.0 # Anzeigedauer pro Credits-Text
BLINK_SECONDS = 0.35 # Blinkrhythmus des selektierten Eintrags
MENU_ITEMS = ['HIGHSCORES', 'ANLEITUNG', 'OPTIONEN', 'START', 'QUIT']
MENU_START_INDEX = 3 # Default-Auswahl im Original: START
# Layout (320x200-Koordinaten, aus den DOSBox-Referenzframes vermessen)
MENU_ITEM_Y0 = 44 # y des ersten Eintrags (HIGHSCORES)
MENU_ITEM_DY = 15 # Zeilenabstand
MENU_QUIT_GAP = 7 # QUIT ist etwas abgesetzt
PARTY1_POS = (34, 80)
PARTY2_POS = (218, 84)
TICKER_Y = 172 # Ticker-Zeile unterhalb der Box
def _draw_ticker(canvas, m, t):
idx = int(t / TICKER_SECONDS) % len(TICKER_TEXTS)
text = TICKER_TEXTS[idx]
# lange Texte auf 2 Zeilen brechen (wie im Original: "REALISATION RAUSER /
# ADVERTAINMENT GMBH REUTLINGEN" stand zweizeilig)
max_chars = 34
lines = []
if len(text) > max_chars:
words, cur = text.split(), ''
for wrd in words:
if len(cur) + len(wrd) + 1 > max_chars and cur:
lines.append(cur)
cur = wrd
else:
cur = (cur + ' ' + wrd).strip()
if cur:
lines.append(cur)
else:
lines = [text]
y = TICKER_Y
for line in lines:
surf = m.font_small.render(line, outline=YELLOW_OUTLINE)
canvas.blit(surf, ((320 - surf.get_width()) // 2, y))
y += 10
def _menu_canvas(m, items, selected, blink_on, t, hidden_indices=()):
'''Baut den kompletten Menue-Screen (320x200): Box, Parties, Eintraege
(selektierter blinkt), Ticker.'''
canvas = m.menu_bg.copy()
canvas.blit(m.party1, PARTY1_POS)
canvas.blit(m.party2, PARTY2_POS)
y = MENU_ITEM_Y0
for i, item in enumerate(items):
if i == len(items) - 1:
y += MENU_QUIT_GAP # letzter Eintrag (QUIT/EXIT) abgesetzt
visible = (i != selected) or blink_on
if i in hidden_indices:
visible = False
if visible:
surf = m.font_big.render(item)
canvas.blit(surf, ((320 - surf.get_width()) // 2, y))
y += MENU_ITEM_DY
_draw_ticker(canvas, m, t)
return canvas
def _text_panel(m, lines, y0=58, line_h=10):
'''Tafel-Screen: Menue-Box mit gelbem Text gefuellt (Anleitung/Copyright-
Stil). lines: Liste von (text, x) -- x=None zentriert.'''
canvas = m.menu_bg.copy()
y = y0
for entry in lines:
text, x = entry if isinstance(entry, tuple) else (entry, None)
if text:
surf = m.font_small.render(text, outline=YELLOW_OUTLINE)
if x is None:
x = (320 - surf.get_width()) // 2
canvas.blit(surf, (x, y))
y += line_h
return canvas
def _present(canvas):
intro._blit_centered(intro.build_letterboxed(canvas))
def _menu_loop(clock, draw_fn, on_key):
'''Generische Menue-Event-Schleife. draw_fn(t) zeichnet, on_key(event)
liefert None (weiter) oder ein Ergebnis (Schleife endet). Alt+Enter und
Fenster-X werden hier zentral behandelt.'''
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
r = on_key(event)
if r is not None:
return r
draw_fn(time.monotonic() - start)
pygame.display.flip()
clock.tick(30)
def run_menu(clock, m):
'''Hauptmenue. Rueckgabe: 'start' | 'quit' | 'title' (ESC -> zurueck zum
Titelbild).'''
state = {'sel': MENU_START_INDEX}
def draw(t):
blink_on = int(t / BLINK_SECONDS) % 2 == 0
_present(_menu_canvas(m, MENU_ITEMS, state['sel'], blink_on, t))
def key(event):
if event.key == pygame.K_ESCAPE:
return 'title'
if event.key in (pygame.K_UP, pygame.K_LEFT):
state['sel'] = (state['sel'] - 1) % len(MENU_ITEMS)
elif event.key in (pygame.K_DOWN, pygame.K_RIGHT):
state['sel'] = (state['sel'] + 1) % len(MENU_ITEMS)
elif event.key in (pygame.K_RETURN, pygame.K_SPACE):
choice = MENU_ITEMS[state['sel']]
if choice == 'START':
return 'start'
if choice == 'QUIT':
return 'quit'
if choice == 'OPTIONEN':
r = run_options(clock, m)
if r == 'quit':
return 'quit'
elif choice == 'ANLEITUNG':
r = run_anleitung(clock, m)
if r == 'quit':
return 'quit'
elif choice == 'HIGHSCORES':
r = run_highscores(clock, m)
if r == 'quit':
return 'quit'
return None
return _menu_loop(clock, draw, key)
def run_options(clock, m):
'''Optionsmenue: MUSIK EIN/AUS, EFFEKTE EIN/AUS, EXIT. Rueckgabe 'back'
oder 'quit'. Musik-Toggle wirkt sofort auf pygame.mixer.music.'''
state = {'sel': 2, 'musik': True, 'effekte': True}
def items():
return [f'MUSIK {"EIN" if state["musik"] else "AUS"}',
f'EFFEKTE {"EIN" if state["effekte"] else "AUS"}',
'EXIT']
def draw(t):
blink_on = int(t / BLINK_SECONDS) % 2 == 0
canvas = m.menu_bg.copy()
canvas.blit(m.party1, PARTY1_POS)
canvas.blit(m.party2, PARTY2_POS)
its = items()
y = 52
for i, item in enumerate(its):
if i == len(its) - 1:
y += 18 # EXIT abgesetzt (wie Original-Referenz)
if (i != state['sel']) or blink_on:
surf = m.font_big.render(item)
canvas.blit(surf, ((320 - surf.get_width()) // 2, y))
y += MENU_ITEM_DY
_draw_ticker(canvas, m, t)
_present(canvas)
def key(event):
if event.key == pygame.K_ESCAPE:
return 'back'
if event.key in (pygame.K_UP, pygame.K_LEFT):
state['sel'] = (state['sel'] - 1) % 3
elif event.key in (pygame.K_DOWN, pygame.K_RIGHT):
state['sel'] = (state['sel'] + 1) % 3
elif event.key in (pygame.K_RETURN, pygame.K_SPACE):
if state['sel'] == 0:
state['musik'] = not state['musik']
if pygame.mixer.get_init():
if state['musik']:
pygame.mixer.music.unpause()
else:
pygame.mixer.music.pause()
elif state['sel'] == 1:
state['effekte'] = not state['effekte']
# Effekte-Flag greift, sobald es im Gameplay Soundeffekte gibt
else:
return 'back'
return None
return _menu_loop(clock, draw, key)
ANLEITUNG_LINES = [
('STEUERUNG:', 30),
('MIT LINKS/RECHTS STEUERST DU DIE', 30),
('FIGUR. MIT LEERTASTE ODER PFEIL', 30),
('HOCH KANN DIE FIGUR SPRINGEN,', 30),
('MIT PFEIL UNTEN KANN SICH DIE', 30),
('FIGUR DUCKEN.', 30),
('MIT ALT AKTIVIERST DU DIE', 30),
('F\xc4HIGKEITEN DER FIGUREN...', 30),
'',
('TONY: RENNEN', 30),
('SMACKS: WASSERBLASEN', 30),
('COCO: HANGELN UND KLETTERN', 30),
('TOUCAN: FLIEGEN', 30),
]
# Default-Highscores aus der Original-Referenzaufnahme
DEFAULT_HIGHSCORES = [
('MATTHIAS', 100000, 5000, 20),
('JAN', 80000, 4000, 17),
('MARC', 60000, 3000, 14),
('MICHAEL', 40000, 2000, 12),
('URTE', 20000, 1000, 10),
('PETER', 10000, 700, 8),
('ANTONY', 5000, 400, 6),
('BORIS', 2500, 200, 4),
]
def run_anleitung(clock, m):
'''Anleitungstafel (gelber Text). Beliebige Taste/ESC -> zurueck.
Text an die Original-Joystick-Seite angelehnt, auf Tastatur uebertragen
(das Remake hat keinen Joystick-Support).'''
canvas = _text_panel(m, ANLEITUNG_LINES, y0=36, line_h=8)
def draw(_t):
_present(canvas)
def key(event):
return 'back' # jede Taste schliesst die Tafel
return _menu_loop(clock, draw, key)
def run_highscores(clock, m):
'''Highscore-Tafel (Tabelle NAME/SCORE/ITEMS/LEV). Beliebige Taste ->
zurueck. Ticker laeuft weiter (wie im Original).'''
def draw(t):
lines = [('NAME SCORE ITEMS LEV', 55)]
for i, (name, score, items_, lev) in enumerate(DEFAULT_HIGHSCORES, 1):
lines.append((f'{i}. {name:<9}{score:>7}{items_:>7}{lev:>5}', 30))
canvas = m.menu_bg.copy()
y = 44 # eng gepackt, damit alle 9 Zeilen in der Box (bis ~y=145) bleiben
for text, x in lines:
surf = m.font_small.render(text, outline=YELLOW_OUTLINE)
canvas.blit(surf, (x, y))
y += 10
_draw_ticker(canvas, m, t)
_present(canvas)
def key(event):
return 'back'
return _menu_loop(clock, draw, key)
def run_worldmap(clock, m):
'''Weltkarte (KARTE.PCC -- kompletter 320x200-Screen inkl. Rahmen und
Charakteren). Wie im Original (DOSBox-Referenz 25.07.2026): SPACE startet
Level 1 (Return tut im Original nichts, wir nehmen beides; Pfeiltasten
bewegen am Spielstart keinen sichtbaren Cursor -- es ist nur Level 1
verfuegbar). ESC -> zurueck ins Menue.
Rueckgabe: 'level' | 'menu' | 'quit'. Die Karten-Animationen (KARTE0-9)
folgen spaeter.'''
canvas = m.karte
def draw(_t):
_present(canvas)
def key(event):
if event.key == pygame.K_ESCAPE:
return 'menu'
if event.key in (pygame.K_SPACE, pygame.K_RETURN):
return 'level'
return None
return _menu_loop(clock, draw, key)