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:
+16
-8
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user