From f40a3ff3bb557dd2e337ebb06a6e4b334a755224 Mon Sep 17 00:00:00 2001 From: Zicklag Date: Fri, 29 Sep 2023 11:25:03 -0500 Subject: [PATCH] feat: implement initial persisted storage API. There is a lot that should be improved on, but it is usable for now. --- Cargo.lock | 59 ++++++- demos/features/assets/locales/en-US/menu.ftl | 2 + demos/features/src/main.rs | 28 +++- .../bones_bevy_renderer/Cargo.toml | 6 + .../bones_bevy_renderer/src/lib.rs | 147 +++++++++++++++++- framework_crates/bones_framework/Cargo.toml | 8 +- framework_crates/bones_framework/src/lib.rs | 5 +- .../bones_framework/src/storage.rs | 121 ++++++++++++++ framework_crates/bones_lib/Cargo.toml | 4 +- .../bones_schema/macros/src/lib.rs | 6 +- framework_crates/bones_schema/src/registry.rs | 14 +- framework_crates/bones_schema/src/ser_de.rs | 2 +- 12 files changed, 378 insertions(+), 24 deletions(-) create mode 100644 framework_crates/bones_framework/src/storage.rs diff --git a/Cargo.lock b/Cargo.lock index 5a2073cee5..bb54447374 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1073,11 +1073,15 @@ dependencies = [ name = "bones_bevy_renderer" version = "0.3.0" dependencies = [ + "anyhow", "bevy", "bevy_egui", "bevy_prototype_lyon", "bones_framework", + "directories", "glam", + "serde", + "serde_yaml", ] [[package]] @@ -1582,6 +1586,27 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -1784,7 +1809,7 @@ checksum = "d4029edd3e734da6fe05b6cd7bd2960760a616bd2ddd0d59a0124746d6272af0" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.3.5", "windows-sys 0.48.0", ] @@ -2962,13 +2987,19 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "orbclient" version = "0.3.46" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8378ac0dfbd4e7895f2d2c1f1345cab3836910baf3a300b000d04250f0c8428f" dependencies = [ - "redox_syscall", + "redox_syscall 0.3.5", ] [[package]] @@ -3010,7 +3041,7 @@ checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.3.5", "smallvec", "windows-targets 0.48.5", ] @@ -3393,6 +3424,15 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0d463f2884048e7153449a55166f91028d5b0ea53c79377099ce4e8cf0cf9bb" +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "redox_syscall" version = "0.3.5" @@ -3402,6 +3442,17 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "redox_users" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" +dependencies = [ + "getrandom 0.2.10", + "redox_syscall 0.2.16", + "thiserror", +] + [[package]] name = "regex" version = "1.9.5" @@ -4750,7 +4801,7 @@ dependencies = [ "orbclient", "percent-encoding", "raw-window-handle", - "redox_syscall", + "redox_syscall 0.3.5", "wasm-bindgen", "wayland-scanner", "web-sys", diff --git a/demos/features/assets/locales/en-US/menu.ftl b/demos/features/assets/locales/en-US/menu.ftl index cc1cf50d2e..e2d7c487e2 100644 --- a/demos/features/assets/locales/en-US/menu.ftl +++ b/demos/features/assets/locales/en-US/menu.ftl @@ -4,3 +4,5 @@ sprite-demo = Sprite Demo atlas-demo = Atlas Demo tilemap-demo = Tilemap Demo path2d-demo = Path2D Demo +save = Save +persisted-text-box-content = Persisted text box content diff --git a/demos/features/src/main.rs b/demos/features/src/main.rs index 94c82318d2..1177a32177 100644 --- a/demos/features/src/main.rs +++ b/demos/features/src/main.rs @@ -77,11 +77,13 @@ struct TileMeta { idx: u32, } +#[derive(HasSchema, Default, Clone)] +#[repr(C)] +struct PersistedTextData(String); + fn main() { - assert!(Color::schema() - .type_data - .get::() - .is_some()); + // Register persistent data's schema so that it can be loaded by the storage loader. + PersistedTextData::schema(); // Create a bones bevy renderer from our bones game BonesBevyRenderer::new(create_game()) @@ -141,6 +143,7 @@ fn menu_system( ctx: Egui, mut sessions: ResMut, mut session_options: ResMut, + mut storage: ResMut, // Get the localization field from our `GameMeta` localization: Localization, ) { @@ -208,6 +211,21 @@ fn menu_system( ui.add_space(20.0); + ui.vertical_centered(|ui| { + ui.set_width(300.0); + { + let data = storage.get_or_insert_default_mut::(); + egui::TextEdit::singleline(&mut data.0) + .hint_text(localization.get("persisted-text-box-content")) + .show(ui); + } + if ui.button(localization.get("save")).clicked() { + storage.save() + } + }); + + ui.add_space(10.0); + // We can use the `widget()` method on the `Egui` to conveniently run bones // systems that can modify the `egui::Ui` and return an `egui::Response`. // @@ -447,5 +465,5 @@ fn demo_widget( ui.label("Demo Widget"); // When using a bones image in egui, we have to get it's corresponding egui texture // from the egui textures resource. - ui.image(egui_textures.get(meta.menu_image), [100., 100.]) + ui.image(egui_textures.get(meta.menu_image), [50., 50.]) } diff --git a/framework_crates/bones_bevy_renderer/Cargo.toml b/framework_crates/bones_bevy_renderer/Cargo.toml index a5aa9e9d4f..2f92dcd9ef 100644 --- a/framework_crates/bones_bevy_renderer/Cargo.toml +++ b/framework_crates/bones_bevy_renderer/Cargo.toml @@ -20,8 +20,14 @@ bones_framework = { version = "0.3", path = "../bones_framework" } bevy_egui = "0.21" glam = { version = "0.24", features = ["serde"] } bevy_prototype_lyon = "0.9" +serde_yaml = "0.9" +serde = "1.0.188" +anyhow = "1.0" [dependencies.bevy] default-features = false features = ["bevy_render", "bevy_core_pipeline", "bevy_sprite", "x11", "bevy_gilrs"] version = "0.11" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +directories = "5.0" diff --git a/framework_crates/bones_bevy_renderer/src/lib.rs b/framework_crates/bones_bevy_renderer/src/lib.rs index a3cd39157d..56faddb0a3 100644 --- a/framework_crates/bones_bevy_renderer/src/lib.rs +++ b/framework_crates/bones_bevy_renderer/src/lib.rs @@ -24,8 +24,9 @@ use bevy_egui::EguiContext; use glam::*; use bevy_prototype_lyon::prelude as lyon; -use bones_framework::prelude as bones; +use bones_framework::prelude::{self as bones, SchemaBox}; use prelude::convert::{IntoBevy, IntoBones}; +use serde::{de::Visitor, Deserialize, Serialize}; /// The prelude pub mod prelude { @@ -47,6 +48,11 @@ pub struct BonesBevyRenderer { pub game: bones::Game, /// The version of the game, used for the asset loader. pub game_version: bones::Version, + /// The (qualifier, organization, application) that will be used to pick a persistent storage + /// location for the game. + /// + /// For example: `("org", "fishfolk", "jumpy")` + pub app_namespace: (String, String, String), /// The path to load assets from. pub asset_dir: PathBuf, /// The path to load asset packs from. @@ -178,6 +184,7 @@ impl BonesBevyRenderer { pixel_art: true, game, game_version: bones::Version::new(0, 1, 0), + app_namespace: ("org".into(), "fishfolk".into(), "bones_demo_game".into()), asset_dir: PathBuf::from("assets"), packs_dir: PathBuf::from("packs"), } @@ -236,6 +243,18 @@ impl BonesBevyRenderer { ); } + // Configure and load the persitent storage + #[cfg(not(target_arch = "wasm32"))] + { + let mut storage = bones::Storage::with_backend(Box::new(StorageBackend::new( + &self.app_namespace.0, + &self.app_namespace.1, + &self.app_namespace.2, + ))); + storage.load(); + self.game.insert_shared_resource(storage); + } + self.game.insert_shared_resource(bones_egui_textures); app.insert_resource(bones_image_ids); @@ -280,6 +299,132 @@ impl BonesBevyRenderer { } } +#[cfg(not(target_arch = "wasm32"))] +struct StorageBackend { + storage_path: PathBuf, +} +#[cfg(not(target_arch = "wasm32"))] +impl StorageBackend { + fn new(qualifier: &str, organization: &str, application: &str) -> Self { + let project_dirs = directories::ProjectDirs::from(qualifier, organization, application) + .expect("Identify system data dir path"); + Self { + storage_path: project_dirs.data_dir().join("storage.yml"), + } + } +} +#[cfg(not(target_arch = "wasm32"))] +impl bones::StorageApi for StorageBackend { + fn save(&mut self, data: Vec) { + let file = std::fs::OpenOptions::new() + .write(true) + .truncate(true) + .create(true) + .open(&self.storage_path) + .expect("Failed to open storage file"); + let mut serializer = serde_yaml::Serializer::new(file); + LoadedStorage(data) + .serialize(&mut serializer) + .expect("Failed to serialize to storage file."); + } + + fn load(&mut self) -> Vec { + use anyhow::Context; + if self.storage_path.exists() { + let result: anyhow::Result = (|| { + let file = std::fs::OpenOptions::new() + .read(true) + .open(&self.storage_path) + .context("Failed to open storage file")?; + let loaded: LoadedStorage = + serde_yaml::from_reader(file).context("Failed to deserialize storage file")?; + + anyhow::Result::Ok(loaded) + })(); + match result { + Ok(loaded) => loaded.0, + Err(e) => { + error!( + "Error deserializing storage file, ignoring file, \ + data will be overwritten when saved: {e:?}" + ); + default() + } + } + } else { + std::fs::create_dir_all(self.storage_path.parent().unwrap()).unwrap(); + default() + } + } +} +struct LoadedStorage(Vec); +impl Serialize for LoadedStorage { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let data: HashMap = self + .0 + .iter() + .map(|x| (x.schema().full_name.to_string(), x.as_ref())) + .collect(); + + use serde::ser::SerializeMap; + let mut map = serializer.serialize_map(Some(data.len()))?; + + for (key, value) in data { + map.serialize_key(&key)?; + map.serialize_value(&bones::SchemaSerializer(value))?; + } + + map.end() + } +} +impl<'de> Deserialize<'de> for LoadedStorage { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_map(LoadedStorageVisitor).map(Self) + } +} +struct LoadedStorageVisitor; +impl<'de> Visitor<'de> for LoadedStorageVisitor { + type Value = Vec; + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(formatter, "Mapping of string type names to type data.") + } + fn visit_map(self, mut map: A) -> Result + where + A: serde::de::MapAccess<'de>, + { + let mut data = Vec::new(); + while let Some(type_name) = map.next_key::()? { + let reg = bones::SCHEMA_REGISTRY.borrow(); + let Some(schema) = reg + .schemas + .iter() + .map(|(_id, schema)| schema) + .find(|schema| schema.full_name.as_ref() == type_name) + else { + error!( + "\n\nCannot find schema registration for `{}` while loading persisted \ + storage. This means you that you need to call \ + `{}::schema()` to register your persisted storage type before \ + creating the `BonesBevyRenderer` or that there is data from an old \ + version of the app inside of the persistent storage file.\n\n", + type_name, type_name, + ); + continue; + }; + + data.push(map.next_value_seed(bones::SchemaDeserializer(schema))?); + } + + Ok(data) + } +} + /// Startup system to load egui fonts and textures. fn setup_egui(world: &mut World) { world.resource_scope(|world: &mut World, mut bones_data: Mut| { diff --git a/framework_crates/bones_framework/Cargo.toml b/framework_crates/bones_framework/Cargo.toml index 1b1339ed21..d6af2c4766 100644 --- a/framework_crates/bones_framework/Cargo.toml +++ b/framework_crates/bones_framework/Cargo.toml @@ -46,8 +46,8 @@ document-features = ["dep:document-features"] [dependencies] # Bones -bones_lib = { version = "0.3", path = "../bones_lib", features = ["glam"] } -bones_asset = { version = "0.3", path = "../bones_asset" } +bones_lib = { version = "0.3", path = "../bones_lib", features = ["glam"] } +bones_asset = { version = "0.3", path = "../bones_asset" } bones_schema = { version = "0.3", path = "../bones_schema", features = ["humantime"] } # Other @@ -69,8 +69,8 @@ serde = { version = "1.0", features = ["derive"] } image = { version = "0.24", default-features = false } # Gui -egui = { version = "0.22", optional = true } -ttf-parser = { version = "0.19.1", default-features = false, optional = true } +egui = { version = "0.22", optional = true } +ttf-parser = { version = "0.19.1", default-features = false, optional = true } # Localization fluent-bundle = { version = "0.15", optional = true } diff --git a/framework_crates/bones_framework/src/lib.rs b/framework_crates/bones_framework/src/lib.rs index 269779fdeb..3935d3b458 100644 --- a/framework_crates/bones_framework/src/lib.rs +++ b/framework_crates/bones_framework/src/lib.rs @@ -20,8 +20,8 @@ pub use glam; /// The prelude. pub mod prelude { pub use crate::{ - animation::*, input::prelude::*, params::*, render::prelude::*, time::*, AssetServerExt, - DefaultSessionPlugin, + animation::*, input::prelude::*, params::*, render::prelude::*, storage::*, time::*, + AssetServerExt, DefaultSessionPlugin, }; pub use bones_asset::anyhow::Context; pub use bones_asset::prelude::*; @@ -36,6 +36,7 @@ pub mod animation; pub mod input; pub mod params; pub mod render; +pub mod storage; pub mod time; #[cfg(feature = "localization")] diff --git a/framework_crates/bones_framework/src/storage.rs b/framework_crates/bones_framework/src/storage.rs new file mode 100644 index 0000000000..752bf367e7 --- /dev/null +++ b/framework_crates/bones_framework/src/storage.rs @@ -0,0 +1,121 @@ +//! Persistant storage API. + +use crate::prelude::*; + +/// Persitent storage resource. +/// +/// > **Note:** data is not actually saved until you call [`Storage::save`] +/// +/// > **🚧 Warning:** The storage interface uses the types [`SchemaData::full_name`] as a storage +/// > key, so you must ensure that all types that are stored have different full names or it may +/// > behave unexpectedly. +#[derive(HasSchema)] +#[schema(no_clone)] +pub struct Storage { + /// The backend storage API. + pub backend: Box, + /// The cache of objects that have been read + pub cache: HashMap, +} +#[allow(clippy::derivable_impls)] // false positive +impl Default for Storage { + fn default() -> Self { + Self { + backend: Box::::default(), + cache: Default::default(), + } + } +} + +impl Storage { + /// Create a new storage resource with the given backend storage API. + pub fn with_backend(backend: Box) -> Self { + Self { + backend, + cache: default(), + } + } + + /// Load the data from the storage backend. + pub fn load(&mut self) { + self.cache = self + .backend + .load() + .into_iter() + .map(|x| (x.schema().id(), x)) + .collect(); + } + + /// Save the data to the storage backend. + pub fn save(&mut self) { + self.backend.save(self.cache.values().cloned().collect()) + } + + /// Insert the data into storage cache. + pub fn insert(&mut self, data: T) { + let b = SchemaBox::new(data); + self.cache.insert(b.schema().id(), b); + } + + /// Get data from the storage cache. + pub fn get(&self) -> Option<&T> { + self.cache.get(&T::schema().id()).map(|x| x.cast_ref()) + } + + /// Get data mutably from the storage cache. + pub fn get_mut(&mut self) -> Option<&mut T> { + self.cache.get_mut(&T::schema().id()).map(|x| x.cast_mut()) + } + + /// Get data from the storage cache or insert it's default value + pub fn get_or_insert_default(&mut self) -> &T { + self.cache + .entry(T::schema().id()) + .or_insert_with(|| SchemaBox::default(T::schema())) + .cast_ref() + } + + /// Get data mutably from the storage cache or insert it's default value + pub fn get_or_insert_default_mut(&mut self) -> &mut T { + self.cache + .entry(T::schema().id()) + .or_insert_with(|| SchemaBox::default(T::schema())) + .cast_mut() + } + + /// Remove data for a type from the storage. + pub fn remove(&mut self) { + self.cache.remove(&T::schema().id()); + } +} + +/// Trait implemented by storage backends. +/// +/// TODO: Implement asynchronous storage API. +/// Currently all storage access is synchronous, which is not good for the user experience when a +/// write to storage could delay the rendering of the next frame. We should come up with a +/// nice-to-use API for asynchronously loading and storing data. +pub trait StorageApi: Sync + Send { + /// Write the entire collection of objects to storage, replacing the previous storage data. If + /// set, the `handler` will be called when the data has been written. + fn save(&mut self, data: Vec); + /// Read the entire collection of objects from storage with `handler` being called with the data + /// once the load is complete. + fn load(&mut self) -> Vec; +} + +/// Non-persistent [`Storage`] backend. +#[derive(Default)] +pub struct MemoryBackend { + data: Vec, +} + +impl StorageApi for MemoryBackend { + fn save(&mut self, data: Vec) { + self.data = data; + } + + fn load(&mut self) -> Vec { + self.data.clone() + } +} diff --git a/framework_crates/bones_lib/Cargo.toml b/framework_crates/bones_lib/Cargo.toml index fa016d0aef..d52ae354dc 100644 --- a/framework_crates/bones_lib/Cargo.toml +++ b/framework_crates/bones_lib/Cargo.toml @@ -15,5 +15,5 @@ default = [] glam = ["bones_ecs/glam"] [dependencies] -bones_ecs = { version = "0.3", path = "../bones_ecs" } -instant = "0.1.12" +bones_ecs = { version = "0.3", path = "../bones_ecs" } +instant = "0.1.12" diff --git a/framework_crates/bones_schema/macros/src/lib.rs b/framework_crates/bones_schema/macros/src/lib.rs index 9ab6953726..06e748d525 100644 --- a/framework_crates/bones_schema/macros/src/lib.rs +++ b/framework_crates/bones_schema/macros/src/lib.rs @@ -195,7 +195,7 @@ pub fn derive_has_schema(input: TokenStream) -> TokenStream { let layout = ::std::alloc::Layout::new::<#ty>(); #schema_mod::registry::SCHEMA_REGISTRY.register(#schema_mod::SchemaData { name: stringify!(#ty).into(), - full_name: concat!(module_path!(), stringify!(#ty)).into(), + full_name: concat!(module_path!(), "::", stringify!(#ty)).into(), kind: #schema_mod::SchemaKind::Primitive(#schema_mod::Primitive::Opaque { size: layout.size(), align: layout.align(), @@ -263,7 +263,7 @@ pub fn derive_has_schema(input: TokenStream) -> TokenStream { S.get_or_init(|| { #schema_mod::registry::SCHEMA_REGISTRY.register(#schema_mod::SchemaData { name: #variant_schema_name.into(), - full_name: concat!(module_path!(), #variant_schema_name).into(), + full_name: concat!(module_path!(), "::", #variant_schema_name).into(), type_id: None, kind: #schema_mod::SchemaKind::Struct(#schema_mod::StructSchemaInfo { fields: vec![ @@ -302,7 +302,7 @@ pub fn derive_has_schema(input: TokenStream) -> TokenStream { let schema_register = quote! { #schema_mod::registry::SCHEMA_REGISTRY.register(#schema_mod::SchemaData { name: stringify!(#name).into(), - full_name: concat!(module_path!(), stringify!(#name)).into(), + full_name: concat!(module_path!(), "::", stringify!(#name)).into(), type_id: Some(::std::any::TypeId::of::()), kind: #schema_kind, type_data: #type_datas, diff --git a/framework_crates/bones_schema/src/registry.rs b/framework_crates/bones_schema/src/registry.rs index 57c88ac97d..28f2a1a160 100644 --- a/framework_crates/bones_schema/src/registry.rs +++ b/framework_crates/bones_schema/src/registry.rs @@ -107,9 +107,11 @@ pub struct SchemaRegistry { state: OnceLock>, } +/// The internal state o the [`SchemaRegistry`]. #[derive(Default)] -struct RegistryState { - schemas: HashMap, +pub struct RegistryState { + /// The registered schemas. + pub schemas: HashMap, } impl SchemaRegistry { @@ -161,6 +163,14 @@ impl SchemaRegistry { .get(&id) .expect("Reflection bug, schema Id created without associated registration") } + + /// Borrow the registry state for reading. + /// + /// > **Note:** This locks the registry for reading, preventing access by things that may need + /// > to register schemas, so it is best to hold the lock for as short as possible. + pub fn borrow(&self) -> bones_utils::parking_lot::RwLockReadGuard { + self.state.get_or_init(default).read() + } } /// Global [`SchemaRegistry`] used to register [`SchemaData`]s and produce [`Schema`]s. diff --git a/framework_crates/bones_schema/src/ser_de.rs b/framework_crates/bones_schema/src/ser_de.rs index 7b252711a0..2c98365587 100644 --- a/framework_crates/bones_schema/src/ser_de.rs +++ b/framework_crates/bones_schema/src/ser_de.rs @@ -36,7 +36,7 @@ mod serializer_deserializer { match &self.0.schema().kind { SchemaKind::Struct(s) => { - if s.fields.len() == 1 { + if s.fields.len() == 1 && s.fields[0].name.is_none() { // Serialize just the inner field // SOUND: it is safe to cast a struct with one field to it's inner type. SchemaSerializer(unsafe {