diff --git a/solitaire_app/src/main.rs b/solitaire_app/src/main.rs index 45039f5..b697b89 100644 --- a/solitaire_app/src/main.rs +++ b/solitaire_app/src/main.rs @@ -6,12 +6,13 @@ use bevy::prelude::*; use bevy::window::{MonitorSelection, PresentMode, WindowPosition}; use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings}; use solitaire_engine::{ - AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, - CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, - FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, - OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin, - SplashPlugin, StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, UiFocusPlugin, - UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin, + register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, + AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin, + CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin, + HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin, PausePlugin, + ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, + SyncPlugin, TablePlugin, TimeAttackPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, + WeeklyGoalsPlugin, WinSummaryPlugin, }; fn main() { @@ -54,7 +55,17 @@ fn main() { ), }; - App::new() + let mut app = App::new(); + + // The card-theme system's `themes://` asset source must be + // registered *before* `DefaultPlugins` builds `AssetPlugin`, + // because that plugin freezes the asset-source list at build + // time. The matching `AssetSourcesPlugin` (added below) finishes + // the wiring after `DefaultPlugins` by populating the embedded + // default theme into Bevy's `EmbeddedAssetRegistry`. + register_theme_asset_sources(&mut app); + + app .add_plugins( DefaultPlugins .set(WindowPlugin { @@ -91,6 +102,7 @@ fn main() { ..default() }), ) + .add_plugins(AssetSourcesPlugin) .add_plugins(FontPlugin) .add_plugins(GamePlugin) .add_plugins(TablePlugin) diff --git a/solitaire_engine/assets/themes/default/PROVENANCE.md b/solitaire_engine/assets/themes/default/PROVENANCE.md new file mode 100644 index 0000000..c6f71c2 --- /dev/null +++ b/solitaire_engine/assets/themes/default/PROVENANCE.md @@ -0,0 +1,43 @@ +# Default theme — provenance + +This directory is the bundled-default card theme that ships embedded in +the binary via Bevy's `embedded_asset!` macro (see +`solitaire_engine/src/assets/sources.rs`). At runtime its files are +addressable as `embedded://solitaire_engine/assets/themes/default/...`. + +## Current state (Phase 3) + +The `theme.ron` manifest in this directory lists all 52 face slots plus +a back slot, but **the referenced SVG files do not yet exist**. The +manifest is intentionally a stub so that: + +1. `embedded_asset!` has a real file to bundle (the manifest itself). +2. `ThemeManifest::validate` accepts the manifest (it requires all 52 + faces to be listed by name). +3. The `embedded://` asset source can be source-registered and queried + without runtime errors during Phase 3. + +The actual SVG art will be added when the project swaps in the +`hayeah/playing-cards-assets` artwork — see the implementation plan in +`/CARD_PLAN.md`. At that point, every `.svg` filename listed in +`theme.ron`'s `faces` map (and `back.svg`) must be added here, and each +new file needs a corresponding `embedded_asset!(app, ...)` call in +`solitaire_engine/src/assets/sources.rs::register_default_theme`. + +## How to add files to the bundled default theme + +For each new file you drop into this directory: + +1. Drop the file under `solitaire_engine/assets/themes/default/`. +2. Add one line to `register_default_theme` in + `solitaire_engine/src/assets/sources.rs` of the form: + ```rust + embedded_asset!(app, "../../assets/themes/default/"); + ``` + (The path is relative to `sources.rs`, which lives in + `solitaire_engine/src/assets/`.) +3. Update this file with the licence and origin of the new asset. + +## Licence + +To be filled in once real artwork lands. diff --git a/solitaire_engine/assets/themes/default/theme.ron b/solitaire_engine/assets/themes/default/theme.ron new file mode 100644 index 0000000..3587c29 --- /dev/null +++ b/solitaire_engine/assets/themes/default/theme.ron @@ -0,0 +1,77 @@ +// Default card theme manifest — Phase 3 stub. +// +// The 53 SVG paths below are deliberate placeholders so the manifest +// validates against `ThemeManifest::validate` (which requires all 52 +// faces plus a back). The actual SVG art lands in a later phase when +// the swap to hayeah/playing-cards-assets is complete; until then this +// file exists so the `embedded://` asset source has something to +// register, and so source-registration tests have a real RON file to +// parse. +// +// Suit / rank tokens follow `CardKey::manifest_name`: +// suits: clubs, diamonds, hearts, spades +// ranks: ace, 2, 3, 4, 5, 6, 7, 8, 9, 10, jack, queen, king +( + meta: ( + id: "default", + name: "Default", + author: "Solitaire Quest", + version: "0.1.0", + card_aspect: (2, 3), + ), + back: "back.svg", + faces: { + "clubs_ace": "clubs_ace.svg", + "clubs_2": "clubs_2.svg", + "clubs_3": "clubs_3.svg", + "clubs_4": "clubs_4.svg", + "clubs_5": "clubs_5.svg", + "clubs_6": "clubs_6.svg", + "clubs_7": "clubs_7.svg", + "clubs_8": "clubs_8.svg", + "clubs_9": "clubs_9.svg", + "clubs_10": "clubs_10.svg", + "clubs_jack": "clubs_jack.svg", + "clubs_queen": "clubs_queen.svg", + "clubs_king": "clubs_king.svg", + "diamonds_ace": "diamonds_ace.svg", + "diamonds_2": "diamonds_2.svg", + "diamonds_3": "diamonds_3.svg", + "diamonds_4": "diamonds_4.svg", + "diamonds_5": "diamonds_5.svg", + "diamonds_6": "diamonds_6.svg", + "diamonds_7": "diamonds_7.svg", + "diamonds_8": "diamonds_8.svg", + "diamonds_9": "diamonds_9.svg", + "diamonds_10": "diamonds_10.svg", + "diamonds_jack": "diamonds_jack.svg", + "diamonds_queen": "diamonds_queen.svg", + "diamonds_king": "diamonds_king.svg", + "hearts_ace": "hearts_ace.svg", + "hearts_2": "hearts_2.svg", + "hearts_3": "hearts_3.svg", + "hearts_4": "hearts_4.svg", + "hearts_5": "hearts_5.svg", + "hearts_6": "hearts_6.svg", + "hearts_7": "hearts_7.svg", + "hearts_8": "hearts_8.svg", + "hearts_9": "hearts_9.svg", + "hearts_10": "hearts_10.svg", + "hearts_jack": "hearts_jack.svg", + "hearts_queen": "hearts_queen.svg", + "hearts_king": "hearts_king.svg", + "spades_ace": "spades_ace.svg", + "spades_2": "spades_2.svg", + "spades_3": "spades_3.svg", + "spades_4": "spades_4.svg", + "spades_5": "spades_5.svg", + "spades_6": "spades_6.svg", + "spades_7": "spades_7.svg", + "spades_8": "spades_8.svg", + "spades_9": "spades_9.svg", + "spades_10": "spades_10.svg", + "spades_jack": "spades_jack.svg", + "spades_queen": "spades_queen.svg", + "spades_king": "spades_king.svg", + }, +) diff --git a/solitaire_engine/src/assets/mod.rs b/solitaire_engine/src/assets/mod.rs index cdb4acd..4581edb 100644 --- a/solitaire_engine/src/assets/mod.rs +++ b/solitaire_engine/src/assets/mod.rs @@ -6,8 +6,13 @@ //! (user-themes directory). Phase 3 will extend it further with custom //! `AssetSource` implementations for `embedded://` and `themes://`. +pub mod sources; pub mod svg_loader; pub mod user_dir; +pub use sources::{ + populate_embedded_default_theme, register_theme_asset_sources, AssetSourcesPlugin, + DEFAULT_THEME_MANIFEST_URL, USER_THEMES, +}; pub use svg_loader::{rasterize_svg, SvgLoader, SvgLoaderError, SvgLoaderSettings}; pub use user_dir::{set_user_theme_dir, user_theme_dir}; diff --git a/solitaire_engine/src/assets/sources.rs b/solitaire_engine/src/assets/sources.rs new file mode 100644 index 0000000..fe09ea8 --- /dev/null +++ b/solitaire_engine/src/assets/sources.rs @@ -0,0 +1,221 @@ +//! 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 default theme manifest. +/// +/// Code that wants to load the embedded default — including the future +/// Phase 4 `ActiveTheme` initialisation — should use exactly this +/// constant rather than re-typing the URL inline. Changing where the +/// default theme lives in the asset graph then becomes a single-line +/// change in this file. +pub const DEFAULT_THEME_MANIFEST_URL: &str = + "embedded://solitaire_engine/assets/themes/default/theme.ron"; + +/// Path the embedded default-theme manifest registers under, relative +/// to the `embedded://` source root. Kept in lockstep with +/// [`DEFAULT_THEME_MANIFEST_URL`] by the unit test +/// `default_theme_url_constant_matches_embedded_path`. +const DEFAULT_THEME_MANIFEST_PATH: &str = "solitaire_engine/assets/themes/default/theme.ron"; + +/// Bytes of the bundled default theme manifest. Embedded at compile +/// time via `include_bytes!` so the binary is self-contained even if +/// the workspace's `solitaire_engine/assets/` directory is absent at +/// runtime (e.g. when shipped to a player). +const DEFAULT_THEME_MANIFEST_BYTES: &[u8] = + include_bytes!("../../assets/themes/default/theme.ron"); + +/// 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_default_theme(app); + } +} + +/// Pushes every bundled default-theme file into the +/// [`EmbeddedAssetRegistry`] under its stable URL. Keeping this in a +/// free function (and not inside the `Plugin::build` body) means the +/// unit test below can exercise it without spinning up a full Bevy +/// `App` with `AssetPlugin`. +/// +/// **Adding files to the bundled default theme** is a single edit +/// per file: add an `include_bytes!` constant that points at the file +/// under `solitaire_engine/assets/themes/default/`, then add a +/// matching `registry.insert_asset(...)` call here. Keep the +/// `asset_path` argument exactly the relative path that the manifest +/// references (e.g. `solitaire_engine/assets/themes/default/back.svg`). +pub fn populate_embedded_default_theme(app: &mut App) { + let registry = app + .world_mut() + .get_resource_or_insert_with(EmbeddedAssetRegistry::default); + + // `full_path` is only consulted by the optional + // `embedded_watcher` cargo feature (which we don't enable). Use + // the manifest's logical workspace path so a future debugger + // session sees a sensible source-of-truth string. + registry.insert_asset( + std::path::PathBuf::from(DEFAULT_THEME_MANIFEST_PATH), + std::path::Path::new(DEFAULT_THEME_MANIFEST_PATH), + DEFAULT_THEME_MANIFEST_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" + ); + } + + /// `populate_embedded_default_theme` must work as a drop-in step + /// regardless of whether `EmbeddedAssetRegistry` already exists, + /// so it can be called both from `AssetSourcesPlugin::build` + /// (after `AssetPlugin` initialised it) and from this test (which + /// uses the resource's `get_resource_or_insert_with` fallback). + #[test] + fn populate_embedded_default_theme_runs_without_asset_plugin() { + let mut app = App::new(); + populate_embedded_default_theme(&mut app); + + // Resource exists and has been inserted into. + assert!(app + .world() + .get_resource::() + .is_some()); + } + + /// The bundled default theme stub must satisfy + /// `ThemeManifest::validate` — otherwise the embedded source + /// would register a manifest the loader will then reject at + /// runtime. + #[test] + fn embedded_default_theme_manifest_validates() { + use crate::theme::ThemeManifest; + + let manifest: ThemeManifest = ron::de::from_bytes(DEFAULT_THEME_MANIFEST_BYTES) + .expect("default manifest must parse as RON"); + let faces = manifest + .validate() + .expect("default manifest must list all 52 faces"); + assert_eq!(faces.len(), 52); + } + + /// Belt-and-braces: if anyone edits `DEFAULT_THEME_MANIFEST_PATH` + /// without updating `DEFAULT_THEME_MANIFEST_URL` (or vice versa) + /// the asset would register at one path and be loaded from + /// another. Pin them together in the test suite so any drift + /// fails CI. + #[test] + fn default_theme_url_constant_matches_embedded_path() { + let url_tail = DEFAULT_THEME_MANIFEST_URL + .strip_prefix("embedded://") + .expect("default theme URL must use embedded:// scheme"); + assert_eq!(url_tail, DEFAULT_THEME_MANIFEST_PATH); + } +} diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index 3eb82e3..f8eaefa 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -40,6 +40,10 @@ pub mod ui_tooltip; pub mod weekly_goals_plugin; pub mod win_summary_plugin; +pub use assets::{ + populate_embedded_default_theme, register_theme_asset_sources, AssetSourcesPlugin, + DEFAULT_THEME_MANIFEST_URL, USER_THEMES, +}; pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen}; pub use challenge_plugin::{ challenge_progress_label, ChallengeAdvancedEvent, ChallengePlugin, CHALLENGE_UNLOCK_LEVEL,