18 Commits

Author SHA1 Message Date
Storm Dragon 0493edf8e6 Removed the last bit from current message. Not needed, and may actually be confusing. 2025-09-23 18:00:43 -04:00
Storm Dragon c257128948 It didn't occur to me for some reason that reviewing messages would also add those messages to history lol. Hopefully fixed now. 2025-09-23 17:37:51 -04:00
Storm Dragon aed7ba523d First pass at implementing a speech history option for games. 2025-09-23 17:21:33 -04:00
Storm Dragon 5444ec4047 Fixed a bug where _ prefixed sounds in sub directories weren't hidden from learn sounds. 2025-09-23 14:16:11 -04:00
Storm Dragon 66bc11099e I misremembered the one I wanted it was event.clear not pump. 2025-09-21 22:38:16 -04:00
Storm Dragon 25d54a4f5e Potential fix for dialogues when running from pyinstaller compiled code. 2025-09-21 22:15:19 -04:00
Storm Dragon a00bdc5ff9 Removed the default text in scoreboard.py. This way people don't have to backspace before entering their name. If the text is empty or None, it then uses Player as the default. 2025-09-21 14:33:18 -04:00
Storm Dragon d050db0d6e Dialogue instructions only show once per game now following the same pattern as display_text. 2025-09-21 13:41:52 -04:00
Storm Dragon a98783dbc4 Fixed critical sound timing bug. Finally, after a couple of days of banging my head against this problem and sound getting progressively worse, it's fixed. Turned out the fix was a very simple timing issue for when to play sounds. 2025-09-19 12:51:45 -04:00
Storm Dragon f2079261d1 More event handling to help with pyinstaller compilation. 2025-09-10 12:57:09 -04:00
Storm Dragon 0190fa3a06 Add the ability to add an optional graphical logo for games. Tested with .png file. 2025-09-10 00:59:21 -04:00
Storm Dragon 09421c4bda Updated donation link. 2025-09-08 11:14:42 -04:00
Storm Dragon dcd204e476 Added the ability to create submenus with instructions. 2025-09-07 02:38:27 -04:00
Storm Dragon ca2d0d34bd A few more tweaks to the input code. 2025-07-10 01:22:24 -04:00
Storm Dragon 8ffa53b69f Attempt to fix speech cutoff when reading default text. 2025-07-10 01:13:59 -04:00
Storm Dragon 68ffde0fc7 Dropped wxpython because of stability issues. 2025-07-10 00:53:36 -04:00
Storm Dragon a17a4c6f15 Code cleanup and sound consolidation. 2025-03-22 17:34:35 -04:00
Storm Dragon 3a478d15d5 Instead of implementing sound changes in one huge go, I'm going to try smaller approaches. Now play_sound has an optional loop parameter set to false by default for backwards compatibility. 2025-03-17 21:12:29 -04:00
11 changed files with 1624 additions and 1021 deletions
+23 -18
View File
@@ -15,7 +15,8 @@ This module provides core functionality for Storm Games including:
from .services import (
ConfigService,
VolumeService,
PathService
PathService,
SpeechHistoryService
)
# Import Sound class and functions
@@ -39,7 +40,7 @@ from .sound import (
)
# Import Speech class and functions
from .speech import messagebox, speak, Speech
from .speech import messagebox, speak, Speech, speak_previous, speak_current, speak_next
# Import Scoreboard
from .scoreboard import Scoreboard
@@ -51,7 +52,7 @@ from .input import get_input, check_for_exit, pause_game
from .display import display_text, initialize_gui
# Import menu functions
from .menu import game_menu, learn_sounds, instructions, credits, donate, exit_game
from .menu import game_menu, instruction_menu, learn_sounds, instructions, credits, donate, exit_game
# Update imports to reference Scoreboard methods
high_scores = Scoreboard.display_high_scores
@@ -78,8 +79,8 @@ __version__ = '2.0.0'
# Make all symbols available at the package level
__all__ = [
# Services
'ConfigService', 'VolumeService', 'PathService',
'ConfigService', 'VolumeService', 'PathService', 'SpeechHistoryService',
# Sound
'Sound',
'play_bgm',
@@ -97,32 +98,35 @@ __all__ = [
'cut_scene',
'play_random_falling',
'calculate_volume_and_pan',
# Speech
'messagebox',
'speak',
'Speech',
'speak_previous',
'speak_current',
'speak_next',
# Scoreboard
'Scoreboard',
# Input
'get_input', 'check_for_exit', 'pause_game',
# Display
'display_text', 'initialize_gui',
# Menu
'game_menu', 'learn_sounds', 'instructions', 'credits', 'donate', 'exit_game', 'high_scores', 'has_high_scores',
'game_menu', 'instruction_menu', 'learn_sounds', 'instructions', 'credits', 'donate', 'exit_game', 'high_scores', 'has_high_scores',
# Game class
'Game',
# Utils
'check_for_updates', 'get_version_tuple', 'check_compatibility',
'sanitize_filename', 'lerp', 'smooth_step', 'distance_2d',
'x_powerbar', 'y_powerbar', 'generate_tone',
# Re-exported functions from pygame, math, random
'get_ticks', 'delay', 'wait',
'sin', 'cos', 'sqrt', 'floor', 'ceil',
@@ -133,6 +137,7 @@ __all__ = [
configService = ConfigService.get_instance()
volumeService = VolumeService.get_instance()
pathService = PathService.get_instance()
speechHistoryService = SpeechHistoryService.get_instance()
# Set up backward compatibility hooks for initialize_gui
_originalInitializeGui = initialize_gui
@@ -141,10 +146,10 @@ def initialize_gui_with_services(gameTitle):
"""Wrapper around initialize_gui that initializes services."""
# Initialize path service
pathService.initialize(gameTitle)
# Connect config service to path service
configService.set_game_info(gameTitle, pathService)
# Call original initialize_gui
return _originalInitializeGui(gameTitle)
@@ -159,11 +164,11 @@ def scoreboard_init_with_services(self, score=0, configService=None, speech=None
# Use global services if not specified
if configService is None:
configService = ConfigService.get_instance()
# Ensure pathService is connected if using defaults
if not hasattr(configService, 'pathService') and pathService.game_path is not None:
configService.pathService = pathService
# Call original init with services
_originalScoreboardInit(self, score, configService, speech)
+11 -11
View File
@@ -13,10 +13,10 @@ from xdg import BaseDirectory
class Config:
"""Configuration management class for Storm Games."""
def __init__(self, gameTitle):
"""Initialize configuration system for a game.
Args:
gameTitle (str): Title of the game
"""
@@ -24,19 +24,19 @@ class Config:
self.globalPath = os.path.join(BaseDirectory.xdg_config_home, "storm-games")
self.gamePath = os.path.join(self.globalPath,
str.lower(str.replace(gameTitle, " ", "-")))
# Create game directory if it doesn't exist
if not os.path.exists(self.gamePath):
os.makedirs(self.gamePath)
# Initialize config parsers
self.localConfig = configparser.ConfigParser()
self.globalConfig = configparser.ConfigParser()
# Load existing configurations
self.read_local_config()
self.read_global_config()
def read_local_config(self):
"""Read local configuration from file."""
try:
@@ -44,7 +44,7 @@ class Config:
self.localConfig.read_file(configFile)
except:
pass
def read_global_config(self):
"""Read global configuration from file."""
try:
@@ -52,12 +52,12 @@ class Config:
self.globalConfig.read_file(configFile)
except:
pass
def write_local_config(self):
"""Write local configuration to file."""
with open(os.path.join(self.gamePath, "config.ini"), 'w') as configFile:
self.localConfig.write(configFile)
def write_global_config(self):
"""Write global configuration to file."""
with open(os.path.join(self.globalPath, "config.ini"), 'w') as configFile:
@@ -71,7 +71,7 @@ globalPath = ""
def write_config(writeGlobal=False):
"""Write configuration to file.
Args:
writeGlobal (bool): If True, write to global config, otherwise local (default: False)
"""
@@ -84,7 +84,7 @@ def write_config(writeGlobal=False):
def read_config(readGlobal=False):
"""Read configuration from file.
Args:
readGlobal (bool): If True, read global config, otherwise local (default: False)
"""
+78 -32
View File
@@ -23,81 +23,123 @@ displayTextUsageInstructions = False
def initialize_gui(gameTitle):
"""Initialize the game GUI and sound system.
Args:
gameTitle (str): Title of the game
Returns:
dict: Dictionary of loaded sound objects
"""
# Initialize path service with game title
pathService = PathService.get_instance().initialize(gameTitle)
# Seed the random generator to the clock
random.seed()
# Set game's name
setproctitle(str.lower(str.replace(gameTitle, " ", "-")))
# Initialize pygame
pygame.init()
pygame.display.set_mode((800, 600))
pygame.display.set_caption(gameTitle)
# Set up audio system
pygame.mixer.pre_init(44100, -16, 2, 1024)
pygame.mixer.init()
pygame.mixer.set_num_channels(32)
pygame.mixer.set_reserved(0) # Reserve channel for cut scenes
# Enable key repeat for volume controls
pygame.key.set_repeat(500, 100)
# Load sound files recursively including subdirectories
soundData = {}
try:
import os
soundDir = "sounds/"
# Walk through directory tree
for dirPath, dirNames, fileNames in os.walk(soundDir):
# Get relative path from soundDir
relPath = os.path.relpath(dirPath, soundDir)
# Process each file
for fileName in fileNames:
# Check if file is a valid sound file
if fileName.lower().endswith(('.ogg', '.wav')):
# Full path to the sound file
fullPath = os.path.join(dirPath, fileName)
# Create sound key (remove extension)
baseName = os.path.splitext(fileName)[0]
# If in root sounds dir, just use basename
if relPath == '.':
soundKey = baseName
else:
# Otherwise use relative path + basename, normalized with forward slashes
soundKey = os.path.join(relPath, baseName).replace('\\', '/')
# Load the sound
soundData[soundKey] = pygame.mixer.Sound(fullPath)
except Exception as e:
print("Error loading sounds:", e)
Speech.get_instance().speak("Error loading sounds.", False)
soundData = {}
# Play intro sound if available
# Play intro sound if available, optionally with visual logo
from .sound import cut_scene
if 'game-intro' in soundData:
cut_scene(soundData, 'game-intro')
_show_logo_with_audio(soundData, 'game-intro')
return soundData
def _show_logo_with_audio(soundData, audioKey):
"""Show visual logo while playing audio intro.
Args:
soundData (dict): Dictionary of loaded sounds
audioKey (str): Key of the audio to play
"""
# Look for logo image files in common formats
logoFiles = ['logo.png', 'logo.jpg', 'logo.jpeg', 'logo.gif', 'logo.bmp']
logoImage = None
for logoFile in logoFiles:
if os.path.exists(logoFile):
try:
logoImage = pygame.image.load(logoFile)
break
except pygame.error:
continue
if logoImage:
# Display logo while audio plays
screen = pygame.display.get_surface()
screenRect = screen.get_rect()
logoRect = logoImage.get_rect(center=screenRect.center)
# Clear screen to black
screen.fill((0, 0, 0))
screen.blit(logoImage, logoRect)
pygame.display.flip()
# Play audio and wait for it to finish
from .sound import cut_scene
cut_scene(soundData, audioKey)
# Clear screen after audio finishes
screen.fill((0, 0, 0))
pygame.display.flip()
else:
# No logo image found, just play audio
from .sound import cut_scene
cut_scene(soundData, audioKey)
def display_text(text):
"""Display and speak text with navigation controls.
Allows users to:
- Navigate text line by line with arrow keys (skipping blank lines)
- Listen to full text with space
@@ -107,20 +149,20 @@ def display_text(text):
- Alt+PageUp/PageDown: Master volume up/down
- Alt+Home/End: Background music volume up/down
- Alt+Insert/Delete: Sound effects volume up/down
Args:
text (list): List of text lines to display
"""
# Get service instances
speech = Speech.get_instance()
volumeService = VolumeService.get_instance()
# Store original text with blank lines for copying
originalText = text.copy()
# Create navigation text by filtering out blank lines
navText = [line for line in text if line.strip()]
# Add instructions at the start on the first display
global displayTextUsageInstructions
if not displayTextUsageInstructions:
@@ -129,20 +171,23 @@ def display_text(text):
"or t to copy the entire text. Press enter or escape when you are done reading.")
navText.insert(0, instructions)
displayTextUsageInstructions = True
# Add end marker
navText.append("End of text.")
currentIndex = 0
speech.speak(navText[currentIndex])
# Clear any pending events
pygame.event.clear()
while True:
event = pygame.event.wait()
if event.type == pygame.KEYDOWN:
# Check for Alt modifier
mods = pygame.key.get_mods()
altPressed = mods & pygame.KMOD_ALT
# Volume controls (require Alt)
if altPressed:
if event.key == pygame.K_PAGEUP:
@@ -160,26 +205,26 @@ def display_text(text):
else:
if event.key in (pygame.K_ESCAPE, pygame.K_RETURN):
return
if event.key in [pygame.K_DOWN, pygame.K_s] and currentIndex < len(navText) - 1:
currentIndex += 1
speech.speak(navText[currentIndex])
if event.key in [pygame.K_UP, pygame.K_w] and currentIndex > 0:
currentIndex -= 1
speech.speak(navText[currentIndex])
if event.key == pygame.K_SPACE:
# Join with newlines to preserve spacing in speech
speech.speak('\n'.join(originalText[1:-1]))
if event.key == pygame.K_c:
try:
pyperclip.copy(navText[currentIndex])
speech.speak("Copied " + navText[currentIndex] + " to the clipboard.")
except:
speech.speak("Failed to copy the text to the clipboard.")
if event.key == pygame.K_t:
try:
# Join with newlines to preserve blank lines in full text
@@ -187,6 +232,7 @@ def display_text(text):
speech.speak("Copied entire message to the clipboard.")
except:
speech.speak("Failed to copy the text to the clipboard.")
event = pygame.event.clear()
pygame.event.pump()
pygame.event.clear()
time.sleep(0.001)
+189 -13
View File
@@ -10,28 +10,200 @@ Provides functionality for:
import pygame
import time
import wx
from .speech import speak
def get_input(prompt="Enter text:", text=""):
"""Display a dialog box for text input.
"""Display an accessible text input dialog using pygame.
Features:
- Speaks each character as typed
- Left/Right arrows navigate and speak characters
- Up/Down arrows read full text content
- Backspace announces deletions
- Enter submits, Escape cancels
- Control key repeats the original prompt message
- Fully accessible without screen reader dependency
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()
# Initialize text buffer and cursor
text_buffer = list(text) # Use list for easier character manipulation
cursor_pos = len(text_buffer) # Start at end of initial text
# Announce the prompt and initial text as a single message
if text:
initial_message = f"{prompt} Default text: {text}"
else:
userInput = None
dialog.Destroy()
return userInput
initial_message = f"{prompt} Empty text field"
speak(initial_message)
# Clear any pending events
pygame.event.clear()
# Main input loop
while True:
event = pygame.event.wait()
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_RETURN:
# Submit the input
result = ''.join(text_buffer)
speak(f"Submitted: {result if result else 'empty'}")
return result
elif event.key == pygame.K_ESCAPE:
# Cancel input
speak("Cancelled")
return None
elif event.key == pygame.K_BACKSPACE:
# Delete character before cursor
if cursor_pos > 0:
deleted_char = text_buffer.pop(cursor_pos - 1)
cursor_pos -= 1
speak(f"{deleted_char} deleted")
else:
speak("Nothing to delete")
elif event.key == pygame.K_DELETE:
# Delete character at cursor
if cursor_pos < len(text_buffer):
deleted_char = text_buffer.pop(cursor_pos)
speak(f"{deleted_char} deleted")
else:
speak("Nothing to delete")
elif event.key == pygame.K_LEFT:
# Move cursor left and speak character
if cursor_pos > 0:
cursor_pos -= 1
if cursor_pos == 0:
speak("Beginning of text")
else:
speak(text_buffer[cursor_pos])
else:
speak("Beginning of text")
elif event.key == pygame.K_RIGHT:
# Move cursor right and speak character
if cursor_pos < len(text_buffer):
speak(text_buffer[cursor_pos])
cursor_pos += 1
if cursor_pos == len(text_buffer):
speak("End of text")
else:
speak("End of text")
elif event.key == pygame.K_UP or event.key == pygame.K_DOWN:
# Read entire text content
if text_buffer:
speak(''.join(text_buffer))
else:
speak("Empty text field")
elif event.key == pygame.K_HOME:
# Move to beginning
cursor_pos = 0
speak("Beginning of text")
elif event.key == pygame.K_END:
# Move to end
cursor_pos = len(text_buffer)
speak("End of text")
elif event.key == pygame.K_LCTRL or event.key == pygame.K_RCTRL:
# Repeat the original prompt message
speak(initial_message)
else:
# Handle regular character input
if event.unicode and event.unicode.isprintable():
char = event.unicode
# Insert character at cursor position
text_buffer.insert(cursor_pos, char)
cursor_pos += 1
# Speak the character name
if char == ' ':
speak("space")
elif char == '\\':
speak("backslash")
elif char == '/':
speak("slash")
elif char == '!':
speak("exclamation mark")
elif char == '"':
speak("quotation mark")
elif char == '#':
speak("hash")
elif char == '$':
speak("dollar sign")
elif char == '%':
speak("percent")
elif char == '&':
speak("ampersand")
elif char == "'":
speak("apostrophe")
elif char == '(':
speak("left parenthesis")
elif char == ')':
speak("right parenthesis")
elif char == '*':
speak("asterisk")
elif char == '+':
speak("plus")
elif char == ',':
speak("comma")
elif char == '-':
speak("minus")
elif char == '.':
speak("period")
elif char == ':':
speak("colon")
elif char == ';':
speak("semicolon")
elif char == '<':
speak("less than")
elif char == '=':
speak("equals")
elif char == '>':
speak("greater than")
elif char == '?':
speak("question mark")
elif char == '@':
speak("at sign")
elif char == '[':
speak("left bracket")
elif char == ']':
speak("right bracket")
elif char == '^':
speak("caret")
elif char == '_':
speak("underscore")
elif char == '`':
speak("grave accent")
elif char == '{':
speak("left brace")
elif char == '|':
speak("pipe")
elif char == '}':
speak("right brace")
elif char == '~':
speak("tilde")
else:
# For regular letters, numbers, and other characters
speak(char)
# Allow other events to be processed
pygame.event.pump()
pygame.event.clear()
time.sleep(0.001)
def pause_game():
"""Pauses the game until user presses backspace."""
@@ -51,6 +223,10 @@ def pause_game():
event = pygame.event.wait()
if event.type == pygame.KEYDOWN and event.key == pygame.K_BACKSPACE:
break
pygame.event.pump()
pygame.event.clear()
time.sleep(0.001)
try:
pygame.mixer.unpause()
@@ -66,7 +242,7 @@ def pause_game():
def check_for_exit():
"""Check if user has pressed escape key.
Returns:
bool: True if escape was pressed, False otherwise
"""
+166 -66
View File
@@ -25,9 +25,97 @@ from .display import display_text
from .scoreboard import Scoreboard
from .services import PathService, ConfigService
def instruction_menu(sounds, instruction_text, *options):
"""Display a menu with an instruction announcement at the top.
The instruction text is announced as item 0 but is not selectable.
The actual menu items start at index 1, and navigation skips the instruction.
Args:
sounds (dict): Dictionary of sound objects
instruction_text (str): The instruction/context text to announce
*options: Menu options to display
Returns:
str: Selected menu option or None if cancelled
"""
# Get speech instance
speech = Speech.get_instance()
# Create combined list with instruction at index 0
all_items = [instruction_text] + list(options)
loop = True
pygame.mixer.stop()
current_index = 0 # Start at instruction
last_spoken = -1
# Clear any pending events
pygame.event.clear()
while loop:
if current_index != last_spoken:
speech.speak(all_items[current_index])
last_spoken = current_index
event = pygame.event.wait()
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
return None
elif event.key in [pygame.K_DOWN, pygame.K_s]:
moved = False
if current_index == 0: # On instruction, go to first menu item
current_index = 1
moved = True
elif current_index < len(all_items) - 1: # Normal navigation
current_index += 1
moved = True
if moved:
try:
sounds['menu-move'].play()
except:
pass
elif event.key in [pygame.K_UP, pygame.K_w]:
if current_index > 1: # Can move up from menu items (but not to instruction)
current_index -= 1
try:
sounds['menu-move'].play()
except:
pass
elif event.key == pygame.K_HOME:
target_index = 1 if current_index != 1 else current_index # Go to first menu item
if target_index != current_index:
current_index = target_index
try:
sounds['menu-move'].play()
except:
pass
elif event.key == pygame.K_END and current_index != len(all_items) - 1:
current_index = len(all_items) - 1
try:
sounds['menu-move'].play()
except:
pass
elif event.key == pygame.K_RETURN:
if current_index == 0: # Can't select the instruction
continue
try:
sounds['menu-select'].play()
time.sleep(sounds['menu-select'].get_length())
except:
pass
return options[current_index - 1] # Adjust for instruction offset
elif event.type == pygame.QUIT:
return None
pygame.event.pump()
pygame.event.clear()
time.sleep(0.001)
def game_menu(sounds, playCallback=None, *customOptions):
"""Display and handle the main game menu with standard and custom options.
Standard menu structure:
1. Play (always first)
2. High Scores
@@ -37,53 +125,53 @@ def game_menu(sounds, playCallback=None, *customOptions):
6. Credits (if available)
7. Donate
8. Exit
Handles navigation with:
- Up/Down arrows for selection
- Home/End for first/last option
- Enter to select
- Escape to exit
- Volume controls (with Alt modifier)
Args:
sounds (dict): Dictionary of sound objects
playCallback (function, optional): Callback function for the "play" option.
If None, "play" is returned as a string like other options.
*customOptions: Additional custom options to include after play but before standard ones
Returns:
str: Selected menu option or "exit" if user pressed escape
"""
# Get speech instance
speech = Speech.get_instance()
# Start with Play option
allOptions = ["play"]
# Add high scores option if scores exist
if Scoreboard.has_high_scores():
allOptions.append("high_scores")
# Add custom options (other menu items, etc.)
allOptions.extend(customOptions)
# Add standard options in preferred order
allOptions.append("learn_sounds")
# Check for instructions file
if os.path.isfile('files/instructions.txt'):
allOptions.append("instructions")
# Check for credits file
if os.path.isfile('files/credits.txt'):
allOptions.append("credits")
# Final options
allOptions.extend(["donate", "exit_game"])
# Track if music was previously playing
musicWasPlaying = pygame.mixer.music.get_busy()
# Only start menu music if no music is currently playing
if not musicWasPlaying:
try:
@@ -91,23 +179,24 @@ def game_menu(sounds, playCallback=None, *customOptions):
play_bgm("sounds/music_menu.ogg")
except:
pass
loop = True
pygame.mixer.stop()
currentIndex = 0
lastSpoken = -1 # Track last spoken index
pygame.event.clear()
while loop:
if currentIndex != lastSpoken:
speech.speak(allOptions[currentIndex])
lastSpoken = currentIndex
event = pygame.event.wait()
if event.type == pygame.KEYDOWN:
# Check for Alt modifier
mods = pygame.key.get_mods()
altPressed = mods & pygame.KMOD_ALT
# Volume controls (require Alt)
if altPressed:
if event.key == pygame.K_PAGEUP:
@@ -169,9 +258,9 @@ def game_menu(sounds, playCallback=None, *customOptions):
time.sleep(sounds['menu-select'].get_length())
except:
pass
selectedOption = allOptions[currentIndex]
# Special case for exit_game with fade
if selectedOption == "exit_game":
exit_game(500 if pygame.mixer.music.get_busy() else 0)
@@ -202,7 +291,7 @@ def game_menu(sounds, playCallback=None, *customOptions):
pygame.mixer.music.pause()
except:
pass
# Handle standard options
if selectedOption == "instructions":
instructions()
@@ -214,7 +303,7 @@ def game_menu(sounds, playCallback=None, *customOptions):
Scoreboard.display_high_scores()
elif selectedOption == "donate":
donate()
# Unpause music after function returns
try:
# Check if music is actually paused before trying to unpause
@@ -248,105 +337,110 @@ def game_menu(sounds, playCallback=None, *customOptions):
except:
pass
return allOptions[currentIndex]
event = pygame.event.clear()
pygame.event.pump()
pygame.event.clear()
time.sleep(0.001)
def learn_sounds(sounds):
"""Interactive menu for learning game sounds.
Allows users to:
- Navigate through available sounds with up/down arrows
- Navigate between sound categories (folders) using Page Up/Page Down or Left/Right arrows
- Play selected sounds with Enter
- Return to menu with Escape
Excluded sounds:
- Files in folders named 'ambience' (at any level)
- Files in any directory starting with '.'
- Files starting with 'game-intro', 'music_menu', or '_'
- Files whose filename starts with 'game-intro', 'music_menu', or '_' (regardless of directory)
Args:
sounds (dict): Dictionary of available sound objects
Returns:
str: "menu" if user exits with escape
"""
# Get speech instance
speech = Speech.get_instance()
# Define exclusion criteria
excludedPrefixes = ["game-intro", "music_menu", "_"]
excludedDirs = ["ambience", "."]
# Organize sounds by directory
soundsByDir = {}
# Process each sound key in the dictionary
for soundKey in sounds.keys():
# Skip if key has any excluded prefix
if any(soundKey.lower().startswith(prefix.lower()) for prefix in excludedPrefixes):
# Extract the filename (part after the last '/')
filename = soundKey.split('/')[-1]
# Skip if filename has any excluded prefix
if any(filename.lower().startswith(prefix.lower()) for prefix in excludedPrefixes):
continue
# Split key into path parts
parts = soundKey.split('/')
# Skip if any part of the path is an excluded directory
if any(part.lower() == dirName.lower() or part.startswith('.') for part in parts for dirName in excludedDirs):
continue
# Determine the directory
if '/' in soundKey:
directory = soundKey.split('/')[0]
else:
directory = 'root' # Root directory sounds
# Add to sounds by directory
if directory not in soundsByDir:
soundsByDir[directory] = []
soundsByDir[directory].append(soundKey)
# Sort each directory's sounds
for directory in soundsByDir:
soundsByDir[directory].sort()
# If no sounds found, inform the user and return
if not soundsByDir:
speech.speak("No sounds available to learn.")
return "menu"
# Get list of directories in sorted order
directories = sorted(soundsByDir.keys())
# Start with first directory
currentDirIndex = 0
currentDir = directories[currentDirIndex]
currentSoundKeys = soundsByDir[currentDir]
currentSoundIndex = 0
# Display appropriate message based on number of directories
if len(directories) > 1:
messagebox(f"Starting with {currentDir if currentDir != 'root' else 'root directory'} sounds. Use left and right arrows or page up and page down to navigate categories.")
# Track last spoken to avoid repetition
lastSpoken = -1
directoryChanged = True # Flag to track if directory just changed
# Flag to track when to exit the loop
returnToMenu = False
pygame.event.clear()
while not returnToMenu:
# Announce current sound
if currentSoundIndex != lastSpoken:
totalSounds = len(currentSoundKeys)
soundName = currentSoundKeys[currentSoundIndex]
# Remove directory prefix if present
if '/' in soundName:
displayName = '/'.join(soundName.split('/')[1:])
else:
displayName = soundName
# If directory just changed, include directory name in announcement
if directoryChanged:
dirDescription = "Root directory" if currentDir == 'root' else currentDir
@@ -354,24 +448,24 @@ def learn_sounds(sounds):
directoryChanged = False # Reset flag after announcement
else:
announcement = f"{displayName}, {currentSoundIndex + 1} of {totalSounds}"
speech.speak(announcement)
lastSpoken = currentSoundIndex
event = pygame.event.wait()
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
returnToMenu = True
# Sound navigation
elif event.key in [pygame.K_DOWN, pygame.K_s] and currentSoundIndex < len(currentSoundKeys) - 1:
pygame.mixer.stop()
currentSoundIndex += 1
elif event.key in [pygame.K_UP, pygame.K_w] and currentSoundIndex > 0:
pygame.mixer.stop()
currentSoundIndex -= 1
# Directory navigation
elif event.key in [pygame.K_PAGEDOWN, pygame.K_RIGHT] and currentDirIndex < len(directories) - 1:
pygame.mixer.stop()
@@ -381,7 +475,7 @@ def learn_sounds(sounds):
currentSoundIndex = 0
directoryChanged = True # Set flag on directory change
lastSpoken = -1 # Force announcement
elif event.key in [pygame.K_PAGEUP, pygame.K_LEFT] and currentDirIndex > 0:
pygame.mixer.stop()
currentDirIndex -= 1
@@ -390,7 +484,7 @@ def learn_sounds(sounds):
currentSoundIndex = 0
directoryChanged = True # Set flag on directory change
lastSpoken = -1 # Force announcement
# Play sound
elif event.key == pygame.K_RETURN:
try:
@@ -400,16 +494,16 @@ def learn_sounds(sounds):
except Exception as e:
print(f"Error playing sound: {e}")
speech.speak("Could not play sound.")
event = pygame.event.clear()
pygame.event.pump() # Process pygame's internal events
time.sleep(0.001)
return "menu"
def instructions():
"""Display game instructions from file.
Reads and displays instructions from 'files/instructions.txt'.
If file is missing, displays an error message.
"""
@@ -441,28 +535,28 @@ def credits():
def donate():
"""Open the donation webpage.
Opens the Ko-fi donation page.
Opens the donation page.
"""
webbrowser.open('https://ko-fi.com/stormux')
webbrowser.open('https://www.paypal.com/donate/?business=stormdragon2976@gmail.com&no_recurring=0&currency_code=USD')
messagebox("The donation page has been opened in your browser.")
def exit_game(fade=0):
"""Clean up and exit the game properly.
Args:
fade (int): Milliseconds to fade out music before exiting.
0 means stop immediately (default)
"""
# Force clear any pending events to prevent hanging
pygame.event.clear()
# Stop all mixer channels first
try:
pygame.mixer.stop()
except Exception as e:
print(f"Warning: Could not stop mixer channels: {e}")
# Get speech instance and handle all providers
try:
speech = Speech.get_instance()
@@ -473,7 +567,7 @@ def exit_game(fade=0):
print(f"Warning: Could not close speech: {e}")
except Exception as e:
print(f"Warning: Could not get speech instance: {e}")
# Handle music based on fade parameter
try:
if fade > 0 and pygame.mixer.music.get_busy():
@@ -484,13 +578,19 @@ def exit_game(fade=0):
pygame.mixer.music.stop()
except Exception as e:
print(f"Warning: Could not handle music during exit: {e}")
# Clean up pygame mixer first
try:
pygame.mixer.quit()
except Exception as e:
print(f"Warning: Error during pygame.mixer.quit(): {e}")
# Clean up pygame
try:
pygame.quit()
except Exception as e:
print(f"Warning: Error during pygame.quit(): {e}")
# Use os._exit for immediate termination
import os
os._exit(0)
-1
View File
@@ -5,4 +5,3 @@ pyxdg>=0.27
setproctitle>=1.2.0
numpy>=1.19.0
accessible-output2>=0.14
wxpython
+56 -56
View File
@@ -18,10 +18,10 @@ from .config import localConfig, write_config, read_config
class Scoreboard:
"""Handles high score tracking with player names."""
def __init__(self, score=0, configService=None, speech=None):
"""Initialize scoreboard.
Args:
score (int): Initial score (default: 0)
configService (ConfigService): Config service (default: global instance)
@@ -29,15 +29,15 @@ class Scoreboard:
"""
# Ensure services are properly initialized
self._ensure_services()
self.configService = configService or ConfigService.get_instance()
self.speech = speech or Speech.get_instance()
self.currentScore = score
self.highScores = []
# For backward compatibility
read_config()
try:
# Try to use configService
self.configService.localConfig.add_section("scoreboard")
@@ -47,7 +47,7 @@ class Scoreboard:
localConfig.add_section("scoreboard")
except:
pass
# Load existing high scores
for i in range(1, 11):
try:
@@ -72,15 +72,15 @@ class Scoreboard:
'name': "Player",
'score': 0
})
# Sort high scores by score value in descending order
self.highScores.sort(key=lambda x: x['score'], reverse=True)
def _ensure_services(self):
"""Ensure PathService and ConfigService are properly initialized."""
# Get PathService and make sure it has a game name
pathService = PathService.get_instance()
# If no game name yet, try to get from pygame window title
if not pathService.gameName:
try:
@@ -89,55 +89,55 @@ class Scoreboard:
pathService.gameName = pygame.display.get_caption()[0]
except:
pass
# Initialize path service if we have a game name but no paths set up
if pathService.gameName and not pathService.gamePath:
pathService.initialize(pathService.gameName)
# Get ConfigService and connect to PathService
configService = ConfigService.get_instance()
if not hasattr(configService, 'pathService') or not configService.pathService:
if pathService.gameName:
configService.set_game_info(pathService.gameName, pathService)
# Ensure the game directory exists
if pathService.gamePath and not os.path.exists(pathService.gamePath):
try:
os.makedirs(pathService.gamePath)
except Exception as e:
print(f"Error creating game directory: {e}")
def get_score(self):
"""Get current score."""
return self.currentScore
def get_high_scores(self):
"""Get list of high scores."""
return self.highScores
def decrease_score(self, points=1):
"""Decrease the current score."""
self.currentScore -= int(points)
return self
def increase_score(self, points=1):
"""Increase the current score."""
self.currentScore += int(points)
return self
def set_score(self, score):
"""Set the current score to a specific value."""
self.currentScore = int(score)
return self
def reset_score(self):
"""Reset the current score to zero."""
self.currentScore = 0
return self
def check_high_score(self):
"""Check if current score qualifies as a high score.
Returns:
int: Position (1-10) if high score, None if not
"""
@@ -145,47 +145,47 @@ class Scoreboard:
if self.currentScore > entry['score']:
return i + 1
return None
def add_high_score(self, name=None):
"""Add current score to high scores if it qualifies.
Args:
name (str): Player name (if None, will prompt user)
Returns:
bool: True if score was added, False if not
"""
# Ensure services are properly set up
self._ensure_services()
position = self.check_high_score()
if position is None:
return False
# Get player name
if name is None:
# Import get_input here to avoid circular imports
from .input import get_input
name = get_input("New high score! Enter your name:", "Player")
if name is None: # User cancelled
name = get_input("New high score! Enter your name:", "")
if name is None or name.strip() == "": # User cancelled or entered empty
name = "Player"
# Insert new score at correct position
self.highScores.insert(position - 1, {
'name': name,
'score': self.currentScore
})
# Keep only top 10
self.highScores = self.highScores[:10]
# Save to config - try both methods for maximum compatibility
try:
# Try new method first
for i, entry in enumerate(self.highScores):
self.configService.localConfig.set("scoreboard", f"score_{i+1}", str(entry['score']))
self.configService.localConfig.set("scoreboard", f"name_{i+1}", entry['name'])
# Try to write with configService
try:
self.configService.write_local_config()
@@ -196,7 +196,7 @@ class Scoreboard:
localConfig.set("scoreboard", f"score_{i+1}", str(entry['score']))
localConfig.set("scoreboard", f"name_{i+1}", entry['name'])
write_config()
except Exception as e:
print(f"Error writing high scores: {e}")
# If all else fails, try direct old method
@@ -204,7 +204,7 @@ class Scoreboard:
localConfig.set("scoreboard", f"score_{i+1}", str(entry['score']))
localConfig.set("scoreboard", f"name_{i+1}", entry['name'])
write_config()
# Announce success
try:
self.speech.messagebox(f"Congratulations {name}! You got position {position} on the scoreboard!")
@@ -212,14 +212,14 @@ class Scoreboard:
# Fallback to global speak function
from .speech import speak
speak(f"Congratulations {name}! You got position {position} on the scoreboard!")
time.sleep(1)
return True
@staticmethod
def has_high_scores():
"""Check if the current game has any high scores.
Returns:
bool: True if at least one high score exists, False otherwise
"""
@@ -227,7 +227,7 @@ class Scoreboard:
# Get PathService to access game name
pathService = PathService.get_instance()
gameName = pathService.gameName
# If no game name, try to get from window title
if not gameName:
try:
@@ -237,31 +237,31 @@ class Scoreboard:
pathService.gameName = gameName
except:
pass
# Ensure path service is properly initialized
if gameName and not pathService.gamePath:
pathService.initialize(gameName)
# Get the config file path
configPath = os.path.join(pathService.gamePath, "config.ini")
# If config file doesn't exist, there are no scores
if not os.path.exists(configPath):
return False
# Ensure config service is properly connected to path service
configService = ConfigService.get_instance()
configService.set_game_info(gameName, pathService)
# Create scoreboard using the properly initialized services
board = Scoreboard(0, configService)
# Force a read of local config to ensure fresh data
configService.read_local_config()
# Get high scores
scores = board.get_high_scores()
# Check if any score is greater than zero
return any(score['score'] > 0 for score in scores)
except Exception as e:
@@ -271,7 +271,7 @@ class Scoreboard:
@staticmethod
def display_high_scores():
"""Display high scores for the current game.
Reads the high scores from Scoreboard class.
Shows the game name at the top followed by the available scores.
"""
@@ -279,7 +279,7 @@ class Scoreboard:
# Get PathService to access game name
pathService = PathService.get_instance()
gameName = pathService.gameName
# If no game name, try to get from window title
if not gameName:
try:
@@ -289,30 +289,30 @@ class Scoreboard:
pathService.gameName = gameName
except:
pass
# Ensure path service is properly initialized
if gameName and not pathService.gamePath:
pathService.initialize(gameName)
# Ensure config service is properly connected to path service
configService = ConfigService.get_instance()
configService.set_game_info(gameName, pathService)
# Create scoreboard using the properly initialized services
board = Scoreboard(0, configService)
# Force a read of local config to ensure fresh data
configService.read_local_config()
# Get high scores
scores = board.get_high_scores()
# Filter out scores with zero points
validScores = [score for score in scores if score['score'] > 0]
# Prepare the lines to display
lines = [f"High Scores for {gameName}:"]
# Add scores to the display list
if validScores:
for i, entry in enumerate(validScores, 1):
@@ -320,13 +320,13 @@ class Scoreboard:
lines.append(scoreStr)
else:
lines.append("No high scores yet.")
# Display the high scores
display_text(lines)
except Exception as e:
print(f"Error displaying high scores: {e}")
info = ["Could not display high scores."]
display_text(info)
# For backward compatibility with older code that might call displayHigh_scores
displayHigh_scores = display_high_scores
+200 -36
View File
@@ -6,10 +6,13 @@ Provides centralized services to replace global variables:
- ConfigService: Manages game configuration
- VolumeService: Handles volume settings
- PathService: Manages file paths
- SpeechHistoryService: Manages speech history for navigation
"""
import configparser
import os
import time
from collections import deque
from xdg import BaseDirectory
# For backward compatibility
@@ -17,37 +20,37 @@ from .config import gamePath, globalPath, write_config, read_config
class ConfigService:
"""Configuration management service."""
_instance = None
@classmethod
def get_instance(cls):
"""Get or create the singleton instance."""
if cls._instance is None:
cls._instance = ConfigService()
return cls._instance
def __init__(self):
"""Initialize configuration parsers."""
self.localConfig = configparser.ConfigParser()
self.globalConfig = configparser.ConfigParser()
self.gameTitle = None
self.pathService = None
def set_game_info(self, gameTitle, pathService):
"""Set game information and initialize configs.
Args:
gameTitle (str): Title of the game
pathService (PathService): Path service instance
"""
self.gameTitle = gameTitle
self.pathService = pathService
# Load existing configurations
self.read_local_config()
self.read_global_config()
def read_local_config(self):
"""Read local configuration from file."""
try:
@@ -66,7 +69,7 @@ class ConfigService:
self.localConfig.read_dict(globals().get('localConfig', {}))
except:
pass
def read_global_config(self):
"""Read global configuration from file."""
try:
@@ -85,7 +88,7 @@ class ConfigService:
self.globalConfig.read_dict(globals().get('globalConfig', {}))
except:
pass
def write_local_config(self):
"""Write local configuration to file."""
try:
@@ -104,7 +107,7 @@ class ConfigService:
write_config(False)
except Exception as e:
print(f"Warning: Failed to write local config: {e}")
def write_global_config(self):
"""Write global configuration to file."""
try:
@@ -127,37 +130,37 @@ class ConfigService:
class VolumeService:
"""Volume management service."""
_instance = None
@classmethod
def get_instance(cls):
"""Get or create the singleton instance."""
if cls._instance is None:
cls._instance = VolumeService()
return cls._instance
def __init__(self):
"""Initialize volume settings."""
self.bgmVolume = 0.75 # Default background music volume
self.sfxVolume = 1.0 # Default sound effects volume
self.masterVolume = 1.0 # Default master volume
def adjust_master_volume(self, change, pygameMixer=None):
"""Adjust the master volume for all sounds.
Args:
change (float): Amount to change volume by (positive or negative)
pygameMixer: Optional pygame.mixer module for real-time updates
"""
self.masterVolume = max(0.0, min(1.0, self.masterVolume + change))
# Update real-time audio if pygame mixer is provided
if pygameMixer:
# Update music volume
if pygameMixer.music.get_busy():
pygameMixer.music.set_volume(self.bgmVolume * self.masterVolume)
# Update all sound channels
for i in range(pygameMixer.get_num_channels()):
channel = pygameMixer.Channel(i)
@@ -170,29 +173,29 @@ class VolumeService:
# Stereo audio
left, right = currentVolume
channel.set_volume(left * self.masterVolume, right * self.masterVolume)
def adjust_bgm_volume(self, change, pygameMixer=None):
"""Adjust only the background music volume.
Args:
change (float): Amount to change volume by (positive or negative)
pygameMixer: Optional pygame.mixer module for real-time updates
"""
self.bgmVolume = max(0.0, min(1.0, self.bgmVolume + change))
# Update real-time audio if pygame mixer is provided
if pygameMixer and pygameMixer.music.get_busy():
pygameMixer.music.set_volume(self.bgmVolume * self.masterVolume)
def adjust_sfx_volume(self, change, pygameMixer=None):
"""Adjust volume for sound effects only.
Args:
change (float): Amount to change volume by (positive or negative)
pygameMixer: Optional pygame.mixer module for real-time updates
"""
self.sfxVolume = max(0.0, min(1.0, self.sfxVolume + change))
# Update real-time audio if pygame mixer is provided
if pygameMixer:
# Update all sound channels except reserved ones
@@ -208,18 +211,18 @@ class VolumeService:
left, right = currentVolume
channel.set_volume(left * self.sfxVolume * self.masterVolume,
right * self.sfxVolume * self.masterVolume)
def get_bgm_volume(self):
"""Get the current BGM volume with master adjustment.
Returns:
float: Current adjusted BGM volume
"""
return self.bgmVolume * self.masterVolume
def get_sfx_volume(self):
"""Get the current SFX volume with master adjustment.
Returns:
float: Current adjusted SFX volume
"""
@@ -228,32 +231,32 @@ class VolumeService:
class PathService:
"""Path management service."""
_instance = None
@classmethod
def get_instance(cls):
"""Get or create the singleton instance."""
if cls._instance is None:
cls._instance = PathService()
return cls._instance
def __init__(self):
"""Initialize path variables."""
self.globalPath = None
self.gamePath = None
self.gameName = None
# Try to initialize from global variables for backward compatibility
global gamePath, globalPath
if gamePath:
self.gamePath = gamePath
if globalPath:
self.globalPath = globalPath
def initialize(self, gameTitle):
"""Initialize paths for a game.
Args:
gameTitle (str): Title of the game
"""
@@ -261,14 +264,175 @@ class PathService:
self.globalPath = os.path.join(BaseDirectory.xdg_config_home, "storm-games")
self.gamePath = os.path.join(self.globalPath,
str.lower(str.replace(gameTitle, " ", "-")))
# Create game directory if it doesn't exist
if not os.path.exists(self.gamePath):
os.makedirs(self.gamePath)
# Update global variables for backward compatibility
global gamePath, globalPath
gamePath = self.gamePath
globalPath = self.globalPath
return self
class SpeechHistoryService:
"""Speech history management service for message navigation."""
_instance = None
@classmethod
def get_instance(cls):
"""Get or create the singleton instance."""
if cls._instance is None:
cls._instance = SpeechHistoryService()
return cls._instance
def __init__(self, max_size=10):
"""Initialize speech history.
Args:
max_size (int): Maximum number of messages to keep in history
"""
self.history = deque(maxlen=max_size)
self.current_index = -1
self.max_size = max_size
def add_message(self, text):
"""Add a message to the speech history.
Args:
text (str): The spoken message to add to history
"""
if not text or not text.strip():
return
# Add message with timestamp
message_entry = {
'text': text.strip(),
'timestamp': time.time()
}
self.history.append(message_entry)
# Reset current index when new message is added
self.current_index = -1
def get_current(self):
"""Get the current message in history.
Returns:
str or None: Current message text, or None if no history
"""
if not self.history:
return None
if self.current_index == -1:
# Return most recent message
return self.history[-1]['text']
else:
# Return message at current index
if 0 <= self.current_index < len(self.history):
return self.history[self.current_index]['text']
return None
def move_previous(self):
"""Navigate to the previous message in history.
Returns:
str or None: Previous message text, or None if at beginning
"""
if not self.history:
return None
if self.current_index == -1:
# Start from the most recent message
self.current_index = len(self.history) - 1
elif self.current_index > 0:
# Move backward in history
self.current_index -= 1
else:
# Already at the beginning, wrap to the end
self.current_index = len(self.history) - 1
return self.get_current()
def move_next(self):
"""Navigate to the next message in history.
Returns:
str or None: Next message text, or None if at end
"""
if not self.history:
return None
if self.current_index == -1:
# Already at most recent, wrap to beginning
self.current_index = 0
elif self.current_index < len(self.history) - 1:
# Move forward in history
self.current_index += 1
else:
# At the end, wrap to beginning
self.current_index = 0
return self.get_current()
def clear_history(self):
"""Clear all messages from history."""
self.history.clear()
self.current_index = -1
def set_max_size(self, max_size):
"""Change the maximum history size.
Args:
max_size (int): New maximum size for history buffer
"""
if max_size > 0:
self.max_size = max_size
# Create new deque with new max size, preserving recent messages
old_history = list(self.history)
self.history = deque(old_history[-max_size:], maxlen=max_size)
# Adjust current index if needed
if self.current_index >= len(self.history):
self.current_index = len(self.history) - 1
def get_history_size(self):
"""Get the current number of messages in history.
Returns:
int: Number of messages in history
"""
return len(self.history)
def is_at_first(self):
"""Check if currently at the first (oldest) message in history.
Returns:
bool: True if at the first message
"""
if not self.history:
return False
return self.current_index == 0
def is_at_last(self):
"""Check if currently at the last (newest) message in history.
Returns:
bool: True if at the last message or viewing most recent
"""
if not self.history:
return False
return self.current_index == -1 or self.current_index == len(self.history) - 1
def get_most_recent(self):
"""Get the most recent message (what F2 should always say).
Returns:
str or None: Most recent message text, or None if no history
"""
if not self.history:
return None
return self.history[-1]['text']
+446 -683
View File
File diff suppressed because it is too large Load Diff
+281 -28
View File
@@ -13,28 +13,31 @@ import textwrap
import time
from sys import exit
# Keep track of whether dialog instructions have been shown
dialogUsageInstructions = False
class Speech:
"""Handles text-to-speech functionality."""
_instance = None
@classmethod
def get_instance(cls):
"""Get or create the singleton instance."""
if cls._instance is None:
cls._instance = Speech()
return cls._instance
def __init__(self):
"""Initialize speech system with available provider."""
# Handle speech delays so we don't get stuttering
self.lastSpoken = {"text": None, "time": 0}
self.speechDelay = 250 # ms
# Try to initialize a speech provider
self.provider = None
self.providerName = None
# Try speechd first
try:
import speechd
@@ -44,7 +47,7 @@ class Speech:
return
except ImportError:
pass
# Try accessible_output2 next
try:
import accessible_output2.outputs.auto
@@ -54,44 +57,79 @@ class Speech:
return
except ImportError:
pass
# No speech providers found
print("No speech providers found.")
def speak(self, text, interrupt=True):
def speak(self, text, interrupt=True, priority="normal", add_to_history=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)
priority (str): Speech priority - "important", "normal", or "notification"
add_to_history (bool): Whether to add this message to speech history (default: True)
"""
if not self.provider:
return
currentTime = pygame.time.get_ticks()
# Check if this is the same text within the delay window
if (self.lastSpoken["text"] == text and
if (self.lastSpoken["text"] == text and
currentTime - self.lastSpoken["time"] < self.speechDelay):
return
# Update last spoken tracking
self.lastSpoken["text"] = text
self.lastSpoken["time"] = currentTime
# Proceed with speech
# Add to speech history (import here to avoid circular imports)
if add_to_history:
try:
from .services import SpeechHistoryService
history_service = SpeechHistoryService.get_instance()
history_service.add_message(text)
except ImportError:
pass
# Proceed with speech based on provider and priority
if self.providerName == "speechd":
if interrupt:
self.spd.cancel()
# Set priority for speechd
if priority == "important":
try:
import speechd
self.spd.set_priority(speechd.Priority.IMPORTANT)
except (ImportError, AttributeError):
pass
elif priority == "notification":
try:
import speechd
self.spd.set_priority(speechd.Priority.NOTIFICATION)
except (ImportError, AttributeError):
pass
else: # normal
try:
import speechd
self.spd.set_priority(speechd.Priority.TEXT)
except (ImportError, AttributeError):
pass
self.spd.say(text)
elif self.providerName == "accessible_output2":
self.ao2.speak(text, interrupt=interrupt)
# For accessible_output2, use interrupt for important messages
use_interrupt = interrupt or (priority == "important")
self.ao2.speak(text, interrupt=use_interrupt)
# Display the text on screen
screen = pygame.display.get_surface()
if not screen:
return
font = pygame.font.Font(None, 36)
# Wrap the text
maxWidth = screen.get_width() - 40 # Leave a 20-pixel margin on each side
@@ -109,7 +147,7 @@ class Speech:
screen.blit(surface, textRect)
currentY += surface.get_height()
pygame.display.flip()
def close(self):
"""Clean up speech resources."""
if self.providerName == "speechd":
@@ -118,27 +156,45 @@ class Speech:
# Global instance for backward compatibility
_speechInstance = None
def speak(text, interrupt=True):
def speak(text, interrupt=True, priority="normal", add_to_history=True):
"""Speak text using the global speech instance.
Args:
text (str): Text to speak and display
interrupt (bool): Whether to interrupt current speech (default: True)
priority (str): Speech priority - "important", "normal", or "notification"
add_to_history (bool): Whether to add this message to speech history (default: True)
"""
global _speechInstance
if _speechInstance is None:
_speechInstance = Speech.get_instance()
_speechInstance.speak(text, interrupt)
_speechInstance.speak(text, interrupt, priority, add_to_history)
def messagebox(text):
"""Display a simple message box with text.
Shows a message that can be repeated until the user chooses to continue.
def messagebox(text, sounds=None):
"""Enhanced messagebox with dialog support.
Args:
text (str): Message to display
text (str or dict): Simple string message or dialog configuration dict
sounds (Sound object, optional): Sound system for playing dialog audio
"""
speech = Speech.get_instance()
# Handle simple string (backward compatibility)
if isinstance(text, str):
_show_simple_message(speech, text)
return
# Handle dialog format
if isinstance(text, dict) and "entries" in text:
_show_dialog_sequence(speech, text, sounds)
return
# Fallback to simple message if format not recognized
_show_simple_message(speech, str(text))
def _show_simple_message(speech, text):
"""Show a simple text message (original messagebox behavior)."""
speech.speak(text + "\nPress any key to repeat or enter to continue.")
while True:
event = pygame.event.wait()
@@ -147,3 +203,200 @@ def messagebox(text):
speech.speak(" ")
return
speech.speak(text + "\nPress any key to repeat or enter to continue.")
def _show_dialog_sequence(speech, dialog_config, sounds):
"""Show a dialog sequence with character speech and optional sounds.
Args:
speech: Speech instance for text-to-speech
dialog_config (dict): Dialog configuration with entries list and optional settings
sounds: Sound system for playing audio files
"""
entries = dialog_config.get("entries", [])
allow_skip = dialog_config.get("allow_skip", False)
dialog_sound = dialog_config.get("sound", None)
if not entries:
return
entry_index = 0
while entry_index < len(entries):
entry = entries[entry_index]
# Play sound before showing dialog
_play_dialog_sound(entry, dialog_config, sounds)
# Format and show the dialog text
formatted_text = _format_dialog_entry(entry)
if not formatted_text:
entry_index += 1
continue
# Show dialog with appropriate controls (only on first dialog of the game)
global dialogUsageInstructions
if not dialogUsageInstructions and entry_index == 0:
if allow_skip:
control_text = "\nPress any key to repeat, enter for next, or escape to skip all."
else:
control_text = "\nPress any key to repeat or enter for next."
dialogUsageInstructions = True
else:
control_text = "" # No instructions after first dialog
speech.speak(formatted_text + control_text)
# Handle user input
while True:
event = pygame.event.wait()
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
if allow_skip:
speech.speak(" ")
return # Skip entire dialog sequence
else:
# Escape acts like enter if skip not allowed
speech.speak(" ")
entry_index += 1
break
elif event.key == pygame.K_RETURN:
speech.speak(" ")
entry_index += 1
break
else:
# Repeat current entry (no instructions when repeating)
speech.speak(formatted_text)
def _format_dialog_entry(entry):
"""Format a dialog entry for display.
Args:
entry (dict): Dialog entry with text, optional speaker, and optional narrative flag
Returns:
str: Formatted text for speech
"""
text = entry.get("text", "")
speaker = entry.get("speaker", None)
is_narrative = entry.get("narrative", False)
if not text:
return ""
if is_narrative:
# Narrative text - no speaker name
return text
elif speaker:
# Character dialog - include speaker name
return f"{speaker}: \"{text}\""
else:
# Plain text - no special formatting
return text
def _play_dialog_sound(entry, dialog_config, sounds):
"""Play appropriate sound for a dialog entry and wait for it to complete.
Args:
entry (dict): Dialog entry that may have a sound
dialog_config (dict): Dialog configuration that may have a default sound
sounds: Sound system (either Sound class instance or dictionary of sounds)
"""
if not sounds:
return
sound_to_play = None
# Determine which sound to play (priority order)
if entry.get("sound"):
# Entry-specific sound (highest priority)
sound_to_play = entry["sound"]
elif dialog_config.get("sound"):
# Dialog-specific sound (medium priority)
sound_to_play = dialog_config["sound"]
else:
# Default dialogue.ogg (lowest priority)
sound_to_play = "dialogue" # Will look for dialogue.ogg
if sound_to_play:
try:
# Handle both Sound class instances and sound dictionaries
if hasattr(sounds, 'sounds') and sound_to_play in sounds.sounds:
# Sound class instance (like from libstormgames Sound class)
sound_obj = sounds.sounds[sound_to_play]
from .sound import get_available_channel
channel = get_available_channel()
channel.play(sound_obj)
sound_duration = sound_obj.get_length()
if sound_duration > 0:
pygame.time.wait(int(sound_duration * 1000))
pygame.event.clear() # Clear all events queued during sound playback
elif isinstance(sounds, dict) and sound_to_play in sounds:
# Dictionary of pygame sound objects (like from initialize_gui)
sound_obj = sounds[sound_to_play]
from .sound import get_available_channel
channel = get_available_channel()
channel.play(sound_obj)
sound_duration = sound_obj.get_length()
if sound_duration > 0:
pygame.time.wait(int(sound_duration * 1000))
pygame.event.clear() # Clear all events queued during sound playback
elif hasattr(sounds, 'play'):
# Try using a play method if available
sounds.play(sound_to_play)
pygame.time.wait(500) # Default delay if can't get duration
pygame.event.pump() # Clear any events queued during sound playback
except Exception:
# Sound missing or error - continue silently without crashing
pass
def speak_previous():
"""Navigate to and speak the previous message in speech history."""
try:
from .services import SpeechHistoryService
history_service = SpeechHistoryService.get_instance()
message = history_service.move_previous()
if message:
# Add position indicator
prefix = ""
if history_service.is_at_first():
prefix = "First: "
speak(prefix + message, interrupt=True, priority="important", add_to_history=False)
else:
speak("No previous messages", interrupt=True, priority="important", add_to_history=False)
except ImportError:
speak("Speech history not available", interrupt=True, priority="important", add_to_history=False)
def speak_current():
"""Repeat the most recent message in speech history (F2 always speaks last message)."""
try:
from .services import SpeechHistoryService
history_service = SpeechHistoryService.get_instance()
message = history_service.get_most_recent()
if message:
speak(message, interrupt=True, priority="important", add_to_history=False)
else:
speak("No messages in history", interrupt=True, priority="important", add_to_history=False)
except ImportError:
speak("Speech history not available", interrupt=True, priority="important", add_to_history=False)
def speak_next():
"""Navigate to and speak the next message in speech history."""
try:
from .services import SpeechHistoryService
history_service = SpeechHistoryService.get_instance()
message = history_service.move_next()
if message:
# Add position indicator
prefix = ""
if history_service.is_at_last():
prefix = "Last: "
speak(prefix + message, interrupt=True, priority="important", add_to_history=False)
else:
speak("No next messages", interrupt=True, priority="important", add_to_history=False)
except ImportError:
speak("Speech history not available", interrupt=True, priority="important", add_to_history=False)
+174 -77
View File
@@ -26,137 +26,234 @@ from .scoreboard import Scoreboard
class Game:
"""Central class to manage all game systems."""
def __init__(self, title):
"""Initialize a new game.
Args:
title (str): Title of the game
"""
self.title = title
# Initialize services
self.pathService = PathService.get_instance().initialize(title)
self.configService = ConfigService.get_instance()
self.configService.set_game_info(title, self.pathService)
self.volumeService = VolumeService.get_instance()
# Initialize game components (lazy loaded)
self._speech = None
self._sound = None
self._scoreboard = None
# Display text instructions flag
self.displayTextUsageInstructions = False
@property
def speech(self):
"""Get the speech system (lazy loaded).
Returns:
Speech: Speech system instance
"""
if not self._speech:
self._speech = Speech.get_instance()
return self._speech
@property
def sound(self):
"""Get the sound system (lazy loaded).
Returns:
Sound: Sound system instance
"""
if not self._sound:
self._sound = Sound("sounds/", self.volumeService)
return self._sound
@property
def scoreboard(self):
"""Get the scoreboard (lazy loaded).
Returns:
Scoreboard: Scoreboard instance
"""
if not self._scoreboard:
self._scoreboard = Scoreboard(self.configService)
return self._scoreboard
def initialize(self):
"""Initialize the game GUI and sound system.
Returns:
Game: Self for method chaining
"""
# Set process title
setproctitle(str.lower(str.replace(self.title, " ", "")))
# Seed the random generator
random.seed()
# Initialize pygame
pygame.init()
pygame.display.set_mode((800, 600))
pygame.display.set_caption(self.title)
# Set up audio system
pygame.mixer.pre_init(44100, -16, 2, 1024)
pygame.mixer.init()
pygame.mixer.set_num_channels(32)
pygame.mixer.set_reserved(0) # Reserve channel for cut scenes
# Enable key repeat for volume controls
pygame.key.set_repeat(500, 100)
# Load sound effects
self.sound
# Play intro sound if available
# Play intro sound if available, optionally with visual logo
if 'game-intro' in self.sound.sounds:
self.sound.cut_scene('game-intro')
self._show_logo_with_audio('game-intro')
return self
def speak(self, text, interrupt=True):
"""Speak text using the speech system.
def _show_logo_with_audio(self, audioKey):
"""Show visual logo while playing audio intro.
Args:
audioKey (str): Key of the audio to play
"""
# Look for logo image files in common formats
logoFiles = ['logo.png', 'logo.jpg', 'logo.jpeg', 'logo.gif', 'logo.bmp']
logoImage = None
for logoFile in logoFiles:
if os.path.exists(logoFile):
try:
logoImage = pygame.image.load(logoFile)
break
except pygame.error:
continue
if logoImage:
# Display logo while audio plays
screen = pygame.display.get_surface()
screenRect = screen.get_rect()
logoRect = logoImage.get_rect(center=screenRect.center)
# Clear screen to black
screen.fill((0, 0, 0))
screen.blit(logoImage, logoRect)
pygame.display.flip()
# Play audio and wait for it to finish
self.sound.cut_scene(audioKey)
# Clear screen after audio finishes
screen.fill((0, 0, 0))
pygame.display.flip()
else:
# No logo image found, just play audio
self.sound.cut_scene(audioKey)
def speak(self, text, interrupt=True, priority="normal", add_to_history=True):
"""Speak text using the speech system.
Args:
text (str): Text to speak
interrupt (bool): Whether to interrupt current speech
priority (str): Speech priority - "important", "normal", or "notification"
add_to_history (bool): Whether to add this message to speech history (default: True)
Returns:
Game: Self for method chaining
"""
self.speech.speak(text, interrupt)
self.speech.speak(text, interrupt, priority, add_to_history)
return self
def speak_previous(self):
"""Navigate to and speak the previous message in speech history.
Returns:
Game: Self for method chaining
"""
from .speech import speak_previous
speak_previous()
return self
def speak_current(self):
"""Repeat the current message in speech history.
Returns:
Game: Self for method chaining
"""
from .speech import speak_current
speak_current()
return self
def speak_next(self):
"""Navigate to and speak the next message in speech history.
Returns:
Game: Self for method chaining
"""
from .speech import speak_next
speak_next()
return self
def setup_speech_history_keys(self, previous_key=None, current_key=None, next_key=None):
"""Set up convenient key bindings for speech history navigation.
Args:
previous_key (int, optional): Key code for previous message (default: F1)
current_key (int, optional): Key code for current message (default: F2)
next_key (int, optional): Key code for next message (default: F3)
Returns:
dict: Dictionary mapping key codes to speech history functions
"""
# Set default keys if not provided
if previous_key is None:
previous_key = pygame.K_F1
if current_key is None:
current_key = pygame.K_F2
if next_key is None:
next_key = pygame.K_F3
# Return a dictionary that games can use in their event loops
return {
previous_key: self.speak_previous,
current_key: self.speak_current,
next_key: self.speak_next
}
def play_bgm(self, musicFile):
"""Play background music.
Args:
musicFile (str): Path to music file
Returns:
Game: Self for method chaining
"""
self.sound.play_bgm(musicFile)
return self
def display_text(self, textLines):
"""Display text with navigation controls.
Args:
textLines (list): List of text lines
Returns:
Game: Self for method chaining
"""
# Store original text with blank lines for copying
originalText = textLines.copy()
# Create navigation text by filtering out blank lines
navText = [line for line in textLines if line.strip()]
# Add instructions at the start on the first display
if not self.displayTextUsageInstructions:
instructions = ("Press space to read the whole text. Use up and down arrows to navigate "
@@ -164,20 +261,20 @@ class Game:
"or t to copy the entire text. Press enter or escape when you are done reading.")
navText.insert(0, instructions)
self.displayTextUsageInstructions = True
# Add end marker
navText.append("End of text.")
currentIndex = 0
self.speech.speak(navText[currentIndex])
while True:
event = pygame.event.wait()
if event.type == pygame.KEYDOWN:
# Check for Alt modifier
mods = pygame.key.get_mods()
altPressed = mods & pygame.KMOD_ALT
# Volume controls (require Alt)
if altPressed:
if event.key == pygame.K_PAGEUP:
@@ -195,19 +292,19 @@ class Game:
else:
if event.key in (pygame.K_ESCAPE, pygame.K_RETURN):
return self
if event.key in [pygame.K_DOWN, pygame.K_s] and currentIndex < len(navText) - 1:
currentIndex += 1
self.speech.speak(navText[currentIndex])
if event.key in [pygame.K_UP, pygame.K_w] and currentIndex > 0:
currentIndex -= 1
self.speech.speak(navText[currentIndex])
if event.key == pygame.K_SPACE:
# Join with newlines to preserve spacing in speech
self.speech.speak('\n'.join(originalText[1:-1]))
if event.key == pygame.K_c:
try:
import pyperclip
@@ -215,7 +312,7 @@ class Game:
self.speech.speak("Copied " + navText[currentIndex] + " to the clipboard.")
except:
self.speech.speak("Failed to copy the text to the clipboard.")
if event.key == pygame.K_t:
try:
import pyperclip
@@ -224,10 +321,10 @@ class Game:
self.speech.speak("Copied entire message to the clipboard.")
except:
self.speech.speak("Failed to copy the text to the clipboard.")
pygame.event.clear()
time.sleep(0.001)
def exit(self):
"""Clean up and exit the game."""
if self._speech and self.speech.providerName == "speechd":
@@ -241,12 +338,12 @@ class Game:
def check_for_updates(currentVersion, gameName, url):
"""Check for game updates.
Args:
currentVersion (str): Current version string (e.g. "1.0.0")
gameName (str): Name of the game
url (str): URL to check for updates
Returns:
dict: Update information or None if no update available
"""
@@ -266,10 +363,10 @@ def check_for_updates(currentVersion, gameName, url):
def get_version_tuple(versionStr):
"""Convert version string to comparable tuple.
Args:
versionStr (str): Version string (e.g. "1.0.0")
Returns:
tuple: Version as tuple of integers
"""
@@ -277,11 +374,11 @@ def get_version_tuple(versionStr):
def check_compatibility(requiredVersion, currentVersion):
"""Check if current version meets minimum required version.
Args:
requiredVersion (str): Minimum required version string
currentVersion (str): Current version string
Returns:
bool: True if compatible, False otherwise
"""
@@ -291,10 +388,10 @@ def check_compatibility(requiredVersion, currentVersion):
def sanitize_filename(filename):
"""Sanitize a filename to be safe for all operating systems.
Args:
filename (str): Original filename
Returns:
str: Sanitized filename
"""
@@ -309,12 +406,12 @@ def sanitize_filename(filename):
def lerp(start, end, factor):
"""Linear interpolation between two values.
Args:
start (float): Start value
end (float): End value
factor (float): Interpolation factor (0.0-1.0)
Returns:
float: Interpolated value
"""
@@ -322,12 +419,12 @@ def lerp(start, end, factor):
def smooth_step(edge0, edge1, x):
"""Hermite interpolation between two values.
Args:
edge0 (float): Start edge
edge1 (float): End edge
x (float): Value to interpolate
Returns:
float: Interpolated value with smooth step
"""
@@ -338,13 +435,13 @@ def smooth_step(edge0, edge1, x):
def distance_2d(x1, y1, x2, y2):
"""Calculate Euclidean distance between two 2D points.
Args:
x1 (float): X coordinate of first point
y1 (float): Y coordinate of first point
x2 (float): X coordinate of second point
y2 (float): Y coordinate of second point
Returns:
float: Distance between points
"""
@@ -352,17 +449,17 @@ def distance_2d(x1, y1, x2, y2):
def generate_tone(frequency, duration=0.1, sampleRate=44100, volume=0.2):
"""Generate a tone at the specified frequency.
Args:
frequency (float): Frequency in Hz
duration (float): Duration in seconds (default: 0.1)
sampleRate (int): Sample rate in Hz (default: 44100)
volume (float): Volume from 0.0 to 1.0 (default: 0.2)
Returns:
pygame.mixer.Sound: Sound object with the generated tone
"""
t = np.linspace(0, duration, int(sampleRate * duration), False)
tone = np.sin(2 * np.pi * frequency * t)
stereoTone = np.vstack((tone, tone)).T # Create a 2D array for stereo
@@ -372,17 +469,17 @@ def generate_tone(frequency, duration=0.1, sampleRate=44100, volume=0.2):
def x_powerbar():
"""Sound based horizontal power bar
Returns:
int: Selected position between -50 and 50
"""
clock = pygame.time.Clock()
screen = pygame.display.get_surface()
position = -50 # Start from the leftmost position
direction = 1 # Move right initially
barHeight = 20
while True:
frequency = 440 # A4 note
leftVolume = (50 - position) / 100
@@ -390,7 +487,7 @@ def x_powerbar():
tone = generate_tone(frequency)
channel = tone.play()
channel.set_volume(leftVolume, rightVolume)
# Visual representation
screen.fill((0, 0, 0))
barWidth = screen.get_width() - 40 # Leave 20px margin on each side
@@ -398,13 +495,13 @@ def x_powerbar():
markerPos = int(20 + (position + 50) / 100 * barWidth)
pygame.draw.rect(screen, (255, 0, 0), (markerPos - 5, screen.get_height() // 2 - barHeight, 10, barHeight * 2))
pygame.display.flip()
for event in pygame.event.get():
check_for_exit()
if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
channel.stop()
return position # This will return a value between -50 and 50
position += direction
if position > 50:
position = 50
@@ -412,27 +509,27 @@ def x_powerbar():
elif position < -50:
position = -50
direction = 1
clock.tick(40) # Speed of bar
def y_powerbar():
"""Sound based vertical power bar
Returns:
int: Selected power level between 0 and 100
"""
clock = pygame.time.Clock()
screen = pygame.display.get_surface()
power = 0
direction = 1 # 1 for increasing, -1 for decreasing
barWidth = 20
while True:
frequency = 220 + (power * 5) # Adjust these values to change the pitch range
tone = generate_tone(frequency)
channel = tone.play()
# Visual representation
screen.fill((0, 0, 0))
barHeight = screen.get_height() - 40 # Leave 20px margin on top and bottom
@@ -440,15 +537,15 @@ def y_powerbar():
markerPos = int(20 + (100 - power) / 100 * barHeight)
pygame.draw.rect(screen, (255, 0, 0), (screen.get_width() // 2 - barWidth, markerPos - 5, barWidth * 2, 10))
pygame.display.flip()
for event in pygame.event.get():
check_for_exit()
if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
channel.stop()
return power
power += direction
if power >= 100 or power <= 0:
direction *= -1 # Reverse direction at limits
clock.tick(40)