invaders/src/game.rs

635 lines
24 KiB
Rust

use std::{cell::RefCell, rc::Rc};
use rand::seq::SliceRandom;
use wasm_bindgen::{prelude::Closure, JsCast, JsValue};
use web_sys::{window, KeyboardEvent};
use crate::{config, render::Renderer, utils};
// include the source code for the vertex and fragment shader. the [`include_str!`] macro embeds
// the contents of the file into the program source code as a string.
const VERT_SRC: &str = include_str!("shader/vert.glsl");
const FRAG_SRC: &str = include_str!("shader/frag.glsl");
/// stores the current player state.
struct Player {
// x position
pos_x: f32,
// variables set by key up / key down events, used to determine if the left / right / shoot
// buttons are pressed
is_moving_left: bool,
is_moving_right: bool,
is_shooting: bool,
// timestamp of the last time the player shot a bullet, used for achieving the delay between
// bullets fired by the player
last_shoot_time: f32,
}
/// stores position of an enemy.
struct Enemy {
// x and y position
pos_x: f32,
pos_y: f32,
// the texture to display. expects a value from 0 to 2.
sprite_type: u8,
}
/// stores the position of a bullet.
struct Bullet {
// x and y position
pos_x: f32,
pos_y: f32,
// direction that the bullet is moving in
direction: f32,
// color of the bullet
color: [f32; 3],
}
/// stores the current state of the game and the game objects.
pub struct Game {
// renderer object - see `render.rs`
renderer: Renderer,
// player, enemies and bullets
player: Player,
enemies: Vec<Enemy>,
player_bullets: Vec<Bullet>,
enemy_bullets: Vec<Bullet>,
// the direction the enemies are moving in
enemy_direction: f32,
// the offset of the enemies, used for determining when they should turn around
enemy_offset: f32,
// timestamp of the last time an enemy shot a bullet, used for achieving the delay between
// bullets fired by enemies
enemy_last_shoot_time: f32,
// time of the last frame, used for calculating delta time
last_time: f32,
// is the game running?
game_running: bool,
}
impl Game {
/// initialize the renderer and set up the game
pub fn new() -> Result<Self, JsValue> {
// obtain a renderer for the canvas of id `canvas`
let mut renderer = Renderer::new("canvas")?;
let vertices = vec![];
let indices = vec![];
// start out with empty vertex and index arrays
renderer.initialize_buffer(&vertices, &indices, false)?;
// create a shader program based on the shader source code included earlier
renderer.set_shader_program(VERT_SRC, FRAG_SRC)?;
// map the shader attributes. the layout of our vertices looks like this:
//
// ┌─────────────────┬─────────────────┬───────────┐
// │ coordinates │ color │ texture │
// │ ╷ ╷ │ ╷ ╷ │ ╷ │
// │ X │ Y │ Z │ R │ G │ B │ U │ V │
// └─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘
//
// stride, offset and data width have to be adjusted accordingly.
renderer.map_shader_attribute("aCoord", 3, 8, 0)?;
renderer.map_shader_attribute("aColor", 3, 8, 3)?;
renderer.map_shader_attribute("aTexCoord", 2, 8, 6)?;
// load the texture file
renderer.set_texture("/texture.png")?;
// initialize the player
let player = Player {
pos_x: 0.0,
is_moving_left: false,
is_moving_right: false,
is_shooting: false,
last_shoot_time: f32::NEG_INFINITY, // allow player to shoot right away
};
// create the enemy game objects
let enemies = {
let mut v = vec![];
let mut pos_x = config::ENEMY_OFFSET_LEFT;
let mut pos_y = config::ENEMY_OFFSET_TOP;
for _ in 0..config::ENEMY_COLS {
for row in 0..config::ENEMY_ROWS {
v.push(Enemy {
pos_x,
pos_y,
sprite_type: row as u8, // give each row a different sprite
});
pos_y -= config::ENEMY_ROW_SKIP;
}
pos_x += config::ENEMY_COL_SKIP;
pos_y = config::ENEMY_OFFSET_TOP;
}
v
};
// finally, construct the game struct
Ok(Self {
renderer,
player,
enemies,
player_bullets: vec![],
enemy_bullets: vec![],
enemy_direction: config::ENEMY_SPEED,
enemy_offset: 0.0,
game_running: true,
// obtain the current time and set it as the "first frame"
last_time: window().unwrap().performance().unwrap().now() as f32,
// enemies should wait a full shooting cycle before they can fire a bullet
enemy_last_shoot_time: window().unwrap().performance().unwrap().now() as f32,
})
}
/// update the game state and draw it to the canvas
fn update(&mut self, time: f32) {
// obtain delta time in seconds
let delta = (time - self.last_time) / 1000.0;
self.last_time = time;
// update player position
self.player.update(delta);
// update bulletes fired by player
self.player_bullets
.iter_mut()
.for_each(|bullet| bullet.update(delta));
// update bulletes fired by enemies
self.enemy_bullets
.iter_mut()
.for_each(|bullet| bullet.update(delta));
// update enemy positions
self.update_enemies(delta);
// allow player and enemies to fire bullets if they can
self.check_player_bullet_shoot(time);
self.check_enemy_bullet_shoot(time);
// check if bullets are colliding with anything
self.check_player_bullet_collisions();
self.check_enemy_bullet_collisions();
// check if the enemies got too low
self.check_enemy_y();
// draw the game
self.draw();
}
/// update the position of the enemies
fn update_enemies(&mut self, delta: f32) {
// if the enemies are outside of the valid range specified by [`config::ENEMY_CLAMP`], swap
// their movement direction
if self.enemy_offset.abs() >= config::ENEMY_CLAMP {
self.enemy_direction = -self.enemy_direction;
self.enemy_offset = self
.enemy_offset
.clamp(-config::ENEMY_CLAMP, config::ENEMY_CLAMP);
}
// move the enemies
let step = self.enemy_direction * delta;
self.enemy_offset += step;
for enemy in self.enemies.iter_mut() {
enemy.pos_x += step;
enemy.pos_y -= delta * config::ENEMY_SPEED_VERT;
}
}
/// check if the player can shoot a bullet. if the player has the shoot button pressed and
/// enough time has passed since the previous shot, shoot a bullet.
fn check_player_bullet_shoot(&mut self, time: f32) {
if self.player.is_shooting && time - self.player.last_shoot_time >= config::BULLET_DELAY {
// shoot the bullet
self.player_bullets.push(Bullet {
pos_x: self.player.pos_x, // shoot from current player position
pos_y: config::PLAYER_OFFSET_BOTTOM + 0.1,
direction: 1.0, // upwards
color: [0.0, 1.0, 0.0], // green
});
// update shoot time
self.player.last_shoot_time = time;
}
// remove out-of-bounds bullets
self.player_bullets.retain(|x| !x.out_of_bounds());
}
/// check if the enemies can shoot a bullet. if enough time has passed since the previous shot,
/// a random enemy is elected to shoot a bullet.
fn check_enemy_bullet_shoot(&mut self, time: f32) {
// if less enemies are present in the game, the delay between shots should be increased.
// otherwise, the shots will be concentrated on very few enemies, making it hard to avoid
// the bullets while aiming to kill enemies.
let delay_factor =
2.0 - self.enemies.len() as f32 / (config::ENEMY_COLS * config::ENEMY_ROWS) as f32;
// check if enough time has passed since the last shot
if time - self.enemy_last_shoot_time >= (config::BULLET_DELAY * delay_factor) {
// choose a random enemy
let enemy = self.enemies.choose(&mut rand::thread_rng());
if let Some(enemy) = enemy {
// shoot the bullet
self.enemy_bullets.push(Bullet {
pos_x: enemy.pos_x, // shoot from current enemy position
pos_y: enemy.pos_y,
direction: -1.0, // downwards
color: [1.0, 1.0, 1.0], // white
});
}
// update last shoot time
self.enemy_last_shoot_time = time;
}
// remove out-of-bounds bullets
self.enemy_bullets.retain(|x| !x.out_of_bounds());
}
/// check if the player bullets are colliding with enemies. if they are, kill the enemies.
fn check_player_bullet_collisions(&mut self) {
// list of bullets and enemies to remove / kill
let mut bullet_remove = vec![];
let mut enemy_remove = vec![];
for (bullet_idx, bullet) in self.player_bullets.iter().enumerate() {
'inner: for (enemy_idx, enemy) in self.enemies.iter().enumerate() {
// check if the bullet is colliding with an enemy
let is_colliding = bullet.is_colliding_with_shape(
enemy.pos_x,
enemy.pos_y,
config::ENEMY_WIDTH * config::ENEMY_PIXEL_SIZE,
config::ENEMY_HEIGHT * config::ENEMY_PIXEL_SIZE,
);
// if it is colliding, add enemy and bullet to the removal list
if is_colliding {
enemy_remove.push(enemy_idx);
bullet_remove.push(bullet_idx);
break 'inner;
}
}
}
// remove bullets that hit an enemy
for i in bullet_remove {
self.player_bullets.remove(i);
}
// remove enemies that were hit by a bullet
for i in enemy_remove {
self.enemies.remove(i);
}
// if the last enemy was shot, display the "you won!" screen and end the game
if self.enemies.is_empty() {
js_sys::eval("window.game_won()").unwrap();
self.game_running = false;
}
}
/// check if enemy bullets are colliding with the player
fn check_enemy_bullet_collisions(&mut self) {
for bullet in &self.enemy_bullets {
// check if the bullet is colliding with the player
let is_colliding = bullet.is_colliding_with_shape(
self.player.pos_x,
config::PLAYER_OFFSET_BOTTOM,
config::PLAYER_WIDTH,
config::PLAYER_HEIGHT,
);
// if it is colliding, display the "you lost!" screen and end the game
if is_colliding {
js_sys::eval("window.game_lost()").unwrap();
self.game_running = false;
break;
}
}
}
/// check if enemies are too low. when the enemies have moved down low enough so that the
/// player could touch them, the player will lose the game.
fn check_enemy_y(&mut self) {
// calculate the y position at which the enemies are considered to be too close to the
// player (i.e. too low)
const ENEMY_MIN_Y: f32 = config::PLAYER_OFFSET_BOTTOM
+ config::PLAYER_HEIGHT / 2.0
+ config::ENEMY_HEIGHT * config::ENEMY_PIXEL_SIZE;
// check every enemy's y position if it is too low
for enemy in &self.enemies {
// if an enemy is too low, display the "you lost!" screen and end the game
if enemy.pos_y < ENEMY_MIN_Y {
js_sys::eval("window.game_lost()").unwrap();
self.game_running = false;
break;
}
}
}
/// draw the game.
fn draw(&mut self) {
// obtain the vertices of all game objects
let (vertices, indices) = self.get_vertices();
// update the vertex and index buffers of the renderer
self.renderer
.update_buffer(&vertices, &indices, false)
.unwrap();
// ask the renderer to draw the scene
self.renderer.draw().unwrap();
}
/// obtain the vertices and indices of all game objects. returns a tuple `(vertices, indices)`.
fn get_vertices(&self) -> (Vec<f32>, Vec<u16>) {
let mut vertices = vec![];
let mut indices = vec![];
let mut i: u16 = 0;
// helper function that adds vertices and indices to the array
let mut add_vertices = |data: (Vec<f32>, Vec<u16>, u16)| {
let (mut vert, ind, num_vert) = data;
vertices.append(&mut vert);
indices.append(&mut ind.iter().map(|x| x + i).collect());
i += num_vert;
};
// add player vertices
add_vertices(self.player.get_vertices());
// add vertices for each enemy
for enemy in &self.enemies {
add_vertices(enemy.get_vertices(self.last_time));
}
// add vertices for each player bullet
for bullet in &self.player_bullets {
add_vertices(bullet.get_vertices());
}
// add vertices for each enemy bullet
for bullet in &self.enemy_bullets {
add_vertices(bullet.get_vertices());
}
(vertices, indices)
}
/// function that is executed when a key has been pressed
pub fn on_keydown(&mut self, event: KeyboardEvent) {
let key = event.key();
if key == "a" || key == "h" || key == "ArrowLeft" {
self.player.is_moving_left = true;
} else if key == "d" || key == "l" || key == "ArrowRight" {
self.player.is_moving_right = true;
} else if key == "w" || key == " " || key == "ArrowUp" {
self.player.is_shooting = true;
}
}
/// function that is executed when a key has been released
pub fn on_keyup(&mut self, event: KeyboardEvent) {
let key = event.key();
if key == "a" || key == "h" || key == "ArrowLeft" {
self.player.is_moving_left = false;
} else if key == "d" || key == "l" || key == "ArrowRight" {
self.player.is_moving_right = false;
} else if key == "w" || key == " " || key == "ArrowUp" {
self.player.is_shooting = false;
}
}
}
impl Enemy {
/// get the vertices of an enemy. returns a tuple consisting of vertices, indices and the
/// number of vertices (since the vertices themselves are stored as a flat array, making
/// determining the number of vertices impossible without knowing their exact layout).
fn get_vertices(&self, time: f32) -> (Vec<f32>, Vec<u16>, u16) {
let mut vertices = vec![];
// the position of an enemy is stored as the center point of the rectangle. in order to get
// to the corners of the rectangle, half of the width or height needs to be added or
// subtracted from the center coordinates.
const ENEMY_WIDTH_HALF: f32 = (config::ENEMY_WIDTH * config::ENEMY_PIXEL_SIZE) / 2.0;
const ENEMY_HEIGHT_HALF: f32 = (config::ENEMY_HEIGHT * config::ENEMY_PIXEL_SIZE) / 2.0;
// since there are multiple sprites for enemies that are stored below each other, calculate
// the offset [`to`] required to get to a certain sprite. the texture offset consists of a
// time offset (since the enemies are animated) and a sprite offset (since there are
// different sprites to choose from).
let to_time = (((time % 2000.0) / 1000.0) as i32) as f32 * 0.125;
let to_sprite = self.sprite_type as f32 * 0.25;
let to = to_time + to_sprite;
// coordinates of the edges of the rectangle
let left = self.pos_x - ENEMY_WIDTH_HALF;
let right = self.pos_x + ENEMY_WIDTH_HALF;
let top = self.pos_y - ENEMY_HEIGHT_HALF;
let bottom = self.pos_y + ENEMY_HEIGHT_HALF;
// append the vertices of the rectangle corners. since rustfmt likes to split these arrays
// into multiple lines (which dirsupts the reading flow), use an attribute to turn it of
// for each line separately.
#[rustfmt::skip] vertices.append(&mut vec![left, top, 0.0, 1.0, 1.0, 1.0, 0.0, 0.0625 + to]);
#[rustfmt::skip] vertices.append(&mut vec![left, bottom, 0.0, 1.0, 1.0, 1.0, 0.0, 0.0 + to]);
#[rustfmt::skip] vertices.append(&mut vec![right, bottom, 0.0, 1.0, 1.0, 1.0, 0.09375, 0.0 + to]);
#[rustfmt::skip] vertices.append(&mut vec![right, top, 0.0, 1.0, 1.0, 1.0, 0.09375, 0.0625 + to]);
// the indices of the triangles that make up the rectangle
let indices = vec![0, 1, 2, 0, 2, 3];
(vertices, indices, 4)
}
}
impl Player {
/// get the vertices of the player. returns a tuple consisting of vertices, indices and the
/// number of vertices (since the vertices themselves are stored as a flat array, making
/// determining the number of vertices impossible without knowing their exact layout).
fn get_vertices(&self) -> (Vec<f32>, Vec<u16>, u16) {
let mut vertices = vec![];
// coordinates of the edges of the rectangle
let left = self.pos_x - config::PLAYER_WIDTH / 2.0;
let right = self.pos_x + config::PLAYER_WIDTH / 2.0;
let bot = config::PLAYER_OFFSET_BOTTOM;
let top = bot + config::PLAYER_HEIGHT;
// append the vertices of the rectangle corners
vertices.append(&mut vec![left, top, 0.0, 1.0, 1.0, 1.0, 0.125, 0.0]);
vertices.append(&mut vec![left, bot, 0.0, 1.0, 1.0, 1.0, 0.125, 0.0625]);
vertices.append(&mut vec![right, bot, 0.0, 1.0, 1.0, 1.0, 0.21875, 0.0625]);
vertices.append(&mut vec![right, top, 0.0, 1.0, 1.0, 1.0, 0.21875, 0.0]);
// the indices of the triangles that make up the rectangle
let indices = vec![0, 1, 2, 0, 2, 3];
(vertices, indices, 4)
}
/// update the player position
fn update(&mut self, delta: f32) {
// move the player left or right
self.pos_x += (self.is_moving_right as i32 - self.is_moving_left as i32) as f32
* config::PLAYER_SPEED
* delta;
// prevent the player from going outside the range that it is allowed to move in
self.pos_x = self
.pos_x
.clamp(-config::PLAYER_CLAMP, config::PLAYER_CLAMP);
}
}
impl Bullet {
/// get the vertices of a bullet. returns a tuple consisting of vertices, indices and the
/// number of vertices (since the vertices themselves are stored as a flat array, making
/// determining the number of vertices impossible without knowing their exact layout).
fn get_vertices(&self) -> (Vec<f32>, Vec<u16>, u16) {
let mut vertices = vec![];
// coordinates of the edges of the rectangle
let left = self.pos_x - config::BULLET_WIDTH / 2.0;
let right = self.pos_x + config::BULLET_WIDTH / 2.0;
let bottom = self.pos_y - config::BULLET_HEIGHT / 2.0;
let top = self.pos_y + config::BULLET_HEIGHT / 2.0;
// get the color of the bullet
let (r, g, b) = (self.color[0], self.color[1], self.color[2]);
// append the vertices of the rectangle corners
vertices.append(&mut vec![left, top, 0.0, r, g, b, 0.9375, 0.9375]);
vertices.append(&mut vec![left, bottom, 0.0, r, g, b, 0.9375, 1.0]);
vertices.append(&mut vec![right, bottom, 0.0, r, g, b, 1.0, 1.0]);
vertices.append(&mut vec![right, top, 0.0, r, g, b, 1.0, 0.9375]);
// the indices of the triangles that make up the rectangle
let indices = vec![0, 1, 2, 0, 2, 3];
(vertices, indices, 4)
}
/// update the bullet position
fn update(&mut self, delta: f32) {
self.pos_y += self.direction * config::BULLET_SPEED * delta;
}
/// check if the bullet is out of bounds (i.e. below or above the visible area)
fn out_of_bounds(&self) -> bool {
self.pos_y < -1.0 || self.pos_y > 1.0
}
/// check if the bullet is colliding with a rectangle. the position of the rectangle is
/// provided as a center point, width and height. the bullet is handled as if it were a
/// 1-dimensional line without any width, since that is good enough for this use case.
fn is_colliding_with_shape(&self, pos_x: f32, pos_y: f32, width: f32, height: f32) -> bool {
// obtain the rectangle corners
let left = pos_x - width / 2.0;
let right = pos_x + width / 2.0;
let bottom = pos_y - height / 2.0;
let top = pos_y + height / 2.0;
// get top and bottom of bullet
let bullet_top = self.pos_y + config::BULLET_HEIGHT / 2.0;
let bullet_bottom = self.pos_y - config::BULLET_HEIGHT / 2.0;
// check if bullet is colliding on the x-axis
let x_collision = left <= self.pos_x && self.pos_x <= right;
// check if the bottom of the bullet is inside the shape
let y_collision_top = bottom <= bullet_bottom && bullet_bottom <= top;
// check if the top of the bullet is inside the shape
let y_collision_bottom = bottom <= bullet_top && bullet_top <= top;
// check if the shape is "inside" the bullet
let y_collision_through = bullet_bottom <= bottom && top <= bullet_top;
x_collision && (y_collision_top || y_collision_bottom || y_collision_through)
}
}
/// the game struct as a mutable global variable. this is a rather sketchy solution, but it works
/// fine for this usecase since multiple closures require mutable access to the game state.
static mut GAME: Option<Game> = None;
/// initialize the game.
fn game_init() -> Result<(), JsValue> {
unsafe {
GAME = Some(Game::new()?);
}
Ok(())
}
/// obtain a mutable reference to the game.
fn game_mut() -> &'static mut Game {
return unsafe { GAME.as_mut().unwrap() };
}
/// the main loop for the game
fn game_animation_loop() {
let f = Rc::new(RefCell::new(None));
let g = f.clone();
*g.borrow_mut() = Some(Closure::wrap(Box::new(move |time: f32| {
let game = game_mut();
// if the game is running, update the game and request another animation frame
if game.game_running {
game.update(time);
utils::request_animation_frame(f.borrow().as_ref().unwrap());
}
}) as Box<dyn FnMut(f32)>));
// call `window.requestAnimationFrame()` for the closure specified above
utils::request_animation_frame(g.borrow().as_ref().unwrap());
}
/// register keydown event handler
fn register_keydown_handler() -> Result<(), JsValue> {
// define a closure that gets called when a key is pressed
let c = Closure::wrap(Box::new(move |e| {
game_mut().on_keydown(e);
}) as Box<dyn FnMut(KeyboardEvent)>);
// add the event listener
let document = window().unwrap().document().unwrap();
document.add_event_listener_with_callback("keydown", c.as_ref().unchecked_ref())?;
c.forget();
Ok(())
}
/// register keyup event handler
fn register_keyup_handler() -> Result<(), JsValue> {
// define a closure that gets called when a key is released
let c = Closure::wrap(Box::new(move |e| {
game_mut().on_keyup(e);
}) as Box<dyn FnMut(KeyboardEvent)>);
// add the event listener
let document = window().unwrap().document().unwrap();
document.add_event_listener_with_callback("keyup", c.as_ref().unchecked_ref())?;
c.forget();
Ok(())
}
/// run the game.
pub fn run() -> Result<(), JsValue> {
game_init()?;
register_keydown_handler()?;
register_keyup_handler()?;
game_animation_loop();
Ok(())
}