#!/usr/bin/env python3 # -*- coding: utf-8 -*- """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 import os from os import listdir from os.path import isfile, join from inspect import isfunction from xdg import BaseDirectory from setproctitle import setproctitle import pygame import pyperclip import random import re import requests import textwrap import webbrowser import math import numpy as np import time import wx # Global variable for speech provider try: import speechd spd = speechd.Client() speechProvider = "speechd" except ImportError: import accessible_output2.outputs.auto s = accessible_output2.outputs.auto.Auto() speechProvider = "accessible_output2" except ImportError: print("No other speech providers found.") exit() # Configuration objects localConfig = configparser.ConfigParser() globalConfig = configparser.ConfigParser() # Volume control globals bgmVolume = 0.75 # Default background music volume sfxVolume = 1.0 # Default sound effects volume masterVolume = 1.0 # Default master volume class Scoreboard: """Handles high score tracking with player names.""" def __init__(self, score=0): """Initialize scoreboard with optional starting score.""" read_config() self.currentScore = score self.highScores = [] try: localConfig.add_section("scoreboard") except: pass # Load existing high scores for i in range(1, 11): try: score = localConfig.getint("scoreboard", f"score_{i}") name = localConfig.get("scoreboard", f"name_{i}") self.highScores.append({ 'name': name, 'score': score }) except: self.highScores.append({ 'name': "Player", 'score': 0 }) # Sort high scores by score value in descending order self.highScores.sort(key=lambda x: x['score'], reverse=True) def get_score(self): """Get current score.""" return self.currentScore def get_high_scores(self): """Get list of high scores.""" return self.highScores def decrease_score(self, points=1): """Decrease the current score.""" self.currentScore -= points def increase_score(self, points=1): """Increase the current score.""" self.currentScore += points def check_high_score(self): """Check if current score qualifies as a high score. Returns: int: Position (1-10) if high score, None if not """ for i, entry in enumerate(self.highScores): if self.currentScore > entry['score']: return i + 1 return None def add_high_score(self): """Add current score to high scores if it qualifies. Returns: bool: True if score was added, False if not """ position = self.check_high_score() if position is None: return False # Prompt for name using get_input name = get_input("New high score! Enter your name:", "Player") if name is None: # User cancelled name = "Player" # Insert new score at correct position self.highScores.insert(position - 1, { 'name': name, 'score': self.currentScore }) # Keep only top 10 self.highScores = self.highScores[:10] # Save to config for i, entry in enumerate(self.highScores): localConfig.set("scoreboard", f"score_{i+1}", str(entry['score'])) localConfig.set("scoreboard", f"name_{i+1}", entry['name']) write_config() speak(f"Congratulations {name}! You got position {position} on the scoreboard!") return True def write_config(write_global=False): """Write configuration to file. Args: write_global (bool): If True, write to global config, otherwise local (default: False) """ if not write_global: with open(gamePath + "/config.ini", 'w') as configfile: localConfig.write(configfile) else: with open(globalPath + "/config.ini", 'w') as configfile: globalConfig.write(configfile) def read_config(read_global=False): """Read configuration from file. Args: read_global (bool): If True, read global config, otherwise local (default: False) """ if not read_global: try: with open(gamePath + "/config.ini", 'r') as configfile: localConfig.read_file(configfile) except: pass else: try: with open(globalPath + "/config.ini", 'r') as configfile: globalConfig.read_file(configfile) except: pass def initialize_gui(gameTitle): """Initialize the game GUI and sound system. Args: gameTitle (str): Title of the game Returns: dict: Dictionary of loaded sound objects """ # Check for, and possibly create, storm-games path global globalPath global gamePath global gameName globalPath = BaseDirectory.xdg_config_home + "/storm-games" gamePath = globalPath + "/" + str.lower(str.replace(gameTitle, " ", "-")) if not os.path.exists(gamePath): os.makedirs(gamePath) # Seed the random generator to the clock random.seed() # Set game's name gameName = gameTitle setproctitle(str.lower(str.replace(gameTitle, " ", ""))) # Initialize pygame pygame.init() pygame.display.set_mode((800, 600)) pygame.display.set_caption(gameTitle) # Set up audio system pygame.mixer.pre_init(44100, -16, 2, 1024) pygame.mixer.init() pygame.mixer.set_num_channels(32) pygame.mixer.set_reserved(0) # Reserve channel for cut scenes # Enable key repeat for volume controls pygame.key.set_repeat(500, 100) # Load sound files try: soundFiles = [f for f in listdir("sounds/") if isfile(join("sounds/", f)) and (f.split('.')[1].lower() in ["ogg", "wav"])] except Exception as e: print("No sounds found.") speak("No sounds found.", False) soundFiles = [] # Create dictionary of sound objects soundData = {} for f in soundFiles: soundData[f.split('.')[0]] = pygame.mixer.Sound("sounds/" + f) # Play intro sound if available if 'game-intro' in soundData: cut_scene(soundData, 'game-intro') return soundData def adjust_master_volume(change): """Adjust the master volume for all sounds. Args: change (float): Amount to change volume by (positive or negative) """ global masterVolume masterVolume = max(0.0, min(1.0, masterVolume + change)) # Update music volume if pygame.mixer.music.get_busy(): pygame.mixer.music.set_volume(bgmVolume * masterVolume) # Update all sound channels for i in range(pygame.mixer.get_num_channels()): channel = pygame.mixer.Channel(i) if channel.get_busy(): current_volume = channel.get_volume() if isinstance(current_volume, (int, float)): # Mono audio channel.set_volume(current_volume * masterVolume) else: # Stereo audio left, right = current_volume channel.set_volume(left * masterVolume, right * masterVolume) def adjust_bgm_volume(change): """Adjust only the background music volume. Args: change (float): Amount to change volume by (positive or negative) """ global bgmVolume bgmVolume = max(0.0, min(1.0, bgmVolume + change)) if pygame.mixer.music.get_busy(): pygame.mixer.music.set_volume(bgmVolume * masterVolume) def adjust_sfx_volume(change): """Adjust volume for sound effects only. Args: change (float): Amount to change volume by (positive or negative) """ global sfxVolume sfxVolume = max(0.0, min(1.0, sfxVolume + change)) # Update all sound channels except reserved ones for i in range(pygame.mixer.get_num_channels()): channel = pygame.mixer.Channel(i) if channel.get_busy(): current_volume = channel.get_volume() if isinstance(current_volume, (int, float)): # Mono audio channel.set_volume(current_volume * sfxVolume * masterVolume) else: # Stereo audio left, right = current_volume channel.set_volume(left * sfxVolume * masterVolume, right * sfxVolume * masterVolume) def play_bgm(music_file): """Play background music with proper volume settings. Args: music_file (str): Path to the music file to play """ try: pygame.mixer.music.stop() pygame.mixer.music.load(music_file) pygame.mixer.music.set_volume(bgmVolume * masterVolume) pygame.mixer.music.play(-1) # Loop indefinitely except Exception as e: pass def get_input(prompt="Enter text:", text=""): """Display a dialog box for text input. Args: prompt (str): Prompt text to display (default: "Enter text:") text (str): Initial text in input box (default: "") Returns: str: User input text, or None if cancelled """ app = wx.App(False) dialog = wx.TextEntryDialog(None, prompt, "Input", text) dialog.SetValue(text) if dialog.ShowModal() == wx.ID_OK: userInput = dialog.GetValue() else: userInput = None dialog.Destroy() return userInput def speak(text, interrupt=True): """Speak text using the configured speech provider and display on screen. Args: text (str): Text to speak and display interrupt (bool): Whether to interrupt current speech (default: True) """ if speechProvider == "speechd": if interrupt: spd.cancel() spd.say(text) else: if speechProvider == "accessible_output2": s.speak(text, interrupt=interrupt) # Display the text on screen screen = pygame.display.get_surface() font = pygame.font.Font(None, 36) # Wrap the text max_width = screen.get_width() - 40 # Leave a 20-pixel margin on each side wrapped_text = textwrap.wrap(text, width=max_width // font.size('A')[0]) # Render each line text_surfaces = [font.render(line, True, (255, 255, 255)) for line in wrapped_text] screen.fill((0, 0, 0)) # Clear screen with black # Calculate total height of text block total_height = sum(surface.get_height() for surface in text_surfaces) # Start y-position (centered vertically) currentY = (screen.get_height() - total_height) // 2 # Blit each line of text for surface in text_surfaces: text_rect = surface.get_rect(center=(screen.get_width() // 2, currentY + surface.get_height() // 2)) screen.blit(surface, text_rect) currentY += surface.get_height() pygame.display.flip() def check_for_exit(): """Check if user has pressed escape key. Returns: bool: True if escape was pressed, False otherwise """ for event in pygame.event.get(): if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE: return True return False pygame.event.pump() def exit_game(): """Clean up and exit the game.""" if speechProvider == "speechd": spd.close() pygame.mixer.music.stop() pygame.quit() exit() def calculate_volume_and_pan(player_pos, obj_pos): """Calculate volume and stereo panning based on relative positions. Args: player_pos (float): Player's position on x-axis obj_pos (float): Object's position on x-axis Returns: tuple: (volume, left_vol, right_vol) values between 0 and 1 """ distance = abs(player_pos - obj_pos) max_distance = 12 # Maximum audible distance if distance > max_distance: return 0, 0, 0 # No sound if out of range # Calculate volume (non-linear scaling for more noticeable changes) # Apply masterVolume as the maximum possible volume volume = (((max_distance - distance) / max_distance) ** 1.5) * masterVolume # Determine left/right based on relative position if player_pos < obj_pos: # Object is to the right left = max(0, 1 - (obj_pos - player_pos) / max_distance) right = 1 elif player_pos > obj_pos: # Object is to the left left = 1 right = max(0, 1 - (player_pos - obj_pos) / max_distance) else: # Player is on the object left = right = 1 return volume, left, right def obj_play(sounds, soundName, player_pos, obj_pos, loop=True): """Play a sound with positional audio. Args: sounds (dict): Dictionary of sound objects soundName (str): Name of sound to play player_pos (float): Player's position for audio panning obj_pos (float): Object's position for audio panning loop (bool): Whether to loop the sound (default: True) Returns: pygame.mixer.Channel: Sound channel object, or None if out of range """ volume, left, right = calculate_volume_and_pan(player_pos, obj_pos) if volume == 0: return None # Don't play if out of range # Play the sound on a new channel channel = sounds[soundName].play(-1 if loop else 0) if channel: channel.set_volume(volume * left * sfxVolume, volume * right * sfxVolume) return channel def obj_update(channel, player_pos, obj_pos): """Update positional audio for a playing sound. Args: channel: Sound channel to update player_pos (float): New player position obj_pos (float): New object position Returns: pygame.mixer.Channel: Updated channel, or None if sound should stop """ if channel is None: return None volume, left, right = calculate_volume_and_pan(player_pos, obj_pos) if volume == 0: channel.stop() return None # Apply the volume and pan channel.set_volume(volume * left * sfxVolume, volume * right * sfxVolume) return channel def obj_stop(channel): """Stop a playing sound channel. Args: channel: Sound channel to stop Returns: None if stopped successfully, otherwise returns original channel """ try: channel.stop() return None except: return channel def play_sound(sound, volume=1.0): """Play a sound with current volume settings applied. Args: sound: pygame Sound object to play volume: base volume for the sound (0.0-1.0, default: 1.0) Returns: pygame.mixer.Channel: The channel the sound is playing on """ channel = sound.play() if channel: channel.set_volume(volume * sfxVolume * masterVolume) return channel def play_ambiance(sounds, soundNames, probability, randomLocation=False): """Play random ambient sounds with optional positional audio. Args: sounds (dict): Dictionary of sound objects soundNames (list): List of possible sound names to choose from probability (int): Chance to play (1-100) randomLocation (bool): Whether to randomize stereo position Returns: pygame.mixer.Channel: Sound channel if played, None otherwise """ # Check if any of the sounds in the list is already playing for soundName in soundNames: if pygame.mixer.find_channel(True) and pygame.mixer.find_channel(True).get_busy(): return None if random.randint(1, 100) > probability: return None # Choose a random sound from the list ambianceSound = random.choice(soundNames) channel = sounds[ambianceSound].play() if randomLocation and channel: leftVolume = random.random() * sfxVolume * masterVolume rightVolume = random.random() * sfxVolume * masterVolume channel.set_volume(leftVolume, rightVolume) return channel def play_random(sounds, soundName, pause=False, interrupt=False): """Play a random variation of a sound. Args: sounds (dict): Dictionary of sound objects soundName (str): Base name of sound (will match all starting with this) pause (bool): Whether to pause execution until sound finishes interrupt (bool): Whether to interrupt other sounds """ key = [] for i in sounds.keys(): if re.match("^" + soundName + ".*", i): key.append(i) if not key: # No matching sounds found return randomKey = random.choice(key) if interrupt: cut_scene(sounds, randomKey) return channel = sounds[randomKey].play() if channel: channel.set_volume(sfxVolume * masterVolume, sfxVolume * masterVolume) if pause: time.sleep(sounds[randomKey].get_length()) def play_random_positional(sounds, soundName, player_x, object_x): """Play a random variation of a sound with positional audio. Args: sounds (dict): Dictionary of sound objects soundName (str): Base name of sound to match player_x (float): Player's x position object_x (float): Object's x position Returns: pygame.mixer.Channel: Sound channel if played, None otherwise """ keys = [k for k in sounds.keys() if k.startswith(soundName)] if not keys: return None randomKey = random.choice(keys) volume, left, right = calculate_volume_and_pan(player_x, object_x) if volume == 0: return None channel = sounds[randomKey].play() if channel: channel.set_volume(volume * left * sfxVolume, volume * right * sfxVolume) return channel def cut_scene(sounds, soundName): """Play a sound as a cut scene, stopping other sounds. Args: sounds (dict): Dictionary of sound objects soundName (str): Name of sound to play """ pygame.event.clear() pygame.mixer.stop() 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: pygame.mixer.Channel: Sound channel for updating position/volume, or None if sound should stop """ # Calculate horizontal positioning volume, left, right = calculate_volume_and_pan(player_x, object_x) # Calculate vertical fall volume multiplier (0 at max_y, 1 at y=0) fallMultiplier = 1 - (currentY / max_y) # Adjust final volumes finalVolume = volume * fallMultiplier finalLeft = left * finalVolume finalRight = right * finalVolume if existing_channel is not None: if volume == 0: # Out of audible range existing_channel.stop() return None existing_channel.set_volume(finalLeft * sfxVolume, finalRight * sfxVolume) return existing_channel else: # Need to create new channel if volume == 0: # Don't start if out of range return None # Find matching sound files keys = [k for k in sounds.keys() if k.startswith(soundName)] if not keys: return None randomKey = random.choice(keys) channel = sounds[randomKey].play() if channel: channel.set_volume(finalLeft * sfxVolume, finalRight * sfxVolume) return channel def display_text(text): """Display and speak text with navigation controls. Allows users to: - Navigate text line by line with arrow keys - Listen to full text with space - Copy current line or full text - Exit with enter/escape - Volume controls (with Alt modifier): - Alt+PageUp/PageDown: Master volume up/down - Alt+Home/End: Background music volume up/down - Alt+Insert/Delete: Sound effects volume up/down Args: text (list): List of text lines to display """ 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: # Check for Alt modifier mods = pygame.key.get_mods() alt_pressed = mods & pygame.KMOD_ALT # Volume controls (require Alt) if alt_pressed: if event.key == pygame.K_PAGEUP: adjust_master_volume(0.1) elif event.key == pygame.K_PAGEDOWN: adjust_master_volume(-0.1) elif event.key == pygame.K_HOME: adjust_bgm_volume(0.1) elif event.key == pygame.K_END: adjust_bgm_volume(-0.1) elif event.key == pygame.K_INSERT: adjust_sfx_volume(0.1) elif event.key == pygame.K_DELETE: adjust_sfx_volume(-0.1) # Regular text navigation (no Alt required) 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(text) - 1: currentIndex += 1 speak(text[currentIndex]) if event.key in [pygame.K_UP, pygame.K_w] 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(): """Display game instructions from file. Reads and displays instructions from 'files/instructions.txt'. If file is missing, displays an error message. """ try: pygame.mixer.music.pause() except: pass try: with open('files/instructions.txt', 'r') as f: info = f.readlines() except: info = ["Instructions file is missing."] display_text(info) try: pygame.mixer.music.unpause() except: pass def credits(): """Display game credits from file. Reads and displays credits from 'files/credits.txt'. Adds game name header before displaying. If file is missing, displays an error message. """ try: pygame.mixer.music.pause() except: pass try: with open('files/credits.txt', 'r') as f: info = f.readlines() # Add the header info.insert(0, gameName + ": brought to you by Storm Dragon") except: info = ["Credits file is missing."] display_text(info) try: pygame.mixer.music.unpause() except: pass def learn_sounds(sounds): """Interactive menu for learning game sounds. Allows users to: - Navigate through available sounds - Play selected sounds - Return to menu with escape key Args: sounds (dict): Dictionary of available sound objects Returns: str: "menu" if user exits with escape """ loop = True try: pygame.mixer.music.pause() except: pass currentIndex = 0 # Get list of available sounds, excluding special sounds soundFiles = [f for f in listdir("sounds/") if isfile(join("sounds/", f)) and (f.split('.')[1].lower() in ["ogg", "wav"]) and (f.split('.')[0].lower() not in ["game-intro", "music_menu"]) and (not f.lower().startswith("_"))] # Track last spoken index to avoid repetition lastSpoken = -1 while loop: if currentIndex != lastSpoken: speak(soundFiles[currentIndex][:-4]) lastSpoken = currentIndex event = pygame.event.wait() if event.type == pygame.KEYDOWN: if event.key == pygame.K_ESCAPE: try: pygame.mixer.music.unpause() except: pass return "menu" if event.key in [pygame.K_DOWN, pygame.K_s] and currentIndex < len(soundFiles) - 1: pygame.mixer.stop() currentIndex += 1 if event.key in [pygame.K_UP, pygame.K_w] and currentIndex > 0: pygame.mixer.stop() currentIndex -= 1 if event.key == pygame.K_RETURN: try: soundName = soundFiles[currentIndex][:-4] pygame.mixer.stop() sounds[soundName].play() except: lastSpoken = -1 speak("Could not play sound.") event = pygame.event.clear() time.sleep(0.001) def game_menu(sounds, *options): """Display and handle the main game menu. Provides menu navigation with: - Up/Down arrows for selection - Home/End for first/last option - Enter to select - Escape to exit - Volume controls (with Alt modifier): - Alt+PageUp/PageDown: Master volume up/down - Alt+Home/End: Background music volume up/down - Alt+Insert/Delete: Sound effects volume up/down """ loop = True pygame.mixer.stop() if pygame.mixer.music.get_busy(): pygame.mixer.music.unpause() else: try: play_bgm("sounds/music_menu.ogg") except: pass currentIndex = 0 lastSpoken = -1 # Track last spoken index while loop: if currentIndex != lastSpoken: speak(options[currentIndex]) lastSpoken = currentIndex event = pygame.event.wait() if event.type == pygame.KEYDOWN: # Check for Alt modifier mods = pygame.key.get_mods() alt_pressed = mods & pygame.KMOD_ALT # Volume controls (require Alt) if alt_pressed: if event.key == pygame.K_PAGEUP: adjust_master_volume(0.1) elif event.key == pygame.K_PAGEDOWN: adjust_master_volume(-0.1) elif event.key == pygame.K_HOME: adjust_bgm_volume(0.1) elif event.key == pygame.K_END: adjust_bgm_volume(-0.1) elif event.key == pygame.K_INSERT: adjust_sfx_volume(0.1) elif event.key == pygame.K_DELETE: adjust_sfx_volume(-0.1) # Regular menu navigation (no Alt required) else: if event.key == pygame.K_ESCAPE: exit_game() elif event.key == pygame.K_HOME: if currentIndex != 0: currentIndex = 0 try: sounds['menu-move'].play() except: pass if options[currentIndex] != "donate": pygame.mixer.music.unpause() elif event.key == pygame.K_END: if currentIndex != len(options) - 1: currentIndex = len(options) - 1 try: sounds['menu-move'].play() except: pass if options[currentIndex] != "donate": pygame.mixer.music.unpause() elif event.key in [pygame.K_DOWN, pygame.K_s] and currentIndex < len(options) - 1: currentIndex += 1 try: sounds['menu-move'].play() except: pass if options[currentIndex] != "donate": pygame.mixer.music.unpause() elif event.key in [pygame.K_UP, pygame.K_w] and currentIndex > 0: currentIndex -= 1 try: sounds['menu-move'].play() except: pass if options[currentIndex] != "donate": pygame.mixer.music.unpause() elif event.key == pygame.K_RETURN: try: lastSpoken = -1 try: sounds['menu-select'].play() time.sleep(sounds['menu-select'].get_length()) except: pass eval(options[currentIndex] + "()") except: lastSpoken = -1 pygame.mixer.music.fadeout(500) try: pygame.mixer.music.fadeout(750) time.sleep(1.0) except: pass return options[currentIndex] event = pygame.event.clear() time.sleep(0.001) def donate(): """Open the donation webpage. Pauses background music and opens the Ko-fi donation page. """ pygame.mixer.music.pause() webbrowser.open('https://ko-fi.com/stormux')