Fix delta time in the invaders example (#252)

- Minor rewrite of the winit integration using the `game-loop` crate for
  fixed time-step updates.
- Updates are now handled at 240 fps, regardless of frame rate.
- Frame rate is capped at 240 fps.
- Adds a pause key.
- Closes #11
This commit is contained in:
Jay Oster 2022-01-08 10:44:52 -08:00 committed by GitHub
parent afd15436d6
commit 3968c9748a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 214 additions and 123 deletions

View file

@ -12,6 +12,7 @@ default = ["optimize"]
[dependencies] [dependencies]
byteorder = "1.3" byteorder = "1.3"
env_logger = "0.9" env_logger = "0.9"
game-loop = { version = "0.8", features = ["window"] }
getrandom = "0.2" getrandom = "0.2"
gilrs = "0.8" gilrs = "0.8"
log = "0.4" log = "0.4"

View file

@ -10,11 +10,21 @@ The pixels have invaded!
cargo run --release --package invaders cargo run --release --package invaders
``` ```
## Controls ## Keyboard Controls
<kbd>Left</kbd> <kbd>Right</kbd> Move tank <kbd>🡰</kbd> <kbd>🡲</kbd>: Move tank
<kbd>space</kbd> Fire cannon <kbd>Space</kbd>: Fire cannon
<kbd>Pause</kbd> <kbd>P</kbd>: Pause
## GamePad Controls
`D-Pad 🡰` `D-Pad 🡲`: Move tank
`XBox 🅐` `PS 🅧` `Switch 🅑`: Fire cannon
`XBox/PS ≡` `Switch ⊕︀`: Pause
## Goal ## Goal

View file

@ -61,8 +61,8 @@ impl Collision {
]; ];
for (x, y) in corners.iter() { for (x, y) in corners.iter() {
let col = (x - left) / GRID.x + invaders.bounds.left_col; let col = x.saturating_sub(left) / GRID.x + invaders.bounds.left_col;
let row = (y - top) / GRID.y + invaders.bounds.top_row; let row = y.saturating_sub(top) / GRID.y + invaders.bounds.top_row;
if col < COLS && row < ROWS && invaders.grid[row][col].is_some() { if col < COLS && row < ROWS && invaders.grid[row][col].is_some() {
let detail = BulletDetail::Invader(col, row); let detail = BulletDetail::Invader(col, row);

View file

@ -7,14 +7,13 @@
#![deny(clippy::all)] #![deny(clippy::all)]
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
use std::time::Duration;
use crate::collision::Collision; use crate::collision::Collision;
pub use crate::controls::{Controls, Direction}; pub use crate::controls::{Controls, Direction};
use crate::geo::Point; use crate::geo::Point;
use crate::loader::{load_assets, Assets}; use crate::loader::{load_assets, Assets};
use crate::sprites::{blit, Animation, Drawable, Frame, Sprite, SpriteRef}; use crate::sprites::{blit, Animation, Drawable, Frame, Sprite, SpriteRef};
use randomize::PCG32; use randomize::PCG32;
use std::time::Duration;
mod collision; mod collision;
mod controls; mod controls;
@ -28,6 +27,12 @@ pub const WIDTH: usize = 224;
/// The screen height is constant (units are in pixels) /// The screen height is constant (units are in pixels)
pub const HEIGHT: usize = 256; pub const HEIGHT: usize = 256;
// Fixed time step (240 fps)
pub const FPS: usize = 240;
pub const TIME_STEP: Duration = Duration::from_nanos(1_000_000_000 / FPS as u64);
// Internally, the game advances at 60 fps
const ONE_FRAME: Duration = Duration::from_nanos(1_000_000_000 / 60);
// Invader positioning // Invader positioning
const START: Point = Point::new(24, 64); const START: Point = Point::new(24, 64);
const GRID: Point = Point::new(16, 16); const GRID: Point = Point::new(16, 16);
@ -92,7 +97,7 @@ struct Bounds {
struct Player { struct Player {
sprite: SpriteRef, sprite: SpriteRef,
pos: Point, pos: Point,
dt: usize, dt: Duration,
} }
/// The shield entity. /// The shield entity.
@ -108,7 +113,7 @@ struct Shield {
struct Laser { struct Laser {
sprite: SpriteRef, sprite: SpriteRef,
pos: Point, pos: Point,
dt: usize, dt: Duration,
} }
/// The cannon entity. /// The cannon entity.
@ -116,7 +121,37 @@ struct Laser {
struct Bullet { struct Bullet {
sprite: SpriteRef, sprite: SpriteRef,
pos: Point, pos: Point,
dt: usize, dt: Duration,
}
trait DeltaTime {
fn update(&mut self) -> usize;
fn update_dt(dest_dt: &mut Duration, step: Duration) -> usize {
*dest_dt += TIME_STEP;
let frames = dest_dt.as_nanos() / step.as_nanos();
*dest_dt -= Duration::from_nanos((frames * step.as_nanos()) as u64);
frames as usize
}
}
impl DeltaTime for Player {
fn update(&mut self) -> usize {
Self::update_dt(&mut self.dt, ONE_FRAME)
}
}
impl DeltaTime for Laser {
fn update(&mut self) -> usize {
Self::update_dt(&mut self.dt, ONE_FRAME)
}
}
impl DeltaTime for Bullet {
fn update(&mut self) -> usize {
Self::update_dt(&mut self.dt, TIME_STEP)
}
} }
impl World { impl World {
@ -168,7 +203,7 @@ impl World {
let player = Player { let player = Player {
sprite: SpriteRef::new(&assets, Player1, Duration::from_millis(100)), sprite: SpriteRef::new(&assets, Player1, Duration::from_millis(100)),
pos: PLAYER_START, pos: PLAYER_START,
dt: 0, dt: Duration::default(),
}; };
let bullet = None; let bullet = None;
let collision = Collision::default(); let collision = Collision::default();
@ -200,36 +235,34 @@ impl World {
/// ///
/// * `dt`: The time delta since last update. /// * `dt`: The time delta since last update.
/// * `controls`: The player inputs. /// * `controls`: The player inputs.
pub fn update(&mut self, dt: &Duration, controls: &Controls) { pub fn update(&mut self, controls: &Controls) {
if self.gameover { if self.gameover {
// TODO: Add a game over screen // TODO: Add a game over screen
return; return;
} }
let one_frame = Duration::new(0, 16_666_667);
// Advance the timer by the delta time // Advance the timer by the delta time
self.dt += *dt; self.dt += TIME_STEP;
// Clear the collision details // Clear the collision details
self.collision.clear(); self.collision.clear();
// Step the invaders one by one // Step the invaders one by one
while self.dt >= one_frame { while self.dt >= ONE_FRAME {
self.dt -= one_frame; self.dt -= ONE_FRAME;
self.step_invaders(); self.step_invaders();
} }
// Handle player movement and animation // Handle player movement and animation
self.step_player(controls, dt); self.step_player(controls);
if let Some(bullet) = &mut self.bullet { if let Some(bullet) = &mut self.bullet {
// Handle bullet movement // Handle bullet movement
let velocity = update_dt(&mut bullet.dt, dt) * 4; let velocity = bullet.update();
if bullet.pos.y > velocity { if bullet.pos.y > velocity {
bullet.pos.y -= velocity; bullet.pos.y -= velocity;
bullet.sprite.animate(&self.assets, dt); bullet.sprite.animate(&self.assets);
// Handle collisions // Handle collisions
if self if self
@ -250,11 +283,11 @@ impl World {
// Handle laser movement // Handle laser movement
let mut destroy = Vec::new(); let mut destroy = Vec::new();
for (i, laser) in self.lasers.iter_mut().enumerate() { for (i, laser) in self.lasers.iter_mut().enumerate() {
let velocity = update_dt(&mut laser.dt, dt) * 2; let velocity = laser.update() * 2;
if laser.pos.y < self.player.pos.y { if laser.pos.y < self.player.pos.y {
laser.pos.y += velocity; laser.pos.y += velocity;
laser.sprite.animate(&self.assets, dt); laser.sprite.animate(&self.assets);
// Handle collisions // Handle collisions
if self.collision.laser_to_player(laser, &self.player) { if self.collision.laser_to_player(laser, &self.player) {
@ -387,28 +420,28 @@ impl World {
let laser = Laser { let laser = Laser {
sprite: SpriteRef::new(&self.assets, Frame::Laser1, Duration::from_millis(16)), sprite: SpriteRef::new(&self.assets, Frame::Laser1, Duration::from_millis(16)),
pos: invader.pos + LASER_OFFSET, pos: invader.pos + LASER_OFFSET,
dt: 0, dt: Duration::default(),
}; };
self.lasers.push(laser); self.lasers.push(laser);
} }
} }
fn step_player(&mut self, controls: &Controls, dt: &Duration) { fn step_player(&mut self, controls: &Controls) {
let frames = update_dt(&mut self.player.dt, dt); let frames = self.player.update();
let width = self.player.sprite.width(); let width = self.player.sprite.width();
match controls.direction { match controls.direction {
Direction::Left => { Direction::Left => {
if self.player.pos.x > width { if self.player.pos.x > width {
self.player.pos.x -= frames; self.player.pos.x -= frames;
self.player.sprite.animate(&self.assets, dt); self.player.sprite.animate(&self.assets);
} }
} }
Direction::Right => { Direction::Right => {
if self.player.pos.x < WIDTH - width * 2 { if self.player.pos.x < WIDTH - width * 2 {
self.player.pos.x += frames; self.player.pos.x += frames;
self.player.sprite.animate(&self.assets, dt); self.player.sprite.animate(&self.assets);
} }
} }
_ => (), _ => (),
@ -418,7 +451,7 @@ impl World {
self.bullet = Some(Bullet { self.bullet = Some(Bullet {
sprite: SpriteRef::new(&self.assets, Frame::Bullet1, Duration::from_millis(32)), sprite: SpriteRef::new(&self.assets, Frame::Bullet1, Duration::from_millis(32)),
pos: self.player.pos + BULLET_OFFSET, pos: self.player.pos + BULLET_OFFSET,
dt: 0, dt: Duration::default(),
}); });
} }
} }
@ -612,11 +645,3 @@ fn next_invader<'a>(
} }
} }
} }
fn update_dt(dest_dt: &mut usize, dt: &Duration) -> usize {
*dest_dt += dt.subsec_nanos() as usize;
let frames = *dest_dt / 16_666_667;
*dest_dt -= frames * 16_666_667;
frames
}

View file

@ -1,11 +1,11 @@
use crate::loader::Assets;
use crate::TIME_STEP;
use crate::{Point, HEIGHT, WIDTH};
use line_drawing::Bresenham;
use std::cmp::min; use std::cmp::min;
use std::rc::Rc; use std::rc::Rc;
use std::time::Duration; use std::time::Duration;
use crate::loader::Assets;
use crate::{Point, HEIGHT, WIDTH};
use line_drawing::Bresenham;
// This is the type stored in the `Assets` hash map // This is the type stored in the `Assets` hash map
pub(crate) type CachedSprite = (usize, usize, Rc<[u8]>); pub(crate) type CachedSprite = (usize, usize, Rc<[u8]>);
@ -74,7 +74,7 @@ pub(crate) trait Drawable {
} }
pub(crate) trait Animation { pub(crate) trait Animation {
fn animate(&mut self, assets: &Assets, dt: &Duration); fn animate(&mut self, assets: &Assets);
} }
impl Sprite { impl Sprite {
@ -172,11 +172,11 @@ impl Drawable for SpriteRef {
} }
impl Animation for SpriteRef { impl Animation for SpriteRef {
fn animate(&mut self, assets: &Assets, dt: &Duration) { fn animate(&mut self, assets: &Assets) {
if self.duration.subsec_nanos() == 0 { if self.duration.subsec_nanos() == 0 {
self.step_frame(assets); self.step_frame(assets);
} else { } else {
self.dt += *dt; self.dt += TIME_STEP;
while self.dt >= self.duration { while self.dt >= self.duration {
self.dt -= self.duration; self.dt -= self.duration;

View file

@ -1,24 +1,117 @@
#![deny(clippy::all)] #![deny(clippy::all)]
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
use gilrs::{Button, Gilrs}; use game_loop::{game_loop, Time, TimeTrait as _};
use gilrs::{Button, GamepadId, Gilrs};
use log::{debug, error}; use log::{debug, error};
use pixels::{Error, Pixels, SurfaceTexture}; use pixels::{Error, Pixels, SurfaceTexture};
use simple_invaders::{Controls, Direction, World, HEIGHT, WIDTH}; use simple_invaders::{Controls, Direction, World, FPS, HEIGHT, TIME_STEP, WIDTH};
use std::{env, time::Instant}; use std::{env, time::Duration};
use winit::{ use winit::{
dpi::LogicalSize, dpi::LogicalSize,
event::{Event, VirtualKeyCode}, event::{Event, VirtualKeyCode},
event_loop::{ControlFlow, EventLoop}, event_loop::EventLoop,
window::WindowBuilder, window::WindowBuilder,
}; };
use winit_input_helper::WinitInputHelper; use winit_input_helper::WinitInputHelper;
/// Uber-struct representing the entire game.
struct Game {
/// Software renderer.
pixels: Pixels,
/// Invaders world.
world: World,
/// Player controls for world updates.
controls: Controls,
/// Event manager.
input: WinitInputHelper,
/// GamePad manager.
gilrs: Gilrs,
/// GamePad ID for the player.
gamepad: Option<GamepadId>,
/// Game pause state.
paused: bool,
/// State for key edge detection.
held: [bool; 2],
}
impl Game {
fn new(pixels: Pixels, debug: bool) -> Self {
Self {
pixels,
world: World::new(generate_seed(), debug),
controls: Controls::default(),
input: WinitInputHelper::new(),
gilrs: Gilrs::new().unwrap(), // XXX: Don't unwrap.
gamepad: None,
paused: false,
held: [false; 2],
}
}
fn update_controls(&mut self, event: &Event<()>) {
// Let winit_input_helper collect events to build its state.
self.input.update(event);
// Pump the gilrs event loop and find an active gamepad
while let Some(gilrs::Event { id, event, .. }) = self.gilrs.next_event() {
let pad = self.gilrs.gamepad(id);
if self.gamepad.is_none() {
debug!("Gamepad with id {} is connected: {}", id, pad.name());
self.gamepad = Some(id);
} else if event == gilrs::ev::EventType::Disconnected {
debug!("Gamepad with id {} is disconnected: {}", id, pad.name());
self.gamepad = None;
}
}
self.controls = {
// Keyboard controls
let held = [
self.input.key_held(VirtualKeyCode::Pause),
self.input.key_held(VirtualKeyCode::P),
];
let mut left = self.input.key_held(VirtualKeyCode::Left);
let mut right = self.input.key_held(VirtualKeyCode::Right);
let mut fire = self.input.key_held(VirtualKeyCode::Space);
let mut pause = (held[0] ^ self.held[0] & held[0]) | (held[1] ^ self.held[1] & held[1]);
self.held = held;
// GamePad controls
if let Some(id) = self.gamepad {
let gamepad = self.gilrs.gamepad(id);
left |= gamepad.is_pressed(Button::DPadLeft);
right |= gamepad.is_pressed(Button::DPadRight);
fire |= gamepad.is_pressed(Button::South);
pause |= gamepad.button_data(Button::Start).map_or(false, |button| {
button.is_pressed() && button.counter() == self.gilrs.counter()
});
}
self.gilrs.inc();
if pause {
self.paused = !self.paused;
}
let direction = if left {
Direction::Left
} else if right {
Direction::Right
} else {
Direction::Still
};
Controls { direction, fire }
};
}
}
fn main() -> Result<(), Error> { fn main() -> Result<(), Error> {
env_logger::init(); env_logger::init();
let event_loop = EventLoop::new(); let event_loop = EventLoop::new();
let mut input = WinitInputHelper::new();
let mut gilrs = Gilrs::new().unwrap();
// Enable debug mode with `DEBUG=true` environment variable // Enable debug mode with `DEBUG=true` environment variable
let debug = env::var("DEBUG") let debug = env::var("DEBUG")
@ -37,95 +130,57 @@ fn main() -> Result<(), Error> {
.unwrap() .unwrap()
}; };
let mut pixels = { let pixels = {
let window_size = window.inner_size(); let window_size = window.inner_size();
let surface_texture = SurfaceTexture::new(window_size.width, window_size.height, &window); let surface_texture = SurfaceTexture::new(window_size.width, window_size.height, &window);
Pixels::new(WIDTH as u32, HEIGHT as u32, surface_texture)? Pixels::new(WIDTH as u32, HEIGHT as u32, surface_texture)?
}; };
let mut invaders = World::new(generate_seed(), debug); let game = Game::new(pixels, debug);
let mut time = Instant::now();
let mut gamepad = None;
event_loop.run(move |event, _, control_flow| { game_loop(
// The one and only event that winit_input_helper doesn't have for us... event_loop,
if let Event::RedrawRequested(_) = event { window,
invaders.draw(pixels.get_frame()); game,
if pixels FPS as u32,
.render() 0.1,
.map_err(|e| error!("pixels.render() failed: {}", e)) move |g| {
.is_err() // Update the world
{ if !g.game.paused {
*control_flow = ControlFlow::Exit; g.game.world.update(&g.game.controls);
return;
} }
} },
move |g| {
// Pump the gilrs event loop and find an active gamepad // Drawing
while let Some(gilrs::Event { id, event, .. }) = gilrs.next_event() { g.game.world.draw(g.game.pixels.get_frame());
let pad = gilrs.gamepad(id); if let Err(e) = g.game.pixels.render() {
if gamepad.is_none() { error!("pixels.render() failed: {}", e);
debug!("Gamepad with id {} is connected: {}", id, pad.name()); g.exit();
gamepad = Some(id);
} else if event == gilrs::ev::EventType::Disconnected {
debug!("Gamepad with id {} is disconnected: {}", id, pad.name());
gamepad = None;
} }
}
// For everything else, for let winit_input_helper collect events to build its state. // Sleep the main thread to limit drawing to the fixed time step.
// It returns `true` when it is time to update our game state and request a redraw. // See: https://github.com/parasyte/pixels/issues/174
if input.update(&event) { let dt = TIME_STEP.as_secs_f64() - Time::now().sub(&g.current_instant());
if dt > 0.0 {
std::thread::sleep(Duration::from_secs_f64(dt));
}
},
|g, event| {
// Update controls
g.game.update_controls(&event);
// Close events // Close events
if input.key_pressed(VirtualKeyCode::Escape) || input.quit() { if g.game.input.key_pressed(VirtualKeyCode::Escape) || g.game.input.quit() {
*control_flow = ControlFlow::Exit; g.exit();
return; return;
} }
let controls = {
// Keyboard controls
let mut left = input.key_held(VirtualKeyCode::Left);
let mut right = input.key_held(VirtualKeyCode::Right);
let mut fire = input.key_pressed(VirtualKeyCode::Space);
// Gamepad controls
if let Some(id) = gamepad {
let gamepad = gilrs.gamepad(id);
left = left || gamepad.is_pressed(Button::DPadLeft);
right = right || gamepad.is_pressed(Button::DPadRight);
fire = fire
|| gamepad.button_data(Button::South).map_or(false, |button| {
button.is_pressed() && button.counter() == gilrs.counter()
});
}
let direction = if left {
Direction::Left
} else if right {
Direction::Right
} else {
Direction::Still
};
Controls { direction, fire }
};
// Resize the window // Resize the window
if let Some(size) = input.window_resized() { if let Some(size) = g.game.input.window_resized() {
pixels.resize_surface(size.width, size.height); g.game.pixels.resize_surface(size.width, size.height);
} }
},
// Get a new delta time. );
let now = Instant::now();
let dt = now.duration_since(time);
time = now;
// Update the game logic and request redraw
invaders.update(&dt, &controls);
window.request_redraw();
}
});
} }
/// Generate a pseudorandom seed for the game's PRNG. /// Generate a pseudorandom seed for the game's PRNG.