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>
436 lines
16 KiB
Python
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)
|