1170 lines
43 KiB
Python
1170 lines
43 KiB
Python
#!/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:
|
|
channel.set_volume(volume * self.volumeService.get_sfx_volume())
|
|
|
|
# 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:
|
|
channel.set_volume(volume * volumeService.get_sfx_volume())
|
|
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
|