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
|
> **Version:** 1.1
|
||||||
> **Language:** Rust (Edition 2021)
|
> **Language:** Rust (Edition 2021)
|
||||||
> **Engine:** Bevy (latest stable)
|
> **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
|
├── Dockerfile # Multi-stage server build
|
||||||
├── docker-compose.yml # Server + Caddy reverse proxy
|
├── docker-compose.yml # Server + Caddy reverse proxy
|
||||||
│
|
│
|
||||||
├── assets/ # Audio embedded at compile time via include_bytes!()
|
├── assets/ # Assets embedded at compile time via include_bytes!()
|
||||||
│ ├── cards/ # Reserved for future art pass (currently unused)
|
│ ├── cards/
|
||||||
│ │ ├── faces/
|
│ │ ├── faces/face.png # placeholder (16×16 cream/ivory)
|
||||||
│ │ └── backs/
|
│ │ └── backs/back_0.png – back_4.png # placeholder patterns
|
||||||
│ ├── backgrounds/ # Reserved for future art pass (currently unused)
|
│ ├── backgrounds/bg_0.png – bg_4.png # placeholder textures
|
||||||
│ ├── fonts/ # Reserved for future art pass (currently unused)
|
│ ├── fonts/main.ttf # FiraMono-Medium (170K, OFL)
|
||||||
│ └── audio/
|
│ └── audio/
|
||||||
│ ├── card_deal.wav
|
│ ├── card_deal.wav
|
||||||
│ ├── card_flip.wav
|
│ ├── card_flip.wav
|
||||||
│ ├── card_place.wav
|
│ ├── card_place.wav
|
||||||
│ ├── card_invalid.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_core/ # Pure Rust game logic — zero external deps beyond rand/serde
|
||||||
├── solitaire_sync/ # Shared API types — used by client and server
|
├── solitaire_sync/ # Shared API types — used by client and server
|
||||||
@@ -143,6 +144,7 @@ Owns:
|
|||||||
- All Bevy UI screens (Home, Stats, Achievements, Settings, Profile)
|
- All Bevy UI screens (Home, Stats, Achievements, Settings, Profile)
|
||||||
- Audio playback systems
|
- Audio playback systems
|
||||||
- Sync status display
|
- Sync status display
|
||||||
|
- Card, background, and font asset loading (embedded via `include_bytes!()` — no `AssetServer` dependency)
|
||||||
|
|
||||||
### `solitaire_server`
|
### `solitaire_server`
|
||||||
**Dependencies:** `solitaire_sync`, `axum`, `sqlx`, `jsonwebtoken`, `bcrypt`, `tower-governor`, `tracing`, `tokio`, `dotenvy`.
|
**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 |
|
| `CardPlugin` | — | Card entity spawning, sprite management, drag-and-drop |
|
||||||
| `TablePlugin` | — | Pile markers, background, layout calculation |
|
| `TablePlugin` | — | Pile markers, background, layout calculation |
|
||||||
|
| `FontPlugin` | — | Embeds FiraMono-Medium font at compile time; exposes `FontResource` handle |
|
||||||
| `AnimationPlugin` | — | Slide, flip, win cascade, toast animations |
|
| `AnimationPlugin` | — | Slide, flip, win cascade, toast animations |
|
||||||
| `FeedbackAnimPlugin` | — | Shake, settle, and deal-stagger animations |
|
| `FeedbackAnimPlugin` | — | Shake, settle, and deal-stagger animations |
|
||||||
| `AutoCompletePlugin` | Enter | Executes auto-complete when the HUD badge is lit |
|
| `AutoCompletePlugin` | Enter | Executes auto-complete when the HUD badge is lit |
|
||||||
@@ -286,6 +289,20 @@ struct StatsResource(StatsSnapshot);
|
|||||||
struct ProgressResource(PlayerProgress);
|
struct ProgressResource(PlayerProgress);
|
||||||
struct AchievementsResource(Vec<AchievementRecord>);
|
struct AchievementsResource(Vec<AchievementRecord>);
|
||||||
struct SettingsResource(Settings);
|
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
|
### Key Bevy Events
|
||||||
@@ -743,7 +760,7 @@ Audio uses `bevy_kira_audio`. All sound files are `.wav`.
|
|||||||
| `card_place.wav` | Valid card placement |
|
| `card_place.wav` | Valid card placement |
|
||||||
| `card_invalid.wav` | Invalid move attempt |
|
| `card_invalid.wav` | Invalid move attempt |
|
||||||
| `win_fanfare.wav` | Game won |
|
| `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.
|
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
|
### 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
|
### 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_deal.wav` | New game deal animation |
|
||||||
| `card_flip.wav` | SFX |
|
| `card_flip.wav` | Card flips face-up |
|
||||||
| `card_place.wav` | SFX |
|
| `card_place.wav` | Valid card placement |
|
||||||
| `card_invalid.wav` | SFX |
|
| `card_invalid.wav` | Invalid move attempt |
|
||||||
| `win_fanfare.wav` | SFX |
|
| `win_fanfare.wav` | Game won |
|
||||||
|
| `ambient_loop.wav` | Looping background music |
|
||||||
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.
|
|
||||||
|
|
||||||
### Future art pass
|
### Future art pass
|
||||||
|
|
||||||
When image-based card art is added, the recommended approach is:
|
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:
|
||||||
- Embed assets via `bevy::asset::embedded_asset!()` macro (keeps the binary self-contained)
|
|
||||||
- Use a texture atlas (`assets/cards/atlas.png` + layout descriptor) 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)
|
- 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 |
|
| `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 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 |
|
| 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"
|
async-trait = "0.1"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
keyring = "2"
|
keyring = "4"
|
||||||
|
keyring-core = "1"
|
||||||
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
|
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
|
||||||
|
|
||||||
solitaire_core = { path = "solitaire_core" }
|
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 }
|
bevy = { workspace = true }
|
||||||
solitaire_engine = { workspace = true }
|
solitaire_engine = { workspace = true }
|
||||||
solitaire_data = { 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::{
|
use solitaire_engine::{
|
||||||
AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardAnimationPlugin,
|
AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardAnimationPlugin,
|
||||||
CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin,
|
CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin,
|
||||||
GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
||||||
OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin,
|
OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin,
|
||||||
StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn main() {
|
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
|
// Load settings before building the app so we can construct the right
|
||||||
// sync provider. Falls back to defaults if no settings file exists yet.
|
// sync provider. Falls back to defaults if no settings file exists yet.
|
||||||
let settings: Settings = settings_file_path()
|
let settings: Settings = settings_file_path()
|
||||||
@@ -32,6 +44,7 @@ fn main() {
|
|||||||
..default()
|
..default()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.add_plugins(FontPlugin)
|
||||||
.add_plugins(GamePlugin)
|
.add_plugins(GamePlugin)
|
||||||
.add_plugins(TablePlugin)
|
.add_plugins(TablePlugin)
|
||||||
.add_plugins(CardPlugin)
|
.add_plugins(CardPlugin)
|
||||||
|
|||||||
@@ -5,9 +5,17 @@ license.workspace = true
|
|||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
publish = false
|
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.
|
# Not depended on by any other workspace crate.
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
png = "0.17"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "gen_sfx"
|
name = "gen_sfx"
|
||||||
path = "src/bin/gen_sfx.rs"
|
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 }
|
thiserror = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
dirs = { workspace = true }
|
dirs = { workspace = true }
|
||||||
keyring = { workspace = true }
|
keyring-core = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|||||||
@@ -8,9 +8,15 @@
|
|||||||
//! [`TokenError::KeychainUnavailable`] — callers should fall back to prompting
|
//! [`TokenError::KeychainUnavailable`] — callers should fall back to prompting
|
||||||
//! the user to log in again.
|
//! 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.
|
//! # Note: no unit tests — requires live OS keychain.
|
||||||
|
|
||||||
use keyring::Entry;
|
use keyring_core::Entry;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
/// Errors that can occur when reading or writing tokens in the OS keychain.
|
/// 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.
|
/// Service name used to namespace all keychain entries for this application.
|
||||||
const SERVICE: &str = "solitaire_quest_server";
|
const SERVICE: &str = "solitaire_quest_server";
|
||||||
|
|
||||||
/// Map a `keyring::Error` to the appropriate `TokenError`.
|
/// Map a `keyring_core::Error` to the appropriate `TokenError`.
|
||||||
fn map_keyring_err(err: keyring::Error, username: &str) -> TokenError {
|
fn map_keyring_err(err: keyring_core::Error, username: &str) -> TokenError {
|
||||||
let msg = err.to_string();
|
let msg = err.to_string();
|
||||||
match err {
|
match err {
|
||||||
keyring::Error::NoStorageAccess(_) => TokenError::KeychainUnavailable(msg),
|
keyring_core::Error::NoStorageAccess(_) => TokenError::KeychainUnavailable(msg),
|
||||||
keyring::Error::NoEntry => TokenError::NotFound(username.to_string()),
|
keyring_core::Error::NoDefaultStore => TokenError::KeychainUnavailable(msg),
|
||||||
|
keyring_core::Error::NoEntry => TokenError::NotFound(username.to_string()),
|
||||||
_ => TokenError::Keyring(msg),
|
_ => 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> {
|
pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
|
||||||
match Entry::new(SERVICE, &format!("{username}_access"))
|
match Entry::new(SERVICE, &format!("{username}_access"))
|
||||||
.map_err(|e| map_keyring_err(e, username))?
|
.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)),
|
Err(e) => return Err(map_keyring_err(e, username)),
|
||||||
}
|
}
|
||||||
|
|
||||||
match Entry::new(SERVICE, &format!("{username}_refresh"))
|
match Entry::new(SERVICE, &format!("{username}_refresh"))
|
||||||
.map_err(|e| map_keyring_err(e, username))?
|
.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)),
|
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 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);
|
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
|
/// 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.
|
/// 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);
|
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::<SettingsChangedEvent>()
|
||||||
.add_message::<CardFlippedEvent>()
|
.add_message::<CardFlippedEvent>()
|
||||||
.add_message::<CardFaceRevealedEvent>()
|
.add_message::<CardFaceRevealedEvent>()
|
||||||
|
.add_systems(Startup, load_card_images)
|
||||||
.add_systems(PostStartup, (sync_cards_startup, update_stock_empty_indicator_startup))
|
.add_systems(PostStartup, (sync_cards_startup, update_stock_empty_indicator_startup))
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
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
|
/// 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).
|
/// new back colour is applied immediately (without waiting for a state change).
|
||||||
fn resync_cards_on_settings_change(
|
fn resync_cards_on_settings_change(
|
||||||
@@ -201,14 +290,14 @@ fn sync_cards_startup(
|
|||||||
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
||||||
settings: Option<Res<SettingsResource>>,
|
settings: Option<Res<SettingsResource>>,
|
||||||
entities: Query<(Entity, &CardEntity, &Transform)>,
|
entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||||
|
card_images: Option<Res<CardImageSet>>,
|
||||||
) {
|
) {
|
||||||
if let Some(layout) = layout {
|
if let Some(layout) = layout {
|
||||||
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
|
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
|
||||||
let back_colour = settings
|
let selected_back = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
|
||||||
.as_ref()
|
let back_colour = card_back_colour(selected_back);
|
||||||
.map_or_else(|| card_back_colour(0), |s| card_back_colour(s.0.selected_card_back));
|
|
||||||
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
|
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>>,
|
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
||||||
settings: Option<Res<SettingsResource>>,
|
settings: Option<Res<SettingsResource>>,
|
||||||
entities: Query<(Entity, &CardEntity, &Transform)>,
|
entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||||
|
card_images: Option<Res<CardImageSet>>,
|
||||||
) {
|
) {
|
||||||
if events.read().next().is_none() {
|
if events.read().next().is_none() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Some(layout) = layout {
|
if let Some(layout) = layout {
|
||||||
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
|
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
|
||||||
let back_colour = settings
|
let selected_back = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
|
||||||
.as_ref()
|
let back_colour = card_back_colour(selected_back);
|
||||||
.map_or_else(|| card_back_colour(0), |s| card_back_colour(s.0.selected_card_back));
|
|
||||||
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
|
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,
|
back_colour: Color,
|
||||||
color_blind: bool,
|
color_blind: bool,
|
||||||
entities: &Query<(Entity, &CardEntity, &Transform)>,
|
entities: &Query<(Entity, &CardEntity, &Transform)>,
|
||||||
|
card_images: Option<&CardImageSet>,
|
||||||
|
selected_back: usize,
|
||||||
) {
|
) {
|
||||||
let positions = card_positions(game, layout);
|
let positions = card_positions(game, layout);
|
||||||
|
|
||||||
@@ -266,10 +357,10 @@ fn sync_cards(
|
|||||||
Some(&(entity, cur)) => {
|
Some(&(entity, cur)) => {
|
||||||
update_card_entity(
|
update_card_entity(
|
||||||
&mut commands, entity, card, position, z, layout,
|
&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) {
|
fn spawn_card_entity(
|
||||||
let body_colour = if card.face_up {
|
commands: &mut Commands,
|
||||||
face_colour(card, color_blind)
|
card: &Card,
|
||||||
} else {
|
pos: Vec2,
|
||||||
back_colour
|
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
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
CardEntity { card_id: card.id },
|
CardEntity { card_id: card.id },
|
||||||
Sprite {
|
sprite,
|
||||||
color: body_colour,
|
|
||||||
custom_size: Some(layout.card_size),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
Transform::from_xyz(pos.x, pos.y, z),
|
Transform::from_xyz(pos.x, pos.y, z),
|
||||||
Visibility::default(),
|
Visibility::default(),
|
||||||
))
|
))
|
||||||
@@ -405,21 +498,13 @@ fn update_card_entity(
|
|||||||
back_colour: Color,
|
back_colour: Color,
|
||||||
color_blind: bool,
|
color_blind: bool,
|
||||||
cur: Vec3,
|
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);
|
let target = Vec3::new(pos.x, pos.y, z);
|
||||||
|
|
||||||
// Always refresh the visual appearance.
|
// Always refresh the visual appearance.
|
||||||
commands.entity(entity).insert(Sprite {
|
commands.entity(entity).insert(card_sprite(card, layout.card_size, back_colour, color_blind, card_images, selected_back));
|
||||||
color: body_colour,
|
|
||||||
custom_size: Some(layout.card_size),
|
|
||||||
..default()
|
|
||||||
});
|
|
||||||
|
|
||||||
// Slide to the new position when it differs meaningfully; snap otherwise.
|
// Slide to the new position when it differs meaningfully; snap otherwise.
|
||||||
if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 {
|
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)>,
|
mut query: Query<(Entity, &mut HintHighlight, &mut Sprite, &CardEntity)>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
settings: Option<Res<SettingsResource>>,
|
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 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() {
|
for (entity, mut hint, mut sprite, card_entity) in query.iter_mut() {
|
||||||
hint.remaining -= time.delta_secs();
|
hint.remaining -= time.delta_secs();
|
||||||
if hint.remaining <= 0.0 {
|
if hint.remaining <= 0.0 {
|
||||||
// Restore normal face-up colour.
|
// Restore the normal sprite colour.
|
||||||
let is_face_up = game.0.piles.values()
|
// When image-based rendering is active, WHITE is the neutral tint;
|
||||||
.flat_map(|p| p.cards.iter())
|
// otherwise restore the solid colour appropriate to the card state.
|
||||||
.find(|c| c.id == card_entity.card_id)
|
sprite.color = if use_images {
|
||||||
.is_some_and(|c| c.face_up);
|
Color::WHITE
|
||||||
sprite.color = if is_face_up {
|
|
||||||
CARD_FACE_COLOUR
|
|
||||||
} else {
|
} 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
|
commands
|
||||||
.entity(entity)
|
.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::auto_complete_plugin::AutoCompleteState;
|
||||||
use crate::daily_challenge_plugin::DailyChallengeResource;
|
use crate::daily_challenge_plugin::DailyChallengeResource;
|
||||||
use crate::events::InfoToastEvent;
|
use crate::events::InfoToastEvent;
|
||||||
|
use crate::font_plugin::FontResource;
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
use crate::selection_plugin::SelectionState;
|
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 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
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
Node {
|
Node {
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ fn handle_keyboard_hint(
|
|||||||
mut confirm: ResMut<KeyboardConfirmState>,
|
mut confirm: ResMut<KeyboardConfirmState>,
|
||||||
mut hint_cycle: ResMut<HintCycleIndex>,
|
mut hint_cycle: ResMut<HintCycleIndex>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
card_entities: Query<(Entity, &CardEntity, &Sprite)>,
|
mut card_entities: Query<(Entity, &CardEntity, &mut Sprite)>,
|
||||||
mut info_toast: MessageWriter<InfoToastEvent>,
|
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||||
mut hint_visual: MessageWriter<HintVisualEvent>,
|
mut hint_visual: MessageWriter<HintVisualEvent>,
|
||||||
) {
|
) {
|
||||||
@@ -308,16 +308,14 @@ fn handle_keyboard_hint(
|
|||||||
.and_then(|p| p.cards.last().filter(|c| c.face_up))
|
.and_then(|p| p.cards.last().filter(|c| c.face_up))
|
||||||
.map(|c| c.id);
|
.map(|c| c.id);
|
||||||
if let Some(card_id) = top_card_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 {
|
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)
|
commands.entity(entity)
|
||||||
.insert(HintHighlight { remaining: 2.0 })
|
.insert(HintHighlight { remaining: 2.0 })
|
||||||
.insert(HintHighlightTimer(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()
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ pub mod animation_plugin;
|
|||||||
pub mod auto_complete_plugin;
|
pub mod auto_complete_plugin;
|
||||||
pub mod audio_plugin;
|
pub mod audio_plugin;
|
||||||
pub mod card_plugin;
|
pub mod card_plugin;
|
||||||
|
pub mod font_plugin;
|
||||||
pub mod feedback_anim_plugin;
|
pub mod feedback_anim_plugin;
|
||||||
pub mod challenge_plugin;
|
pub mod challenge_plugin;
|
||||||
pub mod cursor_plugin;
|
pub mod cursor_plugin;
|
||||||
@@ -59,9 +60,10 @@ pub use feedback_anim_plugin::{
|
|||||||
pub use auto_complete_plugin::AutoCompletePlugin;
|
pub use auto_complete_plugin::AutoCompletePlugin;
|
||||||
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
|
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
|
||||||
pub use card_plugin::{
|
pub use card_plugin::{
|
||||||
CardEntity, CardLabel, CardPlugin, HintHighlight, HintHighlightTimer, RightClickHighlight,
|
CardEntity, CardImageSet, CardLabel, CardPlugin, HintHighlight, HintHighlightTimer,
|
||||||
RightClickHighlightTimer,
|
RightClickHighlight, RightClickHighlightTimer,
|
||||||
};
|
};
|
||||||
|
pub use font_plugin::{FontPlugin, FontResource};
|
||||||
pub use cursor_plugin::CursorPlugin;
|
pub use cursor_plugin::CursorPlugin;
|
||||||
pub use events::{
|
pub use events::{
|
||||||
AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent,
|
AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent,
|
||||||
|
|||||||
@@ -11,9 +11,21 @@ use solitaire_core::pile::PileType;
|
|||||||
use solitaire_data::settings::Theme;
|
use solitaire_data::settings::Theme;
|
||||||
|
|
||||||
use crate::events::HintVisualEvent;
|
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};
|
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.
|
/// Z-depth used for the background — below everything.
|
||||||
const Z_BACKGROUND: f32 = -10.0;
|
const Z_BACKGROUND: f32 = -10.0;
|
||||||
/// Z-depth used for pile markers — below cards (which start at 0) but above
|
/// 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>()
|
app.add_message::<WindowResized>()
|
||||||
.add_message::<SettingsChangedEvent>()
|
.add_message::<SettingsChangedEvent>()
|
||||||
.add_message::<HintVisualEvent>()
|
.add_message::<HintVisualEvent>()
|
||||||
|
.add_systems(Startup, load_background_images.before(setup_table))
|
||||||
.add_systems(Startup, setup_table)
|
.add_systems(Startup, setup_table)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
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.
|
/// 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 {
|
fn theme_colour(theme: &Theme) -> Color {
|
||||||
match theme {
|
match theme {
|
||||||
Theme::Green => Color::srgb(TABLE_COLOUR[0], TABLE_COLOUR[1], TABLE_COLOUR[2]),
|
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
|
/// Effective table background colour: unlocked background index overrides the
|
||||||
/// Theme when `selected_background > 0`.
|
/// 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 {
|
fn effective_background_colour(theme: &Theme, selected_background: usize) -> Color {
|
||||||
match selected_background {
|
match selected_background {
|
||||||
0 => theme_colour(theme),
|
0 => theme_colour(theme),
|
||||||
@@ -93,6 +153,7 @@ fn setup_table(
|
|||||||
windows: Query<&Window>,
|
windows: Query<&Window>,
|
||||||
existing_camera: Query<(), With<Camera>>,
|
existing_camera: Query<(), With<Camera>>,
|
||||||
settings: Option<Res<SettingsResource>>,
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
bg_images: Option<Res<BackgroundImageSet>>,
|
||||||
) {
|
) {
|
||||||
// Only spawn a camera if one does not already exist (e.g. a parent app
|
// Only spawn a camera if one does not already exist (e.g. a parent app
|
||||||
// may have added one in tests).
|
// may have added one in tests).
|
||||||
@@ -107,23 +168,34 @@ fn setup_table(
|
|||||||
.unwrap_or(Vec2::new(1280.0, 800.0));
|
.unwrap_or(Vec2::new(1280.0, 800.0));
|
||||||
let layout = compute_layout(window_size);
|
let layout = compute_layout(window_size);
|
||||||
|
|
||||||
let initial_colour = settings
|
let selected_bg = settings
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|s| effective_background_colour(&s.0.theme, s.0.selected_background))
|
.map(|s| s.0.selected_background)
|
||||||
.unwrap_or_else(|| Color::srgb(TABLE_COLOUR[0], TABLE_COLOUR[1], TABLE_COLOUR[2]));
|
.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);
|
spawn_pile_markers(&mut commands, &layout);
|
||||||
commands.insert_resource(LayoutResource(layout));
|
commands.insert_resource(LayoutResource(layout));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_background(commands: &mut Commands, window_size: Vec2, color: Color) {
|
/// Spawns the felt background sprite using a PNG image handle.
|
||||||
// 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
|
/// The sprite covers the window at twice the window size so brief resize gaps
|
||||||
// before the resize handler runs.
|
/// 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((
|
commands.spawn((
|
||||||
Sprite {
|
Sprite {
|
||||||
color,
|
image,
|
||||||
|
color: Color::WHITE,
|
||||||
custom_size: Some(window_size * 2.0),
|
custom_size: Some(window_size * 2.0),
|
||||||
..default()
|
..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(
|
fn apply_theme_on_settings_change(
|
||||||
mut events: MessageReader<SettingsChangedEvent>,
|
mut events: MessageReader<SettingsChangedEvent>,
|
||||||
mut backgrounds: Query<&mut Sprite, With<TableBackground>>,
|
mut backgrounds: Query<&mut Sprite, With<TableBackground>>,
|
||||||
|
bg_images: Option<Res<BackgroundImageSet>>,
|
||||||
) {
|
) {
|
||||||
let Some(ev) = events.read().last() else {
|
let Some(ev) = events.read().last() else {
|
||||||
return;
|
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 {
|
for mut sprite in &mut backgrounds {
|
||||||
sprite.color = colour;
|
sprite.image = handle.clone();
|
||||||
|
sprite.color = Color::WHITE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||