diff --git a/README.md b/README.md index 9cb35d4..10922a4 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,627 @@ # libstormgames -Library to make writing audiogames easier. +A Python library to make creating audio games easier. -## Requirements -configparser pyxdg pygame pyperclip requests setproctitle -### For mac os X and windows -accessible_output2 +## Overview + +`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 +- Text-to-speech integration +- Configuration management +- Score tracking and high score tables +- Input handling and keyboard controls +- Menu systems and text display +- GUI initialization + +## Installation + +### Requirements + +- pygame>=2.0.0 +- pyperclip>=1.8.0 +- requests>=2.25.0 +- pyxdg>=0.27 +- setproctitle>=1.2.0 +- numpy>=1.19.0 +- wxpython + +#### Speech Providers (one required) + +- Linux/Unix: `python-speechd` or `accessible-output2>=0.14` +- Windows/macOS: `accessible-output2>=0.14` + +### Install from source + +```bash +git clone https://git.stormux.org/storm/libstormgames +cd libstormgames +pip install -e . +``` + +## Getting Started + +You can use libstormgames in two ways: the traditional function-based approach or the new class-based approach. + +### Traditional Function-Based Approach + +```python +#!/usr/bin/env python3 +import libstormgames as sg + +def main(): + # Initialize the game + sounds = sg.initialize_gui("My First Audio Game") + + # Welcome message + sg.speak("Welcome to My First Audio Game!") + + # Create a scoreboard + scoreboard = sg.Scoreboard() + + # Main game loop + def play_game(): + sg.speak("Game started!") + scoreboard.increase_score(10) + sg.speak(f"Your score is {scoreboard.get_score()}") + scoreboard.add_high_score() + return "menu" # Return to menu after game ends + + # Define menu options + while True: + choice = sg.game_menu(sounds, "play_game", "instructions", "credits", "donate", "exit_game") + if choice == "play_game": + play_game() + +if __name__ == "__main__": + main() +``` + +### Modern Class-Based Approach + +```python +#!/usr/bin/env python3 +import libstormgames as sg + +def main(): + # Create and initialize a game + game = sg.Game("My First Audio Game").initialize() + + # Welcome message (using fluent API) + game.speak("Welcome to My First Audio Game!") + + # Main game loop + def play_game(): + game.speak("Game started!") + game.scoreboard.increase_score(10) + game.speak(f"Your score is {game.scoreboard.get_score()}") + game.scoreboard.add_high_score() + return "menu" + + # Define menu options + while True: + choice = sg.game_menu(game.sound.get_sounds(), "play_game", "instructions", "credits", "donate", "exit_game") + if choice == "play_game": + play_game() + +if __name__ == "__main__": + main() +``` + +## Library Structure + +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 +- **speech**: Text-to-speech functionality +- **scoreboard**: High score tracking +- **input**: Input handling and dialogs +- **display**: Text display and GUI functionality +- **menu**: Menu systems +- **utils**: Utility functions and Game class + +## Core Classes + +### Game + +The Game class provides a central way to manage all game systems: + +```python +# Create and initialize a game +game = sg.Game("My Game").initialize() + +# Use fluent API for chaining commands +game.speak("Hello").play_bgm("music/theme.ogg") + +# Access components directly +game.scoreboard.increase_score(10) +game.sound.play_random("explosion") + +# Display text +game.display_text(["Line 1", "Line 2"]) + +# Clean exit +game.exit() +``` + +### Services + +The library includes several service classes that replace global variables: + +```python +# Volume service manages all volume settings +volume = sg.VolumeService.get_instance() +volume.adjust_master_volume(0.1) + +# Path service manages file paths +paths = sg.PathService.get_instance() +print(paths.game_path) + +# Config service manages configuration +config = sg.ConfigService.get_instance() +``` + +### Config + +Handles configuration file management with local and global settings. + +```python +# Create a configuration manager +config = sg.Config("My Game") + +# Access the local configuration +config.local_config.add_section("settings") +config.local_config.set("settings", "difficulty", "easy") +config.write_local_config() + +# Read settings +config.read_local_config() +difficulty = config.local_config.get("settings", "difficulty") +``` + +### 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() + +# Play a sound +sg.play_sound(sounds["explosion"]) + +# Play a sound with positional audio (player at x=5, object at x=10) +channel = sg.obj_play(sounds, "footsteps", 5, 10) + +# Update sound position as player or object moves +channel = sg.obj_update(channel, 6, 10) # Player moved to x=6 + +# Stop the sound +channel = sg.obj_stop(channel) + +# Play background music +sg.play_bgm("sounds/background.ogg") + +# 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 +``` + +### Speech + +Provides text-to-speech functionality using available speech providers. + +```python +# Create a speech manager (usually you'll use the global instance) +speech = sg.Speech() + +# Speak text +speech.speak("Hello, world!") + +# Or use the global function for convenience +sg.speak("Hello, world!") + +# Speak without interrupting previous speech +sg.speak("This won't interrupt", interrupt=False) + +# Clean up when done +speech.close() +``` + +### 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']}") +``` + +## 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 +# Display a menu with options (functions should exist with these names) +choice = sg.game_menu(sounds, "play_game", "high_scores", "instructions", "credits", "exit_game") + +# Display built-in instructions +sg.instructions() + +# Display built-in credits +sg.credits() + +# Open donation page +sg.donate() + +# Interactive menu to learn available sounds +sg.learn_sounds(sounds) +``` + +### 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() +``` + +### Complete Game Structure with Class-Based Architecture + +```python +import libstormgames as sg +import pygame +import random + +class MyGame: + def __init__(self): + # Create a Game instance that manages all subsystems + self.game = sg.Game("My Advanced Game").initialize() + + # Game state + self.player_x = 5 + self.player_y = 5 + self.difficulty = "normal" + + # Load settings + try: + self.difficulty = self.game.config_service.local_config.get("settings", "difficulty") + except: + self.game.config_service.local_config.add_section("settings") + self.game.config_service.local_config.set("settings", "difficulty", "normal") + self.game.config_service.write_local_config() + + def play_game(self): + self.game.speak(f"Starting game on {self.difficulty} difficulty") + self.game.play_bgm("sounds/game_music.ogg") + + # Game loop + running = True + while running: + # Update game state + self.player_x += random.uniform(-0.2, 0.2) + + # 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_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)) + + pygame.time.delay(50) + + # Game over + position = self.game.scoreboard.check_high_score() + if position: + self.game.speak(f"New high score! Position {position}") + self.game.scoreboard.add_high_score() + + return "menu" + + def settings(self): + options = ["easy", "normal", "hard", "back"] + current = options.index(self.difficulty) if self.difficulty in options else 1 + + while True: + self.game.speak(f"Current difficulty: {options[current]}") + + # Wait for input + 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 + self.difficulty = options[current] + self.game.config_service.local_config.set("settings", "difficulty", self.difficulty) + self.game.config_service.write_local_config() + self.game.speak(f"Difficulty set to {self.difficulty}") + return + elif event.key == pygame.K_ESCAPE: + return + + def run(self): + # Main menu loop + while True: + sounds = self.game.sound.get_sounds() + choice = sg.game_menu(sounds, "play_game", "settings", + "instructions", "credits", "donate", "exit_game") + + if choice == "play_game": + self.play_game() + elif 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__": + game = MyGame() + game.run() +``` + +## Best Practices + +1. **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**: + - Use `exit_game()` or `game.exit()` when exiting to ensure proper cleanup + - Stop sounds that are no longer needed + +3. **Volume control**: + - Implement the Alt+key volume controls in your game + - Use volume services for better control + +4. **Speech feedback**: + - Provide clear speech feedback for all actions + - Use the `interrupt` parameter to control speech priority + +5. **Sound positioning**: + - Use positional audio to create an immersive environment + - Update object positions as the game state changes + +6. **Configuration**: + - Save user preferences using the Config class + - Load settings at startup + +## Troubleshooting + +### No Sound + +- Ensure pygame mixer is properly initialized +- Check if sound files exist in the correct directory +- Verify file formats (OGG and WAV are supported) + +### No Speech + +- Make sure at least one speech provider is installed + - Linux/Unix: `python-speechd` or `accessible-output2` + - Windows/macOS: `accessible-output2` +- Check if pygame display is initialized properly + +### Input Issues + +- Ensure pygame is properly handling events +- Check event loop for proper event handling + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +This project is licensed under the GPL v3 License - see the LICENSE file for details. diff --git a/__init__.py b/__init__.py index 6664f8e..e5e667c 100755 --- a/__init__.py +++ b/__init__.py @@ -11,1089 +11,209 @@ This module provides core functionality for Storm Games including: - Game menu systems """ -from sys import exit -import configparser -import os -from os import listdir -from os.path import isfile, join -from inspect import isfunction -from xdg import BaseDirectory -from setproctitle import setproctitle -import pygame -import pyperclip -import random -import re -import requests -import textwrap -import webbrowser +# Import service classes +from .services import ( + ConfigService, + VolumeService, + PathService +) + +# Import Sound class and functions +from .sound import ( + Sound, + play_bgm, + play_sound, + adjust_bgm_volume, + adjust_sfx_volume, + adjust_master_volume, + play_ambiance, + play_random, + play_random_positional, + play_directional_sound, + obj_play, + obj_update, + obj_stop, + cut_scene, + play_random_falling, + calculate_volume_and_pan +) + +# Import Speech class and functions +from .speech import messagebox, speak, Speech + +# Import Scoreboard +from .scoreboard import Scoreboard + +# Import input functions +from .input import get_input, check_for_exit, pause_game + +# Import display functions +from .display import display_text, initialize_gui + +# Import menu functions +from .menu import game_menu, learn_sounds, instructions, credits, donate, exit_game + +# Import utility functions and Game class +from .utils import ( + Game, + check_for_updates, + get_version_tuple, + check_compatibility, + sanitize_filename, + lerp, + smooth_step, + distance_2d +) + +__version__ = '2.0.0' + +# Make all symbols available at the package level +__all__ = [ + # Services + 'ConfigService', 'VolumeService', 'PathService', + + # Sound + 'Sound', + 'play_bgm', + 'play_sound', + 'adjust_bgm_volume', + 'adjust_sfx_volume', + 'adjust_master_volume', + 'play_ambiance', + 'play_random', + 'play_random_positional', + 'play_directional_sound', + 'obj_play', + 'obj_update', + 'obj_stop', + 'cut_scene', + 'play_random_falling', + 'calculate_volume_and_pan', + + # Speech + 'messagebox', + 'speak', + 'Speech', + + # Scoreboard + 'Scoreboard', + + # Input + 'get_input', 'check_for_exit', 'pause_game', + + # Display + 'display_text', 'initialize_gui', + + # Menu + 'game_menu', 'learn_sounds', 'instructions', 'credits', 'donate', 'exit_game', + + # Game class + 'Game', + + # Utils + 'check_for_updates', 'get_version_tuple', 'check_compatibility', + 'sanitize_filename', 'lerp', 'smooth_step', 'distance_2d', + + # Re-exported functions from pygame, math, random + 'get_ticks', 'delay', 'wait', + 'sin', 'cos', 'sqrt', 'floor', 'ceil', + 'randint', 'choice', 'uniform', 'seed' +] + +# Create global instances for backward compatibility +config_service = ConfigService.get_instance() +volume_service = VolumeService.get_instance() +path_service = PathService.get_instance() + +# Set up backward compatibility hooks for initialize_gui +_original_initialize_gui = initialize_gui + +def initialize_gui_with_services(game_title): + """Wrapper around initialize_gui that initializes services.""" + # Initialize path service + path_service.initialize(game_title) + + # Connect config service to path service + config_service.set_game_info(game_title, path_service) + + # Call original initialize_gui + return _original_initialize_gui(game_title) + +# Replace initialize_gui with the wrapped version +initialize_gui = initialize_gui_with_services + +# Initialize global scoreboard constructor +_original_scoreboard_init = Scoreboard.__init__ + +def scoreboard_init_with_services(self, score=0, config_service=None, speech=None): + """Wrapper around Scoreboard.__init__ that ensures services are initialized.""" + # Use global services if not specified + if config_service is None: + config_service = ConfigService.get_instance() + + # Ensure path_service is connected if using defaults + if not hasattr(config_service, 'path_service') and path_service.game_path is not None: + config_service.path_service = path_service + + # Call original init with services + _original_scoreboard_init(self, score, config_service, speech) + +# Replace Scoreboard.__init__ with the wrapped version +Scoreboard.__init__ = scoreboard_init_with_services + +# Re-export pygame time functions for backward compatibility +import pygame.time + +def get_ticks(): + """Get the number of milliseconds since pygame.init() was called.""" + return pygame.time.get_ticks() + +def delay(milliseconds): + """Pause the program for a given number of milliseconds.""" + return pygame.time.delay(milliseconds) + +def wait(milliseconds): + """Pause the program for a given number of milliseconds.""" + return pygame.time.wait(milliseconds) + +# Re-export math functions that might be used import math -import numpy as np -import time -import wx -# Keep track if the instructions for navigating display_text has been shown -displayTextUsageInstructions = False +def sin(x): + """Return the sine of x radians.""" + return math.sin(x) -# Global variable for speech provider -try: - import speechd - spd = speechd.Client() - speechProvider = "speechd" -except ImportError: - import accessible_output2.outputs.auto - s = accessible_output2.outputs.auto.Auto() - speechProvider = "accessible_output2" -except ImportError: - print("No other speech providers found.") - exit() +def cos(x): + """Return the cosine of x radians.""" + return math.cos(x) -# Configuration objects -localConfig = configparser.ConfigParser() -globalConfig = configparser.ConfigParser() +def sqrt(x): + """Return the square root of x.""" + return math.sqrt(x) -# Volume control globals -bgmVolume = 0.75 # Default background music volume -sfxVolume = 1.0 # Default sound effects volume -masterVolume = 1.0 # Default master volume +def floor(x): + """Return the floor of x.""" + return math.floor(x) -# Handle speech delays so we don't get stuttering -_lastSpoken = {"text": None, "time": 0} -_speechDelay = 250 # ms +def ceil(x): + """Return the ceiling of x.""" + return math.ceil(x) -class Scoreboard: - """Handles high score tracking with player names.""" - - def __init__(self, score=0): - """Initialize scoreboard with optional starting score.""" - read_config() - self.currentScore = score - self.highScores = [] - - try: - localConfig.add_section("scoreboard") - except: - pass - - # Load existing high scores - for i in range(1, 11): - try: - score = localConfig.getint("scoreboard", f"score_{i}") - name = localConfig.get("scoreboard", f"name_{i}") - self.highScores.append({ - 'name': name, - 'score': score - }) - except: - self.highScores.append({ - 'name': "Player", - 'score': 0 - }) - - # Sort high scores by score value in descending order - self.highScores.sort(key=lambda x: x['score'], reverse=True) - - def get_score(self): - """Get current score.""" - return self.currentScore - - def get_high_scores(self): - """Get list of high scores.""" - return self.highScores - - def decrease_score(self, points=1): - """Decrease the current score.""" - self.currentScore -= int(points) - - def increase_score(self, points=1): - """Increase the current score.""" - self.currentScore += int(points) - - def check_high_score(self): - """Check if current score qualifies as a high score. - - Returns: - int: Position (1-10) if high score, None if not - """ - for i, entry in enumerate(self.highScores): - if self.currentScore > entry['score']: - return i + 1 - return None - - def add_high_score(self): - """Add current score to high scores if it qualifies. - - Returns: - bool: True if score was added, False if not - """ - position = self.check_high_score() - if position is None: - return False - - # Prompt for name using get_input - name = get_input("New high score! Enter your name:", "Player") - if name is None: # User cancelled - name = "Player" - - # Insert new score at correct position - self.highScores.insert(position - 1, { - 'name': name, - 'score': self.currentScore - }) - - # Keep only top 10 - self.highScores = self.highScores[:10] - - # Save to config - for i, entry in enumerate(self.highScores): - localConfig.set("scoreboard", f"score_{i+1}", str(entry['score'])) - localConfig.set("scoreboard", f"name_{i+1}", entry['name']) - - write_config() - speak(f"Congratulations {name}! You got position {position} on the scoreboard!") - time.sleep(1) - return True +# Re-export random functions that might be used +import random +def randint(a, b): + """Return a random integer N such that a <= N <= b.""" + return random.randint(a, b) -def write_config(write_global=False): - """Write configuration to file. - - Args: - write_global (bool): If True, write to global config, otherwise local (default: False) - """ - if not write_global: - with open(gamePath + "/config.ini", 'w') as configfile: - localConfig.write(configfile) - else: - with open(globalPath + "/config.ini", 'w') as configfile: - globalConfig.write(configfile) +def choice(seq): + """Return a random element from the non-empty sequence seq.""" + return random.choice(seq) -def read_config(readGlobal=False): - """Read configuration from file. - - Args: - readGlobal (bool): If True, read global config, otherwise local (default: False) - """ - if not readGlobal: - try: - with open(gamePath + "/config.ini", 'r') as configfile: - localConfig.read_file(configfile) - except: - pass - else: - try: - with open(globalPath + "/config.ini", 'r') as configfile: - globalConfig.read_file(configfile) - except: - pass +def uniform(a, b): + """Return a random floating point number N such that a <= N <= b.""" + return random.uniform(a, b) -def initialize_gui(gameTitle): - """Initialize the game GUI and sound system. - - Args: - gameTitle (str): Title of the game - - Returns: - dict: Dictionary of loaded sound objects - """ - # Check for, and possibly create, storm-games path - global globalPath - global gamePath - global gameName - - globalPath = BaseDirectory.xdg_config_home + "/storm-games" - gamePath = globalPath + "/" + str.lower(str.replace(gameTitle, " ", "-")) - if not os.path.exists(gamePath): - os.makedirs(gamePath) - - # Seed the random generator to the clock - random.seed() - - # Set game's name - gameName = gameTitle - setproctitle(str.lower(str.replace(gameTitle, " ", ""))) - - # Initialize pygame - pygame.init() - pygame.display.set_mode((800, 600)) - pygame.display.set_caption(gameTitle) - - # Set up audio system - pygame.mixer.pre_init(44100, -16, 2, 1024) - pygame.mixer.init() - pygame.mixer.set_num_channels(32) - pygame.mixer.set_reserved(0) # Reserve channel for cut scenes - - # Enable key repeat for volume controls - pygame.key.set_repeat(500, 100) - - # Load sound files - try: - soundFiles = [f for f in listdir("sounds/") - if isfile(join("sounds/", f)) - and (f.split('.')[1].lower() in ["ogg", "wav"])] - except Exception as e: - print("No sounds found.") - speak("No sounds found.", False) - soundFiles = [] - - # Create dictionary of sound objects - soundData = {} - for f in soundFiles: - soundData[f.split('.')[0]] = pygame.mixer.Sound("sounds/" + f) - - # Play intro sound if available - if 'game-intro' in soundData: - cut_scene(soundData, 'game-intro') - - return soundData - -def adjust_master_volume(change): - """Adjust the master volume for all sounds. - - Args: - change (float): Amount to change volume by (positive or negative) - """ - global masterVolume - masterVolume = max(0.0, min(1.0, masterVolume + change)) - # Update music volume - if pygame.mixer.music.get_busy(): - pygame.mixer.music.set_volume(bgmVolume * masterVolume) - # Update all sound channels - for i in range(pygame.mixer.get_num_channels()): - channel = pygame.mixer.Channel(i) - if channel.get_busy(): - current_volume = channel.get_volume() - if isinstance(current_volume, (int, float)): - # Mono audio - channel.set_volume(current_volume * masterVolume) - else: - # Stereo audio - left, right = current_volume - channel.set_volume(left * masterVolume, right * masterVolume) - -def adjust_bgm_volume(change): - """Adjust only the background music volume. - - Args: - change (float): Amount to change volume by (positive or negative) - """ - global bgmVolume - bgmVolume = max(0.0, min(1.0, bgmVolume + change)) - if pygame.mixer.music.get_busy(): - pygame.mixer.music.set_volume(bgmVolume * masterVolume) - -def adjust_sfx_volume(change): - """Adjust volume for sound effects only. - - Args: - change (float): Amount to change volume by (positive or negative) - """ - global sfxVolume - sfxVolume = max(0.0, min(1.0, sfxVolume + change)) - # Update all sound channels except reserved ones - for i in range(pygame.mixer.get_num_channels()): - channel = pygame.mixer.Channel(i) - if channel.get_busy(): - current_volume = channel.get_volume() - if isinstance(current_volume, (int, float)): - # Mono audio - channel.set_volume(current_volume * sfxVolume * masterVolume) - else: - # Stereo audio - left, right = current_volume - channel.set_volume(left * sfxVolume * masterVolume, - right * sfxVolume * masterVolume) - -def play_bgm(music_file): - """Play background music with proper volume settings. - - Args: - music_file (str): Path to the music file to play - """ - try: - pygame.mixer.music.stop() - pygame.mixer.music.load(music_file) - pygame.mixer.music.set_volume(bgmVolume * masterVolume) - pygame.mixer.music.play(-1) # Loop indefinitely - except Exception as e: - pass - -def pause_game(): - """Pauses the game""" - speak("Game paused, press backspace to resume.") - pygame.event.clear() - try: - pygame.mixer.pause() - except: - pass - - try: - pygame.mixer.music.pause() - except: - pass - - while True: - event = pygame.event.wait() - if event.type == pygame.KEYDOWN and event.key == pygame.K_BACKSPACE: break - - try: - pygame.mixer.unpause() - except: - pass - - try: - pygame.mixer.music.unpause() - except: - pass - - pygame.event.pump() - -def get_input(prompt="Enter text:", text=""): - """Display a dialog box for text input. - - Args: - prompt (str): Prompt text to display (default: "Enter text:") - text (str): Initial text in input box (default: "") - - Returns: - str: User input text, or None if cancelled - """ - app = wx.App(False) - dialog = wx.TextEntryDialog(None, prompt, "Input", text) - dialog.SetValue(text) - if dialog.ShowModal() == wx.ID_OK: - userInput = dialog.GetValue() - else: - userInput = None - dialog.Destroy() - return userInput - -def speak(text, interrupt=True): - """Speak text using the configured speech provider and display on screen. - - Args: - text (str): Text to speak and display - interrupt (bool): Whether to interrupt current speech (default: True) - """ - current_time = pygame.time.get_ticks() - - # Check if this is the same text within the delay window - global lastSpoken - if (_lastSpoken["text"] == text and - current_time - _lastSpoken["time"] < _speechDelay): - return - - # Update last spoken tracking - _lastSpoken["text"] = text - _lastSpoken["time"] = current_time - - # Proceed with speech - if speechProvider == "speechd": - if interrupt: - spd.cancel() - spd.say(text) - else: - if speechProvider == "accessible_output2": - s.speak(text, interrupt=interrupt) - - # Display the text on screen - screen = pygame.display.get_surface() - font = pygame.font.Font(None, 36) - # Wrap the text - max_width = screen.get_width() - 40 # Leave a 20-pixel margin on each side - wrapped_text = textwrap.wrap(text, width=max_width // font.size('A')[0]) - # Render each line - text_surfaces = [font.render(line, True, (255, 255, 255)) for line in wrapped_text] - screen.fill((0, 0, 0)) # Clear screen with black - # Calculate total height of text block - total_height = sum(surface.get_height() for surface in text_surfaces) - # Start y-position (centered vertically) - currentY = (screen.get_height() - total_height) // 2 - # Blit each line of text - for surface in text_surfaces: - text_rect = surface.get_rect(center=(screen.get_width() // 2, currentY + surface.get_height() // 2)) - screen.blit(surface, text_rect) - currentY += surface.get_height() - pygame.display.flip() - -def check_for_exit(): - """Check if user has pressed escape key. - - Returns: - bool: True if escape was pressed, False otherwise - """ - for event in pygame.event.get(): - if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE: - return True - return False - pygame.event.pump() - -def exit_game(): - """Clean up and exit the game.""" - if speechProvider == "speechd": - spd.close() - pygame.mixer.music.stop() - pygame.quit() - exit() - -def calculate_volume_and_pan(player_pos, obj_pos): - """Calculate volume and stereo panning based on relative positions. - - Args: - player_pos (float): Player's position on x-axis - obj_pos (float): Object's position on x-axis - - Returns: - tuple: (volume, left_vol, right_vol) values between 0 and 1 - """ - distance = abs(player_pos - obj_pos) - max_distance = 12 # Maximum audible distance - - if distance > max_distance: - 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 = (((max_distance - distance) / max_distance) ** 1.5) * masterVolume - - # Determine left/right based on relative position - if player_pos < obj_pos: - # Object is to the right - left = max(0, 1 - (obj_pos - player_pos) / max_distance) - right = 1 - elif player_pos > obj_pos: - # Object is to the left - left = 1 - right = max(0, 1 - (player_pos - obj_pos) / max_distance) - else: - # Player is on the object - left = right = 1 - - return volume, left, right - -def obj_play(sounds, soundName, player_pos, obj_pos, loop=True): - """Play a sound with positional audio. - - Args: - sounds (dict): Dictionary of sound objects - soundName (str): Name of sound to play - player_pos (float): Player's position for audio panning - obj_pos (float): Object's position for audio panning - loop (bool): Whether to loop the sound (default: True) - - Returns: - pygame.mixer.Channel: Sound channel object, or None if out of range - """ - volume, left, right = calculate_volume_and_pan(player_pos, obj_pos) - if volume == 0: - return None # Don't play if out of range - - # Play the sound on a new channel - channel = sounds[soundName].play(-1 if loop else 0) - if channel: - channel.set_volume(volume * left * sfxVolume, - volume * right * sfxVolume) - return channel - -def obj_update(channel, player_pos, obj_pos): - """Update positional audio for a playing sound. - - Args: - channel: Sound channel to update - player_pos (float): New player position - obj_pos (float): New object position - - Returns: - pygame.mixer.Channel: Updated channel, or None if sound should stop - """ - if channel is None: - return None - - volume, left, right = calculate_volume_and_pan(player_pos, obj_pos) - if volume == 0: - channel.stop() - return None - - # Apply the volume and pan - channel.set_volume(volume * left * sfxVolume, - volume * right * sfxVolume) - return channel - -def obj_stop(channel): - """Stop a playing sound channel. - - Args: - channel: Sound channel to stop - - Returns: - None if stopped successfully, otherwise returns original channel - """ - try: - channel.stop() - return None - except: - return channel - -def play_sound(sound, volume=1.0): - """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) - - Returns: - pygame.mixer.Channel: The channel the sound is playing on - """ - channel = sound.play() - if channel: - channel.set_volume(volume * sfxVolume * masterVolume) - return channel - -def play_ambiance(sounds, soundNames, probability, randomLocation=False): - """Play random ambient sounds with optional positional audio. - - Args: - sounds (dict): Dictionary of sound objects - soundNames (list): List of possible sound names to choose from - probability (int): Chance to play (1-100) - randomLocation (bool): Whether to randomize stereo position - - 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 - - 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() * sfxVolume * masterVolume - rightVolume = random.random() * sfxVolume * masterVolume - channel.set_volume(leftVolume, rightVolume) - - return channel - -def play_random(sounds, soundName, pause=False, interrupt=False): - """Play a random variation of a sound. - - Args: - sounds (dict): Dictionary of sound objects - soundName (str): Base name of sound (will match all starting with this) - pause (bool): Whether to pause execution until sound finishes - interrupt (bool): Whether to interrupt other sounds - """ - key = [] - for i in sounds.keys(): - if re.match("^" + soundName + ".*", i): - key.append(i) - - if not key: # No matching sounds found - return - - randomKey = random.choice(key) - - if interrupt: - cut_scene(sounds, randomKey) - return - - channel = sounds[randomKey].play() - if channel: - channel.set_volume(sfxVolume * masterVolume, sfxVolume * masterVolume) - - if pause: - time.sleep(sounds[randomKey].get_length()) - -def play_random_positional(sounds, soundName, player_x, object_x): - """Play a random variation of a sound with positional audio. - - Args: - sounds (dict): Dictionary of sound objects - soundName (str): Base name of sound to match - player_x (float): Player's x position - object_x (float): Object's x position - - Returns: - pygame.mixer.Channel: Sound channel if played, None otherwise - """ - keys = [k for k in sounds.keys() if k.startswith(soundName)] - if not keys: - return None - - randomKey = random.choice(keys) - volume, left, right = calculate_volume_and_pan(player_x, object_x) - - if volume == 0: - return None - - channel = sounds[randomKey].play() - if channel: - channel.set_volume(volume * left * sfxVolume, - volume * right * sfxVolume) - return channel - -def play_directional_sound(sounds, soundName, playerPos, objPos, centerDistance=3, volume=1.0): - """Play a sound with simplified directional audio. - - For sounds that need to be heard clearly regardless of distance, but still provide - directional feedback. Sound plays at full volume but pans left/right based on relative position. - - Args: - sounds (dict): Dictionary of sound objects - soundName (str): Name of sound to play - playerPos (float): Player's x position - objPos (float): Object's x position - centerDistance (float): Distance within which sound plays center (default: 3) - volume (float): Base volume multiplier (0.0-1.0, default: 1.0) - - Returns: - pygame.mixer.Channel: The channel the sound is playing on - """ - channel = sounds[soundName].play() - if channel: - # Apply volume settings - finalVolume = volume * sfxVolume * masterVolume - - # If player is within centerDistance tiles of object, play in center - if abs(playerPos - objPos) <= centerDistance: - # Equal volume in both speakers (center) - channel.set_volume(finalVolume, finalVolume) - elif playerPos > objPos: - # Object is to the left of player - channel.set_volume(finalVolume, (finalVolume + 0.01) / 2) - else: - # Object is to the right of player - channel.set_volume((finalVolume + 0.01) / 2, finalVolume) - return channel - -def cut_scene(sounds, soundName): - """Play a sound as a cut scene, stopping other sounds. - - Args: - sounds (dict): Dictionary of sound objects - soundName (str): Name of sound to play - """ - pygame.event.clear() - pygame.mixer.stop() - - # Get the reserved channel (0) for cut scenes - channel = pygame.mixer.Channel(0) - - # Apply the appropriate volume settings - # Use sfxVolume since cut scenes are sound effects - # Multiply by masterVolume for global volume control - channel.set_volume(sfxVolume * masterVolume, sfxVolume * masterVolume) - - # Play the sound - channel.play(sounds[soundName]) - - while pygame.mixer.get_busy(): - for event in pygame.event.get(): - if event.type == pygame.KEYDOWN and event.key in [pygame.K_ESCAPE, pygame.K_RETURN, pygame.K_SPACE]: - pygame.mixer.stop() - return - pygame.time.delay(10) - -def play_random_falling(sounds, soundName, player_x, object_x, start_y, - currentY=0, max_y=20, existing_channel=None): - """Play or update a falling sound with positional audio and volume based on height. - - Args: - sounds (dict): Dictionary of sound objects - soundName (str): Base name of sound to match - player_x (float): Player's x position - object_x (float): Object's x position - start_y (float): Starting Y position (0-20, higher = quieter start) - currentY (float): Current Y position (0 = ground level) (default: 0) - max_y (float): Maximum Y value (default: 20) - existing_channel: Existing sound channel to update (default: None) - - 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(player_x, object_x) - - # Calculate vertical fall volume multiplier (0 at max_y, 1 at y=0) - fallMultiplier = 1 - (currentY / max_y) - - # Adjust final volumes - finalVolume = volume * fallMultiplier - finalLeft = left * finalVolume - finalRight = right * finalVolume - - if existing_channel is not None: - if volume == 0: # Out of audible range - existing_channel.stop() - return None - existing_channel.set_volume(finalLeft * sfxVolume, - finalRight * sfxVolume) - return existing_channel - else: # Need to create new channel - if volume == 0: # Don't start if out of range - return None - - # Find matching sound files - keys = [k for k in sounds.keys() if k.startswith(soundName)] - if not keys: - return None - - randomKey = random.choice(keys) - channel = sounds[randomKey].play() - if channel: - channel.set_volume(finalLeft * sfxVolume, - finalRight * sfxVolume) - return channel - -def display_text(text): - """Display and speak text with navigation controls. - - Allows users to: - - Navigate text line by line with arrow keys (skipping blank lines) - - Listen to full text with space - - Copy current line or full text (preserving blank lines) - - Exit with enter/escape - - Volume controls (with Alt modifier): - - Alt+PageUp/PageDown: Master volume up/down - - Alt+Home/End: Background music volume up/down - - Alt+Insert/Delete: Sound effects volume up/down - - Args: - text (list): List of text lines to display - """ - # Store original text with blank lines for copying - originalText = text.copy() - - # Create navigation text by filtering out blank lines - navText = [line for line in text if line.strip()] - - # Add instructions at the start on the first display - global displayTextUsageInstructions - if not displayTextUsageInstructions: - instructions = ("Press space to read the whole text. Use up and down arrows to navigate " - "the text line by line. Press c to copy the current line to the clipboard " - "or t to copy the entire text. Press enter or escape when you are done reading.") - navText.insert(0, instructions) - displayTextUsageInstructions = True - - # Add end marker - navText.append("End of text.") - - currentIndex = 0 - speak(navText[currentIndex]) - - while True: - event = pygame.event.wait() - if event.type == pygame.KEYDOWN: - # Check for Alt modifier - mods = pygame.key.get_mods() - alt_pressed = mods & pygame.KMOD_ALT - - # Volume controls (require Alt) - if alt_pressed: - if event.key == pygame.K_PAGEUP: - adjust_master_volume(0.1) - elif event.key == pygame.K_PAGEDOWN: - adjust_master_volume(-0.1) - elif event.key == pygame.K_HOME: - adjust_bgm_volume(0.1) - elif event.key == pygame.K_END: - adjust_bgm_volume(-0.1) - elif event.key == pygame.K_INSERT: - adjust_sfx_volume(0.1) - elif event.key == pygame.K_DELETE: - adjust_sfx_volume(-0.1) - else: - if event.key in (pygame.K_ESCAPE, pygame.K_RETURN): - return - - if event.key in [pygame.K_DOWN, pygame.K_s] and currentIndex < len(navText) - 1: - currentIndex += 1 - speak(navText[currentIndex]) - - if event.key in [pygame.K_UP, pygame.K_w] and currentIndex > 0: - currentIndex -= 1 - speak(navText[currentIndex]) - - if event.key == pygame.K_SPACE: - # Join with newlines to preserve spacing in speech - speak('\n'.join(originalText[1:-1])) - - if event.key == pygame.K_c: - try: - pyperclip.copy(navText[currentIndex]) - speak("Copied " + navText[currentIndex] + " to the clipboard.") - except: - speak("Failed to copy the text to the clipboard.") - - if event.key == pygame.K_t: - try: - # Join with newlines to preserve blank lines in full text - pyperclip.copy(''.join(originalText[2:-1])) - speak("Copied entire message to the clipboard.") - except: - speak("Failed to copy the text to the clipboard.") - - event = pygame.event.clear() - time.sleep(0.001) - -def messagebox(text): - """Display a simple message box with text. - - Shows a message that can be repeated until the user chooses to continue. - - Args: - text (str): Message to display - """ - speak(text + "\nPress any key to repeat or enter to continue.") - while True: - event = pygame.event.wait() - if event.type == pygame.KEYDOWN: - if event.key in (pygame.K_ESCAPE, pygame.K_RETURN): - speak(" ") - return - speak(text + "\nPress any key to repeat or enter to continue.") - -def instructions(): - """Display game instructions from file. - - Reads and displays instructions from 'files/instructions.txt'. - If file is missing, displays an error message. - """ - try: - pygame.mixer.music.pause() - except: - pass - - try: - with open('files/instructions.txt', 'r') as f: - info = f.readlines() - except: - info = ["Instructions file is missing."] - display_text(info) - - try: - pygame.mixer.music.unpause() - except: - pass - -def credits(): - """Display game credits from file. - - Reads and displays credits from 'files/credits.txt'. - Adds game name header before displaying. - If file is missing, displays an error message. - """ - try: - pygame.mixer.music.pause() - except: - pass - - try: - with open('files/credits.txt', 'r') as f: - info = f.readlines() - # Add the header - info.insert(0, gameName + ": brought to you by Storm Dragon") - except: - info = ["Credits file is missing."] - display_text(info) - - try: - pygame.mixer.music.unpause() - except: - pass - -def learn_sounds(sounds): - """Interactive menu for learning game sounds. - - Allows users to: - - Navigate through available sounds - - Play selected sounds - - Return to menu with escape key - - Args: - sounds (dict): Dictionary of available sound objects - - Returns: - str: "menu" if user exits with escape - """ - loop = True - try: - pygame.mixer.music.pause() - except: - pass - - currentIndex = 0 - - # Get list of available sounds, excluding special sounds - soundFiles = [f for f in 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("_"))] - - # Track last spoken index to avoid repetition - lastSpoken = -1 - - while loop: - if currentIndex != lastSpoken: - speak(soundFiles[currentIndex][:-4]) - lastSpoken = currentIndex - - event = pygame.event.wait() - if event.type == pygame.KEYDOWN: - if event.key == pygame.K_ESCAPE: - try: - pygame.mixer.music.unpause() - except: - pass - - return "menu" - - if event.key in [pygame.K_DOWN, pygame.K_s] and currentIndex < len(soundFiles) - 1: - pygame.mixer.stop() - currentIndex += 1 - - if event.key in [pygame.K_UP, pygame.K_w] and currentIndex > 0: - pygame.mixer.stop() - currentIndex -= 1 - - if event.key == pygame.K_RETURN: - try: - soundName = soundFiles[currentIndex][:-4] - pygame.mixer.stop() - sounds[soundName].play() - except: - lastSpoken = -1 - speak("Could not play sound.") - - event = pygame.event.clear() - time.sleep(0.001) - -def game_menu(sounds, *options): - """Display and handle the main game menu. - - Provides menu navigation with: - - Up/Down arrows for selection - - Home/End for first/last option - - Enter to select - - Escape to exit - - Volume controls (with Alt modifier): - - Alt+PageUp/PageDown: Master volume up/down - - Alt+Home/End: Background music volume up/down - - Alt+Insert/Delete: Sound effects volume up/down - """ - loop = True - pygame.mixer.stop() - - if pygame.mixer.music.get_busy(): - pygame.mixer.music.unpause() - else: - try: - play_bgm("sounds/music_menu.ogg") - except: - pass - - currentIndex = 0 - lastSpoken = -1 # Track last spoken index - - while loop: - if currentIndex != lastSpoken: - speak(options[currentIndex]) - lastSpoken = currentIndex - - event = pygame.event.wait() - if event.type == pygame.KEYDOWN: - # Check for Alt modifier - mods = pygame.key.get_mods() - alt_pressed = mods & pygame.KMOD_ALT - - # Volume controls (require Alt) - if alt_pressed: - if event.key == pygame.K_PAGEUP: - adjust_master_volume(0.1) - elif event.key == pygame.K_PAGEDOWN: - adjust_master_volume(-0.1) - elif event.key == pygame.K_HOME: - adjust_bgm_volume(0.1) - elif event.key == pygame.K_END: - adjust_bgm_volume(-0.1) - elif event.key == pygame.K_INSERT: - adjust_sfx_volume(0.1) - elif event.key == pygame.K_DELETE: - adjust_sfx_volume(-0.1) - # Regular menu navigation (no Alt required) - else: - if event.key == pygame.K_ESCAPE: - exit_game() - elif event.key == pygame.K_HOME: - if currentIndex != 0: - currentIndex = 0 - try: - sounds['menu-move'].play() - except: - pass - if options[currentIndex] != "donate": - pygame.mixer.music.unpause() - elif event.key == pygame.K_END: - if currentIndex != len(options) - 1: - currentIndex = len(options) - 1 - try: - sounds['menu-move'].play() - except: - pass - if options[currentIndex] != "donate": - pygame.mixer.music.unpause() - elif event.key in [pygame.K_DOWN, pygame.K_s] and currentIndex < len(options) - 1: - currentIndex += 1 - try: - sounds['menu-move'].play() - except: - pass - if options[currentIndex] != "donate": - pygame.mixer.music.unpause() - elif event.key in [pygame.K_UP, pygame.K_w] and currentIndex > 0: - currentIndex -= 1 - try: - sounds['menu-move'].play() - except: - pass - if options[currentIndex] != "donate": - pygame.mixer.music.unpause() - elif event.key == pygame.K_RETURN: - try: - lastSpoken = -1 - try: - sounds['menu-select'].play() - time.sleep(sounds['menu-select'].get_length()) - except: - pass - eval(options[currentIndex] + "()") - except: - lastSpoken = -1 - pygame.mixer.music.fadeout(500) - try: - pygame.mixer.music.fadeout(750) - time.sleep(1.0) - except: - pass - - return options[currentIndex] - - event = pygame.event.clear() - time.sleep(0.001) - -def donate(): - """Open the donation webpage. - - Pauses background music and opens the Ko-fi donation page. - """ - pygame.mixer.music.pause() - webbrowser.open('https://ko-fi.com/stormux') +def seed(a=None): + """Initialize the random number generator.""" + return random.seed(a) diff --git a/config.py b/config.py new file mode 100644 index 0000000..b64be49 --- /dev/null +++ b/config.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Configuration management for Storm Games. + +Provides functionality for: +- Reading and writing configuration files +- Global and local configuration handling +""" + +import configparser +import os +from xdg import BaseDirectory + +class Config: + """Configuration management class for Storm Games.""" + + def __init__(self, game_title): + """Initialize configuration system for a game. + + Args: + game_title (str): Title of the game + """ + self.game_title = game_title + self.global_path = os.path.join(BaseDirectory.xdg_config_home, "storm-games") + self.game_path = os.path.join(self.global_path, + str.lower(str.replace(game_title, " ", "-"))) + + # Create game directory if it doesn't exist + if not os.path.exists(self.game_path): + os.makedirs(self.game_path) + + # Initialize config parsers + self.local_config = configparser.ConfigParser() + self.global_config = configparser.ConfigParser() + + # Load existing configurations + self.read_local_config() + self.read_global_config() + + def read_local_config(self): + """Read local configuration from file.""" + try: + with open(os.path.join(self.game_path, "config.ini"), 'r') as configfile: + self.local_config.read_file(configfile) + except: + pass + + def read_global_config(self): + """Read global configuration from file.""" + try: + with open(os.path.join(self.global_path, "config.ini"), 'r') as configfile: + self.global_config.read_file(configfile) + except: + pass + + def write_local_config(self): + """Write local configuration to file.""" + with open(os.path.join(self.game_path, "config.ini"), 'w') as configfile: + self.local_config.write(configfile) + + def write_global_config(self): + """Write global configuration to file.""" + with open(os.path.join(self.global_path, "config.ini"), 'w') as configfile: + self.global_config.write(configfile) + +# Global variables for backward compatibility +localConfig = configparser.ConfigParser() +globalConfig = configparser.ConfigParser() +gamePath = "" +globalPath = "" + +def write_config(write_global=False): + """Write configuration to file. + + Args: + write_global (bool): If True, write to global config, otherwise local (default: False) + """ + if not write_global: + with open(gamePath + "/config.ini", 'w') as configfile: + localConfig.write(configfile) + else: + with open(globalPath + "/config.ini", 'w') as configfile: + globalConfig.write(configfile) + +def read_config(read_global=False): + """Read configuration from file. + + Args: + read_global (bool): If True, read global config, otherwise local (default: False) + """ + if not read_global: + try: + with open(gamePath + "/config.ini", 'r') as configfile: + localConfig.read_file(configfile) + except: + pass + else: + try: + with open(globalPath + "/config.ini", 'r') as configfile: + globalConfig.read_file(configfile) + except: + pass diff --git a/display.py b/display.py new file mode 100644 index 0000000..3bef3a8 --- /dev/null +++ b/display.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Display functionality for Storm Games. + +Provides functionality for: +- GUI initialization +- Text display with navigation +- Message boxes +""" + +import pygame +import time +import os +import pyperclip +import random +from xdg import BaseDirectory +from setproctitle import setproctitle +from .speech import Speech +from .services import PathService, VolumeService + +# Keep track of the instructions for navigating display_text has been shown +displayTextUsageInstructions = False + +def initialize_gui(gameTitle): + """Initialize the game GUI and sound system. + + Args: + gameTitle (str): Title of the game + + Returns: + dict: Dictionary of loaded sound objects + """ + # Initialize path service with game title + path_service = PathService.get_instance().initialize(gameTitle) + + # Seed the random generator to the clock + random.seed() + + # Set game's name + setproctitle(str.lower(str.replace(gameTitle, " ", "-"))) + + # Initialize pygame + pygame.init() + pygame.display.set_mode((800, 600)) + pygame.display.set_caption(gameTitle) + + # Set up audio system + pygame.mixer.pre_init(44100, -16, 2, 1024) + pygame.mixer.init() + pygame.mixer.set_num_channels(32) + pygame.mixer.set_reserved(0) # Reserve channel for cut scenes + + # Enable key repeat for volume controls + pygame.key.set_repeat(500, 100) + + # Load sound files + try: + from os import listdir + from os.path import isfile, join + soundFiles = [f for f in listdir("sounds/") + if isfile(join("sounds/", f)) + and (f.split('.')[1].lower() in ["ogg", "wav"])] + except Exception as e: + print("No sounds found.") + Speech.get_instance().speak("No sounds found.", False) + soundFiles = [] + + # Create dictionary of sound objects + soundData = {} + for f in soundFiles: + soundData[f.split('.')[0]] = pygame.mixer.Sound("sounds/" + f) + + # Play intro sound if available + from .sound import cut_scene + if 'game-intro' in soundData: + cut_scene(soundData, 'game-intro') + + return soundData + +def display_text(text): + """Display and speak text with navigation controls. + + Allows users to: + - Navigate text line by line with arrow keys (skipping blank lines) + - Listen to full text with space + - Copy current line or full text (preserving blank lines) + - Exit with enter/escape + - Volume controls (with Alt modifier): + - Alt+PageUp/PageDown: Master volume up/down + - Alt+Home/End: Background music volume up/down + - Alt+Insert/Delete: Sound effects volume up/down + + Args: + text (list): List of text lines to display + """ + # Get service instances + speech = Speech.get_instance() + volume_service = VolumeService.get_instance() + + # Store original text with blank lines for copying + originalText = text.copy() + + # Create navigation text by filtering out blank lines + navText = [line for line in text if line.strip()] + + # Add instructions at the start on the first display + global displayTextUsageInstructions + if not displayTextUsageInstructions: + instructions = ("Press space to read the whole text. Use up and down arrows to navigate " + "the text line by line. Press c to copy the current line to the clipboard " + "or t to copy the entire text. Press enter or escape when you are done reading.") + navText.insert(0, instructions) + displayTextUsageInstructions = True + + # Add end marker + navText.append("End of text.") + + currentIndex = 0 + speech.speak(navText[currentIndex]) + + while True: + event = pygame.event.wait() + if event.type == pygame.KEYDOWN: + # Check for Alt modifier + mods = pygame.key.get_mods() + alt_pressed = mods & pygame.KMOD_ALT + + # Volume controls (require Alt) + if alt_pressed: + if event.key == pygame.K_PAGEUP: + volume_service.adjust_master_volume(0.1, pygame.mixer) + elif event.key == pygame.K_PAGEDOWN: + volume_service.adjust_master_volume(-0.1, pygame.mixer) + elif event.key == pygame.K_HOME: + volume_service.adjust_bgm_volume(0.1, pygame.mixer) + elif event.key == pygame.K_END: + volume_service.adjust_bgm_volume(-0.1, pygame.mixer) + elif event.key == pygame.K_INSERT: + volume_service.adjust_sfx_volume(0.1, pygame.mixer) + elif event.key == pygame.K_DELETE: + volume_service.adjust_sfx_volume(-0.1, pygame.mixer) + else: + if event.key in (pygame.K_ESCAPE, pygame.K_RETURN): + return + + if event.key in [pygame.K_DOWN, pygame.K_s] and currentIndex < len(navText) - 1: + currentIndex += 1 + speech.speak(navText[currentIndex]) + + if event.key in [pygame.K_UP, pygame.K_w] and currentIndex > 0: + currentIndex -= 1 + speech.speak(navText[currentIndex]) + + if event.key == pygame.K_SPACE: + # Join with newlines to preserve spacing in speech + speech.speak('\n'.join(originalText[1:-1])) + + if event.key == pygame.K_c: + try: + pyperclip.copy(navText[currentIndex]) + speech.speak("Copied " + navText[currentIndex] + " to the clipboard.") + except: + speech.speak("Failed to copy the text to the clipboard.") + + if event.key == pygame.K_t: + try: + # Join with newlines to preserve blank lines in full text + pyperclip.copy(''.join(originalText[2:-1])) + speech.speak("Copied entire message to the clipboard.") + except: + speech.speak("Failed to copy the text to the clipboard.") + + event = pygame.event.clear() + time.sleep(0.001) diff --git a/input.py b/input.py new file mode 100644 index 0000000..f82fef5 --- /dev/null +++ b/input.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Input handling for Storm Games. + +Provides functionality for: +- Text input dialogs +- Game pause functionality +- Exit handling +""" + +import pygame +import time +import wx +from .speech import speak + +def get_input(prompt="Enter text:", text=""): + """Display a dialog box for text input. + + Args: + prompt (str): Prompt text to display (default: "Enter text:") + text (str): Initial text in input box (default: "") + + Returns: + str: User input text, or None if cancelled + """ + app = wx.App(False) + dialog = wx.TextEntryDialog(None, prompt, "Input", text) + dialog.SetValue(text) + if dialog.ShowModal() == wx.ID_OK: + userInput = dialog.GetValue() + else: + userInput = None + dialog.Destroy() + return userInput + +def pause_game(): + """Pauses the game until user presses backspace.""" + speak("Game paused, press backspace to resume.") + pygame.event.clear() + try: + pygame.mixer.pause() + except: + pass + + try: + pygame.mixer.music.pause() + except: + pass + + while True: + event = pygame.event.wait() + if event.type == pygame.KEYDOWN and event.key == pygame.K_BACKSPACE: + break + + try: + pygame.mixer.unpause() + except: + pass + + try: + pygame.mixer.music.unpause() + except: + pass + + pygame.event.pump() + +def check_for_exit(): + """Check if user has pressed escape key. + + Returns: + bool: True if escape was pressed, False otherwise + """ + for event in pygame.event.get(): + if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE: + return True + return False + pygame.event.pump() diff --git a/menu.py b/menu.py new file mode 100644 index 0000000..14b615e --- /dev/null +++ b/menu.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Menu systems for Storm Games. + +Provides functionality for: +- Game menu navigation +- Instructions display +- Credits display +- Sound learning interface +- Game exit handling +""" + +import pygame +import time +import webbrowser +from sys import exit +from os.path import isfile +from os import listdir +from os.path import join +from inspect import isfunction +from .speech import Speech +from .sound import adjust_master_volume, adjust_bgm_volume, adjust_sfx_volume +from .display import display_text + +def game_menu(sounds, *options): + """Display and handle the main game menu. + + Provides menu navigation with: + - Up/Down arrows for selection + - Home/End for first/last option + - Enter to select + - Escape to exit + - Volume controls (with Alt modifier): + - Alt+PageUp/PageDown: Master volume up/down + - Alt+Home/End: Background music volume up/down + - Alt+Insert/Delete: Sound effects volume up/down + + Args: + sounds (dict): Dictionary of sound objects + *options: Variable list of menu option names (strings) + + Returns: + str: Selected menu option or "exit" if user pressed escape + """ + # Get speech instance + speech = Speech.get_instance() + + loop = True + pygame.mixer.stop() + + if pygame.mixer.music.get_busy(): + pygame.mixer.music.unpause() + else: + try: + from .sound import play_bgm + play_bgm("sounds/music_menu.ogg") + except: + pass + + currentIndex = 0 + lastSpoken = -1 # Track last spoken index + + while loop: + if currentIndex != lastSpoken: + speech.speak(options[currentIndex]) + lastSpoken = currentIndex + + event = pygame.event.wait() + if event.type == pygame.KEYDOWN: + # Check for Alt modifier + mods = pygame.key.get_mods() + alt_pressed = mods & pygame.KMOD_ALT + + # Volume controls (require Alt) + if alt_pressed: + if event.key == pygame.K_PAGEUP: + adjust_master_volume(0.1) + elif event.key == pygame.K_PAGEDOWN: + adjust_master_volume(-0.1) + elif event.key == pygame.K_HOME: + adjust_bgm_volume(0.1) + elif event.key == pygame.K_END: + adjust_bgm_volume(-0.1) + elif event.key == pygame.K_INSERT: + adjust_sfx_volume(0.1) + elif event.key == pygame.K_DELETE: + adjust_sfx_volume(-0.1) + # Regular menu navigation (no Alt required) + else: + if event.key == pygame.K_ESCAPE: + exit_game() + elif event.key == pygame.K_HOME: + if currentIndex != 0: + currentIndex = 0 + try: + sounds['menu-move'].play() + except: + pass + if options[currentIndex] != "donate": + pygame.mixer.music.unpause() + elif event.key == pygame.K_END: + if currentIndex != len(options) - 1: + currentIndex = len(options) - 1 + try: + sounds['menu-move'].play() + except: + pass + if options[currentIndex] != "donate": + pygame.mixer.music.unpause() + elif event.key in [pygame.K_DOWN, pygame.K_s] and currentIndex < len(options) - 1: + currentIndex += 1 + try: + sounds['menu-move'].play() + except: + pass + if options[currentIndex] != "donate": + pygame.mixer.music.unpause() + elif event.key in [pygame.K_UP, pygame.K_w] and currentIndex > 0: + currentIndex -= 1 + try: + sounds['menu-move'].play() + except: + pass + if options[currentIndex] != "donate": + pygame.mixer.music.unpause() + elif event.key == pygame.K_RETURN: + try: + lastSpoken = -1 + try: + sounds['menu-select'].play() + time.sleep(sounds['menu-select'].get_length()) + except: + pass + eval(options[currentIndex] + "()") + except: + lastSpoken = -1 + pygame.mixer.music.fadeout(500) + try: + pygame.mixer.music.fadeout(750) + time.sleep(1.0) + except: + pass + + return options[currentIndex] + + event = pygame.event.clear() + time.sleep(0.001) + +def learn_sounds(sounds): + """Interactive menu for learning game sounds. + + Allows users to: + - Navigate through available sounds + - Play selected sounds + - Return to menu with escape key + + Args: + sounds (dict): Dictionary of available sound objects + + Returns: + str: "menu" if user exits with escape + """ + # Get speech instance + speech = Speech.get_instance() + + loop = True + try: + pygame.mixer.music.pause() + except: + pass + + currentIndex = 0 + + # Get list of available sounds, excluding special sounds + soundFiles = [f for f in 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("_"))] + + # Track last spoken index to avoid repetition + lastSpoken = -1 + + while loop: + if currentIndex != lastSpoken: + speech.speak(soundFiles[currentIndex][:-4]) + lastSpoken = currentIndex + + event = pygame.event.wait() + if event.type == pygame.KEYDOWN: + if event.key == pygame.K_ESCAPE: + try: + pygame.mixer.music.unpause() + except: + pass + + return "menu" + + if event.key in [pygame.K_DOWN, pygame.K_s] and currentIndex < len(soundFiles) - 1: + pygame.mixer.stop() + currentIndex += 1 + + if event.key in [pygame.K_UP, pygame.K_w] and currentIndex > 0: + pygame.mixer.stop() + currentIndex -= 1 + + if event.key == pygame.K_RETURN: + try: + soundName = soundFiles[currentIndex][:-4] + pygame.mixer.stop() + sounds[soundName].play() + except: + lastSpoken = -1 + speech.speak("Could not play sound.") + + event = pygame.event.clear() + time.sleep(0.001) + +def instructions(): + """Display game instructions from file. + + Reads and displays instructions from 'files/instructions.txt'. + If file is missing, displays an error message. + """ + try: + pygame.mixer.music.pause() + except: + pass + + try: + with open('files/instructions.txt', 'r') as f: + info = f.readlines() + except: + info = ["Instructions file is missing."] + display_text(info) + + try: + pygame.mixer.music.unpause() + except: + pass + +def credits(): + """Display game credits from file. + + Reads and displays credits from 'files/credits.txt'. + Adds game name header before displaying. + If file is missing, displays an error message. + """ + try: + pygame.mixer.music.pause() + except: + pass + + try: + with open('files/credits.txt', 'r') as f: + info = f.readlines() + # Add the header + from .config import gameName + info.insert(0, gameName + ": brought to you by Storm Dragon") + except: + info = ["Credits file is missing."] + display_text(info) + + try: + pygame.mixer.music.unpause() + except: + pass + +def donate(): + """Open the donation webpage. + + Pauses background music and opens the Ko-fi donation page. + """ + pygame.mixer.music.pause() + webbrowser.open('https://ko-fi.com/stormux') + +def exit_game(): + """Clean up and exit the game.""" + # Get speech instance and check provider type + speech = Speech.get_instance() + if speech.provider_name == "speechd": + speech.close() + + pygame.mixer.music.stop() + pygame.quit() + exit() diff --git a/scoreboard.py b/scoreboard.py new file mode 100644 index 0000000..7848d1e --- /dev/null +++ b/scoreboard.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Scoreboard handling for Storm Games. + +Provides functionality for: +- Tracking high scores with player names +- Saving/loading high scores from configuration +""" + +import time +from .services import ConfigService +from .speech import Speech + +# For backward compatibility +from .config import localConfig, write_config, read_config + +class Scoreboard: + """Handles high score tracking with player names.""" + + def __init__(self, score=0, config_service=None, speech=None): + """Initialize scoreboard. + + Args: + score (int): Initial score (default: 0) + config_service (ConfigService): Config service (default: global instance) + speech (Speech): Speech system (default: global instance) + """ + self.config_service = config_service or ConfigService.get_instance() + self.speech = speech or Speech.get_instance() + self.current_score = score + self.high_scores = [] + + # For backward compatibility + read_config() + + try: + # Try to use config_service + self.config_service.local_config.add_section("scoreboard") + except: + # Fallback to old method + try: + localConfig.add_section("scoreboard") + except: + pass + + # Load existing high scores + for i in range(1, 11): + try: + # Try to use config_service + score = self.config_service.local_config.getint("scoreboard", f"score_{i}") + name = self.config_service.local_config.get("scoreboard", f"name_{i}") + self.high_scores.append({ + 'name': name, + 'score': score + }) + except: + # Fallback to old method + try: + score = localConfig.getint("scoreboard", f"score_{i}") + name = localConfig.get("scoreboard", f"name_{i}") + self.high_scores.append({ + 'name': name, + 'score': score + }) + except: + self.high_scores.append({ + 'name': "Player", + 'score': 0 + }) + + # Sort high scores by score value in descending order + self.high_scores.sort(key=lambda x: x['score'], reverse=True) + + def get_score(self): + """Get current score.""" + return self.current_score + + def get_high_scores(self): + """Get list of high scores.""" + return self.high_scores + + def decrease_score(self, points=1): + """Decrease the current score.""" + self.current_score -= int(points) + return self + + def increase_score(self, points=1): + """Increase the current score.""" + self.current_score += int(points) + return self + + def set_score(self, score): + """Set the current score to a specific value.""" + self.current_score = int(score) + return self + + def reset_score(self): + """Reset the current score to zero.""" + self.current_score = 0 + return self + + def check_high_score(self): + """Check if current score qualifies as a high score. + + Returns: + int: Position (1-10) if high score, None if not + """ + for i, entry in enumerate(self.high_scores): + if self.current_score > entry['score']: + return i + 1 + return None + + def add_high_score(self, name=None): + """Add current score to high scores if it qualifies. + + Args: + name (str): Player name (if None, will prompt user) + + Returns: + bool: True if score was added, False if not + """ + position = self.check_high_score() + if position is None: + return False + + # Get player name + if name is None: + # Import get_input here to avoid circular imports + from .input import get_input + name = get_input("New high score! Enter your name:", "Player") + if name is None: # User cancelled + name = "Player" + + # Insert new score at correct position + self.high_scores.insert(position - 1, { + 'name': name, + 'score': self.current_score + }) + + # Keep only top 10 + self.high_scores = self.high_scores[:10] + + # Save to config - try both methods for maximum compatibility + try: + # Try new method first + for i, entry in enumerate(self.high_scores): + self.config_service.local_config.set("scoreboard", f"score_{i+1}", str(entry['score'])) + self.config_service.local_config.set("scoreboard", f"name_{i+1}", entry['name']) + + # Try to write with config_service + try: + self.config_service.write_local_config() + except Exception as e: + # Fallback to old method if config_service fails + for i, entry in enumerate(self.high_scores): + localConfig.set("scoreboard", f"score_{i+1}", str(entry['score'])) + localConfig.set("scoreboard", f"name_{i+1}", entry['name']) + write_config() + + except Exception as e: + # If all else fails, try direct old method + for i, entry in enumerate(self.high_scores): + localConfig.set("scoreboard", f"score_{i+1}", str(entry['score'])) + localConfig.set("scoreboard", f"name_{i+1}", entry['name']) + write_config() + + # Announce success + try: + self.speech.messagebox(f"Congratulations {name}! You got position {position} on the scoreboard!") + except: + # Fallback to global speak function + from .speech import speak + speak(f"Congratulations {name}! You got position {position} on the scoreboard!") + + time.sleep(1) + return True diff --git a/services.py b/services.py new file mode 100644 index 0000000..a0d3fb4 --- /dev/null +++ b/services.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Service classes for Storm Games. + +Provides centralized services to replace global variables: +- ConfigService: Manages game configuration +- VolumeService: Handles volume settings +- PathService: Manages file paths +""" + +import configparser +import os +from xdg import BaseDirectory + +# For backward compatibility +from .config import gamePath, globalPath, write_config, read_config + +class ConfigService: + """Configuration management service.""" + + _instance = None + + @classmethod + def get_instance(cls): + """Get or create the singleton instance.""" + if cls._instance is None: + cls._instance = ConfigService() + return cls._instance + + def __init__(self): + """Initialize configuration parsers.""" + self.local_config = configparser.ConfigParser() + self.global_config = configparser.ConfigParser() + self.game_title = None + self.path_service = None + + def set_game_info(self, game_title, path_service): + """Set game information and initialize configs. + + Args: + game_title (str): Title of the game + path_service (PathService): Path service instance + """ + self.game_title = game_title + self.path_service = path_service + + # Load existing configurations + self.read_local_config() + self.read_global_config() + + def read_local_config(self): + """Read local configuration from file.""" + try: + # Try to use path_service if available + if self.path_service and self.path_service.game_path: + with open(os.path.join(self.path_service.game_path, "config.ini"), 'r') as configfile: + self.local_config.read_file(configfile) + # Fallback to global gamePath + elif gamePath: + with open(os.path.join(gamePath, "config.ini"), 'r') as configfile: + self.local_config.read_file(configfile) + # Delegate to old function as last resort + else: + read_config(False) + self.local_config = configparser.ConfigParser() + self.local_config.read_dict(globals().get('localConfig', {})) + except: + pass + + def read_global_config(self): + """Read global configuration from file.""" + try: + # Try to use path_service if available + if self.path_service and self.path_service.global_path: + with open(os.path.join(self.path_service.global_path, "config.ini"), 'r') as configfile: + self.global_config.read_file(configfile) + # Fallback to global globalPath + elif globalPath: + with open(os.path.join(globalPath, "config.ini"), 'r') as configfile: + self.global_config.read_file(configfile) + # Delegate to old function as last resort + else: + read_config(True) + self.global_config = configparser.ConfigParser() + self.global_config.read_dict(globals().get('globalConfig', {})) + except: + pass + + def write_local_config(self): + """Write local configuration to file.""" + try: + # Try to use path_service if available + if self.path_service and self.path_service.game_path: + with open(os.path.join(self.path_service.game_path, "config.ini"), 'w') as configfile: + self.local_config.write(configfile) + # Fallback to global gamePath + elif gamePath: + with open(os.path.join(gamePath, "config.ini"), 'w') as configfile: + self.local_config.write(configfile) + # Delegate to old function as last resort + else: + # Update old global config + globals()['localConfig'] = self.local_config + write_config(False) + except Exception as e: + print(f"Warning: Failed to write local config: {e}") + + def write_global_config(self): + """Write global configuration to file.""" + try: + # Try to use path_service if available + if self.path_service and self.path_service.global_path: + with open(os.path.join(self.path_service.global_path, "config.ini"), 'w') as configfile: + self.global_config.write(configfile) + # Fallback to global globalPath + elif globalPath: + with open(os.path.join(globalPath, "config.ini"), 'w') as configfile: + self.global_config.write(configfile) + # Delegate to old function as last resort + else: + # Update old global config + globals()['globalConfig'] = self.global_config + write_config(True) + except Exception as e: + print(f"Warning: Failed to write global config: {e}") + + +class VolumeService: + """Volume management service.""" + + _instance = None + + @classmethod + def get_instance(cls): + """Get or create the singleton instance.""" + if cls._instance is None: + cls._instance = VolumeService() + return cls._instance + + def __init__(self): + """Initialize volume settings.""" + self.bgm_volume = 0.75 # Default background music volume + self.sfx_volume = 1.0 # Default sound effects volume + self.master_volume = 1.0 # Default master volume + + def adjust_master_volume(self, change, pygame_mixer=None): + """Adjust the master volume for all sounds. + + Args: + change (float): Amount to change volume by (positive or negative) + pygame_mixer: Optional pygame.mixer module for real-time updates + """ + self.master_volume = max(0.0, min(1.0, self.master_volume + change)) + + # Update real-time audio if pygame mixer is provided + if pygame_mixer: + # Update music volume + if pygame_mixer.music.get_busy(): + pygame_mixer.music.set_volume(self.bgm_volume * self.master_volume) + + # Update all sound channels + for i in range(pygame_mixer.get_num_channels()): + channel = pygame_mixer.Channel(i) + if channel.get_busy(): + current_volume = channel.get_volume() + if isinstance(current_volume, (int, float)): + # Mono audio + channel.set_volume(current_volume * self.master_volume) + else: + # Stereo audio + left, right = current_volume + channel.set_volume(left * self.master_volume, right * self.master_volume) + + def adjust_bgm_volume(self, change, pygame_mixer=None): + """Adjust only the background music volume. + + Args: + change (float): Amount to change volume by (positive or negative) + pygame_mixer: Optional pygame.mixer module for real-time updates + """ + self.bgm_volume = max(0.0, min(1.0, self.bgm_volume + change)) + + # Update real-time audio if pygame mixer is provided + if pygame_mixer and pygame_mixer.music.get_busy(): + pygame_mixer.music.set_volume(self.bgm_volume * self.master_volume) + + def adjust_sfx_volume(self, change, pygame_mixer=None): + """Adjust volume for sound effects only. + + Args: + change (float): Amount to change volume by (positive or negative) + pygame_mixer: Optional pygame.mixer module for real-time updates + """ + self.sfx_volume = max(0.0, min(1.0, self.sfx_volume + change)) + + # Update real-time audio if pygame mixer is provided + if pygame_mixer: + # Update all sound channels except reserved ones + for i in range(pygame_mixer.get_num_channels()): + channel = pygame_mixer.Channel(i) + if channel.get_busy(): + current_volume = channel.get_volume() + if isinstance(current_volume, (int, float)): + # Mono audio + channel.set_volume(current_volume * self.sfx_volume * self.master_volume) + else: + # Stereo audio + left, right = current_volume + channel.set_volume(left * self.sfx_volume * self.master_volume, + right * self.sfx_volume * self.master_volume) + + def get_bgm_volume(self): + """Get the current BGM volume with master adjustment. + + Returns: + float: Current adjusted BGM volume + """ + return self.bgm_volume * self.master_volume + + def get_sfx_volume(self): + """Get the current SFX volume with master adjustment. + + Returns: + float: Current adjusted SFX volume + """ + return self.sfx_volume * self.master_volume + + +class PathService: + """Path management service.""" + + _instance = None + + @classmethod + def get_instance(cls): + """Get or create the singleton instance.""" + if cls._instance is None: + cls._instance = PathService() + return cls._instance + + def __init__(self): + """Initialize path variables.""" + self.global_path = None + self.game_path = None + self.game_name = None + + # Try to initialize from global variables for backward compatibility + global gamePath, globalPath + if gamePath: + self.game_path = gamePath + if globalPath: + self.global_path = globalPath + + def initialize(self, game_title): + """Initialize paths for a game. + + Args: + game_title (str): Title of the game + """ + self.game_name = game_title + self.global_path = os.path.join(BaseDirectory.xdg_config_home, "storm-games") + self.game_path = os.path.join(self.global_path, + str.lower(str.replace(game_title, " ", "-"))) + + # Create game directory if it doesn't exist + if not os.path.exists(self.game_path): + os.makedirs(self.game_path) + + # Update global variables for backward compatibility + global gamePath, globalPath + gamePath = self.game_path + globalPath = self.global_path + + return self diff --git a/sound.py b/sound.py new file mode 100644 index 0000000..1147bcc --- /dev/null +++ b/sound.py @@ -0,0 +1,780 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Sound handling for Storm Games. + +Provides functionality for: +- Playing background music and sound effects +- Positional audio +- Volume controls +""" + +import pygame +import random +import re +import time +from os import listdir +from os.path import isfile, join +from .services import VolumeService + +# Global instance for backward compatibility +volume_service = VolumeService.get_instance() + +class Sound: + """Handles sound loading and playback.""" + + def __init__(self, sound_dir="sounds/", volume_service=None): + """Initialize sound system. + + Args: + sound_dir (str): Directory containing sound files (default: "sounds/") + volume_service (VolumeService): Volume service (default: global instance) + """ + self.sound_dir = sound_dir + self.sounds = {} + self.volume_service = volume_service or VolumeService.get_instance() + + # Initialize pygame mixer if not already done + if not pygame.mixer.get_init(): + pygame.mixer.pre_init(44100, -16, 2, 1024) + pygame.mixer.init() + pygame.mixer.set_num_channels(32) + pygame.mixer.set_reserved(0) # Reserve channel for cut scenes + + # Load sounds + self.load_sounds() + + def load_sounds(self): + """Load all sound files from the sound directory.""" + try: + sound_files = [f for f in listdir(self.sound_dir) + if isfile(join(self.sound_dir, f)) + and (f.split('.')[1].lower() in ["ogg", "wav"])] + + # Create dictionary of sound objects + for f in sound_files: + self.sounds[f.split('.')[0]] = pygame.mixer.Sound(join(self.sound_dir, f)) + + except Exception as e: + print(f"Error loading sounds: {e}") + + def play_intro(self): + """Play the game intro sound if available.""" + if 'game-intro' in self.sounds: + self.cut_scene('game-intro') + + def get_sounds(self): + """Get the dictionary of loaded sound objects. + + Returns: + dict: Dictionary of loaded sound objects + """ + return self.sounds + + def play_bgm(self, music_file): + """Play background music with proper volume settings. + + Args: + music_file (str): Path to the music file to play + """ + try: + pygame.mixer.music.stop() + pygame.mixer.music.load(music_file) + pygame.mixer.music.set_volume(self.volume_service.get_bgm_volume()) + pygame.mixer.music.play(-1) # Loop indefinitely + except Exception as e: + pass + + def play_sound(self, sound_name, volume=1.0): + """Play a sound with current volume settings applied. + + Args: + sound_name (str): Name of sound to play + volume (float): Base volume for the sound (0.0-1.0, default: 1.0) + + Returns: + pygame.mixer.Channel: The channel the sound is playing on + """ + if sound_name not in self.sounds: + return None + + sound = self.sounds[sound_name] + channel = sound.play() + if channel: + channel.set_volume(volume * self.volume_service.get_sfx_volume()) + return channel + + def calculate_volume_and_pan(self, player_pos, obj_pos, max_distance=12): + """Calculate volume and stereo panning based on relative positions. + + Args: + player_pos (float): Player's position on x-axis + obj_pos (float): Object's position on x-axis + max_distance (float): Maximum audible distance (default: 12) + + Returns: + tuple: (volume, left_vol, right_vol) values between 0 and 1 + """ + distance = abs(player_pos - obj_pos) + + if distance > max_distance: + 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 = (((max_distance - distance) / max_distance) ** 1.5) * self.volume_service.master_volume + + # Determine left/right based on relative position + if player_pos < obj_pos: + # Object is to the right + left = max(0, 1 - (obj_pos - player_pos) / max_distance) + right = 1 + elif player_pos > obj_pos: + # Object is to the left + left = 1 + right = max(0, 1 - (player_pos - obj_pos) / max_distance) + else: + # Player is on the object + left = right = 1 + + return volume, left, right + + def obj_play(self, sound_name, player_pos, obj_pos, loop=True): + """Play a sound with positional audio. + + Args: + sound_name (str): Name of sound to play + player_pos (float): Player's position for audio panning + obj_pos (float): Object's position for audio panning + loop (bool): Whether to loop the sound (default: True) + + Returns: + pygame.mixer.Channel: Sound channel object, or None if out of range + """ + if sound_name not in self.sounds: + return None + + volume, left, right = self.calculate_volume_and_pan(player_pos, obj_pos) + if volume == 0: + return None # Don't play if out of range + + # Play the sound on a new channel + channel = self.sounds[sound_name].play(-1 if loop else 0) + if channel: + channel.set_volume( + volume * left * self.volume_service.sfx_volume, + volume * right * self.volume_service.sfx_volume + ) + return channel + + def obj_update(self, channel, player_pos, obj_pos): + """Update positional audio for a playing sound. + + Args: + channel: Sound channel to update + player_pos (float): New player position + obj_pos (float): New object position + + Returns: + pygame.mixer.Channel: Updated channel, or None if sound should stop + """ + if channel is None: + return None + + volume, left, right = self.calculate_volume_and_pan(player_pos, obj_pos) + if volume == 0: + channel.stop() + return None + + # Apply the volume and pan + channel.set_volume( + volume * left * self.volume_service.sfx_volume, + volume * right * self.volume_service.sfx_volume + ) + return channel + + def obj_stop(self, channel): + """Stop a playing sound channel. + + Args: + channel: Sound channel to stop + + Returns: + None if stopped successfully, otherwise returns original channel + """ + try: + channel.stop() + return None + except: + return channel + + def play_ambiance(self, sound_names, probability, random_location=False): + """Play random ambient sounds with optional positional audio. + + Args: + sound_names (list): List of possible sound names to choose from + probability (int): Chance to play (1-100) + random_location (bool): Whether to randomize stereo position + + Returns: + pygame.mixer.Channel: Sound channel if played, None otherwise + """ + # Check if any of the sounds in the list is already playing + for sound_name in sound_names: + 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 + ambiance_sound = random.choice(sound_names) + if ambiance_sound not in self.sounds: + return None + + channel = self.sounds[ambiance_sound].play() + + if random_location and channel: + left_volume = random.random() * self.volume_service.get_sfx_volume() + right_volume = random.random() * self.volume_service.get_sfx_volume() + channel.set_volume(left_volume, right_volume) + + return channel + + def play_random(self, sound_prefix, pause=False, interrupt=False): + """Play a random variation of a sound. + + Args: + sound_prefix (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 + """ + keys = [] + for i in self.sounds.keys(): + if re.match("^" + sound_prefix + ".*", i): + keys.append(i) + + if not keys: # No matching sounds found + return None + + random_key = random.choice(keys) + + if interrupt: + self.cut_scene(random_key) + return + + channel = self.sounds[random_key].play() + sfx_volume = self.volume_service.get_sfx_volume() + + if channel: + channel.set_volume(sfx_volume, sfx_volume) + + if pause: + time.sleep(self.sounds[random_key].get_length()) + + return channel + + def play_random_positional(self, sound_prefix, player_x, object_x): + """Play a random variation of a sound with positional audio. + + Args: + sound_prefix (str): Base name of sound to match + player_x (float): Player's x position + object_x (float): Object's x position + + Returns: + pygame.mixer.Channel: Sound channel if played, None otherwise + """ + keys = [k for k in self.sounds.keys() if k.startswith(sound_prefix)] + if not keys: + return None + + random_key = random.choice(keys) + volume, left, right = self.calculate_volume_and_pan(player_x, object_x) + + if volume == 0: + return None + + channel = self.sounds[random_key].play() + if channel: + channel.set_volume( + volume * left * self.volume_service.sfx_volume, + volume * right * self.volume_service.sfx_volume + ) + return channel + + def play_directional_sound(self, sound_name, player_pos, obj_pos, center_distance=3, volume=1.0): + """Play a sound with simplified directional audio. + + For sounds that need to be heard clearly regardless of distance, but still provide + directional feedback. Sound plays at full volume but pans left/right based on relative position. + + Args: + sound_name (str): Name of sound to play + player_pos (float): Player's x position + obj_pos (float): Object's x position + center_distance (float): Distance within which sound plays center (default: 3) + volume (float): Base volume multiplier (0.0-1.0, default: 1.0) + + Returns: + pygame.mixer.Channel: The channel the sound is playing on + """ + if sound_name not in self.sounds: + return None + + channel = self.sounds[sound_name].play() + if channel: + # Apply volume settings + final_volume = volume * self.volume_service.get_sfx_volume() + + # If player is within centerDistance tiles of object, play in center + if abs(player_pos - obj_pos) <= center_distance: + # Equal volume in both speakers (center) + channel.set_volume(final_volume, final_volume) + elif player_pos > obj_pos: + # Object is to the left of player + channel.set_volume(final_volume, (final_volume + 0.01) / 2) + else: + # Object is to the right of player + channel.set_volume((final_volume + 0.01) / 2, final_volume) + return channel + + def cut_scene(self, sound_name): + """Play a sound as a cut scene, stopping other sounds. + + Args: + sound_name (str): Name of sound to play + """ + if sound_name not in self.sounds: + return + + pygame.event.clear() + pygame.mixer.stop() + + # Get the reserved channel (0) for cut scenes + channel = pygame.mixer.Channel(0) + + # Apply the appropriate volume settings + sfx_volume = self.volume_service.get_sfx_volume() + channel.set_volume(sfx_volume, sfx_volume) + + # Play the sound + channel.play(self.sounds[sound_name]) + + while pygame.mixer.get_busy(): + for event in pygame.event.get(): + if event.type == pygame.KEYDOWN and event.key in [pygame.K_ESCAPE, pygame.K_RETURN, pygame.K_SPACE]: + pygame.mixer.stop() + return + pygame.time.delay(10) + + def play_random_falling(self, sound_prefix, player_x, object_x, start_y, + current_y=0, max_y=20, existing_channel=None): + """Play or update a falling sound with positional audio and volume based on height. + + Args: + sound_prefix (str): Base name of sound to match + player_x (float): Player's x position + object_x (float): Object's x position + start_y (float): Starting Y position (0-20, higher = quieter start) + current_y (float): Current Y position (0 = ground level) (default: 0) + max_y (float): Maximum Y value (default: 20) + existing_channel: Existing sound channel to update (default: None) + + 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(player_x, object_x) + + # Calculate vertical fall volume multiplier (0 at max_y, 1 at y=0) + fall_multiplier = 1 - (current_y / max_y) + + # Adjust final volumes + final_volume = volume * fall_multiplier + final_left = left * final_volume + final_right = right * final_volume + + if existing_channel is not None: + if volume == 0: # Out of audible range + existing_channel.stop() + return None + existing_channel.set_volume( + final_left * self.volume_service.sfx_volume, + final_right * self.volume_service.sfx_volume + ) + return existing_channel + else: # Need to create new channel + if volume == 0: # Don't start if out of range + return None + + # Find matching sound files + keys = [k for k in self.sounds.keys() if k.startswith(sound_prefix)] + if not keys: + return None + + random_key = random.choice(keys) + channel = self.sounds[random_key].play() + if channel: + channel.set_volume( + final_left * self.volume_service.sfx_volume, + final_right * self.volume_service.sfx_volume + ) + return channel + + +# Global functions for backward compatibility + +def play_bgm(music_file): + """Play background music with proper volume settings. + + Args: + music_file (str): Path to the music file to play + """ + try: + pygame.mixer.music.stop() + pygame.mixer.music.load(music_file) + pygame.mixer.music.set_volume(volume_service.get_bgm_volume()) + pygame.mixer.music.play(-1) # Loop indefinitely + except Exception as e: + pass + +def adjust_master_volume(change): + """Adjust the master volume for all sounds. + + Args: + change (float): Amount to change volume by (positive or negative) + """ + volume_service.adjust_master_volume(change, pygame.mixer) + +def adjust_bgm_volume(change): + """Adjust only the background music volume. + + Args: + change (float): Amount to change volume by (positive or negative) + """ + volume_service.adjust_bgm_volume(change, pygame.mixer) + +def adjust_sfx_volume(change): + """Adjust volume for sound effects only. + + Args: + change (float): Amount to change volume by (positive or negative) + """ + volume_service.adjust_sfx_volume(change, pygame.mixer) + +def calculate_volume_and_pan(player_pos, obj_pos): + """Calculate volume and stereo panning based on relative positions. + + Args: + player_pos (float): Player's position on x-axis + obj_pos (float): Object's position on x-axis + + Returns: + tuple: (volume, left_vol, right_vol) values between 0 and 1 + """ + distance = abs(player_pos - obj_pos) + max_distance = 12 # Maximum audible distance + + if distance > max_distance: + 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 = (((max_distance - distance) / max_distance) ** 1.5) * volume_service.master_volume + + # Determine left/right based on relative position + if player_pos < obj_pos: + # Object is to the right + left = max(0, 1 - (obj_pos - player_pos) / max_distance) + right = 1 + elif player_pos > obj_pos: + # Object is to the left + left = 1 + right = max(0, 1 - (player_pos - obj_pos) / max_distance) + else: + # Player is on the object + left = right = 1 + + return volume, left, right + +def play_sound(sound, volume=1.0): + """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) + + Returns: + pygame.mixer.Channel: The channel the sound is playing on + """ + channel = sound.play() + if channel: + channel.set_volume(volume * volume_service.get_sfx_volume()) + return channel + +def obj_play(sounds, soundName, player_pos, obj_pos, loop=True): + """Play a sound with positional audio. + + Args: + sounds (dict): Dictionary of sound objects + soundName (str): Name of sound to play + player_pos (float): Player's position for audio panning + obj_pos (float): Object's position for audio panning + loop (bool): Whether to loop the sound (default: True) + + Returns: + pygame.mixer.Channel: Sound channel object, or None if out of range + """ + volume, left, right = calculate_volume_and_pan(player_pos, obj_pos) + if volume == 0: + return None # Don't play if out of range + + # Play the sound on a new channel + channel = sounds[soundName].play(-1 if loop else 0) + if channel: + channel.set_volume(volume * left * volume_service.sfx_volume, + volume * right * volume_service.sfx_volume) + return channel + +def obj_update(channel, player_pos, obj_pos): + """Update positional audio for a playing sound. + + Args: + channel: Sound channel to update + player_pos (float): New player position + obj_pos (float): New object position + + Returns: + pygame.mixer.Channel: Updated channel, or None if sound should stop + """ + if channel is None: + return None + + volume, left, right = calculate_volume_and_pan(player_pos, obj_pos) + if volume == 0: + channel.stop() + return None + + # Apply the volume and pan + channel.set_volume(volume * left * volume_service.sfx_volume, + volume * right * volume_service.sfx_volume) + return channel + +def obj_stop(channel): + """Stop a playing sound channel. + + Args: + channel: Sound channel to stop + + Returns: + None if stopped successfully, otherwise returns original channel + """ + try: + channel.stop() + return None + except: + return channel + +def play_ambiance(sounds, soundNames, probability, randomLocation=False): + """Play random ambient sounds with optional positional audio. + + Args: + sounds (dict): Dictionary of sound objects + soundNames (list): List of possible sound names to choose from + probability (int): Chance to play (1-100) + randomLocation (bool): Whether to randomize stereo position + + 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 + + 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() * volume_service.get_sfx_volume() + rightVolume = random.random() * volume_service.get_sfx_volume() + channel.set_volume(leftVolume, rightVolume) + + return channel + +def play_random(sounds, soundName, pause=False, interrupt=False): + """Play a random variation of a sound. + + Args: + sounds (dict): Dictionary of sound objects + soundName (str): Base name of sound (will match all starting with this) + pause (bool): Whether to pause execution until sound finishes + interrupt (bool): Whether to interrupt other sounds + """ + key = [] + for i in sounds.keys(): + if re.match("^" + soundName + ".*", i): + key.append(i) + + if not key: # No matching sounds found + return + + randomKey = random.choice(key) + + if interrupt: + cut_scene(sounds, randomKey) + return + + channel = sounds[randomKey].play() + if channel: + sfx_volume = volume_service.get_sfx_volume() + channel.set_volume(sfx_volume, sfx_volume) + + if pause: + time.sleep(sounds[randomKey].get_length()) + +def play_random_positional(sounds, soundName, player_x, object_x): + """Play a random variation of a sound with positional audio. + + Args: + sounds (dict): Dictionary of sound objects + soundName (str): Base name of sound to match + player_x (float): Player's x position + object_x (float): Object's x position + + Returns: + pygame.mixer.Channel: Sound channel if played, None otherwise + """ + keys = [k for k in sounds.keys() if k.startswith(soundName)] + if not keys: + return None + + randomKey = random.choice(keys) + volume, left, right = calculate_volume_and_pan(player_x, object_x) + + if volume == 0: + return None + + channel = sounds[randomKey].play() + if channel: + channel.set_volume(volume * left * volume_service.sfx_volume, + volume * right * volume_service.sfx_volume) + return channel + +def play_directional_sound(sounds, soundName, playerPos, objPos, centerDistance=3, volume=1.0): + """Play a sound with simplified directional audio. + + For sounds that need to be heard clearly regardless of distance, but still provide + directional feedback. Sound plays at full volume but pans left/right based on relative position. + + Args: + sounds (dict): Dictionary of sound objects + soundName (str): Name of sound to play + playerPos (float): Player's x position + objPos (float): Object's x position + centerDistance (float): Distance within which sound plays center (default: 3) + volume (float): Base volume multiplier (0.0-1.0, default: 1.0) + + Returns: + pygame.mixer.Channel: The channel the sound is playing on + """ + channel = sounds[soundName].play() + if channel: + # Apply volume settings + finalVolume = volume * volume_service.get_sfx_volume() + + # If player is within centerDistance tiles of object, play in center + if abs(playerPos - objPos) <= centerDistance: + # Equal volume in both speakers (center) + channel.set_volume(finalVolume, finalVolume) + elif playerPos > objPos: + # Object is to the left of player + channel.set_volume(finalVolume, (finalVolume + 0.01) / 2) + else: + # Object is to the right of player + channel.set_volume((finalVolume + 0.01) / 2, finalVolume) + return channel + +def cut_scene(sounds, soundName): + """Play a sound as a cut scene, stopping other sounds. + + Args: + sounds (dict): Dictionary of sound objects + soundName (str): Name of sound to play + """ + pygame.event.clear() + pygame.mixer.stop() + + # Get the reserved channel (0) for cut scenes + channel = pygame.mixer.Channel(0) + + # Apply the appropriate volume settings + sfx_volume = volume_service.get_sfx_volume() + channel.set_volume(sfx_volume, sfx_volume) + + # Play the sound + channel.play(sounds[soundName]) + + while pygame.mixer.get_busy(): + for event in pygame.event.get(): + if event.type == pygame.KEYDOWN and event.key in [pygame.K_ESCAPE, pygame.K_RETURN, pygame.K_SPACE]: + pygame.mixer.stop() + return + pygame.time.delay(10) + +def play_random_falling(sounds, soundName, player_x, object_x, start_y, + currentY=0, max_y=20, existing_channel=None): + """Play or update a falling sound with positional audio and volume based on height. + + Args: + sounds (dict): Dictionary of sound objects + soundName (str): Base name of sound to match + player_x (float): Player's x position + object_x (float): Object's x position + start_y (float): Starting Y position (0-20, higher = quieter start) + currentY (float): Current Y position (0 = ground level) (default: 0) + max_y (float): Maximum Y value (default: 20) + existing_channel: Existing sound channel to update (default: None) + + 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(player_x, object_x) + + # Calculate vertical fall volume multiplier (0 at max_y, 1 at y=0) + fallMultiplier = 1 - (currentY / max_y) + + # Adjust final volumes + finalVolume = volume * fallMultiplier + finalLeft = left * finalVolume + finalRight = right * finalVolume + + if existing_channel is not None: + if volume == 0: # Out of audible range + existing_channel.stop() + return None + existing_channel.set_volume(finalLeft * volume_service.sfx_volume, + finalRight * volume_service.sfx_volume) + return existing_channel + else: # Need to create new channel + if volume == 0: # Don't start if out of range + return None + + # Find matching sound files + keys = [k for k in sounds.keys() if k.startswith(soundName)] + if not keys: + return None + + randomKey = random.choice(keys) + channel = sounds[randomKey].play() + if channel: + channel.set_volume(finalLeft * volume_service.sfx_volume, + finalRight * volume_service.sfx_volume) + return channel diff --git a/speech.py b/speech.py new file mode 100644 index 0000000..907a66a --- /dev/null +++ b/speech.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Speech handling for Storm Games. + +Provides functionality for: +- Text-to-speech using different speech providers +- Speech delay control to prevent stuttering +- On-screen text display +""" + +import pygame +import textwrap +import time +from sys import exit + +class Speech: + """Handles text-to-speech functionality.""" + + _instance = None + + @classmethod + def get_instance(cls): + """Get or create the singleton instance.""" + if cls._instance is None: + cls._instance = Speech() + return cls._instance + + def __init__(self): + """Initialize speech system with available provider.""" + # Handle speech delays so we don't get stuttering + self.last_spoken = {"text": None, "time": 0} + self.speech_delay = 250 # ms + + # Try to initialize a speech provider + self.provider = None + self.provider_name = None + + # Try speechd first + try: + import speechd + self.spd = speechd.Client() + self.provider = self.spd + self.provider_name = "speechd" + return + except ImportError: + pass + + # Try accessible_output2 next + try: + import accessible_output2.outputs.auto + self.ao2 = accessible_output2.outputs.auto.Auto() + self.provider = self.ao2 + self.provider_name = "accessible_output2" + return + except ImportError: + pass + + # No speech providers found + print("No speech providers found.") + + def speak(self, text, interrupt=True): + """Speak text using the configured speech provider and display on screen. + + Args: + text (str): Text to speak and display + interrupt (bool): Whether to interrupt current speech (default: True) + """ + if not self.provider: + return + + current_time = pygame.time.get_ticks() + + # Check if this is the same text within the delay window + if (self.last_spoken["text"] == text and + current_time - self.last_spoken["time"] < self.speech_delay): + return + + # Update last spoken tracking + self.last_spoken["text"] = text + self.last_spoken["time"] = current_time + + # Proceed with speech + if self.provider_name == "speechd": + if interrupt: + self.spd.cancel() + self.spd.say(text) + elif self.provider_name == "accessible_output2": + self.ao2.speak(text, interrupt=interrupt) + + # Display the text on screen + screen = pygame.display.get_surface() + if not screen: + return + + font = pygame.font.Font(None, 36) + # Wrap the text + max_width = screen.get_width() - 40 # Leave a 20-pixel margin on each side + wrapped_text = textwrap.wrap(text, width=max_width // font.size('A')[0]) + # Render each line + text_surfaces = [font.render(line, True, (255, 255, 255)) for line in wrapped_text] + screen.fill((0, 0, 0)) # Clear screen with black + # Calculate total height of text block + total_height = sum(surface.get_height() for surface in text_surfaces) + # Start y-position (centered vertically) + current_y = (screen.get_height() - total_height) // 2 + # Blit each line of text + for surface in text_surfaces: + text_rect = surface.get_rect(center=(screen.get_width() // 2, current_y + surface.get_height() // 2)) + screen.blit(surface, text_rect) + current_y += surface.get_height() + pygame.display.flip() + + def close(self): + """Clean up speech resources.""" + if self.provider_name == "speechd": + self.spd.close() + +# Global instance for backward compatibility +_speech_instance = None + +def speak(text, interrupt=True): + """Speak text using the global speech instance. + + Args: + text (str): Text to speak and display + interrupt (bool): Whether to interrupt current speech (default: True) + """ + global _speech_instance + if _speech_instance is None: + _speech_instance = Speech.get_instance() + _speech_instance.speak(text, interrupt) + +def messagebox(text): + """Display a simple message box with text. + + Shows a message that can be repeated until the user chooses to continue. + + Args: + text (str): Message to display + """ + speech = Speech.get_instance() + speech.speak(text + "\nPress any key to repeat or enter to continue.") + while True: + event = pygame.event.wait() + if event.type == pygame.KEYDOWN: + if event.key in (pygame.K_ESCAPE, pygame.K_RETURN): + speech.speak(" ") + return + speech.speak(text + "\nPress any key to repeat or enter to continue.") diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..3d4af97 --- /dev/null +++ b/utils.py @@ -0,0 +1,350 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Utility functions and Game class for Storm Games. + +Provides: +- Game class for centralized management +- Miscellaneous helper functions +- Version checking utilities +""" + +import pygame +import random +import math +import numpy as np +import time +import re +import requests +import os +from setproctitle import setproctitle + +from .services import PathService, ConfigService, VolumeService +from .sound import Sound +from .speech import Speech +from .scoreboard import Scoreboard + +class Game: + """Central class to manage all game systems.""" + + def __init__(self, title): + """Initialize a new game. + + Args: + title (str): Title of the game + """ + self.title = title + + # Initialize services + self.path_service = PathService.get_instance().initialize(title) + self.config_service = ConfigService.get_instance() + self.config_service.set_game_info(title, self.path_service) + self.volume_service = VolumeService.get_instance() + + # Initialize game components (lazy loaded) + self._speech = None + self._sound = None + self._scoreboard = None + + # Display text instructions flag + self.display_text_usage_instructions = False + + @property + def speech(self): + """Get the speech system (lazy loaded). + + Returns: + Speech: Speech system instance + """ + if not self._speech: + self._speech = Speech.get_instance() + return self._speech + + @property + def sound(self): + """Get the sound system (lazy loaded). + + Returns: + Sound: Sound system instance + """ + if not self._sound: + self._sound = Sound("sounds/", self.volume_service) + return self._sound + + @property + def scoreboard(self): + """Get the scoreboard (lazy loaded). + + Returns: + Scoreboard: Scoreboard instance + """ + if not self._scoreboard: + self._scoreboard = Scoreboard(self.config_service) + return self._scoreboard + + def initialize(self): + """Initialize the game GUI and sound system. + + Returns: + Game: Self for method chaining + """ + # Set process title + setproctitle(str.lower(str.replace(self.title, " ", ""))) + + # Seed the random generator + random.seed() + + # Initialize pygame + pygame.init() + pygame.display.set_mode((800, 600)) + pygame.display.set_caption(self.title) + + # Set up audio system + pygame.mixer.pre_init(44100, -16, 2, 1024) + pygame.mixer.init() + pygame.mixer.set_num_channels(32) + pygame.mixer.set_reserved(0) # Reserve channel for cut scenes + + # Enable key repeat for volume controls + pygame.key.set_repeat(500, 100) + + # Load sound effects + self.sound + + # Play intro sound if available + if 'game-intro' in self.sound.sounds: + self.sound.cut_scene('game-intro') + + return self + + def speak(self, text, interrupt=True): + """Speak text using the speech system. + + Args: + text (str): Text to speak + interrupt (bool): Whether to interrupt current speech + + Returns: + Game: Self for method chaining + """ + self.speech.speak(text, interrupt) + return self + + def play_bgm(self, music_file): + """Play background music. + + Args: + music_file (str): Path to music file + + Returns: + Game: Self for method chaining + """ + self.sound.play_bgm(music_file) + return self + + def display_text(self, text_lines): + """Display text with navigation controls. + + Args: + text_lines (list): List of text lines + + Returns: + Game: Self for method chaining + """ + # Store original text with blank lines for copying + original_text = text_lines.copy() + + # Create navigation text by filtering out blank lines + nav_text = [line for line in text_lines if line.strip()] + + # Add instructions at the start on the first display + if not self.display_text_usage_instructions: + instructions = ("Press space to read the whole text. Use up and down arrows to navigate " + "the text line by line. Press c to copy the current line to the clipboard " + "or t to copy the entire text. Press enter or escape when you are done reading.") + nav_text.insert(0, instructions) + self.display_text_usage_instructions = True + + # Add end marker + nav_text.append("End of text.") + + current_index = 0 + self.speech.speak(nav_text[current_index]) + + while True: + event = pygame.event.wait() + if event.type == pygame.KEYDOWN: + # Check for Alt modifier + mods = pygame.key.get_mods() + alt_pressed = mods & pygame.KMOD_ALT + + # Volume controls (require Alt) + if alt_pressed: + if event.key == pygame.K_PAGEUP: + self.volume_service.adjust_master_volume(0.1, pygame.mixer) + elif event.key == pygame.K_PAGEDOWN: + self.volume_service.adjust_master_volume(-0.1, pygame.mixer) + elif event.key == pygame.K_HOME: + self.volume_service.adjust_bgm_volume(0.1, pygame.mixer) + elif event.key == pygame.K_END: + self.volume_service.adjust_bgm_volume(-0.1, pygame.mixer) + elif event.key == pygame.K_INSERT: + self.volume_service.adjust_sfx_volume(0.1, pygame.mixer) + elif event.key == pygame.K_DELETE: + self.volume_service.adjust_sfx_volume(-0.1, pygame.mixer) + else: + if event.key in (pygame.K_ESCAPE, pygame.K_RETURN): + return self + + if event.key in [pygame.K_DOWN, pygame.K_s] and current_index < len(nav_text) - 1: + current_index += 1 + self.speech.speak(nav_text[current_index]) + + if event.key in [pygame.K_UP, pygame.K_w] and current_index > 0: + current_index -= 1 + self.speech.speak(nav_text[current_index]) + + if event.key == pygame.K_SPACE: + # Join with newlines to preserve spacing in speech + self.speech.speak('\n'.join(original_text[1:-1])) + + if event.key == pygame.K_c: + try: + import pyperclip + pyperclip.copy(nav_text[current_index]) + self.speech.speak("Copied " + nav_text[current_index] + " to the clipboard.") + except: + self.speech.speak("Failed to copy the text to the clipboard.") + + if event.key == pygame.K_t: + try: + import pyperclip + # Join with newlines to preserve blank lines in full text + pyperclip.copy(''.join(original_text[2:-1])) + self.speech.speak("Copied entire message to the clipboard.") + except: + self.speech.speak("Failed to copy the text to the clipboard.") + + pygame.event.clear() + time.sleep(0.001) + + def exit(self): + """Clean up and exit the game.""" + if self._speech and self.speech.provider_name == "speechd": + self.speech.close() + pygame.mixer.music.stop() + pygame.quit() + import sys + sys.exit() + +# Utility functions + +def check_for_updates(current_version, game_name, url): + """Check for game updates. + + Args: + current_version (str): Current version string (e.g. "1.0.0") + game_name (str): Name of the game + url (str): URL to check for updates + + Returns: + dict: Update information or None if no update available + """ + try: + response = requests.get(url, timeout=5) + if response.status_code == 200: + data = response.json() + if 'version' in data and data['version'] > current_version: + return { + 'version': data['version'], + 'url': data.get('url', ''), + 'notes': data.get('notes', '') + } + except Exception as e: + print(f"Error checking for updates: {e}") + return None + +def get_version_tuple(version_str): + """Convert version string to comparable tuple. + + Args: + version_str (str): Version string (e.g. "1.0.0") + + Returns: + tuple: Version as tuple of integers + """ + return tuple(map(int, version_str.split('.'))) + +def check_compatibility(required_version, current_version): + """Check if current version meets minimum required version. + + Args: + required_version (str): Minimum required version string + current_version (str): Current version string + + Returns: + bool: True if compatible, False otherwise + """ + req = get_version_tuple(required_version) + cur = get_version_tuple(current_version) + return cur >= req + +def sanitize_filename(filename): + """Sanitize a filename to be safe for all operating systems. + + Args: + filename (str): Original filename + + Returns: + str: Sanitized filename + """ + # Remove invalid characters + filename = re.sub(r'[\\/*?:"<>|]', "", filename) + # Replace spaces with underscores + filename = filename.replace(" ", "_") + # Limit length + if len(filename) > 255: + filename = filename[:255] + return filename + +def lerp(start, end, factor): + """Linear interpolation between two values. + + Args: + start (float): Start value + end (float): End value + factor (float): Interpolation factor (0.0-1.0) + + Returns: + float: Interpolated value + """ + return start + (end - start) * factor + +def smooth_step(edge0, edge1, x): + """Hermite interpolation between two values. + + Args: + edge0 (float): Start edge + edge1 (float): End edge + x (float): Value to interpolate + + Returns: + float: Interpolated value with smooth step + """ + # Scale, bias and saturate x to 0..1 range + x = max(0.0, min(1.0, (x - edge0) / (edge1 - edge0))) + # Evaluate polynomial + return x * x * (3 - 2 * x) + +def distance_2d(x1, y1, x2, y2): + """Calculate Euclidean distance between two 2D points. + + Args: + x1 (float): X coordinate of first point + y1 (float): Y coordinate of first point + x2 (float): X coordinate of second point + y2 (float): Y coordinate of second point + + Returns: + float: Distance between points + """ + return math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)