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
|
> Last updated: 2026-04-25
|
||||||
> Branch: `master` — pushed to https://git.aleshym.co/funman300/Rusty_Solitare.git
|
> 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.
|
- Help cheat sheet lists the **\[** / **\]** keys.
|
||||||
- 4 plugin tests + 6 data tests added — defaults, clamping, round-trip persistence.
|
- 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
|
## What Is Next
|
||||||
|
|
||||||
### Phase 7 (part 5+) — Final Polish
|
Phase 7 polish slate is done. Phase 8 (sync) is next.
|
||||||
|
|
||||||
- **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 8 — Sync
|
### Phase 8 — Sync
|
||||||
|
|
||||||
| Phase | Scope |
|
| Phase | Scope |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Phase 8A–C | Local storage + `SyncProvider` + self-hosted Axum server + client |
|
| Phase 8A | Local storage scaffolding + `SyncProvider` plumbing in `solitaire_data` |
|
||||||
| Phase 8D | GPGS stub fully wired into settings UI |
|
| 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 bevy::prelude::*;
|
||||||
use solitaire_engine::{
|
use solitaire_engine::{
|
||||||
AchievementPlugin, AnimationPlugin, AudioPlugin, CardPlugin, ChallengePlugin,
|
AchievementPlugin, AnimationPlugin, AudioPlugin, CardPlugin, ChallengePlugin,
|
||||||
DailyChallengePlugin, GamePlugin, HelpPlugin, InputPlugin, PausePlugin, ProgressPlugin,
|
DailyChallengePlugin, GamePlugin, HelpPlugin, InputPlugin, OnboardingPlugin, PausePlugin,
|
||||||
SettingsPlugin, StatsPlugin, TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin,
|
ProgressPlugin, SettingsPlugin, StatsPlugin, TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -33,5 +33,6 @@ fn main() {
|
|||||||
.add_plugins(PausePlugin)
|
.add_plugins(PausePlugin)
|
||||||
.add_plugins(SettingsPlugin::default())
|
.add_plugins(SettingsPlugin::default())
|
||||||
.add_plugins(AudioPlugin)
|
.add_plugins(AudioPlugin)
|
||||||
|
.add_plugins(OnboardingPlugin)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub mod game_plugin;
|
|||||||
pub mod help_plugin;
|
pub mod help_plugin;
|
||||||
pub mod input_plugin;
|
pub mod input_plugin;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
|
pub mod onboarding_plugin;
|
||||||
pub mod pause_plugin;
|
pub mod pause_plugin;
|
||||||
pub mod settings_plugin;
|
pub mod settings_plugin;
|
||||||
pub mod progress_plugin;
|
pub mod progress_plugin;
|
||||||
@@ -39,6 +40,7 @@ pub use events::{
|
|||||||
pub use game_plugin::{GameMutation, GamePlugin};
|
pub use game_plugin::{GameMutation, GamePlugin};
|
||||||
pub use help_plugin::{HelpPlugin, HelpScreen};
|
pub use help_plugin::{HelpPlugin, HelpScreen};
|
||||||
pub use input_plugin::InputPlugin;
|
pub use input_plugin::InputPlugin;
|
||||||
|
pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen};
|
||||||
pub use pause_plugin::{PausePlugin, PauseScreen, PausedResource};
|
pub use pause_plugin::{PausePlugin, PauseScreen, PausedResource};
|
||||||
pub use settings_plugin::{
|
pub use settings_plugin::{
|
||||||
SettingsChangedEvent, SettingsPlugin, SettingsResource, SFX_STEP,
|
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