From 5e5d33256e42571f9baff0ac10e03931fe4ce7c8 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Tue, 16 Sep 2025 01:21:20 -0400 Subject: [PATCH] New functionality added inspired by Wicked Quest game. --- README.md | 329 +++++++++++++++++++++++-- __init__.py | 8 + combat.py | 637 ++++++++++++++++++++++++++++++++++++++++++++++++ save_manager.py | 313 ++++++++++++++++++++++++ stat_tracker.py | 237 ++++++++++++++++++ utils.py | 55 ++++- 6 files changed, 1554 insertions(+), 25 deletions(-) create mode 100644 combat.py create mode 100644 save_manager.py create mode 100644 stat_tracker.py diff --git a/README.md b/README.md index 785ecf6..b43ebda 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,9 @@ A Python library to make creating audio games easier. - Input handling and keyboard controls - Menu systems and text display - GUI initialization +- **NEW**: Statistics tracking with level/total separation +- **NEW**: Save/load management with atomic operations +- **NEW**: Combat systems with weapons and projectiles ## Installation @@ -129,6 +132,9 @@ The library is organized into modules, each with a specific focus: - **display**: Text display and GUI functionality - **menu**: Menu systems - **utils**: Utility functions and Game class +- **stat_tracker**: Statistics tracking system +- **save_manager**: Save/load management +- **combat**: Weapon and projectile systems ## Core Classes @@ -297,6 +303,131 @@ Where `game-name` is the lowercase, hyphenated version of your game title. For e "My Awesome Game" would use the directory `my-awesome-game`. +### StatTracker + +Flexible statistics tracking with separate level and total counters: + +```python +# Initialize with default stats +stats = sg.StatTracker({ + "kills": 0, + "deaths": 0, + "score": 0, + "time_played": 0.0 +}) + +# Update stats during gameplay +stats.update_stat("kills", 1) # Increment kills +stats.update_stat("time_played", 1.5) # Add playtime + +# Access current values +level_kills = stats.get_stat("kills") # Current level kills +total_kills = stats.get_stat("kills", from_total=True) # All-time kills + +# Reset level stats for new level (keeps totals) +stats.reset_level() + +# Add new stats dynamically +stats.add_stat("boss_kills", 0) + +# Serialization for save/load +stats_data = stats.to_dict() +restored_stats = sg.StatTracker.from_dict(stats_data) +``` + + +### SaveManager + +Atomic save/load operations with corruption detection: + +```python +# Initialize save manager +save_manager = sg.SaveManager("my-rpg-game") + +# Create a comprehensive save +game_state = { + "player": { + "level": 5, + "hp": 80, + "inventory": ["sword", "health_potion"] + }, + "world": { + "current_area": "enchanted_forest", + "completed_quests": ["tutorial", "first_boss"] + }, + "stats": stats.to_dict() # Include StatTracker data +} + +# Add metadata for save selection screen +metadata = { + "display_name": "Enchanted Forest - Level 5", + "level": 5, + "playtime": "2.5 hours" +} + +# Create the save (atomic operation) +save_path = save_manager.create_save(game_state, metadata) + +# List and load saves +save_files = save_manager.get_save_files() # Newest first +if save_files: + loaded_data, loaded_metadata = save_manager.load_save(save_files[0]) + restored_stats = sg.StatTracker.from_dict(loaded_data["stats"]) +``` + + +### Combat Systems + +#### Weapons + +```python +# Create weapons using factory methods +sword = sg.Weapon.create_sword("Iron Sword", damage=15) +dagger = sg.Weapon.create_dagger("Steel Dagger", damage=12) + +# Custom weapon with stat bonuses +bow = sg.Weapon( + name="Elvish Bow", damage=18, range_value=8, + cooldown=600, stat_bonuses={"speed": 1.15} +) + +# Combat usage +if sword.can_attack(): # Check cooldown + damage = sword.attack() # Start attack + + if sword.can_hit_target(enemy_pos, player_pos, facing_right, "enemy1"): + actual_damage = sword.hit_target("enemy1") + sg.speak(f"Hit for {actual_damage} damage!") +``` + +#### Projectiles + +```python +# Create projectiles +arrow = sg.Projectile.create_arrow( + start_pos=(player_x, player_y), + direction=(1, 0) # Moving right +) + +# Game loop integration +active_projectiles = [] + +def update_projectiles(): + for projectile in active_projectiles[:]: + if not projectile.update(): # Move and check range + active_projectiles.remove(projectile) + continue + + # Check enemy collisions + for enemy in enemies: + if projectile.check_collision(enemy.position, enemy.size): + damage = projectile.hit() + enemy.take_damage(damage) + active_projectiles.remove(projectile) + break +``` + + ## Key Functions ### Game Initialization and Control @@ -644,40 +775,60 @@ def create_sound_environment(player_x, player_y): ``` -### Complete Game Structure with Class-Based Architecture +### Complete Game Structure with New Systems ```python import libstormgames as sg import pygame import random -class MyGame: +class ModernRPGGame: def __init__(self): # Create a Game instance that manages all subsystems - self.game = sg.Game("My Advanced Game").initialize() + self.game = sg.Game("Modern RPG Demo").initialize() + + # Initialize new game systems + self.player_stats = sg.StatTracker({ + "level": 1, "exp": 0, "hp": 100, "mp": 50, + "kills": 0, "deaths": 0, "playtime": 0.0, + "items_found": 0, "gold": 0 + }) + self.save_manager = sg.SaveManager("modern-rpg-demo") + + # Combat system + self.player_weapon = sg.Weapon.create_sword("Starting Sword", damage=10) + self.projectiles = [] + self.enemies = [] # Game state self.player_x = 5 self.player_y = 5 + self.current_area = "village" self.difficulty = "normal" # Load settings try: - self.difficulty = self.game.config_service.local_config.get("settings", "difficulty") + self.difficulty = self.game.configService.localConfig.get("settings", "difficulty") except: - self.game.config_service.local_config.add_section("settings") - self.game.config_service.local_config.set("settings", "difficulty", "normal") - self.game.config_service.write_local_config() + self.game.configService.localConfig.add_section("settings") + self.game.configService.localConfig.set("settings", "difficulty", "normal") + self.game.configService.write_local_config() def play_game(self): + """Main game loop demonstrating new systems.""" self.game.speak(f"Starting game on {self.difficulty} difficulty") self.game.play_bgm("sounds/game_music.ogg") + start_time = pygame.time.get_ticks() + # Game loop running = True while running: - # Update game state - self.player_x += random.uniform(-0.2, 0.2) + current_time = pygame.time.get_ticks() + + # Update playtime stats + playtime_hours = (current_time - start_time) / 3600000.0 # Convert to hours + self.player_stats.set_stat("playtime", playtime_hours, level_only=True) # Handle input for event in pygame.event.get(): @@ -685,26 +836,148 @@ class MyGame: if event.key == pygame.K_ESCAPE: running = False elif event.key == pygame.K_SPACE: - self.game.scoreboard.increase_score() - self.game.speak(f"Score: {self.game.scoreboard.get_score()}") + # Combat example + self.player_attack() + elif event.key == pygame.K_s: + # Quick save + self.quick_save() + elif event.key == pygame.K_l: + # Quick load + self.quick_load() - # Add some random sounds - if random.random() < 0.05: - sounds = self.game.sound.get_sounds() - if "ambient" in sounds: - sg.play_random_positional(sounds, "ambient", - self.player_x, self.player_x + random.uniform(-10, 10)) + # Update game systems + self.update_combat() + self.update_player_stats() + + # Random events + if random.random() < 0.01: + self.random_encounter() pygame.time.delay(50) - # Game over - position = self.game.scoreboard.check_high_score() - if position: - self.game.speak(f"New high score! Position {position}") - self.game.scoreboard.add_high_score() + # Game over - update total stats + total_playtime = self.player_stats.get_stat("playtime", from_total=True) + self.game.speak(f"Session ended. Total playtime: {total_playtime:.1f} hours") return "menu" + def player_attack(self): + """Handle player combat.""" + if self.player_weapon.can_attack(): + damage = self.player_weapon.attack() + self.game.speak("Attack!") + + # Simulate hitting enemies + if self.enemies and self.player_weapon.is_attack_active(): + enemy = self.enemies[0] # Attack first enemy + if self.player_weapon.can_hit_target( + enemy.position, (self.player_x, self.player_y), + facing_right=True, target_id=enemy.id + ): + actual_damage = self.player_weapon.hit_target(enemy.id) + self.player_stats.update_stat("damage_dealt", actual_damage) + + # Remove enemy if defeated + if enemy.take_damage(actual_damage): + self.enemies.remove(enemy) + self.player_stats.update_stat("kills", 1) + self.player_stats.update_stat("exp", 25) + self.game.speak("Enemy defeated!") + + def update_combat(self): + """Update combat systems.""" + # Update projectiles + for projectile in self.projectiles[:]: + if not projectile.update(): + self.projectiles.remove(projectile) + continue + + # Check enemy collisions + for enemy in self.enemies[:]: + if projectile.check_collision(enemy.position, enemy.size): + damage = projectile.hit() + if enemy.take_damage(damage): + self.enemies.remove(enemy) + self.player_stats.update_stat("kills", 1) + self.projectiles.remove(projectile) + break + + def update_player_stats(self): + """Handle player progression.""" + exp = self.player_stats.get_stat("exp") + level = self.player_stats.get_stat("level") + + # Level up check + exp_needed = level * 100 + if exp >= exp_needed: + self.player_stats.set_stat("level", level + 1) + self.player_stats.set_stat("exp", exp - exp_needed) + self.player_stats.set_stat("hp", 100) # Full heal on level up + self.game.speak(f"Level up! Now level {level + 1}!") + + def random_encounter(self): + """Create random encounters.""" + self.game.speak("An enemy appears!") + # Add enemy logic here + self.player_stats.update_stat("encounters", 1) + + def quick_save(self): + """Create a quick save.""" + try: + save_name = f"Quick Save - Level {self.player_stats.get_stat('level')}" + self.create_complete_save(save_name) + self.game.speak("Game saved!") + except Exception as e: + self.game.speak(f"Save failed: {e}") + + def quick_load(self): + """Load the most recent save.""" + try: + saves = self.save_manager.get_save_files() + if saves: + self.load_complete_save(saves[0]) + self.game.speak("Game loaded!") + else: + self.game.speak("No saves found!") + except Exception as e: + self.game.speak(f"Load failed: {e}") + + def create_complete_save(self, save_name): + """Create comprehensive save with all systems.""" + complete_state = { + "player_stats": self.player_stats.to_dict(), + "weapon": self.player_weapon.to_dict(), + "player_position": (self.player_x, self.player_y), + "current_area": self.current_area, + "difficulty": self.difficulty, + "enemies": [enemy.to_dict() for enemy in self.enemies], + "projectiles": [proj.to_dict() for proj in self.projectiles] + } + + metadata = { + "display_name": save_name, + "level": self.player_stats.get_stat("level"), + "location": self.current_area, + "playtime": f"{self.player_stats.get_stat('playtime', from_total=True):.1f}h" + } + + return self.save_manager.create_save(complete_state, metadata) + + def load_complete_save(self, save_path): + """Load comprehensive save restoring all systems.""" + data, metadata = self.save_manager.load_save(save_path) + + # Restore all systems + self.player_stats = sg.StatTracker.from_dict(data["player_stats"]) + self.player_weapon = sg.Weapon.from_dict(data["weapon"]) + self.player_x, self.player_y = data["player_position"] + self.current_area = data["current_area"] + self.difficulty = data["difficulty"] + + # Restore dynamic objects (implementation depends on your enemy/projectile classes) + # self.enemies = [Enemy.from_dict(e) for e in data["enemies"]] + # self.projectiles = [sg.Projectile.from_dict(p) for p in data["projectiles"]] + def settings(self): options = ["easy", "normal", "hard", "back"] current = options.index(self.difficulty) if self.difficulty in options else 1 @@ -779,6 +1052,18 @@ if __name__ == "__main__": - Game title is used to determine configuration directory paths - Services are interconnected, so proper initialization ensures correct operation +6. **New game systems**: + - Use StatTracker for comprehensive statistics with level/total separation + - Implement SaveManager for reliable save/load with metadata + - Leverage Combat systems for professional weapon and projectile mechanics + - Combine all systems for rich, full-featured games + +7. **Performance considerations**: + - Reset level stats regularly to prevent memory bloat + - Clean up old saves periodically using SaveManager methods + - Remove inactive projectiles from update loops + - Use weapon cooldowns to prevent spam attacks + ## Troubleshooting diff --git a/__init__.py b/__init__.py index 99a3bf8..f0aa976 100755 --- a/__init__.py +++ b/__init__.py @@ -72,6 +72,11 @@ from .utils import ( 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' @@ -118,6 +123,9 @@ __all__ = [ # 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', diff --git a/combat.py b/combat.py new file mode 100644 index 0000000..ab03489 --- /dev/null +++ b/combat.py @@ -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})" \ No newline at end of file diff --git a/save_manager.py b/save_manager.py new file mode 100644 index 0000000..0160da2 --- /dev/null +++ b/save_manager.py @@ -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 \ No newline at end of file diff --git a/stat_tracker.py b/stat_tracker.py new file mode 100644 index 0000000..1e74f67 --- /dev/null +++ b/stat_tracker.py @@ -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__() \ No newline at end of file diff --git a/utils.py b/utils.py index 9b06611..9eb866e 100644 --- a/utils.py +++ b/utils.py @@ -25,13 +25,62 @@ from .speech import Speech from .scoreboard import Scoreboard class Game: - """Central class to manage all game systems.""" + """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. + """Initialize a new game with all core services. Args: - title (str): Title of the game + 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