Projektstruktur: game/-Package angelegt, README ergaenzt
Laufzeit-Code vom Dev-Werkzeug getrennt: - game/: das eigentliche Spiel, startbar per `python -m game [--once]`. - formats.py (war tools/kellogg_formats.py) -- Asset-Parser, Kern-Bibliothek - audio.py (war tools/tfmx_audio.py) -- TFMX-Musik-Wrapper - intro.py (war tools/intro_sequence.py) -- Boot-/Intro-Sequenz - tfmx_player/(war tools/tfmx_player/) -- MIT-C-Renderer - __init__.py / __main__.py fuer `python -m game` - tools/: nur noch Dev-Werkzeuge (dat_extract, pcc_to_png, render_assets), importieren jetzt aus game.formats. - README.md: Ueberblick, Start, Struktur, Voraussetzungen, Rechtliches. - .gitignore: tfmx_player-Binary-Pfad auf game/ nachgezogen. Intro laeuft unveraendert (headless-Smoke-Test `python -m game --once` exit 0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
3fe92d01b8
commit
85404c91f4
+770
@@ -0,0 +1,770 @@
|
||||
#!/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, werden aber 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. 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. Waehrend dieser Reveal-Phase ist die untere Haelfte
|
||||
erst graustichig/entsaettigt MIT Scanlines ueberzogen, die sich danach in
|
||||
einer zweiten Phase zu vollen Farben aufloesen (Stefans "Screenshot
|
||||
4/5/6"-Effekt). HINWEIS: diese Quadranten nutzen denselben
|
||||
Downscale-Mechanismus (smoothscale halbe Hoehe) wie die Rauser-Assets
|
||||
vor dem obigen Fix -- hier bisher KEINE Bildschaerfe-Beschwerde von
|
||||
Stefan, deshalb unangetastet gelassen. Falls das Titelbild auch mal als
|
||||
"verwaschen" gemeldet wird: gleicher Fix-Ansatz (native Groesse zeichnen,
|
||||
nur einmal ganzzahlig hochskalieren) anwendbar.
|
||||
|
||||
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] (--once: nach einem Durchlauf beenden,
|
||||
sonst loopt die Sequenz weiter wie im Original-Titelbildschirm)
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
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, '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
|
||||
|
||||
# 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
|
||||
|
||||
# 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)
|
||||
|
||||
# Factor5-Timing (siehe Docstring-Korrektur -- Stefans Meldung 22.07.2026
|
||||
# abends): feste Anzeigedauer statt (falsch) an eine 123s-Melodie gekoppelt.
|
||||
FACTOR5_HOLD_SECONDS = 5.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 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 ganz oben). Nearest-Neighbor (pygame.transform.scale)
|
||||
statt smoothscale aus dem gleichen Grund wie bei den Rauser-Assets
|
||||
(siehe pcc_surface_scaled) -- konsistent scharf.'''
|
||||
surf = self.pcc_surface(name)
|
||||
w, h = surf.get_size()
|
||||
new_size = (w, max(1, round(h * 0.5)))
|
||||
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.
|
||||
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 render_audio_with_loading(screen, 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.
|
||||
|
||||
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)
|
||||
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 and event.key == pygame.K_ESCAPE:
|
||||
return None, True
|
||||
now = time.monotonic()
|
||||
if now - last_dot_tick > 0.4:
|
||||
dots = (dots + 1) % 4
|
||||
last_dot_tick = now
|
||||
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_sequence] {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):
|
||||
'''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
|
||||
jobs = [
|
||||
(('TITEL2', 0), 'Rauser-Sound'),
|
||||
(('TITEL2', 1), 'Factor5-Sound'),
|
||||
(('TITEL', None), 'Titelmusik'),
|
||||
]
|
||||
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)
|
||||
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 (bei 0.5x Nearest-Neighbor-Groesse, siehe Docstring-
|
||||
Korrektur v3) Rauser-Teile + ihre Endpositionen auf einer 320x240-
|
||||
Leinwand (RAUSER1 links, RAUSER3 rechts daneben, RAUSER2 darunter
|
||||
mittig).'''
|
||||
# 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, audio_paths):
|
||||
'''Rauser-Karte: weisser Grund, Slide-in, Sound-Sting bei Ankunft, Fade zu
|
||||
grauem Grund, kurz halten. Gibt True zurueck wenn der User abbrechen will.
|
||||
audio_paths kommt aus preload_all_audio() -- das Audio wird hier NICHT
|
||||
mehr live gerendert (siehe render_audio_with_loading-Docstring zum
|
||||
Hang-Bug, den das behebt), sondern nur noch aus dem fertigen WAV-Pfad
|
||||
geladen (schnell, nie blockierend).'''
|
||||
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_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 halber Hoehe (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, audio_paths):
|
||||
quit_requested = run_rauser(screen, clock, assets, audio_paths)
|
||||
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). KORREKTUR (22.07.2026 abends, siehe
|
||||
# Docstring): NICHT mehr ONGAME2 (das war falsch zugeordnete Level-
|
||||
# Musik), sondern TITEL2-Song 1 -- ein kurzer "Brausen"-Sound (~2.4s),
|
||||
# der zur Beschreibung "nur ein bisschen laenger als der Rauser-Sound"
|
||||
# passt. Screen-Dauer ist jetzt FEST (~5s), nicht mehr an die (falsche)
|
||||
# 123s-Songlaenge gekoppelt.
|
||||
factor5 = build_letterboxed(assets.pcc_surface('FACTOR5'))
|
||||
factor5_path = audio_paths.get(('TITEL2', 1))
|
||||
if pygame.mixer.get_init() and factor5_path:
|
||||
try:
|
||||
factor5_sound = pygame.mixer.Sound(factor5_path)
|
||||
factor5_sound.play()
|
||||
except Exception as exc:
|
||||
print(f'[intro_sequence] Factor5-Sound 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_HOLD_SECONDS, 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 Song 0, ~109s) 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).
|
||||
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_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)
|
||||
|
||||
# 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)
|
||||
|
||||
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)
|
||||
|
||||
if pygame.mixer.get_init():
|
||||
pygame.mixer.music.stop()
|
||||
pygame.quit()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user