Compare commits
104 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
acb899e6eb | ||
|
5e5d33256e | ||
|
a96f9744a9 | ||
|
8cca66d44e | ||
|
f2079261d1 | ||
|
0190fa3a06 | ||
|
09421c4bda | ||
|
dcd204e476 | ||
|
ca2d0d34bd | ||
|
8ffa53b69f | ||
|
68ffde0fc7 | ||
|
a17a4c6f15 | ||
|
3a478d15d5 | ||
|
3b01662d98 | ||
|
5e926fa7eb | ||
|
e272da1177 | ||
|
1bb9e18ea2 | ||
|
619cb5508a | ||
|
2c34f31a82 | ||
|
a9c2c4332d | ||
|
fedb09be94 | ||
|
27765e62bc | ||
|
23aea6badf | ||
|
af38d5af76 | ||
|
4d0436c5a9 | ||
|
f51bd6dee4 | ||
|
3b2bcd928d | ||
|
91f39aad88 | ||
|
1dc0ac2a7f | ||
|
3f8385599b | ||
|
468c663cc1 | ||
|
fe772cbb1e | ||
|
2c101d1778 | ||
|
8f81323668 | ||
|
be6dfdf53a | ||
|
2ad22ff1ae | ||
|
aba87e87ad | ||
|
df7945e3b6 | ||
|
7902dfacd1 | ||
|
10b46fa168 | ||
|
e7d5b03e55 | ||
|
173220d167 | ||
|
2f791da5b7 | ||
|
9997b684ca | ||
|
e7caff3d0f | ||
|
db6c34f714 | ||
|
3862b36d56 | ||
|
8bfe968b4b | ||
|
e2c69e3af7 | ||
|
da17b71c28 | ||
|
1cb57391d8 | ||
|
7f62e6ccca | ||
|
943e2acf53 | ||
|
c242fc6832 | ||
|
6d2c6e04d8 | ||
|
97431c0c74 | ||
|
4a15f951f0 | ||
|
5a791510ea | ||
|
7cbbc64d27 | ||
|
b479811a98 | ||
|
d5d737d0c0 | ||
|
dd246db5be | ||
|
21216f361a | ||
|
68e72f5d81 | ||
|
80fe2caff3 | ||
|
2df86c9c76 | ||
|
5fa90f9e84 | ||
|
658709ebce | ||
|
d5c79c0770 | ||
|
24f9a126d4 | ||
|
c316d4e570 | ||
|
e66655d75f | ||
|
c5406d5089 | ||
|
b5b472eebe | ||
|
9f03de15b8 | ||
|
428a48678d | ||
|
9a6d6374f9 | ||
|
df386cbbd9 | ||
|
38522aee78 | ||
|
0e9c52f5e1 | ||
|
fabf48ff42 | ||
|
0c73e98876 | ||
|
0ef11785ec | ||
|
58ab5aa854 | ||
|
155ed6ec39 | ||
|
68ad08be46 | ||
|
536659338e | ||
|
37aa764d68 | ||
|
84a722bb8e | ||
|
b897abf0a3 | ||
|
678af54346 | ||
|
d456b8b3b3 | ||
|
c5c32943e2 | ||
|
34d89ca54b | ||
|
e8bf4f9565 | ||
|
dd350c0285 | ||
|
ae93e02e69 | ||
|
21c0795ea9 | ||
|
67d2315cef | ||
|
b6afb5450e | ||
|
42266d4b6c | ||
|
54842bac29 | ||
|
08f06699c8 | ||
|
7ef11be54c |
236
__init__.py
Normal file → Executable file
236
__init__.py
Normal file → Executable 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
637
combat.py
Normal 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
102
config.py
Normal 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
238
display.py
Normal 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
253
input.py
Normal 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()
|
273
libstormgames.py
273
libstormgames.py
@@ -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=¤cy_code=USD&bn=PP-DonationsBF:btn_donateCC_LG.gif:NonHosted')
|
593
menu.py
Normal file
593
menu.py
Normal 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¤cy_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
7
requirements.txt
Normal 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
2
requirements_linux.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
-r requirements.txt
|
||||
python-speechd>=0.11.1
|
313
save_manager.py
Normal file
313
save_manager.py
Normal 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
332
scoreboard.py
Normal 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
274
services.py
Normal 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
532
sound.py
Normal 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
149
speech.py
Normal 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
237
stat_tracker.py
Normal 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
542
utils.py
Normal 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)
|
Reference in New Issue
Block a user