Compare commits

..

73 Commits

Author SHA1 Message Date
a17a4c6f15 Code cleanup and sound consolidation. 2025-03-22 17:34:35 -04:00
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
3b01662d98 Directory names were not speaking. Hopefully fixed it. 2025-03-16 17:45:49 -04:00
5e926fa7eb More updates to learn_sounds. I think I have a good system for multiple sound directories now. 2025-03-16 17:34:22 -04:00
e272da1177 learn_sounds needed updating too. Hopefully this attempt works. 2025-03-16 17:13:02 -04:00
1bb9e18ea2 Sounds can now load from subdirectories. 2025-03-16 17:02:18 -04:00
619cb5508a Learn sound now tells you which item in the list you are on. 2025-03-16 01:20:41 -04:00
2c34f31a82 Fixed music not pausing when play is selected. 2025-03-15 21:28:06 -04:00
a9c2c4332d Fixed a typo in utils.py. 2025-03-15 21:10:20 -04:00
fedb09be94 Updated the readme. 2025-03-15 20:33:57 -04:00
27765e62bc Finally, a working scoreboard! 2025-03-15 19:52:16 -04:00
23aea6badf More fixes for scoreboard. 2025-03-15 19:06:29 -04:00
af38d5af76 Fixed another error in scoreboard. 2025-03-15 18:41:35 -04:00
4d0436c5a9 Fixed typo in menu.py. 2025-03-15 18:34:30 -04:00
f51bd6dee4 Fixed errors in __init__ 2025-03-15 18:31:43 -04:00
3b2bcd928d Fixed errors in scoreboard. 2025-03-15 18:29:55 -04:00
91f39aad88 Oops, I accidently committed my scrp file for menu instead of the actual file. 2025-03-15 18:22:21 -04:00
1dc0ac2a7f Moved the high score stuff to the scoreboard because it makes more sense for it to be there even though it is used in the menu. 2025-03-15 18:18:22 -04:00
3f8385599b Work on fixing the path read for scores. 2025-03-15 17:28:32 -04:00
468c663cc1 Integrate scoreboard with game_menu() 2025-03-15 17:07:51 -04:00
fe772cbb1e Consolidated common menu options for game_menu. Now the simplest call is choice = game_menu(). 2025-03-15 04:22:44 -04:00
2c101d1778 Fixed credits file not displaying. 2025-03-15 03:13:52 -04:00
8f81323668 Attempt to make sure exit works even if there's a problem. 2025-03-14 23:27:17 -04:00
be6dfdf53a Attempt to fix traceback on game exit with some older games. 2025-03-14 23:10:14 -04:00
2ad22ff1ae Added back the power bars that somehow got lost in one of the previous updates. 2025-03-14 21:41:00 -04:00
aba87e87ad Huge refactor of the libstormgames library. It is hopefully mostly backwards compatible. Still lots of testing to do, and probably some fixes needed, but this is a good start. 2025-03-14 18:14:42 -04:00
df7945e3b6 Try to fix the problem with cut scenes sometimes not playing in the correct position and at the correct volume. 2025-03-05 19:39:08 -05:00
7902dfacd1 Pressing enter interrupts messagebox messages. 2025-02-26 03:09:43 -05:00
10b46fa168 Only show display_text message once per session. 2025-02-26 02:55:52 -05:00
e7d5b03e55 Adjust volume a bit for directional play. 2025-02-24 14:51:01 -05:00
173220d167 New function for directional audio that needs to be good volume no matter position but indicate direction. 2025-02-24 14:32:45 -05:00
2f791da5b7 One more addition to pause. 2025-02-16 17:23:19 -05:00
9997b684ca Infinite loops are bad. 2025-02-16 17:19:47 -05:00
e7caff3d0f Fixed a bug with pause game. 2025-02-16 17:04:59 -05:00
db6c34f714 Improved pause_game. 2025-02-16 17:02:33 -05:00
3862b36d56 Pause game function added. 2025-02-16 16:52:29 -05:00
8bfe968b4b Added a small delay so the congratulations message for scoreboard can speak. 2025-02-15 12:32:08 -05:00
e2c69e3af7 Fixed a problem with an extra line being added at the beginning of copied text. 2025-02-15 02:55:01 -05:00
da17b71c28 Updated display_text so that it will hopefully skip blank lines when reading with arrow keys. 2025-02-15 02:39:14 -05:00
1cb57391d8 Updated requirements.txt 2025-02-15 01:50:17 -05:00
7f62e6ccca Forgot to specify global when checking the speech delay. 2025-02-15 00:06:23 -05:00
943e2acf53 Try to keep speech form stuttering with status keys. 2025-02-14 23:58:26 -05:00
c242fc6832 Make sure scores are int only. 2025-02-14 21:37:20 -05:00
6d2c6e04d8 Fixed a bug with score sorting. 2025-02-14 20:46:38 -05:00
97431c0c74 Updated the congrats message for the scoreboard. 2025-02-14 19:41:10 -05:00
4a15f951f0 Rewrote the scoreboard class. Added ability to enter name for scoreboard. 2025-02-14 16:50:04 -05:00
5a791510ea Rewrote the scoreboard class. 2025-02-14 16:45:28 -05:00
7cbbc64d27 Fixed a bug in learn sounds. Forgot to switch the if statement from == to in when converting to the list to also allow use of s and w for menu navigation. 2025-02-08 19:51:52 -05:00
b479811a98 Binding for volume keys changed to alt+pageup/down, alt+home/end, etc. 2025-02-08 19:21:14 -05:00
d5d737d0c0 Make intro sound skipable. 2025-02-08 16:13:46 -05:00
dd246db5be Added w and s as alternate navigation keys for libstormgames menus. 2025-02-07 02:12:34 -05:00
21216f361a removed duplicate function. 2025-02-06 12:37:35 -05:00
68e72f5d81 Play sound function added. Code restructure. Added volume controls for master volume, sounds only, and music only. 2025-02-04 05:12:31 -05:00
80fe2caff3 Oops, accidentally removed the obj_update function. 2025-02-03 23:59:46 -05:00
2df86c9c76 Add the ability for obj_play to play a sound once. 2025-02-03 23:49:08 -05:00
5fa90f9e84 Updated play_falling_random. 2025-02-03 21:58:02 -05:00
658709ebce Random play functions for positional audio and positional falling audio. 2025-02-02 17:04:04 -05:00
d5c79c0770 Hopefully last fix to messagebox. This turned out to be harder than I originally thought. 2025-02-01 15:14:29 -05:00
24f9a126d4 I think I was over complicating it. 2025-02-01 15:11:22 -05:00
c316d4e570 I think I actually got it this time. 2025-02-01 15:05:13 -05:00
e66655d75f Another attempt to keep messagebox from double speaking. 2025-02-01 14:59:46 -05:00
c5406d5089 Attempt to keep messagebox from double speaking. 2025-02-01 14:53:55 -05:00
b5b472eebe Added simple message box for spoken text that might need to be repeated. 2025-02-01 14:41:52 -05:00
9f03de15b8 Add a text entry using wx. 2024-08-01 21:10:27 -04:00
428a48678d Power bars are now visual as well as audio. 2024-07-22 22:20:15 -04:00
9a6d6374f9 Added text along with the speak command. Updated window size. 2024-07-22 16:08:42 -04:00
df386cbbd9 Improved check_for_exit function. 2024-07-16 14:22:36 -04:00
38522aee78 First pass at creating requirements files. 2024-07-15 19:52:02 -04:00
0e9c52f5e1 A few fixes for x-powerbar, more accurately exiting when exit keys are pressed, etc. 2024-07-14 03:51:35 -04:00
fabf48ff42 Powerbars added and working. 2024-07-13 03:03:32 -04:00
0c73e98876 Improved sound while walking on left/right only. 2024-07-05 17:24:23 -04:00
0ef11785ec Revert "A new try at sound panning with walking."
This reverts commit 58ab5aa854.
2024-07-05 17:04:12 -04:00
58ab5aa854 A new try at sound panning with walking. 2024-06-20 02:03:06 -04:00
13 changed files with 3655 additions and 372 deletions

831
README.md
View File

@ -1,8 +1,829 @@
# libstormgames
Library to make writing audiogames easier.
A Python library to make creating audio games easier.
## Requirements
configparser pyxdg pygame pyperclip requests setproctitle
### For mac os X and windows
accessible_output2
## Overview
`libstormgames` provides a comprehensive set of tools for developing accessible games with audio-first design. It handles common game development tasks including:
- Sound and music playback with positional audio
- Text-to-speech integration
- Configuration management
- Score tracking and high score tables
- Input handling and keyboard controls
- Menu systems and text display
- GUI initialization
## Installation
### Requirements
- pygame>=2.0.0
- pyperclip>=1.8.0
- requests>=2.25.0
- pyxdg>=0.27
- setproctitle>=1.2.0
- numpy>=1.19.0
- wxpython
#### Speech Providers (one required)
- Linux/Unix: `python-speechd` or `accessible-output2>=0.14`
- Windows/macOS: `accessible-output2>=0.14`
### Install from source
If you are on Linux, check your package manager first to see if the packages in requirements.txt are available.
```bash
git clone https://git.stormux.org/storm/libstormgames
cd libstormgames
pip install -e .
```
## Getting Started
You can use libstormgames in two ways: the traditional function-based approach or the new class-based approach.
### Traditional Function-Based Approach
```python
#!/usr/bin/env python3
import libstormgames as sg
def main():
# Initialize the game
sounds = sg.initialize_gui("My First Audio Game")
# Welcome message
sg.speak("Welcome to My First Audio Game!")
# Create a scoreboard
scoreboard = sg.Scoreboard()
# Main game loop
def play_game():
sg.speak("Game started!")
scoreboard.increase_score(10)
sg.speak(f"Your score is {scoreboard.get_score()}")
scoreboard.add_high_score()
return "menu" # Return to menu after game ends
# Define menu options
while True:
choice = sg.game_menu(sounds)
if choice == "play":
play_game()
if __name__ == "__main__":
main()
```
### Modern Class-Based Approach
```python
#!/usr/bin/env python3
import libstormgames as sg
def main():
# Create and initialize a game
game = sg.Game("My First Audio Game").initialize()
# Welcome message
game.speak("Welcome to My First Audio Game!")
# Main game loop
def play_game():
game.speak("Game started!")
game.scoreboard.increase_score(10)
game.speak(f"Your score is {game.scoreboard.get_score()}")
game.scoreboard.add_high_score()
return "menu"
# Define menu options
while True:
choice = sg.game_menu(game.sound.get_sounds())
if choice == "play":
play_game()
if __name__ == "__main__":
main()
```
## Library Structure
The library is organized into modules, each with a specific focus:
- **config**: Configuration management
- **services**: Core services that replace global variables
- **sound**: Sound and music playback
- **speech**: Text-to-speech functionality
- **scoreboard**: High score tracking
- **input**: Input handling and dialogs
- **display**: Text display and GUI functionality
- **menu**: Menu systems
- **utils**: Utility functions and Game class
## Core Classes
### Game
The Game class provides a central way to manage all game systems:
```python
# Create and initialize a game
game = sg.Game("My Game").initialize()
# Use fluent API for chaining commands
game.speak("Hello").play_bgm("music/theme")
# Access components directly
game.scoreboard.increase_score(10)
game.sound.play_random("explosion")
# Display text
game.display_text(["Line 1", "Line 2"])
# Clean exit
game.exit()
```
The initialization process sets up proper configuration paths:
- Creates game-specific directories if they don't exist
- Ensures all services have access to correct paths
- Connects all components for seamless operation
### Services
The library includes several service classes that replace global variables:
```python
# Volume service manages all volume settings
volume = sg.VolumeService.get_instance()
volume.adjust_master_volume(0.1)
# Path service manages file paths
paths = sg.PathService.get_instance()
print(paths.game_path)
# Config service manages configuration
config = sg.ConfigService.get_instance()
```
### Config
Handles configuration file management with local and global settings.
```python
# Create a configuration manager
config = sg.Config("My Game")
# Access the local configuration
config.local_config.add_section("settings")
config.local_config.set("settings", "difficulty", "easy")
config.write_local_config()
# Read settings
config.read_local_config()
difficulty = config.local_config.get("settings", "difficulty")
```
Configuration files are automatically stored in standard system locations:
- Linux/Unix: `~/.config/storm-games/`
- Windows: `%APPDATA%\storm-games\`
- macOS: `~/Library/Application Support/storm-games/`
Each game has its own subdirectory based on the game name (lowercase with hyphens), for example, Wicked Quest becomes wicked-quest.
### Sound
Manages sound loading and playback with positional audio support.
```python
# Create a sound manager
sound_system = sg.Sound("sounds/")
# Get the dictionary of loaded sounds
sounds = sound_system.get_sounds()
# Play a sound
sg.play_sound(sounds["explosion"])
# Play a sound with positional audio (player at x=5, object at x=10)
channel = sg.obj_play(sounds, "footsteps", 5, 10)
# Update sound position as player or object moves
channel = sg.obj_update(channel, 6, 10) # Player moved to x=6
# Stop the sound
channel = sg.obj_stop(channel)
# Play background music
sg.play_bgm("sounds/background.ogg")
# Adjust volume
sg.adjust_master_volume(0.1) # Increase master volume
sg.adjust_bgm_volume(-0.1) # Decrease background music volume
sg.adjust_sfx_volume(0.1) # Increase sound effects volume
```
### Speech
Provides text-to-speech functionality using available speech providers.
```python
# Create a speech manager (usually you'll use the global instance)
speech = sg.Speech()
# Speak text
speech.speak("Hello, world!")
# Or use the global function for convenience
sg.speak("Hello, world!")
# Speak without interrupting previous speech
sg.speak("This won't interrupt", interrupt=False)
# Clean up when done
exit_game() or as the class, game.exit_game()
```
### Scoreboard
Tracks scores and manages high score tables.
```python
# Create a scoreboard
scoreboard = sg.Scoreboard()
# Manipulate score
scoreboard.increase_score(10)
scoreboard.decrease_score(5)
current_score = scoreboard.get_score()
# Check for high score
position = scoreboard.check_high_score()
if position:
print(f"You're in position {position}!")
# Add high score (prompts for player name)
scoreboard.add_high_score()
# Get all high scores
high_scores = scoreboard.get_high_scores()
for entry in high_scores:
print(f"{entry['name']}: {entry['score']}")
```
The Scoreboard system automatically manages high score data in the game's configuration directory:
- Linux/Unix: `~/.config/storm-games/game-name/config.ini`
- Windows: `%APPDATA%\storm-games\game-name\config.ini`
- macOS: `~/Library/Application Support/storm-games/game-name/config.ini`
Where `game-name` is the lowercase, hyphenated version of your game title. For example,
"My Awesome Game" would use the directory `my-awesome-game`.
## Key Functions
### Game Initialization and Control
```python
# Initialize game systems (traditional approach)
sounds = sg.initialize_gui("Game Title")
# Or use the Game class (modern approach)
game = sg.Game("Game Title").initialize()
# Pause the game (freezes until user presses backspace)
sg.pause_game()
# Check if user wants to exit
if sg.check_for_exit():
sg.exit_game()
# Exit the game properly
sg.exit_game()
```
### Menu System
```python
# Basic menu example (returns option string)
choice = sg.game_menu(sounds)
if choice == "play":
# Start the game
play_game()
elif choice == "instructions":
sg.instructions()
# etc...
# Menu with play callback (callback is executed directly)
def play_callback():
sg.speak("Game started with callback!")
# Game code goes here
sg.game_menu(sounds, play_callback)
# Menu with custom options
choice = sg.game_menu(sounds, None, "practice_mode")
if choice == "practice_mode":
# Handle practice mode
practice_game()
elif choice == "difficulty":
# Handle difficulty settings
set_difficulty()
elif choice == "options":
# Handle options screen
show_options()
# etc...
```
### Complete Menu Example
```python
import libstormgames as sg
import pygame
def main():
# Initialize the game
sounds = sg.initialize_gui("Menu Example Game")
# Play menu music
sg.play_bgm("sounds/music_menu.ogg")
# Define play function with callback
def play_game():
sg.speak("Starting game!")
# Game code here
sg.speak("Game over!")
return "menu" # Return to menu after game ends
# Define custom menu option handlers
def practice_mode():
sg.speak("Starting practice mode!")
# Practice mode code here
return "menu"
def difficulty_settings():
# Show difficulty options
options = ["easy", "normal", "hard", "back"]
current = 0
while True:
sg.speak(options[current])
event = pygame.event.wait()
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_UP and current > 0:
current -= 1
elif event.key == pygame.K_DOWN and current < len(options) - 1:
current += 1
elif event.key == pygame.K_RETURN:
if options[current] == "back":
return "menu"
sg.speak(f"Difficulty set to {options[current]}")
return "menu"
elif event.key == pygame.K_ESCAPE:
return "menu"
# Main menu loop
while True:
# Display menu with play callback and custom options
choice = sg.game_menu(sounds, play_game, "practice_mode", "difficulty")
# Handle menu choices
if choice == "practice_mode":
practice_mode()
elif choice == "difficulty":
difficulty_settings()
# Other options are handled automatically by game_menu
if __name__ == "__main__":
main()
```
### Class-Based Menu Example
```python
import libstormgames as sg
import pygame
class MyGame:
def __init__(self):
# Create and initialize game
self.game = sg.Game("Class-Based Menu Example").initialize()
self.sounds = self.game.sound.get_sounds()
def play_game(self):
self.game.speak("Starting game!")
# Game code here
self.game.speak("Game over!")
return "menu"
def practice_mode(self):
self.game.speak("Starting practice mode!")
# Practice mode code here
return "menu"
def settings(self):
# A nested menu example
submenu_options = ["graphics", "audio", "controls", "back"]
current = 0
while True:
self.game.speak(submenu_options[current])
event = pygame.event.wait()
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_UP and current > 0:
current -= 1
elif event.key == pygame.K_DOWN and current < len(submenu_options) - 1:
current += 1
elif event.key == pygame.K_RETURN:
if submenu_options[current] == "back":
return "menu"
self.game.speak(f"Selected {submenu_options[current]} settings")
return "menu"
elif event.key == pygame.K_ESCAPE:
return "menu"
def run(self):
# Main menu loop
while True:
# Use playCallback parameter to directly call play_game when "play" is selected
choice = sg.game_menu(self.sounds, None, "practice_mode",
"settings")
# Handle other menu options
if choice == "practice_mode":
self.practice_mode()
elif choice == "settings":
self.settings()
# Run the game
if __name__ == "__main__":
game = MyGame()
game.run()
```
### Text Display
```python
# Display text with navigation
sg.display_text([
"Line 1 of instructions",
"Line 2 of instructions",
"Line 3 of instructions"
])
# Display a simple message box
sg.messagebox("Game Over! You scored 100 points.")
# Get text input from user
name = sg.get_input("Enter your name:", "Player")
```
### Sound Effects
```python
# Play a random variation of a sound
sg.play_random(sounds, "explosion")
# Play positional sound with distance-based volume
sg.play_random_positional(sounds, "footsteps", player_x=5, object_x=10)
# Play directional sound (simplified left/right positioning)
sg.play_directional_sound(sounds, "voice", player_x=5, object_x=10)
# Play a sound as a cutscene (interrupts other sounds, waits until complete)
sg.cut_scene(sounds, "intro_speech")
# Play or update a falling sound
channel = sg.play_random_falling(sounds, "rock", player_x=5, object_x=8, start_y=10, currentY=5)
```
### Utility Functions
```python
# Check for game updates
update_info = sg.check_for_updates("1.0.0", "My Game", "https://example.com/version.json")
# Check compatibility with library version
is_compatible = sg.check_compatibility("1.0.0", "1.2.3")
# Sanitize a filename for any OS
safe_name = sg.sanitize_filename("User's File.txt")
# Calculate distance between points
distance = sg.distance_2d(x1=5, y1=10, x2=8, y2=15)
# Interpolation functions
mid_value = sg.lerp(start=0, end=10, factor=0.5) # Returns 5.0
smooth_value = sg.smooth_step(edge0=0, edge1=10, x=5) # Smooth transition
```
## Advanced Examples
### Using the Game Class (Modern Approach)
```python
import libstormgames as sg
import pygame
import random
def main():
# Create and initialize the game
game = sg.Game("My Advanced Game").initialize()
# Set up game environment
game.play_bgm("sounds/background.ogg")
# Main game loop
running = True
player_x = 5
while running:
# Process events
for event in pygame.event.get():
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
running = False
elif event.key == pygame.K_SPACE:
# Score points
game.scoreboard.increase_score(5)
game.speak(f"Score: {game.scoreboard.get_score()}")
# Update game state
player_x += random.uniform(-0.2, 0.2)
# Add random ambient sounds
if random.random() < 0.05:
sounds = game.sound.get_sounds()
if "ambient" in sounds:
sg.play_random_positional(sounds, "ambient", player_x,
player_x + random.uniform(-5, 5))
pygame.time.delay(50)
# Game over and cleanup
game.speak("Game over!")
game.exit()
if __name__ == "__main__":
main()
```
### Complex Sound Environment
```python
import libstormgames as sg
import time
import random
def create_sound_environment(player_x, player_y):
sounds = sg.initialize_gui("Sound Environment Demo")
# Place sound sources
water_x, water_y = 10, 5
fire_x, fire_y = 15, 8
wind_x, wind_y = 3, 12
# Play ambient sounds
water_channel = sg.obj_play(sounds, "water", player_x, water_x)
fire_channel = sg.obj_play(sounds, "fire", player_x, fire_x)
wind_channel = sg.obj_play(sounds, "wind", player_x, wind_x)
# Main loop
running = True
while running:
# Simulate player movement
player_x += random.uniform(-0.5, 0.5)
player_y += random.uniform(-0.5, 0.5)
# Update sound positions
water_channel = sg.obj_update(water_channel, player_x, water_x)
fire_channel = sg.obj_update(fire_channel, player_x, fire_x)
wind_channel = sg.obj_update(wind_channel, player_x, wind_x)
# Occasionally play random sound
if random.random() < 0.1:
sg.play_random_positional(sounds, "creature", player_x,
player_x + random.uniform(-5, 5))
# Check for exit
if sg.check_for_exit():
running = False
time.sleep(0.1)
# Clean up
sg.obj_stop(water_channel)
sg.obj_stop(fire_channel)
sg.obj_stop(wind_channel)
sg.exit_game()
```
### Complete Game Structure with Class-Based Architecture
```python
import libstormgames as sg
import pygame
import random
class MyGame:
def __init__(self):
# Create a Game instance that manages all subsystems
self.game = sg.Game("My Advanced Game").initialize()
# Game state
self.player_x = 5
self.player_y = 5
self.difficulty = "normal"
# Load settings
try:
self.difficulty = self.game.config_service.local_config.get("settings", "difficulty")
except:
self.game.config_service.local_config.add_section("settings")
self.game.config_service.local_config.set("settings", "difficulty", "normal")
self.game.config_service.write_local_config()
def play_game(self):
self.game.speak(f"Starting game on {self.difficulty} difficulty")
self.game.play_bgm("sounds/game_music.ogg")
# Game loop
running = True
while running:
# Update game state
self.player_x += random.uniform(-0.2, 0.2)
# Handle input
for event in pygame.event.get():
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
running = False
elif event.key == pygame.K_SPACE:
self.game.scoreboard.increase_score()
self.game.speak(f"Score: {self.game.scoreboard.get_score()}")
# Add some random sounds
if random.random() < 0.05:
sounds = self.game.sound.get_sounds()
if "ambient" in sounds:
sg.play_random_positional(sounds, "ambient",
self.player_x, self.player_x + random.uniform(-10, 10))
pygame.time.delay(50)
# Game over
position = self.game.scoreboard.check_high_score()
if position:
self.game.speak(f"New high score! Position {position}")
self.game.scoreboard.add_high_score()
return "menu"
def settings(self):
options = ["easy", "normal", "hard", "back"]
current = options.index(self.difficulty) if self.difficulty in options else 1
while True:
self.game.speak(f"Current difficulty: {options[current]}")
# Wait for input
event = pygame.event.wait()
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_UP and current > 0:
current -= 1
elif event.key == pygame.K_DOWN and current < len(options) - 1:
current += 1
elif event.key == pygame.K_RETURN:
if options[current] == "back":
return
self.difficulty = options[current]
self.game.config_service.local_config.set("settings", "difficulty", self.difficulty)
self.game.config_service.write_local_config()
self.game.speak(f"Difficulty set to {self.difficulty}")
return
elif event.key == pygame.K_ESCAPE:
return
def run(self):
# Main menu loop
while True:
sounds = self.game.sound.get_sounds()
choice = sg.game_menu(sounds, self.play_game, "settings",
"instructions", "credits", "donate", "exit_game")
if choice == "settings":
self.settings()
elif choice == "instructions":
sg.instructions()
elif choice == "credits":
sg.credits()
elif choice == "donate":
sg.donate()
elif choice == "exit_game":
self.game.exit()
# Run the game
if __name__ == "__main__":
game = MyGame()
game.run()
```
## Best Practices
1. **Modern vs Traditional Approach**:
- New projects: Use the Game class for better organization
- Existing projects: Continue with global functions for compatibility
- Both approaches are fully supported
2. **Always clean up resources**:
- Use `exit_game()` or `game.exit()` when exiting to ensure proper cleanup
- Stop sounds that are no longer needed
3. **Volume control**:
- Implement the Alt+key volume controls in your game
- Use volume services for better control
4. **Configuration**:
- Save user preferences using the Config class
- Load settings at startup
5. **Path initialization**:
- Always initialize the framework with a proper game title
- Game title is used to determine configuration directory paths
- Services are interconnected, so proper initialization ensures correct operation
## Troubleshooting
### No Sound
- Ensure pygame mixer is properly initialized
- Check if sound files exist in the correct directory
- Verify file formats (OGG and WAV are supported)
### No Speech
- Make sure at least one speech provider is installed
- Linux/Unix: `python-speechd` or `accessible-output2`
- Windows/macOS: `accessible-output2`
- Check if pygame display is initialized properly
### Input Issues
- Ensure pygame is properly handling events
- Check event loop for proper event handling
### Scoreboard/Configuration Issues
- Check if path services are properly initialized with a game title
- Verify write permissions in the configuration directory
- For debugging, use the following code to view path configuration:
```python
from libstormgames.services import PathService, ConfigService
# Print path information
path_service = PathService.get_instance()
print(f"Game Name: {path_service.gameName}")
print(f"Game Path: {path_service.gamePath}")
# Check config service connection
config_service = ConfigService.get_instance()
print(f"Config connected to path: {hasattr(config_service, 'pathService')}")
```
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
## License
This project is licensed under the GPL v3 License - see the LICENSE file for details.

View File

@ -1,380 +1,228 @@
#!/bin/python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Standard initializations and functions shared by all games."""
"""Standard initializations and functions shared by all Storm Games.
from sys import exit
import configparser
import os
from os import listdir
from os.path import isfile, join
from inspect import isfunction
from xdg import BaseDirectory
from setproctitle import setproctitle
import pyglet
import pyperclip
import random
import re
import requests
import webbrowser
# Global variable for speech provider
try:
import speechd
spd = speechd.Client()
speechProvider = "speechd"
except ImportError:
import accessible_output2.outputs.auto
s = accessible_output2.outputs.auto.Auto()
speechProvider = "accessible_output2"
except ImportError:
print("No other speech providers found.")
exit()
This module provides core functionality for Storm Games including:
- Sound and speech handling
- Volume controls
- Configuration management
- Score tracking
- GUI initialization
- Game menu systems
"""
# Import service classes
from .services import (
ConfigService,
VolumeService,
PathService
)
# Import Sound class and functions
from .sound import (
Sound,
play_bgm,
play_sound,
adjust_bgm_volume,
adjust_sfx_volume,
adjust_master_volume,
play_ambiance,
play_random,
play_random_positional,
play_directional_sound,
obj_play,
obj_update,
obj_stop,
cut_scene,
play_random_falling,
calculate_volume_and_pan
)
# Import Speech class and functions
from .speech import messagebox, speak, Speech
# Import Scoreboard
from .scoreboard import Scoreboard
# Import input functions
from .input import get_input, check_for_exit, pause_game
# Import display functions
from .display import display_text, initialize_gui
# Import menu functions
from .menu import game_menu, learn_sounds, instructions, credits, donate, exit_game
# Update imports to reference Scoreboard methods
high_scores = Scoreboard.display_high_scores
has_high_scores = Scoreboard.has_high_scores
# Import utility functions and Game class
from .utils import (
Game,
check_for_updates,
get_version_tuple,
check_compatibility,
sanitize_filename,
lerp,
smooth_step,
distance_2d,
x_powerbar,
y_powerbar,
generate_tone
)
__version__ = '2.0.0'
# Make all symbols available at the package level
__all__ = [
# Services
'ConfigService', 'VolumeService', 'PathService',
# Sound
'Sound',
'play_bgm',
'play_sound',
'adjust_bgm_volume',
'adjust_sfx_volume',
'adjust_master_volume',
'play_ambiance',
'play_random',
'play_random_positional',
'play_directional_sound',
'obj_play',
'obj_update',
'obj_stop',
'cut_scene',
'play_random_falling',
'calculate_volume_and_pan',
# Speech
'messagebox',
'speak',
'Speech',
# 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 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',
'randint', 'choice', 'uniform', 'seed'
]
# Create global instances for backward compatibility
configService = ConfigService.get_instance()
volumeService = VolumeService.get_instance()
pathService = PathService.get_instance()
# Set up backward compatibility hooks for initialize_gui
_originalInitializeGui = initialize_gui
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)
# Replace initialize_gui with the wrapped version
initialize_gui = initialize_gui_with_services
# Initialize global scoreboard constructor
_originalScoreboardInit = Scoreboard.__init__
def scoreboard_init_with_services(self, score=0, configService=None, speech=None):
"""Wrapper around Scoreboard.__init__ that ensures services are initialized."""
# 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)
# Replace Scoreboard.__init__ with the wrapped version
Scoreboard.__init__ = scoreboard_init_with_services
# Re-export pygame time functions for backward compatibility
import pygame.time
def get_ticks():
"""Get the number of milliseconds since pygame.init() was called."""
return pygame.time.get_ticks()
def delay(milliseconds):
"""Pause the program for a given number of milliseconds."""
return pygame.time.delay(milliseconds)
def wait(milliseconds):
"""Pause the program for a given number of milliseconds."""
return pygame.time.wait(milliseconds)
# Re-export math functions that might be used
import math
import time
localConfig = configparser.ConfigParser()
globalConfig = configparser.ConfigParser()
def sin(x):
"""Return the sine of x radians."""
return math.sin(x)
class scoreboard():
'Handles scores and top 10'
def cos(x):
"""Return the cosine of x radians."""
return math.cos(x)
def __init__(self, startingScore = 0):
read_config()
try:
localConfig.add_section("scoreboard")
except:
pass
self.score = startingScore
self.oldScores = []
for i in range(1, 11):
try:
self.oldScores.insert(i - 1, localConfig.getint("scoreboard", str(i)))
except:
pass
self.oldScores.insert(i - 1, 0)
for i in range(1, 11):
if self.oldScores[i - 1] == None:
self.oldScores[i - 1] = 0
def sqrt(x):
"""Return the square root of x."""
return math.sqrt(x)
def __del__(self):
self.Update_Scores()
try:
write_config()
except:
pass
def floor(x):
"""Return the floor of x."""
return math.floor(x)
def Decrease_Score(self, points = 1):
self.score -= points
def ceil(x):
"""Return the ceiling of x."""
return math.ceil(x)
def Get_High_Score(self, position = 1):
return self.oldScores[position - 1]
# Re-export random functions that might be used
import random
def Get_Score(self):
return self.score
def randint(a, b):
"""Return a random integer N such that a <= N <= b."""
return random.randint(a, b)
def Increase_Score(self, points = 1):
self.score += points
def choice(seq):
"""Return a random element from the non-empty sequence seq."""
return random.choice(seq)
def New_High_Score(self):
for i, j in enumerate(self.oldScores):
if self.score > j: return i + 1
return None
def uniform(a, b):
"""Return a random floating point number N such that a <= N <= b."""
return random.uniform(a, b)
def Update_Scores(self):
# Update the scores
for i, j in enumerate(self.oldScores):
if self.score > j:
self.oldScores.insert(i, self.score)
break
# Only keep the top 10 scores.
self.oldScores = self.oldScores[:10]
# Update the scoreboard section of the games config file.
for i, j in enumerate(self.oldScores):
localConfig.set("scoreboard", str(i + 1), str(j))
def write_config(writeGlobal = False):
if writeGlobal == False:
with open(gamePath + "/config.ini", 'w') as configfile:
localConfig.write(configfile)
else:
with open(globalPath + "/config.ini", 'w') as configfile:
globalConfig.write(configfile)
def read_config(readGlobal = False):
if readGlobal == False:
try:
with open(gamePath + "/config.ini", 'r') as configfile:
localConfig.read_file(configfile)
except:
pass
else:
try:
with open(globalPath + "/config.ini", 'r') as configfile:
globalConfig.read_file(configfile)
except:
pass
def speak(text, interupt = True):
if speechProvider == "speechd":
if interupt == True: spd.cancel()
spd.say(text)
else:
if speechProvider == "accessible_output2":
s.speak(text, interrupt=True)
def exit_game():
if speechProvider == "speechd": spd.close()
# Close the pyglet window
pyglet.app.exit()
def initialize_gui(gameTitle):
# Check for, and possibly create, storm-games path
global globalPath
global gamePath
globalPath = BaseDirectory.xdg_config_home + "/storm-games"
gamePath = globalPath + "/" + str.lower(str.replace(gameTitle, " ", "-"))
if not os.path.exists(gamePath): os.makedirs(gamePath)
# Seed the random generator to the clock
random.seed()
# Set game's name
global gameName
gameName = gameTitle
setproctitle(str.lower(str.replace(gameTitle, " ", "")))
# init pyglet window
window = pyglet.window.Window(500, 300, gameTitle)
# Load sounds from the sound directory and creates a list like {'bottle': 'bottle.ogg'}
soundFiles = [f for f in listdir("sounds/") if isfile(join("sounds/", f)) and (f.split('.')[1].lower() in ["ogg","wav"])]
# make a dict with pyglet media {'bottle':<soundobject>}
soundData = {}
for f in soundFiles:
soundData[f.split('.')[0]] = pyglet.media.load("sounds/" + f, streaming = False)
soundData['game-intro'].play()
time.sleep(soundData['game-intro'].duration)
return soundData
def cut_scene(sounds, soundName):
pygame.event.clear()
pygame.mixer.stop()
c = pygame.mixer.Channel(0)
c.play(sounds[soundName])
while pygame.mixer.get_busy():
event = pygame.event.poll()
if event.type == pygame.KEYDOWN and event.key in [pygame.K_ESCAPE, pygame.K_RETURN, pygame.K_SPACE]:
pygame.mixer.stop()
pygame.event.pump()
def obj_play(sounds, soundName, playerPos, objPos):
distance = playerPos - objPos
if distance > 9 or distance < -9:
# The item is out of range, so play it at 0
left = 0
right = 0
elif distance == 0:
left = 0.9
right = 0.9
else:
angle = math.radians(distance * 5)
left = math.sqrt(2)/2.0 * (math.cos(angle) + math.sin(angle))
right = math.sqrt(2)/2.0 * (math.cos(angle) - math.sin(angle))
if left < 0: left *= -1
if right < 0: right *= -1
# x is the channel for the sound
x = sounds[soundName].play(-1)
# Apply the position information to the channel
x.set_volume(left, right)
# return the channel so that it can be used in the update and stop functions.
return x
def obj_update(x, playerPos, objPos):
distance = playerPos - objPos
if distance > 9 or distance < -9:
left = 0
right = 0
elif distance == 0:
left = 0.9
right = 0.9
else:
angle = math.radians(distance * 5)
left = math.sqrt(2)/2.0 * (math.cos(angle) + math.sin(angle))
right = math.sqrt(2)/2.0 * (math.cos(angle) - math.sin(angle))
if left < 0: left *= -1
if right < 0: right *= -1
# Apply the position information to the channel
x.set_volume(left, right)
# return the channel
return x
def obj_stop(x):
# Tries to stop a playing object channel
try:
x.stop()
return None
except:
return x
def play_random(sounds, soundName, pause = False, interrupt = False):
key = []
for i in sounds.keys():
if re.match("^" + soundName + ".*", i):
key.append(i)
randomKey = random.choice(key)
if interrupt == False:
sounds[randomKey].play()
else:
cut_scene(sounds, randomKey)
# Cut scenes override the pause option
return
if pause == True:
time.sleep(sounds[randomKey].get_length())
def instructions():
# Read in the instructions file
try:
with open('files/instructions.txt', 'r') as f:
info = f.readlines()
except:
info = ["Instructions file is missing."]
display_text(info)
def credits():
# Read in the credits file.
try:
with open('files/credits.txt', 'r') as f:
info = f.readlines()
# Add the header
info.insert(0, gameName + ": brought to you by Storm Dragon")
except:
info = ["Credits file is missing."]
display_text(info)
def display_text(text):
i = 0
text.insert(0, "Press space to read the whole text. Use up and down arrows to navigate the text line by line. Press c to copy the current line to the clipboard or t to copy the entire text. Press enter or escape when you are done reading.")
text.append("End of text.")
speak(text[i])
while True:
event = pygame.event.wait()
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE or event.key == pygame.K_RETURN: return
if event.key == pygame.K_DOWN and i < len(text) - 1: i = i + 1
if event.key == pygame.K_UP and i > 0: i = i - 1
if event.key == pygame.K_SPACE:
speak(' '.join(text[1:]))
else:
speak(text[i])
if event.key == pygame.K_c:
try:
pyperclip.copy(text[i])
speak("Copied " + text[i] + " to the clipboard.")
except:
speak("Failed to copy the text to the clipboard.")
if event.key == pygame.K_t:
try:
pyperclip.copy(' '.join(text[1:-1]))
speak("Copied entire message to the clipboard.")
except:
speak("Failed to copy the text to the clipboard.")
event = pygame.event.clear()
time.sleep(0.001)
def learn_sounds(sounds):
loop = True
pygame.mixer.music.pause()
i = 0
soundFiles = [f for f in listdir("sounds/") if isfile(join("sounds/", f)) and (f.split('.')[1].lower() in ["ogg","wav"]) and (f.split('.')[0].lower() not in ["game-intro", "music_menu"])]
# j keeps track of last spoken index so it isn't voiced on key up.
j = -1
while loop == True:
if i != j:
speak(soundFiles[i][:-4])
j = i
event = pygame.event.wait()
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE: return "menu"
if event.key == pygame.K_DOWN and i < len(soundFiles) - 1:
pygame.mixer.stop()
i = i + 1
if event.key == pygame.K_UP and i > 0:
pygame.mixer.stop()
i = i - 1
if event.key == pygame.K_RETURN:
try:
soundName = soundFiles[i][:-4]
pygame.mixer.stop()
sounds[soundName].play()
continue
except:
j = -1
speak("Could not play sound.")
continue
event = pygame.event.clear()
time.sleep(0.001)
def game_menu(sounds, *options):
loop = True
pygame.mixer.stop()
if pygame.mixer.music.get_busy():
pygame.mixer.music.unpause()
else:
pygame.mixer.music.load("sounds/music_menu.ogg")
pygame.mixer.music.set_volume(0.75)
pygame.mixer.music.play(-1)
i = 0
# j keeps track of last spoken index so it isn't voiced on key up.
j = -1
while loop == True:
if i != j:
speak(options[i])
j = i
event = pygame.event.wait()
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE: exit_game()
if event.key == pygame.K_DOWN and i < len(options) - 1:
i = i + 1
try:
sounds['menu-move'].play()
except:
pass
if options[i] != "donate": pygame.mixer.music.unpause()
if event.key == pygame.K_UP and i > 0:
i = i - 1
try:
sounds['menu-move'].play()
except:
pass
if options[i] != "donate": pygame.mixer.music.unpause()
if event.key == pygame.K_HOME and i != 0:
i = 0
try:
sounds['menu-move'].play()
except:
pass
if options[i] != "donate": pygame.mixer.music.unpause()
if event.key == pygame.K_END and i != len(options) - 1:
i = len(options) -1
try:
sounds['menu-move'].play()
except:
pass
if options[i] != "donate": pygame.mixer.music.unpause()
if event.key == pygame.K_RETURN:
try:
j = -1
try:
sounds['menu-select'].play()
time.sleep(sounds['menu-select'].get_length())
except:
pass
eval(options[i] + "()")
continue
except:
j = -1
return options[i]
continue
event = pygame.event.clear()
time.sleep(0.001)
def donate():
pygame.mixer.music.pause()
webbrowser.open('https://ko-fi.com/stormux')
def seed(a=None):
"""Initialize the random number generator."""
return random.seed(a)

102
config.py Normal file
View File

@ -0,0 +1,102 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Configuration management for Storm Games.
Provides functionality for:
- Reading and writing configuration files
- Global and local configuration handling
"""
import configparser
import os
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
"""
self.gameTitle = gameTitle
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:
with open(os.path.join(self.gamePath, "config.ini"), 'r') as configFile:
self.localConfig.read_file(configFile)
except:
pass
def read_global_config(self):
"""Read global configuration from file."""
try:
with open(os.path.join(self.globalPath, "config.ini"), 'r') as configFile:
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:
self.globalConfig.write(configFile)
# Global variables for backward compatibility
localConfig = configparser.ConfigParser()
globalConfig = configparser.ConfigParser()
gamePath = ""
globalPath = ""
def write_config(writeGlobal=False):
"""Write configuration to file.
Args:
writeGlobal (bool): If True, write to global config, otherwise local (default: False)
"""
if not writeGlobal:
with open(gamePath + "/config.ini", 'w') as configFile:
localConfig.write(configFile)
else:
with open(globalPath + "/config.ini", 'w') as configFile:
globalConfig.write(configFile)
def read_config(readGlobal=False):
"""Read configuration from file.
Args:
readGlobal (bool): If True, read global config, otherwise local (default: False)
"""
if not readGlobal:
try:
with open(gamePath + "/config.ini", 'r') as configFile:
localConfig.read_file(configFile)
except:
pass
else:
try:
with open(globalPath + "/config.ini", 'r') as configFile:
globalConfig.read_file(configFile)
except:
pass

192
display.py Normal file
View File

@ -0,0 +1,192 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Display functionality for Storm Games.
Provides functionality for:
- GUI initialization
- Text display with navigation
- Message boxes
"""
import pygame
import time
import os
import pyperclip
import random
from xdg import BaseDirectory
from setproctitle import setproctitle
from .speech import Speech
from .services import PathService, VolumeService
# Keep track of the instructions for navigating display_text has been shown
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
from .sound import cut_scene
if 'game-intro' in soundData:
cut_scene(soundData, 'game-intro')
return soundData
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
- Copy current line or full text (preserving blank lines)
- Exit with enter/escape
- Volume controls (with Alt modifier):
- Alt+PageUp/PageDown: Master volume up/down
- Alt+Home/End: Background music volume up/down
- Alt+Insert/Delete: Sound effects volume up/down
Args:
text (list): List of text lines to display
"""
# 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:
instructions = ("Press space to read the whole text. Use up and down arrows to navigate "
"the text line by line. Press c to copy the current line to the clipboard "
"or t to copy the entire text. Press enter or escape when you are done reading.")
navText.insert(0, instructions)
displayTextUsageInstructions = True
# Add end marker
navText.append("End of text.")
currentIndex = 0
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:
volumeService.adjust_master_volume(0.1, pygame.mixer)
elif event.key == pygame.K_PAGEDOWN:
volumeService.adjust_master_volume(-0.1, pygame.mixer)
elif event.key == pygame.K_HOME:
volumeService.adjust_bgm_volume(0.1, pygame.mixer)
elif event.key == pygame.K_END:
volumeService.adjust_bgm_volume(-0.1, pygame.mixer)
elif event.key == pygame.K_INSERT:
volumeService.adjust_sfx_volume(0.1, pygame.mixer)
elif event.key == pygame.K_DELETE:
volumeService.adjust_sfx_volume(-0.1, pygame.mixer)
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
pyperclip.copy(''.join(originalText[2:-1]))
speech.speak("Copied entire message to the clipboard.")
except:
speech.speak("Failed to copy the text to the clipboard.")
event = pygame.event.clear()
time.sleep(0.001)

77
input.py Normal file
View File

@ -0,0 +1,77 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Input handling for Storm Games.
Provides functionality for:
- Text input dialogs
- Game pause functionality
- Exit handling
"""
import pygame
import time
import wx
from .speech import speak
def get_input(prompt="Enter text:", text=""):
"""Display a dialog box for text input.
Args:
prompt (str): Prompt text to display (default: "Enter text:")
text (str): Initial text in input box (default: "")
Returns:
str: User input text, or None if cancelled
"""
app = wx.App(False)
dialog = wx.TextEntryDialog(None, prompt, "Input", text)
dialog.SetValue(text)
if dialog.ShowModal() == wx.ID_OK:
userInput = dialog.GetValue()
else:
userInput = None
dialog.Destroy()
return userInput
def pause_game():
"""Pauses the game until user presses backspace."""
speak("Game paused, press backspace to resume.")
pygame.event.clear()
try:
pygame.mixer.pause()
except:
pass
try:
pygame.mixer.music.pause()
except:
pass
while True:
event = pygame.event.wait()
if event.type == pygame.KEYDOWN and event.key == pygame.K_BACKSPACE:
break
try:
pygame.mixer.unpause()
except:
pass
try:
pygame.mixer.music.unpause()
except:
pass
pygame.event.pump()
def check_for_exit():
"""Check if user has pressed escape key.
Returns:
bool: True if escape was pressed, False otherwise
"""
for event in pygame.event.get():
if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
return True
return False
pygame.event.pump()

496
menu.py Normal file
View File

@ -0,0 +1,496 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Menu systems for Storm Games.
Provides functionality for:
- Game menu navigation
- Instructions display
- Credits display
- Sound learning interface
- Game exit handling
"""
import pygame
import time
import webbrowser
import os
from sys import exit
from os.path import isfile
from os import listdir
from os.path import join
from inspect import isfunction
from .speech import messagebox, Speech
from .sound import adjust_master_volume, adjust_bgm_volume, adjust_sfx_volume, play_bgm
from .display import display_text
from .scoreboard import Scoreboard
from .services import PathService, ConfigService
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
3. Custom options (if provided)
4. Learn Sounds
5. Instructions (if available)
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:
from .sound import play_bgm
play_bgm("sounds/music_menu.ogg")
except:
pass
loop = True
pygame.mixer.stop()
currentIndex = 0
lastSpoken = -1 # Track last spoken index
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:
adjust_master_volume(0.1)
elif event.key == pygame.K_PAGEDOWN:
adjust_master_volume(-0.1)
elif event.key == pygame.K_HOME:
adjust_bgm_volume(0.1)
elif event.key == pygame.K_END:
adjust_bgm_volume(-0.1)
elif event.key == pygame.K_INSERT:
adjust_sfx_volume(0.1)
elif event.key == pygame.K_DELETE:
adjust_sfx_volume(-0.1)
# Regular menu navigation (no Alt required)
else:
if event.key == pygame.K_ESCAPE:
# Exit with fade if music is playing
exit_game(500 if pygame.mixer.music.get_busy() else 0)
elif event.key == pygame.K_HOME:
if currentIndex != 0:
currentIndex = 0
try:
sounds['menu-move'].play()
except:
pass
if allOptions[currentIndex] != "donate":
pygame.mixer.music.unpause()
elif event.key == pygame.K_END:
if currentIndex != len(allOptions) - 1:
currentIndex = len(allOptions) - 1
try:
sounds['menu-move'].play()
except:
pass
if allOptions[currentIndex] != "donate":
pygame.mixer.music.unpause()
elif event.key in [pygame.K_DOWN, pygame.K_s] and currentIndex < len(allOptions) - 1:
currentIndex += 1
try:
sounds['menu-move'].play()
except:
pass
if allOptions[currentIndex] != "donate":
pygame.mixer.music.unpause()
elif event.key in [pygame.K_UP, pygame.K_w] and currentIndex > 0:
currentIndex -= 1
try:
sounds['menu-move'].play()
except:
pass
if allOptions[currentIndex] != "donate":
pygame.mixer.music.unpause()
elif event.key == pygame.K_RETURN:
try:
lastSpoken = -1
try:
sounds['menu-select'].play()
time.sleep(sounds['menu-select'].get_length())
except:
pass
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)
# Special case for play option
elif selectedOption == "play":
if playCallback:
# If a play callback is provided, call it directly
try:
pygame.mixer.music.fadeout(500)
time.sleep(0.5)
except Exception as e:
print(f"Could not fade music: {e}")
pass
playCallback()
else:
# Otherwise return "play" to the caller
try:
pygame.mixer.music.fadeout(500)
time.sleep(0.5)
except Exception as e:
print(f"Could not fade music: {e}")
pass
return "play"
# Handle standard options directly
elif selectedOption in ["instructions", "credits", "learn_sounds", "high_scores", "donate"]:
# Pause music before calling the selected function
try:
pygame.mixer.music.pause()
except:
pass
# Handle standard options
if selectedOption == "instructions":
instructions()
elif selectedOption == "credits":
credits()
elif selectedOption == "learn_sounds":
learn_sounds(sounds)
elif selectedOption == "high_scores":
Scoreboard.display_high_scores()
elif selectedOption == "donate":
donate()
# Unpause music after function returns
try:
# Check if music is actually paused before trying to unpause
if not pygame.mixer.music.get_busy():
pygame.mixer.music.unpause()
# If music is already playing, don't try to restart it
except:
# Only start fresh music if no music is playing at all
if not pygame.mixer.music.get_busy():
try:
from .sound import play_bgm
play_bgm("sounds/music_menu.ogg")
except:
pass
# Return custom options to the calling function
else:
lastSpoken = -1
try:
pygame.mixer.music.fadeout(500)
time.sleep(0.5)
except Exception as e:
print(f"Could not fade music: {e}")
pass
return selectedOption
except Exception as e:
print(f"Error handling menu selection: {e}")
lastSpoken = -1
try:
pygame.mixer.music.fadeout(500)
time.sleep(0.5)
except:
pass
return allOptions[currentIndex]
event = 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 '_'
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):
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
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
announcement = f"{dirDescription}: {displayName}, {currentSoundIndex + 1} of {totalSounds}"
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()
currentDirIndex += 1
currentDir = directories[currentDirIndex]
currentSoundKeys = soundsByDir[currentDir]
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
currentDir = directories[currentDirIndex]
currentSoundKeys = soundsByDir[currentDir]
currentSoundIndex = 0
directoryChanged = True # Set flag on directory change
lastSpoken = -1 # Force announcement
# Play sound
elif event.key == pygame.K_RETURN:
try:
soundName = currentSoundKeys[currentSoundIndex]
pygame.mixer.stop()
sounds[soundName].play()
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.
"""
try:
with open('files/instructions.txt', 'r') as f:
info = f.readlines()
except:
info = ["Instructions file is missing."]
display_text(info)
def credits():
"""Display game credits from file.
Reads and displays credits from 'files/credits.txt'.
Adds game name header before displaying.
If file is missing, displays an error message.
"""
try:
with open('files/credits.txt', 'r') as f:
info = f.readlines()
pathService = PathService.get_instance()
info.insert(0, pathService.gameName + "\n")
except Exception as e:
print(f"Error in credits: {e}")
info = ["Credits file is missing."]
display_text(info)
def donate():
"""Open the donation webpage.
Opens the Ko-fi donation page.
"""
webbrowser.open('https://ko-fi.com/stormux')
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()
# Try to close speech regardless of provider type
try:
speech.close()
except Exception as e:
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():
pygame.mixer.music.fadeout(fade)
# Wait for fade to start but don't wait for full completion
pygame.time.wait(min(250, fade))
else:
pygame.mixer.music.stop()
except Exception as e:
print(f"Warning: Could not handle music during exit: {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)

8
requirements.txt Normal file
View File

@ -0,0 +1,8 @@
pygame>=2.0.0
pyperclip>=1.8.0
requests>=2.25.0
pyxdg>=0.27
setproctitle>=1.2.0
numpy>=1.19.0
accessible-output2>=0.14
wxpython

2
requirements_linux.txt Normal file
View File

@ -0,0 +1,2 @@
-r requirements.txt
python-speechd>=0.11.1

332
scoreboard.py Normal file
View File

@ -0,0 +1,332 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Modified Scoreboard class with integrated fixes.
This code should replace the existing Scoreboard class in scoreboard.py.
The modifications ensure proper path handling and config operations.
"""
import time
import os
from .services import ConfigService, PathService
from .speech import Speech
from .display import display_text
# For backward compatibility
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)
speech (Speech): Speech system (default: global instance)
"""
# 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")
except:
# Fallback to old method
try:
localConfig.add_section("scoreboard")
except:
pass
# Load existing high scores
for i in range(1, 11):
try:
# Try to use configService
score = self.configService.localConfig.getint("scoreboard", f"score_{i}")
name = self.configService.localConfig.get("scoreboard", f"name_{i}")
self.highScores.append({
'name': name,
'score': score
})
except:
# Fallback to old method
try:
score = localConfig.getint("scoreboard", f"score_{i}")
name = localConfig.get("scoreboard", f"name_{i}")
self.highScores.append({
'name': name,
'score': score
})
except:
self.highScores.append({
'name': "Player",
'score': 0
})
# Sort high scores by score value in descending order
self.highScores.sort(key=lambda x: x['score'], reverse=True)
def _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:
import pygame
if pygame.display.get_caption()[0]:
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
"""
for i, entry in enumerate(self.highScores):
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 = "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()
except Exception as e:
print(f"Error writing config with configService: {e}")
# Fallback to old method if configService fails
for i, entry in enumerate(self.highScores):
localConfig.set("scoreboard", f"score_{i+1}", str(entry['score']))
localConfig.set("scoreboard", f"name_{i+1}", entry['name'])
write_config()
except Exception as e:
print(f"Error writing high scores: {e}")
# If all else fails, try direct old method
for i, entry in enumerate(self.highScores):
localConfig.set("scoreboard", f"score_{i+1}", str(entry['score']))
localConfig.set("scoreboard", f"name_{i+1}", entry['name'])
write_config()
# Announce success
try:
self.speech.messagebox(f"Congratulations {name}! You got position {position} on the scoreboard!")
except:
# 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
"""
try:
# 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:
import pygame
if pygame.display.get_caption()[0]:
gameName = pygame.display.get_caption()[0]
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:
print(f"Error checking high scores: {e}")
return False
@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.
"""
try:
# 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:
import pygame
if pygame.display.get_caption()[0]:
gameName = pygame.display.get_caption()[0]
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):
scoreStr = f"{i}. {entry['name']}: {entry['score']}"
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

274
services.py Normal file
View File

@ -0,0 +1,274 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Service classes for Storm Games.
Provides centralized services to replace global variables:
- ConfigService: Manages game configuration
- VolumeService: Handles volume settings
- PathService: Manages file paths
"""
import configparser
import os
from xdg import BaseDirectory
# For backward compatibility
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:
# Try to use pathService if available
if self.pathService and self.pathService.gamePath:
with open(os.path.join(self.pathService.gamePath, "config.ini"), 'r') as configFile:
self.localConfig.read_file(configFile)
# Fallback to global gamePath
elif gamePath:
with open(os.path.join(gamePath, "config.ini"), 'r') as configFile:
self.localConfig.read_file(configFile)
# Delegate to old function as last resort
else:
read_config(False)
self.localConfig = configparser.ConfigParser()
self.localConfig.read_dict(globals().get('localConfig', {}))
except:
pass
def read_global_config(self):
"""Read global configuration from file."""
try:
# Try to use pathService if available
if self.pathService and self.pathService.globalPath:
with open(os.path.join(self.pathService.globalPath, "config.ini"), 'r') as configFile:
self.globalConfig.read_file(configFile)
# Fallback to global globalPath
elif globalPath:
with open(os.path.join(globalPath, "config.ini"), 'r') as configFile:
self.globalConfig.read_file(configFile)
# Delegate to old function as last resort
else:
read_config(True)
self.globalConfig = configparser.ConfigParser()
self.globalConfig.read_dict(globals().get('globalConfig', {}))
except:
pass
def write_local_config(self):
"""Write local configuration to file."""
try:
# Try to use pathService if available
if self.pathService and self.pathService.gamePath:
with open(os.path.join(self.pathService.gamePath, "config.ini"), 'w') as configFile:
self.localConfig.write(configFile)
# Fallback to global gamePath
elif gamePath:
with open(os.path.join(gamePath, "config.ini"), 'w') as configFile:
self.localConfig.write(configFile)
# Delegate to old function as last resort
else:
# Update old global config
globals()['localConfig'] = self.localConfig
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:
# Try to use pathService if available
if self.pathService and self.pathService.globalPath:
with open(os.path.join(self.pathService.globalPath, "config.ini"), 'w') as configFile:
self.globalConfig.write(configFile)
# Fallback to global globalPath
elif globalPath:
with open(os.path.join(globalPath, "config.ini"), 'w') as configFile:
self.globalConfig.write(configFile)
# Delegate to old function as last resort
else:
# Update old global config
globals()['globalConfig'] = self.globalConfig
write_config(True)
except Exception as e:
print(f"Warning: Failed to write global config: {e}")
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)
if channel.get_busy():
currentVolume = channel.get_volume()
if isinstance(currentVolume, (int, float)):
# Mono audio
channel.set_volume(currentVolume * self.masterVolume)
else:
# 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
for i in range(pygameMixer.get_num_channels()):
channel = pygameMixer.Channel(i)
if channel.get_busy():
currentVolume = channel.get_volume()
if isinstance(currentVolume, (int, float)):
# Mono audio
channel.set_volume(currentVolume * self.sfxVolume * self.masterVolume)
else:
# Stereo audio
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
"""
return self.sfxVolume * self.masterVolume
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
"""
self.gameName = gameTitle
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

528
sound.py Normal file
View File

@ -0,0 +1,528 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Sound handling for Storm Games.
Provides functionality for:
- Playing background music and sound effects
- 2D positional audio (x,y)
- Volume controls
"""
import os
import pygame
import random
import re
import time
import math
from .services import VolumeService
# Global instance for backward compatibility
volumeService = VolumeService.get_instance()
class Sound:
"""Handles sound loading and playback."""
def __init__(self, soundDir="sounds/", volumeService=None):
"""Initialize sound system."""
self.soundDir = soundDir
self.sounds = {}
self.volumeService = volumeService or VolumeService.get_instance()
if not pygame.mixer.get_init():
pygame.mixer.pre_init(44100, -16, 2, 1024)
pygame.mixer.init()
pygame.mixer.set_num_channels(32)
pygame.mixer.set_reserved(0)
self.load_sounds()
def load_sounds(self):
"""Load all sound files from the sound directory and its subdirectories."""
try:
for dirPath, _, fileNames in os.walk(self.soundDir):
relPath = os.path.relpath(dirPath, self.soundDir)
for fileName in fileNames:
if fileName.lower().endswith(('.ogg', '.wav')):
fullPath = os.path.join(dirPath, fileName)
baseName = os.path.splitext(fileName)[0]
soundKey = baseName if relPath == '.' else os.path.join(relPath, baseName).replace('\\', '/')
self.sounds[soundKey] = pygame.mixer.Sound(fullPath)
except Exception as e:
print(f"Error loading sounds: {e}")
def _find_matching_sound(self, pattern):
"""Find a random sound matching the pattern."""
keys = [k for k in self.sounds.keys() if re.match("^" + pattern + ".*", k)]
return random.choice(keys) if keys else None
def _handle_cutscene(self, soundName):
"""Play a sound as a cut scene."""
pygame.event.clear()
pygame.mixer.stop()
channel = pygame.mixer.Channel(0)
sfxVolume = self.volumeService.get_sfx_volume()
channel.set_volume(sfxVolume, sfxVolume)
channel.play(self.sounds[soundName])
while pygame.mixer.get_busy():
for event in pygame.event.get():
if event.type == pygame.KEYDOWN and event.key in [pygame.K_ESCAPE, pygame.K_RETURN, pygame.K_SPACE]:
pygame.mixer.stop()
return None
pygame.time.delay(10)
return None
def _get_stereo_panning(self, playerPos, objPos, centerDistance=None):
"""Calculate stereo panning based on positions."""
# Extract x-positions
playerX = playerPos[0] if isinstance(playerPos, (tuple, list)) else playerPos
objX = objPos[0] if isinstance(objPos, (tuple, list)) else objPos
# For directional sound with fixed distance
if centerDistance is not None:
if abs(playerX - objX) <= centerDistance:
return (1, 1) # Center
elif playerX > objX:
return (1, 0.505) # Left
else:
return (0.505, 1) # Right
# Calculate regular panning
volume, left, right = self.calculate_volume_and_pan(playerPos, objPos)
return (volume * left, volume * right) if volume > 0 else (0, 0)
def play_sound(self, soundName, volume=1.0, loop=False, playerPos=None, objPos=None,
centerDistance=None, pattern=False, interrupt=False, pause=False, cutScene=False):
"""Unified method to play sounds with various options."""
# Resolve sound name if pattern matching is requested
if pattern:
soundName = self._find_matching_sound(soundName)
if not soundName:
return None
# Check if sound exists
if soundName not in self.sounds:
return None
# Handle cut scene mode
if cutScene:
return self._handle_cutscene(soundName)
# Handle interrupt (stop other sounds)
if interrupt:
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
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
# Apply positional volume adjustments
channel.set_volume(volume * left_vol * sfx_volume, volume * right_vol * sfx_volume)
else:
# Non-positional sound
channel.set_volume(volume * sfx_volume)
# Pause execution if requested
if pause:
time.sleep(self.sounds[soundName].get_length())
return channel
def calculate_volume_and_pan(self, playerPos, objPos, maxDistance=12):
"""Calculate volume and stereo panning based on relative positions."""
# Determine if we're using 2D or 1D positioning
if isinstance(playerPos, (tuple, list)) and isinstance(objPos, (tuple, list)):
# 2D distance calculation
distance = math.sqrt((playerPos[0] - objPos[0])**2 + (playerPos[1] - objPos[1])**2)
playerX, objX = playerPos[0], objPos[0]
else:
# 1D calculation (backward compatible)
distance = abs(playerPos - objPos)
playerX, objX = playerPos, objPos
if distance > maxDistance:
return 0, 0, 0 # No sound if out of range
# Calculate volume (non-linear scaling for more noticeable changes)
volume = (((maxDistance - distance) / maxDistance) ** 1.5) * self.volumeService.masterVolume
# Determine left/right based on relative position
if playerX < objX: # Object is to the right
left = max(0, 1 - (objX - playerX) / maxDistance)
right = 1
elif playerX > objX: # Object is to the left
left = 1
right = max(0, 1 - (playerX - objX) / maxDistance)
else: # Player is on the object
left = right = 1
return volume, left, right
def update_sound_position(self, channel, playerPos, objPos):
"""Update positional audio for a playing sound."""
if not channel:
return None
# Calculate new stereo panning
left_vol, right_vol = self._get_stereo_panning(playerPos, objPos)
# Stop if out of range
if left_vol == 0 and right_vol == 0:
channel.stop()
return None
# Apply the volume and pan
channel.set_volume(left_vol * self.volumeService.sfxVolume, right_vol * self.volumeService.sfxVolume)
return channel
def stop_sound(self, channel):
"""Stop a playing sound channel."""
if channel:
try:
channel.stop()
except:
pass
return None
def play_falling_sound(self, soundPrefix, playerPos, objPos, startY, currentY=0, maxY=20, existingChannel=None):
"""Play or update a sound with positional audio that changes with height."""
# Extract positions
playerX = playerPos[0] if isinstance(playerPos, (tuple, list)) else playerPos
objX = objPos[0] if isinstance(objPos, (tuple, list)) else objPos
# Calculate volumes
volume, left, right = self.calculate_volume_and_pan(playerX, objX)
# Apply vertical fall multiplier (0 at maxY, 1 at y=0)
fallMultiplier = 1 - (currentY / maxY)
finalVolume = volume * fallMultiplier
finalLeft = left * finalVolume
finalRight = right * finalVolume
# Update existing channel or create new one
if existingChannel:
if volume == 0:
existingChannel.stop()
return None
existingChannel.set_volume(
finalLeft * self.volumeService.sfxVolume,
finalRight * self.volumeService.sfxVolume
)
return existingChannel
else:
if volume == 0:
return None
# Find a matching sound
soundName = self._find_matching_sound(soundPrefix)
if not soundName:
return None
# Play the sound
channel = self.sounds[soundName].play()
if channel:
channel.set_volume(
finalLeft * self.volumeService.sfxVolume,
finalRight * self.volumeService.sfxVolume
)
return channel
def play_bgm(self, musicFile):
"""Play background music with proper volume settings."""
try:
pygame.mixer.music.stop()
pygame.mixer.music.load(musicFile)
pygame.mixer.music.set_volume(self.volumeService.get_bgm_volume())
pygame.mixer.music.play(-1)
except Exception as e:
print(f"Error playing background music: {e}")
def adjust_master_volume(self, change):
"""Adjust the master volume for all sounds."""
self.volumeService.adjust_master_volume(change, pygame.mixer)
def adjust_bgm_volume(self, change):
"""Adjust only the background music volume."""
self.volumeService.adjust_bgm_volume(change, pygame.mixer)
def adjust_sfx_volume(self, change):
"""Adjust volume for sound effects only."""
self.volumeService.adjust_sfx_volume(change, pygame.mixer)
# Optimized helper functions for global use
def _get_stereo_panning(playerPos, objPos, centerDistance=None, maxDistance=12):
"""Simplified panning calculation."""
# Extract x-positions
playerX = playerPos[0] if isinstance(playerPos, (tuple, list)) else playerPos
objX = objPos[0] if isinstance(objPos, (tuple, list)) else objPos
# For directional sound with fixed distance
if centerDistance is not None:
if abs(playerX - objX) <= centerDistance:
return (1, 1) # Center
elif playerX > objX:
return (1, 0.505) # Left
else:
return (0.505, 1) # Right
# Calculate distance
if isinstance(playerPos, (tuple, list)) and isinstance(objPos, (tuple, list)):
distance = math.sqrt((playerPos[0] - objPos[0])**2 + (playerPos[1] - objPos[1])**2)
else:
distance = abs(playerPos - objPos)
if distance > maxDistance:
return (0, 0) # No sound if out of range
# Calculate volume (non-linear scaling for more noticeable changes)
volume = (((maxDistance - distance) / maxDistance) ** 1.5) * volumeService.masterVolume
# Determine left/right based on relative position
if playerX < objX: # Object is to the right
left = max(0, 1 - (objX - playerX) / maxDistance)
right = 1
elif playerX > objX: # Object is to the left
left = 1
right = max(0, 1 - (playerX - objX) / maxDistance)
else: # Player is on the object
left = right = 1
return (volume * left, volume * right)
def _play_cutscene(sound, sounds=None):
"""Play a sound as a cut scene."""
pygame.event.clear()
pygame.mixer.stop()
channel = pygame.mixer.Channel(0)
sfxVolume = volumeService.get_sfx_volume()
channel.set_volume(sfxVolume, sfxVolume)
# Determine which sound to play
if isinstance(sound, pygame.mixer.Sound):
channel.play(sound)
elif isinstance(sounds, dict) and sound in sounds:
channel.play(sounds[sound])
elif isinstance(sounds, Sound) and sound in sounds.sounds:
channel.play(sounds.sounds[sound])
else:
return None
# Wait for completion or key press
while pygame.mixer.get_busy():
for event in pygame.event.get():
if event.type == pygame.KEYDOWN and event.key in [pygame.K_ESCAPE, pygame.K_RETURN, pygame.K_SPACE]:
pygame.mixer.stop()
return None
pygame.time.delay(10)
return None
def _find_matching_sound(soundPattern, sounds):
"""Find sounds matching a pattern in a dictionary."""
if isinstance(sounds, Sound):
keys = [k for k in sounds.sounds.keys() if re.match("^" + soundPattern + ".*", k)]
else:
keys = [k for k in sounds.keys() if re.match("^" + soundPattern + ".*", k)]
return random.choice(keys) if keys else None
# Global functions for backward compatibility
def play_bgm(musicFile):
"""Play background music with proper volume settings."""
try:
pygame.mixer.music.stop()
pygame.mixer.music.load(musicFile)
pygame.mixer.music.set_volume(volumeService.get_bgm_volume())
pygame.mixer.music.play(-1)
except: pass
def adjust_master_volume(change):
"""Adjust the master volume."""
volumeService.adjust_master_volume(change, pygame.mixer)
def adjust_bgm_volume(change):
"""Adjust background music volume."""
volumeService.adjust_bgm_volume(change, pygame.mixer)
def adjust_sfx_volume(change):
"""Adjust sound effects volume."""
volumeService.adjust_sfx_volume(change, pygame.mixer)
def calculate_volume_and_pan(playerPos, objPos, maxDistance=12):
"""Calculate volume and stereo panning."""
left_vol, right_vol = _get_stereo_panning(playerPos, objPos, None, maxDistance)
# Convert to old format (volume, left, right)
if left_vol == 0 and right_vol == 0:
return 0, 0, 0
elif left_vol >= right_vol:
volume = left_vol
return volume, 1, right_vol/left_vol
else:
volume = right_vol
return volume, left_vol/right_vol, 1
def play_sound(sound_or_name, volume=1.0, loop=False, playerPos=None, objPos=None,
centerDistance=None, pattern=False, interrupt=False, pause=False,
cutScene=False, sounds=None):
"""Unified sound playing function with backward compatibility."""
# Handle cut scene mode
if cutScene:
return _play_cutscene(sound_or_name, sounds)
# Handle pattern matching
if pattern and isinstance(sound_or_name, str) and sounds:
matched_sound = _find_matching_sound(sound_or_name, sounds)
if not matched_sound:
return None
sound_or_name = matched_sound
# Handle interrupt
if interrupt:
pygame.event.clear()
pygame.mixer.stop()
# Case 1: Sound instance provided
if isinstance(sound_or_name, Sound):
return sound_or_name.play_sound(sound_or_name, volume, loop, playerPos, objPos,
centerDistance, False, False, pause, False)
# Case 2: Sound name with Sound instance
elif isinstance(sounds, Sound) and isinstance(sound_or_name, str):
return sounds.play_sound(sound_or_name, volume, loop, playerPos, objPos,
centerDistance, False, False, pause, False)
# Case 3: Direct pygame.Sound
elif isinstance(sound_or_name, pygame.mixer.Sound):
channel = sound_or_name.play(-1 if loop else 0)
if channel:
channel.set_volume(volume * volumeService.get_sfx_volume())
return channel
# Case 4: Sound name with dictionary
elif isinstance(sounds, dict) and isinstance(sound_or_name, str) and sound_or_name in sounds:
# 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)
# Pause if requested
if pause:
time.sleep(sounds[sound_or_name].get_length())
return channel
return None
def obj_update(channel, playerPos, objPos):
"""Update positional audio for a playing sound."""
if not channel:
return None
left_vol, right_vol = _get_stereo_panning(playerPos, objPos)
if left_vol == 0 and right_vol == 0:
channel.stop()
return None
channel.set_volume(left_vol * volumeService.sfxVolume, right_vol * volumeService.sfxVolume)
return channel
def obj_stop(channel):
"""Stop a sound channel."""
if channel:
try: channel.stop()
except: pass
return None
# Extremely concise lambda definitions for legacy functions
obj_play = lambda sounds, soundName, playerPos, objPos, loop=True: play_sound(
soundName, 1.0, loop, playerPos, objPos, None, False, False, False, False, sounds)
play_ambiance = lambda sounds, soundNames, probability, randomLocation=False: play_sound(
random.choice(soundNames) if random.randint(1, 100) <= probability and not any(
pygame.mixer.find_channel(True) and pygame.mixer.find_channel(True).get_busy()
for _ in ([soundNames] if isinstance(soundNames, str) else soundNames)) else None,
1.0, False, None, None, None, False, False, False, False,
sounds if not isinstance(sounds, Sound) else None)
play_random = lambda sounds, soundName, pause=False, interrupt=False: play_sound(
soundName, 1.0, False, None, None, None, True, interrupt, pause, False, sounds)
play_random_positional = lambda sounds, soundName, playerX, objectX: play_sound(
soundName, 1.0, False, playerX, objectX, None, True, False, False, False, sounds)
play_directional_sound = lambda sounds, soundName, playerPos, objPos, centerDistance=3, volume=1.0: play_sound(
soundName, volume, False, playerPos, objPos, centerDistance, False, False, False, False, sounds)
cut_scene = lambda sounds, soundName: _play_cutscene(soundName, sounds)
def play_random_falling(sounds, soundName, playerX, objectX, startY, currentY=0, maxY=20, existingChannel=None):
"""Handle falling sound."""
if isinstance(sounds, Sound):
return sounds.play_falling_sound(soundName, playerX, objectX, startY, currentY, maxY, existingChannel)
# Legacy implementation
left_vol, right_vol = _get_stereo_panning(playerX, objectX)
if left_vol == 0 and right_vol == 0:
if existingChannel:
existingChannel.stop()
return None
# Calculate fall multiplier
fallMultiplier = 1 - (currentY / maxY)
finalLeft = left_vol * fallMultiplier
finalRight = right_vol * fallMultiplier
if existingChannel:
existingChannel.set_volume(
finalLeft * volumeService.sfxVolume,
finalRight * volumeService.sfxVolume
)
return existingChannel
# Find matching sound
matched_sound = _find_matching_sound(soundName, sounds)
if not matched_sound:
return None
# Play the sound
channel = sounds[matched_sound].play()
if channel:
channel.set_volume(
finalLeft * volumeService.sfxVolume,
finalRight * volumeService.sfxVolume
)
return channel

149
speech.py Normal file
View File

@ -0,0 +1,149 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Speech handling for Storm Games.
Provides functionality for:
- Text-to-speech using different speech providers
- Speech delay control to prevent stuttering
- On-screen text display
"""
import pygame
import textwrap
import time
from sys import exit
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
self.spd = speechd.Client()
self.provider = self.spd
self.providerName = "speechd"
return
except ImportError:
pass
# Try accessible_output2 next
try:
import accessible_output2.outputs.auto
self.ao2 = accessible_output2.outputs.auto.Auto()
self.provider = self.ao2
self.providerName = "accessible_output2"
return
except ImportError:
pass
# No speech providers found
print("No speech providers found.")
def speak(self, text, interrupt=True):
"""Speak text using the configured speech provider and display on screen.
Args:
text (str): Text to speak and display
interrupt (bool): Whether to interrupt current speech (default: True)
"""
if 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
currentTime - self.lastSpoken["time"] < self.speechDelay):
return
# Update last spoken tracking
self.lastSpoken["text"] = text
self.lastSpoken["time"] = currentTime
# Proceed with speech
if self.providerName == "speechd":
if interrupt:
self.spd.cancel()
self.spd.say(text)
elif self.providerName == "accessible_output2":
self.ao2.speak(text, interrupt=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
wrappedText = textwrap.wrap(text, width=maxWidth // font.size('A')[0])
# Render each line
textSurfaces = [font.render(line, True, (255, 255, 255)) for line in wrappedText]
screen.fill((0, 0, 0)) # Clear screen with black
# Calculate total height of text block
totalHeight = sum(surface.get_height() for surface in textSurfaces)
# Start y-position (centered vertically)
currentY = (screen.get_height() - totalHeight) // 2
# Blit each line of text
for surface in textSurfaces:
textRect = surface.get_rect(center=(screen.get_width() // 2, currentY + surface.get_height() // 2))
screen.blit(surface, textRect)
currentY += surface.get_height()
pygame.display.flip()
def close(self):
"""Clean up speech resources."""
if self.providerName == "speechd":
self.spd.close()
# Global instance for backward compatibility
_speechInstance = None
def speak(text, interrupt=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)
"""
global _speechInstance
if _speechInstance is None:
_speechInstance = Speech.get_instance()
_speechInstance.speak(text, interrupt)
def messagebox(text):
"""Display a simple message box with text.
Shows a message that can be repeated until the user chooses to continue.
Args:
text (str): Message to display
"""
speech = Speech.get_instance()
speech.speak(text + "\nPress any key to repeat or enter to continue.")
while True:
event = pygame.event.wait()
if event.type == pygame.KEYDOWN:
if event.key in (pygame.K_ESCAPE, pygame.K_RETURN):
speech.speak(" ")
return
speech.speak(text + "\nPress any key to repeat or enter to continue.")

454
utils.py Normal file
View File

@ -0,0 +1,454 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Utility functions and Game class for Storm Games.
Provides:
- Game class for centralized management
- Miscellaneous helper functions
- Version checking utilities
"""
import pygame
import random
import math
import numpy as np
import time
import re
import requests
import os
from .input import check_for_exit
from setproctitle import setproctitle
from .services import PathService, ConfigService, VolumeService
from .sound import Sound
from .speech import Speech
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
if 'game-intro' in self.sound.sounds:
self.sound.cut_scene('game-intro')
return self
def speak(self, text, interrupt=True):
"""Speak text using the speech system.
Args:
text (str): Text to speak
interrupt (bool): Whether to interrupt current speech
Returns:
Game: Self for method chaining
"""
self.speech.speak(text, interrupt)
return self
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 "
"the text line by line. Press c to copy the current line to the clipboard "
"or t to copy the entire text. Press enter or escape when you are done reading.")
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:
self.volumeService.adjust_master_volume(0.1, pygame.mixer)
elif event.key == pygame.K_PAGEDOWN:
self.volumeService.adjust_master_volume(-0.1, pygame.mixer)
elif event.key == pygame.K_HOME:
self.volumeService.adjust_bgm_volume(0.1, pygame.mixer)
elif event.key == pygame.K_END:
self.volumeService.adjust_bgm_volume(-0.1, pygame.mixer)
elif event.key == pygame.K_INSERT:
self.volumeService.adjust_sfx_volume(0.1, pygame.mixer)
elif event.key == pygame.K_DELETE:
self.volumeService.adjust_sfx_volume(-0.1, pygame.mixer)
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
pyperclip.copy(navText[currentIndex])
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
# Join with newlines to preserve blank lines in full text
pyperclip.copy(''.join(originalText[2:-1]))
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":
self.speech.close()
pygame.mixer.music.stop()
pygame.quit()
import sys
sys.exit()
# Utility functions
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
"""
try:
response = requests.get(url, timeout=5)
if response.status_code == 200:
data = response.json()
if 'version' in data and data['version'] > currentVersion:
return {
'version': data['version'],
'url': data.get('url', ''),
'notes': data.get('notes', '')
}
except Exception as e:
print(f"Error checking for updates: {e}")
return None
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
"""
return tuple(map(int, versionStr.split('.')))
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
"""
req = get_version_tuple(requiredVersion)
cur = get_version_tuple(currentVersion)
return cur >= req
def sanitize_filename(filename):
"""Sanitize a filename to be safe for all operating systems.
Args:
filename (str): Original filename
Returns:
str: Sanitized filename
"""
# Remove invalid characters
filename = re.sub(r'[\\/*?:"<>|]', "", filename)
# Replace spaces with underscores
filename = filename.replace(" ", "_")
# Limit length
if len(filename) > 255:
filename = filename[:255]
return 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
"""
return start + (end - start) * 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
"""
# Scale, bias and saturate x to 0..1 range
x = max(0.0, min(1.0, (x - edge0) / (edge1 - edge0)))
# Evaluate polynomial
return x * x * (3 - 2 * 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
"""
return math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)
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
stereoTone = (stereoTone * 32767 * volume).astype(np.int16) # Apply volume
stereoTone = np.ascontiguousarray(stereoTone) # Ensure C-contiguous array
return pygame.sndarray.make_sound(stereoTone)
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
rightVolume = (position + 50) / 100
tone = generate_tone(frequency)
channel = tone.play()
channel.set_volume(leftVolume, rightVolume)
# Visual representation
screen.fill((0, 0, 0))
barWidth = screen.get_width() - 40 # Leave 20px margin on each side
pygame.draw.rect(screen, (100, 100, 100), (20, screen.get_height() // 2 - barHeight // 2, barWidth, barHeight))
markerPos = int(20 + (position + 50) / 100 * barWidth)
pygame.draw.rect(screen, (255, 0, 0), (markerPos - 5, screen.get_height() // 2 - barHeight, 10, barHeight * 2))
pygame.display.flip()
for event in pygame.event.get():
check_for_exit()
if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
channel.stop()
return position # This will return a value between -50 and 50
position += direction
if position > 50:
position = 50
direction = -1
elif position < -50:
position = -50
direction = 1
clock.tick(40) # Speed of bar
def y_powerbar():
"""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
pygame.draw.rect(screen, (100, 100, 100), (screen.get_width() // 2 - barWidth // 2, 20, barWidth, barHeight))
markerPos = int(20 + (100 - power) / 100 * barHeight)
pygame.draw.rect(screen, (255, 0, 0), (screen.get_width() // 2 - barWidth, markerPos - 5, barWidth * 2, 10))
pygame.display.flip()
for event in pygame.event.get():
check_for_exit()
if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
channel.stop()
return power
power += direction
if power >= 100 or power <= 0:
direction *= -1 # Reverse direction at limits
clock.tick(40)