"""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.3) 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())) self._roll_channel = pygame.mixer.Channel(1) 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: if not self._roll_channel.get_busy(): 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() # ------------------------------------------------------------------ # 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())