65913de2cb
Two situation-fired teaches, each shown exactly once and recorded in Settings like shown_achievement_onboarding: - Stall tip: 45s with no board change in an active, started game (clock frozen while paused / a modal is open) points at Hint with platform-correct copy (H on desktop, bottom-bar Hint on touch). - Radial teach: a player 15 moves into a game who has never opened the radial menu learns the long-press / right-click gesture. Organic radial use marks the tip done silently — nobody is taught what they already know. Tips ride the queued InfoToastEvent path, so they render in the unified toast stack and never interrupt play. This completes Phase I. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
310 lines
10 KiB
Rust
310 lines
10 KiB
Rust
//! Contextual one-time tips (Phase I of the 2026-07 UI redesign).
|
|
//!
|
|
//! Everything the first-run onboarding doesn't cover is invisible until
|
|
//! stumbled upon: hint cycling, the radial quick-action menu. These
|
|
//! tips fire **from the situation, not a tour** — each exactly once,
|
|
//! recorded in `Settings` like `shown_achievement_onboarding`:
|
|
//!
|
|
//! - **Stall tip** — after [`STALL_TIP_SECS`] with no board change in an
|
|
//! active game, an info toast points at Hint. A player staring at a
|
|
//! stuck board is the one moment the tip is welcome.
|
|
//! - **Radial tip** — once a game reaches [`RADIAL_TIP_MIN_MOVES`] moves
|
|
//! (an engaged player) and the radial menu has never been opened, an
|
|
//! info toast teaches the long-press / right-click gesture. Opening
|
|
//! the radial organically marks the tip as unnecessary — it is never
|
|
//! shown to someone who already knows.
|
|
//!
|
|
//! Tips render through the queued [`InfoToastEvent`] path, so they share
|
|
//! the unified toast stack and never interrupt play.
|
|
|
|
use bevy::prelude::*;
|
|
|
|
use solitaire_data::save_settings_to;
|
|
|
|
use crate::events::{InfoToastEvent, StateChangedEvent};
|
|
use crate::game_plugin::GameMutation;
|
|
use crate::pause_plugin::PausedResource;
|
|
use crate::platform::SHOW_KEYBOARD_ACCELERATORS;
|
|
use crate::radial_menu::RightClickRadialState;
|
|
use crate::resources::GameStateResource;
|
|
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
|
use crate::ui_modal::ModalScrim;
|
|
|
|
/// Seconds without any board change before the stall tip fires.
|
|
/// Long enough that normal thinking never triggers it; short enough to
|
|
/// reach a genuinely stuck player before they quit.
|
|
const STALL_TIP_SECS: f32 = 45.0;
|
|
|
|
/// Move count at which an engaged player earns the radial-menu teach.
|
|
const RADIAL_TIP_MIN_MOVES: u32 = 15;
|
|
|
|
/// Stall-tip copy per platform input vocabulary.
|
|
const STALL_TIP: &str = if SHOW_KEYBOARD_ACCELERATORS {
|
|
"Stuck? Press H for a hint."
|
|
} else {
|
|
"Stuck? Tap Hint in the bottom bar for a suggested move."
|
|
};
|
|
|
|
/// Radial-tip copy per platform input vocabulary.
|
|
const RADIAL_TIP: &str = if SHOW_KEYBOARD_ACCELERATORS {
|
|
"Tip: right-click a card for quick actions."
|
|
} else {
|
|
"Tip: long-press a card for quick actions."
|
|
};
|
|
|
|
/// Seconds of board inactivity, frozen while paused / a modal is open,
|
|
/// reset by every [`StateChangedEvent`].
|
|
#[derive(Resource, Debug, Default)]
|
|
pub struct StallClock(pub f32);
|
|
|
|
/// Registers the stall clock and the two tip triggers.
|
|
pub struct ContextualTipsPlugin;
|
|
|
|
impl Plugin for ContextualTipsPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
app.init_resource::<StallClock>()
|
|
.add_message::<InfoToastEvent>()
|
|
.add_message::<StateChangedEvent>()
|
|
.add_systems(
|
|
Update,
|
|
(
|
|
tick_stall_clock,
|
|
fire_stall_hint_tip
|
|
.in_set(crate::game_plugin::InfoToastWriters)
|
|
.ambiguous_with(crate::game_plugin::InfoToastWriters),
|
|
observe_radial_menu_use,
|
|
fire_radial_menu_tip
|
|
.in_set(crate::game_plugin::InfoToastWriters)
|
|
.ambiguous_with(crate::game_plugin::InfoToastWriters),
|
|
)
|
|
.chain()
|
|
.after(GameMutation),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Advances the stall clock; resets on any board change; freezes while
|
|
/// paused, while a modal owns the screen, or when the game is over.
|
|
fn tick_stall_clock(
|
|
time: Res<Time>,
|
|
mut state_events: MessageReader<StateChangedEvent>,
|
|
paused: Option<Res<PausedResource>>,
|
|
scrims: Query<(), With<ModalScrim>>,
|
|
game: Option<Res<GameStateResource>>,
|
|
mut clock: ResMut<StallClock>,
|
|
) {
|
|
if state_events.read().next().is_some() {
|
|
clock.0 = 0.0;
|
|
return;
|
|
}
|
|
if paused.is_some_and(|p| p.0)
|
|
|| !scrims.is_empty()
|
|
|| game.as_ref().is_none_or(|g| g.0.is_won())
|
|
{
|
|
// Frozen, not reset: backgrounding into a menu mid-stall
|
|
// shouldn't restart the wait.
|
|
return;
|
|
}
|
|
clock.0 += time.delta_secs();
|
|
}
|
|
|
|
/// Fires the one-shot stall tip once the clock passes the threshold in
|
|
/// a game the player has actually started (at least one move).
|
|
fn fire_stall_hint_tip(
|
|
clock: Res<StallClock>,
|
|
game: Option<Res<GameStateResource>>,
|
|
mut settings: Option<ResMut<SettingsResource>>,
|
|
storage_path: Option<Res<SettingsStoragePath>>,
|
|
mut toast: MessageWriter<InfoToastEvent>,
|
|
) {
|
|
let Some(s) = settings.as_mut() else { return };
|
|
if s.0.shown_stall_hint_tip || clock.0 < STALL_TIP_SECS {
|
|
return;
|
|
}
|
|
if game
|
|
.as_ref()
|
|
.is_none_or(|g| g.0.move_count() == 0 || g.0.is_won())
|
|
{
|
|
return;
|
|
}
|
|
toast.write(InfoToastEvent(STALL_TIP.to_string()));
|
|
s.0.shown_stall_hint_tip = true;
|
|
persist(storage_path.as_deref(), &s.0);
|
|
}
|
|
|
|
/// A player who opens the radial menu on their own doesn't need the
|
|
/// teach — mark the tip done silently.
|
|
fn observe_radial_menu_use(
|
|
radial: Option<Res<RightClickRadialState>>,
|
|
mut settings: Option<ResMut<SettingsResource>>,
|
|
storage_path: Option<Res<SettingsStoragePath>>,
|
|
) {
|
|
let Some(radial) = radial else { return };
|
|
if !radial.is_active() {
|
|
return;
|
|
}
|
|
let Some(s) = settings.as_mut() else { return };
|
|
if s.0.shown_radial_menu_tip {
|
|
return;
|
|
}
|
|
s.0.shown_radial_menu_tip = true;
|
|
persist(storage_path.as_deref(), &s.0);
|
|
}
|
|
|
|
/// Fires the one-shot radial teach for an engaged player who has never
|
|
/// opened the menu themselves.
|
|
fn fire_radial_menu_tip(
|
|
game: Option<Res<GameStateResource>>,
|
|
mut settings: Option<ResMut<SettingsResource>>,
|
|
storage_path: Option<Res<SettingsStoragePath>>,
|
|
mut toast: MessageWriter<InfoToastEvent>,
|
|
) {
|
|
let Some(s) = settings.as_mut() else { return };
|
|
if s.0.shown_radial_menu_tip {
|
|
return;
|
|
}
|
|
if game
|
|
.as_ref()
|
|
.is_none_or(|g| g.0.move_count() < RADIAL_TIP_MIN_MOVES || g.0.is_won())
|
|
{
|
|
return;
|
|
}
|
|
toast.write(InfoToastEvent(RADIAL_TIP.to_string()));
|
|
s.0.shown_radial_menu_tip = true;
|
|
persist(storage_path.as_deref(), &s.0);
|
|
}
|
|
|
|
fn persist(storage_path: Option<&SettingsStoragePath>, settings: &solitaire_data::Settings) {
|
|
if let Some(p) = storage_path
|
|
&& let Some(path) = p.0.as_deref()
|
|
&& let Err(e) = save_settings_to(path, settings)
|
|
{
|
|
warn!("contextual tips: failed to persist tip flag: {e}");
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use bevy::ecs::message::Messages;
|
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
|
use solitaire_data::Settings;
|
|
|
|
fn app_with(settings: Settings, game: GameState) -> App {
|
|
let mut app = App::new();
|
|
app.add_plugins(MinimalPlugins)
|
|
.add_plugins(ContextualTipsPlugin);
|
|
app.insert_resource(SettingsResource(settings));
|
|
app.insert_resource(GameStateResource(game));
|
|
app.update();
|
|
app
|
|
}
|
|
|
|
fn info_toast_count(app: &App) -> usize {
|
|
let events = app.world().resource::<Messages<InfoToastEvent>>();
|
|
let mut cursor = events.get_cursor();
|
|
cursor.read(events).count()
|
|
}
|
|
|
|
fn started_game() -> GameState {
|
|
let mut game = GameState::new(7, DrawStockConfig::DrawOne);
|
|
game.draw().expect("draw from fresh deal");
|
|
game
|
|
}
|
|
|
|
#[test]
|
|
fn stall_tip_fires_once_past_threshold() {
|
|
let mut app = app_with(Settings::default(), started_game());
|
|
app.world_mut().resource_mut::<StallClock>().0 = STALL_TIP_SECS + 1.0;
|
|
app.update();
|
|
|
|
assert_eq!(info_toast_count(&app), 1, "stall tip must fire");
|
|
assert!(
|
|
app.world()
|
|
.resource::<SettingsResource>()
|
|
.0
|
|
.shown_stall_hint_tip,
|
|
"the tip flag must set so it never repeats"
|
|
);
|
|
|
|
app.world_mut().resource_mut::<StallClock>().0 = STALL_TIP_SECS + 30.0;
|
|
app.world_mut()
|
|
.resource_mut::<Messages<InfoToastEvent>>()
|
|
.clear();
|
|
app.update();
|
|
assert_eq!(info_toast_count(&app), 0, "the tip is one-shot");
|
|
}
|
|
|
|
#[test]
|
|
fn stall_tip_needs_a_started_unwon_game() {
|
|
// Untouched deal: staring at the launch screen is not a stall.
|
|
let mut app = app_with(
|
|
Settings::default(),
|
|
GameState::new(7, DrawStockConfig::DrawOne),
|
|
);
|
|
app.world_mut().resource_mut::<StallClock>().0 = STALL_TIP_SECS + 1.0;
|
|
app.update();
|
|
assert_eq!(info_toast_count(&app), 0, "no tip on an untouched deal");
|
|
|
|
// Won game: nothing to hint at.
|
|
let mut won = started_game();
|
|
won.set_test_won(true);
|
|
let mut app = app_with(Settings::default(), won);
|
|
app.world_mut().resource_mut::<StallClock>().0 = STALL_TIP_SECS + 1.0;
|
|
app.update();
|
|
assert_eq!(info_toast_count(&app), 0, "no tip on a won game");
|
|
}
|
|
|
|
#[test]
|
|
fn stall_clock_resets_on_state_change() {
|
|
let mut app = app_with(Settings::default(), started_game());
|
|
app.world_mut().resource_mut::<StallClock>().0 = 30.0;
|
|
app.world_mut().write_message(StateChangedEvent);
|
|
app.update();
|
|
assert_eq!(
|
|
app.world().resource::<StallClock>().0,
|
|
0.0,
|
|
"any board change must reset the stall clock"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn radial_tip_fires_for_engaged_player() {
|
|
let mut game = started_game();
|
|
game.set_test_move_count(RADIAL_TIP_MIN_MOVES);
|
|
let mut app = app_with(Settings::default(), game);
|
|
app.update();
|
|
|
|
assert_eq!(
|
|
info_toast_count(&app),
|
|
1,
|
|
"radial tip must fire at the move threshold"
|
|
);
|
|
assert!(
|
|
app.world()
|
|
.resource::<SettingsResource>()
|
|
.0
|
|
.shown_radial_menu_tip
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn radial_tip_suppressed_after_organic_use() {
|
|
let mut game = started_game();
|
|
game.set_test_move_count(RADIAL_TIP_MIN_MOVES);
|
|
let mut app = app_with(
|
|
Settings {
|
|
shown_radial_menu_tip: true,
|
|
..Settings::default()
|
|
},
|
|
game,
|
|
);
|
|
app.update();
|
|
assert_eq!(
|
|
info_toast_count(&app),
|
|
0,
|
|
"a player who used the radial menu never sees the teach"
|
|
);
|
|
}
|
|
}
|