From 65913de2cbed7ed919ae7f67cb567e4548cbeffa Mon Sep 17 00:00:00 2001 From: funman300 Date: Mon, 13 Jul 2026 18:16:56 -0700 Subject: [PATCH] =?UTF-8?q?feat(engine):=20contextual=20one-time=20tips=20?= =?UTF-8?q?=E2=80=94=20stall=20hint,=20radial=20menu=20teach=20(Phase=20I)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- solitaire_data/src/settings.rs | 12 + .../src/contextual_tips_plugin.rs | 309 ++++++++++++++++++ solitaire_engine/src/core_game_plugin.rs | 17 +- solitaire_engine/src/lib.rs | 2 + 4 files changed, 332 insertions(+), 8 deletions(-) create mode 100644 solitaire_engine/src/contextual_tips_plugin.rs 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