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>
@@ -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 0–4 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 |
|
||||
|
||||
@@ -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" }
|
||||
|
||||
|
After Width: | Height: | Size: 451 B |
|
After Width: | Height: | Size: 218 B |
|
After Width: | Height: | Size: 247 B |
|
After Width: | Height: | Size: 559 B |
|
After Width: | Height: | Size: 675 B |
|
After Width: | Height: | Size: 221 B |
|
After Width: | Height: | Size: 943 B |
|
After Width: | Height: | Size: 344 B |
|
After Width: | Height: | Size: 337 B |
|
After Width: | Height: | Size: 299 B |
|
After Width: | Height: | Size: 213 B |
@@ -12,3 +12,4 @@ path = "src/main.rs"
|
||||
bevy = { workspace = true }
|
||||
solitaire_engine = { workspace = true }
|
||||
solitaire_data = { workspace = true }
|
||||
keyring = { workspace = true }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
|
||||
|
||||
@@ -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 0–4).
|
||||
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.
|
||||
// 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 {
|
||||
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
|
||||
} else {
|
||||
card_back_colour(back_idx)
|
||||
if is_face_up { CARD_FACE_COLOUR } else { card_back_colour(back_idx) }
|
||||
};
|
||||
commands
|
||||
.entity(entity)
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 1–4 are unlockable.
|
||||
#[derive(Resource)]
|
||||
pub struct BackgroundImageSet {
|
||||
/// One handle per background slot (indices 0–4).
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||