#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Sound handling for Storm Games. Provides functionality for: - Playing background music and sound effects - 2D positional audio (x,y) - Volume controls """ import os import pygame import random import re import time import math from .services import VolumeService # Global instance for backward compatibility volumeService = VolumeService.get_instance() class Sound: """Handles sound loading and playback.""" def __init__(self, soundDir="sounds/", volumeService=None): """Initialize sound system.""" self.soundDir = soundDir self.sounds = {} self.volumeService = volumeService or VolumeService.get_instance() if not pygame.mixer.get_init(): pygame.mixer.pre_init(44100, -16, 2, 1024) pygame.mixer.init() pygame.mixer.set_num_channels(32) pygame.mixer.set_reserved(0) self.load_sounds() def load_sounds(self): """Load all sound files from the sound directory and its subdirectories.""" try: for dirPath, _, fileNames in os.walk(self.soundDir): relPath = os.path.relpath(dirPath, self.soundDir) for fileName in fileNames: if fileName.lower().endswith(('.ogg', '.wav')): fullPath = os.path.join(dirPath, fileName) baseName = os.path.splitext(fileName)[0] soundKey = baseName if relPath == '.' else os.path.join(relPath, baseName).replace('\\', '/') self.sounds[soundKey] = pygame.mixer.Sound(fullPath) except Exception as e: print(f"Error loading sounds: {e}") def _find_matching_sound(self, pattern): """Find a random sound matching the pattern.""" keys = [k for k in self.sounds.keys() if re.match("^" + pattern + ".*", k)] return random.choice(keys) if keys else None def _handle_cutscene(self, soundName): """Play a sound as a cut scene.""" pygame.event.clear() pygame.mixer.stop() channel = pygame.mixer.Channel(0) sfxVolume = self.volumeService.get_sfx_volume() channel.set_volume(sfxVolume, sfxVolume) channel.play(self.sounds[soundName]) while pygame.mixer.get_busy(): for event in pygame.event.get(): if event.type == pygame.KEYDOWN and event.key in [pygame.K_ESCAPE, pygame.K_RETURN, pygame.K_SPACE]: pygame.mixer.stop() return None pygame.time.delay(10) return None def _get_stereo_panning(self, playerPos, objPos, centerDistance=None): """Calculate stereo panning based on positions.""" # Extract x-positions playerX = playerPos[0] if isinstance(playerPos, (tuple, list)) else playerPos objX = objPos[0] if isinstance(objPos, (tuple, list)) else objPos # For directional sound with fixed distance if centerDistance is not None: if abs(playerX - objX) <= centerDistance: return (1, 1) # Center elif playerX > objX: return (1, 0.505) # Left else: return (0.505, 1) # Right # Calculate regular panning volume, left, right = self.calculate_volume_and_pan(playerPos, objPos) return (volume * left, volume * right) if volume > 0 else (0, 0) def play_sound(self, soundName, volume=1.0, loop=False, playerPos=None, objPos=None, centerDistance=None, pattern=False, interrupt=False, pause=False, cutScene=False): """Unified method to play sounds with various options.""" # Resolve sound name if pattern matching is requested if pattern: soundName = self._find_matching_sound(soundName) if not soundName: return None # Check if sound exists if soundName not in self.sounds: return None # Handle cut scene mode if cutScene: return self._handle_cutscene(soundName) # Handle interrupt (stop other sounds) if interrupt: pygame.event.clear() pygame.mixer.stop() # Play the sound channel = self.sounds[soundName].play(-1 if loop else 0) if not channel: return None # Apply appropriate volume settings sfx_volume = self.volumeService.get_sfx_volume() # Handle positional audio if positions are provided if playerPos is not None and objPos is not None: # Calculate stereo panning left_vol, right_vol = self._get_stereo_panning(playerPos, objPos, centerDistance) # Don't play if out of range if left_vol == 0 and right_vol == 0: channel.stop() return None # Apply positional volume adjustments channel.set_volume(volume * left_vol * sfx_volume, volume * right_vol * sfx_volume) else: # Non-positional sound channel.set_volume(volume * sfx_volume) # Pause execution if requested if pause: time.sleep(self.sounds[soundName].get_length()) return channel def calculate_volume_and_pan(self, playerPos, objPos, maxDistance=12): """Calculate volume and stereo panning based on relative positions.""" # Determine if we're using 2D or 1D positioning if isinstance(playerPos, (tuple, list)) and isinstance(objPos, (tuple, list)): # 2D distance calculation distance = math.sqrt((playerPos[0] - objPos[0])**2 + (playerPos[1] - objPos[1])**2) playerX, objX = playerPos[0], objPos[0] else: # 1D calculation (backward compatible) distance = abs(playerPos - objPos) playerX, objX = playerPos, objPos if distance > maxDistance: return 0, 0, 0 # No sound if out of range # Calculate volume (non-linear scaling for more noticeable changes) volume = (((maxDistance - distance) / maxDistance) ** 1.5) * self.volumeService.masterVolume # Determine left/right based on relative position if playerX < objX: # Object is to the right left = max(0, 1 - (objX - playerX) / maxDistance) right = 1 elif playerX > objX: # Object is to the left left = 1 right = max(0, 1 - (playerX - objX) / maxDistance) else: # Player is on the object left = right = 1 return volume, left, right def update_sound_position(self, channel, playerPos, objPos): """Update positional audio for a playing sound.""" if not channel: return None # Calculate new stereo panning left_vol, right_vol = self._get_stereo_panning(playerPos, objPos) # Stop if out of range if left_vol == 0 and right_vol == 0: channel.stop() return None # Apply the volume and pan channel.set_volume(left_vol * self.volumeService.sfxVolume, right_vol * self.volumeService.sfxVolume) return channel def stop_sound(self, channel): """Stop a playing sound channel.""" if channel: try: channel.stop() except: pass return None def play_falling_sound(self, soundPrefix, playerPos, objPos, startY, currentY=0, maxY=20, existingChannel=None): """Play or update a sound with positional audio that changes with height.""" # Extract positions playerX = playerPos[0] if isinstance(playerPos, (tuple, list)) else playerPos objX = objPos[0] if isinstance(objPos, (tuple, list)) else objPos # Calculate volumes volume, left, right = self.calculate_volume_and_pan(playerX, objX) # Apply vertical fall multiplier (0 at maxY, 1 at y=0) fallMultiplier = 1 - (currentY / maxY) finalVolume = volume * fallMultiplier finalLeft = left * finalVolume finalRight = right * finalVolume # Update existing channel or create new one if existingChannel: if volume == 0: existingChannel.stop() return None existingChannel.set_volume( finalLeft * self.volumeService.sfxVolume, finalRight * self.volumeService.sfxVolume ) return existingChannel else: if volume == 0: return None # Find a matching sound soundName = self._find_matching_sound(soundPrefix) if not soundName: return None # Play the sound channel = self.sounds[soundName].play() if channel: channel.set_volume( finalLeft * self.volumeService.sfxVolume, finalRight * self.volumeService.sfxVolume ) return channel def play_bgm(self, musicFile): """Play background music with proper volume settings.""" try: pygame.mixer.music.stop() pygame.mixer.music.load(musicFile) pygame.mixer.music.set_volume(self.volumeService.get_bgm_volume()) pygame.mixer.music.play(-1) except Exception as e: print(f"Error playing background music: {e}") def adjust_master_volume(self, change): """Adjust the master volume for all sounds.""" self.volumeService.adjust_master_volume(change, pygame.mixer) def adjust_bgm_volume(self, change): """Adjust only the background music volume.""" self.volumeService.adjust_bgm_volume(change, pygame.mixer) def adjust_sfx_volume(self, change): """Adjust volume for sound effects only.""" self.volumeService.adjust_sfx_volume(change, pygame.mixer) # Optimized helper functions for global use def _get_stereo_panning(playerPos, objPos, centerDistance=None, maxDistance=12): """Simplified panning calculation.""" # Extract x-positions playerX = playerPos[0] if isinstance(playerPos, (tuple, list)) else playerPos objX = objPos[0] if isinstance(objPos, (tuple, list)) else objPos # For directional sound with fixed distance if centerDistance is not None: if abs(playerX - objX) <= centerDistance: return (1, 1) # Center elif playerX > objX: return (1, 0.505) # Left else: return (0.505, 1) # Right # Calculate distance if isinstance(playerPos, (tuple, list)) and isinstance(objPos, (tuple, list)): distance = math.sqrt((playerPos[0] - objPos[0])**2 + (playerPos[1] - objPos[1])**2) else: distance = abs(playerPos - objPos) if distance > maxDistance: return (0, 0) # No sound if out of range # Calculate volume (non-linear scaling for more noticeable changes) volume = (((maxDistance - distance) / maxDistance) ** 1.5) * volumeService.masterVolume # Determine left/right based on relative position if playerX < objX: # Object is to the right left = max(0, 1 - (objX - playerX) / maxDistance) right = 1 elif playerX > objX: # Object is to the left left = 1 right = max(0, 1 - (playerX - objX) / maxDistance) else: # Player is on the object left = right = 1 return (volume * left, volume * right) def _play_cutscene(sound, sounds=None): """Play a sound as a cut scene.""" pygame.event.clear() pygame.mixer.stop() channel = pygame.mixer.Channel(0) sfxVolume = volumeService.get_sfx_volume() channel.set_volume(sfxVolume, sfxVolume) # Determine which sound to play if isinstance(sound, pygame.mixer.Sound): channel.play(sound) elif isinstance(sounds, dict) and sound in sounds: channel.play(sounds[sound]) elif isinstance(sounds, Sound) and sound in sounds.sounds: channel.play(sounds.sounds[sound]) else: return None # Wait for completion or key press while pygame.mixer.get_busy(): for event in pygame.event.get(): if event.type == pygame.KEYDOWN and event.key in [pygame.K_ESCAPE, pygame.K_RETURN, pygame.K_SPACE]: pygame.mixer.stop() return None pygame.time.delay(10) return None def _find_matching_sound(soundPattern, sounds): """Find sounds matching a pattern in a dictionary.""" if isinstance(sounds, Sound): keys = [k for k in sounds.sounds.keys() if re.match("^" + soundPattern + ".*", k)] else: keys = [k for k in sounds.keys() if re.match("^" + soundPattern + ".*", k)] return random.choice(keys) if keys else None # Global functions for backward compatibility def play_bgm(musicFile): """Play background music with proper volume settings.""" try: pygame.mixer.music.stop() pygame.mixer.music.load(musicFile) pygame.mixer.music.set_volume(volumeService.get_bgm_volume()) pygame.mixer.music.play(-1) except: pass def adjust_master_volume(change): """Adjust the master volume.""" volumeService.adjust_master_volume(change, pygame.mixer) def adjust_bgm_volume(change): """Adjust background music volume.""" volumeService.adjust_bgm_volume(change, pygame.mixer) def adjust_sfx_volume(change): """Adjust sound effects volume.""" volumeService.adjust_sfx_volume(change, pygame.mixer) def calculate_volume_and_pan(playerPos, objPos, maxDistance=12): """Calculate volume and stereo panning.""" left_vol, right_vol = _get_stereo_panning(playerPos, objPos, None, maxDistance) # Convert to old format (volume, left, right) if left_vol == 0 and right_vol == 0: return 0, 0, 0 elif left_vol >= right_vol: volume = left_vol return volume, 1, right_vol/left_vol else: volume = right_vol return volume, left_vol/right_vol, 1 def play_sound(sound_or_name, volume=1.0, loop=False, playerPos=None, objPos=None, centerDistance=None, pattern=False, interrupt=False, pause=False, cutScene=False, sounds=None): """Unified sound playing function with backward compatibility.""" # Handle cut scene mode if cutScene: return _play_cutscene(sound_or_name, sounds) # Handle pattern matching if pattern and isinstance(sound_or_name, str) and sounds: matched_sound = _find_matching_sound(sound_or_name, sounds) if not matched_sound: return None sound_or_name = matched_sound # Handle interrupt if interrupt: pygame.event.clear() pygame.mixer.stop() # Case 1: Sound instance provided if isinstance(sound_or_name, Sound): return sound_or_name.play_sound(sound_or_name, volume, loop, playerPos, objPos, centerDistance, False, False, pause, False) # Case 2: Sound name with Sound instance elif isinstance(sounds, Sound) and isinstance(sound_or_name, str): return sounds.play_sound(sound_or_name, volume, loop, playerPos, objPos, centerDistance, False, False, pause, False) # Case 3: Direct pygame.Sound elif isinstance(sound_or_name, pygame.mixer.Sound): channel = sound_or_name.play(-1 if loop else 0) if channel: channel.set_volume(volume * volumeService.get_sfx_volume()) return channel # Case 4: Sound name with dictionary elif isinstance(sounds, dict) and isinstance(sound_or_name, str) and sound_or_name in sounds: # Play the sound channel = sounds[sound_or_name].play(-1 if loop else 0) if not channel: return None # Apply volume settings sfx_vol = volumeService.get_sfx_volume() # Handle positional audio if playerPos is not None and objPos is not None: left_vol, right_vol = _get_stereo_panning(playerPos, objPos, centerDistance) if left_vol == 0 and right_vol == 0: channel.stop() return None channel.set_volume(volume * left_vol * sfx_vol, volume * right_vol * sfx_vol) else: channel.set_volume(volume * sfx_vol) # Pause if requested if pause: time.sleep(sounds[sound_or_name].get_length()) return channel return None def obj_update(channel, playerPos, objPos): """Update positional audio for a playing sound.""" if not channel: return None left_vol, right_vol = _get_stereo_panning(playerPos, objPos) if left_vol == 0 and right_vol == 0: channel.stop() return None channel.set_volume(left_vol * volumeService.sfxVolume, right_vol * volumeService.sfxVolume) return channel def obj_stop(channel): """Stop a sound channel.""" if channel: try: channel.stop() except: pass return None # Extremely concise lambda definitions for legacy functions obj_play = lambda sounds, soundName, playerPos, objPos, loop=True: play_sound( soundName, 1.0, loop, playerPos, objPos, None, False, False, False, False, sounds) play_ambiance = lambda sounds, soundNames, probability, randomLocation=False: play_sound( random.choice(soundNames) if random.randint(1, 100) <= probability and not any( pygame.mixer.find_channel(True) and pygame.mixer.find_channel(True).get_busy() for _ in ([soundNames] if isinstance(soundNames, str) else soundNames)) else None, 1.0, False, None, None, None, False, False, False, False, sounds if not isinstance(sounds, Sound) else None) play_random = lambda sounds, soundName, pause=False, interrupt=False: play_sound( soundName, 1.0, False, None, None, None, True, interrupt, pause, False, sounds) play_random_positional = lambda sounds, soundName, playerX, objectX: play_sound( soundName, 1.0, False, playerX, objectX, None, True, False, False, False, sounds) play_directional_sound = lambda sounds, soundName, playerPos, objPos, centerDistance=3, volume=1.0: play_sound( soundName, volume, False, playerPos, objPos, centerDistance, False, False, False, False, sounds) cut_scene = lambda sounds, soundName: _play_cutscene(soundName, sounds) def play_random_falling(sounds, soundName, playerX, objectX, startY, currentY=0, maxY=20, existingChannel=None): """Handle falling sound.""" if isinstance(sounds, Sound): return sounds.play_falling_sound(soundName, playerX, objectX, startY, currentY, maxY, existingChannel) # Legacy implementation left_vol, right_vol = _get_stereo_panning(playerX, objectX) if left_vol == 0 and right_vol == 0: if existingChannel: existingChannel.stop() return None # Calculate fall multiplier fallMultiplier = 1 - (currentY / maxY) finalLeft = left_vol * fallMultiplier finalRight = right_vol * fallMultiplier if existingChannel: existingChannel.set_volume( finalLeft * volumeService.sfxVolume, finalRight * volumeService.sfxVolume ) return existingChannel # Find matching sound matched_sound = _find_matching_sound(soundName, sounds) if not matched_sound: return None # Play the sound channel = sounds[matched_sound].play() if channel: channel.set_volume( finalLeft * volumeService.sfxVolume, finalRight * volumeService.sfxVolume ) return channel