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:
co-authored by
Claude Opus 4.8
parent
e7d95190eb
commit
7aea2fac26
@@ -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:
|
||||
|
||||
|
||||
+11
-1
@@ -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)
|
||||
|
||||
|
||||
|
||||
+212
-145
@@ -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)
|
||||
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():
|
||||
|
||||
Reference in New Issue
Block a user