Painter is a bare-bones drawing toolkit for HTML5 canvas. It takes away a lot of boilerplate code, like setting up the canvas, creating the game loop, listening for events, etc.
Open index.html
in your browser and main.js
in a code editor. That's
it for the setup.
There are two base functions you can edit: setup()
and render()
.
setup()
is called once at load time, right before the game loop starts. This is where the Painter instance is initialized (more or less like a class constructor).render(delta_time)
is called once per iteration of the game loop. This is where the individual frames are drawn.delta_time
is the amount of time elapsed since lastrender()
, in fractional seconds (used to decouple animation speed from frame rate).
In addition, there are many (optional) helper methods and properties you
can use. Read through painter.js
and look at the demo/
directory to get a feel for what is available.
Open main.js
and edit the demo code to draw a 70×70px red square at
the center top of the canvas (think "screen", as by default the canvas
takes up the whole screen).
class App extends Painter {
render() {
this.ctx.clear();
this.ctx.fillStyle = "red";
this.ctx.fillRect(this.rect.cx - 35, this.rect.y, 70, 70);
}
}
A few things to note:
this.rect
is an instance ofRect
, a class that holds frequently used properties of the canvas, like X, Y, width, height, etc. (seepainter.js
).cx
andy
are properties ofRect
, and represent the center on the X axis (x + (width / 2)
) and the top of the canvas respectively.this.ctx.clear()
is a convenience method that clears the entire canvas. Equivalent to the longerthis.ctx.clearRect(x, y, w, h)
.- These are all just helpers, you could very well do the same manually. And this is true for all the other helpers.
Let's refactor the code a bit and add velocity to the square.
class App extends Painter {
setup() {
this.square = {
x: this.rect.cx - 35,
y: this.rect.y,
w: 70,
h: 70,
vy: 400, // Add velocity property.
};
}
render(delta_time) {
this.ctx.clear();
this.ctx.fillStyle = "red";
// Move square down (apply velocity).
this.square.y += this.square.vy * delta_time;
// Reset position when it falls out.
if (this.square.y > this.rect.yh) {
this.square.y = this.rect.y - this.square.h;
}
// Draw square.
const { x, y, w, h } = this.square;
this.ctx.fillRect(x, y, w, h);
}
}
A few things to note:
- The square's properties are now part of the state. This way, they are easier to update and keep track of.
vy
means velocity in the Y direction. This is how much the square moves in pixels per second (modulated bydelta_time
).- On each iteration, the square's velocity is added to its current Y
position, making it fall (
this.square.y += this.square.vy
). - Velocity is modulated by
delta_time
(this.square.vy * delta_time
), otherwise it would fall faster with faster frame rates and slower with slower frame rates. If for instancedelta_time = 0.017
, it means time has advanced by 0.017 seconds, so we want the square to move400px/s * 0.017s = 6.8px
. - Finally, the square is teleported back to the top of the canvas if it
falls out on the bottom. This condition uses
yh
, another helper property ofRect
, shorthand fory + h
(= bottom).
Now try and tweak it a little to use gravity (acceleration) instead of a
constant speed. Also, try and find a way to pause the simulation by
hitting the space key (hint: you can overload key_press_event(key)
).
(Solution included in next step).
Lastly, we'll add some rudimentary collision detection with the mouse. If the mouse pointer is in the square, we want to color it green instead of red.
class App extends Painter {
setup() {
this.gravity = 750; // Gravity constant, determined empirically.
this.square = {
x: this.rect.cx - 35,
y: this.rect.y,
w: 70,
h: 70,
vy: 0, // This time, velocity starts at 0.
};
this.is_paused = false;
}
key_press_event(key) {
// Toggle is_paused every time the space key is pressed down.
if (key === " ") this.is_paused = !this.is_paused;
}
render(delta_time) {
// If paused, simply don't clear/update/render.
if (this.is_paused) return;
this.ctx.clear();
// Increase velocity by gravity constant.
this.square.vy += this.gravity * delta_time;
// Apply velocity.
this.square.y += this.square.vy * delta_time;
if (this.square.y > this.rect.yh) {
this.square.y = this.rect.y - this.square.h;
// Now we also need to reset the velocity, otherwise
// it would accelerate forever.
this.square.vy = 0;
}
const { x, y, w, h } = this.square;
// Move collision detection after state updates.
this.ctx.fillStyle = "red";
// Check collision with mouse pointer.
if (
this.mouse.x >= x &&
this.mouse.x <= x + w &&
this.mouse.y >= y &&
this.mouse.y <= y + h
) {
this.ctx.fillStyle = "green";
}
this.ctx.fillRect(x, y, w, h);
}
}
A few things to note:
key_press_event(key)
is one of many helper event handlers. Look atpainter.js
for more (resize, mouse, keyboard, wheel, etc.)this.mouse
works similarly tothis.rect
, but contains info about the current state of the mouse. There is alsothis.keyboard
for the keyboard.
Challenge: Can you make the square rebound at the bottom of the screen by only changing three lines?
Here it is:
Instead of detecting the square being out of screen and teleporting it to the top, we detect that it touches the ground and don't let it go any lower.
Then, it's just a matter of inverting its speed—a negative speed makes it go up—and making it lose some of it in the process (or it would just rebound forever).
This works because gravity now works against the square. Eventually, gravity will "consume" all of the square's negative speed and turn it into positive speed. This is when the square stops and falls back down.
if (this.square.y + this.square.h >= this.rect.yh) {
this.square.y = this.rect.yh - this.square.h;
// Make the square rebound by inverting its velocity
// and dissipate some energy.
this.square.vy = -(this.square.vy * 0.618);
}
This would clearly be overkill here, but larger projects don't usually use a single space like we're doing. They use what's called a "world space" and a "screen space".
This decouples the "engine" part from the "rendering" part. All the physics are computed in world space. That is, unit space, with boundaries of 0 and 1, or -1 and 1 usually (think unit circle). During the rendering, the values in unit space are mapped to real-world screen pixels.
You could try to apply this concept to the example above, using a
world_to_screen(x, y)
function to convert world coordinates to screen
coordinates, and a screen_to_world(x, y)
function to convert the
real-world mouse event coordinates to world coordinates. Don't forget to
take the canvas' aspect ratio (this.rect.ar
) into account if you plan
to move the square in 2D. There's a simple example in
demo/screen_saver.js.