refactor: add ticks to DeltaTime, rewrite Blinking system for tick-based calculations with absolute calculations, rewrite Blinking/Direction tests

This commit is contained in:
Ryan Walters
2025-09-05 19:20:58 -05:00
parent 132067c573
commit 3c50bfeab6
15 changed files with 413 additions and 76 deletions

75
Cargo.lock generated
View File

@@ -570,12 +570,76 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
dependencies = [
"autocfg",
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -618,6 +682,7 @@ dependencies = [
"serde",
"serde_json",
"smallvec",
"speculoos",
"spin_sleep",
"strum",
"strum_macros",
@@ -965,6 +1030,16 @@ dependencies = [
"serde",
]
[[package]]
name = "speculoos"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00c84ba5fa63b0de837c0d3cef5373ac1c3c6342053b7f446a210a1dde79a034"
dependencies = [
"num",
"serde_json",
]
[[package]]
name = "spin"
version = "0.9.8"

View File

@@ -62,6 +62,7 @@ libc = "0.2.175" # TODO: Describe why this is required.
[dev-dependencies]
pretty_assertions = "1.4.1"
speculoos = "0.13.0"
[build-dependencies]
phf = { version = "0.13.1", features = ["macros"] }

View File

@@ -72,8 +72,8 @@ pub mod collider {
pub mod ui {
/// Debug font size in points
pub const DEBUG_FONT_SIZE: u16 = 12;
/// Power pellet blink rate in seconds
pub const POWER_PELLET_BLINK_RATE: f32 = 0.2;
/// Power pellet blink rate in ticks (at 60 FPS, 12 ticks = 0.2 seconds)
pub const POWER_PELLET_BLINK_RATE: u32 = 12;
}
/// Map tile types that define gameplay behavior and collision properties.

View File

@@ -372,7 +372,7 @@ impl Game {
world.insert_resource(SystemTimings::default());
world.insert_resource(Timing::default());
world.insert_resource(Bindings::default());
world.insert_resource(DeltaTime(0f32));
world.insert_resource(DeltaTime { seconds: 0.0, ticks: 0 });
world.insert_resource(RenderDirty::default());
world.insert_resource(DebugState::default());
world.insert_resource(AudioState::default());
@@ -633,7 +633,7 @@ impl Game {
///
/// `true` if the game should terminate (exit command received), `false` to continue
pub fn tick(&mut self, dt: f32) -> bool {
self.world.insert_resource(DeltaTime(dt));
self.world.insert_resource(DeltaTime { seconds: dt, ticks: 1 });
// Note: We don't need to read the current tick here since we increment it after running systems

View File

@@ -12,20 +12,24 @@ use crate::systems::{
#[derive(Component, Debug)]
pub struct Blinking {
pub timer: f32,
pub interval: f32,
pub tick_timer: u32,
pub interval_ticks: u32,
}
impl Blinking {
pub fn new(interval: f32) -> Self {
Self { timer: 0.0, interval }
pub fn new(interval_ticks: u32) -> Self {
Self {
tick_timer: 0,
interval_ticks,
}
}
}
/// Updates blinking entities by toggling their visibility at regular intervals.
///
/// This system manages entities that have both `Blinking` and `Renderable` components,
/// accumulating time and toggling visibility when the specified interval is reached.
/// accumulating ticks and toggling visibility when the specified interval is reached.
/// Uses integer arithmetic for deterministic behavior.
#[allow(clippy::type_complexity)]
pub fn blinking_system(
mut commands: Commands,
@@ -42,22 +46,40 @@ pub fn blinking_system(
continue;
}
// Increase the timer by the delta time
blinking.timer += time.0;
// Increase the timer by the delta ticks
blinking.tick_timer += time.ticks;
// If the timer is less than the interval, there's nothing to do yet
if blinking.timer < blinking.interval {
// Handle zero interval case (immediate toggling)
if blinking.interval_ticks == 0 {
if time.ticks > 0 {
if hidden {
commands.entity(entity).remove::<Hidden>();
} else {
commands.entity(entity).insert(Hidden);
}
}
continue;
}
// Subtract the interval (allows for the timer to retain partial interval progress)
blinking.timer -= blinking.interval;
// Calculate how many complete intervals have passed
let complete_intervals = blinking.tick_timer / blinking.interval_ticks;
// Toggle the Hidden component
if hidden {
commands.entity(entity).remove::<Hidden>();
} else {
commands.entity(entity).insert(Hidden);
// If no complete intervals have passed, there's nothing to do yet
if complete_intervals == 0 {
continue;
}
// Update the timer to the remainder after complete intervals
blinking.tick_timer %= blinking.interval_ticks;
// Toggle the Hidden component for each complete interval
// Since toggling twice is a no-op, we only need to toggle if the count is odd
if complete_intervals % 2 == 1 {
if hidden {
commands.entity(entity).remove::<Hidden>();
} else {
commands.entity(entity).insert(Hidden);
}
}
}
}

View File

@@ -162,7 +162,35 @@ pub struct GlobalState {
pub struct ScoreResource(pub u32);
#[derive(Resource)]
pub struct DeltaTime(pub f32);
pub struct DeltaTime {
/// Floating-point delta time in seconds
pub seconds: f32,
/// Integer tick delta (usually 1, but can be different for testing)
pub ticks: u32,
}
#[allow(dead_code)]
impl DeltaTime {
/// Creates a new DeltaTime from a floating-point delta time in seconds
///
/// While this method exists as a helper, it does not mean that seconds and ticks are interchangeable.
pub fn from_seconds(seconds: f32) -> Self {
Self {
seconds,
ticks: (seconds * 60.0).round() as u32,
}
}
/// Creates a new DeltaTime from an integer tick delta
///
/// While this method exists as a helper, it does not mean that seconds and ticks are interchangeable.
pub fn from_ticks(ticks: u32) -> Self {
Self {
seconds: ticks as f32 / 60.0,
ticks,
}
}
}
/// Movement modifiers that can affect Pac-Man's speed or handling.
#[derive(Component, Debug, Clone, Copy)]

View File

@@ -25,7 +25,7 @@ pub fn ghost_movement_system(
mut ghosts: Query<(&Ghost, &mut Velocity, &mut Position), Without<Frozen>>,
) {
for (_ghost, mut velocity, mut position) in ghosts.iter_mut() {
let mut distance = velocity.speed * 60.0 * delta_time.0;
let mut distance = velocity.speed * 60.0 * delta_time.seconds;
loop {
match *position {
Position::Stopped { node: current_node } => {
@@ -111,7 +111,7 @@ pub fn eaten_ghost_system(
}
}
Position::Moving { to, .. } => {
let distance = velocity.speed * 60.0 * delta_time.0;
let distance = velocity.speed * 60.0 * delta_time.seconds;
if let Some(_overflow) = position.tick(distance) {
// Reached target node, check if we're at ghost house center
if to == ghost_house_center {

View File

@@ -319,7 +319,7 @@ pub fn input_system(
// Update touch reference position with easing
if let Some(ref mut touch_data) = touch_state.active_touch {
// Apply easing to the reference position and get the delta for direction calculation
let (delta, distance) = update_touch_reference_position(touch_data, delta_time.0);
let (delta, distance) = update_touch_reference_position(touch_data, delta_time.seconds);
// Check for direction based on updated reference position
if distance >= TOUCH_DIRECTION_THRESHOLD {
@@ -336,7 +336,7 @@ pub fn input_system(
}
if let (false, CursorPosition::Some { remaining_time, .. }) = (cursor_seen, &mut *cursor) {
*remaining_time -= delta_time.0;
*remaining_time -= delta_time.seconds;
if *remaining_time <= 0.0 {
*cursor = CursorPosition::None;
}

View File

@@ -99,12 +99,12 @@ pub fn player_movement_system(
} else {
*buffered_direction = BufferedDirection::Some {
direction,
remaining_time: remaining_time - delta_time.0,
remaining_time: remaining_time - delta_time.seconds,
};
}
}
let mut distance = velocity.speed * modifiers.speed_multiplier * 60.0 * delta_time.0;
let mut distance = velocity.speed * modifiers.speed_multiplier * 60.0 * delta_time.seconds;
loop {
match *position {

View File

@@ -55,7 +55,7 @@ pub fn directional_render_system(
dt: Res<DeltaTime>,
mut query: Query<(&Position, &Velocity, &mut DirectionalAnimation, &mut Renderable)>,
) {
let ticks = (dt.0 * 60.0).round() as u16; // Convert from seconds to ticks at 60 ticks/sec
let ticks = (dt.seconds * 60.0).round() as u16; // Convert from seconds to ticks at 60 ticks/sec
for (position, velocity, mut anim, mut renderable) in query.iter_mut() {
let stopped = matches!(position, Position::Stopped { .. });
@@ -90,7 +90,7 @@ pub fn directional_render_system(
///
/// This system handles entities that use LinearAnimation component for simple frame cycling.
pub fn linear_render_system(dt: Res<DeltaTime>, mut query: Query<(&mut LinearAnimation, &mut Renderable)>) {
let ticks = (dt.0 * 60.0).round() as u16; // Convert from seconds to ticks at 60 ticks/sec
let ticks = (dt.seconds * 60.0).round() as u16; // Convert from seconds to ticks at 60 ticks/sec
for (mut anim, mut renderable) in query.iter_mut() {
// Tick animation

View File

@@ -4,25 +4,42 @@ use crate::texture::sprite::AtlasTile;
#[derive(Clone)]
pub struct BlinkingTexture {
tile: AtlasTile,
blink_duration: f32,
time_bank: f32,
interval_ticks: u32,
tick_timer: u32,
is_on: bool,
}
impl BlinkingTexture {
pub fn new(tile: AtlasTile, blink_duration: f32) -> Self {
pub fn new(tile: AtlasTile, interval_ticks: u32) -> Self {
Self {
tile,
blink_duration,
time_bank: 0.0,
interval_ticks,
tick_timer: 0,
is_on: true,
}
}
pub fn tick(&mut self, dt: f32) {
self.time_bank += dt;
if self.time_bank >= self.blink_duration {
self.time_bank -= self.blink_duration;
pub fn tick(&mut self, delta_ticks: u32) {
self.tick_timer += delta_ticks;
// Handle zero interval case (immediate toggling)
if self.interval_ticks == 0 {
// With zero interval, any positive ticks should toggle
if delta_ticks > 0 {
self.is_on = !self.is_on;
}
return;
}
// Calculate how many complete intervals have passed
let complete_intervals = self.tick_timer / self.interval_ticks;
// Update the timer to the remainder after complete intervals
self.tick_timer %= self.interval_ticks;
// Toggle for each complete interval, but since toggling twice is a no-op,
// we only need to toggle if the count is odd
if complete_intervals % 2 == 1 {
self.is_on = !self.is_on;
}
}
@@ -36,11 +53,11 @@ impl BlinkingTexture {
}
// Helper methods for testing
pub fn time_bank(&self) -> f32 {
self.time_bank
pub fn tick_timer(&self) -> u32 {
self.tick_timer
}
pub fn blink_duration(&self) -> f32 {
self.blink_duration
pub fn interval_ticks(&self) -> u32 {
self.interval_ticks
}
}

View File

@@ -1,40 +1,205 @@
use pacman::texture::blinking::BlinkingTexture;
use speculoos::prelude::*;
mod common;
#[test]
fn test_blinking_texture() {
let tile = common::mock_atlas_tile(1);
let mut texture = BlinkingTexture::new(tile, 0.5);
let mut texture = BlinkingTexture::new(tile, 30); // 30 ticks = 0.5 seconds at 60 FPS
assert!(texture.is_on());
assert_that(&texture.is_on()).is_true();
texture.tick(0.5);
assert!(!texture.is_on());
texture.tick(30);
assert_that(&texture.is_on()).is_false();
texture.tick(0.5);
assert!(texture.is_on());
texture.tick(30);
assert_that(&texture.is_on()).is_true();
texture.tick(0.5);
assert!(!texture.is_on());
texture.tick(30);
assert_that(&texture.is_on()).is_false();
}
#[test]
fn test_blinking_texture_partial_duration() {
let tile = common::mock_atlas_tile(1);
let mut texture = BlinkingTexture::new(tile, 0.5);
let mut texture = BlinkingTexture::new(tile, 30); // 30 ticks
texture.tick(0.625);
assert!(!texture.is_on());
assert_eq!(texture.time_bank(), 0.125);
texture.tick(37); // 37 ticks, should complete 1 interval (30 ticks) with 7 remaining
assert_that(&texture.is_on()).is_false();
assert_that(&texture.tick_timer()).is_equal_to(7);
}
#[test]
fn test_blinking_texture_negative_time() {
fn test_blinking_texture_zero_interval() {
let tile = common::mock_atlas_tile(1);
let mut texture = BlinkingTexture::new(tile, 0.5);
let mut texture = BlinkingTexture::new(tile, 0);
texture.tick(-0.1);
assert!(texture.is_on());
assert_eq!(texture.time_bank(), -0.1);
assert_that(&texture.is_on()).is_true();
assert_that(&texture.interval_ticks()).is_equal_to(0);
// With zero interval, any positive ticks should toggle
texture.tick(1);
assert_that(&texture.is_on()).is_false();
assert_that(&texture.tick_timer()).is_equal_to(1);
texture.tick(1);
assert_that(&texture.is_on()).is_true();
assert_that(&texture.tick_timer()).is_equal_to(2);
}
#[test]
fn test_blinking_texture_multiple_cycles() {
let tile = common::mock_atlas_tile(1);
let mut texture = BlinkingTexture::new(tile, 15); // 15 ticks
// Test multiple complete cycles
for i in 0..10 {
let expected_state = i % 2 == 0;
assert_that(&texture.is_on()).is_equal_to(expected_state);
texture.tick(15);
}
}
#[test]
fn test_blinking_texture_accumulated_ticks() {
let tile = common::mock_atlas_tile(1);
let mut texture = BlinkingTexture::new(tile, 60); // 60 ticks = 1 second at 60 FPS
// Test that ticks accumulate correctly
texture.tick(18); // 18 ticks
assert_that(&texture.tick_timer()).is_equal_to(18);
assert_that(&texture.is_on()).is_true();
texture.tick(18); // 36 total ticks
assert_that(&texture.tick_timer()).is_equal_to(36);
assert_that(&texture.is_on()).is_true();
texture.tick(18); // 54 total ticks
assert_that(&texture.tick_timer()).is_equal_to(54);
assert_that(&texture.is_on()).is_true();
texture.tick(12); // 66 total ticks, should complete 1 interval (60 ticks) with 6 remaining
assert_that(&texture.tick_timer()).is_equal_to(6);
assert_that(&texture.is_on()).is_false();
}
#[test]
fn test_blinking_texture_large_tick_step() {
let tile = common::mock_atlas_tile(1);
let mut texture = BlinkingTexture::new(tile, 30); // 30 ticks
// Test with a tick step larger than the interval
texture.tick(90); // 90 ticks, should complete 3 intervals (90/30 = 3)
assert_that(&texture.is_on()).is_false(); // 3 toggles: true -> false -> true -> false
assert_that(&texture.tick_timer()).is_equal_to(0); // 90 % 30 = 0
texture.tick(60); // 60 ticks, should complete 2 intervals (60/30 = 2)
assert_that(&texture.is_on()).is_false(); // 2 more toggles: false -> true -> false (no change)
assert_that(&texture.tick_timer()).is_equal_to(0); // 60 % 30 = 0
}
#[test]
fn test_blinking_texture_small_steps() {
let tile = common::mock_atlas_tile(1);
let mut texture = BlinkingTexture::new(tile, 6); // 6 ticks
// Test with small tick steps
for _ in 0..6 {
texture.tick(1);
}
// After 6 ticks, should have completed one cycle
assert_that(&texture.tick_timer()).is_equal_to(0);
assert_that(&texture.is_on()).is_false();
}
#[test]
fn test_blinking_texture_clone() {
let tile = common::mock_atlas_tile(1);
let mut texture1 = BlinkingTexture::new(tile, 30);
let texture2 = texture1.clone();
// Both should have the same initial state
assert_that(&texture1.is_on()).is_equal_to(texture2.is_on());
assert_that(&texture1.tick_timer()).is_equal_to(texture2.tick_timer());
assert_that(&texture1.interval_ticks()).is_equal_to(texture2.interval_ticks());
// Modifying one shouldn't affect the other
texture1.tick(18);
assert_that(&texture1.tick_timer()).is_not_equal_to(texture2.tick_timer());
assert_that(&texture2.tick_timer()).is_equal_to(0);
}
#[test]
fn test_blinking_texture_edge_cases() {
let tile = common::mock_atlas_tile(1);
let mut texture = BlinkingTexture::new(tile, 60); // 60 ticks
// Test exactly at the interval
texture.tick(60);
assert_that(&texture.is_on()).is_false();
assert_that(&texture.tick_timer()).is_equal_to(0);
// Test just under the interval
texture.tick(59);
assert_that(&texture.is_on()).is_false();
assert_that(&texture.tick_timer()).is_equal_to(59);
// Test just over the interval
texture.tick(2);
assert_that(&texture.is_on()).is_true();
assert_that(&texture.tick_timer()).is_equal_to(1);
}
#[test]
fn test_blinking_texture_very_small_interval() {
let tile = common::mock_atlas_tile(1);
let mut texture = BlinkingTexture::new(tile, 1); // 1 tick
// Test with very small interval
texture.tick(1);
assert_that(&texture.is_on()).is_false();
assert_that(&texture.tick_timer()).is_equal_to(0);
texture.tick(1);
assert_that(&texture.is_on()).is_true();
assert_that(&texture.tick_timer()).is_equal_to(0);
}
#[test]
fn test_blinking_texture_very_large_interval() {
let tile = common::mock_atlas_tile(1);
let mut texture = BlinkingTexture::new(tile, 60000); // 60000 ticks = 1000 seconds at 60 FPS
// Test with very large interval
texture.tick(30000);
assert_that(&texture.is_on()).is_true();
assert_that(&texture.tick_timer()).is_equal_to(30000);
texture.tick(30000);
assert_that(&texture.is_on()).is_false();
assert_that(&texture.tick_timer()).is_equal_to(0);
}
#[test]
fn test_blinking_texture_multiple_toggles_no_op() {
let tile = common::mock_atlas_tile(1);
let mut texture = BlinkingTexture::new(tile, 10); // 10 ticks
// Test that multiple toggles work correctly (key feature)
// 2x ticks than the interval should do nothing at all, because toggling twice is a no-op
texture.tick(20); // 20 ticks = 2 complete intervals
assert_that(&texture.is_on()).is_true(); // Should still be true (2 toggles = no-op)
assert_that(&texture.tick_timer()).is_equal_to(0);
// 3x ticks should toggle once (3 toggles = 1 toggle)
texture.tick(30); // 30 ticks = 3 complete intervals
assert_that(&texture.is_on()).is_false(); // Should be false (3 toggles = 1 toggle)
assert_that(&texture.tick_timer()).is_equal_to(0);
// 4x ticks should do nothing (4 toggles = no-op)
texture.tick(40); // 40 ticks = 4 complete intervals
assert_that(&texture.is_on()).is_false(); // Should still be false (4 toggles = no-op)
assert_that(&texture.tick_timer()).is_equal_to(0);
}

View File

@@ -85,7 +85,10 @@ pub fn create_test_world() -> World {
world.insert_resource(AudioState::default());
world.insert_resource(GlobalState { exit: false });
world.insert_resource(DebugState::default());
world.insert_resource(DeltaTime(1.0 / 60.0)); // 60 FPS
world.insert_resource(DeltaTime {
seconds: 1.0 / 60.0,
ticks: 1,
}); // 60 FPS
world.insert_resource(create_test_map());
world

View File

@@ -1,5 +1,5 @@
use glam::I8Vec2;
use pacman::map::direction::*;
use speculoos::prelude::*;
#[test]
fn test_direction_opposite() {
@@ -11,21 +11,47 @@ fn test_direction_opposite() {
];
for (dir, expected) in test_cases {
assert_eq!(dir.opposite(), expected);
assert_that(&dir.opposite()).is_equal_to(expected);
}
}
#[test]
fn test_direction_as_ivec2() {
let test_cases = [
(Direction::Up, -I8Vec2::Y),
(Direction::Down, I8Vec2::Y),
(Direction::Left, -I8Vec2::X),
(Direction::Right, I8Vec2::X),
];
for (dir, expected) in test_cases {
assert_eq!(dir.as_ivec2(), expected);
assert_eq!(I8Vec2::from(dir), expected);
fn test_direction_opposite_symmetry() {
// Test that opposite() is symmetric: opposite(opposite(d)) == d
for &dir in &Direction::DIRECTIONS {
assert_that(&dir.opposite().opposite()).is_equal_to(dir);
}
}
#[test]
fn test_direction_opposite_exhaustive() {
// Test that every direction has a unique opposite
let mut opposites = std::collections::HashSet::new();
for &dir in &Direction::DIRECTIONS {
let opposite = dir.opposite();
assert_that(&opposites.insert(opposite)).is_true();
}
assert_that(&opposites).has_length(4);
}
#[test]
fn test_direction_as_usize_exhaustive() {
// Test that as_usize() returns unique values for all directions
let mut usizes = std::collections::HashSet::new();
for &dir in &Direction::DIRECTIONS {
let usize_val = dir.as_usize();
assert_that(&usizes.insert(usize_val)).is_true();
}
assert_that(&usizes).has_length(4);
}
#[test]
fn test_direction_as_ivec2_exhaustive() {
// Test that as_ivec2() returns unique values for all directions
let mut ivec2s = std::collections::HashSet::new();
for &dir in &Direction::DIRECTIONS {
let ivec2_val = dir.as_ivec2();
assert_that(&ivec2s.insert(ivec2_val)).is_true();
}
assert_that(&ivec2s).has_length(4);
}

View File

@@ -232,7 +232,7 @@ fn test_player_movement_system_buffered_direction_expires() {
});
// Set delta time to expire the buffered direction
world.insert_resource(DeltaTime(0.02));
world.insert_resource(DeltaTime::from_seconds(0.02));
// Run the system
world
@@ -410,7 +410,7 @@ fn test_buffered_direction_timing() {
.expect("System should run successfully");
// Run movement system multiple times with small delta times
world.insert_resource(DeltaTime(0.1)); // 0.1 seconds
world.insert_resource(DeltaTime::from_seconds(0.1)); // 0.1 seconds
// First run - buffered direction should still be active
world
@@ -428,7 +428,7 @@ fn test_buffered_direction_timing() {
}
// Run again to fully expire the buffered direction
world.insert_resource(DeltaTime(0.2)); // Total 0.3 seconds, should expire
world.insert_resource(DeltaTime::from_seconds(0.2)); // Total 0.3 seconds, should expire
world
.run_system_once(player_movement_system)
.expect("System should run successfully");