diff --git a/sounds/save.ogg b/sounds/save.ogg new file mode 100644 index 0000000..ebbcb2b --- /dev/null +++ b/sounds/save.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:411375654d7336eac9f0712024cd3362498b50df09c1785a1c4eac268a64e956 +size 55183 diff --git a/src/game_selection.py b/src/game_selection.py index 44289c4..561d734 100644 --- a/src/game_selection.py +++ b/src/game_selection.py @@ -132,4 +132,6 @@ def get_level_path(gameDir, levelNum): Returns: str: Full path to level JSON file """ + if gameDir is None: + raise ValueError("gameDir cannot be None") return os.path.join("levels", gameDir, f"{levelNum}.json") diff --git a/src/level.py b/src/level.py index fb23260..b0071bc 100644 --- a/src/level.py +++ b/src/level.py @@ -360,6 +360,7 @@ class Level: self.player.stats.update_stat('Items collected', 1) if obj.soundName == "bone_dust": self.player._coins += 1 + self.player.add_save_bone_dust(1) # Add to save bone dust counter too self.levelScore += 100 self.player.stats.update_stat('Bone dust', 1) if self.player._coins % 5 == 0: diff --git a/src/player.py b/src/player.py index 8d750ee..b0fed65 100644 --- a/src/player.py +++ b/src/player.py @@ -43,7 +43,8 @@ class Player: # Inventory system self.inventory = [] self.collectedItems = [] - self._coins = 0 + self._coins = 0 # Regular bone dust for extra lives + self._saveBoneDust = 0 # Separate bone dust counter for saves self._jack_o_lantern_count = 0 self.shinBoneCount = 0 @@ -218,6 +219,25 @@ class Player: """Get remaining coins""" return self._coins + def get_save_bone_dust(self): + """Get bone dust available for saves""" + return self._saveBoneDust + + def add_save_bone_dust(self, amount=1): + """Add bone dust for saves (separate from extra life bone dust)""" + self._saveBoneDust += amount + + def spend_save_bone_dust(self, amount): + """Spend bone dust for saves""" + if self._saveBoneDust >= amount: + self._saveBoneDust -= amount + return True + return False + + def can_save(self): + """Check if player has enough bone dust to save""" + return self._saveBoneDust >= 200 + def get_lives(self): """Get remaining lives""" return self._lives diff --git a/src/powerup.py b/src/powerup.py index dc7bea9..91d5546 100644 --- a/src/powerup.py +++ b/src/powerup.py @@ -73,6 +73,7 @@ class PowerUp(Object): elif self.item_type == 'shin_bone': # Add shin bone handling player.shinBoneCount += 1 player._coins += 5 + player.add_save_bone_dust(5) # Add to save bone dust counter too if player.get_health() < player.get_max_health(): player.set_health(min( player.get_health() + 1, diff --git a/src/save_manager.py b/src/save_manager.py new file mode 100644 index 0000000..d3432ef --- /dev/null +++ b/src/save_manager.py @@ -0,0 +1,293 @@ +# -*- coding: utf-8 -*- + +import os +import pickle +import glob +from datetime import datetime +from pathlib import Path + + +class SaveManager: + def __init__(self): + """Initialize save manager with XDG-compliant save directory""" + # Use XDG_CONFIG_HOME or default to ~/.config + config_home = os.environ.get('XDG_CONFIG_HOME', os.path.expanduser('~/.config')) + self.save_dir = Path(config_home) / 'storm-games' / 'wicked-quest' + self.save_dir.mkdir(parents=True, exist_ok=True) + self.max_saves = 10 + + def create_save(self, player, current_level, game_start_time, current_game): + """Create a save file with current game state""" + if not player.can_save(): + return False, "Not enough bone dust to save (need 200)" + + # Validate required parameters + if current_game is None: + return False, "No game selected to save" + + if current_level is None: + return False, "No current level to save" + + # Spend the bone dust + if not player.spend_save_bone_dust(200): + return False, "Failed to spend bone dust" + + # Create save data + save_data = { + 'player_state': { + 'xPos': player.xPos, + 'yPos': player.yPos, + 'health': player._health, + 'maxHealth': player._maxHealth, + 'lives': player._lives, + 'coins': player._coins, + 'saveBoneDust': player._saveBoneDust, + 'jackOLanternCount': player._jack_o_lantern_count, + 'shinBoneCount': player.shinBoneCount, + 'inventory': player.inventory, + 'collectedItems': player.collectedItems, + 'weapons': self._serialize_weapons(player.weapons), + 'currentWeaponName': player.currentWeapon.name if player.currentWeapon else None, + 'stats': self._serialize_stats(player.stats), + 'scoreboard': self._serialize_scoreboard(player.scoreboard) + }, + 'game_state': { + 'currentLevel': current_level, + 'currentGame': current_game, + 'gameStartTime': game_start_time, + 'saveTime': datetime.now() + }, + 'version': '1.0' + } + + # Generate filename with timestamp + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + filename = f"save_{timestamp}.pickle" + filepath = self.save_dir / filename + + try: + # Write to temporary file first, then rename for atomic operation + temp_filepath = filepath.with_suffix('.tmp') + + with open(temp_filepath, 'wb') as f: + pickle.dump(save_data, f) + f.flush() # Ensure data is written to disk + os.fsync(f.fileno()) # Force write to disk + + # Atomic rename (replaces old file if it exists) + temp_filepath.rename(filepath) + + # Clean up old saves if we exceed max_saves + self._cleanup_old_saves() + + return True, f"Game saved to {filename}" + except Exception as e: + # Clean up temp file if it exists + if temp_filepath.exists(): + try: + temp_filepath.unlink() + except: + pass + return False, f"Failed to save game: {str(e)}" + + def _serialize_weapons(self, weapons): + """Serialize weapons for saving""" + serialized = [] + for weapon in weapons: + serialized.append({ + 'name': weapon.name, + 'damage': weapon.damage, + 'range': weapon.range, + 'attackSound': weapon.attackSound, + 'hitSound': weapon.hitSound, + 'attackDuration': weapon.attackDuration + }) + return serialized + + def _deserialize_weapons(self, weapon_data): + """Deserialize weapons from save data""" + from src.weapon import Weapon + weapons = [] + for data in weapon_data: + weapon = Weapon( + name=data['name'], + damage=data['damage'], + range=data['range'], + attackSound=data['attackSound'], + hitSound=data['hitSound'], + attackDuration=data['attackDuration'] + ) + weapons.append(weapon) + return weapons + + def _serialize_stats(self, stats): + """Serialize stats for saving""" + return { + 'total': stats.total.copy(), + 'level': stats.level.copy() + } + + def _deserialize_stats(self, stats_data): + """Deserialize stats from save data""" + from src.stat_tracker import StatTracker + stats = StatTracker() + if 'total' in stats_data: + stats.total.update(stats_data['total']) + if 'level' in stats_data: + stats.level.update(stats_data['level']) + return stats + + def _serialize_scoreboard(self, scoreboard): + """Serialize scoreboard for saving""" + return { + 'currentScore': getattr(scoreboard, 'currentScore', 0), + 'highScores': getattr(scoreboard, 'highScores', []) + } + + def _deserialize_scoreboard(self, scoreboard_data): + """Deserialize scoreboard from save data""" + from libstormgames import Scoreboard + scoreboard = Scoreboard() + if 'currentScore' in scoreboard_data: + scoreboard.currentScore = scoreboard_data['currentScore'] + if 'highScores' in scoreboard_data: + scoreboard.highScores = scoreboard_data['highScores'] + return scoreboard + + def get_save_files(self): + """Get list of save files with metadata""" + save_files = [] + pattern = str(self.save_dir / "save_*.pickle") + + for filepath in glob.glob(pattern): + try: + with open(filepath, 'rb') as f: + save_data = pickle.load(f) + + # Validate save data structure + if not self._validate_save_data(save_data): + print(f"Invalid save file structure: {filepath}") + continue + + # Extract save info + save_time = save_data['game_state']['saveTime'] + level = save_data['game_state']['currentLevel'] + game_name = save_data['game_state']['currentGame'] + + # Format display name + formatted_time = save_time.strftime("%B %d %I:%M%p") + display_name = f"{formatted_time} {game_name} Level {level}" + + save_files.append({ + 'filepath': filepath, + 'display_name': display_name, + 'save_time': save_time, + 'level': level, + 'game_name': game_name, + 'save_data': save_data + }) + except (pickle.PickleError, EOFError, OSError) as e: + print(f"Corrupted save file {filepath}: {e}") + # Try to remove corrupted save file + try: + os.remove(filepath) + print(f"Removed corrupted save file: {filepath}") + except: + pass + continue + except Exception as e: + print(f"Error reading save file {filepath}: {e}") + continue + + # Sort by save time (newest first) + save_files.sort(key=lambda x: x['save_time'], reverse=True) + return save_files + + def load_save(self, filepath): + """Load game state from save file""" + try: + with open(filepath, 'rb') as f: + save_data = pickle.load(f) + return True, save_data + except Exception as e: + return False, f"Failed to load save: {str(e)}" + + def restore_player_state(self, player, save_data): + """Restore player state from save data""" + player_state = save_data['player_state'] + + # Restore basic attributes + player.xPos = player_state['xPos'] + player.yPos = player_state['yPos'] + player._health = player_state['health'] + player._maxHealth = player_state['maxHealth'] + player._lives = player_state['lives'] + player._coins = player_state['coins'] + player._saveBoneDust = player_state['saveBoneDust'] + player._jack_o_lantern_count = player_state['jackOLanternCount'] + player.shinBoneCount = player_state['shinBoneCount'] + player.inventory = player_state['inventory'] + player.collectedItems = player_state['collectedItems'] + + # Restore weapons + player.weapons = self._deserialize_weapons(player_state['weapons']) + + # Restore current weapon + current_weapon_name = player_state.get('currentWeaponName') + if current_weapon_name: + for weapon in player.weapons: + if weapon.name == current_weapon_name: + player.currentWeapon = weapon + break + + # Restore stats + if 'stats' in player_state: + player.stats = self._deserialize_stats(player_state['stats']) + else: + from src.stat_tracker import StatTracker + player.stats = StatTracker() + + # Restore scoreboard + if 'scoreboard' in player_state: + player.scoreboard = self._deserialize_scoreboard(player_state['scoreboard']) + else: + from libstormgames import Scoreboard + player.scoreboard = Scoreboard() + + def _cleanup_old_saves(self): + """Remove old save files if we exceed max_saves""" + save_files = self.get_save_files() + + if len(save_files) > self.max_saves: + # Remove oldest saves + for save_file in save_files[self.max_saves:]: + try: + os.remove(save_file['filepath']) + except Exception as e: + print(f"Error removing old save {save_file['filepath']}: {e}") + + def _validate_save_data(self, save_data): + """Validate that save data has required structure""" + try: + # Check for required top-level keys + required_keys = ['player_state', 'game_state', 'version'] + if not all(key in save_data for key in required_keys): + return False + + # Check player_state structure + player_required = ['xPos', 'yPos', 'health', 'maxHealth', 'lives', 'coins', 'saveBoneDust'] + if not all(key in save_data['player_state'] for key in player_required): + return False + + # Check game_state structure + game_required = ['currentLevel', 'currentGame', 'gameStartTime', 'saveTime'] + if not all(key in save_data['game_state'] for key in game_required): + return False + + return True + except: + return False + + def has_saves(self): + """Check if any save files exist""" + return len(self.get_save_files()) > 0 \ No newline at end of file diff --git a/wicked_quest.py b/wicked_quest.py index 2b60a65..fa7a586 100755 --- a/wicked_quest.py +++ b/wicked_quest.py @@ -9,6 +9,7 @@ from src.level import Level from src.object import Object from src.player import Player from src.game_selection import select_game, get_level_path +from src.save_manager import SaveManager class WickedQuest: @@ -22,6 +23,7 @@ class WickedQuest: self.player = None self.currentGame = None self.runLock = False # Toggle behavior of the run keys + self.saveManager = SaveManager() def load_level(self, levelNumber): """Load a level from its JSON file.""" @@ -84,6 +86,92 @@ class WickedQuest: return errors + def load_game_menu(self): + """Display load game menu with available saves""" + save_files = self.saveManager.get_save_files() + + if not save_files: + messagebox("No save files found.") + return None + + # Create menu options + options = [] + for save_file in save_files: + options.append(save_file['display_name']) + + options.append("Cancel") + + # Show menu + currentIndex = 0 + lastSpoken = -1 + + messagebox("Select a save file to load:") + + while True: + if currentIndex != lastSpoken: + speak(options[currentIndex]) + lastSpoken = currentIndex + + 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] and currentIndex < len(options) - 1: + currentIndex += 1 + try: + self.sounds['menu-move'].play() + except: + pass + elif event.key in [pygame.K_UP, pygame.K_w] and currentIndex > 0: + currentIndex -= 1 + try: + self.sounds['menu-move'].play() + except: + pass + elif event.key == pygame.K_RETURN: + try: + self.sounds['menu-select'].play() + except: + pass + + if currentIndex == len(options) - 1: # Cancel + return None + else: + return save_files[currentIndex] + + pygame.event.clear() + + def auto_save(self): + """Automatically save the game if player has enough bone dust""" + if not self.player.can_save(): + return False + + # Automatically create save + try: + success, message = self.saveManager.create_save( + self.player, + self.currentLevel.levelId, + self.gameStartTime, + self.currentGame + ) + + if success: + try: + if 'save' in self.sounds: + play_sound(self.sounds['save']) + else: + print("Save sound not found in sounds dictionary") + except Exception as e: + print(f"Error playing save sound: {e}") + pass # Continue if save sound fails to play + else: + print(f"Save failed: {message}") + + return success + except Exception as e: + print(f"Error during save: {e}") + return False + def handle_input(self): """Process keyboard input for player actions.""" keys = pygame.key.get_pressed() @@ -127,7 +215,7 @@ class WickedQuest: # Status queries if keys[pygame.K_c]: - speak(f"{player.get_coins()} bone dust") + speak(f"{player.get_coins()} bone dust for extra lives, {player.get_save_bone_dust()} bone dust for saves") if keys[pygame.K_h]: speak(f"{player.get_health()} health of {player.get_max_health()}") if keys[pygame.K_i]: @@ -213,11 +301,11 @@ class WickedQuest: cut_scene(self.sounds, "game_over") display_text(report) - def game_loop(self): + def game_loop(self, startingLevelNum=1): """Main game loop handling updates and state changes.""" clock = pygame.time.Clock() levelStartTime = pygame.time.get_ticks() - currentLevelNum = 1 + currentLevelNum = startingLevelNum while True: currentTime = pygame.time.get_ticks() @@ -288,6 +376,8 @@ class WickedQuest: currentLevelNum += 1 if self.load_level(currentLevelNum): + # Auto save at the beginning of new level if conditions are met + self.auto_save() levelStartTime = pygame.time.get_ticks() # Reset level timer for new level continue else: @@ -319,10 +409,34 @@ class WickedQuest: pass while True: - choice = game_menu(self.sounds) + # Add load game option if saves exist + custom_options = [] + if self.saveManager.has_saves(): + custom_options.append("load_game") + + choice = game_menu(self.sounds, None, *custom_options) if choice == "exit": exit_game() + elif choice == "load_game": + selected_save = self.load_game_menu() + if selected_save: + success, save_data = self.saveManager.load_save(selected_save['filepath']) + if success: + # Load the saved game + self.currentGame = save_data['game_state']['currentGame'] + self.gameStartTime = save_data['game_state']['gameStartTime'] + current_level = save_data['game_state']['currentLevel'] + + # Load the level + if self.load_level(current_level): + # Restore player state + self.saveManager.restore_player_state(self.player, save_data) + self.game_loop(current_level) + else: + messagebox("Failed to load saved level.") + else: + messagebox(f"Failed to load save: {save_data}") elif choice == "play": self.currentGame = select_game(self.sounds) # Validate level files before starting