diff --git a/assets/music/Clockwork_Thicket.mp3 b/assets/music/Clockwork_Thicket.mp3 new file mode 100644 index 0000000..19bb8f8 Binary files /dev/null and b/assets/music/Clockwork_Thicket.mp3 differ diff --git a/engine/controls.py b/engine/controls.py index 827a58e..cbc1bd1 100644 --- a/engine/controls.py +++ b/engine/controls.py @@ -278,6 +278,10 @@ class KeyBindings: def toggle_audio(self): self.render_engine.audio = not self.render_engine.audio + self.audio = self.render_engine.audio + if not self.render_engine.audio: + self.render_engine.stop_sound() + def toggle_pause(self): self.game_status = "paused" if self.game_status == "game" else "game" diff --git a/engine/sdl2.py b/engine/sdl2.py index 5208c76..c40d1f9 100644 --- a/engine/sdl2.py +++ b/engine/sdl2.py @@ -5,6 +5,10 @@ from ctypes import * import sdl2 import sdl2.ext +try: + import sdl2.sdlmixer as sdlmixer +except ImportError: + sdlmixer = None from sdl2.ext.compat import byteify from sdl2 import SDL_AudioSpec from PIL import Image @@ -124,6 +128,73 @@ class GameWindow: self.audio_devs["base"] = sdl2.SDL_OpenAudioDevice(None, 0, audio_spec, None, 0) self.audio_devs["effects"] = sdl2.SDL_OpenAudioDevice(None, 0, audio_spec, None, 0) self.audio_devs["music"] = sdl2.SDL_OpenAudioDevice(None, 0, audio_spec, None, 0) + self.music_enabled = False + self.music_volume = 128 + self.music_track = None + self.music_path = None + + if sdlmixer is None: + return + + mixer_flags = getattr(sdlmixer, "MIX_INIT_MP3", 0) + init_result = sdlmixer.Mix_Init(mixer_flags) + if mixer_flags and init_result & mixer_flags != mixer_flags: + print("[audio] SDL_mixer MP3 support not available") + return + + if sdlmixer.Mix_OpenAudio(44100, sdl2.AUDIO_S16SYS, sdlmixer.MIX_DEFAULT_CHANNELS, 2048) != 0: + print(f"[audio] failed to open music mixer: {sdl2.SDL_GetError()}") + return + + self.music_enabled = True + sdlmixer.Mix_VolumeMusic(self.music_volume) + + def set_volume(self, volume_percent): + clamped = max(0, min(int(volume_percent), 100)) + self.music_volume = int(round(clamped * 128 / 100)) + if self.music_enabled and sdlmixer is not None: + sdlmixer.Mix_VolumeMusic(self.music_volume) + + def play_music(self, music_file, loop=True): + if not self.audio or not self.music_enabled or sdlmixer is None: + return False + + music_path = str(resolve_bundle_path(os.path.join("assets", "music", music_file))) + if self.music_path != music_path: + self._free_music_track() + self.music_track = sdlmixer.Mix_LoadMUS(byteify(music_path, "utf-8")) + if not self.music_track: + print(f"[audio] failed to load music: {music_path}") + return False + self.music_path = music_path + + if sdlmixer.Mix_PausedMusic(): + sdlmixer.Mix_ResumeMusic() + return True + + if sdlmixer.Mix_PlayingMusic(): + return True + + loops = -1 if loop else 0 + if sdlmixer.Mix_PlayMusic(self.music_track, loops) != 0: + print(f"[audio] failed to play music: {music_path}") + return False + return True + + def pause_music(self): + if self.music_enabled and sdlmixer is not None and sdlmixer.Mix_PlayingMusic() and not sdlmixer.Mix_PausedMusic(): + sdlmixer.Mix_PauseMusic() + + def stop_music(self): + if self.music_enabled and sdlmixer is not None and (sdlmixer.Mix_PlayingMusic() or sdlmixer.Mix_PausedMusic()): + sdlmixer.Mix_HaltMusic() + + def _free_music_track(self): + self.stop_music() + if self.music_enabled and sdlmixer is not None and self.music_track is not None: + sdlmixer.Mix_FreeMusic(self.music_track) + self.music_track = None + self.music_path = None # ====================== # TEXTURE & IMAGE METHODS @@ -437,6 +508,7 @@ class GameWindow: def stop_sound(self): """Stop all audio playback""" + self.stop_music() for dev in self.audio_devs.values(): sdl2.SDL_PauseAudioDevice(dev, 1) sdl2.SDL_ClearQueuedAudio(dev) @@ -722,6 +794,11 @@ class GameWindow: def close(self): """Close the game window and cleanup""" + self._free_music_track() + if self.music_enabled and sdlmixer is not None: + sdlmixer.Mix_CloseAudio() + sdlmixer.Mix_Quit() + self.music_enabled = False if self.game_controller: sdl2.SDL_GameControllerClose(self.game_controller) self.game_controller = None diff --git a/rats.py b/rats.py index 3235186..c5dac97 100644 --- a/rats.py +++ b/rats.py @@ -12,6 +12,9 @@ from engine.user_profile_integration import UserProfileIntegration from runtime_paths import bundle_path +GAMEPLAY_MUSIC = "Clockwork_Thicket.mp3" + + class MiceMaze( controls.KeyBindings, unit_manager.UnitManager, @@ -44,6 +47,7 @@ class MiceMaze( self.render_engine = engine.GameWindow(self.map.width, self.map.height, self.cell_size, window_title, key_callback=self.trigger) + self.render_engine.audio = self.audio # Apply profile settings if hasattr(self.render_engine, 'set_volume'): @@ -245,7 +249,14 @@ class MiceMaze( if random.random() < 0.01: data["count"] = min(data["count"] + 1, data["max"]) + def update_background_music(self): + if self.game_status == "game" and not self.game_end[0]: + self.render_engine.play_music(GAMEPLAY_MUSIC, loop=True) + return + self.render_engine.pause_music() + def update_maze(self): + self.update_background_music() if self.game_over(): return if self.game_status == "paused":