feat(engine): embed classic theme into binary like dark theme

Classic SVGs and manifest are now compiled in via include_bytes!(),
making the theme available on all platforms (desktop, Android) without
requiring filesystem assets. Removes the now-redundant Dockerfile COPY
of solitaire_engine/assets/themes/classic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-14 10:53:14 -07:00
parent 2ef25934ac
commit 3cffbc2c51
4 changed files with 188 additions and 14 deletions
+3 -2
View File
@@ -11,8 +11,9 @@ pub mod svg_loader;
pub mod user_dir;
pub use sources::{
bundled_theme_url, dark_theme_svg_bytes, populate_embedded_dark_theme,
register_theme_asset_sources, AssetSourcesPlugin, DARK_THEME_MANIFEST_URL, USER_THEMES,
bundled_theme_url, classic_theme_svg_bytes, dark_theme_svg_bytes,
populate_embedded_classic_theme, populate_embedded_dark_theme, register_theme_asset_sources,
AssetSourcesPlugin, CLASSIC_THEME_MANIFEST_URL, DARK_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};
+164 -1
View File
@@ -78,6 +78,20 @@ const DARK_THEME_MANIFEST_PATH: &str = "solitaire_engine/assets/themes/dark/them
const DARK_THEME_MANIFEST_BYTES: &[u8] =
include_bytes!("../../assets/themes/dark/theme.ron");
/// Stable embedded asset URL of the bundled Classic theme manifest.
pub const CLASSIC_THEME_MANIFEST_URL: &str =
"embedded://solitaire_engine/assets/themes/classic/theme.ron";
/// Path the embedded Classic-theme manifest registers under, relative
/// to the `embedded://` source root. Kept in lockstep with
/// [`CLASSIC_THEME_MANIFEST_URL`] by the unit test
/// `classic_theme_url_constant_matches_embedded_path`.
const CLASSIC_THEME_MANIFEST_PATH: &str = "solitaire_engine/assets/themes/classic/theme.ron";
/// Bytes of the bundled Classic theme manifest, embedded at compile time.
const CLASSIC_THEME_MANIFEST_BYTES: &[u8] =
include_bytes!("../../assets/themes/classic/theme.ron");
/// Generates a `(stable_path, bytes)` entry for one Dark-theme SVG.
macro_rules! embed_dark_svg {
($name:literal) => {
@@ -88,6 +102,16 @@ macro_rules! embed_dark_svg {
};
}
/// Generates a `(stable_path, bytes)` entry for one Classic-theme SVG.
macro_rules! embed_classic_svg {
($name:literal) => {
(
concat!("solitaire_engine/assets/themes/classic/", $name),
include_bytes!(concat!("../../assets/themes/classic/", $name)) as &[u8],
)
};
}
/// Every Dark-theme SVG file bundled into the binary.
const DARK_THEME_SVGS: &[(&str, &[u8])] = &[
embed_dark_svg!("back.svg"),
@@ -145,6 +169,63 @@ const DARK_THEME_SVGS: &[(&str, &[u8])] = &[
embed_dark_svg!("spades_king.svg"),
];
/// Every Classic-theme SVG file bundled into the binary.
const CLASSIC_THEME_SVGS: &[(&str, &[u8])] = &[
embed_classic_svg!("back.svg"),
embed_classic_svg!("clubs_ace.svg"),
embed_classic_svg!("clubs_2.svg"),
embed_classic_svg!("clubs_3.svg"),
embed_classic_svg!("clubs_4.svg"),
embed_classic_svg!("clubs_5.svg"),
embed_classic_svg!("clubs_6.svg"),
embed_classic_svg!("clubs_7.svg"),
embed_classic_svg!("clubs_8.svg"),
embed_classic_svg!("clubs_9.svg"),
embed_classic_svg!("clubs_10.svg"),
embed_classic_svg!("clubs_jack.svg"),
embed_classic_svg!("clubs_queen.svg"),
embed_classic_svg!("clubs_king.svg"),
embed_classic_svg!("diamonds_ace.svg"),
embed_classic_svg!("diamonds_2.svg"),
embed_classic_svg!("diamonds_3.svg"),
embed_classic_svg!("diamonds_4.svg"),
embed_classic_svg!("diamonds_5.svg"),
embed_classic_svg!("diamonds_6.svg"),
embed_classic_svg!("diamonds_7.svg"),
embed_classic_svg!("diamonds_8.svg"),
embed_classic_svg!("diamonds_9.svg"),
embed_classic_svg!("diamonds_10.svg"),
embed_classic_svg!("diamonds_jack.svg"),
embed_classic_svg!("diamonds_queen.svg"),
embed_classic_svg!("diamonds_king.svg"),
embed_classic_svg!("hearts_ace.svg"),
embed_classic_svg!("hearts_2.svg"),
embed_classic_svg!("hearts_3.svg"),
embed_classic_svg!("hearts_4.svg"),
embed_classic_svg!("hearts_5.svg"),
embed_classic_svg!("hearts_6.svg"),
embed_classic_svg!("hearts_7.svg"),
embed_classic_svg!("hearts_8.svg"),
embed_classic_svg!("hearts_9.svg"),
embed_classic_svg!("hearts_10.svg"),
embed_classic_svg!("hearts_jack.svg"),
embed_classic_svg!("hearts_queen.svg"),
embed_classic_svg!("hearts_king.svg"),
embed_classic_svg!("spades_ace.svg"),
embed_classic_svg!("spades_2.svg"),
embed_classic_svg!("spades_3.svg"),
embed_classic_svg!("spades_4.svg"),
embed_classic_svg!("spades_5.svg"),
embed_classic_svg!("spades_6.svg"),
embed_classic_svg!("spades_7.svg"),
embed_classic_svg!("spades_8.svg"),
embed_classic_svg!("spades_9.svg"),
embed_classic_svg!("spades_10.svg"),
embed_classic_svg!("spades_jack.svg"),
embed_classic_svg!("spades_queen.svg"),
embed_classic_svg!("spades_king.svg"),
];
/// Registers asset sources that must be in place *before*
/// `AssetPlugin` is built.
///
@@ -181,6 +262,7 @@ pub struct AssetSourcesPlugin;
impl Plugin for AssetSourcesPlugin {
fn build(&self, app: &mut App) {
populate_embedded_dark_theme(app);
populate_embedded_classic_theme(app);
}
}
@@ -208,11 +290,44 @@ pub fn dark_theme_svg_bytes(filename: &str) -> Option<&'static [u8]> {
pub fn bundled_theme_url(id: &str) -> Option<&'static str> {
match id {
"dark" => Some(DARK_THEME_MANIFEST_URL),
"classic" => Some("themes/classic/theme.ron"),
"classic" => Some(CLASSIC_THEME_MANIFEST_URL),
_ => None,
}
}
/// Returns the embedded SVG bytes for a single Classic-theme file
/// (e.g. `"back.svg"` or `"spades_ace.svg"`), or `None` when the
/// filename is not bundled.
pub fn classic_theme_svg_bytes(filename: &str) -> Option<&'static [u8]> {
let suffix = format!("/{filename}");
CLASSIC_THEME_SVGS
.iter()
.find(|(path, _)| path.ends_with(&suffix))
.map(|(_, bytes)| *bytes)
}
/// Pushes every bundled Classic-theme file into the
/// [`EmbeddedAssetRegistry`] under its stable URL.
pub fn populate_embedded_classic_theme(app: &mut App) {
let registry = app
.world_mut()
.get_resource_or_insert_with(EmbeddedAssetRegistry::default);
registry.insert_asset(
std::path::PathBuf::from(CLASSIC_THEME_MANIFEST_PATH),
std::path::Path::new(CLASSIC_THEME_MANIFEST_PATH),
CLASSIC_THEME_MANIFEST_BYTES,
);
for (path, bytes) in CLASSIC_THEME_SVGS {
registry.insert_asset(
std::path::PathBuf::from(*path),
std::path::Path::new(*path),
*bytes,
);
}
}
/// Pushes every bundled Dark-theme file into the
/// [`EmbeddedAssetRegistry`] under its stable URL.
pub fn populate_embedded_dark_theme(app: &mut App) {
@@ -305,4 +420,52 @@ mod tests {
.expect("dark theme URL must use embedded:// scheme");
assert_eq!(url_tail, DARK_THEME_MANIFEST_PATH);
}
#[test]
fn populate_embedded_classic_theme_runs_without_asset_plugin() {
let mut app = App::new();
populate_embedded_classic_theme(&mut app);
assert!(app
.world()
.get_resource::<EmbeddedAssetRegistry>()
.is_some());
}
#[test]
fn embedded_classic_theme_manifest_validates() {
use crate::theme::ThemeManifest;
let manifest: ThemeManifest = ron::de::from_bytes(CLASSIC_THEME_MANIFEST_BYTES)
.expect("classic manifest must parse as RON");
let faces = manifest
.validate()
.expect("classic manifest must list all 52 faces");
assert_eq!(faces.len(), 52);
}
#[test]
fn classic_theme_svg_bytes_finds_back_and_ace_of_spades() {
assert!(
classic_theme_svg_bytes("back.svg").is_some(),
"classic theme must bundle a back.svg"
);
assert!(
classic_theme_svg_bytes("spades_ace.svg").is_some(),
"classic theme must bundle a spades_ace.svg"
);
}
#[test]
fn classic_theme_svg_bytes_returns_none_for_unknown_file() {
assert!(classic_theme_svg_bytes("nope.svg").is_none());
assert!(classic_theme_svg_bytes("").is_none());
}
#[test]
fn classic_theme_url_constant_matches_embedded_path() {
let url_tail = CLASSIC_THEME_MANIFEST_URL
.strip_prefix("embedded://")
.expect("classic theme URL must use embedded:// scheme");
assert_eq!(url_tail, CLASSIC_THEME_MANIFEST_PATH);
}
}
+20 -10
View File
@@ -15,7 +15,7 @@ use bevy::prelude::*;
use solitaire_core::card::{Rank, Suit};
use crate::assets::{
bundled_theme_url, dark_theme_svg_bytes, rasterize_svg, user_theme_dir,
bundled_theme_url, classic_theme_svg_bytes, dark_theme_svg_bytes, rasterize_svg, user_theme_dir,
};
use crate::card_plugin::CardImageSet;
use crate::events::StateChangedEvent;
@@ -306,22 +306,18 @@ const PREVIEW_BACK_FILENAME: &str = "back.svg";
///
/// - For the embedded `dark` theme, reads from the in-binary table via
/// [`dark_theme_svg_bytes`]. No filesystem I/O.
/// - For bundled non-embedded themes (e.g. `classic`), reads from the
/// `assets/themes/<id>/` directory.
/// - For the embedded `classic` theme, reads from the in-binary table via
/// [`classic_theme_svg_bytes`]. No filesystem I/O.
/// - For user themes, reads from `<user_theme_dir>/<id>/<filename>`.
/// Returns `None` for any I/O failure.
fn read_theme_preview_svg_bytes(theme_id: &str, filename: &str) -> Option<Vec<u8>> {
if theme_id == "dark" {
return dark_theme_svg_bytes(filename).map(|b| b.to_vec());
}
// Bundled non-embedded themes live alongside the binary in assets/.
let bundled_path = std::path::Path::new("assets/themes")
.join(theme_id)
.join(filename);
if let Ok(bytes) = std::fs::read(&bundled_path) {
return Some(bytes);
if theme_id == "classic" {
return classic_theme_svg_bytes(filename).map(|b| b.to_vec());
}
// Fall back to user theme dir.
// User themes live in the user theme dir.
let path = user_theme_dir().join(theme_id).join(filename);
std::fs::read(&path).ok()
}
@@ -577,6 +573,20 @@ mod tests {
);
}
/// `read_theme_preview_svg_bytes` for the classic theme always returns
/// embedded bytes for the canonical preview pair.
#[test]
fn read_classic_theme_preview_returns_some_for_canonical_files() {
assert!(
read_theme_preview_svg_bytes("classic", PREVIEW_BACK_FILENAME).is_some(),
"classic theme back.svg must be embedded"
);
assert!(
read_theme_preview_svg_bytes("classic", PREVIEW_FACE_FILENAME).is_some(),
"classic theme spades_ace.svg must be embedded"
);
}
/// `ensure_theme_thumbnails` is idempotent: calling it twice with
/// the same registry must not regenerate or replace already-cached
/// entries. This guards against the per-frame Update tick churning
+1 -1
View File
@@ -62,9 +62,9 @@ COPY --from=builder /build/target/release/solitaire_server ./server
# Static web assets are served via ServeDir at runtime from these paths:
# /app/solitaire_server/web → /web route
# /app/assets → /assets route
# Card themes (dark + classic) are embedded in the binary; no theme files needed here.
COPY solitaire_server/web ./solitaire_server/web
COPY assets ./assets
COPY solitaire_engine/assets/themes/classic ./assets/themes/classic
ENV SERVER_PORT=8080
EXPOSE 8080