diff --git a/README.md b/README.md index 785ecf6..39a8dd6 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A Python library to make creating audio games easier. `libstormgames` provides a comprehensive set of tools for developing accessible games with audio-first design. It handles common game development tasks including: -- Sound and music playback with positional audio +- Sound and music playback with positional audio and 3D spatialization - Text-to-speech integration - Configuration management - Score tracking and high score tables @@ -28,6 +28,7 @@ A Python library to make creating audio games easier. - numpy>=1.19.0 - wxpython + #### Speech Providers (one required) - Linux/Unix: `python-speechd` or `accessible-output2>=0.14` @@ -76,14 +77,16 @@ def main(): # Define menu options while True: - choice = sg.game_menu(sounds) - if choice == "play": - play_game() + choice = sg.game_menu(sounds, play_game) + + # The game_menu function already includes standard options like + # high scores, instructions, credits, and exit if __name__ == "__main__": main() ``` + ### Modern Class-Based Approach ```python @@ -107,9 +110,10 @@ def main(): # Define menu options while True: - choice = sg.game_menu(game.sound.get_sounds()) - if choice == "play": - play_game() + choice = sg.game_menu(game.sound.sounds, play_game) + + # The game_menu function already includes standard options like + # high scores, instructions, credits, and exit if __name__ == "__main__": main() @@ -122,7 +126,7 @@ The library is organized into modules, each with a specific focus: - **config**: Configuration management - **services**: Core services that replace global variables -- **sound**: Sound and music playback +- **sound**: Sound and music playback with 3D positional audio - **speech**: Text-to-speech functionality - **scoreboard**: High score tracking - **input**: Input handling and dialogs @@ -149,11 +153,9 @@ game.scoreboard.increase_score(10) game.sound.play_random("explosion") # Display text - game.display_text(["Line 1", "Line 2"]) # Clean exit - game.exit() ``` @@ -205,444 +207,85 @@ Configuration files are automatically stored in standard system locations: Each game has its own subdirectory based on the game name (lowercase with hyphens), for example, Wicked Quest becomes wicked-quest. - -### Sound - -Manages sound loading and playback with positional audio support. - -```python # Create a sound manager sound_system = sg.Sound("sounds/") # Get the dictionary of loaded sounds -sounds = sound_system.get_sounds() +sounds = sound_system.sounds # Play a sound -sg.play_sound(sounds["explosion"]) +channel = sound_system.play_sound("explosion") -# Play a sound with positional audio (player at x=5, object at x=10) -channel = sg.obj_play(sounds, "footsteps", 5, 10) +# Play a looping sound +channel = sound_system.play_sound("ambient", loop=True) + +# Play a sound with 3D positional audio (horizontal and vertical positioning) +channel = sound_system.obj_play("footsteps", + playerPos=5, objPos=10, + playerY=0, objY=5, + loop=True) # Update sound position as player or object moves -channel = sg.obj_update(channel, 6, 10) # Player moved to x=6 +channel = sound_system.obj_update(channel, 6, 10, 0, 5) # Player moved to x=6 # Stop the sound +channel = sound_system.obj_stop(channel) + +# Update all active looping sounds with a single call +updated_count = sound_system.update_all_active_loops(player_x=6, player_y=0) + +# Global function equivalents +channel = sg.obj_play(sounds, "footsteps", 5, 10, 0, 5, loop=True) +channel = sg.obj_update(channel, 6, 10, 0, 5) channel = sg.obj_stop(channel) # Play background music -sg.play_bgm("sounds/background.ogg") +sg.play_bgm("sounds/background.ogg", loop=True) # Adjust volume sg.adjust_master_volume(0.1) # Increase master volume sg.adjust_bgm_volume(-0.1) # Decrease background music volume sg.adjust_sfx_volume(0.1) # Increase sound effects volume -``` +# Create a sound manager +sound_system = sg.Sound("sounds/") -### Speech +# Get the dictionary of loaded sounds +sounds = sound_system.sounds -Provides text-to-speech functionality using available speech providers. +# Play a sound +channel = sound_system.play_sound("explosion") -```python -# Create a speech manager (usually you'll use the global instance) -speech = sg.Speech() +# Play a looping sound +channel = sound_system.play_sound("ambient", loop=True) -# Speak text -speech.speak("Hello, world!") +# Play a sound with 3D positional audio (horizontal and vertical positioning) +channel = sound_system.obj_play("footsteps", + playerPos=5, objPos=10, + playerY=0, objY=5, + loop=True) -# Or use the global function for convenience -sg.speak("Hello, world!") +# Update sound position as player or object moves +channel = sound_system.obj_update(channel, 6, 10, 0, 5) # Player moved to x=6 -# Speak without interrupting previous speech -sg.speak("This won't interrupt", interrupt=False) +# Stop the sound +channel = sound_system.obj_stop(channel) -# Clean up when done -exit_game() or as the class, game.exit_game() -``` +# Update all active looping sounds with a single call +updated_count = sound_system.update_all_active_loops(player_x=6, player_y=0) +# Global function equivalents +channel = sg.obj_play(sounds, "footsteps", 5, 10, 0, 5, loop=True) +channel = sg.obj_update(channel, 6, 10, 0, 5) +channel = sg.obj_stop(channel) -### Scoreboard - -Tracks scores and manages high score tables. - -```python -# Create a scoreboard -scoreboard = sg.Scoreboard() - -# Manipulate score -scoreboard.increase_score(10) -scoreboard.decrease_score(5) -current_score = scoreboard.get_score() - -# Check for high score -position = scoreboard.check_high_score() -if position: - print(f"You're in position {position}!") - -# Add high score (prompts for player name) -scoreboard.add_high_score() - -# Get all high scores -high_scores = scoreboard.get_high_scores() -for entry in high_scores: - print(f"{entry['name']}: {entry['score']}") -``` - -The Scoreboard system automatically manages high score data in the game's configuration directory: -- Linux/Unix: `~/.config/storm-games/game-name/config.ini` -- Windows: `%APPDATA%\storm-games\game-name\config.ini` -- macOS: `~/Library/Application Support/storm-games/game-name/config.ini` - -Where `game-name` is the lowercase, hyphenated version of your game title. For example, -"My Awesome Game" would use the directory `my-awesome-game`. - - -## Key Functions - -### Game Initialization and Control - -```python -# Initialize game systems (traditional approach) -sounds = sg.initialize_gui("Game Title") - -# Or use the Game class (modern approach) -game = sg.Game("Game Title").initialize() - -# Pause the game (freezes until user presses backspace) -sg.pause_game() - -# Check if user wants to exit -if sg.check_for_exit(): - sg.exit_game() - -# Exit the game properly -sg.exit_game() -``` - -### Menu System - -```python -# Basic menu example (returns option string) -choice = sg.game_menu(sounds) -if choice == "play": - # Start the game - play_game() -elif choice == "instructions": - sg.instructions() -# etc... - -# Menu with play callback (callback is executed directly) -def play_callback(): - sg.speak("Game started with callback!") - # Game code goes here - -sg.game_menu(sounds, play_callback) - -# Menu with custom options -choice = sg.game_menu(sounds, None, "practice_mode") -if choice == "practice_mode": - # Handle practice mode - practice_game() -elif choice == "difficulty": - # Handle difficulty settings - set_difficulty() -elif choice == "options": - # Handle options screen - show_options() -# etc... -``` - - -### Complete Menu Example - -```python -import libstormgames as sg -import pygame - -def main(): - # Initialize the game - sounds = sg.initialize_gui("Menu Example Game") - - # Play menu music - sg.play_bgm("sounds/music_menu.ogg") - - # Define play function with callback - def play_game(): - sg.speak("Starting game!") - # Game code here - sg.speak("Game over!") - return "menu" # Return to menu after game ends - - # Define custom menu option handlers - def practice_mode(): - sg.speak("Starting practice mode!") - # Practice mode code here - return "menu" - - def difficulty_settings(): - # Show difficulty options - options = ["easy", "normal", "hard", "back"] - current = 0 - - while True: - sg.speak(options[current]) - - event = pygame.event.wait() - if event.type == pygame.KEYDOWN: - if event.key == pygame.K_UP and current > 0: - current -= 1 - elif event.key == pygame.K_DOWN and current < len(options) - 1: - current += 1 - elif event.key == pygame.K_RETURN: - if options[current] == "back": - return "menu" - sg.speak(f"Difficulty set to {options[current]}") - return "menu" - elif event.key == pygame.K_ESCAPE: - return "menu" - - # Main menu loop - while True: - # Display menu with play callback and custom options - choice = sg.game_menu(sounds, play_game, "practice_mode", "difficulty") - - # Handle menu choices - if choice == "practice_mode": - practice_mode() - elif choice == "difficulty": - difficulty_settings() - # Other options are handled automatically by game_menu - -if __name__ == "__main__": - main() -``` - - -### Class-Based Menu Example - -```python -import libstormgames as sg -import pygame - -class MyGame: - def __init__(self): - # Create and initialize game - self.game = sg.Game("Class-Based Menu Example").initialize() - self.sounds = self.game.sound.get_sounds() - - def play_game(self): - self.game.speak("Starting game!") - # Game code here - self.game.speak("Game over!") - return "menu" - - def practice_mode(self): - self.game.speak("Starting practice mode!") - # Practice mode code here - return "menu" - - def settings(self): - # A nested menu example - submenu_options = ["graphics", "audio", "controls", "back"] - current = 0 - - while True: - self.game.speak(submenu_options[current]) - - event = pygame.event.wait() - if event.type == pygame.KEYDOWN: - if event.key == pygame.K_UP and current > 0: - current -= 1 - elif event.key == pygame.K_DOWN and current < len(submenu_options) - 1: - current += 1 - elif event.key == pygame.K_RETURN: - if submenu_options[current] == "back": - return "menu" - self.game.speak(f"Selected {submenu_options[current]} settings") - return "menu" - elif event.key == pygame.K_ESCAPE: - return "menu" - - def run(self): - # Main menu loop - while True: - # Use playCallback parameter to directly call play_game when "play" is selected - choice = sg.game_menu(self.sounds, None, "practice_mode", - "settings") - - # Handle other menu options - if choice == "practice_mode": - self.practice_mode() - elif choice == "settings": - self.settings() - -# Run the game -if __name__ == "__main__": - game = MyGame() - game.run() -``` - - -### Text Display - -```python -# Display text with navigation -sg.display_text([ - "Line 1 of instructions", - "Line 2 of instructions", - "Line 3 of instructions" -]) - -# Display a simple message box -sg.messagebox("Game Over! You scored 100 points.") - -# Get text input from user -name = sg.get_input("Enter your name:", "Player") -``` - - -### Sound Effects - -```python -# Play a random variation of a sound -sg.play_random(sounds, "explosion") - -# Play positional sound with distance-based volume -sg.play_random_positional(sounds, "footsteps", player_x=5, object_x=10) - -# Play directional sound (simplified left/right positioning) -sg.play_directional_sound(sounds, "voice", player_x=5, object_x=10) - -# Play a sound as a cutscene (interrupts other sounds, waits until complete) -sg.cut_scene(sounds, "intro_speech") - -# Play or update a falling sound -channel = sg.play_random_falling(sounds, "rock", player_x=5, object_x=8, start_y=10, currentY=5) -``` - -### Utility Functions - -```python -# Check for game updates -update_info = sg.check_for_updates("1.0.0", "My Game", "https://example.com/version.json") - -# Check compatibility with library version -is_compatible = sg.check_compatibility("1.0.0", "1.2.3") - -# Sanitize a filename for any OS -safe_name = sg.sanitize_filename("User's File.txt") - -# Calculate distance between points -distance = sg.distance_2d(x1=5, y1=10, x2=8, y2=15) - -# Interpolation functions -mid_value = sg.lerp(start=0, end=10, factor=0.5) # Returns 5.0 -smooth_value = sg.smooth_step(edge0=0, edge1=10, x=5) # Smooth transition -``` - - -## Advanced Examples - -### Using the Game Class (Modern Approach) - -```python -import libstormgames as sg -import pygame -import random - -def main(): - # Create and initialize the game - game = sg.Game("My Advanced Game").initialize() - - # Set up game environment - game.play_bgm("sounds/background.ogg") - - # Main game loop - running = True - player_x = 5 - - while running: - # Process events - for event in pygame.event.get(): - if event.type == pygame.KEYDOWN: - if event.key == pygame.K_ESCAPE: - running = False - elif event.key == pygame.K_SPACE: - # Score points - game.scoreboard.increase_score(5) - game.speak(f"Score: {game.scoreboard.get_score()}") - - # Update game state - player_x += random.uniform(-0.2, 0.2) - - # Add random ambient sounds - if random.random() < 0.05: - sounds = game.sound.get_sounds() - if "ambient" in sounds: - sg.play_random_positional(sounds, "ambient", player_x, - player_x + random.uniform(-5, 5)) - - pygame.time.delay(50) - - # Game over and cleanup - game.speak("Game over!") - game.exit() - -if __name__ == "__main__": - main() -``` - - -### Complex Sound Environment - -```python -import libstormgames as sg -import time -import random - -def create_sound_environment(player_x, player_y): - sounds = sg.initialize_gui("Sound Environment Demo") - - # Place sound sources - water_x, water_y = 10, 5 - fire_x, fire_y = 15, 8 - wind_x, wind_y = 3, 12 - - # Play ambient sounds - water_channel = sg.obj_play(sounds, "water", player_x, water_x) - fire_channel = sg.obj_play(sounds, "fire", player_x, fire_x) - wind_channel = sg.obj_play(sounds, "wind", player_x, wind_x) - - # Main loop - running = True - while running: - # Simulate player movement - player_x += random.uniform(-0.5, 0.5) - player_y += random.uniform(-0.5, 0.5) - - # Update sound positions - water_channel = sg.obj_update(water_channel, player_x, water_x) - fire_channel = sg.obj_update(fire_channel, player_x, fire_x) - wind_channel = sg.obj_update(wind_channel, player_x, wind_x) - - # Occasionally play random sound - if random.random() < 0.1: - sg.play_random_positional(sounds, "creature", player_x, - player_x + random.uniform(-5, 5)) - - # Check for exit - if sg.check_for_exit(): - running = False - - time.sleep(0.1) - - # Clean up - sg.obj_stop(water_channel) - sg.obj_stop(fire_channel) - sg.obj_stop(wind_channel) - sg.exit_game() -``` +# Play background music (now with loop parameter) +sg.play_bgm("sounds/background.ogg", loop=True) +# Adjust volume +sg.adjust_master_volume(0.1) # Increase master volume +sg.adjust_bgm_volume(-0.1) # Decrease background music volume +sg.adjust_sfx_volume(0.1) # Increase sound effects volume ### Complete Game Structure with Class-Based Architecture @@ -658,7 +301,7 @@ class MyGame: # Game state self.player_x = 5 - self.player_y = 5 + self.player_y = 0 self.difficulty = "normal" # Load settings @@ -675,28 +318,53 @@ class MyGame: # Game loop running = True + + # Create enemies with positional audio + enemies = [] + for i in range(3): + enemy = { + 'x': random.uniform(10, 30), + 'y': random.uniform(-5, 5), + 'channel': None + } + # Start looping sound for each enemy + enemy['channel'] = self.game.sound.play_random_positional( + "enemy", self.player_x, enemy['x'], self.player_y, enemy['y'], loop=True + ) + enemies.append(enemy) + while running: # Update game state - self.player_x += random.uniform(-0.2, 0.2) + for enemy in enemies: + # Move enemies toward player + if enemy['x'] > self.player_x: + enemy['x'] -= 0.1 + else: + enemy['x'] += 0.1 # Handle input for event in pygame.event.get(): if event.type == pygame.KEYDOWN: if event.key == pygame.K_ESCAPE: running = False + elif event.key == pygame.K_LEFT: + self.player_x -= 1 + elif event.key == pygame.K_RIGHT: + self.player_x += 1 elif event.key == pygame.K_SPACE: self.game.scoreboard.increase_score() self.game.speak(f"Score: {self.game.scoreboard.get_score()}") - # Add some random sounds - if random.random() < 0.05: - sounds = self.game.sound.get_sounds() - if "ambient" in sounds: - sg.play_random_positional(sounds, "ambient", - self.player_x, self.player_x + random.uniform(-10, 10)) + # Update all sound positions with one call + self.game.sound.update_all_active_loops(self.player_x, self.player_y) pygame.time.delay(50) + # Stop all enemy sounds + for enemy in enemies: + if enemy['channel']: + self.game.sound.obj_stop(enemy['channel']) + # Game over position = self.game.scoreboard.check_high_score() if position: @@ -733,20 +401,12 @@ class MyGame: def run(self): # Main menu loop while True: - sounds = self.game.sound.get_sounds() - choice = sg.game_menu(sounds, self.play_game, "settings", - "instructions", "credits", "donate", "exit_game") + # Use playCallback for play option and add custom "settings" option + # Note: standard options like instructions, high_scores, etc. are handled automatically + choice = sg.game_menu(self.game.sound.sounds, self.play_game, "settings") if choice == "settings": self.settings() - elif choice == "instructions": - sg.instructions() - elif choice == "credits": - sg.credits() - elif choice == "donate": - sg.donate() - elif choice == "exit_game": - self.game.exit() # Run the game if __name__ == "__main__": @@ -757,24 +417,29 @@ if __name__ == "__main__": ## Best Practices -1. **Modern vs Traditional Approach**: +1. **Sound Looping and Positioning**: + - Use the `loop=True` parameter for sounds that need to loop + - For moving sound sources, use `update_all_active_loops()` to efficiently update all sounds at once + - Include vertical positioning (Y-axis) if you need 3D audio + +2. **Modern vs Traditional Approach**: - New projects: Use the Game class for better organization - Existing projects: Continue with global functions for compatibility - Both approaches are fully supported -2. **Always clean up resources**: +3. **Always clean up resources**: - Use `exit_game()` or `game.exit()` when exiting to ensure proper cleanup - - Stop sounds that are no longer needed + - Stop sounds that are no longer needed with `obj_stop()` -3. **Volume control**: +4. **Volume control**: - Implement the Alt+key volume controls in your game - Use volume services for better control -4. **Configuration**: +5. **Configuration**: - Save user preferences using the Config class - Load settings at startup -5. **Path initialization**: +6. **Path initialization**: - Always initialize the framework with a proper game title - Game title is used to determine configuration directory paths - Services are interconnected, so proper initialization ensures correct operation @@ -799,6 +464,7 @@ if __name__ == "__main__": - Ensure pygame is properly handling events - Check event loop for proper event handling +- Remember to use pygame.event.pump() especially in the game loop ### Scoreboard/Configuration Issues diff --git a/sound.py b/sound.py index e395af1..b68cdf4 100644 --- a/sound.py +++ b/sound.py @@ -1,11 +1,12 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -"""Sound handling for Storm Games. +"""Enhanced sound handling for Storm Games. Provides functionality for: - Playing background music and sound effects -- Positional audio +- Positional audio (horizontal and vertical) - Volume controls +- Sound looping with proper channel control """ import os @@ -33,6 +34,7 @@ class Sound: 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(): @@ -80,12 +82,13 @@ class Sound: except Exception as e: print(f"Error loading sounds: {e}") - def play_sound(self, soundName, volume=1.0): + 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 @@ -94,30 +97,84 @@ class Sound: return None sound = self.sounds[soundName] - channel = sound.play() + + # 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 calculate_volume_and_pan(self, playerPos, objPos, maxDistance=12): + 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 - maxDistance (float): Maximum audible distance (default: 12) + 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 """ - distance = abs(playerPos - objPos) + # Calculate horizontal distance + hDistance = abs(playerPos - objPos) - if distance > maxDistance: + # 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 volume (non-linear scaling for more noticeable changes) - # Apply masterVolume as the maximum possible volume - volume = (((maxDistance - distance) / maxDistance) ** 1.5) * self.volumeService.masterVolume + # 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: @@ -134,14 +191,16 @@ class Sound: return volume, left, right - def obj_play(self, soundName, playerPos, objPos, loop=True): + 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 - loop (bool): Whether to loop the sound (default: True) + 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 @@ -149,26 +208,44 @@ class Sound: if soundName not in self.sounds: return None - volume, left, right = self.calculate_volume_and_pan(playerPos, objPos) - if volume == 0: + 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(-1 if loop else 0) + 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): + 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 @@ -176,9 +253,15 @@ class Sound: if channel is None: return None - volume, left, right = self.calculate_volume_and_pan(playerPos, objPos) - if volume == 0: + 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 @@ -186,6 +269,16 @@ class Sound: 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): @@ -199,26 +292,34 @@ class Sound: """ 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): + 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 soundName in soundNames: - if pygame.mixer.find_channel(True) and pygame.mixer.find_channel(True).get_busy(): - return None - + # 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 @@ -227,22 +328,43 @@ class Sound: if ambianceSound not in self.sounds: return None - channel = self.sounds[ambianceSound].play() + # Set loop parameter to -1 for infinite loop or 0 for no loop + loopParam = -1 if loop else 0 - if randomLocation and channel: - leftVolume = random.random() * self.volumeService.get_sfx_volume() - rightVolume = random.random() * self.volumeService.get_sfx_volume() - channel.set_volume(leftVolume, rightVolume) + 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): + 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(): @@ -256,26 +378,41 @@ class Sound: if interrupt: self.cut_scene(randomKey) - return + return None - channel = self.sounds[randomKey].play() + # 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: + if pause and not loop: time.sleep(self.sounds[randomKey].get_length()) return channel - def play_random_positional(self, soundPrefix, playerX, objectX): + 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 @@ -285,20 +422,37 @@ class Sound: return None randomKey = random.choice(keys) - volume, left, right = self.calculate_volume_and_pan(playerX, objectX) + volume, left, right = self.calculate_volume_and_pan(playerX, objectX, playerY, objectY) - if volume == 0: + if volume <= 0: return None - channel = self.sounds[randomKey].play() + # 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, centerDistance=3, volume=1.0): + 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 @@ -308,8 +462,11 @@ class Sound: 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 @@ -317,11 +474,22 @@ class Sound: if soundName not in self.sounds: return None - channel = self.sounds[soundName].play() - if channel: - # Apply volume settings - finalVolume = volume * self.volumeService.get_sfx_volume() + # 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) @@ -332,8 +500,23 @@ class Sound: 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. @@ -346,6 +529,9 @@ class Sound: 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) @@ -364,7 +550,7 @@ class Sound: pygame.time.delay(10) def play_random_falling(self, soundPrefix, playerX, objectX, startY, - currentY=0, maxY=20, existingChannel=None): + 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: @@ -373,15 +559,17 @@ class Sound: 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 positioning - volume, left, right = self.calculate_volume_and_pan(playerX, objectX) + # 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) @@ -392,16 +580,33 @@ class Sound: finalRight = right * finalVolume if existingChannel is not None: - if volume == 0: # Out of audible range + 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 + if volume <= 0: # Don't start if out of range return None # Find matching sound files @@ -410,30 +615,138 @@ class Sound: return None randomKey = random.choice(keys) - channel = self.sounds[randomKey].play() + + # 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): +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()) - pygame.mixer.music.play(-1) # Loop indefinitely + + # 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: - pass + print(f"Error playing background music: {e}") + return False def adjust_master_volume(change): """Adjust the master volume for all sounds. @@ -459,25 +772,38 @@ def adjust_sfx_volume(change): """ volumeService.adjust_sfx_volume(change, pygame.mixer) -def calculate_volume_and_pan(playerPos, objPos): +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 """ - distance = abs(playerPos - objPos) - maxDistance = 12 # Maximum audible distance + # Calculate horizontal distance + hDistance = abs(playerPos - objPos) - if distance > maxDistance: + # 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 volume (non-linear scaling for more noticeable changes) - # Apply masterVolume as the maximum possible volume - volume = (((maxDistance - distance) / maxDistance) ** 1.5) * volumeService.masterVolume + # 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: @@ -494,22 +820,26 @@ def calculate_volume_and_pan(playerPos, objPos): return volume, left, right -def play_sound(sound, volume=1.0): +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 """ - channel = sound.play() + # 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, loop=True): +def obj_play(sounds, soundName, playerPos, objPos, playerY=0, objY=0, loop=False): """Play a sound with positional audio. Args: @@ -517,29 +847,38 @@ def obj_play(sounds, soundName, playerPos, objPos, loop=True): soundName (str): Name of sound to play playerPos (float): Player's position for audio panning objPos (float): Object's position for audio panning - loop (bool): Whether to loop the sound (default: True) + 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) - if volume == 0: + 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(-1 if loop else 0) + channel = sounds[soundName].play(loopParam) if channel: - channel.set_volume(volume * left * volumeService.sfxVolume, - volume * right * volumeService.sfxVolume) + channel.set_volume( + volume * left * volumeService.sfxVolume, + volume * right * volumeService.sfxVolume + ) return channel -def obj_update(channel, playerPos, objPos): +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 @@ -547,14 +886,16 @@ def obj_update(channel, playerPos, objPos): if channel is None: return None - volume, left, right = calculate_volume_and_pan(playerPos, objPos) - if volume == 0: + 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) + channel.set_volume( + volume * left * volumeService.sfxVolume, + volume * right * volumeService.sfxVolume + ) return channel def obj_stop(channel): @@ -572,7 +913,7 @@ def obj_stop(channel): except: return channel -def play_ambiance(sounds, soundNames, probability, randomLocation=False): +def play_ambiance(sounds, soundNames, probability, randomLocation=False, loop=False): """Play random ambient sounds with optional positional audio. Args: @@ -580,30 +921,41 @@ def play_ambiance(sounds, soundNames, probability, randomLocation=False): 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 soundName in soundNames: - if pygame.mixer.find_channel(True) and pygame.mixer.find_channel(True).get_busy(): - return None - + # 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) - channel = sounds[ambianceSound].play() - if randomLocation and channel: - leftVolume = random.random() * volumeService.get_sfx_volume() - rightVolume = random.random() * volumeService.get_sfx_volume() - channel.set_volume(leftVolume, rightVolume) + # 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): +def play_random(sounds, soundName, pause=False, interrupt=False, loop=False): """Play a random variation of a sound. Args: @@ -611,6 +963,10 @@ def play_random(sounds, soundName, pause=False, interrupt=False): 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(): @@ -618,23 +974,28 @@ def play_random(sounds, soundName, pause=False, interrupt=False): key.append(i) if not key: # No matching sounds found - return + return None randomKey = random.choice(key) if interrupt: cut_scene(sounds, randomKey) - return + return None - channel = sounds[randomKey].play() + # 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: + if pause and not loop: time.sleep(sounds[randomKey].get_length()) + + return channel -def play_random_positional(sounds, soundName, playerX, objectX): +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: @@ -642,6 +1003,9 @@ def play_random_positional(sounds, soundName, playerX, objectX): 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 @@ -651,18 +1015,23 @@ def play_random_positional(sounds, soundName, playerX, objectX): return None randomKey = random.choice(keys) - volume, left, right = calculate_volume_and_pan(playerX, objectX) + volume, left, right = calculate_volume_and_pan(playerX, objectX, playerY, objectY) - if volume == 0: + if volume <= 0: return None - channel = sounds[randomKey].play() + # 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) + channel.set_volume( + volume * left * volumeService.sfxVolume, + volume * right * volumeService.sfxVolume + ) return channel -def play_directional_sound(sounds, soundName, playerPos, objPos, centerDistance=3, volume=1.0): +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 @@ -673,17 +1042,31 @@ def play_directional_sound(sounds, soundName, playerPos, objPos, centerDistance= 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 """ - channel = sounds[soundName].play() - if channel: - # Apply volume settings - finalVolume = volume * volumeService.get_sfx_volume() + # 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) @@ -724,7 +1107,7 @@ def cut_scene(sounds, soundName): pygame.time.delay(10) def play_random_falling(sounds, soundName, playerX, objectX, startY, - currentY=0, maxY=20, existingChannel=None): + 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: @@ -734,15 +1117,17 @@ def play_random_falling(sounds, soundName, playerX, objectX, startY, 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 positioning - volume, left, right = calculate_volume_and_pan(playerX, objectX) + # 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) @@ -753,14 +1138,16 @@ def play_random_falling(sounds, soundName, playerX, objectX, startY, finalRight = right * finalVolume if existingChannel is not None: - if volume == 0: # Out of audible range + if volume <= 0: # Out of audible range existingChannel.stop() return None - existingChannel.set_volume(finalLeft * volumeService.sfxVolume, - finalRight * volumeService.sfxVolume) + 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 + if volume <= 0: # Don't start if out of range return None # Find matching sound files @@ -769,8 +1156,14 @@ def play_random_falling(sounds, soundName, playerX, objectX, startY, return None randomKey = random.choice(keys) - channel = sounds[randomKey].play() + + # 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) + channel.set_volume( + finalLeft * volumeService.sfxVolume, + finalRight * volumeService.sfxVolume + ) return channel