"""Sound effects + background music. Clone-safe and entirely optional, the same way the tileset is: the mixer is initialised **lazily** on the first ``play``/``play_music``, every lookup degrades to a silent no-op when there is no audio device, no ``assets/audio`` tree or just a single missing file (mirrors the ``tileset.tile`` try/except → None pattern), and importing this module touches nothing — so the headless smoke tests keep working. Asset layout — drop files in, no code change needed: assets/audio/sfx/.wav # or .ogg assets/audio/music/.ogg # or .wav / .mp3 ``play("shoot")`` looks up ``assets/audio/sfx/shoot.wav``; a level's manifest ``"music"`` value is just such a ```` under music/. Symmetry with ``tileset``: callers pass *names*, not paths, so the audio asset convention lives only here. The names the game already triggers are listed in ``assets/levels/LEGEND.md``. """ import os import pygame # Flipped by the Settings menu (persisted via save.py). Read live by # every play call, so toggling it silences playback immediately. enabled = True # Music level (0.0..1.0). Independent of ``enabled``: muting flips # enabled, the volume slider is the bed level when sound is on. # Reapplied to ``pygame.mixer.music`` on every track change so a switch # never resets it back to 1.0. music_volume = 1.0 _SFX_DIR = os.path.join("assets", "audio", "sfx") _MUSIC_DIR = os.path.join("assets", "audio", "music") _SFX_EXTS = (".wav", ".ogg") _MUSIC_EXTS = (".ogg", ".wav", ".mp3") # One knob for every music transition (track switch, stop, mute). ~700 # ms reads as musical without dragging. ``pygame.mixer.music`` is a # single stream, so there is no true crossfade — we fade the old track # out and fade the new one in (the simple, fine-here option from the # Style.md notes). Reused by stop_music so muting fades too, not clicks. MUSIC_FADE_MS = 700 _mixer_ok = None # None = not tried yet, then True / False _sounds = {} # name -> Sound | None (None = file absent) _current_music = None # name of the looping track, or None def _ensure_mixer(): """Init the mixer once. Stays False forever if there is no audio device, which turns everything here into a no-op.""" global _mixer_ok if _mixer_ok is None: try: pygame.mixer.init() _mixer_ok = True except pygame.error: _mixer_ok = False return _mixer_ok def _find(directory, name, exts): for ext in exts: path = os.path.join(directory, name + ext) if os.path.isfile(path): return path return None def play(name): """Fire-and-forget one-shot SFX. Silent when disabled, when there is no device, or when ``assets/audio/sfx/.*`` is missing.""" if not enabled or not _ensure_mixer(): return if name not in _sounds: path = _find(_SFX_DIR, name, _SFX_EXTS) try: _sounds[name] = pygame.mixer.Sound(path) if path else None except pygame.error: _sounds[name] = None snd = _sounds[name] if snd is not None: snd.play() def play_music(name): """Loop ``assets/audio/music/.*`` as background music. ``name is None`` (the level declares no track) stops the music. Re-requesting the track that is already playing is a no-op, so a level reload / retry doesn't restart the loop.""" global _current_music if name is None: stop_music() return if not enabled or not _ensure_mixer(): return if name == _current_music: return path = _find(_MUSIC_DIR, name, _MUSIC_EXTS) if path is None: stop_music() # Remember the request even though the file is absent: main.py # calls play_music every frame, and without this _current_music # stays None so the unchanged-name guard above never engages — # a missing track would re-stat the FS and re-fade every frame. _current_music = name return try: # Fade the outgoing track, then bring the new one up. Single # stream → not a true crossfade; the incoming fade_ms is what # kills the hard cut on a start↔menu↔game switch. if pygame.mixer.music.get_busy(): pygame.mixer.music.fadeout(MUSIC_FADE_MS) pygame.mixer.music.load(path) pygame.mixer.music.set_volume(music_volume) pygame.mixer.music.play(-1, fade_ms=MUSIC_FADE_MS) _current_music = name except pygame.error: _current_music = None def stop_music(): global _current_music _current_music = None # Only touch the mixer if it actually came up — never spin it up # just to stop silence (keeps cold paths side-effect-free). if _mixer_ok: try: # Fade rather than stop() so a mute / leaving a level goes # quiet smoothly instead of clicking. pygame.mixer.music.fadeout(MUSIC_FADE_MS) except pygame.error: pass def set_enabled(flag): """Settings hook. Muting also kills the music so the world goes quiet at once; SFX are short, they just stop being requested.""" global enabled enabled = bool(flag) if not enabled: stop_music() def set_music_volume(level): """Settings hook for the bed volume (0.0..1.0). Applied live so a slider tweak audibly changes the current track without waiting for the next track switch.""" global music_volume music_volume = max(0.0, min(1.0, float(level))) if _mixer_ok: try: pygame.mixer.music.set_volume(music_volume) except pygame.error: pass