Ability to save added, collect 200 bone dust and the game saves. Load a game from the load option in the main menu.

This commit is contained in:
Storm Dragon
2025-06-05 02:56:06 -04:00
parent d1519b13e2
commit 614d78166f
7 changed files with 439 additions and 5 deletions

BIN
sounds/save.ogg (Stored with Git LFS) Normal file

Binary file not shown.

View File

@@ -132,4 +132,6 @@ def get_level_path(gameDir, levelNum):
Returns: Returns:
str: Full path to level JSON file 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") return os.path.join("levels", gameDir, f"{levelNum}.json")

View File

@@ -360,6 +360,7 @@ class Level:
self.player.stats.update_stat('Items collected', 1) self.player.stats.update_stat('Items collected', 1)
if obj.soundName == "bone_dust": if obj.soundName == "bone_dust":
self.player._coins += 1 self.player._coins += 1
self.player.add_save_bone_dust(1) # Add to save bone dust counter too
self.levelScore += 100 self.levelScore += 100
self.player.stats.update_stat('Bone dust', 1) self.player.stats.update_stat('Bone dust', 1)
if self.player._coins % 5 == 0: if self.player._coins % 5 == 0:

View File

@@ -43,7 +43,8 @@ class Player:
# Inventory system # Inventory system
self.inventory = [] self.inventory = []
self.collectedItems = [] 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._jack_o_lantern_count = 0
self.shinBoneCount = 0 self.shinBoneCount = 0
@@ -218,6 +219,25 @@ class Player:
"""Get remaining coins""" """Get remaining coins"""
return self._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): def get_lives(self):
"""Get remaining lives""" """Get remaining lives"""
return self._lives return self._lives

View File

@@ -73,6 +73,7 @@ class PowerUp(Object):
elif self.item_type == 'shin_bone': # Add shin bone handling elif self.item_type == 'shin_bone': # Add shin bone handling
player.shinBoneCount += 1 player.shinBoneCount += 1
player._coins += 5 player._coins += 5
player.add_save_bone_dust(5) # Add to save bone dust counter too
if player.get_health() < player.get_max_health(): if player.get_health() < player.get_max_health():
player.set_health(min( player.set_health(min(
player.get_health() + 1, player.get_health() + 1,

293
src/save_manager.py Normal file
View File

@@ -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

View File

@@ -9,6 +9,7 @@ from src.level import Level
from src.object import Object from src.object import Object
from src.player import Player from src.player import Player
from src.game_selection import select_game, get_level_path from src.game_selection import select_game, get_level_path
from src.save_manager import SaveManager
class WickedQuest: class WickedQuest:
@@ -22,6 +23,7 @@ class WickedQuest:
self.player = None self.player = None
self.currentGame = None self.currentGame = None
self.runLock = False # Toggle behavior of the run keys self.runLock = False # Toggle behavior of the run keys
self.saveManager = SaveManager()
def load_level(self, levelNumber): def load_level(self, levelNumber):
"""Load a level from its JSON file.""" """Load a level from its JSON file."""
@@ -84,6 +86,92 @@ class WickedQuest:
return errors 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): def handle_input(self):
"""Process keyboard input for player actions.""" """Process keyboard input for player actions."""
keys = pygame.key.get_pressed() keys = pygame.key.get_pressed()
@@ -127,7 +215,7 @@ class WickedQuest:
# Status queries # Status queries
if keys[pygame.K_c]: 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]: if keys[pygame.K_h]:
speak(f"{player.get_health()} health of {player.get_max_health()}") speak(f"{player.get_health()} health of {player.get_max_health()}")
if keys[pygame.K_i]: if keys[pygame.K_i]:
@@ -213,11 +301,11 @@ class WickedQuest:
cut_scene(self.sounds, "game_over") cut_scene(self.sounds, "game_over")
display_text(report) display_text(report)
def game_loop(self): def game_loop(self, startingLevelNum=1):
"""Main game loop handling updates and state changes.""" """Main game loop handling updates and state changes."""
clock = pygame.time.Clock() clock = pygame.time.Clock()
levelStartTime = pygame.time.get_ticks() levelStartTime = pygame.time.get_ticks()
currentLevelNum = 1 currentLevelNum = startingLevelNum
while True: while True:
currentTime = pygame.time.get_ticks() currentTime = pygame.time.get_ticks()
@@ -288,6 +376,8 @@ class WickedQuest:
currentLevelNum += 1 currentLevelNum += 1
if self.load_level(currentLevelNum): 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 levelStartTime = pygame.time.get_ticks() # Reset level timer for new level
continue continue
else: else:
@@ -319,10 +409,34 @@ class WickedQuest:
pass pass
while True: 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": if choice == "exit":
exit_game() 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": elif choice == "play":
self.currentGame = select_game(self.sounds) self.currentGame = select_game(self.sounds)
# Validate level files before starting # Validate level files before starting