feat(engine): playability improvements — rounds 7–9 (#40–#64)

Round 7 — Input & feedback
- H key cycles hints; F1 opens help (conflict resolved)
- N key cancels active Time Attack session
- Hint text distinguishes "draw from stock" vs "recycle waste"
- Forfeit (G) clears AutoCompleteState so chime does not bleed into new deal
- Zen mode timer clears immediately on Z press
- HUD shows recycle count in both draw modes
- Settings scroll position persisted across open/close

Round 8 — Polish & clarity
- Undo unavailable fires "Nothing to undo" toast
- Streak-break toast on forfeit/abandon when streak > 1
- F11 fullscreen toggle with toast; documented in help and home screens
- H-after-win, new-game countdown expiry, Tab-no-cards toasts
- Win cascade duration/stagger scales with animation speed setting
- Draw-Three cycle counter HUD ("Cycle: N/3")
- Forfeit requires G×2 confirmation within 3 s (mirrors N key)

Round 9 — Game feel & information
- Escape dismisses game-over/stuck overlay (PausePlugin skips Escape when overlay visible)
- Shake animation on rejected drag before snap-back
- Forfeit countdown cancels when any other key is pressed (U/H/D/Z/Space)
- Tab wrap-around fires "Back to first card" toast
- HUD selection indicator shows active Tab-selected pile in yellow
- Challenge time-limit HUD turns orange < 60s, red < 30s
- Win summary shows XP breakdown (+50 base, +25 no-undo, +N speed)
- Game-over overlay: "No more moves available" with clear N/Escape/G instructions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-28 02:35:15 +00:00
parent d387ee68d7
commit 03227f8c77
26 changed files with 3278 additions and 264 deletions
+183 -5
View File
@@ -22,7 +22,7 @@ use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
use crate::animation_plugin::{CardAnim, EffectiveSlideDuration};
use crate::events::{CardFlippedEvent, StateChangedEvent};
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent, StateChangedEvent};
use crate::game_plugin::GameMutation;
use crate::layout::{Layout, LayoutResource};
use crate::pause_plugin::PausedResource;
@@ -87,6 +87,11 @@ pub struct HintHighlight {
#[derive(Component, Debug)]
pub struct RightClickHighlight;
/// Marker placed on the child `Text2d` entity that shows "↺" on the stock pile
/// marker when the stock pile is empty.
#[derive(Component, Debug)]
pub struct StockEmptyLabel;
// ---------------------------------------------------------------------------
// Task #34 — Card-flip animation
// ---------------------------------------------------------------------------
@@ -137,7 +142,8 @@ impl Plugin for CardPlugin {
app.init_resource::<ButtonInput<MouseButton>>()
.add_event::<SettingsChangedEvent>()
.add_event::<CardFlippedEvent>()
.add_systems(PostStartup, sync_cards_startup)
.add_event::<CardFaceRevealedEvent>()
.add_systems(PostStartup, (sync_cards_startup, update_stock_empty_indicator_startup))
.add_systems(
Update,
(
@@ -148,6 +154,9 @@ impl Plugin for CardPlugin {
update_drag_shadow,
tick_hint_highlight,
handle_right_click,
clear_right_click_highlights_on_state_change.after(GameMutation),
clear_right_click_highlights_on_pause,
update_stock_empty_indicator.after(GameMutation),
),
);
}
@@ -508,16 +517,18 @@ fn start_flip_anim(
/// Advances `CardFlipAnim` each frame, modifying `Transform::scale.x`.
///
/// - Phase `ScalingDown`: lerps scale.x from 1.0 → 0.0 over `FLIP_HALF_SECS`.
/// - At the midpoint the phase switches to `ScalingUp` and scale.x resets to 0.
/// - At the midpoint the phase switches to `ScalingUp`, scale.x resets to 0,
/// and a `CardFaceRevealedEvent` is fired so audio plays in sync with the reveal.
/// - Phase `ScalingUp`: lerps scale.x from 0.0 → 1.0 over `FLIP_HALF_SECS`.
/// - When complete the component is removed and scale.x is restored to 1.0.
fn tick_flip_anim(
mut commands: Commands,
time: Res<Time>,
mut anims: Query<(Entity, &mut Transform, &mut CardFlipAnim)>,
mut anims: Query<(Entity, &CardEntity, &mut Transform, &mut CardFlipAnim)>,
mut reveal_events: EventWriter<CardFaceRevealedEvent>,
) {
let dt = time.delta_secs();
for (entity, mut transform, mut anim) in &mut anims {
for (entity, card_entity, mut transform, mut anim) in &mut anims {
anim.timer += dt;
match anim.phase {
FlipPhase::ScalingDown => {
@@ -527,6 +538,9 @@ fn tick_flip_anim(
anim.phase = FlipPhase::ScalingUp;
anim.timer = 0.0;
transform.scale.x = 0.0;
// Fire the reveal event exactly once, at the phase transition,
// so the flip sound is synchronised with the visual face reveal.
reveal_events.send(CardFaceRevealedEvent(card_entity.card_id));
}
}
FlipPhase::ScalingUp => {
@@ -650,6 +664,59 @@ const RIGHT_CLICK_HIGHLIGHT_COLOUR: Color = Color::srgba(0.2, 0.8, 0.2, 0.6);
/// Restored color for `PileMarker` sprites when the highlight is cleared.
const PILE_MARKER_DEFAULT_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
/// Removes the `RightClickHighlight` marker from every highlighted pile and
/// resets its sprite colour to `PILE_MARKER_DEFAULT_COLOUR`.
///
/// Shared by the on-state-change and on-pause clear systems to avoid
/// duplicating the removal logic.
fn clear_right_click_highlights(
commands: &mut Commands,
highlighted: &Query<Entity, With<RightClickHighlight>>,
pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite)>,
) {
for entity in highlighted.iter() {
commands.entity(entity).remove::<RightClickHighlight>();
}
for (_entity, _, mut sprite) in pile_markers.iter_mut() {
if sprite.color == RIGHT_CLICK_HIGHLIGHT_COLOUR {
sprite.color = PILE_MARKER_DEFAULT_COLOUR;
}
}
}
/// Clears all right-click destination highlights whenever any game-state
/// mutation succeeds (`StateChangedEvent` fires).
///
/// This ensures stale highlights do not linger after a card is moved.
fn clear_right_click_highlights_on_state_change(
mut events: EventReader<StateChangedEvent>,
mut commands: Commands,
highlighted: Query<Entity, With<RightClickHighlight>>,
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
) {
if events.read().next().is_none() {
return;
}
clear_right_click_highlights(&mut commands, &highlighted, &mut pile_markers);
}
/// Clears all right-click destination highlights when the game is paused
/// (`PausedResource` changes to `true`).
///
/// Prevents highlighted pile markers from remaining visible behind the pause
/// overlay.
fn clear_right_click_highlights_on_pause(
paused: Option<Res<PausedResource>>,
mut commands: Commands,
highlighted: Query<Entity, With<RightClickHighlight>>,
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
) {
let Some(paused) = paused else { return };
if paused.is_changed() && paused.0 {
clear_right_click_highlights(&mut commands, &highlighted, &mut pile_markers);
}
}
/// Handles right-click: highlights legal destination piles for the clicked card,
/// and clears highlights on any subsequent right- or left-click.
///
@@ -766,6 +833,117 @@ fn find_top_card_at(
best.map(|(_, card)| card)
}
// ---------------------------------------------------------------------------
// Task #28 — Stock-empty visual indicator
// ---------------------------------------------------------------------------
/// Sprite colour applied to the stock `PileMarker` when the stock pile is empty,
/// to signal to the player that there are no more cards to draw.
const STOCK_EMPTY_DIM_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.4);
/// Sprite colour applied to the stock `PileMarker` when cards remain in stock.
const STOCK_NORMAL_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
/// Shared logic for updating the stock pile marker's dim state and "↺" label.
///
/// If the stock pile is empty the marker sprite is dimmed to
/// `STOCK_EMPTY_DIM_COLOUR` and a child `Text2d` with `StockEmptyLabel` is
/// spawned (if not already present). When the stock is non-empty the marker is
/// restored to `STOCK_NORMAL_COLOUR` and any `StockEmptyLabel` children are
/// despawned.
fn apply_stock_empty_indicator(
commands: &mut Commands,
game: &GameState,
pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite)>,
label_children: &Query<(Entity, &Parent), With<StockEmptyLabel>>,
layout: &Layout,
) {
let stock_empty = game
.piles
.get(&PileType::Stock)
.is_none_or(|p| p.cards.is_empty());
for (entity, pile_marker, mut sprite) in pile_markers.iter_mut() {
if pile_marker.0 != PileType::Stock {
continue;
}
if stock_empty {
// Dim the marker sprite.
sprite.color = STOCK_EMPTY_DIM_COLOUR;
// Spawn the "↺" label only if one does not already exist.
let already_has_label = label_children
.iter()
.any(|(_, parent)| parent.get() == entity);
if !already_has_label {
let font_size = layout.card_size.x * 0.4;
commands.entity(entity).with_children(|b| {
b.spawn((
StockEmptyLabel,
Text2d::new(""),
TextFont { font_size, ..default() },
TextColor(Color::srgba(1.0, 1.0, 1.0, 0.7)),
Transform::from_xyz(0.0, 0.0, 0.1),
));
});
}
} else {
// Restore normal brightness.
sprite.color = STOCK_NORMAL_COLOUR;
// Despawn any existing "↺" label children.
for (label_entity, parent) in label_children.iter() {
if parent.get() == entity {
commands.entity(label_entity).despawn_recursive();
}
}
}
}
}
/// Runs at `PostStartup` to apply the stock-empty indicator for the initial
/// game state (before any `StateChangedEvent` fires).
fn update_stock_empty_indicator_startup(
mut commands: Commands,
game: Res<GameStateResource>,
layout: Option<Res<LayoutResource>>,
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
label_children: Query<(Entity, &Parent), With<StockEmptyLabel>>,
) {
let Some(layout) = layout else { return };
apply_stock_empty_indicator(
&mut commands,
&game.0,
&mut pile_markers,
&label_children,
&layout.0,
);
}
/// Runs each `Update` tick when a `StateChangedEvent` arrives, keeping the
/// stock pile marker dim state and "↺" label in sync with the current stock.
fn update_stock_empty_indicator(
mut events: EventReader<StateChangedEvent>,
mut commands: Commands,
game: Res<GameStateResource>,
layout: Option<Res<LayoutResource>>,
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
label_children: Query<(Entity, &Parent), With<StockEmptyLabel>>,
) {
if events.read().next().is_none() {
return;
}
let Some(layout) = layout else { return };
apply_stock_empty_indicator(
&mut commands,
&game.0,
&mut pile_markers,
&label_children,
&layout.0,
);
}
#[cfg(test)]
mod tests {
use super::*;