Files
Habegger_loader_game/loader_game/assets.py
T

190 lines
7.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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())