Files
tony-remake/tools/intro_sequence.py
T
ARIA af2ead172f Rauser-Aufloesung wirklich gefixt (0.5x Nearest-Neighbor statt native 1:1 oder smoothscale) + Factor5-Sound korrigiert (TITEL2-Song1 statt ONGAME2, Multi-Song-Fund)
Pixelvermessung von Stefans Referenz-Screenshot widerlegt die letzte
"native Groesse ist richtig"-Annahme -- das Logo ist im Original klein
(~34% Bildbreite), nicht bildschirmfuellend. Der eigentliche Bloedsinn war
smoothscale() (bilinear, verwaescht Pixel-Art), nicht die 0.5x-Groesse
selbst. Fix: 0.5x beibehalten, pygame.transform.scale() (Nearest-Neighbor)
statt smoothscale().

Factor5 spielte bisher faelschlich ONGAME2 (~123s Level-Musik) ab. Fund:
TFX-Module buendeln mehrere Songs (v_songs[]) -- TITEL2 hat 2 (Song0 =
2.04s Rauser-Sting, Song1 = 2.40s), TITEL 3, ONGAME2 sogar 14. TITEL2-Song1
passt laenge-maessig exakt zu "nur ein bisschen laenger als der
Rauser-Sound" -- jetzt der Factor5-Sound, feste 5s Anzeigedauer.
2026-07-22 22:12:01 +00:00

652 lines
28 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, werden aber nur bei **halber Groesse**
(160x120) in ein 2x2-Raster gezeichnet: A|B oben, C|D unten -> 320x240
Gesamtbild. Per Pixel-Rekonstruktion gegen eine echte DOSBox-Aufnahme
verifiziert. Reveal-Effekt: obere Haelfte (A|B) waechst von oben (Hoehe
0->120), untere Haelfte (C|D) waechst von unten (Hoehe 0->120) -- treffen
sich in der Mitte. Waehrend dieser Reveal-Phase ist die untere Haelfte
erst graustichig/entsaettigt MIT Scanlines ueberzogen, die sich danach in
einer zweiten Phase zu vollen Farben aufloesen (Stefans "Screenshot
4/5/6"-Effekt). HINWEIS: diese Quadranten nutzen denselben
Downscale-Mechanismus (smoothscale halbe Hoehe) wie die Rauser-Assets
vor dem obigen Fix -- hier bisher KEINE Bildschaerfe-Beschwerde von
Stefan, deshalb unangetastet gelassen. Falls das Titelbild auch mal als
"verwaschen" gemeldet wird: gleicher Fix-Ansatz (native Groesse zeichnen,
nur einmal ganzzahlig hochskalieren) anwendbar.
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.
Aufruf: python3 intro_sequence.py [--once] (--once: nach einem Durchlauf beenden,
sonst loopt die Sequenz weiter wie im Original-Titelbildschirm)
"""
import sys
import os
import time
from collections import deque
sys.path.insert(0, os.path.dirname(__file__))
from kellogg_formats import DATContainer, load_pcc
import 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, 'raw', 'PCKELL.DAT'))
WINDOW_W, WINDOW_H = 960, 720
BG_COLOR = (0, 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
# 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)
# 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
# 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 pcc_surface_half_height(self, name):
'''Skaliert NUR die Hoehe um 0.5, Breite bleibt nativ -- fuer die
Titelbild-Quadranten KELL256A/B/C/D (160x240 -> 160x120). ACHTUNG:
das ist KEINE gleichmaessige Skalierung wie bei den Rauser-Assets --
per Pixel-Rekonstruktion gegen eine echte DOSBox-Aufnahme verifiziert
(siehe Docstring ganz oben). Nearest-Neighbor (pygame.transform.scale)
statt smoothscale aus dem gleichen Grund wie bei den Rauser-Assets
(siehe pcc_surface_scaled) -- konsistent scharf.'''
surf = self.pcc_surface(name)
w, h = surf.get_size()
new_size = (w, max(1, round(h * 0.5)))
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.
Bricht per ESC/Klick vorzeitig ab (gibt True zurueck wenn User quit will).'''
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 False
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
screen_update_fn(t)
pygame.display.flip()
clock.tick(fps)
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 (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).'''
# 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
total_w = r1.get_width() + r3.get_width()
total_h = r1.get_height() + r2.get_height()
x0 = (320 - total_w) // 2
y0 = (240 - total_h) // 2
r1_final = (x0, y0)
r3_final = (x0 + r1.get_width(), y0 - (r3.get_height() - r1.get_height()) // 2)
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 run_rauser(screen, clock, assets):
'''Rauser-Karte: weisser Grund, Slide-in, Sound-Sting bei Ankunft, Fade zu
grauem Grund, kurz halten. Gibt True zurueck wenn der User abbrechen will.'''
parts = build_rauser_parts(assets)
sting = None
try:
if pygame.mixer.get_init():
# TITEL2 Song 0 -- der kuerzere der beiden TITEL2-Songs (~2.04s),
# Song 1 ist der neu zugeordnete Factor5-Sound (siehe Docstring).
sting_wav = tfmx_audio.render_wav(assets.container, 'TITEL2', song_index=0)
sting = pygame.mixer.Sound(sting_wav)
except Exception as exc:
print(f'[intro_sequence] Rauser-Sound-Sting konnte nicht geladen werden: {exc}', file=sys.stderr)
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
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
# 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:
sting.play()
sting_played = 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
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
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)
# ---------------------------------------------------------------------------
# Titelbild: 2x2-Raster aus halbskalierten Quadranten + Scanline-Reveal
# (siehe Docstring-Korrektur -- NICHT 320x480!)
# ---------------------------------------------------------------------------
def build_title_quadrants(assets):
'''Liefert die 4 Titelbild-Quadranten bei halber Hoehe (160x120 statt
160x240 nativ) -- A/B oben, C/D unten. Per Pixel-Rekonstruktion gegen
eine echte DOSBox-Aufnahme verifiziert.'''
return {
'A': assets.pcc_surface_half_height('KELL256A'),
'B': assets.pcc_surface_half_height('KELL256B'),
'C': assets.pcc_surface_half_height('KELL256C'),
'D': assets.pcc_surface_half_height('KELL256D'),
}
def build_title_image(quads):
'''Komplettes, ruhendes Titelbild (320x240) aus den 4 halbskalierten
Quadranten im 2x2-Raster.'''
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_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:
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)
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).'''
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
canvas = pygame.Surface((full_w, half_h * 2))
canvas.fill((0, 0, 0))
if t < TITLE_HEIGHT_REVEAL_FRACTION:
reveal = t / TITLE_HEIGHT_REVEAL_FRACTION
h = max(0, int(half_h * reveal))
scan_intensity = 1.0
else:
h = half_h
scan_intensity = 1.0 - (t - TITLE_HEIGHT_REVEAL_FRACTION) / (1 - TITLE_HEIGHT_REVEAL_FRACTION)
if h > 0:
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))
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)
canvas.blit(bot_slice, (0, half_h * 2 - h))
return canvas
def run_once(screen, clock, assets):
quit_requested = run_rauser(screen, clock, assets)
if quit_requested:
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
# 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.
factor5 = build_letterboxed(assets.pcc_surface('FACTOR5'))
try:
if pygame.mixer.get_init():
factor5_wav = tfmx_audio.render_wav(assets.container, 'TITEL2', song_index=1)
factor5_sound = pygame.mixer.Sound(factor5_wav)
factor5_sound.play()
except Exception as exc:
print(f'[intro_sequence] Factor5-Sound konnte nicht geladen werden: {exc}', file=sys.stderr)
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).
try:
if pygame.mixer.get_init():
titel_wav = tfmx_audio.render_wav(assets.container, 'TITEL')
pygame.mixer.music.load(titel_wav)
pygame.mixer.music.play(-1)
except Exception as exc:
print(f'[intro_sequence] 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
# 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
# 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)
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
title_img = build_title_image(quads)
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 main():
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_sequence] Audio nicht verfuegbar, laeuft stumm: {exc}', file=sys.stderr)
screen = pygame.display.set_mode((WINDOW_W, WINDOW_H))
pygame.display.set_caption("Tony & Friends in Kellogg's Land -- Intro (Remake)")
clock = pygame.time.Clock()
assets = Assets(DAT_PATH)
quit_requested = run_once(screen, clock, assets)
while not quit_requested and not once:
quit_requested = run_once(screen, clock, assets)
if pygame.mixer.get_init():
pygame.mixer.music.stop()
pygame.quit()
if __name__ == '__main__':
main()