Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 19ddf86c7b |
@@ -265,16 +265,6 @@ pub struct Settings {
|
|||||||
/// `#[serde(default)]`.
|
/// `#[serde(default)]`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_seen_whats_new: String,
|
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
|
/// Custom public name displayed on the leaderboard. When `None`, the
|
||||||
/// player's server `username` is used instead. Trimmed to 32 characters
|
/// player's server `username` is used instead. Trimmed to 32 characters
|
||||||
/// before submission. Older `settings.json` files written before this
|
/// before submission. Older `settings.json` files written before this
|
||||||
@@ -444,8 +434,6 @@ impl Default for Settings {
|
|||||||
last_difficulty: None,
|
last_difficulty: None,
|
||||||
last_mode: GameMode::Classic,
|
last_mode: GameMode::Classic,
|
||||||
last_seen_whats_new: String::new(),
|
last_seen_whats_new: String::new(),
|
||||||
shown_stall_hint_tip: false,
|
|
||||||
shown_radial_menu_tip: false,
|
|
||||||
leaderboard_display_name: None,
|
leaderboard_display_name: None,
|
||||||
leaderboard_opted_in: false,
|
leaderboard_opted_in: false,
|
||||||
take_from_foundation: true,
|
take_from_foundation: true,
|
||||||
|
|||||||
@@ -1,309 +0,0 @@
|
|||||||
//! 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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,14 +14,14 @@ use crate::platform::{
|
|||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, AutoCompletePlugin,
|
AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, AutoCompletePlugin,
|
||||||
CardAnimationPlugin, CardPlugin, ChallengePlugin, ContextualTipsPlugin, CursorPlugin,
|
CardAnimationPlugin, CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin,
|
||||||
DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin,
|
DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
|
||||||
GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin,
|
HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin, PlayBySeedPlugin,
|
||||||
PlayBySeedPlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin,
|
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin,
|
||||||
ReplayPlaybackPlugin, SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin,
|
SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SolutionPlaybackPlugin, SplashPlugin,
|
||||||
SolutionPlaybackPlugin, SplashPlugin, StatsPlugin, SyncProvider, TablePlugin, ThemePlugin,
|
StatsPlugin, SyncProvider, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
|
||||||
ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin, UiFocusPlugin, UiModalPlugin,
|
TouchSelectionPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
|
||||||
UiTooltipPlugin, WeeklyGoalsPlugin, WhatsNewPlugin, WinSummaryPlugin,
|
WhatsNewPlugin, WinSummaryPlugin,
|
||||||
};
|
};
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -116,7 +116,6 @@ impl Plugin for CoreGamePlugin {
|
|||||||
.add_plugins(SettingsPlugin::default())
|
.add_plugins(SettingsPlugin::default())
|
||||||
.add_plugins(OnboardingPlugin)
|
.add_plugins(OnboardingPlugin)
|
||||||
.add_plugins(WhatsNewPlugin)
|
.add_plugins(WhatsNewPlugin)
|
||||||
.add_plugins(ContextualTipsPlugin)
|
|
||||||
.add_plugins(WinSummaryPlugin)
|
.add_plugins(WinSummaryPlugin)
|
||||||
.add_plugins(UiModalPlugin)
|
.add_plugins(UiModalPlugin)
|
||||||
.add_plugins(UiFocusPlugin)
|
.add_plugins(UiFocusPlugin)
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ pub mod avatar_plugin;
|
|||||||
pub mod card_animation;
|
pub mod card_animation;
|
||||||
pub mod card_plugin;
|
pub mod card_plugin;
|
||||||
pub mod challenge_plugin;
|
pub mod challenge_plugin;
|
||||||
pub mod contextual_tips_plugin;
|
|
||||||
pub mod core_game_plugin;
|
pub mod core_game_plugin;
|
||||||
pub mod cursor_plugin;
|
pub mod cursor_plugin;
|
||||||
pub mod daily_challenge_plugin;
|
pub mod daily_challenge_plugin;
|
||||||
@@ -95,7 +94,6 @@ pub use card_plugin::{
|
|||||||
pub use challenge_plugin::{
|
pub use challenge_plugin::{
|
||||||
CHALLENGE_UNLOCK_LEVEL, ChallengeAdvancedEvent, ChallengePlugin, challenge_progress_label,
|
CHALLENGE_UNLOCK_LEVEL, ChallengeAdvancedEvent, ChallengePlugin, challenge_progress_label,
|
||||||
};
|
};
|
||||||
pub use contextual_tips_plugin::ContextualTipsPlugin;
|
|
||||||
pub use core_game_plugin::CoreGamePlugin;
|
pub use core_game_plugin::CoreGamePlugin;
|
||||||
pub use cursor_plugin::CursorPlugin;
|
pub use cursor_plugin::CursorPlugin;
|
||||||
pub use daily_challenge_plugin::{
|
pub use daily_challenge_plugin::{
|
||||||
|
|||||||
Reference in New Issue
Block a user