An Interest In:
Web News this Week
- March 15, 2024
- March 14, 2024
- March 13, 2024
- March 12, 2024
- March 11, 2024
- March 10, 2024
- March 9, 2024
Simple Zombie Shooter
Hi! In this post I'll be showing you how to create a simple 2D zombie shooting game using vanilla JS and the HTML5 canvas. All of the code can be found on my github.
Live Demo
This project is hosted live on repl.it, so go check out what we'll be making here.
Folder Structure
It's often pretty confusing to deal with lengthy coding tutorials like these, so I've provided a simple folder structure that might help. I know my file naming isn't the best (ie: not capitalizing class file names), but you can change those as needed.
index.htmlcss / globals.css index.cssjs / index.js config.js classes / bullet.js player.js zombie.js libs / animate.js input.js pointer.js utils.js
Code Snippets
In a lot of code tutorials, I've seen people put ...
indicating where previously written blocks of code were. In this project, I didn't add or shorten code blocks using ellipses. Everything I wrote will be added to the previous snippet of code, so don't delete anything even if you don't see it in the current code snippet.
Remember, if this gets to confusing or you want to see where functions should be placed, check out the code on github.
HTML Layout
Let's start by making our HTML skeleton. All this really needs to have is a canvas, minimal styles, and our script. I won't be using Webpack in this project, so let's take advantage of browser modules instead.
index.html
<!DOCTYPE html><html lang="en"><head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width" /> <title>Shooter</title> <link href="/css/globals.css" rel="stylesheet" /> <link href="/css/index.css" rel="stylesheet" /> <script src="/js/index.js" type="module"></script></head><body> <div id="app"> <canvas id="app-scene"></canvas> </div></body></html>
So far, we've added basic meta tags, a canvas, and included our CSS and JS files.
Basic CSS
You can skip this part on CSS. I just included it in case I expand the project, like adding a start menu. Generally in my projects, css/globals.css
contains box-sizing resets and any variables for the theme of the site. css/index.css
has everything else needed to style index.html
. Again, this step is mostly unnecessary considering most of the work will be done in JS.
css/globals.css
html, body { height: 100%; width: 100%; padding: 0; margin: 0; box-sizing: border-box; overflow: hidden; /* generally you don't mess with this but I don't want any scrolling regardless */}*, ::before, ::after { box-sizing: inherit;}
css/index.css
/* make the canvas wrapper expand to the entire page */#app { min-height: 100vh; width: 100%;}/* make canvas expand to the entire page */#app-scene { height: 100%; width: 100%;}
JavaScript
This part is a bit more difficult, so I've broken it up into several sections. If you're stuck, you can always compare your work to the solution code.
Config
Normally, you'd want to put variables that alter the behavior of the game in config.js
. For example, you could specify the player's speed, or how many hitpoints a zombie should have. I'll leave the specifics to you, so all I'm exporting is how big the canvas should be (the entire screen).
js/config.js
const width = window.innerWidthconst height = window.innerHeightexport { width, height}
Utils
Libraries like p5.js provide a host of built-in functions that simplify down the math. The only functions we'll need are an implementation of random
and distance
.
js/libs/utils.js
const random = (min, max) => { return (Math.random() * (max - min)) + min}const distance = (x1, y1, x2, y2) => { let xx = Math.pow((x2 - x1), 2) let yy = Math.pow((y2 - y1), 2) return Math.sqrt(xx + yy)}export { random, distance}
Animating
First, we need to reference our canvas and set up a basic game loop. The main rendering & update process will be set up in js/libs/animate.js
, and then imported to use in js/index.js
.
We'll be using window.requestAnimationFrame
to drive the game loop. I've pretty much ripped this off of Stack Overflow, but I'll do my best to explain what's happening.
Here, we're initializing all of the variables we'll be using. update
is a function we'll pass into the animate
function (see below) that we want to run every frame.
js/libs/animate.js
let interval, start, now, then, elapsedlet update
startAnimation
sets our animation to 60 fps and starts the animationLoop
function, which recursively calls with requestAnimationFrame
.
js/libs/animate.js
const startAnimation = () => { interval = 1000 / 60 then = Date.now() start = then animationLoop()}// recursively call animationLoop with requestAnimationFrameconst animationLoop = () => { requestAnimationFrame(animationLoop) now = Date.now() elapsed = now - then if(elapsed > interval) { then = now - (elapsed % interval) update() }}
Finally, we export a utility function to set update
and start the animation.
js/libs/animate.js
const animate = (u) => { update = u startAnimation()}export default animate
Here, we resize the canvas and retrieve the canvas context, allowing us to draw items on the screen. Then we animate a blank update
function, which we'll be filling in very soon.
js/index.js
import animate from "./libs/animate.js"import { width, height } from "./config.js"// get the canvas and contextconst canvas = document.getElementById("app-scene")const ctx = canvas.getContext("2d")Object.assign(canvas, { width, height})const update = () => { ctx.clearRect(0, 0, width, height) // refreshes the background}animate(update)
A Player
If you throw a console.log
into update
, you'll see it being repeatedly run but nothing is drawn onto the screen. It's time to add a player that we can control!
For now, I'm initializing the class with some default variables and blank functions.
js/classes/player.js
import { width, height } from "../config.js"class Player { vector = { x: width / 2, y: height / 2 } speed = 2 radius = 20 angle = - Math.PI / 2 rotate() {} move() {} update() { this.move() } render(ctx) {}}export default Player
Rendering the Player
In Player.render
we'll specify how the character in our game should look. I'm not using a spritesheet and I'm not a pro in designing assets, so our player will literally be a skin-colored ball.
The seemingly random -2 or +5 is used to adjust the location of the arms and gun, so play around with the coordinates I'm passing into the drawing functions. A lot of what I've done to make the player look decent is guess and check.
js/classes/player.js
render(ctx) { // rotation logic (doesn't do anything for now) ctx.save() let tX = this.vector.x let tY = this.vector.y ctx.translate(tX, tY) ctx.rotate(this.angle) ctx.translate(-tX, -tY) // Draw a circle as the body ctx.beginPath() ctx.fillStyle = "#ffe0bd" ctx.arc(this.vector.x, this.vector.y, this.radius, 0, Math.PI * 2) ctx.fill() // Draw a black rectangle as the "gun" ctx.beginPath() ctx.fillStyle = "#000" ctx.rect(this.vector.x + this.radius + 15, this.vector.y - 5, 25, 10) ctx.fill() // Specify how the hands should look ctx.beginPath() ctx.strokeStyle = "#ffe0bd" ctx.lineCap = "round" ctx.lineWidth = 4 // Right Hand ctx.moveTo(this.vector.x + 5, this.vector.y + this.radius - 2) ctx.lineTo(this.vector.x + this.radius + 15, this.vector.y + 5) ctx.stroke() // Left Hand ctx.moveTo(this.vector.x + 5, this.vector.y - this.radius + 2) ctx.lineTo(this.vector.x + this.radius + 15, this.vector.y - 5) ctx.stroke() // also part of the rotation logic ctx.restore()}
Onto the Screen!
After initializing the player class, we can update and render it within the animate
function. Keep in mind I'm only pasting the relevant parts of code, so keep everything that we wrote before.
js/index.js
import Player from "./classes/player.js"const player = new Player()const update = () => { player.update() player.render(ctx)}animate(update)
If all went well, you should now see a ball with a rectangle on the screen.
Movement
I experimented with the keydown
event, but I noticed that I couldn't move the player in multiple directions at once. I hacked together a simple input handler that you can use to help manage this problem.
js/libs/input.js
let keymap = []window.addEventListener("keydown", e => { let { key } = e if(!keymap.includes(key)) { keymap.push(key) }})window.addEventListener("keyup", e => { let { key } = e if(keymap.includes(key)) { keymap.splice(keymap.indexOf(key), 1) }})const key = (x) => { return keymap.includes(x)}// now, we can use key("w") to see if w is still being pressedexport default key
Essentially, we add keys to keymap
when they are pressed, and remove them when they are released. You could cover a few more edge cases by clearing the keymap when the user switches to another tab, but I was lazy.
Back in the Player class, we need to detect whenever the user presses WASD and change the position accordingly. I also made a rudimentary boundary system to prevent the player from leaving the screen.
js/classes/player.js
import key from "../libs/input.js"class Player { move() { if(key("w") && this.vector.y - this.speed - this.radius > 0) { this.vector.y -= this.speed } if(key("s") && this.vector.y + this.speed + this.radius < height) { this.vector.y += this.speed } if(key("a") && this.vector.x - this.speed - this.radius > 0) { this.vector.x -= this.speed } if(key("d") && this.vector.x + this.speed + this.radius < width) { this.vector.x += this.speed } }}
Rotation
They player can move around now, but the gun is only pointing upwards. To fix this, we'll need to find the location of the mouse and rotate the player towards it.
Technically we don't need to get the canvas's position because it covers the entire screen. However, doing so allows us to use the same function even if we change the canvas's location.
js/libs/pointer.js
const pointer = (canvas, event) => { const rect = canvas.getBoundingClientRect() const x = event.clientX - rect.left const y = event.clientY - rect.top return { x, y }}export default pointer
The player needs to rotate towards the pointer coordinates, so let's quickly add that in. We already added logic to account for the player's angle, so we don't need to change anything in Player.render
.
js/classes/player.js
// destructure the pointer coordsrotate({ x, y }) { let dy = y - this.vector.y let dx = x - this.vector.x // essentially get the angle from the player to the cursor in radians this.angle = Math.atan2(dy, dx)}
But wait! When we refresh the demo, the player isn't looking at our mouse. That's because we're never actually listening for a mousemove
event to get the mouse coordinates.
js/index.js
import pointer from "./libs/pointer.js"document.body.addEventListener("mousemove", (e) => { let mouse = pointer(canvas, e) player.rotate(mouse)})
Now we have a moving player that can look around.
The Zombies
Like the player, let's create a Zombie class. A lot of the zombie code will look very familiar. Instead of rotating and moving around depending on user input however, it will just follow the player around.
Zombies will spawn in randomly from the right. Since they should always be facing the player, we will create a rotate function that takes in a player class and grabs their position.
js/classes/zombie.js
import { width, height } from "../config.js"import { random } from "../libs/utils.js"class Zombie { speed = 1.1 radius = 20 health = 5 constructor(player) { this.vector = { x: width + this.radius, y: random(-this.radius, height + this.radius) } this.rotate(player) } rotate(player) {} update(player, zombies) { this.rotate(player) } render(ctx) {}}export default Zombie
Rendering Zombies
Zombies will be green balls with stretched out arms. The rotating logic, body, and arms are essentially the same things found in Player.render
.
js/classes/zombie.js
render(ctx) { ctx.save() let tX = this.vector.x let tY = this.vector.y ctx.translate(tX, tY) ctx.rotate(this.angle) ctx.translate(-tX, -tY) ctx.beginPath() ctx.fillStyle = "#00cc44" ctx.arc(this.vector.x, this.vector.y, this.radius, 0, Math.PI * 2) ctx.fill() // Hands ctx.beginPath() ctx.strokeStyle = "#00cc44" ctx.lineCap = "round" ctx.lineWidth = 4 // Right Hand ctx.moveTo(this.vector.x + 5, this.vector.y + this.radius - 2) ctx.lineTo(this.vector.x + this.radius + 15, this.vector.y + this.radius - 5) ctx.stroke() // Left Hand ctx.moveTo(this.vector.x + 5, this.vector.y - this.radius + 2) ctx.lineTo(this.vector.x + this.radius + 15, this.vector.y - this.radius + 5) ctx.stroke() ctx.restore()}
Onto the Screen!
You could initialize the zombie like we did with the player, but let's store them as an array in case we want to add more.
js/classes/zombie.js
import Zombie from "./classes/zombie.js"const player = new Player()const zombies = [ new Zombie(player) ]const update = () => { zombies.forEach(zombie => { zombie.update(player, zombies) zombie.render(ctx) }) player.update() player.render(ctx)}animate(update)
Follow the Player
Zombies are attracted to human brains. Unfortunately, the zombie we just made just sits off screen. Let's start by making the zombie follow the player around. The main functions that let this happen are Zombie.rotate
(point towards the player) and Zombie.update
(calls rotate and moves in the general direction of player coordinates).
If you don't understand the Math.cos
or Math.sin
, intuitively this makes sense because cosine refers to x and sine refers to y. We're basically converting an angle into an x and y so we can apply it to the zombie position vector.
js/classes/zombie.js
rotate(player) { let dy = player.vector.y - this.vector.y let dx = player.vector.x - this.vector.x this.angle = Math.atan2(dy, dx)}update(player, zombies) { this.rotate(player) this.vector.x += Math.cos(this.angle) * this.speed this.vector.y += Math.sin(this.angle) * this.speed}
Although we haven't implemented a shooting system yet, we want to delete the zombie when its health reaches 0. Let's modify the update function to splice out dead zombies.
js/classes/zombie.js
update(player, zombies) { if(this.health <= 0) { zombies = zombies.splice(zombies.indexOf(this), 1) return } this.rotate(player) this.vector.x += Math.cos(this.angle) * this.speed this.vector.y += Math.sin(this.angle) * this.speed}
Bullets
The zombies are attacking! But what do we do? We have no ammo! We need to make a Bullet class so we can start killing monsters.
When we call for a new Bullet, we need to find out where the bullet should start (Bullet.vector
) and what direction is should start heading (Bullet.angle
). The * 40
near the vector portion shifts up the bullet near the gun, rather than spawning in directly on top of the player.
js/classes/bullet.js
import { width, height } from "../config.js"import { distance } from "../libs/utils.js"class Bullet { radius = 4 speed = 10 constructor(x, y, angle) { this.angle = { x: Math.cos(angle), y: Math.sin(angle) } this.vector = { x: x + this.angle.x * 40, y: y + this.angle.y * 40 } } boundary() {} update(bullets, zombies) { this.vector.x += this.angle.x * this.speed this.vector.y += this.angle.y * this.speed } render(ctx) {}}export default Bullet
Rendering Bullets
The bullet will be a black circle. You could change this to a rectangle or a different shape, but keep in mind you'll want to rotate it depending on the angle.
js/classes/bullet.js
render(ctx) { ctx.beginPath() ctx.arc(this.vector.x, this.vector.y, this.radius, 0, Math.PI * 2) ctx.fillStyle = "#000" ctx.fill()}
Boundary
Bullets should be deleted when they either hit a zombie, or leave the screen's view. Let's implement the border collision first. Bullet.boundary
should indicate if the bullet is out of bounds, and then remove it from the bullets array.
js/classes/bullet.js
boundary() { return (this.vector.x > width + this.radius || this.vector.y > height + this.radius || this.vector.x < 0 - this.radius || this.vector.y < 0 - this.radius)}update(bullets, zombies) { if(this.boundary()) { bullets = bullets.splice(bullets.indexOf(this), 1) return } this.vector.x += this.angle.x * this.speed this.vector.y += this.angle.y * this.speed}
Click to Fire
Every time we click the screen we should fire off a new bullet. After importing the Bullet class into the main script, we'll make a bullets
array that we can push a new Bullet to every time a user clicks the screen. This way, we can loop through and update each bullet.
If you recall just above, we need to pass in the bullets and zombies array directly into the Bullet.update
function so we can remove bullets as needed.
js/index.js
import Bullet from "./classes/bullet.js"const bullets = []document.body.addEventListener("click", () => { bullets.push( new Bullet(player.vector.x, player.vector.y, player.angle) )})const update = () => { bullets.forEach(bullet => { bullet.update(bullets, zombies) bullet.render(ctx) })}animate(update)
Kill the Zombies!
At the moment, bullets pass straight through zombies.
We can loop through each zombie and bullet and check the distance between them. If the distance is lower than the zombie's radius, our bullet hit the target and we need to decrease the zombie's HP and delete the bullet.
js/classes/bullet.js
update(bullets, zombies) { if(this.boundary()) { bullets = bullets.splice(bullets.indexOf(this), 1) return } for(const bullet of bullets) { for(const zombie of zombies) { let d = distance(zombie.vector.x, zombie.vector.y, this.vector.x, this.vector.y) if(d < zombie.radius) { bullets = bullets.splice(bullets.indexOf(this), 1) zombie.health -- return } } } this.vector.x += this.angle.x * this.speed this.vector.y += this.angle.y * this.speed}
Try shooting at a zombie 5 times. Hopefully, the bullets and zombie will disappear.
Bonus: Infinite Waves
One zombie is boring. How about we spawn in a zombie every three seconds?js/index.js
setInterval(() => { zombies.push(new Zombie(player))}, 3 * 1000)
Closing
Now we have a fully functional zombie shooting game. Hopefully this gave you a brief introduction to game development with the HTML5 canvas. Currently, nothing happens when a zombie touches you, but it shouldn't be too hard to implement a player HP bar (look back on the bullet and zombie collision code). I look forward to how you extend or optimize this game!
Original Link: https://dev.to/phamn23/simple-zombie-shooter-49d
Dev To
An online community for sharing and discovering great ideas, having debates, and making friendsMore About this Source Visit Dev To