From 95e5b1edb4b0276dbc251a505d378a2196e6db6d Mon Sep 17 00:00:00 2001 From: duffyduck Date: Fri, 24 Jul 2026 01:49:47 +0200 Subject: [PATCH] Intro: Endlosschleife raus, Alt+Enter-Vollbild, kelloggs.ini - Keine Endlosschleife mehr: nach der Titelbild-Animation bleibt das fertige, voll-farbige Titelbild STEHEN und die Titelmusik loopt weiter, bis Leertaste oder ESC das Programm beendet (neue wait_for_key()). run_once() laeuft nur noch einmal; scene_title haelt am Ende auf Tastendruck (hold_forever), --once haelt stattdessen kurz (fuer headless/Tests). - ALT+ENTER schaltet zwischen Fenster- und Vollbildmodus um (SCALED-Display skaliert das 960x720-Logikbild sauber hoch). Zentrale Event-Behandlung in _process_event(); Zeichenroutinen holen die aktuelle Surface per pygame.display.get_surface(), damit das Neuerzeugen des Display-Modes beim Umschalten keinen veralteten Screen-Bezug hinterlaesst (screen-Parameter aus den Scenes entfernt). - Vollbild-Zustand wird in kelloggs.ini gemerkt (load/save_fullscreen_pref) und beim naechsten Start wiederhergestellt; ohne kelloggs.ini = Fenstermodus. kelloggs.ini ist gitignored (pro-Rechner-Laufzeitzustand). Verifiziert (headless): Config load/save, Alt+Enter-Toggle, Space/ESC, wait_for_key(Space)->quit; python -m game --once exit 0 in ~17s. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 + README.md | 20 +++-- game/intro.py | 227 +++++++++++++++++++++++++++++++++++++------------- 3 files changed, 186 insertions(+), 64 deletions(-) diff --git a/.gitignore b/.gitignore index 7d0a967..a497886 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ game/tfmx_player/*.o # Python-Bytecode-Cache __pycache__/ *.pyc + +# Laufzeit-Einstellungen des Remakes (z.B. Vollbild-Zustand) -- pro Rechner +kelloggs.ini diff --git a/README.md b/README.md index cf2e3b3..0f2871c 100644 --- a/README.md +++ b/README.md @@ -51,13 +51,23 @@ sudo apt install python3-pygame python3-pil python3-numpy gcc ## Starten ``` -python3 -m game # Intro in Endlosschleife (wie Original-Titelbildschirm) -python3 -m game --once # ein Durchlauf, dann beenden +python3 -m game # Intro; bleibt am Titelbild stehen (Musik loopt) +python3 -m game --once # Titelbild nur kurz halten (für Tests/headless) ``` -Steuerung im Intro: **Leertaste** überspringt den aktuellen Screen, **ESC** -beendet das Intro. Ohne Soundkarte (z.B. headless) läuft das Spiel stumm weiter -statt abzustürzen. +Ablauf: Rauser → Factor5 → Kellogg's → Titelbild-Animation; danach bleibt das +fertige Titelbild stehen und die Titelmusik läuft in Schleife, bis eine Taste +das Programm beendet. + +Steuerung: +- **Leertaste** — überspringt den aktuellen Intro-Screen (auf dem Titelbild + beendet sie das Programm) +- **ESC** — beendet das Programm +- **Alt+Enter** — zwischen Fenster- und Vollbildmodus umschalten; der Zustand + wird in `kelloggs.ini` gemerkt und beim nächsten Start wiederhergestellt + (Standard ohne `kelloggs.ini`: Fenstermodus) + +Ohne Soundkarte (z.B. headless) läuft das Spiel stumm weiter statt abzustürzen. Liegt `PCKELL.DAT` woanders, kann der Pfad per Umgebungsvariable gesetzt werden: diff --git a/game/intro.py b/game/intro.py index ba6e4d2..54f85e5 100644 --- a/game/intro.py +++ b/game/intro.py @@ -105,9 +105,14 @@ Ladebildschirm (render_audio_with_loading() -- Rendering laeuft in einem Thread, Hauptthread pumpt weiter Events). run_once() greift danach nur noch auf fertige WAV-Pfade zu, nie mehr Live-Rendering im Hauptloop. -Aufruf: python3 -m game [--once] (--once: nach einem Durchlauf beenden, -sonst loopt die Sequenz weiter wie im Original-Titelbildschirm) -Steuerung: LEERTASTE ueberspringt den aktuellen Screen, ESC beendet das Intro. +Aufruf: python3 -m game [--once] +Ablauf: Rauser -> Factor5 -> Kellogg's -> Titelbild-Animation; danach bleibt das +fertige Titelbild STEHEN (Titelmusik loopt) bis Leertaste/ESC -> Programm-Ende. +Keine Endlosschleife der ganzen Sequenz. (--once: Titelbild nur kurz halten, +fuer Tests/headless.) +Steuerung: LEERTASTE ueberspringt den aktuellen Screen (auf dem Titelbild +beendet sie das Programm), ESC beendet immer, ALT+ENTER schaltet Fenster/Vollbild +um (Zustand wird in kelloggs.ini gemerkt). Stand 24.07.2026 (gegen echte DOSBox-Referenzaufnahmen abgeglichen): - Rauser wird direkt in Fensteraufloesung gerendert (ein Nearest-Upscale der @@ -121,6 +126,7 @@ import sys import os import time import threading +import configparser from collections import deque from .formats import DATContainer, load_pcc @@ -140,6 +146,80 @@ DAT_PATH = os.environ.get('KELLOGG_DAT', os.path.join(PROJECT_ROOT, 'game', 'ori WINDOW_W, WINDOW_H = 960, 720 BG_COLOR = (0, 0, 0) +# Vollbild-Zustand wird in kelloggs.ini gemerkt (Alt+Enter schaltet um). Liegt +# noch keine kelloggs.ini vor, ist der Default Fenstermodus. +CONFIG_PATH = os.environ.get('KELLOGG_CONFIG', os.path.join(PROJECT_ROOT, 'kelloggs.ini')) +_fullscreen = False # aktueller Zustand; in main() aus der INI geladen + + +def load_fullscreen_pref(): + '''Liest den gewuenschten Vollbild-Zustand aus kelloggs.ini. Fehlt die + Datei, ist der Default Fenstermodus (False).''' + cfg = configparser.ConfigParser() + try: + cfg.read(CONFIG_PATH) + return cfg.getboolean('display', 'fullscreen', fallback=False) + except Exception: + return False + + +def save_fullscreen_pref(fullscreen): + '''Schreibt den aktuellen Vollbild-Zustand nach kelloggs.ini.''' + cfg = configparser.ConfigParser() + cfg['display'] = {'fullscreen': 'true' if fullscreen else 'false'} + try: + with open(CONFIG_PATH, 'w') as f: + cfg.write(f) + except Exception as exc: + print(f'[intro] konnte {CONFIG_PATH} nicht schreiben: {exc}', file=sys.stderr) + + +def _display_flags(fullscreen): + # SCALED: das 960x720-Logikbild wird auf Fenster- bzw. Bildschirmgroesse + # skaliert -- so ist Vollbild ein sauberes Hochskalieren statt eines + # winzigen 960x720-Bildes mit schwarzem Rand. + flags = pygame.SCALED + if fullscreen: + flags |= pygame.FULLSCREEN + return flags + + +def toggle_fullscreen(): + '''Alt+Enter: zwischen Fenster und Vollbild umschalten und den Zustand in + kelloggs.ini merken (fuers naechste Starten). Die Zeichenroutinen holen die + aktuelle Surface jeweils per pygame.display.get_surface(), daher ist das + Neuerzeugen des Display-Modes hier unkritisch.''' + global _fullscreen + _fullscreen = not _fullscreen + try: + pygame.display.set_mode((WINDOW_W, WINDOW_H), _display_flags(_fullscreen)) + except Exception as exc: + print(f'[intro] Vollbild-Umschalten fehlgeschlagen: {exc}', file=sys.stderr) + save_fullscreen_pref(_fullscreen) + + +def _process_event(event): + '''Gemeinsame Event-Behandlung. Rueckgabe 'quit'/'skip'/None. Alt+Enter + (Vollbild-Toggle) wird hier abgefangen und zaehlt NICHT als skip/quit.''' + if event.type == pygame.QUIT: + return 'quit' + if event.type == pygame.KEYDOWN: + if event.key == pygame.K_RETURN and (event.mod & pygame.KMOD_ALT): + toggle_fullscreen() + return None + if event.key == pygame.K_ESCAPE: + return 'quit' + if event.key == pygame.K_SPACE: + return 'skip' + return None + + +def _blit_centered(surface): + '''Zeichnet `surface` zentriert/skaliert auf die AKTUELLE Display-Surface + (per get_surface(), damit ein Vollbild-Umschalten mitten in einer Szene + keinen veralteten Screen-Bezug hinterlaesst).''' + blit_scaled_centered(pygame.display.get_surface(), surface, WINDOW_W, WINDOW_H) + # "praesentiert"-Text-Faelle in KELLOGGS.PCC (siehe Docstring) PRAESENTIERT_FADE_INDICES = [34, 42, 43, 44, 45] PRAESENTIERT_FADE_SECONDS = 2.5 @@ -356,19 +436,30 @@ def wait_or_skip(clock, seconds, screen_update_fn, fps=30): pygame.display.flip() return None for event in pygame.event.get(): - if event.type == pygame.QUIT: - return 'quit' - if event.type == pygame.KEYDOWN: - if event.key == pygame.K_ESCAPE: - return 'quit' - if event.key == pygame.K_SPACE: - return 'skip' + r = _process_event(event) + if r: + return r screen_update_fn(t) pygame.display.flip() clock.tick(fps) -def render_audio_with_loading(screen, clock, container, name, song_index, label): +def wait_for_key(clock, draw_fn, fps=30): + '''Zeigt das Standbild (draw_fn(1.0)) und wartet UNENDLICH, bis der User + Leertaste oder ESC drueckt (oder das Fenster schliesst). Alt+Enter schaltet + dabei weiter Vollbild um. Die geloopte Titelmusik laeuft die ganze Zeit + weiter. Gibt immer 'quit' zurueck -- danach ist das Programm zu Ende.''' + while True: + for event in pygame.event.get(): + r = _process_event(event) + if r in ('quit', 'skip'): # auf dem letzten Screen beendet beides + return 'quit' + draw_fn(1.0) + pygame.display.flip() + clock.tick(fps) + + +def render_audio_with_loading(clock, container, name, song_index, label): '''Rendert einen TFMX-Song per tfmx_audio.render_wav in einem Hintergrund- Thread, waehrend der Hauptthread weiter Events pumpt und einen simplen Ladebildschirm zeichnet. @@ -418,12 +509,16 @@ def render_audio_with_loading(screen, clock, container, name, song_index, label) for event in pygame.event.get(): if event.type == pygame.QUIT: return None, True - if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE: - return None, True + if event.type == pygame.KEYDOWN: + if event.key == pygame.K_ESCAPE: + return None, True + if event.key == pygame.K_RETURN and (event.mod & pygame.KMOD_ALT): + toggle_fullscreen() now = time.monotonic() if now - last_dot_tick > 0.4: dots = (dots + 1) % 4 last_dot_tick = now + screen = pygame.display.get_surface() screen.fill(BG_COLOR) if font is not None: text = font.render(f'Lade {label}{"." * dots}', True, (200, 200, 200)) @@ -433,12 +528,12 @@ def render_audio_with_loading(screen, clock, container, name, song_index, label) thread.join() if 'error' in result: - print(f'[intro_sequence] {label} konnte nicht gerendert werden: {result["error"]}', file=sys.stderr) + print(f'[intro] {label} konnte nicht gerendert werden: {result["error"]}', file=sys.stderr) return None, False return result.get('path'), False -def preload_all_audio(screen, clock, assets): +def preload_all_audio(clock, assets): '''Rendert VOR der eigentlichen Sequenz alle drei benoetigten TFMX-Songs einmalig (siehe render_audio_with_loading-Docstring) -- danach ist jeder run_once()-Durchlauf (auch der allererste!) garantiert freeze-frei, weil @@ -455,7 +550,7 @@ def preload_all_audio(screen, clock, assets): audio_paths = {} for key, label in jobs: name, song_index = key - path, quit_requested = render_audio_with_loading(screen, clock, assets.container, name, song_index, label) + path, quit_requested = render_audio_with_loading(clock, assets.container, name, song_index, label) audio_paths[key] = path if quit_requested: return audio_paths, True @@ -557,15 +652,17 @@ def render_rauser_slide(canvas, parts, slide_t): canvas.blit(r3, (r3_final_x, r3_y)) -def _draw_rauser(screen, parts, bg_color, slide_t): +def _draw_rauser(parts, bg_color, slide_t): '''Zeichnet einen Rauser-Frame direkt in Fensteraufloesung: Hintergrund fuellen, dann die (transparent-hinterlegten) Logo-Teile an ihrer Slide- - Position druebersetzen.''' + Position druebersetzen. Zielt immer auf die aktuelle Display-Surface + (get_surface), damit ein Vollbild-Umschalten nichts kaputt macht.''' + screen = pygame.display.get_surface() screen.fill(bg_color) render_rauser_slide(screen, parts, slide_t) -def scene_rauser(screen, clock, assets, audio_paths): +def scene_rauser(clock, assets, audio_paths): '''Rauser-Karte: weisser Grund, Slide-in, Sound-Sting bei Ankunft, Fade zu grauem Grund, kurz halten. Rueckgabe 'quit'/'skip'/None (siehe wait_or_skip). Wird jetzt DIREKT in Fensteraufloesung gerendert (kein 320er-Zwischen- @@ -587,7 +684,7 @@ def scene_rauser(screen, clock, assets, audio_paths): sting_played = [False] def show_slide(t): - _draw_rauser(screen, parts, RAUSER_WHITE, t) + _draw_rauser(parts, RAUSER_WHITE, t) if t >= 1.0 and not sting_played[0]: sting_played[0] = True if sting is not None: @@ -602,19 +699,19 @@ def scene_rauser(screen, clock, assets, audio_paths): sting_played[0] = True r = wait_or_skip(clock, RAUSER_HOLD_SECONDS, - lambda _t: _draw_rauser(screen, parts, RAUSER_WHITE, 1.0)) + lambda _t: _draw_rauser(parts, RAUSER_WHITE, 1.0)) if r: return r def show_fade_grey(t): bg = tuple(int(RAUSER_WHITE[i] + (RAUSER_GREY[i] - RAUSER_WHITE[i]) * t) for i in range(3)) - _draw_rauser(screen, parts, bg, 1.0) + _draw_rauser(parts, bg, 1.0) r = wait_or_skip(clock, RAUSER_FADE_TO_GREY_SECONDS, show_fade_grey) if r: return r return wait_or_skip(clock, RAUSER_GREY_HOLD_SECONDS, - lambda _t: _draw_rauser(screen, parts, RAUSER_GREY, 1.0)) + lambda _t: _draw_rauser(parts, RAUSER_GREY, 1.0)) # --------------------------------------------------------------------------- @@ -711,12 +808,12 @@ def render_title_wipe_frame(quads, t): return canvas -def scene_pause(screen, clock, seconds): +def scene_pause(clock, seconds): '''Kurze schwarze Pause zwischen zwei Screens.''' - return wait_or_skip(clock, seconds, lambda _t: screen.fill(BG_COLOR)) + return wait_or_skip(clock, seconds, lambda _t: pygame.display.get_surface().fill(BG_COLOR)) -def scene_factor5(screen, clock, assets, audio_paths): +def scene_factor5(clock, assets, audio_paths): '''Factor5-Studio-Logo (statisches Bild) + TITEL2-Song 1 ("Brausen"). KORREKTUR (Stefan 24.07.2026): der Screen wird EXAKT so lange gezeigt wie der Sound spielt -- ist er zu Ende, geht es zu Kellogg's weiter. Ohne @@ -734,13 +831,13 @@ def scene_factor5(screen, clock, assets, audio_paths): snd.play() except Exception as exc: print(f'[intro] Factor5-Sound konnte nicht geladen werden: {exc}', file=sys.stderr) - return wait_or_skip(clock, hold, - lambda _t: blit_scaled_centered(screen, factor5, WINDOW_W, WINDOW_H)) + return wait_or_skip(clock, hold, lambda _t: _blit_centered(factor5)) -def scene_kelloggs(screen, clock, assets, audio_paths): +def scene_kelloggs(clock, assets, audio_paths): '''Kellogg's-Markenlogo; darunter faedet "praesentiert" per Palettenfade - ein. Hier setzt auch die Titelmusik (TITEL Song 0) geloopt ein.''' + ein. Hier setzt auch die Titelmusik (TITEL Song 0) geloopt ein und laeuft + dann bis zum Ende des Programms durch (siehe scene_title).''' titel_path = audio_paths.get(('TITEL', None)) if pygame.mixer.get_init() and titel_path: try: @@ -753,7 +850,7 @@ def scene_kelloggs(screen, clock, assets, audio_paths): def show_kelloggs(t): frame = build_kelloggs_frame(kw, kh, kidx, kpal, t) - blit_scaled_centered(screen, build_letterboxed(frame), WINDOW_W, WINDOW_H) + _blit_centered(build_letterboxed(frame)) r = wait_or_skip(clock, PRAESENTIERT_FADE_SECONDS, show_kelloggs) if r: return r @@ -761,33 +858,40 @@ def scene_kelloggs(screen, clock, assets, audio_paths): return wait_or_skip(clock, 1.0, lambda _t: show_kelloggs(1.0)) -def scene_title(screen, clock, assets): +def scene_title(clock, assets, hold_forever=True): '''Titelbild -- 2x2-Quadranten-Reveal, Scanlines loesen sich von der Mitte - auf (siehe render_title_wipe_frame), danach das fertige Bild halten.''' + auf. Danach bleibt das fertige, voll-farbige Titelbild STEHEN (die + Titelmusik loopt weiter), bis der User Leertaste/ESC drueckt -- dann endet + 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) r = wait_or_skip(clock, TITLE_WIPE_SECONDS, - lambda t: blit_scaled_centered(screen, render_title_wipe_frame(quads, t), WINDOW_W, WINDOW_H)) - if r: - return r + lambda t: _blit_centered(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) - return wait_or_skip(clock, 3.0, - lambda _t: blit_scaled_centered(screen, title_img, WINDOW_W, WINDOW_H)) + draw = lambda _t: _blit_centered(title_img) + if hold_forever: + return wait_for_key(clock, draw) + return wait_or_skip(clock, 3.0, draw) -def run_once(screen, clock, assets, audio_paths): - '''Spielt die komplette Intro-Sequenz einmal ab. LEERTASTE ueberspringt den - jeweils aktuellen Screen (die Scene liefert dann 'skip' -> es geht sofort - zur naechsten Scene), ESC/Fenster-X beendet das Intro komplett. Gibt True - zurueck, wenn der User ganz beenden will (dann bricht auch die - Endlosschleife in main() ab).''' +def run_once(clock, assets, audio_paths, hold_title=True): + '''Spielt die komplette Intro-Sequenz EINMAL ab (keine Endlosschleife mehr). + LEERTASTE ueberspringt den jeweils aktuellen Screen (die Scene liefert dann + 'skip' -> naechste Scene), ESC/Fenster-X beendet das Intro. Der letzte + Screen (Titelbild) bleibt bei hold_title=True stehen und die Titelmusik + loopt, bis Leertaste/ESC das Programm beendet. hold_title=False (--once) + haelt das Titelbild nur kurz -- fuer automatisierte/headless Laeufe.''' scenes = [ - lambda: scene_rauser(screen, clock, assets, audio_paths), - lambda: scene_pause(screen, clock, 0.5), - lambda: scene_factor5(screen, clock, assets, audio_paths), - lambda: scene_pause(screen, clock, 0.5), - lambda: scene_kelloggs(screen, clock, assets, audio_paths), - lambda: scene_pause(screen, clock, 0.5), - lambda: scene_title(screen, clock, assets), + lambda: scene_rauser(clock, assets, audio_paths), + lambda: scene_pause(clock, 0.5), + lambda: scene_factor5(clock, assets, audio_paths), + lambda: scene_pause(clock, 0.5), + lambda: scene_kelloggs(clock, assets, audio_paths), + lambda: scene_pause(clock, 0.5), + lambda: scene_title(clock, assets, hold_forever=hold_title), ] for scene in scenes: r = scene() @@ -802,6 +906,7 @@ def run_once(screen, clock, assets, audio_paths): def main(): + global _fullscreen once = '--once' in sys.argv pygame.mixer.pre_init(44100, -16, 2, 512) pygame.init() @@ -810,8 +915,12 @@ def main(): except pygame.error as exc: # Kein Audio-Device vorhanden (z.B. headless VM/Container ohne # Soundkarte) -- Sequenz laeuft stumm weiter statt abzustuerzen. - print(f'[intro_sequence] Audio nicht verfuegbar, laeuft stumm: {exc}', file=sys.stderr) - screen = pygame.display.set_mode((WINDOW_W, WINDOW_H)) + print(f'[intro] Audio nicht verfuegbar, laeuft stumm: {exc}', file=sys.stderr) + + # Vollbild-Zustand aus kelloggs.ini (Default Fenster). Alt+Enter schaltet zur + # Laufzeit um und speichert den neuen Zustand wieder dorthin. + _fullscreen = load_fullscreen_pref() + pygame.display.set_mode((WINDOW_W, WINDOW_H), _display_flags(_fullscreen)) pygame.display.set_caption("Tony & Friends in Kellogg's Land -- Intro (Remake)") clock = pygame.time.Clock() @@ -819,14 +928,14 @@ def main(): # Alle TFMX-Songs EINMALIG vor der eigentlichen Sequenz rendern (mit # sichtbarem, responsivem Ladebildschirm) -- siehe render_audio_with_loading - # zum Hang-Bug, den das behebt. Danach greifen alle run_once()-Durchlaeufe - # nur noch auf fertige WAV-Pfade zu, nie mehr live-render im Hauptloop. - audio_paths, quit_requested = preload_all_audio(screen, clock, assets) + # zum Hang-Bug, den das behebt. Danach greift die Sequenz nur noch auf + # fertige WAV-Pfade zu, nie mehr live-render im Hauptloop. + audio_paths, quit_requested = preload_all_audio(clock, assets) + # EIN Durchlauf (keine Endlosschleife): endet am stehenden Titelbild, das + # bis Leertaste/ESC gehalten wird (Titelmusik loopt). --once haelt nur kurz. if not quit_requested: - quit_requested = run_once(screen, clock, assets, audio_paths) - while not quit_requested and not once: - quit_requested = run_once(screen, clock, assets, audio_paths) + run_once(clock, assets, audio_paths, hold_title=not once) if pygame.mixer.get_init(): pygame.mixer.music.stop()