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
+60 -22
View File
@@ -3,7 +3,7 @@
> **Version:** 1.1
> **Language:** Rust (Edition 2021)
> **Engine:** Bevy (latest stable)
> **Last Updated:** 2026-04-20
> **Last Updated:** 2026-04-29
---
@@ -67,18 +67,19 @@ solitaire_quest/
├── Dockerfile # Multi-stage server build
├── docker-compose.yml # Server + Caddy reverse proxy
├── assets/ # Audio embedded at compile time via include_bytes!()
│ ├── cards/ # Reserved for future art pass (currently unused)
│ │ ├── faces/
│ │ └── backs/
│ ├── backgrounds/ # Reserved for future art pass (currently unused)
│ ├── fonts/ # Reserved for future art pass (currently unused)
├── assets/ # Assets embedded at compile time via include_bytes!()
│ ├── cards/
│ │ ├── faces/face.png # placeholder (16×16 cream/ivory)
│ │ └── backs/back_0.png back_4.png # placeholder patterns
│ ├── backgrounds/bg_0.png bg_4.png # placeholder textures
│ ├── fonts/main.ttf # FiraMono-Medium (170K, OFL)
│ └── audio/
│ ├── card_deal.wav
│ ├── card_flip.wav
│ ├── card_place.wav
│ ├── card_invalid.wav
── win_fanfare.wav
── win_fanfare.wav
│ └── ambient_loop.wav
├── solitaire_core/ # Pure Rust game logic — zero external deps beyond rand/serde
├── solitaire_sync/ # Shared API types — used by client and server
@@ -143,6 +144,7 @@ Owns:
- All Bevy UI screens (Home, Stats, Achievements, Settings, Profile)
- Audio playback systems
- Sync status display
- Card, background, and font asset loading (embedded via `include_bytes!()` — no `AssetServer` dependency)
### `solitaire_server`
**Dependencies:** `solitaire_sync`, `axum`, `sqlx`, `jsonwebtoken`, `bcrypt`, `tower-governor`, `tracing`, `tokio`, `dotenvy`.
@@ -237,6 +239,7 @@ Done
|---|---|---|
| `CardPlugin` | — | Card entity spawning, sprite management, drag-and-drop |
| `TablePlugin` | — | Pile markers, background, layout calculation |
| `FontPlugin` | — | Embeds FiraMono-Medium font at compile time; exposes `FontResource` handle |
| `AnimationPlugin` | — | Slide, flip, win cascade, toast animations |
| `FeedbackAnimPlugin` | — | Shake, settle, and deal-stagger animations |
| `AutoCompletePlugin` | Enter | Executes auto-complete when the HUD badge is lit |
@@ -286,6 +289,20 @@ struct StatsResource(StatsSnapshot);
struct ProgressResource(PlayerProgress);
struct AchievementsResource(Vec<AchievementRecord>);
struct SettingsResource(Settings);
// Pre-loaded card face and back PNG handles
struct CardImageSet {
face: Handle<Image>, // shared face image for all face-up cards
backs: [Handle<Image>; 5], // indexed by selected_card_back setting
}
// Project-wide font handle (FiraMono-Medium embedded at compile time)
struct FontResource(Handle<Font>);
// Pre-loaded background PNG handles
struct BackgroundImageSet {
handles: Vec<Handle<Image>>, // indices 04 match selected_background setting
}
```
### Key Bevy Events
@@ -743,7 +760,7 @@ Audio uses `bevy_kira_audio`. All sound files are `.wav`.
| `card_place.wav` | Valid card placement |
| `card_invalid.wav` | Invalid move attempt |
| `win_fanfare.wav` | Game won |
| `ambient_loop` | Looping background music — uses `card_flip.wav` looped at very low volume as a placeholder until a dedicated track is added |
| `ambient_loop.wav` | Looping background music |
Volume is controlled by two independent sliders in Settings (`sfx_volume`, `music_volume`), each stored in `Settings` and applied as `bevy_kira_audio` channel volumes.
@@ -755,30 +772,49 @@ Audio systems listen for Bevy events and never block the game thread.
### Rendering approach
Cards, backgrounds, and UI are rendered **procedurally** — no image files are used. Cards are Bevy `Sprite` entities (colored rectangles) with `Text` children showing rank and suit symbols. Card back colors and background colors are selected by index from compile-time color tables in `card_plugin.rs` and `table_plugin.rs`. All UI text uses Bevy's built-in default font.
Cards are Bevy `Sprite` entities with `Handle<Image>` from `CardImageSet`. Face-up cards use `face.png` (a single shared image). Face-down cards use `backs/back_N.png` indexed by `settings.selected_card_back`. `Text2d` labels are still overlaid for rank and suit symbols. `CardImageSet` is populated at startup from `include_bytes!()` — no `AssetServer`.
This means the `assets/cards/`, `assets/backgrounds/`, and `assets/fonts/` directories are reserved for a future art pass and are currently empty (`.gitkeep` only).
Backgrounds are Bevy `Sprite` entities with `Handle<Image>` from `BackgroundImageSet`. `BackgroundImageSet` is populated at startup from `include_bytes!()`.
The font `FiraMono-Medium` is embedded via `include_bytes!()` at startup by `FontPlugin` and exposed as `FontResource` for use by all UI and text systems.
The `assets/` directory layout:
```
assets/
├── cards/
│ ├── faces/face.png # placeholder (16×16 cream/ivory)
│ └── backs/back_0.png back_4.png # placeholder patterns
├── backgrounds/bg_0.png bg_4.png # placeholder textures
├── fonts/main.ttf # FiraMono-Medium (170K, OFL)
└── audio/
├── card_deal.wav
├── card_flip.wav
├── card_place.wav
├── card_invalid.wav
├── win_fanfare.wav
└── ambient_loop.wav
```
### Audio
All five sound effect WAV files are embedded at compile time via `include_bytes!()` in `audio_plugin.rs`. There is no runtime asset loading — the binary is fully self-contained.
All sound effect WAV files are embedded at compile time via `include_bytes!()` in `audio_plugin.rs`. There is no runtime asset loading — the binary is fully self-contained.
| File | Size |
| File | Trigger |
|---|---|
| `card_deal.wav` | SFX |
| `card_flip.wav` | SFX |
| `card_place.wav` | SFX |
| `card_invalid.wav` | SFX |
| `win_fanfare.wav` | SFX |
The ambient music loop reuses `card_flip.wav` at very low volume as a placeholder; a dedicated `ambient_loop.wav` can be dropped into `assets/audio/` and wired into `audio_plugin.rs` when ready.
| `card_deal.wav` | New game deal animation |
| `card_flip.wav` | Card flips face-up |
| `card_place.wav` | Valid card placement |
| `card_invalid.wav` | Invalid move attempt |
| `win_fanfare.wav` | Game won |
| `ambient_loop.wav` | Looping background music |
### Future art pass
When image-based card art is added, the recommended approach is:
- Embed assets via `bevy::asset::embedded_asset!()` macro (keeps the binary self-contained)
The placeholder PNG files can be replaced with real artwork without any code changes — just drop in new PNGs and rebuild. The texture atlas approach described below is still the recommended upgrade path for card faces:
- Use a texture atlas (`assets/cards/atlas.png` + layout descriptor) for card faces
- Individual PNGs for card backs and backgrounds (5 each)
- All assets remain embedded via `include_bytes!()` to keep the binary self-contained
---
@@ -975,3 +1011,5 @@ Using `axum::test` and an in-memory SQLite database:
| `SyncProvider` trait, not `SyncBackend` match arms | `SyncPlugin` stays backend-agnostic and testable; new backends can be added without touching the plugin | 2026-04-20 |
| Dropped WebDAV backend | Redundant once the self-hosted server exists; removing it reduces surface area and simplifies settings UI | 2026-04-20 |
| Dropped GPGS backend | Redundant with the self-hosted server; adds JNI complexity for no user-visible benefit on the target platforms | 2026-04-28 |
| PNG assets embedded via `include_bytes!()` | Using `Image::from_buffer()` in startup systems rather than `AssetServer::load()` keeps the binary self-contained and eliminates runtime file-not-found errors | 2026-04-29 |
| FiraMono-Medium font embedded via `include_bytes!()` | Exposed through `FontResource`; avoids runtime font loading errors on headless systems and ensures consistent text rendering across all platforms | 2026-04-29 |
Generated
+1793 -390
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -25,7 +25,8 @@ rand = "0.8"
async-trait = "0.1"
tokio = { version = "1", features = ["full"] }
dirs = "6"
keyring = "2"
keyring = "4"
keyring-core = "1"
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
solitaire_core = { path = "solitaire_core" }
Binary file not shown.

After

Width:  |  Height:  |  Size: 451 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 943 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 B

Binary file not shown.
+1
View File
@@ -12,3 +12,4 @@ path = "src/main.rs"
bevy = { workspace = true }
solitaire_engine = { workspace = true }
solitaire_data = { workspace = true }
keyring = { workspace = true }
+14 -1
View File
@@ -3,12 +3,24 @@ use solitaire_data::{load_settings_from, provider_for_backend, settings_file_pat
use solitaire_engine::{
AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardAnimationPlugin,
CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin,
GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin,
StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
};
fn main() {
// Initialise the platform keyring store before any token operations.
// On Linux this uses the Secret Service (GNOME Keyring / KWallet); on
// macOS it uses the Keychain; on Windows it uses the Credential store.
// If the platform has no OS keyring (e.g. a headless CI box), keyring
// operations will fail gracefully with TokenError::KeychainUnavailable.
if let Err(e) = keyring::use_native_store(true) {
eprintln!(
"warn: could not initialise OS keyring ({e}); \
server sync login will be unavailable"
);
}
// Load settings before building the app so we can construct the right
// sync provider. Falls back to defaults if no settings file exists yet.
let settings: Settings = settings_file_path()
@@ -32,6 +44,7 @@ fn main() {
..default()
}),
)
.add_plugins(FontPlugin)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(CardPlugin)
+9 -1
View File
@@ -5,9 +5,17 @@ license.workspace = true
edition.workspace = true
publish = false
# Dev-only utility: synthesizes placeholder SFX WAV files into `assets/audio/`.
# Dev-only utility: synthesizes placeholder SFX WAV files into `assets/audio/`
# and placeholder PNG images into `assets/cards/` and `assets/backgrounds/`.
# Not depended on by any other workspace crate.
[dependencies]
png = "0.17"
[[bin]]
name = "gen_sfx"
path = "src/bin/gen_sfx.rs"
[[bin]]
name = "gen_art"
path = "src/bin/gen_art.rs"
+229
View File
@@ -0,0 +1,229 @@
//! Generates placeholder PNG assets for card faces, card backs, and table
//! backgrounds. All images are 16×16 pixels — Bevy's Sprite scales them via
//! `custom_size`, so small files keep the repository lightweight.
//!
//! Run with:
//! ```
//! cargo run -p solitaire_assetgen --bin gen_art
//! ```
use std::fs::File;
use std::io::BufWriter;
use std::path::Path;
// ---------------------------------------------------------------------------
// PNG helper
// ---------------------------------------------------------------------------
/// Write a 16×16 RGBA image to `path`. `pixels` is a flat `[R,G,B,A, ...]`
/// byte array with exactly 16 * 16 * 4 = 1024 bytes.
fn save_png(path: &Path, pixels: &[u8; 1024]) {
let file = File::create(path)
.unwrap_or_else(|e| panic!("cannot create {}: {e}", path.display()));
let mut w = BufWriter::new(file);
let mut encoder = png::Encoder::new(&mut w, 16, 16);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder
.write_header()
.unwrap_or_else(|e| panic!("png header error for {}: {e}", path.display()));
writer
.write_image_data(pixels)
.unwrap_or_else(|e| panic!("png data error for {}: {e}", path.display()));
}
/// Build a flat 16×16 RGBA pixel array using a per-pixel closure.
fn make_image<F: Fn(u32, u32) -> [u8; 4]>(f: F) -> [u8; 1024] {
let mut pixels = [0u8; 1024];
for y in 0u32..16 {
for x in 0u32..16 {
let rgba = f(x, y);
let i = ((y * 16 + x) * 4) as usize;
pixels[i..i + 4].copy_from_slice(&rgba);
}
}
pixels
}
// ---------------------------------------------------------------------------
// Card face
// ---------------------------------------------------------------------------
/// Cream/ivory solid fill — represents a blank card face.
fn make_face() -> [u8; 1024] {
make_image(|_, _| [0xF8, 0xF8, 0xF0, 0xFF])
}
// ---------------------------------------------------------------------------
// Card backs (match the colours used in card_plugin.rs `card_back_colour()`)
// ---------------------------------------------------------------------------
/// back_0 — blue base with semi-transparent white horizontal stripes every 4 px.
fn make_back_0() -> [u8; 1024] {
make_image(|_, y| {
if y % 4 < 2 {
[0xFF, 0xFF, 0xFF, 40]
} else {
[0x26, 0x4D, 0x8C, 0xFF]
}
})
}
/// back_1 — red base with semi-transparent white diagonal stripes.
fn make_back_1() -> [u8; 1024] {
make_image(|x, y| {
if (x + y) % 4 < 2 {
[0xFF, 0xFF, 0xFF, 40]
} else {
[0x8C, 0x1A, 0x1A, 0xFF]
}
})
}
/// back_2 — green base with white dots at every 4-px grid intersection.
fn make_back_2() -> [u8; 1024] {
make_image(|x, y| {
if x % 4 == 0 && y % 4 == 0 {
[0xFF, 0xFF, 0xFF, 0xFF]
} else {
[0x0D, 0x66, 0x1A, 0xFF]
}
})
}
/// back_3 — purple base with a white diamond centred at (8, 8).
fn make_back_3() -> [u8; 1024] {
make_image(|x, y| {
let dx = (x as i32 - 8).unsigned_abs();
let dy = (y as i32 - 8).unsigned_abs();
if dx + dy <= 4 {
[0xFF, 0xFF, 0xFF, 0xFF]
} else {
[0x59, 0x14, 0x85, 0xFF]
}
})
}
/// back_4 — teal base with a 1-px white border.
fn make_back_4() -> [u8; 1024] {
make_image(|x, y| {
if x == 0 || x == 15 || y == 0 || y == 15 {
[0xFF, 0xFF, 0xFF, 0xFF]
} else {
[0x0D, 0x66, 0x6B, 0xFF]
}
})
}
// ---------------------------------------------------------------------------
// Backgrounds
// ---------------------------------------------------------------------------
/// bg_0 — dark green felt with very faint lighter grid lines every 8 px.
fn make_bg_0() -> [u8; 1024] {
make_image(|x, y| {
if x % 8 == 0 || y % 8 == 0 {
[0xFF, 0xFF, 0xFF, 30]
} else {
[0x1A, 0x4D, 0x1A, 0xFF]
}
})
}
/// bg_1 — dark wood brown with faint horizontal grain lines every 2 px.
fn make_bg_1() -> [u8; 1024] {
make_image(|_, y| {
if y % 2 == 0 {
[0xFF, 0xFF, 0xFF, 20]
} else {
[0x40, 0x2D, 0x1A, 0xFF]
}
})
}
/// bg_2 — navy with faint star/dot pattern (offset rows) every 8 px.
fn make_bg_2() -> [u8; 1024] {
make_image(|x, y| {
let row_offset: u32 = if (y / 4) % 2 == 0 { 0 } else { 4 };
if (x + row_offset) % 8 == 0 && y % 8 == 0 {
[0xFF, 0xFF, 0xFF, 0xFF]
} else {
[0x0D, 0x14, 0x38, 0xFF]
}
})
}
/// bg_3 — burgundy with a faint diamond-grid pattern.
fn make_bg_3() -> [u8; 1024] {
make_image(|x, y| {
if (x + y) % 8 == 0 {
[0xFF, 0xFF, 0xFF, 30]
} else {
[0x4D, 0x0D, 0x14, 0xFF]
}
})
}
/// bg_4 — charcoal with faint pixel noise (alternating pixels every 3 columns).
fn make_bg_4() -> [u8; 1024] {
make_image(|x, y| {
if (x + y) % 2 == 0 && x % 3 == 0 {
[0xFF, 0xFF, 0xFF, 20]
} else {
[0x1F, 0x1F, 0x24, 0xFF]
}
})
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
fn workspace_root() -> std::path::PathBuf {
let crate_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
crate_dir.parent().unwrap().to_path_buf()
}
fn main() {
let root = workspace_root();
// Ensure output directories exist.
std::fs::create_dir_all(root.join("assets/cards/faces")).unwrap();
std::fs::create_dir_all(root.join("assets/cards/backs")).unwrap();
std::fs::create_dir_all(root.join("assets/backgrounds")).unwrap();
// Card face.
let path = root.join("assets/cards/faces/face.png");
save_png(&path, &make_face());
println!("wrote {}", path.display());
// Card backs.
let backs = [
make_back_0(),
make_back_1(),
make_back_2(),
make_back_3(),
make_back_4(),
];
for (i, pixels) in backs.iter().enumerate() {
let path = root.join(format!("assets/cards/backs/back_{i}.png"));
save_png(&path, pixels);
println!("wrote {}", path.display());
}
// Backgrounds.
let bgs = [
make_bg_0(),
make_bg_1(),
make_bg_2(),
make_bg_3(),
make_bg_4(),
];
for (i, pixels) in bgs.iter().enumerate() {
let path = root.join(format!("assets/backgrounds/bg_{i}.png"));
save_png(&path, pixels);
println!("wrote {}", path.display());
}
println!("gen_art: all placeholder PNG assets generated successfully.");
}
+1 -1
View File
@@ -13,6 +13,6 @@ chrono = { workspace = true }
thiserror = { workspace = true }
async-trait = { workspace = true }
dirs = { workspace = true }
keyring = { workspace = true }
keyring-core = { workspace = true }
reqwest = { workspace = true }
tokio = { workspace = true }
+16 -9
View File
@@ -8,9 +8,15 @@
//! [`TokenError::KeychainUnavailable`] — callers should fall back to prompting
//! the user to log in again.
//!
//! Before calling any function in this module the application must initialise
//! the default keyring store exactly once at startup by calling
//! `keyring::use_native_store` (e.g. in `solitaire_app::main` before building
//! the Bevy `App`). If no default store is set, all operations in this module
//! will return [`TokenError::KeychainUnavailable`].
//!
//! # Note: no unit tests — requires live OS keychain.
use keyring::Entry;
use keyring_core::Entry;
use thiserror::Error;
/// Errors that can occur when reading or writing tokens in the OS keychain.
@@ -30,12 +36,13 @@ pub enum TokenError {
/// Service name used to namespace all keychain entries for this application.
const SERVICE: &str = "solitaire_quest_server";
/// Map a `keyring::Error` to the appropriate `TokenError`.
fn map_keyring_err(err: keyring::Error, username: &str) -> TokenError {
/// Map a `keyring_core::Error` to the appropriate `TokenError`.
fn map_keyring_err(err: keyring_core::Error, username: &str) -> TokenError {
let msg = err.to_string();
match err {
keyring::Error::NoStorageAccess(_) => TokenError::KeychainUnavailable(msg),
keyring::Error::NoEntry => TokenError::NotFound(username.to_string()),
keyring_core::Error::NoStorageAccess(_) => TokenError::KeychainUnavailable(msg),
keyring_core::Error::NoDefaultStore => TokenError::KeychainUnavailable(msg),
keyring_core::Error::NoEntry => TokenError::NotFound(username.to_string()),
_ => TokenError::Keyring(msg),
}
}
@@ -88,17 +95,17 @@ pub fn load_refresh_token(username: &str) -> Result<String, TokenError> {
pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
match Entry::new(SERVICE, &format!("{username}_access"))
.map_err(|e| map_keyring_err(e, username))?
.delete_password()
.delete_credential()
{
Ok(()) | Err(keyring::Error::NoEntry) => {}
Ok(()) | Err(keyring_core::Error::NoEntry) => {}
Err(e) => return Err(map_keyring_err(e, username)),
}
match Entry::new(SERVICE, &format!("{username}_refresh"))
.map_err(|e| map_keyring_err(e, username))?
.delete_password()
.delete_credential()
{
Ok(()) | Err(keyring::Error::NoEntry) => {}
Ok(()) | Err(keyring_core::Error::NoEntry) => {}
Err(e) => return Err(map_keyring_err(e, username)),
}
+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;
}
}