#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Enhanced sound handling for Storm Games. Provides functionality for: - Playing background music and sound effects - Positional audio (horizontal and vertical) - Volume controls - Sound looping with proper channel control """ import os import pygame import random import re import time from os import listdir from os.path import isfile, join 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. Args: soundDir (str): Directory containing sound files (default: "sounds/") volumeService (VolumeService): Volume service (default: global instance) """ self.soundDir = soundDir self.sounds = {} self.volumeService = volumeService or VolumeService.get_instance() self.activeLoops = {} # Track active looping sounds by ID # Initialize pygame mixer if not already done 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) # Reserve channel for cut scenes # Load sounds self.load_sounds() def load_sounds(self): """Load all sound files from the sound directory and its subdirectories. Searches recursively through subdirectories and loads all sound files with .ogg or .wav extensions. Sound names are stored as relative paths from the sound directory, with directory separators replaced by forward slashes. """ try: # Walk through directory tree for dirPath, dirNames, fileNames in os.walk(self.soundDir): # Get relative path from soundDir relPath = os.path.relpath(dirPath, self.soundDir) # Process each file for fileName in fileNames: # Check if file is a valid sound file if fileName.lower().endswith(('.ogg', '.wav')): # Full path to the sound file fullPath = os.path.join(dirPath, fileName) # Create sound key (remove extension) baseName = os.path.splitext(fileName)[0] # If in root sounds dir, just use basename if relPath == '.': soundKey = baseName else: # Otherwise use relative path + basename, normalized with forward slashes soundKey = os.path.join(relPath, baseName).replace('\\', '/') # Load the sound self.sounds[soundKey] = pygame.mixer.Sound(fullPath) except Exception as e: print(f"Error loading sounds: {e}") def play_sound(self, soundName, volume=1.0, loop=False): """Play a sound with current volume settings applied. Args: soundName (str): Name of sound to play volume (float): Base volume for the sound (0.0-1.0, default: 1.0) loop (bool): Whether to loop the sound (default: False) Returns: pygame.mixer.Channel: The channel the sound is playing on """ if soundName not in self.sounds: return None sound = self.sounds[soundName] # Set loop parameter to -1 for infinite loop or 0 for no loop loopParam = -1 if loop else 0 channel = sound.play(loopParam) if channel: sfx_volume = volume * self.volumeService.get_sfx_volume() channel.set_volume(sfx_volume, sfx_volume) # Explicitly set both channels to ensure centered sound # Store in active loops if looping if loop: self.activeLoops[soundName] = { 'channel': channel, 'sound': sound, 'volume': volume } return channel def stop_sound(self, soundName=None, channel=None): """Stop a specific sound or channel. Args: soundName (str, optional): Name of sound to stop channel (pygame.mixer.Channel, optional): Channel to stop Returns: bool: True if sound was stopped, False otherwise """ if channel: channel.stop() # Remove from active loops if present for key in list(self.activeLoops.keys()): if self.activeLoops[key]['channel'] == channel: del self.activeLoops[key] return True elif soundName: if soundName in self.activeLoops: self.activeLoops[soundName]['channel'].stop() del self.activeLoops[soundName] return True return False def calculate_volume_and_pan(self, playerPos, objPos, playerY=0, objY=0, maxDistance=12, maxYDistance=20): """Calculate volume and stereo panning based on relative positions. Args: playerPos (float): Player's position on x-axis objPos (float): Object's position on x-axis playerY (float): Player's position on y-axis (default: 0) objY (float): Object's position on y-axis (default: 0) maxDistance (float): Maximum audible horizontal distance (default: 12) maxYDistance (float): Maximum audible vertical distance (default: 20) Returns: tuple: (volume, left_vol, right_vol) values between 0 and 1 """ # Calculate horizontal distance hDistance = abs(playerPos - objPos) # Calculate vertical distance vDistance = abs(playerY - objY) # If either distance exceeds maximum, no sound if hDistance > maxDistance or vDistance > maxYDistance: return 0, 0, 0 # No sound if out of range # Calculate horizontal volume factor (non-linear scaling) hVolume = ((maxDistance - hDistance) / maxDistance) ** 1.5 # Calculate vertical volume factor (linear is fine for y-axis) vVolume = (maxYDistance - vDistance) / maxYDistance # Combine horizontal and vertical volumes volume = hVolume * vVolume * self.volumeService.masterVolume # Determine left/right based on relative position if playerPos < objPos: # Object is to the right left = max(0, 1 - (objPos - playerPos) / maxDistance) right = 1 elif playerPos > objPos: # Object is to the left left = 1 right = max(0, 1 - (playerPos - objPos) / maxDistance) else: # Player is on the object left = right = 1 return volume, left, right def obj_play(self, soundName, playerPos, objPos, playerY=0, objY=0, loop=False): """Play a sound with positional audio. Args: soundName (str): Name of sound to play playerPos (float): Player's position for audio panning objPos (float): Object's position for audio panning playerY (float): Player's Y position (default: 0) objY (float): Object's Y position (default: 0) loop (bool): Whether to loop the sound (default: False) Returns: pygame.mixer.Channel: Sound channel object, or None if out of range """ if soundName not in self.sounds: return None volume, left, right = self.calculate_volume_and_pan(playerPos, objPos, playerY, objY) if volume <= 0: return None # Don't play if out of range # Set loop parameter to -1 for infinite loop or 0 for no loop loopParam = -1 if loop else 0 # Play the sound on a new channel channel = self.sounds[soundName].play(loopParam) if channel: channel.set_volume( volume * left * self.volumeService.sfxVolume, volume * right * self.volumeService.sfxVolume ) # Store in active loops if looping if loop: loopId = f"{soundName}_{objPos}_{objY}" self.activeLoops[loopId] = { 'channel': channel, 'sound': self.sounds[soundName], 'playerPos': playerPos, 'objPos': objPos, 'playerY': playerY, 'objY': objY } return channel def obj_update(self, channel, playerPos, objPos, playerY=0, objY=0): """Update positional audio for a playing sound. Args: channel: Sound channel to update playerPos (float): New player position objPos (float): New object position playerY (float): Player's Y position (default: 0) objY (float): Object's Y position (default: 0) Returns: pygame.mixer.Channel: Updated channel, or None if sound should stop """ if channel is None: return None volume, left, right = self.calculate_volume_and_pan(playerPos, objPos, playerY, objY) if volume <= 0: channel.stop() # Remove from active loops if present for key in list(self.activeLoops.keys()): if self.activeLoops[key]['channel'] == channel: del self.activeLoops[key] return None # Apply the volume and pan channel.set_volume( volume * left * self.volumeService.sfxVolume, volume * right * self.volumeService.sfxVolume ) # Update loop tracking if this is an active loop for key in list(self.activeLoops.keys()): if self.activeLoops[key]['channel'] == channel: self.activeLoops[key]['playerPos'] = playerPos self.activeLoops[key]['objPos'] = objPos self.activeLoops[key]['playerY'] = playerY self.activeLoops[key]['objY'] = objY break return channel def obj_stop(self, channel): """Stop a playing sound channel. Args: channel: Sound channel to stop Returns: None if stopped successfully, otherwise returns original channel """ try: channel.stop() # Remove from active loops if present for key in list(self.activeLoops.keys()): if self.activeLoops[key]['channel'] == channel: del self.activeLoops[key] return None except: return channel def play_ambiance(self, soundNames, probability, randomLocation=False, loop=False): """Play random ambient sounds with optional positional audio. Args: soundNames (list): List of possible sound names to choose from probability (int): Chance to play (1-100) randomLocation (bool): Whether to randomize stereo position loop (bool): Whether to loop the sound (default: False) Returns: pygame.mixer.Channel: Sound channel if played, None otherwise """ # Check if any of the sounds in the list is already playing (for non-loops) if not loop: for soundName in soundNames: if pygame.mixer.find_channel(True) and pygame.mixer.find_channel(True).get_busy(): return None if random.randint(1, 100) > probability: return None # Choose a random sound from the list ambianceSound = random.choice(soundNames) if ambianceSound not in self.sounds: return None # Set loop parameter to -1 for infinite loop or 0 for no loop loopParam = -1 if loop else 0 channel = self.sounds[ambianceSound].play(loopParam) if channel: sfxVolume = self.volumeService.get_sfx_volume() if randomLocation: leftVolume = random.random() * sfxVolume rightVolume = random.random() * sfxVolume channel.set_volume(leftVolume, rightVolume) else: channel.set_volume(sfxVolume, sfxVolume) # Store in active loops if looping if loop: loopId = f"ambiance_{ambianceSound}" self.activeLoops[loopId] = { 'channel': channel, 'sound': self.sounds[ambianceSound], 'randomLocation': randomLocation } return channel def play_random(self, soundPrefix, pause=False, interrupt=False, loop=False): """Play a random variation of a sound. Args: soundPrefix (str): Base name of sound (will match all starting with this) pause (bool): Whether to pause execution until sound finishes interrupt (bool): Whether to interrupt other sounds loop (bool): Whether to loop the sound (default: False) Returns: pygame.mixer.Channel: Channel of the playing sound or None """ keys = [] for i in self.sounds.keys(): if re.match("^" + soundPrefix + ".*", i): keys.append(i) if not keys: # No matching sounds found return None randomKey = random.choice(keys) if interrupt: self.cut_scene(randomKey) return None # Set loop parameter to -1 for infinite loop or 0 for no loop loopParam = -1 if loop else 0 channel = self.sounds[randomKey].play(loopParam) sfxVolume = self.volumeService.get_sfx_volume() if channel: channel.set_volume(sfxVolume, sfxVolume) # Store in active loops if looping if loop: loopId = f"random_{soundPrefix}_{randomKey}" self.activeLoops[loopId] = { 'channel': channel, 'sound': self.sounds[randomKey], 'prefix': soundPrefix } if pause and not loop: time.sleep(self.sounds[randomKey].get_length()) return channel def play_random_positional(self, soundPrefix, playerX, objectX, playerY=0, objectY=0, loop=False): """Play a random variation of a sound with positional audio. Args: soundPrefix (str): Base name of sound to match playerX (float): Player's x position objectX (float): Object's x position playerY (float): Player's y position (default: 0) objectY (float): Object's y position (default: 0) loop (bool): Whether to loop the sound (default: False) Returns: pygame.mixer.Channel: Sound channel if played, None otherwise """ keys = [k for k in self.sounds.keys() if k.startswith(soundPrefix)] if not keys: return None randomKey = random.choice(keys) volume, left, right = self.calculate_volume_and_pan(playerX, objectX, playerY, objectY) if volume <= 0: return None # Set loop parameter to -1 for infinite loop or 0 for no loop loopParam = -1 if loop else 0 channel = self.sounds[randomKey].play(loopParam) if channel: channel.set_volume( volume * left * self.volumeService.sfxVolume, volume * right * self.volumeService.sfxVolume ) # Store in active loops if looping if loop: loopId = f"random_pos_{soundPrefix}_{objectX}_{objectY}" self.activeLoops[loopId] = { 'channel': channel, 'sound': self.sounds[randomKey], 'prefix': soundPrefix, 'playerX': playerX, 'objectX': objectX, 'playerY': playerY, 'objectY': objectY } return channel def play_directional_sound(self, soundName, playerPos, objPos, playerY=0, objY=0, centerDistance=3, volume=1.0, loop=False): """Play a sound with simplified directional audio. For sounds that need to be heard clearly regardless of distance, but still provide directional feedback. Sound plays at full volume but pans left/right based on relative position. Args: soundName (str): Name of sound to play playerPos (float): Player's x position objPos (float): Object's x position playerY (float): Player's y position (default: 0) objY (float): Object's y position (default: 0) centerDistance (float): Distance within which sound plays center (default: 3) volume (float): Base volume multiplier (0.0-1.0, default: 1.0) loop (bool): Whether to loop the sound (default: False) Returns: pygame.mixer.Channel: The channel the sound is playing on """ if soundName not in self.sounds: return None # Check vertical distance vDistance = abs(playerY - objY) maxYDistance = 20 # Maximum audible vertical distance if vDistance > maxYDistance: return None # Don't play if out of vertical range # Calculate vertical volume factor vVolume = (maxYDistance - vDistance) / maxYDistance finalVolume = volume * vVolume * self.volumeService.get_sfx_volume() # Set loop parameter to -1 for infinite loop or 0 for no loop loopParam = -1 if loop else 0 channel = self.sounds[soundName].play(loopParam) if channel: # If player is within centerDistance tiles of object, play in center if abs(playerPos - objPos) <= centerDistance: # Equal volume in both speakers (center) channel.set_volume(finalVolume, finalVolume) elif playerPos > objPos: # Object is to the left of player channel.set_volume(finalVolume, (finalVolume + 0.01) / 2) else: # Object is to the right of player channel.set_volume((finalVolume + 0.01) / 2, finalVolume) # Store in active loops if looping if loop: loopId = f"directional_{soundName}_{objPos}_{objY}" self.activeLoops[loopId] = { 'channel': channel, 'sound': self.sounds[soundName], 'playerPos': playerPos, 'objPos': objPos, 'playerY': playerY, 'objY': objY, 'centerDistance': centerDistance, 'volume': volume } return channel def cut_scene(self, soundName): """Play a sound as a cut scene, stopping other sounds. Args: soundName (str): Name of sound to play """ if soundName not in self.sounds: return pygame.event.clear() pygame.mixer.stop() # Clear active loops self.activeLoops.clear() # Get the reserved channel (0) for cut scenes channel = pygame.mixer.Channel(0) # Apply the appropriate volume settings sfxVolume = self.volumeService.get_sfx_volume() channel.set_volume(sfxVolume, sfxVolume) # Play the sound 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 pygame.time.delay(10) def play_random_falling(self, soundPrefix, playerX, objectX, startY, currentY=0, playerY=0, maxY=20, existingChannel=None, loop=False): """Play or update a falling sound with positional audio and volume based on height. Args: soundPrefix (str): Base name of sound to match playerX (float): Player's x position objectX (float): Object's x position startY (float): Starting Y position (0-20, higher = quieter start) currentY (float): Current Y position (0 = ground level) (default: 0) playerY (float): Player's Y position (default: 0) maxY (float): Maximum Y value (default: 20) existingChannel: Existing sound channel to update (default: None) loop (bool): Whether to loop the sound (default: False) Returns: pygame.mixer.Channel: Sound channel for updating position/volume, or None if sound should stop """ # Calculate horizontal and vertical positioning volume, left, right = self.calculate_volume_and_pan(playerX, objectX, playerY, currentY) # Calculate vertical fall volume multiplier (0 at maxY, 1 at y=0) fallMultiplier = 1 - (currentY / maxY) # Adjust final volumes finalVolume = volume * fallMultiplier finalLeft = left * finalVolume finalRight = right * finalVolume if existingChannel is not None: if volume <= 0: # Out of audible range existingChannel.stop() # Remove from active loops if present for key in list(self.activeLoops.keys()): if self.activeLoops[key]['channel'] == existingChannel: del self.activeLoops[key] return None existingChannel.set_volume( finalLeft * self.volumeService.sfxVolume, finalRight * self.volumeService.sfxVolume ) # Update loop tracking if this is an active loop for key in list(self.activeLoops.keys()): if self.activeLoops[key]['channel'] == existingChannel: self.activeLoops[key]['playerX'] = playerX self.activeLoops[key]['objectX'] = objectX self.activeLoops[key]['playerY'] = playerY self.activeLoops[key]['currentY'] = currentY break return existingChannel else: # Need to create new channel if volume <= 0: # Don't start if out of range return None # Find matching sound files keys = [k for k in self.sounds.keys() if k.startswith(soundPrefix)] if not keys: return None randomKey = random.choice(keys) # Set loop parameter to -1 for infinite loop or 0 for no loop loopParam = -1 if loop else 0 channel = self.sounds[randomKey].play(loopParam) if channel: channel.set_volume( finalLeft * self.volumeService.sfxVolume, finalRight * self.volumeService.sfxVolume ) # Store in active loops if looping if loop: loopId = f"falling_{soundPrefix}_{objectX}_{currentY}" self.activeLoops[loopId] = { 'channel': channel, 'sound': self.sounds[randomKey], 'prefix': soundPrefix, 'playerX': playerX, 'objectX': objectX, 'playerY': playerY, 'currentY': currentY, 'maxY': maxY } return channel def update_all_active_loops(self, playerX, playerY): """Update all active looping sounds based on new player position. This should be called in the game loop to update positional audio for all looping sounds. Args: playerX (float): Player's new X position playerY (float): Player's new Y position Returns: int: Number of active loops updated """ updatedCount = 0 # Make a copy of keys since we might modify the dictionary during iteration loopKeys = list(self.activeLoops.keys()) for key in loopKeys: loop = self.activeLoops[key] # Skip if channel is no longer active if not loop['channel'].get_busy(): del self.activeLoops[key] continue # Handle different types of loops if 'objPos' in loop: # Update positional audio self.obj_update( loop['channel'], playerX, loop['objPos'], playerY, loop.get('objY', 0) ) updatedCount += 1 elif 'objectX' in loop: # Falling or positional random sound if 'currentY' in loop: # It's a falling sound self.play_random_falling( loop['prefix'], playerX, loop['objectX'], 0, # startY doesn't matter for updates loop['currentY'], playerY, loop['maxY'], loop['channel'], True # Keep it looping ) else: # It's a positional random sound volume, left, right = self.calculate_volume_and_pan( playerX, loop['objectX'], playerY, loop.get('objectY', 0) ) if volume <= 0: loop['channel'].stop() del self.activeLoops[key] continue loop['channel'].set_volume( volume * left * self.volumeService.sfxVolume, volume * right * self.volumeService.sfxVolume ) updatedCount += 1 elif 'randomLocation' in loop and loop['randomLocation']: # Random location ambiance - update with new random panning leftVolume = random.random() * self.volumeService.get_sfx_volume() rightVolume = random.random() * self.volumeService.get_sfx_volume() loop['channel'].set_volume(leftVolume, rightVolume) updatedCount += 1 return updatedCount # Global functions for backward compatibility def play_bgm(musicFile, loop=True): """Play background music with proper volume settings. Args: musicFile (str): Path to the music file to play loop (bool): Whether to loop the music (default: True) Returns: bool: True if music started successfully """ try: pygame.mixer.music.stop() pygame.mixer.music.load(musicFile) pygame.mixer.music.set_volume(volumeService.get_bgm_volume()) # Loop indefinitely if loop=True, otherwise play once loops = -1 if loop else 0 pygame.mixer.music.play(loops) return True except Exception as e: print(f"Error playing background music: {e}") return False def adjust_master_volume(change): """Adjust the master volume for all sounds. Args: change (float): Amount to change volume by (positive or negative) """ volumeService.adjust_master_volume(change, pygame.mixer) def adjust_bgm_volume(change): """Adjust only the background music volume. Args: change (float): Amount to change volume by (positive or negative) """ volumeService.adjust_bgm_volume(change, pygame.mixer) def adjust_sfx_volume(change): """Adjust volume for sound effects only. Args: change (float): Amount to change volume by (positive or negative) """ volumeService.adjust_sfx_volume(change, pygame.mixer) def calculate_volume_and_pan(playerPos, objPos, playerY=0, objY=0, maxDistance=12, maxYDistance=20): """Calculate volume and stereo panning based on relative positions. Args: playerPos (float): Player's position on x-axis objPos (float): Object's position on x-axis playerY (float): Player's position on y-axis (default: 0) objY (float): Object's position on y-axis (default: 0) maxDistance (float): Maximum audible horizontal distance (default: 12) maxYDistance (float): Maximum audible vertical distance (default: 20) Returns: tuple: (volume, left_vol, right_vol) values between 0 and 1 """ # Calculate horizontal distance hDistance = abs(playerPos - objPos) # Calculate vertical distance vDistance = abs(playerY - objY) # If either distance exceeds maximum, no sound if hDistance > maxDistance or vDistance > maxYDistance: return 0, 0, 0 # No sound if out of range # Calculate horizontal volume factor (non-linear scaling) hVolume = ((maxDistance - hDistance) / maxDistance) ** 1.5 # Calculate vertical volume factor (linear is fine for y-axis) vVolume = (maxYDistance - vDistance) / maxYDistance # Combine horizontal and vertical volumes volume = hVolume * vVolume * volumeService.masterVolume # Determine left/right based on relative position if playerPos < objPos: # Object is to the right left = max(0, 1 - (objPos - playerPos) / maxDistance) right = 1 elif playerPos > objPos: # Object is to the left left = 1 right = max(0, 1 - (playerPos - objPos) / maxDistance) else: # Player is on the object left = right = 1 return volume, left, right def play_sound(sound, volume=1.0, loop=False): """Play a sound with current volume settings applied. Args: sound: pygame Sound object to play volume: base volume for the sound (0.0-1.0, default: 1.0) loop (bool): Whether to loop the sound (default: False) Returns: pygame.mixer.Channel: The channel the sound is playing on """ # Set loop parameter to -1 for infinite loop or 0 for no loop loopParam = -1 if loop else 0 channel = sound.play(loopParam) if channel: sfx_volume = volume * volumeService.get_sfx_volume() channel.set_volume(sfx_volume, sfx_volume) # Explicitly set both channels to ensure centered sound return channel def obj_play(sounds, soundName, playerPos, objPos, playerY=0, objY=0, loop=False): """Play a sound with positional audio. Args: sounds (dict): Dictionary of sound objects soundName (str): Name of sound to play playerPos (float): Player's position for audio panning objPos (float): Object's position for audio panning playerY (float): Player's Y position (default: 0) objY (float): Object's Y position (default: 0) loop (bool): Whether to loop the sound (default: False) Returns: pygame.mixer.Channel: Sound channel object, or None if out of range """ volume, left, right = calculate_volume_and_pan(playerPos, objPos, playerY, objY) if volume <= 0: return None # Don't play if out of range # Set loop parameter to -1 for infinite loop or 0 for no loop loopParam = -1 if loop else 0 # Play the sound on a new channel channel = sounds[soundName].play(loopParam) if channel: channel.set_volume( volume * left * volumeService.sfxVolume, volume * right * volumeService.sfxVolume ) return channel def obj_update(channel, playerPos, objPos, playerY=0, objY=0): """Update positional audio for a playing sound. Args: channel: Sound channel to update playerPos (float): New player position objPos (float): New object position playerY (float): Player's Y position (default: 0) objY (float): Object's Y position (default: 0) Returns: pygame.mixer.Channel: Updated channel, or None if sound should stop """ if channel is None: return None volume, left, right = calculate_volume_and_pan(playerPos, objPos, playerY, objY) if volume <= 0: channel.stop() return None # Apply the volume and pan channel.set_volume( volume * left * volumeService.sfxVolume, volume * right * volumeService.sfxVolume ) return channel def obj_stop(channel): """Stop a playing sound channel. Args: channel: Sound channel to stop Returns: None if stopped successfully, otherwise returns original channel """ try: channel.stop() return None except: return channel def play_ambiance(sounds, soundNames, probability, randomLocation=False, loop=False): """Play random ambient sounds with optional positional audio. Args: sounds (dict): Dictionary of sound objects soundNames (list): List of possible sound names to choose from probability (int): Chance to play (1-100) randomLocation (bool): Whether to randomize stereo position loop (bool): Whether to loop the sound (default: False) Returns: pygame.mixer.Channel: Sound channel if played, None otherwise """ # Check if any of the sounds in the list is already playing (for non-loops) if not loop: for soundName in soundNames: if pygame.mixer.find_channel(True) and pygame.mixer.find_channel(True).get_busy(): return None if random.randint(1, 100) > probability: return None # Choose a random sound from the list ambianceSound = random.choice(soundNames) # Set loop parameter to -1 for infinite loop or 0 for no loop loopParam = -1 if loop else 0 channel = sounds[ambianceSound].play(loopParam) if channel: sfxVolume = volumeService.get_sfx_volume() if randomLocation: leftVolume = random.random() * sfxVolume rightVolume = random.random() * sfxVolume channel.set_volume(leftVolume, rightVolume) else: channel.set_volume(sfxVolume, sfxVolume) return channel def play_random(sounds, soundName, pause=False, interrupt=False, loop=False): """Play a random variation of a sound. Args: sounds (dict): Dictionary of sound objects soundName (str): Base name of sound (will match all starting with this) pause (bool): Whether to pause execution until sound finishes interrupt (bool): Whether to interrupt other sounds loop (bool): Whether to loop the sound (default: False) Returns: pygame.mixer.Channel: Channel of the playing sound or None """ key = [] for i in sounds.keys(): if re.match("^" + soundName + ".*", i): key.append(i) if not key: # No matching sounds found return None randomKey = random.choice(key) if interrupt: cut_scene(sounds, randomKey) return None # Set loop parameter to -1 for infinite loop or 0 for no loop loopParam = -1 if loop else 0 channel = sounds[randomKey].play(loopParam) if channel: sfxVolume = volumeService.get_sfx_volume() channel.set_volume(sfxVolume, sfxVolume) if pause and not loop: time.sleep(sounds[randomKey].get_length()) return channel def play_random_positional(sounds, soundName, playerX, objectX, playerY=0, objectY=0, loop=False): """Play a random variation of a sound with positional audio. Args: sounds (dict): Dictionary of sound objects soundName (str): Base name of sound to match playerX (float): Player's x position objectX (float): Object's x position playerY (float): Player's y position (default: 0) objectY (float): Object's y position (default: 0) loop (bool): Whether to loop the sound (default: False) Returns: pygame.mixer.Channel: Sound channel if played, None otherwise """ keys = [k for k in sounds.keys() if k.startswith(soundName)] if not keys: return None randomKey = random.choice(keys) volume, left, right = calculate_volume_and_pan(playerX, objectX, playerY, objectY) if volume <= 0: return None # Set loop parameter to -1 for infinite loop or 0 for no loop loopParam = -1 if loop else 0 channel = sounds[randomKey].play(loopParam) if channel: channel.set_volume( volume * left * volumeService.sfxVolume, volume * right * volumeService.sfxVolume ) return channel def play_directional_sound(sounds, soundName, playerPos, objPos, playerY=0, objY=0, centerDistance=3, volume=1.0, loop=False): """Play a sound with simplified directional audio. For sounds that need to be heard clearly regardless of distance, but still provide directional feedback. Sound plays at full volume but pans left/right based on relative position. Args: sounds (dict): Dictionary of sound objects soundName (str): Name of sound to play playerPos (float): Player's x position objPos (float): Object's x position playerY (float): Player's y position (default: 0) objY (float): Object's y position (default: 0) centerDistance (float): Distance within which sound plays center (default: 3) volume (float): Base volume multiplier (0.0-1.0, default: 1.0) loop (bool): Whether to loop the sound (default: False) Returns: pygame.mixer.Channel: The channel the sound is playing on """ # Check vertical distance vDistance = abs(playerY - objY) maxYDistance = 20 # Maximum audible vertical distance if vDistance > maxYDistance: return None # Don't play if out of vertical range # Calculate vertical volume factor vVolume = (maxYDistance - vDistance) / maxYDistance finalVolume = volume * vVolume * volumeService.get_sfx_volume() # Set loop parameter to -1 for infinite loop or 0 for no loop loopParam = -1 if loop else 0 channel = sounds[soundName].play(loopParam) if channel: # If player is within centerDistance tiles of object, play in center if abs(playerPos - objPos) <= centerDistance: # Equal volume in both speakers (center) channel.set_volume(finalVolume, finalVolume) elif playerPos > objPos: # Object is to the left of player channel.set_volume(finalVolume, (finalVolume + 0.01) / 2) else: # Object is to the right of player channel.set_volume((finalVolume + 0.01) / 2, finalVolume) return channel def cut_scene(sounds, soundName): """Play a sound as a cut scene, stopping other sounds. Args: sounds (dict): Dictionary of sound objects soundName (str): Name of sound to play """ pygame.event.clear() pygame.mixer.stop() # Get the reserved channel (0) for cut scenes channel = pygame.mixer.Channel(0) # Apply the appropriate volume settings sfxVolume = volumeService.get_sfx_volume() channel.set_volume(sfxVolume, sfxVolume) # Play the sound channel.play(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 pygame.time.delay(10) def play_random_falling(sounds, soundName, playerX, objectX, startY, currentY=0, playerY=0, maxY=20, existingChannel=None, loop=False): """Play or update a falling sound with positional audio and volume based on height. Args: sounds (dict): Dictionary of sound objects soundName (str): Base name of sound to match playerX (float): Player's x position objectX (float): Object's x position startY (float): Starting Y position (0-20, higher = quieter start) currentY (float): Current Y position (0 = ground level) (default: 0) playerY (float): Player's Y position (default: 0) maxY (float): Maximum Y value (default: 20) existingChannel: Existing sound channel to update (default: None) loop (bool): Whether to loop the sound (default: False) Returns: pygame.mixer.Channel: Sound channel for updating position/volume, or None if sound should stop """ # Calculate horizontal and vertical positioning volume, left, right = calculate_volume_and_pan(playerX, objectX, playerY, currentY) # Calculate vertical fall volume multiplier (0 at maxY, 1 at y=0) fallMultiplier = 1 - (currentY / maxY) # Adjust final volumes finalVolume = volume * fallMultiplier finalLeft = left * finalVolume finalRight = right * finalVolume if existingChannel is not None: if volume <= 0: # Out of audible range existingChannel.stop() return None existingChannel.set_volume( finalLeft * volumeService.sfxVolume, finalRight * volumeService.sfxVolume ) return existingChannel else: # Need to create new channel if volume <= 0: # Don't start if out of range return None # Find matching sound files keys = [k for k in sounds.keys() if k.startswith(soundName)] if not keys: return None randomKey = random.choice(keys) # Set loop parameter to -1 for infinite loop or 0 for no loop loopParam = -1 if loop else 0 channel = sounds[randomKey].play(loopParam) if channel: channel.set_volume( finalLeft * volumeService.sfxVolume, finalRight * volumeService.sfxVolume ) return channel