Compare commits

...

10 Commits

6 changed files with 915 additions and 90 deletions

643
README.md Normal file
View File

@ -0,0 +1,643 @@
# PygStormGames Library
A Python framework for creating accessible audio games with Pyglet.
## Overview
PygStormGames is a specialized game framework built on top of Pyglet, designed specifically for creating audio-focused games with accessibility in mind. The library provides a complete set of tools for building games with rich audio feedback, text-to-speech integration, menu systems, and more.
## Core Features
- **Accessible Interface**: Built-in text-to-speech support and screen reader compatibility
- **Audio Management**: Spatial audio, sound effects, and background music
- **Menu System**: Fully accessible menu with navigation sounds
- **Configuration**: Local and global settings management
- **Scoreboard**: High score tracking and persistence
- **Text Display**: Accessible text display with navigation
## Installation
PygStormGames requires Python 3.6+ and depends on the following libraries:
- pyglet
- pyperclip
- wx (wxPython)
- xdg
- speechd or accessible_output2 (for text-to-speech)
## Getting Started
### Basic Game Structure
```python
from pygstormgames import pygstormgames
class MyGame:
def __init__(self):
# Initialize the game with a title
self.game = pygstormgames("My Audio Game")
# Set up game-specific variables and components
self.score = 0
self.gameActive = False
# Set up the main game loop
pyglet.clock.schedule_interval(self.update, 1/60.0)
def update(self, dt):
# Main game update loop
if self.gameActive:
# Update game state
pass
def start(self):
# Show main menu
selection = self.game.menu.game_menu()
if selection == "play":
# Start the game
self.gameActive = True
self.game.run()
if __name__ == "__main__":
game = MyGame()
game.start()
```
## Main Components
### pygstormgames Class
The main class that coordinates all game systems. Initialize with the game title.
```python
game = pygstormgames("My Game Title")
```
#### Methods
- `run()`: Start the game loop
- `wait(validKeys=None, timeout=None)`: Wait for key press(es) with optional timeout
- `wait_for_completion(condition, validKeys=None, timeout=None)`: Wait for a condition to be met, keys to be pressed, or timeout
- `pause_game()`: Pause all game systems
- `exit_game()`: Clean up and exit the game
### Config
Handles loading and saving of both local and global game configurations.
```python
# Get a config value (section, key, default value, global or local)
volume = game.config.get_float("audio", "master_volume", 0.8)
# Save a config value
game.config.set_value("audio", "master_volume", 0.9)
```
#### Methods
- `get_value(section, key, default=None, globalConfig=False)`: Get string value
- `set_value(section, key, value, globalConfig=False)`: Set value
- `get_int(section, key, default=0, globalConfig=False)`: Get integer value
- `get_float(section, key, default=0.0, globalConfig=False)`: Get float value
- `get_bool(section, key, default=False, globalConfig=False)`: Get boolean value
### Display
Handles text display, navigation, and information presentation.
```python
# Display text with speech output
game.display.display_text(textLines, game.speech)
# Show a message box
game.display.messagebox("Game over! Your score: 1000")
# Get text input from the user
name = game.display.get_input("Enter your name:", "Player")
```
#### Methods
- `display_text(text, speech)`: Display and navigate text with speech output
- `instructions(speech)`: Display game instructions from file
- `credits(speech)`: Display game credits from file
- `messagebox(text)`: Display a simple message box with text
- `get_input(prompt="Enter text:", defaultText="")`: Display a dialog box for text input
- `donate(speech)`: Open the donation webpage
### Menu
Handles main menu and submenu functionality for games.
```python
# Show a menu and get selection
options = ["start", "options", "exit"]
selection = game.menu.show_menu(options, "Main Menu")
# Show the default game menu
selection = game.menu.game_menu()
```
#### Methods
- `show_menu(options, title=None, with_music=False)`: Display a menu and return selected option
- `game_menu()`: Show main game menu
- `learn_sounds()`: Interactive menu for learning game sounds
### Sound
Handles all audio functionality including background music, sound effects, and volume control.
```python
# Play a sound effect
game.sound.play_sound("explosion")
# Play background music
game.sound.play_bgm("sounds/music_level1.ogg")
# Adjust volume
game.sound.adjust_master_volume(0.1) # Increase by 10%
```
#### Methods
- `play_sound(soundName, volume=1.0)`: Play a sound effect
- `play_bgm(music_file)`: Play background music
- `pause_bgm()`: Pause background music
- `resume_bgm()`: Resume background music
- `play_random(base_name, pause=False, interrupt=False)`: Play random variation of a sound
- `play_positional(soundName, source_pos, listener_pos, mode='2d')`: Play sound with positional audio
- `update_positional(player, source_pos, listener_pos, mode='2d')`: Update position of a playing sound
- `cut_scene(soundName)`: Play a sound as a cut scene
- `adjust_master_volume(change)`: Adjust master volume
- `adjust_bgm_volume(change)`: Adjust background music volume
- `adjust_sfx_volume(change)`: Adjust sound effects volume
- `get_volumes()`: Get current volume levels
- `pause()`: Pause all audio
- `resume()`: Resume all audio
- `stop_all_sounds()`: Stop all playing sounds
### Speech
Provides text-to-speech functionality with screen text display support.
```python
# Speak text
game.speech.speak("Welcome to my game!")
# Speak with no interruption
game.speech.speak("This will wait for previous speech to finish", interrupt=False)
```
#### Methods
- `speak(text, interrupt=True)`: Speak text and display it on screen
### Scoreboard
Handles high score tracking with player names and score management.
```python
# Increase the score
game.scoreboard.increase_score(100)
# Check and add high score if qualified
if game.scoreboard.check_high_score():
game.scoreboard.add_high_score()
# Get high scores
highScores = game.scoreboard.get_high_scores()
```
#### Methods
- `get_score()`: Get current score
- `get_high_scores()`: Get list of high scores
- `decrease_score(points=1)`: Decrease the current score
- `increase_score(points=1)`: Increase the current score
- `check_high_score()`: Check if current score qualifies as a high score
- `add_high_score()`: Add current score to high scores if it qualifies
## Key Event Handling
PygStormGames provides a simple way to handle keyboard input:
```python
# Wait for any key
key, modifiers = game.wait()
# Wait for specific keys
validKeys = [pyglet.window.key.SPACE, pyglet.window.key.RETURN]
key, modifiers = game.wait(validKeys)
# Wait with timeout (5 seconds)
key, modifiers = game.wait(timeout=5.0)
# Check for specific key
if key == pyglet.window.key.SPACE:
# Space was pressed
pass
```
## Directory Structure
PygStormGames expects the following directory structure:
```
your_game/
â __init__.py
â main.py
â sounds/ # Sound effects and music
â â music_menu.ogg
â â game-intro.ogg
â â ...
â files/ # Game text files
â instructions.txt
â credits.txt
```
### Required Sound Files
PygStormGames looks for these sound files, though they're optional:
- `game-intro.ogg`: Played when the game starts
- `music_menu.ogg`: Played during menu navigation
- `menu-move.ogg`: Played when navigating menu items
- `menu-select.ogg`: Played when selecting a menu item
## Advanced Usage Examples
### Creating a Custom Menu
```python
def settings_menu(self):
options = ["sound volume", "music volume", "speech rate", "back"]
while True:
selection = self.game.menu.show_menu(options, "Settings")
if selection == "sound volume":
self.adjust_sound_volume()
elif selection == "music volume":
self.adjust_music_volume()
elif selection == "speech rate":
self.adjust_speech_rate()
elif selection == "back":
return
```
### Playing Positional Audio
```python
# For 2D games (x-position only)
def update_enemy_sounds(self):
for enemy in self.enemies:
# Player at x=0, enemy at some x position
self.game.sound.play_positional(
"enemy-sound",
enemy.x, # Source position
self.player.x, # Listener position
mode='2d'
)
# For 3D games
def update_enemy_sounds_3d(self):
for enemy in self.enemies:
# 3D positions (x, y, z)
source_pos = (enemy.x, enemy.y, enemy.z)
listener_pos = (self.player.x, self.player.y, self.player.z)
self.game.sound.play_positional(
"enemy-sound",
source_pos,
listener_pos,
mode='3d'
)
```
### Creating a Countdown Timer
```python
def start_countdown(self):
for i in range(3, 0, -1):
self.game.speech.speak(str(i))
self.game.sound.play_sound(f"countdown-{i}")
# Wait 1 second
self.game.wait(timeout=1.0)
self.game.speech.speak("Go!")
self.game.sound.play_sound("start")
self.gameActive = True
```
### Saving and Loading Game Progress
```python
def save_game(self):
# Save player progress
self.game.config.set_value("save", "level", self.currentLevel)
self.game.config.set_value("save", "score", self.score)
self.game.config.set_value("save", "health", self.playerHealth)
self.game.speech.speak("Game saved.")
def load_game(self):
# Load player progress
self.currentLevel = self.game.config.get_int("save", "level", 1)
self.score = self.game.config.get_int("save", "score", 0)
self.playerHealth = self.game.config.get_int("save", "health", 100)
self.game.speech.speak(f"Game loaded. Level {self.currentLevel}")
```
## Common Patterns
### Game Loop with Pause Support
```python
def update(self, dt):
# Skip if game is paused
if self.game._paused:
return
# Update game state
self.update_player(dt)
self.update_enemies(dt)
self.check_collisions()
self.update_score()
```
### Handling Game Over
```python
def game_over(self):
# Stop gameplay sounds
self.game.sound.stop_all_sounds()
# Play game over sound
self.game.sound.play_sound("game-over")
# Display final score
finalScore = self.game.scoreboard.get_score()
self.game.speech.speak(f"Game over! Your score: {finalScore}")
# Check for high score
if self.game.scoreboard.check_high_score():
self.game.scoreboard.add_high_score()
# Wait for key press
self.game.display.messagebox("Game over!")
# Return to main menu
self.game.menu.game_menu()
```
### Creating a Timed Event System
```python
class TimedEvent:
def __init__(self, time, callback):
self.triggerTime = time
self.callback = callback
self.triggered = False
class EventSystem:
def __init__(self, game):
self.game = game
self.events = []
self.gameTime = 0
def add_event(self, delay, callback):
triggerTime = self.gameTime + delay
self.events.append(TimedEvent(triggerTime, callback))
def update(self, dt):
self.gameTime += dt
for event in self.events:
if not event.triggered and self.gameTime >= event.triggerTime:
event.callback()
event.triggered = True
# Remove triggered events
self.events = [e for e in self.events if not e.triggered]
# Usage
events = EventSystem(game)
events.add_event(5.0, lambda: game.speech.speak("Warning! Enemy approaching!"))
```
## Tips and Best Practices
1. **Audio Cues**: Always provide clear audio feedback for important actions and events.
2. **Speech Rate**: Keep speech concise and prioritize critical information.
3. **Sound Balance**: Maintain a good balance between speech, sound effects, and music.
4. **Consistent Controls**: Keep controls consistent and provide easy ways to learn them.
5. **Performance**: Be mindful of audio channel usage, especially with spatial audio.
6. **Documentation**: Include clear instructions for controls and gameplay.
7. **File Structure**: Follow the expected file structure for sounds and text files.
## Troubleshooting
### Common Issues
1. **No speech output**
- Ensure speechd or accessible_output2 is installed
2. **Sound files not playing**
- Verify file paths and formats (ogg or wav)
- Check if sound files exist in the correct directory
- Check volume settings in the game
3. **Menus not responding**
- Ensure pyglet event loop is running
- Check if key handlers are properly registered
### Debugging Tips
- Use `print` statements to track game state
- Enable more verbose logging in the speech module
- Test with minimal sound files to isolate issues
## Complete Example Game
Here's a simple example of a complete game that demonstrates the key features of PygStormGames:
```python
import pyglet
from pygstormgames import pygstormgames
import random
import math
class SimpleGame:
def __init__(self):
# Initialize game
self.game = pygstormgames("Sound Hunter")
# Game variables
self.playerX = 0
self.playerY = 0
self.targetX = 0
self.targetY = 0
self.score = 0
self.timeLeft = 60
self.gameActive = False
# Set up game loop
pyglet.clock.schedule_interval(self.update, 1/60.0)
def start_game(self):
# Reset game state
self.score = 0
self.timeLeft = 60
self.gameActive = True
self.game.scoreboard.currentScore = 0
# Place target
self.place_new_target()
# Start background music
self.game.sound.play_bgm("sounds/game_music.ogg")
# Announce game start
self.game.speech.speak("Game started. Use arrow keys to find the target.")
def place_new_target(self):
# Place target randomly within range
distance = random.uniform(5, 15)
angle = random.uniform(0, 2 * math.pi)
self.targetX = math.cos(angle) * distance
self.targetY = math.sin(angle) * distance
# Play new target sound
self.game.sound.play_sound("new-target")
def update(self, dt):
if not self.gameActive or self.game._paused:
return
# Update timer
self.timeLeft -= dt
if self.timeLeft <= 0:
self.game_over()
return
# Play positional target sound every second
if int(self.timeLeft) != int(self.timeLeft - dt):
self.game.sound.play_positional(
"target-ping",
self.targetX,
self.playerX,
mode='2d'
)
# Calculate distance to target
distance = math.sqrt((self.playerX - self.targetX)**2 +
(self.playerY - self.targetY)**2)
# Check for target collection
if distance < 1.0:
self.collect_target()
def on_key_press(self, symbol, modifiers):
if not self.gameActive:
return
# Move player
if symbol == pyglet.window.key.UP:
self.playerY += 1
elif symbol == pyglet.window.key.DOWN:
self.playerY -= 1
elif symbol == pyglet.window.key.LEFT:
self.playerX -= 1
elif symbol == pyglet.window.key.RIGHT:
self.playerX += 1
# Announce position occasionally
if random.random() < 0.1:
self.announce_position()
def announce_position(self):
# Calculate distance and direction to target
dx = self.targetX - self.playerX
dy = self.targetY - self.playerY
distance = math.sqrt(dx*dx + dy*dy)
# Create directional hint
if abs(dx) > abs(dy):
direction = "east" if dx > 0 else "west"
else:
direction = "north" if dy > 0 else "south"
# Announce distance category
if distance < 2.0:
proximity = "very close"
elif distance < 5.0:
proximity = "close"
elif distance < 10.0:
proximity = "medium distance"
else:
proximity = "far away"
self.game.speech.speak(f"Target is {proximity} to the {direction}")
def collect_target(self):
# Play collection sound
self.game.sound.play_sound("target-collect")
# Update score
self.score += 1
self.game.scoreboard.increase_score(100)
# Announce collection
self.game.speech.speak(f"Target collected! Score: {self.score}")
# Place new target
self.place_new_target()
# Add time bonus
self.timeLeft += 5
def game_over(self):
self.gameActive = False
self.game.sound.stop_all_sounds()
self.game.sound.play_sound("game-over")
finalScore = self.game.scoreboard.get_score()
self.game.speech.speak(f"Game over! Final score: {finalScore}")
# Check for high score
self.game.scoreboard.add_high_score()
# Return to menu after delay
self.game.wait(timeout=3.0)
self.start()
def start(self):
# Register key handlers
self.game.display.window.push_handlers(self.on_key_press)
# Show main menu
while True:
selection = self.game.menu.game_menu()
if selection == "play":
self.start_game()
break
elif selection == "exit":
self.game.exit_game()
break
# Start game loop
self.game.run()
if __name__ == "__main__":
game = SimpleGame()
game.start()
```
This example demonstrates using menus, speech, positional audio, and scoreboards in a simple audio-based game where the player needs to find targets using audio cues.

View File

@ -89,17 +89,20 @@ class pygstormgames:
self.speech.cleanup() self.speech.cleanup()
pyglet.app.exit() pyglet.app.exit()
def wait(self, validKeys=None): def wait(self, validKeys=None, timeout=None):
"""Wait for key press(es). """Wait for key press(es) with optional timeout.
Args: Args:
validKeys (list, optional): List of pyglet.window.key values to wait for. validKeys (list, optional): List of pyglet.window.key values to wait for.
If None, accepts any key press. If None, accepts any key press.
timeout (float, optional): Time in seconds to wait for input.
If None, waits indefinitely.
Returns: Returns:
tuple: (key, modifiers) that were pressed tuple: (key, modifiers) that were pressed. Returns (None, None) on timeout.
""" """
keyResult = [None, None] # Use list to allow modification in closure keyResult = [None, None] # Use list to allow modification in closure
start_time = time.time()
def on_key_press(symbol, modifiers): def on_key_press(symbol, modifiers):
if validKeys is None or symbol in validKeys: if validKeys is None or symbol in validKeys:
@ -110,25 +113,31 @@ class pygstormgames:
# Register temporary handler # Register temporary handler
self.display.window.push_handlers(on_key_press=on_key_press) self.display.window.push_handlers(on_key_press=on_key_press)
# Wait for valid key press # Wait for valid key press or timeout
while keyResult[0] is None: while keyResult[0] is None:
self.display.window.dispatch_events() self.display.window.dispatch_events()
pyglet.clock.tick() pyglet.clock.tick()
# Check for timeout
if timeout is not None and time.time() - start_time > timeout:
break
# Clean up # Clean up
self.display.window.remove_handlers() self.display.window.remove_handlers()
return tuple(keyResult) return tuple(keyResult)
def wait_for_completion(self, condition, validKeys=None): def wait_for_completion(self, condition, validKeys=None, timeout=None):
"""Wait for either a condition to be met or valid keys to be pressed. """Wait for either a condition to be met, valid keys to be pressed, or timeout.
Args: Args:
condition: Function that returns True when waiting should end condition: Function that returns True when waiting should end
validKeys (list, optional): List of pyglet.window.key values that can interrupt validKeys (list, optional): List of pyglet.window.key values that can interrupt
If None, uses ESCAPE, RETURN, and SPACE If None, uses ESCAPE, RETURN, and SPACE
timeout (float, optional): Time in seconds to wait before timing out
If None, waits indefinitely
Returns: Returns:
bool: True if interrupted by key press, False if condition was met bool: True if interrupted by key press or timeout, False if condition was met
""" """
if validKeys is None: if validKeys is None:
validKeys = [pyglet.window.key.ESCAPE, validKeys = [pyglet.window.key.ESCAPE,
@ -136,6 +145,7 @@ class pygstormgames:
pyglet.window.key.SPACE] pyglet.window.key.SPACE]
interrupted = [False] # Use list to allow modification in closure interrupted = [False] # Use list to allow modification in closure
start_time = time.time()
def on_key_press(symbol, modifiers): def on_key_press(symbol, modifiers):
if symbol in validKeys: if symbol in validKeys:
@ -148,6 +158,11 @@ class pygstormgames:
self.display.window.dispatch_events() self.display.window.dispatch_events()
pyglet.clock.tick() pyglet.clock.tick()
# Check for timeout
if timeout is not None and time.time() - start_time > timeout:
interrupted[0] = True
break
self.display.window.remove_handlers() self.display.window.remove_handlers()
return interrupted[0] return interrupted[0]

View File

@ -37,6 +37,8 @@ class Display:
text (list): List of text lines to display text (list): List of text lines to display
speech (Speech): Speech system for audio output speech (Speech): Speech system for audio output
""" """
# Stop bgm if present.
self.game.sound.pause_bgm()
# Store original text with blank lines for copying # Store original text with blank lines for copying
self.originalText = text.copy() self.originalText = text.copy()
@ -163,11 +165,20 @@ class Display:
app = wx.App(False) app = wx.App(False)
dialog = wx.TextEntryDialog(None, prompt, "Input", defaultText) dialog = wx.TextEntryDialog(None, prompt, "Input", defaultText)
dialog.SetValue(defaultText) dialog.SetValue(defaultText)
# Bring dialog to front and give it focus
dialog.Raise()
dialog.SetFocus()
if dialog.ShowModal() == wx.ID_OK: if dialog.ShowModal() == wx.ID_OK:
userInput = dialog.GetValue() userInput = dialog.GetValue()
else: else:
userInput = None userInput = None
dialog.Destroy() dialog.Destroy()
# Return focus to game window
self.window.activate()
return userInput return userInput
def donate(self, speech): def donate(self, speech):

38
menu.py
View File

@ -26,7 +26,7 @@ class Menu:
try: try:
if self.game.sound.currentBgm: if self.game.sound.currentBgm:
self.game.sound.currentBgm.pause() self.game.sound.currentBgm.pause()
self.game.sound.play_bgm("sounds/music_menu.ogg") self.game.sound.play_bgm("music_menu")
except: except:
pass pass
@ -171,19 +171,18 @@ class Menu:
except: except:
pass pass
self.currentIndex = 0 # Get list of available sounds, excluding music and ambiance directories
soundList = self.game.sound.get_sound_list()
# Get list of available sounds, excluding special sounds if not soundList:
soundFiles = [f for f in os.listdir("sounds/")
if isfile(join("sounds/", f))
and (f.split('.')[1].lower() in ["ogg", "wav"])
and (f.split('.')[0].lower() not in ["game-intro", "music_menu"])
and (not f.lower().startswith("_"))]
if not soundFiles:
self.game.speech.speak("No sounds available to learn.") self.game.speech.speak("No sounds available to learn.")
return "menu" return "menu"
# Sort sounds by name
soundList.sort()
self.currentIndex = 0
validKeys = [ validKeys = [
pyglet.window.key.ESCAPE, pyglet.window.key.ESCAPE,
pyglet.window.key.RETURN, pyglet.window.key.RETURN,
@ -193,8 +192,13 @@ class Menu:
pyglet.window.key.S pyglet.window.key.S
] ]
# Speak initial instructions
self.game.speech.speak("Learn game sounds. Use up and down arrow keys or W/S to navigate, Enter to play sound, Escape to exit.")
# Speak initial sound name # Speak initial sound name
self.game.speech.speak(soundFiles[self.currentIndex][:-4]) soundName = soundList[self.currentIndex]
displayName = soundName.replace("/", " in folder ")
self.game.speech.speak(f"{displayName}: 1 of {len(soundList)}")
while True: while True:
key, _ = self.game.wait(validKeys) key, _ = self.game.wait(validKeys)
@ -207,18 +211,22 @@ class Menu:
return "menu" return "menu"
if key in [pyglet.window.key.DOWN, pyglet.window.key.S]: if key in [pyglet.window.key.DOWN, pyglet.window.key.S]:
if self.currentIndex < len(soundFiles) - 1: if self.currentIndex < len(soundList) - 1:
self.game.sound.stop_all_sounds() self.game.sound.stop_all_sounds()
self.currentIndex += 1 self.currentIndex += 1
self.game.speech.speak(soundFiles[self.currentIndex][:-4]) soundName = soundList[self.currentIndex]
displayName = soundName.replace("/", " in folder ")
self.game.speech.speak(f"{displayName}: {self.currentIndex + 1} of {len(soundList)}")
if key in [pyglet.window.key.UP, pyglet.window.key.W]: if key in [pyglet.window.key.UP, pyglet.window.key.W]:
if self.currentIndex > 0: if self.currentIndex > 0:
self.game.sound.stop_all_sounds() self.game.sound.stop_all_sounds()
self.currentIndex -= 1 self.currentIndex -= 1
self.game.speech.speak(soundFiles[self.currentIndex][:-4]) soundName = soundList[self.currentIndex]
displayName = soundName.replace("/", " in folder ")
self.game.speech.speak(f"{displayName}: {self.currentIndex + 1} of {len(soundList)}")
if key == pyglet.window.key.RETURN: if key == pyglet.window.key.RETURN:
soundName = soundFiles[self.currentIndex][:-4] soundName = soundList[self.currentIndex]
self.game.sound.stop_all_sounds() self.game.sound.stop_all_sounds()
self.game.sound.play_sound(soundName) self.game.sound.play_sound(soundName)

View File

@ -3,6 +3,7 @@
Handles high score tracking with player names and score management. Handles high score tracking with player names and score management.
""" """
import pyglet
import time import time
class Scoreboard: class Scoreboard:
@ -121,6 +122,12 @@ class Scoreboard:
self.game.config.set_value("scoreboard", f"score_{i+1}", str(entry['score'])) self.game.config.set_value("scoreboard", f"score_{i+1}", str(entry['score']))
self.game.config.set_value("scoreboard", f"name_{i+1}", entry['name']) self.game.config.set_value("scoreboard", f"name_{i+1}", entry['name'])
self.game.speech.speak(f"Congratulations {name}! You got position {position} on the scoreboard!") # Force window refresh after dialog
self.game.display.window.dispatch_events()
self.game.display.messagebox(f"Congratulations {name}! You got position {position} on the scoreboard!")
time.sleep(1) time.sleep(1)
# Make sure window is still responsive
self.game.display.window.dispatch_events()
return True return True

227
sound.py
View File

@ -5,6 +5,7 @@ Handles all audio functionality including:
- Sound effects with 2D/3D positional audio - Sound effects with 2D/3D positional audio
- Volume control for master, BGM, and SFX - Volume control for master, BGM, and SFX
- Audio loading and resource management - Audio loading and resource management
- Support for organizing sounds in subdirectories
""" """
import os import os
@ -12,12 +13,15 @@ import pyglet
import random import random
import re import re
import time import time
from os.path import isfile, join from os.path import isfile, join, isdir
from pyglet.window import key from pyglet.window import key
class Sound: class Sound:
"""Handles audio playback and management.""" """Handles audio playback and management."""
# Directories to exclude from the learn sounds menu
excludedLearnDirs = ['music', 'ambiance']
def __init__(self, game): def __init__(self, game):
"""Initialize sound system. """Initialize sound system.
@ -36,50 +40,139 @@ class Sound:
self.currentBgm = None self.currentBgm = None
# Load sound resources # Load sound resources
self.sounds = self._load_sounds() self.sounds = {} # Dictionary of loaded sound objects
self.soundPaths = {} # Dictionary to track original paths
self.soundDirectories = {} # Track which directory each sound belongs to
self._load_sounds()
self.activeSounds = [] # Track playing sounds self.activeSounds = [] # Track playing sounds
def _load_sounds(self): def _load_sounds(self):
"""Load all sound files from sounds directory. """Load all sound files from sounds directory and subdirectories.
Returns: Returns:
dict: Dictionary of loaded sound objects dict: Dictionary of loaded sound objects
""" """
sounds = {} if not os.path.exists("sounds/"):
try:
soundFiles = [f for f in os.listdir("sounds/")
if isfile(join("sounds/", f))
and f.lower().endswith(('.wav', '.ogg'))]
for f in soundFiles:
name = os.path.splitext(f)[0]
sounds[name] = pyglet.media.load(f"sounds/{f}", streaming=False)
except FileNotFoundError:
print("No sounds directory found") print("No sounds directory found")
return {} return
try:
# Walk through all subdirectories
for root, dirs, files in os.walk("sounds/"):
# Process sound files in this directory
for file in files:
if file.lower().endswith(('.wav', '.ogg', '.opus')):
# Get relative path from sounds directory
rel_path = os.path.relpath(os.path.join(root, file), "sounds/")
# Extract name without extension
basename = os.path.splitext(file)[0]
# Get directory relative to sounds folder
subdir = os.path.dirname(rel_path)
# Create a sound key that maintains subdirectory structure if needed
if subdir:
sound_key = f"{subdir}/{basename}"
directory = subdir.split('/')[0] # Get top-level directory
else:
sound_key = basename
directory = ""
# Full path to the sound file
fullPath = f"sounds/{rel_path}"
# Load the sound
try:
self.sounds[sound_key] = pyglet.media.load(fullPath, streaming=False)
self.soundPaths[sound_key] = fullPath
self.soundDirectories[sound_key] = directory
except Exception as e:
print(f"Error loading sound {fullPath}: {e}")
except Exception as e: except Exception as e:
print(f"Error loading sounds: {e}") print(f"Error loading sounds: {e}")
return sounds def get_sound_list(self, excludeDirs=None, includeSpecial=False):
"""Get a list of available sounds, optionally excluding certain directories and special sounds.
def play_bgm(self, music_file):
"""Play background music with proper volume.
Args: Args:
music_file (str): Path to music file excludeDirs (list): List of directory names to exclude
includeSpecial (bool): Whether to include special sounds (logo, music_menu, etc.)
Returns:
list: List of sound keys
"""
if excludeDirs is None:
excludeDirs = self.excludedLearnDirs
# Special sound names to exclude unless specifically requested
specialSounds = ["music_menu", "logo", "game-intro"]
# Filter sounds based on their directories and special names
filtered_sounds = []
for key, directory in self.soundDirectories.items():
# Skip sounds in excluded directories
if directory in excludeDirs:
continue
# Skip special sounds unless specifically requested
basename = key.split('/')[-1] # Get the filename without directory
if not includeSpecial and (basename in specialSounds or basename.startswith('_')):
continue
filtered_sounds.append(key)
return filtered_sounds
def play_bgm(self, music_name):
"""Play background music with proper volume and looping.
Args:
music_name (str): Name of the music file in the sounds directory
Can be just the name (e.g., "music_menu")
or include subdirectory (e.g., "music/title")
""" """
try: try:
# Clean up old player
if self.currentBgm: if self.currentBgm:
self.currentBgm.pause() self.currentBgm.pause()
self.currentBgm = None
# Load and play new music # Check if the music is in the loaded sounds library
music = pyglet.media.load(music_file, streaming=True) if music_name in self.sounds:
# Use the already loaded sound
music = self.sounds[music_name]
music_file = None
else:
# Try to load with extensions if not found
for ext in ['.ogg', '.wav', '.opus']:
if music_name.endswith(ext):
music_file = f"sounds/{music_name}"
break
elif os.path.exists(f"sounds/{music_name}{ext}"):
music_file = f"sounds/{music_name}{ext}"
break
# If we didn't find a file, try the direct path
if not music_file:
music_file = f"sounds/{music_name}"
if not os.path.exists(music_file):
music_file += ".ogg" # Default to .ogg if no extension
# Load the music file
music = pyglet.media.load(music_file, streaming=True)
# Create and configure player
player = pyglet.media.Player() player = pyglet.media.Player()
player.queue(music)
player.loop = True
player.volume = self.bgmVolume * self.masterVolume player.volume = self.bgmVolume * self.masterVolume
player.queue(music)
player.play() player.play()
player.on_eos = lambda: (player.queue(music), player.play())
# Store reference
self.currentBgm = player self.currentBgm = player
except Exception as e: except Exception as e:
print(f"Error playing background music: {e}") print(f"Error playing background music: {e}")
@ -97,13 +190,33 @@ class Sound:
"""Play a sound effect with volume settings. """Play a sound effect with volume settings.
Args: Args:
soundName (str): Name of sound to play sound_name (str): Name of sound to play, can include subdirectory path
e.g. "explosion" or "monsters/growl"
volume (float): Base volume for sound (0.0-1.0) volume (float): Base volume for sound (0.0-1.0)
Returns: Returns:
pyglet.media.Player: Sound player object pyglet.media.Player: Sound player object
""" """
if soundName not in self.sounds: if soundName not in self.sounds:
# Try adding .ogg extension for direct file paths
if not soundName.endswith(('.wav', '.ogg', '.opus')):
# Try to find the sound with various extensions
for ext in ['.ogg', '.wav', '.opus']:
testName = f"{soundName}{ext}"
if os.path.exists(f"sounds/{testName}"):
try:
sound = pyglet.media.load(f"sounds/{testName}", streaming=False)
player = pyglet.media.Player()
player.queue(sound)
player.volume = volume * self.sfxVolume * self.masterVolume
player.play()
self.activeSounds.append(player)
return player
except Exception:
pass
print(f"Sound not found: {soundName}")
return None return None
player = pyglet.media.Player() player = pyglet.media.Player()
@ -114,16 +227,24 @@ class Sound:
self.activeSounds.append(player) self.activeSounds.append(player)
return player return player
def play_random(self, base_name, pause=False, interrupt=False): def play_random(self, baseName, pause=False, interrupt=False):
"""Play random variation of a sound. """Play random variation of a sound.
Args: Args:
base_name (str): Base name of sound baseName (str): Base name of sound, can include subdirectory
pause (bool): Wait for sound to finish pause (bool): Wait for sound to finish
interrupt (bool): Stop other sounds interrupt (bool): Stop other sounds
""" """
# Check if baseName includes a directory
if '/' in baseName:
dirPart = os.path.dirname(baseName)
namePart = os.path.basename(baseName)
pattern = f"^{dirPart}/.*{namePart}.*"
else:
pattern = f"^{baseName}.*"
matches = [name for name in self.sounds.keys() matches = [name for name in self.sounds.keys()
if re.match(f"^{base_name}.*", name)] if re.match(pattern, name)]
if not matches: if not matches:
return None return None
@ -169,25 +290,26 @@ class Sound:
return (x, y, z) return (x, y, z)
def play_positional(self, soundName, source_pos, listener_pos, mode='2d', def play_positional(self, soundName, sourcePos, listenerPos, mode='2d',
direction=None, cone_angles=None): direction=None, coneAngles=None):
"""Play sound with positional audio. """Play sound with positional audio.
Args: Args:
soundName (str): Name of sound to play soundName (str): Name of sound to play, can include subdirectory
source_pos: Position of sound source (float for 2D, tuple for 3D) sourcePos: Position of sound source (float for 2D, tuple for 3D)
listener_pos: Position of listener (float for 2D, tuple for 3D) listenerPos: Position of listener (float for 2D, tuple for 3D)
mode: '2d' or '3d' to specify positioning mode mode: '2d' or '3d' to specify positioning mode
direction: Optional tuple (x,y,z) for directional sound direction: Optional tuple (x,y,z) for directional sound
cone_angles: Optional tuple (inner, outer) angles for sound cone coneAngles: Optional tuple (inner, outer) angles for sound cone
Returns: Returns:
pyglet.media.Player: Sound player object pyglet.media.Player: Sound player object
""" """
if soundName not in self.sounds: if soundName not in self.sounds:
print(f"Sound not found for positional audio: {soundName}")
return None return None
position = self.calculate_positional_audio(source_pos, listener_pos, mode) position = self.calculate_positional_audio(sourcePos, listenerPos, mode)
if position is None: # Too far to hear if position is None: # Too far to hear
return None return None
@ -199,29 +321,29 @@ class Sound:
# Set up directional audio if specified # Set up directional audio if specified
if direction and mode == '3d': if direction and mode == '3d':
player.cone_orientation = direction player.cone_orientation = direction
if cone_angles: if coneAngles:
player.cone_inner_angle, player.cone_outer_angle = cone_angles player.cone_inner_angle, player.cone_outer_angle = coneAngles
player.cone_outer_gain = 0.5 # Reduced volume outside cone player.cone_outer_gain = 0.5 # Reduced volume outside cone
player.play() player.play()
self.activeSounds.append(player) self.activeSounds.append(player)
return player return player
def update_positional(self, player, source_pos, listener_pos, mode='2d', def update_positional(self, player, sourcePos, listenerPos, mode='2d',
direction=None): direction=None):
"""Update position of a playing sound. """Update position of a playing sound.
Args: Args:
player: Sound player to update player: Sound player to update
source_pos: New source position sourcePos: New source position
listener_pos: New listener position listenerPos: New listener position
mode: '2d' or '3d' positioning mode mode: '2d' or '3d' positioning mode
direction: Optional new direction for directional sound direction: Optional new direction for directional sound
""" """
if not player or not player.playing: if not player or not player.playing:
return return
position = self.calculate_positional_audio(source_pos, listener_pos, mode) position = self.calculate_positional_audio(sourcePos, listenerPos, mode)
if position is None: if position is None:
player.pause() player.pause()
return return
@ -231,18 +353,37 @@ class Sound:
player.cone_orientation = direction player.cone_orientation = direction
def cut_scene(self, soundName): def cut_scene(self, soundName):
"""Play a sound as a cut scene, stopping other sounds and waiting for completion.""" """Play a sound as a cut scene, stopping other sounds and waiting for completion.
Args:
soundName (str): Name of sound to play, can include subdirectory
"""
# Stop all current sounds # Stop all current sounds
self.stop_all_sounds() self.stop_all_sounds()
if self.currentBgm: if self.currentBgm:
self.currentBgm.pause() self.currentBgm.pause()
if soundName not in self.sounds: # Find all matching sound variations
if '/' in soundName:
dirPart = os.path.dirname(soundName)
namePart = os.path.basename(soundName)
pattern = f"^{dirPart}/.*{namePart}.*"
else:
pattern = f"^{soundName}.*"
matches = [name for name in self.sounds.keys()
if re.match(pattern, name)]
if not matches:
print(f"No matching sounds found for cut scene: {soundName}")
return return
# Pick a random variation
selectedSound = random.choice(matches)
# Create and configure the player # Create and configure the player
player = pyglet.media.Player() player = pyglet.media.Player()
player.queue(self.sounds[soundName]) player.queue(self.sounds[selectedSound])
player.volume = self.sfxVolume * self.masterVolume player.volume = self.sfxVolume * self.masterVolume
# Start playback # Start playback
@ -250,7 +391,7 @@ class Sound:
# Make sure to give pyglet enough cycles to start playing # Make sure to give pyglet enough cycles to start playing
startTime = time.time() startTime = time.time()
duration = self.sounds[soundName].duration duration = self.sounds[selectedSound].duration
pyglet.clock.tick() pyglet.clock.tick()
# Wait for completion or skip # Wait for completion or skip