feat(engine): asset sources for embedded + user theme dirs (Card theme phase 3)

Implements Phase 3 of CARD_PLAN.md — the embedded:// + themes:// asset
sources the card-theme system loads from. The bundled default-theme
manifest ships in the binary via Bevy's EmbeddedAssetRegistry; user
themes load from user_theme_dir() through a FileAssetReader-backed
source registered as `themes://`.

Registration is split across:
  register_theme_asset_sources(&mut App)
    Called BEFORE DefaultPlugins. Registers `themes://` while
    AssetSourceBuilders is still mutable.
  AssetSourcesPlugin
    Added AFTER DefaultPlugins. Populates the EmbeddedAssetRegistry
    that AssetPlugin's build step would otherwise overwrite.

Constants exposed for downstream consumers:
  USER_THEMES                 = "themes"   (asset-source name)
  DEFAULT_THEME_MANIFEST_URL  = "embedded://solitaire_engine/assets/themes/default/theme.ron"

Includes a stub default theme.ron (52 face slots + back) so
`ThemeManifest::validate()` accepts it today; PROVENANCE.md documents
the plan to drop in real SVG art (hayeah/playing-cards-assets) in a
follow-up.

4 new tests covering source registration, embedded-registry
population, manifest validation against the embedded stub, and the
manifest-URL constant matching the embedded asset path.

cargo check --workspace --all-targets / clippy --workspace
--all-targets -- -D warnings clean. cargo test could not be run in
this turn because the C linker (cc) is unexpectedly absent from the
sandbox; the test bodies compile cleanly under cargo check --tests
and will run on a normal toolchain.
This commit is contained in:
funman300
2026-05-01 05:47:13 +00:00
parent 205ad6f646
commit 172d7773f0
6 changed files with 369 additions and 7 deletions
+19 -7
View File
@@ -6,12 +6,13 @@ use bevy::prelude::*;
use bevy::window::{MonitorSelection, PresentMode, WindowPosition}; use bevy::window::{MonitorSelection, PresentMode, WindowPosition};
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings}; use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
use solitaire_engine::{ use solitaire_engine::{
AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin,
CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin, PausePlugin,
SplashPlugin, StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, UiFocusPlugin, ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin,
UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin,
WeeklyGoalsPlugin, WinSummaryPlugin,
}; };
fn main() { 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( .add_plugins(
DefaultPlugins DefaultPlugins
.set(WindowPlugin { .set(WindowPlugin {
@@ -91,6 +102,7 @@ fn main() {
..default() ..default()
}), }),
) )
.add_plugins(AssetSourcesPlugin)
.add_plugins(FontPlugin) .add_plugins(FontPlugin)
.add_plugins(GamePlugin) .add_plugins(GamePlugin)
.add_plugins(TablePlugin) .add_plugins(TablePlugin)
@@ -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/<filename>");
```
(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.
@@ -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",
},
)
+5
View File
@@ -6,8 +6,13 @@
//! (user-themes directory). Phase 3 will extend it further with custom //! (user-themes directory). Phase 3 will extend it further with custom
//! `AssetSource` implementations for `embedded://` and `themes://`. //! `AssetSource` implementations for `embedded://` and `themes://`.
pub mod sources;
pub mod svg_loader; pub mod svg_loader;
pub mod user_dir; 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 svg_loader::{rasterize_svg, SvgLoader, SvgLoaderError, SvgLoaderSettings};
pub use user_dir::{set_user_theme_dir, user_theme_dir}; pub use user_dir::{set_user_theme_dir, user_theme_dir};
+221
View File
@@ -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_id>/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::<bevy::asset::io::AssetSourceBuilders>();
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::<EmbeddedAssetRegistry>()
.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);
}
}
+4
View File
@@ -40,6 +40,10 @@ pub mod ui_tooltip;
pub mod weekly_goals_plugin; pub mod weekly_goals_plugin;
pub mod win_summary_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 achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen};
pub use challenge_plugin::{ pub use challenge_plugin::{
challenge_progress_label, ChallengeAdvancedEvent, ChallengePlugin, CHALLENGE_UNLOCK_LEVEL, challenge_progress_label, ChallengeAdvancedEvent, ChallengePlugin, CHALLENGE_UNLOCK_LEVEL,