529 lines
19 KiB
Python
529 lines
19 KiB
Python
#!/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
|