#!/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()
