feat(simulation): add tick rate scaling system
Replace binary paused flag with TickRate enum (Full/Half/Paused) per D-052. Full rate advances every frame, Half every 2 frames via fractional accumulation, Paused blocks all advances. Add SetTickRate player action for client-driven rate changes. Implements ticket #406. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -260,7 +260,7 @@ mod tests {
|
||||
world.insert_resource(thresholds);
|
||||
|
||||
// Tick 7: not a multiple of 10, decay should NOT run
|
||||
world.insert_resource(SimulationTime { tick: 7, paused: false });
|
||||
world.insert_resource({ let mut t = SimulationTime::default(); t.tick = 7; t });
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(decay_knowledge);
|
||||
schedule.run(&mut world);
|
||||
@@ -273,7 +273,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// Tick 10: multiple of 10, decay SHOULD run (age = 10 > decay_after = 5)
|
||||
world.insert_resource(SimulationTime { tick: 10, paused: false });
|
||||
world.insert_resource({ let mut t = SimulationTime::default(); t.tick = 10; t });
|
||||
let mut schedule2 = bevy_ecs::schedule::Schedule::default();
|
||||
schedule2.add_systems(decay_knowledge);
|
||||
schedule2.run(&mut world);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use crate::bridge::types::{PlayerAction, PlayerInput};
|
||||
use crate::perception::vision_cone::{facing_from_delta, Facing};
|
||||
use crate::simulation::movement::{MoveIntent, PlayerCharacter, TilePosition};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
use crate::simulation::time::{SimulationTime, TickRate};
|
||||
use bevy_ecs::prelude::*;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
@@ -75,13 +75,17 @@ pub fn process_player_input(
|
||||
PlayerAction::MoveSoutheast => apply_move(&player_query, &mut commands, 1, 1),
|
||||
PlayerAction::MoveSouthwest => apply_move(&player_query, &mut commands, -1, 1),
|
||||
PlayerAction::Pause => {
|
||||
time.paused = true;
|
||||
time.tick_rate = TickRate::Paused;
|
||||
tracing::debug!("Simulation paused by player input");
|
||||
}
|
||||
PlayerAction::Unpause => {
|
||||
time.paused = false;
|
||||
time.tick_rate = TickRate::Full;
|
||||
tracing::debug!("Simulation unpaused by player input");
|
||||
}
|
||||
PlayerAction::SetTickRate(rate) => {
|
||||
time.tick_rate = rate;
|
||||
tracing::debug!("Tick rate set to {:?} by player input", rate);
|
||||
}
|
||||
PlayerAction::Interact => {
|
||||
tracing::trace!("Interact action — no-op for Sprint 1");
|
||||
}
|
||||
@@ -160,10 +164,7 @@ mod tests {
|
||||
fn process_input_move_creates_intent() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime {
|
||||
tick: 0,
|
||||
paused: false,
|
||||
});
|
||||
world.insert_resource(SimulationTime::default());
|
||||
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
|
||||
@@ -183,13 +184,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_input_pause_toggles() {
|
||||
fn process_input_pause_sets_paused() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime {
|
||||
tick: 0,
|
||||
paused: false,
|
||||
});
|
||||
world.insert_resource(SimulationTime::default());
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
@@ -200,17 +198,32 @@ mod tests {
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert!(world.resource::<SimulationTime>().paused);
|
||||
assert_eq!(world.resource::<SimulationTime>().tick_rate, TickRate::Paused);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_input_set_tick_rate() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::SetTickRate(TickRate::Half),
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert_eq!(world.resource::<SimulationTime>().tick_rate, TickRate::Half);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_input_no_player_no_panic() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime {
|
||||
tick: 0,
|
||||
paused: false,
|
||||
});
|
||||
world.insert_resource(SimulationTime::default());
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
@@ -227,10 +240,7 @@ mod tests {
|
||||
fn process_input_future_tick_ignored() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime {
|
||||
tick: 0,
|
||||
paused: false,
|
||||
});
|
||||
world.insert_resource(SimulationTime::default());
|
||||
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
|
||||
|
||||
@@ -18,12 +18,54 @@ pub enum DayPhase {
|
||||
Night,
|
||||
}
|
||||
|
||||
/// Tick rate states per D-052
|
||||
/// Full: normal gameplay. Half: UI overlay open (knowledge panel, dialogue).
|
||||
/// Paused: spacebar pause (0 ticks advance).
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub enum TickRate {
|
||||
#[default]
|
||||
Full,
|
||||
Half,
|
||||
Paused,
|
||||
}
|
||||
|
||||
impl TickRate {
|
||||
/// Scale factor: Full=1.0, Half=0.5, Paused=0.0
|
||||
pub fn scale(self) -> f32 {
|
||||
match self {
|
||||
TickRate::Full => 1.0,
|
||||
TickRate::Half => 0.5,
|
||||
TickRate::Paused => 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Simulation time resource
|
||||
/// Tracks current tick and pause state for deterministic simulation
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
/// Tracks current tick and tick rate for deterministic simulation (D-052).
|
||||
/// Uses fractional accumulation: at Half speed, one tick advances every 2 frames.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct SimulationTime {
|
||||
pub tick: u64,
|
||||
pub paused: bool,
|
||||
pub tick_rate: TickRate,
|
||||
/// Fractional tick accumulator for sub-1.0 rates
|
||||
accumulated: f32,
|
||||
}
|
||||
|
||||
impl Default for SimulationTime {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tick: 0,
|
||||
tick_rate: TickRate::Full,
|
||||
accumulated: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SimulationTime {
|
||||
/// Whether the simulation is effectively paused (tick_rate == Paused)
|
||||
pub fn paused(&self) -> bool {
|
||||
self.tick_rate == TickRate::Paused
|
||||
}
|
||||
}
|
||||
|
||||
impl SimulationTime {
|
||||
@@ -48,10 +90,17 @@ impl SimulationTime {
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance the simulation tick if not paused
|
||||
/// Advance the simulation tick based on current tick rate.
|
||||
/// Full: +1 every frame. Half: +1 every 2 frames. Paused: no advance.
|
||||
pub fn advance_tick(mut time: ResMut<SimulationTime>) {
|
||||
if !time.paused {
|
||||
let scale = time.tick_rate.scale();
|
||||
if scale <= 0.0 {
|
||||
return;
|
||||
}
|
||||
time.accumulated += scale;
|
||||
if time.accumulated >= 1.0 {
|
||||
time.tick += 1;
|
||||
time.accumulated -= 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,44 +110,26 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tick_to_minute_conversion() {
|
||||
let time = SimulationTime {
|
||||
tick: 10,
|
||||
paused: false,
|
||||
};
|
||||
let time = SimulationTime { tick: 10, ..Default::default() };
|
||||
assert_eq!(time.game_minutes(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn day_phase_boundaries() {
|
||||
let time = SimulationTime {
|
||||
tick: 0,
|
||||
paused: false,
|
||||
};
|
||||
let time = SimulationTime::default();
|
||||
assert_eq!(time.day_phase(), DayPhase::Morning);
|
||||
let time = SimulationTime {
|
||||
tick: 360 * TICKS_PER_GAME_MINUTE,
|
||||
paused: false,
|
||||
};
|
||||
let time = SimulationTime { tick: 360 * TICKS_PER_GAME_MINUTE, ..Default::default() };
|
||||
assert_eq!(time.day_phase(), DayPhase::Afternoon);
|
||||
let time = SimulationTime {
|
||||
tick: 720 * TICKS_PER_GAME_MINUTE,
|
||||
paused: false,
|
||||
};
|
||||
let time = SimulationTime { tick: 720 * TICKS_PER_GAME_MINUTE, ..Default::default() };
|
||||
assert_eq!(time.day_phase(), DayPhase::Evening);
|
||||
let time = SimulationTime {
|
||||
tick: 1080 * TICKS_PER_GAME_MINUTE,
|
||||
paused: false,
|
||||
};
|
||||
let time = SimulationTime { tick: 1080 * TICKS_PER_GAME_MINUTE, ..Default::default() };
|
||||
assert_eq!(time.day_phase(), DayPhase::Night);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pause_prevents_tick_advance() {
|
||||
fn paused_prevents_tick_advance() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(SimulationTime {
|
||||
tick: 0,
|
||||
paused: true,
|
||||
});
|
||||
world.insert_resource(SimulationTime { tick_rate: TickRate::Paused, ..Default::default() });
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(advance_tick);
|
||||
schedule.run(&mut world);
|
||||
@@ -106,25 +137,52 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unpause_allows_tick_advance() {
|
||||
fn full_rate_advances_every_frame() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(SimulationTime {
|
||||
tick: 0,
|
||||
paused: false,
|
||||
});
|
||||
world.insert_resource(SimulationTime::default());
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(advance_tick);
|
||||
schedule.run(&mut world);
|
||||
assert_eq!(world.resource::<SimulationTime>().tick, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_rate_advances_every_two_frames() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(SimulationTime { tick_rate: TickRate::Half, ..Default::default() });
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(advance_tick);
|
||||
|
||||
// Frame 1: accumulate 0.5, no tick
|
||||
schedule.run(&mut world);
|
||||
assert_eq!(world.resource::<SimulationTime>().tick, 0);
|
||||
|
||||
// Frame 2: accumulate 1.0, tick advances
|
||||
schedule.run(&mut world);
|
||||
assert_eq!(world.resource::<SimulationTime>().tick, 1);
|
||||
|
||||
// Frame 3: accumulate 0.5 again, no tick
|
||||
schedule.run(&mut world);
|
||||
assert_eq!(world.resource::<SimulationTime>().tick, 1);
|
||||
|
||||
// Frame 4: tick advances again
|
||||
schedule.run(&mut world);
|
||||
assert_eq!(world.resource::<SimulationTime>().tick, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paused_helper_method() {
|
||||
let time = SimulationTime::default();
|
||||
assert!(!time.paused());
|
||||
let time = SimulationTime { tick_rate: TickRate::Paused, ..Default::default() };
|
||||
assert!(time.paused());
|
||||
let time = SimulationTime { tick_rate: TickRate::Half, ..Default::default() };
|
||||
assert!(!time.paused());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn day_wraparound_at_midnight() {
|
||||
// 1440 minutes = 1 full day, should wrap back to Morning
|
||||
let time = SimulationTime {
|
||||
tick: MINUTES_PER_DAY * TICKS_PER_GAME_MINUTE,
|
||||
paused: false,
|
||||
};
|
||||
let time = SimulationTime { tick: MINUTES_PER_DAY * TICKS_PER_GAME_MINUTE, ..Default::default() };
|
||||
assert_eq!(time.day_phase(), DayPhase::Morning);
|
||||
assert_eq!(time.time_of_day_minutes(), 0);
|
||||
assert_eq!(time.day(), 1);
|
||||
@@ -132,10 +190,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn day_calculation() {
|
||||
let time = SimulationTime {
|
||||
tick: 3 * MINUTES_PER_DAY * TICKS_PER_GAME_MINUTE + 100,
|
||||
paused: false,
|
||||
};
|
||||
let time = SimulationTime { tick: 3 * MINUTES_PER_DAY * TICKS_PER_GAME_MINUTE + 100, ..Default::default() };
|
||||
assert_eq!(time.day(), 3);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user