104 Commits

Author SHA1 Message Date
Storm Dragon
acb899e6eb Fixed mismatch in buffer initialization in display.py and sound.py. 2025-09-16 02:17:56 -04:00
Storm Dragon
5e5d33256e New functionality added inspired by Wicked Quest game. 2025-09-16 01:21:20 -04:00
Storm Dragon
a96f9744a9 Attempt to reduce pops and clicks. 2025-09-15 20:54:45 -04:00
Storm Dragon
8cca66d44e Tweaked the mixer initialization just a tiny bit, hopefully will make sound smoother. 2025-09-12 17:03:58 -04:00
Storm Dragon
f2079261d1 More event handling to help with pyinstaller compilation. 2025-09-10 12:57:09 -04:00
Storm Dragon
0190fa3a06 Add the ability to add an optional graphical logo for games. Tested with .png file. 2025-09-10 00:59:21 -04:00
Storm Dragon
09421c4bda Updated donation link. 2025-09-08 11:14:42 -04:00
Storm Dragon
dcd204e476 Added the ability to create submenus with instructions. 2025-09-07 02:38:27 -04:00
Storm Dragon
ca2d0d34bd A few more tweaks to the input code. 2025-07-10 01:22:24 -04:00
Storm Dragon
8ffa53b69f Attempt to fix speech cutoff when reading default text. 2025-07-10 01:13:59 -04:00
Storm Dragon
68ffde0fc7 Dropped wxpython because of stability issues. 2025-07-10 00:53:36 -04:00
Storm Dragon
a17a4c6f15 Code cleanup and sound consolidation. 2025-03-22 17:34:35 -04:00
Storm Dragon
3a478d15d5 Instead of implementing sound changes in one huge go, I'm going to try smaller approaches. Now play_sound has an optional loop parameter set to false by default for backwards compatibility. 2025-03-17 21:12:29 -04:00
Storm Dragon
3b01662d98 Directory names were not speaking. Hopefully fixed it. 2025-03-16 17:45:49 -04:00
Storm Dragon
5e926fa7eb More updates to learn_sounds. I think I have a good system for multiple sound directories now. 2025-03-16 17:34:22 -04:00
Storm Dragon
e272da1177 learn_sounds needed updating too. Hopefully this attempt works. 2025-03-16 17:13:02 -04:00
Storm Dragon
1bb9e18ea2 Sounds can now load from subdirectories. 2025-03-16 17:02:18 -04:00
Storm Dragon
619cb5508a Learn sound now tells you which item in the list you are on. 2025-03-16 01:20:41 -04:00
Storm Dragon
2c34f31a82 Fixed music not pausing when play is selected. 2025-03-15 21:28:06 -04:00
Storm Dragon
a9c2c4332d Fixed a typo in utils.py. 2025-03-15 21:10:20 -04:00
Storm Dragon
fedb09be94 Updated the readme. 2025-03-15 20:33:57 -04:00
Storm Dragon
27765e62bc Finally, a working scoreboard! 2025-03-15 19:52:16 -04:00
Storm Dragon
23aea6badf More fixes for scoreboard. 2025-03-15 19:06:29 -04:00
Storm Dragon
af38d5af76 Fixed another error in scoreboard. 2025-03-15 18:41:35 -04:00
Storm Dragon
4d0436c5a9 Fixed typo in menu.py. 2025-03-15 18:34:30 -04:00
Storm Dragon
f51bd6dee4 Fixed errors in __init__ 2025-03-15 18:31:43 -04:00
Storm Dragon
3b2bcd928d Fixed errors in scoreboard. 2025-03-15 18:29:55 -04:00
Storm Dragon
91f39aad88 Oops, I accidently committed my scrp file for menu instead of the actual file. 2025-03-15 18:22:21 -04:00
Storm Dragon
1dc0ac2a7f Moved the high score stuff to the scoreboard because it makes more sense for it to be there even though it is used in the menu. 2025-03-15 18:18:22 -04:00
Storm Dragon
3f8385599b Work on fixing the path read for scores. 2025-03-15 17:28:32 -04:00
Storm Dragon
468c663cc1 Integrate scoreboard with game_menu() 2025-03-15 17:07:51 -04:00
Storm Dragon
fe772cbb1e Consolidated common menu options for game_menu. Now the simplest call is choice = game_menu(). 2025-03-15 04:22:44 -04:00
Storm Dragon
2c101d1778 Fixed credits file not displaying. 2025-03-15 03:13:52 -04:00
Storm Dragon
8f81323668 Attempt to make sure exit works even if there's a problem. 2025-03-14 23:27:17 -04:00
Storm Dragon
be6dfdf53a Attempt to fix traceback on game exit with some older games. 2025-03-14 23:10:14 -04:00
Storm Dragon
2ad22ff1ae Added back the power bars that somehow got lost in one of the previous updates. 2025-03-14 21:41:00 -04:00
Storm Dragon
aba87e87ad Huge refactor of the libstormgames library. It is hopefully mostly backwards compatible. Still lots of testing to do, and probably some fixes needed, but this is a good start. 2025-03-14 18:14:42 -04:00
Storm Dragon
df7945e3b6 Try to fix the problem with cut scenes sometimes not playing in the correct position and at the correct volume. 2025-03-05 19:39:08 -05:00
Storm Dragon
7902dfacd1 Pressing enter interrupts messagebox messages. 2025-02-26 03:09:43 -05:00
Storm Dragon
10b46fa168 Only show display_text message once per session. 2025-02-26 02:55:52 -05:00
Storm Dragon
e7d5b03e55 Adjust volume a bit for directional play. 2025-02-24 14:51:01 -05:00
Storm Dragon
173220d167 New function for directional audio that needs to be good volume no matter position but indicate direction. 2025-02-24 14:32:45 -05:00
Storm Dragon
2f791da5b7 One more addition to pause. 2025-02-16 17:23:19 -05:00
Storm Dragon
9997b684ca Infinite loops are bad. 2025-02-16 17:19:47 -05:00
Storm Dragon
e7caff3d0f Fixed a bug with pause game. 2025-02-16 17:04:59 -05:00
Storm Dragon
db6c34f714 Improved pause_game. 2025-02-16 17:02:33 -05:00
Storm Dragon
3862b36d56 Pause game function added. 2025-02-16 16:52:29 -05:00
Storm Dragon
8bfe968b4b Added a small delay so the congratulations message for scoreboard can speak. 2025-02-15 12:32:08 -05:00
Storm Dragon
e2c69e3af7 Fixed a problem with an extra line being added at the beginning of copied text. 2025-02-15 02:55:01 -05:00
Storm Dragon
da17b71c28 Updated display_text so that it will hopefully skip blank lines when reading with arrow keys. 2025-02-15 02:39:14 -05:00
Storm Dragon
1cb57391d8 Updated requirements.txt 2025-02-15 01:50:17 -05:00
Storm Dragon
7f62e6ccca Forgot to specify global when checking the speech delay. 2025-02-15 00:06:23 -05:00
Storm Dragon
943e2acf53 Try to keep speech form stuttering with status keys. 2025-02-14 23:58:26 -05:00
Storm Dragon
c242fc6832 Make sure scores are int only. 2025-02-14 21:37:20 -05:00
Storm Dragon
6d2c6e04d8 Fixed a bug with score sorting. 2025-02-14 20:46:38 -05:00
Storm Dragon
97431c0c74 Updated the congrats message for the scoreboard. 2025-02-14 19:41:10 -05:00
Storm Dragon
4a15f951f0 Rewrote the scoreboard class. Added ability to enter name for scoreboard. 2025-02-14 16:50:04 -05:00
Storm Dragon
5a791510ea Rewrote the scoreboard class. 2025-02-14 16:45:28 -05:00
Storm Dragon
7cbbc64d27 Fixed a bug in learn sounds. Forgot to switch the if statement from == to in when converting to the list to also allow use of s and w for menu navigation. 2025-02-08 19:51:52 -05:00
Storm Dragon
b479811a98 Binding for volume keys changed to alt+pageup/down, alt+home/end, etc. 2025-02-08 19:21:14 -05:00
Storm Dragon
d5d737d0c0 Make intro sound skipable. 2025-02-08 16:13:46 -05:00
Storm Dragon
dd246db5be Added w and s as alternate navigation keys for libstormgames menus. 2025-02-07 02:12:34 -05:00
Storm Dragon
21216f361a removed duplicate function. 2025-02-06 12:37:35 -05:00
Storm Dragon
68e72f5d81 Play sound function added. Code restructure. Added volume controls for master volume, sounds only, and music only. 2025-02-04 05:12:31 -05:00
Storm Dragon
80fe2caff3 Oops, accidentally removed the obj_update function. 2025-02-03 23:59:46 -05:00
Storm Dragon
2df86c9c76 Add the ability for obj_play to play a sound once. 2025-02-03 23:49:08 -05:00
Storm Dragon
5fa90f9e84 Updated play_falling_random. 2025-02-03 21:58:02 -05:00
Storm Dragon
658709ebce Random play functions for positional audio and positional falling audio. 2025-02-02 17:04:04 -05:00
Storm Dragon
d5c79c0770 Hopefully last fix to messagebox. This turned out to be harder than I originally thought. 2025-02-01 15:14:29 -05:00
Storm Dragon
24f9a126d4 I think I was over complicating it. 2025-02-01 15:11:22 -05:00
Storm Dragon
c316d4e570 I think I actually got it this time. 2025-02-01 15:05:13 -05:00
Storm Dragon
e66655d75f Another attempt to keep messagebox from double speaking. 2025-02-01 14:59:46 -05:00
Storm Dragon
c5406d5089 Attempt to keep messagebox from double speaking. 2025-02-01 14:53:55 -05:00
Storm Dragon
b5b472eebe Added simple message box for spoken text that might need to be repeated. 2025-02-01 14:41:52 -05:00
Storm Dragon
9f03de15b8 Add a text entry using wx. 2024-08-01 21:10:27 -04:00
Storm Dragon
428a48678d Power bars are now visual as well as audio. 2024-07-22 22:20:15 -04:00
Storm Dragon
9a6d6374f9 Added text along with the speak command. Updated window size. 2024-07-22 16:08:42 -04:00
Storm Dragon
df386cbbd9 Improved check_for_exit function. 2024-07-16 14:22:36 -04:00
Storm Dragon
38522aee78 First pass at creating requirements files. 2024-07-15 19:52:02 -04:00
Storm Dragon
0e9c52f5e1 A few fixes for x-powerbar, more accurately exiting when exit keys are pressed, etc. 2024-07-14 03:51:35 -04:00
Storm Dragon
fabf48ff42 Powerbars added and working. 2024-07-13 03:03:32 -04:00
Storm Dragon
0c73e98876 Improved sound while walking on left/right only. 2024-07-05 17:24:23 -04:00
Storm Dragon
0ef11785ec Revert "A new try at sound panning with walking."
This reverts commit 58ab5aa854.
2024-07-05 17:04:12 -04:00
Storm Dragon
58ab5aa854 A new try at sound panning with walking. 2024-06-20 02:03:06 -04:00
Storm Dragon
155ed6ec39 Updated donation link to use ko-fi. 2022-01-09 01:59:30 -05:00
Storm Dragon
68ad08be46 moved the libstormgames.py file to __init__.py so you can just import libstormgames. 2020-09-15 19:35:59 -04:00
Storm Dragon
536659338e Updated the object placement code code so that if sounds are out of range they still clame the channel, but they do not play at an audible level.[A 2020-09-10 18:02:53 -04:00
Storm Dragon
37aa764d68 Small update to object positioning. 2020-09-10 13:44:51 -04:00
Storm Dragon
84a722bb8e Attempt to fix object positioning. 2020-09-10 02:51:33 -04:00
Storm Dragon
b897abf0a3 Quickly jump to the top or bottom of the game menu with home or end keys. 2020-09-09 21:38:59 -04:00
Storm Dragon
678af54346 More work on the sound functions for object placement. 2020-09-09 20:55:40 -04:00
Storm Dragon
d456b8b3b3 Fixed a couple bugs on the object positioning code. 2020-09-09 20:41:41 -04:00
Storm Dragon
c5c32943e2 Added the rest of the object management functions, hopefully. 2020-09-09 20:06:34 -04:00
Storm Dragon
34d89ca54b Started work on sound positioning for objects. 2020-09-09 19:50:56 -04:00
Storm Dragon
e8bf4f9565 Scoreboard now works! Finally! 2020-09-04 11:17:05 -04:00
Storm Dragon
dd350c0285 Scoreboard still not saving past the first position. 2020-08-30 02:00:07 -04:00
Storm Dragon
ae93e02e69 Another approach to writing the scoreboard. 2020-08-30 01:41:52 -04:00
Storm Dragon
21c0795ea9 Updated scoreboard. 2020-08-30 01:23:32 -04:00
Storm Dragon
67d2315cef Finally fixed the scoreboard I think. 2020-08-29 22:11:06 -04:00
Storm Dragon
b6afb5450e New_High_Score method added. 2019-12-11 23:31:19 -05:00
Storm Dragon
42266d4b6c More progress on the scoreboard. Not complete yet. 2019-12-11 23:09:31 -05:00
Storm Dragon
54842bac29 Scoreboard mostly working. It doesn't read the existing scores correctly yet though. 2019-12-11 21:45:52 -05:00
Storm Dragon
08f06699c8 Merge branch 'devel'
Merged in set process name
2019-12-11 09:38:30 -05:00
Storm Dragon
7ef11be54c New scoreboard feature added. Partially implimented. 2019-12-11 09:32:50 -05:00
17 changed files with 5558 additions and 278 deletions

1116
README.md

File diff suppressed because it is too large Load Diff

236
__init__.py Normal file → Executable file
View File

@@ -0,0 +1,236 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Standard initializations and functions shared by all Storm Games.
This module provides core functionality for Storm Games including:
- Sound and speech handling
- Volume controls
- Configuration management
- Score tracking
- GUI initialization
- Game menu systems
"""
# Import service classes
from .services import (
ConfigService,
VolumeService,
PathService
)
# Import Sound class and functions
from .sound import (
Sound,
play_bgm,
play_sound,
adjust_bgm_volume,
adjust_sfx_volume,
adjust_master_volume,
play_ambiance,
play_random,
play_random_positional,
play_directional_sound,
obj_play,
obj_update,
obj_stop,
cut_scene,
play_random_falling,
calculate_volume_and_pan
)
# Import Speech class and functions
from .speech import messagebox, speak, Speech
# Import Scoreboard
from .scoreboard import Scoreboard
# Import input functions
from .input import get_input, check_for_exit, pause_game
# Import display functions
from .display import display_text, initialize_gui
# Import menu functions
from .menu import game_menu, instruction_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
)
# Import new game systems
from .stat_tracker import StatTracker
from .save_manager import SaveManager
from .combat import Weapon, Projectile
__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', 'instruction_menu', 'learn_sounds', 'instructions', 'credits', 'donate', 'exit_game', 'high_scores', 'has_high_scores',
# Game class
'Game',
# Game Systems
'StatTracker', 'SaveManager', 'Weapon', 'Projectile',
# 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
import random
def randint(a, b):
"""Return a random integer N such that a <= N <= b."""
return random.randint(a, b)
def choice(seq):
"""Return a random element from the non-empty sequence seq."""
return random.choice(seq)
def uniform(a, b):
"""Return a random floating point number N such that a <= N <= b."""
return random.uniform(a, b)
def seed(a=None):
"""Initialize the random number generator."""
return random.seed(a)

637
combat.py Normal file
View File

@@ -0,0 +1,637 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Combat system for Storm Games.
Provides weapon and projectile systems with cooldowns, hit detection,
stat bonuses, and flexible configuration options.
"""
import time
import math
from typing import Dict, Any, Optional, Tuple, Union, Callable, List
import copy
class Weapon:
"""Generic weapon system with cooldown, range, and stat bonuses.
Features:
- Cooldown-based attack system
- Hit detection with attack duration
- Configurable stat bonuses (speed, jump, etc.)
- Serialization support for save/load
- Factory methods for common weapon types
- Sound integration (optional)
Example usage:
# Create a sword
sword = Weapon("Iron Sword", damage=10, range_value=2, cooldown=500)
# Add stat bonuses
sword.stat_bonuses = {"speed": 1.1, "critical_chance": 0.05}
# Use in combat
if sword.can_attack():
if sword.can_hit_target(enemy_pos, player_pos, facing_right):
damage_dealt = sword.attack()
"""
def __init__(
self,
name: str,
damage: Union[int, float],
range_value: Union[int, float],
attack_sound: Optional[str] = None,
hit_sound: Optional[str] = None,
cooldown: int = 500,
attack_duration: int = 200,
stat_bonuses: Optional[Dict[str, Union[int, float]]] = None
):
"""Initialize weapon.
Args:
name: Display name of the weapon
damage: Base damage value
range_value: Attack range
attack_sound: Sound key for attack (optional)
hit_sound: Sound key for successful hit (optional)
cooldown: Milliseconds between attacks
attack_duration: Milliseconds the attack window stays open
stat_bonuses: Dictionary of stat multipliers/bonuses
"""
self.name = name
self.damage = damage
self.range_value = range_value
self.attack_sound = attack_sound
self.hit_sound = hit_sound
self.cooldown = cooldown
self.attack_duration = attack_duration
self.stat_bonuses = stat_bonuses or {}
# Attack state tracking
self.last_attack_time = 0
self.attack_start_time = 0
self.is_attacking = False
self.hit_targets = set() # Track what we've hit during current attack
def can_attack(self) -> bool:
"""Check if weapon can currently attack (not on cooldown).
Returns:
True if weapon can attack, False if on cooldown
"""
current_time = time.time() * 1000 # Convert to milliseconds
return current_time - self.last_attack_time >= self.cooldown
def attack(self) -> Union[int, float]:
"""Initiate an attack.
Returns:
Damage value if attack is successful, 0 if on cooldown
"""
if not self.can_attack():
return 0
current_time = time.time() * 1000
self.last_attack_time = current_time
self.attack_start_time = current_time
self.is_attacking = True
self.hit_targets.clear()
return self.damage
def is_attack_active(self) -> bool:
"""Check if the attack window is currently active.
Returns:
True if within attack duration, False otherwise
"""
if not self.is_attacking:
return False
current_time = time.time() * 1000
elapsed = current_time - self.attack_start_time
if elapsed > self.attack_duration:
self.is_attacking = False
return False
return True
def can_hit_target(
self,
target_pos: Union[Tuple[float, float], float],
attacker_pos: Union[Tuple[float, float], float],
facing_right: bool = True,
target_id: Any = None
) -> bool:
"""Check if target is within range and can be hit.
Args:
target_pos: Position of target (x, y) or just x for 1D
attacker_pos: Position of attacker (x, y) or just x for 1D
facing_right: Direction attacker is facing (for 1D games)
target_id: Optional identifier to prevent multiple hits
Returns:
True if target can be hit, False otherwise
"""
# Check if attack is active
if not self.is_attack_active():
return False
# Check if we've already hit this target
if target_id is not None and target_id in self.hit_targets:
return False
# Calculate distance
if isinstance(target_pos, (tuple, list)) and isinstance(attacker_pos, (tuple, list)):
# 2D distance calculation
distance = math.sqrt(
(target_pos[0] - attacker_pos[0])**2 +
(target_pos[1] - attacker_pos[1])**2
)
else:
# 1D distance calculation
target_x = target_pos if isinstance(target_pos, (int, float)) else target_pos[0]
attacker_x = attacker_pos if isinstance(attacker_pos, (int, float)) else attacker_pos[0]
distance = abs(target_x - attacker_x)
# For 1D, also check direction
if facing_right and target_x <= attacker_x:
return False
elif not facing_right and target_x >= attacker_x:
return False
return distance <= self.range_value
def hit_target(self, target_id: Any = None) -> Union[int, float]:
"""Mark a target as hit and return damage.
Args:
target_id: Optional identifier for the target
Returns:
Damage value if hit is valid, 0 if target already hit
"""
if target_id is not None:
if target_id in self.hit_targets:
return 0
self.hit_targets.add(target_id)
return self.damage
def apply_stat_bonuses(self, base_stats: Dict[str, Union[int, float]]) -> Dict[str, Union[int, float]]:
"""Apply weapon's stat bonuses to base stats.
Args:
base_stats: Dictionary of base stat values
Returns:
Dictionary with stat bonuses applied
"""
modified_stats = base_stats.copy()
for stat_name, bonus in self.stat_bonuses.items():
if stat_name in modified_stats:
if isinstance(bonus, (int, float)) and bonus > 0:
if bonus > 1:
# Multiplicative bonus (e.g., 1.2 = 20% increase)
modified_stats[stat_name] *= bonus
else:
# Additive bonus (e.g., 0.05 = +5%)
modified_stats[stat_name] += bonus
return modified_stats
def to_dict(self) -> Dict[str, Any]:
"""Serialize weapon to dictionary for saving.
Returns:
Dictionary representation of weapon
"""
return {
"name": self.name,
"damage": self.damage,
"range_value": self.range_value,
"attack_sound": self.attack_sound,
"hit_sound": self.hit_sound,
"cooldown": self.cooldown,
"attack_duration": self.attack_duration,
"stat_bonuses": self.stat_bonuses.copy()
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Weapon':
"""Create weapon from dictionary data.
Args:
data: Dictionary containing weapon data
Returns:
New Weapon instance
"""
return cls(
name=data.get("name", "Unknown Weapon"),
damage=data.get("damage", 1),
range_value=data.get("range_value", 1),
attack_sound=data.get("attack_sound"),
hit_sound=data.get("hit_sound"),
cooldown=data.get("cooldown", 500),
attack_duration=data.get("attack_duration", 200),
stat_bonuses=data.get("stat_bonuses", {})
)
@classmethod
def create_from_config(cls, config_dict: Dict[str, Any]) -> 'Weapon':
"""Create weapon from configuration dictionary.
Args:
config_dict: Configuration dictionary
Returns:
New Weapon instance
"""
return cls.from_dict(config_dict)
# Factory methods for common weapon types
@classmethod
def create_sword(cls, name: str = "Sword", damage: Union[int, float] = 10) -> 'Weapon':
"""Create a standard sword weapon.
Args:
name: Name of the sword
damage: Damage value
Returns:
Sword weapon instance
"""
return cls(
name=name,
damage=damage,
range_value=2,
attack_sound="sword_swing",
hit_sound="sword_hit",
cooldown=800,
attack_duration=300
)
@classmethod
def create_dagger(cls, name: str = "Dagger", damage: Union[int, float] = 6) -> 'Weapon':
"""Create a fast dagger weapon.
Args:
name: Name of the dagger
damage: Damage value
Returns:
Dagger weapon instance
"""
return cls(
name=name,
damage=damage,
range_value=1,
attack_sound="dagger_stab",
hit_sound="dagger_hit",
cooldown=400,
attack_duration=150,
stat_bonuses={"speed": 1.1}
)
@classmethod
def create_staff(cls, name: str = "Staff", damage: Union[int, float] = 8) -> 'Weapon':
"""Create a magical staff weapon.
Args:
name: Name of the staff
damage: Damage value
Returns:
Staff weapon instance
"""
return cls(
name=name,
damage=damage,
range_value=3,
attack_sound="staff_cast",
hit_sound="magic_hit",
cooldown=1200,
attack_duration=400
)
def __str__(self) -> str:
"""String representation of weapon."""
return f"{self.name} (Damage: {self.damage}, Range: {self.range_value})"
def __repr__(self) -> str:
"""Detailed string representation."""
return f"Weapon(name='{self.name}', damage={self.damage}, range={self.range_value})"
class Projectile:
"""Generic projectile system for ranged combat.
Features:
- Position-based movement with direction vectors
- Range limiting and collision detection
- Configurable damage, speed, and behavior
- 1D and 2D movement support
- Hit callbacks for custom effects
Example usage:
# Create an arrow
arrow = Projectile("arrow", (10, 5), (1, 0), speed=0.3, damage=8)
# Update in game loop
while arrow.is_active():
arrow.update()
# Check collision with target
if arrow.check_collision(target_pos, target_size):
damage = arrow.hit()
break
"""
def __init__(
self,
projectile_type: str,
start_pos: Union[Tuple[float, float], float],
direction: Union[Tuple[float, float], float],
speed: float = 0.2,
damage: Union[int, float] = 5,
max_range: Union[int, float] = 12,
on_hit_callback: Optional[Callable] = None
):
"""Initialize projectile.
Args:
projectile_type: Type identifier for the projectile
start_pos: Starting position (x, y) or just x for 1D
direction: Direction vector (dx, dy) or just direction for 1D
speed: Movement speed per update
damage: Damage value
max_range: Maximum travel distance
on_hit_callback: Optional function called when projectile hits
"""
self.projectile_type = projectile_type
self.damage = damage
self.max_range = max_range
self.speed = speed
self.on_hit_callback = on_hit_callback
# Position tracking
if isinstance(start_pos, (tuple, list)):
self.position = list(start_pos)
self.start_position = list(start_pos)
self.is_2d = True
else:
self.position = float(start_pos)
self.start_position = float(start_pos)
self.is_2d = False
# Direction handling
if self.is_2d and isinstance(direction, (tuple, list)):
self.direction = list(direction)
# Normalize direction vector
magnitude = math.sqrt(direction[0]**2 + direction[1]**2)
if magnitude > 0:
self.direction = [direction[0]/magnitude, direction[1]/magnitude]
elif not self.is_2d:
self.direction = float(direction)
else:
raise ValueError("Direction format must match position format (1D or 2D)")
# State tracking
self.active = True
self.has_hit = False
self.distance_traveled = 0.0
def update(self) -> bool:
"""Update projectile position.
Returns:
True if projectile is still active, False if it should be removed
"""
if not self.active:
return False
# Move projectile
if self.is_2d:
old_pos = self.position.copy()
self.position[0] += self.direction[0] * self.speed
self.position[1] += self.direction[1] * self.speed
# Calculate distance traveled
dx = self.position[0] - old_pos[0]
dy = self.position[1] - old_pos[1]
self.distance_traveled += math.sqrt(dx**2 + dy**2)
else:
old_pos = self.position
self.position += self.direction * self.speed
self.distance_traveled += abs(self.position - old_pos)
# Check if projectile has exceeded range
if self.distance_traveled >= self.max_range:
self.active = False
return self.active
def check_collision(
self,
target_pos: Union[Tuple[float, float], float],
target_size: Union[Tuple[float, float], float] = 1.0
) -> bool:
"""Check if projectile collides with a target.
Args:
target_pos: Target position (x, y) or just x for 1D
target_size: Target size (width, height) or just width for 1D
Returns:
True if collision detected, False otherwise
"""
if not self.active or self.has_hit:
return False
if self.is_2d and isinstance(target_pos, (tuple, list)):
# 2D collision detection (simple rectangle/circle)
target_width = target_size[0] if isinstance(target_size, (tuple, list)) else target_size
target_height = target_size[1] if isinstance(target_size, (tuple, list)) else target_size
# Simple bounding box collision
distance_x = abs(self.position[0] - target_pos[0])
distance_y = abs(self.position[1] - target_pos[1])
return distance_x <= target_width / 2 and distance_y <= target_height / 2
else:
# 1D collision detection
target_x = target_pos if isinstance(target_pos, (int, float)) else target_pos[0]
proj_x = self.position if isinstance(self.position, (int, float)) else self.position[0]
target_width = target_size if isinstance(target_size, (int, float)) else target_size[0]
return abs(proj_x - target_x) <= target_width / 2
def hit(self) -> Union[int, float]:
"""Mark projectile as having hit a target.
Returns:
Damage value of the projectile
"""
if self.has_hit:
return 0
self.has_hit = True
self.active = False
# Call hit callback if provided
if self.on_hit_callback:
try:
self.on_hit_callback(self)
except Exception as e:
print(f"Warning: Hit callback failed: {e}")
return self.damage
def is_active(self) -> bool:
"""Check if projectile is still active.
Returns:
True if projectile is active, False otherwise
"""
return self.active and not self.has_hit
def get_position(self) -> Union[Tuple[float, float], float]:
"""Get current position of projectile.
Returns:
Current position (x, y) or just x for 1D
"""
if self.is_2d:
return tuple(self.position)
else:
return self.position
def get_distance_traveled(self) -> float:
"""Get total distance traveled by projectile.
Returns:
Distance traveled from start position
"""
return self.distance_traveled
def to_dict(self) -> Dict[str, Any]:
"""Serialize projectile to dictionary.
Returns:
Dictionary representation of projectile
"""
return {
"projectile_type": self.projectile_type,
"position": self.position,
"start_position": self.start_position,
"direction": self.direction,
"speed": self.speed,
"damage": self.damage,
"max_range": self.max_range,
"distance_traveled": self.distance_traveled,
"active": self.active,
"has_hit": self.has_hit,
"is_2d": self.is_2d
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Projectile':
"""Create projectile from dictionary data.
Args:
data: Dictionary containing projectile data
Returns:
New Projectile instance
"""
projectile = cls(
projectile_type=data.get("projectile_type", "projectile"),
start_pos=data.get("start_position", (0, 0)),
direction=data.get("direction", (1, 0)),
speed=data.get("speed", 0.2),
damage=data.get("damage", 5),
max_range=data.get("max_range", 12)
)
# Restore state
projectile.position = data.get("position", projectile.position)
projectile.distance_traveled = data.get("distance_traveled", 0.0)
projectile.active = data.get("active", True)
projectile.has_hit = data.get("has_hit", False)
return projectile
# Factory methods for common projectile types
@classmethod
def create_arrow(cls, start_pos: Union[Tuple[float, float], float], direction: Union[Tuple[float, float], float]) -> 'Projectile':
"""Create a standard arrow projectile.
Args:
start_pos: Starting position
direction: Direction vector
Returns:
Arrow projectile instance
"""
return cls(
projectile_type="arrow",
start_pos=start_pos,
direction=direction,
speed=0.4,
damage=7,
max_range=15
)
@classmethod
def create_fireball(cls, start_pos: Union[Tuple[float, float], float], direction: Union[Tuple[float, float], float]) -> 'Projectile':
"""Create a fireball projectile with area damage.
Args:
start_pos: Starting position
direction: Direction vector
Returns:
Fireball projectile instance
"""
return cls(
projectile_type="fireball",
start_pos=start_pos,
direction=direction,
speed=0.3,
damage=12,
max_range=20
)
@classmethod
def create_bullet(cls, start_pos: Union[Tuple[float, float], float], direction: Union[Tuple[float, float], float]) -> 'Projectile':
"""Create a fast bullet projectile.
Args:
start_pos: Starting position
direction: Direction vector
Returns:
Bullet projectile instance
"""
return cls(
projectile_type="bullet",
start_pos=start_pos,
direction=direction,
speed=0.8,
damage=5,
max_range=25
)
def __str__(self) -> str:
"""String representation of projectile."""
return f"{self.projectile_type} at {self.position} (Damage: {self.damage})"
def __repr__(self) -> str:
"""Detailed string representation."""
return f"Projectile(type='{self.projectile_type}', pos={self.position}, damage={self.damage})"

102
config.py Normal file
View File

@@ -0,0 +1,102 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Configuration management for Storm Games.
Provides functionality for:
- Reading and writing configuration files
- Global and local configuration handling
"""
import configparser
import os
from xdg import BaseDirectory
class Config:
"""Configuration management class for Storm Games."""
def __init__(self, gameTitle):
"""Initialize configuration system for a game.
Args:
gameTitle (str): Title of the game
"""
self.gameTitle = gameTitle
self.globalPath = os.path.join(BaseDirectory.xdg_config_home, "storm-games")
self.gamePath = os.path.join(self.globalPath,
str.lower(str.replace(gameTitle, " ", "-")))
# Create game directory if it doesn't exist
if not os.path.exists(self.gamePath):
os.makedirs(self.gamePath)
# Initialize config parsers
self.localConfig = configparser.ConfigParser()
self.globalConfig = configparser.ConfigParser()
# Load existing configurations
self.read_local_config()
self.read_global_config()
def read_local_config(self):
"""Read local configuration from file."""
try:
with open(os.path.join(self.gamePath, "config.ini"), 'r') as configFile:
self.localConfig.read_file(configFile)
except:
pass
def read_global_config(self):
"""Read global configuration from file."""
try:
with open(os.path.join(self.globalPath, "config.ini"), 'r') as configFile:
self.globalConfig.read_file(configFile)
except:
pass
def write_local_config(self):
"""Write local configuration to file."""
with open(os.path.join(self.gamePath, "config.ini"), 'w') as configFile:
self.localConfig.write(configFile)
def write_global_config(self):
"""Write global configuration to file."""
with open(os.path.join(self.globalPath, "config.ini"), 'w') as configFile:
self.globalConfig.write(configFile)
# Global variables for backward compatibility
localConfig = configparser.ConfigParser()
globalConfig = configparser.ConfigParser()
gamePath = ""
globalPath = ""
def write_config(writeGlobal=False):
"""Write configuration to file.
Args:
writeGlobal (bool): If True, write to global config, otherwise local (default: False)
"""
if not writeGlobal:
with open(gamePath + "/config.ini", 'w') as configFile:
localConfig.write(configFile)
else:
with open(globalPath + "/config.ini", 'w') as configFile:
globalConfig.write(configFile)
def read_config(readGlobal=False):
"""Read configuration from file.
Args:
readGlobal (bool): If True, read global config, otherwise local (default: False)
"""
if not readGlobal:
try:
with open(gamePath + "/config.ini", 'r') as configFile:
localConfig.read_file(configFile)
except:
pass
else:
try:
with open(globalPath + "/config.ini", 'r') as configFile:
globalConfig.read_file(configFile)
except:
pass

238
display.py Normal file
View File

@@ -0,0 +1,238 @@
#!/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, 512)
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, optionally with visual logo
from .sound import cut_scene
if 'game-intro' in soundData:
_show_logo_with_audio(soundData, 'game-intro')
return soundData
def _show_logo_with_audio(soundData, audioKey):
"""Show visual logo while playing audio intro.
Args:
soundData (dict): Dictionary of loaded sounds
audioKey (str): Key of the audio to play
"""
# Look for logo image files in common formats
logoFiles = ['logo.png', 'logo.jpg', 'logo.jpeg', 'logo.gif', 'logo.bmp']
logoImage = None
for logoFile in logoFiles:
if os.path.exists(logoFile):
try:
logoImage = pygame.image.load(logoFile)
break
except pygame.error:
continue
if logoImage:
# Display logo while audio plays
screen = pygame.display.get_surface()
screenRect = screen.get_rect()
logoRect = logoImage.get_rect(center=screenRect.center)
# Clear screen to black
screen.fill((0, 0, 0))
screen.blit(logoImage, logoRect)
pygame.display.flip()
# Play audio and wait for it to finish
from .sound import cut_scene
cut_scene(soundData, audioKey)
# Clear screen after audio finishes
screen.fill((0, 0, 0))
pygame.display.flip()
else:
# No logo image found, just play audio
from .sound import cut_scene
cut_scene(soundData, audioKey)
def display_text(text):
"""Display and speak text with navigation controls.
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])
# Clear any pending events
pygame.event.clear()
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.")
pygame.event.pump()
pygame.event.clear()
time.sleep(0.001)

253
input.py Normal file
View File

@@ -0,0 +1,253 @@
#!/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
from .speech import speak
def get_input(prompt="Enter text:", text=""):
"""Display an accessible text input dialog using pygame.
Features:
- Speaks each character as typed
- Left/Right arrows navigate and speak characters
- Up/Down arrows read full text content
- Backspace announces deletions
- Enter submits, Escape cancels
- Control key repeats the original prompt message
- Fully accessible without screen reader dependency
Args:
prompt (str): Prompt text to display (default: "Enter text:")
text (str): Initial text in input box (default: "")
Returns:
str: User input text, or None if cancelled
"""
# Initialize text buffer and cursor
text_buffer = list(text) # Use list for easier character manipulation
cursor_pos = len(text_buffer) # Start at end of initial text
# Announce the prompt and initial text as a single message
if text:
initial_message = f"{prompt} Default text: {text}"
else:
initial_message = f"{prompt} Empty text field"
speak(initial_message)
# Clear any pending events
pygame.event.clear()
# Main input loop
while True:
event = pygame.event.wait()
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_RETURN:
# Submit the input
result = ''.join(text_buffer)
speak(f"Submitted: {result if result else 'empty'}")
return result
elif event.key == pygame.K_ESCAPE:
# Cancel input
speak("Cancelled")
return None
elif event.key == pygame.K_BACKSPACE:
# Delete character before cursor
if cursor_pos > 0:
deleted_char = text_buffer.pop(cursor_pos - 1)
cursor_pos -= 1
speak(f"{deleted_char} deleted")
else:
speak("Nothing to delete")
elif event.key == pygame.K_DELETE:
# Delete character at cursor
if cursor_pos < len(text_buffer):
deleted_char = text_buffer.pop(cursor_pos)
speak(f"{deleted_char} deleted")
else:
speak("Nothing to delete")
elif event.key == pygame.K_LEFT:
# Move cursor left and speak character
if cursor_pos > 0:
cursor_pos -= 1
if cursor_pos == 0:
speak("Beginning of text")
else:
speak(text_buffer[cursor_pos])
else:
speak("Beginning of text")
elif event.key == pygame.K_RIGHT:
# Move cursor right and speak character
if cursor_pos < len(text_buffer):
speak(text_buffer[cursor_pos])
cursor_pos += 1
if cursor_pos == len(text_buffer):
speak("End of text")
else:
speak("End of text")
elif event.key == pygame.K_UP or event.key == pygame.K_DOWN:
# Read entire text content
if text_buffer:
speak(''.join(text_buffer))
else:
speak("Empty text field")
elif event.key == pygame.K_HOME:
# Move to beginning
cursor_pos = 0
speak("Beginning of text")
elif event.key == pygame.K_END:
# Move to end
cursor_pos = len(text_buffer)
speak("End of text")
elif event.key == pygame.K_LCTRL or event.key == pygame.K_RCTRL:
# Repeat the original prompt message
speak(initial_message)
else:
# Handle regular character input
if event.unicode and event.unicode.isprintable():
char = event.unicode
# Insert character at cursor position
text_buffer.insert(cursor_pos, char)
cursor_pos += 1
# Speak the character name
if char == ' ':
speak("space")
elif char == '\\':
speak("backslash")
elif char == '/':
speak("slash")
elif char == '!':
speak("exclamation mark")
elif char == '"':
speak("quotation mark")
elif char == '#':
speak("hash")
elif char == '$':
speak("dollar sign")
elif char == '%':
speak("percent")
elif char == '&':
speak("ampersand")
elif char == "'":
speak("apostrophe")
elif char == '(':
speak("left parenthesis")
elif char == ')':
speak("right parenthesis")
elif char == '*':
speak("asterisk")
elif char == '+':
speak("plus")
elif char == ',':
speak("comma")
elif char == '-':
speak("minus")
elif char == '.':
speak("period")
elif char == ':':
speak("colon")
elif char == ';':
speak("semicolon")
elif char == '<':
speak("less than")
elif char == '=':
speak("equals")
elif char == '>':
speak("greater than")
elif char == '?':
speak("question mark")
elif char == '@':
speak("at sign")
elif char == '[':
speak("left bracket")
elif char == ']':
speak("right bracket")
elif char == '^':
speak("caret")
elif char == '_':
speak("underscore")
elif char == '`':
speak("grave accent")
elif char == '{':
speak("left brace")
elif char == '|':
speak("pipe")
elif char == '}':
speak("right brace")
elif char == '~':
speak("tilde")
else:
# For regular letters, numbers, and other characters
speak(char)
# Allow other events to be processed
pygame.event.pump()
pygame.event.clear()
time.sleep(0.001)
def pause_game():
"""Pauses the game until user presses backspace."""
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
pygame.event.pump()
pygame.event.clear()
time.sleep(0.001)
try:
pygame.mixer.unpause()
except:
pass
try:
pygame.mixer.music.unpause()
except:
pass
pygame.event.pump()
def check_for_exit():
"""Check if user has pressed escape key.
Returns:
bool: True if escape was pressed, False otherwise
"""
for event in pygame.event.get():
if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
return True
return False
pygame.event.pump()

View File

@@ -1,273 +0,0 @@
#!/bin/python
# -*- coding: utf-8 -*-
"""Standard initializations and functions shared by all games."""
from sys import exit
import configparser
import os
from os import listdir
from os.path import isfile, join
from inspect import isfunction
from xdg import BaseDirectory
from setproctitle import setproctitle
import pygame
import pyperclip
import random
import re
import requests
import webbrowser
# Global variable for speech provider
try:
import speechd
spd = speechd.Client()
speechProvider = "speechd"
except ImportError:
import accessible_output2.outputs.auto
s = accessible_output2.outputs.auto.Auto()
speechProvider = "accessible_output2"
except ImportError:
print("No other speech providers found.")
exit()
import time
localConfig = configparser.ConfigParser()
globalConfig = configparser.ConfigParser()
class scoreboard():
'Handles scores and top 10'
def __init__(self):
self.oldScores = []
for i in range(9):
try:
self.oldScores[i] = read_config("scoreboard", i)
except:
self.oldScores[i] = 0
def write_config(writeGlobal = False):
if writeGlobal == False:
with open(gamePath + "config.ini", 'w') as configfile:
localConfig.write(configfile)
else:
with open(globalPath + "config.ini", 'w') as configfile:
globalConfig.write(configfile)
def read_config(section, value, readGlobal = False):
if readGlobal == False:
with open(gamePath + "config.ini", 'r') as configfile:
return localConfig.read(section, value)
else:
with open(globalPath + "config.ini", 'r') as configfile:
return globalConfig.read(section, value)
def speak(text, interupt = True):
if speechProvider == "speechd":
if interupt == True: spd.cancel()
spd.say(text)
else:
if speechProvider == "accessible_output2":
s.speak(text, interrupt=True)
def exit_game():
if speechProvider == "speechd": spd.close()
pygame.mixer.music.stop()
pygame.quit()
exit()
def initialize_gui(gameTitle):
# Check for, and possibly create, storm-games path
global globalPath
global gamePath
globalPath = BaseDirectory.xdg_config_home + "/storm-games"
gamePath = globalPath + "/" + str.lower(str.replace(gameTitle, " ", "-"))
if not os.path.exists(gamePath): os.makedirs(gamePath)
# Seed the random generator to the clock
random.seed()
# Set game's name
global gameName
gameName = gameTitle
setproctitle(str.lower(str.replace(gameTitle, " ", "")))
# start pygame
pygame.init()
# start the display (required by the event loop)
pygame.display.set_mode((320, 200))
pygame.display.set_caption(gameTitle)
# Set 32 channels for sound by default
pygame.mixer.init()
pygame.mixer.set_num_channels(32)
# Reserve the cut scene channel
pygame.mixer.set_reserved(0)
# Load sounds from the sound directory and creates a list like that {'bottle': 'bottle.ogg'}
soundFiles = [f for f in listdir("sounds/") if isfile(join("sounds/", f)) and (f.split('.')[1].lower() in ["ogg","wav"])]
#lets make a dict with pygame.mixer.Sound() objects {'bottle':<soundobject>}
soundData = {}
for f in soundFiles:
soundData[f.split('.')[0]] = pygame.mixer.Sound("sounds/" + f)
soundData['game-intro'].play()
time.sleep(soundData['game-intro'].get_length())
return soundData
def cut_scene(sounds, soundName):
pygame.event.clear()
pygame.mixer.stop()
c = pygame.mixer.Channel(0)
c.play(sounds[soundName])
while pygame.mixer.get_busy():
event = pygame.event.poll()
if event.type == pygame.KEYDOWN and event.key in [pygame.K_ESCAPE, pygame.K_RETURN, pygame.K_SPACE]:
pygame.mixer.stop()
pygame.event.pump()
def play_random(sounds, soundName, pause = False, interrupt = False):
key = []
for i in sounds.keys():
if re.match("^" + soundName + ".*", i):
key.append(i)
randomKey = random.choice(key)
if interrupt == False:
sounds[randomKey].play()
else:
cut_scene(sounds, randomKey)
# Cut scenes override the pause option
return
if pause == True:
time.sleep(sounds[randomKey].get_length())
def instructions():
# Read in the instructions file
try:
with open('files/instructions.txt', 'r') as f:
info = f.readlines()
except:
info = ["Instructions file is missing."]
display_text(info)
def credits():
# Read in the credits file.
try:
with open('files/credits.txt', 'r') as f:
info = f.readlines()
# Add the header
info.insert(0, gameName + ": brought to you by Storm Dragon")
except:
info = ["Credits file is missing."]
display_text(info)
def display_text(text):
i = 0
text.insert(0, "Press space to read the whole text. Use up and down arrows to navigate the text line by line. Press c to copy the current line to the clipboard or t to copy the entire text. Press enter or escape when you are done reading.")
text.append("End of text.")
speak(text[i])
while True:
event = pygame.event.wait()
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE or event.key == pygame.K_RETURN: return
if event.key == pygame.K_DOWN and i < len(text) - 1: i = i + 1
if event.key == pygame.K_UP and i > 0: i = i - 1
if event.key == pygame.K_SPACE:
speak(' '.join(text[1:]))
else:
speak(text[i])
if event.key == pygame.K_c:
try:
pyperclip.copy(text[i])
speak("Copied " + text[i] + " to the clipboard.")
except:
speak("Failed to copy the text to the clipboard.")
if event.key == pygame.K_t:
try:
pyperclip.copy(''.join(text[1:-1]))
speak("Copied entire message to the clipboard.")
except:
speak("Failed to copy the text to the clipboard.")
event = pygame.event.clear()
time.sleep(0.001)
def learn_sounds(sounds):
loop = True
pygame.mixer.music.pause()
i = 0
soundFiles = [f for f in listdir("sounds/") if isfile(join("sounds/", f)) and (f.split('.')[1].lower() in ["ogg","wav"]) and (f.split('.')[0].lower() not in ["game-intro", "music_menu"])]
# j keeps track of last spoken index so it isn't voiced on key up.
j = -1
while loop == True:
if i != j:
speak(soundFiles[i][:-4])
j = i
event = pygame.event.wait()
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE: return "menu"
if event.key == pygame.K_DOWN and i < len(soundFiles) - 1:
pygame.mixer.stop()
i = i + 1
if event.key == pygame.K_UP and i > 0:
pygame.mixer.stop()
i = i - 1
if event.key == pygame.K_RETURN:
try:
soundName = soundFiles[i][:-4]
pygame.mixer.stop()
sounds[soundName].play()
continue
except:
j = -1
speak("Could not play sound.")
continue
event = pygame.event.clear()
time.sleep(0.001)
def game_menu(sounds, *options):
loop = True
if pygame.mixer.music.get_busy():
pygame.mixer.music.unpause()
else:
pygame.mixer.music.load("sounds/music_menu.ogg")
pygame.mixer.music.set_volume(0.75)
pygame.mixer.music.play(-1)
i = 0
# j keeps track of last spoken index so it isn't voiced on key up.
j = -1
while loop == True:
if i != j:
speak(options[i])
j = i
event = pygame.event.wait()
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE: exit_game()
if event.key == pygame.K_DOWN and i < len(options) - 1:
i = i + 1
try:
sounds['menu-move'].play()
except:
pass
if options[i] != "donate": pygame.mixer.music.unpause()
if event.key == pygame.K_UP and i > 0:
i = i - 1
try:
sounds['menu-move'].play()
except:
pass
if options[i] != "donate": pygame.mixer.music.unpause()
if event.key == pygame.K_RETURN:
try:
j = -1
try:
sounds['menu-select'].play()
time.sleep(sounds['menu-select'].get_length())
except:
pass
eval(options[i] + "()")
continue
except:
j = -1
return options[i]
continue
event = pygame.event.clear()
time.sleep(0.001)
def donate():
pygame.mixer.music.pause()
webbrowser.open('https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=stormdragon2976@gmail.com&lc=US&item_name=Donation+to+Storm+Games&no_note=0&cn=&currency_code=USD&bn=PP-DonationsBF:btn_donateCC_LG.gif:NonHosted')

593
menu.py Normal file
View File

@@ -0,0 +1,593 @@
#!/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 instruction_menu(sounds, instruction_text, *options):
"""Display a menu with an instruction announcement at the top.
The instruction text is announced as item 0 but is not selectable.
The actual menu items start at index 1, and navigation skips the instruction.
Args:
sounds (dict): Dictionary of sound objects
instruction_text (str): The instruction/context text to announce
*options: Menu options to display
Returns:
str: Selected menu option or None if cancelled
"""
# Get speech instance
speech = Speech.get_instance()
# Create combined list with instruction at index 0
all_items = [instruction_text] + list(options)
loop = True
pygame.mixer.stop()
current_index = 0 # Start at instruction
last_spoken = -1
# Clear any pending events
pygame.event.clear()
while loop:
if current_index != last_spoken:
speech.speak(all_items[current_index])
last_spoken = current_index
event = pygame.event.wait()
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
return None
elif event.key in [pygame.K_DOWN, pygame.K_s]:
moved = False
if current_index == 0: # On instruction, go to first menu item
current_index = 1
moved = True
elif current_index < len(all_items) - 1: # Normal navigation
current_index += 1
moved = True
if moved:
try:
sounds['menu-move'].play()
except:
pass
elif event.key in [pygame.K_UP, pygame.K_w]:
if current_index > 1: # Can move up from menu items (but not to instruction)
current_index -= 1
try:
sounds['menu-move'].play()
except:
pass
elif event.key == pygame.K_HOME:
target_index = 1 if current_index != 1 else current_index # Go to first menu item
if target_index != current_index:
current_index = target_index
try:
sounds['menu-move'].play()
except:
pass
elif event.key == pygame.K_END and current_index != len(all_items) - 1:
current_index = len(all_items) - 1
try:
sounds['menu-move'].play()
except:
pass
elif event.key == pygame.K_RETURN:
if current_index == 0: # Can't select the instruction
continue
try:
sounds['menu-select'].play()
time.sleep(sounds['menu-select'].get_length())
except:
pass
return options[current_index - 1] # Adjust for instruction offset
elif event.type == pygame.QUIT:
return None
pygame.event.pump()
pygame.event.clear()
time.sleep(0.001)
def game_menu(sounds, playCallback=None, *customOptions):
"""Display and handle the main game menu with standard and custom options.
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
pygame.event.clear()
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]
pygame.event.pump()
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
pygame.event.clear()
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 donation page.
"""
webbrowser.open('https://www.paypal.com/donate/?business=stormdragon2976@gmail.com&no_recurring=0&currency_code=USD')
messagebox("The donation page has been opened in your browser.")
def exit_game(fade=0):
"""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 mixer first
try:
pygame.mixer.quit()
except Exception as e:
print(f"Warning: Error during pygame.mixer.quit(): {e}")
# Clean up pygame
try:
pygame.quit()
except Exception as e:
print(f"Warning: Error during pygame.quit(): {e}")
# Use os._exit for immediate termination
import os
os._exit(0)

7
requirements.txt Normal file
View File

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

2
requirements_linux.txt Normal file
View File

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

313
save_manager.py Normal file
View File

@@ -0,0 +1,313 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Save/Load management system for Storm Games.
Provides atomic save operations with XDG-compliant paths,
corruption detection, and automatic cleanup.
"""
import os
import pickle
import tempfile
import shutil
import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
from .services import PathService
class SaveManager:
"""Generic save/load manager for game state persistence.
Features:
- XDG-compliant save directories
- Atomic file operations using temporary files
- Pickle-based serialization with version tracking
- Automatic cleanup of old saves
- Corruption detection and recovery
- Comprehensive error handling
Example usage:
# Initialize for a game
save_manager = SaveManager("my-awesome-game")
# Save game state with metadata
game_state = {"level": 5, "score": 1000, "inventory": ["sword", "potion"]}
metadata = {"display_name": "Boss Level", "level": 5}
save_manager.create_save(game_state, metadata)
# Load a save
save_files = save_manager.get_save_files()
if save_files:
game_state, metadata = save_manager.load_save(save_files[0])
"""
def __init__(self, game_name: Optional[str] = None, max_saves: int = 10):
"""Initialize SaveManager.
Args:
game_name: Name of the game for save directory. If None, uses PathService
max_saves: Maximum number of saves to keep (older saves auto-deleted)
"""
self.max_saves = max_saves
self.version = "1.0" # Save format version
# Get or initialize path service
self.path_service = PathService.get_instance()
if game_name:
# Initialize with specific game name
self.path_service.initialize(game_name)
# Ensure we have a valid game path
if not self.path_service.gamePath:
raise ValueError("Game path not initialized. Either provide game_name or initialize PathService first.")
self.save_dir = Path(self.path_service.gamePath) / "saves"
# Create saves directory if it doesn't exist
self.save_dir.mkdir(parents=True, exist_ok=True)
def create_save(self, save_data: Any, metadata: Optional[Dict[str, Any]] = None) -> str:
"""Create a new save file with the given data.
Args:
save_data: Any pickle-serializable object to save
metadata: Optional metadata dictionary for display purposes
Returns:
Path to the created save file
Raises:
Exception: If save operation fails
"""
if metadata is None:
metadata = {}
# Generate filename with timestamp
timestamp = int(time.time())
display_name = metadata.get("display_name", f"Save {timestamp}")
safe_name = self._sanitize_filename(display_name)
filename = f"{timestamp}_{safe_name}.save"
save_path = self.save_dir / filename
# Prepare save structure
save_structure = {
"version": self.version,
"timestamp": timestamp,
"metadata": metadata,
"data": save_data
}
# Atomic save operation using temporary file
with tempfile.NamedTemporaryFile(mode='wb', dir=self.save_dir, delete=False) as temp_file:
try:
pickle.dump(save_structure, temp_file)
temp_file.flush()
os.fsync(temp_file.fileno()) # Force write to disk
# Atomically move temporary file to final location
shutil.move(temp_file.name, save_path)
# Clean up old saves if we exceed max_saves
self._cleanup_old_saves()
return str(save_path)
except Exception as e:
# Clean up temporary file on error
try:
os.unlink(temp_file.name)
except:
pass
raise Exception(f"Failed to create save: {e}")
def load_save(self, filepath: Union[str, Path]) -> tuple[Any, Dict[str, Any]]:
"""Load save data from file.
Args:
filepath: Path to the save file
Returns:
Tuple of (save_data, metadata)
Raises:
Exception: If load operation fails or file is corrupted
"""
filepath = Path(filepath)
if not filepath.exists():
raise FileNotFoundError(f"Save file not found: {filepath}")
try:
with open(filepath, 'rb') as save_file:
save_structure = pickle.load(save_file)
# Validate save structure
if not isinstance(save_structure, dict):
raise ValueError("Invalid save file format")
required_fields = ["version", "timestamp", "data"]
for field in required_fields:
if field not in save_structure:
raise ValueError(f"Save file missing required field: {field}")
# Extract data
save_data = save_structure["data"]
metadata = save_structure.get("metadata", {})
return save_data, metadata
except Exception as e:
# Log corruption and attempt cleanup
print(f"Warning: Corrupted save file detected: {filepath} - {e}")
self._handle_corrupted_save(filepath)
raise Exception(f"Failed to load save: {e}")
def get_save_files(self) -> List[Path]:
"""Get list of save files sorted by creation time (newest first).
Returns:
List of Path objects for save files
"""
if not self.save_dir.exists():
return []
save_files = []
for file_path in self.save_dir.glob("*.save"):
try:
# Validate file can be opened
with open(file_path, 'rb') as f:
save_structure = pickle.load(f)
if isinstance(save_structure, dict) and "timestamp" in save_structure:
save_files.append((file_path, save_structure["timestamp"]))
except:
# Skip corrupted files
print(f"Warning: Skipping corrupted save file: {file_path}")
continue
# Sort by timestamp (newest first)
save_files.sort(key=lambda x: x[1], reverse=True)
return [file_path for file_path, _ in save_files]
def get_save_info(self, filepath: Union[str, Path]) -> Dict[str, Any]:
"""Get metadata information from a save file without loading the full data.
Args:
filepath: Path to the save file
Returns:
Dictionary with save information (metadata + timestamp)
"""
filepath = Path(filepath)
try:
with open(filepath, 'rb') as save_file:
save_structure = pickle.load(save_file)
return {
"timestamp": save_structure.get("timestamp", 0),
"metadata": save_structure.get("metadata", {}),
"version": save_structure.get("version", "unknown"),
"filepath": str(filepath),
"filename": filepath.name
}
except Exception as e:
return {
"timestamp": 0,
"metadata": {"display_name": "Corrupted Save"},
"version": "unknown",
"filepath": str(filepath),
"filename": filepath.name,
"error": str(e)
}
def has_saves(self) -> bool:
"""Check if any save files exist.
Returns:
True if save files exist, False otherwise
"""
return len(self.get_save_files()) > 0
def delete_save(self, filepath: Union[str, Path]) -> bool:
"""Delete a specific save file.
Args:
filepath: Path to the save file to delete
Returns:
True if file was deleted, False if it didn't exist
"""
filepath = Path(filepath)
try:
if filepath.exists():
filepath.unlink()
return True
return False
except Exception as e:
print(f"Warning: Failed to delete save file {filepath}: {e}")
return False
def cleanup_all_saves(self) -> int:
"""Delete all save files.
Returns:
Number of files deleted
"""
save_files = self.get_save_files()
deleted_count = 0
for save_file in save_files:
if self.delete_save(save_file):
deleted_count += 1
return deleted_count
def _cleanup_old_saves(self) -> None:
"""Remove old save files if we exceed max_saves limit."""
save_files = self.get_save_files()
if len(save_files) > self.max_saves:
# Delete oldest saves
for save_file in save_files[self.max_saves:]:
self.delete_save(save_file)
def _handle_corrupted_save(self, filepath: Path) -> None:
"""Handle a corrupted save file by moving it to a backup location."""
try:
backup_dir = self.save_dir / "corrupted"
backup_dir.mkdir(exist_ok=True)
backup_path = backup_dir / f"{filepath.name}.corrupted"
shutil.move(str(filepath), str(backup_path))
print(f"Moved corrupted save to: {backup_path}")
except Exception as e:
print(f"Failed to move corrupted save: {e}")
# As last resort, try to delete it
try:
filepath.unlink()
print(f"Deleted corrupted save: {filepath}")
except:
print(f"Could not clean up corrupted save: {filepath}")
def _sanitize_filename(self, filename: str) -> str:
"""Sanitize filename for cross-platform compatibility."""
# Remove invalid characters
invalid_chars = '<>:"/\\|?*'
for char in invalid_chars:
filename = filename.replace(char, "_")
# Limit length and handle edge cases
filename = filename.strip()
if not filename:
filename = "save"
# Truncate if too long
if len(filename) > 100:
filename = filename[:100]
return filename

332
scoreboard.py Normal file
View File

@@ -0,0 +1,332 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Modified Scoreboard class with integrated fixes.
This code should replace the existing Scoreboard class in scoreboard.py.
The modifications ensure proper path handling and config operations.
"""
import time
import os
from .services import ConfigService, PathService
from .speech import Speech
from .display import display_text
# For backward compatibility
from .config import localConfig, write_config, read_config
class Scoreboard:
"""Handles high score tracking with player names."""
def __init__(self, score=0, configService=None, speech=None):
"""Initialize scoreboard.
Args:
score (int): Initial score (default: 0)
configService (ConfigService): Config service (default: global instance)
speech (Speech): Speech system (default: global instance)
"""
# Ensure services are properly initialized
self._ensure_services()
self.configService = configService or ConfigService.get_instance()
self.speech = speech or Speech.get_instance()
self.currentScore = score
self.highScores = []
# For backward compatibility
read_config()
try:
# Try to use configService
self.configService.localConfig.add_section("scoreboard")
except:
# Fallback to old method
try:
localConfig.add_section("scoreboard")
except:
pass
# Load existing high scores
for i in range(1, 11):
try:
# Try to use configService
score = self.configService.localConfig.getint("scoreboard", f"score_{i}")
name = self.configService.localConfig.get("scoreboard", f"name_{i}")
self.highScores.append({
'name': name,
'score': score
})
except:
# Fallback to old method
try:
score = localConfig.getint("scoreboard", f"score_{i}")
name = localConfig.get("scoreboard", f"name_{i}")
self.highScores.append({
'name': name,
'score': score
})
except:
self.highScores.append({
'name': "Player",
'score': 0
})
# Sort high scores by score value in descending order
self.highScores.sort(key=lambda x: x['score'], reverse=True)
def _ensure_services(self):
"""Ensure PathService and ConfigService are properly initialized."""
# Get PathService and make sure it has a game name
pathService = PathService.get_instance()
# If no game name yet, try to get from pygame window title
if not pathService.gameName:
try:
import pygame
if pygame.display.get_caption()[0]:
pathService.gameName = pygame.display.get_caption()[0]
except:
pass
# Initialize path service if we have a game name but no paths set up
if pathService.gameName and not pathService.gamePath:
pathService.initialize(pathService.gameName)
# Get ConfigService and connect to PathService
configService = ConfigService.get_instance()
if not hasattr(configService, 'pathService') or not configService.pathService:
if pathService.gameName:
configService.set_game_info(pathService.gameName, pathService)
# Ensure the game directory exists
if pathService.gamePath and not os.path.exists(pathService.gamePath):
try:
os.makedirs(pathService.gamePath)
except Exception as e:
print(f"Error creating game directory: {e}")
def get_score(self):
"""Get current score."""
return self.currentScore
def get_high_scores(self):
"""Get list of high scores."""
return self.highScores
def decrease_score(self, points=1):
"""Decrease the current score."""
self.currentScore -= int(points)
return self
def increase_score(self, points=1):
"""Increase the current score."""
self.currentScore += int(points)
return self
def set_score(self, score):
"""Set the current score to a specific value."""
self.currentScore = int(score)
return self
def reset_score(self):
"""Reset the current score to zero."""
self.currentScore = 0
return self
def check_high_score(self):
"""Check if current score qualifies as a high score.
Returns:
int: Position (1-10) if high score, None if not
"""
for i, entry in enumerate(self.highScores):
if self.currentScore > entry['score']:
return i + 1
return None
def add_high_score(self, name=None):
"""Add current score to high scores if it qualifies.
Args:
name (str): Player name (if None, will prompt user)
Returns:
bool: True if score was added, False if not
"""
# Ensure services are properly set up
self._ensure_services()
position = self.check_high_score()
if position is None:
return False
# Get player name
if name is None:
# Import get_input here to avoid circular imports
from .input import get_input
name = get_input("New high score! Enter your name:", "Player")
if name is None: # User cancelled
name = "Player"
# Insert new score at correct position
self.highScores.insert(position - 1, {
'name': name,
'score': self.currentScore
})
# Keep only top 10
self.highScores = self.highScores[:10]
# Save to config - try both methods for maximum compatibility
try:
# Try new method first
for i, entry in enumerate(self.highScores):
self.configService.localConfig.set("scoreboard", f"score_{i+1}", str(entry['score']))
self.configService.localConfig.set("scoreboard", f"name_{i+1}", entry['name'])
# Try to write with configService
try:
self.configService.write_local_config()
except Exception as e:
print(f"Error writing config with configService: {e}")
# Fallback to old method if configService fails
for i, entry in enumerate(self.highScores):
localConfig.set("scoreboard", f"score_{i+1}", str(entry['score']))
localConfig.set("scoreboard", f"name_{i+1}", entry['name'])
write_config()
except Exception as e:
print(f"Error writing high scores: {e}")
# If all else fails, try direct old method
for i, entry in enumerate(self.highScores):
localConfig.set("scoreboard", f"score_{i+1}", str(entry['score']))
localConfig.set("scoreboard", f"name_{i+1}", entry['name'])
write_config()
# Announce success
try:
self.speech.messagebox(f"Congratulations {name}! You got position {position} on the scoreboard!")
except:
# Fallback to global speak function
from .speech import speak
speak(f"Congratulations {name}! You got position {position} on the scoreboard!")
time.sleep(1)
return True
@staticmethod
def has_high_scores():
"""Check if the current game has any high scores.
Returns:
bool: True if at least one high score exists, False otherwise
"""
try:
# Get PathService to access game name
pathService = PathService.get_instance()
gameName = pathService.gameName
# If no game name, try to get from window title
if not gameName:
try:
import pygame
if pygame.display.get_caption()[0]:
gameName = pygame.display.get_caption()[0]
pathService.gameName = gameName
except:
pass
# Ensure path service is properly initialized
if gameName and not pathService.gamePath:
pathService.initialize(gameName)
# Get the config file path
configPath = os.path.join(pathService.gamePath, "config.ini")
# If config file doesn't exist, there are no scores
if not os.path.exists(configPath):
return False
# Ensure config service is properly connected to path service
configService = ConfigService.get_instance()
configService.set_game_info(gameName, pathService)
# Create scoreboard using the properly initialized services
board = Scoreboard(0, configService)
# Force a read of local config to ensure fresh data
configService.read_local_config()
# Get high scores
scores = board.get_high_scores()
# Check if any score is greater than zero
return any(score['score'] > 0 for score in scores)
except Exception as e:
print(f"Error checking high scores: {e}")
return False
@staticmethod
def display_high_scores():
"""Display high scores for the current game.
Reads the high scores from Scoreboard class.
Shows the game name at the top followed by the available scores.
"""
try:
# Get PathService to access game name
pathService = PathService.get_instance()
gameName = pathService.gameName
# If no game name, try to get from window title
if not gameName:
try:
import pygame
if pygame.display.get_caption()[0]:
gameName = pygame.display.get_caption()[0]
pathService.gameName = gameName
except:
pass
# Ensure path service is properly initialized
if gameName and not pathService.gamePath:
pathService.initialize(gameName)
# Ensure config service is properly connected to path service
configService = ConfigService.get_instance()
configService.set_game_info(gameName, pathService)
# Create scoreboard using the properly initialized services
board = Scoreboard(0, configService)
# Force a read of local config to ensure fresh data
configService.read_local_config()
# Get high scores
scores = board.get_high_scores()
# Filter out scores with zero points
validScores = [score for score in scores if score['score'] > 0]
# Prepare the lines to display
lines = [f"High Scores for {gameName}:"]
# Add scores to the display list
if validScores:
for i, entry in enumerate(validScores, 1):
scoreStr = f"{i}. {entry['name']}: {entry['score']}"
lines.append(scoreStr)
else:
lines.append("No high scores yet.")
# Display the high scores
display_text(lines)
except Exception as e:
print(f"Error displaying high scores: {e}")
info = ["Could not display high scores."]
display_text(info)
# For backward compatibility with older code that might call displayHigh_scores
displayHigh_scores = display_high_scores

274
services.py Normal file
View File

@@ -0,0 +1,274 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Service classes for Storm Games.
Provides centralized services to replace global variables:
- ConfigService: Manages game configuration
- VolumeService: Handles volume settings
- PathService: Manages file paths
"""
import configparser
import os
from xdg import BaseDirectory
# For backward compatibility
from .config import gamePath, globalPath, write_config, read_config
class ConfigService:
"""Configuration management service."""
_instance = None
@classmethod
def get_instance(cls):
"""Get or create the singleton instance."""
if cls._instance is None:
cls._instance = ConfigService()
return cls._instance
def __init__(self):
"""Initialize configuration parsers."""
self.localConfig = configparser.ConfigParser()
self.globalConfig = configparser.ConfigParser()
self.gameTitle = None
self.pathService = None
def set_game_info(self, gameTitle, pathService):
"""Set game information and initialize configs.
Args:
gameTitle (str): Title of the game
pathService (PathService): Path service instance
"""
self.gameTitle = gameTitle
self.pathService = pathService
# Load existing configurations
self.read_local_config()
self.read_global_config()
def read_local_config(self):
"""Read local configuration from file."""
try:
# Try to use pathService if available
if self.pathService and self.pathService.gamePath:
with open(os.path.join(self.pathService.gamePath, "config.ini"), 'r') as configFile:
self.localConfig.read_file(configFile)
# Fallback to global gamePath
elif gamePath:
with open(os.path.join(gamePath, "config.ini"), 'r') as configFile:
self.localConfig.read_file(configFile)
# Delegate to old function as last resort
else:
read_config(False)
self.localConfig = configparser.ConfigParser()
self.localConfig.read_dict(globals().get('localConfig', {}))
except:
pass
def read_global_config(self):
"""Read global configuration from file."""
try:
# Try to use pathService if available
if self.pathService and self.pathService.globalPath:
with open(os.path.join(self.pathService.globalPath, "config.ini"), 'r') as configFile:
self.globalConfig.read_file(configFile)
# Fallback to global globalPath
elif globalPath:
with open(os.path.join(globalPath, "config.ini"), 'r') as configFile:
self.globalConfig.read_file(configFile)
# Delegate to old function as last resort
else:
read_config(True)
self.globalConfig = configparser.ConfigParser()
self.globalConfig.read_dict(globals().get('globalConfig', {}))
except:
pass
def write_local_config(self):
"""Write local configuration to file."""
try:
# Try to use pathService if available
if self.pathService and self.pathService.gamePath:
with open(os.path.join(self.pathService.gamePath, "config.ini"), 'w') as configFile:
self.localConfig.write(configFile)
# Fallback to global gamePath
elif gamePath:
with open(os.path.join(gamePath, "config.ini"), 'w') as configFile:
self.localConfig.write(configFile)
# Delegate to old function as last resort
else:
# Update old global config
globals()['localConfig'] = self.localConfig
write_config(False)
except Exception as e:
print(f"Warning: Failed to write local config: {e}")
def write_global_config(self):
"""Write global configuration to file."""
try:
# Try to use pathService if available
if self.pathService and self.pathService.globalPath:
with open(os.path.join(self.pathService.globalPath, "config.ini"), 'w') as configFile:
self.globalConfig.write(configFile)
# Fallback to global globalPath
elif globalPath:
with open(os.path.join(globalPath, "config.ini"), 'w') as configFile:
self.globalConfig.write(configFile)
# Delegate to old function as last resort
else:
# Update old global config
globals()['globalConfig'] = self.globalConfig
write_config(True)
except Exception as e:
print(f"Warning: Failed to write global config: {e}")
class VolumeService:
"""Volume management service."""
_instance = None
@classmethod
def get_instance(cls):
"""Get or create the singleton instance."""
if cls._instance is None:
cls._instance = VolumeService()
return cls._instance
def __init__(self):
"""Initialize volume settings."""
self.bgmVolume = 0.75 # Default background music volume
self.sfxVolume = 1.0 # Default sound effects volume
self.masterVolume = 1.0 # Default master volume
def adjust_master_volume(self, change, pygameMixer=None):
"""Adjust the master volume for all sounds.
Args:
change (float): Amount to change volume by (positive or negative)
pygameMixer: Optional pygame.mixer module for real-time updates
"""
self.masterVolume = max(0.0, min(1.0, self.masterVolume + change))
# Update real-time audio if pygame mixer is provided
if pygameMixer:
# Update music volume
if pygameMixer.music.get_busy():
pygameMixer.music.set_volume(self.bgmVolume * self.masterVolume)
# Update all sound channels
for i in range(pygameMixer.get_num_channels()):
channel = pygameMixer.Channel(i)
if channel.get_busy():
currentVolume = channel.get_volume()
if isinstance(currentVolume, (int, float)):
# Mono audio
channel.set_volume(currentVolume * self.masterVolume)
else:
# Stereo audio
left, right = currentVolume
channel.set_volume(left * self.masterVolume, right * self.masterVolume)
def adjust_bgm_volume(self, change, pygameMixer=None):
"""Adjust only the background music volume.
Args:
change (float): Amount to change volume by (positive or negative)
pygameMixer: Optional pygame.mixer module for real-time updates
"""
self.bgmVolume = max(0.0, min(1.0, self.bgmVolume + change))
# Update real-time audio if pygame mixer is provided
if pygameMixer and pygameMixer.music.get_busy():
pygameMixer.music.set_volume(self.bgmVolume * self.masterVolume)
def adjust_sfx_volume(self, change, pygameMixer=None):
"""Adjust volume for sound effects only.
Args:
change (float): Amount to change volume by (positive or negative)
pygameMixer: Optional pygame.mixer module for real-time updates
"""
self.sfxVolume = max(0.0, min(1.0, self.sfxVolume + change))
# Update real-time audio if pygame mixer is provided
if pygameMixer:
# Update all sound channels except reserved ones
for i in range(pygameMixer.get_num_channels()):
channel = pygameMixer.Channel(i)
if channel.get_busy():
currentVolume = channel.get_volume()
if isinstance(currentVolume, (int, float)):
# Mono audio
channel.set_volume(currentVolume * self.sfxVolume * self.masterVolume)
else:
# Stereo audio
left, right = currentVolume
channel.set_volume(left * self.sfxVolume * self.masterVolume,
right * self.sfxVolume * self.masterVolume)
def get_bgm_volume(self):
"""Get the current BGM volume with master adjustment.
Returns:
float: Current adjusted BGM volume
"""
return self.bgmVolume * self.masterVolume
def get_sfx_volume(self):
"""Get the current SFX volume with master adjustment.
Returns:
float: Current adjusted SFX volume
"""
return self.sfxVolume * self.masterVolume
class PathService:
"""Path management service."""
_instance = None
@classmethod
def get_instance(cls):
"""Get or create the singleton instance."""
if cls._instance is None:
cls._instance = PathService()
return cls._instance
def __init__(self):
"""Initialize path variables."""
self.globalPath = None
self.gamePath = None
self.gameName = None
# Try to initialize from global variables for backward compatibility
global gamePath, globalPath
if gamePath:
self.gamePath = gamePath
if globalPath:
self.globalPath = globalPath
def initialize(self, gameTitle):
"""Initialize paths for a game.
Args:
gameTitle (str): Title of the game
"""
self.gameName = gameTitle
self.globalPath = os.path.join(BaseDirectory.xdg_config_home, "storm-games")
self.gamePath = os.path.join(self.globalPath,
str.lower(str.replace(gameTitle, " ", "-")))
# Create game directory if it doesn't exist
if not os.path.exists(self.gamePath):
os.makedirs(self.gamePath)
# Update global variables for backward compatibility
global gamePath, globalPath
gamePath = self.gamePath
globalPath = self.globalPath
return self

532
sound.py Normal file
View File

@@ -0,0 +1,532 @@
#!/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, 512)
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.event.pump()
pygame.event.clear()
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.event.pump()
pygame.event.clear()
pygame.time.delay(10)
return None
def _find_matching_sound(soundPattern, sounds):
"""Find sounds matching a pattern in a dictionary."""
if isinstance(sounds, Sound):
keys = [k for k in sounds.sounds.keys() if re.match("^" + soundPattern + ".*", k)]
else:
keys = [k for k in sounds.keys() if re.match("^" + soundPattern + ".*", k)]
return random.choice(keys) if keys else None
# Global functions for backward compatibility
def play_bgm(musicFile):
"""Play background music with proper volume settings."""
try:
pygame.mixer.music.stop()
pygame.mixer.music.load(musicFile)
pygame.mixer.music.set_volume(volumeService.get_bgm_volume())
pygame.mixer.music.play(-1)
except: pass
def adjust_master_volume(change):
"""Adjust the master volume."""
volumeService.adjust_master_volume(change, pygame.mixer)
def adjust_bgm_volume(change):
"""Adjust background music volume."""
volumeService.adjust_bgm_volume(change, pygame.mixer)
def adjust_sfx_volume(change):
"""Adjust sound effects volume."""
volumeService.adjust_sfx_volume(change, pygame.mixer)
def calculate_volume_and_pan(playerPos, objPos, maxDistance=12):
"""Calculate volume and stereo panning."""
left_vol, right_vol = _get_stereo_panning(playerPos, objPos, None, maxDistance)
# Convert to old format (volume, left, right)
if left_vol == 0 and right_vol == 0:
return 0, 0, 0
elif left_vol >= right_vol:
volume = left_vol
return volume, 1, right_vol/left_vol
else:
volume = right_vol
return volume, left_vol/right_vol, 1
def play_sound(sound_or_name, volume=1.0, loop=False, playerPos=None, objPos=None,
centerDistance=None, pattern=False, interrupt=False, pause=False,
cutScene=False, sounds=None):
"""Unified sound playing function with backward compatibility."""
# Handle cut scene mode
if cutScene:
return _play_cutscene(sound_or_name, sounds)
# Handle pattern matching
if pattern and isinstance(sound_or_name, str) and sounds:
matched_sound = _find_matching_sound(sound_or_name, sounds)
if not matched_sound:
return None
sound_or_name = matched_sound
# Handle interrupt
if interrupt:
pygame.event.clear()
pygame.mixer.stop()
# Case 1: Sound instance provided
if isinstance(sound_or_name, Sound):
return sound_or_name.play_sound(sound_or_name, volume, loop, playerPos, objPos,
centerDistance, False, False, pause, False)
# Case 2: Sound name with Sound instance
elif isinstance(sounds, Sound) and isinstance(sound_or_name, str):
return sounds.play_sound(sound_or_name, volume, loop, playerPos, objPos,
centerDistance, False, False, pause, False)
# Case 3: Direct pygame.Sound
elif isinstance(sound_or_name, pygame.mixer.Sound):
channel = sound_or_name.play(-1 if loop else 0)
if channel:
channel.set_volume(volume * volumeService.get_sfx_volume())
return channel
# Case 4: Sound name with dictionary
elif isinstance(sounds, dict) and isinstance(sound_or_name, str) and sound_or_name in sounds:
# Play the sound
channel = sounds[sound_or_name].play(-1 if loop else 0)
if not channel:
return None
# Apply volume settings
sfx_vol = volumeService.get_sfx_volume()
# Handle positional audio
if playerPos is not None and objPos is not None:
left_vol, right_vol = _get_stereo_panning(playerPos, objPos, centerDistance)
if left_vol == 0 and right_vol == 0:
channel.stop()
return None
channel.set_volume(volume * left_vol * sfx_vol, volume * right_vol * sfx_vol)
else:
channel.set_volume(volume * sfx_vol)
# Pause if requested
if pause:
time.sleep(sounds[sound_or_name].get_length())
return channel
return None
def obj_update(channel, playerPos, objPos):
"""Update positional audio for a playing sound."""
if not channel:
return None
left_vol, right_vol = _get_stereo_panning(playerPos, objPos)
if left_vol == 0 and right_vol == 0:
channel.stop()
return None
channel.set_volume(left_vol * volumeService.sfxVolume, right_vol * volumeService.sfxVolume)
return channel
def obj_stop(channel):
"""Stop a sound channel."""
if channel:
try: channel.stop()
except: pass
return None
# Extremely concise lambda definitions for legacy functions
obj_play = lambda sounds, soundName, playerPos, objPos, loop=True: play_sound(
soundName, 1.0, loop, playerPos, objPos, None, False, False, False, False, sounds)
play_ambiance = lambda sounds, soundNames, probability, randomLocation=False: play_sound(
random.choice(soundNames) if random.randint(1, 100) <= probability and not any(
pygame.mixer.find_channel(True) and pygame.mixer.find_channel(True).get_busy()
for _ in ([soundNames] if isinstance(soundNames, str) else soundNames)) else None,
1.0, False, None, None, None, False, False, False, False,
sounds if not isinstance(sounds, Sound) else None)
play_random = lambda sounds, soundName, pause=False, interrupt=False: play_sound(
soundName, 1.0, False, None, None, None, True, interrupt, pause, False, sounds)
play_random_positional = lambda sounds, soundName, playerX, objectX: play_sound(
soundName, 1.0, False, playerX, objectX, None, True, False, False, False, sounds)
play_directional_sound = lambda sounds, soundName, playerPos, objPos, centerDistance=3, volume=1.0: play_sound(
soundName, volume, False, playerPos, objPos, centerDistance, False, False, False, False, sounds)
cut_scene = lambda sounds, soundName: _play_cutscene(soundName, sounds)
def play_random_falling(sounds, soundName, playerX, objectX, startY, currentY=0, maxY=20, existingChannel=None):
"""Handle falling sound."""
if isinstance(sounds, Sound):
return sounds.play_falling_sound(soundName, playerX, objectX, startY, currentY, maxY, existingChannel)
# Legacy implementation
left_vol, right_vol = _get_stereo_panning(playerX, objectX)
if left_vol == 0 and right_vol == 0:
if existingChannel:
existingChannel.stop()
return None
# Calculate fall multiplier
fallMultiplier = 1 - (currentY / maxY)
finalLeft = left_vol * fallMultiplier
finalRight = right_vol * fallMultiplier
if existingChannel:
existingChannel.set_volume(
finalLeft * volumeService.sfxVolume,
finalRight * volumeService.sfxVolume
)
return existingChannel
# Find matching sound
matched_sound = _find_matching_sound(soundName, sounds)
if not matched_sound:
return None
# Play the sound
channel = sounds[matched_sound].play()
if channel:
channel.set_volume(
finalLeft * volumeService.sfxVolume,
finalRight * volumeService.sfxVolume
)
return channel

149
speech.py Normal file
View File

@@ -0,0 +1,149 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Speech handling for Storm Games.
Provides functionality for:
- Text-to-speech using different speech providers
- Speech delay control to prevent stuttering
- On-screen text display
"""
import pygame
import textwrap
import time
from sys import exit
class Speech:
"""Handles text-to-speech functionality."""
_instance = None
@classmethod
def get_instance(cls):
"""Get or create the singleton instance."""
if cls._instance is None:
cls._instance = Speech()
return cls._instance
def __init__(self):
"""Initialize speech system with available provider."""
# Handle speech delays so we don't get stuttering
self.lastSpoken = {"text": None, "time": 0}
self.speechDelay = 250 # ms
# Try to initialize a speech provider
self.provider = None
self.providerName = None
# Try speechd first
try:
import speechd
self.spd = speechd.Client()
self.provider = self.spd
self.providerName = "speechd"
return
except ImportError:
pass
# Try accessible_output2 next
try:
import accessible_output2.outputs.auto
self.ao2 = accessible_output2.outputs.auto.Auto()
self.provider = self.ao2
self.providerName = "accessible_output2"
return
except ImportError:
pass
# No speech providers found
print("No speech providers found.")
def speak(self, text, interrupt=True):
"""Speak text using the configured speech provider and display on screen.
Args:
text (str): Text to speak and display
interrupt (bool): Whether to interrupt current speech (default: True)
"""
if not self.provider:
return
currentTime = pygame.time.get_ticks()
# Check if this is the same text within the delay window
if (self.lastSpoken["text"] == text and
currentTime - self.lastSpoken["time"] < self.speechDelay):
return
# Update last spoken tracking
self.lastSpoken["text"] = text
self.lastSpoken["time"] = currentTime
# Proceed with speech
if self.providerName == "speechd":
if interrupt:
self.spd.cancel()
self.spd.say(text)
elif self.providerName == "accessible_output2":
self.ao2.speak(text, interrupt=interrupt)
# Display the text on screen
screen = pygame.display.get_surface()
if not screen:
return
font = pygame.font.Font(None, 36)
# Wrap the text
maxWidth = screen.get_width() - 40 # Leave a 20-pixel margin on each side
wrappedText = textwrap.wrap(text, width=maxWidth // font.size('A')[0])
# Render each line
textSurfaces = [font.render(line, True, (255, 255, 255)) for line in wrappedText]
screen.fill((0, 0, 0)) # Clear screen with black
# Calculate total height of text block
totalHeight = sum(surface.get_height() for surface in textSurfaces)
# Start y-position (centered vertically)
currentY = (screen.get_height() - totalHeight) // 2
# Blit each line of text
for surface in textSurfaces:
textRect = surface.get_rect(center=(screen.get_width() // 2, currentY + surface.get_height() // 2))
screen.blit(surface, textRect)
currentY += surface.get_height()
pygame.display.flip()
def close(self):
"""Clean up speech resources."""
if self.providerName == "speechd":
self.spd.close()
# Global instance for backward compatibility
_speechInstance = None
def speak(text, interrupt=True):
"""Speak text using the global speech instance.
Args:
text (str): Text to speak and display
interrupt (bool): Whether to interrupt current speech (default: True)
"""
global _speechInstance
if _speechInstance is None:
_speechInstance = Speech.get_instance()
_speechInstance.speak(text, interrupt)
def messagebox(text):
"""Display a simple message box with text.
Shows a message that can be repeated until the user chooses to continue.
Args:
text (str): Message to display
"""
speech = Speech.get_instance()
speech.speak(text + "\nPress any key to repeat or enter to continue.")
while True:
event = pygame.event.wait()
if event.type == pygame.KEYDOWN:
if event.key in (pygame.K_ESCAPE, pygame.K_RETURN):
speech.speak(" ")
return
speech.speak(text + "\nPress any key to repeat or enter to continue.")

237
stat_tracker.py Normal file
View File

@@ -0,0 +1,237 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Statistics tracking system for Storm Games.
Provides flexible stat tracking with separate level and total counters.
Supports any pickle-serializable data type including nested structures.
"""
from typing import Dict, Any, Union, Optional
import copy
class StatTracker:
"""Flexible statistics tracking system.
Tracks statistics with separate level and total counters, supporting
any data type that can be added or assigned.
Example usage:
# Initialize with default stats
stats = StatTracker({"kills": 0, "deaths": 0, "time_played": 0.0})
# Update stats during gameplay
stats.update_stat("kills", 1) # Increment kills
stats.update_stat("time_played", 1.5) # Add time
# Reset level stats for new level
stats.reset_level()
# Get current stats
level_kills = stats.level["kills"]
total_kills = stats.total["kills"]
"""
def __init__(self, default_stats: Optional[Dict[str, Any]] = None):
"""Initialize stat tracker with optional default statistics.
Args:
default_stats: Dictionary of default stat definitions.
If None, uses empty dict for maximum flexibility.
"""
if default_stats is None:
default_stats = {}
# Deep copy to prevent shared references
self.total = copy.deepcopy(default_stats)
self.level = copy.deepcopy(default_stats)
def update_stat(self, stat_name: str, value: Any) -> None:
"""Update a statistic by adding the value to both level and total.
Args:
stat_name: Name of the statistic to update
value: Value to add to the statistic
Note:
For numeric types, this performs addition.
For other types, behavior depends on the type's __add__ method.
If the stat doesn't exist, it will be created with the given value.
"""
if stat_name in self.level:
try:
self.level[stat_name] += value
except TypeError:
# Handle types that don't support += (assign directly)
self.level[stat_name] = value
else:
self.level[stat_name] = value
if stat_name in self.total:
try:
self.total[stat_name] += value
except TypeError:
# Handle types that don't support += (assign directly)
self.total[stat_name] = value
else:
self.total[stat_name] = value
def set_stat(self, stat_name: str, value: Any, level_only: bool = False) -> None:
"""Set a statistic to a specific value.
Args:
stat_name: Name of the statistic to set
value: Value to set
level_only: If True, only update level stats (not total)
"""
self.level[stat_name] = value
if not level_only:
self.total[stat_name] = value
def get_stat(self, stat_name: str, from_total: bool = False) -> Any:
"""Get the current value of a statistic.
Args:
stat_name: Name of the statistic to retrieve
from_total: If True, get from total stats, otherwise from level stats
Returns:
The current value of the statistic, or None if it doesn't exist
"""
source = self.total if from_total else self.level
return source.get(stat_name)
def reset_level(self) -> None:
"""Reset all level statistics to their initial values.
Preserves the structure but resets values to what they were
when the StatTracker was initialized.
"""
# Reset to initial state based on current total structure
for stat_name in self.level:
if isinstance(self.level[stat_name], (int, float)):
self.level[stat_name] = 0 if isinstance(self.level[stat_name], int) else 0.0
elif isinstance(self.level[stat_name], str):
self.level[stat_name] = ""
elif isinstance(self.level[stat_name], list):
self.level[stat_name] = []
elif isinstance(self.level[stat_name], dict):
self.level[stat_name] = {}
else:
# For other types, try to create a new instance or set to None
try:
self.level[stat_name] = type(self.level[stat_name])()
except:
self.level[stat_name] = None
def add_stat(self, stat_name: str, initial_value: Any = 0) -> None:
"""Add a new statistic to both level and total tracking.
Args:
stat_name: Name of the new statistic
initial_value: Initial value for the statistic
"""
self.level[stat_name] = copy.deepcopy(initial_value)
self.total[stat_name] = copy.deepcopy(initial_value)
def remove_stat(self, stat_name: str) -> bool:
"""Remove a statistic from both level and total tracking.
Args:
stat_name: Name of the statistic to remove
Returns:
True if the statistic was removed, False if it didn't exist
"""
removed = False
if stat_name in self.level:
del self.level[stat_name]
removed = True
if stat_name in self.total:
del self.total[stat_name]
removed = True
return removed
def get_all_stats(self, include_level: bool = True, include_total: bool = True) -> Dict[str, Any]:
"""Get dictionary of all statistics.
Args:
include_level: Include level statistics in result
include_total: Include total statistics in result
Returns:
Dictionary containing requested statistics
"""
result = {}
if include_level:
result["level"] = copy.deepcopy(self.level)
if include_total:
result["total"] = copy.deepcopy(self.total)
return result
def merge_stats(self, other_tracker: 'StatTracker') -> None:
"""Merge statistics from another StatTracker instance.
Args:
other_tracker: Another StatTracker to merge stats from
Note:
For numeric types, values are added together.
For other types, behavior depends on the type's __add__ method.
If addition fails, the other tracker's value is used.
"""
# Merge total stats
for stat_name, value in other_tracker.total.items():
if stat_name in self.total:
try:
self.total[stat_name] += value
except TypeError:
self.total[stat_name] = copy.deepcopy(value)
else:
self.total[stat_name] = copy.deepcopy(value)
# Merge level stats
for stat_name, value in other_tracker.level.items():
if stat_name in self.level:
try:
self.level[stat_name] += value
except TypeError:
self.level[stat_name] = copy.deepcopy(value)
else:
self.level[stat_name] = copy.deepcopy(value)
def to_dict(self) -> Dict[str, Any]:
"""Convert StatTracker to dictionary for serialization.
Returns:
Dictionary representation of all stats
"""
return {
"level": copy.deepcopy(self.level),
"total": copy.deepcopy(self.total)
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'StatTracker':
"""Create StatTracker from dictionary.
Args:
data: Dictionary containing level and total stats
Returns:
New StatTracker instance with loaded data
"""
tracker = cls()
if "level" in data:
tracker.level = copy.deepcopy(data["level"])
if "total" in data:
tracker.total = copy.deepcopy(data["total"])
return tracker
def __str__(self) -> str:
"""String representation of current stats."""
return f"StatTracker(level={self.level}, total={self.total})"
def __repr__(self) -> str:
"""Detailed string representation."""
return self.__str__()

542
utils.py Normal file
View File

@@ -0,0 +1,542 @@
#!/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.
The Game class provides a unified interface to all libstormgames functionality,
including the new v2.0+ systems (StatTracker, SaveManager, Combat).
Example usage with new systems:
```python
import libstormgames as sg
# Initialize game with all systems
game = sg.Game("My RPG").initialize()
# Set up new game systems
stats = sg.StatTracker({"level": 1, "exp": 0, "kills": 0})
save_manager = sg.SaveManager("my-rpg") # Uses game's PathService
weapon = sg.Weapon.create_sword("Iron Sword", damage=15)
# Use in game loop
stats.update_stat("kills", 1)
game.speak(f"Level {stats.get_stat('level')} warrior!")
# Save complete game state
game_state = {
"stats": stats.to_dict(),
"weapon": weapon.to_dict(),
"progress": {"area": "forest", "time": 1200}
}
save_manager.create_save(game_state, {"display_name": "Forest Adventure"})
# Clean exit
game.exit()
```
Integration with services:
The Game class automatically initializes and connects all services:
- PathService: Manages XDG-compliant file paths
- ConfigService: Handles game configuration
- VolumeService: Controls audio volume settings
New systems like SaveManager automatically use the Game's PathService
for consistent directory structure.
"""
def __init__(self, title):
"""Initialize a new game with all core services.
Args:
title (str): Title of the game, used for configuration paths
Note:
The game title is used to create XDG-compliant directories:
- Linux: ~/.config/storm-games/game-title/
- Windows: %APPDATA%/storm-games/game-title/
- macOS: ~/Library/Application Support/storm-games/game-title/
All new systems (SaveManager, etc.) will use these paths automatically.
"""
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, optionally with visual logo
if 'game-intro' in self.sound.sounds:
self._show_logo_with_audio('game-intro')
return self
def _show_logo_with_audio(self, audioKey):
"""Show visual logo while playing audio intro.
Args:
audioKey (str): Key of the audio to play
"""
# Look for logo image files in common formats
logoFiles = ['logo.png', 'logo.jpg', 'logo.jpeg', 'logo.gif', 'logo.bmp']
logoImage = None
for logoFile in logoFiles:
if os.path.exists(logoFile):
try:
logoImage = pygame.image.load(logoFile)
break
except pygame.error:
continue
if logoImage:
# Display logo while audio plays
screen = pygame.display.get_surface()
screenRect = screen.get_rect()
logoRect = logoImage.get_rect(center=screenRect.center)
# Clear screen to black
screen.fill((0, 0, 0))
screen.blit(logoImage, logoRect)
pygame.display.flip()
# Play audio and wait for it to finish
self.sound.cut_scene(audioKey)
# Clear screen after audio finishes
screen.fill((0, 0, 0))
pygame.display.flip()
else:
# No logo image found, just play audio
self.sound.cut_scene(audioKey)
def speak(self, text, interrupt=True):
"""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)