Merge pull request 'feat(engine): win-summary action hierarchy — Play Again, Watch, Share' (#164) from feat/win-flow into master
This commit was merged in pull request #164.
This commit is contained in:
@@ -5,6 +5,13 @@
|
||||
//! started), a full-screen modal is spawned showing score, time, XP, and a
|
||||
//! "Play Again" button that fires `NewGameRequestEvent` and closes the modal.
|
||||
//!
|
||||
//! # Phase G (docs/ui-redesign-2026-07.md) — action hierarchy
|
||||
//! The modal leads with actions: **Play Again** (primary, Enter
|
||||
//! accelerator), then **Watch Replay** and **Share Replay** (shared
|
||||
//! `stats_plugin` markers, so the global handlers there act on the
|
||||
//! just-won replay), with the score/time/XP recap reading quietly
|
||||
//! below.
|
||||
//!
|
||||
//! # Task #47 — Win fanfare screen-shake
|
||||
//! When `GameWonEvent` fires, `ScreenShakeResource` is set. A system offsets
|
||||
//! the `Camera2d` `Transform` each frame with a decaying oscillation until the
|
||||
@@ -23,7 +30,7 @@ use crate::game_plugin::GameMutation;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::settings_plugin::SettingsResource;
|
||||
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
||||
use crate::stats_plugin::{CopyShareLinkButton, StatsResource, StatsUpdate, WatchReplayButton};
|
||||
use crate::ui_modal::ModalScrim;
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, MOTION_SCORE_BREAKDOWN_FADE_SECS,
|
||||
@@ -163,12 +170,15 @@ pub struct SessionAchievements {
|
||||
#[derive(Component, Debug)]
|
||||
pub struct WinSummaryOverlay;
|
||||
|
||||
/// Marker on the "Play Again" / "Watch Replay" buttons inside the win-summary modal.
|
||||
/// Marker on the "Play Again" button inside the win-summary modal.
|
||||
///
|
||||
/// Watch Replay and Share Replay carry the shared
|
||||
/// [`WatchReplayButton`] / [`CopyShareLinkButton`] markers from
|
||||
/// `stats_plugin`, so the global handlers there drive them (both act
|
||||
/// on [`crate::stats_plugin::SelectedReplayIndex`], which snaps to the
|
||||
/// just-won replay on every `GameWonEvent`).
|
||||
#[derive(Component, Debug)]
|
||||
enum WinSummaryButton {
|
||||
PlayAgain,
|
||||
WatchReplay,
|
||||
}
|
||||
struct WinSummaryPlayAgainButton;
|
||||
|
||||
/// Marker for one row of the win-modal score-breakdown reveal.
|
||||
///
|
||||
@@ -230,6 +240,7 @@ impl Plugin for WinSummaryPlugin {
|
||||
collect_session_achievements,
|
||||
spawn_win_summary_after_delay,
|
||||
handle_win_summary_buttons,
|
||||
close_overlay_on_watch_replay,
|
||||
handle_win_summary_keyboard,
|
||||
apply_screen_shake,
|
||||
reveal_score_breakdown,
|
||||
@@ -604,51 +615,49 @@ fn spawn_win_summary_after_delay(
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles "Play Again" and "Watch Replay" in the win-summary modal.
|
||||
/// Handles "Play Again" and "Watch Replay" in the win-summary modal.
|
||||
/// Handles "Play Again" in the win-summary modal: collapses the
|
||||
/// overlay and requests a fresh deal. `NewGameRequestEvent::default()`
|
||||
/// reuses the current game's `GameMode`, and `handle_new_game` reads
|
||||
/// the deal options (draw mode, difficulty) from `Settings` — so the
|
||||
/// rematch is "same mode + same options" in one tap.
|
||||
fn handle_win_summary_buttons(
|
||||
interaction_query: Query<(&Interaction, &WinSummaryButton), Changed<Interaction>>,
|
||||
interaction_query: Query<&Interaction, (Changed<Interaction>, With<WinSummaryPlayAgainButton>)>,
|
||||
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
||||
mut commands: Commands,
|
||||
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
history: Option<Res<crate::stats_plugin::ReplayHistoryResource>>,
|
||||
mut playback: Option<ResMut<crate::replay_playback::ReplayPlaybackState>>,
|
||||
) {
|
||||
// Collect all pressed buttons first to avoid moving `playback` inside the loop.
|
||||
let pressed: Vec<&WinSummaryButton> = interaction_query
|
||||
.iter()
|
||||
.filter(|(i, _)| **i == Interaction::Pressed)
|
||||
.map(|(_, b)| b)
|
||||
.collect();
|
||||
|
||||
for button in pressed {
|
||||
match button {
|
||||
WinSummaryButton::PlayAgain => {
|
||||
if !interaction_query.iter().any(|i| *i == Interaction::Pressed) {
|
||||
return;
|
||||
}
|
||||
for entity in &overlays {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
new_game.write(NewGameRequestEvent::default());
|
||||
}
|
||||
WinSummaryButton::WatchReplay => {
|
||||
let latest = history.as_ref().and_then(|h| h.0.replays.last()).cloned();
|
||||
match (latest, playback.as_mut()) {
|
||||
(Some(replay), Some(pb)) => {
|
||||
|
||||
/// Collapses the win-summary overlay when its "Watch Replay" button is
|
||||
/// pressed, so playback (started by `stats_plugin`'s global
|
||||
/// [`WatchReplayButton`] handler reacting to the same press) is not
|
||||
/// hidden behind the celebration scrim. No-op while the overlay is
|
||||
/// closed — the Replays-tab copy of the button manages its own modal.
|
||||
///
|
||||
/// "Share Replay" ([`CopyShareLinkButton`]) deliberately does NOT
|
||||
/// close the overlay: the player stays on the celebration while the
|
||||
/// copy-feedback toast confirms the link.
|
||||
fn close_overlay_on_watch_replay(
|
||||
buttons: Query<&Interaction, (Changed<Interaction>, With<WatchReplayButton>)>,
|
||||
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
if overlays.is_empty() {
|
||||
return;
|
||||
}
|
||||
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||
return;
|
||||
}
|
||||
for entity in &overlays {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
crate::replay_playback::start_replay_playback(&mut commands, pb, replay);
|
||||
}
|
||||
(Some(_), None) => {
|
||||
toast.write(InfoToastEvent("Replay playback not available".to_string()));
|
||||
}
|
||||
(None, _) => {
|
||||
toast.write(InfoToastEvent("No replay saved yet".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keyboard accelerator for the win summary's "Play Again" button.
|
||||
@@ -818,18 +827,68 @@ fn spawn_overlay(
|
||||
));
|
||||
}
|
||||
|
||||
// Score breakdown reveal — replaces the previous single
|
||||
// "Score:" line with a per-component multi-row layout.
|
||||
// --- Action hierarchy (Phase G) ---
|
||||
// Play Again is the hero action; the two replay
|
||||
// actions sit under it; the stats recap reads quietly
|
||||
// below all three. Rematch is one tap.
|
||||
|
||||
// Play Again (primary, full row)
|
||||
card.spawn((
|
||||
WinSummaryPlayAgainButton,
|
||||
Button,
|
||||
Node {
|
||||
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
|
||||
justify_content: JustifyContent::Center,
|
||||
align_self: AlignSelf::Stretch,
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||
margin: UiRect::top(VAL_SPACE_2),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(ACCENT_PRIMARY),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
Text::new("Play Again \u{21B5}"),
|
||||
TextFont {
|
||||
font_size: TYPE_BODY_LG,
|
||||
..default()
|
||||
},
|
||||
TextColor(BG_BASE),
|
||||
));
|
||||
});
|
||||
|
||||
// Watch Replay + Share Replay (secondary, side by side).
|
||||
// Both reuse the global stats_plugin handlers, which
|
||||
// target the just-won replay (`SelectedReplayIndex`
|
||||
// snaps to 0 on every win). Each is always rendered —
|
||||
// with no replay / no share URL the handler explains
|
||||
// itself in a toast instead of silently doing nothing.
|
||||
card.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
justify_content: JustifyContent::Center,
|
||||
column_gap: VAL_SPACE_3,
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
spawn_replay_action(row, WatchReplayButton, "Watch Replay");
|
||||
spawn_replay_action(row, CopyShareLinkButton, "Share Replay");
|
||||
});
|
||||
|
||||
// --- Quiet stats recap, below the actions ---
|
||||
|
||||
// Score breakdown reveal — per-component multi-row
|
||||
// layout with the staggered fade-in.
|
||||
spawn_score_breakdown(card, &breakdown, anim_speed);
|
||||
|
||||
// Time
|
||||
// Time (demoted to body/secondary — part of the quiet
|
||||
// recap, not the celebration headline)
|
||||
card.spawn((
|
||||
Text::new(format!("Time: {}", format_win_time(pending.time_seconds))),
|
||||
TextFont {
|
||||
font_size: TYPE_HEADLINE,
|
||||
font_size: TYPE_BODY_LG,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_PRIMARY),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
|
||||
// XP total
|
||||
@@ -859,19 +918,17 @@ fn spawn_overlay(
|
||||
if !session.names.is_empty() {
|
||||
spawn_achievements_section(card, &session.names);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Button row: Watch Replay + Play Again side by side.
|
||||
card.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
justify_content: JustifyContent::Center,
|
||||
column_gap: VAL_SPACE_3,
|
||||
margin: UiRect::top(VAL_SPACE_2),
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
// Watch Replay (secondary style)
|
||||
/// Spawns one secondary (outline-style) replay action button in the
|
||||
/// win modal's replay row. `marker` is the shared `stats_plugin`
|
||||
/// click-target component (`WatchReplayButton` / `CopyShareLinkButton`)
|
||||
/// whose global handler reacts to the press.
|
||||
fn spawn_replay_action<M: Component>(row: &mut ChildSpawnerCommands, marker: M, label: &str) {
|
||||
row.spawn((
|
||||
WinSummaryButton::WatchReplay,
|
||||
marker,
|
||||
Button,
|
||||
Node {
|
||||
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
|
||||
@@ -885,7 +942,7 @@ fn spawn_overlay(
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
Text::new("Watch Replay"),
|
||||
Text::new(label.to_string()),
|
||||
TextFont {
|
||||
font_size: TYPE_BODY_LG,
|
||||
..default()
|
||||
@@ -893,32 +950,6 @@ fn spawn_overlay(
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
));
|
||||
});
|
||||
|
||||
// Play Again (primary style)
|
||||
row.spawn((
|
||||
WinSummaryButton::PlayAgain,
|
||||
Button,
|
||||
Node {
|
||||
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
|
||||
justify_content: JustifyContent::Center,
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(ACCENT_PRIMARY),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
Text::new("Play Again \u{21B5}"),
|
||||
TextFont {
|
||||
font_size: TYPE_BODY_LG,
|
||||
..default()
|
||||
},
|
||||
TextColor(BG_BASE),
|
||||
));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Maximum number of achievement names shown explicitly in the win modal before
|
||||
@@ -1863,4 +1894,134 @@ mod tests {
|
||||
assert_eq!(stagger, 0.0);
|
||||
assert_eq!(fade, 0.0);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase G — action hierarchy
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Like [`make_app`] but with `TimePlugin` disabled and a manual
|
||||
/// `Time` resource, so tests can step the win-summary delay timer
|
||||
/// deterministically via `Time::advance_by` (the real clock's
|
||||
/// microsecond deltas would never reach the 0.5 s threshold).
|
||||
fn make_app_manual_clock() -> App {
|
||||
use bevy::time::TimePlugin;
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins.build().disable::<TimePlugin>())
|
||||
.add_plugins(WinSummaryPlugin)
|
||||
.insert_resource(StatsResource(StatsSnapshot::default()))
|
||||
.insert_resource(GameStateResource(GameState::new(
|
||||
0,
|
||||
solitaire_core::DrawStockConfig::DrawOne,
|
||||
)))
|
||||
.insert_resource(ProgressResource(PlayerProgress::default()));
|
||||
app.init_resource::<Time>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
/// Drives the real spawn path: fire `GameWonEvent`, then advance
|
||||
/// `Time` past the 0.5 s celebration delay so
|
||||
/// `spawn_win_summary_after_delay` spawns the overlay.
|
||||
fn open_win_overlay(app: &mut App) {
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 1200,
|
||||
time_seconds: 90,
|
||||
});
|
||||
app.update();
|
||||
{
|
||||
let mut time = app.world_mut().resource_mut::<Time>();
|
||||
time.advance_by(std::time::Duration::from_secs_f32(
|
||||
WIN_SUMMARY_DELAY_SECS + 0.1,
|
||||
));
|
||||
}
|
||||
app.update();
|
||||
// One more frame so the deferred spawn commands flush.
|
||||
app.update();
|
||||
}
|
||||
|
||||
fn overlay_count(app: &mut App) -> usize {
|
||||
app.world_mut()
|
||||
.query::<&WinSummaryOverlay>()
|
||||
.iter(app.world())
|
||||
.count()
|
||||
}
|
||||
|
||||
fn button_entity<M: Component>(app: &mut App) -> Entity {
|
||||
let entities: Vec<Entity> = app
|
||||
.world_mut()
|
||||
.query_filtered::<Entity, With<M>>()
|
||||
.iter(app.world())
|
||||
.collect();
|
||||
assert_eq!(entities.len(), 1, "expected exactly one button");
|
||||
entities[0]
|
||||
}
|
||||
|
||||
/// The win modal must render all three Phase G actions: the
|
||||
/// primary Play Again plus the shared Watch/Share replay buttons.
|
||||
#[test]
|
||||
fn win_modal_renders_play_again_watch_and_share_actions() {
|
||||
let mut app = make_app_manual_clock();
|
||||
open_win_overlay(&mut app);
|
||||
assert_eq!(overlay_count(&mut app), 1, "overlay must spawn after delay");
|
||||
button_entity::<WinSummaryPlayAgainButton>(&mut app);
|
||||
button_entity::<WatchReplayButton>(&mut app);
|
||||
button_entity::<CopyShareLinkButton>(&mut app);
|
||||
}
|
||||
|
||||
/// Play Again collapses the overlay and requests a fresh deal.
|
||||
#[test]
|
||||
fn play_again_press_closes_overlay_and_requests_new_game() {
|
||||
use bevy::ecs::message::Messages;
|
||||
|
||||
let mut app = make_app_manual_clock();
|
||||
open_win_overlay(&mut app);
|
||||
let button = button_entity::<WinSummaryPlayAgainButton>(&mut app);
|
||||
app.world_mut()
|
||||
.entity_mut(button)
|
||||
.insert(Interaction::Pressed);
|
||||
app.update();
|
||||
|
||||
assert_eq!(overlay_count(&mut app), 0, "Play Again must close overlay");
|
||||
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
|
||||
assert!(
|
||||
!events.is_empty(),
|
||||
"Play Again must write NewGameRequestEvent"
|
||||
);
|
||||
}
|
||||
|
||||
/// Watch Replay collapses the overlay so playback (driven by the
|
||||
/// global `stats_plugin` handler on the same press) is visible.
|
||||
#[test]
|
||||
fn watch_replay_press_closes_the_overlay() {
|
||||
let mut app = make_app_manual_clock();
|
||||
open_win_overlay(&mut app);
|
||||
let button = button_entity::<WatchReplayButton>(&mut app);
|
||||
app.world_mut()
|
||||
.entity_mut(button)
|
||||
.insert(Interaction::Pressed);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
overlay_count(&mut app),
|
||||
0,
|
||||
"Watch Replay must close the celebration overlay"
|
||||
);
|
||||
}
|
||||
|
||||
/// Share Replay leaves the overlay open — the player stays on the
|
||||
/// celebration while the copy-feedback toast confirms the link.
|
||||
#[test]
|
||||
fn share_replay_press_keeps_the_overlay_open() {
|
||||
let mut app = make_app_manual_clock();
|
||||
open_win_overlay(&mut app);
|
||||
let button = button_entity::<CopyShareLinkButton>(&mut app);
|
||||
app.world_mut()
|
||||
.entity_mut(button)
|
||||
.insert(Interaction::Pressed);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
overlay_count(&mut app),
|
||||
1,
|
||||
"Share Replay must not close the overlay"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user