"""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)