Transformation and choosing a graphics interface in JavaScript

1. Transformation

But what if we want our character to walk to the left instead of to the right? We could draw another set of sprites, of course. But we can also instruct the canvas to draw the picture the other way round.

Calling the scale method will cause anything drawn after it to be scaled. This method takes two parameters, one to set a horizontal scale and one to set a vertical scale.

<canvas></canvas>

<script>

let cx = document.querySelector(“canvas”).getContext(“2d”);

cx.scale(3, .5);

cx.beginPath();

cx.arc(50, 50, 40, 0, 7);

cx.lineWidth = 3;

cx.stroke();

</script>

Because of the call to scale, the circle is drawn three times as wide and half as high.

Scaling will cause everything about the drawn image, including the line width, to be stretched out or squeezed together as specified. Scaling by a negative amount will flip the picture around. The flipping happens around

point (0,0), which means it will also flip the direction of the coordinate sys­tem. When a horizontal scaling of -1 is applied, a shape drawn at x position 100 will end up at what used to be position -100.

So to turn a picture around, we can’t simply add cx.scale(-l, 1) before the call to drawImage because that would move our picture outside of the canvas, where it won’t be visible. You could adjust the coordinates given to drawImage to compensate for this by drawing the image at x position -50 instead of 0. Another solution, which doesn’t require the code that does the drawing to know about the scale change, is to adjust the axis around which the scaling happens.

There are several other methods besides scale that influence the coordinate system for a canvas. You can rotate subsequently drawn shapes with the rotate method and move them with the translate method. The interesting—and confusing—thing is that these transformations stack, meaning that each one happens relative to the previous transformations.

So if we translate by 10 horizontal pixels twice, everything will be drawn 20 pixels to the right. If we first move the center of the coordinate system to (50,50) and then rotate by 20 degrees (about 0.1n radians), that rotation will happen around point (50,50).

But if we first rotate by 20 degrees and then translate by (50,50), the translation will happen in the rotated coordinate system and thus produce a different orientation. The order in which transformations are applied matters.

To flip a picture around the vertical line at a given x position, we can do the following:

function flipHorizontally(context, around) {

context.translate(around, 0);

context.scale(-1, 1);

context.translate(-around, 0);

}

We move the y-axis to where we want our mirror to be, apply the mir­roring, and finally move the y-axis back to its proper place in the mirrored universe. The following picture explains why this works.

This shows the coordinate systems before and after mirroring across the central line. The triangles are numbered to illustrate each step. If we draw a triangle at a positive x position, it would, by default, be in the place where triangle 1 is. A call to flipHorizontally first does a translation to the right, which gets us to triangle 2. It then scales, flipping the triangle over to posi­tion 3. This is not where it should be, if it were mirrored in the given line. The second translate call fixes this—it “cancels” the initial translation and makes triangle 4 appear exactly where it should.

We can now draw a mirrored character at position (100,0) by flipping the world around the character’s vertical center.

<canvas></canvas>

<script>

let cx = document.querySelector(“canvas”).getContext(“2d”);

let img = document.createElement(“img”);

img.src = “img/player.png”;

let spriteW = 24, spriteH = 30;

img.addEventListener(“load”, () => {

flipHorizontally(cx, 100 + spriteW / 2);

cx.drawImage(img, 0, 0, spriteW, spriteH,

100, 0, spriteW, spriteH);

});

</script>

2. Storing and Clearing Transformations

Transformations stick around. Everything else we draw after drawing that mirrored character would also be mirrored. That might be inconvenient.

It is possible to save the current transformation, do some drawing and transforming, and then restore the old transformation. This is usu­ally the proper thing to do for a function that needs to temporarily trans­form the coordinate system. First, we save whatever transformation the code that called the function was using. Then the function does its thing, adding more transformations on top of the current transformation. Finally, we revert to the transformation we started with.

The save and restore methods on the 2D canvas context do this trans­formation management. They conceptually keep a stack of transformation states. When you call save, the current state is pushed onto the stack, and when you call restore, the state on top of the stack is taken off and used as

the context’s current transformation. You can also call resetTransform to fully reset the transformation.

The branch function in the following example illustrates what you can do with a function that changes the transformation and then calls a function (in this case itself), which continues drawing with the given transformation.

This function draws a treelike shape by drawing a line, then moving the center of the coordinate system to the end of the line, and then calling itself twice—first rotated to the left and then rotated to the right. Every call reduces the length of the branch drawn, and the recursion stops when the length drops below 8.

<canvas width=”600″ height=”300″></canvas>

<script>

let cx = document.querySelector(“canvas”).getContext(“2d”);

function branch(length, angle, scale) {

cx.fillRect(0, 0, 1, length);

if (length < 8) return;

cx.save();

cx.translate(0, length);

cx.rotate(-angle);

branch(length * scale, angle, scale);

cx.rotate(2 * angle);

branch(length * scale, angle, scale);

cx.restore();

}

cx.translate(300, 0);

branch(60, 0.5, 0.8);

</script>

 

If the calls to save and restore were not there, the second recursive call to branch would end up with the position and rotation created by the first call. It wouldn’t be connected to the current branch but rather to the inner­most, rightmost branch drawn by the first call. The resulting shape might also be interesting, but it is definitely not a tree.

3. Back to the Game

We now know enough about canvas drawing to start working on a canvas- based display system for the game from the previous chapter. The new dis­play will no longer be showingjust colored boxes. Instead, we’ll use drawImage to draw pictures that represent the game’s elements.

We define another display object type called CanvasDisplay, supporting the same interface as DOMDisplay from “Drawing” on page 273, namely, the methods syncState and clear.

This object keeps a little more information than DOMDisplay. Rather than using the scroll position of its DOM element, it tracks its own viewport, which tells us what part of the level we are currently looking at. And finally, it keeps a flipPlayer property so that even when the player is standing still, it keeps facing the direction it last moved in.

class CanvasDisplay {

constructor(parent, level) {

this.canvas = document.createElement(“canvas”);

this.canvas.width = Math.min(600, level.width * scale);

this.canvas.height = Math.min(450, level.height * scale);

parent.appendChild(this.canvas);

this.cx = this.canvas.getContext(“2d”);

this.flipPlayer = false;

this.viewport = { left: 0, top: 0,

width: this.canvas.width / scale, height: this.canvas.height / scale

};

}

clear() {

this.canvas.remove();

}

}

The syncState method first computes a new viewport and then draws the game scene at the appropriate position.

CanvasDisplay.prototype.syncState = function(state) {

this.updateViewport(state);

this.clearDisplay(state.status);

this.drawBackground(state.level);

this.drawActors(state.actors);

};

Contrary to DOMDisplay, this display style does have to redraw the back­ground on every update. Because shapes on a canvas are just pixels, after we draw them there is no good way to move them (or remove them). The only way to update the canvas display is to clear it and redraw the scene. We may also have scrolled, which requires the background to be in a different position.

The updateViewport method is similar to DOMDisplay’s scrollPlayerIntoView method. It checks whether the player is too close to the edge of the screen and moves the viewport when this is the case.

CanvasDisplay.prototype.updateViewport = function(state) {

let view = this.viewport, margin = view.width / 3;

let player = state.player;

let center = player.pos.plus(player.size.times(0.5));

if (center.x < view.left + margin) {

view.left = Math.max(center.x – margin, 0);

} else if (center.x > view.left + view.width – margin) {

view.left = Math.min(center.x + margin – view.width, state.level.width – view.width);

}

if (center.y < view.top + margin) {

view.top = Math.max(center.y – margin, 0);

} else if (center.y > view.top + view.height – margin) {

view.top = Math.min(center.y + margin – view.height, state.level.height – view.height);

}

};

The calls to Math.max and Math.min ensure that the viewport does not end up showing space outside of the level. Math.max(x, 0) makes sure the result­ing number is not less than zero. Math.min similarly guarantees that a value stays below a given bound.

When clearing the display, we’ll use a slightly different color depending on whether the game is won (brighter) or lost (darker).

CanvasDisplay.prototype.clearDisplay = function(status) {

if (status == “won”) {

this.cx.fillStyle = “rgb(68, 191, 255)”;

}

else if (status == “lost”) {

this.cx.fillStyle = “rgb(44, 136, 214)”;

}

else {

this.cx.fillStyle = “rgb(52, 166, 251)”;

}

this.cx.fillRect(0, 0,

this.canvas.width, this.canvas.height);

};

To draw the background, we run through the tiles that are visible in the current viewport, using the same trick used in the touches method from the previous chapter.

let otherSprites = document.createElement(“img”);

otherSprites.src = “img/sprites.png”;

CanvasDisplay.prototype.drawBackground = function(level) {

let {left, top, width, height} = this.viewport;

let xStart = Math.floor(left);

let xEnd = Math.ceil(left + width);

let yStart = Math.floor(top);

let yEnd = Math.ceil(top + height);

for (let y = yStart; y < yEnd; y++) {

for (let x = xStart; x < xEnd; x++) {

let tile = level.rows[y][x];

if (tile == “empty”) continue;

let screenX = (x – left) * scale;

let screenY = (y – top) * scale;

let tileX = tile == “lava” ? scale : 0;

this.cx.drawImage(otherSprites,tileX, 0, scale, scale,screenX, screenY, scale, scale);

}

}

};

Tiles that are not empty are drawn with drawImage. The otherSprites image contains the pictures used for elements other than the player. It con­tains, from left to right, the wall tile, the lava tile, and the sprite for a coin.

Background tiles are 20 by 20 pixels since we will use the same scale that we used in DOMDisplay. Thus, the offset for lava tiles is 20 (the value of the scale binding), and the offset for walls is 0.

We don’t bother waiting for the sprite image to load. Calling drawImage with an image that hasn’t been loaded yet will simply do nothing. Thus, we might fail to draw the game properly for the first few frames, while the image is still loading, but that is not a serious problem. Since we keep updat­ing the screen, the correct scene will appear as soon as the loading finishes.

The walking character shown earlier will be used to represent the player. The code that draws it needs to pick the right sprite and direction based on the player’s current motion. The first eight sprites contain a walking ani­mation. When the player is moving along a floor, we cycle through them based on the current time. We want to switch frames every 60 milliseconds, so the time is divided by 60 first. When the player is standing still, we draw the ninth sprite. Duringjumps, which are recognized by the fact that the vertical speed is not zero, we use the tenth, rightmost sprite.

Because the sprites are slightly wider than the player object—24 instead of 16 pixels, to allow some space for feet and arms—the method has to adjust the x-coordinate and width by a given amount (playerXOverlap).

let playerSprites = document.createElement(“img”);

playerSprites.src = “img/player.png”;

const playerXOverlap = 4;

CanvasDisplay.prototype.drawPlayer = function(player, x, y,

width, height){

width += playerXOverlap * 2; x -= playerXOverlap;

if (player.speed.x != 0) {

this.flipPlayer = player.speed.x < 0;

}

let tile = 8;

if (player.speed.y != 0) {

tile = 9;

}

else if (player.speed.x != 0) {

tile = Math.floor(Date.now() / 60) % 8;

}

this.cx.save();

if (this.flipPlayer) {

flipHorizontally(this.cx, x + width / 2);

}

let tileX = tile * width;

this.cx.drawImage(playerSprites, tileX, 0, width, height,

x, y, width, height);

this.cx.restore();

};

The drawPlayer method is called by drawActors, which is responsible for drawing all the actors in the game.

CanvasDisplay.prototype.drawActors = function(actors) {

for (let actor of actors) {

let width = actor.size.x * scale; let height = actor.size.y * scale;

let x = (actor.pos.x – this.viewport.left) * scale;

let y = (actor.pos.y – this.viewport.top) * scale;

if (actor.type == “player”) {

this.drawPlayer(actor, x, y, width, height);

} else {

let tileX = (actor.type == “coin” ? 2 : 1) * scale;

this.cx.drawImage(otherSprites,

tileX, 0, width, height, x, y, width, height);

}

}

};

When drawing something that is not the player, we look at its type to find the offset of the correct sprite. The lava tile is found at offset 20, and the coin sprite is found at 40 (two times scale).

We have to subtract the viewport’s position when computing the actor’s position since (0,0) on our canvas corresponds to the top left of the view­port, not the top left of the level. We could also have used translate for this. Either way works.

That concludes the new display system. The resulting game looks some­thing like this:

4. Choosing a Graphics Interface

So when you need to generate graphics in the browser, you can choose between plain HTML, SVG, and canvas. There is no single best approach that works in all situations. Each option has strengths and weaknesses.

Plain HTML has the advantage of being simple. It also integrates well with text. Both SVG and canvas allow you to draw text, but they won’t help you position that text or wrap it when it takes up more than one line. In an HTML-based picture, it is much easier to include blocks of text.

SVG can be used to produce crisp graphics that look good at any zoom level. Unlike HTML, it is designed for drawing and is thus more suitable for that purpose.

Both SVG and HTML build up a data structure (the DOM) that rep­resents your picture. This makes it possible to modify elements after they are drawn. If you need to repeatedly change a small part of a big picture in response to what the user is doing or as part of an animation, doing it in a canvas can be needlessly expensive. The DOM also allows us to register mouse event handlers on every element in the picture (even on shapes drawn with SVG). You can’t do that with canvas.

But canvas’s pixel-oriented approach can be an advantage when drawing a huge number of tiny elements. The fact that it does not build up a data structure but only repeatedly draws onto the same pixel surface gives canvas a lower cost per shape.

There are also effects, such as rendering a scene one pixel at a time (for example, using a ray tracer) or postprocessing an image with JavaScript (blurring or distorting it), that can be realistically handled only by a pixel- based approach.

In some cases, you may want to combine several of these techniques.

For example, you might draw a graph with SVG or canvas but show textual information by positioning an HTML element on top of the picture.

For nondemanding applications, it really doesn’t matter much which interface you choose. The display we built for our game in this chapter could have been implemented using any of these three graphics technolo­gies since it does not need to draw text, handle mouse interaction, or work with an extraordinarily large number of elements.

Source: Haverbeke Marijn (2018), Eloquent JavaScript: A Modern Introduction to Programming, No Starch Press; 3rd edition.

Leave a Reply

Your email address will not be published. Required fields are marked *