From a17a4c6f15d2a697d336c76842aa668f9ae0e211 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sat, 22 Mar 2025 17:34:35 -0400 Subject: [PATCH] Code cleanup and sound consolidation. --- __init__.py | 26 +- config.py | 22 +- display.py | 60 +-- input.py | 6 +- menu.py | 120 +++--- scoreboard.py | 108 ++--- services.py | 72 ++-- sound.py | 1124 +++++++++++++++++++------------------------------ speech.py | 34 +- utils.py | 146 +++---- 10 files changed, 732 insertions(+), 986 deletions(-) diff --git a/__init__.py b/__init__.py index 1fea9ec..6b0afdb 100755 --- a/__init__.py +++ b/__init__.py @@ -79,7 +79,7 @@ __version__ = '2.0.0' __all__ = [ # Services 'ConfigService', 'VolumeService', 'PathService', - + # Sound 'Sound', 'play_bgm', @@ -97,32 +97,32 @@ __all__ = [ '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', 'high_scores', 'has_high_scores', - + # Game class 'Game', - + # Utils 'check_for_updates', 'get_version_tuple', 'check_compatibility', 'sanitize_filename', 'lerp', 'smooth_step', 'distance_2d', 'x_powerbar', 'y_powerbar', 'generate_tone', - + # Re-exported functions from pygame, math, random 'get_ticks', 'delay', 'wait', 'sin', 'cos', 'sqrt', 'floor', 'ceil', @@ -141,10 +141,10 @@ def initialize_gui_with_services(gameTitle): """Wrapper around initialize_gui that initializes services.""" # Initialize path service pathService.initialize(gameTitle) - + # Connect config service to path service configService.set_game_info(gameTitle, pathService) - + # Call original initialize_gui return _originalInitializeGui(gameTitle) @@ -159,11 +159,11 @@ def scoreboard_init_with_services(self, score=0, configService=None, speech=None # Use global services if not specified if configService is None: configService = ConfigService.get_instance() - + # Ensure pathService is connected if using defaults if not hasattr(configService, 'pathService') and pathService.game_path is not None: configService.pathService = pathService - + # Call original init with services _originalScoreboardInit(self, score, configService, speech) diff --git a/config.py b/config.py index a5715bc..6eaf55a 100644 --- a/config.py +++ b/config.py @@ -13,10 +13,10 @@ from xdg import BaseDirectory class Config: """Configuration management class for Storm Games.""" - + def __init__(self, gameTitle): """Initialize configuration system for a game. - + Args: gameTitle (str): Title of the game """ @@ -24,19 +24,19 @@ class Config: self.globalPath = os.path.join(BaseDirectory.xdg_config_home, "storm-games") self.gamePath = os.path.join(self.globalPath, str.lower(str.replace(gameTitle, " ", "-"))) - + # Create game directory if it doesn't exist if not os.path.exists(self.gamePath): os.makedirs(self.gamePath) - + # Initialize config parsers self.localConfig = configparser.ConfigParser() self.globalConfig = configparser.ConfigParser() - + # Load existing configurations self.read_local_config() self.read_global_config() - + def read_local_config(self): """Read local configuration from file.""" try: @@ -44,7 +44,7 @@ class Config: self.localConfig.read_file(configFile) except: pass - + def read_global_config(self): """Read global configuration from file.""" try: @@ -52,12 +52,12 @@ class Config: self.globalConfig.read_file(configFile) except: pass - + def write_local_config(self): """Write local configuration to file.""" with open(os.path.join(self.gamePath, "config.ini"), 'w') as configFile: self.localConfig.write(configFile) - + def write_global_config(self): """Write global configuration to file.""" with open(os.path.join(self.globalPath, "config.ini"), 'w') as configFile: @@ -71,7 +71,7 @@ globalPath = "" def write_config(writeGlobal=False): """Write configuration to file. - + Args: writeGlobal (bool): If True, write to global config, otherwise local (default: False) """ @@ -84,7 +84,7 @@ def write_config(writeGlobal=False): def read_config(readGlobal=False): """Read configuration from file. - + Args: readGlobal (bool): If True, read global config, otherwise local (default: False) """ diff --git a/display.py b/display.py index 23c2749..c9753be 100644 --- a/display.py +++ b/display.py @@ -23,81 +23,81 @@ 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 pathService = 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 recursively including subdirectories soundData = {} try: import os - + soundDir = "sounds/" # Walk through directory tree for dirPath, dirNames, fileNames in os.walk(soundDir): # Get relative path from soundDir relPath = os.path.relpath(dirPath, soundDir) - + # Process each file for fileName in fileNames: # Check if file is a valid sound file if fileName.lower().endswith(('.ogg', '.wav')): # Full path to the sound file fullPath = os.path.join(dirPath, fileName) - + # Create sound key (remove extension) baseName = os.path.splitext(fileName)[0] - + # If in root sounds dir, just use basename if relPath == '.': soundKey = baseName else: # Otherwise use relative path + basename, normalized with forward slashes soundKey = os.path.join(relPath, baseName).replace('\\', '/') - + # Load the sound soundData[soundKey] = pygame.mixer.Sound(fullPath) except Exception as e: print("Error loading sounds:", e) Speech.get_instance().speak("Error loading sounds.", False) soundData = {} - + # 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 @@ -107,20 +107,20 @@ def display_text(text): - 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() volumeService = 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: @@ -129,20 +129,20 @@ def display_text(text): "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() altPressed = mods & pygame.KMOD_ALT - + # Volume controls (require Alt) if altPressed: if event.key == pygame.K_PAGEUP: @@ -160,26 +160,26 @@ def display_text(text): 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 @@ -187,6 +187,6 @@ def display_text(text): 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 index f82fef5..b0960cc 100644 --- a/input.py +++ b/input.py @@ -15,11 +15,11 @@ 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 """ @@ -66,7 +66,7 @@ def pause_game(): def check_for_exit(): """Check if user has pressed escape key. - + Returns: bool: True if escape was pressed, False otherwise """ diff --git a/menu.py b/menu.py index 70135a0..31355db 100644 --- a/menu.py +++ b/menu.py @@ -27,7 +27,7 @@ from .services import PathService, ConfigService def game_menu(sounds, playCallback=None, *customOptions): """Display and handle the main game menu with standard and custom options. - + Standard menu structure: 1. Play (always first) 2. High Scores @@ -37,53 +37,53 @@ def game_menu(sounds, playCallback=None, *customOptions): 6. Credits (if available) 7. Donate 8. Exit - + Handles navigation with: - Up/Down arrows for selection - Home/End for first/last option - Enter to select - Escape to exit - Volume controls (with Alt modifier) - + Args: sounds (dict): Dictionary of sound objects playCallback (function, optional): Callback function for the "play" option. If None, "play" is returned as a string like other options. *customOptions: Additional custom options to include after play but before standard ones - + Returns: str: Selected menu option or "exit" if user pressed escape """ # Get speech instance speech = Speech.get_instance() - + # Start with Play option allOptions = ["play"] - + # Add high scores option if scores exist if Scoreboard.has_high_scores(): allOptions.append("high_scores") - + # Add custom options (other menu items, etc.) allOptions.extend(customOptions) - + # Add standard options in preferred order allOptions.append("learn_sounds") - + # Check for instructions file if os.path.isfile('files/instructions.txt'): allOptions.append("instructions") - + # Check for credits file if os.path.isfile('files/credits.txt'): allOptions.append("credits") - + # Final options allOptions.extend(["donate", "exit_game"]) - + # Track if music was previously playing musicWasPlaying = pygame.mixer.music.get_busy() - + # Only start menu music if no music is currently playing if not musicWasPlaying: try: @@ -91,23 +91,23 @@ def game_menu(sounds, playCallback=None, *customOptions): play_bgm("sounds/music_menu.ogg") except: pass - + loop = True pygame.mixer.stop() currentIndex = 0 lastSpoken = -1 # Track last spoken index - + while loop: if currentIndex != lastSpoken: speech.speak(allOptions[currentIndex]) lastSpoken = currentIndex - + event = pygame.event.wait() if event.type == pygame.KEYDOWN: # Check for Alt modifier mods = pygame.key.get_mods() altPressed = mods & pygame.KMOD_ALT - + # Volume controls (require Alt) if altPressed: if event.key == pygame.K_PAGEUP: @@ -169,9 +169,9 @@ def game_menu(sounds, playCallback=None, *customOptions): time.sleep(sounds['menu-select'].get_length()) except: pass - + selectedOption = allOptions[currentIndex] - + # Special case for exit_game with fade if selectedOption == "exit_game": exit_game(500 if pygame.mixer.music.get_busy() else 0) @@ -202,7 +202,7 @@ def game_menu(sounds, playCallback=None, *customOptions): pygame.mixer.music.pause() except: pass - + # Handle standard options if selectedOption == "instructions": instructions() @@ -214,7 +214,7 @@ def game_menu(sounds, playCallback=None, *customOptions): Scoreboard.display_high_scores() elif selectedOption == "donate": donate() - + # Unpause music after function returns try: # Check if music is actually paused before trying to unpause @@ -248,105 +248,105 @@ def game_menu(sounds, playCallback=None, *customOptions): except: pass return allOptions[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 with up/down arrows - Navigate between sound categories (folders) using Page Up/Page Down or Left/Right arrows - Play selected sounds with Enter - Return to menu with Escape - + Excluded sounds: - Files in folders named 'ambience' (at any level) - Files in any directory starting with '.' - Files starting with 'game-intro', 'music_menu', or '_' - + Args: sounds (dict): Dictionary of available sound objects - + Returns: str: "menu" if user exits with escape """ # Get speech instance speech = Speech.get_instance() - + # Define exclusion criteria excludedPrefixes = ["game-intro", "music_menu", "_"] excludedDirs = ["ambience", "."] - + # Organize sounds by directory soundsByDir = {} - + # Process each sound key in the dictionary for soundKey in sounds.keys(): # Skip if key has any excluded prefix if any(soundKey.lower().startswith(prefix.lower()) for prefix in excludedPrefixes): continue - + # Split key into path parts parts = soundKey.split('/') - + # Skip if any part of the path is an excluded directory if any(part.lower() == dirName.lower() or part.startswith('.') for part in parts for dirName in excludedDirs): continue - + # Determine the directory if '/' in soundKey: directory = soundKey.split('/')[0] else: directory = 'root' # Root directory sounds - + # Add to sounds by directory if directory not in soundsByDir: soundsByDir[directory] = [] soundsByDir[directory].append(soundKey) - + # Sort each directory's sounds for directory in soundsByDir: soundsByDir[directory].sort() - + # If no sounds found, inform the user and return if not soundsByDir: speech.speak("No sounds available to learn.") return "menu" - + # Get list of directories in sorted order directories = sorted(soundsByDir.keys()) - + # Start with first directory currentDirIndex = 0 currentDir = directories[currentDirIndex] currentSoundKeys = soundsByDir[currentDir] currentSoundIndex = 0 - + # Display appropriate message based on number of directories if len(directories) > 1: messagebox(f"Starting with {currentDir if currentDir != 'root' else 'root directory'} sounds. Use left and right arrows or page up and page down to navigate categories.") - + # Track last spoken to avoid repetition lastSpoken = -1 directoryChanged = True # Flag to track if directory just changed - + # Flag to track when to exit the loop returnToMenu = False - + while not returnToMenu: # Announce current sound if currentSoundIndex != lastSpoken: totalSounds = len(currentSoundKeys) soundName = currentSoundKeys[currentSoundIndex] - + # Remove directory prefix if present if '/' in soundName: displayName = '/'.join(soundName.split('/')[1:]) else: displayName = soundName - + # If directory just changed, include directory name in announcement if directoryChanged: dirDescription = "Root directory" if currentDir == 'root' else currentDir @@ -354,24 +354,24 @@ def learn_sounds(sounds): directoryChanged = False # Reset flag after announcement else: announcement = f"{displayName}, {currentSoundIndex + 1} of {totalSounds}" - + speech.speak(announcement) lastSpoken = currentSoundIndex - + event = pygame.event.wait() if event.type == pygame.KEYDOWN: if event.key == pygame.K_ESCAPE: returnToMenu = True - + # Sound navigation elif event.key in [pygame.K_DOWN, pygame.K_s] and currentSoundIndex < len(currentSoundKeys) - 1: pygame.mixer.stop() currentSoundIndex += 1 - + elif event.key in [pygame.K_UP, pygame.K_w] and currentSoundIndex > 0: pygame.mixer.stop() currentSoundIndex -= 1 - + # Directory navigation elif event.key in [pygame.K_PAGEDOWN, pygame.K_RIGHT] and currentDirIndex < len(directories) - 1: pygame.mixer.stop() @@ -381,7 +381,7 @@ def learn_sounds(sounds): currentSoundIndex = 0 directoryChanged = True # Set flag on directory change lastSpoken = -1 # Force announcement - + elif event.key in [pygame.K_PAGEUP, pygame.K_LEFT] and currentDirIndex > 0: pygame.mixer.stop() currentDirIndex -= 1 @@ -390,7 +390,7 @@ def learn_sounds(sounds): currentSoundIndex = 0 directoryChanged = True # Set flag on directory change lastSpoken = -1 # Force announcement - + # Play sound elif event.key == pygame.K_RETURN: try: @@ -400,16 +400,16 @@ def learn_sounds(sounds): except Exception as e: print(f"Error playing sound: {e}") speech.speak("Could not play sound.") - + event = pygame.event.clear() pygame.event.pump() # Process pygame's internal events time.sleep(0.001) - + return "menu" def instructions(): """Display game instructions from file. - + Reads and displays instructions from 'files/instructions.txt'. If file is missing, displays an error message. """ @@ -441,7 +441,7 @@ def credits(): def donate(): """Open the donation webpage. - + Opens the Ko-fi donation page. """ webbrowser.open('https://ko-fi.com/stormux') @@ -449,20 +449,20 @@ def donate(): def exit_game(fade=0): """Clean up and exit the game properly. - + Args: fade (int): Milliseconds to fade out music before exiting. 0 means stop immediately (default) """ # Force clear any pending events to prevent hanging pygame.event.clear() - + # Stop all mixer channels first try: pygame.mixer.stop() except Exception as e: print(f"Warning: Could not stop mixer channels: {e}") - + # Get speech instance and handle all providers try: speech = Speech.get_instance() @@ -473,7 +473,7 @@ def exit_game(fade=0): print(f"Warning: Could not close speech: {e}") except Exception as e: print(f"Warning: Could not get speech instance: {e}") - + # Handle music based on fade parameter try: if fade > 0 and pygame.mixer.music.get_busy(): @@ -484,13 +484,13 @@ def exit_game(fade=0): pygame.mixer.music.stop() except Exception as e: print(f"Warning: Could not handle music during exit: {e}") - + # Clean up pygame try: pygame.quit() except Exception as e: print(f"Warning: Error during pygame.quit(): {e}") - + # Use os._exit for immediate termination import os os._exit(0) diff --git a/scoreboard.py b/scoreboard.py index 5c7c131..904b00c 100644 --- a/scoreboard.py +++ b/scoreboard.py @@ -18,10 +18,10 @@ from .config import localConfig, write_config, read_config class Scoreboard: """Handles high score tracking with player names.""" - + def __init__(self, score=0, configService=None, speech=None): """Initialize scoreboard. - + Args: score (int): Initial score (default: 0) configService (ConfigService): Config service (default: global instance) @@ -29,15 +29,15 @@ class Scoreboard: """ # Ensure services are properly initialized self._ensure_services() - + self.configService = configService or ConfigService.get_instance() self.speech = speech or Speech.get_instance() self.currentScore = score self.highScores = [] - + # For backward compatibility read_config() - + try: # Try to use configService self.configService.localConfig.add_section("scoreboard") @@ -47,7 +47,7 @@ class Scoreboard: localConfig.add_section("scoreboard") except: pass - + # Load existing high scores for i in range(1, 11): try: @@ -72,15 +72,15 @@ class Scoreboard: 'name': "Player", 'score': 0 }) - + # Sort high scores by score value in descending order self.highScores.sort(key=lambda x: x['score'], reverse=True) - + def _ensure_services(self): """Ensure PathService and ConfigService are properly initialized.""" # Get PathService and make sure it has a game name pathService = PathService.get_instance() - + # If no game name yet, try to get from pygame window title if not pathService.gameName: try: @@ -89,55 +89,55 @@ class Scoreboard: pathService.gameName = pygame.display.get_caption()[0] except: pass - + # Initialize path service if we have a game name but no paths set up if pathService.gameName and not pathService.gamePath: pathService.initialize(pathService.gameName) - + # Get ConfigService and connect to PathService configService = ConfigService.get_instance() if not hasattr(configService, 'pathService') or not configService.pathService: if pathService.gameName: configService.set_game_info(pathService.gameName, pathService) - + # Ensure the game directory exists if pathService.gamePath and not os.path.exists(pathService.gamePath): try: os.makedirs(pathService.gamePath) except Exception as e: print(f"Error creating game directory: {e}") - + 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) return self - + def increase_score(self, points=1): """Increase the current score.""" self.currentScore += int(points) return self - + def set_score(self, score): """Set the current score to a specific value.""" self.currentScore = int(score) return self - + def reset_score(self): """Reset the current score to zero.""" self.currentScore = 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 """ @@ -145,23 +145,23 @@ class Scoreboard: if self.currentScore > 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 """ # Ensure services are properly set up self._ensure_services() - + 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 @@ -169,23 +169,23 @@ class Scoreboard: 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 - try both methods for maximum compatibility try: # Try new method first for i, entry in enumerate(self.highScores): self.configService.localConfig.set("scoreboard", f"score_{i+1}", str(entry['score'])) self.configService.localConfig.set("scoreboard", f"name_{i+1}", entry['name']) - + # Try to write with configService try: self.configService.write_local_config() @@ -196,7 +196,7 @@ class Scoreboard: 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: print(f"Error writing high scores: {e}") # If all else fails, try direct old method @@ -204,7 +204,7 @@ class Scoreboard: 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!") @@ -212,14 +212,14 @@ class Scoreboard: # 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 @staticmethod def has_high_scores(): """Check if the current game has any high scores. - + Returns: bool: True if at least one high score exists, False otherwise """ @@ -227,7 +227,7 @@ class Scoreboard: # Get PathService to access game name pathService = PathService.get_instance() gameName = pathService.gameName - + # If no game name, try to get from window title if not gameName: try: @@ -237,31 +237,31 @@ class Scoreboard: pathService.gameName = gameName except: pass - + # Ensure path service is properly initialized if gameName and not pathService.gamePath: pathService.initialize(gameName) - + # Get the config file path configPath = os.path.join(pathService.gamePath, "config.ini") - + # If config file doesn't exist, there are no scores if not os.path.exists(configPath): return False - + # Ensure config service is properly connected to path service configService = ConfigService.get_instance() configService.set_game_info(gameName, pathService) - + # Create scoreboard using the properly initialized services board = Scoreboard(0, configService) - + # Force a read of local config to ensure fresh data configService.read_local_config() - + # Get high scores scores = board.get_high_scores() - + # Check if any score is greater than zero return any(score['score'] > 0 for score in scores) except Exception as e: @@ -271,7 +271,7 @@ class Scoreboard: @staticmethod def display_high_scores(): """Display high scores for the current game. - + Reads the high scores from Scoreboard class. Shows the game name at the top followed by the available scores. """ @@ -279,7 +279,7 @@ class Scoreboard: # Get PathService to access game name pathService = PathService.get_instance() gameName = pathService.gameName - + # If no game name, try to get from window title if not gameName: try: @@ -289,30 +289,30 @@ class Scoreboard: pathService.gameName = gameName except: pass - + # Ensure path service is properly initialized if gameName and not pathService.gamePath: pathService.initialize(gameName) - + # Ensure config service is properly connected to path service configService = ConfigService.get_instance() configService.set_game_info(gameName, pathService) - + # Create scoreboard using the properly initialized services board = Scoreboard(0, configService) - + # Force a read of local config to ensure fresh data configService.read_local_config() - + # Get high scores scores = board.get_high_scores() - + # Filter out scores with zero points validScores = [score for score in scores if score['score'] > 0] - + # Prepare the lines to display lines = [f"High Scores for {gameName}:"] - + # Add scores to the display list if validScores: for i, entry in enumerate(validScores, 1): @@ -320,13 +320,13 @@ class Scoreboard: lines.append(scoreStr) else: lines.append("No high scores yet.") - + # Display the high scores display_text(lines) except Exception as e: print(f"Error displaying high scores: {e}") info = ["Could not display high scores."] display_text(info) - + # For backward compatibility with older code that might call displayHigh_scores displayHigh_scores = display_high_scores diff --git a/services.py b/services.py index fb16e54..dd2fd7a 100644 --- a/services.py +++ b/services.py @@ -17,37 +17,37 @@ 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.localConfig = configparser.ConfigParser() self.globalConfig = configparser.ConfigParser() self.gameTitle = None self.pathService = None - + def set_game_info(self, gameTitle, pathService): """Set game information and initialize configs. - + Args: gameTitle (str): Title of the game pathService (PathService): Path service instance """ self.gameTitle = gameTitle self.pathService = pathService - + # Load existing configurations self.read_local_config() self.read_global_config() - + def read_local_config(self): """Read local configuration from file.""" try: @@ -66,7 +66,7 @@ class ConfigService: self.localConfig.read_dict(globals().get('localConfig', {})) except: pass - + def read_global_config(self): """Read global configuration from file.""" try: @@ -85,7 +85,7 @@ class ConfigService: self.globalConfig.read_dict(globals().get('globalConfig', {})) except: pass - + def write_local_config(self): """Write local configuration to file.""" try: @@ -104,7 +104,7 @@ class ConfigService: 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: @@ -127,37 +127,37 @@ class ConfigService: 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.bgmVolume = 0.75 # Default background music volume self.sfxVolume = 1.0 # Default sound effects volume self.masterVolume = 1.0 # Default master volume - + def adjust_master_volume(self, change, pygameMixer=None): """Adjust the master volume for all sounds. - + Args: change (float): Amount to change volume by (positive or negative) pygameMixer: Optional pygame.mixer module for real-time updates """ self.masterVolume = max(0.0, min(1.0, self.masterVolume + change)) - + # Update real-time audio if pygame mixer is provided if pygameMixer: # Update music volume if pygameMixer.music.get_busy(): pygameMixer.music.set_volume(self.bgmVolume * self.masterVolume) - + # Update all sound channels for i in range(pygameMixer.get_num_channels()): channel = pygameMixer.Channel(i) @@ -170,29 +170,29 @@ class VolumeService: # Stereo audio left, right = currentVolume channel.set_volume(left * self.masterVolume, right * self.masterVolume) - + def adjust_bgm_volume(self, change, pygameMixer=None): """Adjust only the background music volume. - + Args: change (float): Amount to change volume by (positive or negative) pygameMixer: Optional pygame.mixer module for real-time updates """ self.bgmVolume = max(0.0, min(1.0, self.bgmVolume + change)) - + # Update real-time audio if pygame mixer is provided if pygameMixer and pygameMixer.music.get_busy(): pygameMixer.music.set_volume(self.bgmVolume * self.masterVolume) - + def adjust_sfx_volume(self, change, pygameMixer=None): """Adjust volume for sound effects only. - + Args: change (float): Amount to change volume by (positive or negative) pygameMixer: Optional pygame.mixer module for real-time updates """ self.sfxVolume = max(0.0, min(1.0, self.sfxVolume + change)) - + # Update real-time audio if pygame mixer is provided if pygameMixer: # Update all sound channels except reserved ones @@ -208,18 +208,18 @@ class VolumeService: left, right = currentVolume channel.set_volume(left * self.sfxVolume * self.masterVolume, right * self.sfxVolume * self.masterVolume) - + def get_bgm_volume(self): """Get the current BGM volume with master adjustment. - + Returns: float: Current adjusted BGM volume """ return self.bgmVolume * self.masterVolume - + def get_sfx_volume(self): """Get the current SFX volume with master adjustment. - + Returns: float: Current adjusted SFX volume """ @@ -228,32 +228,32 @@ class VolumeService: 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.globalPath = None self.gamePath = None self.gameName = None - + # Try to initialize from global variables for backward compatibility global gamePath, globalPath if gamePath: self.gamePath = gamePath if globalPath: self.globalPath = globalPath - + def initialize(self, gameTitle): """Initialize paths for a game. - + Args: gameTitle (str): Title of the game """ @@ -261,14 +261,14 @@ class PathService: self.globalPath = os.path.join(BaseDirectory.xdg_config_home, "storm-games") self.gamePath = os.path.join(self.globalPath, str.lower(str.replace(gameTitle, " ", "-"))) - + # Create game directory if it doesn't exist if not os.path.exists(self.gamePath): os.makedirs(self.gamePath) - + # Update global variables for backward compatibility global gamePath, globalPath gamePath = self.gamePath globalPath = self.globalPath - + return self diff --git a/sound.py b/sound.py index bf01427..de38a17 100644 --- a/sound.py +++ b/sound.py @@ -4,7 +4,7 @@ Provides functionality for: - Playing background music and sound effects -- Positional audio +- 2D positional audio (x,y) - Volume controls """ @@ -13,8 +13,7 @@ import pygame import random import re import time -from os import listdir -from os.path import isfile, join +import math from .services import VolumeService # Global instance for backward compatibility @@ -22,380 +21,207 @@ volumeService = VolumeService.get_instance() class Sound: """Handles sound loading and playback.""" - + def __init__(self, soundDir="sounds/", volumeService=None): - """Initialize sound system. - - Args: - soundDir (str): Directory containing sound files (default: "sounds/") - volumeService (VolumeService): Volume service (default: global instance) - """ + """Initialize sound system.""" self.soundDir = soundDir self.sounds = {} self.volumeService = volumeService 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 + pygame.mixer.set_reserved(0) + self.load_sounds() - + def load_sounds(self): - """Load all sound files from the sound directory and its subdirectories. - - Searches recursively through subdirectories and loads all sound files with - .ogg or .wav extensions. Sound names are stored as relative paths from the - sound directory, with directory separators replaced by forward slashes. - """ + """Load all sound files from the sound directory and its subdirectories.""" try: - # Walk through directory tree - for dirPath, dirNames, fileNames in os.walk(self.soundDir): - # Get relative path from soundDir + for dirPath, _, fileNames in os.walk(self.soundDir): relPath = os.path.relpath(dirPath, self.soundDir) - - # Process each file + for fileName in fileNames: - # Check if file is a valid sound file if fileName.lower().endswith(('.ogg', '.wav')): - # Full path to the sound file fullPath = os.path.join(dirPath, fileName) - - # Create sound key (remove extension) baseName = os.path.splitext(fileName)[0] - - # If in root sounds dir, just use basename - if relPath == '.': - soundKey = baseName - else: - # Otherwise use relative path + basename, normalized with forward slashes - soundKey = os.path.join(relPath, baseName).replace('\\', '/') - - # Load the sound + + soundKey = baseName if relPath == '.' else os.path.join(relPath, baseName).replace('\\', '/') self.sounds[soundKey] = pygame.mixer.Sound(fullPath) - except Exception as e: print(f"Error loading sounds: {e}") - - def play_sound(self, soundName, volume=1.0, loop=False): - """Play a sound with current volume settings applied. - - Args: - soundName (str): Name of sound to play - volume (float): Base volume for the sound (0.0-1.0, default: 1.0) - - Returns: - pygame.mixer.Channel: The channel the sound is playing on - """ - if soundName not in self.sounds: - return None - - sound = self.sounds[soundName] - if loop: - channel = sound.play(-1) - else: - channel = sound.play() - if channel: - channel.set_volume(volume * self.volumeService.get_sfx_volume()) - return channel - - def calculate_volume_and_pan(self, playerPos, objPos, maxDistance=12): - """Calculate volume and stereo panning based on relative positions. - - Args: - playerPos (float): Player's position on x-axis - objPos (float): Object's position on x-axis - maxDistance (float): Maximum audible distance (default: 12) - - Returns: - tuple: (volume, left_vol, right_vol) values between 0 and 1 - """ - distance = abs(playerPos - objPos) - - if distance > maxDistance: - return 0, 0, 0 # No sound if out of range - - # Calculate volume (non-linear scaling for more noticeable changes) - # Apply masterVolume as the maximum possible volume - volume = (((maxDistance - distance) / maxDistance) ** 1.5) * self.volumeService.masterVolume - - # Determine left/right based on relative position - if playerPos < objPos: - # Object is to the right - left = max(0, 1 - (objPos - playerPos) / maxDistance) - right = 1 - elif playerPos > objPos: - # Object is to the left - left = 1 - right = max(0, 1 - (playerPos - objPos) / maxDistance) - else: - # Player is on the object - left = right = 1 - - return volume, left, right - - def obj_play(self, soundName, playerPos, objPos, loop=True): - """Play a sound with positional audio. - - Args: - soundName (str): Name of sound to play - playerPos (float): Player's position for audio panning - objPos (float): Object's position for audio panning - loop (bool): Whether to loop the sound (default: True) - - Returns: - pygame.mixer.Channel: Sound channel object, or None if out of range - """ - if soundName not in self.sounds: - return None - - volume, left, right = self.calculate_volume_and_pan(playerPos, objPos) - if volume == 0: - return None # Don't play if out of range - - # Play the sound on a new channel - channel = self.sounds[soundName].play(-1 if loop else 0) - if channel: - channel.set_volume( - volume * left * self.volumeService.sfxVolume, - volume * right * self.volumeService.sfxVolume - ) - return channel - - def obj_update(self, channel, playerPos, objPos): - """Update positional audio for a playing sound. - - Args: - channel: Sound channel to update - playerPos (float): New player position - objPos (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(playerPos, objPos) - if volume == 0: - channel.stop() - return None - - # Apply the volume and pan - channel.set_volume( - volume * left * self.volumeService.sfxVolume, - volume * right * self.volumeService.sfxVolume - ) - 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, soundNames, probability, randomLocation=False): - """Play random ambient sounds with optional positional audio. - - Args: - soundNames (list): List of possible sound names to choose from - probability (int): Chance to play (1-100) - randomLocation (bool): Whether to randomize stereo position - - 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) - if ambianceSound not in self.sounds: - return None - - channel = self.sounds[ambianceSound].play() - - if randomLocation and channel: - leftVolume = random.random() * self.volumeService.get_sfx_volume() - rightVolume = random.random() * self.volumeService.get_sfx_volume() - channel.set_volume(leftVolume, rightVolume) - - return channel - - def play_random(self, soundPrefix, pause=False, interrupt=False): - """Play a random variation of a sound. - - Args: - soundPrefix (str): Base name of sound (will match all starting with this) - pause (bool): Whether to pause execution until sound finishes - interrupt (bool): Whether to interrupt other sounds - """ - keys = [] - for i in self.sounds.keys(): - if re.match("^" + soundPrefix + ".*", i): - keys.append(i) - - if not keys: # No matching sounds found - return None - - randomKey = random.choice(keys) - - if interrupt: - self.cut_scene(randomKey) - return - - channel = self.sounds[randomKey].play() - sfxVolume = self.volumeService.get_sfx_volume() - - if channel: - channel.set_volume(sfxVolume, sfxVolume) - - if pause: - time.sleep(self.sounds[randomKey].get_length()) - - return channel - - def play_random_positional(self, soundPrefix, playerX, objectX): - """Play a random variation of a sound with positional audio. - - Args: - soundPrefix (str): Base name of sound to match - playerX (float): Player's x position - objectX (float): Object's x position - - Returns: - pygame.mixer.Channel: Sound channel if played, None otherwise - """ - keys = [k for k in self.sounds.keys() if k.startswith(soundPrefix)] - if not keys: - return None - - randomKey = random.choice(keys) - volume, left, right = self.calculate_volume_and_pan(playerX, objectX) - - if volume == 0: - return None - - channel = self.sounds[randomKey].play() - if channel: - channel.set_volume( - volume * left * self.volumeService.sfxVolume, - volume * right * self.volumeService.sfxVolume - ) - return channel - - def play_directional_sound(self, 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: - 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 - """ - if soundName not in self.sounds: - return None - - channel = self.sounds[soundName].play() - if channel: - # Apply volume settings - finalVolume = volume * self.volumeService.get_sfx_volume() - - # 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(self, soundName): - """Play a sound as a cut scene, stopping other sounds. - - Args: - soundName (str): Name of sound to play - """ - if soundName not in self.sounds: - return - + + def _find_matching_sound(self, pattern): + """Find a random sound matching the pattern.""" + keys = [k for k in self.sounds.keys() if re.match("^" + pattern + ".*", k)] + return random.choice(keys) if keys else None + + def _handle_cutscene(self, soundName): + """Play a sound as a cut scene.""" pygame.event.clear() pygame.mixer.stop() - - # Get the reserved channel (0) for cut scenes + channel = pygame.mixer.Channel(0) - - # Apply the appropriate volume settings sfxVolume = self.volumeService.get_sfx_volume() channel.set_volume(sfxVolume, sfxVolume) - - # Play the sound + channel.play(self.sounds[soundName]) - + while pygame.mixer.get_busy(): for event in pygame.event.get(): if event.type == pygame.KEYDOWN and event.key in [pygame.K_ESCAPE, pygame.K_RETURN, pygame.K_SPACE]: pygame.mixer.stop() - return + return None pygame.time.delay(10) - - def play_random_falling(self, soundPrefix, playerX, objectX, startY, - currentY=0, maxY=20, existingChannel=None): - """Play or update a falling sound with positional audio and volume based on height. - - Args: - soundPrefix (str): Base name of sound to match - playerX (float): Player's x position - objectX (float): Object's x position - startY (float): Starting Y position (0-20, higher = quieter start) - currentY (float): Current Y position (0 = ground level) (default: 0) - maxY (float): Maximum Y value (default: 20) - existingChannel: 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(playerX, objectX) - - # Calculate vertical fall volume multiplier (0 at maxY, 1 at y=0) + + return None + + def _get_stereo_panning(self, playerPos, objPos, centerDistance=None): + """Calculate stereo panning based on positions.""" + # Extract x-positions + playerX = playerPos[0] if isinstance(playerPos, (tuple, list)) else playerPos + objX = objPos[0] if isinstance(objPos, (tuple, list)) else objPos + + # For directional sound with fixed distance + if centerDistance is not None: + if abs(playerX - objX) <= centerDistance: + return (1, 1) # Center + elif playerX > objX: + return (1, 0.505) # Left + else: + return (0.505, 1) # Right + + # Calculate regular panning + volume, left, right = self.calculate_volume_and_pan(playerPos, objPos) + return (volume * left, volume * right) if volume > 0 else (0, 0) + + def play_sound(self, soundName, volume=1.0, loop=False, playerPos=None, objPos=None, + centerDistance=None, pattern=False, interrupt=False, pause=False, cutScene=False): + """Unified method to play sounds with various options.""" + # Resolve sound name if pattern matching is requested + if pattern: + soundName = self._find_matching_sound(soundName) + if not soundName: + return None + + # Check if sound exists + if soundName not in self.sounds: + return None + + # Handle cut scene mode + if cutScene: + return self._handle_cutscene(soundName) + + # Handle interrupt (stop other sounds) + if interrupt: + pygame.event.clear() + pygame.mixer.stop() + + # Play the sound + channel = self.sounds[soundName].play(-1 if loop else 0) + if not channel: + return None + + # Apply appropriate volume settings + sfx_volume = self.volumeService.get_sfx_volume() + + # Handle positional audio if positions are provided + if playerPos is not None and objPos is not None: + # Calculate stereo panning + left_vol, right_vol = self._get_stereo_panning(playerPos, objPos, centerDistance) + + # Don't play if out of range + if left_vol == 0 and right_vol == 0: + channel.stop() + return None + + # Apply positional volume adjustments + channel.set_volume(volume * left_vol * sfx_volume, volume * right_vol * sfx_volume) + else: + # Non-positional sound + channel.set_volume(volume * sfx_volume) + + # Pause execution if requested + if pause: + time.sleep(self.sounds[soundName].get_length()) + + return channel + + def calculate_volume_and_pan(self, playerPos, objPos, maxDistance=12): + """Calculate volume and stereo panning based on relative positions.""" + # Determine if we're using 2D or 1D positioning + if isinstance(playerPos, (tuple, list)) and isinstance(objPos, (tuple, list)): + # 2D distance calculation + distance = math.sqrt((playerPos[0] - objPos[0])**2 + (playerPos[1] - objPos[1])**2) + playerX, objX = playerPos[0], objPos[0] + else: + # 1D calculation (backward compatible) + distance = abs(playerPos - objPos) + playerX, objX = playerPos, objPos + + if distance > maxDistance: + return 0, 0, 0 # No sound if out of range + + # Calculate volume (non-linear scaling for more noticeable changes) + volume = (((maxDistance - distance) / maxDistance) ** 1.5) * self.volumeService.masterVolume + + # Determine left/right based on relative position + if playerX < objX: # Object is to the right + left = max(0, 1 - (objX - playerX) / maxDistance) + right = 1 + elif playerX > objX: # Object is to the left + left = 1 + right = max(0, 1 - (playerX - objX) / maxDistance) + else: # Player is on the object + left = right = 1 + + return volume, left, right + + def update_sound_position(self, channel, playerPos, objPos): + """Update positional audio for a playing sound.""" + if not channel: + return None + + # Calculate new stereo panning + left_vol, right_vol = self._get_stereo_panning(playerPos, objPos) + + # Stop if out of range + if left_vol == 0 and right_vol == 0: + channel.stop() + return None + + # Apply the volume and pan + channel.set_volume(left_vol * self.volumeService.sfxVolume, right_vol * self.volumeService.sfxVolume) + return channel + + def stop_sound(self, channel): + """Stop a playing sound channel.""" + if channel: + try: + channel.stop() + except: + pass + return None + + def play_falling_sound(self, soundPrefix, playerPos, objPos, startY, currentY=0, maxY=20, existingChannel=None): + """Play or update a sound with positional audio that changes with height.""" + # Extract positions + playerX = playerPos[0] if isinstance(playerPos, (tuple, list)) else playerPos + objX = objPos[0] if isinstance(objPos, (tuple, list)) else objPos + + # Calculate volumes + volume, left, right = self.calculate_volume_and_pan(playerX, objX) + + # Apply vertical fall multiplier (0 at maxY, 1 at y=0) fallMultiplier = 1 - (currentY / maxY) - - # Adjust final volumes finalVolume = volume * fallMultiplier finalLeft = left * finalVolume finalRight = right * finalVolume - - if existingChannel is not None: - if volume == 0: # Out of audible range + + # Update existing channel or create new one + if existingChannel: + if volume == 0: existingChannel.stop() return None existingChannel.set_volume( @@ -403,17 +229,17 @@ class Sound: finalRight * self.volumeService.sfxVolume ) return existingChannel - else: # Need to create new channel - if volume == 0: # Don't start if out of range + else: + if volume == 0: return None - - # Find matching sound files - keys = [k for k in self.sounds.keys() if k.startswith(soundPrefix)] - if not keys: + + # Find a matching sound + soundName = self._find_matching_sound(soundPrefix) + if not soundName: return None - - randomKey = random.choice(keys) - channel = self.sounds[randomKey].play() + + # Play the sound + channel = self.sounds[soundName].play() if channel: channel.set_volume( finalLeft * self.volumeService.sfxVolume, @@ -421,362 +247,282 @@ class Sound: ) return channel + def play_bgm(self, musicFile): + """Play background music with proper volume settings.""" + try: + pygame.mixer.music.stop() + pygame.mixer.music.load(musicFile) + pygame.mixer.music.set_volume(self.volumeService.get_bgm_volume()) + pygame.mixer.music.play(-1) + except Exception as e: + print(f"Error playing background music: {e}") -# Global functions for backward compatibility + def adjust_master_volume(self, change): + """Adjust the master volume for all sounds.""" + self.volumeService.adjust_master_volume(change, pygame.mixer) -def play_bgm(musicFile): - """Play background music with proper volume settings. - - Args: - musicFile (str): Path to the music file to play - """ - try: - pygame.mixer.music.stop() - pygame.mixer.music.load(musicFile) - pygame.mixer.music.set_volume(volumeService.get_bgm_volume()) - pygame.mixer.music.play(-1) # Loop indefinitely - except Exception as e: - pass + def adjust_bgm_volume(self, change): + """Adjust only the background music volume.""" + self.volumeService.adjust_bgm_volume(change, pygame.mixer) -def adjust_master_volume(change): - """Adjust the master volume for all sounds. - - Args: - change (float): Amount to change volume by (positive or negative) - """ - volumeService.adjust_master_volume(change, pygame.mixer) + def adjust_sfx_volume(self, change): + """Adjust volume for sound effects only.""" + self.volumeService.adjust_sfx_volume(change, pygame.mixer) -def adjust_bgm_volume(change): - """Adjust only the background music volume. - - Args: - change (float): Amount to change volume by (positive or negative) - """ - volumeService.adjust_bgm_volume(change, pygame.mixer) -def adjust_sfx_volume(change): - """Adjust volume for sound effects only. - - Args: - change (float): Amount to change volume by (positive or negative) - """ - volumeService.adjust_sfx_volume(change, pygame.mixer) +# Optimized helper functions for global use +def _get_stereo_panning(playerPos, objPos, centerDistance=None, maxDistance=12): + """Simplified panning calculation.""" + # Extract x-positions + playerX = playerPos[0] if isinstance(playerPos, (tuple, list)) else playerPos + objX = objPos[0] if isinstance(objPos, (tuple, list)) else objPos -def calculate_volume_and_pan(playerPos, objPos): - """Calculate volume and stereo panning based on relative positions. - - Args: - playerPos (float): Player's position on x-axis - objPos (float): Object's position on x-axis - - Returns: - tuple: (volume, left_vol, right_vol) values between 0 and 1 - """ - distance = abs(playerPos - objPos) - maxDistance = 12 # Maximum audible distance - - if distance > maxDistance: - return 0, 0, 0 # No sound if out of range - - # Calculate volume (non-linear scaling for more noticeable changes) - # Apply masterVolume as the maximum possible volume - volume = (((maxDistance - distance) / maxDistance) ** 1.5) * volumeService.masterVolume - - # Determine left/right based on relative position - if playerPos < objPos: - # Object is to the right - left = max(0, 1 - (objPos - playerPos) / maxDistance) - right = 1 - elif playerPos > objPos: - # Object is to the left - left = 1 - right = max(0, 1 - (playerPos - objPos) / maxDistance) - else: - # Player is on the object - left = right = 1 - - return volume, left, right - -def play_sound(sound, volume=1.0, loop=False): - """Play a sound with current volume settings applied. - - Args: - sound: pygame Sound object to play - volume: base volume for the sound (0.0-1.0, default: 1.0) - - Returns: - pygame.mixer.Channel: The channel the sound is playing on - """ - if loop: - channel = sound.play(-1) - else: - channel = sound.play() - if channel: - channel.set_volume(volume * volumeService.get_sfx_volume()) - return channel - -def obj_play(sounds, soundName, playerPos, objPos, loop=True): - """Play a sound with positional audio. - - Args: - sounds (dict): Dictionary of sound objects - soundName (str): Name of sound to play - playerPos (float): Player's position for audio panning - objPos (float): Object's position for audio panning - 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(playerPos, objPos) - 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 * volumeService.sfxVolume, - volume * right * volumeService.sfxVolume) - return channel - -def obj_update(channel, playerPos, objPos): - """Update positional audio for a playing sound. - - Args: - channel: Sound channel to update - playerPos (float): New player position - objPos (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(playerPos, objPos) - if volume == 0: - channel.stop() - return None - - # Apply the volume and pan - channel.set_volume(volume * left * volumeService.sfxVolume, - volume * right * volumeService.sfxVolume) - return channel - -def obj_stop(channel): - """Stop a playing sound channel. - - Args: - channel: Sound channel to stop - - Returns: - None if stopped successfully, otherwise returns original channel - """ - try: - channel.stop() - return None - except: - return channel - -def play_ambiance(sounds, soundNames, probability, randomLocation=False): - """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() * volumeService.get_sfx_volume() - rightVolume = random.random() * volumeService.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: - sfxVolume = volumeService.get_sfx_volume() - channel.set_volume(sfxVolume, sfxVolume) - - if pause: - time.sleep(sounds[randomKey].get_length()) - -def play_random_positional(sounds, soundName, playerX, objectX): - """Play a random variation of a sound with positional audio. - - Args: - sounds (dict): Dictionary of sound objects - soundName (str): Base name of sound to match - playerX (float): Player's x position - objectX (float): Object's x position - - Returns: - pygame.mixer.Channel: Sound channel if played, None otherwise - """ - keys = [k for k in sounds.keys() if k.startswith(soundName)] - if not keys: - return None - - randomKey = random.choice(keys) - volume, left, right = calculate_volume_and_pan(playerX, objectX) - - if volume == 0: - return None - - channel = sounds[randomKey].play() - if channel: - channel.set_volume(volume * left * volumeService.sfxVolume, - volume * right * volumeService.sfxVolume) - return channel - -def play_directional_sound(sounds, soundName, playerPos, objPos, centerDistance=3, volume=1.0): - """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 * volumeService.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) + # For directional sound with fixed distance + if centerDistance is not None: + if abs(playerX - objX) <= centerDistance: + return (1, 1) # Center + elif playerX > objX: + return (1, 0.505) # Left else: - # Object is to the right of player - channel.set_volume((finalVolume + 0.01) / 2, finalVolume) - return channel + return (0.505, 1) # Right -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 - """ + # Calculate distance + if isinstance(playerPos, (tuple, list)) and isinstance(objPos, (tuple, list)): + distance = math.sqrt((playerPos[0] - objPos[0])**2 + (playerPos[1] - objPos[1])**2) + else: + distance = abs(playerPos - objPos) + + if distance > maxDistance: + return (0, 0) # No sound if out of range + + # Calculate volume (non-linear scaling for more noticeable changes) + volume = (((maxDistance - distance) / maxDistance) ** 1.5) * volumeService.masterVolume + + # Determine left/right based on relative position + if playerX < objX: # Object is to the right + left = max(0, 1 - (objX - playerX) / maxDistance) + right = 1 + elif playerX > objX: # Object is to the left + left = 1 + right = max(0, 1 - (playerX - objX) / maxDistance) + else: # Player is on the object + left = right = 1 + + return (volume * left, volume * right) + +def _play_cutscene(sound, sounds=None): + """Play a sound as a cut scene.""" pygame.event.clear() pygame.mixer.stop() - - # Get the reserved channel (0) for cut scenes + channel = pygame.mixer.Channel(0) - - # Apply the appropriate volume settings sfxVolume = volumeService.get_sfx_volume() channel.set_volume(sfxVolume, sfxVolume) - - # Play the sound - channel.play(sounds[soundName]) - + + # Determine which sound to play + if isinstance(sound, pygame.mixer.Sound): + channel.play(sound) + elif isinstance(sounds, dict) and sound in sounds: + channel.play(sounds[sound]) + elif isinstance(sounds, Sound) and sound in sounds.sounds: + channel.play(sounds.sounds[sound]) + else: + return None + + # Wait for completion or key press while pygame.mixer.get_busy(): for event in pygame.event.get(): if event.type == pygame.KEYDOWN and event.key in [pygame.K_ESCAPE, pygame.K_RETURN, pygame.K_SPACE]: pygame.mixer.stop() - return + return None pygame.time.delay(10) -def play_random_falling(sounds, soundName, playerX, objectX, startY, - currentY=0, maxY=20, existingChannel=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 - playerX (float): Player's x position - objectX (float): Object's x position - startY (float): Starting Y position (0-20, higher = quieter start) - currentY (float): Current Y position (0 = ground level) (default: 0) - maxY (float): Maximum Y value (default: 20) - existingChannel: 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(playerX, objectX) - - # Calculate vertical fall volume multiplier (0 at maxY, 1 at y=0) - fallMultiplier = 1 - (currentY / maxY) - - # Adjust final volumes - finalVolume = volume * fallMultiplier - finalLeft = left * finalVolume - finalRight = right * finalVolume - - if existingChannel is not None: - if volume == 0: # Out of audible range - existingChannel.stop() + return None + +def _find_matching_sound(soundPattern, sounds): + """Find sounds matching a pattern in a dictionary.""" + if isinstance(sounds, Sound): + keys = [k for k in sounds.sounds.keys() if re.match("^" + soundPattern + ".*", k)] + else: + keys = [k for k in sounds.keys() if re.match("^" + soundPattern + ".*", k)] + return random.choice(keys) if keys else None + +# Global functions for backward compatibility +def play_bgm(musicFile): + """Play background music with proper volume settings.""" + try: + pygame.mixer.music.stop() + pygame.mixer.music.load(musicFile) + pygame.mixer.music.set_volume(volumeService.get_bgm_volume()) + pygame.mixer.music.play(-1) + except: pass + +def adjust_master_volume(change): + """Adjust the master volume.""" + volumeService.adjust_master_volume(change, pygame.mixer) + +def adjust_bgm_volume(change): + """Adjust background music volume.""" + volumeService.adjust_bgm_volume(change, pygame.mixer) + +def adjust_sfx_volume(change): + """Adjust sound effects volume.""" + volumeService.adjust_sfx_volume(change, pygame.mixer) + +def calculate_volume_and_pan(playerPos, objPos, maxDistance=12): + """Calculate volume and stereo panning.""" + left_vol, right_vol = _get_stereo_panning(playerPos, objPos, None, maxDistance) + # Convert to old format (volume, left, right) + if left_vol == 0 and right_vol == 0: + return 0, 0, 0 + elif left_vol >= right_vol: + volume = left_vol + return volume, 1, right_vol/left_vol + else: + volume = right_vol + return volume, left_vol/right_vol, 1 + +def play_sound(sound_or_name, volume=1.0, loop=False, playerPos=None, objPos=None, + centerDistance=None, pattern=False, interrupt=False, pause=False, + cutScene=False, sounds=None): + """Unified sound playing function with backward compatibility.""" + # Handle cut scene mode + if cutScene: + return _play_cutscene(sound_or_name, sounds) + + # Handle pattern matching + if pattern and isinstance(sound_or_name, str) and sounds: + matched_sound = _find_matching_sound(sound_or_name, sounds) + if not matched_sound: return None - existingChannel.set_volume(finalLeft * volumeService.sfxVolume, - finalRight * volumeService.sfxVolume) - return existingChannel - else: # Need to create new channel - if volume == 0: # Don't start if out of range - return None - - # Find matching sound files - keys = [k for k in sounds.keys() if k.startswith(soundName)] - if not keys: - return None - - randomKey = random.choice(keys) - channel = sounds[randomKey].play() + sound_or_name = matched_sound + + # Handle interrupt + if interrupt: + pygame.event.clear() + pygame.mixer.stop() + + # Case 1: Sound instance provided + if isinstance(sound_or_name, Sound): + return sound_or_name.play_sound(sound_or_name, volume, loop, playerPos, objPos, + centerDistance, False, False, pause, False) + + # Case 2: Sound name with Sound instance + elif isinstance(sounds, Sound) and isinstance(sound_or_name, str): + return sounds.play_sound(sound_or_name, volume, loop, playerPos, objPos, + centerDistance, False, False, pause, False) + + # Case 3: Direct pygame.Sound + elif isinstance(sound_or_name, pygame.mixer.Sound): + channel = sound_or_name.play(-1 if loop else 0) if channel: - channel.set_volume(finalLeft * volumeService.sfxVolume, - finalRight * volumeService.sfxVolume) + channel.set_volume(volume * volumeService.get_sfx_volume()) return channel + + # Case 4: Sound name with dictionary + elif isinstance(sounds, dict) and isinstance(sound_or_name, str) and sound_or_name in sounds: + # Play the sound + channel = sounds[sound_or_name].play(-1 if loop else 0) + if not channel: + return None + + # Apply volume settings + sfx_vol = volumeService.get_sfx_volume() + + # Handle positional audio + if playerPos is not None and objPos is not None: + left_vol, right_vol = _get_stereo_panning(playerPos, objPos, centerDistance) + if left_vol == 0 and right_vol == 0: + channel.stop() + return None + channel.set_volume(volume * left_vol * sfx_vol, volume * right_vol * sfx_vol) + else: + channel.set_volume(volume * sfx_vol) + + # Pause if requested + if pause: + time.sleep(sounds[sound_or_name].get_length()) + + return channel + + return None + +def obj_update(channel, playerPos, objPos): + """Update positional audio for a playing sound.""" + if not channel: + return None + + left_vol, right_vol = _get_stereo_panning(playerPos, objPos) + if left_vol == 0 and right_vol == 0: + channel.stop() + return None + + channel.set_volume(left_vol * volumeService.sfxVolume, right_vol * volumeService.sfxVolume) + return channel + +def obj_stop(channel): + """Stop a sound channel.""" + if channel: + try: channel.stop() + except: pass + return None + +# Extremely concise lambda definitions for legacy functions +obj_play = lambda sounds, soundName, playerPos, objPos, loop=True: play_sound( + soundName, 1.0, loop, playerPos, objPos, None, False, False, False, False, sounds) + +play_ambiance = lambda sounds, soundNames, probability, randomLocation=False: play_sound( + random.choice(soundNames) if random.randint(1, 100) <= probability and not any( + pygame.mixer.find_channel(True) and pygame.mixer.find_channel(True).get_busy() + for _ in ([soundNames] if isinstance(soundNames, str) else soundNames)) else None, + 1.0, False, None, None, None, False, False, False, False, + sounds if not isinstance(sounds, Sound) else None) + +play_random = lambda sounds, soundName, pause=False, interrupt=False: play_sound( + soundName, 1.0, False, None, None, None, True, interrupt, pause, False, sounds) + +play_random_positional = lambda sounds, soundName, playerX, objectX: play_sound( + soundName, 1.0, False, playerX, objectX, None, True, False, False, False, sounds) + +play_directional_sound = lambda sounds, soundName, playerPos, objPos, centerDistance=3, volume=1.0: play_sound( + soundName, volume, False, playerPos, objPos, centerDistance, False, False, False, False, sounds) + +cut_scene = lambda sounds, soundName: _play_cutscene(soundName, sounds) + +def play_random_falling(sounds, soundName, playerX, objectX, startY, currentY=0, maxY=20, existingChannel=None): + """Handle falling sound.""" + if isinstance(sounds, Sound): + return sounds.play_falling_sound(soundName, playerX, objectX, startY, currentY, maxY, existingChannel) + + # Legacy implementation + left_vol, right_vol = _get_stereo_panning(playerX, objectX) + if left_vol == 0 and right_vol == 0: + if existingChannel: + existingChannel.stop() + return None + + # Calculate fall multiplier + fallMultiplier = 1 - (currentY / maxY) + finalLeft = left_vol * fallMultiplier + finalRight = right_vol * fallMultiplier + + if existingChannel: + existingChannel.set_volume( + finalLeft * volumeService.sfxVolume, + finalRight * volumeService.sfxVolume + ) + return existingChannel + + # Find matching sound + matched_sound = _find_matching_sound(soundName, sounds) + if not matched_sound: + return None + + # Play the sound + channel = sounds[matched_sound].play() + if channel: + channel.set_volume( + finalLeft * volumeService.sfxVolume, + finalRight * volumeService.sfxVolume + ) + return channel diff --git a/speech.py b/speech.py index 7e9a591..4da3f28 100644 --- a/speech.py +++ b/speech.py @@ -15,26 +15,26 @@ 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.lastSpoken = {"text": None, "time": 0} self.speechDelay = 250 # ms - + # Try to initialize a speech provider self.provider = None self.providerName = None - + # Try speechd first try: import speechd @@ -44,7 +44,7 @@ class Speech: return except ImportError: pass - + # Try accessible_output2 next try: import accessible_output2.outputs.auto @@ -54,31 +54,31 @@ class Speech: 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 - + currentTime = pygame.time.get_ticks() - + # Check if this is the same text within the delay window if (self.lastSpoken["text"] == text and currentTime - self.lastSpoken["time"] < self.speechDelay): return - + # Update last spoken tracking self.lastSpoken["text"] = text self.lastSpoken["time"] = currentTime - + # Proceed with speech if self.providerName == "speechd": if interrupt: @@ -86,12 +86,12 @@ class Speech: self.spd.say(text) elif self.providerName == "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 maxWidth = screen.get_width() - 40 # Leave a 20-pixel margin on each side @@ -109,7 +109,7 @@ class Speech: screen.blit(surface, textRect) currentY += surface.get_height() pygame.display.flip() - + def close(self): """Clean up speech resources.""" if self.providerName == "speechd": @@ -120,7 +120,7 @@ _speechInstance = 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) diff --git a/utils.py b/utils.py index 14b0503..4c0092e 100644 --- a/utils.py +++ b/utils.py @@ -26,137 +26,137 @@ 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.pathService = PathService.get_instance().initialize(title) self.configService = ConfigService.get_instance() self.configService.set_game_info(title, self.pathService) self.volumeService = VolumeService.get_instance() - + # Initialize game components (lazy loaded) self._speech = None self._sound = None self._scoreboard = None - + # Display text instructions flag self.displayTextUsageInstructions = 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.volumeService) return self._sound - + @property def scoreboard(self): """Get the scoreboard (lazy loaded). - + Returns: Scoreboard: Scoreboard instance """ if not self._scoreboard: self._scoreboard = Scoreboard(self.configService) 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, musicFile): """Play background music. - + Args: musicFile (str): Path to music file - + Returns: Game: Self for method chaining """ self.sound.play_bgm(musicFile) return self - + def display_text(self, textLines): """Display text with navigation controls. - + Args: textLines (list): List of text lines - + Returns: Game: Self for method chaining """ # Store original text with blank lines for copying originalText = textLines.copy() - + # Create navigation text by filtering out blank lines navText = [line for line in textLines if line.strip()] - + # Add instructions at the start on the first display if not self.displayTextUsageInstructions: instructions = ("Press space to read the whole text. Use up and down arrows to navigate " @@ -164,20 +164,20 @@ class Game: "or t to copy the entire text. Press enter or escape when you are done reading.") navText.insert(0, instructions) self.displayTextUsageInstructions = True - + # Add end marker navText.append("End of text.") - + currentIndex = 0 self.speech.speak(navText[currentIndex]) - + while True: event = pygame.event.wait() if event.type == pygame.KEYDOWN: # Check for Alt modifier mods = pygame.key.get_mods() altPressed = mods & pygame.KMOD_ALT - + # Volume controls (require Alt) if altPressed: if event.key == pygame.K_PAGEUP: @@ -195,19 +195,19 @@ class Game: else: if event.key in (pygame.K_ESCAPE, pygame.K_RETURN): return self - + if event.key in [pygame.K_DOWN, pygame.K_s] and currentIndex < len(navText) - 1: currentIndex += 1 self.speech.speak(navText[currentIndex]) - + if event.key in [pygame.K_UP, pygame.K_w] and currentIndex > 0: currentIndex -= 1 self.speech.speak(navText[currentIndex]) - + if event.key == pygame.K_SPACE: # Join with newlines to preserve spacing in speech self.speech.speak('\n'.join(originalText[1:-1])) - + if event.key == pygame.K_c: try: import pyperclip @@ -215,7 +215,7 @@ class Game: self.speech.speak("Copied " + navText[currentIndex] + " to the clipboard.") except: self.speech.speak("Failed to copy the text to the clipboard.") - + if event.key == pygame.K_t: try: import pyperclip @@ -224,10 +224,10 @@ class Game: 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.providerName == "speechd": @@ -241,12 +241,12 @@ class Game: def check_for_updates(currentVersion, gameName, url): """Check for game updates. - + Args: currentVersion (str): Current version string (e.g. "1.0.0") gameName (str): Name of the game url (str): URL to check for updates - + Returns: dict: Update information or None if no update available """ @@ -266,10 +266,10 @@ def check_for_updates(currentVersion, gameName, url): def get_version_tuple(versionStr): """Convert version string to comparable tuple. - + Args: versionStr (str): Version string (e.g. "1.0.0") - + Returns: tuple: Version as tuple of integers """ @@ -277,11 +277,11 @@ def get_version_tuple(versionStr): def check_compatibility(requiredVersion, currentVersion): """Check if current version meets minimum required version. - + Args: requiredVersion (str): Minimum required version string currentVersion (str): Current version string - + Returns: bool: True if compatible, False otherwise """ @@ -291,10 +291,10 @@ def check_compatibility(requiredVersion, currentVersion): def sanitize_filename(filename): """Sanitize a filename to be safe for all operating systems. - + Args: filename (str): Original filename - + Returns: str: Sanitized filename """ @@ -309,12 +309,12 @@ def sanitize_filename(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 """ @@ -322,12 +322,12 @@ def lerp(start, end, 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 """ @@ -338,13 +338,13 @@ def smooth_step(edge0, edge1, 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 """ @@ -352,17 +352,17 @@ def distance_2d(x1, y1, x2, y2): def generate_tone(frequency, duration=0.1, sampleRate=44100, volume=0.2): """Generate a tone at the specified frequency. - + Args: frequency (float): Frequency in Hz duration (float): Duration in seconds (default: 0.1) sampleRate (int): Sample rate in Hz (default: 44100) volume (float): Volume from 0.0 to 1.0 (default: 0.2) - + Returns: pygame.mixer.Sound: Sound object with the generated tone """ - + t = np.linspace(0, duration, int(sampleRate * duration), False) tone = np.sin(2 * np.pi * frequency * t) stereoTone = np.vstack((tone, tone)).T # Create a 2D array for stereo @@ -372,17 +372,17 @@ def generate_tone(frequency, duration=0.1, sampleRate=44100, volume=0.2): def x_powerbar(): """Sound based horizontal power bar - + Returns: int: Selected position between -50 and 50 """ - + clock = pygame.time.Clock() screen = pygame.display.get_surface() position = -50 # Start from the leftmost position direction = 1 # Move right initially barHeight = 20 - + while True: frequency = 440 # A4 note leftVolume = (50 - position) / 100 @@ -390,7 +390,7 @@ def x_powerbar(): tone = generate_tone(frequency) channel = tone.play() channel.set_volume(leftVolume, rightVolume) - + # Visual representation screen.fill((0, 0, 0)) barWidth = screen.get_width() - 40 # Leave 20px margin on each side @@ -398,13 +398,13 @@ def x_powerbar(): markerPos = int(20 + (position + 50) / 100 * barWidth) pygame.draw.rect(screen, (255, 0, 0), (markerPos - 5, screen.get_height() // 2 - barHeight, 10, barHeight * 2)) pygame.display.flip() - + for event in pygame.event.get(): check_for_exit() if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE: channel.stop() return position # This will return a value between -50 and 50 - + position += direction if position > 50: position = 50 @@ -412,27 +412,27 @@ def x_powerbar(): elif position < -50: position = -50 direction = 1 - + clock.tick(40) # Speed of bar def y_powerbar(): """Sound based vertical power bar - + Returns: int: Selected power level between 0 and 100 """ - + clock = pygame.time.Clock() screen = pygame.display.get_surface() power = 0 direction = 1 # 1 for increasing, -1 for decreasing barWidth = 20 - + while True: frequency = 220 + (power * 5) # Adjust these values to change the pitch range tone = generate_tone(frequency) channel = tone.play() - + # Visual representation screen.fill((0, 0, 0)) barHeight = screen.get_height() - 40 # Leave 20px margin on top and bottom @@ -440,15 +440,15 @@ def y_powerbar(): markerPos = int(20 + (100 - power) / 100 * barHeight) pygame.draw.rect(screen, (255, 0, 0), (screen.get_width() // 2 - barWidth, markerPos - 5, barWidth * 2, 10)) pygame.display.flip() - + for event in pygame.event.get(): check_for_exit() if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE: channel.stop() return power - + power += direction if power >= 100 or power <= 0: direction *= -1 # Reverse direction at limits - + clock.tick(40)