To tie up our global variable assignments, we'll create variables for the viewport, player, map, and canvas, and an object for the FPS display:
var viewport;
var player;
var map;
var context;
var fps = {
count: 0,
shown: 0,
last: 0
};
Now, let's create a custom function called Setup()
that will create a pointer to the canvas, setup the viewport, and draw in the player and map:
function Setup() {
context = document.getElementById("game").getContext("2d");
viewport = new Viewport(0, 0, config.win.width, config.win.height);
player = new Player(4, 3);
map = new Map("Map");
Sizing();
setInterval(function() {
fps.shown = fps.count;
}, 1000);
}
The viewport
object points to a custom class, Viewport
, which we'll cover in a bit, and defines a display of the size of the browser's viewport using our pre-defined global config
values.
The Sizing()
function, another method we'll dive into shortly, is invoked to set the width and height of the window
and canvas
elements.
We also set up a recurring loop that updates the FPS count every second.
Here is our Sizing()
function in action that resets the width and height of the window and canvas
element, the number of tiles horizontally and vertically on the screen, and the viewport's center. This function is called when the page loads or when the window is resized manually by the player:
function Sizing() {
config.win = {
width: window.innerWidth,
height: window.innerHeight
};
config.tiles = {
x: Math.ceil(config.win.width / config.size.tile),
y: Math.ceil(config.win.height / config.size.tile)
}
config.center = {
x: Math.round(config.tiles.x / 2),
y: Math.round(config.tiles.y / 2)
}
viewport.x = 0;
viewport.y = 0;
viewport.w = config.win.width;
viewport.h = config.win.height;
context.canvas.width = config.win.width;
context.canvas.height = config.win.height;
}
Resetting the canvas size and other pre-defined object variables is crucial to prevent skewing of the game display.
Let's create a function to log the FPS count and player coordinates to the div
container:
function Log(type, text) {
document.getElementById(type).innerHTML = text;
}
This method accepts two arguments, type
, which is the div
element with ID fps
, which is added in the event you want to add multiple div
containers to log data, and the text
argument, which is the text data that will output on the screen.
Next, we have a custom function, LoadURL()
, that accepts a single argument, url
, which is the relative URL to a JSON file within our code, and loads JSON data for our map:
function LoadURL(url, callback) {
let http = new XMLHttpRequest();
http.overrideMimeType("application/json");
http.open("GET", url + "?v=" + new Date().getTime(), true);
http.onreadystatechange = function() {
if (http.readyState === 4 && http.status == "200") {
callback(http.responseText);
}
}
http.send(null);
}
If the JSON data is found and parsed correctly, it's returned to the originating process using the callback
method.
And now we get to the game loop in the Loop()
function, a custom method that loops within itself using the window.requestAnimationFrame()
method to handle animations of the player and map within the canvas
element:
function Loop() {
window.requestAnimationFrame(Loop);
Sizing();
viewport.center();
map.draw();
player.draw();
if (!fps.last) {
fps.last = Date.now();
fps.count = 0;
}
let delta = (Date.now() - fps.last) / 1000;
fps.last = Date.now();
fps.count = Math.round(1 / delta);
Log("fps", "FPS: " + fps.shown);
}
As the loop continues, the Sizing()
method is called to handle any manual resizing of the browser window, so the config
values and canvas
size are updated accordingly. Not doing this will cause unwanted stretching of the game display.
We continue by re-centering the viewport with viewport.center()
, updating the map's on screen position with map.draw()
, and updating the player's position and sprite frame with player.draw()
.
Finally, we run a few calculations to determine the frames per second to display them to the user.
Once the page is loaded and all elements are available in the DOM, we'll call our custom Setup()
function that we defined a bit ago to process all the page elements:
window.onload = function() {
Setup();
};
If the user resizes the window, our custom Sizing()
function is called to recalculate and reposition the window and canvas
element size along with the number of tiles on the screen and the viewport's center:
window.onresize = function() {
Sizing();
};
This script handles the loading and drawing of the map using the JSON data pulled from a local file.
Map
ClassHere, we define our Map
class and its associated variables:
const Map = function(title) {
this.data = {};
this.tiles = [];
this.timer = setInterval("map.frame()", 750);
this.load(title);
};
this.data
: contains the map data loaded from our JSON file.this.tiles
: contains pointers to each of the tile images defined in the JSON file.this.timer
: loops through the map.frame()
function every 3/4 seconds and animates the map sprites, giving the tiles an animated effect and making the map more lively.load()
is a function within our Map
class, accepting a single argument, title
, which is the name of the JSON file in title-case format:
load: function(title) {
LoadURL("assets/json/" + title.toString().toLowerCase() + ".json", function(result) {
map.data = JSON.parse(result);
map.data.frame = 0;
let init = false;
let loaded = 0;
for (let i = 0; i < map.data.assets.length; i++) {
map.tiles.push(new Image());
map.tiles[i].src = "assets/img/tile/" + map.data.assets[i].file_name + ".png?v=" + new Date().getTime();
map.tiles[i].onload = function() {
loaded++;
if (!init && loaded == map.data.assets.length) {
init = true;
Loop();
}
};
}
});
}
This method loads our map data using the LoadURL()
function we defined earlier, loops through each of the unique tile assets, and loads the image files into memory so we can display them in our map.
Next, our draw()
method within the Map
class handles painting the map tiles to the canvas
element. This is a bit more complicated, so we'll split it up into multiple sections:
draw: function() {
...
}
There are four variables, x_min
, y_min
, x_max
, and y_max
, which determine the starting and ending horizontal and vertical positions of the map relative to the viewport:
let x_min = Math.floor(viewport.x / config.size.tile);
let y_min = Math.floor(viewport.y / config.size.tile);
let x_max = Math.ceil((viewport.x + viewport.w) / config.size.tile);
let y_max = Math.ceil((viewport.y + viewport.h) / config.size.tile);
These calculations are made to improve the processing of the player and map animations by cropping out the parts of the map that aren't visible in the viewport. Only the visible tiles are animated. This process is called occlusion culling and can be accomplished in both 2D and 3D environments.
Here's an example screenshot where the area within the red box contains the visible tiles and is output to the canvas
element. The rest of the map tiles are omitted from the output, improving the performance of the game and the animations overall:
We then run a few checks to see if the player has hit any of the four edges of the map and adjust our values accordingly:
if (x_min < 0) { x_min = 0; }
if (y_min < 0) { y_min = 0; }
if (x_max > map.width) { x_max = map.width; }
if (y_max > map.height) { y_max = map.height; }
Next, we loop through our viewable tile positions from start to finish and paint them to the canvas
element:
for (let y = y_min; y < y_max; y++) {
for (let x = x_min; x < x_max; x++) {
let value = this.data.layout[y][x] - 1;
let tile_x = Math.floor((x * config.size.tile) - viewport.x + (config.win.width / 2) - (viewport.w / 2));
let tile_y = Math.floor((y * config.size.tile) - viewport.y + (config.win.height / 2) - (viewport.h / 2));
if (value > -1) {
let frame = this.data.frame;
if (frame > this.data.assets[value].frames) {
frame = 0;
}
context.drawImage(
map.tiles[value],
frame * config.size.tile,
0,
config.size.tile,
config.size.tile,
tile_x,
tile_y,
config.size.tile,
config.size.tile
);
}
}
}
As we paint each tile, we loop through the two animated map sprite frames and output to the screen accordingly, giving the tall grass and water sprites that "blowing in the wind" effect with our sprite images.
Finally, we have the frame()
method that determines which frame of each of the map tile sprites should be displayed, either the first frame or the second frame:
frame: function() {
this.data.frame = (this.data.frame == 0) ? 1 : 0;
}
This script contains everything necessary to load, display, and control the player within the map.
Player
ClassLet's look at the Player
class and its associated functions and variables. Here is the class definition, accepting two arguments, tile_x
and tile_y
, which are the X and Y position of the player relative to the tiles on the map:
const Player = function(tile_x, tile_y) {
this.timer = setInterval("player.frame()", 125);
this.frames = [0.40, 0.42, 0.44, 0.46, 0.48, 0.50, 0.48, 0.46, 0.44, 0.42, 0.40];
this.sprite = new Image();
this.sprite.src = "assets/img/char/hero.png";
this.movement = {
moving: false,
key: 40,
frame: 1
};
this.pos = {
x: config.size.tile * tile_x,
y: config.size.tile * tile_y
};
this.tile = {
x: tile_x,
y: tile_y
};
this.torch = {
lit: false,
frame: 0
};
};
this.timer
: loops every 1/8 seconds to change the animation sprite frame of the player. These sprite frames are pre-determined in the global keys
object's f
array.this.frames
: the player has the option of toggle a torch by pressing the T
key on the keyboard. This variable controls the opacity of the surrounding torch frames. We'll get into this more in a bit.this.sprite
: the sprite image frames for the player.this.movement
: determines if the player is moving, what key is being pressed, and which animation frame the player is currently on as the sprite is animating.this.pos
: the X and Y position of the player on the map in pixels.this.tile
: the X and Y tile position of the player on the map relative to the tiles of the map.this.torch
: determines if the torch is being used and the opacity of the torch light's edge.First, let's explore the draw()
function:
draw: function() {
let frame = (player.movement.moving) ? keys[player.movement.key].f[player.movement.frame] : keys[player.movement.key].f[1];
let pos_x = Math.round(player.pos.x - viewport.x + (config.win.width / 2) - (viewport.w / 2));
let pos_y = Math.round(player.pos.y - viewport.y + (config.win.height / 2) - (viewport.h / 2));
this.light(pos_x, pos_y);
this.torch_func(pos_x, pos_y);
context.drawImage(
this.sprite,
frame * config.size.char,
0,
config.size.char,
config.size.char,
pos_x,
pos_y,
config.size.char,
config.size.char
);
}
Here, the player's frame is determined by whether or not the player is moving, then the position is calculated based on their location on the map relative to the viewport.
Next, we move the player highlight behind the player's new position. The position of the torch is also updated in the event the player toggles the torch on.
Then, we draw the player to the screen and move the map's position, keeping the player centered in the viewport.
Here, we create a highlight that's visible behind the player. The highlight is just a radial gradient effect added to separate the player from the map to make the player stand out more:
light: function(pos_x, pos_y) {
let light_x = pos_x + (config.size.tile / 2);
let light_y = pos_y + (config.size.tile / 2);
let radius = 100;
let radialGradient = context.createRadialGradient(light_x, light_y, 0, light_x, light_y, radius);
radialGradient.addColorStop(0, "rgba(238, 229, 171, 0.325)");
radialGradient.addColorStop(1, "rgba(238, 229, 171, 0)");
context.fillStyle = radialGradient;
context.arc(light_x, light_y, radius, 0, Math.PI * 2);
context.fill();
}
The player has a hidden torch that can be activated and deactivated by pressing the T
key on your keyboard. When activated, a large, pixelated gradient effect pulsates and surrounds the player:
To accomplish this effect, the torch_func()
method accepts two arguments, pos_x
and pos_y
, which are the player's X and Y positions on the map. If the torch is lit, we loop through the visible tiles and create a pixelated radial gradient effect around the player, hiding everything else that exceeds the surroundings of the torchlight, providing an eerie lighting effect:
torch_func: function(pos_x, pos_y) {
if (this.torch.lit) {
for (let y = 0; y < config.tiles.y; y++) {
for (let x = 0; x < config.tiles.x; x++) {
let distance = Math.sqrt((Math.pow(x - config.center.x, 2)) + (Math.pow(y - config.center.y, 2)));
let opacity = (distance / 4) - this.frames[this.torch.frame];
context.fillStyle = "rgba(0, 0, 0, " + opacity + ")";
context.fillRect((x * config.size.tile) - (config.size.tile / 2), (y * config.size.tile) - (config.size.tile / 2), config.size.tile, config.size.tile);
}
}
}
}
In the frame()
method, we loop through the player's movement and torch frames and update accordingly. As the player moves, it steps through each of the frames in regular intervals, making the player appear like they're walking. Once the animation is completed, it loops back to the beginning so the player appears to move in a linear motion.
The torch frames are also controlled here, giving the torch in the player's hand a glowing effect:
frame: function() {
this.movement.frame++;
if (this.movement.frame == 4) {
this.movement.frame = 0;
}
this.torch.frame++;
if (this.torch.frame == this.frames.length) {
this.torch.frame = 0;
}
player.movement.frame = this.movement.frame;
player.torch = this.torch;
}
The move()
method determines the player's movement across the map and adds collision detection for tiles that the player can't walk over. This method accepts two arguments, x
and y
, which are the player's current position relative to the map, in pixels.
Collisions are determined based on the JSON data when loading the map. Each tile has a collision flag represented by a boolean value. 1 means the player can walk across the tile, and 0 means they can't. As the player moves, their position is updated and a collision check is run.
The player
object is updated with the latest coordinates and the player's position is logged to the div
element:
move: function(x, y) {
let pos = {
x: Math.ceil(this.pos.x / config.size.tile),
y: Math.ceil(this.pos.y / config.size.tile)
};
let new_pos = {
x: Math.ceil((this.pos.x + x) / config.size.tile),
y: Math.ceil((this.pos.y + y) / config.size.tile)
};
for (let i = 0; i <= 1; i++) {
let tile = ((i == 0) ? map.data.layout[pos.y][new_pos.x] : map.data.layout[new_pos.y][pos.x]) - 1;
let collision = map.data.assets[tile].collision;
if (!collision) {
if (i == 0) {
this.pos.x += x;
this.tile.x = new_pos.x;
}
else {
this.pos.y += y;
this.tile.y = new_pos.y;
}
}
}
player = this;
Log("coords", "Coords: " + this.tile.x + ", " + this.tile.y);
}
The last piece of the player
script takes care of event handling. The only two events we have are keydown
and keyup
, detecting whether a key has been pressed or released so the player can be moved or stopped.
If the player presses any of the four arrow keys, the player.movement.moving
flag is set to true, the player's position will update, the player will animate, and the map will move accordingly. This is all done by referencing the pressed key against the keys
global object. If the key code matches any of these values, an action is performed by the player.
The active flag, a
, determines if that key is currently being pressed. If it is, its value is set to true
. This allows the player to move in multiple directions at once, or diagonally, instead of only four directions one at a time.
And, if the T
key is pressed, the torch will be lit or extinguished.
document.addEventListener("keydown", function(event) {
if (event.keyCode >= 37 && event.keyCode <= 40) {
player.movement.moving = true;
player.movement.key = event.keyCode;
for (let key in keys) {
if (key == event.keyCode) {
keys[key].a = true;
}
}
}
else {
switch (event.keyCode) {
case 84:
player.torch.lit = (player.torch.lit) ? false : true;
break;
}
}
});
When an arrow key is released, the global keys
object is looped to see if any remaining keys are pressed. If none remain, the player.movement.moving
flag is set to false, meaning the player is no longer moving.
document.addEventListener("keyup", function(event) {
let found = false;
for (let key in keys) {
if (key == event.keyCode) {
keys[key].a = false;
}
}
for (let key in keys) {
if (keys[key].a) {
player.movement.key = key;
found = true;
}
}
if (!found) {
player.movement.moving = false;
}
});
The last class we'll cover is the Viewport
class, available to keep the viewport positioned and centered with the player at all times.
Here is a quick look at the Viewport
class:
const Viewport = function(x, y, w, h) {
this.x = x;
this.y = y;
this.w = w;
this.h = h;
};
The variable assignments are as follows:
this.x
: top-left position of the viewport on the x-axis.this.y
: top-left position of the viewport on the y-axis.this.w
: width of the viewportthis.h
: height of the viewportThe following code snippet determines the player's position based on its movement, calculates its width and height in pixels, and divides it by two to capture the center of the viewport's position. The viewport's position is updated as the player moves, keeping the player centered on the screen at all times:
Viewport.prototype = {
center: function() {
let move_x = 0;
let move_y = 0;
let center_x = player.pos.x + (config.size.char / 2);
let center_y = player.pos.y + (config.size.char / 2);
for (let key in keys) {
if (keys[key].a) {
if (keys[key].x != 0) {
move_x = keys[key].x;
}
if (keys[key].y != 0) {
move_y = keys[key].y;
}
}
}
player.move(move_x, move_y);
viewport.scroll(center_x, center_y);
},
scroll: function(x, y) {
this.x = x - (this.w / 2);
this.y = y - (this.h / 2);
}
};
That was a deep dive into creating a 2D tile-based game with JavaScript. I really hope you had fun and learned something new as you followed along through the examples.
You can download or clone the full codeset from the GitHub repository. Feel free to play around with the code and turn it into something unique!
If you have any questions or comments, leave them in the comments section below, and make sure to subscribe to the newsletter for more coding updates!
Comments
Lyndon