Compare commits

..

10 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
9 changed files with 547 additions and 37 deletions

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
@@ -78,7 +79,7 @@ __version__ = '2.0.0'
# Make all symbols available at the package level
__all__ = [
# Services
'ConfigService', 'VolumeService', 'PathService',
'ConfigService', 'VolumeService', 'PathService', 'SpeechHistoryService',
# Sound
'Sound',
@@ -102,6 +103,9 @@ __all__ = [
'messagebox',
'speak',
'Speech',
'speak_previous',
'speak_current',
'speak_next',
# Scoreboard
'Scoreboard',
@@ -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

View File

@@ -177,6 +177,9 @@ def display_text(text):
currentIndex = 0
speech.speak(navText[currentIndex])
# Clear any pending events
pygame.event.clear()
while True:
event = pygame.event.wait()
@@ -230,5 +233,6 @@ def display_text(text):
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)

View File

@@ -43,6 +43,9 @@ def get_input(prompt="Enter text:", text=""):
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()
@@ -199,6 +202,8 @@ def get_input(prompt="Enter text:", text=""):
# 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."""
@@ -218,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()

14
menu.py
View File

@@ -184,6 +184,7 @@ def game_menu(sounds, playCallback=None, *customOptions):
pygame.mixer.stop()
currentIndex = 0
lastSpoken = -1 # Track last spoken index
pygame.event.clear()
while loop:
if currentIndex != lastSpoken:
@@ -337,7 +338,8 @@ def game_menu(sounds, playCallback=None, *customOptions):
pass
return allOptions[currentIndex]
event = pygame.event.clear()
pygame.event.pump()
pygame.event.clear()
time.sleep(0.001)
def learn_sounds(sounds):
@@ -352,7 +354,7 @@ def learn_sounds(sounds):
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
@@ -372,8 +374,11 @@ def learn_sounds(sounds):
# 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
@@ -422,6 +427,7 @@ def learn_sounds(sounds):
# Flag to track when to exit the loop
returnToMenu = False
pygame.event.clear()
while not returnToMenu:
# Announce current sound

View File

@@ -166,8 +166,8 @@ class Scoreboard:
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

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
@@ -272,3 +275,164 @@ class PathService:
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']

View File

@@ -73,6 +73,8 @@ class Sound:
if event.type == pygame.KEYDOWN and event.key in [pygame.K_ESCAPE, pygame.K_RETURN, pygame.K_SPACE]:
pygame.mixer.stop()
return None
pygame.event.pump()
pygame.event.clear()
pygame.time.delay(10)
return None
@@ -118,24 +120,25 @@ class Sound:
pygame.event.clear()
pygame.mixer.stop()
# Play the sound
channel = self.sounds[soundName].play(-1 if loop else 0)
if not channel:
return None
# Apply appropriate volume settings
sfx_volume = self.volumeService.get_sfx_volume()
# Handle positional audio if positions are provided
# Handle positional audio if positions are provided - check range BEFORE starting sound
if playerPos is not None and objPos is not None:
# Calculate stereo panning
left_vol, right_vol = self._get_stereo_panning(playerPos, objPos, centerDistance)
# Don't play if out of range
if left_vol == 0 and right_vol == 0:
channel.stop()
return None
# Play the sound
channel = self.sounds[soundName].play(-1 if loop else 0)
if not channel:
return None
# Apply volume settings
if playerPos is not None and objPos is not None:
# Apply positional volume adjustments
channel.set_volume(volume * left_vol * sfx_volume, volume * right_vol * sfx_volume)
else:
@@ -335,6 +338,8 @@ def _play_cutscene(sound, sounds=None):
if event.type == pygame.KEYDOWN and event.key in [pygame.K_ESCAPE, pygame.K_RETURN, pygame.K_SPACE]:
pygame.mixer.stop()
return None
pygame.event.pump()
pygame.event.clear()
pygame.time.delay(10)
return None
@@ -347,6 +352,10 @@ def _find_matching_sound(soundPattern, sounds):
keys = [k for k in sounds.keys() if re.match("^" + soundPattern + ".*", k)]
return random.choice(keys) if keys else None
def get_available_channel():
"""Get an available channel for playing sounds."""
return pygame.mixer.find_channel()
# Global functions for backward compatibility
def play_bgm(musicFile):
"""Play background music with proper volume settings."""
@@ -421,20 +430,22 @@ def play_sound(sound_or_name, volume=1.0, loop=False, playerPos=None, objPos=Non
# Case 4: Sound name with dictionary
elif isinstance(sounds, dict) and isinstance(sound_or_name, str) and sound_or_name in sounds:
# Apply volume settings
sfx_vol = volumeService.get_sfx_volume()
# Handle positional audio - check range BEFORE starting sound
if playerPos is not None and objPos is not None:
left_vol, right_vol = _get_stereo_panning(playerPos, objPos, centerDistance)
if left_vol == 0 and right_vol == 0:
return None # Don't start sound if out of range
# Play the sound
channel = sounds[sound_or_name].play(-1 if loop else 0)
if not channel:
return None
# Apply volume settings
sfx_vol = volumeService.get_sfx_volume()
# Handle positional audio
if playerPos is not None and objPos is not None:
left_vol, right_vol = _get_stereo_panning(playerPos, objPos, centerDistance)
if left_vol == 0 and right_vol == 0:
channel.stop()
return None
channel.set_volume(volume * left_vol * sfx_vol, volume * right_vol * sfx_vol)
else:
channel.set_volume(volume * sfx_vol)

275
speech.py
View File

@@ -13,6 +13,9 @@ 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."""
@@ -58,12 +61,14 @@ class Speech:
# 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
@@ -71,7 +76,7 @@ class Speech:
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
@@ -79,13 +84,46 @@ class Speech:
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()
@@ -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)

View File

@@ -156,19 +156,77 @@ class Game:
# No logo image found, just play audio
self.sound.cut_scene(audioKey)
def speak(self, text, interrupt=True):
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.