Intro gegen echte DOSBox-Referenz korrigiert + Leertaste-Skip

Referenzaufnahmen aus DOSBox (Xvfb) gemacht und danach:

- Rauser-Aufloesung gefixt: die nativen PCC-Assets werden jetzt EINMAL direkt
  auf Endgroesse hochskaliert (Nearest) und der Screen direkt in Fenster-
  aufloesung gerendert -- statt vorher 0.5x-Downscale (verwirft Pixel) + x3-
  Upscale ueber eine 320er-Zwischenleinwand. Gleiche Groesse wie bisher (~33%
  Bildbreite, gegen Referenz gemessen), aber gestochen scharf. Zusaetzlich das
  tuerkise "!"-Kaestchen nachgebaut (im Original Engine-gezeichnet, in keinem
  Asset enthalten).
- Titelbild-Reveal: Grau/Entsaettigung raus (Stefan "das gray raus"); die
  Scanlines auf der unteren Haelfte loesen sich jetzt von der Mitte nach unten
  auf ("scanlines ab der mitte getrennt weg") statt gleichmaessig auszufaden.
- Factor5: Logo haelt exakt so lange wie sein Sound (TITEL2 Song 1) spielt,
  dann weiter zu Kellogg's.
- Leertaste ueberspringt jetzt den aktuellen Intro-Screen, ESC beendet das
  ganze Intro (wait_or_skip gibt 'quit'/'skip'/None; run_once ist in Scenes
  zerlegt).

HANG-FIX (dabei gefunden): das kompilierte render_tfmx-Binary war veraltet
(vor dem Song-Index-Feature aus render_tfmx.c gebaut) und renderte TITEL2 Song1
mit 180s statt 2.4s -- gekoppelt an die neue "Factor5-Dauer = Songlaenge"-Logik
haette das Intro 3 Minuten eingefroren. Fixes: audio._ensure_binary() baut jetzt
neu, wenn die Quelle neuer ist als das Binary (verhindert stale Binary nach git
pull); zusaetzlich FACTOR5_MAX_SECONDS als Sicherheits-Obergrenze.

Verifiziert: python -m game --once (headless) exit 0 in ~30s; Rauser/Titelbild
visuell gegen die DOSBox-Referenz gegengeprueft.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
duffyduck
2026-07-24 01:39:28 +02:00
co-authored by Claude Opus 4.8
parent e7d95190eb
commit 7aea2fac26
3 changed files with 227 additions and 149 deletions
+3 -2
View File
@@ -55,8 +55,9 @@ python3 -m game # Intro in Endlosschleife (wie Original-Titelbildschi
python3 -m game --once # ein Durchlauf, dann beenden python3 -m game --once # ein Durchlauf, dann beenden
``` ```
Abbruch jederzeit per `ESC`. Ohne Soundkarte (z.B. headless) läuft das Spiel Steuerung im Intro: **Leertaste** überspringt den aktuellen Screen, **ESC**
stumm weiter statt abzustürzen. 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: Liegt `PCKELL.DAT` woanders, kann der Pfad per Umgebungsvariable gesetzt werden:
+11 -1
View File
@@ -47,8 +47,18 @@ _CACHE_DIR = os.environ.get('KELLOGG_AUDIO_CACHE', os.path.join(_PROJECT_ROOT, '
def _ensure_binary(): 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): 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) subprocess.run(['bash', os.path.join(_PLAYER_DIR, 'build.sh')], check=True)
+213 -146
View File
@@ -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, Aufruf: python3 -m game [--once] (--once: nach einem Durchlauf beenden,
sonst loopt die Sequenz weiter wie im Original-Titelbildschirm) 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 sys
import os import os
@@ -148,6 +157,24 @@ PRAESENTIERT_FADE_SECONDS = 2.5
# Weichzeichnen) statt smoothscale skalieren -- siehe pcc_surface_scaled(). # Weichzeichnen) statt smoothscale skalieren -- siehe pcc_surface_scaled().
ASSET_DISPLAY_SCALE = 0.5 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-Timing (siehe Docstring)
RAUSER_SLIDE_SECONDS = 0.9 RAUSER_SLIDE_SECONDS = 0.9
RAUSER_HOLD_SECONDS = 1.3 RAUSER_HOLD_SECONDS = 1.3
@@ -155,10 +182,21 @@ RAUSER_FADE_TO_GREY_SECONDS = 0.4
RAUSER_GREY_HOLD_SECONDS = 0.3 RAUSER_GREY_HOLD_SECONDS = 0.3
RAUSER_GREY = (73, 73, 73) RAUSER_GREY = (73, 73, 73)
RAUSER_WHITE = (255, 255, 255) 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 # Factor5-Timing (Stefan 24.07.2026): das Factor5-Logo wird exakt so lange
# abends): feste Anzeigedauer statt (falsch) an eine 123s-Melodie gekoppelt. # gezeigt, wie der zugehoerige Sound (TITEL2 Song 1, ~2.4s) spielt -- ist der
FACTOR5_HOLD_SECONDS = 5.0 # 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) # Titelbild-Timing (siehe Docstring)
TITLE_HEIGHT_REVEAL_FRACTION = 0.55 # Anteil der Wipe-Dauer fuer das Hoehenwachstum 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): 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. '''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() start = time.monotonic()
while True: while True:
now = time.monotonic() now = time.monotonic()
@@ -310,12 +354,15 @@ def wait_or_skip(clock, seconds, screen_update_fn, fps=30):
if t >= 1.0: if t >= 1.0:
screen_update_fn(1.0) screen_update_fn(1.0)
pygame.display.flip() pygame.display.flip()
return False return None
for event in pygame.event.get(): for event in pygame.event.get():
if event.type == pygame.QUIT: if event.type == pygame.QUIT:
return True return 'quit'
if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE: if event.type == pygame.KEYDOWN:
return True if event.key == pygame.K_ESCAPE:
return 'quit'
if event.key == pygame.K_SPACE:
return 'skip'
screen_update_fn(t) screen_update_fn(t)
pygame.display.flip() pygame.display.flip()
clock.tick(fps) clock.tick(fps)
@@ -446,26 +493,43 @@ def build_kelloggs_frame(w, h, indices, palette, fade_t):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def build_rauser_parts(assets): def build_rauser_parts(assets):
'''Liefert die drei (bei 0.5x Nearest-Neighbor-Groesse, siehe Docstring- '''Liefert die drei Rauser-Teile bei RAUSER_LOGO_SCALE (Nearest-Neighbor-
Korrektur v3) Rauser-Teile + ihre Endpositionen auf einer 320x240- UPSCALE direkt aus der Nativaufloesung, siehe Docstring-Korrektur #3) +
Leinwand (RAUSER1 links, RAUSER3 rechts daneben, RAUSER2 darunter ihre Endpositionen direkt in FENSTER-Koordinaten (960x720). RAUSER1
mittig).''' ("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 # cut_border_bg=True (Fix 22.07.2026 abends): macht den papierweissen
# Hintergrund um jede Box transparent (nur die vom Bildrand aus # Hintergrund um jede Box transparent (nur die vom Bildrand aus
# zusammenhaengende Flaeche, siehe make_border_bg_transparent) -- vorher # zusammenhaengende Flaeche, siehe make_border_bg_transparent) -- vorher
# blieb hier ein harter weisser Kasten stehen, der beim Fade zu Grau nicht # blieb hier ein harter weisser Kasten stehen, der beim Fade zu Grau nicht
# mitgefadet ist (Stefans Meldung "Text bleibt hart umrandet stehen"). # mitgefadet ist (Stefans Meldung "Text bleibt hart umrandet stehen").
r1 = assets.pcc_surface_scaled('RAUSER1', cut_border_bg=True) # "RAUSER"-Box # scale=RAUSER_LOGO_SCALE ist ein UPSCALE der nativen Assets -> pcc_surface_scaled
r2 = assets.pcc_surface_scaled('RAUSER2', cut_border_bg=True) # "ADVERTAINMENT" # macht cut_border auf der Nativ-Surface und skaliert dann per Nearest hoch,
r3 = assets.pcc_surface_scaled('RAUSER3', cut_border_bg=True) # "!" auf blauer Box # 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() total_h = r1.get_height() + r2.get_height()
x0 = (320 - total_w) // 2 x0 = (WINDOW_W - total_w) // 2
y0 = (240 - total_h) // 2 y0 = (WINDOW_H - total_h) // 2
r1_final = (x0, y0) 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()) r2_final = (x0, y0 + r1.get_height())
return { return {
'r1': r1, 'r2': r2, 'r3': r3, '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)) 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 '''Rauser-Karte: weisser Grund, Slide-in, Sound-Sting bei Ankunft, Fade zu
grauem Grund, kurz halten. Gibt True zurueck wenn der User abbrechen will. grauem Grund, kurz halten. Rueckgabe 'quit'/'skip'/None (siehe wait_or_skip).
audio_paths kommt aus preload_all_audio() -- das Audio wird hier NICHT Wird jetzt DIREKT in Fensteraufloesung gerendert (kein 320er-Zwischen-
mehr live gerendert (siehe render_audio_with_loading-Docstring zum canvas + Upscale mehr) -- das war die Ursache des Rauser-Aufloesungs-
Hang-Bug, den das behebt), sondern nur noch aus dem fertigen WAV-Pfad Problems (siehe RAUSER_LOGO_SCALE-Kommentar).
geladen (schnell, nie blockierend).''' 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) parts = build_rauser_parts(assets)
sting = None sting = None
# TITEL2 Song 0 -- der kuerzere der beiden TITEL2-Songs (~2.04s), Song 1 # 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: try:
sting = pygame.mixer.Sound(sting_path) sting = pygame.mixer.Sound(sting_path)
except Exception as exc: 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): def show_slide(t):
nonlocal sting_played _draw_rauser(screen, parts, RAUSER_WHITE, t)
canvas = pygame.Surface((320, 240)) if t >= 1.0 and not sting_played[0]:
canvas.fill(RAUSER_WHITE) sting_played[0] = True
render_rauser_slide(canvas, parts, t)
if t >= 1.0 and not sting_played:
sting_played = True
if sting is not None: if sting is not None:
sting.play() sting.play()
blit_scaled_centered(screen, canvas, WINDOW_W, WINDOW_H, bg_color=RAUSER_WHITE) r = wait_or_skip(clock, RAUSER_SLIDE_SECONDS, show_slide)
quit_requested = wait_or_skip(clock, RAUSER_SLIDE_SECONDS, show_slide) if r:
if quit_requested: return r
return True
# falls die Slide-Phase zu kurz war um sting_played zu triggern (sollte # falls die Slide-Phase zu kurz war um den Sting zu triggern
# durch t>=1.0 im letzten Frame von wait_or_skip immer der Fall sein) if not sting_played[0] and sting is not None:
if not sting_played and sting is not None:
sting.play() sting.play()
sting_played = True sting_played[0] = True
def show_hold_white(_t): r = wait_or_skip(clock, RAUSER_HOLD_SECONDS,
canvas = pygame.Surface((320, 240)) lambda _t: _draw_rauser(screen, parts, RAUSER_WHITE, 1.0))
canvas.fill(RAUSER_WHITE) if r:
render_rauser_slide(canvas, parts, 1.0) return r
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
def show_fade_grey(t): def show_fade_grey(t):
bg = tuple(int(RAUSER_WHITE[i] + (RAUSER_GREY[i] - RAUSER_WHITE[i]) * t) for i in range(3)) bg = tuple(int(RAUSER_WHITE[i] + (RAUSER_GREY[i] - RAUSER_WHITE[i]) * t) for i in range(3))
canvas = pygame.Surface((320, 240)) _draw_rauser(screen, parts, bg, 1.0)
canvas.fill(bg) r = wait_or_skip(clock, RAUSER_FADE_TO_GREY_SECONDS, show_fade_grey)
render_rauser_slide(canvas, parts, 1.0) if r:
blit_scaled_centered(screen, canvas, WINDOW_W, WINDOW_H, bg_color=bg) return r
quit_requested = wait_or_skip(clock, RAUSER_FADE_TO_GREY_SECONDS, show_fade_grey)
if quit_requested:
return True
def show_hold_grey(_t): return wait_or_skip(clock, RAUSER_GREY_HOLD_SECONDS,
canvas = pygame.Surface((320, 240)) lambda _t: _draw_rauser(screen, parts, RAUSER_GREY, 1.0))
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)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -591,27 +648,35 @@ def build_title_image(quads):
return canvas return canvas
def _scanline_desaturate(surface, intensity): def _scanline_clear_from_middle(surface, clear_progress):
'''intensity 0..1: 0 = unveraendert, 1 = komplett entsaettigt + jede '''KORREKTUR (Stefan 24.07.2026 -- gegen echte DOSBox-Referenz gemessen):
zweite Zeile abgedunkelt (Stefans "Scanlines drueber"-Beobachtung waehrend KEINE Entsaettigung/kein Grau mehr (das "gray raus"), nur noch Scanlines
des Titelbild-Reveals, siehe Docstring). Braucht numpy; ohne numpy wird (jede zweite Zeile abgedunkelt). Die Scanlines loesen sich von der MITTE
intensity ignoriert (Bild bleibt scharf) statt abzustuerzen.''' des Titelbilds aus auf -- `surface` ist die untere Bildhaelfte, ihre
if intensity <= 0 or not HAVE_NUMPY: 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 return surface
arr = pygame.surfarray.array3d(surface).astype(np.float32) # (w,h,3) arr = pygame.surfarray.array3d(surface).astype(np.float32) # (w, h, 3)
gray = arr.mean(axis=2, keepdims=True) h = arr.shape[1]
out = arr * (1 - intensity) + gray * intensity scan = np.ones((h,), dtype=np.float32)
scan = np.ones((1, arr.shape[1], 1), dtype=np.float32) scan[1::2] = 0.5 # jede zweite Zeile abgedunkelt (kein Grau, nur dunkler)
scan[0, ::2, 0] = 1.0 - 0.5 * intensity clear_rows = int(round(h * max(0.0, min(1.0, clear_progress))))
out = np.clip(out * scan, 0, 255).astype(np.uint8) 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) return pygame.surfarray.make_surface(out)
def render_title_wipe_frame(quads, t): def render_title_wipe_frame(quads, t):
'''t: 0..1 ueber die gesamte Wipe-Dauer. Erste TITLE_HEIGHT_REVEAL_FRACTION '''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 Vorhang-Reveal (obere Haelfte waechst 0->120 von oben, untere Haelfte
sich der Scanline/Graustufen-Effekt auf der unteren Haelfte auf (Stefans 0->120 von unten, treffen sich in der Mitte). Danach: die Scanlines auf der
Screenshot4->5->6-Beobachtung).''' 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'] a, b, c, d = quads['A'], quads['B'], quads['C'], quads['D']
half_h = a.get_height() # 120 half_h = a.get_height() # 120
full_w = a.get_width() + b.get_width() # 320 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: if t < TITLE_HEIGHT_REVEAL_FRACTION:
reveal = t / TITLE_HEIGHT_REVEAL_FRACTION reveal = t / TITLE_HEIGHT_REVEAL_FRACTION
h = max(0, int(half_h * reveal)) h = max(0, int(half_h * reveal))
scan_intensity = 1.0 clear_progress = 0.0 # waehrend des Reveals noch komplett scanlined
else: else:
h = half_h 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: if h > 0:
# obere Haelfte (A|B): volle Farbe, waechst von oben nach unten
top_row = pygame.Surface((full_w, half_h)) top_row = pygame.Surface((full_w, half_h))
top_row.blit(a, (0, 0)) top_row.blit(a, (0, 0))
top_row.blit(b, (a.get_width(), 0)) top_row.blit(b, (a.get_width(), 0))
top_slice = top_row.subsurface((0, 0, full_w, h)) canvas.blit(top_row.subsurface((0, 0, full_w, h)), (0, 0))
canvas.blit(top_slice, (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 = pygame.Surface((full_w, half_h))
bot_row.blit(c, (0, 0)) bot_row.blit(c, (0, 0))
bot_row.blit(d, (a.get_width(), 0)) bot_row.blit(d, (a.get_width(), 0))
bot_slice = bot_row.subsurface((0, half_h - h, full_w, h)).copy() bot_scan = _scanline_clear_from_middle(bot_row, clear_progress)
bot_slice = _scanline_desaturate(bot_slice, scan_intensity) bot_slice = bot_scan.subsurface((0, half_h - h, full_w, h))
canvas.blit(bot_slice, (0, half_h * 2 - h)) canvas.blit(bot_slice, (0, half_h * 2 - h))
return canvas return canvas
def run_once(screen, clock, assets, audio_paths): def scene_pause(screen, clock, seconds):
quit_requested = run_rauser(screen, clock, assets, audio_paths) '''Kurze schwarze Pause zwischen zwei Screens.'''
if quit_requested: return wait_or_skip(clock, seconds, lambda _t: screen.fill(BG_COLOR))
return True
# 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 def scene_factor5(screen, clock, assets, audio_paths):
# Docstring): NICHT mehr ONGAME2 (das war falsch zugeordnete Level- '''Factor5-Studio-Logo (statisches Bild) + TITEL2-Song 1 ("Brausen").
# Musik), sondern TITEL2-Song 1 -- ein kurzer "Brausen"-Sound (~2.4s), KORREKTUR (Stefan 24.07.2026): der Screen wird EXAKT so lange gezeigt wie
# der zur Beschreibung "nur ein bisschen laenger als der Rauser-Sound" der Sound spielt -- ist er zu Ende, geht es zu Kellogg's weiter. Ohne
# passt. Screen-Dauer ist jetzt FEST (~5s), nicht mehr an die (falsche) Audio-Geraet (headless) faellt es auf FACTOR5_FALLBACK_SECONDS zurueck.'''
# 123s-Songlaenge gekoppelt.
factor5 = build_letterboxed(assets.pcc_surface('FACTOR5')) factor5 = build_letterboxed(assets.pcc_surface('FACTOR5'))
hold = FACTOR5_FALLBACK_SECONDS
factor5_path = audio_paths.get(('TITEL2', 1)) factor5_path = audio_paths.get(('TITEL2', 1))
if pygame.mixer.get_init() and factor5_path: if pygame.mixer.get_init() and factor5_path:
try: try:
factor5_sound = pygame.mixer.Sound(factor5_path) snd = pygame.mixer.Sound(factor5_path)
factor5_sound.play() # 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: 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 def scene_kelloggs(screen, clock, assets, audio_paths):
quit_requested = wait_or_skip(clock, 0.5, lambda t: screen.fill(BG_COLOR)) '''Kellogg's-Markenlogo; darunter faedet "praesentiert" per Palettenfade
if quit_requested: ein. Hier setzt auch die Titelmusik (TITEL Song 0) geloopt ein.'''
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).
titel_path = audio_paths.get(('TITEL', None)) titel_path = audio_paths.get(('TITEL', None))
if pygame.mixer.get_init() and titel_path: if pygame.mixer.get_init() and titel_path:
try: try:
pygame.mixer.music.load(titel_path) pygame.mixer.music.load(titel_path)
pygame.mixer.music.play(-1) pygame.mixer.music.play(-1)
except Exception as exc: 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') kw, kh, kidx, kpal = assets.pcc('KELLOGGS')
def show_kelloggs(t): def show_kelloggs(t):
frame = build_kelloggs_frame(kw, kh, kidx, kpal, t) frame = build_kelloggs_frame(kw, kh, kidx, kpal, t)
letterboxed = build_letterboxed(frame) blit_scaled_centered(screen, build_letterboxed(frame), WINDOW_W, WINDOW_H)
blit_scaled_centered(screen, letterboxed, WINDOW_W, WINDOW_H) r = wait_or_skip(clock, PRAESENTIERT_FADE_SECONDS, show_kelloggs)
quit_requested = wait_or_skip(clock, PRAESENTIERT_FADE_SECONDS, show_kelloggs) if r:
if quit_requested: return r
return True
# kurz mit vollem Text stehen lassen # kurz mit vollem Text stehen lassen
quit_requested = 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))
if quit_requested:
return True
# 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 def scene_title(screen, clock, assets):
# Docstring-Korrektur) '''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) quads = build_title_quadrants(assets)
r = wait_or_skip(clock, TITLE_WIPE_SECONDS,
def show_title_wipe(t): lambda t: blit_scaled_centered(screen, render_title_wipe_frame(quads, t), WINDOW_W, WINDOW_H))
canvas = render_title_wipe_frame(quads, t) if r:
blit_scaled_centered(screen, canvas, WINDOW_W, WINDOW_H) return r
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
title_img = build_title_image(quads) 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) def run_once(screen, clock, assets, audio_paths):
quit_requested = wait_or_skip(clock, 3.0, show_title_full) '''Spielt die komplette Intro-Sequenz einmal ab. LEERTASTE ueberspringt den
return quit_requested 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(): def main():