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]
byteorder = "1.3"
env_logger = "0.9"
game-loop = { version = "0.8", features = ["window"] }
getrandom = "0.2"
gilrs = "0.8"
log = "0.4"

View file

@ -10,11 +10,21 @@ The pixels have invaded!
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

View file

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

View file

@ -7,14 +7,13 @@
#![deny(clippy::all)]
#![forbid(unsafe_code)]
use std::time::Duration;
use crate::collision::Collision;
pub use crate::controls::{Controls, Direction};
use crate::geo::Point;
use crate::loader::{load_assets, Assets};
use crate::sprites::{blit, Animation, Drawable, Frame, Sprite, SpriteRef};
use randomize::PCG32;
use std::time::Duration;
mod collision;
mod controls;
@ -28,6 +27,12 @@ pub const WIDTH: usize = 224;
/// The screen height is constant (units are in pixels)
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
const START: Point = Point::new(24, 64);
const GRID: Point = Point::new(16, 16);
@ -92,7 +97,7 @@ struct Bounds {
struct Player {
sprite: SpriteRef,
pos: Point,
dt: usize,
dt: Duration,
}
/// The shield entity.
@ -108,7 +113,7 @@ struct Shield {
struct Laser {
sprite: SpriteRef,
pos: Point,
dt: usize,
dt: Duration,
}
/// The cannon entity.
@ -116,7 +121,37 @@ struct Laser {
struct Bullet {
sprite: SpriteRef,
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 {
@ -168,7 +203,7 @@ impl World {
let player = Player {
sprite: SpriteRef::new(&assets, Player1, Duration::from_millis(100)),
pos: PLAYER_START,
dt: 0,
dt: Duration::default(),
};
let bullet = None;
let collision = Collision::default();
@ -200,36 +235,34 @@ impl World {
///
/// * `dt`: The time delta since last update.
/// * `controls`: The player inputs.
pub fn update(&mut self, dt: &Duration, controls: &Controls) {
pub fn update(&mut self, controls: &Controls) {
if self.gameover {
// TODO: Add a game over screen
return;
}
let one_frame = Duration::new(0, 16_666_667);
// Advance the timer by the delta time
self.dt += *dt;
self.dt += TIME_STEP;
// Clear the collision details
self.collision.clear();
// Step the invaders one by one
while self.dt >= one_frame {
self.dt -= one_frame;
while self.dt >= ONE_FRAME {
self.dt -= ONE_FRAME;
self.step_invaders();
}
// Handle player movement and animation
self.step_player(controls, dt);
self.step_player(controls);
if let Some(bullet) = &mut self.bullet {
// Handle bullet movement
let velocity = update_dt(&mut bullet.dt, dt) * 4;
let velocity = bullet.update();
if bullet.pos.y > velocity {
bullet.pos.y -= velocity;
bullet.sprite.animate(&self.assets, dt);
bullet.sprite.animate(&self.assets);
// Handle collisions
if self
@ -250,11 +283,11 @@ impl World {
// Handle laser movement
let mut destroy = Vec::new();
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 {
laser.pos.y += velocity;
laser.sprite.animate(&self.assets, dt);
laser.sprite.animate(&self.assets);
// Handle collisions
if self.collision.laser_to_player(laser, &self.player) {
@ -387,28 +420,28 @@ impl World {
let laser = Laser {
sprite: SpriteRef::new(&self.assets, Frame::Laser1, Duration::from_millis(16)),
pos: invader.pos + LASER_OFFSET,
dt: 0,
dt: Duration::default(),
};
self.lasers.push(laser);
}
}
fn step_player(&mut self, controls: &Controls, dt: &Duration) {
let frames = update_dt(&mut self.player.dt, dt);
fn step_player(&mut self, controls: &Controls) {
let frames = self.player.update();
let width = self.player.sprite.width();
match controls.direction {
Direction::Left => {
if self.player.pos.x > width {
self.player.pos.x -= frames;
self.player.sprite.animate(&self.assets, dt);
self.player.sprite.animate(&self.assets);
}
}
Direction::Right => {
if self.player.pos.x < WIDTH - width * 2 {
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 {
sprite: SpriteRef::new(&self.assets, Frame::Bullet1, Duration::from_millis(32)),
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::rc::Rc;
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
pub(crate) type CachedSprite = (usize, usize, Rc<[u8]>);
@ -74,7 +74,7 @@ pub(crate) trait Drawable {
}
pub(crate) trait Animation {
fn animate(&mut self, assets: &Assets, dt: &Duration);
fn animate(&mut self, assets: &Assets);
}
impl Sprite {
@ -172,11 +172,11 @@ impl Drawable 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 {
self.step_frame(assets);
} else {
self.dt += *dt;
self.dt += TIME_STEP;
while self.dt >= self.duration {
self.dt -= self.duration;

View file

@ -1,24 +1,117 @@
#![deny(clippy::all)]
#![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 pixels::{Error, Pixels, SurfaceTexture};
use simple_invaders::{Controls, Direction, World, HEIGHT, WIDTH};
use std::{env, time::Instant};
use simple_invaders::{Controls, Direction, World, FPS, HEIGHT, TIME_STEP, WIDTH};
use std::{env, time::Duration};
use winit::{
dpi::LogicalSize,
event::{Event, VirtualKeyCode},
event_loop::{ControlFlow, EventLoop},
event_loop::EventLoop,
window::WindowBuilder,
};
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> {
env_logger::init();
let event_loop = EventLoop::new();
let mut input = WinitInputHelper::new();
let mut gilrs = Gilrs::new().unwrap();
// Enable debug mode with `DEBUG=true` environment variable
let debug = env::var("DEBUG")
@ -37,95 +130,57 @@ fn main() -> Result<(), Error> {
.unwrap()
};
let mut pixels = {
let pixels = {
let window_size = window.inner_size();
let surface_texture = SurfaceTexture::new(window_size.width, window_size.height, &window);
Pixels::new(WIDTH as u32, HEIGHT as u32, surface_texture)?
};
let mut invaders = World::new(generate_seed(), debug);
let mut time = Instant::now();
let mut gamepad = None;
let game = Game::new(pixels, debug);
event_loop.run(move |event, _, control_flow| {
// The one and only event that winit_input_helper doesn't have for us...
if let Event::RedrawRequested(_) = event {
invaders.draw(pixels.get_frame());
if pixels
.render()
.map_err(|e| error!("pixels.render() failed: {}", e))
.is_err()
{
*control_flow = ControlFlow::Exit;
return;
game_loop(
event_loop,
window,
game,
FPS as u32,
0.1,
move |g| {
// Update the world
if !g.game.paused {
g.game.world.update(&g.game.controls);
}
},
move |g| {
// Drawing
g.game.world.draw(g.game.pixels.get_frame());
if let Err(e) = g.game.pixels.render() {
error!("pixels.render() failed: {}", e);
g.exit();
}
// Pump the gilrs event loop and find an active gamepad
while let Some(gilrs::Event { id, event, .. }) = gilrs.next_event() {
let pad = gilrs.gamepad(id);
if gamepad.is_none() {
debug!("Gamepad with id {} is connected: {}", id, pad.name());
gamepad = Some(id);
} else if event == gilrs::ev::EventType::Disconnected {
debug!("Gamepad with id {} is disconnected: {}", id, pad.name());
gamepad = None;
}
// Sleep the main thread to limit drawing to the fixed time step.
// See: https://github.com/parasyte/pixels/issues/174
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);
// For everything else, for let winit_input_helper collect events to build its state.
// It returns `true` when it is time to update our game state and request a redraw.
if input.update(&event) {
// Close events
if input.key_pressed(VirtualKeyCode::Escape) || input.quit() {
*control_flow = ControlFlow::Exit;
if g.game.input.key_pressed(VirtualKeyCode::Escape) || g.game.input.quit() {
g.exit();
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
if let Some(size) = input.window_resized() {
pixels.resize_surface(size.width, size.height);
if let Some(size) = g.game.input.window_resized() {
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.