1014 lines
46 KiB
Python
1014 lines
46 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Boot-/Intro-Sequenz von "Tony & Friends in Kellogg's Land" -- erster
|
|
spielbarer (bzw. hier: zuschaubarer) pygame-Meilenstein des Remakes.
|
|
|
|
Reihenfolge + Timing wurde am 22.07.2026 durch eine echte DOSBox-Screenshot-
|
|
Serie vermessen, siehe NOTES.md Abschnitt "Boot-Sequenz vermessen". Kurzfassung:
|
|
|
|
1. Rauser-Advertainment-Karte (Komposit aus RAUSER1+2+3.PCC) auf WEISSEM
|
|
Grund. RAUSER1 ("RAUSER"-Box) faehrt von LINKS ein, RAUSER3 (blaue Box
|
|
mit "!") faehrt von OBEN ein, RAUSER2 ("ADVERTAINMENT") steht von Anfang
|
|
an fest. Sobald beide Teile ihre Endposition erreicht haben, spielt ein
|
|
kurzer Sound-Sting (TITEL2-Song 0 -- 2.04s natuerliche Laenge, siehe
|
|
tfmx_audio-Docstring zu den mehreren Songs pro Modul). Danach faedet der
|
|
WEISSE Hintergrund kurz zu GRAU um, haelt kurz, dann schwarze Pause.
|
|
KORREKTUR-GESCHICHTE (Aufloesungs-Bug, zwei Anlaeufe):
|
|
v1: Rauser-Assets bei 0.5x per smoothscale verkleinert -- Ergebnis war
|
|
verwaschen/unscharf (Stefans erste Meldung "Aufloesung ist
|
|
schlecht").
|
|
v2 (falscher Fix): Annahme war, die Verkleinerung selbst sei der
|
|
Fehler -- also ASSET_DISPLAY_SCALE auf 1.0 (native Groesse ohne
|
|
jede Skalierung) gesetzt. Sah in DIESEM Container scharf aus, war
|
|
aber ein NEUER Bug: bei nativer Groesse (RAUSER1+RAUSER3 = 198px
|
|
Breite) ist das Logo auf der 320px-Leinwand riesig -- Stefan hat
|
|
nie "zu gross" gemeldet, weil das schon in v1 unter dem Verwaschen
|
|
unterging.
|
|
v3 (echter Fix, 22.07.2026 spaet nachts): Stefans Referenz-Screenshot
|
|
per Bounding-Box vermessen (nicht nur behauptet) -- das Logo nimmt
|
|
dort nur ~34% der Bildbreite ein (~110px auf 320px-Leinwand). Die
|
|
urspruengliche 0.5x-GROESSE war also die ganze Zeit richtig
|
|
(182+16=198px * 0.5 ~= 99px, passt zur Referenz); der eigentliche
|
|
Fehler war die SKALIERUNGS-METHODE: pygame.transform.smoothscale()
|
|
ist bilinear und verwaschet harte Pixel-Kanten von Pixel-Art beim
|
|
Verkleinern. Fix: ASSET_DISPLAY_SCALE bleibt 0.5, aber
|
|
pygame.transform.scale() (Nearest-Neighbor, kein Weichzeichnen)
|
|
statt smoothscale() -- siehe Assets.pcc_surface_scaled(). Per
|
|
Bounding-Box-Nachmessung des eigenen Renders bestaetigt: 33.0%
|
|
Breite / 13.6% Hoehe vs. Referenz 34.5% / 16.6% -- nah dran, nicht
|
|
mehr der 62%-Breite-Fehlgriff von v2.
|
|
2. kurze schwarze Pause
|
|
3. Factor5-Studio-Logo (FACTOR5.PCC, STATISCHES Bild -- keine Animation,
|
|
das "Geister"-Doppellogo ist Teil des Assets selbst). KORREKTUR
|
|
(22.07.2026, spaeter Abend -- Stefans Meldung: "beim factor 5 hast ein
|
|
Level-Sound genommen ... der factor 5 Sound ist so ein Brausen, geht nur
|
|
ein bisschen laenger als der Rauser-Sound, das Bild wird ca. 5 Sekunden
|
|
eingeblendet"): die vorige Version hat faelschlich ONGAME2-Song 0
|
|
(~123s!) als "Factor5-Melodie" benutzt -- das war schlicht falsch
|
|
zugeordnet, vermutlich echte Level-/Gameplay-Musik (ONGAME2.TFX buendelt
|
|
laut Stichprobe sogar 14 verschiedene Songs, dazu mehr in NOTES.md).
|
|
Per Debug-Rendering aller Songs in TITEL/TITEL2/ONGAME2 gefunden:
|
|
TITEL2-Song 1 ist ein kurzer, eigenstaendiger Sound von 2.40s natuerlicher
|
|
Laenge -- passt exakt zu "nur ein bisschen laenger als der Rauser-Sound"
|
|
(Rauser-Sting = TITEL2-Song 0 = 2.04s). Das ist jetzt der Factor5-Sound.
|
|
WICHTIG (ehrlich, nicht nur behauptet): ich kann Audio nicht selbst
|
|
abhoeren, die Zuordnung ist eine Indizien-Schlussfolgerung aus der
|
|
Songlaenge (passt sehr gut), nicht durch Reinhoeren verifiziert -- falls
|
|
es beim Testen doch nicht "brausen" klingt, bitte melden, dann probieren
|
|
wir TITEL-Song 2 (~8.0s) oder einen der kurzen ONGAME2-Songs (3/11/12).
|
|
Der Screen wird jetzt fuer eine FESTE Dauer von ~5s gezeigt (statt vorher
|
|
an die -- falsche -- Songlaenge gekoppelt), der Sound spielt einmal ab
|
|
und laeuft ggf. vor Screen-Ende aus (er ist ja nur 2.4s lang).
|
|
4. kurze schwarze Pause
|
|
5. Kellogg's-Markenlogo (KELLOGGS.PCC) erscheint SOFORT komplett; darunter
|
|
faedet der Text "praesentiert" per Palette-Fade von blass nach saettigt-
|
|
rot ein (~3s).
|
|
6. kurze schwarze Pause
|
|
7. Titelbild "TONY & FRIENDS in Kellogg's Land" -- die 4 Quadranten KELL256A
|
|
(oben links) / B (oben rechts) / C (unten links) / D (unten rechts) sind
|
|
je 160x240 PCC-Nativaufloesung und werden in NATIVER Groesse in ein
|
|
2x2-Raster gezeichnet: A|B oben, C|D unten -> 320x480 Gesamtbild. Das wird
|
|
beim Anzeigen ANAMORPH auf 4:3 (Fenstergroesse) gestreckt -- doppelt hohe
|
|
Pixel, genau wie im Original (siehe _blit_title). KORREKTUR 24.07.2026:
|
|
frueher wurden die Quadranten auf halbe Hoehe gestaucht (320x240) -- das
|
|
war falsch (halbe vertikale Aufloesung). RMSE-Vergleich gegen die echte
|
|
DOSBox-Referenz: 0.014 (voll) vs. 0.083 (halbiert). Reveal-Effekt: obere
|
|
Haelfte (A|B) waechst von oben, untere (C|D) von unten, treffen sich in
|
|
der Mitte (y=240). Waehrend/nach dem Reveal ist die untere Haelfte mit
|
|
Scanlines ueberzogen (KEIN Grau mehr), die sich von der Mitte nach unten
|
|
aufloesen.
|
|
|
|
Alle Nicht-BOB/ICO/MAP-Assets werden per kellogg_formats.load_pcc() (Pillows
|
|
PCX-Decoder) aus PCKELL.DAT gelesen -- kein eigener RLE-Code mehr (siehe
|
|
Regressions-Warnung in pcc_to_png.py).
|
|
|
|
Rauser-Layout und Fade-Text-Bounding-Box sind aus den echten Screenshots per
|
|
Augenmass/Index-Analyse bestimmt (siehe tools/analyze_kelloggs.py-Fund:
|
|
Indizes 34/42/43/44/45 in KELLOGGS.PCC = "praesentiert"-Text, bbox x71-241
|
|
y108-141) -- nicht 100% pixelidentisch zum Original, aber nah dran. Kann bei
|
|
Bedarf spaeter nachjustiert werden.
|
|
|
|
HANG-FIX (23.07.2026 -- Stefans Meldung "nur noch ein schwarzes Bild,
|
|
verschiebe ich das Fenster wird der Desktop reingerendert, das Programm
|
|
haengt"): war real und per Xvfb-Test verifiziert, kein Bedienfehler. Ursache:
|
|
tfmx_audio.render_wav() rendert TFMX-Musik per BLOCKIERENDEM subprocess.run()
|
|
-- bei einem frischen, ungecachten Song (v.a. TITEL, ~109s) dauert das
|
|
reproduzierbar mehrere -zig Sekunden (gemessen: ~14s), waehrend derer die
|
|
pygame-Eventloop NICHT lief -- das Betriebssystem haelt das Fenster dann fuer
|
|
"nicht reagierend" (schwarz eingefroren, Desktop blitzt beim Verschieben
|
|
durch). Fix: alle drei benoetigten Songs werden jetzt VOR der eigentlichen
|
|
Sequenz einmalig in preload_all_audio() gerendert, mit sichtbarem, responsivem
|
|
Ladebildschirm (render_audio_with_loading() -- Rendering laeuft in einem
|
|
Thread, Hauptthread pumpt weiter Events). run_once() greift danach nur noch
|
|
auf fertige WAV-Pfade zu, nie mehr Live-Rendering im Hauptloop.
|
|
|
|
Aufruf: python3 -m game [--once]
|
|
Ablauf: Rauser -> Factor5 -> Kellogg's -> Titelbild-Animation; danach bleibt das
|
|
fertige Titelbild STEHEN (Titelmusik loopt) bis Leertaste/ESC -> Programm-Ende.
|
|
Keine Endlosschleife der ganzen Sequenz. (--once: Titelbild nur kurz halten,
|
|
fuer Tests/headless.)
|
|
Steuerung: LEERTASTE ueberspringt den aktuellen Screen (auf dem Titelbild
|
|
beendet sie das Programm), ESC beendet immer, ALT+ENTER schaltet Fenster/Vollbild
|
|
um (Zustand wird in kelloggs.ini gemerkt).
|
|
|
|
Stand 24.07.2026 (gegen echte DOSBox-Referenzaufnahmen abgeglichen):
|
|
- Rauser wird direkt in Fensteraufloesung gerendert (ein Nearest-Upscale der
|
|
nativen Assets statt 0.5x-Downscale + x3-Upscale) -> gestochen scharf; das
|
|
tuerkise "!"-Kaestchen (Engine-gezeichnet, kein Asset) wird nachgebaut.
|
|
- Factor5-Logo haelt exakt so lange wie sein Sound (TITEL2 Song 1, ~2.4s).
|
|
- Titelbild-Reveal: KEIN Grau/keine Entsaettigung mehr; die Scanlines auf der
|
|
unteren Haelfte loesen sich von der Mitte nach unten auf.
|
|
"""
|
|
import sys
|
|
import os
|
|
import time
|
|
import threading
|
|
import configparser
|
|
from collections import deque
|
|
|
|
from .formats import DATContainer, load_pcc
|
|
from . import audio as tfmx_audio
|
|
|
|
import pygame
|
|
|
|
try:
|
|
import numpy as np
|
|
HAVE_NUMPY = True
|
|
except ImportError:
|
|
HAVE_NUMPY = False
|
|
|
|
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
DAT_PATH = os.environ.get('KELLOGG_DAT', os.path.join(PROJECT_ROOT, 'game', 'original', 'PCKELL.DAT'))
|
|
|
|
WINDOW_W, WINDOW_H = 960, 720
|
|
BG_COLOR = (0, 0, 0)
|
|
|
|
# Vollbild-Zustand wird in kelloggs.ini gemerkt (Alt+Enter schaltet um). Liegt
|
|
# noch keine kelloggs.ini vor, ist der Default Fenstermodus.
|
|
CONFIG_PATH = os.environ.get('KELLOGG_CONFIG', os.path.join(PROJECT_ROOT, 'kelloggs.ini'))
|
|
_fullscreen = False # aktueller Zustand; in main() aus der INI geladen
|
|
|
|
|
|
def load_fullscreen_pref():
|
|
'''Liest den gewuenschten Vollbild-Zustand aus kelloggs.ini. Fehlt die
|
|
Datei, ist der Default Fenstermodus (False).'''
|
|
cfg = configparser.ConfigParser()
|
|
try:
|
|
cfg.read(CONFIG_PATH)
|
|
return cfg.getboolean('display', 'fullscreen', fallback=False)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def save_fullscreen_pref(fullscreen):
|
|
'''Schreibt den aktuellen Vollbild-Zustand nach kelloggs.ini.'''
|
|
cfg = configparser.ConfigParser()
|
|
cfg['display'] = {'fullscreen': 'true' if fullscreen else 'false'}
|
|
try:
|
|
with open(CONFIG_PATH, 'w') as f:
|
|
cfg.write(f)
|
|
except Exception as exc:
|
|
print(f'[intro] konnte {CONFIG_PATH} nicht schreiben: {exc}', file=sys.stderr)
|
|
|
|
|
|
def _display_flags(fullscreen):
|
|
# SCALED: das 960x720-Logikbild wird auf Fenster- bzw. Bildschirmgroesse
|
|
# skaliert -- so ist Vollbild ein sauberes Hochskalieren statt eines
|
|
# winzigen 960x720-Bildes mit schwarzem Rand.
|
|
flags = pygame.SCALED
|
|
if fullscreen:
|
|
flags |= pygame.FULLSCREEN
|
|
return flags
|
|
|
|
|
|
def toggle_fullscreen():
|
|
'''Alt+Enter: zwischen Fenster und Vollbild umschalten und den Zustand in
|
|
kelloggs.ini merken (fuers naechste Starten). Die Zeichenroutinen holen die
|
|
aktuelle Surface jeweils per pygame.display.get_surface(), daher ist das
|
|
Neuerzeugen des Display-Modes hier unkritisch.'''
|
|
global _fullscreen
|
|
_fullscreen = not _fullscreen
|
|
try:
|
|
pygame.display.set_mode((WINDOW_W, WINDOW_H), _display_flags(_fullscreen))
|
|
except Exception as exc:
|
|
print(f'[intro] Vollbild-Umschalten fehlgeschlagen: {exc}', file=sys.stderr)
|
|
save_fullscreen_pref(_fullscreen)
|
|
|
|
|
|
def _process_event(event):
|
|
'''Gemeinsame Event-Behandlung. Rueckgabe 'quit'/'skip'/None. Alt+Enter
|
|
(Vollbild-Toggle) wird hier abgefangen und zaehlt NICHT als skip/quit.'''
|
|
if event.type == pygame.QUIT:
|
|
return 'quit'
|
|
if event.type == pygame.KEYDOWN:
|
|
if event.key == pygame.K_RETURN and (event.mod & pygame.KMOD_ALT):
|
|
toggle_fullscreen()
|
|
return None
|
|
if event.key == pygame.K_ESCAPE:
|
|
return 'quit'
|
|
if event.key == pygame.K_SPACE:
|
|
return 'skip'
|
|
return None
|
|
|
|
|
|
def _blit_centered(surface):
|
|
'''Zeichnet `surface` zentriert/skaliert auf die AKTUELLE Display-Surface
|
|
(per get_surface(), damit ein Vollbild-Umschalten mitten in einer Szene
|
|
keinen veralteten Screen-Bezug hinterlaesst).'''
|
|
blit_scaled_centered(pygame.display.get_surface(), surface, WINDOW_W, WINDOW_H)
|
|
|
|
|
|
def _blit_title(surface):
|
|
'''Titelbild-Sonderfall: die Quelle ist 320x480 (ein 4:3-Bild mit doppelt
|
|
hohen Pixeln -- genau so liegt es im Original vor, siehe Titelbild-
|
|
Aufloesungs-Fund 24.07.2026). Es wird ANAMORPH auf die volle Fenstergroesse
|
|
gestreckt (320x480 -> 960x720, also x3 Breite / x1.5 Hoehe) -- fuellt das
|
|
Fenster wie die 320x240-Screens, behaelt aber die volle vertikale
|
|
Aufloesung (480 statt der frueher faelschlich halbierten 240 Zeilen).'''
|
|
s = pygame.display.get_surface()
|
|
s.blit(pygame.transform.scale(surface, (WINDOW_W, WINDOW_H)), (0, 0))
|
|
|
|
# "praesentiert"-Text-Faelle in KELLOGGS.PCC (siehe Docstring)
|
|
PRAESENTIERT_FADE_INDICES = [34, 42, 43, 44, 45]
|
|
PRAESENTIERT_FADE_SECONDS = 2.5
|
|
|
|
# KORREKTUR #2 (22.07.2026 spaet nachts, Stefans Referenz-Screenshot
|
|
# vermessen): 1.0 (native Groesse) war KEIN Aufloesungs-Fix, sondern ein
|
|
# neuer Bug -- ich hatte die Blur-Ursache falsch diagnostiziert. Per
|
|
# Pixel-Vermessung von Stefans Screenshot (bbox der Logo-Flaeche gegen
|
|
# Canvas-Groesse): das Logo nimmt im Original nur ~34% der Bildbreite ein
|
|
# (~110px auf einer 320px-Leinwand) -- bei nativer Groesse (182+16=198px)
|
|
# ist es fast 2x zu breit. Die vorige 0.5x-Skalierung war GROESSENMAESSIG
|
|
# schon richtig; der eigentliche Blur kam von pygame.transform.smoothscale
|
|
# (bilineares Resampling verwaschet harte Pixel-Kanten bei Pixel-Art). Fix:
|
|
# 0.5x beibehalten, aber mit pygame.transform.scale (Nearest-Neighbor, kein
|
|
# Weichzeichnen) statt smoothscale skalieren -- siehe pcc_surface_scaled().
|
|
ASSET_DISPLAY_SCALE = 0.5
|
|
|
|
# KORREKTUR #3 (24.07.2026, endlich mit echter DOSBox-Referenz vermessen):
|
|
# Das Rauser-Logo (RAUSER1 "RAUSER"-Box + RAUSER3 "!"-Box + RAUSER2
|
|
# "ADVERTAINMENT") ist im Original nur ~105px breit auf einer 320er-Leinwand
|
|
# (~33% Breite) -- die Groesse (netto ~1.5x = 0.5x * 3x-Fensterskalierung) war
|
|
# also die ganze Zeit richtig. Der QUALITAETS-Bug war der ZWEISTUFIGE Skalier-
|
|
# weg: die nativen Assets (RAUSER1 ist 182px!) wurden erst per Nearest auf 0.5x
|
|
# RUNTER (verwirft die Haelfte der Pixel -> zerbrochene Serifen) und die 320er-
|
|
# Leinwand danach x3 HOCH. Fix: die nativen Assets EINMAL direkt auf ihre
|
|
# Endgroesse hochskalieren (netto ~1.6x, ein reiner Upscale -> alle nativen
|
|
# Pixel bleiben erhalten, gestochen scharf) und den Rauser-Screen direkt in
|
|
# Fensteraufloesung rendern (nicht ueber die 320er-Zwischenleinwand). Wert so
|
|
# gewaehlt = 1.5 = exakt die alte Netto-Groesse (0.5x * 3x), die Stefan
|
|
# groessenmaessig nie bemaengelt hat -- nur jetzt als EIN Upscale statt
|
|
# ueber den verlustreichen 0.5x-Zwischenschritt, also identische Groesse/
|
|
# Position wie bisher, aber gestochen scharf. Deckt sich mit der Referenz-
|
|
# messung (Logo ~33% Bildbreite).
|
|
RAUSER_LOGO_SCALE = 1.5
|
|
|
|
# Rauser-Timing (siehe Docstring)
|
|
RAUSER_SLIDE_SECONDS = 0.9
|
|
RAUSER_HOLD_SECONDS = 1.3
|
|
RAUSER_FADE_TO_GREY_SECONDS = 0.4
|
|
RAUSER_GREY_HOLD_SECONDS = 0.3
|
|
RAUSER_GREY = (73, 73, 73)
|
|
RAUSER_WHITE = (255, 255, 255)
|
|
# Tuerkis-Kaestchen hinter dem "!" -- aus der DOSBox-Referenz gesampelt.
|
|
# Es steckt in KEINEM PCC-Asset (RAUSER3 ist nur das nackte "!"), sondern wird
|
|
# im Original von der Engine als Vollfarb-Rechteck gezeichnet.
|
|
RAUSER_TEAL = (5, 135, 183)
|
|
|
|
# Factor5-Timing (Stefan 24.07.2026): das Factor5-Logo wird exakt so lange
|
|
# gezeigt, wie der zugehoerige Sound (TITEL2 Song 1, ~2.4s) spielt -- ist der
|
|
# Sound zu Ende, geht es zu Kellogg's weiter. FALLBACK gilt ohne Audio-Geraet
|
|
# (keine Songlaenge ermittelbar). MAX ist eine Sicherheits-Obergrenze: der
|
|
# Factor5-Sound ist bewusst kurz ("kurzes Brausen"); sollte je ein fehlerhaft
|
|
# gerendertes (zu langes) WAV im Cache landen, darf der Screen NICHT minutenlang
|
|
# haengen (genau dieser Bug ist am 24.07. durch ein veraltetes render_tfmx-
|
|
# Binary passiert -- 180s statt 2.4s).
|
|
FACTOR5_FALLBACK_SECONDS = 2.5
|
|
FACTOR5_MAX_SECONDS = 12.0
|
|
|
|
# Titelbild-Timing (siehe Docstring)
|
|
TITLE_HEIGHT_REVEAL_FRACTION = 0.55 # Anteil der Wipe-Dauer fuer das Hoehenwachstum
|
|
TITLE_WIPE_SECONDS = 2.3
|
|
|
|
|
|
def indices_to_surface(width, height, indices, palette):
|
|
'''Baut ein pygame.Surface (RGB) aus Palette-Indizes + (r,g,b)-Liste.'''
|
|
buf = bytearray(width * height * 3)
|
|
for i, idx in enumerate(indices):
|
|
r, g, b = palette[idx & 0xFF]
|
|
buf[i*3:i*3+3] = bytes([r, g, b])
|
|
surf = pygame.image.frombuffer(bytes(buf), (width, height), 'RGB')
|
|
return surf.convert()
|
|
|
|
|
|
def make_border_bg_transparent(surface, threshold=235):
|
|
'''Fix fuer Stefans Meldung (22.07.2026 abends): "der Rauser-Text fadet
|
|
nicht mit aus, bleibt hart umrandet". Ursache: die Rauser-PCC-Assets haben
|
|
KEINEN Alphakanal -- ihr papierweisser Hintergrund sitzt als hartes
|
|
Rechteck um die eigentliche Box-Grafik (RAUSER-Schriftzug/ADVERTAINMENT/
|
|
"!"-Box). Auf weissem Bildhintergrund faellt das nicht auf, aber sobald
|
|
die Szene zu Grau ueberblendet, bleibt dieses Rechteck stur weiss stehen
|
|
statt mit auszufaden. Ein v1-Versuch mit set_colorkey() wurde verworfen,
|
|
weil er auch die WEISSEN BUCHSTABEN in RAUSER1 durchsichtig machte.
|
|
|
|
Fix: Flood-Fill vom Bildrand aus ueber alle "papierweissen" Pixel (alle
|
|
Kanaele >= threshold) -- NUR die vom Rand aus zusammenhaengende Flaeche
|
|
wird transparent gemacht. Isolierte weisse Pixel MITTEN im Motiv (z.B.
|
|
weisse Buchstaben im RAUSER-Schriftzug) beruehren den Bildrand nicht und
|
|
bleiben deshalb unangetastet. Ergebnis: eine Surface mit echtem
|
|
Alphakanal, die beim Blit auf einen sich veraendernden Hintergrund
|
|
(weiss->grau) sauber durchscheint, statt eine harte Kante zu zeigen.'''
|
|
w, h = surface.get_size()
|
|
surf = surface.convert_alpha()
|
|
px = pygame.PixelArray(surf)
|
|
|
|
def is_bg(x, y):
|
|
color = surf.unmap_rgb(px[x, y])
|
|
return color.r >= threshold and color.g >= threshold and color.b >= threshold
|
|
|
|
seen = bytearray(w * h)
|
|
dq = deque()
|
|
|
|
def consider(x, y):
|
|
if not seen[y * w + x] and is_bg(x, y):
|
|
seen[y * w + x] = 1
|
|
dq.append((x, y))
|
|
|
|
for x in range(w):
|
|
consider(x, 0)
|
|
consider(x, h - 1)
|
|
for y in range(h):
|
|
consider(0, y)
|
|
consider(w - 1, y)
|
|
|
|
while dq:
|
|
cx, cy = dq.popleft()
|
|
if cx + 1 < w:
|
|
consider(cx + 1, cy)
|
|
if cx - 1 >= 0:
|
|
consider(cx - 1, cy)
|
|
if cy + 1 < h:
|
|
consider(cx, cy + 1)
|
|
if cy - 1 >= 0:
|
|
consider(cx, cy - 1)
|
|
del px
|
|
|
|
alpha = pygame.surfarray.pixels_alpha(surf)
|
|
for x in range(w):
|
|
for y in range(h):
|
|
if seen[y * w + x]:
|
|
alpha[x, y] = 0
|
|
del alpha
|
|
return surf
|
|
|
|
|
|
class Assets:
|
|
def __init__(self, dat_path):
|
|
self.container = DATContainer(dat_path)
|
|
|
|
def pcc(self, name):
|
|
'''name z.B. "RAUSER1" (ohne .PCC). Gibt (w,h,indices,palette) zurueck.'''
|
|
return load_pcc(self.container.entries[name + '.PCC'])
|
|
|
|
def pcc_surface(self, name):
|
|
w, h, indices, palette = self.pcc(name)
|
|
return indices_to_surface(w, h, indices, palette)
|
|
|
|
def pcc_surface_scaled(self, name, scale=ASSET_DISPLAY_SCALE, cut_border_bg=False):
|
|
'''Skaliert BEIDE Achsen um scale. KORREKTUR #2 (22.07.2026 spaet
|
|
nachts, siehe Docstring-Korrektur ganz oben): scale ist wieder 0.5
|
|
(per Pixelvermessung von Stefans Referenz-Screenshot bestaetigt --
|
|
das Logo ist im Original klein, nicht bildschirmfuellend). Der
|
|
eigentliche Blur-Fix ist pygame.transform.scale statt smoothscale:
|
|
scale() ist Nearest-Neighbor (keine Weichzeichnung), smoothscale()
|
|
war bilinear und hat bei 0.5x harte Pixel-Kanten der Pixel-Art
|
|
verwaschen -- DAS war Stefans "Aufloesung ist schlecht"-Bug, nicht
|
|
die Groesse selbst.
|
|
cut_border_bg=True macht den vom Bildrand aus erreichbaren papierweissen
|
|
Hintergrund transparent (siehe make_border_bg_transparent) -- fuer die
|
|
Rauser-Teile, damit sie beim Fade zu Grau nicht als harte weisse
|
|
Kaesten stehen bleiben.'''
|
|
surf = self.pcc_surface(name)
|
|
if cut_border_bg:
|
|
surf = make_border_bg_transparent(surf)
|
|
if scale == 1.0:
|
|
return surf
|
|
w, h = surf.get_size()
|
|
new_size = (max(1, round(w * scale)), max(1, round(h * scale)))
|
|
return pygame.transform.scale(surf, new_size)
|
|
|
|
def fit_scale(src_w, src_h, max_w, max_h):
|
|
return min(max_w / src_w, max_h / src_h)
|
|
|
|
|
|
def blit_scaled_centered(screen, surface, window_w, window_h, bg_color=BG_COLOR):
|
|
scale = fit_scale(surface.get_width(), surface.get_height(), window_w, window_h)
|
|
new_size = (max(1, int(surface.get_width() * scale)), max(1, int(surface.get_height() * scale)))
|
|
scaled = pygame.transform.scale(surface, new_size)
|
|
x = (window_w - new_size[0]) // 2
|
|
y = (window_h - new_size[1]) // 2
|
|
screen.fill(bg_color)
|
|
screen.blit(scaled, (x, y))
|
|
return scale, x, y
|
|
|
|
|
|
def wait_or_skip(clock, seconds, screen_update_fn, fps=30):
|
|
'''Laeuft seconds lang, ruft pro Frame screen_update_fn(t_norm 0..1) auf.
|
|
Rueckgabe:
|
|
'quit' -- User will das ganze Intro beenden (ESC oder Fenster-X)
|
|
'skip' -- User will den aktuellen Screen ueberspringen (Leertaste)
|
|
None -- die Dauer ist regulaer abgelaufen
|
|
'quit' und 'skip' sind beide truthy, None ist falsy -- Aufrufer koennen also
|
|
kurz `if r: return r` schreiben, um Scene-Abbruch (skip) und Gesamt-Abbruch
|
|
(quit) gemeinsam nach oben durchzureichen.'''
|
|
start = time.monotonic()
|
|
while True:
|
|
now = time.monotonic()
|
|
t = (now - start) / seconds if seconds > 0 else 1.0
|
|
if t >= 1.0:
|
|
screen_update_fn(1.0)
|
|
pygame.display.flip()
|
|
return None
|
|
for event in pygame.event.get():
|
|
r = _process_event(event)
|
|
if r:
|
|
return r
|
|
screen_update_fn(t)
|
|
pygame.display.flip()
|
|
clock.tick(fps)
|
|
|
|
|
|
def wait_for_key(clock, draw_fn, fps=30):
|
|
'''Zeigt das Standbild (draw_fn(1.0)) und wartet UNENDLICH auf eine Taste.
|
|
Alt+Enter schaltet dabei weiter Vollbild um; die geloopte Titelmusik
|
|
laeuft die ganze Zeit weiter. Rueckgabe:
|
|
'quit' -- Fenster-X (Programm beenden)
|
|
'key' -- ESC oder Leertaste gedrueckt (im Original oeffnet ESC am
|
|
Titelbild das Hauptmenue -- der Aufrufer entscheidet).'''
|
|
while True:
|
|
for event in pygame.event.get():
|
|
r = _process_event(event)
|
|
if event.type == pygame.QUIT:
|
|
return 'quit'
|
|
if r in ('quit', 'skip'):
|
|
return 'key'
|
|
draw_fn(1.0)
|
|
pygame.display.flip()
|
|
clock.tick(fps)
|
|
|
|
|
|
def render_audio_with_loading(clock, container, name, song_index, label, tail_seconds=None):
|
|
'''Rendert einen TFMX-Song per tfmx_audio.render_wav in einem Hintergrund-
|
|
Thread, waehrend der Hauptthread weiter Events pumpt und einen simplen
|
|
Ladebildschirm zeichnet.
|
|
|
|
FIX (23.07.2026 -- Stefans Meldung "kommt nur noch ein schwarzes Bild,
|
|
verschiebe ich das Fenster wird der Desktop reingerendert, das Programm
|
|
haengt"): Ursache gefunden und per Test verifiziert, nicht nur vermutet --
|
|
render_wav() ruft render_tfmx als BLOCKIERENDEN subprocess.run() auf, ohne
|
|
dass die pygame-Eventloop weiterlaeuft. Bei einem frischen, noch nicht
|
|
gecachten Song (v.a. TITEL, ~109s Musik) dauert dieser eine Call
|
|
reproduzierbar mehrere -zig Sekunden (per Xvfb-Testlauf auf aria-wohnung
|
|
gemessen: ~14s Totalblockade direkt nach Factor5, bevor das Kellogg's-Logo
|
|
kommt). Ohne pygame.event.get()/display.flip() waehrend dieser Zeit stuft
|
|
das Betriebssystem das Fenster als "nicht reagierend" ein -- exakt das
|
|
beschriebene Bild (Inhalt bleibt schwarz stehen, beim Verschieben des
|
|
Fensters blitzt der darunterliegende Desktop durch, weil nichts neu
|
|
gezeichnet wird).
|
|
|
|
Fix: render_wav laeuft in einem Thread; der Haupt-Thread bleibt die ganze
|
|
Zeit responsiv (Events pumpen, Ladehinweis zeichnen, display.flip()) --
|
|
egal wie lange das Rendering dauert (auch beim allerersten Start ohne
|
|
Cache oder wenn build.sh den render_tfmx-Binary neu kompilieren muss).
|
|
|
|
Gibt (wav_path_or_None, quit_requested) zurueck. wav_path ist None wenn
|
|
das Rendering fehlschlaegt (z.B. kein C-Compiler verfuegbar) -- die
|
|
Sequenz laeuft dann wie bisher an dieser Stelle stumm weiter, kein
|
|
Absturz.'''
|
|
result = {}
|
|
|
|
def worker():
|
|
try:
|
|
result['path'] = tfmx_audio.render_wav(container, name, song_index=song_index,
|
|
tail_seconds=tail_seconds)
|
|
except Exception as exc:
|
|
result['error'] = exc
|
|
|
|
thread = threading.Thread(target=worker, daemon=True)
|
|
thread.start()
|
|
|
|
try:
|
|
font = pygame.font.SysFont(None, 28)
|
|
except Exception:
|
|
font = None
|
|
|
|
dots = 0
|
|
last_dot_tick = time.monotonic()
|
|
while thread.is_alive():
|
|
for event in pygame.event.get():
|
|
if event.type == pygame.QUIT:
|
|
return None, True
|
|
if event.type == pygame.KEYDOWN:
|
|
if event.key == pygame.K_ESCAPE:
|
|
return None, True
|
|
if event.key == pygame.K_RETURN and (event.mod & pygame.KMOD_ALT):
|
|
toggle_fullscreen()
|
|
now = time.monotonic()
|
|
if now - last_dot_tick > 0.4:
|
|
dots = (dots + 1) % 4
|
|
last_dot_tick = now
|
|
screen = pygame.display.get_surface()
|
|
screen.fill(BG_COLOR)
|
|
if font is not None:
|
|
text = font.render(f'Lade {label}{"." * dots}', True, (200, 200, 200))
|
|
screen.blit(text, (WINDOW_W // 2 - text.get_width() // 2, WINDOW_H // 2 - text.get_height() // 2))
|
|
pygame.display.flip()
|
|
clock.tick(30)
|
|
|
|
thread.join()
|
|
if 'error' in result:
|
|
print(f'[intro] {label} konnte nicht gerendert werden: {result["error"]}', file=sys.stderr)
|
|
return None, False
|
|
return result.get('path'), False
|
|
|
|
|
|
def preload_all_audio(clock, assets):
|
|
'''Rendert VOR der eigentlichen Sequenz alle drei benoetigten TFMX-Songs
|
|
einmalig (siehe render_audio_with_loading-Docstring) -- danach ist jeder
|
|
run_once()-Durchlauf (auch der allererste!) garantiert freeze-frei, weil
|
|
zur Laufzeit nur noch aus dem WAV-Cache abgespielt wird statt live zu
|
|
rendern. Ueberspringt das Preloading komplett, wenn gar kein Audio-Device
|
|
verfuegbar ist (dann bliebe es sowieso stumm, siehe main()).'''
|
|
if not pygame.mixer.get_init():
|
|
return {}, False
|
|
# Die beiden kurzen Jingles bekommen einen Ausklang-Tail (siehe
|
|
# render_wav-Docstring: real_song_end schneidet sonst die noch klingenden
|
|
# Samples ab -- Stefans "Factor5-Soundfile ist noch nicht ganz abgespielt").
|
|
# Die geloopte Titelmusik bewusst OHNE Tail (exakter Loop-Punkt-Schnitt).
|
|
# Factor5-Tail = 2.6s -> Gesamt ~5.0s: das Brausen-Sample loopt endlos
|
|
# (kein natuerliches Ende), im Original zeigt die Engine den Screen ~5s
|
|
# (DOSBox-Messung ~4.8s; Stefans Erinnerung "ca. 5 Sekunden") und stoppt
|
|
# den Sound beim Wechsel. render_tfmx blendet das Ende sauber aus (600ms
|
|
# Fade), scene_factor5 haelt den Screen genau Songlaenge.
|
|
# ONGAME2 (Song 0) = Levelmusik-Annahme fuer Level 1 (~123s Loop) --
|
|
# einmalig hier mitrendern, damit der Levelstart nie blockiert.
|
|
jobs = [
|
|
(('TITEL2', 0), 'Rauser-Sound', 5.0),
|
|
(('TITEL2', 1), 'Factor5-Sound', 2.6),
|
|
(('TITEL', None), 'Titelmusik', None),
|
|
(('ONGAME2', None), 'Levelmusik', None),
|
|
]
|
|
audio_paths = {}
|
|
for key, label, tail in jobs:
|
|
name, song_index = key
|
|
path, quit_requested = render_audio_with_loading(clock, assets.container, name, song_index,
|
|
label, tail_seconds=tail)
|
|
audio_paths[key] = path
|
|
if quit_requested:
|
|
return audio_paths, True
|
|
return audio_paths, False
|
|
|
|
|
|
def build_letterboxed(surface, canvas_w=320, canvas_h=240):
|
|
'''Zentriert ein kleineres PCC-Bild (z.B. 320x199/200) auf eine 320x240-
|
|
Leinwand mit schwarzen Balken -- wie im echten DOSBox-Fenster beobachtet.'''
|
|
canvas = pygame.Surface((canvas_w, canvas_h))
|
|
canvas.fill((0, 0, 0))
|
|
x = (canvas_w - surface.get_width()) // 2
|
|
y = (canvas_h - surface.get_height()) // 2
|
|
canvas.blit(surface, (x, y))
|
|
return canvas
|
|
|
|
|
|
def build_kelloggs_frame(w, h, indices, palette, fade_t):
|
|
'''fade_t: 0..1, faedet PRAESENTIERT_FADE_INDICES von blass (Richtung
|
|
Hintergrundweiss) zu ihrer echten Palettenfarbe.'''
|
|
dyn_pal = list(palette)
|
|
for idx in PRAESENTIERT_FADE_INDICES:
|
|
r, g, b = palette[idx]
|
|
# start: sehr blass (nah am weissen Logo-Hintergrund), Ende: echte Farbe
|
|
pale = (255, 245, 245)
|
|
nr = int(pale[0] + (r - pale[0]) * fade_t)
|
|
ng = int(pale[1] + (g - pale[1]) * fade_t)
|
|
nb = int(pale[2] + (b - pale[2]) * fade_t)
|
|
dyn_pal[idx] = (nr, ng, nb)
|
|
return indices_to_surface(w, h, indices, dyn_pal)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Rauser: Slide-in-Animation + Sound-Sting (siehe Docstring-Korrektur)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def build_rauser_parts(assets):
|
|
'''Liefert die drei Rauser-Teile bei RAUSER_LOGO_SCALE (Nearest-Neighbor-
|
|
UPSCALE direkt aus der Nativaufloesung, siehe Docstring-Korrektur #3) +
|
|
ihre Endpositionen direkt in FENSTER-Koordinaten (960x720). RAUSER1
|
|
("RAUSER") links, RAUSER3 ("!") rechts daneben, RAUSER2 ("ADVERTAINMENT")
|
|
darunter -- der Rauser-Screen wird NICHT ueber die 320er-Zwischenleinwand
|
|
gerendert (die den Blur verursacht hat), sondern direkt in voller
|
|
Fensteraufloesung.'''
|
|
# cut_border_bg=True (Fix 22.07.2026 abends): macht den papierweissen
|
|
# Hintergrund um jede Box transparent (nur die vom Bildrand aus
|
|
# zusammenhaengende Flaeche, siehe make_border_bg_transparent) -- vorher
|
|
# blieb hier ein harter weisser Kasten stehen, der beim Fade zu Grau nicht
|
|
# mitgefadet ist (Stefans Meldung "Text bleibt hart umrandet stehen").
|
|
# scale=RAUSER_LOGO_SCALE ist ein UPSCALE der nativen Assets -> pcc_surface_scaled
|
|
# macht cut_border auf der Nativ-Surface und skaliert dann per Nearest hoch,
|
|
# es geht also kein natives Pixel verloren (Gegensatz zum alten 0.5x-Downscale).
|
|
r1 = assets.pcc_surface_scaled('RAUSER1', scale=RAUSER_LOGO_SCALE, cut_border_bg=True) # "RAUSER"-Box
|
|
r2 = assets.pcc_surface_scaled('RAUSER2', scale=RAUSER_LOGO_SCALE, cut_border_bg=True) # "ADVERTAINMENT"
|
|
excl = assets.pcc_surface_scaled('RAUSER3', scale=RAUSER_LOGO_SCALE, cut_border_bg=True) # nur das nackte "!"
|
|
|
|
# Tuerkis-Kaestchen hinter dem "!" nachbauen (siehe RAUSER_TEAL -- im
|
|
# Original von der Engine gezeichnet, in keinem Asset). Gegen die DOSBox-
|
|
# Referenz vermessen: ~gleich hoch wie die RAUSER-Box, ~0.85x so breit wie
|
|
# hoch, das "!" ragt oben aus dem Kaestchen heraus.
|
|
tb_h = r1.get_height()
|
|
tb_w = max(excl.get_width() + 6, round(tb_h * 0.85))
|
|
overhang = max(0, excl.get_height() - tb_h)
|
|
r3 = pygame.Surface((tb_w, tb_h + overhang), pygame.SRCALPHA)
|
|
pygame.draw.rect(r3, RAUSER_TEAL, (0, overhang, tb_w, tb_h))
|
|
r3.blit(excl, ((tb_w - excl.get_width()) // 2, 0)) # "!" mittig, ragt oben raus
|
|
|
|
total_w = max(r1.get_width() + r3.get_width(), r2.get_width())
|
|
total_h = r1.get_height() + r2.get_height()
|
|
x0 = (WINDOW_W - total_w) // 2
|
|
y0 = (WINDOW_H - total_h) // 2
|
|
|
|
r1_final = (x0, y0)
|
|
r3_final = (x0 + r1.get_width(), y0 - overhang) # teal-Teil oben buendig mit der RAUSER-Box
|
|
r2_final = (x0, y0 + r1.get_height())
|
|
return {
|
|
'r1': r1, 'r2': r2, 'r3': r3,
|
|
'r1_final': r1_final, 'r2_final': r2_final, 'r3_final': r3_final,
|
|
}
|
|
|
|
|
|
def render_rauser_slide(canvas, parts, slide_t):
|
|
'''slide_t: 0..1. r1 faehrt von links, r3 von oben ein; r2 steht fest.
|
|
Bei slide_t>=1 sind alle Teile an ihrer Endposition.'''
|
|
r1, r2, r3 = parts['r1'], parts['r2'], parts['r3']
|
|
ease = slide_t * slide_t * (3 - 2 * slide_t) # smoothstep, wirkt weniger linear/robotisch
|
|
|
|
r2_final = parts['r2_final']
|
|
canvas.blit(r2, r2_final)
|
|
|
|
r1_final_x, r1_final_y = parts['r1_final']
|
|
r1_start_x = -r1.get_width()
|
|
r1_x = int(r1_start_x + (r1_final_x - r1_start_x) * ease)
|
|
canvas.blit(r1, (r1_x, r1_final_y))
|
|
|
|
r3_final_x, r3_final_y = parts['r3_final']
|
|
r3_start_y = -r3.get_height()
|
|
r3_y = int(r3_start_y + (r3_final_y - r3_start_y) * ease)
|
|
canvas.blit(r3, (r3_final_x, r3_y))
|
|
|
|
|
|
def _draw_rauser(parts, bg_color, slide_t):
|
|
'''Zeichnet einen Rauser-Frame direkt in Fensteraufloesung: Hintergrund
|
|
fuellen, dann die (transparent-hinterlegten) Logo-Teile an ihrer Slide-
|
|
Position druebersetzen. Zielt immer auf die aktuelle Display-Surface
|
|
(get_surface), damit ein Vollbild-Umschalten nichts kaputt macht.'''
|
|
screen = pygame.display.get_surface()
|
|
screen.fill(bg_color)
|
|
render_rauser_slide(screen, parts, slide_t)
|
|
|
|
|
|
def scene_rauser(clock, assets, audio_paths):
|
|
'''Rauser-Karte: weisser Grund, Slide-in, Sound-Sting bei Ankunft, Fade zu
|
|
grauem Grund, kurz halten. Rueckgabe 'quit'/'skip'/None (siehe wait_or_skip).
|
|
Wird jetzt DIREKT in Fensteraufloesung gerendert (kein 320er-Zwischen-
|
|
canvas + Upscale mehr) -- das war die Ursache des Rauser-Aufloesungs-
|
|
Problems (siehe RAUSER_LOGO_SCALE-Kommentar).
|
|
audio_paths kommt aus preload_all_audio() -- das Audio wird hier nur noch
|
|
aus fertigen WAV-Pfaden geladen (nie live gerendert, siehe Hang-Fix).'''
|
|
parts = build_rauser_parts(assets)
|
|
sting = None
|
|
# TITEL2 Song 0 -- der kuerzere der beiden TITEL2-Songs (~2.04s), Song 1
|
|
# ist der Factor5-Sound (siehe Docstring).
|
|
sting_path = audio_paths.get(('TITEL2', 0))
|
|
if pygame.mixer.get_init() and sting_path:
|
|
try:
|
|
sting = pygame.mixer.Sound(sting_path)
|
|
except Exception as exc:
|
|
print(f'[intro] Rauser-Sound-Sting konnte nicht geladen werden: {exc}', file=sys.stderr)
|
|
|
|
sting_played = [False]
|
|
|
|
def show_slide(t):
|
|
_draw_rauser(parts, RAUSER_WHITE, t)
|
|
if t >= 1.0 and not sting_played[0]:
|
|
sting_played[0] = True
|
|
if sting is not None:
|
|
sting.play()
|
|
r = wait_or_skip(clock, RAUSER_SLIDE_SECONDS, show_slide)
|
|
if r:
|
|
return r
|
|
|
|
# falls die Slide-Phase zu kurz war um den Sting zu triggern
|
|
if not sting_played[0] and sting is not None:
|
|
sting.play()
|
|
sting_played[0] = True
|
|
|
|
r = wait_or_skip(clock, RAUSER_HOLD_SECONDS,
|
|
lambda _t: _draw_rauser(parts, RAUSER_WHITE, 1.0))
|
|
if r:
|
|
return r
|
|
|
|
def show_fade_grey(t):
|
|
bg = tuple(int(RAUSER_WHITE[i] + (RAUSER_GREY[i] - RAUSER_WHITE[i]) * t) for i in range(3))
|
|
_draw_rauser(parts, bg, 1.0)
|
|
r = wait_or_skip(clock, RAUSER_FADE_TO_GREY_SECONDS, show_fade_grey)
|
|
if r:
|
|
return r
|
|
|
|
return wait_or_skip(clock, RAUSER_GREY_HOLD_SECONDS,
|
|
lambda _t: _draw_rauser(parts, RAUSER_GREY, 1.0))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Titelbild: 2x2-Raster aus halbskalierten Quadranten + Scanline-Reveal
|
|
# (siehe Docstring-Korrektur -- NICHT 320x480!)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def build_title_quadrants(assets):
|
|
'''Liefert die 4 Titelbild-Quadranten in NATIVER Aufloesung (je 160x240)
|
|
-- A/B oben, C/D unten -> zusammen ein 320x480-Bild. KORREKTUR 24.07.2026:
|
|
frueher wurden die Quadranten auf halbe Hoehe (160x120) gestaucht; ein
|
|
Vergleich gegen die echte DOSBox-Referenz (RMSE 0.014 voll vs. 0.083
|
|
halbiert) zeigt aber, dass das Original das Bild in voller Hoehe fuehrt und
|
|
nur ANAMORPH (doppelt hohe Pixel) auf 4:3 anzeigt. Die native Hoehe bleibt
|
|
also erhalten und wird erst beim Anzeigen gestreckt (siehe _blit_title).'''
|
|
return {
|
|
'A': assets.pcc_surface('KELL256A'),
|
|
'B': assets.pcc_surface('KELL256B'),
|
|
'C': assets.pcc_surface('KELL256C'),
|
|
'D': assets.pcc_surface('KELL256D'),
|
|
}
|
|
|
|
|
|
def build_title_image(quads):
|
|
'''Komplettes, ruhendes Titelbild (320x480) aus den 4 nativen Quadranten im
|
|
2x2-Raster (wird beim Anzeigen anamorph auf 4:3 gestreckt, siehe
|
|
_blit_title).'''
|
|
a, b, c, d = quads['A'], quads['B'], quads['C'], quads['D']
|
|
w = a.get_width() + b.get_width()
|
|
h = a.get_height() + c.get_height()
|
|
canvas = pygame.Surface((w, h))
|
|
canvas.blit(a, (0, 0))
|
|
canvas.blit(b, (a.get_width(), 0))
|
|
canvas.blit(c, (0, a.get_height()))
|
|
canvas.blit(d, (a.get_width(), a.get_height()))
|
|
return canvas
|
|
|
|
|
|
def _scanline_clear_from_middle(surface, clear_progress):
|
|
'''KORREKTUR (Stefan 24.07.2026 -- gegen echte DOSBox-Referenz gemessen):
|
|
KEINE Entsaettigung/kein Grau mehr (das "gray raus"), nur noch Scanlines
|
|
(jede zweite Zeile abgedunkelt). Die Scanlines loesen sich von der MITTE
|
|
des Titelbilds aus auf -- `surface` ist die untere Bildhaelfte, ihre
|
|
Zeile 0 liegt genau an der Bildmitte. clear_progress 0..1: der Anteil der
|
|
Zeilen (von oben=Mitte aus) der schon scanline-frei ist. 0 = komplett
|
|
scanlined, 1 = komplett sauber. Die Scanline-Grenze wandert also von der
|
|
Mitte nach unten weg. Braucht numpy; ohne numpy bleibt das Bild sauber
|
|
(Effekt entfaellt) statt abzustuerzen.'''
|
|
if not HAVE_NUMPY:
|
|
return surface
|
|
arr = pygame.surfarray.array3d(surface).astype(np.float32) # (w, h, 3)
|
|
h = arr.shape[1]
|
|
scan = np.ones((h,), dtype=np.float32)
|
|
scan[1::2] = 0.5 # jede zweite Zeile abgedunkelt (kein Grau, nur dunkler)
|
|
clear_rows = int(round(h * max(0.0, min(1.0, clear_progress))))
|
|
if clear_rows > 0:
|
|
scan[:clear_rows] = 1.0 # obere (mittennahe) Zeilen bereits scanline-frei
|
|
out = np.clip(arr * scan[np.newaxis, :, np.newaxis], 0, 255).astype(np.uint8)
|
|
return pygame.surfarray.make_surface(out)
|
|
|
|
|
|
def render_title_wipe_frame(quads, t):
|
|
'''t: 0..1 ueber die gesamte Wipe-Dauer. Erste TITLE_HEIGHT_REVEAL_FRACTION:
|
|
Vorhang-Reveal (obere Haelfte waechst 0->120 von oben, untere Haelfte
|
|
0->120 von unten, treffen sich in der Mitte). Danach: die Scanlines auf der
|
|
unteren Haelfte loesen sich von der Mitte nach unten auf. Obere Haelfte ist
|
|
durchgehend volle Farbe ohne Scanlines (wie in der DOSBox-Referenz).'''
|
|
a, b, c, d = quads['A'], quads['B'], quads['C'], quads['D']
|
|
half_h = a.get_height() # 240 (native Quadrantenhoehe)
|
|
full_w = a.get_width() + b.get_width() # 320
|
|
|
|
canvas = pygame.Surface((full_w, half_h * 2)) # 320x480
|
|
canvas.fill((0, 0, 0))
|
|
|
|
if t < TITLE_HEIGHT_REVEAL_FRACTION:
|
|
reveal = t / TITLE_HEIGHT_REVEAL_FRACTION
|
|
h = max(0, int(half_h * reveal))
|
|
clear_progress = 0.0 # waehrend des Reveals noch komplett scanlined
|
|
else:
|
|
h = half_h
|
|
clear_progress = (t - TITLE_HEIGHT_REVEAL_FRACTION) / (1 - TITLE_HEIGHT_REVEAL_FRACTION)
|
|
|
|
if h > 0:
|
|
# obere Haelfte (A|B): volle Farbe, waechst von oben nach unten
|
|
top_row = pygame.Surface((full_w, half_h))
|
|
top_row.blit(a, (0, 0))
|
|
top_row.blit(b, (a.get_width(), 0))
|
|
canvas.blit(top_row.subsurface((0, 0, full_w, h)), (0, 0))
|
|
|
|
# untere Haelfte (C|D): Scanlines, loesen sich von der Mitte (=Zeile 0
|
|
# dieser Haelfte) nach unten auf; waechst von unten nach oben rein.
|
|
bot_row = pygame.Surface((full_w, half_h))
|
|
bot_row.blit(c, (0, 0))
|
|
bot_row.blit(d, (a.get_width(), 0))
|
|
bot_scan = _scanline_clear_from_middle(bot_row, clear_progress)
|
|
bot_slice = bot_scan.subsurface((0, half_h - h, full_w, h))
|
|
canvas.blit(bot_slice, (0, half_h * 2 - h))
|
|
|
|
return canvas
|
|
|
|
|
|
def scene_pause(clock, seconds):
|
|
'''Kurze schwarze Pause zwischen zwei Screens.'''
|
|
return wait_or_skip(clock, seconds, lambda _t: pygame.display.get_surface().fill(BG_COLOR))
|
|
|
|
|
|
def scene_factor5(clock, assets, audio_paths):
|
|
'''Factor5-Studio-Logo (statisches Bild) + TITEL2-Song 1 ("Brausen").
|
|
KORREKTUR (Stefan 24.07.2026): der Screen wird EXAKT so lange gezeigt wie
|
|
der Sound spielt -- ist er zu Ende, geht es zu Kellogg's weiter. Ohne
|
|
Audio-Geraet (headless) faellt es auf FACTOR5_FALLBACK_SECONDS zurueck.'''
|
|
factor5 = build_letterboxed(assets.pcc_surface('FACTOR5'))
|
|
hold = FACTOR5_FALLBACK_SECONDS
|
|
factor5_path = audio_paths.get(('TITEL2', 1))
|
|
if pygame.mixer.get_init() and factor5_path:
|
|
try:
|
|
snd = pygame.mixer.Sound(factor5_path)
|
|
# Screen exakt so lange wie der Sound -- aber mit Sicherheits-
|
|
# Obergrenze, damit ein evtl. fehlerhaft langes WAV das Intro nicht
|
|
# minutenlang einfriert (siehe FACTOR5_MAX_SECONDS).
|
|
hold = min(snd.get_length(), FACTOR5_MAX_SECONDS)
|
|
snd.play()
|
|
except Exception as exc:
|
|
print(f'[intro] Factor5-Sound konnte nicht geladen werden: {exc}', file=sys.stderr)
|
|
return wait_or_skip(clock, hold, lambda _t: _blit_centered(factor5))
|
|
|
|
|
|
def scene_kelloggs(clock, assets, audio_paths):
|
|
'''Kellogg's-Markenlogo; darunter faedet "praesentiert" per Palettenfade
|
|
ein. Hier setzt auch die Titelmusik (TITEL Song 0) geloopt ein und laeuft
|
|
dann bis zum Ende des Programms durch (siehe scene_title).'''
|
|
titel_path = audio_paths.get(('TITEL', None))
|
|
if pygame.mixer.get_init() and titel_path:
|
|
try:
|
|
pygame.mixer.music.load(titel_path)
|
|
pygame.mixer.music.play(-1)
|
|
except Exception as exc:
|
|
print(f'[intro] Titelmusik konnte nicht geladen werden: {exc}', file=sys.stderr)
|
|
|
|
kw, kh, kidx, kpal = assets.pcc('KELLOGGS')
|
|
|
|
def show_kelloggs(t):
|
|
frame = build_kelloggs_frame(kw, kh, kidx, kpal, t)
|
|
_blit_centered(build_letterboxed(frame))
|
|
r = wait_or_skip(clock, PRAESENTIERT_FADE_SECONDS, show_kelloggs)
|
|
if r:
|
|
return r
|
|
# kurz mit vollem Text stehen lassen
|
|
return wait_or_skip(clock, 1.0, lambda _t: show_kelloggs(1.0))
|
|
|
|
|
|
def scene_title_reveal(clock, assets):
|
|
'''Titelbild-Animation: 2x2-Quadranten-Reveal, Scanlines loesen sich von
|
|
der Mitte auf. Gibt ('quit'|None, title_img) zurueck -- 'skip' waehrend
|
|
der Wipe springt direkt zum fertigen Bild.'''
|
|
quads = build_title_quadrants(assets)
|
|
r = wait_or_skip(clock, TITLE_WIPE_SECONDS,
|
|
lambda t: _blit_title(render_title_wipe_frame(quads, t)))
|
|
title_img = build_title_image(quads)
|
|
return ('quit' if r == 'quit' else None), title_img
|
|
|
|
|
|
def hold_title(clock, title_img):
|
|
'''Fertiges Titelbild stehen lassen (Titelmusik loopt), bis eine Taste
|
|
kommt. Rueckgabe 'quit' (Fenster-X) oder 'key' (ESC/Leertaste -> der
|
|
Aufrufer oeffnet das Hauptmenue, wie im Original ESC).'''
|
|
return wait_for_key(clock, lambda _t: _blit_title(title_img))
|
|
|
|
|
|
def run_intro(clock, assets, audio_paths):
|
|
'''Spielt die Intro-Sequenz bis einschliesslich Titelbild-Animation EINMAL
|
|
ab. LEERTASTE ueberspringt den jeweils aktuellen Screen, Fenster-X/ESC
|
|
bricht ab. Rueckgabe ('quit'|None, title_img) -- title_img ist das fertige
|
|
Titelbild fuer die anschliessende Halte-Phase.'''
|
|
scenes = [
|
|
lambda: scene_rauser(clock, assets, audio_paths),
|
|
lambda: scene_pause(clock, 0.5),
|
|
lambda: scene_factor5(clock, assets, audio_paths),
|
|
lambda: scene_pause(clock, 0.5),
|
|
lambda: scene_kelloggs(clock, assets, audio_paths),
|
|
lambda: scene_pause(clock, 0.5),
|
|
]
|
|
for scene in scenes:
|
|
r = scene()
|
|
if r == 'quit':
|
|
return 'quit', None
|
|
if r == 'skip' and pygame.mixer.get_init():
|
|
# beim Ueberspringen haengende Sound-Effekte (Sting/Factor5-Brausen)
|
|
# abschneiden -- pygame.mixer.stop() betrifft nur Sound-Kanaele, die
|
|
# geloopte Titelmusik (mixer.music) laeuft ungestoert weiter.
|
|
pygame.mixer.stop()
|
|
return scene_title_reveal(clock, assets)
|
|
|
|
|
|
def main():
|
|
global _fullscreen
|
|
once = '--once' in sys.argv
|
|
pygame.mixer.pre_init(44100, -16, 2, 512)
|
|
pygame.init()
|
|
try:
|
|
pygame.mixer.init()
|
|
except pygame.error as exc:
|
|
# Kein Audio-Device vorhanden (z.B. headless VM/Container ohne
|
|
# Soundkarte) -- Sequenz laeuft stumm weiter statt abzustuerzen.
|
|
print(f'[intro] Audio nicht verfuegbar, laeuft stumm: {exc}', file=sys.stderr)
|
|
|
|
# Vollbild-Zustand aus kelloggs.ini (Default Fenster). Alt+Enter schaltet zur
|
|
# Laufzeit um und speichert den neuen Zustand wieder dorthin.
|
|
_fullscreen = load_fullscreen_pref()
|
|
pygame.display.set_mode((WINDOW_W, WINDOW_H), _display_flags(_fullscreen))
|
|
pygame.display.set_caption("Tony & Friends in Kellogg's Land -- Intro (Remake)")
|
|
clock = pygame.time.Clock()
|
|
|
|
assets = Assets(DAT_PATH)
|
|
|
|
# Alle TFMX-Songs EINMALIG vor der eigentlichen Sequenz rendern (mit
|
|
# sichtbarem, responsivem Ladebildschirm) -- siehe render_audio_with_loading
|
|
# zum Hang-Bug, den das behebt. Danach greift die Sequenz nur noch auf
|
|
# fertige WAV-Pfade zu, nie mehr live-render im Hauptloop.
|
|
audio_paths, quit_requested = preload_all_audio(clock, assets)
|
|
|
|
# Ablauf (wie Original, per DOSBox-Referenz): Intro -> Titelbild steht
|
|
# (Musik loopt) -> Taste oeffnet das Hauptmenue -> START=Weltkarte,
|
|
# QUIT=Ende, ESC=zurueck zum Titelbild. --once: nach der Titelbild-
|
|
# Animation kurz halten und beenden (fuer Tests/headless).
|
|
if not quit_requested:
|
|
r, title_img = run_intro(clock, assets, audio_paths)
|
|
if r != 'quit' and title_img is not None:
|
|
if once:
|
|
wait_or_skip(clock, 3.0, lambda _t: _blit_title(title_img))
|
|
else:
|
|
from . import menu as menu_mod
|
|
from . import level as level_mod
|
|
from . import worldmap as worldmap_mod
|
|
m = menu_mod.MenuAssets(assets)
|
|
map_state = {}
|
|
pending_map = 'W1L1'
|
|
|
|
def play_title_music():
|
|
path = audio_paths.get(('TITEL', None))
|
|
if pygame.mixer.get_init() and path:
|
|
try:
|
|
pygame.mixer.music.load(path)
|
|
pygame.mixer.music.play(-1)
|
|
except Exception:
|
|
pass
|
|
|
|
state = 'title'
|
|
while state not in ('quit',):
|
|
if state == 'title':
|
|
state = 'menu' if hold_title(clock, title_img) == 'key' else 'quit'
|
|
elif state == 'menu':
|
|
r = menu_mod.run_menu(clock, m)
|
|
state = {'start': 'map', 'title': 'title'}.get(r, 'quit')
|
|
elif state == 'map':
|
|
r = worldmap_mod.run_worldmap(clock, assets, m, state=map_state)
|
|
if isinstance(r, tuple) and r[0] == 'level':
|
|
pending_map = r[1]
|
|
state = 'level'
|
|
elif r == 'menu':
|
|
state = 'menu'
|
|
else:
|
|
state = 'quit'
|
|
elif state == 'level':
|
|
r = level_mod.run_level(clock, assets, audio_paths,
|
|
map_name=pending_map)
|
|
if r == 'completed':
|
|
# Level geschafft: Kartenfigur laeuft zum
|
|
# naechsten Punkt weiter (Punkt/Spur werden blau)
|
|
wm = map_state.get('wm')
|
|
if wm is not None and wm.current < len(wm.segments) - 1:
|
|
wm.current += 1
|
|
play_title_music()
|
|
state = 'map'
|
|
elif r == 'map':
|
|
play_title_music() # Karte/Menue laufen mit Titelmusik
|
|
state = 'map'
|
|
else:
|
|
state = 'quit'
|
|
|
|
if pygame.mixer.get_init():
|
|
pygame.mixer.music.stop()
|
|
pygame.quit()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|