commit d7199f499f8b1940facdb09844e916a2a9303bde Author: Storm Dragon Date: Sun Feb 23 01:22:30 2025 -0500 initial commit, definitely not ready for use quite yet. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..0d78b6c --- /dev/null +++ b/__init__.py @@ -0,0 +1,97 @@ +""" +Core initialization module for PygStormGames framework. + +Provides the main PygStormGames class that serves as the central hub for game functionality. +""" + +from .config import Config +from .display import Display +from .menu import Menu +from .scoreboard import Scoreboard +from .sound import Sound +from .speech import Speech +import pyglet + +class pygstormgames: + """Main class that coordinates all game systems.""" + + def __init__(self, gameTitle): + """Initialize the game framework. + + Args: + gameTitle (str): Title of the game + """ + self.gameTitle = gameTitle + self._paused = False + + # Initialize core systems + self.config = Config(gameTitle) + self.display = Display(gameTitle) + self.speech = Speech() + self.sound = Sound(self) + self.scoreboard = Scoreboard(self) + self.menu = Menu(self) + + # Play intro sound if available + try: + player = self.sound.play_sound('game-intro') + if player: + # Wait for completion or skip input + @self.display.window.event + def on_key_press(symbol, modifiers): + if symbol in (pyglet.window.key.ESCAPE, + pyglet.window.key.RETURN, + pyglet.window.key.SPACE): + player.pause() + # Remove the temporary event handler + self.display.window.remove_handler('on_key_press', on_key_press) + return True + + # Wait for sound to finish or user to skip + while player.playing: + self.display.window.dispatch_events() + + # Remove the temporary event handler if not already removed + self.display.window.remove_handler('on_key_press', on_key_press) + except: + pass + + # Set up window event handlers + self.display.window.push_handlers(self.on_key_press) + + def on_key_press(self, symbol, modifiers): + """Handle global keyboard events. + + Args: + symbol: Pyglet key symbol + modifiers: Key modifiers + """ + if self._paused: + if symbol == pyglet.window.key.BACKSPACE: + self._paused = False + self.sound.resume() + self.speech.speak("Game resumed") + else: + # Global exit handler + if symbol == pyglet.window.key.ESCAPE: + self.exit_game() + + # Global pause handler + if symbol == pyglet.window.key.BACKSPACE: + self.pause_game() + + def run(self): + """Start the game loop.""" + pyglet.app.run() + + def pause_game(self): + """Pause all game systems and wait for resume.""" + self._paused = True + self.sound.pause() + self.speech.speak("Game paused, press backspace to resume.") + + def exit_game(self): + """Clean up and exit the game.""" + self.sound.cleanup() + self.speech.cleanup() + pyglet.app.exit() diff --git a/__pycache__/__init__.cpython-313.pyc b/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..ecd28dc Binary files /dev/null and b/__pycache__/__init__.cpython-313.pyc differ diff --git a/__pycache__/config.cpython-313.pyc b/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000..05be1be Binary files /dev/null and b/__pycache__/config.cpython-313.pyc differ diff --git a/__pycache__/display.cpython-313.pyc b/__pycache__/display.cpython-313.pyc new file mode 100644 index 0000000..01d7540 Binary files /dev/null and b/__pycache__/display.cpython-313.pyc differ diff --git a/__pycache__/menu.cpython-313.pyc b/__pycache__/menu.cpython-313.pyc new file mode 100644 index 0000000..ba3baa4 Binary files /dev/null and b/__pycache__/menu.cpython-313.pyc differ diff --git a/__pycache__/scoreboard.cpython-313.pyc b/__pycache__/scoreboard.cpython-313.pyc new file mode 100644 index 0000000..7764ef9 Binary files /dev/null and b/__pycache__/scoreboard.cpython-313.pyc differ diff --git a/__pycache__/sound.cpython-313.pyc b/__pycache__/sound.cpython-313.pyc new file mode 100644 index 0000000..b8409cd Binary files /dev/null and b/__pycache__/sound.cpython-313.pyc differ diff --git a/__pycache__/speech.cpython-313.pyc b/__pycache__/speech.cpython-313.pyc new file mode 100644 index 0000000..e4e7f5b Binary files /dev/null and b/__pycache__/speech.cpython-313.pyc differ diff --git a/config.py b/config.py new file mode 100644 index 0000000..6c74fb8 --- /dev/null +++ b/config.py @@ -0,0 +1,158 @@ +"""Configuration management module for PygStormGames. + +Handles loading and saving of both local and global game configurations. +""" + +import os +import configparser +from xdg import BaseDirectory + +class Config: + """Handles configuration file management.""" + + def __init__(self, gameTitle): + """Initialize configuration system. + + Args: + gameTitle (str): Title of the game + """ + # Set up config parsers + self.localConfig = configparser.ConfigParser() + self.globalConfig = configparser.ConfigParser() + + # Set up paths + self.globalPath = os.path.join(BaseDirectory.xdg_config_home, "storm-games") + gameDir = str.lower(str.replace(gameTitle, " ", "-")) + self.gamePath = os.path.join(self.globalPath, gameDir) + + # Create directories if needed + if not os.path.exists(self.gamePath): + os.makedirs(self.gamePath) + + # Full paths to config files + self.localConfigPath = os.path.join(self.gamePath, "config.ini") + self.globalConfigPath = os.path.join(self.globalPath, "config.ini") + + # Load initial configurations + self.read_config() + self.read_config(globalConfig=True) + + def read_config(self, globalConfig=False): + """Read configuration from file. + + Args: + globalConfig (bool): If True, read global config, otherwise local + """ + config = self.globalConfig if globalConfig else self.localConfig + path = self.globalConfigPath if globalConfig else self.localConfigPath + + try: + with open(path, 'r') as configfile: + config.read_file(configfile) + except FileNotFoundError: + # It's okay if the file doesn't exist yet + pass + except Exception as e: + print(f"Error reading {'global' if globalConfig else 'local'} config: {e}") + + def write_config(self, globalConfig=False): + """Write configuration to file. + + Args: + globalConfig (bool): If True, write to global config, otherwise local + """ + config = self.globalConfig if globalConfig else self.localConfig + path = self.globalConfigPath if globalConfig else self.localConfigPath + + try: + with open(path, 'w') as configfile: + config.write(configfile) + except Exception as e: + print(f"Error writing {'global' if globalConfig else 'local'} config: {e}") + + def get_value(self, section, key, default=None, globalConfig=False): + """Get value from configuration. + + Args: + section (str): Configuration section + key (str): Configuration key + default: Default value if not found + globalConfig (bool): If True, read from global config + + Returns: + Value from config or default if not found + """ + config = self.globalConfig if globalConfig else self.localConfig + try: + return config.get(section, key) + except: + return default + + def set_value(self, section, key, value, globalConfig=False): + """Set value in configuration. + + Args: + section (str): Configuration section + key (str): Configuration key + value: Value to set + globalConfig (bool): If True, write to global config + """ + config = self.globalConfig if globalConfig else self.localConfig + + # Create section if it doesn't exist + if not config.has_section(section): + config.add_section(section) + + config.set(section, key, str(value)) + self.write_config(globalConfig) + + def get_int(self, section, key, default=0, globalConfig=False): + """Get integer value from configuration. + + Args: + section (str): Configuration section + key (str): Configuration key + default (int): Default value if not found + globalConfig (bool): If True, read from global config + + Returns: + int: Value from config or default if not found + """ + try: + return int(self.get_value(section, key, default, globalConfig)) + except: + return default + + def get_float(self, section, key, default=0.0, globalConfig=False): + """Get float value from configuration. + + Args: + section (str): Configuration section + key (str): Configuration key + default (float): Default value if not found + globalConfig (bool): If True, read from global config + + Returns: + float: Value from config or default if not found + """ + try: + return float(self.get_value(section, key, default, globalConfig)) + except: + return default + + def get_bool(self, section, key, default=False, globalConfig=False): + """Get boolean value from configuration. + + Args: + section (str): Configuration section + key (str): Configuration key + default (bool): Default value if not found + globalConfig (bool): If True, read from global config + + Returns: + bool: Value from config or default if not found + """ + try: + return self.get_value(section, key, default, globalConfig).lower() in ['true', '1', 'yes', 'on'] + except: + return default diff --git a/display.py b/display.py new file mode 100644 index 0000000..071d27b --- /dev/null +++ b/display.py @@ -0,0 +1,140 @@ +"""Display management module for PygStormGames. + +Handles text display, navigation, and information presentation including: +- Text display with navigation +- Instructions display +- Credits display +- Donation link handling +""" + +import os +import webbrowser +import pyglet +from pyglet.window import key +import pyperclip +import wx + +class Display: + """Handles display and text navigation systems.""" + + def __init__(self, gameTitle): + """Initialize display system. + + Args: + gameTitle (str): Title of the game + """ + self.window = pyglet.window.Window(800, 600, caption=gameTitle) + self.currentText = [] + self.currentIndex = 0 + self.gameTitle = gameTitle + + def display_text(self, text, speech): + """Display and navigate text with speech output. + + Args: + text (list): List of text lines to display + speech (Speech): Speech system for audio output + """ + # Store original text with blank lines for copying + self.originalText = text.copy() + + # Create navigation text by filtering out blank lines + self.navText = [line for line in text if line.strip()] + + # Add instructions at start + 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.") + self.navText.insert(0, instructions) + + # Add end marker + self.navText.append("End of text.") + + self.currentIndex = 0 + speech.speak(self.navText[self.currentIndex]) + + @self.window.event + def on_key_press(symbol, modifiers): + if symbol in (key.ESCAPE, key.RETURN): + self.window.remove_handler('on_key_press', on_key_press) + return + + if symbol in (key.DOWN, key.S) and self.currentIndex < len(self.navText) - 1: + self.currentIndex += 1 + speech.speak(self.navText[self.currentIndex]) + + if symbol in (key.UP, key.W) and self.currentIndex > 0: + self.currentIndex -= 1 + speech.speak(self.navText[self.currentIndex]) + + if symbol == key.SPACE: + speech.speak('\n'.join(self.originalText[1:-1])) + + if symbol == key.C: + try: + pyperclip.copy(self.navText[self.currentIndex]) + speech.speak("Copied " + self.navText[self.currentIndex] + " to the clipboard.") + except: + speech.speak("Failed to copy the text to the clipboard.") + + if symbol == key.T: + try: + pyperclip.copy(''.join(self.originalText[2:-1])) + speech.speak("Copied entire message to the clipboard.") + except: + speech.speak("Failed to copy the text to the clipboard.") + + def instructions(self, speech): + """Display game instructions from file. + + Args: + speech (Speech): Speech system for audio output + """ + try: + with open('files/instructions.txt', 'r') as f: + info = f.readlines() + except: + info = ["Instructions file is missing."] + + self.display_text(info, speech) + + def credits(self, speech): + """Display game credits from file. + + Args: + speech (Speech): Speech system for audio output + """ + try: + with open('files/credits.txt', 'r') as f: + info = f.readlines() + # Add the header + info.insert(0, f"{self.gameTitle}: brought to you by Storm Dragon") + except: + info = ["Credits file is missing."] + + self.display_text(info, speech) + + def get_input(self, prompt="Enter text:", default_text=""): + """Display a dialog box for text input. + + Args: + prompt (str): Prompt text to display + default_text (str): Initial text in input box + + Returns: + str: User input text, or None if cancelled + """ + app = wx.App(False) + dialog = wx.TextEntryDialog(None, prompt, "Input", default_text) + dialog.SetValue(default_text) + if dialog.ShowModal() == wx.ID_OK: + userInput = dialog.GetValue() + else: + userInput = None + dialog.Destroy() + return userInput + + def donate(self, speech): + """Open the donation webpage.""" + speech.speak("Opening donation page.") + webbrowser.open('https://ko-fi.com/stormux') diff --git a/menu.py b/menu.py new file mode 100644 index 0000000..f4951ff --- /dev/null +++ b/menu.py @@ -0,0 +1,176 @@ +"""Menu system module for PygStormGames. + +Handles main menu and submenu functionality for games. +""" + +import os +import pyglet +from os.path import isfile, join +from pyglet.window import key + +class Menu: + """Handles menu systems.""" + + def __init__(self, game): + """Initialize menu system. + + Args: + game (PygStormGames): Reference to main game object + """ + self.game = game + self.currentIndex = 0 + + def show_menu(self, options, title=None, with_music=False): + """Display a menu and return selected option.""" + if with_music: + try: + if self.game.sound.currentBgm: + self.game.sound.currentBgm.pause() + self.game.sound.play_bgm("sounds/music_menu.ogg") + except: + pass + + self.currentIndex = 0 + lastSpoken = -1 + selection = None # Add this to store the selection + + if title: + self.game.speech.speak(title) + + def key_handler(symbol, modifiers): # Define handler outside event + nonlocal selection, lastSpoken + # Handle Alt+volume controls + if modifiers & key.MOD_ALT: + if symbol == key.PAGEUP: + self.game.sound.adjust_master_volume(0.1) + elif symbol == key.PAGEDOWN: + self.game.sound.adjust_master_volume(-0.1) + elif symbol == key.HOME: + self.game.sound.adjust_bgm_volume(0.1) + elif symbol == key.END: + self.game.sound.adjust_bgm_volume(-0.1) + elif symbol == key.INSERT: + self.game.sound.adjust_sfx_volume(0.1) + elif symbol == key.DELETE: + self.game.sound.adjust_sfx_volume(-0.1) + return + + if symbol == key.ESCAPE: + selection = "exit" + return pyglet.event.EVENT_HANDLED + + if symbol == key.HOME and self.currentIndex != 0: + self.currentIndex = 0 + self.game.sound.play_sound('menu-move') + lastSpoken = -1 # Force speech + + elif symbol == key.END and self.currentIndex != len(options) - 1: + self.currentIndex = len(options) - 1 + self.game.sound.play_sound('menu-move') + lastSpoken = -1 # Force speech + + elif symbol in (key.DOWN, key.S) and self.currentIndex < len(options) - 1: + self.currentIndex += 1 + self.game.sound.play_sound('menu-move') + lastSpoken = -1 # Force speech + + elif symbol in (key.UP, key.W) and self.currentIndex > 0: + self.currentIndex -= 1 + self.game.sound.play_sound('menu-move') + lastSpoken = -1 # Force speech + + elif symbol == key.RETURN: + self.game.sound.play_sound('menu-select') + selection = options[self.currentIndex] + return pyglet.event.EVENT_HANDLED + + return pyglet.event.EVENT_HANDLED + + # Register the handler + self.game.display.window.push_handlers(on_key_press=key_handler) + + # Main menu loop + while selection is None: + if self.currentIndex != lastSpoken: + self.game.speech.speak(options[self.currentIndex]) + lastSpoken = self.currentIndex + self.game.display.window.dispatch_events() + + # Clean up + self.game.display.window.remove_handlers() + return selection + + def game_menu(self): + """Show main game menu.""" + options = [ + "play", + "instructions", + "learn_sounds", + "credits", + "donate", + "exit" + ] + + return self.show_menu(options, with_music=True) + + def learn_sounds(self): + """Interactive menu for learning game sounds. + + Allows users to: + - Navigate through available sounds + - Play selected sounds + - Return to menu with escape key + + Returns: + str: "menu" if user exits with escape + """ + try: + self.game.sound.currentBgm.pause() + except: + pass + + self.currentIndex = 0 + + # Get list of available sounds, excluding special sounds + soundFiles = [f for f in os.listdir("sounds/") + if isfile(join("sounds/", f)) + and (f.split('.')[1].lower() in ["ogg", "wav"]) + and (f.split('.')[0].lower() not in ["game-intro", "music_menu"]) + and (not f.lower().startswith("_"))] + + # Track last spoken index to avoid repetition + lastSpoken = -1 + + while True: + if self.currentIndex != lastSpoken: + self.game.speech.speak(soundFiles[self.currentIndex][:-4]) + lastSpoken = self.currentIndex + + event = self.game.display.window.dispatch_events() + + @self.game.display.window.event + def on_key_press(symbol, modifiers): + if symbol == key.ESCAPE: + try: + self.game.sound.currentBgm.unpause() + except: + pass + self.game.display.window.remove_handler('on_key_press', on_key_press) + return "menu" + + if symbol in [key.DOWN, key.S] and self.currentIndex < len(soundFiles) - 1: + self.game.sound.stop_all_sounds() + self.currentIndex += 1 + + if symbol in [key.UP, key.W] and self.currentIndex > 0: + self.game.sound.stop_all_sounds() + self.currentIndex -= 1 + + if symbol == key.RETURN: + try: + soundName = soundFiles[self.currentIndex][:-4] + self.game.sound.stop_all_sounds() + self.game.sound.play_sound(soundName) + except: + lastSpoken = -1 + self.game.speech.speak("Could not play sound.") diff --git a/scoreboard.py b/scoreboard.py new file mode 100644 index 0000000..1c31189 --- /dev/null +++ b/scoreboard.py @@ -0,0 +1,126 @@ +"""Scoreboard management module for PygStormGames. + +Handles high score tracking with player names and score management. +""" + +import time + +class Scoreboard: + """Handles score tracking and high score management.""" + + def __init__(self, game): + """Initialize scoreboard system. + + Args: + game (PygStormGames): Reference to main game object + """ + self.game = game + self.currentScore = 0 + self.highScores = [] + + # Initialize high scores section in config + try: + self.game.config.localConfig.add_section("scoreboard") + except: + pass + + # Load existing high scores + self._loadHighScores() + + def _loadHighScores(self): + """Load high scores from config file.""" + self.highScores = [] + + for i in range(1, 11): + try: + score = self.game.config.get_int("scoreboard", f"score_{i}") + name = self.game.config.get_value("scoreboard", f"name_{i}", "Player") + 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. + + Returns: + int: Current score + """ + return self.currentScore + + def get_high_scores(self): + """Get list of high scores. + + Returns: + list: List of high score dictionaries + """ + return self.highScores + + def decrease_score(self, points=1): + """Decrease the current score. + + Args: + points (int): Points to decrease by + """ + self.currentScore -= int(points) + + def increase_score(self, points=1): + """Increase the current score. + + Args: + points (int): Points to increase by + """ + 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 + + # Get player name + self.game.speech.speak("New high score! Enter your name:") + name = self.game.display.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): + self.game.config.set_value("scoreboard", f"score_{i+1}", str(entry['score'])) + self.game.config.set_value("scoreboard", f"name_{i+1}", entry['name']) + + self.game.speech.speak(f"Congratulations {name}! You got position {position} on the scoreboard!") + time.sleep(1) + return True diff --git a/sound.py b/sound.py new file mode 100644 index 0000000..af47058 --- /dev/null +++ b/sound.py @@ -0,0 +1,366 @@ +"""Sound management module for PygStormGames. + +Handles all audio functionality including: +- Background music playback +- Sound effects with 2D/3D positional audio +- Volume control for master, BGM, and SFX +- Audio loading and resource management +""" + +import os +import random +import re +import pyglet +from os.path import isfile, join +from pyglet.window import key + +class Sound: + """Handles audio playback and management.""" + + def __init__(self, game): + """Initialize sound system. + + Args: + game (PygStormGames): Reference to main game object + """ + # Game reference for component access + self.game = game + + # Volume control (0.0 - 1.0) + self.bgmVolume = 0.75 # Background music + self.sfxVolume = 1.0 # Sound effects + self.masterVolume = 1.0 # Master volume + + # Current background music + self.currentBgm = None + + # Load sound resources + self.sounds = self._load_sounds() + self.activeSounds = [] # Track playing sounds + + def _load_sounds(self): + """Load all sound files from sounds directory. + + Returns: + dict: Dictionary of loaded sound objects + """ + sounds = {} + try: + soundFiles = [f for f in os.listdir("sounds/") + if isfile(join("sounds/", f)) + and f.lower().endswith(('.wav', '.ogg'))] + for f in soundFiles: + name = os.path.splitext(f)[0] + sounds[name] = pyglet.media.load(f"sounds/{f}", streaming=False) + except FileNotFoundError: + print("No sounds directory found") + return {} + except Exception as e: + print(f"Error loading sounds: {e}") + + return sounds + + def play_bgm(self, music_file): + """Play background music with proper volume. + + Args: + music_file (str): Path to music file + """ + try: + if self.currentBgm: + self.currentBgm.pause() + + # Load and play new music + music = pyglet.media.load(music_file, streaming=True) + player = pyglet.media.Player() + player.queue(music) + player.loop = True + player.volume = self.bgmVolume * self.masterVolume + player.play() + + self.currentBgm = player + except Exception as e: + print(f"Error playing background music: {e}") + + def play_sound(self, soundName, volume=1.0): + """Play a sound effect with volume settings. + + Args: + soundName (str): Name of sound to play + volume (float): Base volume for sound (0.0-1.0) + + Returns: + pyglet.media.Player: Sound player object + """ + if soundName not in self.sounds: + return None + + player = pyglet.media.Player() + player.queue(self.sounds[soundName]) + player.volume = volume * self.sfxVolume * self.masterVolume + player.play() + + self.activeSounds.append(player) + return player + + def play_random(self, base_name, pause=False, interrupt=False): + """Play random variation of a sound. + + Args: + base_name (str): Base name of sound + pause (bool): Wait for sound to finish + interrupt (bool): Stop other sounds + """ + matches = [name for name in self.sounds.keys() + if re.match(f"^{base_name}.*", name)] + + if not matches: + return None + + if interrupt: + self.stop_all_sounds() + + soundName = random.choice(matches) + player = self.play_sound(soundName) + + if pause and player: + player.on_player_eos = lambda: None # Wait for completion + + def calculate_positional_audio(self, source_pos, listener_pos, mode='2d'): + """Calculate position for 3D audio. + + Args: + source_pos: Either float (2D x-position) or tuple (3D x,y,z position) + listener_pos: Either float (2D x-position) or tuple (3D x,y,z position) + mode: '2d' or '3d' to specify positioning mode + + Returns: + tuple: (x, y, z) position for sound source, or None if out of range + """ + if mode == '2d': + distance = abs(source_pos - listener_pos) + max_distance = 12 + + if distance > max_distance: + return None + + return (source_pos - listener_pos, 0, -1) + else: + x = source_pos[0] - listener_pos[0] + y = source_pos[1] - listener_pos[1] + z = source_pos[2] - listener_pos[2] + + distance = (x*x + y*y + z*z) ** 0.5 + max_distance = 20 # Larger for 3D space + + if distance > max_distance: + return None + + return (x, y, z) + + def play_positional(self, soundName, source_pos, listener_pos, mode='2d', + direction=None, cone_angles=None): + """Play sound with positional audio. + + Args: + soundName (str): Name of sound to play + source_pos: Position of sound source (float for 2D, tuple for 3D) + listener_pos: Position of listener (float for 2D, tuple for 3D) + mode: '2d' or '3d' to specify positioning mode + direction: Optional tuple (x,y,z) for directional sound + cone_angles: Optional tuple (inner, outer) angles for sound cone + + Returns: + pyglet.media.Player: Sound player object + """ + if soundName not in self.sounds: + return None + + position = self.calculate_positional_audio(source_pos, listener_pos, mode) + if position is None: # Too far to hear + return None + + player = pyglet.media.Player() + player.queue(self.sounds[soundName]) + player.position = position + player.volume = self.sfxVolume * self.masterVolume + + # Set up directional audio if specified + if direction and mode == '3d': + player.cone_orientation = direction + if cone_angles: + player.cone_inner_angle, player.cone_outer_angle = cone_angles + player.cone_outer_gain = 0.5 # Reduced volume outside cone + + player.play() + self.activeSounds.append(player) + return player + + def update_positional(self, player, source_pos, listener_pos, mode='2d', + direction=None): + """Update position of a playing sound. + + Args: + player: Sound player to update + source_pos: New source position + listener_pos: New listener position + mode: '2d' or '3d' positioning mode + direction: Optional new direction for directional sound + """ + if not player or not player.playing: + return + + position = self.calculate_positional_audio(source_pos, listener_pos, mode) + if position is None: + player.pause() + return + + player.position = position + if direction and mode == '3d': + player.cone_orientation = direction + + def cut_scene(self, soundName): + """Play a sound as a cut scene, stopping other sounds and waiting for completion. + + Args: + soundName (str): Name of sound to play + + The method will block until either: + - The sound finishes playing + - The user presses ESC/RETURN/SPACE (if window is provided) + """ + # Stop all current sounds + self.stop_all_sounds() + if self.currentBgm: + self.currentBgm.pause() + + if soundName not in self.sounds: + return + + # Create and configure the player + player = pyglet.media.Player() + player.queue(self.sounds[soundName]) + player.volume = self.sfxVolume * self.masterVolume + + # Flag to track if we should continue waiting + shouldContinue = True + + def on_player_eos(): + nonlocal shouldContinue + shouldContinue = False + + # Set up completion callback + player.push_handlers(on_eos=on_player_eos) + + # Get window from game display + window = self.game.display.window + + # If we have a window, set up key handler for skipping + if window: + skipKeys = [key.ESCAPE, key.RETURN, key.SPACE] + + @window.event + def on_key_press(symbol, modifiers): + nonlocal shouldContinue + if symbol in skipKeys: + shouldContinue = False + return True + + # Start playback + player.play() + + # Wait for completion or skip + while shouldContinue and player.playing: + if window: + window.dispatch_events() + pyglet.clock.tick() + + # Ensure cleanup + player.pause() + player.delete() + + # Resume background music if it was playing + if self.currentBgm: + self.currentBgm.play() + + def adjust_master_volume(self, change): + """Adjust master volume. + + Args: + change (float): Volume change (-1.0 to 1.0) + """ + if not -1.0 <= change <= 1.0: + return + + self.masterVolume = max(0.0, min(1.0, self.masterVolume + change)) + + # Update BGM + if self.currentBgm: + self.currentBgm.volume = self.bgmVolume * self.masterVolume + + # Update active sounds + for sound in self.activeSounds: + if sound.playing: + sound.volume *= self.masterVolume + + def adjust_bgm_volume(self, change): + """Adjust background music volume. + + Args: + change (float): Volume change (-1.0 to 1.0) + """ + if not -1.0 <= change <= 1.0: + return + + self.bgmVolume = max(0.0, min(1.0, self.bgmVolume + change)) + if self.currentBgm: + self.currentBgm.volume = self.bgmVolume * self.masterVolume + + def adjust_sfx_volume(self, change): + """Adjust sound effects volume. + + Args: + change (float): Volume change (-1.0 to 1.0) + """ + if not -1.0 <= change <= 1.0: + return + + self.sfxVolume = max(0.0, min(1.0, self.sfxVolume + change)) + for sound in self.activeSounds: + if sound.playing: + sound.volume *= self.sfxVolume + + def get_volumes(self): + """Get current volume levels. + + Returns: + tuple: (masterVolume, bgmVolume, sfxVolume) + """ + return (self.masterVolume, self.bgmVolume, self.sfxVolume) + + def pause(self): + """Pause all audio.""" + if self.currentBgm: + self.currentBgm.pause() + for sound in self.activeSounds: + if sound.playing: + sound.pause() + + def resume(self): + """Resume all audio.""" + if self.currentBgm: + self.currentBgm.play() + for sound in self.activeSounds: + sound.play() + + def stop_all_sounds(self): + """Stop all playing sounds.""" + for sound in self.activeSounds: + sound.pause() + self.activeSounds.clear() + + def cleanup(self): + """Clean up sound resources.""" + if self.currentBgm: + self.currentBgm.pause() + self.stop_all_sounds() diff --git a/speech.py b/speech.py new file mode 100644 index 0000000..2b6221a --- /dev/null +++ b/speech.py @@ -0,0 +1,84 @@ +"""Speech and text display module for PygStormGames. + +Provides text-to-speech functionality with screen text display support. +Uses either speechd or accessible_output2 as the speech backend. +""" + +import time +import pyglet +from pyglet.window import key +import textwrap + +class Speech: + """Handles speech output and text display.""" + + def __init__(self): + """Initialize speech system with fallback providers.""" + self._lastSpoken = {"text": None, "time": 0} + self._speechDelay = 250 # ms delay between identical messages + + # Try to initialize speech providers in order of preference + try: + import speechd + self._speech = speechd.Client() + self._provider = "speechd" + except ImportError: + try: + import accessible_output2.outputs.auto + self._speech = accessible_output2.outputs.auto.Auto() + self._provider = "accessible_output2" + except ImportError: + raise RuntimeError("No speech providers found. Install either speechd or accessible_output2.") + + # Display settings + self._font = pyglet.text.Label( + '', + font_name='Arial', + font_size=36, + x=400, y=300, # Will be centered later + anchor_x='center', anchor_y='center', + multiline=True, + width=760 # Allow 20px margin on each side + ) + + def speak(self, text, interrupt=True): + """Speak text and display it on screen. + + Args: + text (str): Text to speak and display + interrupt (bool): Whether to interrupt current speech + """ + current_time = time.time() * 1000 + + # Prevent rapid repeated messages + if (self._lastSpoken["text"] == text and + current_time - self._lastSpoken["time"] < self._speechDelay): + return + + # Update last spoken tracking + self._lastSpoken["text"] = text + self._lastSpoken["time"] = current_time + + # Handle speech output based on provider + if self._provider == "speechd": + if interrupt: + self._speech.cancel() + self._speech.speak(text) + else: + self._speech.speak(text, interrupt=interrupt) + + # Update display text + self._font.text = text + + # Center text vertically based on line count + lineCount = len(text.split('\n')) + self._font.y = 300 + (lineCount * self._font.font_size // 4) + + def cleanup(self): + """Clean up speech system resources.""" + if self._provider == "speechd": + self._speech.close() + + def draw(self): + """Draw the current text on screen.""" + self._font.draw()