📜 ⬆️ ⬇️

Create a game using canvas and sprites.

The web is everywhere now and offers a very powerful environment for creating and distributing applications. Instead of a cycle: writing code → compiling → launch, just update the application or even write the code “live” in the browser. In addition, it relatively painlessly allows you to distribute your application on a huge number of platforms. Interestingly, in the past few years, developing games using HTML5 has become a reality.
The canvas element was introduced with HTML5 and provides an API for working with it. The API is simple, but if you have never worked with graphics, you will need time to get used to. Canvas is supported by a large number of browsers, which makes the web a good platform for creating games.
Using canvas is simple: create a tag, create a display context in javascript, and use methods such as fillRect and drawImage on this context to display forms and images. The API contains many methods for creating a variety of contours, image conversion and more.
In this article, we will create a game using canvas; real game with sprites, tracking collisions, and of course explosions. What a game without explosions!
And here is the game we are going to create - to play .

Getting ready


The game may seem complicated, but in fact it all comes down to using several components. I have always been amazed at how far you can go with the canvas, a few sprites, collision tracking and the game loop.
In order to fully focus on the components of the game, I will not chew every line of code and API. This article is written by advanced-level, but I hope that it will be clear for people of all levels. The article assumes that you are already familiar with JavaScript and the basics of HTML. We will also touch upon the canvas API and such basic game principles as the game cycle.

Creating a Canvas


Let's start learning the code. Most of the game is in app.js.
The first thing we do is create a tag and set its width and height. We do this dynamically in order to keep everything in JS, but you can create a canvas in an HTML document and get it using getElementById . There is no difference between these two ways, it is just a matter of preference.
// Create the canvas var canvas = document.createElement("canvas"); var ctx = canvas.getContext("2d"); canvas.width = 512; canvas.height = 480; document.body.appendChild(canvas); 

Canvas has a getContext method that is used to get the display context. A context is an object, calling methods of which you interact with the canvas API. You can also pass the 'webgl' parameter if you want to use WebGL for 3D scenes.
Next we will use the variable ctx to display all elements.
')

Game cycle


We need a cycle of the game that would constantly update and display the game. Here's what it looks like :
 // The main game loop var lastTime; function main() { var now = Date.now(); var dt = (now - lastTime) / 1000.0; update(dt); render(); lastTime = now; requestAnimFrame(main); }; 

We update and display the scenes, and then use requestAnimationFrame to queue up the next loop. Indeed, it would be easier to use setTimeout (main, 1000/60), trying to display 60 frames / sec. In top app.js itself, we wrapped the requestAnimationFrame, since not all browsers support this method.
SetTimeout (main, 1000/60) is never used, as it is less accurate and spends many cycles on display when it is not required.
The parameter in dt in the update function is the difference between the current time and the time of the last update. Never update the scene using a constant value for the frame (in the spirit of, x + = 5). Your game will work on different computers / platforms in different ways, so you need to update the scene regardless of the frame rate.
This is achieved by calculating the time since the last update and expressing all displacements in pixels per second. And the motion becomes the next x + = 50 * dt, or 50 pixels per second.

Download resources and start the game


The next part of the code initializes the game and loads all the necessary resources. This is done using one of the separately written helper classes, resources.js . This is a very simple library that loads all images and raises an event when they all load.
The game includes more resources, such as images, scene data and more. For 2D games, the main resource is images. You need to download all the resources before running the application, in order to be able to use from immediately.
In JavaScript, it is easy to upload images and use them when they are needed:
 var img = new Image(); img.onload = function() { startGame(); }; img.src = url; 

It becomes very tedious, if of course you have a lot of images. You need to make a bunch of global variables and check for each one. I wrote a basic resource loader to do all this automatically:
 (function() { var resourceCache = {}; var loading = []; var readyCallbacks = []; // Load an image url or an array of image urls function load(urlOrArr) { if(urlOrArr instanceof Array) { urlOrArr.forEach(function(url) { _load(url); }); } else { _load(urlOrArr); } } function _load(url) { if(resourceCache[url]) { return resourceCache[url]; } else { var img = new Image(); img.onload = function() { resourceCache[url] = img; if(isReady()) { readyCallbacks.forEach(function(func) { func(); }); } }; resourceCache[url] = false; img.src = url; } } function get(url) { return resourceCache[url]; } function isReady() { var ready = true; for(var k in resourceCache) { if(resourceCache.hasOwnProperty(k) && !resourceCache[k]) { ready = false; } } return ready; } function onReady(func) { readyCallbacks.push(func); } window.resources = { load: load, get: get, onReady: onReady, isReady: isReady }; })(); 

How it works: You call resources.load with all the images to load, and then call resources.onReady to create a callback to the event of loading all the data. resources.load is not used later in the game, only at the start time
Downloaded images are stored in a cache in the resourcesCache, and when all images are uploaded, all callbacks will be called. Now we can just do this:
 resources.load([ 'img/sprites.png', 'img/terrain.png' ]); resources.onReady(init); 

To get the image used resources.get ('img / sprites.png). Easy!
You can manually download all the images and run the game, or, to simplify the process, use something in the spirit of resources.js.
In the above code, init is called when all images have been loaded. Init will create a background image, hang events on the “Play Again” buttons, reset and start the game, start the game.
 function init() { terrainPattern = ctx.createPattern(resources.get('img/terrain.png'), 'repeat'); document.getElementById('play-again').addEventListener('click', function() { reset(); }); reset(); lastTime = Date.now(); main(); } 


Game state


Now we begin! Let's get down to implementing some game logic. In the core of each game - the "state of the game." This is data that represents the current state of the game: a list of objects on the map, coordinates and other data; current points, and more.
Below is the state of our game:
 // Game state var player = { pos: [0, 0], sprite: new Sprite('img/sprites.png', [0, 0], [39, 39], 16, [0, 1]) }; var bullets = []; var enemies = []; var explosions = []; var lastFire = Date.now(); var gameTime = 0; var isGameOver; var terrainPattern; // The score var score = 0; var scoreEl = document.getElementById('score'); 

It seems that a lot of things, but in fact everything is not so difficult. Most of the variables are tracked values: when the player last shot (lastFired), how long the game is running (gameTime), is the game finished (isGameOver), terrain image (terrainPattern) and points (score). Objects on the map are also described: bullets, enemies, explosions.
There is also the essence of the player, which tracks the position of the player and the state of the sprite. Before we get to the code, let's talk about entities and sprites.

Entities and Sprites



Entities

Entity is an object on the map. It doesn't matter if a ship, a bullet or an explosion are all entities.
Entities in the system is a javascript object that stores information about the position of the object and much more. This is a fairly simple system, in which we manually monitor each type of entity. Each our entity has the property pos and sprite, and possibly others. For example, if we want to add an enemy to the map, we do:
 enemies.push({ pos: [100, 50], sprite: new Sprite(/* sprite parameters */) }); 

This code adds the enemy to the map, to the position x = 100, y = 50 with a certain sprite.

Sprites and animations

A sprite is an image that displays a representation of an entity. Without animation, sprites are a regular image represented by ctx.drawImage.
We can implement animation by loading several images and changing them over time. This is called frame animation.
image
If we alternate these images from the first to the last, it will look like this:
image
In order to simplify editing and uploading of images, usually everyone composes on one, this is called a sprite map. You may already be familiar with this CSS technique.
This is a sprite map for our game (with a transparent background):
image
We use the Hard Vacuum image set. This set is a set of bmp files, so I copied the images I needed and pasted out onto one sprite sheet. For this you need a simple graphic editor.
It will be difficult to manage all the animations manually. For this we use the second auxiliary class - sprite.js . This is a small file that contains the animation logic. We'll see:
 function Sprite(url, pos, size, speed, frames, dir, once) { this.pos = pos; this.size = size; this.speed = typeof speed === 'number' ? speed : 0; this.frames = frames; this._index = 0; this.url = url; this.dir = dir || 'horizontal'; this.once = once; }; 

This is the Sprite class constructor. It takes a lot of arguments, but not all of them are required. Consider each of them:

The frames argument probably needs further explanation. This implies that all animation frames are the same size (this size is transferred above). During the animation, the system simply “passes” the sprite map horizontally or vertically (depending on the dir value) starting from the position pos c in increments along the x or y axis by the size value. You need to define the frames in order to describe how to go through the frames of the animation. The value of [0, 1, 2, 3, 2, 1] frames will pass from beginning to end and back to the beginning.
Only url, pos, size are required, as you may not need animation.
Each Sprite object has an update method for updating the animation, and its argument is the time delta, as well as in our global update. Each sprite must be updated for each frame.
 Sprite.prototype.update = function(dt) { this._index += this.speed*dt; } 

Each Sprite object also has a render method for rendering itself. It contains the basic logic of the animation. It keeps track of which frame should be drawn, calculates its coordinates on the sprite map, and calls ctx.drawImage to draw the frame.
 Sprite.prototype.render = function(ctx) { var frame; if(this.speed > 0) { var max = this.frames.length; var idx = Math.floor(this._index); frame = this.frames[idx % max]; if(this.once && idx >= max) { this.done = true; return; } } else { frame = 0; } var x = this.pos[0]; var y = this.pos[1]; if(this.dir == 'vertical') { y += frame * this.size[1]; } else { x += frame * this.size[0]; } ctx.drawImage(resources.get(this.url), x, y, this.size[0], this.size[1], 0, 0, this.size[0], this.size[1]); } 

We use the 3rd form drawImage, which allows us to specify the size of the sprite, offset and direction separately.

Scene update


Remember how in our game cycle we called update (dt) every frame? We need to define this function now, which should handle the update of all sprites, the update of the positions of entities and collisions.
 unction update(dt) { gameTime += dt; handleInput(dt); updateEntities(dt); // It gets harder over time by adding enemies using this // equation: 1-.993^gameTime if(Math.random() < 1 - Math.pow(.993, gameTime)) { enemies.push({ pos: [canvas.width, Math.random() * (canvas.height - 39)], sprite: new Sprite('img/sprites.png', [0, 78], [80, 39], 6, [0, 1, 2, 3, 2, 1]) }); } checkCollisions(); scoreEl.innerHTML = score; }; 

Notice how we add enemies to the map. We add an enemy if a random value is less than the set threshold, and we add it on the right side, out of sight. The ordinate establishes, by multiplying a random number by the difference between the height of the map and the height of the enemy. The height of the image of the enemy is “hardcoded”, so we know its height, and this code is used as an example.
The threshold is raised every time by the function 1 - Math.pow(.993, gameTime) .

Keystrokes

To handle keystrokes, I created another small library: input.js . This is a very small library that simply saves the state of the key pressed by adding a keyup and keydown event handler.
This library provides one single function - input.isDown. Which takes as its argument a character, for example, 'a', and returns true, in case this key was pressed. You can also pass the following values:

Now we can handle keystrokes:
 function handleInput(dt) { if(input.isDown('DOWN') || input.isDown('s')) { player.pos[1] += playerSpeed * dt; } if(input.isDown('UP') || input.isDown('w')) { player.pos[1] -= playerSpeed * dt; } if(input.isDown('LEFT') || input.isDown('a')) { player.pos[0] -= playerSpeed * dt; } if(input.isDown('RIGHT') || input.isDown('d')) { player.pos[0] += playerSpeed * dt; } if(input.isDown('SPACE') && !isGameOver && Date.now() - lastFire > 100) { var x = player.pos[0] + player.sprite.size[0] / 2; var y = player.pos[1] + player.sprite.size[1] / 2; bullets.push({ pos: [x, y], dir: 'forward', sprite: new Sprite('img/sprites.png', [0, 39], [18, 8]) }); bullets.push({ pos: [x, y], dir: 'up', sprite: new Sprite('img/sprites.png', [0, 50], [9, 5]) }); bullets.push({ pos: [x, y], dir: 'down', sprite: new Sprite('img/sprites.png', [0, 60], [9, 5]) }); lastFire = Date.now(); } } 

If the player presses "s" or the down arrow, we move the player up the vertical axis. The canvas coordinate system has coordinates (0,0) in the upper left corner and therefore an increase in the player’s position leads to a decrease in the player’s position on the screen. We did the same for all the other keys.
Please note that we defined playerSpeed ​​at the beginning of app.js. Here are the speeds that we asked:
 // Speed in pixels per second var playerSpeed = 200; var bulletSpeed = 500; var enemySpeed = 100; 

Multiplying playerSpeed ​​with the dt parameter, we consider the sum of pixels for moving through the frame. If one second has passed since the last update, the player will advance 200 pixels, if 0.5 then 100. This is shown as a constant movement speed depending on the frame rate.
The last thing we do is a bullet shot, under the conditions: a space was pressed and this happened more than 100 milliseconds since the last shot. lastFire is a global variable and is part of the game state. It helps us control the frequency of shots, otherwise the player could shoot each frame. And it is very easy, right ?!
 var x = player.pos[0] + player.sprite.size[0] / 2; var y = player.pos[1] + player.sprite.size[1] / 2; bullets.push({ pos: [x, y], dir: 'forward', sprite: new Sprite('img/sprites.png', [0, 39], [18, 8]) }); bullets.push({ pos: [x, y], dir: 'up', sprite: new Sprite('img/sprites.png', [0, 50], [9, 5]) }); bullets.push({ pos: [x, y], dir: 'down', sprite: new Sprite('img/sprites.png', [0, 60], [9, 5]) }); lastFire = Date.now(); 

We calculate the position of the new bullets in x and y coordinates. We will add to them the position of the player, plus half of its height and width, so that it shoots from the center of the ship.
image
We add 3 bullets because they shoot in different directions. This makes the game easier because the player will not be trapped. To distinguish between bullet entities, we added the 'dir' property with values: 'forward', 'up', 'down'.

Entities

All entities need updating. We have the essence of the player and 3 arrays with the essence of bullets, enemies and explosions.
 function updateEntities(dt) { // Update the player sprite animation player.sprite.update(dt); // Update all the bullets for(var i=0; i<bullets.length; i++) { var bullet = bullets[i]; switch(bullet.dir) { case 'up': bullet.pos[1] -= bulletSpeed * dt; break; case 'down': bullet.pos[1] += bulletSpeed * dt; break; default: bullet.pos[0] += bulletSpeed * dt; } // Remove the bullet if it goes offscreen if(bullet.pos[1] < 0 || bullet.pos[1] > canvas.height || bullet.pos[0] > canvas.width) { bullets.splice(i, 1); i--; } } // Update all the enemies for(var i=0; i<enemies.length; i++) { enemies[i].pos[0] -= enemySpeed * dt; enemies[i].sprite.update(dt); // Remove if offscreen if(enemies[i].pos[0] + enemies[i].sprite.size[0] < 0) { enemies.splice(i, 1); i--; } } // Update all the explosions for(var i=0; i<explosions.length; i++) { explosions[i].sprite.update(dt); // Remove if animation is done if(explosions[i].sprite.done) { explosions.splice(i, 1); i--; } } } 

Let's start over: the player's sprite is updated simply by calling the update function of the sprite. This moves the animation forward.
The following 3 cycles for bullets, enemies and explosions. The process is the same for everyone: refresh the sprite, refresh the motion, and delete if the entity has gone offstage. Since all entities can never change the direction of their movement, we do not need to save their essence after leaving the zone of visibility.
Bullet movement is the most difficult:
 switch(bullet.dir) { case 'up': bullet.pos[1] -= bulletSpeed * dt; break; case 'down': bullet.pos[1] += bulletSpeed * dt; break; default: bullet.pos[0] += bulletSpeed * dt; } 

If bullet.dir = 'up', we move the bullet down along the ordinate axis. On the contrary, if dir = 'down', for the default value, we move along the abscissa axis.
 // Remove the bullet if it goes offscreen if(bullet.pos[1] < 0 || bullet.pos[1] > canvas.height || bullet.pos[0] > canvas.width) { bullets.splice(i, 1); i--; } 

Then we check whether we can remove the bullet's essence. Positions are checked against the top, bottom and right edges, because the bullets only move in these directions.
To remove bullets, we remove this object from the array and decrease i, otherwise the next bullet will be skipped.

Collision tracking

And now for what everyone fears: collision tracking! In fact, it is not as difficult as it seems, at least for our game.
There are 3 types of collisions that we should track:
  1. Enemies and bullets
  2. Enemies and player
  3. Player and screen edge

The definition of 2D collisions is simple:
 function collides(x, y, r, b, x2, y2, r2, b2) { return !(r <= x2 || x > r2 || b <= y2 || y > b2); } function boxCollides(pos, size, pos2, size2) { return collides(pos[0], pos[1], pos[0] + size[0], pos[1] + size[1], pos2[0], pos2[1], pos2[0] + size2[0], pos2[1] + size2[1]); } 

These 2 functions could be combined into one, but it seems to me easier to read. collides takes the coordinates of the top / left and bottom / right corners of both objects and checks if there are any intersections.
The boxCollides function is a wrapper for collides that accepts arrays with the position and size of each element. In the function using the dimensions in calculates the absolute coordinates of the position.
And here is the code that actually detects collisions:
 function checkCollisions() { checkPlayerBounds(); // Run collision detection for all enemies and bullets for(var i=0; i<enemies.length; i++) { var pos = enemies[i].pos; var size = enemies[i].sprite.size; for(var j=0; j<bullets.length; j++) { var pos2 = bullets[j].pos; var size2 = bullets[j].sprite.size; if(boxCollides(pos, size, pos2, size2)) { // Remove the enemy enemies.splice(i, 1); i--; // Add score score += 100; // Add an explosion explosions.push({ pos: pos, sprite: new Sprite('img/sprites.png', [0, 117], [39, 39], 16, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], null, true) }); // Remove the bullet and stop this iteration bullets.splice(j, 1); break; } } if(boxCollides(pos, size, player.pos, player.sprite.size)) { gameOver(); } } } 

The collision detection turned out to be exponential, because we have to check the collisions between each entity on the stage. For our game, we must check every enemy against every bullet. We run a loop through the array of enemies and check for collisions in the loop for each bullet.
Called boxCollides pass the function the position and size of the enemy and the bullet, and if the function returns true, the following happens:

Notice how we create the explosion. We create an object with pos and sprite properties, and an indication of 13 frames for animation on the sprite map. It also indicates that the once parameter is true, so that the animation is played only once.
Look at these 3 lines:
 if(boxCollides(pos, size, player.pos, player.sprite.size)) { gameOver(); } 

Here we check player and enemy collisions, and if there is a collision, the game is over.
Finally, let's talk about checkPlayerBounds:
 function checkPlayerBounds() { // Check bounds if(player.pos[0] < 0) { player.pos[0] = 0; } else if(player.pos[0] > canvas.width - player.sprite.size[0]) { player.pos[0] = canvas.width - player.sprite.size[0]; } if(player.pos[1] < 0) { player.pos[1] = 0; } else if(player.pos[1] > canvas.height - player.sprite.size[1]) { player.pos[1] = canvas.height - player.sprite.size[1]; } } 

It simply does not allow the player to go beyond the map, keeping his coordinates within 0 and canvas.width / canvas.height.

Render


We are almost done! Now we just need to define the render function, which will be called by our game loop to display the scene of each frame. Here's what it looks like:
 // Draw everything function render() { ctx.fillStyle = terrainPattern; ctx.fillRect(0, 0, canvas.width, canvas.height); // Render the player if the game isn't over if(!isGameOver) { renderEntity(player); } renderEntities(bullets); renderEntities(enemies); renderEntities(explosions); }; function renderEntities(list) { for(var i=0; i<list.length; i++) { renderEntity(list[i]); } } function renderEntity(entity) { ctx.save(); ctx.translate(entity.pos[0], entity.pos[1]); entity.sprite.render(ctx); ctx.restore(); } 

The first thing we do is draw the background. We created the terrain background in the init functions using ctx.createPattern, and we draw the background by setting fillStyle and calling the fillRect functions.
Then we draw the player, all the bullets, all the enemies and the explosions. renderEntites loop around arrays of entities and draws them. renderEntity uses the canvas transformation to position the object on the screen. ctx.save saves the current transformation, and ctx.restore restores it.
If you look at the render function of sprites, you will see that sprite is in position (0,0), but calling ctx.translate moves the object to the right place.

Game over


The last thing we need to do is handle the end of the game. We need to define the gameOver function, which will show the game end screen, and another reset, which will start the game again.
 // Game over function gameOver() { document.getElementById('game-over').style.display = 'block'; document.getElementById('game-over-overlay').style.display = 'block'; isGameOver = true; } // Reset game to original state function reset() { document.getElementById('game-over').style.display = 'none'; document.getElementById('game-over-overlay').style.display = 'none'; isGameOver = false; gameTime = 0; score = 0; enemies = []; bullets = []; player.pos = [50, canvas.height / 2]; }; 

gameOver shows the screen defined in index.html, saying “Gane Over” and having a “restart” button.
reset sets all game state values ​​to the initial ones, hides the game end screen, and restarts the game.

Final thought


There are a lot of things to learn in this article, but I hope that I broke it into quite simple pieces to show that creating a game is not so difficult.
I focused on using the low-level Canvas API to shed light on how easy it is to create 2D games these days. Of course, there are several game engines that you can use to create something really complex. Many game engines standardize the interface for entities, and all you need to do is define the render and update functions for each type and the scene manager will automatically call them for all entities in all frames.

Source: https://habr.com/ru/post/184666/


All Articles