-
Notifications
You must be signed in to change notification settings - Fork 57
Under the Hood
For the most part, the code in this project should be pretty straightforward to read; it's boilerplate after all, and not intended to be magical. However, there are a couple of parts that may be interesting to know how they work -- or more importantly, why they work, since there are some things that require more knowledge to write than to read. You don't need to understand any of this to use the project, but this page addresses some of those parts for those who want to learn more.
Scrolling the canvas is pretty simple. Fundamentally, it's just a call to context.translate(xDelta, yDelta)
. The translate method takes care of shifting the viewport.
There are two additional tricky bits. The first is that when you translate the canvas the origin shifts with it (so it could be off-screen), and this makes it harder to convert from a physical location on the screen into a virtual location in your canvas world. H5CBP deals with that by tracking how much the canvas has been translated in world.xOffset
and world.yOffset
. It then uses those values to convert things like the on-screen mouse location into canvas-space coordinates.
The second tricky bit is knowing how far to translate the canvas. H5CBP provides three ways of determining this:
- Mouse.Scroll translates based on the mouse's distance to the edges of the canvas.
- By default, the canvas scrolls if a Player object gets near an edge of the canvas.
-
world.centerViewportAround()
translates the canvas so that a specific point is in the center of the viewable area.
All of these just do a little math based on distances.
This is more or less the code that gets called whenever the pointer (mouse or touch) moves:
// Get the pointer location
var x = e.pageX || e.originalEvent.touches[0].pageX;
var y = e.pageY || e.originalEvent.touches[0].pageY;
// The position we want is relative to the canvas
var mouseX = x - $canvas.offset().left;
var mouseY = y - $canvas.offset().top;
What this does is adjust the mouse location coordinates to be relative to the upper-left corner of the canvas instead of the upper-left corner of the page. It actually gets a little more complicated than this because H5CGB incorporates support for converting those on-screen coordinates to be relative to the canvas origin, even if the user is zoomed in or out; and we only track the mouse position when it's over the canvas.
Since objects rendered onto the canvas aren't DOM elements, there's no native way for the mouse to interact with them specifically (and not the entire canvas). The basis for mouse interaction is determining whether the mouse is hovered over a given object, so we implement that using this code:
return Mouse.Coords.worldX() > obj.x && Mouse.Coords.worldX() < obj.x + obj.width &&
Mouse.Coords.worldY() > obj.y && Mouse.Coords.worldY() < obj.y + obj.height;
This checks whether the X- and Y-coordinates of the mouse in the world (as calculated previously) fall inside of the box defined by an x
, y
, width
, and height
property of an object.
H5CGB provides an event system for objects on the canvas that works a lot like the DOM event system (as wrapped by jQuery). This is necessary because as noted above there is no native way for users to interact with objects on a canvas like they can with DOM objects. All of the default supported events have to do with the pointer (mouse or touch) and they all work by listening for the corresponding event on the canvas, and then checking whether the mouse is hovered over an object with a listener for that event (as determined above). When an object listens for an event, it basically just gets added to a list that maps events to objects and their callback functions. The original event object gets passed on from the canvas event to the object callback.
In order to understand how zooming works, you first need to understand the difference between the width
/height
attributes of a canvas and the CSS width/height:
<canvas width="100" height="100" style="width: 200px; height: 200px;"></canvas>
Above, the attributes are 100px and the CSS is 200px. This means that the canvas will display at 200px * 200px on the screen, but only 100px * 100px will actually be calculated, and then it will be scaled up to match the CSS size.
Zooming works by changing the ratio of the CSS size to the attribute size (the code refers to this as "physical" or "displayed" size versus "world" size, respectively). Here is the (lightly edited) method that does the work:
// Scale by a factor of `factor`, then center around the `x` and `y` coordinates
this.scaleResolution = function(factor, x, y) {
canvas.width = Math.round(this.originalCanvasWidth*factor);
canvas.height = Math.round(this.originalCanvasHeight*factor);
this._actualXscale = canvas.width / this.originalCanvasWidth;
this._actualYscale = canvas.height / this.originalCanvasHeight;
this.xOffset = Math.round(Math.min(this.width - canvas.width, Math.max(0, x - canvas.width / 2)));
this.yOffset = Math.round(Math.min(this.height - canvas.height, Math.max(0, y - canvas.height / 2)));
context.translate(-this.xOffset, -this.yOffset);
this.scale = factor;
};
The math to do this isn't that bad, and zooming in actually provides a pretty significant performance boost. However, it creates some additional complications with tracking the mouse location because the on-screen location of the mouse no longer maps cleanly to the "world" pixels. H5CBP takes care of this for you in Mouse.Coords by adjusting for the "actual scale" change on each axis.
Every time you draw a new frame onto the canvas in an animation, you have to clear the entire canvas and then draw everything back onto it. Sometimes this can be a bottleneck (particularly in certain edge cases, like when drawing text or images that haven't been cached). Also, most of the time there are some things in a scene that never or rarely move and some things that move a lot. In both of those cases, it can be useful to have an intermediate buffer that changes less frequently than the whole canvas frame. This is where Layers come in.
Layers are literally invisible <canvas>
es with some extra goodies. You can treat them just like any other canvas, drawing onto their graphics contexts. The reason this works is because drawing a <canvas>
onto another <canvas>
is fast (technically, it's a blitting operation). Layers can be thought of as graphical caches.
H5CGB Layers provide some useful helpers, like support for parallax scrolling, positioning over the canvas, and debugging.
Most drawing in H5CGB is pretty straightforward. The one thing worth noting is the way images are drawn. Using the default Canvas APIs, you have to manually load an image into memory in JavaScript, then draw the DOM image object onto the canvas after it's loaded. This is annoying, and done incorrectly it can result in lots of unintentional loading, images popping onto the canvas after a delay, etc.
H5CGB overrides the drawImage()
method so that images can be drawn directly from their URLs, with support for pre-loading and caching. (It also helpfully adds support for drawing a number of other things, like Layers and Sprites.) The caching is pretty simple and just involves a map from image file paths to the DOM image object. Drawing an image object looks something like this, with a "finished loading" callback added:
src = image._src || image.src; // check for preloaded src
if (!src) { // can't draw an empty image
return;
}
if (!Caches.images[src]) { // cache the image by source
Caches.images[src] = image;
}
if (image.complete || (image.width && image.height)) { // draw loaded images
_drawImage(image, x, y, w, h, sx, sy, sw, sh); // native drawImage call
}
else if (image._src) { // We've already tried to draw this one
return;
}
The naive way to move objects in code is to simply change the object's position. For example, to move an object across the canvas, you might write obj.x += 10;
inside your animation loop. This is called Euler integration, and it suffers from the issue that it is dependent on the frame rate: if your game is running slowly, your object will also appear to move slowly, whereas if your game is running quickly, your object will appear to move quickly.
One solution is to multiply the movement amount by the amount of time that has passed between rendering frames. For example, if you want your object to move 600 pixels per second, you might write obj.x += 600 * delta
. That way your object will move a constant distance over time. However, at low frame rates, your object will be moving large distances every frame, which can cause it to do strange things like move through walls. At high frame rates, computing your physics might take longer than the amount of time between frames, which will cause your application to freeze and crash. Additionally, we would like to achieve perfect reproducibility. That is, every time we run the application with the same input, we would like exactly the same output. If we have variable deltas, our output will diverge the longer the program runs due to accumulated rounding errors, even at normal frame rates.
H5CGB solves this by separating physics updates from frame refreshes. The physics engine receives (close to) fixed-size time deltas, while the rendering engine determines how many physics updates should occur per frame. The basic formula in the animation loop (i.e. the rendering side) is pretty simple:
var frameDelta = App.timer.getDelta();
while (frameDelta > 0 && !App.isGameOver) {
App.physicsDelta = Math.min(frameDelta, 1 / App.MAX_FPS);
update(App.physicsDelta, App.physicsTimeElapsed);
frameDelta -= App.physicsDelta;
}
And on the physics side, we can safely consume the time delta. Here we're using the midpoint formula, applying velocity in steps, because it's significantly more accurate than adding the velocity to the position up-front. You might read elsewhere that Runge-Kutta integration or RK4 is the recommended integration method; H5CGB doesn't use that because it's slower, more complicated, and not significantly more accurate unless you're dealing with things like elastic forces. (If that's what you're doing, you should probably be using a physics library.)
var delta = App.physicsDelta, d2 = delta / 2;
// Apply half acceleration (first half of midpoint formula)
this.xVelocity += this.xAcceleration*d2;
this.yVelocity += this.yAcceleration*d2;
/* ... don't let diagonal movement be faster than axial movement ... */
// Apply thrust
this.x += this.xVelocity*delta;
this.y += this.yVelocity*delta;
// Apply half acceleration (second half of midpoint formula)
this.xVelocity += this.xAcceleration*d2;
this.yVelocity += this.yAcceleration*d2;
// Clip
this.stayInWorld();
You can think of Collections as fancy arrays, and work with them the same way you would work with arrays. Collections support array access with square brackets (e.g. coll[0]
) and the length
property, and they show up in the JavaScript console the same way arrays do. There is one major difference: assigning directly to a numeric index of a collection that has not yet been allocated causes unexpected behavior.
This works because the browser considers any object with a length
property, numeric indices, and a splice
method to be an "array-like object," a special category of object that includes arrays, the arguments
special keyword, and (except in IE < 9) any list returned by a DOM command.