617 lines
26 KiB
Python
617 lines
26 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.TFX/.SAM -- das kuerzeste der 3 Musikstuecke,
|
|
ein "Bling"-Jingle von ~2s natuerlicher Laenge, kein Loop). Danach faedet
|
|
der WEISSE Hintergrund kurz zu GRAU um (NICHT weiss -- war ein Fehler in
|
|
v1!), haelt kurz, dann schwarze Pause. WICHTIG: alle Rauser-Assets werden
|
|
nur bei **halber Pixel-Groesse** (0.5x) ihrer PCC-Nativaufloesung
|
|
gezeichnet -- bei 1:1 sind sie in der echten DOSBox-Aufnahme klar zu groß
|
|
(per Pixel-Vermessung bestaetigt, siehe NOTES.md "Rauser/Titelbild-
|
|
Korrektur"). KORREKTUR (22.07.2026 abends): die Assets haben keinen
|
|
Alphakanal, ihr papierweisser Hintergrund sass als harter Kasten um die
|
|
Box-Grafik und fadete beim Uebergang zu Grau NICHT mit (Stefans Meldung:
|
|
"Text bleibt hart umrandet stehen"). Fix: make_border_bg_transparent()
|
|
macht per Flood-Fill vom Bildrand aus genau diesen Aussenbereich
|
|
transparent, isolierte weisse Buchstaben MITTEN im Motiv bleiben
|
|
unangetastet.
|
|
2. kurze schwarze Pause
|
|
3. Factor5-Studio-Logo (FACTOR5.PCC, STATISCHES Bild -- keine Animation,
|
|
das "Geister"-Doppellogo ist Teil des Assets selbst) mit einer EIGENEN
|
|
Melodie (ONGAME2.TFX/.SAM, der bis 22.07.2026 abends noch keinem Screen
|
|
zugeordnete dritte TFMX-Track -- Stefans Hinweis: "bei factor 5 gibts
|
|
auch noch ne melodie"). Der Screen bleibt exakt so lange stehen wie das
|
|
Stueck einmal komplett durchspielt (natuerliche Laenge per render_tfmx
|
|
erkannt, ~123s), nicht mehr eine feste Dauer.
|
|
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" -- **KORREKTUR (22.07.2026,
|
|
spaeter Abend): das Bild ist NICHT 320x480!** Die 4 Quadranten KELL256A
|
|
(oben links) / B (oben rechts) / C (unten links) / D (unten rechts) sind
|
|
zwar je 160x240 PCC-Nativaufloesung, werden aber -- exakt wie die
|
|
Rauser-Assets -- 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 (nicht nur
|
|
behauptet). Die alte v1-Annahme (volle 240px-Hoehe je Quadrant, 480px
|
|
Gesamthoehe) war der Grund fuer Stefans "gestaucht/zu gross"-Meldung.
|
|
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. ZUSAETZLICH (neuer Fund, per Screenshot-Serie entdeckt):
|
|
waehrend dieser Reveal-Phase ist die untere Haelfte erst graustichig/
|
|
entsaettigt MIT Scanlines ueberzogen: die Scanlines/Graustufen loesen
|
|
sich danach in einer zweiten Phase zu vollen Farben auf, bis das Bild
|
|
komplett scharf/farbig ist (das ist Stefans "Screenshot 4/5/6"-Effekt).
|
|
|
|
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
|
|
|
|
# Alle Rauser- und Titelbild-PCC-Assets werden bei HALBER Nativgroesse
|
|
# gezeichnet (siehe Docstring-Korrektur) -- per Pixelvermessung gegen eine
|
|
# echte DOSBox-Aufnahme bestaetigter Skalierungsfaktor.
|
|
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)
|
|
|
|
# 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 -- fuer die Rauser-Assets (dort per
|
|
Pixelvermessung als gleichmaessige 0.5x-Verkleinerung bestaetigt).
|
|
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)
|
|
w, h = surf.get_size()
|
|
new_size = (max(1, round(w * scale)), max(1, round(h * scale)))
|
|
return pygame.transform.smoothscale(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-Korrektur ganz oben in dieser Datei). Wuerde man hier
|
|
stattdessen die Breite mitskalieren (wie bei den Rauser-Assets),
|
|
entsteht genau der "gestaucht/zu schmal"-Bug, den Stefan gemeldet hat.'''
|
|
surf = self.pcc_surface(name)
|
|
w, h = surf.get_size()
|
|
new_size = (w, max(1, round(h * 0.5)))
|
|
return pygame.transform.smoothscale(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 skalierten Rauser-Teile + ihre Endpositionen auf einer
|
|
320x240-Leinwand (RAUSER1 links, RAUSER3 rechts daneben, RAUSER2 darunter
|
|
mittig -- alle bei ASSET_DISPLAY_SCALE, siehe Docstring).'''
|
|
# 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():
|
|
# KEIN fester seconds-Cap mehr (Fix 22.07.2026 abends, siehe
|
|
# tfmx_audio.render_wav-Docstring) -- render_tfmx erkennt das
|
|
# natuerliche Ende von TITEL2 selbst (~2s Jingle) und schneidet
|
|
# dort ab. Der alte harte 1.2s-Cap konnte den Sting theoretisch
|
|
# noch VOR seinem echten Ende kappen.
|
|
sting_wav = tfmx_audio.render_wav(assets.container, 'TITEL2')
|
|
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 ASSET_DISPLAY_SCALE (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, ABER mit eigener Melodie -- ONGAME2, der
|
|
# dritte/letzte bisher nicht zugeordnete TFMX-Track, siehe Stefans
|
|
# Meldung 22.07.2026 abends: "bei factor 5 gibts auch noch ne melodie
|
|
# solange das lied ist factor 5 anzeigen und dann zum kellogs
|
|
# praesentiert". Der Screen bleibt jetzt exakt so lange stehen wie das
|
|
# Stueck einmal komplett durchspielt (~123s natuerliche Laenge, siehe
|
|
# tfmx_audio.render_wav-Docstring), statt einer festen 3.0s-Dauer.
|
|
factor5 = build_letterboxed(assets.pcc_surface('FACTOR5'))
|
|
factor5_duration = 3.0 # Fallback falls kein Audio verfuegbar
|
|
factor5_sound = None
|
|
try:
|
|
if pygame.mixer.get_init():
|
|
factor5_wav = tfmx_audio.render_wav(assets.container, 'ONGAME2')
|
|
factor5_sound = pygame.mixer.Sound(factor5_wav)
|
|
factor5_duration = factor5_sound.get_length()
|
|
factor5_sound.play()
|
|
except Exception as exc:
|
|
print(f'[intro_sequence] Factor5-Melodie 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_duration, show_factor5)
|
|
if factor5_sound is not None:
|
|
factor5_sound.stop()
|
|
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.TFX/.SAM) 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).
|
|
# KEIN fester seconds-Cap mehr (Fix 22.07.2026 abends): TITEL ist
|
|
# tatsaechlich ~109s lang -- der alte 60s-Cap (plus ein liegen
|
|
# gebliebener 8s-Debug-Cache) hat das Stueck weit vor seinem echten Ende
|
|
# abgeschnitten. Das war Stefans "Musik ist zu kurz, da fehlt der
|
|
# Rest"-Bug. render_tfmx erkennt den natuerlichen Loop-Punkt jetzt selbst.
|
|
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()
|