190 lines
7.5 KiB
Python
190 lines
7.5 KiB
Python
"""Laden und Zwischenspeichern aller Bilder, Sounds und Fonts."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import array
|
||
import math
|
||
import re
|
||
|
||
import pygame
|
||
|
||
from . import config
|
||
|
||
_CASE_FILENAME = re.compile(r"^case_w(\d+)_h(\d+)\.png$")
|
||
|
||
|
||
class Assets:
|
||
"""Sammelstelle für alles, was aus Dateien kommt.
|
||
|
||
Muss **nach** ``pygame.init()`` und ``pygame.display.set_mode()``
|
||
instanziiert werden, weil ``convert_alpha()`` ein Display voraussetzt.
|
||
Fehlende Dateien führen nicht zum Absturz, sondern zu ``None`` – das
|
||
Spiel zeichnet dann einen farbigen Platzhalter bzw. bleibt stumm.
|
||
"""
|
||
|
||
def __init__(self) -> None:
|
||
self.font: pygame.font.Font | None = None
|
||
self.font_small: pygame.font.Font | None = None
|
||
self.background: pygame.Surface | None = None
|
||
self.instruction: pygame.Surface | None = None
|
||
self.sounds: dict[str, pygame.mixer.Sound] = {}
|
||
self._case_originals: dict[tuple[int, int], pygame.Surface] = {}
|
||
self._case_cache: dict[tuple[int, int], pygame.Surface] = {}
|
||
self._roll_channel: pygame.mixer.Channel | None = None
|
||
|
||
# ------------------------------------------------------------------
|
||
# Laden
|
||
# ------------------------------------------------------------------
|
||
def load(self) -> None:
|
||
self.font = self._load_font(config.FONT_SIZE)
|
||
self.font_small = self._load_font(config.FONT_SIZE_SMALL)
|
||
self.background = self._load_image(config.BACKGROUND_IMAGE, (config.SCREEN_WIDTH, config.GAME_AREA_HEIGHT))
|
||
self.instruction = self._load_image(config.TUTORIAL_IMAGE, (config.SCREEN_WIDTH, config.SCREEN_HEIGHT))
|
||
self._load_case_images()
|
||
self._load_sounds()
|
||
self._load_beeps()
|
||
|
||
def _load_font(self, size: int) -> pygame.font.Font:
|
||
try:
|
||
return pygame.font.Font(str(config.FONT_REGULAR), size)
|
||
except (OSError, pygame.error):
|
||
return pygame.font.SysFont(None, size)
|
||
|
||
@staticmethod
|
||
def _load_image(path, size: tuple[int, int] | None = None) -> pygame.Surface | None:
|
||
if not path.exists():
|
||
return None
|
||
try:
|
||
image = pygame.image.load(str(path))
|
||
image = image.convert_alpha() if image.get_alpha() is not None else image.convert()
|
||
except pygame.error:
|
||
return None
|
||
if size is not None:
|
||
image = pygame.transform.smoothscale(image, size)
|
||
return image
|
||
|
||
def _load_case_images(self) -> None:
|
||
if not config.IMAGES_DIR.is_dir():
|
||
return
|
||
for path in sorted(config.IMAGES_DIR.glob("case_w*_h*.png")):
|
||
match = _CASE_FILENAME.match(path.name)
|
||
if not match:
|
||
continue
|
||
key = (int(match.group(1)), int(match.group(2)))
|
||
try:
|
||
self._case_originals[key] = pygame.image.load(str(path)).convert_alpha()
|
||
except pygame.error:
|
||
continue
|
||
|
||
def _load_sounds(self) -> None:
|
||
if not pygame.mixer.get_init():
|
||
return
|
||
for name, path in config.SOUND_FILES.items():
|
||
if not path.exists():
|
||
continue
|
||
try:
|
||
self.sounds[name] = pygame.mixer.Sound(str(path))
|
||
except pygame.error:
|
||
continue
|
||
if "roll" in self.sounds:
|
||
self.sounds["roll"].set_volume(0.5)
|
||
for name in ("place", "fail"):
|
||
if name in self.sounds:
|
||
self.sounds[name].set_volume(0.6)
|
||
if "zip" in self.sounds:
|
||
self.sounds["zip"].set_volume(1.0)
|
||
pygame.mixer.set_num_channels(max(8, pygame.mixer.get_num_channels()))
|
||
# Kanal 0 bleibt für den Roll-Sound reserviert, damit ihn kein
|
||
# automatisch vergebener Einzelklang wegschnappt.
|
||
pygame.mixer.set_reserved(2)
|
||
self._roll_channel = pygame.mixer.Channel(0)
|
||
pygame.mixer.unpause()
|
||
|
||
def _load_beeps(self) -> None:
|
||
if not pygame.mixer.get_init():
|
||
return
|
||
for index, spec in enumerate(config.INTRO_BEEPS, start=1):
|
||
self.sounds[f"beep{index}"] = self._make_beep(**spec)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Koffer-Grafiken
|
||
# ------------------------------------------------------------------
|
||
def case_surface(self, units_w: int, units_h: int) -> pygame.Surface | None:
|
||
"""Skalierte Grafik für einen Koffer der Grösse ``units_w`` x ``units_h``.
|
||
|
||
Fehlt die passende Datei, wird die gespiegelte Variante um 90° gedreht.
|
||
Gibt ``None`` zurück, wenn es gar keine Grafik gibt.
|
||
"""
|
||
key = (units_w, units_h)
|
||
cached = self._case_cache.get(key)
|
||
if cached is not None:
|
||
return cached
|
||
|
||
size = (int(units_w * config.UNIT_LENGTH), int(units_h * config.UNIT_HEIGHT))
|
||
natural = self._case_originals.get(key)
|
||
surface: pygame.Surface | None
|
||
if natural is not None:
|
||
surface = pygame.transform.smoothscale(natural, size)
|
||
else:
|
||
mirrored_key = (units_h, units_w)
|
||
mirrored = self._case_originals.get(mirrored_key)
|
||
if mirrored is None:
|
||
return None
|
||
mirrored_size = (
|
||
int(units_h * config.UNIT_LENGTH),
|
||
int(units_w * config.UNIT_HEIGHT),
|
||
)
|
||
surface = pygame.transform.rotate(
|
||
pygame.transform.smoothscale(mirrored, mirrored_size), 90
|
||
)
|
||
self._case_cache[key] = surface
|
||
return surface
|
||
|
||
# ------------------------------------------------------------------
|
||
# Sound-Steuerung
|
||
# ------------------------------------------------------------------
|
||
def play(self, name: str, loops: int = 0) -> None:
|
||
sound = self.sounds.get(name)
|
||
if sound is None:
|
||
return
|
||
if name == "roll" and self._roll_channel is not None:
|
||
# Immer neu starten: ein evtl. noch laufender Roll (z. B. aus der
|
||
# vorherigen Runde) darf den neuen nicht blockieren.
|
||
self._roll_channel.play(sound, loops=loops)
|
||
return
|
||
sound.play(loops=loops)
|
||
|
||
def stop_roll(self) -> None:
|
||
if self._roll_channel is not None:
|
||
self._roll_channel.stop()
|
||
|
||
def stop_all(self) -> None:
|
||
if pygame.mixer.get_init():
|
||
pygame.mixer.stop()
|
||
|
||
def audio_info(self) -> str:
|
||
"""Kurzauskunft fürs Start-Log."""
|
||
init = pygame.mixer.get_init()
|
||
if not init:
|
||
return "Audio: nicht verfügbar (kein Ausgabegerät?)"
|
||
rate, _, channels = init
|
||
return f"Audio: {rate} Hz, {channels} Kanäle, {len(self.sounds)} Sounds geladen"
|
||
|
||
# ------------------------------------------------------------------
|
||
# Töne ohne Dateien (für das Intro)
|
||
# ------------------------------------------------------------------
|
||
@staticmethod
|
||
def _make_beep(frequency: int = 440, duration_ms: int = 500, volume: float = 0.5):
|
||
"""Erzeugt einen Sinuston als ``pygame.mixer.Sound`` (ohne numpy)."""
|
||
init = pygame.mixer.get_init()
|
||
if not init:
|
||
raise pygame.error("Mixer ist nicht initialisiert")
|
||
sample_rate, _, channels = init
|
||
sample_count = int(sample_rate * duration_ms / 1000.0)
|
||
buffer = array.array("h")
|
||
for index in range(sample_count):
|
||
value = int(math.sin(2 * math.pi * frequency * index / sample_rate) * volume * 32767)
|
||
for _ in range(channels):
|
||
buffer.append(value)
|
||
return pygame.mixer.Sound(buffer=buffer.tobytes())
|