320 lines
12 KiB
Python
320 lines
12 KiB
Python
# -*- 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 Exception:
|
|
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,
|
|
"cooldown": weapon.cooldown,
|
|
"attackDuration": weapon.attackDuration,
|
|
"speedBonus": getattr(weapon, "speedBonus", 1.0),
|
|
"jumpDurationBonus": getattr(weapon, "jumpDurationBonus", 1.0),
|
|
}
|
|
)
|
|
return serialized
|
|
|
|
def _deserialize_weapons(self, weapon_data):
|
|
"""Deserialize weapons from save data"""
|
|
from src.weapon import Weapon
|
|
|
|
weapons = []
|
|
for data in weapon_data:
|
|
# Handle backward compatibility for old saves
|
|
speedBonus = data.get("speedBonus", 1.0)
|
|
jumpDurationBonus = data.get("jumpDurationBonus", 1.0)
|
|
cooldown = data.get("cooldown", 500) # Default cooldown for old saves
|
|
|
|
# For old saves, restore proper bonuses and cooldowns for specific weapons
|
|
if data["name"] == "witch_broom" and speedBonus == 1.0:
|
|
speedBonus = 1.17
|
|
jumpDurationBonus = 1.25
|
|
|
|
# Restore proper cooldown for nunchucks in old saves
|
|
if data["name"] == "nunchucks" and cooldown == 500:
|
|
cooldown = 250
|
|
|
|
weapon = Weapon(
|
|
name=data["name"],
|
|
damage=data["damage"],
|
|
range=data["range"],
|
|
attackSound=data["attackSound"],
|
|
hitSound=data["hitSound"],
|
|
cooldown=cooldown,
|
|
attackDuration=data["attackDuration"],
|
|
speedBonus=speedBonus,
|
|
jumpDurationBonus=jumpDurationBonus,
|
|
)
|
|
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 Exception:
|
|
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 Exception:
|
|
return False
|
|
|
|
def has_saves(self):
|
|
"""Check if any save files exist"""
|
|
return len(self.get_save_files()) > 0
|