use bevy::prelude::*;
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use crate::core::item::ItemId;
use crate::core::player_components::{
Equipment, Gold, Health, Inventory, Level, PlayerName, Stats,
};
pub struct SavePlugin;
impl Plugin for SavePlugin {
fn build(&self, app: &mut App) {
app.add_message::<SaveGameEvent>()
.add_message::<LoadGameEvent>()
.add_message::<SaveCompleteEvent>()
.add_message::<LoadCompleteEvent>()
.init_resource::<SaveConfig>()
.add_systems(Update, (handle_save_game_system, handle_load_game_system));
}
}
#[derive(bevy::ecs::message::Message, Clone)]
pub struct SaveGameEvent {
pub slot: SaveSlot,
}
#[derive(bevy::ecs::message::Message, Clone)]
pub struct LoadGameEvent {
pub slot: SaveSlot,
}
#[derive(bevy::ecs::message::Message, Clone)]
pub struct SaveCompleteEvent {
pub success: bool,
pub slot: SaveSlot,
pub error: Option<String>,
}
#[derive(bevy::ecs::message::Message, Clone)]
pub struct LoadCompleteEvent {
pub success: bool,
pub slot: SaveSlot,
pub error: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum SaveSlot {
Numbered(u32),
Named(String),
}
impl SaveSlot {
pub fn filename(&self) -> String {
match self {
SaveSlot::Numbered(n) => format!("save_{}.toml", n),
SaveSlot::Named(name) => format!("{}.toml", name),
}
}
}
impl Default for SaveSlot {
fn default() -> Self {
SaveSlot::Numbered(1)
}
}
#[derive(Resource)]
pub struct SaveConfig {
pub save_directory: PathBuf,
pub max_slots: u32,
}
impl Default for SaveConfig {
fn default() -> Self {
Self {
save_directory: PathBuf::from("saves"),
max_slots: 3,
}
}
}
#[derive(Component, Default, Reflect)]
#[reflect(Component)]
pub struct Saveable;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SaveData {
pub version: u32,
pub timestamp: String,
pub player: PlayerSaveData,
pub progress: ProgressSaveData,
}
impl Default for SaveData {
fn default() -> Self {
Self {
version: SAVE_VERSION,
timestamp: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
player: PlayerSaveData::default(),
progress: ProgressSaveData::default(),
}
}
}
pub const SAVE_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PlayerSaveData {
pub name: String,
pub level: LevelData,
pub health: HealthData,
pub stats: StatsData,
pub gold: usize,
pub equipment: EquipmentData,
pub inventory: InventoryData,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LevelData {
pub lv: usize,
pub exp: usize,
pub next_exp: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HealthData {
pub current: usize,
pub max: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct StatsData {
pub attack: usize,
pub defense: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EquipmentData {
pub weapon: String,
pub armor: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct InventoryData {
pub items: Vec<String>,
pub capacity: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProgressSaveData {
pub current_map: String,
pub position: (f32, f32),
pub flags: std::collections::HashMap<String, bool>,
}
impl Default for ProgressSaveData {
fn default() -> Self {
Self {
current_map: "overworld/levels/ruins/ruins_3.tmx".to_string(),
position: (0.0, 0.0),
flags: std::collections::HashMap::new(),
}
}
}
type MainPlayerQuery<'w, 's> = Query<
'w,
's,
(
&'static PlayerName,
&'static Level,
&'static Health,
&'static Stats,
&'static Gold,
&'static Equipment,
&'static Inventory,
&'static Transform,
),
With<crate::core::data::MainPlayer>,
>;
type MainPlayerMutQuery<'w, 's> = Query<
'w,
's,
(
&'static mut PlayerName,
&'static mut Level,
&'static mut Health,
&'static mut Stats,
&'static mut Gold,
&'static mut Equipment,
&'static mut Inventory,
&'static mut Transform,
),
With<crate::core::data::MainPlayer>,
>;
fn handle_save_game_system(
mut events: MessageReader<SaveGameEvent>,
mut complete_events: MessageWriter<SaveCompleteEvent>,
save_config: Res<SaveConfig>,
player_query: MainPlayerQuery,
souprune_config: Res<crate::config::SoupruneConfig>,
) {
for event in events.read() {
let result = save_game(
&save_config,
&player_query,
&souprune_config.game,
&event.slot,
);
match result {
Ok(()) => {
info!("Game saved to slot {:?}", event.slot);
complete_events.write(SaveCompleteEvent {
success: true,
slot: event.slot.clone(),
error: None,
});
}
Err(e) => {
error!("Failed to save game: {}", e);
complete_events.write(SaveCompleteEvent {
success: false,
slot: event.slot.clone(),
error: Some(e.to_string()),
});
}
}
}
}
fn handle_load_game_system(
mut events: MessageReader<LoadGameEvent>,
mut complete_events: MessageWriter<LoadCompleteEvent>,
save_config: Res<SaveConfig>,
mut player_query: MainPlayerMutQuery,
) {
for event in events.read() {
let result = load_game(&save_config, &mut player_query, &event.slot);
match result {
Ok(()) => {
info!("Game loaded from slot {:?}", event.slot);
complete_events.write(LoadCompleteEvent {
success: true,
slot: event.slot.clone(),
error: None,
});
}
Err(e) => {
error!("Failed to load game: {}", e);
complete_events.write(LoadCompleteEvent {
success: false,
slot: event.slot.clone(),
error: Some(e.to_string()),
});
}
}
}
}
fn save_game(
config: &SaveConfig,
player_query: &MainPlayerQuery,
game_config: &crate::config::GameConfig,
slot: &SaveSlot,
) -> anyhow::Result<()> {
fs::create_dir_all(&config.save_directory)?;
let Ok((name, level, health, stats, gold, equipment, inventory, transform)) =
player_query.single()
else {
anyhow::bail!("No player entity found");
};
let save_data = SaveData {
version: SAVE_VERSION,
timestamp: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
player: PlayerSaveData {
name: name.0.clone(),
level: LevelData {
lv: level.lv,
exp: level.exp,
next_exp: level.next_exp,
},
health: HealthData {
current: health.current,
max: health.max,
},
stats: StatsData {
attack: stats.attack,
defense: stats.defense,
},
gold: gold.0,
equipment: EquipmentData {
weapon: equipment.weapon.0.clone(),
armor: equipment.armor.0.clone(),
},
inventory: InventoryData {
items: inventory.items.iter().map(|id| id.0.clone()).collect(),
capacity: inventory.capacity,
},
},
progress: ProgressSaveData {
current_map: game_config.initial_map_path.clone(),
position: (transform.translation.x, transform.translation.y),
flags: std::collections::HashMap::new(),
},
};
let toml_string = toml::to_string_pretty(&save_data)?;
let save_path = config.save_directory.join(slot.filename());
let mut file = fs::File::create(&save_path)?;
file.write_all(toml_string.as_bytes())?;
info!("Saved game to: {:?}", save_path);
Ok(())
}
fn load_game(
config: &SaveConfig,
player_query: &mut MainPlayerMutQuery,
slot: &SaveSlot,
) -> anyhow::Result<()> {
let save_path = config.save_directory.join(slot.filename());
if !save_path.exists() {
anyhow::bail!("Save file does not exist: {:?}", save_path);
}
let contents = fs::read_to_string(&save_path)?;
let save_data: SaveData = toml::from_str(&contents)?;
if save_data.version > SAVE_VERSION {
anyhow::bail!(
"Save file version {} is newer than supported version {}",
save_data.version,
SAVE_VERSION
);
}
let Ok((
mut name,
mut level,
mut health,
mut stats,
mut gold,
mut equipment,
mut inventory,
mut transform,
)) = player_query.single_mut()
else {
anyhow::bail!("No player entity found");
};
let player_data = &save_data.player;
name.0 = player_data.name.clone();
level.lv = player_data.level.lv;
level.exp = player_data.level.exp;
level.next_exp = player_data.level.next_exp;
health.current = player_data.health.current;
health.max = player_data.health.max;
stats.attack = player_data.stats.attack;
stats.defense = player_data.stats.defense;
gold.0 = player_data.gold;
equipment.weapon = ItemId(player_data.equipment.weapon.clone());
equipment.armor = ItemId(player_data.equipment.armor.clone());
inventory.items = player_data
.inventory
.items
.iter()
.map(|s| ItemId(s.clone()))
.collect();
inventory.capacity = player_data.inventory.capacity;
transform.translation.x = save_data.progress.position.0;
transform.translation.y = save_data.progress.position.1;
info!("Loaded game from: {:?}", save_path);
Ok(())
}
pub fn get_save_path(config: &SaveConfig, slot: &SaveSlot) -> PathBuf {
config.save_directory.join(slot.filename())
}
pub fn save_exists(config: &SaveConfig, slot: &SaveSlot) -> bool {
get_save_path(config, slot).exists()
}
pub fn list_saves(config: &SaveConfig) -> Vec<SaveSlot> {
let mut saves = Vec::new();
if !config.save_directory.exists() {
return saves;
}
if let Ok(entries) = fs::read_dir(&config.save_directory) {
for entry in entries.flatten() {
let path = entry.path();
if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
if ext == "toml" {
if let Some(num_str) = name.strip_prefix("save_") {
if let Ok(num) = num_str.parse::<u32>() {
saves.push(SaveSlot::Numbered(num));
continue;
}
}
saves.push(SaveSlot::Named(name.to_string()));
}
}
}
}
}
saves.sort_by(|a, b| match (a, b) {
(SaveSlot::Numbered(n1), SaveSlot::Numbered(n2)) => n1.cmp(n2),
(SaveSlot::Numbered(_), SaveSlot::Named(_)) => std::cmp::Ordering::Less,
(SaveSlot::Named(_), SaveSlot::Numbered(_)) => std::cmp::Ordering::Greater,
(SaveSlot::Named(s1), SaveSlot::Named(s2)) => s1.cmp(s2),
});
saves
}
pub fn delete_save(config: &SaveConfig, slot: &SaveSlot) -> anyhow::Result<()> {
let path = get_save_path(config, slot);
if path.exists() {
fs::remove_file(&path)?;
info!("Deleted save: {:?}", path);
}
Ok(())
}
pub fn read_save_metadata(config: &SaveConfig, slot: &SaveSlot) -> anyhow::Result<SaveMetadata> {
let path = get_save_path(config, slot);
let contents = fs::read_to_string(&path)?;
let save_data: SaveData = toml::from_str(&contents)?;
Ok(SaveMetadata {
slot: slot.clone(),
version: save_data.version,
timestamp: save_data.timestamp,
player_name: save_data.player.name,
player_level: save_data.player.level.lv,
current_map: save_data.progress.current_map,
})
}
#[derive(Debug, Clone)]
pub struct SaveMetadata {
pub slot: SaveSlot,
pub version: u32,
pub timestamp: String,
pub player_name: String,
pub player_level: usize,
pub current_map: String,
}