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"
|
eframe = "0.31"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
quick-xml = { version = "0.37", features = ["serialize"] }
|
||||||
|
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
rfd = "0.15"
|
rfd = "0.15"
|
||||||
tiny-skia = "0.11"
|
tiny-skia = "0.11"
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ pub struct WindowSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(default)]
|
||||||
pub struct DungeonLayout {
|
pub struct DungeonLayout {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub rooms: Vec<Room>,
|
pub rooms: Vec<Room>,
|
||||||
|
|||||||
+123
-15
@@ -1,13 +1,16 @@
|
|||||||
/*
|
/*
|
||||||
* Persistence logic for dungeon state.
|
* Persistence logic for dungeon state.
|
||||||
* Handles saving and loading the complete 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 rfd::FileDialog;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::io::{Cursor, Read, Write};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use zip::write::SimpleFileOptions;
|
||||||
|
use zip::{CompressionMethod, ZipArchive, ZipWriter};
|
||||||
|
|
||||||
use crate::layout::DungeonLayout;
|
use crate::layout::DungeonLayout;
|
||||||
use crate::ui::UiSettings;
|
use crate::ui::UiSettings;
|
||||||
@@ -19,7 +22,66 @@ pub struct DungeonSave {
|
|||||||
pub svgs: Vec<String>,
|
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(
|
pub fn save_dungeon(
|
||||||
settings: &UiSettings,
|
settings: &UiSettings,
|
||||||
layouts: Vec<DungeonLayout>,
|
layouts: Vec<DungeonLayout>,
|
||||||
@@ -27,8 +89,8 @@ pub fn save_dungeon(
|
|||||||
) -> Result<PathBuf, String> {
|
) -> Result<PathBuf, String> {
|
||||||
let dialog = FileDialog::new()
|
let dialog = FileDialog::new()
|
||||||
.set_title("Save Dungeon State")
|
.set_title("Save Dungeon State")
|
||||||
.add_filter("Dungeon File", &["dungeon"])
|
.add_filter("Dungeon File", &["dun"])
|
||||||
.set_file_name("my_dungeon.dungeon");
|
.set_file_name("my_dungeon.dun");
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
let path = std::thread::spawn(move || dialog.save_file())
|
let path = std::thread::spawn(move || dialog.save_file())
|
||||||
@@ -37,7 +99,10 @@ pub fn save_dungeon(
|
|||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
let path = dialog.save_file();
|
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 {
|
let save_data = DungeonSave {
|
||||||
settings: settings.clone(),
|
settings: settings.clone(),
|
||||||
@@ -45,19 +110,17 @@ pub fn save_dungeon(
|
|||||||
svgs,
|
svgs,
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string_pretty(&save_data)
|
let archive = create_archive(&save_data)?;
|
||||||
.map_err(|e| format!("Failed to serialize dungeon: {e}"))?;
|
fs::write(&path, archive).map_err(|e| format!("Failed to write file: {e}"))?;
|
||||||
|
|
||||||
fs::write(&path, json).map_err(|e| format!("Failed to write file: {e}"))?;
|
|
||||||
|
|
||||||
Ok(path)
|
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> {
|
pub fn load_dungeon() -> Result<DungeonSave, String> {
|
||||||
let dialog = FileDialog::new()
|
let dialog = FileDialog::new()
|
||||||
.set_title("Load Dungeon State")
|
.set_title("Load Dungeon State")
|
||||||
.add_filter("Dungeon File", &["dungeon"]);
|
.add_filter("Dungeon File", &["dun"]);
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
let path = std::thread::spawn(move || dialog.pick_file())
|
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 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 =
|
#[cfg(test)]
|
||||||
serde_json::from_str(&content).map_err(|e| format!("Failed to parse dungeon file: {e}"))?;
|
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 export_height: u32,
|
||||||
pub allow_export_aspect_change: bool,
|
pub allow_export_aspect_change: bool,
|
||||||
pub export_show_grid: bool,
|
pub export_show_grid: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub last_export_path: Option<String>,
|
pub last_export_path: Option<String>,
|
||||||
pub add_tool: AddTool,
|
pub add_tool: AddTool,
|
||||||
pub add_text_value: String,
|
pub add_text_value: String,
|
||||||
@@ -156,6 +157,7 @@ pub struct UiSettings {
|
|||||||
pub min_levels: usize,
|
pub min_levels: usize,
|
||||||
pub max_levels: usize,
|
pub max_levels: usize,
|
||||||
pub active_level_index: usize,
|
pub active_level_index: usize,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub overlay_level_index: Option<usize>,
|
pub overlay_level_index: Option<usize>,
|
||||||
pub export_level_index: usize,
|
pub export_level_index: usize,
|
||||||
pub trap_frequency_percent: usize,
|
pub trap_frequency_percent: usize,
|
||||||
|
|||||||
Reference in New Issue
Block a user