Compare commits
10 Commits
6b793aa2ab
...
13b428b81c
| Author | SHA1 | Date | |
|---|---|---|---|
| 13b428b81c | |||
| 9d0f9478b2 | |||
| b720588687 | |||
| adacdf533c | |||
| 7dfbff45d1 | |||
| 193410200e | |||
| 294f6fe9d4 | |||
| 788ac9f65a | |||
| 09d62f4255 | |||
| 8afb1f3fe5 |
Generated
+4
@@ -5672,6 +5672,10 @@ dependencies = [
|
||||
"solitaire_engine",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solitaire_assetgen"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "solitaire_core"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -7,6 +7,7 @@ members = [
|
||||
"solitaire_server",
|
||||
"solitaire_gpgs",
|
||||
"solitaire_app",
|
||||
"solitaire_assetgen",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+69
-13
@@ -1,8 +1,8 @@
|
||||
# Solitaire Quest — Session Handoff
|
||||
|
||||
> Last updated: 2026-04-24
|
||||
> Last updated: 2026-04-25
|
||||
> Branch: `master` — pushed to https://git.aleshym.co/funman300/Rusty_Solitare.git
|
||||
> Test count: **196 passing** (77 core + 50 data + 69 engine), `cargo clippy --workspace -- -D warnings` clean
|
||||
> Test count: **242 passing** (83 core + 60 data + 99 engine), `cargo clippy --workspace -- -D warnings` clean
|
||||
|
||||
---
|
||||
|
||||
@@ -125,22 +125,78 @@ All sub-phases (3A–3F) done. Plugins: `GamePlugin`, `TablePlugin`, `CardPlugin
|
||||
- `AnimationPlugin` now surfaces `DailyChallengeCompletedEvent` (shows streak) and `WeeklyGoalCompletedEvent` (shows goal description) as 3-second toasts.
|
||||
- Stats overlay (**S** key) appends a Progression section: level, total XP, daily streak, and a Weekly Goals list iterating `WEEKLY_GOALS` with `progress/target` for each.
|
||||
|
||||
### Phase 6 (part 4a) — Elapsed Time + Zen Mode ✅ COMPLETE
|
||||
|
||||
- `tick_elapsed_time` in `GamePlugin` ticks `GameState.elapsed_seconds` once per real-world second while not won; `advance_elapsed` is a pure helper for direct unit testing.
|
||||
- `GameMode` enum (`Classic` / `Zen`) added to `solitaire_core::game_state`. `GameState.mode` field; `GameState::new_with_mode` ctor. Zen suppresses scoring in `move_cards` and `undo`. Field is `#[serde(default)]` for backwards-compatible saved games.
|
||||
- `NewGameRequestEvent` carries an optional `mode`; `handle_new_game` falls back to the current game's mode when `None`.
|
||||
- `Z` key starts a fresh Zen game.
|
||||
|
||||
### Phase 6 (part 4b) — Challenge Mode + Level-5 Gate ✅ COMPLETE
|
||||
|
||||
- `GameMode::Challenge` variant in core; `undo()` returns `RuleViolation` in Challenge.
|
||||
- `solitaire_data::challenge` — `CHALLENGE_SEEDS` static list, `challenge_seed_for(index)` wrapping modulo length, `challenge_count()`.
|
||||
- `PlayerProgress.challenge_index` (serde-default) tracks progression.
|
||||
- `ChallengePlugin` advances the cursor on Challenge-mode wins, persists, fires `ChallengeAdvancedEvent`. **X** key starts a Challenge-mode game with the current seed.
|
||||
- Both **Z** (Zen) and **X** (Challenge) are gated to `level >= CHALLENGE_UNLOCK_LEVEL` (5).
|
||||
|
||||
### Phase 6 (part 4c) — Time Attack + Unlock UI ✅ COMPLETE
|
||||
|
||||
- `GameMode::TimeAttack` variant added to core (no scoring/undo changes — just a session marker).
|
||||
- `TimeAttackPlugin` (engine) — `TimeAttackResource { active, remaining_secs, wins }` (session-only, not persisted), `TimeAttackEndedEvent { wins }`. **T** starts a session (gated to level ≥ 5) and deals a TimeAttack-mode game; the timer (`TIME_ATTACK_DURATION_SECS = 600.0`) decrements each frame; wins during the active session bump the counter and auto-deal a fresh game.
|
||||
- `AnimationPlugin` surfaces `TimeAttackEndedEvent` as a 5-second summary toast.
|
||||
- `StatsPlugin` overlay (**S**) appends an "Unlocks" subsection (card backs / backgrounds, sorted/deduped, "None" when empty) and a live "Time Attack" panel showing remaining minutes/seconds + wins while a session is active.
|
||||
- Helper `format_id_list` factored out + tested.
|
||||
|
||||
### Phase 7 (part 1) — Help Overlay + Challenge Toast ✅ COMPLETE
|
||||
|
||||
- `HelpPlugin`: **H** or `?` toggles a full-window cheat sheet listing all keybindings (gameplay, mode hotkeys, overlays). 3 unit tests.
|
||||
- `AnimationPlugin` now surfaces `ChallengeAdvancedEvent` as a 3-second toast ("Challenge N cleared!").
|
||||
|
||||
### Phase 7 (part 2) — Synthesized SFX + AudioPlugin ✅ COMPLETE
|
||||
|
||||
- New workspace crate `solitaire_assetgen` with bin `gen_sfx`. Synthesizes five 44.1kHz mono 16-bit PCM WAVs from a deterministic LCG noise source + sine/square synths into `assets/audio/`. Run with `cargo run -p solitaire_assetgen --bin gen_sfx`. Output is committed; end users never run the generator.
|
||||
- `AudioPlugin` (`solitaire_engine`): embeds the WAVs via `include_bytes!()`, decodes once via `kira::StaticSoundData::from_cursor`, plays on `DrawRequestEvent` (flip), `MoveRequestEvent` (place), `NewGameRequestEvent` (deal), `GameWonEvent` (fanfare).
|
||||
- Backend handle stored as `NonSend` (cpal stream is `!Send` on some platforms). Plugin degrades gracefully if no audio device is available — logs a warning, gameplay continues silently.
|
||||
- Single decode unit test (`embedded_wavs_decode_successfully`) keeps the loader and generator in sync.
|
||||
|
||||
### Phase 7 (part 3) — MoveRejectedEvent + Pause Menu ✅ COMPLETE
|
||||
|
||||
- New `MoveRejectedEvent { from, to, count }`. `end_drag` fires it when the cursor is over a real pile but `can_place_*` rejects the placement. `AudioPlugin` plays `card_invalid.wav` on it.
|
||||
- New `PausePlugin` + `PausedResource(bool)`. **Esc** toggles a full-window pause overlay (ZIndex 220) and flips the resource. `tick_elapsed_time` and `advance_time_attack` skip work while paused. Input is deliberately not blocked — pause is a "stop the clock" screen, nothing more.
|
||||
- `HelpPlugin` cheat sheet updated to reflect the new Esc behaviour.
|
||||
|
||||
### Phase 7 (part 4) — Settings + SFX Volume Control ✅ COMPLETE
|
||||
|
||||
- New `solitaire_data::Settings { sfx_volume, first_run_complete }` with atomic JSON persistence (`save_settings_to` / `load_settings_from`). `sanitized()` clamps out-of-range volumes after deserialization. Default `sfx_volume = 0.8`.
|
||||
- New `SettingsPlugin` (engine) with `SettingsResource`, `headless()` ctor, and `SettingsChangedEvent`. **\[** / **\]** adjust SFX volume by `SFX_STEP` (0.1), clamped; persists on change. No-op + no event when already at the rail.
|
||||
- `AudioPlugin` applies `sfx_volume` to kira's main track at startup and on every `SettingsChangedEvent` (so changes take effect mid-game without restart).
|
||||
- `AnimationPlugin` shows a brief "SFX: 70%" toast on every change so players see the new value.
|
||||
- Help cheat sheet lists the **\[** / **\]** keys.
|
||||
- 4 plugin tests + 6 data tests added — defaults, clamping, round-trip persistence.
|
||||
|
||||
### Phase 7 (part 5) — First-Run Onboarding ✅ COMPLETE
|
||||
|
||||
- New `OnboardingPlugin`. At `PostStartup`, if `Settings.first_run_complete == false`, spawns a centered welcome banner pointing at the **H**/`?` cheat sheet (ZIndex 230). Any key or mouse-button press dismisses it, sets the flag, and persists `settings.json` — returning players never see it again.
|
||||
- 4 unit tests cover spawn-only-on-first-run, key dismiss, and click dismiss.
|
||||
|
||||
## What Is Next
|
||||
|
||||
### Phase 6 (part 4) — Special Modes + Unlock UI
|
||||
Phase 7 polish slate is done. Phase 8 (sync) is next.
|
||||
|
||||
- Time Attack / Challenge / Zen modes (unlock at level 5). Add a `GameMode` enum in `solitaire_core::game_state`; `GameState` tracks its mode; Zen skips scoring, Challenge disables undo, Time Attack ends on timer.
|
||||
- Mode selector UI + keyboard shortcut (e.g. `Z` for Zen) + extend `NewGameRequestEvent` with an optional mode.
|
||||
- Card-back / background unlock UI for `unlocked_card_backs` / `unlocked_backgrounds`.
|
||||
- Elapsed-time tracking — currently `GameState.elapsed_seconds` stays at 0; wire a timer system.
|
||||
|
||||
### Phases 7–8 (in order after Phase 6 part 4)
|
||||
### Phase 8 — Sync
|
||||
|
||||
| Phase | Scope |
|
||||
|---|---|
|
||||
| Phase 7 | Audio (`kira`), polish, hints, onboarding, pause menu |
|
||||
| Phase 8A–C | Local storage + `SyncProvider` + self-hosted Axum server + client |
|
||||
| Phase 8D | GPGS stub fully wired into settings UI |
|
||||
| Phase 8A | Local storage scaffolding + `SyncProvider` plumbing in `solitaire_data` |
|
||||
| Phase 8B | Self-hosted Axum server (auth, sync endpoints, SQLite schema) |
|
||||
| Phase 8C | `SolitaireServerClient` (`SyncProvider` impl) + `SyncPlugin` lifecycle |
|
||||
| Phase 8D | GPGS stub fully wired into the settings UI (Android-only `cfg`-gated) |
|
||||
|
||||
### Tiny optional polish (anytime)
|
||||
|
||||
- **Ambient loop**: optional sixth WAV — needs taste, deferred until artwork phase.
|
||||
- **Block input while paused**: drag/hotkeys still work mid-pause; tightening this would make pause behave more like a true modal.
|
||||
|
||||
---
|
||||
|
||||
@@ -191,7 +247,7 @@ For Phase 3 onwards, write a new plan using the `superpowers:writing-plans` skil
|
||||
# Check everything compiles
|
||||
cargo check --workspace
|
||||
|
||||
# Run all tests (196 tests, all should pass)
|
||||
# Run all tests (214 tests, all should pass)
|
||||
cargo test --workspace
|
||||
|
||||
# Lint (must be zero warnings)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use bevy::prelude::*;
|
||||
use solitaire_engine::{
|
||||
AchievementPlugin, AnimationPlugin, CardPlugin, DailyChallengePlugin, GamePlugin, InputPlugin,
|
||||
ProgressPlugin, StatsPlugin, TablePlugin, WeeklyGoalsPlugin,
|
||||
AchievementPlugin, AnimationPlugin, AudioPlugin, CardPlugin, ChallengePlugin,
|
||||
DailyChallengePlugin, GamePlugin, HelpPlugin, InputPlugin, OnboardingPlugin, PausePlugin,
|
||||
ProgressPlugin, SettingsPlugin, StatsPlugin, TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
@@ -26,5 +27,12 @@ fn main() {
|
||||
.add_plugins(AchievementPlugin::default())
|
||||
.add_plugins(DailyChallengePlugin)
|
||||
.add_plugins(WeeklyGoalsPlugin)
|
||||
.add_plugins(ChallengePlugin)
|
||||
.add_plugins(TimeAttackPlugin)
|
||||
.add_plugins(HelpPlugin)
|
||||
.add_plugins(PausePlugin)
|
||||
.add_plugins(SettingsPlugin::default())
|
||||
.add_plugins(AudioPlugin)
|
||||
.add_plugins(OnboardingPlugin)
|
||||
.run();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "solitaire_assetgen"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish = false
|
||||
|
||||
# Dev-only utility: synthesizes placeholder SFX WAV files into `assets/audio/`.
|
||||
# Not depended on by any other workspace crate.
|
||||
|
||||
[[bin]]
|
||||
name = "gen_sfx"
|
||||
path = "src/bin/gen_sfx.rs"
|
||||
@@ -0,0 +1,207 @@
|
||||
//! Synthesize placeholder SFX into `assets/audio/`.
|
||||
//!
|
||||
//! Output: 44.1kHz mono 16-bit PCM WAV. Run with
|
||||
//! `cargo run -p solitaire_assetgen --bin gen_sfx`. Files are committed to
|
||||
//! the repo so end-users never need to run this generator.
|
||||
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const SAMPLE_RATE: u32 = 44_100;
|
||||
|
||||
type Generator = fn() -> Vec<i16>;
|
||||
|
||||
fn main() -> io::Result<()> {
|
||||
let out_dir = workspace_root().join("assets").join("audio");
|
||||
fs::create_dir_all(&out_dir)?;
|
||||
|
||||
let effects: [(&str, Generator); 5] = [
|
||||
("card_flip.wav", card_flip),
|
||||
("card_place.wav", card_place),
|
||||
("card_deal.wav", card_deal),
|
||||
("card_invalid.wav", card_invalid),
|
||||
("win_fanfare.wav", win_fanfare),
|
||||
];
|
||||
|
||||
for (name, gen) in &effects {
|
||||
let samples = gen();
|
||||
let path = out_dir.join(name);
|
||||
write_wav_mono_pcm16(&path, SAMPLE_RATE, &samples)?;
|
||||
println!("wrote {} ({} samples)", path.display(), samples.len());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Synth primitives
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Simple deterministic noise source — LCG, no `rand` dep needed.
|
||||
struct Lcg(u64);
|
||||
|
||||
impl Lcg {
|
||||
fn new(seed: u64) -> Self {
|
||||
Self(seed)
|
||||
}
|
||||
fn next_f32(&mut self) -> f32 {
|
||||
self.0 = self
|
||||
.0
|
||||
.wrapping_mul(6_364_136_223_846_793_005)
|
||||
.wrapping_add(1_442_695_040_888_963_407);
|
||||
((self.0 >> 32) as i32 as f32) / (i32::MAX as f32)
|
||||
}
|
||||
}
|
||||
|
||||
fn duration_samples(seconds: f32) -> usize {
|
||||
(seconds * SAMPLE_RATE as f32) as usize
|
||||
}
|
||||
|
||||
/// Linear attack / exponential decay envelope. `attack` and length in seconds.
|
||||
fn ar_envelope(t_secs: f32, attack: f32, total: f32, decay_rate: f32) -> f32 {
|
||||
if t_secs < attack {
|
||||
(t_secs / attack).clamp(0.0, 1.0)
|
||||
} else {
|
||||
(-decay_rate * (t_secs - attack)).exp() * (1.0 - (t_secs - total).max(0.0))
|
||||
}
|
||||
}
|
||||
|
||||
fn quantize(sample: f32) -> i16 {
|
||||
let clipped = sample.clamp(-1.0, 1.0);
|
||||
(clipped * 32_767.0) as i16
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Effect generators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn card_flip() -> Vec<i16> {
|
||||
let n = duration_samples(0.08);
|
||||
let mut rng = Lcg::new(0x1234_5678_DEAD_BEEF);
|
||||
let mut out = Vec::with_capacity(n);
|
||||
let mut prev = 0.0f32;
|
||||
let alpha = 0.35;
|
||||
for i in 0..n {
|
||||
let t = i as f32 / SAMPLE_RATE as f32;
|
||||
let raw = rng.next_f32();
|
||||
// High-pass-ish: subtract a low-pass-smoothed signal.
|
||||
let lp = alpha * raw + (1.0 - alpha) * prev;
|
||||
prev = lp;
|
||||
let hp = raw - lp;
|
||||
let env = ar_envelope(t, 0.005, 0.08, 60.0);
|
||||
out.push(quantize(hp * env * 0.6));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn card_place() -> Vec<i16> {
|
||||
let n = duration_samples(0.14);
|
||||
let mut rng = Lcg::new(0xCAFE_F00D_8BAD_F00D);
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let t = i as f32 / SAMPLE_RATE as f32;
|
||||
// Low sine for body (~120 Hz) + filtered noise for click.
|
||||
let body = (2.0 * std::f32::consts::PI * 120.0 * t).sin();
|
||||
let click = rng.next_f32() * 0.5;
|
||||
let env = ar_envelope(t, 0.003, 0.14, 35.0);
|
||||
let sample = (body * 0.7 + click) * env * 0.55;
|
||||
out.push(quantize(sample));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn card_deal() -> Vec<i16> {
|
||||
let n = duration_samples(0.18);
|
||||
let mut rng = Lcg::new(0xFEE1_DEAD_DEAD_BEEF);
|
||||
let mut out = Vec::with_capacity(n);
|
||||
let mut lp = 0.0f32;
|
||||
for i in 0..n {
|
||||
let t = i as f32 / SAMPLE_RATE as f32;
|
||||
let raw = rng.next_f32();
|
||||
// Sweeping low-pass: cutoff falls over time → "whoosh".
|
||||
let alpha = 0.6 - (t / 0.18) * 0.5;
|
||||
lp = alpha * raw + (1.0 - alpha) * lp;
|
||||
let env = ar_envelope(t, 0.01, 0.18, 18.0);
|
||||
out.push(quantize(lp * env * 0.7));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn card_invalid() -> Vec<i16> {
|
||||
let n = duration_samples(0.18);
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let t = i as f32 / SAMPLE_RATE as f32;
|
||||
// Two dissonant squarish tones — strong beat creates a buzz.
|
||||
let a = (2.0 * std::f32::consts::PI * 196.0 * t).sin().signum();
|
||||
let b = (2.0 * std::f32::consts::PI * 207.65 * t).sin().signum();
|
||||
let env = ar_envelope(t, 0.005, 0.18, 12.0);
|
||||
out.push(quantize((a + b) * env * 0.18));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn win_fanfare() -> Vec<i16> {
|
||||
// C major arpeggio: C5, E5, G5, C6.
|
||||
let notes = [523.25_f32, 659.25, 783.99, 1046.50];
|
||||
let note_dur = 0.18_f32;
|
||||
let total = note_dur * notes.len() as f32 + 0.25;
|
||||
let n = duration_samples(total);
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let t = i as f32 / SAMPLE_RATE as f32;
|
||||
let mut sample = 0.0f32;
|
||||
for (idx, freq) in notes.iter().enumerate() {
|
||||
let start = idx as f32 * note_dur;
|
||||
let local = t - start;
|
||||
if !(0.0..=0.4).contains(&local) {
|
||||
continue;
|
||||
}
|
||||
// Layered sine + soft 2nd harmonic for warmth.
|
||||
let s = (2.0 * std::f32::consts::PI * freq * local).sin()
|
||||
+ 0.3 * (2.0 * std::f32::consts::PI * freq * 2.0 * local).sin();
|
||||
let env = ar_envelope(local, 0.008, 0.4, 6.0);
|
||||
sample += s * env;
|
||||
}
|
||||
out.push(quantize(sample * 0.22));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal WAV writer (mono 16-bit PCM)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn write_wav_mono_pcm16(path: &Path, sample_rate: u32, samples: &[i16]) -> io::Result<()> {
|
||||
let mut f = File::create(path)?;
|
||||
let byte_rate = sample_rate * 2; // mono 16-bit
|
||||
let data_bytes = samples.len() as u32 * 2;
|
||||
let chunk_size = 36 + data_bytes;
|
||||
|
||||
f.write_all(b"RIFF")?;
|
||||
f.write_all(&chunk_size.to_le_bytes())?;
|
||||
f.write_all(b"WAVE")?;
|
||||
|
||||
f.write_all(b"fmt ")?;
|
||||
f.write_all(&16u32.to_le_bytes())?; // PCM fmt chunk size
|
||||
f.write_all(&1u16.to_le_bytes())?; // PCM
|
||||
f.write_all(&1u16.to_le_bytes())?; // mono
|
||||
f.write_all(&sample_rate.to_le_bytes())?;
|
||||
f.write_all(&byte_rate.to_le_bytes())?;
|
||||
f.write_all(&2u16.to_le_bytes())?; // block align
|
||||
f.write_all(&16u16.to_le_bytes())?; // bits per sample
|
||||
|
||||
f.write_all(b"data")?;
|
||||
f.write_all(&data_bytes.to_le_bytes())?;
|
||||
for &s in samples {
|
||||
f.write_all(&s.to_le_bytes())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn workspace_root() -> PathBuf {
|
||||
// CARGO_MANIFEST_DIR points at the assetgen crate; parent is workspace.
|
||||
let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
crate_dir.parent().expect("workspace root").to_path_buf()
|
||||
}
|
||||
@@ -16,6 +16,24 @@ pub enum DrawMode {
|
||||
DrawThree,
|
||||
}
|
||||
|
||||
/// Top-level game mode. Affects scoring, undo, and (eventually) timer behaviour.
|
||||
///
|
||||
/// - `Classic`: standard Klondike scoring, undo allowed.
|
||||
/// - `Zen`: scoring suppressed (stays at 0); undo allowed; intended for relaxed play.
|
||||
/// - `Challenge`: standard scoring, **undo disabled** (returns
|
||||
/// `MoveError::RuleViolation`).
|
||||
/// - `TimeAttack`: standard scoring + undo; the engine wraps a 10-minute
|
||||
/// countdown around the session and auto-deals a fresh game on every win
|
||||
/// (see `solitaire_engine::TimeAttackPlugin`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum GameMode {
|
||||
#[default]
|
||||
Classic,
|
||||
Zen,
|
||||
Challenge,
|
||||
TimeAttack,
|
||||
}
|
||||
|
||||
/// Snapshot of game state used for undo.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
struct StateSnapshot {
|
||||
@@ -29,6 +47,10 @@ struct StateSnapshot {
|
||||
pub struct GameState {
|
||||
pub piles: HashMap<PileType, Pile>,
|
||||
pub draw_mode: DrawMode,
|
||||
/// Top-level mode (Classic / Zen). Defaults to Classic for backwards
|
||||
/// compatibility with older save files via `#[serde(default)]`.
|
||||
#[serde(default)]
|
||||
pub mode: GameMode,
|
||||
pub score: i32,
|
||||
pub move_count: u32,
|
||||
pub elapsed_seconds: u64,
|
||||
@@ -42,8 +64,13 @@ pub struct GameState {
|
||||
}
|
||||
|
||||
impl GameState {
|
||||
/// Creates a new game dealt from the given seed and draw mode.
|
||||
/// Creates a new Classic-mode game dealt from the given seed and draw mode.
|
||||
pub fn new(seed: u64, draw_mode: DrawMode) -> Self {
|
||||
Self::new_with_mode(seed, draw_mode, GameMode::Classic)
|
||||
}
|
||||
|
||||
/// Creates a new game with an explicit `GameMode`.
|
||||
pub fn new_with_mode(seed: u64, draw_mode: DrawMode, mode: GameMode) -> Self {
|
||||
let mut deck = Deck::new();
|
||||
deck.shuffle(seed);
|
||||
let (tableau, stock) = deal_klondike(deck);
|
||||
@@ -61,6 +88,7 @@ impl GameState {
|
||||
Self {
|
||||
piles,
|
||||
draw_mode,
|
||||
mode,
|
||||
score: 0,
|
||||
move_count: 0,
|
||||
elapsed_seconds: 0,
|
||||
@@ -200,7 +228,11 @@ impl GameState {
|
||||
start
|
||||
};
|
||||
|
||||
let score_delta = score_move(&from, &to);
|
||||
let score_delta = if self.mode == GameMode::Zen {
|
||||
0
|
||||
} else {
|
||||
score_move(&from, &to)
|
||||
};
|
||||
self.push_snapshot();
|
||||
|
||||
// Execute move
|
||||
@@ -236,13 +268,23 @@ impl GameState {
|
||||
}
|
||||
|
||||
/// Restore the most recent undo snapshot and apply the undo score penalty (-15).
|
||||
/// Disabled in `GameMode::Challenge` — returns `MoveError::RuleViolation`.
|
||||
pub fn undo(&mut self) -> Result<(), MoveError> {
|
||||
if self.is_won {
|
||||
return Err(MoveError::GameAlreadyWon);
|
||||
}
|
||||
if self.mode == GameMode::Challenge {
|
||||
return Err(MoveError::RuleViolation(
|
||||
"undo is disabled in Challenge mode".into(),
|
||||
));
|
||||
}
|
||||
let snapshot = self.undo_stack.pop().ok_or(MoveError::UndoStackEmpty)?;
|
||||
self.piles = snapshot.piles;
|
||||
self.score = (snapshot.score + scoring_undo()).max(0);
|
||||
self.score = if self.mode == GameMode::Zen {
|
||||
0
|
||||
} else {
|
||||
(snapshot.score + scoring_undo()).max(0)
|
||||
};
|
||||
self.move_count = snapshot.move_count;
|
||||
self.is_won = false;
|
||||
self.is_auto_completable = false;
|
||||
@@ -508,6 +550,56 @@ mod tests {
|
||||
assert!(g.score >= 0);
|
||||
}
|
||||
|
||||
// --- GameMode: Zen ---
|
||||
|
||||
#[test]
|
||||
fn zen_mode_score_stays_zero_after_undo() {
|
||||
let mut g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Zen);
|
||||
g.draw().unwrap();
|
||||
g.undo().unwrap();
|
||||
assert_eq!(g.score, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zen_mode_default_is_classic_via_default_trait() {
|
||||
assert_eq!(GameMode::default(), GameMode::Classic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zen_mode_field_persists_through_construction() {
|
||||
let g = GameState::new_with_mode(1, DrawMode::DrawThree, GameMode::Zen);
|
||||
assert_eq!(g.mode, GameMode::Zen);
|
||||
assert_eq!(g.draw_mode, DrawMode::DrawThree);
|
||||
}
|
||||
|
||||
// --- GameMode: Challenge ---
|
||||
|
||||
#[test]
|
||||
fn challenge_mode_disables_undo() {
|
||||
let mut g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Challenge);
|
||||
g.draw().unwrap();
|
||||
let result = g.undo();
|
||||
assert!(matches!(result, Err(MoveError::RuleViolation(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_mode_still_allows_normal_moves() {
|
||||
let g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Challenge);
|
||||
// Just verify the game initialises cleanly with Challenge mode.
|
||||
assert_eq!(g.mode, GameMode::Challenge);
|
||||
assert_eq!(g.score, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_mode_scoring_applies_normally() {
|
||||
// Challenge uses Classic scoring; only undo is disabled.
|
||||
let g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Challenge);
|
||||
assert_eq!(g.score, 0);
|
||||
// Note: Verifying score increases on actual moves would require
|
||||
// hand-crafting a legal move from the dealt state. We rely on the
|
||||
// fact that move_cards' score path is identical to Classic.
|
||||
}
|
||||
|
||||
// --- Auto-complete ---
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Static seed list for Challenge mode + helpers.
|
||||
//!
|
||||
//! Challenge mode walks a fixed sequence of hard-but-winnable seeds. The
|
||||
//! player advances by winning a deal in `GameMode::Challenge`. The
|
||||
//! `challenge_index` cursor is stored per-player in `PlayerProgress`.
|
||||
//!
|
||||
//! Seeds wrap modulo `CHALLENGE_SEEDS.len()` so a sufficiently dedicated
|
||||
//! player never runs out of challenges.
|
||||
|
||||
/// Curated Challenge-mode seeds. Order is stable across versions; add new
|
||||
/// seeds at the end.
|
||||
pub const CHALLENGE_SEEDS: &[u64] = &[
|
||||
0xDEAD_BEEF_CAFE_F00D,
|
||||
0xC0DE_FACE_8BAD_F00D,
|
||||
0xFEE1_DEAD_DEAD_BEEF,
|
||||
0xBAAD_F00D_BAAD_F00D,
|
||||
0x1337_C0DE_4242_BABE,
|
||||
];
|
||||
|
||||
/// Resolve a `challenge_index` to its corresponding seed, wrapping when
|
||||
/// the index exceeds the seed-list length. Returns `None` if the seed list
|
||||
/// is empty (defensive — `CHALLENGE_SEEDS` is non-empty by construction).
|
||||
pub fn challenge_seed_for(index: u32) -> Option<u64> {
|
||||
if CHALLENGE_SEEDS.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(CHALLENGE_SEEDS[(index as usize) % CHALLENGE_SEEDS.len()])
|
||||
}
|
||||
|
||||
/// Total number of currently-defined challenges. Useful for displaying
|
||||
/// "Challenge {n + 1} of {total}" in UI.
|
||||
pub fn challenge_count() -> u32 {
|
||||
CHALLENGE_SEEDS.len() as u32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn challenge_seed_for_0_is_first_seed() {
|
||||
assert_eq!(challenge_seed_for(0), Some(CHALLENGE_SEEDS[0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_seed_wraps_past_end() {
|
||||
let len = CHALLENGE_SEEDS.len() as u32;
|
||||
assert_eq!(
|
||||
challenge_seed_for(len),
|
||||
Some(CHALLENGE_SEEDS[0]),
|
||||
"wraps to seed 0 when index == len"
|
||||
);
|
||||
assert_eq!(
|
||||
challenge_seed_for(len + 2),
|
||||
Some(CHALLENGE_SEEDS[2]),
|
||||
"wraps modulo len"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_challenge_seeds_are_unique() {
|
||||
let mut seeds: Vec<u64> = CHALLENGE_SEEDS.to_vec();
|
||||
seeds.sort();
|
||||
let len_before = seeds.len();
|
||||
seeds.dedup();
|
||||
assert_eq!(seeds.len(), len_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_count_matches_seed_list_length() {
|
||||
assert_eq!(challenge_count() as usize, CHALLENGE_SEEDS.len());
|
||||
}
|
||||
}
|
||||
@@ -57,3 +57,9 @@ pub use weekly::{
|
||||
current_iso_week_key, weekly_goal_by_id, WeeklyGoalContext, WeeklyGoalDef, WeeklyGoalKind,
|
||||
WEEKLY_GOALS, WEEKLY_GOAL_XP,
|
||||
};
|
||||
|
||||
pub mod challenge;
|
||||
pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
|
||||
|
||||
pub mod settings;
|
||||
pub use settings::{load_settings_from, save_settings_to, settings_file_path, Settings};
|
||||
|
||||
@@ -67,6 +67,11 @@ pub struct PlayerProgress {
|
||||
pub weekly_goal_week_iso: Option<String>,
|
||||
pub unlocked_card_backs: Vec<usize>,
|
||||
pub unlocked_backgrounds: Vec<usize>,
|
||||
/// Index of the next Challenge-mode seed the player will be served.
|
||||
/// Increments on each Challenge-mode win. Out-of-range values wrap modulo
|
||||
/// `CHALLENGE_SEEDS.len()` at lookup time.
|
||||
#[serde(default)]
|
||||
pub challenge_index: u32,
|
||||
pub last_modified: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -81,6 +86,7 @@ impl Default for PlayerProgress {
|
||||
weekly_goal_week_iso: None,
|
||||
unlocked_card_backs: vec![0], // back #0 always available
|
||||
unlocked_backgrounds: vec![0], // background #0 always available
|
||||
challenge_index: 0,
|
||||
last_modified: DateTime::UNIX_EPOCH,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! User settings (persistent).
|
||||
//!
|
||||
//! Currently tracks SFX volume and the first-run flag. Other fields from
|
||||
//! ARCHITECTURE.md §9 (`draw_mode`, `music_volume`, `theme`, `sync_backend`)
|
||||
//! will land alongside the systems that need them.
|
||||
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
||||
const SETTINGS_FILE_NAME: &str = "settings.json";
|
||||
|
||||
/// Persistent user settings.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
/// Linear SFX volume in `[0.0, 1.0]`. Applied to kira's main track gain.
|
||||
pub sfx_volume: f32,
|
||||
/// Set to `true` once the player has dismissed the first-run banner.
|
||||
pub first_run_complete: bool,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sfx_volume: 0.8,
|
||||
first_run_complete: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// Clamps `sfx_volume` into `[0.0, 1.0]` after deserialization or
|
||||
/// hand-editing of `settings.json`.
|
||||
pub fn sanitized(self) -> Self {
|
||||
Self {
|
||||
sfx_volume: self.sfx_volume.clamp(0.0, 1.0),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
/// Adjust SFX volume by `delta`, clamped to `[0.0, 1.0]`. Returns the new value.
|
||||
pub fn adjust_sfx_volume(&mut self, delta: f32) -> f32 {
|
||||
self.sfx_volume = (self.sfx_volume + delta).clamp(0.0, 1.0);
|
||||
self.sfx_volume
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the platform-specific path to `settings.json`, or `None` if
|
||||
/// `dirs::data_dir()` is unavailable.
|
||||
pub fn settings_file_path() -> Option<PathBuf> {
|
||||
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(SETTINGS_FILE_NAME))
|
||||
}
|
||||
|
||||
/// Load settings from an explicit path. Returns `Settings::default()` if the
|
||||
/// file is missing or cannot be deserialized.
|
||||
pub fn load_settings_from(path: &Path) -> Settings {
|
||||
let Ok(data) = fs::read(path) else {
|
||||
return Settings::default();
|
||||
};
|
||||
serde_json::from_slice::<Settings>(&data)
|
||||
.unwrap_or_default()
|
||||
.sanitized()
|
||||
}
|
||||
|
||||
/// Save settings to an explicit path using an atomic write (`.tmp` → rename).
|
||||
pub fn save_settings_to(path: &Path, settings: &Settings) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(settings).map_err(io::Error::other)?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
fs::write(&tmp, json.as_bytes())?;
|
||||
fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env;
|
||||
|
||||
fn tmp_path(name: &str) -> PathBuf {
|
||||
env::temp_dir().join(format!("solitaire_settings_test_{name}.json"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_are_reasonable() {
|
||||
let s = Settings::default();
|
||||
assert!((s.sfx_volume - 0.8).abs() < 1e-6);
|
||||
assert!(!s.first_run_complete);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjust_sfx_volume_clamps() {
|
||||
let mut s = Settings::default();
|
||||
s.sfx_volume = 0.5;
|
||||
assert!((s.adjust_sfx_volume(0.3) - 0.8).abs() < 1e-6);
|
||||
assert!((s.adjust_sfx_volume(0.5) - 1.0).abs() < 1e-6);
|
||||
assert!((s.adjust_sfx_volume(-2.0) - 0.0).abs() < 1e-6);
|
||||
assert!((s.adjust_sfx_volume(-1.0) - 0.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitized_clamps_out_of_range_volume() {
|
||||
let s = Settings {
|
||||
sfx_volume: 5.0,
|
||||
first_run_complete: true,
|
||||
}
|
||||
.sanitized();
|
||||
assert_eq!(s.sfx_volume, 1.0);
|
||||
assert!(s.first_run_complete);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_save_and_load() {
|
||||
let path = tmp_path("round_trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
let s = Settings {
|
||||
sfx_volume: 0.42,
|
||||
first_run_complete: true,
|
||||
};
|
||||
save_settings_to(&path, &s).expect("save");
|
||||
let loaded = load_settings_from(&path);
|
||||
assert_eq!(loaded, s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_missing_file_returns_default() {
|
||||
let path = tmp_path("missing_xyz");
|
||||
let _ = fs::remove_file(&path);
|
||||
let s = load_settings_from(&path);
|
||||
assert_eq!(s, Settings::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_corrupt_file_returns_default() {
|
||||
let path = tmp_path("corrupt");
|
||||
fs::write(&path, b"definitely not json").expect("write");
|
||||
let s = load_settings_from(&path);
|
||||
assert_eq!(s, Settings::default());
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,14 @@ use bevy::prelude::*;
|
||||
|
||||
use crate::achievement_plugin::display_name_for;
|
||||
use crate::card_plugin::CardEntity;
|
||||
use crate::challenge_plugin::ChallengeAdvancedEvent;
|
||||
use crate::daily_challenge_plugin::DailyChallengeCompletedEvent;
|
||||
use crate::events::{AchievementUnlockedEvent, GameWonEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::layout::LayoutResource;
|
||||
use crate::progress_plugin::LevelUpEvent;
|
||||
use crate::settings_plugin::SettingsChangedEvent;
|
||||
use crate::time_attack_plugin::TimeAttackEndedEvent;
|
||||
use crate::weekly_goals_plugin::WeeklyGoalCompletedEvent;
|
||||
|
||||
/// Duration of a card slide (move) animation in seconds.
|
||||
@@ -22,6 +25,9 @@ const ACHIEVEMENT_TOAST_SECS: f32 = 3.0;
|
||||
const LEVELUP_TOAST_SECS: f32 = 3.0;
|
||||
const DAILY_TOAST_SECS: f32 = 3.0;
|
||||
const WEEKLY_TOAST_SECS: f32 = 3.0;
|
||||
const TIME_ATTACK_TOAST_SECS: f32 = 5.0;
|
||||
const CHALLENGE_TOAST_SECS: f32 = 3.0;
|
||||
const VOLUME_TOAST_SECS: f32 = 1.4;
|
||||
const CASCADE_STAGGER: f32 = 0.05;
|
||||
const CASCADE_DURATION: f32 = 0.5;
|
||||
|
||||
@@ -59,6 +65,9 @@ impl Plugin for AnimationPlugin {
|
||||
.add_event::<LevelUpEvent>()
|
||||
.add_event::<DailyChallengeCompletedEvent>()
|
||||
.add_event::<WeeklyGoalCompletedEvent>()
|
||||
.add_event::<TimeAttackEndedEvent>()
|
||||
.add_event::<ChallengeAdvancedEvent>()
|
||||
.add_event::<SettingsChangedEvent>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -68,6 +77,9 @@ impl Plugin for AnimationPlugin {
|
||||
handle_levelup_toast,
|
||||
handle_daily_toast,
|
||||
handle_weekly_toast,
|
||||
handle_time_attack_toast,
|
||||
handle_challenge_toast,
|
||||
handle_settings_toast,
|
||||
tick_toasts,
|
||||
)
|
||||
.after(GameMutation),
|
||||
@@ -181,6 +193,46 @@ fn handle_weekly_toast(
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_time_attack_toast(
|
||||
mut commands: Commands,
|
||||
mut events: EventReader<TimeAttackEndedEvent>,
|
||||
) {
|
||||
for ev in events.read() {
|
||||
spawn_toast(
|
||||
&mut commands,
|
||||
format!("Time Attack: {} win{}", ev.wins, if ev.wins == 1 { "" } else { "s" }),
|
||||
TIME_ATTACK_TOAST_SECS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_challenge_toast(
|
||||
mut commands: Commands,
|
||||
mut events: EventReader<ChallengeAdvancedEvent>,
|
||||
) {
|
||||
for ev in events.read() {
|
||||
spawn_toast(
|
||||
&mut commands,
|
||||
format!("Challenge {} cleared!", ev.previous_index.saturating_add(1)),
|
||||
CHALLENGE_TOAST_SECS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_settings_toast(
|
||||
mut commands: Commands,
|
||||
mut events: EventReader<SettingsChangedEvent>,
|
||||
) {
|
||||
for ev in events.read() {
|
||||
let pct = (ev.0.sfx_volume * 100.0).round() as i32;
|
||||
spawn_toast(
|
||||
&mut commands,
|
||||
format!("SFX: {pct}%"),
|
||||
VOLUME_TOAST_SECS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn tick_toasts(
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
//! Sound-effect playback via `kira`.
|
||||
//!
|
||||
//! Loads five embedded WAVs (`include_bytes!`) at startup and plays them in
|
||||
//! response to gameplay events:
|
||||
//!
|
||||
//! | Event | Sound |
|
||||
//! |---|---|
|
||||
//! | `DrawRequestEvent` | `card_flip.wav` |
|
||||
//! | `MoveRequestEvent` | `card_place.wav` |
|
||||
//! | `MoveRejectedEvent` | `card_invalid.wav` |
|
||||
//! | `NewGameRequestEvent` | `card_deal.wav` |
|
||||
//! | `GameWonEvent` | `win_fanfare.wav` |
|
||||
//!
|
||||
//! If the audio device cannot be opened (e.g. a headless CI machine or a
|
||||
//! Linux box without a running PulseAudio/Pipewire session), the plugin
|
||||
//! logs a warning and degrades gracefully — gameplay continues, just
|
||||
//! silently.
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use kira::manager::backend::DefaultBackend;
|
||||
use kira::manager::{AudioManager, AudioManagerSettings};
|
||||
use kira::sound::static_sound::StaticSoundData;
|
||||
use kira::tween::Tween;
|
||||
|
||||
use crate::events::{
|
||||
DrawRequestEvent, GameWonEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent,
|
||||
};
|
||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||
|
||||
/// Pre-decoded sound effects. Cheap to clone (frames are an `Arc<[Frame]>`),
|
||||
/// so we hand a fresh handle to `manager.play()` on every event.
|
||||
#[derive(Resource, Clone)]
|
||||
pub struct SoundLibrary {
|
||||
pub deal: StaticSoundData,
|
||||
pub flip: StaticSoundData,
|
||||
pub place: StaticSoundData,
|
||||
pub invalid: StaticSoundData,
|
||||
pub fanfare: StaticSoundData,
|
||||
}
|
||||
|
||||
/// Wraps the audio backend. `NonSend` because cpal streams are `!Send` on
|
||||
/// some platforms.
|
||||
pub struct AudioState {
|
||||
manager: Option<AudioManager<DefaultBackend>>,
|
||||
}
|
||||
|
||||
pub struct AudioPlugin;
|
||||
|
||||
impl Plugin for AudioPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
let manager = AudioManager::<DefaultBackend>::new(AudioManagerSettings::default()).ok();
|
||||
if manager.is_none() {
|
||||
warn!("audio device unavailable; SFX disabled");
|
||||
}
|
||||
app.insert_non_send_resource(AudioState { manager });
|
||||
|
||||
let library = build_library();
|
||||
if let Some(lib) = library {
|
||||
app.insert_resource(lib);
|
||||
} else {
|
||||
warn!("failed to decode embedded SFX assets; SFX disabled");
|
||||
}
|
||||
|
||||
app.add_event::<DrawRequestEvent>()
|
||||
.add_event::<MoveRequestEvent>()
|
||||
.add_event::<MoveRejectedEvent>()
|
||||
.add_event::<NewGameRequestEvent>()
|
||||
.add_event::<GameWonEvent>()
|
||||
.add_event::<SettingsChangedEvent>()
|
||||
.add_systems(
|
||||
Startup,
|
||||
apply_initial_volume,
|
||||
)
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
play_on_draw,
|
||||
play_on_move,
|
||||
play_on_rejected,
|
||||
play_on_new_game,
|
||||
play_on_win,
|
||||
apply_volume_on_change,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn build_library() -> Option<SoundLibrary> {
|
||||
let deal = decode(include_bytes!("../../assets/audio/card_deal.wav"))?;
|
||||
let flip = decode(include_bytes!("../../assets/audio/card_flip.wav"))?;
|
||||
let place = decode(include_bytes!("../../assets/audio/card_place.wav"))?;
|
||||
let invalid = decode(include_bytes!("../../assets/audio/card_invalid.wav"))?;
|
||||
let fanfare = decode(include_bytes!("../../assets/audio/win_fanfare.wav"))?;
|
||||
Some(SoundLibrary {
|
||||
deal,
|
||||
flip,
|
||||
place,
|
||||
invalid,
|
||||
fanfare,
|
||||
})
|
||||
}
|
||||
|
||||
fn decode(bytes: &'static [u8]) -> Option<StaticSoundData> {
|
||||
match StaticSoundData::from_cursor(Cursor::new(bytes.to_vec())) {
|
||||
Ok(data) => Some(data),
|
||||
Err(e) => {
|
||||
warn!("failed to decode SFX: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn play(audio: &mut AudioState, sound: &StaticSoundData) {
|
||||
let Some(manager) = audio.manager.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = manager.play(sound.clone()) {
|
||||
warn!("failed to play SFX: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn set_main_track_volume(audio: &mut AudioState, volume: f32) {
|
||||
let Some(manager) = audio.manager.as_mut() else {
|
||||
return;
|
||||
};
|
||||
manager
|
||||
.main_track()
|
||||
.set_volume(volume.clamp(0.0, 1.0) as f64, Tween::default());
|
||||
}
|
||||
|
||||
fn apply_initial_volume(
|
||||
mut audio: NonSendMut<AudioState>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
) {
|
||||
let volume = settings.map_or(1.0, |s| s.0.sfx_volume);
|
||||
set_main_track_volume(&mut audio, volume);
|
||||
}
|
||||
|
||||
fn apply_volume_on_change(
|
||||
mut events: EventReader<SettingsChangedEvent>,
|
||||
mut audio: NonSendMut<AudioState>,
|
||||
) {
|
||||
for ev in events.read() {
|
||||
set_main_track_volume(&mut audio, ev.0.sfx_volume);
|
||||
}
|
||||
}
|
||||
|
||||
fn play_on_draw(
|
||||
mut events: EventReader<DrawRequestEvent>,
|
||||
mut audio: NonSendMut<AudioState>,
|
||||
lib: Option<Res<SoundLibrary>>,
|
||||
) {
|
||||
let Some(lib) = lib else {
|
||||
return;
|
||||
};
|
||||
for _ in events.read() {
|
||||
play(&mut audio, &lib.flip);
|
||||
}
|
||||
}
|
||||
|
||||
fn play_on_move(
|
||||
mut events: EventReader<MoveRequestEvent>,
|
||||
mut audio: NonSendMut<AudioState>,
|
||||
lib: Option<Res<SoundLibrary>>,
|
||||
) {
|
||||
let Some(lib) = lib else {
|
||||
return;
|
||||
};
|
||||
for _ in events.read() {
|
||||
play(&mut audio, &lib.place);
|
||||
}
|
||||
}
|
||||
|
||||
fn play_on_rejected(
|
||||
mut events: EventReader<MoveRejectedEvent>,
|
||||
mut audio: NonSendMut<AudioState>,
|
||||
lib: Option<Res<SoundLibrary>>,
|
||||
) {
|
||||
let Some(lib) = lib else {
|
||||
return;
|
||||
};
|
||||
for _ in events.read() {
|
||||
play(&mut audio, &lib.invalid);
|
||||
}
|
||||
}
|
||||
|
||||
fn play_on_new_game(
|
||||
mut events: EventReader<NewGameRequestEvent>,
|
||||
mut audio: NonSendMut<AudioState>,
|
||||
lib: Option<Res<SoundLibrary>>,
|
||||
) {
|
||||
let Some(lib) = lib else {
|
||||
return;
|
||||
};
|
||||
for _ in events.read() {
|
||||
play(&mut audio, &lib.deal);
|
||||
}
|
||||
}
|
||||
|
||||
fn play_on_win(
|
||||
mut events: EventReader<GameWonEvent>,
|
||||
mut audio: NonSendMut<AudioState>,
|
||||
lib: Option<Res<SoundLibrary>>,
|
||||
) {
|
||||
let Some(lib) = lib else {
|
||||
return;
|
||||
};
|
||||
for _ in events.read() {
|
||||
play(&mut audio, &lib.fanfare);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn embedded_wavs_decode_successfully() {
|
||||
// Verifies the include_bytes! paths resolve and the bytes are valid
|
||||
// WAV (so the gen_sfx output stays in sync with the loader).
|
||||
let lib = build_library();
|
||||
assert!(lib.is_some(), "embedded SFX failed to decode");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
//! Challenge-mode bookkeeping: serves the current challenge seed, advances
|
||||
//! `PlayerProgress::challenge_index` on a Challenge-mode win, persists.
|
||||
//!
|
||||
//! Pressing **X** starts a new game with the current Challenge seed in
|
||||
//! `GameMode::Challenge` (gated by level ≥ `CHALLENGE_UNLOCK_LEVEL`).
|
||||
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::game_state::GameMode;
|
||||
use solitaire_data::{challenge_count, challenge_seed_for, save_progress_to};
|
||||
|
||||
use crate::events::{GameWonEvent, NewGameRequestEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
|
||||
use crate::resources::GameStateResource;
|
||||
|
||||
/// Minimum player level required to start a Challenge run.
|
||||
pub const CHALLENGE_UNLOCK_LEVEL: u32 = 5;
|
||||
|
||||
/// Fired when the player has just completed a Challenge-mode game and the
|
||||
/// `challenge_index` cursor advances.
|
||||
#[derive(Event, Debug, Clone, Copy)]
|
||||
pub struct ChallengeAdvancedEvent {
|
||||
pub previous_index: u32,
|
||||
pub new_index: u32,
|
||||
}
|
||||
|
||||
pub struct ChallengePlugin;
|
||||
|
||||
impl Plugin for ChallengePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_event::<ChallengeAdvancedEvent>()
|
||||
.add_event::<GameWonEvent>()
|
||||
.add_event::<NewGameRequestEvent>()
|
||||
// Run after ProgressUpdate so we don't fight ProgressPlugin's add_xp.
|
||||
.add_systems(Update, advance_on_challenge_win.after(ProgressUpdate))
|
||||
.add_systems(Update, handle_start_challenge_request.before(GameMutation));
|
||||
}
|
||||
}
|
||||
|
||||
fn advance_on_challenge_win(
|
||||
mut wins: EventReader<GameWonEvent>,
|
||||
game: Res<GameStateResource>,
|
||||
mut progress: ResMut<ProgressResource>,
|
||||
path: Res<ProgressStoragePath>,
|
||||
mut advanced: EventWriter<ChallengeAdvancedEvent>,
|
||||
) {
|
||||
for _ in wins.read() {
|
||||
if game.0.mode != GameMode::Challenge {
|
||||
continue;
|
||||
}
|
||||
let prev = progress.0.challenge_index;
|
||||
progress.0.challenge_index = prev.saturating_add(1);
|
||||
if let Some(target) = &path.0 {
|
||||
if let Err(e) = save_progress_to(target, &progress.0) {
|
||||
warn!("failed to save progress after challenge advance: {e}");
|
||||
}
|
||||
}
|
||||
advanced.send(ChallengeAdvancedEvent {
|
||||
previous_index: prev,
|
||||
new_index: progress.0.challenge_index,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_start_challenge_request(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
progress: Res<ProgressResource>,
|
||||
mut new_game: EventWriter<NewGameRequestEvent>,
|
||||
) {
|
||||
if !keys.just_pressed(KeyCode::KeyX) {
|
||||
return;
|
||||
}
|
||||
if progress.0.level < CHALLENGE_UNLOCK_LEVEL {
|
||||
info!(
|
||||
"Challenge mode locked — reach level {} (currently {}).",
|
||||
CHALLENGE_UNLOCK_LEVEL, progress.0.level
|
||||
);
|
||||
return;
|
||||
}
|
||||
let Some(seed) = challenge_seed_for(progress.0.challenge_index) else {
|
||||
warn!("challenge seed list is empty");
|
||||
return;
|
||||
};
|
||||
new_game.send(NewGameRequestEvent {
|
||||
seed: Some(seed),
|
||||
mode: Some(GameMode::Challenge),
|
||||
});
|
||||
}
|
||||
|
||||
/// Convenience for stat overlays: returns the human-friendly position
|
||||
/// string `"{index + 1} / {total}"`.
|
||||
pub fn challenge_progress_label(index: u32) -> String {
|
||||
format!("{} / {}", index.saturating_add(1), challenge_count())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use crate::progress_plugin::ProgressPlugin;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(ProgressPlugin::headless())
|
||||
.add_plugins(ChallengePlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_win_advances_index() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
|
||||
|
||||
app.world_mut().send_event(GameWonEvent {
|
||||
score: 500,
|
||||
time_seconds: 100,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let p = &app.world().resource::<ProgressResource>().0;
|
||||
assert_eq!(p.challenge_index, 1);
|
||||
|
||||
let events = app.world().resource::<Events<ChallengeAdvancedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
let fired: Vec<_> = cursor.read(events).copied().collect();
|
||||
assert_eq!(fired.len(), 1);
|
||||
assert_eq!(fired[0].previous_index, 0);
|
||||
assert_eq!(fired[0].new_index, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_win_does_not_advance_challenge_index() {
|
||||
let mut app = headless_app();
|
||||
// Default GameStateResource is Classic mode.
|
||||
app.world_mut().send_event(GameWonEvent {
|
||||
score: 500,
|
||||
time_seconds: 100,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let p = &app.world().resource::<ProgressResource>().0;
|
||||
assert_eq!(p.challenge_index, 0);
|
||||
|
||||
let events = app.world().resource::<Events<ChallengeAdvancedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
assert!(cursor.read(events).next().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_x_below_unlock_level_is_ignored() {
|
||||
let mut app = headless_app();
|
||||
// Default level is 0; below CHALLENGE_UNLOCK_LEVEL.
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::KeyX);
|
||||
app.update();
|
||||
|
||||
let events = app.world().resource::<Events<NewGameRequestEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
assert!(cursor.read(events).next().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_x_at_unlock_level_fires_new_game_with_challenge_seed() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut().resource_mut::<ProgressResource>().0.level =
|
||||
CHALLENGE_UNLOCK_LEVEL;
|
||||
app.world_mut()
|
||||
.resource_mut::<ProgressResource>()
|
||||
.0
|
||||
.challenge_index = 2;
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::KeyX);
|
||||
app.update();
|
||||
|
||||
let events = app.world().resource::<Events<NewGameRequestEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
let fired: Vec<_> = cursor.read(events).copied().collect();
|
||||
assert_eq!(fired.len(), 1);
|
||||
assert_eq!(fired[0].seed, challenge_seed_for(2));
|
||||
assert_eq!(fired[0].mode, Some(GameMode::Challenge));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_progress_label_uses_human_indexing() {
|
||||
let total = challenge_count();
|
||||
assert_eq!(challenge_progress_label(0), format!("1 / {total}"));
|
||||
assert_eq!(challenge_progress_label(2), format!("3 / {total}"));
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,7 @@ fn handle_start_daily_request(
|
||||
if keys.just_pressed(KeyCode::KeyC) {
|
||||
new_game.send(NewGameRequestEvent {
|
||||
seed: Some(daily.seed),
|
||||
mode: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Cross-system events used by the engine's plugins.
|
||||
|
||||
use bevy::prelude::Event;
|
||||
use solitaire_core::game_state::GameMode;
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
/// Request to move `count` cards from `from` to `to`. Fired by input systems,
|
||||
@@ -21,9 +22,11 @@ pub struct DrawRequestEvent;
|
||||
pub struct UndoRequestEvent;
|
||||
|
||||
/// Request to start a new game. `seed = None` uses a system-time seed.
|
||||
/// `mode = None` reuses the current game's `GameMode`.
|
||||
#[derive(Event, Debug, Clone, Copy, Default)]
|
||||
pub struct NewGameRequestEvent {
|
||||
pub seed: Option<u64>,
|
||||
pub mode: Option<GameMode>,
|
||||
}
|
||||
|
||||
/// Fired by `GamePlugin` after any successful state mutation. Rendering and
|
||||
@@ -31,6 +34,16 @@ pub struct NewGameRequestEvent {
|
||||
#[derive(Event, Debug, Clone, Copy, Default)]
|
||||
pub struct StateChangedEvent;
|
||||
|
||||
/// Fired by input/UI systems when a player attempts to drop dragged cards
|
||||
/// on a real pile but the move violates the rules. Drives the
|
||||
/// `card_invalid.wav` SFX. Not fired for drops in empty space.
|
||||
#[derive(Event, Debug, Clone)]
|
||||
pub struct MoveRejectedEvent {
|
||||
pub from: PileType,
|
||||
pub to: PileType,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
/// Fired once when the active game transitions to won.
|
||||
#[derive(Event, Debug, Clone, Copy)]
|
||||
pub struct GameWonEvent {
|
||||
|
||||
@@ -35,6 +35,7 @@ impl Plugin for GamePlugin {
|
||||
.add_event::<UndoRequestEvent>()
|
||||
.add_event::<NewGameRequestEvent>()
|
||||
.add_event::<StateChangedEvent>()
|
||||
.add_event::<crate::events::MoveRejectedEvent>()
|
||||
.add_event::<GameWonEvent>()
|
||||
.add_event::<crate::events::CardFlippedEvent>()
|
||||
.add_event::<crate::events::AchievementUnlockedEvent>()
|
||||
@@ -72,13 +73,18 @@ pub fn advance_elapsed(
|
||||
}
|
||||
|
||||
/// Increment `GameState.elapsed_seconds` once per real-world second while
|
||||
/// the game is in progress (not won). Stops counting on win so the final
|
||||
/// time reflects how long the player took to solve the deal.
|
||||
/// the game is in progress (not won) and not paused. Stops counting on
|
||||
/// win so the final time reflects how long the player took to solve the
|
||||
/// deal; stops while the pause overlay is open.
|
||||
fn tick_elapsed_time(
|
||||
time: Res<Time>,
|
||||
mut game: ResMut<GameStateResource>,
|
||||
mut accumulator: Local<f32>,
|
||||
paused: Option<Res<crate::pause_plugin::PausedResource>>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
let is_won = game.0.is_won;
|
||||
advance_elapsed(
|
||||
&mut game.0.elapsed_seconds,
|
||||
@@ -103,7 +109,8 @@ fn handle_new_game(
|
||||
for ev in new_game.read() {
|
||||
let seed = ev.seed.unwrap_or_else(seed_from_system_time);
|
||||
let draw_mode = game.0.draw_mode.clone();
|
||||
game.0 = GameState::new(seed, draw_mode);
|
||||
let mode = ev.mode.unwrap_or(game.0.mode);
|
||||
game.0 = GameState::new_with_mode(seed, draw_mode, mode);
|
||||
changed.send(StateChangedEvent);
|
||||
}
|
||||
}
|
||||
@@ -253,7 +260,7 @@ mod tests {
|
||||
.map(|c| c.id)
|
||||
.collect();
|
||||
|
||||
app.world_mut().send_event(NewGameRequestEvent { seed: Some(999) });
|
||||
app.world_mut().send_event(NewGameRequestEvent { seed: Some(999), mode: None });
|
||||
app.update();
|
||||
|
||||
let after: Vec<u32> = app
|
||||
@@ -292,11 +299,16 @@ mod tests {
|
||||
fn advance_elapsed_handles_subsecond_deltas_without_skipping() {
|
||||
let mut elapsed = 0;
|
||||
let mut acc = 0.0;
|
||||
// 16ms × 60 frames/sec ≈ 1 second; should produce 1 tick.
|
||||
for _ in 0..60 {
|
||||
advance_elapsed(&mut elapsed, &mut acc, 1.0 / 60.0, false);
|
||||
// 4 × 0.25 = 1.0 (exactly representable in f32) — must produce 1 tick.
|
||||
for _ in 0..4 {
|
||||
advance_elapsed(&mut elapsed, &mut acc, 0.25, false);
|
||||
}
|
||||
assert!(elapsed == 1, "expected 1 second, got {elapsed}");
|
||||
assert_eq!(elapsed, 1);
|
||||
// Repeat once more for a total of 2 seconds.
|
||||
for _ in 0..4 {
|
||||
advance_elapsed(&mut elapsed, &mut acc, 0.25, false);
|
||||
}
|
||||
assert_eq!(elapsed, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Toggleable on-screen help / cheat sheet showing keyboard bindings.
|
||||
//!
|
||||
//! Press **H** (or `?`) to toggle. Listed shortcuts are grouped by intent —
|
||||
//! gameplay, modes, and overlays.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// Marker on the help overlay root node.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HelpScreen;
|
||||
|
||||
pub struct HelpPlugin;
|
||||
|
||||
impl Plugin for HelpPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(Update, toggle_help_screen);
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_help_screen(
|
||||
mut commands: Commands,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
screens: Query<Entity, With<HelpScreen>>,
|
||||
) {
|
||||
let pressed_help = keys.just_pressed(KeyCode::KeyH) || keys.just_pressed(KeyCode::Slash);
|
||||
if !pressed_help {
|
||||
return;
|
||||
}
|
||||
if let Ok(entity) = screens.get_single() {
|
||||
commands.entity(entity).despawn_recursive();
|
||||
} else {
|
||||
spawn_help_screen(&mut commands);
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_help_screen(commands: &mut Commands) {
|
||||
let lines: Vec<String> = vec![
|
||||
"=== Controls ===".to_string(),
|
||||
String::new(),
|
||||
"-- Gameplay --".to_string(),
|
||||
" D Draw from stock".to_string(),
|
||||
" U Undo last move".to_string(),
|
||||
" Drag Move cards between piles".to_string(),
|
||||
" Click stock Draw".to_string(),
|
||||
String::new(),
|
||||
"-- New Game --".to_string(),
|
||||
" N New Classic game".to_string(),
|
||||
" C Start today's daily challenge".to_string(),
|
||||
" Z Start a Zen game (level 5+)".to_string(),
|
||||
" X Start the next Challenge (level 5+)".to_string(),
|
||||
" T Start a Time Attack session (level 5+)".to_string(),
|
||||
String::new(),
|
||||
"-- Overlays --".to_string(),
|
||||
" S Toggle stats / progression".to_string(),
|
||||
" H or ? Toggle this help".to_string(),
|
||||
" Esc Pause / resume".to_string(),
|
||||
" [ / ] SFX volume down / up".to_string(),
|
||||
String::new(),
|
||||
"Press H or ? to close".to_string(),
|
||||
];
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
HelpScreen,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Percent(0.0),
|
||||
top: Val::Percent(0.0),
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Percent(100.0),
|
||||
flex_direction: FlexDirection::Column,
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
row_gap: Val::Px(4.0),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.88)),
|
||||
ZIndex(210),
|
||||
))
|
||||
.with_children(|b| {
|
||||
for line in lines {
|
||||
b.spawn((
|
||||
Text::new(line),
|
||||
TextFont {
|
||||
font_size: 22.0,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::srgb(0.95, 0.95, 0.90)),
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins).add_plugins(HelpPlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_h_spawns_help_screen() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::KeyH);
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&HelpScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_h_twice_closes_help_screen() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::KeyH);
|
||||
app.update();
|
||||
|
||||
{
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release(KeyCode::KeyH);
|
||||
input.clear();
|
||||
input.press(KeyCode::KeyH);
|
||||
}
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&HelpScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_slash_also_toggles_help() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::Slash);
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&HelpScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -24,10 +24,13 @@ use solitaire_core::pile::PileType;
|
||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||
|
||||
use crate::card_plugin::{CardEntity, TABLEAU_FAN_FRAC};
|
||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||
use crate::events::{
|
||||
DrawRequestEvent, MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
|
||||
DrawRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent, StateChangedEvent,
|
||||
UndoRequestEvent,
|
||||
};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::layout::{Layout, LayoutResource};
|
||||
use crate::resources::{DragState, GameStateResource};
|
||||
|
||||
@@ -61,6 +64,7 @@ impl Plugin for InputPlugin {
|
||||
|
||||
fn handle_keyboard(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
mut undo: EventWriter<UndoRequestEvent>,
|
||||
mut new_game: EventWriter<NewGameRequestEvent>,
|
||||
mut draw: EventWriter<DrawRequestEvent>,
|
||||
@@ -69,15 +73,28 @@ fn handle_keyboard(
|
||||
undo.send(UndoRequestEvent);
|
||||
}
|
||||
if keys.just_pressed(KeyCode::KeyN) {
|
||||
new_game.send(NewGameRequestEvent { seed: None });
|
||||
new_game.send(NewGameRequestEvent::default());
|
||||
}
|
||||
if keys.just_pressed(KeyCode::KeyZ) {
|
||||
// Zen / Challenge / Time Attack are gated to level >= CHALLENGE_UNLOCK_LEVEL.
|
||||
// X is gated separately by ChallengePlugin.
|
||||
let level = progress.as_ref().map_or(0, |p| p.0.level);
|
||||
if level >= CHALLENGE_UNLOCK_LEVEL {
|
||||
new_game.send(NewGameRequestEvent {
|
||||
seed: None,
|
||||
mode: Some(solitaire_core::game_state::GameMode::Zen),
|
||||
});
|
||||
} else {
|
||||
info!(
|
||||
"Zen mode locked — reach level {} (currently {}).",
|
||||
CHALLENGE_UNLOCK_LEVEL, level
|
||||
);
|
||||
}
|
||||
}
|
||||
if keys.just_pressed(KeyCode::KeyD) {
|
||||
draw.send(DrawRequestEvent);
|
||||
}
|
||||
if keys.just_pressed(KeyCode::Escape) {
|
||||
// Pause placeholder — the pause screen hooks this up in a later phase.
|
||||
info!("pause requested (not yet wired)");
|
||||
}
|
||||
// Esc is handled by `PausePlugin` (overlay toggle + paused flag).
|
||||
}
|
||||
|
||||
fn handle_stock_click(
|
||||
@@ -196,6 +213,7 @@ fn end_drag(
|
||||
game: Res<GameStateResource>,
|
||||
mut drag: ResMut<DragState>,
|
||||
mut moves: EventWriter<MoveRequestEvent>,
|
||||
mut rejected: EventWriter<MoveRejectedEvent>,
|
||||
mut changed: EventWriter<StateChangedEvent>,
|
||||
) {
|
||||
if !buttons.just_released(MouseButton::Left) || drag.is_idle() {
|
||||
@@ -215,7 +233,9 @@ fn end_drag(
|
||||
|
||||
// Whether we fire a MoveRequestEvent or not, always trigger a resync so
|
||||
// the dragged cards snap back to their resting positions if the move is
|
||||
// rejected (or never fired).
|
||||
// rejected (or never fired). When the cursor was over a real pile but
|
||||
// the placement is illegal, fire MoveRejectedEvent so AudioPlugin can
|
||||
// play card_invalid.wav.
|
||||
let mut fired = false;
|
||||
if let Some(target) = target {
|
||||
if target != origin {
|
||||
@@ -237,11 +257,17 @@ fn end_drag(
|
||||
};
|
||||
if ok {
|
||||
moves.send(MoveRequestEvent {
|
||||
from: origin,
|
||||
to: target,
|
||||
from: origin.clone(),
|
||||
to: target.clone(),
|
||||
count,
|
||||
});
|
||||
fired = true;
|
||||
} else {
|
||||
rejected.send(MoveRejectedEvent {
|
||||
from: origin.clone(),
|
||||
to: target.clone(),
|
||||
count,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,33 +2,53 @@
|
||||
|
||||
pub mod achievement_plugin;
|
||||
pub mod animation_plugin;
|
||||
pub mod audio_plugin;
|
||||
pub mod card_plugin;
|
||||
pub mod challenge_plugin;
|
||||
pub mod daily_challenge_plugin;
|
||||
pub mod events;
|
||||
pub mod game_plugin;
|
||||
pub mod help_plugin;
|
||||
pub mod input_plugin;
|
||||
pub mod layout;
|
||||
pub mod onboarding_plugin;
|
||||
pub mod pause_plugin;
|
||||
pub mod settings_plugin;
|
||||
pub mod progress_plugin;
|
||||
pub mod resources;
|
||||
pub mod stats_plugin;
|
||||
pub mod table_plugin;
|
||||
pub mod time_attack_plugin;
|
||||
pub mod weekly_goals_plugin;
|
||||
|
||||
pub use achievement_plugin::{AchievementPlugin, AchievementsResource};
|
||||
pub use challenge_plugin::{
|
||||
challenge_progress_label, ChallengeAdvancedEvent, ChallengePlugin, CHALLENGE_UNLOCK_LEVEL,
|
||||
};
|
||||
pub use daily_challenge_plugin::{
|
||||
DailyChallengeCompletedEvent, DailyChallengePlugin, DailyChallengeResource,
|
||||
};
|
||||
pub use progress_plugin::{LevelUpEvent, ProgressPlugin, ProgressResource, ProgressUpdate};
|
||||
pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
|
||||
pub use animation_plugin::{AnimationPlugin, CardAnim};
|
||||
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
|
||||
pub use card_plugin::{CardEntity, CardLabel, CardPlugin};
|
||||
pub use events::{
|
||||
AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRequestEvent,
|
||||
NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
|
||||
AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRejectedEvent,
|
||||
MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
|
||||
};
|
||||
pub use game_plugin::{GameMutation, GamePlugin};
|
||||
pub use help_plugin::{HelpPlugin, HelpScreen};
|
||||
pub use input_plugin::InputPlugin;
|
||||
pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen};
|
||||
pub use pause_plugin::{PausePlugin, PauseScreen, PausedResource};
|
||||
pub use settings_plugin::{
|
||||
SettingsChangedEvent, SettingsPlugin, SettingsResource, SFX_STEP,
|
||||
};
|
||||
pub use layout::{compute_layout, Layout, LayoutResource};
|
||||
pub use resources::{DragState, GameStateResource, SyncStatus, SyncStatusResource};
|
||||
pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate};
|
||||
pub use table_plugin::{PileMarker, TableBackground, TablePlugin};
|
||||
pub use time_attack_plugin::{
|
||||
TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource, TIME_ATTACK_DURATION_SECS,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
//! First-run onboarding banner.
|
||||
//!
|
||||
//! On startup, if `Settings.first_run_complete` is `false`, spawn a centered
|
||||
//! welcome banner pointing at the **H**/`?` cheat sheet. The first key or
|
||||
//! mouse-button press dismisses it, sets the flag, and persists settings —
|
||||
//! so returning players never see it again.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use solitaire_data::{save_settings_to, Settings};
|
||||
|
||||
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||
|
||||
/// Marker on the onboarding overlay root node.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct OnboardingScreen;
|
||||
|
||||
pub struct OnboardingPlugin;
|
||||
|
||||
impl Plugin for OnboardingPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(PostStartup, spawn_if_first_run)
|
||||
.add_systems(Update, dismiss_on_any_input);
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_if_first_run(mut commands: Commands, settings: Option<Res<SettingsResource>>) {
|
||||
let Some(s) = settings else {
|
||||
return;
|
||||
};
|
||||
if s.0.first_run_complete {
|
||||
return;
|
||||
}
|
||||
spawn_onboarding_screen(&mut commands);
|
||||
}
|
||||
|
||||
fn dismiss_on_any_input(
|
||||
mut commands: Commands,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mouse: Res<ButtonInput<MouseButton>>,
|
||||
mut settings: ResMut<SettingsResource>,
|
||||
path: Option<Res<SettingsStoragePath>>,
|
||||
screens: Query<Entity, With<OnboardingScreen>>,
|
||||
) {
|
||||
let Ok(entity) = screens.get_single() else {
|
||||
return;
|
||||
};
|
||||
let pressed = keys.get_just_pressed().next().is_some()
|
||||
|| mouse.get_just_pressed().next().is_some();
|
||||
if !pressed {
|
||||
return;
|
||||
}
|
||||
commands.entity(entity).despawn_recursive();
|
||||
settings.0.first_run_complete = true;
|
||||
persist(path.as_deref().map(|p| &p.0), &settings.0);
|
||||
}
|
||||
|
||||
fn persist(path: Option<&Option<PathBuf>>, settings: &Settings) {
|
||||
let Some(Some(target)) = path else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = save_settings_to(target, settings) {
|
||||
warn!("failed to save settings (onboarding): {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_onboarding_screen(commands: &mut Commands) {
|
||||
let lines: Vec<(String, f32)> = vec![
|
||||
("Welcome to Solitaire Quest!".to_string(), 40.0),
|
||||
(String::new(), 20.0),
|
||||
(
|
||||
"Drag cards between piles. Press D to draw, U to undo.".to_string(),
|
||||
22.0,
|
||||
),
|
||||
(
|
||||
"Press H or ? at any time to see the full controls.".to_string(),
|
||||
22.0,
|
||||
),
|
||||
(String::new(), 20.0),
|
||||
("Press any key to begin".to_string(), 20.0),
|
||||
];
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
OnboardingScreen,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Percent(0.0),
|
||||
top: Val::Percent(0.0),
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Percent(100.0),
|
||||
flex_direction: FlexDirection::Column,
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
row_gap: Val::Px(8.0),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.92)),
|
||||
ZIndex(230),
|
||||
))
|
||||
.with_children(|b| {
|
||||
for (line, size) in lines {
|
||||
b.spawn((
|
||||
Text::new(line),
|
||||
TextFont {
|
||||
font_size: size,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::srgb(1.0, 0.87, 0.0)),
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::settings_plugin::SettingsPlugin;
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(SettingsPlugin::headless())
|
||||
.add_plugins(OnboardingPlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.init_resource::<ButtonInput<MouseButton>>();
|
||||
app
|
||||
}
|
||||
|
||||
fn count_screens(app: &mut App) -> usize {
|
||||
app.world_mut()
|
||||
.query::<&OnboardingScreen>()
|
||||
.iter(app.world())
|
||||
.count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_run_spawns_banner() {
|
||||
let mut app = headless_app();
|
||||
app.update(); // PostStartup runs
|
||||
assert_eq!(count_screens(&mut app), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returning_player_does_not_see_banner() {
|
||||
let mut app = headless_app();
|
||||
// Mark already-completed before PostStartup runs.
|
||||
app.world_mut()
|
||||
.resource_mut::<SettingsResource>()
|
||||
.0
|
||||
.first_run_complete = true;
|
||||
app.update();
|
||||
assert_eq!(count_screens(&mut app), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keypress_dismisses_and_sets_flag() {
|
||||
let mut app = headless_app();
|
||||
app.update();
|
||||
assert_eq!(count_screens(&mut app), 1);
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::Space);
|
||||
app.update();
|
||||
|
||||
assert_eq!(count_screens(&mut app), 0);
|
||||
assert!(
|
||||
app.world()
|
||||
.resource::<SettingsResource>()
|
||||
.0
|
||||
.first_run_complete,
|
||||
"first_run_complete should flip to true"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouseclick_dismisses_banner() {
|
||||
let mut app = headless_app();
|
||||
app.update();
|
||||
assert_eq!(count_screens(&mut app), 1);
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<MouseButton>>()
|
||||
.press(MouseButton::Left);
|
||||
app.update();
|
||||
|
||||
assert_eq!(count_screens(&mut app), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Pause overlay (Esc).
|
||||
//!
|
||||
//! While paused:
|
||||
//! - The `PausedResource` flag is true.
|
||||
//! - Elapsed-time and Time Attack tickers stop counting (they read this
|
||||
//! resource and bail out early).
|
||||
//!
|
||||
//! Pressing Esc again dismisses the overlay and resumes ticking. Other
|
||||
//! input (drag, keyboard hotkeys) is **not** blocked — pause is purely a
|
||||
//! "stop the clock" screen for now. A future polish slice can layer
|
||||
//! input-blocking on top if desired.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// Toggleable flag read by `tick_elapsed_time` and `advance_time_attack`.
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct PausedResource(pub bool);
|
||||
|
||||
/// Marker on the pause overlay root node.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct PauseScreen;
|
||||
|
||||
pub struct PausePlugin;
|
||||
|
||||
impl Plugin for PausePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<PausedResource>()
|
||||
.add_systems(Update, toggle_pause);
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_pause(
|
||||
mut commands: Commands,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut paused: ResMut<PausedResource>,
|
||||
screens: Query<Entity, With<PauseScreen>>,
|
||||
) {
|
||||
if !keys.just_pressed(KeyCode::Escape) {
|
||||
return;
|
||||
}
|
||||
if let Ok(entity) = screens.get_single() {
|
||||
commands.entity(entity).despawn_recursive();
|
||||
paused.0 = false;
|
||||
} else {
|
||||
spawn_pause_screen(&mut commands);
|
||||
paused.0 = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_pause_screen(commands: &mut Commands) {
|
||||
commands
|
||||
.spawn((
|
||||
PauseScreen,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Percent(0.0),
|
||||
top: Val::Percent(0.0),
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Percent(100.0),
|
||||
flex_direction: FlexDirection::Column,
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
row_gap: Val::Px(8.0),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.82)),
|
||||
ZIndex(220),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
Text::new("Paused"),
|
||||
TextFont {
|
||||
font_size: 48.0,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::srgb(1.0, 0.87, 0.0)),
|
||||
));
|
||||
b.spawn((
|
||||
Text::new("Press Esc to resume"),
|
||||
TextFont {
|
||||
font_size: 22.0,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::srgb(0.85, 0.85, 0.80)),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
fn press_esc(app: &mut App) {
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release(KeyCode::Escape);
|
||||
input.clear();
|
||||
input.press(KeyCode::Escape);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_esc_pauses() {
|
||||
let mut app = headless_app();
|
||||
press_esc(&mut app);
|
||||
app.update();
|
||||
assert!(app.world().resource::<PausedResource>().0);
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&PauseScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_esc_twice_resumes() {
|
||||
let mut app = headless_app();
|
||||
press_esc(&mut app);
|
||||
app.update();
|
||||
press_esc(&mut app);
|
||||
app.update();
|
||||
assert!(!app.world().resource::<PausedResource>().0);
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&PauseScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Persists `solitaire_data::Settings` and exposes hotkeys for live tuning.
|
||||
//!
|
||||
//! Hotkeys (always active, no overlay required):
|
||||
//! - `[` decrease SFX volume by `SFX_STEP`
|
||||
//! - `]` increase SFX volume by `SFX_STEP`
|
||||
//!
|
||||
//! On change, the plugin persists `settings.json` and fires
|
||||
//! `SettingsChangedEvent` so dependents (e.g. `AudioPlugin`) can react.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, Settings};
|
||||
|
||||
/// Volume adjustment step.
|
||||
pub const SFX_STEP: f32 = 0.1;
|
||||
|
||||
/// Bevy resource wrapping the current `Settings`.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct SettingsResource(pub Settings);
|
||||
|
||||
/// Persistence path for `SettingsResource`. `None` disables I/O.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct SettingsStoragePath(pub Option<PathBuf>);
|
||||
|
||||
/// Fired any time settings change so consumers (audio, UI) can react.
|
||||
#[derive(Event, Debug, Clone)]
|
||||
pub struct SettingsChangedEvent(pub Settings);
|
||||
|
||||
pub struct SettingsPlugin {
|
||||
pub storage_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for SettingsPlugin {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
storage_path: settings_file_path(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SettingsPlugin {
|
||||
/// Plugin configured with no persistence — for tests and headless apps.
|
||||
pub fn headless() -> Self {
|
||||
Self { storage_path: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for SettingsPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
let loaded = match &self.storage_path {
|
||||
Some(path) => load_settings_from(path),
|
||||
None => Settings::default(),
|
||||
};
|
||||
app.insert_resource(SettingsResource(loaded))
|
||||
.insert_resource(SettingsStoragePath(self.storage_path.clone()))
|
||||
.add_event::<SettingsChangedEvent>()
|
||||
.add_systems(Update, handle_volume_keys);
|
||||
}
|
||||
}
|
||||
|
||||
fn persist(path: &SettingsStoragePath, settings: &Settings) {
|
||||
let Some(target) = &path.0 else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = save_settings_to(target, settings) {
|
||||
warn!("failed to save settings: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_volume_keys(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut settings: ResMut<SettingsResource>,
|
||||
path: Res<SettingsStoragePath>,
|
||||
mut changed: EventWriter<SettingsChangedEvent>,
|
||||
) {
|
||||
let mut delta = 0.0;
|
||||
if keys.just_pressed(KeyCode::BracketLeft) {
|
||||
delta -= SFX_STEP;
|
||||
}
|
||||
if keys.just_pressed(KeyCode::BracketRight) {
|
||||
delta += SFX_STEP;
|
||||
}
|
||||
if delta == 0.0 {
|
||||
return;
|
||||
}
|
||||
let before = settings.0.sfx_volume;
|
||||
let after = settings.0.adjust_sfx_volume(delta);
|
||||
if (before - after).abs() < f32::EPSILON {
|
||||
// Already at the rail — no point persisting or notifying.
|
||||
return;
|
||||
}
|
||||
persist(&path, &settings.0);
|
||||
changed.send(SettingsChangedEvent(settings.0.clone()));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(SettingsPlugin::headless());
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
fn press(app: &mut App, key: KeyCode) {
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release(key);
|
||||
input.clear();
|
||||
input.press(key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_are_loaded() {
|
||||
let app = headless_app();
|
||||
assert_eq!(
|
||||
app.world().resource::<SettingsResource>().0,
|
||||
Settings::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_left_bracket_decreases_volume_and_emits_event() {
|
||||
let mut app = headless_app();
|
||||
let before = app.world().resource::<SettingsResource>().0.sfx_volume;
|
||||
|
||||
press(&mut app, KeyCode::BracketLeft);
|
||||
app.update();
|
||||
|
||||
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
|
||||
assert!(after < before);
|
||||
|
||||
let events = app.world().resource::<Events<SettingsChangedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
assert_eq!(cursor.read(events).count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_right_bracket_increases_volume() {
|
||||
let mut app = headless_app();
|
||||
// Drop volume first so there's headroom to grow.
|
||||
app.world_mut().resource_mut::<SettingsResource>().0.sfx_volume = 0.5;
|
||||
|
||||
press(&mut app, KeyCode::BracketRight);
|
||||
app.update();
|
||||
|
||||
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
|
||||
assert!((after - 0.6).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamped_change_does_not_emit_event() {
|
||||
let mut app = headless_app();
|
||||
// Already at max — pressing right bracket should be a no-op.
|
||||
app.world_mut().resource_mut::<SettingsResource>().0.sfx_volume = 1.0;
|
||||
|
||||
press(&mut app, KeyCode::BracketRight);
|
||||
app.update();
|
||||
|
||||
let events = app.world().resource::<Events<SettingsChangedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
assert_eq!(cursor.read(events).count(), 0);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ use crate::events::{GameWonEvent, NewGameRequestEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::time_attack_plugin::TimeAttackResource;
|
||||
|
||||
/// Bevy resource wrapping the current stats.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
@@ -127,6 +128,7 @@ fn toggle_stats_screen(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
stats: Res<StatsResource>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
time_attack: Option<Res<TimeAttackResource>>,
|
||||
screens: Query<Entity, With<StatsScreen>>,
|
||||
) {
|
||||
if !keys.just_pressed(KeyCode::KeyS) {
|
||||
@@ -135,7 +137,12 @@ fn toggle_stats_screen(
|
||||
if let Ok(entity) = screens.get_single() {
|
||||
commands.entity(entity).despawn_recursive();
|
||||
} else {
|
||||
spawn_stats_screen(&mut commands, &stats.0, progress.as_deref().map(|p| &p.0));
|
||||
spawn_stats_screen(
|
||||
&mut commands,
|
||||
&stats.0,
|
||||
progress.as_deref().map(|p| &p.0),
|
||||
time_attack.as_deref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +150,7 @@ fn spawn_stats_screen(
|
||||
commands: &mut Commands,
|
||||
stats: &StatsSnapshot,
|
||||
progress: Option<&PlayerProgress>,
|
||||
time_attack: Option<&TimeAttackResource>,
|
||||
) {
|
||||
let win_rate = stats
|
||||
.win_rate()
|
||||
@@ -194,6 +202,27 @@ fn spawn_stats_screen(
|
||||
goal.description, progress_value, goal.target
|
||||
));
|
||||
}
|
||||
lines.push(String::new());
|
||||
lines.push("-- Unlocks --".to_string());
|
||||
lines.push(format!(
|
||||
" Card Backs: {}",
|
||||
format_id_list(&p.unlocked_card_backs)
|
||||
));
|
||||
lines.push(format!(
|
||||
" Backgrounds: {}",
|
||||
format_id_list(&p.unlocked_backgrounds)
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(ta) = time_attack {
|
||||
if ta.active {
|
||||
let mins = (ta.remaining_secs / 60.0).floor() as u64;
|
||||
let secs = (ta.remaining_secs % 60.0).floor() as u64;
|
||||
lines.push(String::new());
|
||||
lines.push("=== Time Attack ===".to_string());
|
||||
lines.push(format!("Remaining: {mins}m {secs:02}s"));
|
||||
lines.push(format!("Wins: {}", ta.wins));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(String::new());
|
||||
@@ -237,6 +266,22 @@ fn format_duration(secs: u64) -> String {
|
||||
format!("{m}m {s:02}s")
|
||||
}
|
||||
|
||||
/// Renders a sorted, comma-separated list of unlock indexes for the overlay.
|
||||
/// Empty list shows as "None".
|
||||
fn format_id_list(ids: &[usize]) -> String {
|
||||
if ids.is_empty() {
|
||||
return "None".to_string();
|
||||
}
|
||||
let mut sorted: Vec<usize> = ids.to_vec();
|
||||
sorted.sort_unstable();
|
||||
sorted.dedup();
|
||||
sorted
|
||||
.iter()
|
||||
.map(|i| format!("#{i}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -296,7 +341,7 @@ mod tests {
|
||||
.move_count = 3;
|
||||
|
||||
app.world_mut()
|
||||
.send_event(NewGameRequestEvent { seed: Some(999) });
|
||||
.send_event(NewGameRequestEvent { seed: Some(999), mode: None });
|
||||
app.update();
|
||||
|
||||
let stats = &app.world().resource::<StatsResource>().0;
|
||||
@@ -309,7 +354,7 @@ mod tests {
|
||||
fn new_game_without_moves_does_not_record_abandoned() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.send_event(NewGameRequestEvent { seed: Some(42) });
|
||||
.send_event(NewGameRequestEvent { seed: Some(42), mode: None });
|
||||
app.update();
|
||||
|
||||
let stats = &app.world().resource::<StatsResource>().0;
|
||||
@@ -369,4 +414,14 @@ mod tests {
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_id_list_renders_empty_as_none() {
|
||||
assert_eq!(format_id_list(&[]), "None");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_id_list_sorts_dedups_and_prefixes() {
|
||||
assert_eq!(format_id_list(&[3, 1, 1, 2]), "#1, #2, #3");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
//! Time Attack mode runtime: 10-minute countdown wrapped around back-to-back
|
||||
//! `GameMode::TimeAttack` games. Pressing **T** starts a session (gated by
|
||||
//! level ≥ `CHALLENGE_UNLOCK_LEVEL`); each win during the session bumps the
|
||||
//! counter and auto-deals a fresh game. When the timer expires the session
|
||||
//! ends and `TimeAttackEndedEvent` fires.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::game_state::GameMode;
|
||||
|
||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||
use crate::events::{GameWonEvent, NewGameRequestEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::resources::GameStateResource;
|
||||
|
||||
/// Length of a Time Attack session in real-world seconds (10 minutes).
|
||||
pub const TIME_ATTACK_DURATION_SECS: f32 = 600.0;
|
||||
|
||||
/// Session state for an in-progress Time Attack run. Not persisted.
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
pub struct TimeAttackResource {
|
||||
pub active: bool,
|
||||
pub remaining_secs: f32,
|
||||
pub wins: u32,
|
||||
}
|
||||
|
||||
/// Fired when the Time Attack timer expires. The summary toast in
|
||||
/// `AnimationPlugin` consumes this; UI/stats consumers can also subscribe.
|
||||
#[derive(Event, Debug, Clone, Copy)]
|
||||
pub struct TimeAttackEndedEvent {
|
||||
pub wins: u32,
|
||||
}
|
||||
|
||||
pub struct TimeAttackPlugin;
|
||||
|
||||
impl Plugin for TimeAttackPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<TimeAttackResource>()
|
||||
.add_event::<TimeAttackEndedEvent>()
|
||||
.add_event::<GameWonEvent>()
|
||||
.add_event::<NewGameRequestEvent>()
|
||||
.add_systems(
|
||||
Update,
|
||||
handle_start_time_attack_request.before(GameMutation),
|
||||
)
|
||||
.add_systems(Update, advance_time_attack)
|
||||
.add_systems(Update, auto_deal_on_time_attack_win.after(GameMutation));
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_start_time_attack_request(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
progress: Res<ProgressResource>,
|
||||
mut session: ResMut<TimeAttackResource>,
|
||||
mut new_game: EventWriter<NewGameRequestEvent>,
|
||||
) {
|
||||
if !keys.just_pressed(KeyCode::KeyT) {
|
||||
return;
|
||||
}
|
||||
if progress.0.level < CHALLENGE_UNLOCK_LEVEL {
|
||||
info!(
|
||||
"Time Attack locked — reach level {} (currently {}).",
|
||||
CHALLENGE_UNLOCK_LEVEL, progress.0.level
|
||||
);
|
||||
return;
|
||||
}
|
||||
*session = TimeAttackResource {
|
||||
active: true,
|
||||
remaining_secs: TIME_ATTACK_DURATION_SECS,
|
||||
wins: 0,
|
||||
};
|
||||
new_game.send(NewGameRequestEvent {
|
||||
seed: None,
|
||||
mode: Some(GameMode::TimeAttack),
|
||||
});
|
||||
}
|
||||
|
||||
fn advance_time_attack(
|
||||
time: Res<Time>,
|
||||
mut session: ResMut<TimeAttackResource>,
|
||||
mut ended: EventWriter<TimeAttackEndedEvent>,
|
||||
paused: Option<Res<crate::pause_plugin::PausedResource>>,
|
||||
) {
|
||||
if !session.active {
|
||||
return;
|
||||
}
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
session.remaining_secs -= time.delta_secs();
|
||||
if session.remaining_secs <= 0.0 {
|
||||
let wins = session.wins;
|
||||
session.active = false;
|
||||
session.remaining_secs = 0.0;
|
||||
ended.send(TimeAttackEndedEvent { wins });
|
||||
}
|
||||
}
|
||||
|
||||
fn auto_deal_on_time_attack_win(
|
||||
mut wins: EventReader<GameWonEvent>,
|
||||
game: Res<GameStateResource>,
|
||||
mut session: ResMut<TimeAttackResource>,
|
||||
mut new_game: EventWriter<NewGameRequestEvent>,
|
||||
) {
|
||||
for _ in wins.read() {
|
||||
if !session.active || game.0.mode != GameMode::TimeAttack {
|
||||
continue;
|
||||
}
|
||||
session.wins = session.wins.saturating_add(1);
|
||||
new_game.send(NewGameRequestEvent {
|
||||
seed: None,
|
||||
mode: Some(GameMode::TimeAttack),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use crate::progress_plugin::ProgressPlugin;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(ProgressPlugin::headless())
|
||||
.add_plugins(TimeAttackPlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
fn press_t(app: &mut App) {
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release(KeyCode::KeyT);
|
||||
input.clear();
|
||||
input.press(KeyCode::KeyT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_t_below_unlock_level_is_ignored() {
|
||||
let mut app = headless_app();
|
||||
press_t(&mut app);
|
||||
app.update();
|
||||
|
||||
let session = app.world().resource::<TimeAttackResource>();
|
||||
assert!(!session.active);
|
||||
|
||||
let events = app.world().resource::<Events<NewGameRequestEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
assert!(cursor.read(events).next().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_t_at_unlock_level_starts_session_and_deals_time_attack_game() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut().resource_mut::<ProgressResource>().0.level = CHALLENGE_UNLOCK_LEVEL;
|
||||
|
||||
press_t(&mut app);
|
||||
app.update();
|
||||
|
||||
let session = app.world().resource::<TimeAttackResource>().clone();
|
||||
assert!(session.active);
|
||||
assert_eq!(session.wins, 0);
|
||||
assert!((session.remaining_secs - TIME_ATTACK_DURATION_SECS).abs() < 1.0);
|
||||
|
||||
let events = app.world().resource::<Events<NewGameRequestEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
let fired: Vec<_> = cursor.read(events).copied().collect();
|
||||
assert_eq!(fired.len(), 1);
|
||||
assert_eq!(fired[0].mode, Some(GameMode::TimeAttack));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timer_expiry_fires_ended_event_and_clears_active() {
|
||||
let mut app = headless_app();
|
||||
// Manually start a near-expired session.
|
||||
*app.world_mut().resource_mut::<TimeAttackResource>() = TimeAttackResource {
|
||||
active: true,
|
||||
remaining_secs: 0.001,
|
||||
wins: 5,
|
||||
};
|
||||
app.update();
|
||||
// First update advances time slightly; force the timer past zero.
|
||||
*app.world_mut().resource_mut::<TimeAttackResource>() = TimeAttackResource {
|
||||
active: true,
|
||||
remaining_secs: -1.0,
|
||||
wins: 5,
|
||||
};
|
||||
app.update();
|
||||
|
||||
let session = app.world().resource::<TimeAttackResource>();
|
||||
assert!(!session.active);
|
||||
assert_eq!(session.remaining_secs, 0.0);
|
||||
|
||||
let events = app.world().resource::<Events<TimeAttackEndedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
let fired: Vec<_> = cursor.read(events).copied().collect();
|
||||
assert_eq!(fired.len(), 1);
|
||||
assert_eq!(fired[0].wins, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn win_during_session_increments_wins_and_auto_deals() {
|
||||
let mut app = headless_app();
|
||||
// Start a session manually.
|
||||
*app.world_mut().resource_mut::<TimeAttackResource>() = TimeAttackResource {
|
||||
active: true,
|
||||
remaining_secs: 100.0,
|
||||
wins: 0,
|
||||
};
|
||||
// The current game must be in TimeAttack mode for auto-deal to fire.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::TimeAttack);
|
||||
|
||||
app.world_mut().send_event(GameWonEvent {
|
||||
score: 500,
|
||||
time_seconds: 60,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let session = app.world().resource::<TimeAttackResource>();
|
||||
assert_eq!(session.wins, 1);
|
||||
|
||||
let events = app.world().resource::<Events<NewGameRequestEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
let fired: Vec<_> = cursor.read(events).copied().collect();
|
||||
assert_eq!(fired.len(), 1);
|
||||
assert_eq!(fired[0].mode, Some(GameMode::TimeAttack));
|
||||
assert!(fired[0].seed.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn win_when_session_inactive_does_not_increment() {
|
||||
let mut app = headless_app();
|
||||
// Default session is inactive. Game is TimeAttack mode — still no count.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::TimeAttack);
|
||||
|
||||
app.world_mut().send_event(GameWonEvent {
|
||||
score: 500,
|
||||
time_seconds: 60,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let session = app.world().resource::<TimeAttackResource>();
|
||||
assert_eq!(session.wins, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_win_during_session_does_not_increment() {
|
||||
let mut app = headless_app();
|
||||
*app.world_mut().resource_mut::<TimeAttackResource>() = TimeAttackResource {
|
||||
active: true,
|
||||
remaining_secs: 100.0,
|
||||
wins: 0,
|
||||
};
|
||||
// GameStateResource defaults to Classic mode.
|
||||
app.world_mut().send_event(GameWonEvent {
|
||||
score: 500,
|
||||
time_seconds: 60,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let session = app.world().resource::<TimeAttackResource>();
|
||||
assert_eq!(session.wins, 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user