Altstand nach old_version/ eingefroren (untracked). Monolith ersetzt durch main.py + Paket loader_game/. Neu: run.sh/run.command mit venv+requirements, .gitignore, docs/ANALYSE.md, 35 Tests. Secrets entfernt. Gelöscht: loader_game.py, qr_scan*.py, INSTALL_README.txt.

This commit is contained in:
Project Bridge committed 2026-11-01 10:25:13 +00:00
1 parent 6a2c891c3e
commit de550e1d4d
24 files changed
+2269 -1008

No files matched your search

+176
View File
@@ -0,0 +1,176 @@
"""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())