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 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-25 23:14:10 -07:00
parent 9d0f9478b2
commit 13b428b81c
4 changed files with 212 additions and 10 deletions
+16 -8
View File
@@ -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 (3A3F) 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 8AC | 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.
---
+3 -2
View File
@@ -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();
}
+2
View File
@@ -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,
+191
View File
@@ -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);
}
}