Files
tony-remake/game/menu.py
T
duffyduckandClaude Fable 5 c8f7d4d990 Hauptmenue, Optionen, Anleitung, Highscores und Weltkarte implementiert
Per DOSBox-Referenzaufnahmen (Xvfb + XTEST-Tastensimulation) den kompletten
Original-Menuefluss erfasst und nachgebaut:
- ESC am stehenden Titelbild oeffnet das Hauptmenue (Original-Verhalten --
  Space/Return tun dort nichts, per Einzeltasten-Sessions verifiziert; das
  Original zeigt auch nach 115s passiv keinen Attract-Loop).
- Hauptmenue: HIGHSCORES/ANLEITUNG/OPTIONEN/START/QUIT in roter FON_2-
  Schrift, Default-Auswahl START, der selektierte Eintrag BLINKT (so zeigt
  das Original den Cursor). PARTY1/PARTY2-Charaktergruppen links/rechts,
  Credits-Ticker in gelber FON_1-Schrift unter der Box.
- OPTIONEN: MUSIK EIN/AUS (wirkt auf pygame.mixer.music), EFFEKTE EIN/AUS
  (Flag fuer spaeteres Gameplay), EXIT.
- ANLEITUNG: gelbe Texttafel (Original-Joystick-Text auf Tastatur uebertragen).
- HIGHSCORES: Tabelle NAME/SCORE/ITEMS/LEV mit den Original-Defaults.
- START: Weltkarte (KARTE.PCC, kompletter Screen) -- v1 statisch, Taste
  zurueck ins Menue; Levelauswahl/Animationen folgen mit dem Gameplay.
- QUIT: Programm-Ende.

Technik (game/menu.py):
- BitmapFont-Parser fuer die Original-Fonts: FON_1/FON_1B (9x7-Zellen,
  8px-Vorschub -- nur so passen die 32-Zeichen-Zeilen der Anleitung in die
  Box) und FON_2 (16x15-Zellen, 3 Reihen). Outline-Rendering fuer die gelbe
  Schrift (Original zeichnet sie ueberall mit schwarzem Rand).
- PARTY-Sprites via make_border_bg_transparent freigestellt (papierweisser
  BG wie bei den Rauser-Assets -- ein Schwarz-Replace loescht faelschlich
  die Konturen, per Vergleichsrender nachgewiesen und verworfen).
- intro.py: Zustandsautomat Titelbild <-> Menue <-> Weltkarte; wait_for_key
  unterscheidet jetzt Fenster-X ('quit') von Taste ('key').

Verifiziert: End-to-End-Lauf des Remakes unter Xvfb mit echten XTEST-Tasten
-- Intro -> ESC -> Menue -> START -> Karte -> zurueck -> Optionen -> Anleitung
-> Highscores -> QUIT beendet sauber (rc=0); alle Screens als Screenshots
gegen die DOSBox-Referenz geprueft. python -m game --once weiterhin exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 23:12:52 +02:00

428 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). v1: statisch; die kleinen Animationsframes KARTE0-9 und die
Levelauswahl folgen mit dem Gameplay. ESC/Taste -> zurueck ins Menue.'''
canvas = m.karte
def draw(_t):
_present(canvas)
def key(event):
return 'menu'
return _menu_loop(clock, draw, key)