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
+10 -5
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,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',
@@ -113,7 +117,7 @@ __all__ = [
'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',
@@ -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
+49 -3
View File
@@ -88,13 +88,55 @@ def initialize_gui(gameTitle):
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.
@@ -136,6 +178,9 @@ def display_text(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:
@@ -188,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)
+186 -10
View File
@@ -10,11 +10,19 @@ 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:")
@@ -23,15 +31,179 @@ def get_input(prompt="Enter text:", text=""):
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."""
@@ -52,6 +224,10 @@ def pause_game():
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()
except:
+106 -6
View File
@@ -25,6 +25,94 @@ 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.
@@ -96,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:
@@ -249,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):
@@ -264,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
@@ -284,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
@@ -334,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
@@ -442,9 +536,9 @@ 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):
@@ -485,6 +579,12 @@ def exit_game(fade=0):
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()
-1
View File
@@ -5,4 +5,3 @@ pyxdg>=0.27
setproctitle>=1.2.0
numpy>=1.19.0
accessible-output2>=0.14
wxpython
+2 -2
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
+164
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']
+407 -644
View File
File diff suppressed because it is too large Load Diff
+263 -10
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
@@ -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)
+101 -4
View File
@@ -111,25 +111,122 @@ class Game:
# 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):
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.