diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index 0f44d6e..f80774c 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -24,6 +24,8 @@ pub mod onboarding_plugin; pub mod pause_plugin; pub mod profile_plugin; pub mod radial_menu; +pub mod replay_overlay; +pub mod replay_playback; pub mod settings_plugin; pub mod progress_plugin; pub mod resources; @@ -112,6 +114,14 @@ pub use radial_menu::{ legal_destinations_for_card, radial_anchor_for_index, radial_hovered_index, RadialIcon, RadialMenuPlugin, RightClickRadialState, Z_RADIAL_MENU, }; +pub use replay_overlay::{ + ReplayOverlayBannerText, ReplayOverlayPlugin, ReplayOverlayProgressText, ReplayOverlayRoot, + ReplayStopButton, Z_REPLAY_OVERLAY, +}; +pub use replay_playback::{ + start_replay_playback, stop_replay_playback, ReplayPlaybackPlugin, ReplayPlaybackState, + REPLAY_COMPLETION_LINGER_SECS, REPLAY_MOVE_INTERVAL_SECS, +}; pub use settings_plugin::{ PendingWindowGeometry, SettingsChangedEvent, SettingsPlugin, SettingsResource, SettingsScreen, SFX_STEP, WINDOW_GEOMETRY_DEBOUNCE_SECS, diff --git a/solitaire_engine/src/replay_playback.rs b/solitaire_engine/src/replay_playback.rs new file mode 100644 index 0000000..6d22c73 --- /dev/null +++ b/solitaire_engine/src/replay_playback.rs @@ -0,0 +1,682 @@ +//! In-engine replay playback core. +//! +//! When the player clicks "Watch replay" on the Stats overlay, the live +//! game state is reset to the deal seeded from the replay's `seed` / +//! `mode` / `draw_mode`, and the engine ticks through `replay.moves` at a +//! steady cadence — firing the canonical [`MoveRequestEvent`] / +//! [`DrawRequestEvent`] for each one. The existing animation pipeline +//! plays back identically to a live game. +//! +//! ## Public surface +//! +//! - [`ReplayPlaybackState`] — single source of truth for whether +//! playback is live, how far through the move list we've ticked, and +//! how long until the next advance. +//! - [`start_replay_playback`] — public entry point; the Stats +//! "Watch replay" button calls this. Resets the game to the recorded +//! deal and transitions the state machine to +//! [`ReplayPlaybackState::Playing`]. +//! - [`stop_replay_playback`] — interrupts playback at any time. Safe to +//! call when [`ReplayPlaybackState::Inactive`]. +//! - [`ReplayPlaybackPlugin`] — registers the resource and the tick / +//! linger systems. +//! +//! ## Coordination note +//! +//! This module is built in parallel with the Stats-side overlay. The +//! resource shape, helper signatures, and plugin marker match the +//! contract the overlay agent reads against — see also the docs on the +//! enum variants. +//! +//! ## Recording is paused during playback +//! +//! Playback fires the same [`MoveRequestEvent`] / [`DrawRequestEvent`] +//! the live engine handles. Without intervention, [`RecordingReplay`] +//! would re-record those events and a replay would re-record itself +//! indefinitely. To prevent that, [`record_replay_skip_during_playback`] +//! snapshots the recording's length at the start of playback and +//! truncates the buffer back to that length every frame. This keeps +//! the recording contract opaque to `game_plugin` — no event-source +//! flag is threaded through, no every-callsite gate is added. + +use bevy::prelude::*; +use solitaire_data::{Replay, ReplayMove}; + +use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent}; +use crate::game_plugin::{GameMutation, RecordingReplay}; +use crate::resources::GameStateResource; + +/// Per-move duration during playback. Tunable in Settings later; +/// hardcoded for v1. +pub const REPLAY_MOVE_INTERVAL_SECS: f32 = 0.45; + +/// How long the [`ReplayPlaybackState::Completed`] state lingers before +/// the auto-clear system transitions it back to +/// [`ReplayPlaybackState::Inactive`]. Gives the overlay UI time to +/// display "Replay complete" before dismissing. +pub const REPLAY_COMPLETION_LINGER_SECS: f32 = 5.0; + +/// Lifecycle state of an in-flight replay playback. +/// +/// The default state is [`Inactive`](Self::Inactive) — no replay is +/// running. The overlay (and any other consumer) reads this resource to +/// decide whether the "Replay" banner should be visible and what +/// progress to display. +/// +/// Lifecycle: +/// 1. Default state is [`Inactive`](Self::Inactive). +/// 2. [`start_replay_playback`] transitions to +/// [`Playing`](Self::Playing) and resets the live `GameState` to the +/// replay's recorded deal. +/// 3. The tick system [`tick_replay_playback`] advances `cursor` once +/// per [`REPLAY_MOVE_INTERVAL_SECS`] and fires the canonical event +/// for each [`ReplayMove`]. +/// 4. When `cursor == replay.moves.len()`, the state transitions to +/// [`Completed`](Self::Completed). It lingers for +/// [`REPLAY_COMPLETION_LINGER_SECS`] (driven by +/// [`auto_clear_completed_replay`]) before returning to +/// [`Inactive`](Self::Inactive). +/// 5. [`stop_replay_playback`] interrupts at any time and forces the +/// state back to [`Inactive`](Self::Inactive). +#[derive(Resource, Debug, Default)] +pub enum ReplayPlaybackState { + /// No replay is being played back. The overlay despawns itself when + /// the resource transitions back to this variant. + #[default] + Inactive, + /// A replay is currently being played back. The overlay reads + /// `replay.moves.len()` for the denominator of the progress + /// indicator and `cursor` for the numerator. + Playing { + /// The replay being played back. Owned so the state is the + /// only place playback metadata lives — no separate resource + /// needed. + replay: Replay, + /// Index of the next move to apply, in `[0, replay.moves.len()]`. + cursor: usize, + /// Seconds remaining until the next move is dispatched. + secs_to_next: f32, + }, + /// The replay finished playing back. The overlay swaps the banner + /// label to "Replay complete" until [`auto_clear_completed_replay`] + /// transitions back to [`Inactive`](Self::Inactive) a few seconds + /// later. + Completed, +} + +impl ReplayPlaybackState { + /// Returns `true` when a replay is currently being played back. + pub fn is_playing(&self) -> bool { + matches!(self, Self::Playing { .. }) + } + + /// Returns `true` when the replay has finished but the resource has + /// not yet been auto-cleared back to [`Self::Inactive`]. + pub fn is_completed(&self) -> bool { + matches!(self, Self::Completed) + } + + /// Returns `(cursor, total)` when a replay is in progress so the + /// overlay can render `"Move N of M"`. Returns `None` while + /// [`Inactive`](Self::Inactive) or [`Completed`](Self::Completed) — + /// the replay is consumed when transitioning out of `Playing`, so + /// the total is no longer available in `Completed`. + pub fn progress(&self) -> Option<(usize, usize)> { + match self { + Self::Playing { replay, cursor, .. } => Some((*cursor, replay.moves.len())), + Self::Inactive | Self::Completed => None, + } + } +} + +/// Public entry point — call from the Stats "Watch replay" button +/// handler. +/// +/// Resets the live [`GameStateResource`] to a fresh deal seeded from +/// `replay.seed` / `replay.draw_mode` / `replay.mode` (via +/// [`Commands::insert_resource`]), then transitions the state machine +/// to [`ReplayPlaybackState::Playing`] with `cursor: 0` and +/// `secs_to_next: REPLAY_MOVE_INTERVAL_SECS`. +/// +/// `commands` is used to overwrite [`GameStateResource`] in a deferred +/// flush — equivalent to what `handle_new_game` does, minus the +/// [`crate::events::NewGameRequestEvent`] round-trip and the +/// abandon-current-game confirmation modal (which would block playback +/// indefinitely). Using `Commands` rather than [`crate::events::NewGameRequestEvent`] +/// also sidesteps the fact that `NewGameRequestEvent` has no +/// `draw_mode_override` field — `handle_new_game` always reads +/// `draw_mode` from `Settings`, which would silently coerce a Draw-1 +/// replay into a Draw-3 game (or vice versa) when the player's +/// settings disagree with the recording. +/// +/// Safe to call from any state — if a replay is already playing it is +/// dropped and the new one starts immediately. +pub fn start_replay_playback( + commands: &mut Commands, + state: &mut ResMut, + replay: Replay, +) { + use solitaire_core::game_state::GameState; + + let fresh = GameState::new_with_mode(replay.seed, replay.draw_mode.clone(), replay.mode); + commands.insert_resource(GameStateResource(fresh)); + + **state = ReplayPlaybackState::Playing { + replay, + cursor: 0, + secs_to_next: REPLAY_MOVE_INTERVAL_SECS, + }; +} + +/// Aborts an in-flight replay playback and resets +/// [`ReplayPlaybackState`] back to [`ReplayPlaybackState::Inactive`]. +/// +/// Safe to call from any state — when already +/// [`ReplayPlaybackState::Inactive`] it simply re-asserts inactivity. +/// +/// The current [`GameStateResource`] is left as-is: the player sees the +/// replay's most-recently-applied state until they start a fresh game +/// manually. This avoids forcing an extra deal animation in their face +/// the moment they cancel. +/// +/// `commands` is currently unused but accepted to match the +/// [`start_replay_playback`] signature — leaves room to hook in +/// cleanup (e.g. despawning playback-only overlays) without a future +/// API break. +pub fn stop_replay_playback( + _commands: &mut Commands, + state: &mut ResMut, +) { + **state = ReplayPlaybackState::Inactive; +} + +/// Tick system. Runs every frame; only does work when +/// [`ReplayPlaybackState::is_playing`]. +/// +/// Drains `secs_to_next` by `time.delta_secs()`. When the countdown +/// expires, fires the canonical event for the move at `cursor`, +/// increments `cursor`, and resets `secs_to_next`. When `cursor` +/// reaches `replay.moves.len()`, transitions to +/// [`ReplayPlaybackState::Completed`]. +/// +/// The advance loop is a `while`, not an `if`, so coarse time steps +/// (e.g. test-driven 200 ms ticks against a 450 ms interval) still +/// fire the right number of events — accumulated debt is paid off +/// across as many advances as needed in the same frame. In normal +/// gameplay frame deltas are well below `REPLAY_MOVE_INTERVAL_SECS`, +/// so the loop runs at most once per frame. +fn tick_replay_playback( + time: Res