Compare commits

..

68 Commits

Author SHA1 Message Date
Storm Dragon
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
Storm Dragon
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
Storm Dragon
7902dfacd1 Pressing enter interrupts messagebox messages. 2025-02-26 03:09:43 -05:00
Storm Dragon
10b46fa168 Only show display_text message once per session. 2025-02-26 02:55:52 -05:00
Storm Dragon
e7d5b03e55 Adjust volume a bit for directional play. 2025-02-24 14:51:01 -05:00
Storm Dragon
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
Storm Dragon
2f791da5b7 One more addition to pause. 2025-02-16 17:23:19 -05:00
Storm Dragon
9997b684ca Infinite loops are bad. 2025-02-16 17:19:47 -05:00
Storm Dragon
e7caff3d0f Fixed a bug with pause game. 2025-02-16 17:04:59 -05:00
Storm Dragon
db6c34f714 Improved pause_game. 2025-02-16 17:02:33 -05:00
Storm Dragon
3862b36d56 Pause game function added. 2025-02-16 16:52:29 -05:00
Storm Dragon
8bfe968b4b Added a small delay so the congratulations message for scoreboard can speak. 2025-02-15 12:32:08 -05:00
Storm Dragon
e2c69e3af7 Fixed a problem with an extra line being added at the beginning of copied text. 2025-02-15 02:55:01 -05:00
Storm Dragon
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
Storm Dragon
1cb57391d8 Updated requirements.txt 2025-02-15 01:50:17 -05:00
Storm Dragon
7f62e6ccca Forgot to specify global when checking the speech delay. 2025-02-15 00:06:23 -05:00
Storm Dragon
943e2acf53 Try to keep speech form stuttering with status keys. 2025-02-14 23:58:26 -05:00
Storm Dragon
c242fc6832 Make sure scores are int only. 2025-02-14 21:37:20 -05:00
Storm Dragon
6d2c6e04d8 Fixed a bug with score sorting. 2025-02-14 20:46:38 -05:00
Storm Dragon
97431c0c74 Updated the congrats message for the scoreboard. 2025-02-14 19:41:10 -05:00
Storm Dragon
4a15f951f0 Rewrote the scoreboard class. Added ability to enter name for scoreboard. 2025-02-14 16:50:04 -05:00
Storm Dragon
5a791510ea Rewrote the scoreboard class. 2025-02-14 16:45:28 -05:00
Storm Dragon
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
Storm Dragon
b479811a98 Binding for volume keys changed to alt+pageup/down, alt+home/end, etc. 2025-02-08 19:21:14 -05:00
Storm Dragon
d5d737d0c0 Make intro sound skipable. 2025-02-08 16:13:46 -05:00
Storm Dragon
dd246db5be Added w and s as alternate navigation keys for libstormgames menus. 2025-02-07 02:12:34 -05:00
Storm Dragon
21216f361a removed duplicate function. 2025-02-06 12:37:35 -05:00
Storm Dragon
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
Storm Dragon
80fe2caff3 Oops, accidentally removed the obj_update function. 2025-02-03 23:59:46 -05:00
Storm Dragon
2df86c9c76 Add the ability for obj_play to play a sound once. 2025-02-03 23:49:08 -05:00
Storm Dragon
5fa90f9e84 Updated play_falling_random. 2025-02-03 21:58:02 -05:00
Storm Dragon
658709ebce Random play functions for positional audio and positional falling audio. 2025-02-02 17:04:04 -05:00
Storm Dragon
d5c79c0770 Hopefully last fix to messagebox. This turned out to be harder than I originally thought. 2025-02-01 15:14:29 -05:00
Storm Dragon
24f9a126d4 I think I was over complicating it. 2025-02-01 15:11:22 -05:00
Storm Dragon
c316d4e570 I think I actually got it this time. 2025-02-01 15:05:13 -05:00
Storm Dragon
e66655d75f Another attempt to keep messagebox from double speaking. 2025-02-01 14:59:46 -05:00
Storm Dragon
c5406d5089 Attempt to keep messagebox from double speaking. 2025-02-01 14:53:55 -05:00
Storm Dragon
b5b472eebe Added simple message box for spoken text that might need to be repeated. 2025-02-01 14:41:52 -05:00
Storm Dragon
9f03de15b8 Add a text entry using wx. 2024-08-01 21:10:27 -04:00
Storm Dragon
428a48678d Power bars are now visual as well as audio. 2024-07-22 22:20:15 -04:00
Storm Dragon
9a6d6374f9 Added text along with the speak command. Updated window size. 2024-07-22 16:08:42 -04:00
Storm Dragon
df386cbbd9 Improved check_for_exit function. 2024-07-16 14:22:36 -04:00
Storm Dragon
38522aee78 First pass at creating requirements files. 2024-07-15 19:52:02 -04:00
Storm Dragon
0e9c52f5e1 A few fixes for x-powerbar, more accurately exiting when exit keys are pressed, etc. 2024-07-14 03:51:35 -04:00
Storm Dragon
fabf48ff42 Powerbars added and working. 2024-07-13 03:03:32 -04:00
Storm Dragon
0c73e98876 Improved sound while walking on left/right only. 2024-07-05 17:24:23 -04:00
Storm Dragon
0ef11785ec Revert "A new try at sound panning with walking."
This reverts commit 58ab5aa854.
2024-07-05 17:04:12 -04:00
Storm Dragon
58ab5aa854 A new try at sound panning with walking. 2024-06-20 02:03:06 -04:00
Storm Dragon
155ed6ec39 Updated donation link to use ko-fi. 2022-01-09 01:59:30 -05:00
Storm Dragon
68ad08be46 moved the libstormgames.py file to __init__.py so you can just import libstormgames. 2020-09-15 19:35:59 -04:00
Storm Dragon
536659338e Updated the object placement code code so that if sounds are out of range they still clame the channel, but they do not play at an audible level.[A 2020-09-10 18:02:53 -04:00
Storm Dragon
37aa764d68 Small update to object positioning. 2020-09-10 13:44:51 -04:00
Storm Dragon
84a722bb8e Attempt to fix object positioning. 2020-09-10 02:51:33 -04:00
Storm Dragon
b897abf0a3 Quickly jump to the top or bottom of the game menu with home or end keys. 2020-09-09 21:38:59 -04:00
Storm Dragon
678af54346 More work on the sound functions for object placement. 2020-09-09 20:55:40 -04:00
Storm Dragon
d456b8b3b3 Fixed a couple bugs on the object positioning code. 2020-09-09 20:41:41 -04:00
Storm Dragon
c5c32943e2 Added the rest of the object management functions, hopefully. 2020-09-09 20:06:34 -04:00
Storm Dragon
34d89ca54b Started work on sound positioning for objects. 2020-09-09 19:50:56 -04:00
Storm Dragon
e8bf4f9565 Scoreboard now works! Finally! 2020-09-04 11:17:05 -04:00
Storm Dragon
dd350c0285 Scoreboard still not saving past the first position. 2020-08-30 02:00:07 -04:00
Storm Dragon
ae93e02e69 Another approach to writing the scoreboard. 2020-08-30 01:41:52 -04:00
Storm Dragon
21c0795ea9 Updated scoreboard. 2020-08-30 01:23:32 -04:00
Storm Dragon
67d2315cef Finally fixed the scoreboard I think. 2020-08-29 22:11:06 -04:00
Storm Dragon
b6afb5450e New_High_Score method added. 2019-12-11 23:31:19 -05:00
Storm Dragon
42266d4b6c More progress on the scoreboard. Not complete yet. 2019-12-11 23:09:31 -05:00
Storm Dragon
54842bac29 Scoreboard mostly working. It doesn't read the existing scores correctly yet though. 2019-12-11 21:45:52 -05:00
Storm Dragon
08f06699c8 Merge branch 'devel'
Merged in set process name
2019-12-11 09:38:30 -05:00
Storm Dragon
7ef11be54c New scoreboard feature added. Partially implimented. 2019-12-11 09:32:50 -05:00
14 changed files with 3221 additions and 278 deletions

629
README.md
View File

@@ -1,8 +1,627 @@
# 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
```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, "play_game", "instructions", "credits", "donate", "exit_game")
if choice == "play_game":
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 (using fluent API)
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(), "play_game", "instructions", "credits", "donate", "exit_game")
if choice == "play_game":
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.ogg")
# 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()
```
### 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")
```
### 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
speech.close()
```
### 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']}")
```
## 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
# Display a menu with options (functions should exist with these names)
choice = sg.game_menu(sounds, "play_game", "high_scores", "instructions", "credits", "exit_game")
# Display built-in instructions
sg.instructions()
# Display built-in credits
sg.credits()
# Open donation page
sg.donate()
# Interactive menu to learn available sounds
sg.learn_sounds(sounds)
```
### 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, "play_game", "settings",
"instructions", "credits", "donate", "exit_game")
if choice == "play_game":
self.play_game()
elif 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. **Speech feedback**:
- Provide clear speech feedback for all actions
- Use the `interrupt` parameter to control speech priority
5. **Sound positioning**:
- Use positional audio to create an immersive environment
- Update object positions as the game state changes
6. **Configuration**:
- Save user preferences using the Config class
- Load settings at startup
## 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
## 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.

219
__init__.py Normal file → Executable file
View File

@@ -0,0 +1,219 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Standard initializations and functions shared by all Storm Games.
This module provides core functionality for Storm Games including:
- Sound and speech handling
- Volume controls
- Configuration management
- Score tracking
- GUI initialization
- Game menu systems
"""
# 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
# 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
)
__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',
# Game class
'Game',
# Utils
'check_for_updates', 'get_version_tuple', 'check_compatibility',
'sanitize_filename', 'lerp', 'smooth_step', 'distance_2d',
# 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
config_service = ConfigService.get_instance()
volume_service = VolumeService.get_instance()
path_service = PathService.get_instance()
# Set up backward compatibility hooks for initialize_gui
_original_initialize_gui = initialize_gui
def initialize_gui_with_services(game_title):
"""Wrapper around initialize_gui that initializes services."""
# Initialize path service
path_service.initialize(game_title)
# Connect config service to path service
config_service.set_game_info(game_title, path_service)
# Call original initialize_gui
return _original_initialize_gui(game_title)
# Replace initialize_gui with the wrapped version
initialize_gui = initialize_gui_with_services
# Initialize global scoreboard constructor
_original_scoreboard_init = Scoreboard.__init__
def scoreboard_init_with_services(self, score=0, config_service=None, speech=None):
"""Wrapper around Scoreboard.__init__ that ensures services are initialized."""
# Use global services if not specified
if config_service is None:
config_service = ConfigService.get_instance()
# Ensure path_service is connected if using defaults
if not hasattr(config_service, 'path_service') and path_service.game_path is not None:
config_service.path_service = path_service
# Call original init with services
_original_scoreboard_init(self, score, config_service, 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
def sin(x):
"""Return the sine of x radians."""
return math.sin(x)
def cos(x):
"""Return the cosine of x radians."""
return math.cos(x)
def sqrt(x):
"""Return the square root of x."""
return math.sqrt(x)
def floor(x):
"""Return the floor of x."""
return math.floor(x)
def ceil(x):
"""Return the ceiling of x."""
return math.ceil(x)
# Re-export random functions that might be used
import random
def randint(a, b):
"""Return a random integer N such that a <= N <= b."""
return random.randint(a, b)
def choice(seq):
"""Return a random element from the non-empty sequence seq."""
return random.choice(seq)
def uniform(a, b):
"""Return a random floating point number N such that a <= N <= b."""
return random.uniform(a, b)
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, game_title):
"""Initialize configuration system for a game.
Args:
game_title (str): Title of the game
"""
self.game_title = game_title
self.global_path = os.path.join(BaseDirectory.xdg_config_home, "storm-games")
self.game_path = os.path.join(self.global_path,
str.lower(str.replace(game_title, " ", "-")))
# Create game directory if it doesn't exist
if not os.path.exists(self.game_path):
os.makedirs(self.game_path)
# Initialize config parsers
self.local_config = configparser.ConfigParser()
self.global_config = 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.game_path, "config.ini"), 'r') as configfile:
self.local_config.read_file(configfile)
except:
pass
def read_global_config(self):
"""Read global configuration from file."""
try:
with open(os.path.join(self.global_path, "config.ini"), 'r') as configfile:
self.global_config.read_file(configfile)
except:
pass
def write_local_config(self):
"""Write local configuration to file."""
with open(os.path.join(self.game_path, "config.ini"), 'w') as configfile:
self.local_config.write(configfile)
def write_global_config(self):
"""Write global configuration to file."""
with open(os.path.join(self.global_path, "config.ini"), 'w') as configfile:
self.global_config.write(configfile)
# Global variables for backward compatibility
localConfig = configparser.ConfigParser()
globalConfig = configparser.ConfigParser()
gamePath = ""
globalPath = ""
def write_config(write_global=False):
"""Write configuration to file.
Args:
write_global (bool): If True, write to global config, otherwise local (default: False)
"""
if not write_global:
with open(gamePath + "/config.ini", 'w') as configfile:
localConfig.write(configfile)
else:
with open(globalPath + "/config.ini", 'w') as configfile:
globalConfig.write(configfile)
def read_config(read_global=False):
"""Read configuration from file.
Args:
read_global (bool): If True, read global config, otherwise local (default: False)
"""
if not read_global:
try:
with open(gamePath + "/config.ini", 'r') as configfile:
localConfig.read_file(configfile)
except:
pass
else:
try:
with open(globalPath + "/config.ini", 'r') as configfile:
globalConfig.read_file(configfile)
except:
pass

174
display.py Normal file
View File

@@ -0,0 +1,174 @@
#!/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
path_service = 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
try:
from os import listdir
from os.path import isfile, join
soundFiles = [f for f in listdir("sounds/")
if isfile(join("sounds/", f))
and (f.split('.')[1].lower() in ["ogg", "wav"])]
except Exception as e:
print("No sounds found.")
Speech.get_instance().speak("No sounds found.", False)
soundFiles = []
# Create dictionary of sound objects
soundData = {}
for f in soundFiles:
soundData[f.split('.')[0]] = pygame.mixer.Sound("sounds/" + f)
# Play intro sound if available
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()
volume_service = 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()
alt_pressed = mods & pygame.KMOD_ALT
# Volume controls (require Alt)
if alt_pressed:
if event.key == pygame.K_PAGEUP:
volume_service.adjust_master_volume(0.1, pygame.mixer)
elif event.key == pygame.K_PAGEDOWN:
volume_service.adjust_master_volume(-0.1, pygame.mixer)
elif event.key == pygame.K_HOME:
volume_service.adjust_bgm_volume(0.1, pygame.mixer)
elif event.key == pygame.K_END:
volume_service.adjust_bgm_volume(-0.1, pygame.mixer)
elif event.key == pygame.K_INSERT:
volume_service.adjust_sfx_volume(0.1, pygame.mixer)
elif event.key == pygame.K_DELETE:
volume_service.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()

View File

@@ -1,273 +0,0 @@
#!/bin/python
# -*- coding: utf-8 -*-
"""Standard initializations and functions shared by all 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 pygame
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()
import time
localConfig = configparser.ConfigParser()
globalConfig = configparser.ConfigParser()
class scoreboard():
'Handles scores and top 10'
def __init__(self):
self.oldScores = []
for i in range(9):
try:
self.oldScores[i] = read_config("scoreboard", i)
except:
self.oldScores[i] = 0
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(section, value, readGlobal = False):
if readGlobal == False:
with open(gamePath + "config.ini", 'r') as configfile:
return localConfig.read(section, value)
else:
with open(globalPath + "config.ini", 'r') as configfile:
return globalConfig.read(section, value)
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()
pygame.mixer.music.stop()
pygame.quit()
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, " ", "")))
# start pygame
pygame.init()
# start the display (required by the event loop)
pygame.display.set_mode((320, 200))
pygame.display.set_caption(gameTitle)
# Set 32 channels for sound by default
pygame.mixer.init()
pygame.mixer.set_num_channels(32)
# Reserve the cut scene channel
pygame.mixer.set_reserved(0)
# Load sounds from the sound directory and creates a list like that {'bottle': 'bottle.ogg'}
soundFiles = [f for f in listdir("sounds/") if isfile(join("sounds/", f)) and (f.split('.')[1].lower() in ["ogg","wav"])]
#lets make a dict with pygame.mixer.Sound() objects {'bottle':<soundobject>}
soundData = {}
for f in soundFiles:
soundData[f.split('.')[0]] = pygame.mixer.Sound("sounds/" + f)
soundData['game-intro'].play()
time.sleep(soundData['game-intro'].get_length())
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 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
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_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://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=stormdragon2976@gmail.com&lc=US&item_name=Donation+to+Storm+Games&no_note=0&cn=&currency_code=USD&bn=PP-DonationsBF:btn_donateCC_LG.gif:NonHosted')

286
menu.py Normal file
View File

@@ -0,0 +1,286 @@
#!/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
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 Speech
from .sound import adjust_master_volume, adjust_bgm_volume, adjust_sfx_volume
from .display import display_text
def game_menu(sounds, *options):
"""Display and handle the main game menu.
Provides menu navigation with:
- Up/Down arrows for selection
- Home/End for first/last option
- Enter to select
- Escape to exit
- Volume controls (with Alt modifier):
- Alt+PageUp/PageDown: Master volume up/down
- Alt+Home/End: Background music volume up/down
- Alt+Insert/Delete: Sound effects volume up/down
Args:
sounds (dict): Dictionary of sound objects
*options: Variable list of menu option names (strings)
Returns:
str: Selected menu option or "exit" if user pressed escape
"""
# Get speech instance
speech = Speech.get_instance()
loop = True
pygame.mixer.stop()
if pygame.mixer.music.get_busy():
pygame.mixer.music.unpause()
else:
try:
from .sound import play_bgm
play_bgm("sounds/music_menu.ogg")
except:
pass
currentIndex = 0
lastSpoken = -1 # Track last spoken index
while loop:
if currentIndex != lastSpoken:
speech.speak(options[currentIndex])
lastSpoken = currentIndex
event = pygame.event.wait()
if event.type == pygame.KEYDOWN:
# Check for Alt modifier
mods = pygame.key.get_mods()
alt_pressed = mods & pygame.KMOD_ALT
# Volume controls (require Alt)
if alt_pressed:
if event.key == pygame.K_PAGEUP:
adjust_master_volume(0.1)
elif event.key == pygame.K_PAGEDOWN:
adjust_master_volume(-0.1)
elif event.key == pygame.K_HOME:
adjust_bgm_volume(0.1)
elif event.key == pygame.K_END:
adjust_bgm_volume(-0.1)
elif event.key == pygame.K_INSERT:
adjust_sfx_volume(0.1)
elif event.key == pygame.K_DELETE:
adjust_sfx_volume(-0.1)
# Regular menu navigation (no Alt required)
else:
if event.key == pygame.K_ESCAPE:
exit_game()
elif event.key == pygame.K_HOME:
if currentIndex != 0:
currentIndex = 0
try:
sounds['menu-move'].play()
except:
pass
if options[currentIndex] != "donate":
pygame.mixer.music.unpause()
elif event.key == pygame.K_END:
if currentIndex != len(options) - 1:
currentIndex = len(options) - 1
try:
sounds['menu-move'].play()
except:
pass
if options[currentIndex] != "donate":
pygame.mixer.music.unpause()
elif event.key in [pygame.K_DOWN, pygame.K_s] and currentIndex < len(options) - 1:
currentIndex += 1
try:
sounds['menu-move'].play()
except:
pass
if options[currentIndex] != "donate":
pygame.mixer.music.unpause()
elif event.key in [pygame.K_UP, pygame.K_w] and currentIndex > 0:
currentIndex -= 1
try:
sounds['menu-move'].play()
except:
pass
if options[currentIndex] != "donate":
pygame.mixer.music.unpause()
elif event.key == pygame.K_RETURN:
try:
lastSpoken = -1
try:
sounds['menu-select'].play()
time.sleep(sounds['menu-select'].get_length())
except:
pass
eval(options[currentIndex] + "()")
except:
lastSpoken = -1
pygame.mixer.music.fadeout(500)
try:
pygame.mixer.music.fadeout(750)
time.sleep(1.0)
except:
pass
return options[currentIndex]
event = pygame.event.clear()
time.sleep(0.001)
def learn_sounds(sounds):
"""Interactive menu for learning game sounds.
Allows users to:
- Navigate through available sounds
- Play selected sounds
- Return to menu with escape key
Args:
sounds (dict): Dictionary of available sound objects
Returns:
str: "menu" if user exits with escape
"""
# Get speech instance
speech = Speech.get_instance()
loop = True
try:
pygame.mixer.music.pause()
except:
pass
currentIndex = 0
# Get list of available sounds, excluding special sounds
soundFiles = [f for f in listdir("sounds/")
if isfile(join("sounds/", f))
and (f.split('.')[1].lower() in ["ogg", "wav"])
and (f.split('.')[0].lower() not in ["game-intro", "music_menu"])
and (not f.lower().startswith("_"))]
# Track last spoken index to avoid repetition
lastSpoken = -1
while loop:
if currentIndex != lastSpoken:
speech.speak(soundFiles[currentIndex][:-4])
lastSpoken = currentIndex
event = pygame.event.wait()
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
try:
pygame.mixer.music.unpause()
except:
pass
return "menu"
if event.key in [pygame.K_DOWN, pygame.K_s] and currentIndex < len(soundFiles) - 1:
pygame.mixer.stop()
currentIndex += 1
if event.key in [pygame.K_UP, pygame.K_w] and currentIndex > 0:
pygame.mixer.stop()
currentIndex -= 1
if event.key == pygame.K_RETURN:
try:
soundName = soundFiles[currentIndex][:-4]
pygame.mixer.stop()
sounds[soundName].play()
except:
lastSpoken = -1
speech.speak("Could not play sound.")
event = pygame.event.clear()
time.sleep(0.001)
def instructions():
"""Display game instructions from file.
Reads and displays instructions from 'files/instructions.txt'.
If file is missing, displays an error message.
"""
try:
pygame.mixer.music.pause()
except:
pass
try:
with open('files/instructions.txt', 'r') as f:
info = f.readlines()
except:
info = ["Instructions file is missing."]
display_text(info)
try:
pygame.mixer.music.unpause()
except:
pass
def credits():
"""Display game credits from file.
Reads and displays credits from 'files/credits.txt'.
Adds game name header before displaying.
If file is missing, displays an error message.
"""
try:
pygame.mixer.music.pause()
except:
pass
try:
with open('files/credits.txt', 'r') as f:
info = f.readlines()
# Add the header
from .config import gameName
info.insert(0, gameName + ": brought to you by Storm Dragon")
except:
info = ["Credits file is missing."]
display_text(info)
try:
pygame.mixer.music.unpause()
except:
pass
def donate():
"""Open the donation webpage.
Pauses background music and opens the Ko-fi donation page.
"""
pygame.mixer.music.pause()
webbrowser.open('https://ko-fi.com/stormux')
def exit_game():
"""Clean up and exit the game."""
# Get speech instance and check provider type
speech = Speech.get_instance()
if speech.provider_name == "speechd":
speech.close()
pygame.mixer.music.stop()
pygame.quit()
exit()

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

176
scoreboard.py Normal file
View File

@@ -0,0 +1,176 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Scoreboard handling for Storm Games.
Provides functionality for:
- Tracking high scores with player names
- Saving/loading high scores from configuration
"""
import time
from .services import ConfigService
from .speech import Speech
# 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, config_service=None, speech=None):
"""Initialize scoreboard.
Args:
score (int): Initial score (default: 0)
config_service (ConfigService): Config service (default: global instance)
speech (Speech): Speech system (default: global instance)
"""
self.config_service = config_service or ConfigService.get_instance()
self.speech = speech or Speech.get_instance()
self.current_score = score
self.high_scores = []
# For backward compatibility
read_config()
try:
# Try to use config_service
self.config_service.local_config.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 config_service
score = self.config_service.local_config.getint("scoreboard", f"score_{i}")
name = self.config_service.local_config.get("scoreboard", f"name_{i}")
self.high_scores.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.high_scores.append({
'name': name,
'score': score
})
except:
self.high_scores.append({
'name': "Player",
'score': 0
})
# Sort high scores by score value in descending order
self.high_scores.sort(key=lambda x: x['score'], reverse=True)
def get_score(self):
"""Get current score."""
return self.current_score
def get_high_scores(self):
"""Get list of high scores."""
return self.high_scores
def decrease_score(self, points=1):
"""Decrease the current score."""
self.current_score -= int(points)
return self
def increase_score(self, points=1):
"""Increase the current score."""
self.current_score += int(points)
return self
def set_score(self, score):
"""Set the current score to a specific value."""
self.current_score = int(score)
return self
def reset_score(self):
"""Reset the current score to zero."""
self.current_score = 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.high_scores):
if self.current_score > 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
"""
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.high_scores.insert(position - 1, {
'name': name,
'score': self.current_score
})
# Keep only top 10
self.high_scores = self.high_scores[:10]
# Save to config - try both methods for maximum compatibility
try:
# Try new method first
for i, entry in enumerate(self.high_scores):
self.config_service.local_config.set("scoreboard", f"score_{i+1}", str(entry['score']))
self.config_service.local_config.set("scoreboard", f"name_{i+1}", entry['name'])
# Try to write with config_service
try:
self.config_service.write_local_config()
except Exception as e:
# Fallback to old method if config_service fails
for i, entry in enumerate(self.high_scores):
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:
# If all else fails, try direct old method
for i, entry in enumerate(self.high_scores):
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

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.local_config = configparser.ConfigParser()
self.global_config = configparser.ConfigParser()
self.game_title = None
self.path_service = None
def set_game_info(self, game_title, path_service):
"""Set game information and initialize configs.
Args:
game_title (str): Title of the game
path_service (PathService): Path service instance
"""
self.game_title = game_title
self.path_service = path_service
# 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 path_service if available
if self.path_service and self.path_service.game_path:
with open(os.path.join(self.path_service.game_path, "config.ini"), 'r') as configfile:
self.local_config.read_file(configfile)
# Fallback to global gamePath
elif gamePath:
with open(os.path.join(gamePath, "config.ini"), 'r') as configfile:
self.local_config.read_file(configfile)
# Delegate to old function as last resort
else:
read_config(False)
self.local_config = configparser.ConfigParser()
self.local_config.read_dict(globals().get('localConfig', {}))
except:
pass
def read_global_config(self):
"""Read global configuration from file."""
try:
# Try to use path_service if available
if self.path_service and self.path_service.global_path:
with open(os.path.join(self.path_service.global_path, "config.ini"), 'r') as configfile:
self.global_config.read_file(configfile)
# Fallback to global globalPath
elif globalPath:
with open(os.path.join(globalPath, "config.ini"), 'r') as configfile:
self.global_config.read_file(configfile)
# Delegate to old function as last resort
else:
read_config(True)
self.global_config = configparser.ConfigParser()
self.global_config.read_dict(globals().get('globalConfig', {}))
except:
pass
def write_local_config(self):
"""Write local configuration to file."""
try:
# Try to use path_service if available
if self.path_service and self.path_service.game_path:
with open(os.path.join(self.path_service.game_path, "config.ini"), 'w') as configfile:
self.local_config.write(configfile)
# Fallback to global gamePath
elif gamePath:
with open(os.path.join(gamePath, "config.ini"), 'w') as configfile:
self.local_config.write(configfile)
# Delegate to old function as last resort
else:
# Update old global config
globals()['localConfig'] = self.local_config
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 path_service if available
if self.path_service and self.path_service.global_path:
with open(os.path.join(self.path_service.global_path, "config.ini"), 'w') as configfile:
self.global_config.write(configfile)
# Fallback to global globalPath
elif globalPath:
with open(os.path.join(globalPath, "config.ini"), 'w') as configfile:
self.global_config.write(configfile)
# Delegate to old function as last resort
else:
# Update old global config
globals()['globalConfig'] = self.global_config
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.bgm_volume = 0.75 # Default background music volume
self.sfx_volume = 1.0 # Default sound effects volume
self.master_volume = 1.0 # Default master volume
def adjust_master_volume(self, change, pygame_mixer=None):
"""Adjust the master volume for all sounds.
Args:
change (float): Amount to change volume by (positive or negative)
pygame_mixer: Optional pygame.mixer module for real-time updates
"""
self.master_volume = max(0.0, min(1.0, self.master_volume + change))
# Update real-time audio if pygame mixer is provided
if pygame_mixer:
# Update music volume
if pygame_mixer.music.get_busy():
pygame_mixer.music.set_volume(self.bgm_volume * self.master_volume)
# Update all sound channels
for i in range(pygame_mixer.get_num_channels()):
channel = pygame_mixer.Channel(i)
if channel.get_busy():
current_volume = channel.get_volume()
if isinstance(current_volume, (int, float)):
# Mono audio
channel.set_volume(current_volume * self.master_volume)
else:
# Stereo audio
left, right = current_volume
channel.set_volume(left * self.master_volume, right * self.master_volume)
def adjust_bgm_volume(self, change, pygame_mixer=None):
"""Adjust only the background music volume.
Args:
change (float): Amount to change volume by (positive or negative)
pygame_mixer: Optional pygame.mixer module for real-time updates
"""
self.bgm_volume = max(0.0, min(1.0, self.bgm_volume + change))
# Update real-time audio if pygame mixer is provided
if pygame_mixer and pygame_mixer.music.get_busy():
pygame_mixer.music.set_volume(self.bgm_volume * self.master_volume)
def adjust_sfx_volume(self, change, pygame_mixer=None):
"""Adjust volume for sound effects only.
Args:
change (float): Amount to change volume by (positive or negative)
pygame_mixer: Optional pygame.mixer module for real-time updates
"""
self.sfx_volume = max(0.0, min(1.0, self.sfx_volume + change))
# Update real-time audio if pygame mixer is provided
if pygame_mixer:
# Update all sound channels except reserved ones
for i in range(pygame_mixer.get_num_channels()):
channel = pygame_mixer.Channel(i)
if channel.get_busy():
current_volume = channel.get_volume()
if isinstance(current_volume, (int, float)):
# Mono audio
channel.set_volume(current_volume * self.sfx_volume * self.master_volume)
else:
# Stereo audio
left, right = current_volume
channel.set_volume(left * self.sfx_volume * self.master_volume,
right * self.sfx_volume * self.master_volume)
def get_bgm_volume(self):
"""Get the current BGM volume with master adjustment.
Returns:
float: Current adjusted BGM volume
"""
return self.bgm_volume * self.master_volume
def get_sfx_volume(self):
"""Get the current SFX volume with master adjustment.
Returns:
float: Current adjusted SFX volume
"""
return self.sfx_volume * self.master_volume
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.global_path = None
self.game_path = None
self.game_name = None
# Try to initialize from global variables for backward compatibility
global gamePath, globalPath
if gamePath:
self.game_path = gamePath
if globalPath:
self.global_path = globalPath
def initialize(self, game_title):
"""Initialize paths for a game.
Args:
game_title (str): Title of the game
"""
self.game_name = game_title
self.global_path = os.path.join(BaseDirectory.xdg_config_home, "storm-games")
self.game_path = os.path.join(self.global_path,
str.lower(str.replace(game_title, " ", "-")))
# Create game directory if it doesn't exist
if not os.path.exists(self.game_path):
os.makedirs(self.game_path)
# Update global variables for backward compatibility
global gamePath, globalPath
gamePath = self.game_path
globalPath = self.global_path
return self

780
sound.py Normal file
View File

@@ -0,0 +1,780 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Sound handling for Storm Games.
Provides functionality for:
- Playing background music and sound effects
- Positional audio
- Volume controls
"""
import pygame
import random
import re
import time
from os import listdir
from os.path import isfile, join
from .services import VolumeService
# Global instance for backward compatibility
volume_service = VolumeService.get_instance()
class Sound:
"""Handles sound loading and playback."""
def __init__(self, sound_dir="sounds/", volume_service=None):
"""Initialize sound system.
Args:
sound_dir (str): Directory containing sound files (default: "sounds/")
volume_service (VolumeService): Volume service (default: global instance)
"""
self.sound_dir = sound_dir
self.sounds = {}
self.volume_service = volume_service or VolumeService.get_instance()
# Initialize pygame mixer if not already done
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) # Reserve channel for cut scenes
# Load sounds
self.load_sounds()
def load_sounds(self):
"""Load all sound files from the sound directory."""
try:
sound_files = [f for f in listdir(self.sound_dir)
if isfile(join(self.sound_dir, f))
and (f.split('.')[1].lower() in ["ogg", "wav"])]
# Create dictionary of sound objects
for f in sound_files:
self.sounds[f.split('.')[0]] = pygame.mixer.Sound(join(self.sound_dir, f))
except Exception as e:
print(f"Error loading sounds: {e}")
def play_intro(self):
"""Play the game intro sound if available."""
if 'game-intro' in self.sounds:
self.cut_scene('game-intro')
def get_sounds(self):
"""Get the dictionary of loaded sound objects.
Returns:
dict: Dictionary of loaded sound objects
"""
return self.sounds
def play_bgm(self, music_file):
"""Play background music with proper volume settings.
Args:
music_file (str): Path to the music file to play
"""
try:
pygame.mixer.music.stop()
pygame.mixer.music.load(music_file)
pygame.mixer.music.set_volume(self.volume_service.get_bgm_volume())
pygame.mixer.music.play(-1) # Loop indefinitely
except Exception as e:
pass
def play_sound(self, sound_name, volume=1.0):
"""Play a sound with current volume settings applied.
Args:
sound_name (str): Name of sound to play
volume (float): Base volume for the sound (0.0-1.0, default: 1.0)
Returns:
pygame.mixer.Channel: The channel the sound is playing on
"""
if sound_name not in self.sounds:
return None
sound = self.sounds[sound_name]
channel = sound.play()
if channel:
channel.set_volume(volume * self.volume_service.get_sfx_volume())
return channel
def calculate_volume_and_pan(self, player_pos, obj_pos, max_distance=12):
"""Calculate volume and stereo panning based on relative positions.
Args:
player_pos (float): Player's position on x-axis
obj_pos (float): Object's position on x-axis
max_distance (float): Maximum audible distance (default: 12)
Returns:
tuple: (volume, left_vol, right_vol) values between 0 and 1
"""
distance = abs(player_pos - obj_pos)
if distance > max_distance:
return 0, 0, 0 # No sound if out of range
# Calculate volume (non-linear scaling for more noticeable changes)
# Apply masterVolume as the maximum possible volume
volume = (((max_distance - distance) / max_distance) ** 1.5) * self.volume_service.master_volume
# Determine left/right based on relative position
if player_pos < obj_pos:
# Object is to the right
left = max(0, 1 - (obj_pos - player_pos) / max_distance)
right = 1
elif player_pos > obj_pos:
# Object is to the left
left = 1
right = max(0, 1 - (player_pos - obj_pos) / max_distance)
else:
# Player is on the object
left = right = 1
return volume, left, right
def obj_play(self, sound_name, player_pos, obj_pos, loop=True):
"""Play a sound with positional audio.
Args:
sound_name (str): Name of sound to play
player_pos (float): Player's position for audio panning
obj_pos (float): Object's position for audio panning
loop (bool): Whether to loop the sound (default: True)
Returns:
pygame.mixer.Channel: Sound channel object, or None if out of range
"""
if sound_name not in self.sounds:
return None
volume, left, right = self.calculate_volume_and_pan(player_pos, obj_pos)
if volume == 0:
return None # Don't play if out of range
# Play the sound on a new channel
channel = self.sounds[sound_name].play(-1 if loop else 0)
if channel:
channel.set_volume(
volume * left * self.volume_service.sfx_volume,
volume * right * self.volume_service.sfx_volume
)
return channel
def obj_update(self, channel, player_pos, obj_pos):
"""Update positional audio for a playing sound.
Args:
channel: Sound channel to update
player_pos (float): New player position
obj_pos (float): New object position
Returns:
pygame.mixer.Channel: Updated channel, or None if sound should stop
"""
if channel is None:
return None
volume, left, right = self.calculate_volume_and_pan(player_pos, obj_pos)
if volume == 0:
channel.stop()
return None
# Apply the volume and pan
channel.set_volume(
volume * left * self.volume_service.sfx_volume,
volume * right * self.volume_service.sfx_volume
)
return channel
def obj_stop(self, channel):
"""Stop a playing sound channel.
Args:
channel: Sound channel to stop
Returns:
None if stopped successfully, otherwise returns original channel
"""
try:
channel.stop()
return None
except:
return channel
def play_ambiance(self, sound_names, probability, random_location=False):
"""Play random ambient sounds with optional positional audio.
Args:
sound_names (list): List of possible sound names to choose from
probability (int): Chance to play (1-100)
random_location (bool): Whether to randomize stereo position
Returns:
pygame.mixer.Channel: Sound channel if played, None otherwise
"""
# Check if any of the sounds in the list is already playing
for sound_name in sound_names:
if pygame.mixer.find_channel(True) and pygame.mixer.find_channel(True).get_busy():
return None
if random.randint(1, 100) > probability:
return None
# Choose a random sound from the list
ambiance_sound = random.choice(sound_names)
if ambiance_sound not in self.sounds:
return None
channel = self.sounds[ambiance_sound].play()
if random_location and channel:
left_volume = random.random() * self.volume_service.get_sfx_volume()
right_volume = random.random() * self.volume_service.get_sfx_volume()
channel.set_volume(left_volume, right_volume)
return channel
def play_random(self, sound_prefix, pause=False, interrupt=False):
"""Play a random variation of a sound.
Args:
sound_prefix (str): Base name of sound (will match all starting with this)
pause (bool): Whether to pause execution until sound finishes
interrupt (bool): Whether to interrupt other sounds
"""
keys = []
for i in self.sounds.keys():
if re.match("^" + sound_prefix + ".*", i):
keys.append(i)
if not keys: # No matching sounds found
return None
random_key = random.choice(keys)
if interrupt:
self.cut_scene(random_key)
return
channel = self.sounds[random_key].play()
sfx_volume = self.volume_service.get_sfx_volume()
if channel:
channel.set_volume(sfx_volume, sfx_volume)
if pause:
time.sleep(self.sounds[random_key].get_length())
return channel
def play_random_positional(self, sound_prefix, player_x, object_x):
"""Play a random variation of a sound with positional audio.
Args:
sound_prefix (str): Base name of sound to match
player_x (float): Player's x position
object_x (float): Object's x position
Returns:
pygame.mixer.Channel: Sound channel if played, None otherwise
"""
keys = [k for k in self.sounds.keys() if k.startswith(sound_prefix)]
if not keys:
return None
random_key = random.choice(keys)
volume, left, right = self.calculate_volume_and_pan(player_x, object_x)
if volume == 0:
return None
channel = self.sounds[random_key].play()
if channel:
channel.set_volume(
volume * left * self.volume_service.sfx_volume,
volume * right * self.volume_service.sfx_volume
)
return channel
def play_directional_sound(self, sound_name, player_pos, obj_pos, center_distance=3, volume=1.0):
"""Play a sound with simplified directional audio.
For sounds that need to be heard clearly regardless of distance, but still provide
directional feedback. Sound plays at full volume but pans left/right based on relative position.
Args:
sound_name (str): Name of sound to play
player_pos (float): Player's x position
obj_pos (float): Object's x position
center_distance (float): Distance within which sound plays center (default: 3)
volume (float): Base volume multiplier (0.0-1.0, default: 1.0)
Returns:
pygame.mixer.Channel: The channel the sound is playing on
"""
if sound_name not in self.sounds:
return None
channel = self.sounds[sound_name].play()
if channel:
# Apply volume settings
final_volume = volume * self.volume_service.get_sfx_volume()
# If player is within centerDistance tiles of object, play in center
if abs(player_pos - obj_pos) <= center_distance:
# Equal volume in both speakers (center)
channel.set_volume(final_volume, final_volume)
elif player_pos > obj_pos:
# Object is to the left of player
channel.set_volume(final_volume, (final_volume + 0.01) / 2)
else:
# Object is to the right of player
channel.set_volume((final_volume + 0.01) / 2, final_volume)
return channel
def cut_scene(self, sound_name):
"""Play a sound as a cut scene, stopping other sounds.
Args:
sound_name (str): Name of sound to play
"""
if sound_name not in self.sounds:
return
pygame.event.clear()
pygame.mixer.stop()
# Get the reserved channel (0) for cut scenes
channel = pygame.mixer.Channel(0)
# Apply the appropriate volume settings
sfx_volume = self.volume_service.get_sfx_volume()
channel.set_volume(sfx_volume, sfx_volume)
# Play the sound
channel.play(self.sounds[sound_name])
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
pygame.time.delay(10)
def play_random_falling(self, sound_prefix, player_x, object_x, start_y,
current_y=0, max_y=20, existing_channel=None):
"""Play or update a falling sound with positional audio and volume based on height.
Args:
sound_prefix (str): Base name of sound to match
player_x (float): Player's x position
object_x (float): Object's x position
start_y (float): Starting Y position (0-20, higher = quieter start)
current_y (float): Current Y position (0 = ground level) (default: 0)
max_y (float): Maximum Y value (default: 20)
existing_channel: Existing sound channel to update (default: None)
Returns:
pygame.mixer.Channel: Sound channel for updating position/volume,
or None if sound should stop
"""
# Calculate horizontal positioning
volume, left, right = self.calculate_volume_and_pan(player_x, object_x)
# Calculate vertical fall volume multiplier (0 at max_y, 1 at y=0)
fall_multiplier = 1 - (current_y / max_y)
# Adjust final volumes
final_volume = volume * fall_multiplier
final_left = left * final_volume
final_right = right * final_volume
if existing_channel is not None:
if volume == 0: # Out of audible range
existing_channel.stop()
return None
existing_channel.set_volume(
final_left * self.volume_service.sfx_volume,
final_right * self.volume_service.sfx_volume
)
return existing_channel
else: # Need to create new channel
if volume == 0: # Don't start if out of range
return None
# Find matching sound files
keys = [k for k in self.sounds.keys() if k.startswith(sound_prefix)]
if not keys:
return None
random_key = random.choice(keys)
channel = self.sounds[random_key].play()
if channel:
channel.set_volume(
final_left * self.volume_service.sfx_volume,
final_right * self.volume_service.sfx_volume
)
return channel
# Global functions for backward compatibility
def play_bgm(music_file):
"""Play background music with proper volume settings.
Args:
music_file (str): Path to the music file to play
"""
try:
pygame.mixer.music.stop()
pygame.mixer.music.load(music_file)
pygame.mixer.music.set_volume(volume_service.get_bgm_volume())
pygame.mixer.music.play(-1) # Loop indefinitely
except Exception as e:
pass
def adjust_master_volume(change):
"""Adjust the master volume for all sounds.
Args:
change (float): Amount to change volume by (positive or negative)
"""
volume_service.adjust_master_volume(change, pygame.mixer)
def adjust_bgm_volume(change):
"""Adjust only the background music volume.
Args:
change (float): Amount to change volume by (positive or negative)
"""
volume_service.adjust_bgm_volume(change, pygame.mixer)
def adjust_sfx_volume(change):
"""Adjust volume for sound effects only.
Args:
change (float): Amount to change volume by (positive or negative)
"""
volume_service.adjust_sfx_volume(change, pygame.mixer)
def calculate_volume_and_pan(player_pos, obj_pos):
"""Calculate volume and stereo panning based on relative positions.
Args:
player_pos (float): Player's position on x-axis
obj_pos (float): Object's position on x-axis
Returns:
tuple: (volume, left_vol, right_vol) values between 0 and 1
"""
distance = abs(player_pos - obj_pos)
max_distance = 12 # Maximum audible distance
if distance > max_distance:
return 0, 0, 0 # No sound if out of range
# Calculate volume (non-linear scaling for more noticeable changes)
# Apply masterVolume as the maximum possible volume
volume = (((max_distance - distance) / max_distance) ** 1.5) * volume_service.master_volume
# Determine left/right based on relative position
if player_pos < obj_pos:
# Object is to the right
left = max(0, 1 - (obj_pos - player_pos) / max_distance)
right = 1
elif player_pos > obj_pos:
# Object is to the left
left = 1
right = max(0, 1 - (player_pos - obj_pos) / max_distance)
else:
# Player is on the object
left = right = 1
return volume, left, right
def play_sound(sound, volume=1.0):
"""Play a sound with current volume settings applied.
Args:
sound: pygame Sound object to play
volume: base volume for the sound (0.0-1.0, default: 1.0)
Returns:
pygame.mixer.Channel: The channel the sound is playing on
"""
channel = sound.play()
if channel:
channel.set_volume(volume * volume_service.get_sfx_volume())
return channel
def obj_play(sounds, soundName, player_pos, obj_pos, loop=True):
"""Play a sound with positional audio.
Args:
sounds (dict): Dictionary of sound objects
soundName (str): Name of sound to play
player_pos (float): Player's position for audio panning
obj_pos (float): Object's position for audio panning
loop (bool): Whether to loop the sound (default: True)
Returns:
pygame.mixer.Channel: Sound channel object, or None if out of range
"""
volume, left, right = calculate_volume_and_pan(player_pos, obj_pos)
if volume == 0:
return None # Don't play if out of range
# Play the sound on a new channel
channel = sounds[soundName].play(-1 if loop else 0)
if channel:
channel.set_volume(volume * left * volume_service.sfx_volume,
volume * right * volume_service.sfx_volume)
return channel
def obj_update(channel, player_pos, obj_pos):
"""Update positional audio for a playing sound.
Args:
channel: Sound channel to update
player_pos (float): New player position
obj_pos (float): New object position
Returns:
pygame.mixer.Channel: Updated channel, or None if sound should stop
"""
if channel is None:
return None
volume, left, right = calculate_volume_and_pan(player_pos, obj_pos)
if volume == 0:
channel.stop()
return None
# Apply the volume and pan
channel.set_volume(volume * left * volume_service.sfx_volume,
volume * right * volume_service.sfx_volume)
return channel
def obj_stop(channel):
"""Stop a playing sound channel.
Args:
channel: Sound channel to stop
Returns:
None if stopped successfully, otherwise returns original channel
"""
try:
channel.stop()
return None
except:
return channel
def play_ambiance(sounds, soundNames, probability, randomLocation=False):
"""Play random ambient sounds with optional positional audio.
Args:
sounds (dict): Dictionary of sound objects
soundNames (list): List of possible sound names to choose from
probability (int): Chance to play (1-100)
randomLocation (bool): Whether to randomize stereo position
Returns:
pygame.mixer.Channel: Sound channel if played, None otherwise
"""
# Check if any of the sounds in the list is already playing
for soundName in soundNames:
if pygame.mixer.find_channel(True) and pygame.mixer.find_channel(True).get_busy():
return None
if random.randint(1, 100) > probability:
return None
# Choose a random sound from the list
ambianceSound = random.choice(soundNames)
channel = sounds[ambianceSound].play()
if randomLocation and channel:
leftVolume = random.random() * volume_service.get_sfx_volume()
rightVolume = random.random() * volume_service.get_sfx_volume()
channel.set_volume(leftVolume, rightVolume)
return channel
def play_random(sounds, soundName, pause=False, interrupt=False):
"""Play a random variation of a sound.
Args:
sounds (dict): Dictionary of sound objects
soundName (str): Base name of sound (will match all starting with this)
pause (bool): Whether to pause execution until sound finishes
interrupt (bool): Whether to interrupt other sounds
"""
key = []
for i in sounds.keys():
if re.match("^" + soundName + ".*", i):
key.append(i)
if not key: # No matching sounds found
return
randomKey = random.choice(key)
if interrupt:
cut_scene(sounds, randomKey)
return
channel = sounds[randomKey].play()
if channel:
sfx_volume = volume_service.get_sfx_volume()
channel.set_volume(sfx_volume, sfx_volume)
if pause:
time.sleep(sounds[randomKey].get_length())
def play_random_positional(sounds, soundName, player_x, object_x):
"""Play a random variation of a sound with positional audio.
Args:
sounds (dict): Dictionary of sound objects
soundName (str): Base name of sound to match
player_x (float): Player's x position
object_x (float): Object's x position
Returns:
pygame.mixer.Channel: Sound channel if played, None otherwise
"""
keys = [k for k in sounds.keys() if k.startswith(soundName)]
if not keys:
return None
randomKey = random.choice(keys)
volume, left, right = calculate_volume_and_pan(player_x, object_x)
if volume == 0:
return None
channel = sounds[randomKey].play()
if channel:
channel.set_volume(volume * left * volume_service.sfx_volume,
volume * right * volume_service.sfx_volume)
return channel
def play_directional_sound(sounds, soundName, playerPos, objPos, centerDistance=3, volume=1.0):
"""Play a sound with simplified directional audio.
For sounds that need to be heard clearly regardless of distance, but still provide
directional feedback. Sound plays at full volume but pans left/right based on relative position.
Args:
sounds (dict): Dictionary of sound objects
soundName (str): Name of sound to play
playerPos (float): Player's x position
objPos (float): Object's x position
centerDistance (float): Distance within which sound plays center (default: 3)
volume (float): Base volume multiplier (0.0-1.0, default: 1.0)
Returns:
pygame.mixer.Channel: The channel the sound is playing on
"""
channel = sounds[soundName].play()
if channel:
# Apply volume settings
finalVolume = volume * volume_service.get_sfx_volume()
# If player is within centerDistance tiles of object, play in center
if abs(playerPos - objPos) <= centerDistance:
# Equal volume in both speakers (center)
channel.set_volume(finalVolume, finalVolume)
elif playerPos > objPos:
# Object is to the left of player
channel.set_volume(finalVolume, (finalVolume + 0.01) / 2)
else:
# Object is to the right of player
channel.set_volume((finalVolume + 0.01) / 2, finalVolume)
return channel
def cut_scene(sounds, soundName):
"""Play a sound as a cut scene, stopping other sounds.
Args:
sounds (dict): Dictionary of sound objects
soundName (str): Name of sound to play
"""
pygame.event.clear()
pygame.mixer.stop()
# Get the reserved channel (0) for cut scenes
channel = pygame.mixer.Channel(0)
# Apply the appropriate volume settings
sfx_volume = volume_service.get_sfx_volume()
channel.set_volume(sfx_volume, sfx_volume)
# Play the sound
channel.play(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
pygame.time.delay(10)
def play_random_falling(sounds, soundName, player_x, object_x, start_y,
currentY=0, max_y=20, existing_channel=None):
"""Play or update a falling sound with positional audio and volume based on height.
Args:
sounds (dict): Dictionary of sound objects
soundName (str): Base name of sound to match
player_x (float): Player's x position
object_x (float): Object's x position
start_y (float): Starting Y position (0-20, higher = quieter start)
currentY (float): Current Y position (0 = ground level) (default: 0)
max_y (float): Maximum Y value (default: 20)
existing_channel: Existing sound channel to update (default: None)
Returns:
pygame.mixer.Channel: Sound channel for updating position/volume,
or None if sound should stop
"""
# Calculate horizontal positioning
volume, left, right = calculate_volume_and_pan(player_x, object_x)
# Calculate vertical fall volume multiplier (0 at max_y, 1 at y=0)
fallMultiplier = 1 - (currentY / max_y)
# Adjust final volumes
finalVolume = volume * fallMultiplier
finalLeft = left * finalVolume
finalRight = right * finalVolume
if existing_channel is not None:
if volume == 0: # Out of audible range
existing_channel.stop()
return None
existing_channel.set_volume(finalLeft * volume_service.sfx_volume,
finalRight * volume_service.sfx_volume)
return existing_channel
else: # Need to create new channel
if volume == 0: # Don't start if out of range
return None
# Find matching sound files
keys = [k for k in sounds.keys() if k.startswith(soundName)]
if not keys:
return None
randomKey = random.choice(keys)
channel = sounds[randomKey].play()
if channel:
channel.set_volume(finalLeft * volume_service.sfx_volume,
finalRight * volume_service.sfx_volume)
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.last_spoken = {"text": None, "time": 0}
self.speech_delay = 250 # ms
# Try to initialize a speech provider
self.provider = None
self.provider_name = None
# Try speechd first
try:
import speechd
self.spd = speechd.Client()
self.provider = self.spd
self.provider_name = "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.provider_name = "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
current_time = pygame.time.get_ticks()
# Check if this is the same text within the delay window
if (self.last_spoken["text"] == text and
current_time - self.last_spoken["time"] < self.speech_delay):
return
# Update last spoken tracking
self.last_spoken["text"] = text
self.last_spoken["time"] = current_time
# Proceed with speech
if self.provider_name == "speechd":
if interrupt:
self.spd.cancel()
self.spd.say(text)
elif self.provider_name == "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
max_width = screen.get_width() - 40 # Leave a 20-pixel margin on each side
wrapped_text = textwrap.wrap(text, width=max_width // font.size('A')[0])
# Render each line
text_surfaces = [font.render(line, True, (255, 255, 255)) for line in wrapped_text]
screen.fill((0, 0, 0)) # Clear screen with black
# Calculate total height of text block
total_height = sum(surface.get_height() for surface in text_surfaces)
# Start y-position (centered vertically)
current_y = (screen.get_height() - total_height) // 2
# Blit each line of text
for surface in text_surfaces:
text_rect = surface.get_rect(center=(screen.get_width() // 2, current_y + surface.get_height() // 2))
screen.blit(surface, text_rect)
current_y += surface.get_height()
pygame.display.flip()
def close(self):
"""Clean up speech resources."""
if self.provider_name == "speechd":
self.spd.close()
# Global instance for backward compatibility
_speech_instance = 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 _speech_instance
if _speech_instance is None:
_speech_instance = Speech.get_instance()
_speech_instance.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.")

350
utils.py Normal file
View File

@@ -0,0 +1,350 @@
#!/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 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.path_service = PathService.get_instance().initialize(title)
self.config_service = ConfigService.get_instance()
self.config_service.set_game_info(title, self.path_service)
self.volume_service = VolumeService.get_instance()
# Initialize game components (lazy loaded)
self._speech = None
self._sound = None
self._scoreboard = None
# Display text instructions flag
self.display_text_usage_instructions = 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.volume_service)
return self._sound
@property
def scoreboard(self):
"""Get the scoreboard (lazy loaded).
Returns:
Scoreboard: Scoreboard instance
"""
if not self._scoreboard:
self._scoreboard = Scoreboard(self.config_service)
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, music_file):
"""Play background music.
Args:
music_file (str): Path to music file
Returns:
Game: Self for method chaining
"""
self.sound.play_bgm(music_file)
return self
def display_text(self, text_lines):
"""Display text with navigation controls.
Args:
text_lines (list): List of text lines
Returns:
Game: Self for method chaining
"""
# Store original text with blank lines for copying
original_text = text_lines.copy()
# Create navigation text by filtering out blank lines
nav_text = [line for line in text_lines if line.strip()]
# Add instructions at the start on the first display
if not self.display_text_usage_instructions:
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.")
nav_text.insert(0, instructions)
self.display_text_usage_instructions = True
# Add end marker
nav_text.append("End of text.")
current_index = 0
self.speech.speak(nav_text[current_index])
while True:
event = pygame.event.wait()
if event.type == pygame.KEYDOWN:
# Check for Alt modifier
mods = pygame.key.get_mods()
alt_pressed = mods & pygame.KMOD_ALT
# Volume controls (require Alt)
if alt_pressed:
if event.key == pygame.K_PAGEUP:
self.volume_service.adjust_master_volume(0.1, pygame.mixer)
elif event.key == pygame.K_PAGEDOWN:
self.volume_service.adjust_master_volume(-0.1, pygame.mixer)
elif event.key == pygame.K_HOME:
self.volume_service.adjust_bgm_volume(0.1, pygame.mixer)
elif event.key == pygame.K_END:
self.volume_service.adjust_bgm_volume(-0.1, pygame.mixer)
elif event.key == pygame.K_INSERT:
self.volume_service.adjust_sfx_volume(0.1, pygame.mixer)
elif event.key == pygame.K_DELETE:
self.volume_service.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 current_index < len(nav_text) - 1:
current_index += 1
self.speech.speak(nav_text[current_index])
if event.key in [pygame.K_UP, pygame.K_w] and current_index > 0:
current_index -= 1
self.speech.speak(nav_text[current_index])
if event.key == pygame.K_SPACE:
# Join with newlines to preserve spacing in speech
self.speech.speak('\n'.join(original_text[1:-1]))
if event.key == pygame.K_c:
try:
import pyperclip
pyperclip.copy(nav_text[current_index])
self.speech.speak("Copied " + nav_text[current_index] + " 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(original_text[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.provider_name == "speechd":
self.speech.close()
pygame.mixer.music.stop()
pygame.quit()
import sys
sys.exit()
# Utility functions
def check_for_updates(current_version, game_name, url):
"""Check for game updates.
Args:
current_version (str): Current version string (e.g. "1.0.0")
game_name (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'] > current_version:
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(version_str):
"""Convert version string to comparable tuple.
Args:
version_str (str): Version string (e.g. "1.0.0")
Returns:
tuple: Version as tuple of integers
"""
return tuple(map(int, version_str.split('.')))
def check_compatibility(required_version, current_version):
"""Check if current version meets minimum required version.
Args:
required_version (str): Minimum required version string
current_version (str): Current version string
Returns:
bool: True if compatible, False otherwise
"""
req = get_version_tuple(required_version)
cur = get_version_tuple(current_version)
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)