feat(engine): art pass — PNG assets, custom font, and keyring v4 upgrade

Art pass (Phase 4):
- Generate placeholder PNG assets: face.png, back_0–4.png, bg_0–4.png via
  solitaire_assetgen gen_art binary (16×16 RGBA, embedded via include_bytes!)
- Add FiraMono-Medium font (assets/fonts/main.ttf) embedded at compile time
- Add FontPlugin: loads font at startup, exposes FontResource; gracefully
  falls back to default handle when Assets<Font> absent (MinimalPlugins tests)
- Wire CardImageSet into card_plugin: face/back PNGs replace solid-colour
  sprites when available; tests continue using colour fallback via MinimalPlugins
- Wire BackgroundImageSet into table_plugin: bg PNGs replace solid-colour
  background; empty set inserted when Assets<Image> absent in tests
- Fix hint highlight system (input_plugin): tint sprite.color directly instead
  of replacing the whole Sprite (which would discard the image handle)
- Export FontPlugin, FontResource, CardImageSet from solitaire_engine::lib
- Register FontPlugin in solitaire_app before other plugins

Dependency upgrades (latest releases):
- keyring "2" → keyring "4" + keyring-core "1" (v4 split architecture into
  separate core library crate)
- auth_tokens.rs: Entry::new now returns Result; delete_password →
  delete_credential; NoDefaultStore error variant handled
- solitaire_app: add keyring::use_native_store(true) at startup for Linux
  Secret Service / macOS Keychain / Windows Credential Store selection

ARCHITECTURE.md: fix Edition 2025→2021, update asset pipeline section,
add FontPlugin/CardImageSet/BackgroundImageSet to plugin and resource tables,
update Section 14 to reflect actual include_bytes!() rendering approach,
add Decision Log entries for embedded PNG and font decisions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-29 00:30:55 +00:00
parent 41d75b50de
commit 18ac5adef5
27 changed files with 2405 additions and 489 deletions
+129 -40
View File
@@ -47,6 +47,19 @@ pub const CARD_FACE_COLOUR: Color = Color::srgb(0.98, 0.98, 0.95);
pub const RED_SUIT_COLOUR: Color = Color::srgb(0.78, 0.12, 0.15);
pub const BLACK_SUIT_COLOUR: Color = Color::srgb(0.08, 0.08, 0.08);
/// Pre-loaded [`Handle<Image>`]s for card face and back PNG textures.
///
/// Loaded once at startup by [`load_card_images`]. When this resource is
/// present, card sprites use the PNG artwork; otherwise they fall back to
/// solid-colour sprites (used in tests with `MinimalPlugins`).
#[derive(Resource)]
pub struct CardImageSet {
/// Shared face image used for all face-up cards.
pub face: Handle<Image>,
/// One handle per unlockable card-back design (indices 04).
pub backs: [Handle<Image>; 5],
}
/// Alternative face tint for red-suit cards in color-blind mode — a subtle
/// blue wash that distinguishes them from black-suit cards without colour alone.
const CARD_FACE_COLOUR_RED_CBM: Color = Color::srgba(0.85, 0.92, 1.0, 1.0);
@@ -160,6 +173,7 @@ impl Plugin for CardPlugin {
.add_message::<SettingsChangedEvent>()
.add_message::<CardFlippedEvent>()
.add_message::<CardFaceRevealedEvent>()
.add_systems(Startup, load_card_images)
.add_systems(PostStartup, (sync_cards_startup, update_stock_empty_indicator_startup))
.add_systems(
Update,
@@ -180,6 +194,81 @@ impl Plugin for CardPlugin {
}
}
/// Loads card face and back PNGs at startup and inserts [`CardImageSet`].
///
/// The PNGs are embedded at compile time via `include_bytes!()`. Missing
/// files are compile errors, not runtime panics. Under `MinimalPlugins`
/// (tests) this system is still registered but `Assets<Image>` is unavailable,
/// so it does nothing and the plugin falls back to solid-colour sprites.
fn load_card_images(images: Option<ResMut<Assets<Image>>>, mut commands: Commands) {
let Some(mut images) = images else {
// Assets<Image> is absent (e.g. MinimalPlugins in tests) — skip so
// tests can still run. The plugin falls back to solid-colour sprites.
return;
};
use bevy::asset::RenderAssetUsages;
use bevy::image::{CompressedImageFormats, ImageSampler, ImageType};
let load = |bytes: &[u8]| {
Image::from_buffer(
bytes,
ImageType::Extension("png"),
CompressedImageFormats::NONE,
true,
ImageSampler::default(),
RenderAssetUsages::RENDER_WORLD,
)
.expect("valid card PNG")
};
let face = images.add(load(include_bytes!("../../assets/cards/faces/face.png")));
let backs = [
images.add(load(include_bytes!("../../assets/cards/backs/back_0.png"))),
images.add(load(include_bytes!("../../assets/cards/backs/back_1.png"))),
images.add(load(include_bytes!("../../assets/cards/backs/back_2.png"))),
images.add(load(include_bytes!("../../assets/cards/backs/back_3.png"))),
images.add(load(include_bytes!("../../assets/cards/backs/back_4.png"))),
];
commands.insert_resource(CardImageSet { face, backs });
}
/// Builds the [`Sprite`] for a card, using PNG artwork when [`CardImageSet`] is
/// available and falling back to a solid-colour sprite in tests.
fn card_sprite(
card: &Card,
card_size: Vec2,
back_colour: Color,
color_blind: bool,
card_images: Option<&CardImageSet>,
selected_back: usize,
) -> Sprite {
if let Some(set) = card_images {
let image = if card.face_up {
set.face.clone()
} else {
let idx = selected_back.min(set.backs.len() - 1);
set.backs[idx].clone()
};
Sprite {
image,
color: Color::WHITE,
custom_size: Some(card_size),
..default()
}
} else {
let body_colour = if card.face_up {
face_colour(card, color_blind)
} else {
back_colour
};
Sprite {
color: body_colour,
custom_size: Some(card_size),
..default()
}
}
}
/// When card-back selection changes in Settings, re-render all cards so the
/// new back colour is applied immediately (without waiting for a state change).
fn resync_cards_on_settings_change(
@@ -201,14 +290,14 @@ fn sync_cards_startup(
slide_dur: Option<Res<EffectiveSlideDuration>>,
settings: Option<Res<SettingsResource>>,
entities: Query<(Entity, &CardEntity, &Transform)>,
card_images: Option<Res<CardImageSet>>,
) {
if let Some(layout) = layout {
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
let back_colour = settings
.as_ref()
.map_or_else(|| card_back_colour(0), |s| card_back_colour(s.0.selected_card_back));
let selected_back = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
let back_colour = card_back_colour(selected_back);
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, &entities);
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, &entities, card_images.as_deref(), selected_back);
}
}
@@ -220,17 +309,17 @@ fn sync_cards_on_change(
slide_dur: Option<Res<EffectiveSlideDuration>>,
settings: Option<Res<SettingsResource>>,
entities: Query<(Entity, &CardEntity, &Transform)>,
card_images: Option<Res<CardImageSet>>,
) {
if events.read().next().is_none() {
return;
}
if let Some(layout) = layout {
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
let back_colour = settings
.as_ref()
.map_or_else(|| card_back_colour(0), |s| card_back_colour(s.0.selected_card_back));
let selected_back = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
let back_colour = card_back_colour(selected_back);
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, &entities);
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, &entities, card_images.as_deref(), selected_back);
}
}
@@ -242,6 +331,8 @@ fn sync_cards(
back_colour: Color,
color_blind: bool,
entities: &Query<(Entity, &CardEntity, &Transform)>,
card_images: Option<&CardImageSet>,
selected_back: usize,
) {
let positions = card_positions(game, layout);
@@ -266,10 +357,10 @@ fn sync_cards(
Some(&(entity, cur)) => {
update_card_entity(
&mut commands, entity, card, position, z, layout,
slide_secs, back_colour, color_blind, cur,
slide_secs, back_colour, color_blind, cur, card_images, selected_back,
)
}
None => spawn_card_entity(&mut commands, card, position, z, layout, back_colour, color_blind),
None => spawn_card_entity(&mut commands, card, position, z, layout, back_colour, color_blind, card_images, selected_back),
}
}
}
@@ -358,21 +449,23 @@ fn face_colour(card: &Card, color_blind: bool) -> Color {
}
}
fn spawn_card_entity(commands: &mut Commands, card: &Card, pos: Vec2, z: f32, layout: &Layout, back_colour: Color, color_blind: bool) {
let body_colour = if card.face_up {
face_colour(card, color_blind)
} else {
back_colour
};
fn spawn_card_entity(
commands: &mut Commands,
card: &Card,
pos: Vec2,
z: f32,
layout: &Layout,
back_colour: Color,
color_blind: bool,
card_images: Option<&CardImageSet>,
selected_back: usize,
) {
let sprite = card_sprite(card, layout.card_size, back_colour, color_blind, card_images, selected_back);
commands
.spawn((
CardEntity { card_id: card.id },
Sprite {
color: body_colour,
custom_size: Some(layout.card_size),
..default()
},
sprite,
Transform::from_xyz(pos.x, pos.y, z),
Visibility::default(),
))
@@ -405,21 +498,13 @@ fn update_card_entity(
back_colour: Color,
color_blind: bool,
cur: Vec3,
card_images: Option<&CardImageSet>,
selected_back: usize,
) {
let body_colour = if card.face_up {
face_colour(card, color_blind)
} else {
back_colour
};
let target = Vec3::new(pos.x, pos.y, z);
// Always refresh the visual appearance.
commands.entity(entity).insert(Sprite {
color: body_colour,
custom_size: Some(layout.card_size),
..default()
});
commands.entity(entity).insert(card_sprite(card, layout.card_size, back_colour, color_blind, card_images, selected_back));
// Slide to the new position when it differs meaningfully; snap otherwise.
if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 {
@@ -653,20 +738,24 @@ fn tick_hint_highlight(
mut query: Query<(Entity, &mut HintHighlight, &mut Sprite, &CardEntity)>,
game: Res<GameStateResource>,
settings: Option<Res<SettingsResource>>,
card_images: Option<Res<CardImageSet>>,
) {
let back_idx = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
let use_images = card_images.is_some();
for (entity, mut hint, mut sprite, card_entity) in query.iter_mut() {
hint.remaining -= time.delta_secs();
if hint.remaining <= 0.0 {
// Restore normal face-up colour.
let is_face_up = game.0.piles.values()
.flat_map(|p| p.cards.iter())
.find(|c| c.id == card_entity.card_id)
.is_some_and(|c| c.face_up);
sprite.color = if is_face_up {
CARD_FACE_COLOUR
// Restore the normal sprite colour.
// When image-based rendering is active, WHITE is the neutral tint;
// otherwise restore the solid colour appropriate to the card state.
sprite.color = if use_images {
Color::WHITE
} else {
card_back_colour(back_idx)
let is_face_up = game.0.piles.values()
.flat_map(|p| p.cards.iter())
.find(|c| c.id == card_entity.card_id)
.is_some_and(|c| c.face_up);
if is_face_up { CARD_FACE_COLOUR } else { card_back_colour(back_idx) }
};
commands
.entity(entity)
+36
View File
@@ -0,0 +1,36 @@
// Register FontPlugin in solitaire_engine/src/lib.rs before use.
//! Embeds FiraMono-Medium as the project font and exposes it via [`FontResource`].
use bevy::prelude::*;
/// Holds the project-wide [`Handle<Font>`] loaded at startup.
#[derive(Resource)]
pub struct FontResource(pub Handle<Font>);
/// Loads FiraMono-Medium at startup and inserts [`FontResource`].
pub struct FontPlugin;
impl Plugin for FontPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, load_font);
}
}
fn load_font(fonts: Option<ResMut<Assets<Font>>>, mut commands: Commands) {
let Some(mut fonts) = fonts else {
// Assets<Font> absent (e.g. MinimalPlugins in tests) — insert default.
commands.insert_resource(FontResource(Handle::default()));
return;
};
let bytes: &'static [u8] = include_bytes!("../../assets/fonts/main.ttf");
match Font::try_from_bytes(bytes.to_vec()) {
Ok(font) => {
commands.insert_resource(FontResource(fonts.add(font)));
}
Err(e) => {
warn!("failed to load main.ttf: {e}; falling back to Bevy default font");
commands.insert_resource(FontResource(Handle::default()));
}
}
}
+7 -2
View File
@@ -14,6 +14,7 @@ use solitaire_core::pile::PileType;
use crate::auto_complete_plugin::AutoCompleteState;
use crate::daily_challenge_plugin::DailyChallengeResource;
use crate::events::InfoToastEvent;
use crate::font_plugin::FontResource;
use crate::game_plugin::GameMutation;
use crate::resources::GameStateResource;
use crate::selection_plugin::SelectionState;
@@ -98,9 +99,13 @@ impl Plugin for HudPlugin {
}
}
fn spawn_hud(mut commands: Commands) {
fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
let white = TextColor(Color::srgba(1.0, 1.0, 1.0, 0.80));
let font = TextFont { font_size: 18.0, ..default() };
let font = TextFont {
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
font_size: 18.0,
..default()
};
commands
.spawn((
Node {
+6 -8
View File
@@ -252,7 +252,7 @@ fn handle_keyboard_hint(
mut confirm: ResMut<KeyboardConfirmState>,
mut hint_cycle: ResMut<HintCycleIndex>,
mut commands: Commands,
card_entities: Query<(Entity, &CardEntity, &Sprite)>,
mut card_entities: Query<(Entity, &CardEntity, &mut Sprite)>,
mut info_toast: MessageWriter<InfoToastEvent>,
mut hint_visual: MessageWriter<HintVisualEvent>,
) {
@@ -308,16 +308,14 @@ fn handle_keyboard_hint(
.and_then(|p| p.cards.last().filter(|c| c.face_up))
.map(|c| c.id);
if let Some(card_id) = top_card_id {
for (entity, card_entity, _sprite) in card_entities.iter() {
for (entity, card_entity, mut sprite) in card_entities.iter_mut() {
if card_entity.card_id == card_id {
// Tint the card gold without replacing the Sprite (which would
// discard the image handle set by CardImageSet).
sprite.color = Color::srgba(1.0, 1.0, 0.4, 1.0);
commands.entity(entity)
.insert(HintHighlight { remaining: 2.0 })
.insert(HintHighlightTimer(2.0))
.insert(Sprite {
color: Color::srgba(1.0, 1.0, 0.4, 1.0),
custom_size: Some(layout_res.0.card_size),
..default()
});
.insert(HintHighlightTimer(2.0));
break;
}
}
+4 -2
View File
@@ -6,6 +6,7 @@ pub mod animation_plugin;
pub mod auto_complete_plugin;
pub mod audio_plugin;
pub mod card_plugin;
pub mod font_plugin;
pub mod feedback_anim_plugin;
pub mod challenge_plugin;
pub mod cursor_plugin;
@@ -59,9 +60,10 @@ pub use feedback_anim_plugin::{
pub use auto_complete_plugin::AutoCompletePlugin;
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
pub use card_plugin::{
CardEntity, CardLabel, CardPlugin, HintHighlight, HintHighlightTimer, RightClickHighlight,
RightClickHighlightTimer,
CardEntity, CardImageSet, CardLabel, CardPlugin, HintHighlight, HintHighlightTimer,
RightClickHighlight, RightClickHighlightTimer,
};
pub use font_plugin::{FontPlugin, FontResource};
pub use cursor_plugin::CursorPlugin;
pub use events::{
AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent,
+98 -12
View File
@@ -11,9 +11,21 @@ use solitaire_core::pile::PileType;
use solitaire_data::settings::Theme;
use crate::events::HintVisualEvent;
use crate::layout::{compute_layout, Layout, LayoutResource, TABLE_COLOUR};
use crate::layout::{compute_layout, Layout, LayoutResource};
#[cfg(test)]
use crate::layout::TABLE_COLOUR;
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
/// Holds pre-loaded [`Handle<Image>`]s for the 5 selectable table backgrounds.
///
/// Loaded once at startup by [`load_background_images`]. Index 0 is the
/// default; indices 14 are unlockable.
#[derive(Resource)]
pub struct BackgroundImageSet {
/// One handle per background slot (indices 04).
pub handles: Vec<Handle<Image>>,
}
/// Z-depth used for the background — below everything.
const Z_BACKGROUND: f32 = -10.0;
/// Z-depth used for pile markers — below cards (which start at 0) but above
@@ -50,6 +62,7 @@ impl Plugin for TablePlugin {
app.add_message::<WindowResized>()
.add_message::<SettingsChangedEvent>()
.add_message::<HintVisualEvent>()
.add_systems(Startup, load_background_images.before(setup_table))
.add_systems(Startup, setup_table)
.add_systems(
Update,
@@ -63,7 +76,50 @@ impl Plugin for TablePlugin {
}
}
/// Loads the 5 background PNG files at startup and stores their
/// [`Handle<Image>`]s in [`BackgroundImageSet`].
///
/// The PNGs are embedded at compile time via `include_bytes!()`. If a file
/// is missing the build will fail with a clear error rather than a runtime
/// panic.
fn load_background_images(images: Option<ResMut<Assets<Image>>>, mut commands: Commands) {
let Some(mut images) = images else {
// Assets<Image> is absent (e.g. MinimalPlugins in tests) — insert an
// empty set so setup_table can proceed using a default handle.
commands.insert_resource(BackgroundImageSet { handles: Vec::new() });
return;
};
const BG_BYTES: [&[u8]; 5] = [
include_bytes!("../../assets/backgrounds/bg_0.png"),
include_bytes!("../../assets/backgrounds/bg_1.png"),
include_bytes!("../../assets/backgrounds/bg_2.png"),
include_bytes!("../../assets/backgrounds/bg_3.png"),
include_bytes!("../../assets/backgrounds/bg_4.png"),
];
let handles = BG_BYTES
.iter()
.map(|bytes| {
use bevy::image::{CompressedImageFormats, ImageSampler, ImageType};
let image = Image::from_buffer(
bytes,
ImageType::Extension("png"),
CompressedImageFormats::NONE,
true,
ImageSampler::default(),
bevy::asset::RenderAssetUsages::RENDER_WORLD,
)
.expect("valid background PNG");
images.add(image)
})
.collect();
commands.insert_resource(BackgroundImageSet { handles });
}
/// Returns the felt colour for a given theme.
///
/// Only used in tests — the runtime path now picks a PNG image via
/// [`BackgroundImageSet`] rather than a solid colour.
#[cfg(test)]
fn theme_colour(theme: &Theme) -> Color {
match theme {
Theme::Green => Color::srgb(TABLE_COLOUR[0], TABLE_COLOUR[1], TABLE_COLOUR[2]),
@@ -74,6 +130,10 @@ fn theme_colour(theme: &Theme) -> Color {
/// Effective table background colour: unlocked background index overrides the
/// Theme when `selected_background > 0`.
///
/// Only used in tests — the runtime path now picks a PNG image via
/// [`BackgroundImageSet`] rather than a solid colour.
#[cfg(test)]
fn effective_background_colour(theme: &Theme, selected_background: usize) -> Color {
match selected_background {
0 => theme_colour(theme),
@@ -93,6 +153,7 @@ fn setup_table(
windows: Query<&Window>,
existing_camera: Query<(), With<Camera>>,
settings: Option<Res<SettingsResource>>,
bg_images: Option<Res<BackgroundImageSet>>,
) {
// Only spawn a camera if one does not already exist (e.g. a parent app
// may have added one in tests).
@@ -107,23 +168,34 @@ fn setup_table(
.unwrap_or(Vec2::new(1280.0, 800.0));
let layout = compute_layout(window_size);
let initial_colour = settings
let selected_bg = settings
.as_ref()
.map(|s| effective_background_colour(&s.0.theme, s.0.selected_background))
.unwrap_or_else(|| Color::srgb(TABLE_COLOUR[0], TABLE_COLOUR[1], TABLE_COLOUR[2]));
.map(|s| s.0.selected_background)
.unwrap_or(0);
spawn_background(&mut commands, window_size, initial_colour);
let image_handle = bg_images
.as_ref()
.and_then(|set| set.handles.get(selected_bg).cloned())
.unwrap_or_default();
spawn_background(&mut commands, window_size, image_handle);
spawn_pile_markers(&mut commands, &layout);
commands.insert_resource(LayoutResource(layout));
}
fn spawn_background(commands: &mut Commands, window_size: Vec2, color: Color) {
// Spawn a felt-coloured rectangle that always covers the window. We give
// it the window size plus headroom so resizing up doesn't expose edges
// before the resize handler runs.
/// Spawns the felt background sprite using a PNG image handle.
///
/// The sprite covers the window at twice the window size so brief resize gaps
/// are never visible. The image is tinted `Color::WHITE` (no tint) so the PNG
/// pixel data is rendered as-is.
fn spawn_background(commands: &mut Commands, window_size: Vec2, image: Handle<Image>) {
// Spawn a sprite covering the window. We give it the window size plus
// headroom so resizing up doesn't expose edges before the resize handler
// runs.
commands.spawn((
Sprite {
color,
image,
color: Color::WHITE,
custom_size: Some(window_size * 2.0),
..default()
},
@@ -132,16 +204,30 @@ fn spawn_background(commands: &mut Commands, window_size: Vec2, color: Color) {
));
}
/// Reacts to settings changes by updating the background sprite's image handle.
///
/// When [`BackgroundImageSet`] is available the selected PNG handle is applied
/// directly (color is kept at `Color::WHITE` so the PNG pixel data shows
/// unmodified). If the resource is not yet ready the sprite is left unchanged.
fn apply_theme_on_settings_change(
mut events: MessageReader<SettingsChangedEvent>,
mut backgrounds: Query<&mut Sprite, With<TableBackground>>,
bg_images: Option<Res<BackgroundImageSet>>,
) {
let Some(ev) = events.read().last() else {
return;
};
let colour = effective_background_colour(&ev.0.theme, ev.0.selected_background);
let Some(set) = bg_images else {
// BackgroundImageSet not ready yet — leave sprite unchanged.
return;
};
let selected = ev.0.selected_background;
let Some(handle) = set.handles.get(selected).cloned() else {
return;
};
for mut sprite in &mut backgrounds {
sprite.color = colour;
sprite.image = handle.clone();
sprite.color = Color::WHITE;
}
}