diff --git a/README.md b/README.md index 8f7b6e5..cf2e3b3 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,9 @@ python3 -m game # Intro in Endlosschleife (wie Original-Titelbildschi python3 -m game --once # ein Durchlauf, dann beenden ``` -Abbruch jederzeit per `ESC`. Ohne Soundkarte (z.B. headless) läuft das Spiel -stumm weiter statt abzustürzen. +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. Liegt `PCKELL.DAT` woanders, kann der Pfad per Umgebungsvariable gesetzt werden: diff --git a/game/audio.py b/game/audio.py index e4bc9a8..08af8db 100644 --- a/game/audio.py +++ b/game/audio.py @@ -47,8 +47,18 @@ _CACHE_DIR = os.environ.get('KELLOGG_AUDIO_CACHE', os.path.join(_PROJECT_ROOT, ' def _ensure_binary(): + '''Baut render_tfmx, wenn das Binary fehlt ODER aelter ist als eine der + Quelldateien. Das "aelter als die Quelle"-Kriterium ist wichtig: nach einem + git pull (z.B. dem Song-Index-Feature in render_tfmx.c) blieb sonst ein + veraltetes Binary liegen, das falsche/zu lange WAVs rendert -- das hat am + 24.07.2026 einen 180s-statt-2.4s-Render und damit einen Intro-Hang + verursacht (Factor5-Dauer haengt an der Songlaenge).''' + srcs = [os.path.join(_PLAYER_DIR, f) + for f in ('render_tfmx.c', 'tfmx.h', 'paula.h', 'player_api.h')] if os.path.exists(_BINARY): - return + bmtime = os.path.getmtime(_BINARY) + if all(not os.path.exists(s) or os.path.getmtime(s) <= bmtime for s in srcs): + return # Binary ist aktuell subprocess.run(['bash', os.path.join(_PLAYER_DIR, 'build.sh')], check=True) diff --git a/game/intro.py b/game/intro.py index 970c936..ba6e4d2 100644 --- a/game/intro.py +++ b/game/intro.py @@ -107,6 +107,15 @@ 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. + +Stand 24.07.2026 (gegen echte DOSBox-Referenzaufnahmen abgeglichen): +- Rauser wird direkt in Fensteraufloesung gerendert (ein Nearest-Upscale der + nativen Assets statt 0.5x-Downscale + x3-Upscale) -> gestochen scharf; das + tuerkise "!"-Kaestchen (Engine-gezeichnet, kein Asset) wird nachgebaut. +- Factor5-Logo haelt exakt so lange wie sein Sound (TITEL2 Song 1, ~2.4s). +- Titelbild-Reveal: KEIN Grau/keine Entsaettigung mehr; die Scanlines auf der + unteren Haelfte loesen sich von der Mitte nach unten auf. """ import sys import os @@ -148,6 +157,24 @@ PRAESENTIERT_FADE_SECONDS = 2.5 # Weichzeichnen) statt smoothscale skalieren -- siehe pcc_surface_scaled(). ASSET_DISPLAY_SCALE = 0.5 +# KORREKTUR #3 (24.07.2026, endlich mit echter DOSBox-Referenz vermessen): +# Das Rauser-Logo (RAUSER1 "RAUSER"-Box + RAUSER3 "!"-Box + RAUSER2 +# "ADVERTAINMENT") ist im Original nur ~105px breit auf einer 320er-Leinwand +# (~33% Breite) -- die Groesse (netto ~1.5x = 0.5x * 3x-Fensterskalierung) war +# also die ganze Zeit richtig. Der QUALITAETS-Bug war der ZWEISTUFIGE Skalier- +# weg: die nativen Assets (RAUSER1 ist 182px!) wurden erst per Nearest auf 0.5x +# RUNTER (verwirft die Haelfte der Pixel -> zerbrochene Serifen) und die 320er- +# Leinwand danach x3 HOCH. Fix: die nativen Assets EINMAL direkt auf ihre +# Endgroesse hochskalieren (netto ~1.6x, ein reiner Upscale -> alle nativen +# Pixel bleiben erhalten, gestochen scharf) und den Rauser-Screen direkt in +# Fensteraufloesung rendern (nicht ueber die 320er-Zwischenleinwand). Wert so +# gewaehlt = 1.5 = exakt die alte Netto-Groesse (0.5x * 3x), die Stefan +# groessenmaessig nie bemaengelt hat -- nur jetzt als EIN Upscale statt +# ueber den verlustreichen 0.5x-Zwischenschritt, also identische Groesse/ +# Position wie bisher, aber gestochen scharf. Deckt sich mit der Referenz- +# messung (Logo ~33% Bildbreite). +RAUSER_LOGO_SCALE = 1.5 + # Rauser-Timing (siehe Docstring) RAUSER_SLIDE_SECONDS = 0.9 RAUSER_HOLD_SECONDS = 1.3 @@ -155,10 +182,21 @@ RAUSER_FADE_TO_GREY_SECONDS = 0.4 RAUSER_GREY_HOLD_SECONDS = 0.3 RAUSER_GREY = (73, 73, 73) RAUSER_WHITE = (255, 255, 255) +# Tuerkis-Kaestchen hinter dem "!" -- aus der DOSBox-Referenz gesampelt. +# Es steckt in KEINEM PCC-Asset (RAUSER3 ist nur das nackte "!"), sondern wird +# im Original von der Engine als Vollfarb-Rechteck gezeichnet. +RAUSER_TEAL = (5, 135, 183) -# Factor5-Timing (siehe Docstring-Korrektur -- Stefans Meldung 22.07.2026 -# abends): feste Anzeigedauer statt (falsch) an eine 123s-Melodie gekoppelt. -FACTOR5_HOLD_SECONDS = 5.0 +# Factor5-Timing (Stefan 24.07.2026): das Factor5-Logo wird exakt so lange +# gezeigt, wie der zugehoerige Sound (TITEL2 Song 1, ~2.4s) spielt -- ist der +# Sound zu Ende, geht es zu Kellogg's weiter. FALLBACK gilt ohne Audio-Geraet +# (keine Songlaenge ermittelbar). MAX ist eine Sicherheits-Obergrenze: der +# Factor5-Sound ist bewusst kurz ("kurzes Brausen"); sollte je ein fehlerhaft +# gerendertes (zu langes) WAV im Cache landen, darf der Screen NICHT minutenlang +# haengen (genau dieser Bug ist am 24.07. durch ein veraltetes render_tfmx- +# Binary passiert -- 180s statt 2.4s). +FACTOR5_FALLBACK_SECONDS = 2.5 +FACTOR5_MAX_SECONDS = 12.0 # Titelbild-Timing (siehe Docstring) TITLE_HEIGHT_REVEAL_FRACTION = 0.55 # Anteil der Wipe-Dauer fuer das Hoehenwachstum @@ -302,7 +340,13 @@ def blit_scaled_centered(screen, surface, window_w, window_h, bg_color=BG_COLOR) def wait_or_skip(clock, seconds, screen_update_fn, fps=30): '''Laeuft seconds lang, ruft pro Frame screen_update_fn(t_norm 0..1) auf. - Bricht per ESC/Klick vorzeitig ab (gibt True zurueck wenn User quit will).''' + Rueckgabe: + 'quit' -- User will das ganze Intro beenden (ESC oder Fenster-X) + 'skip' -- User will den aktuellen Screen ueberspringen (Leertaste) + None -- die Dauer ist regulaer abgelaufen + 'quit' und 'skip' sind beide truthy, None ist falsy -- Aufrufer koennen also + kurz `if r: return r` schreiben, um Scene-Abbruch (skip) und Gesamt-Abbruch + (quit) gemeinsam nach oben durchzureichen.''' start = time.monotonic() while True: now = time.monotonic() @@ -310,12 +354,15 @@ def wait_or_skip(clock, seconds, screen_update_fn, fps=30): if t >= 1.0: screen_update_fn(1.0) pygame.display.flip() - return False + return None for event in pygame.event.get(): if event.type == pygame.QUIT: - return True - if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE: - return True + return 'quit' + if event.type == pygame.KEYDOWN: + if event.key == pygame.K_ESCAPE: + return 'quit' + if event.key == pygame.K_SPACE: + return 'skip' screen_update_fn(t) pygame.display.flip() clock.tick(fps) @@ -446,26 +493,43 @@ def build_kelloggs_frame(w, h, indices, palette, fade_t): # --------------------------------------------------------------------------- def build_rauser_parts(assets): - '''Liefert die drei (bei 0.5x Nearest-Neighbor-Groesse, siehe Docstring- - Korrektur v3) Rauser-Teile + ihre Endpositionen auf einer 320x240- - Leinwand (RAUSER1 links, RAUSER3 rechts daneben, RAUSER2 darunter - mittig).''' + '''Liefert die drei Rauser-Teile bei RAUSER_LOGO_SCALE (Nearest-Neighbor- + UPSCALE direkt aus der Nativaufloesung, siehe Docstring-Korrektur #3) + + ihre Endpositionen direkt in FENSTER-Koordinaten (960x720). RAUSER1 + ("RAUSER") links, RAUSER3 ("!") rechts daneben, RAUSER2 ("ADVERTAINMENT") + darunter -- der Rauser-Screen wird NICHT ueber die 320er-Zwischenleinwand + gerendert (die den Blur verursacht hat), sondern direkt in voller + Fensteraufloesung.''' # cut_border_bg=True (Fix 22.07.2026 abends): macht den papierweissen # Hintergrund um jede Box transparent (nur die vom Bildrand aus # zusammenhaengende Flaeche, siehe make_border_bg_transparent) -- vorher # blieb hier ein harter weisser Kasten stehen, der beim Fade zu Grau nicht # mitgefadet ist (Stefans Meldung "Text bleibt hart umrandet stehen"). - r1 = assets.pcc_surface_scaled('RAUSER1', cut_border_bg=True) # "RAUSER"-Box - r2 = assets.pcc_surface_scaled('RAUSER2', cut_border_bg=True) # "ADVERTAINMENT" - r3 = assets.pcc_surface_scaled('RAUSER3', cut_border_bg=True) # "!" auf blauer Box + # scale=RAUSER_LOGO_SCALE ist ein UPSCALE der nativen Assets -> pcc_surface_scaled + # macht cut_border auf der Nativ-Surface und skaliert dann per Nearest hoch, + # es geht also kein natives Pixel verloren (Gegensatz zum alten 0.5x-Downscale). + r1 = assets.pcc_surface_scaled('RAUSER1', scale=RAUSER_LOGO_SCALE, cut_border_bg=True) # "RAUSER"-Box + r2 = assets.pcc_surface_scaled('RAUSER2', scale=RAUSER_LOGO_SCALE, cut_border_bg=True) # "ADVERTAINMENT" + excl = assets.pcc_surface_scaled('RAUSER3', scale=RAUSER_LOGO_SCALE, cut_border_bg=True) # nur das nackte "!" - total_w = r1.get_width() + r3.get_width() + # Tuerkis-Kaestchen hinter dem "!" nachbauen (siehe RAUSER_TEAL -- im + # Original von der Engine gezeichnet, in keinem Asset). Gegen die DOSBox- + # Referenz vermessen: ~gleich hoch wie die RAUSER-Box, ~0.85x so breit wie + # hoch, das "!" ragt oben aus dem Kaestchen heraus. + tb_h = r1.get_height() + tb_w = max(excl.get_width() + 6, round(tb_h * 0.85)) + overhang = max(0, excl.get_height() - tb_h) + r3 = pygame.Surface((tb_w, tb_h + overhang), pygame.SRCALPHA) + pygame.draw.rect(r3, RAUSER_TEAL, (0, overhang, tb_w, tb_h)) + r3.blit(excl, ((tb_w - excl.get_width()) // 2, 0)) # "!" mittig, ragt oben raus + + total_w = max(r1.get_width() + r3.get_width(), r2.get_width()) total_h = r1.get_height() + r2.get_height() - x0 = (320 - total_w) // 2 - y0 = (240 - total_h) // 2 + x0 = (WINDOW_W - total_w) // 2 + y0 = (WINDOW_H - total_h) // 2 r1_final = (x0, y0) - r3_final = (x0 + r1.get_width(), y0 - (r3.get_height() - r1.get_height()) // 2) + r3_final = (x0 + r1.get_width(), y0 - overhang) # teal-Teil oben buendig mit der RAUSER-Box r2_final = (x0, y0 + r1.get_height()) return { 'r1': r1, 'r2': r2, 'r3': r3, @@ -493,13 +557,22 @@ def render_rauser_slide(canvas, parts, slide_t): canvas.blit(r3, (r3_final_x, r3_y)) -def run_rauser(screen, clock, assets, audio_paths): +def _draw_rauser(screen, 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.''' + screen.fill(bg_color) + render_rauser_slide(screen, parts, slide_t) + + +def scene_rauser(screen, clock, assets, audio_paths): '''Rauser-Karte: weisser Grund, Slide-in, Sound-Sting bei Ankunft, Fade zu - grauem Grund, kurz halten. Gibt True zurueck wenn der User abbrechen will. - audio_paths kommt aus preload_all_audio() -- das Audio wird hier NICHT - mehr live gerendert (siehe render_audio_with_loading-Docstring zum - Hang-Bug, den das behebt), sondern nur noch aus dem fertigen WAV-Pfad - geladen (schnell, nie blockierend).''' + grauem Grund, kurz halten. Rueckgabe 'quit'/'skip'/None (siehe wait_or_skip). + Wird jetzt DIREKT in Fensteraufloesung gerendert (kein 320er-Zwischen- + canvas + Upscale mehr) -- das war die Ursache des Rauser-Aufloesungs- + Problems (siehe RAUSER_LOGO_SCALE-Kommentar). + audio_paths kommt aus preload_all_audio() -- das Audio wird hier nur noch + aus fertigen WAV-Pfaden geladen (nie live gerendert, siehe Hang-Fix).''' parts = build_rauser_parts(assets) sting = None # TITEL2 Song 0 -- der kuerzere der beiden TITEL2-Songs (~2.04s), Song 1 @@ -509,55 +582,39 @@ def run_rauser(screen, clock, assets, audio_paths): try: sting = pygame.mixer.Sound(sting_path) except Exception as exc: - print(f'[intro_sequence] Rauser-Sound-Sting konnte nicht geladen werden: {exc}', file=sys.stderr) + print(f'[intro] Rauser-Sound-Sting konnte nicht geladen werden: {exc}', file=sys.stderr) - sting_played = False + sting_played = [False] def show_slide(t): - nonlocal sting_played - canvas = pygame.Surface((320, 240)) - canvas.fill(RAUSER_WHITE) - render_rauser_slide(canvas, parts, t) - if t >= 1.0 and not sting_played: - sting_played = True + _draw_rauser(screen, parts, RAUSER_WHITE, t) + if t >= 1.0 and not sting_played[0]: + sting_played[0] = True if sting is not None: sting.play() - blit_scaled_centered(screen, canvas, WINDOW_W, WINDOW_H, bg_color=RAUSER_WHITE) - quit_requested = wait_or_skip(clock, RAUSER_SLIDE_SECONDS, show_slide) - if quit_requested: - return True + r = wait_or_skip(clock, RAUSER_SLIDE_SECONDS, show_slide) + if r: + return r - # falls die Slide-Phase zu kurz war um sting_played zu triggern (sollte - # durch t>=1.0 im letzten Frame von wait_or_skip immer der Fall sein) - if not sting_played and sting is not None: + # falls die Slide-Phase zu kurz war um den Sting zu triggern + if not sting_played[0] and sting is not None: sting.play() - sting_played = True + sting_played[0] = True - def show_hold_white(_t): - canvas = pygame.Surface((320, 240)) - canvas.fill(RAUSER_WHITE) - render_rauser_slide(canvas, parts, 1.0) - blit_scaled_centered(screen, canvas, WINDOW_W, WINDOW_H, bg_color=RAUSER_WHITE) - quit_requested = wait_or_skip(clock, RAUSER_HOLD_SECONDS, show_hold_white) - if quit_requested: - return True + r = wait_or_skip(clock, RAUSER_HOLD_SECONDS, + lambda _t: _draw_rauser(screen, 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)) - canvas = pygame.Surface((320, 240)) - canvas.fill(bg) - render_rauser_slide(canvas, parts, 1.0) - blit_scaled_centered(screen, canvas, WINDOW_W, WINDOW_H, bg_color=bg) - quit_requested = wait_or_skip(clock, RAUSER_FADE_TO_GREY_SECONDS, show_fade_grey) - if quit_requested: - return True + _draw_rauser(screen, parts, bg, 1.0) + r = wait_or_skip(clock, RAUSER_FADE_TO_GREY_SECONDS, show_fade_grey) + if r: + return r - def show_hold_grey(_t): - canvas = pygame.Surface((320, 240)) - canvas.fill(RAUSER_GREY) - render_rauser_slide(canvas, parts, 1.0) - blit_scaled_centered(screen, canvas, WINDOW_W, WINDOW_H, bg_color=RAUSER_GREY) - return wait_or_skip(clock, RAUSER_GREY_HOLD_SECONDS, show_hold_grey) + return wait_or_skip(clock, RAUSER_GREY_HOLD_SECONDS, + lambda _t: _draw_rauser(screen, parts, RAUSER_GREY, 1.0)) # --------------------------------------------------------------------------- @@ -591,27 +648,35 @@ def build_title_image(quads): return canvas -def _scanline_desaturate(surface, intensity): - '''intensity 0..1: 0 = unveraendert, 1 = komplett entsaettigt + jede - zweite Zeile abgedunkelt (Stefans "Scanlines drueber"-Beobachtung waehrend - des Titelbild-Reveals, siehe Docstring). Braucht numpy; ohne numpy wird - intensity ignoriert (Bild bleibt scharf) statt abzustuerzen.''' - if intensity <= 0 or not HAVE_NUMPY: +def _scanline_clear_from_middle(surface, clear_progress): + '''KORREKTUR (Stefan 24.07.2026 -- gegen echte DOSBox-Referenz gemessen): + KEINE Entsaettigung/kein Grau mehr (das "gray raus"), nur noch Scanlines + (jede zweite Zeile abgedunkelt). Die Scanlines loesen sich von der MITTE + des Titelbilds aus auf -- `surface` ist die untere Bildhaelfte, ihre + Zeile 0 liegt genau an der Bildmitte. clear_progress 0..1: der Anteil der + Zeilen (von oben=Mitte aus) der schon scanline-frei ist. 0 = komplett + scanlined, 1 = komplett sauber. Die Scanline-Grenze wandert also von der + Mitte nach unten weg. Braucht numpy; ohne numpy bleibt das Bild sauber + (Effekt entfaellt) statt abzustuerzen.''' + if not HAVE_NUMPY: return surface - arr = pygame.surfarray.array3d(surface).astype(np.float32) # (w,h,3) - gray = arr.mean(axis=2, keepdims=True) - out = arr * (1 - intensity) + gray * intensity - scan = np.ones((1, arr.shape[1], 1), dtype=np.float32) - scan[0, ::2, 0] = 1.0 - 0.5 * intensity - out = np.clip(out * scan, 0, 255).astype(np.uint8) + arr = pygame.surfarray.array3d(surface).astype(np.float32) # (w, h, 3) + h = arr.shape[1] + scan = np.ones((h,), dtype=np.float32) + scan[1::2] = 0.5 # jede zweite Zeile abgedunkelt (kein Grau, nur dunkler) + clear_rows = int(round(h * max(0.0, min(1.0, clear_progress)))) + if clear_rows > 0: + scan[:clear_rows] = 1.0 # obere (mittennahe) Zeilen bereits scanline-frei + out = np.clip(arr * scan[np.newaxis, :, np.newaxis], 0, 255).astype(np.uint8) return pygame.surfarray.make_surface(out) def render_title_wipe_frame(quads, t): - '''t: 0..1 ueber die gesamte Wipe-Dauer. Erste TITLE_HEIGHT_REVEAL_FRACTION - waechst Hoehe (oben 0->120 von oben, unten 0->120 von unten). Danach loest - sich der Scanline/Graustufen-Effekt auf der unteren Haelfte auf (Stefans - Screenshot4->5->6-Beobachtung).''' + '''t: 0..1 ueber die gesamte Wipe-Dauer. Erste TITLE_HEIGHT_REVEAL_FRACTION: + Vorhang-Reveal (obere Haelfte waechst 0->120 von oben, untere Haelfte + 0->120 von unten, treffen sich in der Mitte). Danach: die Scanlines auf der + unteren Haelfte loesen sich von der Mitte nach unten auf. Obere Haelfte ist + durchgehend volle Farbe ohne Scanlines (wie in der DOSBox-Referenz).''' a, b, c, d = quads['A'], quads['B'], quads['C'], quads['D'] half_h = a.get_height() # 120 full_w = a.get_width() + b.get_width() # 320 @@ -622,116 +687,118 @@ def render_title_wipe_frame(quads, t): if t < TITLE_HEIGHT_REVEAL_FRACTION: reveal = t / TITLE_HEIGHT_REVEAL_FRACTION h = max(0, int(half_h * reveal)) - scan_intensity = 1.0 + clear_progress = 0.0 # waehrend des Reveals noch komplett scanlined else: h = half_h - scan_intensity = 1.0 - (t - TITLE_HEIGHT_REVEAL_FRACTION) / (1 - TITLE_HEIGHT_REVEAL_FRACTION) + clear_progress = (t - TITLE_HEIGHT_REVEAL_FRACTION) / (1 - TITLE_HEIGHT_REVEAL_FRACTION) if h > 0: + # obere Haelfte (A|B): volle Farbe, waechst von oben nach unten top_row = pygame.Surface((full_w, half_h)) top_row.blit(a, (0, 0)) top_row.blit(b, (a.get_width(), 0)) - top_slice = top_row.subsurface((0, 0, full_w, h)) - canvas.blit(top_slice, (0, 0)) + canvas.blit(top_row.subsurface((0, 0, full_w, h)), (0, 0)) + # untere Haelfte (C|D): Scanlines, loesen sich von der Mitte (=Zeile 0 + # dieser Haelfte) nach unten auf; waechst von unten nach oben rein. bot_row = pygame.Surface((full_w, half_h)) bot_row.blit(c, (0, 0)) bot_row.blit(d, (a.get_width(), 0)) - bot_slice = bot_row.subsurface((0, half_h - h, full_w, h)).copy() - bot_slice = _scanline_desaturate(bot_slice, scan_intensity) + bot_scan = _scanline_clear_from_middle(bot_row, clear_progress) + bot_slice = bot_scan.subsurface((0, half_h - h, full_w, h)) canvas.blit(bot_slice, (0, half_h * 2 - h)) return canvas -def run_once(screen, clock, assets, audio_paths): - quit_requested = run_rauser(screen, clock, assets, audio_paths) - if quit_requested: - return True +def scene_pause(screen, clock, seconds): + '''Kurze schwarze Pause zwischen zwei Screens.''' + return wait_or_skip(clock, seconds, lambda _t: screen.fill(BG_COLOR)) - # 2) schwarze Pause - screen.fill(BG_COLOR) - pygame.display.flip() - quit_requested = wait_or_skip(clock, 0.5, lambda t: screen.fill(BG_COLOR)) - if quit_requested: - return True - # 3) Factor5 (statisches Bild). KORREKTUR (22.07.2026 abends, siehe - # Docstring): NICHT mehr ONGAME2 (das war falsch zugeordnete Level- - # Musik), sondern TITEL2-Song 1 -- ein kurzer "Brausen"-Sound (~2.4s), - # der zur Beschreibung "nur ein bisschen laenger als der Rauser-Sound" - # passt. Screen-Dauer ist jetzt FEST (~5s), nicht mehr an die (falsche) - # 123s-Songlaenge gekoppelt. +def scene_factor5(screen, 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 + Audio-Geraet (headless) faellt es auf FACTOR5_FALLBACK_SECONDS zurueck.''' factor5 = build_letterboxed(assets.pcc_surface('FACTOR5')) + hold = FACTOR5_FALLBACK_SECONDS factor5_path = audio_paths.get(('TITEL2', 1)) if pygame.mixer.get_init() and factor5_path: try: - factor5_sound = pygame.mixer.Sound(factor5_path) - factor5_sound.play() + snd = pygame.mixer.Sound(factor5_path) + # Screen exakt so lange wie der Sound -- aber mit Sicherheits- + # Obergrenze, damit ein evtl. fehlerhaft langes WAV das Intro nicht + # minutenlang einfriert (siehe FACTOR5_MAX_SECONDS). + hold = min(snd.get_length(), FACTOR5_MAX_SECONDS) + snd.play() except Exception as exc: - print(f'[intro_sequence] Factor5-Sound konnte nicht geladen werden: {exc}', file=sys.stderr) + 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)) - def show_factor5(_t): - blit_scaled_centered(screen, factor5, WINDOW_W, WINDOW_H) - quit_requested = wait_or_skip(clock, FACTOR5_HOLD_SECONDS, show_factor5) - if quit_requested: - return True - # 4) schwarze Pause - quit_requested = wait_or_skip(clock, 0.5, lambda t: screen.fill(BG_COLOR)) - if quit_requested: - return True - - # 5) Kellogg's-Logo + "praesentiert"-Palettenfade - # Titelmusik (TITEL Song 0, ~109s) setzt hier ein und laeuft geloopt - # weiter bis zum Ende der Sequenz (Original hat vermutlich durchgehende - # Titelmusik ueber Logo+Titelbild+Karte -- noch nicht per Referenz- - # Sichtung bestaetigt, aber plausibelste Annahme fuers erste v1). +def scene_kelloggs(screen, clock, assets, audio_paths): + '''Kellogg's-Markenlogo; darunter faedet "praesentiert" per Palettenfade + ein. Hier setzt auch die Titelmusik (TITEL Song 0) geloopt ein.''' titel_path = audio_paths.get(('TITEL', None)) if pygame.mixer.get_init() and titel_path: try: pygame.mixer.music.load(titel_path) pygame.mixer.music.play(-1) except Exception as exc: - print(f'[intro_sequence] Titelmusik konnte nicht geladen werden: {exc}', file=sys.stderr) + print(f'[intro] Titelmusik konnte nicht geladen werden: {exc}', file=sys.stderr) kw, kh, kidx, kpal = assets.pcc('KELLOGGS') def show_kelloggs(t): frame = build_kelloggs_frame(kw, kh, kidx, kpal, t) - letterboxed = build_letterboxed(frame) - blit_scaled_centered(screen, letterboxed, WINDOW_W, WINDOW_H) - quit_requested = wait_or_skip(clock, PRAESENTIERT_FADE_SECONDS, show_kelloggs) - if quit_requested: - return True + blit_scaled_centered(screen, build_letterboxed(frame), WINDOW_W, WINDOW_H) + r = wait_or_skip(clock, PRAESENTIERT_FADE_SECONDS, show_kelloggs) + if r: + return r # kurz mit vollem Text stehen lassen - quit_requested = wait_or_skip(clock, 1.0, lambda t: show_kelloggs(1.0)) - if quit_requested: - return True + return wait_or_skip(clock, 1.0, lambda _t: show_kelloggs(1.0)) - # 6) schwarze Pause - quit_requested = wait_or_skip(clock, 0.5, lambda t: screen.fill(BG_COLOR)) - if quit_requested: - return True - # 7) Titelbild -- 2x2-Quadranten-Reveal mit Scanline-Aufloesung (siehe - # Docstring-Korrektur) +def scene_title(screen, clock, assets): + '''Titelbild -- 2x2-Quadranten-Reveal, Scanlines loesen sich von der Mitte + auf (siehe render_title_wipe_frame), danach das fertige Bild halten.''' quads = build_title_quadrants(assets) - - def show_title_wipe(t): - canvas = render_title_wipe_frame(quads, t) - blit_scaled_centered(screen, canvas, WINDOW_W, WINDOW_H) - quit_requested = wait_or_skip(clock, TITLE_WIPE_SECONDS, show_title_wipe) - if quit_requested: - return True - - # danach: fertig zusammengesetztes Titelbild (320x240, siehe Korrektur) halten + 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 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)) - def show_title_full(_t): - blit_scaled_centered(screen, title_img, WINDOW_W, WINDOW_H) - quit_requested = wait_or_skip(clock, 3.0, show_title_full) - return quit_requested + +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).''' + 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), + ] + for scene in scenes: + r = scene() + if r == 'quit': + return True + if r == 'skip' and pygame.mixer.get_init(): + # beim Ueberspringen haengende Sound-Effekte (Sting/Factor5-Brausen) + # abschneiden -- pygame.mixer.stop() betrifft nur Sound-Kanaele, die + # geloopte Titelmusik (mixer.music) laeuft ungestoert weiter. + pygame.mixer.stop() + return False def main():