Compare commits
2 Commits
master
...
401b2a8527
Author | SHA1 | Date | |
---|---|---|---|
401b2a8527 | |||
639198e8de |
831
README.md
831
README.md
@ -1,829 +1,8 @@
|
||||
# libstormgames
|
||||
|
||||
A Python library to make creating audio games easier.
|
||||
Library to make writing audiogames easier.
|
||||
|
||||
|
||||
## Overview
|
||||
|
||||
`libstormgames` provides a comprehensive set of tools for developing accessible games with audio-first design. It handles common game development tasks including:
|
||||
|
||||
- Sound and music playback with positional audio
|
||||
- Text-to-speech integration
|
||||
- Configuration management
|
||||
- Score tracking and high score tables
|
||||
- Input handling and keyboard controls
|
||||
- Menu systems and text display
|
||||
- GUI initialization
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
### Requirements
|
||||
|
||||
- pygame>=2.0.0
|
||||
- pyperclip>=1.8.0
|
||||
- requests>=2.25.0
|
||||
- pyxdg>=0.27
|
||||
- setproctitle>=1.2.0
|
||||
- numpy>=1.19.0
|
||||
- wxpython
|
||||
|
||||
#### Speech Providers (one required)
|
||||
|
||||
- Linux/Unix: `python-speechd` or `accessible-output2>=0.14`
|
||||
- Windows/macOS: `accessible-output2>=0.14`
|
||||
|
||||
|
||||
### Install from source
|
||||
|
||||
If you are on Linux, check your package manager first to see if the packages in requirements.txt are available.
|
||||
|
||||
```bash
|
||||
git clone https://git.stormux.org/storm/libstormgames
|
||||
cd libstormgames
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
|
||||
## Getting Started
|
||||
|
||||
You can use libstormgames in two ways: the traditional function-based approach or the new class-based approach.
|
||||
|
||||
|
||||
### Traditional Function-Based Approach
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
import libstormgames as sg
|
||||
|
||||
def main():
|
||||
# Initialize the game
|
||||
sounds = sg.initialize_gui("My First Audio Game")
|
||||
|
||||
# Welcome message
|
||||
sg.speak("Welcome to My First Audio Game!")
|
||||
|
||||
# Create a scoreboard
|
||||
scoreboard = sg.Scoreboard()
|
||||
|
||||
# Main game loop
|
||||
def play_game():
|
||||
sg.speak("Game started!")
|
||||
scoreboard.increase_score(10)
|
||||
sg.speak(f"Your score is {scoreboard.get_score()}")
|
||||
scoreboard.add_high_score()
|
||||
return "menu" # Return to menu after game ends
|
||||
|
||||
# Define menu options
|
||||
while True:
|
||||
choice = sg.game_menu(sounds)
|
||||
if choice == "play":
|
||||
play_game()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
### Modern Class-Based Approach
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
import libstormgames as sg
|
||||
|
||||
def main():
|
||||
# Create and initialize a game
|
||||
game = sg.Game("My First Audio Game").initialize()
|
||||
|
||||
# Welcome message
|
||||
game.speak("Welcome to My First Audio Game!")
|
||||
|
||||
# Main game loop
|
||||
def play_game():
|
||||
game.speak("Game started!")
|
||||
game.scoreboard.increase_score(10)
|
||||
game.speak(f"Your score is {game.scoreboard.get_score()}")
|
||||
game.scoreboard.add_high_score()
|
||||
return "menu"
|
||||
|
||||
# Define menu options
|
||||
while True:
|
||||
choice = sg.game_menu(game.sound.get_sounds())
|
||||
if choice == "play":
|
||||
play_game()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
|
||||
## Library Structure
|
||||
|
||||
The library is organized into modules, each with a specific focus:
|
||||
|
||||
- **config**: Configuration management
|
||||
- **services**: Core services that replace global variables
|
||||
- **sound**: Sound and music playback
|
||||
- **speech**: Text-to-speech functionality
|
||||
- **scoreboard**: High score tracking
|
||||
- **input**: Input handling and dialogs
|
||||
- **display**: Text display and GUI functionality
|
||||
- **menu**: Menu systems
|
||||
- **utils**: Utility functions and Game class
|
||||
|
||||
|
||||
## Core Classes
|
||||
|
||||
### Game
|
||||
|
||||
The Game class provides a central way to manage all game systems:
|
||||
|
||||
```python
|
||||
# Create and initialize a game
|
||||
game = sg.Game("My Game").initialize()
|
||||
|
||||
# Use fluent API for chaining commands
|
||||
game.speak("Hello").play_bgm("music/theme")
|
||||
|
||||
# Access components directly
|
||||
game.scoreboard.increase_score(10)
|
||||
game.sound.play_random("explosion")
|
||||
|
||||
# Display text
|
||||
|
||||
game.display_text(["Line 1", "Line 2"])
|
||||
|
||||
# Clean exit
|
||||
|
||||
game.exit()
|
||||
```
|
||||
|
||||
The initialization process sets up proper configuration paths:
|
||||
- Creates game-specific directories if they don't exist
|
||||
- Ensures all services have access to correct paths
|
||||
- Connects all components for seamless operation
|
||||
|
||||
|
||||
### Services
|
||||
|
||||
The library includes several service classes that replace global variables:
|
||||
|
||||
```python
|
||||
# Volume service manages all volume settings
|
||||
volume = sg.VolumeService.get_instance()
|
||||
volume.adjust_master_volume(0.1)
|
||||
|
||||
# Path service manages file paths
|
||||
paths = sg.PathService.get_instance()
|
||||
print(paths.game_path)
|
||||
|
||||
# Config service manages configuration
|
||||
config = sg.ConfigService.get_instance()
|
||||
```
|
||||
|
||||
### Config
|
||||
|
||||
Handles configuration file management with local and global settings.
|
||||
|
||||
```python
|
||||
# Create a configuration manager
|
||||
config = sg.Config("My Game")
|
||||
|
||||
# Access the local configuration
|
||||
config.local_config.add_section("settings")
|
||||
config.local_config.set("settings", "difficulty", "easy")
|
||||
config.write_local_config()
|
||||
|
||||
# Read settings
|
||||
config.read_local_config()
|
||||
difficulty = config.local_config.get("settings", "difficulty")
|
||||
```
|
||||
|
||||
Configuration files are automatically stored in standard system locations:
|
||||
- Linux/Unix: `~/.config/storm-games/`
|
||||
- Windows: `%APPDATA%\storm-games\`
|
||||
- macOS: `~/Library/Application Support/storm-games/`
|
||||
|
||||
Each game has its own subdirectory based on the game name (lowercase with hyphens), for example, Wicked Quest becomes wicked-quest.
|
||||
|
||||
|
||||
### Sound
|
||||
|
||||
Manages sound loading and playback with positional audio support.
|
||||
|
||||
```python
|
||||
# Create a sound manager
|
||||
sound_system = sg.Sound("sounds/")
|
||||
|
||||
# Get the dictionary of loaded sounds
|
||||
sounds = sound_system.get_sounds()
|
||||
|
||||
# Play a sound
|
||||
sg.play_sound(sounds["explosion"])
|
||||
|
||||
# Play a sound with positional audio (player at x=5, object at x=10)
|
||||
channel = sg.obj_play(sounds, "footsteps", 5, 10)
|
||||
|
||||
# Update sound position as player or object moves
|
||||
channel = sg.obj_update(channel, 6, 10) # Player moved to x=6
|
||||
|
||||
# Stop the sound
|
||||
channel = sg.obj_stop(channel)
|
||||
|
||||
# Play background music
|
||||
sg.play_bgm("sounds/background.ogg")
|
||||
|
||||
# Adjust volume
|
||||
sg.adjust_master_volume(0.1) # Increase master volume
|
||||
sg.adjust_bgm_volume(-0.1) # Decrease background music volume
|
||||
sg.adjust_sfx_volume(0.1) # Increase sound effects volume
|
||||
```
|
||||
|
||||
|
||||
### Speech
|
||||
|
||||
Provides text-to-speech functionality using available speech providers.
|
||||
|
||||
```python
|
||||
# Create a speech manager (usually you'll use the global instance)
|
||||
speech = sg.Speech()
|
||||
|
||||
# Speak text
|
||||
speech.speak("Hello, world!")
|
||||
|
||||
# Or use the global function for convenience
|
||||
sg.speak("Hello, world!")
|
||||
|
||||
# Speak without interrupting previous speech
|
||||
sg.speak("This won't interrupt", interrupt=False)
|
||||
|
||||
# Clean up when done
|
||||
exit_game() or as the class, game.exit_game()
|
||||
```
|
||||
|
||||
|
||||
### Scoreboard
|
||||
|
||||
Tracks scores and manages high score tables.
|
||||
|
||||
```python
|
||||
# Create a scoreboard
|
||||
scoreboard = sg.Scoreboard()
|
||||
|
||||
# Manipulate score
|
||||
scoreboard.increase_score(10)
|
||||
scoreboard.decrease_score(5)
|
||||
current_score = scoreboard.get_score()
|
||||
|
||||
# Check for high score
|
||||
position = scoreboard.check_high_score()
|
||||
if position:
|
||||
print(f"You're in position {position}!")
|
||||
|
||||
# Add high score (prompts for player name)
|
||||
scoreboard.add_high_score()
|
||||
|
||||
# Get all high scores
|
||||
high_scores = scoreboard.get_high_scores()
|
||||
for entry in high_scores:
|
||||
print(f"{entry['name']}: {entry['score']}")
|
||||
```
|
||||
|
||||
The Scoreboard system automatically manages high score data in the game's configuration directory:
|
||||
- Linux/Unix: `~/.config/storm-games/game-name/config.ini`
|
||||
- Windows: `%APPDATA%\storm-games\game-name\config.ini`
|
||||
- macOS: `~/Library/Application Support/storm-games/game-name/config.ini`
|
||||
|
||||
Where `game-name` is the lowercase, hyphenated version of your game title. For example,
|
||||
"My Awesome Game" would use the directory `my-awesome-game`.
|
||||
|
||||
|
||||
## Key Functions
|
||||
|
||||
### Game Initialization and Control
|
||||
|
||||
```python
|
||||
# Initialize game systems (traditional approach)
|
||||
sounds = sg.initialize_gui("Game Title")
|
||||
|
||||
# Or use the Game class (modern approach)
|
||||
game = sg.Game("Game Title").initialize()
|
||||
|
||||
# Pause the game (freezes until user presses backspace)
|
||||
sg.pause_game()
|
||||
|
||||
# Check if user wants to exit
|
||||
if sg.check_for_exit():
|
||||
sg.exit_game()
|
||||
|
||||
# Exit the game properly
|
||||
sg.exit_game()
|
||||
```
|
||||
|
||||
### Menu System
|
||||
|
||||
```python
|
||||
# Basic menu example (returns option string)
|
||||
choice = sg.game_menu(sounds)
|
||||
if choice == "play":
|
||||
# Start the game
|
||||
play_game()
|
||||
elif choice == "instructions":
|
||||
sg.instructions()
|
||||
# etc...
|
||||
|
||||
# Menu with play callback (callback is executed directly)
|
||||
def play_callback():
|
||||
sg.speak("Game started with callback!")
|
||||
# Game code goes here
|
||||
|
||||
sg.game_menu(sounds, play_callback)
|
||||
|
||||
# Menu with custom options
|
||||
choice = sg.game_menu(sounds, None, "practice_mode")
|
||||
if choice == "practice_mode":
|
||||
# Handle practice mode
|
||||
practice_game()
|
||||
elif choice == "difficulty":
|
||||
# Handle difficulty settings
|
||||
set_difficulty()
|
||||
elif choice == "options":
|
||||
# Handle options screen
|
||||
show_options()
|
||||
# etc...
|
||||
```
|
||||
|
||||
|
||||
### Complete Menu Example
|
||||
|
||||
```python
|
||||
import libstormgames as sg
|
||||
import pygame
|
||||
|
||||
def main():
|
||||
# Initialize the game
|
||||
sounds = sg.initialize_gui("Menu Example Game")
|
||||
|
||||
# Play menu music
|
||||
sg.play_bgm("sounds/music_menu.ogg")
|
||||
|
||||
# Define play function with callback
|
||||
def play_game():
|
||||
sg.speak("Starting game!")
|
||||
# Game code here
|
||||
sg.speak("Game over!")
|
||||
return "menu" # Return to menu after game ends
|
||||
|
||||
# Define custom menu option handlers
|
||||
def practice_mode():
|
||||
sg.speak("Starting practice mode!")
|
||||
# Practice mode code here
|
||||
return "menu"
|
||||
|
||||
def difficulty_settings():
|
||||
# Show difficulty options
|
||||
options = ["easy", "normal", "hard", "back"]
|
||||
current = 0
|
||||
|
||||
while True:
|
||||
sg.speak(options[current])
|
||||
|
||||
event = pygame.event.wait()
|
||||
if event.type == pygame.KEYDOWN:
|
||||
if event.key == pygame.K_UP and current > 0:
|
||||
current -= 1
|
||||
elif event.key == pygame.K_DOWN and current < len(options) - 1:
|
||||
current += 1
|
||||
elif event.key == pygame.K_RETURN:
|
||||
if options[current] == "back":
|
||||
return "menu"
|
||||
sg.speak(f"Difficulty set to {options[current]}")
|
||||
return "menu"
|
||||
elif event.key == pygame.K_ESCAPE:
|
||||
return "menu"
|
||||
|
||||
# Main menu loop
|
||||
while True:
|
||||
# Display menu with play callback and custom options
|
||||
choice = sg.game_menu(sounds, play_game, "practice_mode", "difficulty")
|
||||
|
||||
# Handle menu choices
|
||||
if choice == "practice_mode":
|
||||
practice_mode()
|
||||
elif choice == "difficulty":
|
||||
difficulty_settings()
|
||||
# Other options are handled automatically by game_menu
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
|
||||
### Class-Based Menu Example
|
||||
|
||||
```python
|
||||
import libstormgames as sg
|
||||
import pygame
|
||||
|
||||
class MyGame:
|
||||
def __init__(self):
|
||||
# Create and initialize game
|
||||
self.game = sg.Game("Class-Based Menu Example").initialize()
|
||||
self.sounds = self.game.sound.get_sounds()
|
||||
|
||||
def play_game(self):
|
||||
self.game.speak("Starting game!")
|
||||
# Game code here
|
||||
self.game.speak("Game over!")
|
||||
return "menu"
|
||||
|
||||
def practice_mode(self):
|
||||
self.game.speak("Starting practice mode!")
|
||||
# Practice mode code here
|
||||
return "menu"
|
||||
|
||||
def settings(self):
|
||||
# A nested menu example
|
||||
submenu_options = ["graphics", "audio", "controls", "back"]
|
||||
current = 0
|
||||
|
||||
while True:
|
||||
self.game.speak(submenu_options[current])
|
||||
|
||||
event = pygame.event.wait()
|
||||
if event.type == pygame.KEYDOWN:
|
||||
if event.key == pygame.K_UP and current > 0:
|
||||
current -= 1
|
||||
elif event.key == pygame.K_DOWN and current < len(submenu_options) - 1:
|
||||
current += 1
|
||||
elif event.key == pygame.K_RETURN:
|
||||
if submenu_options[current] == "back":
|
||||
return "menu"
|
||||
self.game.speak(f"Selected {submenu_options[current]} settings")
|
||||
return "menu"
|
||||
elif event.key == pygame.K_ESCAPE:
|
||||
return "menu"
|
||||
|
||||
def run(self):
|
||||
# Main menu loop
|
||||
while True:
|
||||
# Use playCallback parameter to directly call play_game when "play" is selected
|
||||
choice = sg.game_menu(self.sounds, None, "practice_mode",
|
||||
"settings")
|
||||
|
||||
# Handle other menu options
|
||||
if choice == "practice_mode":
|
||||
self.practice_mode()
|
||||
elif choice == "settings":
|
||||
self.settings()
|
||||
|
||||
# Run the game
|
||||
if __name__ == "__main__":
|
||||
game = MyGame()
|
||||
game.run()
|
||||
```
|
||||
|
||||
|
||||
### Text Display
|
||||
|
||||
```python
|
||||
# Display text with navigation
|
||||
sg.display_text([
|
||||
"Line 1 of instructions",
|
||||
"Line 2 of instructions",
|
||||
"Line 3 of instructions"
|
||||
])
|
||||
|
||||
# Display a simple message box
|
||||
sg.messagebox("Game Over! You scored 100 points.")
|
||||
|
||||
# Get text input from user
|
||||
name = sg.get_input("Enter your name:", "Player")
|
||||
```
|
||||
|
||||
|
||||
### Sound Effects
|
||||
|
||||
```python
|
||||
# Play a random variation of a sound
|
||||
sg.play_random(sounds, "explosion")
|
||||
|
||||
# Play positional sound with distance-based volume
|
||||
sg.play_random_positional(sounds, "footsteps", player_x=5, object_x=10)
|
||||
|
||||
# Play directional sound (simplified left/right positioning)
|
||||
sg.play_directional_sound(sounds, "voice", player_x=5, object_x=10)
|
||||
|
||||
# Play a sound as a cutscene (interrupts other sounds, waits until complete)
|
||||
sg.cut_scene(sounds, "intro_speech")
|
||||
|
||||
# Play or update a falling sound
|
||||
channel = sg.play_random_falling(sounds, "rock", player_x=5, object_x=8, start_y=10, currentY=5)
|
||||
```
|
||||
|
||||
### Utility Functions
|
||||
|
||||
```python
|
||||
# Check for game updates
|
||||
update_info = sg.check_for_updates("1.0.0", "My Game", "https://example.com/version.json")
|
||||
|
||||
# Check compatibility with library version
|
||||
is_compatible = sg.check_compatibility("1.0.0", "1.2.3")
|
||||
|
||||
# Sanitize a filename for any OS
|
||||
safe_name = sg.sanitize_filename("User's File.txt")
|
||||
|
||||
# Calculate distance between points
|
||||
distance = sg.distance_2d(x1=5, y1=10, x2=8, y2=15)
|
||||
|
||||
# Interpolation functions
|
||||
mid_value = sg.lerp(start=0, end=10, factor=0.5) # Returns 5.0
|
||||
smooth_value = sg.smooth_step(edge0=0, edge1=10, x=5) # Smooth transition
|
||||
```
|
||||
|
||||
|
||||
## Advanced Examples
|
||||
|
||||
### Using the Game Class (Modern Approach)
|
||||
|
||||
```python
|
||||
import libstormgames as sg
|
||||
import pygame
|
||||
import random
|
||||
|
||||
def main():
|
||||
# Create and initialize the game
|
||||
game = sg.Game("My Advanced Game").initialize()
|
||||
|
||||
# Set up game environment
|
||||
game.play_bgm("sounds/background.ogg")
|
||||
|
||||
# Main game loop
|
||||
running = True
|
||||
player_x = 5
|
||||
|
||||
while running:
|
||||
# Process events
|
||||
for event in pygame.event.get():
|
||||
if event.type == pygame.KEYDOWN:
|
||||
if event.key == pygame.K_ESCAPE:
|
||||
running = False
|
||||
elif event.key == pygame.K_SPACE:
|
||||
# Score points
|
||||
game.scoreboard.increase_score(5)
|
||||
game.speak(f"Score: {game.scoreboard.get_score()}")
|
||||
|
||||
# Update game state
|
||||
player_x += random.uniform(-0.2, 0.2)
|
||||
|
||||
# Add random ambient sounds
|
||||
if random.random() < 0.05:
|
||||
sounds = game.sound.get_sounds()
|
||||
if "ambient" in sounds:
|
||||
sg.play_random_positional(sounds, "ambient", player_x,
|
||||
player_x + random.uniform(-5, 5))
|
||||
|
||||
pygame.time.delay(50)
|
||||
|
||||
# Game over and cleanup
|
||||
game.speak("Game over!")
|
||||
game.exit()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
|
||||
### Complex Sound Environment
|
||||
|
||||
```python
|
||||
import libstormgames as sg
|
||||
import time
|
||||
import random
|
||||
|
||||
def create_sound_environment(player_x, player_y):
|
||||
sounds = sg.initialize_gui("Sound Environment Demo")
|
||||
|
||||
# Place sound sources
|
||||
water_x, water_y = 10, 5
|
||||
fire_x, fire_y = 15, 8
|
||||
wind_x, wind_y = 3, 12
|
||||
|
||||
# Play ambient sounds
|
||||
water_channel = sg.obj_play(sounds, "water", player_x, water_x)
|
||||
fire_channel = sg.obj_play(sounds, "fire", player_x, fire_x)
|
||||
wind_channel = sg.obj_play(sounds, "wind", player_x, wind_x)
|
||||
|
||||
# Main loop
|
||||
running = True
|
||||
while running:
|
||||
# Simulate player movement
|
||||
player_x += random.uniform(-0.5, 0.5)
|
||||
player_y += random.uniform(-0.5, 0.5)
|
||||
|
||||
# Update sound positions
|
||||
water_channel = sg.obj_update(water_channel, player_x, water_x)
|
||||
fire_channel = sg.obj_update(fire_channel, player_x, fire_x)
|
||||
wind_channel = sg.obj_update(wind_channel, player_x, wind_x)
|
||||
|
||||
# Occasionally play random sound
|
||||
if random.random() < 0.1:
|
||||
sg.play_random_positional(sounds, "creature", player_x,
|
||||
player_x + random.uniform(-5, 5))
|
||||
|
||||
# Check for exit
|
||||
if sg.check_for_exit():
|
||||
running = False
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
# Clean up
|
||||
sg.obj_stop(water_channel)
|
||||
sg.obj_stop(fire_channel)
|
||||
sg.obj_stop(wind_channel)
|
||||
sg.exit_game()
|
||||
```
|
||||
|
||||
|
||||
### Complete Game Structure with Class-Based Architecture
|
||||
|
||||
```python
|
||||
import libstormgames as sg
|
||||
import pygame
|
||||
import random
|
||||
|
||||
class MyGame:
|
||||
def __init__(self):
|
||||
# Create a Game instance that manages all subsystems
|
||||
self.game = sg.Game("My Advanced Game").initialize()
|
||||
|
||||
# Game state
|
||||
self.player_x = 5
|
||||
self.player_y = 5
|
||||
self.difficulty = "normal"
|
||||
|
||||
# Load settings
|
||||
try:
|
||||
self.difficulty = self.game.config_service.local_config.get("settings", "difficulty")
|
||||
except:
|
||||
self.game.config_service.local_config.add_section("settings")
|
||||
self.game.config_service.local_config.set("settings", "difficulty", "normal")
|
||||
self.game.config_service.write_local_config()
|
||||
|
||||
def play_game(self):
|
||||
self.game.speak(f"Starting game on {self.difficulty} difficulty")
|
||||
self.game.play_bgm("sounds/game_music.ogg")
|
||||
|
||||
# Game loop
|
||||
running = True
|
||||
while running:
|
||||
# Update game state
|
||||
self.player_x += random.uniform(-0.2, 0.2)
|
||||
|
||||
# Handle input
|
||||
for event in pygame.event.get():
|
||||
if event.type == pygame.KEYDOWN:
|
||||
if event.key == pygame.K_ESCAPE:
|
||||
running = False
|
||||
elif event.key == pygame.K_SPACE:
|
||||
self.game.scoreboard.increase_score()
|
||||
self.game.speak(f"Score: {self.game.scoreboard.get_score()}")
|
||||
|
||||
# Add some random sounds
|
||||
if random.random() < 0.05:
|
||||
sounds = self.game.sound.get_sounds()
|
||||
if "ambient" in sounds:
|
||||
sg.play_random_positional(sounds, "ambient",
|
||||
self.player_x, self.player_x + random.uniform(-10, 10))
|
||||
|
||||
pygame.time.delay(50)
|
||||
|
||||
# Game over
|
||||
position = self.game.scoreboard.check_high_score()
|
||||
if position:
|
||||
self.game.speak(f"New high score! Position {position}")
|
||||
self.game.scoreboard.add_high_score()
|
||||
|
||||
return "menu"
|
||||
|
||||
def settings(self):
|
||||
options = ["easy", "normal", "hard", "back"]
|
||||
current = options.index(self.difficulty) if self.difficulty in options else 1
|
||||
|
||||
while True:
|
||||
self.game.speak(f"Current difficulty: {options[current]}")
|
||||
|
||||
# Wait for input
|
||||
event = pygame.event.wait()
|
||||
if event.type == pygame.KEYDOWN:
|
||||
if event.key == pygame.K_UP and current > 0:
|
||||
current -= 1
|
||||
elif event.key == pygame.K_DOWN and current < len(options) - 1:
|
||||
current += 1
|
||||
elif event.key == pygame.K_RETURN:
|
||||
if options[current] == "back":
|
||||
return
|
||||
self.difficulty = options[current]
|
||||
self.game.config_service.local_config.set("settings", "difficulty", self.difficulty)
|
||||
self.game.config_service.write_local_config()
|
||||
self.game.speak(f"Difficulty set to {self.difficulty}")
|
||||
return
|
||||
elif event.key == pygame.K_ESCAPE:
|
||||
return
|
||||
|
||||
def run(self):
|
||||
# Main menu loop
|
||||
while True:
|
||||
sounds = self.game.sound.get_sounds()
|
||||
choice = sg.game_menu(sounds, self.play_game, "settings",
|
||||
"instructions", "credits", "donate", "exit_game")
|
||||
|
||||
if choice == "settings":
|
||||
self.settings()
|
||||
elif choice == "instructions":
|
||||
sg.instructions()
|
||||
elif choice == "credits":
|
||||
sg.credits()
|
||||
elif choice == "donate":
|
||||
sg.donate()
|
||||
elif choice == "exit_game":
|
||||
self.game.exit()
|
||||
|
||||
# Run the game
|
||||
if __name__ == "__main__":
|
||||
game = MyGame()
|
||||
game.run()
|
||||
```
|
||||
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Modern vs Traditional Approach**:
|
||||
- New projects: Use the Game class for better organization
|
||||
- Existing projects: Continue with global functions for compatibility
|
||||
- Both approaches are fully supported
|
||||
|
||||
2. **Always clean up resources**:
|
||||
- Use `exit_game()` or `game.exit()` when exiting to ensure proper cleanup
|
||||
- Stop sounds that are no longer needed
|
||||
|
||||
3. **Volume control**:
|
||||
- Implement the Alt+key volume controls in your game
|
||||
- Use volume services for better control
|
||||
|
||||
4. **Configuration**:
|
||||
- Save user preferences using the Config class
|
||||
- Load settings at startup
|
||||
|
||||
5. **Path initialization**:
|
||||
- Always initialize the framework with a proper game title
|
||||
- Game title is used to determine configuration directory paths
|
||||
- Services are interconnected, so proper initialization ensures correct operation
|
||||
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### No Sound
|
||||
|
||||
- Ensure pygame mixer is properly initialized
|
||||
- Check if sound files exist in the correct directory
|
||||
- Verify file formats (OGG and WAV are supported)
|
||||
|
||||
### No Speech
|
||||
|
||||
- Make sure at least one speech provider is installed
|
||||
- Linux/Unix: `python-speechd` or `accessible-output2`
|
||||
- Windows/macOS: `accessible-output2`
|
||||
- Check if pygame display is initialized properly
|
||||
|
||||
### Input Issues
|
||||
|
||||
- Ensure pygame is properly handling events
|
||||
- Check event loop for proper event handling
|
||||
|
||||
### Scoreboard/Configuration Issues
|
||||
|
||||
- Check if path services are properly initialized with a game title
|
||||
- Verify write permissions in the configuration directory
|
||||
- For debugging, use the following code to view path configuration:
|
||||
|
||||
```python
|
||||
from libstormgames.services import PathService, ConfigService
|
||||
|
||||
# Print path information
|
||||
path_service = PathService.get_instance()
|
||||
print(f"Game Name: {path_service.gameName}")
|
||||
print(f"Game Path: {path_service.gamePath}")
|
||||
|
||||
# Check config service connection
|
||||
config_service = ConfigService.get_instance()
|
||||
print(f"Config connected to path: {hasattr(config_service, 'pathService')}")
|
||||
```
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the GPL v3 License - see the LICENSE file for details.
|
||||
## Requirements
|
||||
configparser pyxdg pygame pyperclip requests setproctitle
|
||||
### For mac os X and windows
|
||||
accessible_output2
|
||||
|
725
__init__.py
725
__init__.py
@ -1,228 +1,511 @@
|
||||
#!/usr/bin/env python3
|
||||
#!/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Standard initializations and functions shared by all Storm Games.
|
||||
"""Standard initializations and functions shared by all 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
|
||||
|
||||
# Update imports to reference Scoreboard methods
|
||||
high_scores = Scoreboard.display_high_scores
|
||||
has_high_scores = Scoreboard.has_high_scores
|
||||
|
||||
# Import utility functions and Game class
|
||||
from .utils import (
|
||||
Game,
|
||||
check_for_updates,
|
||||
get_version_tuple,
|
||||
check_compatibility,
|
||||
sanitize_filename,
|
||||
lerp,
|
||||
smooth_step,
|
||||
distance_2d,
|
||||
x_powerbar,
|
||||
y_powerbar,
|
||||
generate_tone
|
||||
)
|
||||
|
||||
|
||||
__version__ = '2.0.0'
|
||||
|
||||
# Make all symbols available at the package level
|
||||
__all__ = [
|
||||
# Services
|
||||
'ConfigService', 'VolumeService', 'PathService',
|
||||
|
||||
# Sound
|
||||
'Sound',
|
||||
'play_bgm',
|
||||
'play_sound',
|
||||
'adjust_bgm_volume',
|
||||
'adjust_sfx_volume',
|
||||
'adjust_master_volume',
|
||||
'play_ambiance',
|
||||
'play_random',
|
||||
'play_random_positional',
|
||||
'play_directional_sound',
|
||||
'obj_play',
|
||||
'obj_update',
|
||||
'obj_stop',
|
||||
'cut_scene',
|
||||
'play_random_falling',
|
||||
'calculate_volume_and_pan',
|
||||
|
||||
# Speech
|
||||
'messagebox',
|
||||
'speak',
|
||||
'Speech',
|
||||
|
||||
# Scoreboard
|
||||
'Scoreboard',
|
||||
|
||||
# Input
|
||||
'get_input', 'check_for_exit', 'pause_game',
|
||||
|
||||
# Display
|
||||
'display_text', 'initialize_gui',
|
||||
|
||||
# Menu
|
||||
'game_menu', 'learn_sounds', 'instructions', 'credits', 'donate', 'exit_game', 'high_scores', 'has_high_scores',
|
||||
|
||||
# Game class
|
||||
'Game',
|
||||
|
||||
# Utils
|
||||
'check_for_updates', 'get_version_tuple', 'check_compatibility',
|
||||
'sanitize_filename', 'lerp', 'smooth_step', 'distance_2d',
|
||||
'x_powerbar', 'y_powerbar', 'generate_tone',
|
||||
|
||||
# Re-exported functions from pygame, math, random
|
||||
'get_ticks', 'delay', 'wait',
|
||||
'sin', 'cos', 'sqrt', 'floor', 'ceil',
|
||||
'randint', 'choice', 'uniform', 'seed'
|
||||
]
|
||||
|
||||
# Create global instances for backward compatibility
|
||||
configService = ConfigService.get_instance()
|
||||
volumeService = VolumeService.get_instance()
|
||||
pathService = PathService.get_instance()
|
||||
|
||||
# Set up backward compatibility hooks for initialize_gui
|
||||
_originalInitializeGui = initialize_gui
|
||||
|
||||
def initialize_gui_with_services(gameTitle):
|
||||
"""Wrapper around initialize_gui that initializes services."""
|
||||
# Initialize path service
|
||||
pathService.initialize(gameTitle)
|
||||
|
||||
# Connect config service to path service
|
||||
configService.set_game_info(gameTitle, pathService)
|
||||
|
||||
# Call original initialize_gui
|
||||
return _originalInitializeGui(gameTitle)
|
||||
|
||||
# Replace initialize_gui with the wrapped version
|
||||
initialize_gui = initialize_gui_with_services
|
||||
|
||||
# Initialize global scoreboard constructor
|
||||
_originalScoreboardInit = Scoreboard.__init__
|
||||
|
||||
def scoreboard_init_with_services(self, score=0, configService=None, speech=None):
|
||||
"""Wrapper around Scoreboard.__init__ that ensures services are initialized."""
|
||||
# Use global services if not specified
|
||||
if configService is None:
|
||||
configService = ConfigService.get_instance()
|
||||
|
||||
# Ensure pathService is connected if using defaults
|
||||
if not hasattr(configService, 'pathService') and pathService.game_path is not None:
|
||||
configService.pathService = pathService
|
||||
|
||||
# Call original init with services
|
||||
_originalScoreboardInit(self, score, configService, speech)
|
||||
|
||||
# Replace Scoreboard.__init__ with the wrapped version
|
||||
Scoreboard.__init__ = scoreboard_init_with_services
|
||||
|
||||
# Re-export pygame time functions for backward compatibility
|
||||
import pygame.time
|
||||
|
||||
def get_ticks():
|
||||
"""Get the number of milliseconds since pygame.init() was called."""
|
||||
return pygame.time.get_ticks()
|
||||
|
||||
def delay(milliseconds):
|
||||
"""Pause the program for a given number of milliseconds."""
|
||||
return pygame.time.delay(milliseconds)
|
||||
|
||||
def wait(milliseconds):
|
||||
"""Pause the program for a given number of milliseconds."""
|
||||
return pygame.time.wait(milliseconds)
|
||||
|
||||
# Re-export math functions that might be used
|
||||
import math
|
||||
|
||||
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
|
||||
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 textwrap
|
||||
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 math
|
||||
import numpy as np
|
||||
import time
|
||||
|
||||
def randint(a, b):
|
||||
"""Return a random integer N such that a <= N <= b."""
|
||||
return random.randint(a, b)
|
||||
localConfig = configparser.ConfigParser()
|
||||
globalConfig = configparser.ConfigParser()
|
||||
|
||||
def choice(seq):
|
||||
"""Return a random element from the non-empty sequence seq."""
|
||||
return random.choice(seq)
|
||||
class scoreboard():
|
||||
'Handles scores and top 10'
|
||||
|
||||
def uniform(a, b):
|
||||
"""Return a random floating point number N such that a <= N <= b."""
|
||||
return random.uniform(a, b)
|
||||
def __init__(self, startingScore = 0):
|
||||
read_config()
|
||||
try:
|
||||
localConfig.add_section("scoreboard")
|
||||
except:
|
||||
pass
|
||||
self.score = startingScore
|
||||
self.oldScores = []
|
||||
for i in range(1, 11):
|
||||
try:
|
||||
self.oldScores.insert(i - 1, localConfig.getint("scoreboard", str(i)))
|
||||
except:
|
||||
pass
|
||||
self.oldScores.insert(i - 1, 0)
|
||||
for i in range(1, 11):
|
||||
if self.oldScores[i - 1] == None:
|
||||
self.oldScores[i - 1] = 0
|
||||
|
||||
def seed(a=None):
|
||||
"""Initialize the random number generator."""
|
||||
return random.seed(a)
|
||||
def __del__(self):
|
||||
self.Update_Scores()
|
||||
try:
|
||||
write_config()
|
||||
except:
|
||||
pass
|
||||
|
||||
def Decrease_Score(self, points = 1):
|
||||
self.score -= points
|
||||
|
||||
def Get_High_Score(self, position = 1):
|
||||
return self.oldScores[position - 1]
|
||||
|
||||
def Get_Score(self):
|
||||
return self.score
|
||||
|
||||
def Increase_Score(self, points = 1):
|
||||
self.score += points
|
||||
|
||||
def New_High_Score(self):
|
||||
for i, j in enumerate(self.oldScores):
|
||||
if self.score > j: return i + 1
|
||||
return None
|
||||
|
||||
def Update_Scores(self):
|
||||
# Update the scores
|
||||
for i, j in enumerate(self.oldScores):
|
||||
if self.score > j:
|
||||
self.oldScores.insert(i, self.score)
|
||||
break
|
||||
# Only keep the top 10 scores.
|
||||
self.oldScores = self.oldScores[:10]
|
||||
# Update the scoreboard section of the games config file.
|
||||
for i, j in enumerate(self.oldScores):
|
||||
localConfig.set("scoreboard", str(i + 1), str(j))
|
||||
|
||||
|
||||
def write_config(writeGlobal = False):
|
||||
if writeGlobal == False:
|
||||
with open(gamePath + "/config.ini", 'w') as configfile:
|
||||
localConfig.write(configfile)
|
||||
else:
|
||||
with open(globalPath + "/config.ini", 'w') as configfile:
|
||||
globalConfig.write(configfile)
|
||||
|
||||
def read_config(readGlobal = False):
|
||||
if readGlobal == False:
|
||||
try:
|
||||
with open(gamePath + "/config.ini", 'r') as configfile:
|
||||
localConfig.read_file(configfile)
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
with open(globalPath + "/config.ini", 'r') as configfile:
|
||||
globalConfig.read_file(configfile)
|
||||
except:
|
||||
pass
|
||||
|
||||
def speak(text, interupt=True):
|
||||
if speechProvider == "speechd":
|
||||
if interupt: spd.cancel()
|
||||
spd.say(text)
|
||||
else:
|
||||
if speechProvider == "accessible_output2":
|
||||
s.speak(text, interrupt=True)
|
||||
# Display the text on screen
|
||||
screen = pygame.display.get_surface()
|
||||
font = pygame.font.Font(None, 36)
|
||||
# Wrap the text
|
||||
maxWidth = screen.get_width() - 40 # Leave a 20-pixel margin on each side
|
||||
wrappedText = textwrap.wrap(text, width=maxWidth // font.size('A')[0])
|
||||
# Render each line
|
||||
textSurfaces = [font.render(line, True, (255, 255, 255)) for line in wrappedText]
|
||||
screen.fill((0, 0, 0)) # Clear screen with black
|
||||
# Calculate total height of text block
|
||||
totalHeight = sum(surface.get_height() for surface in textSurfaces)
|
||||
# Start y-position (centered vertically)
|
||||
currentY = (screen.get_height() - totalHeight) // 2
|
||||
# Blit each line of text
|
||||
for surface in textSurfaces:
|
||||
textRect = surface.get_rect(center=(screen.get_width() // 2, currentY + surface.get_height() // 2))
|
||||
screen.blit(surface, textRect)
|
||||
currentY += surface.get_height()
|
||||
pygame.display.flip()
|
||||
|
||||
def check_for_exit():
|
||||
for event in pygame.event.get():
|
||||
if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
|
||||
return True
|
||||
return False
|
||||
pygame.event.pump()
|
||||
|
||||
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((800, 600))
|
||||
pygame.display.set_caption(gameTitle)
|
||||
# Set 32 channels for sound by default
|
||||
pygame.mixer.pre_init(44100, -16, 2, 1024)
|
||||
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 {'bottle': 'bottle.ogg'}
|
||||
try:
|
||||
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.")
|
||||
speak("No sounds found.", False)
|
||||
#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 generate_tone(frequency, duration=0.1, sample_rate=44100, volume = 0.2):
|
||||
t = np.linspace(0, duration, int(sample_rate * duration), False)
|
||||
tone = np.sin(2 * np.pi * frequency * t)
|
||||
stereo_tone = np.vstack((tone, tone)).T # Create a 2D array for stereo
|
||||
stereo_tone = (stereo_tone * 32767).astype(np.int16)
|
||||
stereo_tone = (stereo_tone * 32767 * volume).astype(np.int16) # Apply volume
|
||||
stereo_tone = np.ascontiguousarray(stereo_tone) # Ensure C-contiguous array
|
||||
return pygame.sndarray.make_sound(stereo_tone)
|
||||
|
||||
def x_powerbar():
|
||||
clock = pygame.time.Clock()
|
||||
screen = pygame.display.get_surface()
|
||||
position = -50 # Start from the leftmost position
|
||||
direction = 1 # Move right initially
|
||||
barHeight = 20
|
||||
while True:
|
||||
frequency = 440 # A4 note
|
||||
leftVolume = (50 - position) / 100
|
||||
rightVolume = (position + 50) / 100
|
||||
tone = generate_tone(frequency)
|
||||
channel = tone.play()
|
||||
channel.set_volume(leftVolume, rightVolume)
|
||||
# Visual representation
|
||||
screen.fill((0, 0, 0))
|
||||
barWidth = screen.get_width() - 40 # Leave 20px margin on each side
|
||||
pygame.draw.rect(screen, (100, 100, 100), (20, screen.get_height() // 2 - barHeight // 2, barWidth, barHeight))
|
||||
markerPos = int(20 + (position + 50) / 100 * barWidth)
|
||||
pygame.draw.rect(screen, (255, 0, 0), (markerPos - 5, screen.get_height() // 2 - barHeight, 10, barHeight * 2))
|
||||
pygame.display.flip()
|
||||
for event in pygame.event.get():
|
||||
check_for_exit()
|
||||
if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
|
||||
channel.stop()
|
||||
return position # This will return a value between -50 and 50
|
||||
position += direction
|
||||
if position > 50:
|
||||
position = 50
|
||||
direction = -1
|
||||
elif position < -50:
|
||||
position = -50
|
||||
direction = 1
|
||||
clock.tick(40) # Speed of bar
|
||||
|
||||
def y_powerbar():
|
||||
clock = pygame.time.Clock()
|
||||
screen = pygame.display.get_surface()
|
||||
power = 0
|
||||
direction = 1 # 1 for increasing, -1 for decreasing
|
||||
barWidth = 20
|
||||
while True:
|
||||
frequency = 220 + (power * 5) # Adjust these values to change the pitch range
|
||||
tone = generate_tone(frequency)
|
||||
channel = tone.play()
|
||||
# Visual representation
|
||||
screen.fill((0, 0, 0))
|
||||
barHeight = screen.get_height() - 40 # Leave 20px margin on top and bottom
|
||||
pygame.draw.rect(screen, (100, 100, 100), (screen.get_width() // 2 - barWidth // 2, 20, barWidth, barHeight))
|
||||
markerPos = int(20 + (100 - power) / 100 * barHeight)
|
||||
pygame.draw.rect(screen, (255, 0, 0), (screen.get_width() // 2 - barWidth, markerPos - 5, barWidth * 2, 10))
|
||||
pygame.display.flip()
|
||||
for event in pygame.event.get():
|
||||
check_for_exit()
|
||||
if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
|
||||
channel.stop()
|
||||
return power
|
||||
power += direction
|
||||
if power >= 100 or power <= 0:
|
||||
direction *= -1 # Reverse direction at limits
|
||||
clock.tick(40)
|
||||
|
||||
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 calculate_volume_and_pan(player_pos, obj_pos):
|
||||
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)
|
||||
volume = ((max_distance - distance) / max_distance) ** 1.5
|
||||
# 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(sounds, soundName, player_pos, obj_pos):
|
||||
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
|
||||
x = sounds[soundName].play(-1)
|
||||
# Apply the volume and pan
|
||||
x.set_volume(volume * left, volume * right)
|
||||
return x
|
||||
def obj_update(x, player_pos, obj_pos):
|
||||
if x is None:
|
||||
return None
|
||||
volume, left, right = calculate_volume_and_pan(player_pos, obj_pos)
|
||||
if volume == 0:
|
||||
x.stop()
|
||||
return None
|
||||
# Apply the volume and pan
|
||||
x.set_volume(volume * left, volume * right)
|
||||
return x
|
||||
|
||||
def obj_stop(x):
|
||||
# Tries to stop a playing object channel
|
||||
try:
|
||||
x.stop()
|
||||
return None
|
||||
except:
|
||||
return x
|
||||
|
||||
def play_ambiance(sounds, soundNames, probability, randomLocation = False):
|
||||
# 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
|
||||
if random.randint(1, 100) > probability:
|
||||
return
|
||||
# Choose a random sound from the list
|
||||
ambianceSound = random.choice(soundNames)
|
||||
channel = sounds[ambianceSound].play()
|
||||
if randomLocation and channel:
|
||||
left_volume = random.random()
|
||||
right_volume = random.random()
|
||||
channel.set_volume(left_volume, right_volume)
|
||||
return channel # Return the channel object for potential further manipulation
|
||||
|
||||
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"]) and (not f.lower().startswith("_"))]
|
||||
# j keeps track of last spoken index so it isn't voiced on key up.
|
||||
j = -1
|
||||
while loop == True:
|
||||
if i != j:
|
||||
speak(soundFiles[i][:-4])
|
||||
j = i
|
||||
event = pygame.event.wait()
|
||||
if event.type == pygame.KEYDOWN:
|
||||
if event.key == pygame.K_ESCAPE: return "menu"
|
||||
if event.key == pygame.K_DOWN and i < len(soundFiles) - 1:
|
||||
pygame.mixer.stop()
|
||||
i = i + 1
|
||||
if event.key == pygame.K_UP and i > 0:
|
||||
pygame.mixer.stop()
|
||||
i = i - 1
|
||||
if event.key == pygame.K_RETURN:
|
||||
try:
|
||||
soundName = soundFiles[i][:-4]
|
||||
pygame.mixer.stop()
|
||||
sounds[soundName].play()
|
||||
continue
|
||||
except:
|
||||
j = -1
|
||||
speak("Could not play sound.")
|
||||
continue
|
||||
event = pygame.event.clear()
|
||||
time.sleep(0.001)
|
||||
|
||||
def game_menu(sounds, *options):
|
||||
loop = True
|
||||
pygame.mixer.stop()
|
||||
if pygame.mixer.music.get_busy():
|
||||
pygame.mixer.music.unpause()
|
||||
else:
|
||||
try:
|
||||
pygame.mixer.music.load("sounds/music_menu.ogg")
|
||||
pygame.mixer.music.set_volume(0.75)
|
||||
pygame.mixer.music.play(-1)
|
||||
except:
|
||||
pass
|
||||
i = 0
|
||||
# j keeps track of last spoken index so it isn't voiced on key up.
|
||||
j = -1
|
||||
while loop == True:
|
||||
if i != j:
|
||||
speak(options[i])
|
||||
j = i
|
||||
event = pygame.event.wait()
|
||||
if event.type == pygame.KEYDOWN:
|
||||
if event.key == pygame.K_ESCAPE: exit_game()
|
||||
if event.key == pygame.K_DOWN and i < len(options) - 1:
|
||||
i = i + 1
|
||||
try:
|
||||
sounds['menu-move'].play()
|
||||
except:
|
||||
pass
|
||||
if options[i] != "donate": pygame.mixer.music.unpause()
|
||||
if event.key == pygame.K_UP and i > 0:
|
||||
i = i - 1
|
||||
try:
|
||||
sounds['menu-move'].play()
|
||||
except:
|
||||
pass
|
||||
if options[i] != "donate": pygame.mixer.music.unpause()
|
||||
if event.key == pygame.K_HOME and i != 0:
|
||||
i = 0
|
||||
try:
|
||||
sounds['menu-move'].play()
|
||||
except:
|
||||
pass
|
||||
if options[i] != "donate": pygame.mixer.music.unpause()
|
||||
if event.key == pygame.K_END and i != len(options) - 1:
|
||||
i = len(options) -1
|
||||
try:
|
||||
sounds['menu-move'].play()
|
||||
except:
|
||||
pass
|
||||
if options[i] != "donate": pygame.mixer.music.unpause()
|
||||
if event.key == pygame.K_RETURN:
|
||||
try:
|
||||
j = -1
|
||||
try:
|
||||
sounds['menu-select'].play()
|
||||
time.sleep(sounds['menu-select'].get_length())
|
||||
except:
|
||||
pass
|
||||
eval(options[i] + "()")
|
||||
continue
|
||||
except:
|
||||
j = -1
|
||||
return options[i]
|
||||
continue
|
||||
event = pygame.event.clear()
|
||||
time.sleep(0.001)
|
||||
|
||||
def donate():
|
||||
pygame.mixer.music.pause()
|
||||
webbrowser.open('https://ko-fi.com/stormux')
|
||||
|
102
config.py
102
config.py
@ -1,102 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Configuration management for Storm Games.
|
||||
|
||||
Provides functionality for:
|
||||
- Reading and writing configuration files
|
||||
- Global and local configuration handling
|
||||
"""
|
||||
|
||||
import configparser
|
||||
import os
|
||||
from xdg import BaseDirectory
|
||||
|
||||
class Config:
|
||||
"""Configuration management class for Storm Games."""
|
||||
|
||||
def __init__(self, gameTitle):
|
||||
"""Initialize configuration system for a game.
|
||||
|
||||
Args:
|
||||
gameTitle (str): Title of the game
|
||||
"""
|
||||
self.gameTitle = gameTitle
|
||||
self.globalPath = os.path.join(BaseDirectory.xdg_config_home, "storm-games")
|
||||
self.gamePath = os.path.join(self.globalPath,
|
||||
str.lower(str.replace(gameTitle, " ", "-")))
|
||||
|
||||
# Create game directory if it doesn't exist
|
||||
if not os.path.exists(self.gamePath):
|
||||
os.makedirs(self.gamePath)
|
||||
|
||||
# Initialize config parsers
|
||||
self.localConfig = configparser.ConfigParser()
|
||||
self.globalConfig = configparser.ConfigParser()
|
||||
|
||||
# Load existing configurations
|
||||
self.read_local_config()
|
||||
self.read_global_config()
|
||||
|
||||
def read_local_config(self):
|
||||
"""Read local configuration from file."""
|
||||
try:
|
||||
with open(os.path.join(self.gamePath, "config.ini"), 'r') as configFile:
|
||||
self.localConfig.read_file(configFile)
|
||||
except:
|
||||
pass
|
||||
|
||||
def read_global_config(self):
|
||||
"""Read global configuration from file."""
|
||||
try:
|
||||
with open(os.path.join(self.globalPath, "config.ini"), 'r') as configFile:
|
||||
self.globalConfig.read_file(configFile)
|
||||
except:
|
||||
pass
|
||||
|
||||
def write_local_config(self):
|
||||
"""Write local configuration to file."""
|
||||
with open(os.path.join(self.gamePath, "config.ini"), 'w') as configFile:
|
||||
self.localConfig.write(configFile)
|
||||
|
||||
def write_global_config(self):
|
||||
"""Write global configuration to file."""
|
||||
with open(os.path.join(self.globalPath, "config.ini"), 'w') as configFile:
|
||||
self.globalConfig.write(configFile)
|
||||
|
||||
# Global variables for backward compatibility
|
||||
localConfig = configparser.ConfigParser()
|
||||
globalConfig = configparser.ConfigParser()
|
||||
gamePath = ""
|
||||
globalPath = ""
|
||||
|
||||
def write_config(writeGlobal=False):
|
||||
"""Write configuration to file.
|
||||
|
||||
Args:
|
||||
writeGlobal (bool): If True, write to global config, otherwise local (default: False)
|
||||
"""
|
||||
if not writeGlobal:
|
||||
with open(gamePath + "/config.ini", 'w') as configFile:
|
||||
localConfig.write(configFile)
|
||||
else:
|
||||
with open(globalPath + "/config.ini", 'w') as configFile:
|
||||
globalConfig.write(configFile)
|
||||
|
||||
def read_config(readGlobal=False):
|
||||
"""Read configuration from file.
|
||||
|
||||
Args:
|
||||
readGlobal (bool): If True, read global config, otherwise local (default: False)
|
||||
"""
|
||||
if not readGlobal:
|
||||
try:
|
||||
with open(gamePath + "/config.ini", 'r') as configFile:
|
||||
localConfig.read_file(configFile)
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
with open(globalPath + "/config.ini", 'r') as configFile:
|
||||
globalConfig.read_file(configFile)
|
||||
except:
|
||||
pass
|
192
display.py
192
display.py
@ -1,192 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Display functionality for Storm Games.
|
||||
|
||||
Provides functionality for:
|
||||
- GUI initialization
|
||||
- Text display with navigation
|
||||
- Message boxes
|
||||
"""
|
||||
|
||||
import pygame
|
||||
import time
|
||||
import os
|
||||
import pyperclip
|
||||
import random
|
||||
from xdg import BaseDirectory
|
||||
from setproctitle import setproctitle
|
||||
from .speech import Speech
|
||||
from .services import PathService, VolumeService
|
||||
|
||||
# Keep track of the instructions for navigating display_text has been shown
|
||||
displayTextUsageInstructions = False
|
||||
|
||||
def initialize_gui(gameTitle):
|
||||
"""Initialize the game GUI and sound system.
|
||||
|
||||
Args:
|
||||
gameTitle (str): Title of the game
|
||||
|
||||
Returns:
|
||||
dict: Dictionary of loaded sound objects
|
||||
"""
|
||||
# Initialize path service with game title
|
||||
pathService = PathService.get_instance().initialize(gameTitle)
|
||||
|
||||
# Seed the random generator to the clock
|
||||
random.seed()
|
||||
|
||||
# Set game's name
|
||||
setproctitle(str.lower(str.replace(gameTitle, " ", "-")))
|
||||
|
||||
# Initialize pygame
|
||||
pygame.init()
|
||||
pygame.display.set_mode((800, 600))
|
||||
pygame.display.set_caption(gameTitle)
|
||||
|
||||
# Set up audio system
|
||||
pygame.mixer.pre_init(44100, -16, 2, 1024)
|
||||
pygame.mixer.init()
|
||||
pygame.mixer.set_num_channels(32)
|
||||
pygame.mixer.set_reserved(0) # Reserve channel for cut scenes
|
||||
|
||||
# Enable key repeat for volume controls
|
||||
pygame.key.set_repeat(500, 100)
|
||||
|
||||
# Load sound files recursively including subdirectories
|
||||
soundData = {}
|
||||
try:
|
||||
import os
|
||||
|
||||
soundDir = "sounds/"
|
||||
# Walk through directory tree
|
||||
for dirPath, dirNames, fileNames in os.walk(soundDir):
|
||||
# Get relative path from soundDir
|
||||
relPath = os.path.relpath(dirPath, soundDir)
|
||||
|
||||
# Process each file
|
||||
for fileName in fileNames:
|
||||
# Check if file is a valid sound file
|
||||
if fileName.lower().endswith(('.ogg', '.wav')):
|
||||
# Full path to the sound file
|
||||
fullPath = os.path.join(dirPath, fileName)
|
||||
|
||||
# Create sound key (remove extension)
|
||||
baseName = os.path.splitext(fileName)[0]
|
||||
|
||||
# If in root sounds dir, just use basename
|
||||
if relPath == '.':
|
||||
soundKey = baseName
|
||||
else:
|
||||
# Otherwise use relative path + basename, normalized with forward slashes
|
||||
soundKey = os.path.join(relPath, baseName).replace('\\', '/')
|
||||
|
||||
# Load the sound
|
||||
soundData[soundKey] = pygame.mixer.Sound(fullPath)
|
||||
except Exception as e:
|
||||
print("Error loading sounds:", e)
|
||||
Speech.get_instance().speak("Error loading sounds.", False)
|
||||
soundData = {}
|
||||
|
||||
# Play intro sound if available
|
||||
from .sound import cut_scene
|
||||
if 'game-intro' in soundData:
|
||||
cut_scene(soundData, 'game-intro')
|
||||
|
||||
return soundData
|
||||
|
||||
def display_text(text):
|
||||
"""Display and speak text with navigation controls.
|
||||
|
||||
Allows users to:
|
||||
- Navigate text line by line with arrow keys (skipping blank lines)
|
||||
- Listen to full text with space
|
||||
- Copy current line or full text (preserving blank lines)
|
||||
- Exit with enter/escape
|
||||
- Volume controls (with Alt modifier):
|
||||
- Alt+PageUp/PageDown: Master volume up/down
|
||||
- Alt+Home/End: Background music volume up/down
|
||||
- Alt+Insert/Delete: Sound effects volume up/down
|
||||
|
||||
Args:
|
||||
text (list): List of text lines to display
|
||||
"""
|
||||
# Get service instances
|
||||
speech = Speech.get_instance()
|
||||
volumeService = VolumeService.get_instance()
|
||||
|
||||
# Store original text with blank lines for copying
|
||||
originalText = text.copy()
|
||||
|
||||
# Create navigation text by filtering out blank lines
|
||||
navText = [line for line in text if line.strip()]
|
||||
|
||||
# Add instructions at the start on the first display
|
||||
global displayTextUsageInstructions
|
||||
if not displayTextUsageInstructions:
|
||||
instructions = ("Press space to read the whole text. Use up and down arrows to navigate "
|
||||
"the text line by line. Press c to copy the current line to the clipboard "
|
||||
"or t to copy the entire text. Press enter or escape when you are done reading.")
|
||||
navText.insert(0, instructions)
|
||||
displayTextUsageInstructions = True
|
||||
|
||||
# Add end marker
|
||||
navText.append("End of text.")
|
||||
|
||||
currentIndex = 0
|
||||
speech.speak(navText[currentIndex])
|
||||
|
||||
while True:
|
||||
event = pygame.event.wait()
|
||||
if event.type == pygame.KEYDOWN:
|
||||
# Check for Alt modifier
|
||||
mods = pygame.key.get_mods()
|
||||
altPressed = mods & pygame.KMOD_ALT
|
||||
|
||||
# Volume controls (require Alt)
|
||||
if altPressed:
|
||||
if event.key == pygame.K_PAGEUP:
|
||||
volumeService.adjust_master_volume(0.1, pygame.mixer)
|
||||
elif event.key == pygame.K_PAGEDOWN:
|
||||
volumeService.adjust_master_volume(-0.1, pygame.mixer)
|
||||
elif event.key == pygame.K_HOME:
|
||||
volumeService.adjust_bgm_volume(0.1, pygame.mixer)
|
||||
elif event.key == pygame.K_END:
|
||||
volumeService.adjust_bgm_volume(-0.1, pygame.mixer)
|
||||
elif event.key == pygame.K_INSERT:
|
||||
volumeService.adjust_sfx_volume(0.1, pygame.mixer)
|
||||
elif event.key == pygame.K_DELETE:
|
||||
volumeService.adjust_sfx_volume(-0.1, pygame.mixer)
|
||||
else:
|
||||
if event.key in (pygame.K_ESCAPE, pygame.K_RETURN):
|
||||
return
|
||||
|
||||
if event.key in [pygame.K_DOWN, pygame.K_s] and currentIndex < len(navText) - 1:
|
||||
currentIndex += 1
|
||||
speech.speak(navText[currentIndex])
|
||||
|
||||
if event.key in [pygame.K_UP, pygame.K_w] and currentIndex > 0:
|
||||
currentIndex -= 1
|
||||
speech.speak(navText[currentIndex])
|
||||
|
||||
if event.key == pygame.K_SPACE:
|
||||
# Join with newlines to preserve spacing in speech
|
||||
speech.speak('\n'.join(originalText[1:-1]))
|
||||
|
||||
if event.key == pygame.K_c:
|
||||
try:
|
||||
pyperclip.copy(navText[currentIndex])
|
||||
speech.speak("Copied " + navText[currentIndex] + " to the clipboard.")
|
||||
except:
|
||||
speech.speak("Failed to copy the text to the clipboard.")
|
||||
|
||||
if event.key == pygame.K_t:
|
||||
try:
|
||||
# Join with newlines to preserve blank lines in full text
|
||||
pyperclip.copy(''.join(originalText[2:-1]))
|
||||
speech.speak("Copied entire message to the clipboard.")
|
||||
except:
|
||||
speech.speak("Failed to copy the text to the clipboard.")
|
||||
|
||||
event = pygame.event.clear()
|
||||
time.sleep(0.001)
|
77
input.py
77
input.py
@ -1,77 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Input handling for Storm Games.
|
||||
|
||||
Provides functionality for:
|
||||
- Text input dialogs
|
||||
- Game pause functionality
|
||||
- Exit handling
|
||||
"""
|
||||
|
||||
import pygame
|
||||
import time
|
||||
import wx
|
||||
from .speech import speak
|
||||
|
||||
def get_input(prompt="Enter text:", text=""):
|
||||
"""Display a dialog box for text input.
|
||||
|
||||
Args:
|
||||
prompt (str): Prompt text to display (default: "Enter text:")
|
||||
text (str): Initial text in input box (default: "")
|
||||
|
||||
Returns:
|
||||
str: User input text, or None if cancelled
|
||||
"""
|
||||
app = wx.App(False)
|
||||
dialog = wx.TextEntryDialog(None, prompt, "Input", text)
|
||||
dialog.SetValue(text)
|
||||
if dialog.ShowModal() == wx.ID_OK:
|
||||
userInput = dialog.GetValue()
|
||||
else:
|
||||
userInput = None
|
||||
dialog.Destroy()
|
||||
return userInput
|
||||
|
||||
def pause_game():
|
||||
"""Pauses the game until user presses backspace."""
|
||||
speak("Game paused, press backspace to resume.")
|
||||
pygame.event.clear()
|
||||
try:
|
||||
pygame.mixer.pause()
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
pygame.mixer.music.pause()
|
||||
except:
|
||||
pass
|
||||
|
||||
while True:
|
||||
event = pygame.event.wait()
|
||||
if event.type == pygame.KEYDOWN and event.key == pygame.K_BACKSPACE:
|
||||
break
|
||||
|
||||
try:
|
||||
pygame.mixer.unpause()
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
pygame.mixer.music.unpause()
|
||||
except:
|
||||
pass
|
||||
|
||||
pygame.event.pump()
|
||||
|
||||
def check_for_exit():
|
||||
"""Check if user has pressed escape key.
|
||||
|
||||
Returns:
|
||||
bool: True if escape was pressed, False otherwise
|
||||
"""
|
||||
for event in pygame.event.get():
|
||||
if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
|
||||
return True
|
||||
return False
|
||||
pygame.event.pump()
|
496
menu.py
496
menu.py
@ -1,496 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Menu systems for Storm Games.
|
||||
|
||||
Provides functionality for:
|
||||
- Game menu navigation
|
||||
- Instructions display
|
||||
- Credits display
|
||||
- Sound learning interface
|
||||
- Game exit handling
|
||||
"""
|
||||
|
||||
import pygame
|
||||
import time
|
||||
import webbrowser
|
||||
import os
|
||||
from sys import exit
|
||||
from os.path import isfile
|
||||
from os import listdir
|
||||
from os.path import join
|
||||
from inspect import isfunction
|
||||
from .speech import messagebox, Speech
|
||||
from .sound import adjust_master_volume, adjust_bgm_volume, adjust_sfx_volume, play_bgm
|
||||
from .display import display_text
|
||||
from .scoreboard import Scoreboard
|
||||
from .services import PathService, ConfigService
|
||||
|
||||
def game_menu(sounds, playCallback=None, *customOptions):
|
||||
"""Display and handle the main game menu with standard and custom options.
|
||||
|
||||
Standard menu structure:
|
||||
1. Play (always first)
|
||||
2. High Scores
|
||||
3. Custom options (if provided)
|
||||
4. Learn Sounds
|
||||
5. Instructions (if available)
|
||||
6. Credits (if available)
|
||||
7. Donate
|
||||
8. Exit
|
||||
|
||||
Handles navigation with:
|
||||
- Up/Down arrows for selection
|
||||
- Home/End for first/last option
|
||||
- Enter to select
|
||||
- Escape to exit
|
||||
- Volume controls (with Alt modifier)
|
||||
|
||||
Args:
|
||||
sounds (dict): Dictionary of sound objects
|
||||
playCallback (function, optional): Callback function for the "play" option.
|
||||
If None, "play" is returned as a string like other options.
|
||||
*customOptions: Additional custom options to include after play but before standard ones
|
||||
|
||||
Returns:
|
||||
str: Selected menu option or "exit" if user pressed escape
|
||||
"""
|
||||
# Get speech instance
|
||||
speech = Speech.get_instance()
|
||||
|
||||
# Start with Play option
|
||||
allOptions = ["play"]
|
||||
|
||||
# Add high scores option if scores exist
|
||||
if Scoreboard.has_high_scores():
|
||||
allOptions.append("high_scores")
|
||||
|
||||
# Add custom options (other menu items, etc.)
|
||||
allOptions.extend(customOptions)
|
||||
|
||||
# Add standard options in preferred order
|
||||
allOptions.append("learn_sounds")
|
||||
|
||||
# Check for instructions file
|
||||
if os.path.isfile('files/instructions.txt'):
|
||||
allOptions.append("instructions")
|
||||
|
||||
# Check for credits file
|
||||
if os.path.isfile('files/credits.txt'):
|
||||
allOptions.append("credits")
|
||||
|
||||
# Final options
|
||||
allOptions.extend(["donate", "exit_game"])
|
||||
|
||||
# Track if music was previously playing
|
||||
musicWasPlaying = pygame.mixer.music.get_busy()
|
||||
|
||||
# Only start menu music if no music is currently playing
|
||||
if not musicWasPlaying:
|
||||
try:
|
||||
from .sound import play_bgm
|
||||
play_bgm("sounds/music_menu.ogg")
|
||||
except:
|
||||
pass
|
||||
|
||||
loop = True
|
||||
pygame.mixer.stop()
|
||||
currentIndex = 0
|
||||
lastSpoken = -1 # Track last spoken index
|
||||
|
||||
while loop:
|
||||
if currentIndex != lastSpoken:
|
||||
speech.speak(allOptions[currentIndex])
|
||||
lastSpoken = currentIndex
|
||||
|
||||
event = pygame.event.wait()
|
||||
if event.type == pygame.KEYDOWN:
|
||||
# Check for Alt modifier
|
||||
mods = pygame.key.get_mods()
|
||||
altPressed = mods & pygame.KMOD_ALT
|
||||
|
||||
# Volume controls (require Alt)
|
||||
if altPressed:
|
||||
if event.key == pygame.K_PAGEUP:
|
||||
adjust_master_volume(0.1)
|
||||
elif event.key == pygame.K_PAGEDOWN:
|
||||
adjust_master_volume(-0.1)
|
||||
elif event.key == pygame.K_HOME:
|
||||
adjust_bgm_volume(0.1)
|
||||
elif event.key == pygame.K_END:
|
||||
adjust_bgm_volume(-0.1)
|
||||
elif event.key == pygame.K_INSERT:
|
||||
adjust_sfx_volume(0.1)
|
||||
elif event.key == pygame.K_DELETE:
|
||||
adjust_sfx_volume(-0.1)
|
||||
# Regular menu navigation (no Alt required)
|
||||
else:
|
||||
if event.key == pygame.K_ESCAPE:
|
||||
# Exit with fade if music is playing
|
||||
exit_game(500 if pygame.mixer.music.get_busy() else 0)
|
||||
elif event.key == pygame.K_HOME:
|
||||
if currentIndex != 0:
|
||||
currentIndex = 0
|
||||
try:
|
||||
sounds['menu-move'].play()
|
||||
except:
|
||||
pass
|
||||
if allOptions[currentIndex] != "donate":
|
||||
pygame.mixer.music.unpause()
|
||||
elif event.key == pygame.K_END:
|
||||
if currentIndex != len(allOptions) - 1:
|
||||
currentIndex = len(allOptions) - 1
|
||||
try:
|
||||
sounds['menu-move'].play()
|
||||
except:
|
||||
pass
|
||||
if allOptions[currentIndex] != "donate":
|
||||
pygame.mixer.music.unpause()
|
||||
elif event.key in [pygame.K_DOWN, pygame.K_s] and currentIndex < len(allOptions) - 1:
|
||||
currentIndex += 1
|
||||
try:
|
||||
sounds['menu-move'].play()
|
||||
except:
|
||||
pass
|
||||
if allOptions[currentIndex] != "donate":
|
||||
pygame.mixer.music.unpause()
|
||||
elif event.key in [pygame.K_UP, pygame.K_w] and currentIndex > 0:
|
||||
currentIndex -= 1
|
||||
try:
|
||||
sounds['menu-move'].play()
|
||||
except:
|
||||
pass
|
||||
if allOptions[currentIndex] != "donate":
|
||||
pygame.mixer.music.unpause()
|
||||
elif event.key == pygame.K_RETURN:
|
||||
try:
|
||||
lastSpoken = -1
|
||||
try:
|
||||
sounds['menu-select'].play()
|
||||
time.sleep(sounds['menu-select'].get_length())
|
||||
except:
|
||||
pass
|
||||
|
||||
selectedOption = allOptions[currentIndex]
|
||||
|
||||
# Special case for exit_game with fade
|
||||
if selectedOption == "exit_game":
|
||||
exit_game(500 if pygame.mixer.music.get_busy() else 0)
|
||||
# Special case for play option
|
||||
elif selectedOption == "play":
|
||||
if playCallback:
|
||||
# If a play callback is provided, call it directly
|
||||
try:
|
||||
pygame.mixer.music.fadeout(500)
|
||||
time.sleep(0.5)
|
||||
except Exception as e:
|
||||
print(f"Could not fade music: {e}")
|
||||
pass
|
||||
playCallback()
|
||||
else:
|
||||
# Otherwise return "play" to the caller
|
||||
try:
|
||||
pygame.mixer.music.fadeout(500)
|
||||
time.sleep(0.5)
|
||||
except Exception as e:
|
||||
print(f"Could not fade music: {e}")
|
||||
pass
|
||||
return "play"
|
||||
# Handle standard options directly
|
||||
elif selectedOption in ["instructions", "credits", "learn_sounds", "high_scores", "donate"]:
|
||||
# Pause music before calling the selected function
|
||||
try:
|
||||
pygame.mixer.music.pause()
|
||||
except:
|
||||
pass
|
||||
|
||||
# Handle standard options
|
||||
if selectedOption == "instructions":
|
||||
instructions()
|
||||
elif selectedOption == "credits":
|
||||
credits()
|
||||
elif selectedOption == "learn_sounds":
|
||||
learn_sounds(sounds)
|
||||
elif selectedOption == "high_scores":
|
||||
Scoreboard.display_high_scores()
|
||||
elif selectedOption == "donate":
|
||||
donate()
|
||||
|
||||
# Unpause music after function returns
|
||||
try:
|
||||
# Check if music is actually paused before trying to unpause
|
||||
if not pygame.mixer.music.get_busy():
|
||||
pygame.mixer.music.unpause()
|
||||
# If music is already playing, don't try to restart it
|
||||
except:
|
||||
# Only start fresh music if no music is playing at all
|
||||
if not pygame.mixer.music.get_busy():
|
||||
try:
|
||||
from .sound import play_bgm
|
||||
play_bgm("sounds/music_menu.ogg")
|
||||
except:
|
||||
pass
|
||||
# Return custom options to the calling function
|
||||
else:
|
||||
lastSpoken = -1
|
||||
try:
|
||||
pygame.mixer.music.fadeout(500)
|
||||
time.sleep(0.5)
|
||||
except Exception as e:
|
||||
print(f"Could not fade music: {e}")
|
||||
pass
|
||||
return selectedOption
|
||||
except Exception as e:
|
||||
print(f"Error handling menu selection: {e}")
|
||||
lastSpoken = -1
|
||||
try:
|
||||
pygame.mixer.music.fadeout(500)
|
||||
time.sleep(0.5)
|
||||
except:
|
||||
pass
|
||||
return allOptions[currentIndex]
|
||||
|
||||
event = pygame.event.clear()
|
||||
time.sleep(0.001)
|
||||
|
||||
def learn_sounds(sounds):
|
||||
"""Interactive menu for learning game sounds.
|
||||
|
||||
Allows users to:
|
||||
- Navigate through available sounds with up/down arrows
|
||||
- Navigate between sound categories (folders) using Page Up/Page Down or Left/Right arrows
|
||||
- Play selected sounds with Enter
|
||||
- Return to menu with Escape
|
||||
|
||||
Excluded sounds:
|
||||
- Files in folders named 'ambience' (at any level)
|
||||
- Files in any directory starting with '.'
|
||||
- Files starting with 'game-intro', 'music_menu', or '_'
|
||||
|
||||
Args:
|
||||
sounds (dict): Dictionary of available sound objects
|
||||
|
||||
Returns:
|
||||
str: "menu" if user exits with escape
|
||||
"""
|
||||
# Get speech instance
|
||||
speech = Speech.get_instance()
|
||||
|
||||
# Define exclusion criteria
|
||||
excludedPrefixes = ["game-intro", "music_menu", "_"]
|
||||
excludedDirs = ["ambience", "."]
|
||||
|
||||
# Organize sounds by directory
|
||||
soundsByDir = {}
|
||||
|
||||
# Process each sound key in the dictionary
|
||||
for soundKey in sounds.keys():
|
||||
# Skip if key has any excluded prefix
|
||||
if any(soundKey.lower().startswith(prefix.lower()) for prefix in excludedPrefixes):
|
||||
continue
|
||||
|
||||
# Split key into path parts
|
||||
parts = soundKey.split('/')
|
||||
|
||||
# Skip if any part of the path is an excluded directory
|
||||
if any(part.lower() == dirName.lower() or part.startswith('.') for part in parts for dirName in excludedDirs):
|
||||
continue
|
||||
|
||||
# Determine the directory
|
||||
if '/' in soundKey:
|
||||
directory = soundKey.split('/')[0]
|
||||
else:
|
||||
directory = 'root' # Root directory sounds
|
||||
|
||||
# Add to sounds by directory
|
||||
if directory not in soundsByDir:
|
||||
soundsByDir[directory] = []
|
||||
soundsByDir[directory].append(soundKey)
|
||||
|
||||
# Sort each directory's sounds
|
||||
for directory in soundsByDir:
|
||||
soundsByDir[directory].sort()
|
||||
|
||||
# If no sounds found, inform the user and return
|
||||
if not soundsByDir:
|
||||
speech.speak("No sounds available to learn.")
|
||||
return "menu"
|
||||
|
||||
# Get list of directories in sorted order
|
||||
directories = sorted(soundsByDir.keys())
|
||||
|
||||
# Start with first directory
|
||||
currentDirIndex = 0
|
||||
currentDir = directories[currentDirIndex]
|
||||
currentSoundKeys = soundsByDir[currentDir]
|
||||
currentSoundIndex = 0
|
||||
|
||||
# Display appropriate message based on number of directories
|
||||
if len(directories) > 1:
|
||||
messagebox(f"Starting with {currentDir if currentDir != 'root' else 'root directory'} sounds. Use left and right arrows or page up and page down to navigate categories.")
|
||||
|
||||
# Track last spoken to avoid repetition
|
||||
lastSpoken = -1
|
||||
directoryChanged = True # Flag to track if directory just changed
|
||||
|
||||
# Flag to track when to exit the loop
|
||||
returnToMenu = False
|
||||
|
||||
while not returnToMenu:
|
||||
# Announce current sound
|
||||
if currentSoundIndex != lastSpoken:
|
||||
totalSounds = len(currentSoundKeys)
|
||||
soundName = currentSoundKeys[currentSoundIndex]
|
||||
|
||||
# Remove directory prefix if present
|
||||
if '/' in soundName:
|
||||
displayName = '/'.join(soundName.split('/')[1:])
|
||||
else:
|
||||
displayName = soundName
|
||||
|
||||
# If directory just changed, include directory name in announcement
|
||||
if directoryChanged:
|
||||
dirDescription = "Root directory" if currentDir == 'root' else currentDir
|
||||
announcement = f"{dirDescription}: {displayName}, {currentSoundIndex + 1} of {totalSounds}"
|
||||
directoryChanged = False # Reset flag after announcement
|
||||
else:
|
||||
announcement = f"{displayName}, {currentSoundIndex + 1} of {totalSounds}"
|
||||
|
||||
speech.speak(announcement)
|
||||
lastSpoken = currentSoundIndex
|
||||
|
||||
event = pygame.event.wait()
|
||||
if event.type == pygame.KEYDOWN:
|
||||
if event.key == pygame.K_ESCAPE:
|
||||
returnToMenu = True
|
||||
|
||||
# Sound navigation
|
||||
elif event.key in [pygame.K_DOWN, pygame.K_s] and currentSoundIndex < len(currentSoundKeys) - 1:
|
||||
pygame.mixer.stop()
|
||||
currentSoundIndex += 1
|
||||
|
||||
elif event.key in [pygame.K_UP, pygame.K_w] and currentSoundIndex > 0:
|
||||
pygame.mixer.stop()
|
||||
currentSoundIndex -= 1
|
||||
|
||||
# Directory navigation
|
||||
elif event.key in [pygame.K_PAGEDOWN, pygame.K_RIGHT] and currentDirIndex < len(directories) - 1:
|
||||
pygame.mixer.stop()
|
||||
currentDirIndex += 1
|
||||
currentDir = directories[currentDirIndex]
|
||||
currentSoundKeys = soundsByDir[currentDir]
|
||||
currentSoundIndex = 0
|
||||
directoryChanged = True # Set flag on directory change
|
||||
lastSpoken = -1 # Force announcement
|
||||
|
||||
elif event.key in [pygame.K_PAGEUP, pygame.K_LEFT] and currentDirIndex > 0:
|
||||
pygame.mixer.stop()
|
||||
currentDirIndex -= 1
|
||||
currentDir = directories[currentDirIndex]
|
||||
currentSoundKeys = soundsByDir[currentDir]
|
||||
currentSoundIndex = 0
|
||||
directoryChanged = True # Set flag on directory change
|
||||
lastSpoken = -1 # Force announcement
|
||||
|
||||
# Play sound
|
||||
elif event.key == pygame.K_RETURN:
|
||||
try:
|
||||
soundName = currentSoundKeys[currentSoundIndex]
|
||||
pygame.mixer.stop()
|
||||
sounds[soundName].play()
|
||||
except Exception as e:
|
||||
print(f"Error playing sound: {e}")
|
||||
speech.speak("Could not play sound.")
|
||||
|
||||
event = pygame.event.clear()
|
||||
pygame.event.pump() # Process pygame's internal events
|
||||
time.sleep(0.001)
|
||||
|
||||
return "menu"
|
||||
|
||||
def instructions():
|
||||
"""Display game instructions from file.
|
||||
|
||||
Reads and displays instructions from 'files/instructions.txt'.
|
||||
If file is missing, displays an error message.
|
||||
"""
|
||||
try:
|
||||
with open('files/instructions.txt', 'r') as f:
|
||||
info = f.readlines()
|
||||
except:
|
||||
info = ["Instructions file is missing."]
|
||||
display_text(info)
|
||||
|
||||
def credits():
|
||||
"""Display game credits from file.
|
||||
|
||||
Reads and displays credits from 'files/credits.txt'.
|
||||
Adds game name header before displaying.
|
||||
If file is missing, displays an error message.
|
||||
"""
|
||||
try:
|
||||
with open('files/credits.txt', 'r') as f:
|
||||
info = f.readlines()
|
||||
|
||||
pathService = PathService.get_instance()
|
||||
info.insert(0, pathService.gameName + "\n")
|
||||
except Exception as e:
|
||||
print(f"Error in credits: {e}")
|
||||
info = ["Credits file is missing."]
|
||||
|
||||
display_text(info)
|
||||
|
||||
def donate():
|
||||
"""Open the donation webpage.
|
||||
|
||||
Opens the Ko-fi donation page.
|
||||
"""
|
||||
webbrowser.open('https://ko-fi.com/stormux')
|
||||
messagebox("The donation page has been opened in your browser.")
|
||||
|
||||
def exit_game(fade=0):
|
||||
"""Clean up and exit the game properly.
|
||||
|
||||
Args:
|
||||
fade (int): Milliseconds to fade out music before exiting.
|
||||
0 means stop immediately (default)
|
||||
"""
|
||||
# Force clear any pending events to prevent hanging
|
||||
pygame.event.clear()
|
||||
|
||||
# Stop all mixer channels first
|
||||
try:
|
||||
pygame.mixer.stop()
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not stop mixer channels: {e}")
|
||||
|
||||
# Get speech instance and handle all providers
|
||||
try:
|
||||
speech = Speech.get_instance()
|
||||
# Try to close speech regardless of provider type
|
||||
try:
|
||||
speech.close()
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not close speech: {e}")
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not get speech instance: {e}")
|
||||
|
||||
# Handle music based on fade parameter
|
||||
try:
|
||||
if fade > 0 and pygame.mixer.music.get_busy():
|
||||
pygame.mixer.music.fadeout(fade)
|
||||
# Wait for fade to start but don't wait for full completion
|
||||
pygame.time.wait(min(250, fade))
|
||||
else:
|
||||
pygame.mixer.music.stop()
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not handle music during exit: {e}")
|
||||
|
||||
# Clean up pygame
|
||||
try:
|
||||
pygame.quit()
|
||||
except Exception as e:
|
||||
print(f"Warning: Error during pygame.quit(): {e}")
|
||||
|
||||
# Use os._exit for immediate termination
|
||||
import os
|
||||
os._exit(0)
|
@ -5,4 +5,3 @@ pyxdg>=0.27
|
||||
setproctitle>=1.2.0
|
||||
numpy>=1.19.0
|
||||
accessible-output2>=0.14
|
||||
wxpython
|
||||
|
332
scoreboard.py
332
scoreboard.py
@ -1,332 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Modified Scoreboard class with integrated fixes.
|
||||
|
||||
This code should replace the existing Scoreboard class in scoreboard.py.
|
||||
The modifications ensure proper path handling and config operations.
|
||||
"""
|
||||
|
||||
import time
|
||||
import os
|
||||
from .services import ConfigService, PathService
|
||||
from .speech import Speech
|
||||
from .display import display_text
|
||||
|
||||
# For backward compatibility
|
||||
from .config import localConfig, write_config, read_config
|
||||
|
||||
class Scoreboard:
|
||||
"""Handles high score tracking with player names."""
|
||||
|
||||
def __init__(self, score=0, configService=None, speech=None):
|
||||
"""Initialize scoreboard.
|
||||
|
||||
Args:
|
||||
score (int): Initial score (default: 0)
|
||||
configService (ConfigService): Config service (default: global instance)
|
||||
speech (Speech): Speech system (default: global instance)
|
||||
"""
|
||||
# Ensure services are properly initialized
|
||||
self._ensure_services()
|
||||
|
||||
self.configService = configService or ConfigService.get_instance()
|
||||
self.speech = speech or Speech.get_instance()
|
||||
self.currentScore = score
|
||||
self.highScores = []
|
||||
|
||||
# For backward compatibility
|
||||
read_config()
|
||||
|
||||
try:
|
||||
# Try to use configService
|
||||
self.configService.localConfig.add_section("scoreboard")
|
||||
except:
|
||||
# Fallback to old method
|
||||
try:
|
||||
localConfig.add_section("scoreboard")
|
||||
except:
|
||||
pass
|
||||
|
||||
# Load existing high scores
|
||||
for i in range(1, 11):
|
||||
try:
|
||||
# Try to use configService
|
||||
score = self.configService.localConfig.getint("scoreboard", f"score_{i}")
|
||||
name = self.configService.localConfig.get("scoreboard", f"name_{i}")
|
||||
self.highScores.append({
|
||||
'name': name,
|
||||
'score': score
|
||||
})
|
||||
except:
|
||||
# Fallback to old method
|
||||
try:
|
||||
score = localConfig.getint("scoreboard", f"score_{i}")
|
||||
name = localConfig.get("scoreboard", f"name_{i}")
|
||||
self.highScores.append({
|
||||
'name': name,
|
||||
'score': score
|
||||
})
|
||||
except:
|
||||
self.highScores.append({
|
||||
'name': "Player",
|
||||
'score': 0
|
||||
})
|
||||
|
||||
# Sort high scores by score value in descending order
|
||||
self.highScores.sort(key=lambda x: x['score'], reverse=True)
|
||||
|
||||
def _ensure_services(self):
|
||||
"""Ensure PathService and ConfigService are properly initialized."""
|
||||
# Get PathService and make sure it has a game name
|
||||
pathService = PathService.get_instance()
|
||||
|
||||
# If no game name yet, try to get from pygame window title
|
||||
if not pathService.gameName:
|
||||
try:
|
||||
import pygame
|
||||
if pygame.display.get_caption()[0]:
|
||||
pathService.gameName = pygame.display.get_caption()[0]
|
||||
except:
|
||||
pass
|
||||
|
||||
# Initialize path service if we have a game name but no paths set up
|
||||
if pathService.gameName and not pathService.gamePath:
|
||||
pathService.initialize(pathService.gameName)
|
||||
|
||||
# Get ConfigService and connect to PathService
|
||||
configService = ConfigService.get_instance()
|
||||
if not hasattr(configService, 'pathService') or not configService.pathService:
|
||||
if pathService.gameName:
|
||||
configService.set_game_info(pathService.gameName, pathService)
|
||||
|
||||
# Ensure the game directory exists
|
||||
if pathService.gamePath and not os.path.exists(pathService.gamePath):
|
||||
try:
|
||||
os.makedirs(pathService.gamePath)
|
||||
except Exception as e:
|
||||
print(f"Error creating game directory: {e}")
|
||||
|
||||
def get_score(self):
|
||||
"""Get current score."""
|
||||
return self.currentScore
|
||||
|
||||
def get_high_scores(self):
|
||||
"""Get list of high scores."""
|
||||
return self.highScores
|
||||
|
||||
def decrease_score(self, points=1):
|
||||
"""Decrease the current score."""
|
||||
self.currentScore -= int(points)
|
||||
return self
|
||||
|
||||
def increase_score(self, points=1):
|
||||
"""Increase the current score."""
|
||||
self.currentScore += int(points)
|
||||
return self
|
||||
|
||||
def set_score(self, score):
|
||||
"""Set the current score to a specific value."""
|
||||
self.currentScore = int(score)
|
||||
return self
|
||||
|
||||
def reset_score(self):
|
||||
"""Reset the current score to zero."""
|
||||
self.currentScore = 0
|
||||
return self
|
||||
|
||||
def check_high_score(self):
|
||||
"""Check if current score qualifies as a high score.
|
||||
|
||||
Returns:
|
||||
int: Position (1-10) if high score, None if not
|
||||
"""
|
||||
for i, entry in enumerate(self.highScores):
|
||||
if self.currentScore > entry['score']:
|
||||
return i + 1
|
||||
return None
|
||||
|
||||
def add_high_score(self, name=None):
|
||||
"""Add current score to high scores if it qualifies.
|
||||
|
||||
Args:
|
||||
name (str): Player name (if None, will prompt user)
|
||||
|
||||
Returns:
|
||||
bool: True if score was added, False if not
|
||||
"""
|
||||
# Ensure services are properly set up
|
||||
self._ensure_services()
|
||||
|
||||
position = self.check_high_score()
|
||||
if position is None:
|
||||
return False
|
||||
|
||||
# Get player name
|
||||
if name is None:
|
||||
# Import get_input here to avoid circular imports
|
||||
from .input import get_input
|
||||
name = get_input("New high score! Enter your name:", "Player")
|
||||
if name is None: # User cancelled
|
||||
name = "Player"
|
||||
|
||||
# Insert new score at correct position
|
||||
self.highScores.insert(position - 1, {
|
||||
'name': name,
|
||||
'score': self.currentScore
|
||||
})
|
||||
|
||||
# Keep only top 10
|
||||
self.highScores = self.highScores[:10]
|
||||
|
||||
# Save to config - try both methods for maximum compatibility
|
||||
try:
|
||||
# Try new method first
|
||||
for i, entry in enumerate(self.highScores):
|
||||
self.configService.localConfig.set("scoreboard", f"score_{i+1}", str(entry['score']))
|
||||
self.configService.localConfig.set("scoreboard", f"name_{i+1}", entry['name'])
|
||||
|
||||
# Try to write with configService
|
||||
try:
|
||||
self.configService.write_local_config()
|
||||
except Exception as e:
|
||||
print(f"Error writing config with configService: {e}")
|
||||
# Fallback to old method if configService fails
|
||||
for i, entry in enumerate(self.highScores):
|
||||
localConfig.set("scoreboard", f"score_{i+1}", str(entry['score']))
|
||||
localConfig.set("scoreboard", f"name_{i+1}", entry['name'])
|
||||
write_config()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error writing high scores: {e}")
|
||||
# If all else fails, try direct old method
|
||||
for i, entry in enumerate(self.highScores):
|
||||
localConfig.set("scoreboard", f"score_{i+1}", str(entry['score']))
|
||||
localConfig.set("scoreboard", f"name_{i+1}", entry['name'])
|
||||
write_config()
|
||||
|
||||
# Announce success
|
||||
try:
|
||||
self.speech.messagebox(f"Congratulations {name}! You got position {position} on the scoreboard!")
|
||||
except:
|
||||
# Fallback to global speak function
|
||||
from .speech import speak
|
||||
speak(f"Congratulations {name}! You got position {position} on the scoreboard!")
|
||||
|
||||
time.sleep(1)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def has_high_scores():
|
||||
"""Check if the current game has any high scores.
|
||||
|
||||
Returns:
|
||||
bool: True if at least one high score exists, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Get PathService to access game name
|
||||
pathService = PathService.get_instance()
|
||||
gameName = pathService.gameName
|
||||
|
||||
# If no game name, try to get from window title
|
||||
if not gameName:
|
||||
try:
|
||||
import pygame
|
||||
if pygame.display.get_caption()[0]:
|
||||
gameName = pygame.display.get_caption()[0]
|
||||
pathService.gameName = gameName
|
||||
except:
|
||||
pass
|
||||
|
||||
# Ensure path service is properly initialized
|
||||
if gameName and not pathService.gamePath:
|
||||
pathService.initialize(gameName)
|
||||
|
||||
# Get the config file path
|
||||
configPath = os.path.join(pathService.gamePath, "config.ini")
|
||||
|
||||
# If config file doesn't exist, there are no scores
|
||||
if not os.path.exists(configPath):
|
||||
return False
|
||||
|
||||
# Ensure config service is properly connected to path service
|
||||
configService = ConfigService.get_instance()
|
||||
configService.set_game_info(gameName, pathService)
|
||||
|
||||
# Create scoreboard using the properly initialized services
|
||||
board = Scoreboard(0, configService)
|
||||
|
||||
# Force a read of local config to ensure fresh data
|
||||
configService.read_local_config()
|
||||
|
||||
# Get high scores
|
||||
scores = board.get_high_scores()
|
||||
|
||||
# Check if any score is greater than zero
|
||||
return any(score['score'] > 0 for score in scores)
|
||||
except Exception as e:
|
||||
print(f"Error checking high scores: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def display_high_scores():
|
||||
"""Display high scores for the current game.
|
||||
|
||||
Reads the high scores from Scoreboard class.
|
||||
Shows the game name at the top followed by the available scores.
|
||||
"""
|
||||
try:
|
||||
# Get PathService to access game name
|
||||
pathService = PathService.get_instance()
|
||||
gameName = pathService.gameName
|
||||
|
||||
# If no game name, try to get from window title
|
||||
if not gameName:
|
||||
try:
|
||||
import pygame
|
||||
if pygame.display.get_caption()[0]:
|
||||
gameName = pygame.display.get_caption()[0]
|
||||
pathService.gameName = gameName
|
||||
except:
|
||||
pass
|
||||
|
||||
# Ensure path service is properly initialized
|
||||
if gameName and not pathService.gamePath:
|
||||
pathService.initialize(gameName)
|
||||
|
||||
# Ensure config service is properly connected to path service
|
||||
configService = ConfigService.get_instance()
|
||||
configService.set_game_info(gameName, pathService)
|
||||
|
||||
# Create scoreboard using the properly initialized services
|
||||
board = Scoreboard(0, configService)
|
||||
|
||||
# Force a read of local config to ensure fresh data
|
||||
configService.read_local_config()
|
||||
|
||||
# Get high scores
|
||||
scores = board.get_high_scores()
|
||||
|
||||
# Filter out scores with zero points
|
||||
validScores = [score for score in scores if score['score'] > 0]
|
||||
|
||||
# Prepare the lines to display
|
||||
lines = [f"High Scores for {gameName}:"]
|
||||
|
||||
# Add scores to the display list
|
||||
if validScores:
|
||||
for i, entry in enumerate(validScores, 1):
|
||||
scoreStr = f"{i}. {entry['name']}: {entry['score']}"
|
||||
lines.append(scoreStr)
|
||||
else:
|
||||
lines.append("No high scores yet.")
|
||||
|
||||
# Display the high scores
|
||||
display_text(lines)
|
||||
except Exception as e:
|
||||
print(f"Error displaying high scores: {e}")
|
||||
info = ["Could not display high scores."]
|
||||
display_text(info)
|
||||
|
||||
# For backward compatibility with older code that might call displayHigh_scores
|
||||
displayHigh_scores = display_high_scores
|
274
services.py
274
services.py
@ -1,274 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Service classes for Storm Games.
|
||||
|
||||
Provides centralized services to replace global variables:
|
||||
- ConfigService: Manages game configuration
|
||||
- VolumeService: Handles volume settings
|
||||
- PathService: Manages file paths
|
||||
"""
|
||||
|
||||
import configparser
|
||||
import os
|
||||
from xdg import BaseDirectory
|
||||
|
||||
# For backward compatibility
|
||||
from .config import gamePath, globalPath, write_config, read_config
|
||||
|
||||
class ConfigService:
|
||||
"""Configuration management service."""
|
||||
|
||||
_instance = None
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls):
|
||||
"""Get or create the singleton instance."""
|
||||
if cls._instance is None:
|
||||
cls._instance = ConfigService()
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize configuration parsers."""
|
||||
self.localConfig = configparser.ConfigParser()
|
||||
self.globalConfig = configparser.ConfigParser()
|
||||
self.gameTitle = None
|
||||
self.pathService = None
|
||||
|
||||
def set_game_info(self, gameTitle, pathService):
|
||||
"""Set game information and initialize configs.
|
||||
|
||||
Args:
|
||||
gameTitle (str): Title of the game
|
||||
pathService (PathService): Path service instance
|
||||
"""
|
||||
self.gameTitle = gameTitle
|
||||
self.pathService = pathService
|
||||
|
||||
# Load existing configurations
|
||||
self.read_local_config()
|
||||
self.read_global_config()
|
||||
|
||||
def read_local_config(self):
|
||||
"""Read local configuration from file."""
|
||||
try:
|
||||
# Try to use pathService if available
|
||||
if self.pathService and self.pathService.gamePath:
|
||||
with open(os.path.join(self.pathService.gamePath, "config.ini"), 'r') as configFile:
|
||||
self.localConfig.read_file(configFile)
|
||||
# Fallback to global gamePath
|
||||
elif gamePath:
|
||||
with open(os.path.join(gamePath, "config.ini"), 'r') as configFile:
|
||||
self.localConfig.read_file(configFile)
|
||||
# Delegate to old function as last resort
|
||||
else:
|
||||
read_config(False)
|
||||
self.localConfig = configparser.ConfigParser()
|
||||
self.localConfig.read_dict(globals().get('localConfig', {}))
|
||||
except:
|
||||
pass
|
||||
|
||||
def read_global_config(self):
|
||||
"""Read global configuration from file."""
|
||||
try:
|
||||
# Try to use pathService if available
|
||||
if self.pathService and self.pathService.globalPath:
|
||||
with open(os.path.join(self.pathService.globalPath, "config.ini"), 'r') as configFile:
|
||||
self.globalConfig.read_file(configFile)
|
||||
# Fallback to global globalPath
|
||||
elif globalPath:
|
||||
with open(os.path.join(globalPath, "config.ini"), 'r') as configFile:
|
||||
self.globalConfig.read_file(configFile)
|
||||
# Delegate to old function as last resort
|
||||
else:
|
||||
read_config(True)
|
||||
self.globalConfig = configparser.ConfigParser()
|
||||
self.globalConfig.read_dict(globals().get('globalConfig', {}))
|
||||
except:
|
||||
pass
|
||||
|
||||
def write_local_config(self):
|
||||
"""Write local configuration to file."""
|
||||
try:
|
||||
# Try to use pathService if available
|
||||
if self.pathService and self.pathService.gamePath:
|
||||
with open(os.path.join(self.pathService.gamePath, "config.ini"), 'w') as configFile:
|
||||
self.localConfig.write(configFile)
|
||||
# Fallback to global gamePath
|
||||
elif gamePath:
|
||||
with open(os.path.join(gamePath, "config.ini"), 'w') as configFile:
|
||||
self.localConfig.write(configFile)
|
||||
# Delegate to old function as last resort
|
||||
else:
|
||||
# Update old global config
|
||||
globals()['localConfig'] = self.localConfig
|
||||
write_config(False)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to write local config: {e}")
|
||||
|
||||
def write_global_config(self):
|
||||
"""Write global configuration to file."""
|
||||
try:
|
||||
# Try to use pathService if available
|
||||
if self.pathService and self.pathService.globalPath:
|
||||
with open(os.path.join(self.pathService.globalPath, "config.ini"), 'w') as configFile:
|
||||
self.globalConfig.write(configFile)
|
||||
# Fallback to global globalPath
|
||||
elif globalPath:
|
||||
with open(os.path.join(globalPath, "config.ini"), 'w') as configFile:
|
||||
self.globalConfig.write(configFile)
|
||||
# Delegate to old function as last resort
|
||||
else:
|
||||
# Update old global config
|
||||
globals()['globalConfig'] = self.globalConfig
|
||||
write_config(True)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to write global config: {e}")
|
||||
|
||||
|
||||
class VolumeService:
|
||||
"""Volume management service."""
|
||||
|
||||
_instance = None
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls):
|
||||
"""Get or create the singleton instance."""
|
||||
if cls._instance is None:
|
||||
cls._instance = VolumeService()
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize volume settings."""
|
||||
self.bgmVolume = 0.75 # Default background music volume
|
||||
self.sfxVolume = 1.0 # Default sound effects volume
|
||||
self.masterVolume = 1.0 # Default master volume
|
||||
|
||||
def adjust_master_volume(self, change, pygameMixer=None):
|
||||
"""Adjust the master volume for all sounds.
|
||||
|
||||
Args:
|
||||
change (float): Amount to change volume by (positive or negative)
|
||||
pygameMixer: Optional pygame.mixer module for real-time updates
|
||||
"""
|
||||
self.masterVolume = max(0.0, min(1.0, self.masterVolume + change))
|
||||
|
||||
# Update real-time audio if pygame mixer is provided
|
||||
if pygameMixer:
|
||||
# Update music volume
|
||||
if pygameMixer.music.get_busy():
|
||||
pygameMixer.music.set_volume(self.bgmVolume * self.masterVolume)
|
||||
|
||||
# Update all sound channels
|
||||
for i in range(pygameMixer.get_num_channels()):
|
||||
channel = pygameMixer.Channel(i)
|
||||
if channel.get_busy():
|
||||
currentVolume = channel.get_volume()
|
||||
if isinstance(currentVolume, (int, float)):
|
||||
# Mono audio
|
||||
channel.set_volume(currentVolume * self.masterVolume)
|
||||
else:
|
||||
# Stereo audio
|
||||
left, right = currentVolume
|
||||
channel.set_volume(left * self.masterVolume, right * self.masterVolume)
|
||||
|
||||
def adjust_bgm_volume(self, change, pygameMixer=None):
|
||||
"""Adjust only the background music volume.
|
||||
|
||||
Args:
|
||||
change (float): Amount to change volume by (positive or negative)
|
||||
pygameMixer: Optional pygame.mixer module for real-time updates
|
||||
"""
|
||||
self.bgmVolume = max(0.0, min(1.0, self.bgmVolume + change))
|
||||
|
||||
# Update real-time audio if pygame mixer is provided
|
||||
if pygameMixer and pygameMixer.music.get_busy():
|
||||
pygameMixer.music.set_volume(self.bgmVolume * self.masterVolume)
|
||||
|
||||
def adjust_sfx_volume(self, change, pygameMixer=None):
|
||||
"""Adjust volume for sound effects only.
|
||||
|
||||
Args:
|
||||
change (float): Amount to change volume by (positive or negative)
|
||||
pygameMixer: Optional pygame.mixer module for real-time updates
|
||||
"""
|
||||
self.sfxVolume = max(0.0, min(1.0, self.sfxVolume + change))
|
||||
|
||||
# Update real-time audio if pygame mixer is provided
|
||||
if pygameMixer:
|
||||
# Update all sound channels except reserved ones
|
||||
for i in range(pygameMixer.get_num_channels()):
|
||||
channel = pygameMixer.Channel(i)
|
||||
if channel.get_busy():
|
||||
currentVolume = channel.get_volume()
|
||||
if isinstance(currentVolume, (int, float)):
|
||||
# Mono audio
|
||||
channel.set_volume(currentVolume * self.sfxVolume * self.masterVolume)
|
||||
else:
|
||||
# Stereo audio
|
||||
left, right = currentVolume
|
||||
channel.set_volume(left * self.sfxVolume * self.masterVolume,
|
||||
right * self.sfxVolume * self.masterVolume)
|
||||
|
||||
def get_bgm_volume(self):
|
||||
"""Get the current BGM volume with master adjustment.
|
||||
|
||||
Returns:
|
||||
float: Current adjusted BGM volume
|
||||
"""
|
||||
return self.bgmVolume * self.masterVolume
|
||||
|
||||
def get_sfx_volume(self):
|
||||
"""Get the current SFX volume with master adjustment.
|
||||
|
||||
Returns:
|
||||
float: Current adjusted SFX volume
|
||||
"""
|
||||
return self.sfxVolume * self.masterVolume
|
||||
|
||||
|
||||
class PathService:
|
||||
"""Path management service."""
|
||||
|
||||
_instance = None
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls):
|
||||
"""Get or create the singleton instance."""
|
||||
if cls._instance is None:
|
||||
cls._instance = PathService()
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize path variables."""
|
||||
self.globalPath = None
|
||||
self.gamePath = None
|
||||
self.gameName = None
|
||||
|
||||
# Try to initialize from global variables for backward compatibility
|
||||
global gamePath, globalPath
|
||||
if gamePath:
|
||||
self.gamePath = gamePath
|
||||
if globalPath:
|
||||
self.globalPath = globalPath
|
||||
|
||||
def initialize(self, gameTitle):
|
||||
"""Initialize paths for a game.
|
||||
|
||||
Args:
|
||||
gameTitle (str): Title of the game
|
||||
"""
|
||||
self.gameName = gameTitle
|
||||
self.globalPath = os.path.join(BaseDirectory.xdg_config_home, "storm-games")
|
||||
self.gamePath = os.path.join(self.globalPath,
|
||||
str.lower(str.replace(gameTitle, " ", "-")))
|
||||
|
||||
# Create game directory if it doesn't exist
|
||||
if not os.path.exists(self.gamePath):
|
||||
os.makedirs(self.gamePath)
|
||||
|
||||
# Update global variables for backward compatibility
|
||||
global gamePath, globalPath
|
||||
gamePath = self.gamePath
|
||||
globalPath = self.globalPath
|
||||
|
||||
return self
|
528
sound.py
528
sound.py
@ -1,528 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Sound handling for Storm Games.
|
||||
|
||||
Provides functionality for:
|
||||
- Playing background music and sound effects
|
||||
- 2D positional audio (x,y)
|
||||
- Volume controls
|
||||
"""
|
||||
|
||||
import os
|
||||
import pygame
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
import math
|
||||
from .services import VolumeService
|
||||
|
||||
# Global instance for backward compatibility
|
||||
volumeService = VolumeService.get_instance()
|
||||
|
||||
class Sound:
|
||||
"""Handles sound loading and playback."""
|
||||
|
||||
def __init__(self, soundDir="sounds/", volumeService=None):
|
||||
"""Initialize sound system."""
|
||||
self.soundDir = soundDir
|
||||
self.sounds = {}
|
||||
self.volumeService = volumeService or VolumeService.get_instance()
|
||||
|
||||
if not pygame.mixer.get_init():
|
||||
pygame.mixer.pre_init(44100, -16, 2, 1024)
|
||||
pygame.mixer.init()
|
||||
pygame.mixer.set_num_channels(32)
|
||||
pygame.mixer.set_reserved(0)
|
||||
|
||||
self.load_sounds()
|
||||
|
||||
def load_sounds(self):
|
||||
"""Load all sound files from the sound directory and its subdirectories."""
|
||||
try:
|
||||
for dirPath, _, fileNames in os.walk(self.soundDir):
|
||||
relPath = os.path.relpath(dirPath, self.soundDir)
|
||||
|
||||
for fileName in fileNames:
|
||||
if fileName.lower().endswith(('.ogg', '.wav')):
|
||||
fullPath = os.path.join(dirPath, fileName)
|
||||
baseName = os.path.splitext(fileName)[0]
|
||||
|
||||
soundKey = baseName if relPath == '.' else os.path.join(relPath, baseName).replace('\\', '/')
|
||||
self.sounds[soundKey] = pygame.mixer.Sound(fullPath)
|
||||
except Exception as e:
|
||||
print(f"Error loading sounds: {e}")
|
||||
|
||||
def _find_matching_sound(self, pattern):
|
||||
"""Find a random sound matching the pattern."""
|
||||
keys = [k for k in self.sounds.keys() if re.match("^" + pattern + ".*", k)]
|
||||
return random.choice(keys) if keys else None
|
||||
|
||||
def _handle_cutscene(self, soundName):
|
||||
"""Play a sound as a cut scene."""
|
||||
pygame.event.clear()
|
||||
pygame.mixer.stop()
|
||||
|
||||
channel = pygame.mixer.Channel(0)
|
||||
sfxVolume = self.volumeService.get_sfx_volume()
|
||||
channel.set_volume(sfxVolume, sfxVolume)
|
||||
|
||||
channel.play(self.sounds[soundName])
|
||||
|
||||
while pygame.mixer.get_busy():
|
||||
for event in pygame.event.get():
|
||||
if event.type == pygame.KEYDOWN and event.key in [pygame.K_ESCAPE, pygame.K_RETURN, pygame.K_SPACE]:
|
||||
pygame.mixer.stop()
|
||||
return None
|
||||
pygame.time.delay(10)
|
||||
|
||||
return None
|
||||
|
||||
def _get_stereo_panning(self, playerPos, objPos, centerDistance=None):
|
||||
"""Calculate stereo panning based on positions."""
|
||||
# Extract x-positions
|
||||
playerX = playerPos[0] if isinstance(playerPos, (tuple, list)) else playerPos
|
||||
objX = objPos[0] if isinstance(objPos, (tuple, list)) else objPos
|
||||
|
||||
# For directional sound with fixed distance
|
||||
if centerDistance is not None:
|
||||
if abs(playerX - objX) <= centerDistance:
|
||||
return (1, 1) # Center
|
||||
elif playerX > objX:
|
||||
return (1, 0.505) # Left
|
||||
else:
|
||||
return (0.505, 1) # Right
|
||||
|
||||
# Calculate regular panning
|
||||
volume, left, right = self.calculate_volume_and_pan(playerPos, objPos)
|
||||
return (volume * left, volume * right) if volume > 0 else (0, 0)
|
||||
|
||||
def play_sound(self, soundName, volume=1.0, loop=False, playerPos=None, objPos=None,
|
||||
centerDistance=None, pattern=False, interrupt=False, pause=False, cutScene=False):
|
||||
"""Unified method to play sounds with various options."""
|
||||
# Resolve sound name if pattern matching is requested
|
||||
if pattern:
|
||||
soundName = self._find_matching_sound(soundName)
|
||||
if not soundName:
|
||||
return None
|
||||
|
||||
# Check if sound exists
|
||||
if soundName not in self.sounds:
|
||||
return None
|
||||
|
||||
# Handle cut scene mode
|
||||
if cutScene:
|
||||
return self._handle_cutscene(soundName)
|
||||
|
||||
# Handle interrupt (stop other sounds)
|
||||
if interrupt:
|
||||
pygame.event.clear()
|
||||
pygame.mixer.stop()
|
||||
|
||||
# Play the sound
|
||||
channel = self.sounds[soundName].play(-1 if loop else 0)
|
||||
if not channel:
|
||||
return None
|
||||
|
||||
# Apply appropriate volume settings
|
||||
sfx_volume = self.volumeService.get_sfx_volume()
|
||||
|
||||
# Handle positional audio if positions are provided
|
||||
if playerPos is not None and objPos is not None:
|
||||
# Calculate stereo panning
|
||||
left_vol, right_vol = self._get_stereo_panning(playerPos, objPos, centerDistance)
|
||||
|
||||
# Don't play if out of range
|
||||
if left_vol == 0 and right_vol == 0:
|
||||
channel.stop()
|
||||
return None
|
||||
|
||||
# Apply positional volume adjustments
|
||||
channel.set_volume(volume * left_vol * sfx_volume, volume * right_vol * sfx_volume)
|
||||
else:
|
||||
# Non-positional sound
|
||||
channel.set_volume(volume * sfx_volume)
|
||||
|
||||
# Pause execution if requested
|
||||
if pause:
|
||||
time.sleep(self.sounds[soundName].get_length())
|
||||
|
||||
return channel
|
||||
|
||||
def calculate_volume_and_pan(self, playerPos, objPos, maxDistance=12):
|
||||
"""Calculate volume and stereo panning based on relative positions."""
|
||||
# Determine if we're using 2D or 1D positioning
|
||||
if isinstance(playerPos, (tuple, list)) and isinstance(objPos, (tuple, list)):
|
||||
# 2D distance calculation
|
||||
distance = math.sqrt((playerPos[0] - objPos[0])**2 + (playerPos[1] - objPos[1])**2)
|
||||
playerX, objX = playerPos[0], objPos[0]
|
||||
else:
|
||||
# 1D calculation (backward compatible)
|
||||
distance = abs(playerPos - objPos)
|
||||
playerX, objX = playerPos, objPos
|
||||
|
||||
if distance > maxDistance:
|
||||
return 0, 0, 0 # No sound if out of range
|
||||
|
||||
# Calculate volume (non-linear scaling for more noticeable changes)
|
||||
volume = (((maxDistance - distance) / maxDistance) ** 1.5) * self.volumeService.masterVolume
|
||||
|
||||
# Determine left/right based on relative position
|
||||
if playerX < objX: # Object is to the right
|
||||
left = max(0, 1 - (objX - playerX) / maxDistance)
|
||||
right = 1
|
||||
elif playerX > objX: # Object is to the left
|
||||
left = 1
|
||||
right = max(0, 1 - (playerX - objX) / maxDistance)
|
||||
else: # Player is on the object
|
||||
left = right = 1
|
||||
|
||||
return volume, left, right
|
||||
|
||||
def update_sound_position(self, channel, playerPos, objPos):
|
||||
"""Update positional audio for a playing sound."""
|
||||
if not channel:
|
||||
return None
|
||||
|
||||
# Calculate new stereo panning
|
||||
left_vol, right_vol = self._get_stereo_panning(playerPos, objPos)
|
||||
|
||||
# Stop if out of range
|
||||
if left_vol == 0 and right_vol == 0:
|
||||
channel.stop()
|
||||
return None
|
||||
|
||||
# Apply the volume and pan
|
||||
channel.set_volume(left_vol * self.volumeService.sfxVolume, right_vol * self.volumeService.sfxVolume)
|
||||
return channel
|
||||
|
||||
def stop_sound(self, channel):
|
||||
"""Stop a playing sound channel."""
|
||||
if channel:
|
||||
try:
|
||||
channel.stop()
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
def play_falling_sound(self, soundPrefix, playerPos, objPos, startY, currentY=0, maxY=20, existingChannel=None):
|
||||
"""Play or update a sound with positional audio that changes with height."""
|
||||
# Extract positions
|
||||
playerX = playerPos[0] if isinstance(playerPos, (tuple, list)) else playerPos
|
||||
objX = objPos[0] if isinstance(objPos, (tuple, list)) else objPos
|
||||
|
||||
# Calculate volumes
|
||||
volume, left, right = self.calculate_volume_and_pan(playerX, objX)
|
||||
|
||||
# Apply vertical fall multiplier (0 at maxY, 1 at y=0)
|
||||
fallMultiplier = 1 - (currentY / maxY)
|
||||
finalVolume = volume * fallMultiplier
|
||||
finalLeft = left * finalVolume
|
||||
finalRight = right * finalVolume
|
||||
|
||||
# Update existing channel or create new one
|
||||
if existingChannel:
|
||||
if volume == 0:
|
||||
existingChannel.stop()
|
||||
return None
|
||||
existingChannel.set_volume(
|
||||
finalLeft * self.volumeService.sfxVolume,
|
||||
finalRight * self.volumeService.sfxVolume
|
||||
)
|
||||
return existingChannel
|
||||
else:
|
||||
if volume == 0:
|
||||
return None
|
||||
|
||||
# Find a matching sound
|
||||
soundName = self._find_matching_sound(soundPrefix)
|
||||
if not soundName:
|
||||
return None
|
||||
|
||||
# Play the sound
|
||||
channel = self.sounds[soundName].play()
|
||||
if channel:
|
||||
channel.set_volume(
|
||||
finalLeft * self.volumeService.sfxVolume,
|
||||
finalRight * self.volumeService.sfxVolume
|
||||
)
|
||||
return channel
|
||||
|
||||
def play_bgm(self, musicFile):
|
||||
"""Play background music with proper volume settings."""
|
||||
try:
|
||||
pygame.mixer.music.stop()
|
||||
pygame.mixer.music.load(musicFile)
|
||||
pygame.mixer.music.set_volume(self.volumeService.get_bgm_volume())
|
||||
pygame.mixer.music.play(-1)
|
||||
except Exception as e:
|
||||
print(f"Error playing background music: {e}")
|
||||
|
||||
def adjust_master_volume(self, change):
|
||||
"""Adjust the master volume for all sounds."""
|
||||
self.volumeService.adjust_master_volume(change, pygame.mixer)
|
||||
|
||||
def adjust_bgm_volume(self, change):
|
||||
"""Adjust only the background music volume."""
|
||||
self.volumeService.adjust_bgm_volume(change, pygame.mixer)
|
||||
|
||||
def adjust_sfx_volume(self, change):
|
||||
"""Adjust volume for sound effects only."""
|
||||
self.volumeService.adjust_sfx_volume(change, pygame.mixer)
|
||||
|
||||
|
||||
# Optimized helper functions for global use
|
||||
def _get_stereo_panning(playerPos, objPos, centerDistance=None, maxDistance=12):
|
||||
"""Simplified panning calculation."""
|
||||
# Extract x-positions
|
||||
playerX = playerPos[0] if isinstance(playerPos, (tuple, list)) else playerPos
|
||||
objX = objPos[0] if isinstance(objPos, (tuple, list)) else objPos
|
||||
|
||||
# For directional sound with fixed distance
|
||||
if centerDistance is not None:
|
||||
if abs(playerX - objX) <= centerDistance:
|
||||
return (1, 1) # Center
|
||||
elif playerX > objX:
|
||||
return (1, 0.505) # Left
|
||||
else:
|
||||
return (0.505, 1) # Right
|
||||
|
||||
# Calculate distance
|
||||
if isinstance(playerPos, (tuple, list)) and isinstance(objPos, (tuple, list)):
|
||||
distance = math.sqrt((playerPos[0] - objPos[0])**2 + (playerPos[1] - objPos[1])**2)
|
||||
else:
|
||||
distance = abs(playerPos - objPos)
|
||||
|
||||
if distance > maxDistance:
|
||||
return (0, 0) # No sound if out of range
|
||||
|
||||
# Calculate volume (non-linear scaling for more noticeable changes)
|
||||
volume = (((maxDistance - distance) / maxDistance) ** 1.5) * volumeService.masterVolume
|
||||
|
||||
# Determine left/right based on relative position
|
||||
if playerX < objX: # Object is to the right
|
||||
left = max(0, 1 - (objX - playerX) / maxDistance)
|
||||
right = 1
|
||||
elif playerX > objX: # Object is to the left
|
||||
left = 1
|
||||
right = max(0, 1 - (playerX - objX) / maxDistance)
|
||||
else: # Player is on the object
|
||||
left = right = 1
|
||||
|
||||
return (volume * left, volume * right)
|
||||
|
||||
def _play_cutscene(sound, sounds=None):
|
||||
"""Play a sound as a cut scene."""
|
||||
pygame.event.clear()
|
||||
pygame.mixer.stop()
|
||||
|
||||
channel = pygame.mixer.Channel(0)
|
||||
sfxVolume = volumeService.get_sfx_volume()
|
||||
channel.set_volume(sfxVolume, sfxVolume)
|
||||
|
||||
# Determine which sound to play
|
||||
if isinstance(sound, pygame.mixer.Sound):
|
||||
channel.play(sound)
|
||||
elif isinstance(sounds, dict) and sound in sounds:
|
||||
channel.play(sounds[sound])
|
||||
elif isinstance(sounds, Sound) and sound in sounds.sounds:
|
||||
channel.play(sounds.sounds[sound])
|
||||
else:
|
||||
return None
|
||||
|
||||
# Wait for completion or key press
|
||||
while pygame.mixer.get_busy():
|
||||
for event in pygame.event.get():
|
||||
if event.type == pygame.KEYDOWN and event.key in [pygame.K_ESCAPE, pygame.K_RETURN, pygame.K_SPACE]:
|
||||
pygame.mixer.stop()
|
||||
return None
|
||||
pygame.time.delay(10)
|
||||
|
||||
return None
|
||||
|
||||
def _find_matching_sound(soundPattern, sounds):
|
||||
"""Find sounds matching a pattern in a dictionary."""
|
||||
if isinstance(sounds, Sound):
|
||||
keys = [k for k in sounds.sounds.keys() if re.match("^" + soundPattern + ".*", k)]
|
||||
else:
|
||||
keys = [k for k in sounds.keys() if re.match("^" + soundPattern + ".*", k)]
|
||||
return random.choice(keys) if keys else None
|
||||
|
||||
# Global functions for backward compatibility
|
||||
def play_bgm(musicFile):
|
||||
"""Play background music with proper volume settings."""
|
||||
try:
|
||||
pygame.mixer.music.stop()
|
||||
pygame.mixer.music.load(musicFile)
|
||||
pygame.mixer.music.set_volume(volumeService.get_bgm_volume())
|
||||
pygame.mixer.music.play(-1)
|
||||
except: pass
|
||||
|
||||
def adjust_master_volume(change):
|
||||
"""Adjust the master volume."""
|
||||
volumeService.adjust_master_volume(change, pygame.mixer)
|
||||
|
||||
def adjust_bgm_volume(change):
|
||||
"""Adjust background music volume."""
|
||||
volumeService.adjust_bgm_volume(change, pygame.mixer)
|
||||
|
||||
def adjust_sfx_volume(change):
|
||||
"""Adjust sound effects volume."""
|
||||
volumeService.adjust_sfx_volume(change, pygame.mixer)
|
||||
|
||||
def calculate_volume_and_pan(playerPos, objPos, maxDistance=12):
|
||||
"""Calculate volume and stereo panning."""
|
||||
left_vol, right_vol = _get_stereo_panning(playerPos, objPos, None, maxDistance)
|
||||
# Convert to old format (volume, left, right)
|
||||
if left_vol == 0 and right_vol == 0:
|
||||
return 0, 0, 0
|
||||
elif left_vol >= right_vol:
|
||||
volume = left_vol
|
||||
return volume, 1, right_vol/left_vol
|
||||
else:
|
||||
volume = right_vol
|
||||
return volume, left_vol/right_vol, 1
|
||||
|
||||
def play_sound(sound_or_name, volume=1.0, loop=False, playerPos=None, objPos=None,
|
||||
centerDistance=None, pattern=False, interrupt=False, pause=False,
|
||||
cutScene=False, sounds=None):
|
||||
"""Unified sound playing function with backward compatibility."""
|
||||
# Handle cut scene mode
|
||||
if cutScene:
|
||||
return _play_cutscene(sound_or_name, sounds)
|
||||
|
||||
# Handle pattern matching
|
||||
if pattern and isinstance(sound_or_name, str) and sounds:
|
||||
matched_sound = _find_matching_sound(sound_or_name, sounds)
|
||||
if not matched_sound:
|
||||
return None
|
||||
sound_or_name = matched_sound
|
||||
|
||||
# Handle interrupt
|
||||
if interrupt:
|
||||
pygame.event.clear()
|
||||
pygame.mixer.stop()
|
||||
|
||||
# Case 1: Sound instance provided
|
||||
if isinstance(sound_or_name, Sound):
|
||||
return sound_or_name.play_sound(sound_or_name, volume, loop, playerPos, objPos,
|
||||
centerDistance, False, False, pause, False)
|
||||
|
||||
# Case 2: Sound name with Sound instance
|
||||
elif isinstance(sounds, Sound) and isinstance(sound_or_name, str):
|
||||
return sounds.play_sound(sound_or_name, volume, loop, playerPos, objPos,
|
||||
centerDistance, False, False, pause, False)
|
||||
|
||||
# Case 3: Direct pygame.Sound
|
||||
elif isinstance(sound_or_name, pygame.mixer.Sound):
|
||||
channel = sound_or_name.play(-1 if loop else 0)
|
||||
if channel:
|
||||
channel.set_volume(volume * volumeService.get_sfx_volume())
|
||||
return channel
|
||||
|
||||
# Case 4: Sound name with dictionary
|
||||
elif isinstance(sounds, dict) and isinstance(sound_or_name, str) and sound_or_name in sounds:
|
||||
# Play the sound
|
||||
channel = sounds[sound_or_name].play(-1 if loop else 0)
|
||||
if not channel:
|
||||
return None
|
||||
|
||||
# Apply volume settings
|
||||
sfx_vol = volumeService.get_sfx_volume()
|
||||
|
||||
# Handle positional audio
|
||||
if playerPos is not None and objPos is not None:
|
||||
left_vol, right_vol = _get_stereo_panning(playerPos, objPos, centerDistance)
|
||||
if left_vol == 0 and right_vol == 0:
|
||||
channel.stop()
|
||||
return None
|
||||
channel.set_volume(volume * left_vol * sfx_vol, volume * right_vol * sfx_vol)
|
||||
else:
|
||||
channel.set_volume(volume * sfx_vol)
|
||||
|
||||
# Pause if requested
|
||||
if pause:
|
||||
time.sleep(sounds[sound_or_name].get_length())
|
||||
|
||||
return channel
|
||||
|
||||
return None
|
||||
|
||||
def obj_update(channel, playerPos, objPos):
|
||||
"""Update positional audio for a playing sound."""
|
||||
if not channel:
|
||||
return None
|
||||
|
||||
left_vol, right_vol = _get_stereo_panning(playerPos, objPos)
|
||||
if left_vol == 0 and right_vol == 0:
|
||||
channel.stop()
|
||||
return None
|
||||
|
||||
channel.set_volume(left_vol * volumeService.sfxVolume, right_vol * volumeService.sfxVolume)
|
||||
return channel
|
||||
|
||||
def obj_stop(channel):
|
||||
"""Stop a sound channel."""
|
||||
if channel:
|
||||
try: channel.stop()
|
||||
except: pass
|
||||
return None
|
||||
|
||||
# Extremely concise lambda definitions for legacy functions
|
||||
obj_play = lambda sounds, soundName, playerPos, objPos, loop=True: play_sound(
|
||||
soundName, 1.0, loop, playerPos, objPos, None, False, False, False, False, sounds)
|
||||
|
||||
play_ambiance = lambda sounds, soundNames, probability, randomLocation=False: play_sound(
|
||||
random.choice(soundNames) if random.randint(1, 100) <= probability and not any(
|
||||
pygame.mixer.find_channel(True) and pygame.mixer.find_channel(True).get_busy()
|
||||
for _ in ([soundNames] if isinstance(soundNames, str) else soundNames)) else None,
|
||||
1.0, False, None, None, None, False, False, False, False,
|
||||
sounds if not isinstance(sounds, Sound) else None)
|
||||
|
||||
play_random = lambda sounds, soundName, pause=False, interrupt=False: play_sound(
|
||||
soundName, 1.0, False, None, None, None, True, interrupt, pause, False, sounds)
|
||||
|
||||
play_random_positional = lambda sounds, soundName, playerX, objectX: play_sound(
|
||||
soundName, 1.0, False, playerX, objectX, None, True, False, False, False, sounds)
|
||||
|
||||
play_directional_sound = lambda sounds, soundName, playerPos, objPos, centerDistance=3, volume=1.0: play_sound(
|
||||
soundName, volume, False, playerPos, objPos, centerDistance, False, False, False, False, sounds)
|
||||
|
||||
cut_scene = lambda sounds, soundName: _play_cutscene(soundName, sounds)
|
||||
|
||||
def play_random_falling(sounds, soundName, playerX, objectX, startY, currentY=0, maxY=20, existingChannel=None):
|
||||
"""Handle falling sound."""
|
||||
if isinstance(sounds, Sound):
|
||||
return sounds.play_falling_sound(soundName, playerX, objectX, startY, currentY, maxY, existingChannel)
|
||||
|
||||
# Legacy implementation
|
||||
left_vol, right_vol = _get_stereo_panning(playerX, objectX)
|
||||
if left_vol == 0 and right_vol == 0:
|
||||
if existingChannel:
|
||||
existingChannel.stop()
|
||||
return None
|
||||
|
||||
# Calculate fall multiplier
|
||||
fallMultiplier = 1 - (currentY / maxY)
|
||||
finalLeft = left_vol * fallMultiplier
|
||||
finalRight = right_vol * fallMultiplier
|
||||
|
||||
if existingChannel:
|
||||
existingChannel.set_volume(
|
||||
finalLeft * volumeService.sfxVolume,
|
||||
finalRight * volumeService.sfxVolume
|
||||
)
|
||||
return existingChannel
|
||||
|
||||
# Find matching sound
|
||||
matched_sound = _find_matching_sound(soundName, sounds)
|
||||
if not matched_sound:
|
||||
return None
|
||||
|
||||
# Play the sound
|
||||
channel = sounds[matched_sound].play()
|
||||
if channel:
|
||||
channel.set_volume(
|
||||
finalLeft * volumeService.sfxVolume,
|
||||
finalRight * volumeService.sfxVolume
|
||||
)
|
||||
return channel
|
149
speech.py
149
speech.py
@ -1,149 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Speech handling for Storm Games.
|
||||
|
||||
Provides functionality for:
|
||||
- Text-to-speech using different speech providers
|
||||
- Speech delay control to prevent stuttering
|
||||
- On-screen text display
|
||||
"""
|
||||
|
||||
import pygame
|
||||
import textwrap
|
||||
import time
|
||||
from sys import exit
|
||||
|
||||
class Speech:
|
||||
"""Handles text-to-speech functionality."""
|
||||
|
||||
_instance = None
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls):
|
||||
"""Get or create the singleton instance."""
|
||||
if cls._instance is None:
|
||||
cls._instance = Speech()
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize speech system with available provider."""
|
||||
# Handle speech delays so we don't get stuttering
|
||||
self.lastSpoken = {"text": None, "time": 0}
|
||||
self.speechDelay = 250 # ms
|
||||
|
||||
# Try to initialize a speech provider
|
||||
self.provider = None
|
||||
self.providerName = None
|
||||
|
||||
# Try speechd first
|
||||
try:
|
||||
import speechd
|
||||
self.spd = speechd.Client()
|
||||
self.provider = self.spd
|
||||
self.providerName = "speechd"
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Try accessible_output2 next
|
||||
try:
|
||||
import accessible_output2.outputs.auto
|
||||
self.ao2 = accessible_output2.outputs.auto.Auto()
|
||||
self.provider = self.ao2
|
||||
self.providerName = "accessible_output2"
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# No speech providers found
|
||||
print("No speech providers found.")
|
||||
|
||||
def speak(self, text, interrupt=True):
|
||||
"""Speak text using the configured speech provider and display on screen.
|
||||
|
||||
Args:
|
||||
text (str): Text to speak and display
|
||||
interrupt (bool): Whether to interrupt current speech (default: True)
|
||||
"""
|
||||
if not self.provider:
|
||||
return
|
||||
|
||||
currentTime = pygame.time.get_ticks()
|
||||
|
||||
# Check if this is the same text within the delay window
|
||||
if (self.lastSpoken["text"] == text and
|
||||
currentTime - self.lastSpoken["time"] < self.speechDelay):
|
||||
return
|
||||
|
||||
# Update last spoken tracking
|
||||
self.lastSpoken["text"] = text
|
||||
self.lastSpoken["time"] = currentTime
|
||||
|
||||
# Proceed with speech
|
||||
if self.providerName == "speechd":
|
||||
if interrupt:
|
||||
self.spd.cancel()
|
||||
self.spd.say(text)
|
||||
elif self.providerName == "accessible_output2":
|
||||
self.ao2.speak(text, interrupt=interrupt)
|
||||
|
||||
# Display the text on screen
|
||||
screen = pygame.display.get_surface()
|
||||
if not screen:
|
||||
return
|
||||
|
||||
font = pygame.font.Font(None, 36)
|
||||
# Wrap the text
|
||||
maxWidth = screen.get_width() - 40 # Leave a 20-pixel margin on each side
|
||||
wrappedText = textwrap.wrap(text, width=maxWidth // font.size('A')[0])
|
||||
# Render each line
|
||||
textSurfaces = [font.render(line, True, (255, 255, 255)) for line in wrappedText]
|
||||
screen.fill((0, 0, 0)) # Clear screen with black
|
||||
# Calculate total height of text block
|
||||
totalHeight = sum(surface.get_height() for surface in textSurfaces)
|
||||
# Start y-position (centered vertically)
|
||||
currentY = (screen.get_height() - totalHeight) // 2
|
||||
# Blit each line of text
|
||||
for surface in textSurfaces:
|
||||
textRect = surface.get_rect(center=(screen.get_width() // 2, currentY + surface.get_height() // 2))
|
||||
screen.blit(surface, textRect)
|
||||
currentY += surface.get_height()
|
||||
pygame.display.flip()
|
||||
|
||||
def close(self):
|
||||
"""Clean up speech resources."""
|
||||
if self.providerName == "speechd":
|
||||
self.spd.close()
|
||||
|
||||
# Global instance for backward compatibility
|
||||
_speechInstance = None
|
||||
|
||||
def speak(text, interrupt=True):
|
||||
"""Speak text using the global speech instance.
|
||||
|
||||
Args:
|
||||
text (str): Text to speak and display
|
||||
interrupt (bool): Whether to interrupt current speech (default: True)
|
||||
"""
|
||||
global _speechInstance
|
||||
if _speechInstance is None:
|
||||
_speechInstance = Speech.get_instance()
|
||||
_speechInstance.speak(text, interrupt)
|
||||
|
||||
def messagebox(text):
|
||||
"""Display a simple message box with text.
|
||||
|
||||
Shows a message that can be repeated until the user chooses to continue.
|
||||
|
||||
Args:
|
||||
text (str): Message to display
|
||||
"""
|
||||
speech = Speech.get_instance()
|
||||
speech.speak(text + "\nPress any key to repeat or enter to continue.")
|
||||
while True:
|
||||
event = pygame.event.wait()
|
||||
if event.type == pygame.KEYDOWN:
|
||||
if event.key in (pygame.K_ESCAPE, pygame.K_RETURN):
|
||||
speech.speak(" ")
|
||||
return
|
||||
speech.speak(text + "\nPress any key to repeat or enter to continue.")
|
454
utils.py
454
utils.py
@ -1,454 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Utility functions and Game class for Storm Games.
|
||||
|
||||
Provides:
|
||||
- Game class for centralized management
|
||||
- Miscellaneous helper functions
|
||||
- Version checking utilities
|
||||
"""
|
||||
|
||||
import pygame
|
||||
import random
|
||||
import math
|
||||
import numpy as np
|
||||
import time
|
||||
import re
|
||||
import requests
|
||||
import os
|
||||
from .input import check_for_exit
|
||||
from setproctitle import setproctitle
|
||||
|
||||
from .services import PathService, ConfigService, VolumeService
|
||||
from .sound import Sound
|
||||
from .speech import Speech
|
||||
from .scoreboard import Scoreboard
|
||||
|
||||
class Game:
|
||||
"""Central class to manage all game systems."""
|
||||
|
||||
def __init__(self, title):
|
||||
"""Initialize a new game.
|
||||
|
||||
Args:
|
||||
title (str): Title of the game
|
||||
"""
|
||||
self.title = title
|
||||
|
||||
# Initialize services
|
||||
self.pathService = PathService.get_instance().initialize(title)
|
||||
self.configService = ConfigService.get_instance()
|
||||
self.configService.set_game_info(title, self.pathService)
|
||||
self.volumeService = VolumeService.get_instance()
|
||||
|
||||
# Initialize game components (lazy loaded)
|
||||
self._speech = None
|
||||
self._sound = None
|
||||
self._scoreboard = None
|
||||
|
||||
# Display text instructions flag
|
||||
self.displayTextUsageInstructions = False
|
||||
|
||||
@property
|
||||
def speech(self):
|
||||
"""Get the speech system (lazy loaded).
|
||||
|
||||
Returns:
|
||||
Speech: Speech system instance
|
||||
"""
|
||||
if not self._speech:
|
||||
self._speech = Speech.get_instance()
|
||||
return self._speech
|
||||
|
||||
@property
|
||||
def sound(self):
|
||||
"""Get the sound system (lazy loaded).
|
||||
|
||||
Returns:
|
||||
Sound: Sound system instance
|
||||
"""
|
||||
if not self._sound:
|
||||
self._sound = Sound("sounds/", self.volumeService)
|
||||
return self._sound
|
||||
|
||||
@property
|
||||
def scoreboard(self):
|
||||
"""Get the scoreboard (lazy loaded).
|
||||
|
||||
Returns:
|
||||
Scoreboard: Scoreboard instance
|
||||
"""
|
||||
if not self._scoreboard:
|
||||
self._scoreboard = Scoreboard(self.configService)
|
||||
return self._scoreboard
|
||||
|
||||
def initialize(self):
|
||||
"""Initialize the game GUI and sound system.
|
||||
|
||||
Returns:
|
||||
Game: Self for method chaining
|
||||
"""
|
||||
# Set process title
|
||||
setproctitle(str.lower(str.replace(self.title, " ", "")))
|
||||
|
||||
# Seed the random generator
|
||||
random.seed()
|
||||
|
||||
# Initialize pygame
|
||||
pygame.init()
|
||||
pygame.display.set_mode((800, 600))
|
||||
pygame.display.set_caption(self.title)
|
||||
|
||||
# Set up audio system
|
||||
pygame.mixer.pre_init(44100, -16, 2, 1024)
|
||||
pygame.mixer.init()
|
||||
pygame.mixer.set_num_channels(32)
|
||||
pygame.mixer.set_reserved(0) # Reserve channel for cut scenes
|
||||
|
||||
# Enable key repeat for volume controls
|
||||
pygame.key.set_repeat(500, 100)
|
||||
|
||||
# Load sound effects
|
||||
self.sound
|
||||
|
||||
# Play intro sound if available
|
||||
if 'game-intro' in self.sound.sounds:
|
||||
self.sound.cut_scene('game-intro')
|
||||
|
||||
return self
|
||||
|
||||
def speak(self, text, interrupt=True):
|
||||
"""Speak text using the speech system.
|
||||
|
||||
Args:
|
||||
text (str): Text to speak
|
||||
interrupt (bool): Whether to interrupt current speech
|
||||
|
||||
Returns:
|
||||
Game: Self for method chaining
|
||||
"""
|
||||
self.speech.speak(text, interrupt)
|
||||
return self
|
||||
|
||||
def play_bgm(self, musicFile):
|
||||
"""Play background music.
|
||||
|
||||
Args:
|
||||
musicFile (str): Path to music file
|
||||
|
||||
Returns:
|
||||
Game: Self for method chaining
|
||||
"""
|
||||
self.sound.play_bgm(musicFile)
|
||||
return self
|
||||
|
||||
def display_text(self, textLines):
|
||||
"""Display text with navigation controls.
|
||||
|
||||
Args:
|
||||
textLines (list): List of text lines
|
||||
|
||||
Returns:
|
||||
Game: Self for method chaining
|
||||
"""
|
||||
# Store original text with blank lines for copying
|
||||
originalText = textLines.copy()
|
||||
|
||||
# Create navigation text by filtering out blank lines
|
||||
navText = [line for line in textLines if line.strip()]
|
||||
|
||||
# Add instructions at the start on the first display
|
||||
if not self.displayTextUsageInstructions:
|
||||
instructions = ("Press space to read the whole text. Use up and down arrows to navigate "
|
||||
"the text line by line. Press c to copy the current line to the clipboard "
|
||||
"or t to copy the entire text. Press enter or escape when you are done reading.")
|
||||
navText.insert(0, instructions)
|
||||
self.displayTextUsageInstructions = True
|
||||
|
||||
# Add end marker
|
||||
navText.append("End of text.")
|
||||
|
||||
currentIndex = 0
|
||||
self.speech.speak(navText[currentIndex])
|
||||
|
||||
while True:
|
||||
event = pygame.event.wait()
|
||||
if event.type == pygame.KEYDOWN:
|
||||
# Check for Alt modifier
|
||||
mods = pygame.key.get_mods()
|
||||
altPressed = mods & pygame.KMOD_ALT
|
||||
|
||||
# Volume controls (require Alt)
|
||||
if altPressed:
|
||||
if event.key == pygame.K_PAGEUP:
|
||||
self.volumeService.adjust_master_volume(0.1, pygame.mixer)
|
||||
elif event.key == pygame.K_PAGEDOWN:
|
||||
self.volumeService.adjust_master_volume(-0.1, pygame.mixer)
|
||||
elif event.key == pygame.K_HOME:
|
||||
self.volumeService.adjust_bgm_volume(0.1, pygame.mixer)
|
||||
elif event.key == pygame.K_END:
|
||||
self.volumeService.adjust_bgm_volume(-0.1, pygame.mixer)
|
||||
elif event.key == pygame.K_INSERT:
|
||||
self.volumeService.adjust_sfx_volume(0.1, pygame.mixer)
|
||||
elif event.key == pygame.K_DELETE:
|
||||
self.volumeService.adjust_sfx_volume(-0.1, pygame.mixer)
|
||||
else:
|
||||
if event.key in (pygame.K_ESCAPE, pygame.K_RETURN):
|
||||
return self
|
||||
|
||||
if event.key in [pygame.K_DOWN, pygame.K_s] and currentIndex < len(navText) - 1:
|
||||
currentIndex += 1
|
||||
self.speech.speak(navText[currentIndex])
|
||||
|
||||
if event.key in [pygame.K_UP, pygame.K_w] and currentIndex > 0:
|
||||
currentIndex -= 1
|
||||
self.speech.speak(navText[currentIndex])
|
||||
|
||||
if event.key == pygame.K_SPACE:
|
||||
# Join with newlines to preserve spacing in speech
|
||||
self.speech.speak('\n'.join(originalText[1:-1]))
|
||||
|
||||
if event.key == pygame.K_c:
|
||||
try:
|
||||
import pyperclip
|
||||
pyperclip.copy(navText[currentIndex])
|
||||
self.speech.speak("Copied " + navText[currentIndex] + " to the clipboard.")
|
||||
except:
|
||||
self.speech.speak("Failed to copy the text to the clipboard.")
|
||||
|
||||
if event.key == pygame.K_t:
|
||||
try:
|
||||
import pyperclip
|
||||
# Join with newlines to preserve blank lines in full text
|
||||
pyperclip.copy(''.join(originalText[2:-1]))
|
||||
self.speech.speak("Copied entire message to the clipboard.")
|
||||
except:
|
||||
self.speech.speak("Failed to copy the text to the clipboard.")
|
||||
|
||||
pygame.event.clear()
|
||||
time.sleep(0.001)
|
||||
|
||||
def exit(self):
|
||||
"""Clean up and exit the game."""
|
||||
if self._speech and self.speech.providerName == "speechd":
|
||||
self.speech.close()
|
||||
pygame.mixer.music.stop()
|
||||
pygame.quit()
|
||||
import sys
|
||||
sys.exit()
|
||||
|
||||
# Utility functions
|
||||
|
||||
def check_for_updates(currentVersion, gameName, url):
|
||||
"""Check for game updates.
|
||||
|
||||
Args:
|
||||
currentVersion (str): Current version string (e.g. "1.0.0")
|
||||
gameName (str): Name of the game
|
||||
url (str): URL to check for updates
|
||||
|
||||
Returns:
|
||||
dict: Update information or None if no update available
|
||||
"""
|
||||
try:
|
||||
response = requests.get(url, timeout=5)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if 'version' in data and data['version'] > currentVersion:
|
||||
return {
|
||||
'version': data['version'],
|
||||
'url': data.get('url', ''),
|
||||
'notes': data.get('notes', '')
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error checking for updates: {e}")
|
||||
return None
|
||||
|
||||
def get_version_tuple(versionStr):
|
||||
"""Convert version string to comparable tuple.
|
||||
|
||||
Args:
|
||||
versionStr (str): Version string (e.g. "1.0.0")
|
||||
|
||||
Returns:
|
||||
tuple: Version as tuple of integers
|
||||
"""
|
||||
return tuple(map(int, versionStr.split('.')))
|
||||
|
||||
def check_compatibility(requiredVersion, currentVersion):
|
||||
"""Check if current version meets minimum required version.
|
||||
|
||||
Args:
|
||||
requiredVersion (str): Minimum required version string
|
||||
currentVersion (str): Current version string
|
||||
|
||||
Returns:
|
||||
bool: True if compatible, False otherwise
|
||||
"""
|
||||
req = get_version_tuple(requiredVersion)
|
||||
cur = get_version_tuple(currentVersion)
|
||||
return cur >= req
|
||||
|
||||
def sanitize_filename(filename):
|
||||
"""Sanitize a filename to be safe for all operating systems.
|
||||
|
||||
Args:
|
||||
filename (str): Original filename
|
||||
|
||||
Returns:
|
||||
str: Sanitized filename
|
||||
"""
|
||||
# Remove invalid characters
|
||||
filename = re.sub(r'[\\/*?:"<>|]', "", filename)
|
||||
# Replace spaces with underscores
|
||||
filename = filename.replace(" ", "_")
|
||||
# Limit length
|
||||
if len(filename) > 255:
|
||||
filename = filename[:255]
|
||||
return filename
|
||||
|
||||
def lerp(start, end, factor):
|
||||
"""Linear interpolation between two values.
|
||||
|
||||
Args:
|
||||
start (float): Start value
|
||||
end (float): End value
|
||||
factor (float): Interpolation factor (0.0-1.0)
|
||||
|
||||
Returns:
|
||||
float: Interpolated value
|
||||
"""
|
||||
return start + (end - start) * factor
|
||||
|
||||
def smooth_step(edge0, edge1, x):
|
||||
"""Hermite interpolation between two values.
|
||||
|
||||
Args:
|
||||
edge0 (float): Start edge
|
||||
edge1 (float): End edge
|
||||
x (float): Value to interpolate
|
||||
|
||||
Returns:
|
||||
float: Interpolated value with smooth step
|
||||
"""
|
||||
# Scale, bias and saturate x to 0..1 range
|
||||
x = max(0.0, min(1.0, (x - edge0) / (edge1 - edge0)))
|
||||
# Evaluate polynomial
|
||||
return x * x * (3 - 2 * x)
|
||||
|
||||
def distance_2d(x1, y1, x2, y2):
|
||||
"""Calculate Euclidean distance between two 2D points.
|
||||
|
||||
Args:
|
||||
x1 (float): X coordinate of first point
|
||||
y1 (float): Y coordinate of first point
|
||||
x2 (float): X coordinate of second point
|
||||
y2 (float): Y coordinate of second point
|
||||
|
||||
Returns:
|
||||
float: Distance between points
|
||||
"""
|
||||
return math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)
|
||||
|
||||
def generate_tone(frequency, duration=0.1, sampleRate=44100, volume=0.2):
|
||||
"""Generate a tone at the specified frequency.
|
||||
|
||||
Args:
|
||||
frequency (float): Frequency in Hz
|
||||
duration (float): Duration in seconds (default: 0.1)
|
||||
sampleRate (int): Sample rate in Hz (default: 44100)
|
||||
volume (float): Volume from 0.0 to 1.0 (default: 0.2)
|
||||
|
||||
Returns:
|
||||
pygame.mixer.Sound: Sound object with the generated tone
|
||||
"""
|
||||
|
||||
t = np.linspace(0, duration, int(sampleRate * duration), False)
|
||||
tone = np.sin(2 * np.pi * frequency * t)
|
||||
stereoTone = np.vstack((tone, tone)).T # Create a 2D array for stereo
|
||||
stereoTone = (stereoTone * 32767 * volume).astype(np.int16) # Apply volume
|
||||
stereoTone = np.ascontiguousarray(stereoTone) # Ensure C-contiguous array
|
||||
return pygame.sndarray.make_sound(stereoTone)
|
||||
|
||||
def x_powerbar():
|
||||
"""Sound based horizontal power bar
|
||||
|
||||
Returns:
|
||||
int: Selected position between -50 and 50
|
||||
"""
|
||||
|
||||
clock = pygame.time.Clock()
|
||||
screen = pygame.display.get_surface()
|
||||
position = -50 # Start from the leftmost position
|
||||
direction = 1 # Move right initially
|
||||
barHeight = 20
|
||||
|
||||
while True:
|
||||
frequency = 440 # A4 note
|
||||
leftVolume = (50 - position) / 100
|
||||
rightVolume = (position + 50) / 100
|
||||
tone = generate_tone(frequency)
|
||||
channel = tone.play()
|
||||
channel.set_volume(leftVolume, rightVolume)
|
||||
|
||||
# Visual representation
|
||||
screen.fill((0, 0, 0))
|
||||
barWidth = screen.get_width() - 40 # Leave 20px margin on each side
|
||||
pygame.draw.rect(screen, (100, 100, 100), (20, screen.get_height() // 2 - barHeight // 2, barWidth, barHeight))
|
||||
markerPos = int(20 + (position + 50) / 100 * barWidth)
|
||||
pygame.draw.rect(screen, (255, 0, 0), (markerPos - 5, screen.get_height() // 2 - barHeight, 10, barHeight * 2))
|
||||
pygame.display.flip()
|
||||
|
||||
for event in pygame.event.get():
|
||||
check_for_exit()
|
||||
if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
|
||||
channel.stop()
|
||||
return position # This will return a value between -50 and 50
|
||||
|
||||
position += direction
|
||||
if position > 50:
|
||||
position = 50
|
||||
direction = -1
|
||||
elif position < -50:
|
||||
position = -50
|
||||
direction = 1
|
||||
|
||||
clock.tick(40) # Speed of bar
|
||||
|
||||
def y_powerbar():
|
||||
"""Sound based vertical power bar
|
||||
|
||||
Returns:
|
||||
int: Selected power level between 0 and 100
|
||||
"""
|
||||
|
||||
clock = pygame.time.Clock()
|
||||
screen = pygame.display.get_surface()
|
||||
power = 0
|
||||
direction = 1 # 1 for increasing, -1 for decreasing
|
||||
barWidth = 20
|
||||
|
||||
while True:
|
||||
frequency = 220 + (power * 5) # Adjust these values to change the pitch range
|
||||
tone = generate_tone(frequency)
|
||||
channel = tone.play()
|
||||
|
||||
# Visual representation
|
||||
screen.fill((0, 0, 0))
|
||||
barHeight = screen.get_height() - 40 # Leave 20px margin on top and bottom
|
||||
pygame.draw.rect(screen, (100, 100, 100), (screen.get_width() // 2 - barWidth // 2, 20, barWidth, barHeight))
|
||||
markerPos = int(20 + (100 - power) / 100 * barHeight)
|
||||
pygame.draw.rect(screen, (255, 0, 0), (screen.get_width() // 2 - barWidth, markerPos - 5, barWidth * 2, 10))
|
||||
pygame.display.flip()
|
||||
|
||||
for event in pygame.event.get():
|
||||
check_for_exit()
|
||||
if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
|
||||
channel.stop()
|
||||
return power
|
||||
|
||||
power += direction
|
||||
if power >= 100 or power <= 0:
|
||||
direction *= -1 # Reverse direction at limits
|
||||
|
||||
clock.tick(40)
|
Reference in New Issue
Block a user