feat(engine): bundle Rusty Pixel as a built-in theme
The pixel-art card theme generated via Claude Design (53 PNGs at
256x384, ~340 KB total) now ships embedded in the binary alongside
the existing default SVG theme. Players see the new theme in the
picker out of the box without needing to drop files into
~/.local/share/solitaire_quest/themes/.
solitaire_engine/assets/themes/rusty-pixel/:
- 53 PNGs (52 face cards + 1 back) at 256x384
- theme.ron declaring meta.id = "rusty-pixel",
card_aspect = (2, 3), pixel_art = true
assets/sources.rs:
- New constants RUSTY_PIXEL_THEME_MANIFEST_URL,
RUSTY_PIXEL_THEME_MANIFEST_PATH,
RUSTY_PIXEL_THEME_MANIFEST_BYTES.
- New embed_rusty_pixel_png! macro mirroring embed_default_svg!.
- New RUSTY_PIXEL_THEME_PNGS table — 53 entries, one per file.
- New rusty_pixel_theme_png_bytes(filename) lookup helper
mirroring default_theme_svg_bytes for the thumbnail cache.
- New populate_embedded_rusty_pixel_theme(app) registers the
manifest + every PNG into Bevy's EmbeddedAssetRegistry.
- AssetSourcesPlugin::build now calls both populate functions
so the picker has both themes loadable from the binary alone.
theme/registry.rs:
- New rusty_pixel_entry() returns the bundled metadata.
- build_registry now inserts default + rusty-pixel ahead of the
user-dir scan, and filters user themes whose id collides with
a bundled built-in. Bundled wins on collision because it's
guaranteed complete; the user's overriding copy may be partial
or stale.
- Updated existing tests for the new len()=2-instead-of-1 baseline.
- New test user_theme_id_collision_with_bundled_is_dropped pins
the dedup contract.
theme/plugin.rs:
- load_initial_theme + react_to_settings_theme_change now both
consult a new manifest_url_for(theme_id) helper that routes
bundled built-ins through embedded:// and unknown ids through
themes://. Drops the previous hard-coded "default →
DEFAULT_THEME_MANIFEST_URL else themes://" branch.
- read_theme_preview_bytes also checks the rusty-pixel embed
table before falling through to the user-dir filesystem read,
so the picker chip's thumbnail works on a fresh install where
the user-dir doesn't exist.
Workspace: 1172 passing tests / 0 failing, was 1171 (+1 net from
the new collision test). cargo clippy --workspace --all-targets
-- -D warnings clean. Binary grows by ~340 KB (the 53 bundled
PNGs).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -17,7 +17,8 @@ use bevy::prelude::*;
|
||||
use solitaire_core::card::{Rank, Suit};
|
||||
|
||||
use crate::assets::{
|
||||
default_theme_svg_bytes, rasterize_svg, user_theme_dir, DEFAULT_THEME_MANIFEST_URL,
|
||||
default_theme_svg_bytes, rasterize_svg, rusty_pixel_theme_png_bytes, user_theme_dir,
|
||||
DEFAULT_THEME_MANIFEST_URL, RUSTY_PIXEL_THEME_MANIFEST_URL,
|
||||
};
|
||||
use crate::card_plugin::CardImageSet;
|
||||
use crate::events::StateChangedEvent;
|
||||
@@ -128,16 +129,32 @@ fn load_initial_theme(
|
||||
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let url = match settings.as_deref() {
|
||||
Some(s) if s.0.selected_theme_id != "default" => {
|
||||
format!("themes://{}/theme.ron", s.0.selected_theme_id)
|
||||
}
|
||||
_ => DEFAULT_THEME_MANIFEST_URL.to_string(),
|
||||
};
|
||||
let id = settings
|
||||
.as_deref()
|
||||
.map(|s| s.0.selected_theme_id.as_str())
|
||||
.unwrap_or("default");
|
||||
let url = manifest_url_for(id);
|
||||
let handle: Handle<CardTheme> = asset_server.load(url);
|
||||
commands.insert_resource(ActiveTheme(handle));
|
||||
}
|
||||
|
||||
/// Resolves a theme id to its manifest asset URL.
|
||||
///
|
||||
/// Bundled built-ins (default, rusty-pixel) route to `embedded://`
|
||||
/// so the binary's compile-time-baked manifest + face files load
|
||||
/// without touching disk. Anything else routes to `themes://`,
|
||||
/// which `register_theme_asset_sources` points at the user themes
|
||||
/// directory. Callers (load_initial_theme,
|
||||
/// react_to_settings_theme_change) consult this helper instead of
|
||||
/// hard-coding the URL shape per id.
|
||||
fn manifest_url_for(theme_id: &str) -> String {
|
||||
match theme_id {
|
||||
"default" => DEFAULT_THEME_MANIFEST_URL.to_string(),
|
||||
"rusty-pixel" => RUSTY_PIXEL_THEME_MANIFEST_URL.to_string(),
|
||||
_ => format!("themes://{theme_id}/theme.ron"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Watches [`crate::settings_plugin::SettingsChangedEvent`] and
|
||||
/// triggers a fresh theme load whenever
|
||||
/// `Settings::selected_theme_id` changes. The settings panel's theme
|
||||
@@ -163,11 +180,7 @@ fn react_to_settings_theme_change(
|
||||
return;
|
||||
}
|
||||
|
||||
let url = if new_id == "default" {
|
||||
DEFAULT_THEME_MANIFEST_URL.to_string()
|
||||
} else {
|
||||
format!("themes://{new_id}/theme.ron")
|
||||
};
|
||||
let url = manifest_url_for(new_id);
|
||||
let handle: Handle<CardTheme> = asset_server.load(url);
|
||||
commands.insert_resource(ActiveTheme(handle));
|
||||
}
|
||||
@@ -362,11 +375,20 @@ enum ThemePreviewBytes {
|
||||
/// `<basename>.svg` then `<basename>.png`. Either branch returns
|
||||
/// `None` on I/O failure (file missing, permission denied, etc.).
|
||||
fn read_theme_preview_bytes(theme_id: &str, basename: &str) -> Option<ThemePreviewBytes> {
|
||||
// Bundled built-ins consult their embed tables before any
|
||||
// filesystem I/O so the thumbnail works on a fresh install where
|
||||
// the user themes directory doesn't exist yet.
|
||||
if theme_id == "default" {
|
||||
let filename = format!("{basename}.svg");
|
||||
return default_theme_svg_bytes(&filename)
|
||||
.map(|b| ThemePreviewBytes::Svg(b.to_vec()));
|
||||
}
|
||||
if theme_id == "rusty-pixel" {
|
||||
let filename = format!("{basename}.png");
|
||||
if let Some(bytes) = rusty_pixel_theme_png_bytes(&filename) {
|
||||
return Some(ThemePreviewBytes::Png(bytes.to_vec()));
|
||||
}
|
||||
}
|
||||
let dir = user_theme_dir().join(theme_id);
|
||||
if let Ok(bytes) = std::fs::read(dir.join(format!("{basename}.svg"))) {
|
||||
return Some(ThemePreviewBytes::Svg(bytes));
|
||||
|
||||
@@ -25,7 +25,7 @@ use bevy::prelude::{App, Plugin, Resource, Startup};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::ThemeMeta;
|
||||
use crate::assets::{user_theme_dir, DEFAULT_THEME_MANIFEST_URL};
|
||||
use crate::assets::{user_theme_dir, DEFAULT_THEME_MANIFEST_URL, RUSTY_PIXEL_THEME_MANIFEST_URL};
|
||||
|
||||
/// One entry in the [`ThemeRegistry`] — the data the picker UI needs
|
||||
/// to render a row and load the theme on selection.
|
||||
@@ -98,10 +98,24 @@ fn build_registry_on_startup(mut registry: bevy::ecs::system::ResMut<ThemeRegist
|
||||
/// Pure helper: builds a registry given an explicit user-themes
|
||||
/// directory. Tests pass a temp dir; production uses
|
||||
/// [`user_theme_dir`].
|
||||
///
|
||||
/// Order: bundled built-ins first (default, then rusty-pixel), then
|
||||
/// user themes in `read_dir` order. User themes whose `id` collides
|
||||
/// with a bundled built-in are silently dropped — built-ins win the
|
||||
/// collision because they're guaranteed to be a complete, valid set;
|
||||
/// the user's overriding copy may be partial or stale and silently
|
||||
/// preferring a complete-but-different theme is less surprising than
|
||||
/// crashing on a missing face.
|
||||
pub fn build_registry(user_dir: &Path) -> ThemeRegistry {
|
||||
let mut entries = Vec::new();
|
||||
entries.push(default_entry());
|
||||
entries.extend(discover_user_themes(user_dir));
|
||||
entries.push(rusty_pixel_entry());
|
||||
let bundled_ids: std::collections::HashSet<String> =
|
||||
entries.iter().map(|e| e.id.clone()).collect();
|
||||
let user = discover_user_themes(user_dir)
|
||||
.into_iter()
|
||||
.filter(|t| !bundled_ids.contains(&t.id));
|
||||
entries.extend(user);
|
||||
ThemeRegistry { entries }
|
||||
}
|
||||
|
||||
@@ -123,6 +137,26 @@ fn default_entry() -> ThemeEntry {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bundled rusty-pixel theme entry — pixel-art faces by Claude
|
||||
/// Design, embedded under `embedded://solitaire_engine/assets/themes/rusty-pixel/`.
|
||||
/// Inserted alongside the default so the picker offers both
|
||||
/// out-of-the-box on a fresh install with no user themes directory.
|
||||
fn rusty_pixel_entry() -> ThemeEntry {
|
||||
ThemeEntry {
|
||||
id: "rusty-pixel".to_string(),
|
||||
display_name: "Rusty Pixel".to_string(),
|
||||
manifest_url: RUSTY_PIXEL_THEME_MANIFEST_URL.to_string(),
|
||||
meta: ThemeMeta {
|
||||
id: "rusty-pixel".to_string(),
|
||||
name: "Rusty Pixel".to_string(),
|
||||
author: "Claude Design".to_string(),
|
||||
version: "0.1.0".to_string(),
|
||||
card_aspect: (2, 3),
|
||||
pixel_art: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Walks `user_dir`, treating every immediate subdirectory as a
|
||||
/// candidate theme. A subdirectory contributes one entry if and only
|
||||
/// if it contains a `theme.ron` whose `meta` block parses cleanly and
|
||||
@@ -240,20 +274,22 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_user_dir_yields_only_the_default_entry() {
|
||||
fn empty_user_dir_yields_only_the_bundled_built_ins() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let registry = build_registry(tmp.path());
|
||||
assert_eq!(registry.len(), 1);
|
||||
assert_eq!(registry.len(), 2, "default + rusty-pixel always present");
|
||||
assert_eq!(registry.entries[0].id, "default");
|
||||
assert_eq!(registry.entries[1].id, "rusty-pixel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonexistent_user_dir_still_yields_default() {
|
||||
fn nonexistent_user_dir_still_yields_bundled_built_ins() {
|
||||
let registry = build_registry(Path::new(
|
||||
"/definitely/not/a/real/path/should/not/panic",
|
||||
));
|
||||
assert_eq!(registry.len(), 1);
|
||||
assert_eq!(registry.len(), 2);
|
||||
assert_eq!(registry.entries[0].id, "default");
|
||||
assert_eq!(registry.entries[1].id, "rusty-pixel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -264,12 +300,40 @@ mod tests {
|
||||
write_manifest(&theme_dir, "midnight", "Midnight");
|
||||
|
||||
let registry = build_registry(tmp.path());
|
||||
assert_eq!(registry.len(), 2);
|
||||
assert_eq!(registry.len(), 3, "default + rusty-pixel + midnight");
|
||||
let entry = registry.find("midnight").expect("midnight registered");
|
||||
assert_eq!(entry.display_name, "Midnight");
|
||||
assert_eq!(entry.manifest_url, "themes://midnight/theme.ron");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_theme_id_collision_with_bundled_is_dropped() {
|
||||
// A user-supplied directory whose `id` matches a bundled
|
||||
// built-in (rusty-pixel) must not produce a duplicate
|
||||
// registry entry. The bundled version wins because it's
|
||||
// guaranteed complete; the user's overriding copy may be
|
||||
// partial, stale, or otherwise broken.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let theme_dir = tmp.path().join("rusty-pixel");
|
||||
fs::create_dir_all(&theme_dir).unwrap();
|
||||
write_manifest(&theme_dir, "rusty-pixel", "User Override");
|
||||
|
||||
let registry = build_registry(tmp.path());
|
||||
assert_eq!(
|
||||
registry.len(), 2,
|
||||
"user override of bundled id must not appear as a duplicate",
|
||||
);
|
||||
let entry = registry.find("rusty-pixel").expect("rusty-pixel registered");
|
||||
assert_eq!(
|
||||
entry.display_name, "Rusty Pixel",
|
||||
"bundled entry's display_name wins over the user override",
|
||||
);
|
||||
assert_eq!(
|
||||
entry.manifest_url, RUSTY_PIXEL_THEME_MANIFEST_URL,
|
||||
"bundled embed:// URL wins over the user themes:// URL",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_manifest_also_works_via_meta_only_parser() {
|
||||
// The meta-only deserialiser must tolerate the full ThemeManifest
|
||||
@@ -310,8 +374,9 @@ mod tests {
|
||||
write_manifest(&theme_dir, "../etc/passwd", "Evil");
|
||||
|
||||
let registry = build_registry(tmp.path());
|
||||
assert_eq!(registry.len(), 1, "escape attempt must not register");
|
||||
assert_eq!(registry.len(), 2, "escape attempt must not register; built-ins remain");
|
||||
assert_eq!(registry.entries[0].id, "default");
|
||||
assert_eq!(registry.entries[1].id, "rusty-pixel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -322,7 +387,11 @@ mod tests {
|
||||
fs::write(lonely.join("readme.md"), "wrong filename").unwrap();
|
||||
|
||||
let registry = build_registry(tmp.path());
|
||||
assert_eq!(registry.len(), 1);
|
||||
assert_eq!(
|
||||
registry.len(),
|
||||
2,
|
||||
"no user themes register; only the bundled built-ins remain",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -351,8 +420,9 @@ mod tests {
|
||||
|
||||
refresh_registry(&mut registry, tmp.path());
|
||||
|
||||
assert_eq!(registry.len(), 1);
|
||||
assert_eq!(registry.len(), 2, "stale entry replaced; built-ins remain");
|
||||
assert_eq!(registry.entries[0].id, "default");
|
||||
assert_eq!(registry.entries[1].id, "rusty-pixel");
|
||||
assert!(registry.find("stale").is_none());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user