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:
@@ -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