feat(engine): playability improvements — input intelligence, audio, HUD, onboarding (#27–#30, #37, #39–#40, #44, #48–#49)

Task #27: Double-click auto-move — best_destination() finds optimal target
(foundation over tableau); handle_double_click() fires MoveRequestEvent.

Task #28: Hint system — find_hint() returns first legal from/to/count triple;
H key tints the source stack HintHighlight (yellow pulse via tick_hint_highlight).

Task #29: No-moves detection — has_legal_moves() checks stock/waste/all face-up
cards; check_no_moves system fires InfoToastEvent("No moves available") once per
stalemate (debounced so it fires only once until the state changes).

Task #30: Forfeit — G key fires ForfeitEvent; StatsPlugin records abandoned game,
persists stats, starts a new deal.

Task #37: Mute-all (M) and mute-music (Shift+M) toggles; MuteState resource
applied in apply_volume_on_change.

Task #39: Daily challenge HUD constraint label (time limit / target score).

Task #40: Undo-count HUD label; amber colour when undos > 0.

Task #44: Win-streak and level line on pause screen.

Task #48: Undo sound routes UndoRequestEvent → lib.flip audio channel.

Task #49: Onboarding banner rich-text key highlights — D and H rendered as
orange KeyHighlightSpan children so they stand out from body text.

Also registers CursorPlugin in solitaire_app (tasks #31/#32 wire-up).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-27 19:11:47 +00:00
parent c3ee7c45a7
commit ddd7502a06
16 changed files with 1269 additions and 46 deletions
+95 -24
View File
@@ -4,6 +4,10 @@
//! 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.
//!
//! **Key highlights** (#49): The key names **D**, **H**, and **U** inside the
//! instructional text are rendered in a bright orange colour via `TextSpan`
//! children tagged with `KeyHighlightSpan`.
use std::path::PathBuf;
@@ -16,6 +20,18 @@ use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
#[derive(Component, Debug)]
pub struct OnboardingScreen;
/// Marker on `TextSpan` entities that display a key name (D, H, U …) in the
/// onboarding banner. Colour distinct from body text; usable by tests and any
/// future flash-animation system.
#[derive(Component, Debug)]
pub struct KeyHighlightSpan;
/// Body text colour — golden yellow matching the rest of the UI.
const BODY_COLOR: Color = Color::srgb(1.0, 0.87, 0.0);
/// Bright orange used for key-name spans so they stand out from body text.
const KEY_COLOR: Color = Color::srgb(1.0, 0.55, 0.1);
pub struct OnboardingPlugin;
impl Plugin for OnboardingPlugin {
@@ -66,21 +82,6 @@ fn persist(path: Option<&Option<PathBuf>>, settings: &Settings) {
}
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,
@@ -100,16 +101,62 @@ fn spawn_onboarding_screen(commands: &mut Commands) {
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)),
// Title
b.spawn((
Text::new("Welcome to Solitaire Quest!"),
TextFont { font_size: 40.0, ..default() },
TextColor(BODY_COLOR),
));
// Spacer
b.spawn((Text::new(""), TextFont { font_size: 20.0, ..default() }));
// Instruction line: "Drag cards between piles. Press D to draw, U to undo."
// D and U rendered as KeyHighlightSpan children with KEY_COLOR.
b.spawn((
Text::new("Drag cards between piles. Press "),
TextFont { font_size: 22.0, ..default() },
TextColor(BODY_COLOR),
))
.with_children(|t| {
t.spawn((
TextSpan::new("D"),
TextColor(KEY_COLOR),
KeyHighlightSpan,
));
}
t.spawn((TextSpan::new(" to draw, "), TextColor(BODY_COLOR)));
t.spawn((TextSpan::new("U"), TextColor(KEY_COLOR)));
t.spawn((TextSpan::new(" to undo."), TextColor(BODY_COLOR)));
});
// Help line: "Press H or ? at any time to see the full controls."
// H rendered as a KeyHighlightSpan child with KEY_COLOR.
b.spawn((
Text::new("Press "),
TextFont { font_size: 22.0, ..default() },
TextColor(BODY_COLOR),
))
.with_children(|t| {
t.spawn((
TextSpan::new("H"),
TextColor(KEY_COLOR),
KeyHighlightSpan,
));
t.spawn((
TextSpan::new(" or ? at any time to see the full controls."),
TextColor(BODY_COLOR),
));
});
// Spacer
b.spawn((Text::new(""), TextFont { font_size: 20.0, ..default() }));
// Dismiss hint
b.spawn((
Text::new("Press any key to begin"),
TextFont { font_size: 20.0, ..default() },
TextColor(Color::srgb(0.8, 0.8, 0.8)),
));
});
}
@@ -188,4 +235,28 @@ mod tests {
assert_eq!(count_screens(&mut app), 0);
}
#[test]
fn banner_has_two_key_highlight_spans() {
// D and H must be tagged KeyHighlightSpan so their colour is distinct
// from body text and future flash-animation systems can target them.
let mut app = headless_app();
app.update();
let count = app
.world_mut()
.query::<&KeyHighlightSpan>()
.iter(app.world())
.count();
assert_eq!(count, 2, "expected KeyHighlightSpan for D and H");
}
#[test]
fn key_highlight_colour_differs_from_body_colour() {
// Regression guard: KEY_COLOR must not accidentally match BODY_COLOR.
assert_ne!(
format!("{KEY_COLOR:?}"),
format!("{BODY_COLOR:?}"),
"key highlight colour should differ from body text colour"
);
}
}