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:
funman300
2026-05-06 19:28:53 -07:00
parent 17e3112502
commit 21ec03b157
58 changed files with 326 additions and 24 deletions
+34 -12
View File
@@ -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));