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:
BIN
sounds/save.ogg
(Stored with Git LFS)
Normal file
BIN
sounds/save.ogg
(Stored with Git LFS)
Normal file
Binary file not shown.
@@ -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")
|
||||
|
@@ -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:
|
||||
|
@@ -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
|
||||
|
@@ -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,
|
||||
|
293
src/save_manager.py
Normal file
293
src/save_manager.py
Normal 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
|
122
wicked_quest.py
122
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
|
||||
|
Reference in New Issue
Block a user