Creating a modular game in JavaScript, part 3: Viewport and Organizing a spritesheet

Organizing Spritesheets
You may have seen that the skeleton spritesheet was organized by row and column, which makes sense for an animated sprite that constantly needs to change. However, even for static sprites, how you organize them can have big impacts on your code. Something I had already implemented as of last time, but didn’t talk about, is how I approached the spritesheet for the walls. Here it is:

There are sixteen sprites in four rows of four, one sprite for each combination of connections. To keep things efficient for dynamically created walls, these are arranged in a specific order. Consider each direction, right, left, down, up, as open (connected) or closed (not connected). Write those directions as four digits of binary, for example a sprite with two connections down and right would be written 1010. Now, it turns out that all 16 sprites we need when written this way are all numbers 0 through 15 written in binary. Thus, if organized in the proper order, can be easily referenced for assignment.
Implementation
When a wall instance is first created, in the constructor, we assume it’s closed on all sides so it starts off with a sprite matrix of 0000:
this.spriteMatrix = [0,0,0,0];
Before we draw the wall’s sprite though, we need to figure out how many neighbors the wall has and in which direction. When the map is generated, this function is called for each instance of the Wall class:
findNeighbors() {
let neighbors = [];
function checkProximity(a,b) {
return Math.hypot(a.centerX-b.centerX,a.centerY-b.centerY);
}
Wall.all.forEach(w => {
let d = checkProximity(this,w)
if (d<this.width+1) {neighbors.push(w)}
});
neighbors.forEach(w => {
this.updateMatrix(this, w);
})
}
For each wall instance, we find all the other wall instances that are adjacent and add those to the neighbors list. For each neighbor, we update the spriteMatrix to open the appropriate side.
updateMatrix(obj1, obj2) {
//spritesheet is organized in binary
let right = !!(obj1.centerX<obj2.centerX);
let left = !!(obj1.centerX>obj2.centerX);
let down = !!(obj1.centerY<obj2.centerY);
let up = !!(obj1.centerY>obj2.centerY);
if (right) {this.spriteMatrix[0] = 1}
if (left) (this.spriteMatrix[1] = 1)
if (down) {this.spriteMatrix[2] = 1}
if (up) (this.spriteMatrix[3] = 1)
}
Because we’re only running this code for adjacent wall objects, it’s fairly simple to check which direction they’re in based on coordinates.
Finally, we can use a few getters to parse the row and column of the correct sprite based on the binary spritecode:
//Convert the matrix to a binary string and convert it to an integer
get spriteCode() {return parseInt(this.spriteMatrix.join(""), 2);}
get spriteX() {return this.spriteCode % 4;}
get spriteY() {return Math.floor(this.spriteCode/4);}
Then we can use that information to draw the appropriate sprite.
draw() {
mapCtx.drawImage(this.img, this.spriteX * this.rawWidth, this.spriteY * this.rawHeight * 2, this.rawWidth, this.rawHeight * 2, this.x, this.y-this.height, this.width, this.height * 2);
}
Viewport
Because that was a relatively short tutorial, I’m including another one for making a viewport that will follow the player around the map. I’m not going to go over the player code yet, since there’s not much to show. It’s basically the code for the skeleton except the target, and thus movement, is set with the arrow keys. The viewport will be a new class that calculates its coordinates based on the player’s coordinates:
class Viewport {
constructor() {
this.width = gameCanvas.width;
this.height = gameCanvas.height;
} get centerX() {return this.x+this.width/2;}
get centerY() {return this.y+this.height/2;}
get xMin() {
if (Player.all[0]) {
let viewLeft = Player.all[0].centerX-this.width/2;
if (viewLeft < 0) {viewLeft = 0;}
if (viewLeft > mapCanvas.width-this.width) {
viewLeft = mapCanvas.width-this.width;
}
return viewLeft;
}
return 0;
}
get yMin() {
if (Player.all[0]) {
let viewUp = Player.all[0].centerY-this.height/2;
if (viewUp < 0) {viewUp = 0;}
if (viewUp > mapCanvas.height-this.height) {
viewUp = mapCanvas.height-this.height;
}
return viewUp;
}
return 0;
}
get xMax() {return this.xMin+this.width;}
get yMax() {return this.yMin+this.height;}
}
Basically we check if there’s a player, and if there isn’t, default the view to the upper left corner of the map. If there is a player, set the view so that the player is in the center, but restrict the view so it doesn’t go past the map borders. Because these are getters, they will recalculate when called, which we will do every time we execute the drawLoop on the game. Before we were drawing directly onto the canvas that was in the html, but now we’re drawing onto an offscreen canvas just like with the map generator. Then, we capture the view we want according to the viewport and draw that onto the onscreen canvas.
let view = new Viewport();//Draw the view of the map onto the main game canvas
function renderView(img) {
gameCtx.drawImage(img, view.xMin, view.yMin, view.width, view.height, 0, 0, gameCanvas.width, gameCanvas.height);
}function drawLoop() {
mapCtx.clearRect(0, 0, mapCanvas.width, mapCanvas.height); objects.sort(compareZAxis);
drawObjects(objects); //Render view of game onscreen
gameCtx.clearRect(0, 0, gameCanvas.width, gameCanvas.height);
renderView(mapCanvas); window.requestAnimationFrame(drawLoop);
}
Try it out: