#!/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, genau ein "Bling"-Jingle, 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"). 2. kurze schwarze Pause 3. Factor5-Studio-Logo (FACTOR5.PCC, STATISCHES Bild -- keine Animation, das "Geister"-Doppellogo ist Teil des Assets selbst), ~6s 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 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() 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): '''Skaliert BEIDE Achsen um scale -- fuer die Rauser-Assets (dort per Pixelvermessung als gleichmaessige 0.5x-Verkleinerung bestaetigt).''' surf = self.pcc_surface(name) 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).''' r1 = assets.pcc_surface_scaled('RAUSER1') # "RAUSER"-Box r2 = assets.pcc_surface_scaled('RAUSER2') # "ADVERTAINMENT" r3 = assets.pcc_surface_scaled('RAUSER3') # "!" auf blauer Box # BEKANNTE UNSCHOENHEIT (bewusst NICHT "gefixt"): die PCC-Assets haben # weissen Hintergrund in ihrer eigenen Bounding-Box (kein Alpha-Kanal). # Auf weissem Grund faellt das nicht auf, nach dem Fade zu Grau sieht man # einen schwachen weissen Kasten um RAUSER2/RAUSER3. Ein Versuch, Weiss # per set_colorkey() durchsichtig zu machen, wurde wieder verworfen -- # dadurch wurden auch die WEISSEN BUCHSTABEN in RAUSER1 durchsichtig # (schlimmer als der Kasten). Sauberer Fix braucht eine echte # Alpha-Maske pro Asset statt eines einzelnen Colorkeys; low priority, # siehe "Noch offen" unten. 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(): sting_wav = tfmx_audio.render_wav(assets.container, 'TITEL2', seconds=1.2) 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 (statisch) factor5 = build_letterboxed(assets.pcc_surface('FACTOR5')) def show_factor5(_t): blit_scaled_centered(screen, factor5, WINDOW_W, WINDOW_H) quit_requested = wait_or_skip(clock, 3.0, 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.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). try: if pygame.mixer.get_init(): titel_wav = tfmx_audio.render_wav(assets.container, 'TITEL', seconds=60) 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()