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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
949a7eeda2
commit
c8f7d4d990
@@ -15,6 +15,10 @@ werden keine vorab entpackten Assets benötigt.
|
|||||||
ICO (Tilesets), MAP (Level), TFMX (Musik).
|
ICO (Tilesets), MAP (Level), TFMX (Musik).
|
||||||
- ✅ **Boot-/Intro-Sequenz** spielbar (Rauser → Factor5 → Kellogg's-Logo →
|
- ✅ **Boot-/Intro-Sequenz** spielbar (Rauser → Factor5 → Kellogg's-Logo →
|
||||||
Titelbild, mit Fades, Vorhang-Wipe und Musik).
|
Titelbild, mit Fades, Vorhang-Wipe und Musik).
|
||||||
|
- ✅ **Hauptmenü** (HIGHSCORES / ANLEITUNG / OPTIONEN / START / QUIT) mit
|
||||||
|
blinkender Auswahl, Credits-Ticker, Optionsmenü, Anleitungs- und
|
||||||
|
Highscore-Tafel — gegen DOSBox-Referenzaufnahmen nachgebaut.
|
||||||
|
- ✅ **Weltkarte** (statisch; Levelauswahl folgt mit dem Gameplay).
|
||||||
- ⏳ **Nächster Meilenstein:** echter Gameplay-Loop — Tony auf Level `W1L0`
|
- ⏳ **Nächster Meilenstein:** echter Gameplay-Loop — Tony auf Level `W1L0`
|
||||||
bewegen (Physik, Kollision, Level-Scrolling). Existiert noch nicht.
|
bewegen (Physik, Kollision, Level-Scrolling). Existiert noch nicht.
|
||||||
|
|
||||||
@@ -55,14 +59,17 @@ python3 -m game # Intro; bleibt am Titelbild stehen (Musik loopt)
|
|||||||
python3 -m game --once # Titelbild nur kurz halten (für Tests/headless)
|
python3 -m game --once # Titelbild nur kurz halten (für Tests/headless)
|
||||||
```
|
```
|
||||||
|
|
||||||
Ablauf: Rauser → Factor5 → Kellogg's → Titelbild-Animation; danach bleibt das
|
Ablauf (wie im Original): Rauser → Factor5 → Kellogg's → Titelbild-Animation;
|
||||||
fertige Titelbild stehen und die Titelmusik läuft in Schleife, bis eine Taste
|
das fertige Titelbild bleibt stehen (Titelmusik loopt). **ESC/Leertaste**
|
||||||
das Programm beendet.
|
öffnet dort das Hauptmenü — START führt zur Weltkarte, QUIT beendet das
|
||||||
|
Programm, ESC im Menü kehrt zum Titelbild zurück.
|
||||||
|
|
||||||
Steuerung:
|
Steuerung:
|
||||||
- **Leertaste** — überspringt den aktuellen Intro-Screen (auf dem Titelbild
|
- **Leertaste** — überspringt den aktuellen Intro-Screen; am Titelbild öffnet
|
||||||
beendet sie das Programm)
|
sie das Hauptmenü
|
||||||
- **ESC** — beendet das Programm
|
- **ESC** — am Titelbild: Hauptmenü öffnen (wie im Original); im Menü: zurück
|
||||||
|
- **Pfeiltasten + Enter** — Menü-Navigation (die Auswahl blinkt, wie im
|
||||||
|
Original)
|
||||||
- **Alt+Enter** — zwischen Fenster- und Vollbildmodus umschalten; der Zustand
|
- **Alt+Enter** — zwischen Fenster- und Vollbildmodus umschalten; der Zustand
|
||||||
wird in `kelloggs.ini` gemerkt und beim nächsten Start wiederhergestellt
|
wird in `kelloggs.ini` gemerkt und beim nächsten Start wiederhergestellt
|
||||||
(Standard ohne `kelloggs.ini`: Fenstermodus)
|
(Standard ohne `kelloggs.ini`: Fenstermodus)
|
||||||
@@ -81,7 +88,8 @@ KELLOGG_DAT=/pfad/zu/PCKELL.DAT python3 -m game
|
|||||||
game/ Das eigentliche Spiel (startbar per `python -m game`)
|
game/ Das eigentliche Spiel (startbar per `python -m game`)
|
||||||
formats.py Parser/Decoder für alle Asset-Formate aus PCKELL.DAT
|
formats.py Parser/Decoder für alle Asset-Formate aus PCKELL.DAT
|
||||||
audio.py Wrapper um den TFMX-Musik-Renderer (rendert & cached WAVs)
|
audio.py Wrapper um den TFMX-Musik-Renderer (rendert & cached WAVs)
|
||||||
intro.py Boot-/Intro-Sequenz
|
intro.py Boot-/Intro-Sequenz + Ablaufsteuerung
|
||||||
|
menu.py Hauptmenü, Optionen, Anleitung, Highscores, Weltkarte
|
||||||
tfmx_player/ MIT-lizenzierter C-Renderer für TFMX-Musik
|
tfmx_player/ MIT-lizenzierter C-Renderer für TFMX-Musik
|
||||||
original/ Original-DOS-Spieldateien (PCKELL.DAT etc.) — NICHT im Repo
|
original/ Original-DOS-Spieldateien (PCKELL.DAT etc.) — NICHT im Repo
|
||||||
tools/ Reine Dev-Werkzeuge (Assets auspacken / als PNG ansehen)
|
tools/ Reine Dev-Werkzeuge (Assets auspacken / als PNG ansehen)
|
||||||
|
|||||||
+49
-31
@@ -439,15 +439,19 @@ def wait_or_skip(clock, seconds, screen_update_fn, fps=30):
|
|||||||
|
|
||||||
|
|
||||||
def wait_for_key(clock, draw_fn, fps=30):
|
def wait_for_key(clock, draw_fn, fps=30):
|
||||||
'''Zeigt das Standbild (draw_fn(1.0)) und wartet UNENDLICH, bis der User
|
'''Zeigt das Standbild (draw_fn(1.0)) und wartet UNENDLICH auf eine Taste.
|
||||||
Leertaste oder ESC drueckt (oder das Fenster schliesst). Alt+Enter schaltet
|
Alt+Enter schaltet dabei weiter Vollbild um; die geloopte Titelmusik
|
||||||
dabei weiter Vollbild um. Die geloopte Titelmusik laeuft die ganze Zeit
|
laeuft die ganze Zeit weiter. Rueckgabe:
|
||||||
weiter. Gibt immer 'quit' zurueck -- danach ist das Programm zu Ende.'''
|
'quit' -- Fenster-X (Programm beenden)
|
||||||
|
'key' -- ESC oder Leertaste gedrueckt (im Original oeffnet ESC am
|
||||||
|
Titelbild das Hauptmenue -- der Aufrufer entscheidet).'''
|
||||||
while True:
|
while True:
|
||||||
for event in pygame.event.get():
|
for event in pygame.event.get():
|
||||||
r = _process_event(event)
|
r = _process_event(event)
|
||||||
if r in ('quit', 'skip'): # auf dem letzten Screen beendet beides
|
if event.type == pygame.QUIT:
|
||||||
return 'quit'
|
return 'quit'
|
||||||
|
if r in ('quit', 'skip'):
|
||||||
|
return 'key'
|
||||||
draw_fn(1.0)
|
draw_fn(1.0)
|
||||||
pygame.display.flip()
|
pygame.display.flip()
|
||||||
clock.tick(fps)
|
clock.tick(fps)
|
||||||
@@ -868,32 +872,29 @@ def scene_kelloggs(clock, assets, audio_paths):
|
|||||||
return wait_or_skip(clock, 1.0, lambda _t: show_kelloggs(1.0))
|
return wait_or_skip(clock, 1.0, lambda _t: show_kelloggs(1.0))
|
||||||
|
|
||||||
|
|
||||||
def scene_title(clock, assets, hold_forever=True):
|
def scene_title_reveal(clock, assets):
|
||||||
'''Titelbild -- 2x2-Quadranten-Reveal, Scanlines loesen sich von der Mitte
|
'''Titelbild-Animation: 2x2-Quadranten-Reveal, Scanlines loesen sich von
|
||||||
auf. Danach bleibt das fertige, voll-farbige Titelbild STEHEN (die
|
der Mitte auf. Gibt ('quit'|None, title_img) zurueck -- 'skip' waehrend
|
||||||
Titelmusik loopt weiter), bis der User Leertaste/ESC drueckt -- dann endet
|
der Wipe springt direkt zum fertigen Bild.'''
|
||||||
das Programm. hold_forever=False (fuer --once/Tests) haelt stattdessen nur
|
|
||||||
kurz und kehrt zurueck, statt auf eine Taste zu warten.'''
|
|
||||||
quads = build_title_quadrants(assets)
|
quads = build_title_quadrants(assets)
|
||||||
r = wait_or_skip(clock, TITLE_WIPE_SECONDS,
|
r = wait_or_skip(clock, TITLE_WIPE_SECONDS,
|
||||||
lambda t: _blit_title(render_title_wipe_frame(quads, t)))
|
lambda t: _blit_title(render_title_wipe_frame(quads, t)))
|
||||||
if r == 'quit':
|
|
||||||
return 'quit'
|
|
||||||
# 'skip' waehrend der Wipe-Animation -> direkt zum fertigen Titelbild
|
|
||||||
title_img = build_title_image(quads)
|
title_img = build_title_image(quads)
|
||||||
draw = lambda _t: _blit_title(title_img)
|
return ('quit' if r == 'quit' else None), title_img
|
||||||
if hold_forever:
|
|
||||||
return wait_for_key(clock, draw)
|
|
||||||
return wait_or_skip(clock, 3.0, draw)
|
|
||||||
|
|
||||||
|
|
||||||
def run_once(clock, assets, audio_paths, hold_title=True):
|
def hold_title(clock, title_img):
|
||||||
'''Spielt die komplette Intro-Sequenz EINMAL ab (keine Endlosschleife mehr).
|
'''Fertiges Titelbild stehen lassen (Titelmusik loopt), bis eine Taste
|
||||||
LEERTASTE ueberspringt den jeweils aktuellen Screen (die Scene liefert dann
|
kommt. Rueckgabe 'quit' (Fenster-X) oder 'key' (ESC/Leertaste -> der
|
||||||
'skip' -> naechste Scene), ESC/Fenster-X beendet das Intro. Der letzte
|
Aufrufer oeffnet das Hauptmenue, wie im Original ESC).'''
|
||||||
Screen (Titelbild) bleibt bei hold_title=True stehen und die Titelmusik
|
return wait_for_key(clock, lambda _t: _blit_title(title_img))
|
||||||
loopt, bis Leertaste/ESC das Programm beendet. hold_title=False (--once)
|
|
||||||
haelt das Titelbild nur kurz -- fuer automatisierte/headless Laeufe.'''
|
|
||||||
|
def run_intro(clock, assets, audio_paths):
|
||||||
|
'''Spielt die Intro-Sequenz bis einschliesslich Titelbild-Animation EINMAL
|
||||||
|
ab. LEERTASTE ueberspringt den jeweils aktuellen Screen, Fenster-X/ESC
|
||||||
|
bricht ab. Rueckgabe ('quit'|None, title_img) -- title_img ist das fertige
|
||||||
|
Titelbild fuer die anschliessende Halte-Phase.'''
|
||||||
scenes = [
|
scenes = [
|
||||||
lambda: scene_rauser(clock, assets, audio_paths),
|
lambda: scene_rauser(clock, assets, audio_paths),
|
||||||
lambda: scene_pause(clock, 0.5),
|
lambda: scene_pause(clock, 0.5),
|
||||||
@@ -901,18 +902,17 @@ def run_once(clock, assets, audio_paths, hold_title=True):
|
|||||||
lambda: scene_pause(clock, 0.5),
|
lambda: scene_pause(clock, 0.5),
|
||||||
lambda: scene_kelloggs(clock, assets, audio_paths),
|
lambda: scene_kelloggs(clock, assets, audio_paths),
|
||||||
lambda: scene_pause(clock, 0.5),
|
lambda: scene_pause(clock, 0.5),
|
||||||
lambda: scene_title(clock, assets, hold_forever=hold_title),
|
|
||||||
]
|
]
|
||||||
for scene in scenes:
|
for scene in scenes:
|
||||||
r = scene()
|
r = scene()
|
||||||
if r == 'quit':
|
if r == 'quit':
|
||||||
return True
|
return 'quit', None
|
||||||
if r == 'skip' and pygame.mixer.get_init():
|
if r == 'skip' and pygame.mixer.get_init():
|
||||||
# beim Ueberspringen haengende Sound-Effekte (Sting/Factor5-Brausen)
|
# beim Ueberspringen haengende Sound-Effekte (Sting/Factor5-Brausen)
|
||||||
# abschneiden -- pygame.mixer.stop() betrifft nur Sound-Kanaele, die
|
# abschneiden -- pygame.mixer.stop() betrifft nur Sound-Kanaele, die
|
||||||
# geloopte Titelmusik (mixer.music) laeuft ungestoert weiter.
|
# geloopte Titelmusik (mixer.music) laeuft ungestoert weiter.
|
||||||
pygame.mixer.stop()
|
pygame.mixer.stop()
|
||||||
return False
|
return scene_title_reveal(clock, assets)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -942,10 +942,28 @@ def main():
|
|||||||
# fertige WAV-Pfade zu, nie mehr live-render im Hauptloop.
|
# fertige WAV-Pfade zu, nie mehr live-render im Hauptloop.
|
||||||
audio_paths, quit_requested = preload_all_audio(clock, assets)
|
audio_paths, quit_requested = preload_all_audio(clock, assets)
|
||||||
|
|
||||||
# EIN Durchlauf (keine Endlosschleife): endet am stehenden Titelbild, das
|
# Ablauf (wie Original, per DOSBox-Referenz): Intro -> Titelbild steht
|
||||||
# bis Leertaste/ESC gehalten wird (Titelmusik loopt). --once haelt nur kurz.
|
# (Musik loopt) -> Taste oeffnet das Hauptmenue -> START=Weltkarte,
|
||||||
|
# QUIT=Ende, ESC=zurueck zum Titelbild. --once: nach der Titelbild-
|
||||||
|
# Animation kurz halten und beenden (fuer Tests/headless).
|
||||||
if not quit_requested:
|
if not quit_requested:
|
||||||
run_once(clock, assets, audio_paths, hold_title=not once)
|
r, title_img = run_intro(clock, assets, audio_paths)
|
||||||
|
if r != 'quit' and title_img is not None:
|
||||||
|
if once:
|
||||||
|
wait_or_skip(clock, 3.0, lambda _t: _blit_title(title_img))
|
||||||
|
else:
|
||||||
|
from . import menu as menu_mod
|
||||||
|
m = menu_mod.MenuAssets(assets)
|
||||||
|
state = 'title'
|
||||||
|
while state not in ('quit',):
|
||||||
|
if state == 'title':
|
||||||
|
state = 'menu' if hold_title(clock, title_img) == 'key' else 'quit'
|
||||||
|
elif state == 'menu':
|
||||||
|
r = menu_mod.run_menu(clock, m)
|
||||||
|
state = {'start': 'map', 'title': 'title'}.get(r, 'quit')
|
||||||
|
elif state == 'map':
|
||||||
|
r = menu_mod.run_worldmap(clock, m)
|
||||||
|
state = 'menu' if r == 'menu' else 'quit'
|
||||||
|
|
||||||
if pygame.mixer.get_init():
|
if pygame.mixer.get_init():
|
||||||
pygame.mixer.music.stop()
|
pygame.mixer.music.stop()
|
||||||
|
|||||||
+427
@@ -0,0 +1,427 @@
|
|||||||
|
"""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)
|
||||||
Reference in New Issue
Block a user