redid save file so it's compressed and in xml (which is used for a lot of stuff like this)
This commit is contained in:
@@ -7,6 +7,8 @@ edition = "2024"
|
||||
eframe = "0.31"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
quick-xml = { version = "0.37", features = ["serialize"] }
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
dirs = "6"
|
||||
rfd = "0.15"
|
||||
tiny-skia = "0.11"
|
||||
|
||||
@@ -212,6 +212,7 @@ pub struct WindowSettings {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct DungeonLayout {
|
||||
pub name: String,
|
||||
pub rooms: Vec<Room>,
|
||||
|
||||
+123
-15
@@ -1,13 +1,16 @@
|
||||
/*
|
||||
* Persistence logic for dungeon state.
|
||||
* Handles saving and loading the complete dungeon state
|
||||
* (settings and layout data) to and from disk using JSON.
|
||||
* (settings, layout data, and SVGs) to and from compressed XML archives.
|
||||
*/
|
||||
|
||||
use rfd::FileDialog;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use zip::write::SimpleFileOptions;
|
||||
use zip::{CompressionMethod, ZipArchive, ZipWriter};
|
||||
|
||||
use crate::layout::DungeonLayout;
|
||||
use crate::ui::UiSettings;
|
||||
@@ -19,7 +22,66 @@ pub struct DungeonSave {
|
||||
pub svgs: Vec<String>,
|
||||
}
|
||||
|
||||
// Saves dungeon state to a user-selected JSON file.
|
||||
const XML_ENTRY_NAME: &str = "dungeon.xml";
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct DungeonXml {
|
||||
#[serde(rename = "$text")]
|
||||
payload: String,
|
||||
}
|
||||
|
||||
fn serialize_dungeon(save_data: &DungeonSave) -> Result<Vec<u8>, String> {
|
||||
let payload = serde_json::to_string(save_data)
|
||||
.map_err(|e| format!("Failed to serialize dungeon: {e}"))?;
|
||||
quick_xml::se::to_string(&DungeonXml { payload })
|
||||
.map(|xml| {
|
||||
let mut document = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||
document.push_str(&xml);
|
||||
document.into_bytes()
|
||||
})
|
||||
.map_err(|e| format!("Failed to serialize dungeon: {e}"))
|
||||
}
|
||||
|
||||
fn deserialize_dungeon(xml: &[u8]) -> Result<DungeonSave, String> {
|
||||
let document: DungeonXml = quick_xml::de::from_reader(Cursor::new(xml))
|
||||
.map_err(|e| format!("Failed to parse dungeon XML: {e}"))?;
|
||||
serde_json::from_str(&document.payload)
|
||||
.map_err(|e| format!("Failed to parse dungeon XML payload: {e}"))
|
||||
}
|
||||
|
||||
fn create_archive(save_data: &DungeonSave) -> Result<Vec<u8>, String> {
|
||||
let xml = serialize_dungeon(save_data)?;
|
||||
let mut archive = Cursor::new(Vec::new());
|
||||
{
|
||||
let mut writer = ZipWriter::new(&mut archive);
|
||||
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
|
||||
writer
|
||||
.start_file(XML_ENTRY_NAME, options)
|
||||
.map_err(|e| format!("Failed to create dungeon archive: {e}"))?;
|
||||
writer
|
||||
.write_all(&xml)
|
||||
.map_err(|e| format!("Failed to write dungeon XML: {e}"))?;
|
||||
writer
|
||||
.finish()
|
||||
.map_err(|e| format!("Failed to finish dungeon archive: {e}"))?;
|
||||
}
|
||||
Ok(archive.into_inner())
|
||||
}
|
||||
|
||||
fn read_archive(bytes: &[u8]) -> Result<DungeonSave, String> {
|
||||
let mut archive =
|
||||
ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("Invalid dungeon archive: {e}"))?;
|
||||
let mut xml_file = archive
|
||||
.by_name(XML_ENTRY_NAME)
|
||||
.map_err(|e| format!("Dungeon archive is missing {XML_ENTRY_NAME}: {e}"))?;
|
||||
let mut xml = Vec::new();
|
||||
xml_file
|
||||
.read_to_end(&mut xml)
|
||||
.map_err(|e| format!("Failed to read dungeon XML: {e}"))?;
|
||||
deserialize_dungeon(&xml)
|
||||
}
|
||||
|
||||
// Saves dungeon state to a compressed XML archive.
|
||||
pub fn save_dungeon(
|
||||
settings: &UiSettings,
|
||||
layouts: Vec<DungeonLayout>,
|
||||
@@ -27,8 +89,8 @@ pub fn save_dungeon(
|
||||
) -> Result<PathBuf, String> {
|
||||
let dialog = FileDialog::new()
|
||||
.set_title("Save Dungeon State")
|
||||
.add_filter("Dungeon File", &["dungeon"])
|
||||
.set_file_name("my_dungeon.dungeon");
|
||||
.add_filter("Dungeon File", &["dun"])
|
||||
.set_file_name("my_dungeon.dun");
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let path = std::thread::spawn(move || dialog.save_file())
|
||||
@@ -37,7 +99,10 @@ pub fn save_dungeon(
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let path = dialog.save_file();
|
||||
|
||||
let path = path.ok_or_else(|| "Save canceled".to_string())?;
|
||||
let mut path = path.ok_or_else(|| "Save canceled".to_string())?;
|
||||
if path.extension().is_none() {
|
||||
path.set_extension("dun");
|
||||
}
|
||||
|
||||
let save_data = DungeonSave {
|
||||
settings: settings.clone(),
|
||||
@@ -45,19 +110,17 @@ pub fn save_dungeon(
|
||||
svgs,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string_pretty(&save_data)
|
||||
.map_err(|e| format!("Failed to serialize dungeon: {e}"))?;
|
||||
|
||||
fs::write(&path, json).map_err(|e| format!("Failed to write file: {e}"))?;
|
||||
let archive = create_archive(&save_data)?;
|
||||
fs::write(&path, archive).map_err(|e| format!("Failed to write file: {e}"))?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
// Loads dungeon state from a user-selected JSON file.
|
||||
// Loads dungeon state from a compressed XML archive.
|
||||
pub fn load_dungeon() -> Result<DungeonSave, String> {
|
||||
let dialog = FileDialog::new()
|
||||
.set_title("Load Dungeon State")
|
||||
.add_filter("Dungeon File", &["dungeon"]);
|
||||
.add_filter("Dungeon File", &["dun"]);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let path = std::thread::spawn(move || dialog.pick_file())
|
||||
@@ -68,10 +131,55 @@ pub fn load_dungeon() -> Result<DungeonSave, String> {
|
||||
|
||||
let path = path.ok_or_else(|| "Load canceled".to_string())?;
|
||||
|
||||
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {e}"))?;
|
||||
let content = fs::read(path).map_err(|e| format!("Failed to read file: {e}"))?;
|
||||
read_archive(&content)
|
||||
}
|
||||
|
||||
let save_data: DungeonSave =
|
||||
serde_json::from_str(&content).map_err(|e| format!("Failed to parse dungeon file: {e}"))?;
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Ok(save_data)
|
||||
#[test]
|
||||
fn archive_round_trip_preserves_saved_data() {
|
||||
let save = DungeonSave {
|
||||
settings: UiSettings::default(),
|
||||
layouts: vec![DungeonLayout {
|
||||
name: "Level 1".to_string(),
|
||||
rooms: vec![crate::layout::types::Room {
|
||||
x: 2,
|
||||
y: 3,
|
||||
width: 5,
|
||||
height: 4,
|
||||
}],
|
||||
text_labels: vec![crate::layout::types::TextLabel {
|
||||
cell: (4, 5),
|
||||
text: "Keep & escape".to_string(),
|
||||
font_size: 18,
|
||||
}],
|
||||
..DungeonLayout::default()
|
||||
}],
|
||||
svgs: vec!["<svg><text>test &</text></svg>".to_string()],
|
||||
};
|
||||
|
||||
let archive = create_archive(&save).expect("archive should serialize");
|
||||
let restored = read_archive(&archive).expect("archive should deserialize");
|
||||
|
||||
assert_eq!(restored.settings, save.settings);
|
||||
assert_eq!(restored.layouts, save.layouts);
|
||||
assert_eq!(restored.svgs, save.svgs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_uses_deflate_compression() {
|
||||
let save = DungeonSave {
|
||||
settings: UiSettings::default(),
|
||||
layouts: Vec::new(),
|
||||
svgs: vec!["<svg>".repeat(100)],
|
||||
};
|
||||
let archive = create_archive(&save).expect("archive should serialize");
|
||||
let mut zip = ZipArchive::new(Cursor::new(archive)).expect("archive should be valid");
|
||||
let entry = zip.by_name(XML_ENTRY_NAME).expect("XML entry should exist");
|
||||
|
||||
assert_eq!(entry.compression(), CompressionMethod::Deflated);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,7 @@ pub struct UiSettings {
|
||||
pub export_height: u32,
|
||||
pub allow_export_aspect_change: bool,
|
||||
pub export_show_grid: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_export_path: Option<String>,
|
||||
pub add_tool: AddTool,
|
||||
pub add_text_value: String,
|
||||
@@ -156,6 +157,7 @@ pub struct UiSettings {
|
||||
pub min_levels: usize,
|
||||
pub max_levels: usize,
|
||||
pub active_level_index: usize,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub overlay_level_index: Option<usize>,
|
||||
pub export_level_index: usize,
|
||||
pub trap_frequency_percent: usize,
|
||||
|
||||
Reference in New Issue
Block a user