//! Custom Bevy asset sources for the card-theme system. //! //! Two sources are wired up here: //! //! - **`embedded://`** — the bundled default theme. The default theme //! manifest (and, in later phases, every default-theme SVG) is //! compiled into the binary via `include_bytes!` and inserted into //! Bevy's [`EmbeddedAssetRegistry`] under a stable, pretty path. //! The default theme manifest is reachable as //! `embedded://solitaire_engine/assets/themes/default/theme.ron`. //! //! - **`themes://`** — user-supplied themes living in //! [`crate::assets::user_dir::user_theme_dir`]. Reads delegate to //! `FileAssetReader` rooted at that absolute path. If the directory //! doesn't exist yet (the common case on first run), the source //! still registers cleanly — individual reads simply return //! `NotFound` until the player drops a theme there, which is the //! correct behaviour for an empty user-themes directory. //! //! # Why two registration paths? //! //! Bevy treats the two sources differently: //! //! - **`themes://`** is a *new* source the engine doesn't know about. //! It must be registered with [`App::register_asset_source`] *before* //! `AssetPlugin` runs, because that plugin freezes the source list //! when it builds the `AssetServer`. //! //! - **`embedded://`** is already registered by `AssetPlugin` itself //! (via `EmbeddedAssetRegistry::register_source`). What we have to //! do is *populate* the registry with our default-theme files — //! exactly what Bevy's own `embedded_asset!` macro does. That //! population must happen *after* `AssetPlugin` runs, because //! `AssetPlugin::build` overwrites the `EmbeddedAssetRegistry` //! resource with a fresh empty one as part of its own setup. //! //! These two timing constraints can't be satisfied by a single //! `Plugin::build` call, so the public API splits the work into: //! //! 1. [`register_theme_asset_sources`] — call *before* `DefaultPlugins`, //! typically immediately after `App::new()`. Registers `themes://`. //! 2. [`AssetSourcesPlugin`] — add *after* `DefaultPlugins`. Populates //! the embedded default-theme files into Bevy's already-built //! `EmbeddedAssetRegistry`. //! //! Both must run for the card-theme system to function. The doc //! comments on each call out the pairing so a future reader doesn't //! accidentally drop one half. use bevy::asset::io::embedded::EmbeddedAssetRegistry; use bevy::asset::io::file::FileAssetReader; use bevy::asset::io::AssetSourceBuilder; use bevy::asset::AssetApp; use bevy::prelude::*; use crate::assets::user_dir::user_theme_dir; /// `AssetSourceId` of the user-themes asset source. Use it as /// `themes:///theme.ron` from any code that wants to load /// from the user-themes directory. pub const USER_THEMES: &str = "themes"; /// Stable embedded asset URL of the bundled Dark theme manifest. /// /// Code that wants to load the embedded Dark theme — including /// `ActiveTheme` initialisation — should use exactly this constant /// rather than re-typing the URL inline. pub const DARK_THEME_MANIFEST_URL: &str = "embedded://solitaire_engine/assets/themes/dark/theme.ron"; /// Path the embedded Dark-theme manifest registers under, relative /// to the `embedded://` source root. Kept in lockstep with /// [`DARK_THEME_MANIFEST_URL`] by the unit test /// `dark_theme_url_constant_matches_embedded_path`. const DARK_THEME_MANIFEST_PATH: &str = "solitaire_engine/assets/themes/dark/theme.ron"; /// Bytes of the bundled Dark theme manifest, embedded at compile time. const DARK_THEME_MANIFEST_BYTES: &[u8] = include_bytes!("../../assets/themes/dark/theme.ron"); /// Generates a `(stable_path, bytes)` entry for one Dark-theme SVG. macro_rules! embed_dark_svg { ($name:literal) => { ( concat!("solitaire_engine/assets/themes/dark/", $name), include_bytes!(concat!("../../assets/themes/dark/", $name)) as &[u8], ) }; } /// Every Dark-theme SVG file bundled into the binary. const DARK_THEME_SVGS: &[(&str, &[u8])] = &[ embed_dark_svg!("back.svg"), embed_dark_svg!("clubs_ace.svg"), embed_dark_svg!("clubs_2.svg"), embed_dark_svg!("clubs_3.svg"), embed_dark_svg!("clubs_4.svg"), embed_dark_svg!("clubs_5.svg"), embed_dark_svg!("clubs_6.svg"), embed_dark_svg!("clubs_7.svg"), embed_dark_svg!("clubs_8.svg"), embed_dark_svg!("clubs_9.svg"), embed_dark_svg!("clubs_10.svg"), embed_dark_svg!("clubs_jack.svg"), embed_dark_svg!("clubs_queen.svg"), embed_dark_svg!("clubs_king.svg"), embed_dark_svg!("diamonds_ace.svg"), embed_dark_svg!("diamonds_2.svg"), embed_dark_svg!("diamonds_3.svg"), embed_dark_svg!("diamonds_4.svg"), embed_dark_svg!("diamonds_5.svg"), embed_dark_svg!("diamonds_6.svg"), embed_dark_svg!("diamonds_7.svg"), embed_dark_svg!("diamonds_8.svg"), embed_dark_svg!("diamonds_9.svg"), embed_dark_svg!("diamonds_10.svg"), embed_dark_svg!("diamonds_jack.svg"), embed_dark_svg!("diamonds_queen.svg"), embed_dark_svg!("diamonds_king.svg"), embed_dark_svg!("hearts_ace.svg"), embed_dark_svg!("hearts_2.svg"), embed_dark_svg!("hearts_3.svg"), embed_dark_svg!("hearts_4.svg"), embed_dark_svg!("hearts_5.svg"), embed_dark_svg!("hearts_6.svg"), embed_dark_svg!("hearts_7.svg"), embed_dark_svg!("hearts_8.svg"), embed_dark_svg!("hearts_9.svg"), embed_dark_svg!("hearts_10.svg"), embed_dark_svg!("hearts_jack.svg"), embed_dark_svg!("hearts_queen.svg"), embed_dark_svg!("hearts_king.svg"), embed_dark_svg!("spades_ace.svg"), embed_dark_svg!("spades_2.svg"), embed_dark_svg!("spades_3.svg"), embed_dark_svg!("spades_4.svg"), embed_dark_svg!("spades_5.svg"), embed_dark_svg!("spades_6.svg"), embed_dark_svg!("spades_7.svg"), embed_dark_svg!("spades_8.svg"), embed_dark_svg!("spades_9.svg"), embed_dark_svg!("spades_10.svg"), embed_dark_svg!("spades_jack.svg"), embed_dark_svg!("spades_queen.svg"), embed_dark_svg!("spades_king.svg"), ]; /// Registers asset sources that must be in place *before* /// `AssetPlugin` is built. /// /// In practice that means just `themes://`: `embedded://` is owned by /// Bevy itself and is always registered by `AssetPlugin`. To finish /// wiring up the embedded default theme, also add /// [`AssetSourcesPlugin`] *after* `DefaultPlugins`. /// /// Returns the `&mut App` so the call can be chained from the binary /// entry point. pub fn register_theme_asset_sources(app: &mut App) -> &mut App { let root = user_theme_dir(); app.register_asset_source( USER_THEMES, AssetSourceBuilder::new(move || Box::new(FileAssetReader::new(root.clone()))), ); app } /// Bevy `Plugin` that pushes the bundled default theme files into /// [`EmbeddedAssetRegistry`]. /// /// Add this *after* `DefaultPlugins`. It pairs with /// [`register_theme_asset_sources`] (which has to run *before* /// `DefaultPlugins`); both are required for the card-theme system to /// function. See the module-level doc comment for why the work has to /// be split across two calls. /// /// To bundle additional default-theme files (the SVG art slated to /// land in a later phase), edit [`populate_embedded_default_theme`]. #[derive(Debug, Default)] pub struct AssetSourcesPlugin; impl Plugin for AssetSourcesPlugin { fn build(&self, app: &mut App) { populate_embedded_dark_theme(app); } } /// Returns the embedded SVG bytes for a single Dark-theme file /// (e.g. `"back.svg"` or `"spades_ace.svg"`), or `None` when the /// filename is not bundled. /// /// The thumbnail generator uses this to rasterise preview-sized art /// without going through Bevy's async asset graph. pub fn dark_theme_svg_bytes(filename: &str) -> Option<&'static [u8]> { let suffix = format!("/{filename}"); DARK_THEME_SVGS .iter() .find(|(path, _)| path.ends_with(&suffix)) .map(|(_, bytes)| *bytes) } /// Returns the manifest URL for a bundled (non-user) theme by id, or /// `None` if `id` belongs to a user theme that lives under `themes://`. /// /// Callers that need to resolve a theme URL without access to /// [`crate::theme::ThemeRegistry`] (e.g. Startup systems where registry /// ordering isn't guaranteed) should use this instead of constructing /// the URL manually. pub fn bundled_theme_url(id: &str) -> Option<&'static str> { match id { "dark" => Some(DARK_THEME_MANIFEST_URL), "classic" => Some("themes/classic/theme.ron"), _ => None, } } /// Pushes every bundled Dark-theme file into the /// [`EmbeddedAssetRegistry`] under its stable URL. pub fn populate_embedded_dark_theme(app: &mut App) { let registry = app .world_mut() .get_resource_or_insert_with(EmbeddedAssetRegistry::default); registry.insert_asset( std::path::PathBuf::from(DARK_THEME_MANIFEST_PATH), std::path::Path::new(DARK_THEME_MANIFEST_PATH), DARK_THEME_MANIFEST_BYTES, ); for (path, bytes) in DARK_THEME_SVGS { registry.insert_asset( std::path::PathBuf::from(*path), std::path::Path::new(*path), *bytes, ); } } #[cfg(test)] mod tests { use super::*; /// `register_theme_asset_sources` must register `themes://` /// without panicking, even when `user_theme_dir()` resolves to a /// directory that doesn't exist on disk — Bevy's /// `FileAssetReader` constructs lazily, so a missing root is /// fine. #[test] fn register_theme_asset_sources_inserts_themes_source() { let mut app = App::new(); register_theme_asset_sources(&mut app); let mut sources = app .world_mut() .get_resource_or_init::(); assert!( sources.get_mut(USER_THEMES).is_some(), "themes:// source not registered" ); } #[test] fn populate_embedded_dark_theme_runs_without_asset_plugin() { let mut app = App::new(); populate_embedded_dark_theme(&mut app); assert!(app .world() .get_resource::() .is_some()); } #[test] fn embedded_dark_theme_manifest_validates() { use crate::theme::ThemeManifest; let manifest: ThemeManifest = ron::de::from_bytes(DARK_THEME_MANIFEST_BYTES) .expect("dark manifest must parse as RON"); let faces = manifest .validate() .expect("dark manifest must list all 52 faces"); assert_eq!(faces.len(), 52); } #[test] fn dark_theme_svg_bytes_finds_back_and_ace_of_spades() { assert!( dark_theme_svg_bytes("back.svg").is_some(), "dark theme must bundle a back.svg" ); assert!( dark_theme_svg_bytes("spades_ace.svg").is_some(), "dark theme must bundle a spades_ace.svg" ); } #[test] fn dark_theme_svg_bytes_returns_none_for_unknown_file() { assert!(dark_theme_svg_bytes("nope.svg").is_none()); assert!(dark_theme_svg_bytes("").is_none()); } #[test] fn dark_theme_url_constant_matches_embedded_path() { let url_tail = DARK_THEME_MANIFEST_URL .strip_prefix("embedded://") .expect("dark theme URL must use embedded:// scheme"); assert_eq!(url_tail, DARK_THEME_MANIFEST_PATH); } }