Intro: Endlosschleife raus, Alt+Enter-Vollbild, kelloggs.ini

- Keine Endlosschleife mehr: nach der Titelbild-Animation bleibt das fertige,
  voll-farbige Titelbild STEHEN und die Titelmusik loopt weiter, bis Leertaste
  oder ESC das Programm beendet (neue wait_for_key()). run_once() laeuft nur
  noch einmal; scene_title haelt am Ende auf Tastendruck (hold_forever), --once
  haelt stattdessen kurz (fuer headless/Tests).
- ALT+ENTER schaltet zwischen Fenster- und Vollbildmodus um (SCALED-Display
  skaliert das 960x720-Logikbild sauber hoch). Zentrale Event-Behandlung in
  _process_event(); Zeichenroutinen holen die aktuelle Surface per
  pygame.display.get_surface(), damit das Neuerzeugen des Display-Modes beim
  Umschalten keinen veralteten Screen-Bezug hinterlaesst (screen-Parameter aus
  den Scenes entfernt).
- Vollbild-Zustand wird in kelloggs.ini gemerkt (load/save_fullscreen_pref) und
  beim naechsten Start wiederhergestellt; ohne kelloggs.ini = Fenstermodus.
  kelloggs.ini ist gitignored (pro-Rechner-Laufzeitzustand).

Verifiziert (headless): Config load/save, Alt+Enter-Toggle, Space/ESC,
wait_for_key(Space)->quit; python -m game --once exit 0 in ~17s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
duffyduck
2026-07-24 01:49:47 +02:00
co-authored by Claude Opus 4.8
parent 7aea2fac26
commit 95e5b1edb4
3 changed files with 186 additions and 64 deletions
+3
View File
@@ -12,3 +12,6 @@ game/tfmx_player/*.o
# Python-Bytecode-Cache
__pycache__/
*.pyc
# Laufzeit-Einstellungen des Remakes (z.B. Vollbild-Zustand) -- pro Rechner
kelloggs.ini
+15 -5
View File
@@ -51,13 +51,23 @@ sudo apt install python3-pygame python3-pil python3-numpy gcc
## Starten
```
python3 -m game # Intro in Endlosschleife (wie Original-Titelbildschirm)
python3 -m game --once # ein Durchlauf, dann beenden
python3 -m game # Intro; bleibt am Titelbild stehen (Musik loopt)
python3 -m game --once # Titelbild nur kurz halten (für Tests/headless)
```
Steuerung im Intro: **Leertaste** überspringt den aktuellen Screen, **ESC**
beendet das Intro. Ohne Soundkarte (z.B. headless) läuft das Spiel stumm weiter
statt abzustürzen.
Ablauf: Rauser → Factor5 → Kellogg's → Titelbild-Animation; danach bleibt das
fertige Titelbild stehen und die Titelmusik läuft in Schleife, bis eine Taste
das Programm beendet.
Steuerung:
- **Leertaste** — überspringt den aktuellen Intro-Screen (auf dem Titelbild
beendet sie das Programm)
- **ESC** — beendet das Programm
- **Alt+Enter** — zwischen Fenster- und Vollbildmodus umschalten; der Zustand
wird in `kelloggs.ini` gemerkt und beim nächsten Start wiederhergestellt
(Standard ohne `kelloggs.ini`: Fenstermodus)
Ohne Soundkarte (z.B. headless) läuft das Spiel stumm weiter statt abzustürzen.
Liegt `PCKELL.DAT` woanders, kann der Pfad per Umgebungsvariable gesetzt werden:
+168 -59
View File
@@ -105,9 +105,14 @@ 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] (--once: nach einem Durchlauf beenden,
sonst loopt die Sequenz weiter wie im Original-Titelbildschirm)
Steuerung: LEERTASTE ueberspringt den aktuellen Screen, ESC beendet das Intro.
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
@@ -121,6 +126,7 @@ import sys
import os
import time
import threading
import configparser
from collections import deque
from .formats import DATContainer, load_pcc
@@ -140,6 +146,80 @@ DAT_PATH = os.environ.get('KELLOGG_DAT', os.path.join(PROJECT_ROOT, 'game', 'ori
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)
# "praesentiert"-Text-Faelle in KELLOGGS.PCC (siehe Docstring)
PRAESENTIERT_FADE_INDICES = [34, 42, 43, 44, 45]
PRAESENTIERT_FADE_SECONDS = 2.5
@@ -356,19 +436,30 @@ def wait_or_skip(clock, seconds, screen_update_fn, fps=30):
pygame.display.flip()
return None
for event in pygame.event.get():
if event.type == pygame.QUIT:
return 'quit'
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
return 'quit'
if event.key == pygame.K_SPACE:
return 'skip'
r = _process_event(event)
if r:
return r
screen_update_fn(t)
pygame.display.flip()
clock.tick(fps)
def render_audio_with_loading(screen, clock, container, name, song_index, label):
def wait_for_key(clock, draw_fn, fps=30):
'''Zeigt das Standbild (draw_fn(1.0)) und wartet UNENDLICH, bis der User
Leertaste oder ESC drueckt (oder das Fenster schliesst). Alt+Enter schaltet
dabei weiter Vollbild um. Die geloopte Titelmusik laeuft die ganze Zeit
weiter. Gibt immer 'quit' zurueck -- danach ist das Programm zu Ende.'''
while True:
for event in pygame.event.get():
r = _process_event(event)
if r in ('quit', 'skip'): # auf dem letzten Screen beendet beides
return 'quit'
draw_fn(1.0)
pygame.display.flip()
clock.tick(fps)
def render_audio_with_loading(clock, container, name, song_index, label):
'''Rendert einen TFMX-Song per tfmx_audio.render_wav in einem Hintergrund-
Thread, waehrend der Hauptthread weiter Events pumpt und einen simplen
Ladebildschirm zeichnet.
@@ -418,12 +509,16 @@ def render_audio_with_loading(screen, clock, container, name, song_index, label)
for event in pygame.event.get():
if event.type == pygame.QUIT:
return None, True
if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
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))
@@ -433,12 +528,12 @@ def render_audio_with_loading(screen, clock, container, name, song_index, label)
thread.join()
if 'error' in result:
print(f'[intro_sequence] {label} konnte nicht gerendert werden: {result["error"]}', file=sys.stderr)
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(screen, clock, assets):
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
@@ -455,7 +550,7 @@ def preload_all_audio(screen, clock, assets):
audio_paths = {}
for key, label in jobs:
name, song_index = key
path, quit_requested = render_audio_with_loading(screen, clock, assets.container, name, song_index, label)
path, quit_requested = render_audio_with_loading(clock, assets.container, name, song_index, label)
audio_paths[key] = path
if quit_requested:
return audio_paths, True
@@ -557,15 +652,17 @@ def render_rauser_slide(canvas, parts, slide_t):
canvas.blit(r3, (r3_final_x, r3_y))
def _draw_rauser(screen, parts, bg_color, slide_t):
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.'''
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(screen, clock, assets, audio_paths):
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-
@@ -587,7 +684,7 @@ def scene_rauser(screen, clock, assets, audio_paths):
sting_played = [False]
def show_slide(t):
_draw_rauser(screen, parts, RAUSER_WHITE, 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:
@@ -602,19 +699,19 @@ def scene_rauser(screen, clock, assets, audio_paths):
sting_played[0] = True
r = wait_or_skip(clock, RAUSER_HOLD_SECONDS,
lambda _t: _draw_rauser(screen, parts, RAUSER_WHITE, 1.0))
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(screen, parts, bg, 1.0)
_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(screen, parts, RAUSER_GREY, 1.0))
lambda _t: _draw_rauser(parts, RAUSER_GREY, 1.0))
# ---------------------------------------------------------------------------
@@ -711,12 +808,12 @@ def render_title_wipe_frame(quads, t):
return canvas
def scene_pause(screen, clock, seconds):
def scene_pause(clock, seconds):
'''Kurze schwarze Pause zwischen zwei Screens.'''
return wait_or_skip(clock, seconds, lambda _t: screen.fill(BG_COLOR))
return wait_or_skip(clock, seconds, lambda _t: pygame.display.get_surface().fill(BG_COLOR))
def scene_factor5(screen, clock, assets, audio_paths):
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
@@ -734,13 +831,13 @@ def scene_factor5(screen, clock, assets, audio_paths):
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_scaled_centered(screen, factor5, WINDOW_W, WINDOW_H))
return wait_or_skip(clock, hold, lambda _t: _blit_centered(factor5))
def scene_kelloggs(screen, clock, assets, audio_paths):
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.'''
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:
@@ -753,7 +850,7 @@ def scene_kelloggs(screen, clock, assets, audio_paths):
def show_kelloggs(t):
frame = build_kelloggs_frame(kw, kh, kidx, kpal, t)
blit_scaled_centered(screen, build_letterboxed(frame), WINDOW_W, WINDOW_H)
_blit_centered(build_letterboxed(frame))
r = wait_or_skip(clock, PRAESENTIERT_FADE_SECONDS, show_kelloggs)
if r:
return r
@@ -761,33 +858,40 @@ def scene_kelloggs(screen, clock, assets, audio_paths):
return wait_or_skip(clock, 1.0, lambda _t: show_kelloggs(1.0))
def scene_title(screen, clock, assets):
def scene_title(clock, assets, hold_forever=True):
'''Titelbild -- 2x2-Quadranten-Reveal, Scanlines loesen sich von der Mitte
auf (siehe render_title_wipe_frame), danach das fertige Bild halten.'''
auf. Danach bleibt das fertige, voll-farbige Titelbild STEHEN (die
Titelmusik loopt weiter), bis der User Leertaste/ESC drueckt -- dann endet
das Programm. hold_forever=False (fuer --once/Tests) haelt stattdessen nur
kurz und kehrt zurueck, statt auf eine Taste zu warten.'''
quads = build_title_quadrants(assets)
r = wait_or_skip(clock, TITLE_WIPE_SECONDS,
lambda t: blit_scaled_centered(screen, render_title_wipe_frame(quads, t), WINDOW_W, WINDOW_H))
if r:
return r
lambda t: _blit_centered(render_title_wipe_frame(quads, t)))
if r == 'quit':
return 'quit'
# 'skip' waehrend der Wipe-Animation -> direkt zum fertigen Titelbild
title_img = build_title_image(quads)
return wait_or_skip(clock, 3.0,
lambda _t: blit_scaled_centered(screen, title_img, WINDOW_W, WINDOW_H))
draw = lambda _t: _blit_centered(title_img)
if hold_forever:
return wait_for_key(clock, draw)
return wait_or_skip(clock, 3.0, draw)
def run_once(screen, clock, assets, audio_paths):
'''Spielt die komplette Intro-Sequenz einmal ab. LEERTASTE ueberspringt den
jeweils aktuellen Screen (die Scene liefert dann 'skip' -> es geht sofort
zur naechsten Scene), ESC/Fenster-X beendet das Intro komplett. Gibt True
zurueck, wenn der User ganz beenden will (dann bricht auch die
Endlosschleife in main() ab).'''
def run_once(clock, assets, audio_paths, hold_title=True):
'''Spielt die komplette Intro-Sequenz EINMAL ab (keine Endlosschleife mehr).
LEERTASTE ueberspringt den jeweils aktuellen Screen (die Scene liefert dann
'skip' -> naechste Scene), ESC/Fenster-X beendet das Intro. Der letzte
Screen (Titelbild) bleibt bei hold_title=True stehen und die Titelmusik
loopt, bis Leertaste/ESC das Programm beendet. hold_title=False (--once)
haelt das Titelbild nur kurz -- fuer automatisierte/headless Laeufe.'''
scenes = [
lambda: scene_rauser(screen, clock, assets, audio_paths),
lambda: scene_pause(screen, clock, 0.5),
lambda: scene_factor5(screen, clock, assets, audio_paths),
lambda: scene_pause(screen, clock, 0.5),
lambda: scene_kelloggs(screen, clock, assets, audio_paths),
lambda: scene_pause(screen, clock, 0.5),
lambda: scene_title(screen, clock, assets),
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),
lambda: scene_title(clock, assets, hold_forever=hold_title),
]
for scene in scenes:
r = scene()
@@ -802,6 +906,7 @@ def run_once(screen, clock, assets, audio_paths):
def main():
global _fullscreen
once = '--once' in sys.argv
pygame.mixer.pre_init(44100, -16, 2, 512)
pygame.init()
@@ -810,8 +915,12 @@ def main():
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))
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()
@@ -819,14 +928,14 @@ def main():
# 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 greifen alle run_once()-Durchlaeufe
# nur noch auf fertige WAV-Pfade zu, nie mehr live-render im Hauptloop.
audio_paths, quit_requested = preload_all_audio(screen, clock, assets)
# 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)
# EIN Durchlauf (keine Endlosschleife): endet am stehenden Titelbild, das
# bis Leertaste/ESC gehalten wird (Titelmusik loopt). --once haelt nur kurz.
if not quit_requested:
quit_requested = run_once(screen, clock, assets, audio_paths)
while not quit_requested and not once:
quit_requested = run_once(screen, clock, assets, audio_paths)
run_once(clock, assets, audio_paths, hold_title=not once)
if pygame.mixer.get_init():
pygame.mixer.music.stop()