From 68e72f5d81e24223fabf21f4591ab472d8356891 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Tue, 4 Feb 2025 05:12:31 -0500 Subject: [PATCH] Play sound function added. Code restructure. Added volume controls for master volume, sounds only, and music only. --- __init__.py | 990 ++++++++++++++++++++++++++++++++++------------------ 1 file changed, 655 insertions(+), 335 deletions(-) diff --git a/__init__.py b/__init__.py index 2f82a97..b5ae5f8 100755 --- a/__init__.py +++ b/__init__.py @@ -1,6 +1,15 @@ -#!/bin/python +#!/usr/bin/env python3 # -*- coding: utf-8 -*- -"""Standard initializations and functions shared by all games.""" +"""Standard initializations and functions shared by all Storm Games. + +This module provides core functionality for Storm Games including: +- Sound and speech handling +- Volume controls +- Configuration management +- Score tracking +- GUI initialization +- Game menu systems +""" from sys import exit import configparser @@ -17,6 +26,11 @@ import re import requests import textwrap import webbrowser +import math +import numpy as np +import time +import wx + # Global variable for speech provider try: import speechd @@ -29,82 +43,134 @@ except ImportError: except ImportError: print("No other speech providers found.") exit() -import math -import numpy as np -import time -import wx +# Configuration objects localConfig = configparser.ConfigParser() globalConfig = configparser.ConfigParser() -class scoreboard(): - 'Handles scores and top 10' +# Volume control globals +bgmVolume = 0.75 # Default background music volume +sfxVolume = 1.0 # Default sound effects volume +masterVolume = 1.0 # Default master volume - def __init__(self, startingScore = 0): +class Scoreboard: + """Handles score tracking and top 10 high scores for games. + + This class manages the scoring system including: + - Score tracking + - High score storage + - Score updates and persistence + """ + + def __init__(self, starting_score=0): + """Initialize a new scoreboard with optional starting score. + + Args: + starting_score (int): Initial score value (default: 0) + """ read_config() try: localConfig.add_section("scoreboard") except: pass - self.score = startingScore - self.oldScores = [] + self.score = starting_score + self.old_scores = [] for i in range(1, 11): try: - self.oldScores.insert(i - 1, localConfig.getint("scoreboard", str(i))) + self.old_scores.insert(i - 1, localConfig.getint("scoreboard", str(i))) except: - pass - self.oldScores.insert(i - 1, 0) + self.old_scores.insert(i - 1, 0) for i in range(1, 11): - if self.oldScores[i - 1] == None: - self.oldScores[i - 1] = 0 + if self.old_scores[i - 1] is None: + self.old_scores[i - 1] = 0 def __del__(self): - self.Update_Scores() + """Save scores when object is destroyed.""" + self.update_scores() try: write_config() except: pass - def Decrease_Score(self, points = 1): + def decrease_score(self, points=1): + """Decrease the current score. + + Args: + points (int): Number of points to decrease (default: 1) + """ self.score -= points - def Get_High_Score(self, position = 1): - return self.oldScores[position - 1] + def get_high_score(self, position=1): + """Get a high score at specified position. + + Args: + position (int): Position in high score list (1-10, default: 1) + + Returns: + int: Score at specified position + """ + return self.old_scores[position - 1] - def Get_Score(self): + def get_score(self): + """Get current score. + + Returns: + int: Current score + """ return self.score - def Increase_Score(self, points = 1): + def increase_score(self, points=1): + """Increase the current score. + + Args: + points (int): Number of points to increase (default: 1) + """ self.score += points - def New_High_Score(self): - for i, j in enumerate(self.oldScores): - if self.score > j: return i + 1 + def new_high_score(self): + """Check if current score qualifies as a new high score. + + Returns: + int: Position of new high score (1-10), or None if not a high score + """ + for i, j in enumerate(self.old_scores): + if self.score > j: + return i + 1 return None - def Update_Scores(self): + def update_scores(self): + """Update the high score list with current score if qualified.""" # Update the scores - for i, j in enumerate(self.oldScores): + for i, j in enumerate(self.old_scores): if self.score > j: - self.oldScores.insert(i, self.score) + self.old_scores.insert(i, self.score) break - # Only keep the top 10 scores. - self.oldScores = self.oldScores[:10] - # Update the scoreboard section of the games config file. - for i, j in enumerate(self.oldScores): - localConfig.set("scoreboard", str(i + 1), str(j)) + # Only keep the top 10 scores + self.old_scores = self.old_scores[:10] + # Update the scoreboard section of the games config file + for i, j in enumerate(self.old_scores): + localConfig.set("scoreboard", str(i + 1), str(j)) - -def write_config(writeGlobal = False): - if writeGlobal == False: +def write_config(write_global=False): + """Write configuration to file. + + Args: + write_global (bool): If True, write to global config, otherwise local (default: False) + """ + if not write_global: with open(gamePath + "/config.ini", 'w') as configfile: localConfig.write(configfile) else: with open(globalPath + "/config.ini", 'w') as configfile: globalConfig.write(configfile) -def read_config(readGlobal = False): - if readGlobal == False: +def read_config(read_global=False): + """Read configuration from file. + + Args: + read_global (bool): If True, read global config, otherwise local (default: False) + """ + if not read_global: try: with open(gamePath + "/config.ini", 'r') as configfile: localConfig.read_file(configfile) @@ -117,8 +183,160 @@ def read_config(readGlobal = False): except: pass +def initialize_gui(gameTitle): + """Initialize the game GUI and sound system. + + Args: + gameTitle (str): Title of the game + + Returns: + dict: Dictionary of loaded sound objects + """ + # Check for, and possibly create, storm-games path + global globalPath + global gamePath + global gameName + + globalPath = BaseDirectory.xdg_config_home + "/storm-games" + gamePath = globalPath + "/" + str.lower(str.replace(gameTitle, " ", "-")) + if not os.path.exists(gamePath): + os.makedirs(gamePath) + + # Seed the random generator to the clock + random.seed() + + # Set game's name + gameName = gameTitle + setproctitle(str.lower(str.replace(gameTitle, " ", ""))) + + # Initialize pygame + pygame.init() + pygame.display.set_mode((800, 600)) + pygame.display.set_caption(gameTitle) + + # Set up audio system + pygame.mixer.pre_init(44100, -16, 2, 1024) + pygame.mixer.init() + pygame.mixer.set_num_channels(32) + pygame.mixer.set_reserved(0) # Reserve channel for cut scenes + + # Enable key repeat for volume controls + pygame.key.set_repeat(500, 100) + + # Load sound files + try: + soundFiles = [f for f in listdir("sounds/") + if isfile(join("sounds/", f)) + and (f.split('.')[1].lower() in ["ogg", "wav"])] + except Exception as e: + print("No sounds found.") + speak("No sounds found.", False) + soundFiles = [] + + # Create dictionary of sound objects + soundData = {} + for f in soundFiles: + soundData[f.split('.')[0]] = pygame.mixer.Sound("sounds/" + f) + + # Play intro sound if available + if 'game-intro' in soundData: + soundData['game-intro'].play() + time.sleep(soundData['game-intro'].get_length()) + + return soundData -def get_input(prompt = "Enter text:", text = ""): +def adjust_master_volume(change): + """Adjust the master volume for all sounds. + + Args: + change (float): Amount to change volume by (positive or negative) + """ + global masterVolume + masterVolume = max(0.0, min(1.0, masterVolume + change)) + # Update music volume + if pygame.mixer.music.get_busy(): + pygame.mixer.music.set_volume(bgmVolume * masterVolume) + # Update all sound channels + for i in range(pygame.mixer.get_num_channels()): + channel = pygame.mixer.Channel(i) + if channel.get_busy(): + current_volume = channel.get_volume() + if isinstance(current_volume, (int, float)): + # Mono audio + channel.set_volume(current_volume * masterVolume) + else: + # Stereo audio + left, right = current_volume + channel.set_volume(left * masterVolume, right * masterVolume) + +def adjust_bgm_volume(change): + """Adjust only the background music volume. + + Args: + change (float): Amount to change volume by (positive or negative) + """ + global bgmVolume + bgmVolume = max(0.0, min(1.0, bgmVolume + change)) + if pygame.mixer.music.get_busy(): + pygame.mixer.music.set_volume(bgmVolume * masterVolume) + +def adjust_sfx_volume(change): + """Adjust volume for sound effects only. + + Args: + change (float): Amount to change volume by (positive or negative) + """ + global sfxVolume + sfxVolume = max(0.0, min(1.0, sfxVolume + change)) + # Update all sound channels except reserved ones + for i in range(pygame.mixer.get_num_channels()): + channel = pygame.mixer.Channel(i) + if channel.get_busy(): + current_volume = channel.get_volume() + if isinstance(current_volume, (int, float)): + # Mono audio + channel.set_volume(current_volume * sfxVolume * masterVolume) + else: + # Stereo audio + left, right = current_volume + channel.set_volume(left * sfxVolume * masterVolume, + right * sfxVolume * masterVolume) + +def adjust_bgm_volume(change): + """Adjust only the background music volume. + + Args: + change (float): Amount to change volume by (positive or negative) + """ + global bgmVolume + bgmVolume = max(0.0, min(1.0, bgmVolume + change)) + if pygame.mixer.music.get_busy(): + pygame.mixer.music.set_volume(bgmVolume * masterVolume) + +def play_bgm(music_file): + """Play background music with proper volume settings. + + Args: + music_file (str): Path to the music file to play + """ + try: + pygame.mixer.music.stop() + pygame.mixer.music.load(music_file) + pygame.mixer.music.set_volume(bgmVolume * masterVolume) + pygame.mixer.music.play(-1) # Loop indefinitely + except Exception as e: + pass + +def get_input(prompt="Enter text:", text=""): + """Display a dialog box for text input. + + Args: + prompt (str): Prompt text to display (default: "Enter text:") + text (str): Initial text in input box (default: "") + + Returns: + str: User input text, or None if cancelled + """ app = wx.App(False) dialog = wx.TextEntryDialog(None, prompt, "Input", text) dialog.SetValue(text) @@ -129,34 +347,47 @@ def get_input(prompt = "Enter text:", text = ""): dialog.Destroy() return userInput -def speak(text, interupt=True): +def speak(text, interrupt=True): + """Speak text using the configured speech provider and display on screen. + + Args: + text (str): Text to speak and display + interrupt (bool): Whether to interrupt current speech (default: True) + """ if speechProvider == "speechd": - if interupt: spd.cancel() + if interrupt: + spd.cancel() spd.say(text) else: if speechProvider == "accessible_output2": - s.speak(text, interrupt=True) + s.speak(text, interrupt=interrupt) + # Display the text on screen screen = pygame.display.get_surface() font = pygame.font.Font(None, 36) # Wrap the text - maxWidth = screen.get_width() - 40 # Leave a 20-pixel margin on each side - wrappedText = textwrap.wrap(text, width=maxWidth // font.size('A')[0]) + max_width = screen.get_width() - 40 # Leave a 20-pixel margin on each side + wrapped_text = textwrap.wrap(text, width=max_width // font.size('A')[0]) # Render each line - textSurfaces = [font.render(line, True, (255, 255, 255)) for line in wrappedText] + text_surfaces = [font.render(line, True, (255, 255, 255)) for line in wrapped_text] screen.fill((0, 0, 0)) # Clear screen with black # Calculate total height of text block - totalHeight = sum(surface.get_height() for surface in textSurfaces) + total_height = sum(surface.get_height() for surface in text_surfaces) # Start y-position (centered vertically) - currentY = (screen.get_height() - totalHeight) // 2 + currentY = (screen.get_height() - total_height) // 2 # Blit each line of text - for surface in textSurfaces: - textRect = surface.get_rect(center=(screen.get_width() // 2, currentY + surface.get_height() // 2)) - screen.blit(surface, textRect) + for surface in text_surfaces: + text_rect = surface.get_rect(center=(screen.get_width() // 2, currentY + surface.get_height() // 2)) + screen.blit(surface, text_rect) currentY += surface.get_height() pygame.display.flip() def check_for_exit(): + """Check if user has pressed escape key. + + Returns: + bool: True if escape was pressed, False otherwise + """ for event in pygame.event.get(): if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE: return True @@ -164,138 +395,33 @@ def check_for_exit(): pygame.event.pump() def exit_game(): - if speechProvider == "speechd": spd.close() + """Clean up and exit the game.""" + if speechProvider == "speechd": + spd.close() pygame.mixer.music.stop() pygame.quit() exit() -def initialize_gui(gameTitle): - # Check for, and possibly create, storm-games path - global globalPath - global gamePath - globalPath = BaseDirectory.xdg_config_home + "/storm-games" - gamePath = globalPath + "/" + str.lower(str.replace(gameTitle, " ", "-")) - if not os.path.exists(gamePath): os.makedirs(gamePath) - # Seed the random generator to the clock - random.seed() - # Set game's name - global gameName - gameName = gameTitle - setproctitle(str.lower(str.replace(gameTitle, " ", ""))) - # start pygame - pygame.init() - # start the display (required by the event loop) - pygame.display.set_mode((800, 600)) - pygame.display.set_caption(gameTitle) - # Set 32 channels for sound by default - pygame.mixer.pre_init(44100, -16, 2, 1024) - pygame.mixer.init() - pygame.mixer.set_num_channels(32) - # Reserve the cut scene channel - pygame.mixer.set_reserved(0) - # Load sounds from the sound directory and creates a list like {'bottle': 'bottle.ogg'} - try: - soundFiles = [f for f in listdir("sounds/") if isfile(join("sounds/", f)) and (f.split('.')[1].lower() in ["ogg","wav"])] - except Exception as e: - print("No sounds found.") - speak("No sounds found.", False) - #lets make a dict with pygame.mixer.Sound() objects {'bottle':} - soundData = {} - for f in soundFiles: - soundData[f.split('.')[0]] = pygame.mixer.Sound("sounds/" + f) - soundData['game-intro'].play() - time.sleep(soundData['game-intro'].get_length()) - return soundData - -def generate_tone(frequency, duration=0.1, sample_rate=44100, volume = 0.2): - t = np.linspace(0, duration, int(sample_rate * duration), False) - tone = np.sin(2 * np.pi * frequency * t) - stereo_tone = np.vstack((tone, tone)).T # Create a 2D array for stereo - stereo_tone = (stereo_tone * 32767).astype(np.int16) - stereo_tone = (stereo_tone * 32767 * volume).astype(np.int16) # Apply volume - stereo_tone = np.ascontiguousarray(stereo_tone) # Ensure C-contiguous array - return pygame.sndarray.make_sound(stereo_tone) - -def x_powerbar(): - 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 - rightVolume = (position + 50) / 100 - 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 - pygame.draw.rect(screen, (100, 100, 100), (20, screen.get_height() // 2 - barHeight // 2, barWidth, barHeight)) - 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 - direction = -1 - elif position < -50: - position = -50 - direction = 1 - clock.tick(40) # Speed of bar - -def y_powerbar(): - 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 - pygame.draw.rect(screen, (100, 100, 100), (screen.get_width() // 2 - barWidth // 2, 20, barWidth, barHeight)) - 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) - -def cut_scene(sounds, soundName): - pygame.event.clear() - pygame.mixer.stop() - c = pygame.mixer.Channel(0) - c.play(sounds[soundName]) - while pygame.mixer.get_busy(): - event = pygame.event.poll() - if event.type == pygame.KEYDOWN and event.key in [pygame.K_ESCAPE, pygame.K_RETURN, pygame.K_SPACE]: - pygame.mixer.stop() - pygame.event.pump() - - def calculate_volume_and_pan(player_pos, obj_pos): + """Calculate volume and stereo panning based on relative positions. + + Args: + player_pos (float): Player's position on x-axis + obj_pos (float): Object's position on x-axis + + Returns: + tuple: (volume, left_vol, right_vol) values between 0 and 1 + """ distance = abs(player_pos - obj_pos) max_distance = 12 # Maximum audible distance + if distance > max_distance: return 0, 0, 0 # No sound if out of range + # Calculate volume (non-linear scaling for more noticeable changes) - volume = ((max_distance - distance) / max_distance) ** 1.5 + # Apply masterVolume as the maximum possible volume + volume = (((max_distance - distance) / max_distance) ** 1.5) * masterVolume + # Determine left/right based on relative position if player_pos < obj_pos: # Object is to the right @@ -308,140 +434,229 @@ def calculate_volume_and_pan(player_pos, obj_pos): else: # Player is on the object left = right = 1 + return volume, left, right def obj_play(sounds, soundName, player_pos, obj_pos, loop=True): """Play a sound with positional audio. Args: - sounds: Dictionary of sound objects - soundName: Name of sound to play - player_pos: Player's position for audio panning - obj_pos: Object's position for audio panning - loop: Whether to loop the sound (default True) - + sounds (dict): Dictionary of sound objects + soundName (str): Name of sound to play + player_pos (float): Player's position for audio panning + obj_pos (float): Object's position for audio panning + loop (bool): Whether to loop the sound (default: True) + Returns: - The sound channel object, or None if out of range + pygame.mixer.Channel: Sound channel object, or None if out of range """ volume, left, right = calculate_volume_and_pan(player_pos, obj_pos) if volume == 0: return None # Don't play if out of range + # Play the sound on a new channel - x = sounds[soundName].play(-1 if loop else 0) # -1 for loop, 0 for once - # Apply the volume and pan - if x: - x.set_volume(volume * left, volume * right) - return x + channel = sounds[soundName].play(-1 if loop else 0) + if channel: + channel.set_volume(volume * left * sfxVolume, + volume * right * sfxVolume) + return channel -def obj_update(x, player_pos, obj_pos): - if x is None: +def obj_update(channel, player_pos, obj_pos): + """Update positional audio for a playing sound. + + Args: + channel: Sound channel to update + player_pos (float): New player position + obj_pos (float): New object position + + Returns: + pygame.mixer.Channel: Updated channel, or None if sound should stop + """ + if channel is None: return None + volume, left, right = calculate_volume_and_pan(player_pos, obj_pos) if volume == 0: - x.stop() + channel.stop() return None + # Apply the volume and pan - x.set_volume(volume * left, volume * right) - return x + channel.set_volume(volume * left * sfxVolume, + volume * right * sfxVolume) + return channel + +def obj_stop(channel): + """Stop a playing sound channel. -def obj_stop(x): - # Tries to stop a playing object channel + Args: + channel: Sound channel to stop + + Returns: + None if stopped successfully, otherwise returns original channel + """ try: - x.stop() + channel.stop() return None except: - return x + return channel -def play_ambiance(sounds, soundNames, probability, randomLocation = False): +def play_sound(sound, volume=1.0): + """Play a sound with current volume settings applied. + + Args: + sound: pygame Sound object to play + volume: base volume for the sound (0.0-1.0, default: 1.0) + + Returns: + pygame.mixer.Channel: The channel the sound is playing on + """ + channel = sound.play() + if channel: + channel.set_volume(volume * sfxVolume * masterVolume) + return channel + +def play_ambiance(sounds, soundNames, probability, randomLocation=False): + """Play random ambient sounds with optional positional audio. + + Args: + sounds (dict): Dictionary of sound objects + soundNames (list): List of possible sound names to choose from + probability (int): Chance to play (1-100) + randomLocation (bool): Whether to randomize stereo position + + Returns: + pygame.mixer.Channel: Sound channel if played, None otherwise + """ # Check if any of the sounds in the list is already playing for soundName in soundNames: if pygame.mixer.find_channel(True) and pygame.mixer.find_channel(True).get_busy(): - return + return None + if random.randint(1, 100) > probability: - return + return None + # Choose a random sound from the list ambianceSound = random.choice(soundNames) channel = sounds[ambianceSound].play() + if randomLocation and channel: - left_volume = random.random() - right_volume = random.random() - channel.set_volume(left_volume, right_volume) - return channel # Return the channel object for potential further manipulation + leftVolume = random.random() * sfxVolume * masterVolume + rightVolume = random.random() * sfxVolume * masterVolume + channel.set_volume(leftVolume, rightVolume) + + return channel -def play_random(sounds, soundName, pause = False, interrupt = False): +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) - randomKey = random.choice(key) - if interrupt == False: - sounds[randomKey].play() - else: - cut_scene(sounds, randomKey) - # Cut scenes override the pause option + + if not key: # No matching sounds found return - if pause == True: - time.sleep(sounds[randomKey].get_length()) + + randomKey = random.choice(key) -def play_random_positional(sounds, soundName, playerX, objectX): - """Play a random sound with positional audio. + if interrupt: + cut_scene(sounds, randomKey) + return + + channel = sounds[randomKey].play() + if channel: + channel.set_volume(sfxVolume * masterVolume, sfxVolume * masterVolume) + + if pause: + time.sleep(sounds[randomKey].get_length()) + +def play_random_positional(sounds, soundName, player_x, object_x): + """Play a random variation of a sound with positional audio. Args: - sounds: Dictionary of sound objects - soundName: Base name of sound (e.g. 'falling_skull' will match 'falling_skull1', 'falling_skull2', etc.) - playerX: Player's x position for audio panning - objectX: Object's x position for audio panning - + sounds (dict): Dictionary of sound objects + soundName (str): Base name of sound to match + player_x (float): Player's x position + object_x (float): Object's x position + Returns: - The sound channel object for updating position + 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) + volume, left, right = calculate_volume_and_pan(player_x, object_x) + if volume == 0: return None channel = sounds[randomKey].play() if channel: - channel.set_volume(volume * left, volume * right) + channel.set_volume(volume * left * sfxVolume, + volume * right * sfxVolume) return channel -def play_random_falling(sounds, soundName, playerX, objectX, startY, currentY=0, maxY=20, existingChannel=None): - """Play or update a random sound with positional audio that increases in volume as it 'falls'. +def cut_scene(sounds, soundName): + """Play a sound as a cut scene, stopping other sounds. Args: - sounds: Dictionary of sound objects - soundName: Base name of sound (e.g. 'falling_skull' will match 'falling_skull1', 'falling_skull2', etc.) - playerX: Player's x position for audio panning - objectX: Object's x position for audio panning - startY: Starting Y position (0-20, higher = quieter start) - currentY: Current Y position (0 = ground level) - maxY: Maximum Y value (default 20) - existingChannel: Existing sound channel to update instead of creating new one (default None) + sounds (dict): Dictionary of sound objects + soundName (str): Name of sound to play + """ + pygame.event.clear() + pygame.mixer.stop() + channel = pygame.mixer.Channel(0) + channel.play(sounds[soundName]) + while pygame.mixer.get_busy(): + event = pygame.event.poll() + if event.type == pygame.KEYDOWN and event.key in [pygame.K_ESCAPE, pygame.K_RETURN, pygame.K_SPACE]: + pygame.mixer.stop() + pygame.event.pump() + +def play_random_falling(sounds, soundName, player_x, object_x, start_y, + currentY=0, max_y=20, existing_channel=None): + """Play or update a falling sound with positional audio and volume based on height. + Args: + sounds (dict): Dictionary of sound objects + soundName (str): Base name of sound to match + player_x (float): Player's x position + object_x (float): Object's x position + start_y (float): Starting Y position (0-20, higher = quieter start) + currentY (float): Current Y position (0 = ground level) (default: 0) + max_y (float): Maximum Y value (default: 20) + existing_channel: Existing sound channel to update (default: None) + Returns: - The sound channel object for updating position/volume, or None if sound should stop + 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) + volume, left, right = calculate_volume_and_pan(player_x, object_x) - # Calculate vertical fall volume multiplier (0 at maxY, 1 at y=0) - fallMultiplier = 1 - (currentY / maxY) + # Calculate vertical fall volume multiplier (0 at max_y, 1 at y=0) + fallMultiplier = 1 - (currentY / max_y) # Adjust final volumes finalVolume = volume * fallMultiplier finalLeft = left * finalVolume finalRight = right * finalVolume - if existingChannel is not None: + if existing_channel is not None: if volume == 0: # Out of audible range - existingChannel.stop() + existing_channel.stop() return None - existingChannel.set_volume(finalLeft, finalRight) - return existingChannel + existing_channel.set_volume(finalLeft * sfxVolume, + finalRight * sfxVolume) + return existing_channel else: # Need to create new channel if volume == 0: # Don't start if out of range return None @@ -454,11 +669,85 @@ def play_random_falling(sounds, soundName, playerX, objectX, startY, currentY=0, randomKey = random.choice(keys) channel = sounds[randomKey].play() if channel: - channel.set_volume(finalLeft, finalRight) + channel.set_volume(finalLeft * sfxVolume, + finalRight * sfxVolume) return channel +def display_text(text): + """Display and speak text with navigation controls. + + Allows users to: + - Navigate text line by line with arrow keys + - Listen to full text with space + - Copy current line or full text + - Exit with enter/escape + + Args: + text (list): List of text lines to display + """ + currentIndex = 0 + text.insert(0, "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.") + text.append("End of text.") + speak(text[currentIndex]) + + while True: + event = pygame.event.wait() + if event.type == pygame.KEYDOWN: + if event.key in (pygame.K_ESCAPE, pygame.K_RETURN): + return + + if event.key == pygame.K_DOWN and currentIndex < len(text) - 1: + currentIndex += 1 + speak(text[currentIndex]) + + if event.key == pygame.K_UP and currentIndex > 0: + currentIndex -= 1 + speak(text[currentIndex]) + + if event.key == pygame.K_SPACE: + speak(' '.join(text[1:])) + + if event.key == pygame.K_c: + try: + pyperclip.copy(text[currentIndex]) + speak("Copied " + text[currentIndex] + " to the clipboard.") + except: + speak("Failed to copy the text to the clipboard.") + + if event.key == pygame.K_t: + try: + pyperclip.copy(' '.join(text[1:-1])) + speak("Copied entire message to the clipboard.") + except: + speak("Failed to copy the text to the clipboard.") + + event = pygame.event.clear() + time.sleep(0.001) + +def messagebox(text): + """Display a simple message box with text. + + Shows a message that can be repeated until the user chooses to continue. + + Args: + text (str): Message to display + """ + speak(text + "\nPress any key to repeat or enter to continue.") + while True: + event = pygame.event.wait() + if event.type == pygame.KEYDOWN: + if event.key in (pygame.K_ESCAPE, pygame.K_RETURN): + return + speak(text + "\nPress any key to repeat or enter to continue.") + def instructions(): - # Read in the instructions file + """Display game instructions from file. + + Reads and displays instructions from 'files/instructions.txt'. + If file is missing, displays an error message. + """ try: with open('files/instructions.txt', 'r') as f: info = f.readlines() @@ -467,7 +756,12 @@ def instructions(): display_text(info) def credits(): - # Read in the credits file. + """Display game credits from file. + + Reads and displays credits from 'files/credits.txt'. + Adds game name header before displaying. + If file is missing, displays an error message. + """ try: with open('files/credits.txt', 'r') as f: info = f.readlines() @@ -477,146 +771,172 @@ def credits(): info = ["Credits file is missing."] display_text(info) -def display_text(text): - i = 0 - text.insert(0, "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.") - text.append("End of text.") - speak(text[i]) - while True: - event = pygame.event.wait() - if event.type == pygame.KEYDOWN: - if event.key == pygame.K_ESCAPE or event.key == pygame.K_RETURN: return - if event.key == pygame.K_DOWN and i < len(text) - 1: i = i + 1 - if event.key == pygame.K_UP and i > 0: i = i - 1 - if event.key == pygame.K_SPACE: - speak(' '.join(text[1:])) - else: - speak(text[i]) - if event.key == pygame.K_c: - try: - pyperclip.copy(text[i]) - speak("Copied " + text[i] + " to the clipboard.") - except: - speak("Failed to copy the text to the clipboard.") - if event.key == pygame.K_t: - try: - pyperclip.copy(' '.join(text[1:-1])) - speak("Copied entire message to the clipboard.") - except: - speak("Failed to copy the text to the clipboard.") - event = pygame.event.clear() - time.sleep(0.001) - -def messagebox(text): - speak(text + "\nPress any key to repeat or enter to continue.") - while True: - event = pygame.event.wait() - if event.type == pygame.KEYDOWN: - if event.key == pygame.K_ESCAPE or event.key == pygame.K_RETURN: - return - else: - speak(text + "\nPress any key to repeat or enter to continue.") - def learn_sounds(sounds): + """Interactive menu for learning game sounds. + + Allows users to: + - Navigate through available sounds + - Play selected sounds + - Return to menu with escape key + + Args: + sounds (dict): Dictionary of available sound objects + + Returns: + str: "menu" if user exits with escape + """ loop = True pygame.mixer.music.pause() - i = 0 - soundFiles = [f for f in listdir("sounds/") if isfile(join("sounds/", f)) and (f.split('.')[1].lower() in ["ogg","wav"]) and (f.split('.')[0].lower() not in ["game-intro", "music_menu"]) and (not f.lower().startswith("_"))] - # j keeps track of last spoken index so it isn't voiced on key up. - j = -1 - while loop == True: - if i != j: - speak(soundFiles[i][:-4]) - j = i + currentIndex = 0 + + # Get list of available sounds, excluding special sounds + soundFiles = [f for f in listdir("sounds/") + if isfile(join("sounds/", f)) + and (f.split('.')[1].lower() in ["ogg", "wav"]) + and (f.split('.')[0].lower() not in ["game-intro", "music_menu"]) + and (not f.lower().startswith("_"))] + + # Track last spoken index to avoid repetition + lastSpoken = -1 + + while loop: + if currentIndex != lastSpoken: + speak(soundFiles[currentIndex][:-4]) + lastSpoken = currentIndex + event = pygame.event.wait() if event.type == pygame.KEYDOWN: - if event.key == pygame.K_ESCAPE: return "menu" - if event.key == pygame.K_DOWN and i < len(soundFiles) - 1: + if event.key == pygame.K_ESCAPE: + return "menu" + + if event.key == pygame.K_DOWN and currentIndex < len(soundFiles) - 1: pygame.mixer.stop() - i = i + 1 - if event.key == pygame.K_UP and i > 0: + currentIndex += 1 + + if event.key == pygame.K_UP and currentIndex > 0: pygame.mixer.stop() - i = i - 1 + currentIndex -= 1 + if event.key == pygame.K_RETURN: try: - soundName = soundFiles[i][:-4] + soundName = soundFiles[currentIndex][:-4] pygame.mixer.stop() sounds[soundName].play() - continue except: - j = -1 + lastSpoken = -1 speak("Could not play sound.") - continue + event = pygame.event.clear() time.sleep(0.001) def game_menu(sounds, *options): + """Display and handle the main game menu. + + Provides menu navigation with: + - Up/Down arrows for selection + - Home/End for first/last option + - Enter to select + - Escape to exit + - Volume controls for all options + + Args: + sounds (dict): Dictionary of sound objects + *options: Variable list of menu options + + Returns: + str: Selected menu option if not handled internally + """ loop = True pygame.mixer.stop() + if pygame.mixer.music.get_busy(): pygame.mixer.music.unpause() else: try: - pygame.mixer.music.load("sounds/music_menu.ogg") - pygame.mixer.music.set_volume(0.75) - pygame.mixer.music.play(-1) + play_bgm("sounds/music_menu.ogg") except: pass - i = 0 - # j keeps track of last spoken index so it isn't voiced on key up. - j = -1 - while loop == True: - if i != j: - speak(options[i]) - j = i + + currentIndex = 0 + lastSpoken = -1 # Track last spoken index + + while loop: + if currentIndex != lastSpoken: + speak(options[currentIndex]) + lastSpoken = currentIndex + event = pygame.event.wait() if event.type == pygame.KEYDOWN: - if event.key == pygame.K_ESCAPE: exit_game() - if event.key == pygame.K_DOWN and i < len(options) - 1: - i = i + 1 + # Volume controls + if event.key == pygame.K_PAGEUP: + adjust_master_volume(0.1) + elif event.key == pygame.K_PAGEDOWN: + adjust_master_volume(-0.1) + elif event.key == pygame.K_HOME: + if currentIndex != 0: + currentIndex = 0 + try: + sounds['menu-move'].play() + except: + pass + if options[currentIndex] != "donate": + pygame.mixer.music.unpause() + else: + adjust_bgm_volume(0.1) + elif event.key == pygame.K_END: + if currentIndex != len(options) - 1: + currentIndex = len(options) - 1 + try: + sounds['menu-move'].play() + except: + pass + if options[currentIndex] != "donate": + pygame.mixer.music.unpause() + else: + adjust_bgm_volume(-0.1) + elif event.key == pygame.K_INSERT: + adjust_sfx_volume(0.1) + elif event.key == pygame.K_DELETE: + adjust_sfx_volume(-0.1) + # Menu navigation + elif event.key == pygame.K_ESCAPE: + exit_game() + elif event.key == pygame.K_DOWN and currentIndex < len(options) - 1: + currentIndex += 1 try: sounds['menu-move'].play() except: pass - if options[i] != "donate": pygame.mixer.music.unpause() - if event.key == pygame.K_UP and i > 0: - i = i - 1 + if options[currentIndex] != "donate": + pygame.mixer.music.unpause() + elif event.key == pygame.K_UP and currentIndex > 0: + currentIndex -= 1 try: sounds['menu-move'].play() except: pass - if options[i] != "donate": pygame.mixer.music.unpause() - if event.key == pygame.K_HOME and i != 0: - i = 0 + if options[currentIndex] != "donate": + pygame.mixer.music.unpause() + elif event.key == pygame.K_RETURN: try: - sounds['menu-move'].play() - except: - pass - if options[i] != "donate": pygame.mixer.music.unpause() - if event.key == pygame.K_END and i != len(options) - 1: - i = len(options) -1 - try: - sounds['menu-move'].play() - except: - pass - if options[i] != "donate": pygame.mixer.music.unpause() - if event.key == pygame.K_RETURN: - try: - j = -1 + lastSpoken = -1 try: sounds['menu-select'].play() time.sleep(sounds['menu-select'].get_length()) except: pass - eval(options[i] + "()") - continue + eval(options[currentIndex] + "()") except: - j = -1 - return options[i] - continue + lastSpoken = -1 + return options[currentIndex] + event = pygame.event.clear() time.sleep(0.001) def donate(): + """Open the donation webpage. + + Pauses background music and opens the Ko-fi donation page. + """ pygame.mixer.music.pause() webbrowser.open('https://ko-fi.com/stormux')