fix(engine): restore 60 fps Android card animations + dead-code sweep #166
@@ -37,11 +37,6 @@ fn load_settings() -> Settings {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the Bevy app without entering the event loop.
|
|
||||||
pub fn build_app(sync_provider: Box<dyn SyncProvider + Send + Sync>) -> App {
|
|
||||||
build_app_with_settings(load_settings(), sync_provider)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// App entry point — configures runtime services, builds, and runs the app.
|
/// App entry point — configures runtime services, builds, and runs the app.
|
||||||
///
|
///
|
||||||
/// Called from both the desktop `bin` target's `main` shim and (on
|
/// Called from both the desktop `bin` target's `main` shim and (on
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ pub mod spider;
|
|||||||
// re-exported — they are only used internally (in `klondike_adapter.rs` and
|
// re-exported — they are only used internally (in `klondike_adapter.rs` and
|
||||||
// when decoding instructions to piles in `instruction_to_piles`) and do not
|
// when decoding instructions to piles in `instruction_to_piles`) and do not
|
||||||
// appear in any public method signature.
|
// appear in any public method signature.
|
||||||
pub use card_game::{Card, Deck, Rank, Session, SolveError, Suit};
|
pub use card_game::{Card, Deck, Rank, SolveError, Suit};
|
||||||
pub use klondike::{
|
pub use klondike::{
|
||||||
DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau,
|
DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -105,10 +105,9 @@ pub use stats::{StatsExt, StatsSnapshot};
|
|||||||
pub mod storage;
|
pub mod storage;
|
||||||
pub use storage::{
|
pub use storage::{
|
||||||
TimeAttackSession, cleanup_orphaned_tmp_files, delete_game_state_at,
|
TimeAttackSession, cleanup_orphaned_tmp_files, delete_game_state_at,
|
||||||
delete_time_attack_session_at, game_state_file_path, load_game_state_from, load_stats,
|
delete_time_attack_session_at, game_state_file_path, load_game_state_from, load_stats_from,
|
||||||
load_stats_from, load_time_attack_session_from, load_time_attack_session_from_at,
|
load_time_attack_session_from, save_game_state_to, save_stats_to, save_time_attack_session_to,
|
||||||
save_game_state_to, save_stats, save_stats_to, save_time_attack_session_to, stats_file_path,
|
stats_file_path, time_attack_session_path,
|
||||||
time_attack_session_path, time_attack_session_with_now,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod achievements;
|
pub mod achievements;
|
||||||
@@ -136,11 +135,9 @@ pub use difficulty_seeds::{DifficultySeeds, seeds_for};
|
|||||||
|
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub use settings::{
|
pub use settings::{
|
||||||
AnimSpeed, REPLAY_MOVE_INTERVAL_MAX_SECS, REPLAY_MOVE_INTERVAL_MIN_SECS,
|
AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, Settings, SyncBackend,
|
||||||
REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, Settings, SyncBackend,
|
TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_STEP_SECS, Theme, WindowGeometry, load_settings_from,
|
||||||
TIME_BONUS_MULTIPLIER_MAX, TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_STEP,
|
save_settings_to, settings_file_path,
|
||||||
TOOLTIP_DELAY_MAX_SECS, TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS, Theme, WindowGeometry,
|
|
||||||
load_settings_from, save_settings_to, settings_file_path,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
@@ -152,9 +149,7 @@ mod android_keystore;
|
|||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod auth_tokens;
|
pub mod auth_tokens;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub use auth_tokens::{
|
pub use auth_tokens::{TokenError, delete_tokens, store_tokens};
|
||||||
TokenError, delete_tokens, load_access_token, load_refresh_token, store_tokens,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub mod sync_client;
|
pub mod sync_client;
|
||||||
pub use sync_client::LocalOnlyProvider;
|
pub use sync_client::LocalOnlyProvider;
|
||||||
|
|||||||
@@ -46,22 +46,6 @@ pub fn save_stats_to(path: &Path, stats: &StatsSnapshot) -> io::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load stats from the platform default path. Returns default if the path
|
|
||||||
/// is unavailable or the file is missing/corrupt.
|
|
||||||
pub fn load_stats() -> StatsSnapshot {
|
|
||||||
stats_file_path()
|
|
||||||
.map(|p| load_stats_from(&p))
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Save stats to the platform default path. Returns an error if the platform
|
|
||||||
/// data dir is unavailable or the write fails.
|
|
||||||
pub fn save_stats(stats: &StatsSnapshot) -> io::Result<()> {
|
|
||||||
let path = stats_file_path()
|
|
||||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "platform data dir unavailable"))?;
|
|
||||||
save_stats_to(&path, stats)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// In-progress game state
|
// In-progress game state
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -245,18 +229,6 @@ pub fn delete_time_attack_session_at(path: &Path) -> io::Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience helper for callers that want to stamp a session with the
|
|
||||||
/// current wall-clock time. Equivalent to constructing the struct
|
|
||||||
/// manually and setting `saved_at_unix_secs` to `SystemTime::now()`.
|
|
||||||
pub fn time_attack_session_with_now(remaining_secs: f32, wins: u32) -> TimeAttackSession {
|
|
||||||
let now = Utc::now().timestamp().max(0) as u64;
|
|
||||||
TimeAttackSession {
|
|
||||||
remaining_secs,
|
|
||||||
wins,
|
|
||||||
saved_at_unix_secs: now,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Inner helper: delete `*.tmp` entries inside `dir`.
|
/// Inner helper: delete `*.tmp` entries inside `dir`.
|
||||||
///
|
///
|
||||||
/// Per-file errors (already deleted, permission denied) are silently ignored.
|
/// Per-file errors (already deleted, permission denied) are silently ignored.
|
||||||
|
|||||||
@@ -252,10 +252,17 @@ fn advance_card_anims(
|
|||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
paused: Option<Res<PausedResource>>,
|
paused: Option<Res<PausedResource>>,
|
||||||
mut anims: Query<(Entity, &mut Transform, &mut CardAnim)>,
|
mut anims: Query<(Entity, &mut Transform, &mut CardAnim)>,
|
||||||
|
mut redraw: MessageWriter<RequestRedraw>,
|
||||||
) {
|
) {
|
||||||
if paused.is_some_and(|p| p.0) {
|
if paused.is_some_and(|p| p.0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Keep the winit loop awake at full frame rate while slides (including
|
||||||
|
// staggered deals still in their delay phase) are in flight — required
|
||||||
|
// for Android's reactive_low_power focused_mode.
|
||||||
|
if !anims.is_empty() {
|
||||||
|
redraw.write(RequestRedraw);
|
||||||
|
}
|
||||||
let dt = time.delta_secs();
|
let dt = time.delta_secs();
|
||||||
for (entity, mut transform, mut anim) in &mut anims {
|
for (entity, mut transform, mut anim) in &mut anims {
|
||||||
if anim.delay > 0.0 {
|
if anim.delay > 0.0 {
|
||||||
@@ -558,10 +565,17 @@ fn drive_toast_display(
|
|||||||
paused: Option<Res<PausedResource>>,
|
paused: Option<Res<PausedResource>>,
|
||||||
mut queue: ResMut<ToastQueue>,
|
mut queue: ResMut<ToastQueue>,
|
||||||
mut active: ResMut<ActiveToast>,
|
mut active: ResMut<ActiveToast>,
|
||||||
|
mut redraw: MessageWriter<RequestRedraw>,
|
||||||
) {
|
) {
|
||||||
if paused.is_some_and(|p| p.0) {
|
if paused.is_some_and(|p| p.0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Keep the loop ticking while a toast is displayed or queued so the
|
||||||
|
// countdown advances and the despawn frame isn't held hostage by
|
||||||
|
// Android's reactive_low_power wake ceiling.
|
||||||
|
if active.entity.is_some() || !queue.0.is_empty() {
|
||||||
|
redraw.write(RequestRedraw);
|
||||||
|
}
|
||||||
let dt = time.delta_secs();
|
let dt = time.delta_secs();
|
||||||
|
|
||||||
// Tick down the active toast timer.
|
// Tick down the active toast timer.
|
||||||
@@ -593,9 +607,9 @@ pub enum ToastVariant {
|
|||||||
/// Neutral system message — teal border. Default for `InfoToastEvent`,
|
/// Neutral system message — teal border. Default for `InfoToastEvent`,
|
||||||
/// settings volume notifications, and the auto-complete announcement.
|
/// settings volume notifications, and the auto-complete announcement.
|
||||||
Info,
|
Info,
|
||||||
/// Caution / penalty — gold border. Currently unused by an in-engine
|
/// Caution / penalty — gold border. Used by [`handle_warning_toast`]
|
||||||
/// event; kept so future warning-flavoured toasts have a slot.
|
/// for `WarningToastEvent` messages (daily-challenge expiry, sync,
|
||||||
#[allow(dead_code)]
|
/// theme-store, and leaderboard warnings).
|
||||||
Warning,
|
Warning,
|
||||||
/// Failure / rejected action — pink border. Used by
|
/// Failure / rejected action — pink border. Used by
|
||||||
/// [`handle_move_rejected_toast`] for illegal-placement
|
/// [`handle_move_rejected_toast`] for illegal-placement
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Sound-effect playback via `kira`.
|
//! Sound-effect playback via `kira`.
|
||||||
//!
|
//!
|
||||||
//! Loads five embedded WAVs (`include_bytes!`) at startup and plays them in
|
//! Loads seven embedded WAVs (`include_bytes!`) at startup — six SFX plus
|
||||||
//! response to gameplay events:
|
//! the ambient loop — and plays them in response to gameplay events:
|
||||||
//!
|
//!
|
||||||
//! | Event | Sound |
|
//! | Event | Sound |
|
||||||
//! |---|---|
|
//! |---|---|
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
//! | `MoveRejectedEvent` | `card_invalid.wav` |
|
//! | `MoveRejectedEvent` | `card_invalid.wav` |
|
||||||
//! | `NewGameRequestEvent` | `card_deal.wav` |
|
//! | `NewGameRequestEvent` | `card_deal.wav` |
|
||||||
//! | `GameWonEvent` | `win_fanfare.wav` |
|
//! | `GameWonEvent` | `win_fanfare.wav` |
|
||||||
|
//! | `FoundationCompletedEvent` | `foundation_complete.wav` |
|
||||||
//!
|
//!
|
||||||
//! An ambient loop (`ambient_loop.wav`) is started at plugin startup at very
|
//! An ambient loop (`ambient_loop.wav`) is started at plugin startup at very
|
||||||
//! low volume (0.05 amplitude) routed through `music_track`.
|
//! low volume (0.05 amplitude) routed through `music_track`.
|
||||||
@@ -38,7 +39,7 @@ use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
|||||||
/// Volume amplitude for the stock-recycle draw sound (half of normal 1.0).
|
/// Volume amplitude for the stock-recycle draw sound (half of normal 1.0).
|
||||||
const RECYCLE_VOLUME: f64 = 0.5;
|
const RECYCLE_VOLUME: f64 = 0.5;
|
||||||
|
|
||||||
/// Volume amplitude for the ambient music loop placeholder.
|
/// Volume amplitude for the ambient music loop.
|
||||||
const AMBIENT_VOLUME: f64 = 0.05;
|
const AMBIENT_VOLUME: f64 = 0.05;
|
||||||
|
|
||||||
/// Converts a linear amplitude (0.0–1.0+) to the `Decibels` type used by
|
/// Converts a linear amplitude (0.0–1.0+) to the `Decibels` type used by
|
||||||
@@ -101,7 +102,7 @@ pub struct MuteState {
|
|||||||
pub music_muted: bool,
|
pub music_muted: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Plays sound effects and background music via `bevy_kira_audio`. Responds to game events (card place, flip, invalid move, win fanfare) and respects volume settings from `SettingsResource`.
|
/// Plays sound effects and background music via `kira`. Responds to game events (card place, flip, invalid move, win fanfare) and respects volume settings from `SettingsResource`.
|
||||||
pub struct AudioPlugin;
|
pub struct AudioPlugin;
|
||||||
|
|
||||||
impl Plugin for AudioPlugin {
|
impl Plugin for AudioPlugin {
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ fn drive_auto_complete(
|
|||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
paused: Option<Res<PausedResource>>,
|
paused: Option<Res<PausedResource>>,
|
||||||
mut moves: MessageWriter<MoveRequestEvent>,
|
mut moves: MessageWriter<MoveRequestEvent>,
|
||||||
|
mut redraw: MessageWriter<RequestRedraw>,
|
||||||
) {
|
) {
|
||||||
if !state.active {
|
if !state.active {
|
||||||
return;
|
return;
|
||||||
@@ -154,6 +155,10 @@ fn drive_auto_complete(
|
|||||||
if paused.is_some_and(|p| p.0) {
|
if paused.is_some_and(|p| p.0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Keepalive: the step-interval cooldown only advances on frames that
|
||||||
|
// actually run, so keep the winit loop awake for the whole burst under
|
||||||
|
// Android's reactive_low_power focused_mode.
|
||||||
|
redraw.write(RequestRedraw);
|
||||||
|
|
||||||
state.cooldown -= time.delta_secs();
|
state.cooldown -= time.delta_secs();
|
||||||
if state.cooldown > 0.0 {
|
if state.cooldown > 0.0 {
|
||||||
|
|||||||
@@ -18,11 +18,6 @@
|
|||||||
//! The sine term is 0 at `t = 0` and `t = 1` and peaks at `t = 0.5`, so the
|
//! The sine term is 0 at `t = 0` and `t = 1` and peaks at `t = 0.5`, so the
|
||||||
//! card "floats up" in the middle of its travel and lands at its correct rest z.
|
//! card "floats up" in the middle of its travel and lands at its correct rest z.
|
||||||
//!
|
//!
|
||||||
//! # Retargeting
|
|
||||||
//!
|
|
||||||
//! When a card is redirected mid-flight, call [`retarget_animation`]. It reads
|
|
||||||
//! the current interpolated position so the card never snaps.
|
|
||||||
//!
|
|
||||||
//! # Coexistence with `CardAnim`
|
//! # Coexistence with `CardAnim`
|
||||||
//!
|
//!
|
||||||
//! `CardAnimation` and the legacy `CardAnim` can coexist in the same world but
|
//! `CardAnimation` and the legacy `CardAnim` can coexist in the same world but
|
||||||
@@ -33,6 +28,7 @@
|
|||||||
use std::f32::consts::PI;
|
use std::f32::consts::PI;
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use bevy::window::RequestRedraw;
|
||||||
|
|
||||||
use super::curves::{MotionCurve, sample_curve};
|
use super::curves::{MotionCurve, sample_curve};
|
||||||
use super::timing::compute_duration;
|
use super::timing::compute_duration;
|
||||||
@@ -122,8 +118,6 @@ impl CardAnimation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the current interpolated XY position without advancing time.
|
/// Returns the current interpolated XY position without advancing time.
|
||||||
///
|
|
||||||
/// Used by [`retarget_animation`] to read mid-flight position cleanly.
|
|
||||||
pub fn current_xy(&self) -> Vec2 {
|
pub fn current_xy(&self) -> Vec2 {
|
||||||
if self.duration <= 0.0 {
|
if self.duration <= 0.0 {
|
||||||
return self.end;
|
return self.end;
|
||||||
@@ -134,90 +128,6 @@ impl CardAnimation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Retarget helper
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Redirects a card to a new destination without snapping or interrupting motion.
|
|
||||||
///
|
|
||||||
/// Reads the card's current interpolated position (from a live [`CardAnimation`]
|
|
||||||
/// if present, or from `Transform` if stationary) and starts a fresh
|
|
||||||
/// [`CardAnimation`] from that position. Duration is recalculated from the
|
|
||||||
/// remaining distance so short paths stay quick.
|
|
||||||
///
|
|
||||||
/// # Velocity continuity
|
|
||||||
///
|
|
||||||
/// When a card is mid-flight, the new animation starts with a small positive
|
|
||||||
/// `elapsed` offset (`carry`) derived from how far through the current animation
|
|
||||||
/// the card is. This preserves a sense of forward momentum: the new curve does
|
|
||||||
/// not restart from zero velocity, avoiding a visible "lurch" when the target
|
|
||||||
/// changes rapidly.
|
|
||||||
///
|
|
||||||
/// The carry is deliberately small (≤ 10 % of the new duration) so that it
|
|
||||||
/// never causes a visible position jump — the card's start position is still
|
|
||||||
/// read from the current transform.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```ignore
|
|
||||||
/// // Inside a system that decides to move a card to a new target:
|
|
||||||
/// let (entity, transform, anim) = cards.get(card_entity)?;
|
|
||||||
/// retarget_animation(
|
|
||||||
/// &mut commands,
|
|
||||||
/// entity,
|
|
||||||
/// anim, // Option<&CardAnimation>
|
|
||||||
/// transform,
|
|
||||||
/// Vec2::new(400.0, 200.0),
|
|
||||||
/// resting_z,
|
|
||||||
/// MotionCurve::SmoothSnap,
|
|
||||||
/// );
|
|
||||||
/// ```
|
|
||||||
pub fn retarget_animation(
|
|
||||||
commands: &mut Commands,
|
|
||||||
entity: Entity,
|
|
||||||
current_anim: Option<&CardAnimation>,
|
|
||||||
transform: &Transform,
|
|
||||||
new_end: Vec2,
|
|
||||||
new_end_z: f32,
|
|
||||||
curve: MotionCurve,
|
|
||||||
) {
|
|
||||||
let (current_xy, current_z, momentum_carry) = match current_anim {
|
|
||||||
Some(anim) if anim.duration > 0.0 => {
|
|
||||||
// Estimate how far into the current animation we are and carry
|
|
||||||
// a small fraction of that progress into the new animation.
|
|
||||||
// This avoids restarting from zero velocity and makes the motion
|
|
||||||
// feel continuous when the target changes mid-flight.
|
|
||||||
let t = (anim.elapsed / anim.duration).clamp(0.0, 1.0);
|
|
||||||
// Cap at 10 % of the new animation so there's no visible jump.
|
|
||||||
let carry = (t * 0.12).min(0.10);
|
|
||||||
(anim.current_xy(), transform.translation.z, carry)
|
|
||||||
}
|
|
||||||
_ => (
|
|
||||||
transform.translation.truncate(),
|
|
||||||
transform.translation.z,
|
|
||||||
0.0,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
let distance = current_xy.distance(new_end);
|
|
||||||
let duration = compute_duration(distance);
|
|
||||||
|
|
||||||
commands.entity(entity).insert(CardAnimation {
|
|
||||||
start: current_xy,
|
|
||||||
end: new_end,
|
|
||||||
// Start slightly into the new animation to carry forward momentum.
|
|
||||||
elapsed: momentum_carry * duration,
|
|
||||||
duration,
|
|
||||||
curve,
|
|
||||||
delay: 0.0,
|
|
||||||
start_z: current_z,
|
|
||||||
end_z: new_end_z,
|
|
||||||
z_lift: 8.0,
|
|
||||||
scale_start: 1.0,
|
|
||||||
scale_end: 1.0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// System
|
// System
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -232,10 +142,18 @@ pub(crate) fn advance_card_animations(
|
|||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
paused: Option<Res<PausedResource>>,
|
paused: Option<Res<PausedResource>>,
|
||||||
mut q: Query<(Entity, &mut Transform, &mut CardAnimation)>,
|
mut q: Query<(Entity, &mut Transform, &mut CardAnimation)>,
|
||||||
|
mut redraw: MessageWriter<RequestRedraw>,
|
||||||
) {
|
) {
|
||||||
if paused.is_some_and(|p| p.0) {
|
if paused.is_some_and(|p| p.0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Keep the winit event loop awake while any animation (including one
|
||||||
|
// still in its delay phase) needs per-frame ticks. Without this,
|
||||||
|
// Android's reactive_low_power focused_mode only wakes at its 100 ms
|
||||||
|
// ceiling and card slides render at ~10 fps.
|
||||||
|
if !q.is_empty() {
|
||||||
|
redraw.write(RequestRedraw);
|
||||||
|
}
|
||||||
let dt = time.delta_secs();
|
let dt = time.delta_secs();
|
||||||
|
|
||||||
for (entity, mut transform, mut anim) in &mut q {
|
for (entity, mut transform, mut anim) in &mut q {
|
||||||
|
|||||||
@@ -31,28 +31,6 @@
|
|||||||
//! ));
|
//! ));
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! Retarget a card mid-flight:
|
|
||||||
//!
|
|
||||||
//! ```ignore
|
|
||||||
//! use solitaire_engine::card_animation::retarget_animation;
|
|
||||||
//!
|
|
||||||
//! fn handle_drop(
|
|
||||||
//! mut commands: Commands,
|
|
||||||
//! q: Query<(Entity, &Transform, Option<&CardAnimation>), With<CardEntity>>,
|
|
||||||
//! ) {
|
|
||||||
//! let (entity, transform, anim) = q.get(card_entity).unwrap();
|
|
||||||
//! retarget_animation(
|
|
||||||
//! &mut commands,
|
|
||||||
//! entity,
|
|
||||||
//! anim,
|
|
||||||
//! transform,
|
|
||||||
//! new_target_xy,
|
|
||||||
//! new_target_z,
|
|
||||||
//! MotionCurve::SmoothSnap,
|
|
||||||
//! );
|
|
||||||
//! }
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! # Win cascade with `Expressive` curve
|
//! # Win cascade with `Expressive` curve
|
||||||
//!
|
//!
|
||||||
//! The existing `AnimationPlugin` drives the win cascade with `CardAnim`
|
//! The existing `AnimationPlugin` drives the win cascade with `CardAnim`
|
||||||
@@ -80,7 +58,7 @@ pub mod interaction;
|
|||||||
pub mod timing;
|
pub mod timing;
|
||||||
pub mod tuning;
|
pub mod tuning;
|
||||||
|
|
||||||
pub use animation::{CardAnimation, retarget_animation, win_scatter_targets};
|
pub use animation::{CardAnimation, win_scatter_targets};
|
||||||
pub use chain::AnimationChain;
|
pub use chain::AnimationChain;
|
||||||
pub use curves::{MotionCurve, sample_curve};
|
pub use curves::{MotionCurve, sample_curve};
|
||||||
pub use diagnostics::{FrameTimeDiagnostics, WINDOW_SIZE as DIAG_WINDOW_SIZE};
|
pub use diagnostics::{FrameTimeDiagnostics, WINDOW_SIZE as DIAG_WINDOW_SIZE};
|
||||||
@@ -307,6 +285,49 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression test for the v0.40.0 Android animation-lag bug: commit
|
||||||
|
/// 38e4c03 switched Android to `reactive_low_power` focused_mode on the
|
||||||
|
/// premise that animation systems write `RequestRedraw` while active,
|
||||||
|
/// but the writers were never added — card slides rendered at the 100 ms
|
||||||
|
/// wake ceiling (~10 fps). Active animations MUST emit `RequestRedraw`
|
||||||
|
/// every frame; an idle board must not.
|
||||||
|
#[test]
|
||||||
|
fn active_card_animation_requests_redraw() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(CardAnimationPlugin);
|
||||||
|
|
||||||
|
// Idle board: no redraw requests.
|
||||||
|
app.update();
|
||||||
|
assert!(
|
||||||
|
app.world().resource::<Messages<RequestRedraw>>().is_empty(),
|
||||||
|
"no RequestRedraw expected while no animation is active"
|
||||||
|
);
|
||||||
|
|
||||||
|
app.world_mut().spawn((
|
||||||
|
Transform::from_translation(Vec3::ZERO),
|
||||||
|
CardAnimation {
|
||||||
|
start: Vec2::ZERO,
|
||||||
|
end: Vec2::new(100.0, 0.0),
|
||||||
|
elapsed: 0.0,
|
||||||
|
duration: 1.0,
|
||||||
|
curve: MotionCurve::Responsive,
|
||||||
|
delay: 0.0,
|
||||||
|
start_z: 0.0,
|
||||||
|
end_z: 0.0,
|
||||||
|
z_lift: 0.0,
|
||||||
|
scale_start: 1.0,
|
||||||
|
scale_end: 1.0,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
app.update();
|
||||||
|
assert!(
|
||||||
|
!app.world().resource::<Messages<RequestRedraw>>().is_empty(),
|
||||||
|
"an active CardAnimation must write RequestRedraw each frame to \
|
||||||
|
sustain the reactive render loop"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn card_animation_instant_snaps_on_zero_duration() {
|
fn card_animation_instant_snaps_on_zero_duration() {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
|
|||||||
@@ -300,15 +300,6 @@ pub struct ForfeitEvent;
|
|||||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
pub struct ForfeitRequestEvent;
|
pub struct ForfeitRequestEvent;
|
||||||
|
|
||||||
/// Fired when the player clicks "Scan for new themes" in Settings.
|
|
||||||
///
|
|
||||||
/// Consumed by `handle_scan_themes` in `SettingsPlugin`, which scans
|
|
||||||
/// `user_theme_dir()` for `.zip` files, calls `import_theme()` on each
|
|
||||||
/// unrecognised archive, refreshes [`crate::theme::ThemeRegistry`], and
|
|
||||||
/// fires [`InfoToastEvent`] messages to report results.
|
|
||||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
|
||||||
pub struct ScanThemesRequestEvent;
|
|
||||||
|
|
||||||
/// Fired when the player requests a hint (H key). Carries the source card ID
|
/// Fired when the player requests a hint (H key). Carries the source card ID
|
||||||
/// and destination pile for visual highlighting.
|
/// and destination pile for visual highlighting.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -276,10 +276,16 @@ fn tick_shake_anim(
|
|||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
paused: Option<Res<PausedResource>>,
|
paused: Option<Res<PausedResource>>,
|
||||||
mut anims: Query<(Entity, &mut Transform, &mut ShakeAnim)>,
|
mut anims: Query<(Entity, &mut Transform, &mut ShakeAnim)>,
|
||||||
|
mut redraw: MessageWriter<RequestRedraw>,
|
||||||
) {
|
) {
|
||||||
if paused.is_some_and(|p| p.0) {
|
if paused.is_some_and(|p| p.0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Sustain full-rate frames for the shake under Android's
|
||||||
|
// reactive_low_power focused_mode.
|
||||||
|
if !anims.is_empty() {
|
||||||
|
redraw.write(RequestRedraw);
|
||||||
|
}
|
||||||
let dt = time.delta_secs();
|
let dt = time.delta_secs();
|
||||||
for (entity, mut transform, mut anim) in &mut anims {
|
for (entity, mut transform, mut anim) in &mut anims {
|
||||||
anim.elapsed += dt;
|
anim.elapsed += dt;
|
||||||
@@ -356,10 +362,16 @@ fn tick_settle_anim(
|
|||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
paused: Option<Res<PausedResource>>,
|
paused: Option<Res<PausedResource>>,
|
||||||
mut anims: Query<(Entity, &mut Transform, &mut SettleAnim)>,
|
mut anims: Query<(Entity, &mut Transform, &mut SettleAnim)>,
|
||||||
|
mut redraw: MessageWriter<RequestRedraw>,
|
||||||
) {
|
) {
|
||||||
if paused.is_some_and(|p| p.0) {
|
if paused.is_some_and(|p| p.0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Sustain full-rate frames for the settle bounce under Android's
|
||||||
|
// reactive_low_power focused_mode.
|
||||||
|
if !anims.is_empty() {
|
||||||
|
redraw.write(RequestRedraw);
|
||||||
|
}
|
||||||
let dt = time.delta_secs();
|
let dt = time.delta_secs();
|
||||||
for (entity, mut transform, mut anim) in &mut anims {
|
for (entity, mut transform, mut anim) in &mut anims {
|
||||||
anim.elapsed += dt;
|
anim.elapsed += dt;
|
||||||
@@ -581,10 +593,16 @@ fn tick_foundation_flourish(
|
|||||||
(Entity, &mut Sprite, &mut FoundationMarkerFlourish),
|
(Entity, &mut Sprite, &mut FoundationMarkerFlourish),
|
||||||
Without<FoundationFlourish>,
|
Without<FoundationFlourish>,
|
||||||
>,
|
>,
|
||||||
|
mut redraw: MessageWriter<RequestRedraw>,
|
||||||
) {
|
) {
|
||||||
if paused.is_some_and(|p| p.0) {
|
if paused.is_some_and(|p| p.0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Sustain full-rate frames for the flourish under Android's
|
||||||
|
// reactive_low_power focused_mode.
|
||||||
|
if !card_anims.is_empty() || !marker_anims.is_empty() {
|
||||||
|
redraw.write(RequestRedraw);
|
||||||
|
}
|
||||||
let dt = time.delta_secs();
|
let dt = time.delta_secs();
|
||||||
|
|
||||||
// Advance the King's scale pulse.
|
// Advance the King's scale pulse.
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ use std::collections::HashMap;
|
|||||||
use bevy::ecs::system::SystemParam;
|
use bevy::ecs::system::SystemParam;
|
||||||
use bevy::input::ButtonInput;
|
use bevy::input::ButtonInput;
|
||||||
use bevy::input::touch::{TouchInput, TouchPhase, Touches};
|
use bevy::input::touch::{TouchInput, TouchPhase, Touches};
|
||||||
use bevy::math::{Vec2, Vec3};
|
use bevy::math::Vec2;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::PrimaryWindow;
|
use bevy::window::PrimaryWindow;
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
@@ -1806,10 +1806,5 @@ pub fn find_hint(game: &GameState) -> Option<(KlondikePile, KlondikePile)> {
|
|||||||
all_hints(game).into_iter().next()
|
all_hints(game).into_iter().next()
|
||||||
}
|
}
|
||||||
|
|
||||||
// `Vec3` is referenced only via the `DRAG_Z` constant; keep the import silenced
|
|
||||||
// when the compiler can't see it used.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
const _VEC3_REFERENCED: Option<Vec3> = None;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -84,8 +84,8 @@ pub use card_animation::{
|
|||||||
AnimationChain, AnimationTuning, BufferedInput, CardAnimation, CardAnimationPlugin,
|
AnimationChain, AnimationTuning, BufferedInput, CardAnimation, CardAnimationPlugin,
|
||||||
DEAL_INTERVAL_SECS, DIAG_WINDOW_SIZE, FrameTimeDiagnostics, HoverState, InputBuffer,
|
DEAL_INTERVAL_SECS, DIAG_WINDOW_SIZE, FrameTimeDiagnostics, HoverState, InputBuffer,
|
||||||
InputPlatform, MAX_DURATION_SECS, MIN_DURATION_SECS, MotionCurve, WIN_CASCADE_INTERVAL_SECS,
|
InputPlatform, MAX_DURATION_SECS, MIN_DURATION_SECS, MotionCurve, WIN_CASCADE_INTERVAL_SECS,
|
||||||
WinCascadePlugin, cascade_delay, compute_duration, micro_vary, retarget_animation,
|
WinCascadePlugin, cascade_delay, compute_duration, micro_vary, sample_curve,
|
||||||
sample_curve, win_scatter_targets,
|
win_scatter_targets,
|
||||||
};
|
};
|
||||||
pub use card_plugin::{
|
pub use card_plugin::{
|
||||||
CardEntity, CardImageSet, CardLabel, CardPlugin, HintHighlight, HintHighlightTimer,
|
CardEntity, CardImageSet, CardLabel, CardPlugin, HintHighlight, HintHighlightTimer,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
#![allow(dead_code)]
|
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -27,960 +25,6 @@ pub(crate) struct ReplayScrubKeyHold {
|
|||||||
pub(crate) right_held_secs: f32,
|
pub(crate) right_held_secs: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Marker on the keybind-hint footer row at the bottom edge of the
|
|
||||||
/// banner. Carries two `Text` children: a vim-style mode indicator
|
|
||||||
/// (`▌ NORMAL │ replay`) on the left and the keybind hint
|
|
||||||
/// (`[SPACE] pause/resume`) on the right. 1 px top border in
|
|
||||||
/// [`BORDER_SUBTLE`] separates it from the notch-label row above.
|
|
||||||
///
|
|
||||||
/// Surfaces the existing Space-key accelerator visually so the
|
|
||||||
/// UI-first contract from CLAUDE.md §3.3 (every player action has
|
|
||||||
/// a visible UI control) holds for keyboard accelerators too.
|
|
||||||
/// Future commits that wire ESC for stop or ← / → for scrub will
|
|
||||||
/// extend the right-hand text in lockstep — the footer always
|
|
||||||
/// reflects what's actually wired, never aspirational.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct ReplayOverlayKeybindFooter;
|
|
||||||
|
|
||||||
/// Marker on the bottom-edge **Move Log** panel — a separate root
|
|
||||||
/// UI entity (not a child of the banner) that sits anchored to the
|
|
||||||
/// viewport's bottom edge. Carries a header (`▌ MOVE LOG · N/M`)
|
|
||||||
/// plus a row showing the most-recently-applied move.
|
|
||||||
///
|
|
||||||
/// Spawned by `spawn_overlay` alongside the banner and the
|
|
||||||
/// floating progress chip; despawned by `react_to_state_change`
|
|
||||||
/// on the same `Playing → Inactive` transition. Same lifecycle
|
|
||||||
/// pattern as `ReplayFloatingProgressChip` — a sibling root, not
|
|
||||||
/// a banner child, because it lives at a different screen anchor.
|
|
||||||
///
|
|
||||||
/// First slice of the move-log mockup at
|
|
||||||
/// `docs/ui-mockups/replay-overlay-mobile.html` § "Move Log Card".
|
|
||||||
/// Subsequent commits add prev/next rows and scrolling.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct ReplayOverlayMoveLogPanel;
|
|
||||||
|
|
||||||
/// Marker on the move-log panel's header `Text`. Carries
|
|
||||||
/// `▌ MOVE LOG · N/M` while a replay is playing; the
|
|
||||||
/// `update_move_log_header` system repaints it as the cursor
|
|
||||||
/// advances.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct ReplayOverlayMoveLogHeader;
|
|
||||||
|
|
||||||
/// Marker on the move-log panel's active-row `Text`. Carries the
|
|
||||||
/// most-recently-applied move's text (`47 │ waste → tableau 5`)
|
|
||||||
/// when `cursor > 0`; empty when no moves have been applied yet
|
|
||||||
/// (initial spawn) or in `Completed`/`Inactive` states. The
|
|
||||||
/// `update_move_log_active_row` system repaints it as the cursor
|
|
||||||
/// advances.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct ReplayOverlayMoveLogActiveRow;
|
|
||||||
|
|
||||||
/// Marker on a "previous move" row above the active row.
|
|
||||||
/// `offset` is the 1-based distance backwards from the active
|
|
||||||
/// row: `offset = 1` is the move applied just before the active
|
|
||||||
/// one (e.g. cursor=47 → row reads "46 │ ..."), `offset = 2` is
|
|
||||||
/// the one before that, and so on. Up to [`MOVE_LOG_PREV_ROWS`]
|
|
||||||
/// rows render above the active row.
|
|
||||||
///
|
|
||||||
/// Empty text when there isn't enough history (`offset >= cursor`,
|
|
||||||
/// e.g. cursor=1 has no prev rows; cursor=2 has only the
|
|
||||||
/// `offset = 1` row populated).
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct ReplayOverlayMoveLogPrevRow {
|
|
||||||
/// Distance backwards from the active row (1-based).
|
|
||||||
pub offset: u8,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Marker on a "next move" row below the active row. `offset`
|
|
||||||
/// is the 1-based distance forward from the active row:
|
|
||||||
/// `offset = 1` is the move that will apply next
|
|
||||||
/// (`replay.moves[cursor]`, displayed as `cursor + 1`),
|
|
||||||
/// `offset = 2` is the one after that, and so on. Up to
|
|
||||||
/// [`MOVE_LOG_NEXT_ROWS`] rows render below the active row.
|
|
||||||
///
|
|
||||||
/// Empty text when there isn't enough remaining replay
|
|
||||||
/// (`cursor + offset - 1 >= moves.len()`, e.g. cursor=99 of
|
|
||||||
/// a 100-move replay shows offset 1 but offset 2 stays empty).
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct ReplayOverlayMoveLogNextRow {
|
|
||||||
/// Distance forward from the active row (1-based).
|
|
||||||
pub offset: u8,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Marker added to every top-level entity spawned by [`spawn_overlay`].
|
|
||||||
/// `react_to_state_change` uses a single `Query<Entity, With<DespawnWithReplay>>`
|
|
||||||
/// to despawn all of them, rather than keeping a separate query per
|
|
||||||
/// entity type. Future sibling overlay surfaces just need this marker
|
|
||||||
/// at spawn time — no changes to the despawn logic required.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct DespawnWithReplay;
|
|
||||||
|
|
||||||
/// Marker on the mini-tableau preview panel root. A right-edge-anchored
|
|
||||||
/// panel that shows a compact summary of the live game state during
|
|
||||||
/// replay: the four foundation tops and the stock / waste heads.
|
|
||||||
/// Spawned as a sibling root entity (same lifecycle pattern as
|
|
||||||
/// [`ReplayOverlayMoveLogPanel`]) at `right: 0`, `top: MINI_TABLEAU_TOP_OFFSET`.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct ReplayMiniTableauPanel;
|
|
||||||
|
|
||||||
/// Marker on the foundations row `Text` inside the mini-tableau panel.
|
|
||||||
/// Carries `F: A♠ 7♥ 5♦ K♣` (or `--` for empty slots); repainted by
|
|
||||||
/// `update_mini_tableau` whenever [`GameStateResource`] changes.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct ReplayMiniTableauFoundations;
|
|
||||||
|
|
||||||
/// Marker on the stock/waste row `Text` inside the mini-tableau panel.
|
|
||||||
/// Carries `STK:14 WST:7♥`; repainted by `update_mini_tableau` whenever
|
|
||||||
/// [`GameStateResource`] changes.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct ReplayMiniTableauStockWaste;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Plugin
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Bevy plugin that registers every system needed to drive the replay
|
|
||||||
/// overlay's lifecycle.
|
|
||||||
///
|
|
||||||
/// The plugin is independent of [`crate::replay_playback::ReplayPlaybackPlugin`]
|
|
||||||
/// — it only reads the shared `ReplayPlaybackState` resource. Tests insert
|
|
||||||
/// the resource manually and exercise the overlay in isolation.
|
|
||||||
pub struct ReplayOverlayPlugin;
|
|
||||||
|
|
||||||
impl Plugin for ReplayOverlayPlugin {
|
|
||||||
fn build(&self, app: &mut App) {
|
|
||||||
// The systems are ordered so that, on a single frame:
|
|
||||||
// 1. The state-watcher spawns or despawns the overlay if the
|
|
||||||
// `ReplayPlaybackState` resource changed.
|
|
||||||
// 2. The completion-text update swaps the banner label when the
|
|
||||||
// state is `Completed`.
|
|
||||||
// 3. The progress-text update writes the latest "Move N of M".
|
|
||||||
// 4. The Stop-button click handler reads `Interaction::Pressed`
|
|
||||||
// and calls `stop_replay_playback` (which mutates the state).
|
|
||||||
// Putting Stop last means a click in frame N is observed by
|
|
||||||
// `react_to_state_change` in frame N+1, which then despawns the
|
|
||||||
// overlay in response — a clean state-driven loop.
|
|
||||||
// Step-button handler dispatches into the same canonical move
|
|
||||||
// / draw events that the tick loop fires. Register them
|
|
||||||
// defensively here so this plugin can run under
|
|
||||||
// `MinimalPlugins` without the playback plugin attached;
|
|
||||||
// `add_message` is idempotent so the duplicate registration
|
|
||||||
// in production (alongside `replay_playback`) is harmless.
|
|
||||||
app.init_resource::<ReplayScrubKeyHold>()
|
|
||||||
.add_message::<MoveRequestEvent>()
|
|
||||||
.add_message::<DrawRequestEvent>()
|
|
||||||
.add_message::<UndoRequestEvent>()
|
|
||||||
.add_message::<StateChangedEvent>()
|
|
||||||
.add_systems(
|
|
||||||
Update,
|
|
||||||
(
|
|
||||||
react_to_state_change,
|
|
||||||
update_banner_label,
|
|
||||||
update_progress_text,
|
|
||||||
update_floating_progress_chip,
|
|
||||||
update_scrub_fill,
|
|
||||||
update_move_log_header,
|
|
||||||
update_move_log_active_row,
|
|
||||||
update_move_log_prev_rows,
|
|
||||||
update_move_log_next_rows,
|
|
||||||
update_mini_tableau_foundations,
|
|
||||||
update_mini_tableau_stock_waste,
|
|
||||||
update_pause_button_label,
|
|
||||||
handle_pause_button,
|
|
||||||
handle_step_button,
|
|
||||||
handle_pause_keyboard,
|
|
||||||
handle_stop_keyboard,
|
|
||||||
handle_arrow_keyboard,
|
|
||||||
handle_stop_button,
|
|
||||||
)
|
|
||||||
.chain(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Spawning
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Reads [`ReplayPlaybackState`] every time the resource changes and either
|
|
||||||
/// spawns or despawns the overlay accordingly. Treats the resource as the
|
|
||||||
/// single source of truth — the spawn / despawn decision is derived from
|
|
||||||
/// `is_playing() || is_completed()` rather than tracking previous-state
|
|
||||||
/// transitions explicitly, which keeps the system stateless.
|
|
||||||
pub(crate) fn react_to_state_change(
|
|
||||||
mut commands: Commands,
|
|
||||||
state: Res<ReplayPlaybackState>,
|
|
||||||
roots: Query<Entity, With<ReplayOverlayRoot>>,
|
|
||||||
despawnable: Query<Entity, With<DespawnWithReplay>>,
|
|
||||||
font_res: Option<Res<FontResource>>,
|
|
||||||
) {
|
|
||||||
if !state.is_changed() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let should_be_visible = state.is_playing() || state.is_completed();
|
|
||||||
let already_spawned = roots.iter().next().is_some();
|
|
||||||
|
|
||||||
if should_be_visible && !already_spawned {
|
|
||||||
spawn_overlay(&mut commands, font_res.as_deref(), &state);
|
|
||||||
} else if !should_be_visible && already_spawned {
|
|
||||||
// Despawn all sibling root entities in one loop — every entity
|
|
||||||
// spawned by `spawn_overlay` carries `DespawnWithReplay` for
|
|
||||||
// exactly this purpose.
|
|
||||||
for entity in &despawnable {
|
|
||||||
commands.entity(entity).despawn();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The `should_be_visible && already_spawned` branch is a no-op here —
|
|
||||||
// the per-frame text update systems below repaint the banner label
|
|
||||||
// and progress readout in place without a respawn.
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spawns the banner — a flex-row Node anchored to the top edge of the
|
|
||||||
/// window with three children: the "▌ replay" / "▌ replay complete" label,
|
|
||||||
/// the centred progress text, and the right-aligned Stop button.
|
|
||||||
pub(crate) fn spawn_overlay(
|
|
||||||
commands: &mut Commands,
|
|
||||||
font_res: Option<&FontResource>,
|
|
||||||
state: &ReplayPlaybackState,
|
|
||||||
) {
|
|
||||||
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
|
||||||
// Clone for the floating chip spawn that runs *after* the
|
|
||||||
// banner's `.with_children(|banner| { ... })` closure consumes
|
|
||||||
// the original `font_handle`. Cheap — Bevy's `Handle<Font>` is
|
|
||||||
// `Arc`-backed, the clone bumps a refcount.
|
|
||||||
let font_handle_for_floating = font_handle.clone();
|
|
||||||
// Second clone for the scrub-bar label row and keybind footer
|
|
||||||
// inside the outer banner closure. The inner top-row closure
|
|
||||||
// consumes the original `font_handle` for the progress-chip
|
|
||||||
// text, so by the time the outer closure reaches the
|
|
||||||
// label-row / footer spawns the original is gone.
|
|
||||||
// `font_handle_for_labels` is `.clone()`'d (never moved) inside
|
|
||||||
// the labels closure, so it's still alive for the footer
|
|
||||||
// spawn afterwards — single shared clone covers both.
|
|
||||||
let font_handle_for_labels = font_handle.clone();
|
|
||||||
// Third clone for the move-log panel — a separate root
|
|
||||||
// entity spawned after the banner closure closes. Mirrors the
|
|
||||||
// floating-chip clone reasoning.
|
|
||||||
let font_handle_for_move_log = font_handle.clone();
|
|
||||||
// Fourth clone for the mini-tableau preview panel.
|
|
||||||
let font_handle_for_mini_tableau = font_handle.clone();
|
|
||||||
|
|
||||||
let banner_label = if state.is_completed() {
|
|
||||||
"\u{258C} replay complete" // ▌ — cursor-block prefix; matches the splash boot-screen convention.
|
|
||||||
} else {
|
|
||||||
"\u{258C} replay" // ▌
|
|
||||||
};
|
|
||||||
let progress_label = format_progress(state);
|
|
||||||
|
|
||||||
// Tableau dim layer — full-screen scrim at z = Z_REPLAY_DIM (= 54).
|
|
||||||
// Spawned first so it sits behind the banner (z=55) and move-log (z=55)
|
|
||||||
// in the UI stacking context. World-space sprites (cards, badges) are
|
|
||||||
// always below any UI node, so the dim layer darkens the entire
|
|
||||||
// gameplay scene without needing to touch card_plugin. No Interaction
|
|
||||||
// component — purely visual.
|
|
||||||
commands.spawn((
|
|
||||||
ReplayTableauDimLayer,
|
|
||||||
DespawnWithReplay,
|
|
||||||
Node {
|
|
||||||
position_type: PositionType::Absolute,
|
|
||||||
left: Val::Px(0.0),
|
|
||||||
top: Val::Px(0.0),
|
|
||||||
width: Val::Percent(100.0),
|
|
||||||
height: Val::Percent(100.0),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, TABLEAU_DIM_ALPHA)),
|
|
||||||
ZIndex(Z_REPLAY_DIM),
|
|
||||||
GlobalZIndex(Z_REPLAY_DIM),
|
|
||||||
));
|
|
||||||
|
|
||||||
let banner_bg = Color::srgba(
|
|
||||||
BG_ELEVATED_HI.to_srgba().red,
|
|
||||||
BG_ELEVATED_HI.to_srgba().green,
|
|
||||||
BG_ELEVATED_HI.to_srgba().blue,
|
|
||||||
BANNER_ALPHA,
|
|
||||||
);
|
|
||||||
|
|
||||||
commands
|
|
||||||
.spawn((
|
|
||||||
ReplayOverlayRoot,
|
|
||||||
DespawnWithReplay,
|
|
||||||
Node {
|
|
||||||
position_type: PositionType::Absolute,
|
|
||||||
left: Val::Px(0.0),
|
|
||||||
top: Val::Px(0.0),
|
|
||||||
width: Val::Percent(100.0),
|
|
||||||
height: Val::Px(BANNER_HEIGHT),
|
|
||||||
// Column outer so the content row sits above the 1px
|
|
||||||
// scrub bar at the bottom edge.
|
|
||||||
flex_direction: FlexDirection::Column,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(banner_bg),
|
|
||||||
// Pin the banner to its z layer in both the local and the
|
|
||||||
// global stacking context — `GlobalZIndex` matters because
|
|
||||||
// the overlay is a top-level Node (no parent), and Bevy 0.18
|
|
||||||
// has historically had subtle stacking-context drift here.
|
|
||||||
ZIndex(Z_REPLAY_OVERLAY),
|
|
||||||
GlobalZIndex(Z_REPLAY_OVERLAY),
|
|
||||||
))
|
|
||||||
.with_children(|banner| {
|
|
||||||
// Top row: the existing content (label / progress / Stop).
|
|
||||||
banner
|
|
||||||
.spawn(Node {
|
|
||||||
flex_grow: 1.0,
|
|
||||||
flex_direction: FlexDirection::Row,
|
|
||||||
align_items: AlignItems::Center,
|
|
||||||
justify_content: JustifyContent::SpaceBetween,
|
|
||||||
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
|
|
||||||
column_gap: VAL_SPACE_4,
|
|
||||||
..default()
|
|
||||||
})
|
|
||||||
.with_children(|row| {
|
|
||||||
// Left: column with the accent "▌ replay" headline
|
|
||||||
// above and a small `GAME #YYYY-DDD` caption below.
|
|
||||||
// The caption mirrors the mockup's right-anchored
|
|
||||||
// game identifier but stays visually grouped with
|
|
||||||
// the headline so the two pieces of "this is a
|
|
||||||
// replay of game X" read as a single unit.
|
|
||||||
row.spawn(Node {
|
|
||||||
flex_direction: FlexDirection::Column,
|
|
||||||
align_items: AlignItems::FlexStart,
|
|
||||||
row_gap: Val::Px(2.0),
|
|
||||||
..default()
|
|
||||||
})
|
|
||||||
.with_children(|left| {
|
|
||||||
left.spawn((
|
|
||||||
ReplayOverlayBannerText,
|
|
||||||
Text::new(banner_label),
|
|
||||||
TextFont {
|
|
||||||
font: font_handle.clone(),
|
|
||||||
font_size: TYPE_HEADLINE,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(ACCENT_PRIMARY),
|
|
||||||
));
|
|
||||||
left.spawn((
|
|
||||||
ReplayOverlayGameCaption,
|
|
||||||
Text::new(format_game_caption(state).unwrap_or_default()),
|
|
||||||
TextFont {
|
|
||||||
font: font_handle.clone(),
|
|
||||||
font_size: TYPE_CAPTION,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(TEXT_SECONDARY),
|
|
||||||
));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Centre: progress readout, wrapped in a 1 px
|
|
||||||
// ACCENT_PRIMARY-bordered chip so it reads as a
|
|
||||||
// discrete callout rather than free-floating
|
|
||||||
// text. No fill — the Terminal aesthetic gets
|
|
||||||
// depth from borders + tonal layering, not
|
|
||||||
// shadows. The marker stays on the inner Text so
|
|
||||||
// `update_progress_text` keeps working unchanged.
|
|
||||||
row.spawn((
|
|
||||||
Node {
|
|
||||||
border: UiRect::all(Val::Px(1.0)),
|
|
||||||
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BorderColor::all(ACCENT_PRIMARY),
|
|
||||||
))
|
|
||||||
.with_children(|chip| {
|
|
||||||
chip.spawn((
|
|
||||||
ReplayOverlayProgressText,
|
|
||||||
Text::new(progress_label),
|
|
||||||
TextFont {
|
|
||||||
font: font_handle,
|
|
||||||
font_size: TYPE_BODY,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(TEXT_PRIMARY),
|
|
||||||
));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Right: Stop button. Tertiary variant — the
|
|
||||||
// action is available but not the loudest element
|
|
||||||
// in the banner; the "Replay" primary accent owns
|
|
||||||
// that slot. `spawn_modal_button` gives us hover /
|
|
||||||
// press paint and focus rings for free via the
|
|
||||||
// existing `UiModalPlugin` paint system.
|
|
||||||
row.spawn(Node {
|
|
||||||
flex_direction: FlexDirection::Row,
|
|
||||||
align_items: AlignItems::Center,
|
|
||||||
column_gap: VAL_SPACE_2,
|
|
||||||
..default()
|
|
||||||
})
|
|
||||||
.with_children(|wrap| {
|
|
||||||
// Pause / Resume label is set from the current
|
|
||||||
// state so a freshly-spawned overlay (which
|
|
||||||
// currently always starts unpaused) reads
|
|
||||||
// "Pause". `update_pause_button_label`
|
|
||||||
// repaints it whenever the state changes.
|
|
||||||
spawn_modal_button(
|
|
||||||
wrap,
|
|
||||||
ReplayPauseButton,
|
|
||||||
pause_button_label(state),
|
|
||||||
None,
|
|
||||||
ButtonVariant::Tertiary,
|
|
||||||
font_res,
|
|
||||||
);
|
|
||||||
spawn_modal_button(
|
|
||||||
wrap,
|
|
||||||
ReplayStepButton,
|
|
||||||
"Step",
|
|
||||||
None,
|
|
||||||
ButtonVariant::Tertiary,
|
|
||||||
font_res,
|
|
||||||
);
|
|
||||||
spawn_modal_button(
|
|
||||||
wrap,
|
|
||||||
ReplayStopButton,
|
|
||||||
"Stop",
|
|
||||||
None,
|
|
||||||
ButtonVariant::Tertiary,
|
|
||||||
font_res,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Bottom edge: 1px-tall scrub bar. Track in `BORDER_SUBTLE`,
|
|
||||||
// fill in `ACCENT_PRIMARY`. The fill width is rewritten by
|
|
||||||
// [`update_scrub_fill`] every tick the cursor advances.
|
|
||||||
// Initial fill width matches the spawn-time progress so the
|
|
||||||
// first-frame paint already reflects state instead of
|
|
||||||
// popping from 0 → cursor on the first tick.
|
|
||||||
let initial_scrub_pct = scrub_pct(state);
|
|
||||||
let win_pct = win_move_marker_pct(state);
|
|
||||||
banner
|
|
||||||
.spawn((
|
|
||||||
Node {
|
|
||||||
width: Val::Percent(100.0),
|
|
||||||
height: Val::Px(1.0),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(BORDER_SUBTLE),
|
|
||||||
// HC marker: bumps the 1 px track from #505050
|
|
||||||
// → #a0a0a0 under high-contrast mode. The track
|
|
||||||
// paints via BackgroundColor (it's a 1 px Node,
|
|
||||||
// not a border on a wider container) so the
|
|
||||||
// BorderColor-targeting HighContrastBorder marker
|
|
||||||
// doesn't apply — HighContrastBackground is the
|
|
||||||
// parallel primitive for this case.
|
|
||||||
HighContrastBackground::with_default(BORDER_SUBTLE),
|
|
||||||
))
|
|
||||||
.with_children(|track| {
|
|
||||||
track.spawn((
|
|
||||||
ReplayOverlayScrubFill,
|
|
||||||
Node {
|
|
||||||
width: Val::Percent(initial_scrub_pct),
|
|
||||||
height: Val::Percent(100.0),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(ACCENT_PRIMARY),
|
|
||||||
));
|
|
||||||
// WIN MOVE marker — small green tick anchored at
|
|
||||||
// `win_move_index / total`. Spawned only when the
|
|
||||||
// active replay carries the field; older replays
|
|
||||||
// pre-dating `win_move_index` simply don't get a
|
|
||||||
// marker. Centered vertically on the 1px track via
|
|
||||||
// a 3px-tall node offset 1px above the track top so
|
|
||||||
// 1px sits above and 1px below the track line.
|
|
||||||
if let Some(pct) = win_pct {
|
|
||||||
track.spawn((
|
|
||||||
ReplayOverlayWinMoveMarker,
|
|
||||||
Node {
|
|
||||||
position_type: PositionType::Absolute,
|
|
||||||
left: Val::Percent(pct),
|
|
||||||
top: Val::Px(-1.0),
|
|
||||||
width: Val::Px(2.0),
|
|
||||||
height: Val::Px(3.0),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(STATE_SUCCESS),
|
|
||||||
// HC bump: lime → brighter lime so the win
|
|
||||||
// marker reads clearly above the bumped
|
|
||||||
// notch ticks (BORDER_SUBTLE_HC gray) under
|
|
||||||
// high-contrast mode.
|
|
||||||
HighContrastBackground::with_hc(STATE_SUCCESS, STATE_SUCCESS_HC),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
// Fixed quarter-mark notches: five 1px vertical
|
|
||||||
// ticks at 0 / 25 / 50 / 75 / 100 % that give the
|
|
||||||
// player visual anchor points without needing to
|
|
||||||
// mentally bisect the bar. Painted in
|
|
||||||
// BORDER_SUBTLE — same colour as the unfilled
|
|
||||||
// track — so visibility comes from extending past
|
|
||||||
// the 1px track height (5px tall, anchored 2px
|
|
||||||
// above the track top) rather than colour
|
|
||||||
// contrast. Spawned *after* the WIN MOVE marker
|
|
||||||
// so a notch and the marker landing on the same
|
|
||||||
// percentage paint the marker on top.
|
|
||||||
for pct in scrub_notch_positions() {
|
|
||||||
track.spawn((
|
|
||||||
ReplayOverlayScrubNotch,
|
|
||||||
Node {
|
|
||||||
position_type: PositionType::Absolute,
|
|
||||||
left: Val::Percent(pct),
|
|
||||||
top: Val::Px(-2.0),
|
|
||||||
width: Val::Px(1.0),
|
|
||||||
height: Val::Px(5.0),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(BORDER_SUBTLE),
|
|
||||||
// Same HC-paint reasoning as the track
|
|
||||||
// above: 5 px tall × 1 px wide tick mark
|
|
||||||
// paints via BackgroundColor, so
|
|
||||||
// HighContrastBackground (not -Border) is
|
|
||||||
// the right marker.
|
|
||||||
HighContrastBackground::with_default(BORDER_SUBTLE),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Third banner row: percentage labels (`0%` / `25%` /
|
|
||||||
// `50%` / `75%` / `100%`) under each scrub-bar notch.
|
|
||||||
// Sibling of (not child of) the 1px track because labels
|
|
||||||
// need their own vertical real estate (TYPE_CAPTION text
|
|
||||||
// doesn't fit inside a 1px container). Position math:
|
|
||||||
// track Node has `Val::Percent(p)` referencing the
|
|
||||||
// banner's full width; this label row also has the
|
|
||||||
// banner's full width, so labels at the same
|
|
||||||
// percentages line up vertically with their notches.
|
|
||||||
let labels = scrub_notch_labels();
|
|
||||||
let positions = scrub_notch_positions();
|
|
||||||
banner
|
|
||||||
.spawn(Node {
|
|
||||||
width: Val::Percent(100.0),
|
|
||||||
height: Val::Px(SCRUB_LABEL_ROW_HEIGHT),
|
|
||||||
position_type: PositionType::Relative,
|
|
||||||
..default()
|
|
||||||
})
|
|
||||||
.with_children(|row| {
|
|
||||||
for (i, (label, pct)) in labels.iter().zip(positions.iter()).enumerate() {
|
|
||||||
// Endpoints flush to the row's edges; middle
|
|
||||||
// three labels use the `translateX(-50%)`
|
|
||||||
// pattern for Bevy 0.18 UI: a fixed-width
|
|
||||||
// container is placed at `left: Percent(pct)`
|
|
||||||
// then shifted left by half its own width via
|
|
||||||
// `margin.left: Px(-SCRUB_LABEL_CENTER_WIDTH/2)`.
|
|
||||||
// `Justify::Center` renders the text centred
|
|
||||||
// within the container so the text's visual
|
|
||||||
// centre coincides with the notch line.
|
|
||||||
let (node, justify) = if i == 0 {
|
|
||||||
(
|
|
||||||
Node {
|
|
||||||
position_type: PositionType::Absolute,
|
|
||||||
top: Val::Px(2.0),
|
|
||||||
left: Val::Px(0.0),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
Justify::Left,
|
|
||||||
)
|
|
||||||
} else if i == labels.len() - 1 {
|
|
||||||
(
|
|
||||||
Node {
|
|
||||||
position_type: PositionType::Absolute,
|
|
||||||
top: Val::Px(2.0),
|
|
||||||
right: Val::Px(0.0),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
Justify::Right,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
(
|
|
||||||
Node {
|
|
||||||
position_type: PositionType::Absolute,
|
|
||||||
top: Val::Px(2.0),
|
|
||||||
left: Val::Percent(*pct),
|
|
||||||
width: Val::Px(SCRUB_LABEL_CENTER_WIDTH),
|
|
||||||
margin: UiRect {
|
|
||||||
left: Val::Px(-SCRUB_LABEL_CENTER_WIDTH / 2.0),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
Justify::Center,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
row.spawn((
|
|
||||||
ReplayOverlayScrubNotchLabel,
|
|
||||||
node,
|
|
||||||
Text::new(*label),
|
|
||||||
TextLayout::new_with_justify(justify),
|
|
||||||
TextFont {
|
|
||||||
font: font_handle_for_labels.clone(),
|
|
||||||
font_size: TYPE_CAPTION,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
// TEXT_SECONDARY keeps the subdued visual
|
|
||||||
// hierarchy (caption, not headline) while
|
|
||||||
// staying readable against BG_ELEVATED_HI.
|
|
||||||
TextColor(TEXT_SECONDARY),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fourth banner row: keybind-hint footer. Vim-style
|
|
||||||
// mode line on the left (`▌ NORMAL │ replay`), keybind
|
|
||||||
// hint on the right (`[SPACE] pause/resume`), 1px top
|
|
||||||
// border in BORDER_SUBTLE separating it from the
|
|
||||||
// labels row above. Surfaces the existing Space
|
|
||||||
// accelerator visually so CLAUDE.md §3.3's UI-first
|
|
||||||
// contract holds for keyboard accelerators too.
|
|
||||||
banner
|
|
||||||
.spawn((
|
|
||||||
ReplayOverlayKeybindFooter,
|
|
||||||
Node {
|
|
||||||
width: Val::Percent(100.0),
|
|
||||||
height: Val::Px(KEYBIND_FOOTER_HEIGHT),
|
|
||||||
flex_direction: FlexDirection::Row,
|
|
||||||
justify_content: JustifyContent::SpaceBetween,
|
|
||||||
align_items: AlignItems::Center,
|
|
||||||
padding: UiRect::horizontal(VAL_SPACE_4),
|
|
||||||
border: UiRect::top(Val::Px(1.0)),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BorderColor::all(BORDER_SUBTLE),
|
|
||||||
// Marker for `apply_high_contrast_borders`: bumps
|
|
||||||
// the 1 px top border from BORDER_SUBTLE (#505050)
|
|
||||||
// to BORDER_SUBTLE_HC (#a0a0a0) when
|
|
||||||
// `Settings::high_contrast_mode` is on. Without
|
|
||||||
// this the footer reads as floating loose under
|
|
||||||
// HC because the border that visually anchors it
|
|
||||||
// to the labels row above is near-invisible.
|
|
||||||
HighContrastBorder::with_default(BORDER_SUBTLE),
|
|
||||||
))
|
|
||||||
.with_children(|footer| {
|
|
||||||
footer.spawn((
|
|
||||||
Text::new(keybind_footer_mode_text()),
|
|
||||||
TextFont {
|
|
||||||
font: font_handle_for_labels.clone(),
|
|
||||||
font_size: TYPE_CAPTION,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(TEXT_SECONDARY),
|
|
||||||
));
|
|
||||||
if SHOW_KEYBOARD_ACCELERATORS {
|
|
||||||
footer.spawn((
|
|
||||||
Text::new(keybind_footer_hint_text()),
|
|
||||||
TextFont {
|
|
||||||
font: font_handle_for_labels.clone(),
|
|
||||||
font_size: TYPE_CAPTION,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(TEXT_SECONDARY),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Floating progress chip — a 2D world-space `Text2d` rendered
|
|
||||||
// above the destination pile of the most-recently-applied move.
|
|
||||||
// Sibling of (not child of) the banner overlay because it lives
|
|
||||||
// in world-space coordinates, not the UI tree. Spawned hidden;
|
|
||||||
// `update_floating_progress_chip` shows + positions it on the
|
|
||||||
// first frame the cursor advances past 0. Lifecycle matches
|
|
||||||
// the banner overlay — `react_to_state_change` despawns both
|
|
||||||
// when the replay state transitions back to `Inactive`.
|
|
||||||
commands.spawn((
|
|
||||||
ReplayFloatingProgressChip,
|
|
||||||
DespawnWithReplay,
|
|
||||||
Text2d::new(format_progress(state)),
|
|
||||||
TextFont {
|
|
||||||
font: font_handle_for_floating,
|
|
||||||
font_size: TYPE_BODY,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(TEXT_PRIMARY),
|
|
||||||
// High Z keeps the chip above every card stack
|
|
||||||
// (Z_DROP_OVERLAY = 50, Z_STOCK_BADGE = 30, regular cards
|
|
||||||
// stack to the low double digits at most).
|
|
||||||
Transform::from_xyz(0.0, 0.0, 100.0),
|
|
||||||
Visibility::Hidden,
|
|
||||||
));
|
|
||||||
|
|
||||||
// Move-log panel — a separate root UI entity anchored to the
|
|
||||||
// viewport's bottom edge. Carries a `▌ MOVE LOG · N/M` header
|
|
||||||
// plus a row showing the most-recently-applied move.
|
|
||||||
// Sibling-of-banner pattern (not a banner child) because the
|
|
||||||
// panel lives at a different screen anchor and has its own
|
|
||||||
// spawn/despawn lifecycle synced via `react_to_state_change`.
|
|
||||||
let banner_bg = Color::srgba(
|
|
||||||
BG_ELEVATED_HI.to_srgba().red,
|
|
||||||
BG_ELEVATED_HI.to_srgba().green,
|
|
||||||
BG_ELEVATED_HI.to_srgba().blue,
|
|
||||||
BANNER_ALPHA,
|
|
||||||
);
|
|
||||||
commands
|
|
||||||
.spawn((
|
|
||||||
ReplayOverlayMoveLogPanel,
|
|
||||||
DespawnWithReplay,
|
|
||||||
Node {
|
|
||||||
position_type: PositionType::Absolute,
|
|
||||||
left: Val::Px(0.0),
|
|
||||||
bottom: Val::Px(0.0),
|
|
||||||
width: Val::Percent(100.0),
|
|
||||||
height: Val::Px(MOVE_LOG_PANEL_HEIGHT),
|
|
||||||
flex_direction: FlexDirection::Column,
|
|
||||||
align_items: AlignItems::FlexStart,
|
|
||||||
justify_content: JustifyContent::Center,
|
|
||||||
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
|
|
||||||
row_gap: VAL_SPACE_1,
|
|
||||||
border: UiRect::top(Val::Px(1.0)),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(banner_bg),
|
|
||||||
BorderColor::all(BORDER_SUBTLE),
|
|
||||||
// Same z-stack rationale as the banner — above gameplay,
|
|
||||||
// below modals.
|
|
||||||
ZIndex(Z_REPLAY_OVERLAY),
|
|
||||||
GlobalZIndex(Z_REPLAY_OVERLAY),
|
|
||||||
// HC marker so the top border bumps under HC mode.
|
|
||||||
// Without it the panel reads as floating loose because
|
|
||||||
// the border that anchors it to the gameplay area above
|
|
||||||
// is near-invisible at #505050.
|
|
||||||
HighContrastBorder::with_default(BORDER_SUBTLE),
|
|
||||||
))
|
|
||||||
.with_children(|panel| {
|
|
||||||
// Header row: `▌ MOVE LOG · N/M` in ACCENT_PRIMARY for
|
|
||||||
// the cursor-block prefix consistency with the banner
|
|
||||||
// headline.
|
|
||||||
panel.spawn((
|
|
||||||
ReplayOverlayMoveLogHeader,
|
|
||||||
Text::new(format_move_log_header(state)),
|
|
||||||
TextFont {
|
|
||||||
font: font_handle_for_move_log.clone(),
|
|
||||||
font_size: TYPE_CAPTION,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(ACCENT_PRIMARY),
|
|
||||||
));
|
|
||||||
// Prev rows — render above the active row in display
|
|
||||||
// order (oldest first), so the active row sits at the
|
|
||||||
// bottom of the visible window. Spawn from
|
|
||||||
// MOVE_LOG_PREV_ROWS down to 1 (offset 2, then 1) so
|
|
||||||
// the highest-offset (oldest) row is topmost in the
|
|
||||||
// panel's flex column. Each carries
|
|
||||||
// ReplayOverlayMoveLogPrevRow { offset } — the
|
|
||||||
// per-frame system reads `offset` and recomputes the
|
|
||||||
// text on cursor advance. Painted in TEXT_SECONDARY
|
|
||||||
// so the active row stands out from context rows.
|
|
||||||
for offset in (1..=MOVE_LOG_PREV_ROWS as u8).rev() {
|
|
||||||
panel.spawn((
|
|
||||||
ReplayOverlayMoveLogPrevRow { offset },
|
|
||||||
Text::new(format_kth_recent_row(state, offset as usize + 1)),
|
|
||||||
TextFont {
|
|
||||||
font: font_handle_for_move_log.clone(),
|
|
||||||
font_size: TYPE_BODY,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(TEXT_SECONDARY),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
// Active move row. Wrapped in a Node with an
|
|
||||||
// ACCENT_PRIMARY background so the row reads as
|
|
||||||
// "current focus" — the player can scan vertically
|
|
||||||
// and the highlighted row is the move that just
|
|
||||||
// applied. Empty text at spawn time when cursor=0;
|
|
||||||
// the per-frame update system populates it as the
|
|
||||||
// cursor advances. Text colour is TEXT_PRIMARY_HC
|
|
||||||
// (near-white) for contrast against the brick-red
|
|
||||||
// background — same trick as the modal-button
|
|
||||||
// primary-variant paint.
|
|
||||||
panel
|
|
||||||
.spawn((
|
|
||||||
Node {
|
|
||||||
width: Val::Percent(100.0),
|
|
||||||
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(ACCENT_PRIMARY),
|
|
||||||
))
|
|
||||||
.with_children(|active| {
|
|
||||||
active.spawn((
|
|
||||||
ReplayOverlayMoveLogActiveRow,
|
|
||||||
Text::new(format_active_move_row(state)),
|
|
||||||
TextFont {
|
|
||||||
font: font_handle_for_move_log.clone(),
|
|
||||||
font_size: TYPE_BODY,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(TEXT_PRIMARY_HC),
|
|
||||||
));
|
|
||||||
});
|
|
||||||
// Next rows — render below the active row in display
|
|
||||||
// order (offset 1 directly below active, then offset
|
|
||||||
// 2). Same TEXT_SECONDARY de-emphasis as prev rows so
|
|
||||||
// the active row stays the focal point. Empty text
|
|
||||||
// late in the replay (when cursor + offset exceeds
|
|
||||||
// moves.len()) — the panel under-fills gracefully.
|
|
||||||
for offset in 1..=MOVE_LOG_NEXT_ROWS as u8 {
|
|
||||||
panel.spawn((
|
|
||||||
ReplayOverlayMoveLogNextRow { offset },
|
|
||||||
Text::new(format_kth_next_row(state, offset as usize)),
|
|
||||||
TextFont {
|
|
||||||
font: font_handle_for_move_log.clone(),
|
|
||||||
font_size: TYPE_BODY,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(TEXT_SECONDARY),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mini-tableau preview panel — right-edge anchor, just below the banner.
|
|
||||||
// Compact two-row readout: foundation tops then stock/waste head.
|
|
||||||
// Sibling-of-banner pattern (separate root entity, own spawn/despawn).
|
|
||||||
let banner_bg = Color::srgba(
|
|
||||||
BG_ELEVATED_HI.to_srgba().red,
|
|
||||||
BG_ELEVATED_HI.to_srgba().green,
|
|
||||||
BG_ELEVATED_HI.to_srgba().blue,
|
|
||||||
BANNER_ALPHA,
|
|
||||||
);
|
|
||||||
commands
|
|
||||||
.spawn((
|
|
||||||
ReplayMiniTableauPanel,
|
|
||||||
DespawnWithReplay,
|
|
||||||
Node {
|
|
||||||
position_type: PositionType::Absolute,
|
|
||||||
right: Val::Px(0.0),
|
|
||||||
top: Val::Px(MINI_TABLEAU_TOP_OFFSET),
|
|
||||||
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
|
|
||||||
flex_direction: FlexDirection::Column,
|
|
||||||
align_items: AlignItems::FlexStart,
|
|
||||||
row_gap: VAL_SPACE_1,
|
|
||||||
border: UiRect::left(Val::Px(1.0)),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(banner_bg),
|
|
||||||
BorderColor::all(BORDER_SUBTLE),
|
|
||||||
ZIndex(Z_REPLAY_OVERLAY),
|
|
||||||
GlobalZIndex(Z_REPLAY_OVERLAY),
|
|
||||||
HighContrastBorder::with_default(BORDER_SUBTLE),
|
|
||||||
))
|
|
||||||
.with_children(|panel| {
|
|
||||||
panel.spawn((
|
|
||||||
Text::new("\u{258C} BOARD"),
|
|
||||||
TextFont {
|
|
||||||
font: font_handle_for_mini_tableau.clone(),
|
|
||||||
font_size: TYPE_CAPTION,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(ACCENT_PRIMARY),
|
|
||||||
));
|
|
||||||
panel.spawn((
|
|
||||||
ReplayMiniTableauFoundations,
|
|
||||||
Text::new("F: -- -- -- --"),
|
|
||||||
TextFont {
|
|
||||||
font: font_handle_for_mini_tableau.clone(),
|
|
||||||
font_size: TYPE_CAPTION,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(TEXT_PRIMARY),
|
|
||||||
));
|
|
||||||
panel.spawn((
|
|
||||||
ReplayMiniTableauStockWaste,
|
|
||||||
Text::new("STK:-- WST:--"),
|
|
||||||
TextFont {
|
|
||||||
font: font_handle_for_mini_tableau,
|
|
||||||
font_size: TYPE_CAPTION,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(TEXT_SECONDARY),
|
|
||||||
));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure helper — returns the scrub-fill width as a percentage of the
|
|
||||||
/// track for the given playback state. `Completed` reads as 100 %;
|
|
||||||
/// `Inactive` and `Playing` with no progress read as 0 %.
|
|
||||||
pub(crate) fn scrub_pct(state: &ReplayPlaybackState) -> f32 {
|
|
||||||
if state.is_completed() {
|
|
||||||
return 100.0;
|
|
||||||
}
|
|
||||||
match state.progress() {
|
|
||||||
Some((_, 0)) | None => 0.0,
|
|
||||||
Some((cursor, total)) => {
|
|
||||||
let frac = (cursor as f32 / total as f32).clamp(0.0, 1.0);
|
|
||||||
frac * 100.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure helper — returns the fixed scrub-bar notch positions as
|
|
||||||
/// percentages along the track. Five evenly-spaced notches at the
|
|
||||||
/// quarter-marks: `[0, 25, 50, 75, 100]`. Function (rather than
|
|
||||||
/// const) so the unit-test surface is obvious and a future
|
|
||||||
/// regression — e.g. someone simplifying to three notches — fails
|
|
||||||
/// at the helper test rather than at visual review.
|
|
||||||
pub(crate) fn scrub_notch_positions() -> [f32; 5] {
|
|
||||||
[0.0, 25.0, 50.0, 75.0, 100.0]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure helper — returns the percentage-label text for each notch,
|
|
||||||
/// in left-to-right order. Paired with [`scrub_notch_positions`] so
|
|
||||||
/// `labels[i]` belongs at `positions[i]`. Lifted to a function for
|
|
||||||
/// the same reason as the positions helper: a clean unit-test
|
|
||||||
/// surface that fails at a regression (e.g. someone simplifying
|
|
||||||
/// `100%` → `MAX`) rather than at visual review.
|
|
||||||
pub(crate) fn scrub_notch_labels() -> [&'static str; 5] {
|
|
||||||
["0%", "25%", "50%", "75%", "100%"]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure helper — returns the vim-style mode indicator text shown on
|
|
||||||
/// the left side of the keybind-hint footer row. `▌ NORMAL │ replay`
|
|
||||||
/// matches the `▌replay.tsx` motif from the splash boot-screen and
|
|
||||||
/// the screen-takeover mockup. The cursor block (`▌`) matches the
|
|
||||||
/// banner-label prefix; "NORMAL" is the vim mode (mockup parity);
|
|
||||||
/// "replay" identifies the surface.
|
|
||||||
pub(crate) fn keybind_footer_mode_text() -> &'static str {
|
|
||||||
"\u{258C} NORMAL \u{2502} replay" // ▌ NORMAL │ replay
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure helper — returns the keybind-hint text shown on the right
|
|
||||||
/// side of the keybind-hint footer row. Lists only the keys that
|
|
||||||
/// are *actually wired* today: the Space accelerator for
|
|
||||||
/// pause/resume, the ESC accelerator for stop, and the ← / →
|
|
||||||
/// accelerators for paused single-move stepping. The footer never
|
|
||||||
/// lists unimplemented keybinds (would lie to users).
|
|
||||||
pub(crate) fn keybind_footer_hint_text() -> &'static str {
|
|
||||||
if SHOW_KEYBOARD_ACCELERATORS {
|
|
||||||
"[SPACE] pause/resume \u{00B7} [ESC] stop \u{00B7} [\u{2190}\u{2192}] step" // · separator
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure helper — returns the WIN MOVE marker's left-edge position as
|
|
||||||
/// a percentage of the scrub track, or `None` when no marker should
|
|
||||||
/// be drawn.
|
|
||||||
///
|
|
||||||
/// `None` is returned in any of these cases:
|
|
||||||
/// - The state isn't `Playing` (no replay attached).
|
|
||||||
/// - The replay's `win_move_index` is `None` (older replay loaded
|
|
||||||
/// from disk pre-dating the field).
|
|
||||||
/// - The replay's move list is empty (shouldn't happen for real wins,
|
|
||||||
/// but guards the divide-by-zero).
|
|
||||||
///
|
|
||||||
/// The percentage clamps to `[0, 100]` so a malformed
|
|
||||||
/// `win_move_index >= total` (defensive — shouldn't happen) doesn't
|
|
||||||
/// position the marker outside the track.
|
|
||||||
pub(crate) fn win_move_marker_pct(state: &ReplayPlaybackState) -> Option<f32> {
|
|
||||||
let ReplayPlaybackState::Playing { replay, .. } = state else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
let idx = replay.win_move_index?;
|
|
||||||
let total = replay.moves.len();
|
|
||||||
if total == 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let frac = (idx as f32 / total as f32).clamp(0.0, 1.0);
|
|
||||||
Some(frac * 100.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Playback-control button handlers
|
// Playback-control button handlers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -228,11 +228,6 @@ impl ReplayPlayer {
|
|||||||
pub fn step_idx(&self) -> usize {
|
pub fn step_idx(&self) -> usize {
|
||||||
self.step_idx
|
self.step_idx
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `true` once every move has been applied.
|
|
||||||
pub fn is_finished(&self) -> bool {
|
|
||||||
self.step_idx >= self.moves.len()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user