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:
parent
afd15436d6
commit
3968c9748a
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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.
|
||||
|
|
Loading…
Reference in a new issue