Files
tony-remake/game/intro.py
T
duffyduckandClaude Fable 5 949a7eeda2 Factor5-Sound vollstaendig: Ausklang-Tail im TFMX-Renderer (loop_mode-Stopp + Fade)
Stefans Meldung "Factor5-Zeit zu kurz, Soundfile nicht ganz abgespielt" -- die
Ursache lag im Renderer: real_song_end markiert nur das Ende der TRACK-SEQUENZ
(2.4s), die dann noch klingenden Samples wurden hart abgeschnitten.

- render_tfmx.c: neues optionales 6. CLI-Arg tail_max_seconds. Im Tail-Modus
  wird loop_mode=0 gesetzt -- tfmx.h laesst den Sequencer bei song_end dann
  STEHEN (kein Restart, keine neuen Noten), waehrend die Paula-Voices weiter
  ausklingen. (Erster Versuch ohne loop_mode-Stopp nahm stattdessen die
  naechste Loop-Iteration auf -- per RMS-Selbstaehnlichkeit nachgewiesen.)
  Tail endet bei ~300ms Stille oder tail-Limit; bei Limit-Ende (loopende
  Samples ohne natuerliches Ende) wird ein 600ms-Fade-out angewendet.
- Factor5 ("Brausen") loopt auf Paula-Ebene endlos -> Tail 2.6s => Gesamt
  exakt 5.0s (DOSBox-Messung ~4.8s Screendauer, Stefans "ca. 5 Sekunden"),
  sauber ausgeblendet. Rauser-Sting endet natuerlich bei 2.34s.
- audio.py: tail_seconds-Parameter, Cache-Suffix _tail (alte abgeschnittene
  Caches greifen nicht mehr). intro.py: Jingles mit Tail, Titelmusik bewusst
  ohne (exakter Loop-Punkt-Schnitt fuers nahtlose pygame-Loopen).

Screen haelt weiterhin exakt Songlaenge (jetzt 5.0s), verifiziert: WAV klingt
aus (RMS 2065->261), --once headless exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 22:22:31 +02:00

957 lines
44 KiB
Python

#!/usr/bin/env python3
"""
Boot-/Intro-Sequenz von "Tony & Friends in Kellogg's Land" -- erster
spielbarer (bzw. hier: zuschaubarer) pygame-Meilenstein des Remakes.
Reihenfolge + Timing wurde am 22.07.2026 durch eine echte DOSBox-Screenshot-
Serie vermessen, siehe NOTES.md Abschnitt "Boot-Sequenz vermessen". Kurzfassung:
1. Rauser-Advertainment-Karte (Komposit aus RAUSER1+2+3.PCC) auf WEISSEM
Grund. RAUSER1 ("RAUSER"-Box) faehrt von LINKS ein, RAUSER3 (blaue Box
mit "!") faehrt von OBEN ein, RAUSER2 ("ADVERTAINMENT") steht von Anfang
an fest. Sobald beide Teile ihre Endposition erreicht haben, spielt ein
kurzer Sound-Sting (TITEL2-Song 0 -- 2.04s natuerliche Laenge, siehe
tfmx_audio-Docstring zu den mehreren Songs pro Modul). Danach faedet der
WEISSE Hintergrund kurz zu GRAU um, haelt kurz, dann schwarze Pause.
KORREKTUR-GESCHICHTE (Aufloesungs-Bug, zwei Anlaeufe):
v1: Rauser-Assets bei 0.5x per smoothscale verkleinert -- Ergebnis war
verwaschen/unscharf (Stefans erste Meldung "Aufloesung ist
schlecht").
v2 (falscher Fix): Annahme war, die Verkleinerung selbst sei der
Fehler -- also ASSET_DISPLAY_SCALE auf 1.0 (native Groesse ohne
jede Skalierung) gesetzt. Sah in DIESEM Container scharf aus, war
aber ein NEUER Bug: bei nativer Groesse (RAUSER1+RAUSER3 = 198px
Breite) ist das Logo auf der 320px-Leinwand riesig -- Stefan hat
nie "zu gross" gemeldet, weil das schon in v1 unter dem Verwaschen
unterging.
v3 (echter Fix, 22.07.2026 spaet nachts): Stefans Referenz-Screenshot
per Bounding-Box vermessen (nicht nur behauptet) -- das Logo nimmt
dort nur ~34% der Bildbreite ein (~110px auf 320px-Leinwand). Die
urspruengliche 0.5x-GROESSE war also die ganze Zeit richtig
(182+16=198px * 0.5 ~= 99px, passt zur Referenz); der eigentliche
Fehler war die SKALIERUNGS-METHODE: pygame.transform.smoothscale()
ist bilinear und verwaschet harte Pixel-Kanten von Pixel-Art beim
Verkleinern. Fix: ASSET_DISPLAY_SCALE bleibt 0.5, aber
pygame.transform.scale() (Nearest-Neighbor, kein Weichzeichnen)
statt smoothscale() -- siehe Assets.pcc_surface_scaled(). Per
Bounding-Box-Nachmessung des eigenen Renders bestaetigt: 33.0%
Breite / 13.6% Hoehe vs. Referenz 34.5% / 16.6% -- nah dran, nicht
mehr der 62%-Breite-Fehlgriff von v2.
2. kurze schwarze Pause
3. Factor5-Studio-Logo (FACTOR5.PCC, STATISCHES Bild -- keine Animation,
das "Geister"-Doppellogo ist Teil des Assets selbst). KORREKTUR
(22.07.2026, spaeter Abend -- Stefans Meldung: "beim factor 5 hast ein
Level-Sound genommen ... der factor 5 Sound ist so ein Brausen, geht nur
ein bisschen laenger als der Rauser-Sound, das Bild wird ca. 5 Sekunden
eingeblendet"): die vorige Version hat faelschlich ONGAME2-Song 0
(~123s!) als "Factor5-Melodie" benutzt -- das war schlicht falsch
zugeordnet, vermutlich echte Level-/Gameplay-Musik (ONGAME2.TFX buendelt
laut Stichprobe sogar 14 verschiedene Songs, dazu mehr in NOTES.md).
Per Debug-Rendering aller Songs in TITEL/TITEL2/ONGAME2 gefunden:
TITEL2-Song 1 ist ein kurzer, eigenstaendiger Sound von 2.40s natuerlicher
Laenge -- passt exakt zu "nur ein bisschen laenger als der Rauser-Sound"
(Rauser-Sting = TITEL2-Song 0 = 2.04s). Das ist jetzt der Factor5-Sound.
WICHTIG (ehrlich, nicht nur behauptet): ich kann Audio nicht selbst
abhoeren, die Zuordnung ist eine Indizien-Schlussfolgerung aus der
Songlaenge (passt sehr gut), nicht durch Reinhoeren verifiziert -- falls
es beim Testen doch nicht "brausen" klingt, bitte melden, dann probieren
wir TITEL-Song 2 (~8.0s) oder einen der kurzen ONGAME2-Songs (3/11/12).
Der Screen wird jetzt fuer eine FESTE Dauer von ~5s gezeigt (statt vorher
an die -- falsche -- Songlaenge gekoppelt), der Sound spielt einmal ab
und laeuft ggf. vor Screen-Ende aus (er ist ja nur 2.4s lang).
4. kurze schwarze Pause
5. Kellogg's-Markenlogo (KELLOGGS.PCC) erscheint SOFORT komplett; darunter
faedet der Text "praesentiert" per Palette-Fade von blass nach saettigt-
rot ein (~3s).
6. kurze schwarze Pause
7. Titelbild "TONY & FRIENDS in Kellogg's Land" -- die 4 Quadranten KELL256A
(oben links) / B (oben rechts) / C (unten links) / D (unten rechts) sind
je 160x240 PCC-Nativaufloesung und werden in NATIVER Groesse in ein
2x2-Raster gezeichnet: A|B oben, C|D unten -> 320x480 Gesamtbild. Das wird
beim Anzeigen ANAMORPH auf 4:3 (Fenstergroesse) gestreckt -- doppelt hohe
Pixel, genau wie im Original (siehe _blit_title). KORREKTUR 24.07.2026:
frueher wurden die Quadranten auf halbe Hoehe gestaucht (320x240) -- das
war falsch (halbe vertikale Aufloesung). RMSE-Vergleich gegen die echte
DOSBox-Referenz: 0.014 (voll) vs. 0.083 (halbiert). Reveal-Effekt: obere
Haelfte (A|B) waechst von oben, untere (C|D) von unten, treffen sich in
der Mitte (y=240). Waehrend/nach dem Reveal ist die untere Haelfte mit
Scanlines ueberzogen (KEIN Grau mehr), die sich von der Mitte nach unten
aufloesen.
Alle Nicht-BOB/ICO/MAP-Assets werden per kellogg_formats.load_pcc() (Pillows
PCX-Decoder) aus PCKELL.DAT gelesen -- kein eigener RLE-Code mehr (siehe
Regressions-Warnung in pcc_to_png.py).
Rauser-Layout und Fade-Text-Bounding-Box sind aus den echten Screenshots per
Augenmass/Index-Analyse bestimmt (siehe tools/analyze_kelloggs.py-Fund:
Indizes 34/42/43/44/45 in KELLOGGS.PCC = "praesentiert"-Text, bbox x71-241
y108-141) -- nicht 100% pixelidentisch zum Original, aber nah dran. Kann bei
Bedarf spaeter nachjustiert werden.
HANG-FIX (23.07.2026 -- Stefans Meldung "nur noch ein schwarzes Bild,
verschiebe ich das Fenster wird der Desktop reingerendert, das Programm
haengt"): war real und per Xvfb-Test verifiziert, kein Bedienfehler. Ursache:
tfmx_audio.render_wav() rendert TFMX-Musik per BLOCKIERENDEM subprocess.run()
-- bei einem frischen, ungecachten Song (v.a. TITEL, ~109s) dauert das
reproduzierbar mehrere -zig Sekunden (gemessen: ~14s), waehrend derer die
pygame-Eventloop NICHT lief -- das Betriebssystem haelt das Fenster dann fuer
"nicht reagierend" (schwarz eingefroren, Desktop blitzt beim Verschieben
durch). Fix: alle drei benoetigten Songs werden jetzt VOR der eigentlichen
Sequenz einmalig in preload_all_audio() gerendert, mit sichtbarem, responsivem
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]
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
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
import time
import threading
import configparser
from collections import deque
from .formats import DATContainer, load_pcc
from . import audio as tfmx_audio
import pygame
try:
import numpy as np
HAVE_NUMPY = True
except ImportError:
HAVE_NUMPY = False
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DAT_PATH = os.environ.get('KELLOGG_DAT', os.path.join(PROJECT_ROOT, 'game', 'original', 'PCKELL.DAT'))
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)
def _blit_title(surface):
'''Titelbild-Sonderfall: die Quelle ist 320x480 (ein 4:3-Bild mit doppelt
hohen Pixeln -- genau so liegt es im Original vor, siehe Titelbild-
Aufloesungs-Fund 24.07.2026). Es wird ANAMORPH auf die volle Fenstergroesse
gestreckt (320x480 -> 960x720, also x3 Breite / x1.5 Hoehe) -- fuellt das
Fenster wie die 320x240-Screens, behaelt aber die volle vertikale
Aufloesung (480 statt der frueher faelschlich halbierten 240 Zeilen).'''
s = pygame.display.get_surface()
s.blit(pygame.transform.scale(surface, (WINDOW_W, WINDOW_H)), (0, 0))
# "praesentiert"-Text-Faelle in KELLOGGS.PCC (siehe Docstring)
PRAESENTIERT_FADE_INDICES = [34, 42, 43, 44, 45]
PRAESENTIERT_FADE_SECONDS = 2.5
# KORREKTUR #2 (22.07.2026 spaet nachts, Stefans Referenz-Screenshot
# vermessen): 1.0 (native Groesse) war KEIN Aufloesungs-Fix, sondern ein
# neuer Bug -- ich hatte die Blur-Ursache falsch diagnostiziert. Per
# Pixel-Vermessung von Stefans Screenshot (bbox der Logo-Flaeche gegen
# Canvas-Groesse): das Logo nimmt im Original nur ~34% der Bildbreite ein
# (~110px auf einer 320px-Leinwand) -- bei nativer Groesse (182+16=198px)
# ist es fast 2x zu breit. Die vorige 0.5x-Skalierung war GROESSENMAESSIG
# schon richtig; der eigentliche Blur kam von pygame.transform.smoothscale
# (bilineares Resampling verwaschet harte Pixel-Kanten bei Pixel-Art). Fix:
# 0.5x beibehalten, aber mit pygame.transform.scale (Nearest-Neighbor, kein
# 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
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 (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
TITLE_WIPE_SECONDS = 2.3
def indices_to_surface(width, height, indices, palette):
'''Baut ein pygame.Surface (RGB) aus Palette-Indizes + (r,g,b)-Liste.'''
buf = bytearray(width * height * 3)
for i, idx in enumerate(indices):
r, g, b = palette[idx & 0xFF]
buf[i*3:i*3+3] = bytes([r, g, b])
surf = pygame.image.frombuffer(bytes(buf), (width, height), 'RGB')
return surf.convert()
def make_border_bg_transparent(surface, threshold=235):
'''Fix fuer Stefans Meldung (22.07.2026 abends): "der Rauser-Text fadet
nicht mit aus, bleibt hart umrandet". Ursache: die Rauser-PCC-Assets haben
KEINEN Alphakanal -- ihr papierweisser Hintergrund sitzt als hartes
Rechteck um die eigentliche Box-Grafik (RAUSER-Schriftzug/ADVERTAINMENT/
"!"-Box). Auf weissem Bildhintergrund faellt das nicht auf, aber sobald
die Szene zu Grau ueberblendet, bleibt dieses Rechteck stur weiss stehen
statt mit auszufaden. Ein v1-Versuch mit set_colorkey() wurde verworfen,
weil er auch die WEISSEN BUCHSTABEN in RAUSER1 durchsichtig machte.
Fix: Flood-Fill vom Bildrand aus ueber alle "papierweissen" Pixel (alle
Kanaele >= threshold) -- NUR die vom Rand aus zusammenhaengende Flaeche
wird transparent gemacht. Isolierte weisse Pixel MITTEN im Motiv (z.B.
weisse Buchstaben im RAUSER-Schriftzug) beruehren den Bildrand nicht und
bleiben deshalb unangetastet. Ergebnis: eine Surface mit echtem
Alphakanal, die beim Blit auf einen sich veraendernden Hintergrund
(weiss->grau) sauber durchscheint, statt eine harte Kante zu zeigen.'''
w, h = surface.get_size()
surf = surface.convert_alpha()
px = pygame.PixelArray(surf)
def is_bg(x, y):
color = surf.unmap_rgb(px[x, y])
return color.r >= threshold and color.g >= threshold and color.b >= threshold
seen = bytearray(w * h)
dq = deque()
def consider(x, y):
if not seen[y * w + x] and is_bg(x, y):
seen[y * w + x] = 1
dq.append((x, y))
for x in range(w):
consider(x, 0)
consider(x, h - 1)
for y in range(h):
consider(0, y)
consider(w - 1, y)
while dq:
cx, cy = dq.popleft()
if cx + 1 < w:
consider(cx + 1, cy)
if cx - 1 >= 0:
consider(cx - 1, cy)
if cy + 1 < h:
consider(cx, cy + 1)
if cy - 1 >= 0:
consider(cx, cy - 1)
del px
alpha = pygame.surfarray.pixels_alpha(surf)
for x in range(w):
for y in range(h):
if seen[y * w + x]:
alpha[x, y] = 0
del alpha
return surf
class Assets:
def __init__(self, dat_path):
self.container = DATContainer(dat_path)
def pcc(self, name):
'''name z.B. "RAUSER1" (ohne .PCC). Gibt (w,h,indices,palette) zurueck.'''
return load_pcc(self.container.entries[name + '.PCC'])
def pcc_surface(self, name):
w, h, indices, palette = self.pcc(name)
return indices_to_surface(w, h, indices, palette)
def pcc_surface_scaled(self, name, scale=ASSET_DISPLAY_SCALE, cut_border_bg=False):
'''Skaliert BEIDE Achsen um scale. KORREKTUR #2 (22.07.2026 spaet
nachts, siehe Docstring-Korrektur ganz oben): scale ist wieder 0.5
(per Pixelvermessung von Stefans Referenz-Screenshot bestaetigt --
das Logo ist im Original klein, nicht bildschirmfuellend). Der
eigentliche Blur-Fix ist pygame.transform.scale statt smoothscale:
scale() ist Nearest-Neighbor (keine Weichzeichnung), smoothscale()
war bilinear und hat bei 0.5x harte Pixel-Kanten der Pixel-Art
verwaschen -- DAS war Stefans "Aufloesung ist schlecht"-Bug, nicht
die Groesse selbst.
cut_border_bg=True macht den vom Bildrand aus erreichbaren papierweissen
Hintergrund transparent (siehe make_border_bg_transparent) -- fuer die
Rauser-Teile, damit sie beim Fade zu Grau nicht als harte weisse
Kaesten stehen bleiben.'''
surf = self.pcc_surface(name)
if cut_border_bg:
surf = make_border_bg_transparent(surf)
if scale == 1.0:
return surf
w, h = surf.get_size()
new_size = (max(1, round(w * scale)), max(1, round(h * scale)))
return pygame.transform.scale(surf, new_size)
def fit_scale(src_w, src_h, max_w, max_h):
return min(max_w / src_w, max_h / src_h)
def blit_scaled_centered(screen, surface, window_w, window_h, bg_color=BG_COLOR):
scale = fit_scale(surface.get_width(), surface.get_height(), window_w, window_h)
new_size = (max(1, int(surface.get_width() * scale)), max(1, int(surface.get_height() * scale)))
scaled = pygame.transform.scale(surface, new_size)
x = (window_w - new_size[0]) // 2
y = (window_h - new_size[1]) // 2
screen.fill(bg_color)
screen.blit(scaled, (x, y))
return scale, x, y
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.
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()
t = (now - start) / seconds if seconds > 0 else 1.0
if t >= 1.0:
screen_update_fn(1.0)
pygame.display.flip()
return None
for event in pygame.event.get():
r = _process_event(event)
if r:
return r
screen_update_fn(t)
pygame.display.flip()
clock.tick(fps)
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, tail_seconds=None):
'''Rendert einen TFMX-Song per tfmx_audio.render_wav in einem Hintergrund-
Thread, waehrend der Hauptthread weiter Events pumpt und einen simplen
Ladebildschirm zeichnet.
FIX (23.07.2026 -- Stefans Meldung "kommt nur noch ein schwarzes Bild,
verschiebe ich das Fenster wird der Desktop reingerendert, das Programm
haengt"): Ursache gefunden und per Test verifiziert, nicht nur vermutet --
render_wav() ruft render_tfmx als BLOCKIERENDEN subprocess.run() auf, ohne
dass die pygame-Eventloop weiterlaeuft. Bei einem frischen, noch nicht
gecachten Song (v.a. TITEL, ~109s Musik) dauert dieser eine Call
reproduzierbar mehrere -zig Sekunden (per Xvfb-Testlauf auf aria-wohnung
gemessen: ~14s Totalblockade direkt nach Factor5, bevor das Kellogg's-Logo
kommt). Ohne pygame.event.get()/display.flip() waehrend dieser Zeit stuft
das Betriebssystem das Fenster als "nicht reagierend" ein -- exakt das
beschriebene Bild (Inhalt bleibt schwarz stehen, beim Verschieben des
Fensters blitzt der darunterliegende Desktop durch, weil nichts neu
gezeichnet wird).
Fix: render_wav laeuft in einem Thread; der Haupt-Thread bleibt die ganze
Zeit responsiv (Events pumpen, Ladehinweis zeichnen, display.flip()) --
egal wie lange das Rendering dauert (auch beim allerersten Start ohne
Cache oder wenn build.sh den render_tfmx-Binary neu kompilieren muss).
Gibt (wav_path_or_None, quit_requested) zurueck. wav_path ist None wenn
das Rendering fehlschlaegt (z.B. kein C-Compiler verfuegbar) -- die
Sequenz laeuft dann wie bisher an dieser Stelle stumm weiter, kein
Absturz.'''
result = {}
def worker():
try:
result['path'] = tfmx_audio.render_wav(container, name, song_index=song_index,
tail_seconds=tail_seconds)
except Exception as exc:
result['error'] = exc
thread = threading.Thread(target=worker, daemon=True)
thread.start()
try:
font = pygame.font.SysFont(None, 28)
except Exception:
font = None
dots = 0
last_dot_tick = time.monotonic()
while thread.is_alive():
for event in pygame.event.get():
if event.type == pygame.QUIT:
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))
screen.blit(text, (WINDOW_W // 2 - text.get_width() // 2, WINDOW_H // 2 - text.get_height() // 2))
pygame.display.flip()
clock.tick(30)
thread.join()
if 'error' in result:
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(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
zur Laufzeit nur noch aus dem WAV-Cache abgespielt wird statt live zu
rendern. Ueberspringt das Preloading komplett, wenn gar kein Audio-Device
verfuegbar ist (dann bliebe es sowieso stumm, siehe main()).'''
if not pygame.mixer.get_init():
return {}, False
# Die beiden kurzen Jingles bekommen einen Ausklang-Tail (siehe
# render_wav-Docstring: real_song_end schneidet sonst die noch klingenden
# Samples ab -- Stefans "Factor5-Soundfile ist noch nicht ganz abgespielt").
# Die geloopte Titelmusik bewusst OHNE Tail (exakter Loop-Punkt-Schnitt).
# Factor5-Tail = 2.6s -> Gesamt ~5.0s: das Brausen-Sample loopt endlos
# (kein natuerliches Ende), im Original zeigt die Engine den Screen ~5s
# (DOSBox-Messung ~4.8s; Stefans Erinnerung "ca. 5 Sekunden") und stoppt
# den Sound beim Wechsel. render_tfmx blendet das Ende sauber aus (600ms
# Fade), scene_factor5 haelt den Screen genau Songlaenge.
jobs = [
(('TITEL2', 0), 'Rauser-Sound', 5.0),
(('TITEL2', 1), 'Factor5-Sound', 2.6),
(('TITEL', None), 'Titelmusik', None),
]
audio_paths = {}
for key, label, tail in jobs:
name, song_index = key
path, quit_requested = render_audio_with_loading(clock, assets.container, name, song_index,
label, tail_seconds=tail)
audio_paths[key] = path
if quit_requested:
return audio_paths, True
return audio_paths, False
def build_letterboxed(surface, canvas_w=320, canvas_h=240):
'''Zentriert ein kleineres PCC-Bild (z.B. 320x199/200) auf eine 320x240-
Leinwand mit schwarzen Balken -- wie im echten DOSBox-Fenster beobachtet.'''
canvas = pygame.Surface((canvas_w, canvas_h))
canvas.fill((0, 0, 0))
x = (canvas_w - surface.get_width()) // 2
y = (canvas_h - surface.get_height()) // 2
canvas.blit(surface, (x, y))
return canvas
def build_kelloggs_frame(w, h, indices, palette, fade_t):
'''fade_t: 0..1, faedet PRAESENTIERT_FADE_INDICES von blass (Richtung
Hintergrundweiss) zu ihrer echten Palettenfarbe.'''
dyn_pal = list(palette)
for idx in PRAESENTIERT_FADE_INDICES:
r, g, b = palette[idx]
# start: sehr blass (nah am weissen Logo-Hintergrund), Ende: echte Farbe
pale = (255, 245, 245)
nr = int(pale[0] + (r - pale[0]) * fade_t)
ng = int(pale[1] + (g - pale[1]) * fade_t)
nb = int(pale[2] + (b - pale[2]) * fade_t)
dyn_pal[idx] = (nr, ng, nb)
return indices_to_surface(w, h, indices, dyn_pal)
# ---------------------------------------------------------------------------
# Rauser: Slide-in-Animation + Sound-Sting (siehe Docstring-Korrektur)
# ---------------------------------------------------------------------------
def build_rauser_parts(assets):
'''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").
# 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 "!"
# 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 = (WINDOW_W - total_w) // 2
y0 = (WINDOW_H - total_h) // 2
r1_final = (x0, y0)
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,
'r1_final': r1_final, 'r2_final': r2_final, 'r3_final': r3_final,
}
def render_rauser_slide(canvas, parts, slide_t):
'''slide_t: 0..1. r1 faehrt von links, r3 von oben ein; r2 steht fest.
Bei slide_t>=1 sind alle Teile an ihrer Endposition.'''
r1, r2, r3 = parts['r1'], parts['r2'], parts['r3']
ease = slide_t * slide_t * (3 - 2 * slide_t) # smoothstep, wirkt weniger linear/robotisch
r2_final = parts['r2_final']
canvas.blit(r2, r2_final)
r1_final_x, r1_final_y = parts['r1_final']
r1_start_x = -r1.get_width()
r1_x = int(r1_start_x + (r1_final_x - r1_start_x) * ease)
canvas.blit(r1, (r1_x, r1_final_y))
r3_final_x, r3_final_y = parts['r3_final']
r3_start_y = -r3.get_height()
r3_y = int(r3_start_y + (r3_final_y - r3_start_y) * ease)
canvas.blit(r3, (r3_final_x, r3_y))
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. 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(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-
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
# ist der Factor5-Sound (siehe Docstring).
sting_path = audio_paths.get(('TITEL2', 0))
if pygame.mixer.get_init() and sting_path:
try:
sting = pygame.mixer.Sound(sting_path)
except Exception as exc:
print(f'[intro] Rauser-Sound-Sting konnte nicht geladen werden: {exc}', file=sys.stderr)
sting_played = [False]
def show_slide(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:
sting.play()
r = wait_or_skip(clock, RAUSER_SLIDE_SECONDS, show_slide)
if r:
return r
# 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[0] = True
r = wait_or_skip(clock, RAUSER_HOLD_SECONDS,
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(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(parts, RAUSER_GREY, 1.0))
# ---------------------------------------------------------------------------
# Titelbild: 2x2-Raster aus halbskalierten Quadranten + Scanline-Reveal
# (siehe Docstring-Korrektur -- NICHT 320x480!)
# ---------------------------------------------------------------------------
def build_title_quadrants(assets):
'''Liefert die 4 Titelbild-Quadranten in NATIVER Aufloesung (je 160x240)
-- A/B oben, C/D unten -> zusammen ein 320x480-Bild. KORREKTUR 24.07.2026:
frueher wurden die Quadranten auf halbe Hoehe (160x120) gestaucht; ein
Vergleich gegen die echte DOSBox-Referenz (RMSE 0.014 voll vs. 0.083
halbiert) zeigt aber, dass das Original das Bild in voller Hoehe fuehrt und
nur ANAMORPH (doppelt hohe Pixel) auf 4:3 anzeigt. Die native Hoehe bleibt
also erhalten und wird erst beim Anzeigen gestreckt (siehe _blit_title).'''
return {
'A': assets.pcc_surface('KELL256A'),
'B': assets.pcc_surface('KELL256B'),
'C': assets.pcc_surface('KELL256C'),
'D': assets.pcc_surface('KELL256D'),
}
def build_title_image(quads):
'''Komplettes, ruhendes Titelbild (320x480) aus den 4 nativen Quadranten im
2x2-Raster (wird beim Anzeigen anamorph auf 4:3 gestreckt, siehe
_blit_title).'''
a, b, c, d = quads['A'], quads['B'], quads['C'], quads['D']
w = a.get_width() + b.get_width()
h = a.get_height() + c.get_height()
canvas = pygame.Surface((w, h))
canvas.blit(a, (0, 0))
canvas.blit(b, (a.get_width(), 0))
canvas.blit(c, (0, a.get_height()))
canvas.blit(d, (a.get_width(), a.get_height()))
return canvas
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)
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:
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() # 240 (native Quadrantenhoehe)
full_w = a.get_width() + b.get_width() # 320
canvas = pygame.Surface((full_w, half_h * 2)) # 320x480
canvas.fill((0, 0, 0))
if t < TITLE_HEIGHT_REVEAL_FRACTION:
reveal = t / TITLE_HEIGHT_REVEAL_FRACTION
h = max(0, int(half_h * reveal))
clear_progress = 0.0 # waehrend des Reveals noch komplett scanlined
else:
h = half_h
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))
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_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 scene_pause(clock, seconds):
'''Kurze schwarze Pause zwischen zwei Screens.'''
return wait_or_skip(clock, seconds, lambda _t: pygame.display.get_surface().fill(BG_COLOR))
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
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:
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] Factor5-Sound konnte nicht geladen werden: {exc}', file=sys.stderr)
return wait_or_skip(clock, hold, lambda _t: _blit_centered(factor5))
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 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:
pygame.mixer.music.load(titel_path)
pygame.mixer.music.play(-1)
except Exception as exc:
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)
_blit_centered(build_letterboxed(frame))
r = wait_or_skip(clock, PRAESENTIERT_FADE_SECONDS, show_kelloggs)
if r:
return r
# kurz mit vollem Text stehen lassen
return wait_or_skip(clock, 1.0, lambda _t: show_kelloggs(1.0))
def scene_title(clock, assets, hold_forever=True):
'''Titelbild -- 2x2-Quadranten-Reveal, Scanlines loesen sich von der Mitte
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_title(render_title_wipe_frame(quads, t)))
if r == 'quit':
return 'quit'
# 'skip' waehrend der Wipe-Animation -> direkt zum fertigen Titelbild
title_img = build_title_image(quads)
draw = lambda _t: _blit_title(title_img)
if hold_forever:
return wait_for_key(clock, draw)
return wait_or_skip(clock, 3.0, draw)
def run_once(clock, assets, audio_paths, hold_title=True):
'''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(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()
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():
global _fullscreen
once = '--once' in sys.argv
pygame.mixer.pre_init(44100, -16, 2, 512)
pygame.init()
try:
pygame.mixer.init()
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] 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()
assets = Assets(DAT_PATH)
# 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 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:
run_once(clock, assets, audio_paths, hold_title=not once)
if pygame.mixer.get_init():
pygame.mixer.music.stop()
pygame.quit()
if __name__ == '__main__':
main()