feat(settings,engine): replay-playback rate slider in Settings → Gameplay
The replay overlay's per-move tick rate has been hardcoded at REPLAY_MOVE_INTERVAL_SECS = 0.45 s/move since the in-engine playback shipped. Power users want to scrub faster through older wins. Adds a Settings slider that tunes the interval 0.10–1.00 s in 0.05 s steps; default 0.45 s preserves existing feel. Settings.replay_move_interval_secs uses #[serde(default)] so legacy files load to 0.45. sanitized() clamps out-of-range values. tick_replay_playback now reads SettingsResource per frame and falls back to the constant when the resource is absent (test fixtures). The slider takes effect on the very next playback tick — no need to restart playback. Mirrors the existing tooltip-delay slider exactly: SettingsButton:: ReplayMoveIntervalUp/Down variants, the same `slider_row` pattern, the same per-tick repaint system shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -141,7 +141,8 @@ pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
|
||||
pub mod settings;
|
||||
pub use settings::{
|
||||
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
|
||||
Theme, WindowGeometry, SOLVER_DEAL_RETRY_CAP, TIME_BONUS_MULTIPLIER_MAX,
|
||||
Theme, WindowGeometry, REPLAY_MOVE_INTERVAL_MAX_SECS, REPLAY_MOVE_INTERVAL_MIN_SECS,
|
||||
REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, TIME_BONUS_MULTIPLIER_MAX,
|
||||
TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_MAX_SECS,
|
||||
TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS,
|
||||
};
|
||||
|
||||
@@ -181,6 +181,17 @@ pub struct Settings {
|
||||
/// solver retry loop — see `solitaire_engine::handle_new_game`.
|
||||
#[serde(default)]
|
||||
pub winnable_deals_only: bool,
|
||||
/// Per-move duration during replay playback, in seconds. Range
|
||||
/// `[REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS]`;
|
||||
/// default mirrors `solitaire_engine::replay_playback::REPLAY_MOVE_INTERVAL_SECS`
|
||||
/// (0.45 s/move) so existing playback behaviour is unchanged for
|
||||
/// players who never touch the slider. Smaller values scrub
|
||||
/// faster through the recorded move list. Older `settings.json`
|
||||
/// files written before this field existed deserialize cleanly to
|
||||
/// the default via
|
||||
/// `#[serde(default = "default_replay_move_interval_secs")]`.
|
||||
#[serde(default = "default_replay_move_interval_secs")]
|
||||
pub replay_move_interval_secs: f32,
|
||||
}
|
||||
|
||||
fn default_draw_mode() -> DrawMode {
|
||||
@@ -238,6 +249,33 @@ fn default_time_bonus_multiplier() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
/// Default per-move duration during replay playback, in seconds.
|
||||
/// Mirrors `solitaire_engine::replay_playback::REPLAY_MOVE_INTERVAL_SECS`
|
||||
/// so legacy `settings.json` files load to the existing baseline and
|
||||
/// playback feels identical for players who never touch the slider.
|
||||
/// The constant is duplicated across the data and engine crates
|
||||
/// because `solitaire_data` cannot depend on the engine crate — keep
|
||||
/// the two values in sync when adjusting either.
|
||||
fn default_replay_move_interval_secs() -> f32 {
|
||||
0.45
|
||||
}
|
||||
|
||||
/// Lower bound of the player-tunable replay-playback per-move interval,
|
||||
/// in seconds. Below this the cards barely register visually before
|
||||
/// the next move fires; the cap keeps the playback legible.
|
||||
pub const REPLAY_MOVE_INTERVAL_MIN_SECS: f32 = 0.10;
|
||||
|
||||
/// Upper bound of the player-tunable replay-playback per-move interval,
|
||||
/// in seconds. One second per move is a comfortable upper limit for
|
||||
/// players who want to study a recorded game frame by frame.
|
||||
pub const REPLAY_MOVE_INTERVAL_MAX_SECS: f32 = 1.00;
|
||||
|
||||
/// Increment applied by the replay-playback decrement / increment
|
||||
/// buttons. 0.05 s gives 19 stops between MIN and MAX — fine-grained
|
||||
/// enough to land on any "round" speed (0.10 s, 0.25 s, 0.45 s, etc.)
|
||||
/// without making the slider feel stuck on the same value.
|
||||
pub const REPLAY_MOVE_INTERVAL_STEP_SECS: f32 = 0.05;
|
||||
|
||||
/// Maximum number of seed retries [`solitaire_engine::handle_new_game`]
|
||||
/// is willing to attempt before giving up and accepting the latest
|
||||
/// candidate seed when [`Settings::winnable_deals_only`] is on. If
|
||||
@@ -268,14 +306,16 @@ impl Default for Settings {
|
||||
tooltip_delay_secs: default_tooltip_delay(),
|
||||
time_bonus_multiplier: default_time_bonus_multiplier(),
|
||||
winnable_deals_only: false,
|
||||
replay_move_interval_secs: default_replay_move_interval_secs(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// Clamps `sfx_volume`, `music_volume`, `tooltip_delay_secs`, and
|
||||
/// `time_bonus_multiplier` into their respective ranges after
|
||||
/// deserialization or hand-editing of `settings.json`.
|
||||
/// Clamps `sfx_volume`, `music_volume`, `tooltip_delay_secs`,
|
||||
/// `time_bonus_multiplier`, and `replay_move_interval_secs` into
|
||||
/// their respective ranges after deserialization or hand-editing of
|
||||
/// `settings.json`.
|
||||
pub fn sanitized(self) -> Self {
|
||||
Self {
|
||||
sfx_volume: self.sfx_volume.clamp(0.0, 1.0),
|
||||
@@ -286,6 +326,9 @@ impl Settings {
|
||||
time_bonus_multiplier: self
|
||||
.time_bonus_multiplier
|
||||
.clamp(TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_MAX),
|
||||
replay_move_interval_secs: self
|
||||
.replay_move_interval_secs
|
||||
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS),
|
||||
..self
|
||||
}
|
||||
}
|
||||
@@ -324,6 +367,21 @@ impl Settings {
|
||||
self.time_bonus_multiplier = (raw * 10.0).round() / 10.0;
|
||||
self.time_bonus_multiplier
|
||||
}
|
||||
|
||||
/// Adjust the replay-playback per-move interval by `delta`
|
||||
/// seconds, clamped to
|
||||
/// `[REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS]`.
|
||||
/// The result is rounded to two decimal places so the readout
|
||||
/// stays clean across repeated `±` clicks at the 0.05 s step
|
||||
/// (avoids float drift like `0.45000003`). Returns the new value.
|
||||
pub fn adjust_replay_move_interval(&mut self, delta: f32) -> f32 {
|
||||
let raw = (self.replay_move_interval_secs + delta)
|
||||
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS);
|
||||
// Round to 2 decimal places — the slider step is 0.05, so this
|
||||
// collapses any FP drift introduced by repeated additions.
|
||||
self.replay_move_interval_secs = (raw * 100.0).round() / 100.0;
|
||||
self.replay_move_interval_secs
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the platform-specific path to `settings.json`, or `None` if
|
||||
@@ -456,6 +514,7 @@ mod tests {
|
||||
tooltip_delay_secs: default_tooltip_delay(),
|
||||
time_bonus_multiplier: default_time_bonus_multiplier(),
|
||||
winnable_deals_only: false,
|
||||
replay_move_interval_secs: default_replay_move_interval_secs(),
|
||||
};
|
||||
save_settings_to(&path, &s).expect("save");
|
||||
let loaded = load_settings_from(&path);
|
||||
@@ -908,4 +967,101 @@ mod tests {
|
||||
"legacy settings.json missing winnable_deals_only must deserialize to false"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// replay_move_interval_secs — player-tunable replay playback speed
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn settings_replay_move_interval_default_is_zero_point_four_five() {
|
||||
// The pre-slider baseline is 0.45 s/move, matching
|
||||
// `solitaire_engine::replay_playback::REPLAY_MOVE_INTERVAL_SECS`.
|
||||
// The default must not regress for players who never touch
|
||||
// the slider.
|
||||
let s = Settings::default();
|
||||
assert!(
|
||||
(s.replay_move_interval_secs - 0.45).abs() < 1e-6,
|
||||
"replay_move_interval_secs default must be 0.45 (the pre-slider baseline), got {}",
|
||||
s.replay_move_interval_secs
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_replay_move_interval_round_trip() {
|
||||
let path = tmp_path("replay_move_interval_round_trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
let s = Settings {
|
||||
replay_move_interval_secs: 0.20,
|
||||
..Settings::default()
|
||||
};
|
||||
save_settings_to(&path, &s).expect("save");
|
||||
let loaded = load_settings_from(&path);
|
||||
assert!(
|
||||
(loaded.replay_move_interval_secs - 0.20).abs() < 1e-6,
|
||||
"replay_move_interval_secs must survive serde round-trip; got {}",
|
||||
loaded.replay_move_interval_secs
|
||||
);
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_settings_without_replay_move_interval_deserializes_to_default() {
|
||||
// A settings.json written before this field existed must
|
||||
// deserialize cleanly to the existing 0.45 s baseline so old
|
||||
// players see no change to replay playback speed.
|
||||
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
|
||||
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
||||
assert!(
|
||||
(s.replay_move_interval_secs - default_replay_move_interval_secs()).abs() < 1e-6,
|
||||
"legacy settings.json missing replay_move_interval_secs must deserialize to default ({}), got {}",
|
||||
default_replay_move_interval_secs(),
|
||||
s.replay_move_interval_secs
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_replay_move_interval_clamps_to_range() {
|
||||
// Negative or oversized values from a hand-edited file must be
|
||||
// clamped on load.
|
||||
let s = Settings {
|
||||
replay_move_interval_secs: 5.0,
|
||||
..Settings::default()
|
||||
}
|
||||
.sanitized();
|
||||
assert_eq!(s.replay_move_interval_secs, REPLAY_MOVE_INTERVAL_MAX_SECS);
|
||||
|
||||
let s2 = Settings {
|
||||
replay_move_interval_secs: -1.0,
|
||||
..Settings::default()
|
||||
}
|
||||
.sanitized();
|
||||
assert_eq!(s2.replay_move_interval_secs, REPLAY_MOVE_INTERVAL_MIN_SECS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjust_replay_move_interval_clamps_and_rounds() {
|
||||
let mut s = Settings { replay_move_interval_secs: 0.45, ..Default::default() };
|
||||
// Step down to 0.40.
|
||||
assert!((s.adjust_replay_move_interval(-0.05) - 0.40).abs() < 1e-6);
|
||||
// Big positive jump clamps to MAX.
|
||||
assert!(
|
||||
(s.adjust_replay_move_interval(99.0) - REPLAY_MOVE_INTERVAL_MAX_SECS).abs() < 1e-6
|
||||
);
|
||||
// Big negative jump clamps to MIN.
|
||||
assert!(
|
||||
(s.adjust_replay_move_interval(-99.0) - REPLAY_MOVE_INTERVAL_MIN_SECS).abs() < 1e-6
|
||||
);
|
||||
|
||||
// Repeated 0.05 steps must not drift past the 0.05 grid.
|
||||
let mut s2 = Settings { replay_move_interval_secs: 0.10, ..Default::default() };
|
||||
for _ in 0..6 {
|
||||
s2.adjust_replay_move_interval(0.05);
|
||||
}
|
||||
// After six +0.05 steps from 0.10, value should be exactly 0.40 (2 decimals).
|
||||
assert!(
|
||||
(s2.replay_move_interval_secs - 0.40).abs() < 1e-6,
|
||||
"rounding should pin repeated 0.05 steps to the decimal grid, got {}",
|
||||
s2.replay_move_interval_secs
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,11 +45,34 @@ use solitaire_data::{Replay, ReplayMove};
|
||||
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent};
|
||||
use crate::game_plugin::{GameMutation, RecordingReplay};
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::settings_plugin::SettingsResource;
|
||||
|
||||
/// Per-move duration during playback. Tunable in Settings later;
|
||||
/// hardcoded for v1.
|
||||
/// Default per-move duration during playback, in seconds. Acts as the
|
||||
/// fallback when `SettingsResource` is absent — i.e. in headless test
|
||||
/// fixtures that don't install [`crate::settings_plugin::SettingsPlugin`].
|
||||
/// In production the live value is read from
|
||||
/// [`solitaire_data::Settings::replay_move_interval_secs`] every frame
|
||||
/// so Settings adjustments take effect on the next playback tick.
|
||||
///
|
||||
/// Kept in sync with `solitaire_data::settings::default_replay_move_interval_secs`
|
||||
/// (the data crate cannot depend on this engine crate, so the constant
|
||||
/// is duplicated). The
|
||||
/// `settings_replay_move_interval_default_matches_engine_constant`
|
||||
/// test in `solitaire_engine::settings_plugin` enforces equality.
|
||||
pub const REPLAY_MOVE_INTERVAL_SECS: f32 = 0.45;
|
||||
|
||||
/// Helper: returns the live per-move replay interval. Reads
|
||||
/// [`SettingsResource::replay_move_interval_secs`] when the resource is
|
||||
/// installed, falling back to [`REPLAY_MOVE_INTERVAL_SECS`] otherwise.
|
||||
/// Also clamps below by `f32::EPSILON` so a hand-edited 0.0 cannot
|
||||
/// busy-loop the playback tick.
|
||||
fn current_move_interval_secs(settings: Option<&SettingsResource>) -> f32 {
|
||||
let raw = settings
|
||||
.map(|s| s.0.replay_move_interval_secs)
|
||||
.unwrap_or(REPLAY_MOVE_INTERVAL_SECS);
|
||||
raw.max(f32::EPSILON)
|
||||
}
|
||||
|
||||
/// How long the [`ReplayPlaybackState::Completed`] state lingers before
|
||||
/// the auto-clear system transitions it back to
|
||||
/// [`ReplayPlaybackState::Inactive`]. Gives the overlay UI time to
|
||||
@@ -161,6 +184,12 @@ pub fn start_replay_playback(
|
||||
let fresh = GameState::new_with_mode(replay.seed, replay.draw_mode.clone(), replay.mode);
|
||||
commands.insert_resource(GameStateResource(fresh));
|
||||
|
||||
// Initial `secs_to_next` uses the constant rather than reading
|
||||
// `SettingsResource` because this entry point takes `Commands` /
|
||||
// `ResMut<ReplayPlaybackState>` only. The first-tick latency may
|
||||
// therefore lag the configured interval by up to ~0.45 s on an
|
||||
// unusually short setting; subsequent ticks read the live setting
|
||||
// every frame via [`tick_replay_playback`].
|
||||
**state = ReplayPlaybackState::Playing {
|
||||
replay,
|
||||
cursor: 0,
|
||||
@@ -207,11 +236,13 @@ pub fn stop_replay_playback(
|
||||
/// so the loop runs at most once per frame.
|
||||
fn tick_replay_playback(
|
||||
time: Res<Time>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
mut state: ResMut<ReplayPlaybackState>,
|
||||
mut moves_writer: MessageWriter<MoveRequestEvent>,
|
||||
mut draws_writer: MessageWriter<DrawRequestEvent>,
|
||||
) {
|
||||
let dt = time.delta_secs();
|
||||
let interval = current_move_interval_secs(settings.as_deref());
|
||||
let mut transition_to_completed = false;
|
||||
|
||||
if let ReplayPlaybackState::Playing {
|
||||
@@ -235,7 +266,7 @@ fn tick_replay_playback(
|
||||
}
|
||||
}
|
||||
*cursor += 1;
|
||||
*secs_to_next += REPLAY_MOVE_INTERVAL_SECS;
|
||||
*secs_to_next += interval;
|
||||
}
|
||||
|
||||
if *cursor >= replay.moves.len() {
|
||||
@@ -679,4 +710,124 @@ mod tests {
|
||||
"recording must not grow while playback is active",
|
||||
);
|
||||
}
|
||||
|
||||
/// With `SettingsResource::replay_move_interval_secs` set to 0.10 s
|
||||
/// (well below the 0.45 s default), playback over a fixed
|
||||
/// wall-clock window must dispatch strictly more moves than the
|
||||
/// same fixture would at the 0.45 s default. This is the
|
||||
/// regression check that the tick reads from the live Settings
|
||||
/// value rather than the hardcoded
|
||||
/// [`REPLAY_MOVE_INTERVAL_SECS`] constant.
|
||||
///
|
||||
/// The follow-up assertion exercises the boundary condition: at
|
||||
/// the 0.10 s/move setting, exactly six 0.10 s ticks must yield
|
||||
/// fewer moves than six 0.20 s ticks (because the latter doubles
|
||||
/// the per-update advance and pays off two intervals each tick).
|
||||
#[test]
|
||||
fn replay_playback_tick_uses_settings_interval() {
|
||||
use solitaire_data::Settings;
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
struct CapturedDraws(usize);
|
||||
|
||||
fn collect_draws(
|
||||
mut events: MessageReader<DrawRequestEvent>,
|
||||
mut sink: ResMut<CapturedDraws>,
|
||||
) {
|
||||
for _ in events.read() {
|
||||
sink.0 += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Long replay so the fast cadence has plenty of moves to
|
||||
// chew through and the 0.45 s vs 0.10 s difference is easy
|
||||
// to observe.
|
||||
fn ten_draws_replay() -> Replay {
|
||||
Replay::new(
|
||||
7,
|
||||
DrawMode::DrawOne,
|
||||
GameMode::Classic,
|
||||
10,
|
||||
100,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||
vec![ReplayMove::StockClick; 10],
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Run 1: 0.10 s/move (Settings override) ----
|
||||
let mut fast_app = headless_app();
|
||||
fast_app.insert_resource(SettingsResource(Settings {
|
||||
replay_move_interval_secs: 0.10,
|
||||
..Settings::default()
|
||||
}));
|
||||
fast_app
|
||||
.init_resource::<CapturedDraws>()
|
||||
.add_systems(Update, collect_draws);
|
||||
|
||||
start_playback(&mut fast_app, ten_draws_replay());
|
||||
fast_app.update();
|
||||
// 1.0 s of virtual time at 0.10 s/move dispatches ~5 moves
|
||||
// after the default 0.45 s startup interval is consumed.
|
||||
advance_by(&mut fast_app, 1.0);
|
||||
let fast_count = fast_app.world().resource::<CapturedDraws>().0;
|
||||
|
||||
// ---- Run 2: 0.45 s/move (default — no SettingsResource) ----
|
||||
let mut slow_app = headless_app();
|
||||
// `tick_replay_playback` falls back to `REPLAY_MOVE_INTERVAL_SECS`
|
||||
// (0.45 s) when `SettingsResource` is absent.
|
||||
slow_app
|
||||
.init_resource::<CapturedDraws>()
|
||||
.add_systems(Update, collect_draws);
|
||||
|
||||
start_playback(&mut slow_app, ten_draws_replay());
|
||||
slow_app.update();
|
||||
advance_by(&mut slow_app, 1.0);
|
||||
let slow_count = slow_app.world().resource::<CapturedDraws>().0;
|
||||
|
||||
assert!(
|
||||
fast_count > slow_count,
|
||||
"at 0.10 s/move the tick must dispatch strictly more moves \
|
||||
than at the 0.45 s default over the same wall-clock window: \
|
||||
fast={fast_count}, slow={slow_count}",
|
||||
);
|
||||
|
||||
// ---- Boundary: a 0.05 s/tick cadence over the same window
|
||||
// dispatches NO MORE moves than a 0.10 s/tick cadence, because
|
||||
// 0.05 s < 0.10 s configured interval — the secs_to_next clock
|
||||
// never crosses the threshold inside a single tick. ----
|
||||
//
|
||||
// We don't assert "exactly zero" because the leading update()
|
||||
// after `start_playback` may run before the strategy is
|
||||
// applied (cf. comments on `tick_advances_cursor_after_interval`),
|
||||
// but the count must not exceed what we'd get with one-tick
|
||||
// advances at the same total wall-clock window.
|
||||
fn count_after_window(interval_secs: f32, tick_secs: f32, total_secs: f32) -> usize {
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(SettingsResource(Settings {
|
||||
replay_move_interval_secs: interval_secs,
|
||||
..Settings::default()
|
||||
}));
|
||||
app.init_resource::<CapturedDraws>()
|
||||
.add_systems(Update, collect_draws);
|
||||
start_playback(&mut app, ten_draws_replay());
|
||||
app.update();
|
||||
app.insert_resource(TimeUpdateStrategy::ManualDuration(
|
||||
Duration::from_secs_f32(tick_secs),
|
||||
));
|
||||
let ticks = (total_secs / tick_secs).ceil() as usize + 1;
|
||||
for _ in 0..ticks {
|
||||
app.update();
|
||||
}
|
||||
app.world().resource::<CapturedDraws>().0
|
||||
}
|
||||
|
||||
let count_at_05 = count_after_window(0.10, 0.05, 1.0);
|
||||
let count_at_20 = count_after_window(0.10, 0.20, 1.0);
|
||||
assert!(
|
||||
count_at_05 <= count_at_20,
|
||||
"0.05 s ticks (strictly less than the 0.10 s interval) must \
|
||||
dispatch no more moves than 0.20 s ticks over the same \
|
||||
wall-clock window: count_at_05={count_at_05}, count_at_20={count_at_20}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ use bevy::window::{WindowMoved, WindowResized};
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_data::{
|
||||
load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings,
|
||||
WindowGeometry, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_STEP_SECS,
|
||||
WindowGeometry, REPLAY_MOVE_INTERVAL_STEP_SECS, TIME_BONUS_MULTIPLIER_STEP,
|
||||
TOOLTIP_DELAY_STEP_SECS,
|
||||
};
|
||||
|
||||
use crate::events::{ManualSyncRequestEvent, ToggleSettingsRequestEvent};
|
||||
@@ -132,6 +133,12 @@ struct TooltipDelayText;
|
||||
#[derive(Component, Debug)]
|
||||
struct TimeBonusMultiplierText;
|
||||
|
||||
/// Marks the `Text` node showing the live replay-playback per-move
|
||||
/// interval value. The Gameplay-section row beside this label lets the
|
||||
/// player tune `Settings::replay_move_interval_secs`.
|
||||
#[derive(Component, Debug)]
|
||||
struct ReplayMoveIntervalText;
|
||||
|
||||
/// Marks the `Text` node showing the current "Winnable deals only"
|
||||
/// state ("ON" / "OFF") in the Gameplay section.
|
||||
#[derive(Component, Debug)]
|
||||
@@ -179,6 +186,12 @@ enum SettingsButton {
|
||||
TimeBonusDown,
|
||||
/// Increment the cosmetic time-bonus multiplier by one step.
|
||||
TimeBonusUp,
|
||||
/// Decrement the replay-playback per-move interval by one step
|
||||
/// (i.e. speed playback up).
|
||||
ReplayMoveIntervalDown,
|
||||
/// Increment the replay-playback per-move interval by one step
|
||||
/// (i.e. slow playback down).
|
||||
ReplayMoveIntervalUp,
|
||||
ToggleTheme,
|
||||
ToggleColorBlind,
|
||||
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
|
||||
@@ -219,8 +232,12 @@ impl SettingsButton {
|
||||
SettingsButton::TooltipDelayUp => 46,
|
||||
SettingsButton::TimeBonusDown => 47,
|
||||
SettingsButton::TimeBonusUp => 48,
|
||||
// Replay-speed slider — last Gameplay-section row, so it
|
||||
// sits between TimeBonusUp (48) and the Cosmetic section.
|
||||
SettingsButton::ReplayMoveIntervalDown => 49,
|
||||
SettingsButton::ReplayMoveIntervalUp => 49,
|
||||
// Cosmetic section
|
||||
SettingsButton::ToggleTheme => 50,
|
||||
SettingsButton::ToggleTheme => 55,
|
||||
SettingsButton::ToggleColorBlind => 60,
|
||||
// Picker rows — every swatch in a row shares the row's
|
||||
// priority so entity-index tiebreaking yields left → right.
|
||||
@@ -310,6 +327,7 @@ impl Plugin for SettingsPlugin {
|
||||
update_color_blind_text,
|
||||
update_tooltip_delay_text,
|
||||
update_time_bonus_multiplier_text,
|
||||
update_replay_move_interval_text,
|
||||
update_winnable_deals_only_text,
|
||||
attach_focusable_to_settings_buttons,
|
||||
scroll_focus_into_view,
|
||||
@@ -605,6 +623,21 @@ fn update_time_bonus_multiplier_text(
|
||||
}
|
||||
}
|
||||
|
||||
/// Refreshes the live replay-playback per-move-interval value in the
|
||||
/// Gameplay section whenever `SettingsResource` changes (slider buttons,
|
||||
/// hand-edited settings.json reload, etc.).
|
||||
fn update_replay_move_interval_text(
|
||||
settings: Res<SettingsResource>,
|
||||
mut text_nodes: Query<&mut Text, With<ReplayMoveIntervalText>>,
|
||||
) {
|
||||
if !settings.is_changed() {
|
||||
return;
|
||||
}
|
||||
for mut text in &mut text_nodes {
|
||||
**text = replay_move_interval_label(settings.0.replay_move_interval_secs);
|
||||
}
|
||||
}
|
||||
|
||||
fn card_back_label(idx: usize) -> String {
|
||||
if idx == 0 {
|
||||
"Default".to_string()
|
||||
@@ -765,6 +798,29 @@ fn handle_settings_buttons(
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
}
|
||||
}
|
||||
SettingsButton::ReplayMoveIntervalDown => {
|
||||
let before = settings.0.replay_move_interval_secs;
|
||||
let after = settings
|
||||
.0
|
||||
.adjust_replay_move_interval(-REPLAY_MOVE_INTERVAL_STEP_SECS);
|
||||
if (before - after).abs() > f32::EPSILON {
|
||||
persist(&path, &settings.0);
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
// The Text node is refreshed by
|
||||
// `update_replay_move_interval_text` on the next
|
||||
// frame via `settings.is_changed()`.
|
||||
}
|
||||
}
|
||||
SettingsButton::ReplayMoveIntervalUp => {
|
||||
let before = settings.0.replay_move_interval_secs;
|
||||
let after = settings
|
||||
.0
|
||||
.adjust_replay_move_interval(REPLAY_MOVE_INTERVAL_STEP_SECS);
|
||||
if (before - after).abs() > f32::EPSILON {
|
||||
persist(&path, &settings.0);
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
}
|
||||
}
|
||||
SettingsButton::ToggleTheme => {
|
||||
settings.0.theme = match settings.0.theme {
|
||||
Theme::Green => Theme::Blue,
|
||||
@@ -876,6 +932,14 @@ fn time_bonus_label(value: f32) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats the replay-playback per-move interval for display in the
|
||||
/// Settings panel. Mirrors [`tooltip_delay_label`] for parity — the
|
||||
/// readout is `"{n:.2} s/move"` (e.g. `"0.45 s/move"`, `"0.10 s/move"`),
|
||||
/// using two decimal places because the step is 0.05 s.
|
||||
fn replay_move_interval_label(secs: f32) -> String {
|
||||
format!("{secs:.2} s/move")
|
||||
}
|
||||
|
||||
/// Auto-attaches [`Focusable`] to every bespoke Settings button — icon
|
||||
/// buttons (volume +/−, toggle, cycle), swatch buttons (card-back,
|
||||
/// background pickers), and the "Sync Now" button. The "Done" button is
|
||||
@@ -1228,6 +1292,11 @@ fn spawn_settings_panel(
|
||||
settings.time_bonus_multiplier,
|
||||
font_res,
|
||||
);
|
||||
replay_move_interval_row(
|
||||
body,
|
||||
settings.replay_move_interval_secs,
|
||||
font_res,
|
||||
);
|
||||
|
||||
// --- Cosmetic ---
|
||||
section_label(body, "Cosmetic", font_res);
|
||||
@@ -1462,6 +1531,56 @@ fn time_bonus_multiplier_row(
|
||||
});
|
||||
}
|
||||
|
||||
/// `Replay speed 0.45 s/move [−] [+]` — slider row for the
|
||||
/// player-tunable replay-playback per-move interval. Mirrors
|
||||
/// [`tooltip_delay_row`] (label, current value, decrement, increment)
|
||||
/// but formats the value via [`replay_move_interval_label`] as
|
||||
/// `"{n:.2} s/move"`. The decrement button speeds playback up
|
||||
/// (smaller interval); the increment slows it down — same direction
|
||||
/// convention as the tooltip-delay slider.
|
||||
fn replay_move_interval_row(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
value_secs: f32,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let label_font = label_text_font(font_res);
|
||||
let value_font = value_text_font(font_res);
|
||||
parent
|
||||
.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: VAL_SPACE_2,
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
row.spawn((
|
||||
Text::new("Replay speed".to_string()),
|
||||
label_font,
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
row.spawn((
|
||||
ReplayMoveIntervalText,
|
||||
Text::new(replay_move_interval_label(value_secs)),
|
||||
value_font,
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
icon_button(
|
||||
row,
|
||||
"−",
|
||||
SettingsButton::ReplayMoveIntervalDown,
|
||||
"Speed up replay playback (shorter per-move interval).",
|
||||
font_res,
|
||||
);
|
||||
icon_button(
|
||||
row,
|
||||
"+",
|
||||
SettingsButton::ReplayMoveIntervalUp,
|
||||
"Slow down replay playback (longer per-move interval).",
|
||||
font_res,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// `Label Value [⇄]` — used for cycle/toggle rows (draw mode, theme,
|
||||
/// anim speed, colour-blind).
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user