From 13b428b81cb887594a5d8b90e46e92e5335b3b5d Mon Sep 17 00:00:00 2001 From: funman300 Date: Sat, 25 Apr 2026 23:14:10 -0700 Subject: [PATCH] feat(engine): first-run onboarding banner OnboardingPlugin spawns a centered welcome banner at PostStartup when Settings.first_run_complete is false. Any key or mouse press dismisses it, sets the flag, and persists settings.json so returning players never see it again. Co-Authored-By: Claude Opus 4.7 --- docs/SESSION_HANDOFF.md | 24 ++- solitaire_app/src/main.rs | 5 +- solitaire_engine/src/lib.rs | 2 + solitaire_engine/src/onboarding_plugin.rs | 191 ++++++++++++++++++++++ 4 files changed, 212 insertions(+), 10 deletions(-) create mode 100644 solitaire_engine/src/onboarding_plugin.rs diff --git a/docs/SESSION_HANDOFF.md b/docs/SESSION_HANDOFF.md index aa144ae..616b695 100644 --- a/docs/SESSION_HANDOFF.md +++ b/docs/SESSION_HANDOFF.md @@ -2,7 +2,7 @@ > Last updated: 2026-04-25 > Branch: `master` — pushed to https://git.aleshym.co/funman300/Rusty_Solitare.git -> Test count: **238 passing** (83 core + 60 data + 95 engine), `cargo clippy --workspace -- -D warnings` clean +> Test count: **242 passing** (83 core + 60 data + 99 engine), `cargo clippy --workspace -- -D warnings` clean --- @@ -175,20 +175,28 @@ All sub-phases (3A–3F) done. Plugins: `GamePlugin`, `TablePlugin`, `CardPlugin - 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 7 (part 5+) — Final Polish - -- **Onboarding**: first-run banner pointing at the **H**/`?` cheat sheet, single-shot via `Settings.first_run_complete`. -- **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. +Phase 7 polish slate is done. Phase 8 (sync) is next. ### Phase 8 — Sync | Phase | Scope | |---|---| -| 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. --- diff --git a/solitaire_app/src/main.rs b/solitaire_app/src/main.rs index 784c5ef..58e9dee 100644 --- a/solitaire_app/src/main.rs +++ b/solitaire_app/src/main.rs @@ -1,8 +1,8 @@ use bevy::prelude::*; use solitaire_engine::{ AchievementPlugin, AnimationPlugin, AudioPlugin, CardPlugin, ChallengePlugin, - DailyChallengePlugin, GamePlugin, HelpPlugin, InputPlugin, PausePlugin, ProgressPlugin, - SettingsPlugin, StatsPlugin, TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin, + DailyChallengePlugin, GamePlugin, HelpPlugin, InputPlugin, OnboardingPlugin, PausePlugin, + ProgressPlugin, SettingsPlugin, StatsPlugin, TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin, }; fn main() { @@ -33,5 +33,6 @@ fn main() { .add_plugins(PausePlugin) .add_plugins(SettingsPlugin::default()) .add_plugins(AudioPlugin) + .add_plugins(OnboardingPlugin) .run(); } diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index f54c824..afc426d 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -11,6 +11,7 @@ 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; @@ -39,6 +40,7 @@ pub use events::{ 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, diff --git a/solitaire_engine/src/onboarding_plugin.rs b/solitaire_engine/src/onboarding_plugin.rs new file mode 100644 index 0000000..148cfa1 --- /dev/null +++ b/solitaire_engine/src/onboarding_plugin.rs @@ -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>) { + 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>, + mouse: Res>, + mut settings: ResMut, + path: Option>, + screens: Query>, +) { + 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>, 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::>(); + app.init_resource::>(); + 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::() + .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::>() + .press(KeyCode::Space); + app.update(); + + assert_eq!(count_screens(&mut app), 0); + assert!( + app.world() + .resource::() + .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::>() + .press(MouseButton::Left); + app.update(); + + assert_eq!(count_screens(&mut app), 0); + } +}