diff --git a/solitaire_data/src/settings.rs b/solitaire_data/src/settings.rs index 1a31d9e..4f70e23 100644 --- a/solitaire_data/src/settings.rs +++ b/solitaire_data/src/settings.rs @@ -265,6 +265,16 @@ pub struct Settings { /// `#[serde(default)]`. #[serde(default)] pub last_seen_whats_new: String, + /// `true` once the one-shot "Stuck? Try a hint" contextual tip has + /// fired (or been suppressed as unnecessary). Phase I teach: fired + /// by the situation, shown once, like `shown_achievement_onboarding`. + #[serde(default)] + pub shown_stall_hint_tip: bool, + /// `true` once the one-shot radial-menu contextual tip has fired — + /// or the player has already opened the radial menu on their own, + /// which marks the tip as unnecessary without showing it. + #[serde(default)] + pub shown_radial_menu_tip: bool, /// Custom public name displayed on the leaderboard. When `None`, the /// player's server `username` is used instead. Trimmed to 32 characters /// before submission. Older `settings.json` files written before this @@ -434,6 +444,8 @@ impl Default for Settings { last_difficulty: None, last_mode: GameMode::Classic, last_seen_whats_new: String::new(), + shown_stall_hint_tip: false, + shown_radial_menu_tip: false, leaderboard_display_name: None, leaderboard_opted_in: false, take_from_foundation: true, diff --git a/solitaire_engine/src/contextual_tips_plugin.rs b/solitaire_engine/src/contextual_tips_plugin.rs new file mode 100644 index 0000000..ddae327 --- /dev/null +++ b/solitaire_engine/src/contextual_tips_plugin.rs @@ -0,0 +1,309 @@ +//! 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::() + .add_message::() + .add_message::() + .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