Erik Onarheim
erikonarheim.com
Making Games in the Web!?!
HTML5 Canvas
The HTML Canvas Element
// Create canvas
const canvas = document.createElement('canvas');
// Set resolution in pixels
canvas.width = 800;
canvas.height = 600;
<!-- Create canvas and set resolution in pixels -->
<canvas width="800" height="600"></canvas>
Creating a Canvas
<canvas width="100" height="100"></canvas>
<style>
canvas {
width: 100px;
height: 100px;
}
</style>
<canvas width="100" height="100"></canvas>
<style>
canvas {
width: 200px;
height: 100px;
}
</style>
Resolution and Size
Painting that masterpiece
Graphics Context
const canvas = document.getElementById('my-game-id');
const graphicsContext = canvas.getContext('2d');
Rectangles
// Fill
graphicsContext.fillStyle = 'red';
// x, y, width, height
graphicsContext.fillRect(50, 50, 100, 100);
// Lines
graphicsContext.strokeStyle = 'black';
graphicsContext.lineWidth = '3px';
// x, y, width, height
graphicsContext.strokeRect(50, 50, 100, 100);
Circles
// Fill
graphicsContext.fillStyle = "blue";
// Lines
graphicsContext.strokeStyle = "black";
graphicsContext.lineWidth = 3;
// x, y, radius, start angle, end angle
graphicsContext.arc(150, 150, 100, 0, Math.PI * 2);
graphicsContext.fill();
graphicsContext.stroke();
Text
// Fill
graphicsContext.fillStyle = "lime";
// Lines
graphicsContext.strokeStyle = "black";
graphicsContext.lineWidth = 1;
// Text
graphicsContext.font = "80px Consolas";
// text, x, y
graphicsContext.fillText("Hello MDC 2020! ❤", 10, 150);
graphicsContext.strokeText("Hello MDC 2020! ❤", 10, 150);
Images
// Optional
graphicsContext.imageSmoothingEnabled = false;
// Load the image
const image = new Image();
image.src ="path/to/img.png";
image.onload = () => {
// Draw the image
};
graphicsContext.drawImage(image, 0, 0);
graphicsContext.drawImage(image, 0, 0, 50, 50);
graphicsContext.drawImage(image,
// Source
96, 0, 32, 32,
// Destination
0, 0, 32, 32
);
HiDPI Displays
const canvas = document.getElementById('hidpi-fix');
const logicalWidth = canvas.width;
const logicalHeight = canvas.height;
// Increase resolution
canvas.width = logicalWidth * window.devicePixelRatio;
canvas.height = logicalHeight * window.devicePixelRatio;
const img = new Image();
img.onload = () => {
// Scale drawing context
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
ctx.drawImage(img, 0, 0, logicalWidth, logicalHeight);
}
HiDPI Correction
#hidpi-fix {
width: 400px;
height: 400px;
}
Affine Transformations
Translate, Rotate, and Scale
// scale(x, y)
graphicsContext.scale(2, 2);
// translate(x, y)
graphicsContext.translate(x, y)
// rotate(radian)
graphicsContext.rotate(Math.PI / 2)
Save and Restore
graphicsContext.save()
graphicsContext.restore()
graphicsContext.translate(x, y);
graphicsContext.rotate(radians);
graphicsContext.scale(sx, sy);
graphicsContext.drawImage(mySprite, 0, 0);
Game Loop
Game Loop
let mainloop = () => {
requestAnimationFrame(mainloop);
// update and draw game
};
mainloop();
Timing & Frames Per Second
let lastTime = performance.now(); // Date.now()
let mainloop = () => {
requestAnimationFrame(mainloop);
const now = performance.now(); // Date.now()
let elapsedMs = now - lastTime;
let fps = 1 / (elapsedMs / 1000);
// update & draw your game
lastTime = now;
};
mainloop();
Time Based Movement
const updateNotTimeBased = (box: Box) => {
box.x += box.v;
};
const updateTimeBased = (box: Box, delta: number) => {
box.x += box.v * (delta / 1000);
};
Input
Keyboard
const keybuffer: string[] = [];
document.body.addEventListener("keydown", e => {
const index = keybuffer.indexOf(e.key);
if (index === -1) {
keybuffer.push(e.key);
}
});
document.body.addEventListener("keyup", e => {
const index = keybuffer.indexOf(e.key);
if (index > -1) {
keybuffer.splice(index, 1);
}
});
Pointer
const pos = {
x: 0,
y: 0
};
canvas.addEventListener("pointermove", e => {
pos.x = e.clientX - canvas.offsetLeft;
pos.y = e.clientY - canvas.offsetTop;
});
Audio
WebAudio API
AudioContext
const audioCtx = new AudioContext();
const play = document.getElementById('play');
play?.addEventListener('click', () => {
if (audioCtx.state === 'suspended') {
audioCtx.resume();
}
// play audio
});
AudioBufferSourceNode
const audioResponse = await fetch('./path/to/my/cool.mp3');
const rawAudioBuffer = await audioResponse.arrayBuffer();
const decodedBuffer = await audioCtx.decodeAudioData(rawAudioBuffer);
const audioSourceNode = audioCtx.createBufferSource();
audioSourceNode.buffer = decodedBuffer;
GainNode
const volumeNode = audioCtx.createGain();
volumeNode.gain.value = 0.5;
// https://developer.mozilla.org/en-US/docs/Web/API/AudioParam/setTargetAtTime
// After each .1 seconds timestep, the target value will ~63.2% closer.
// This exponential ramp provides a more pleasant transition in gain
volumeNode.setTargetAtTime(0.5, audioCtx.currentTime, 0.1);
Connecting and Playing
// Connect the graph
audioSourceNode.connect(volumeNode).connect(audioCtx.destination);
// Play AudioBufferSourceNode.start([when][, offset][, duration]);
audioSourceNode.start(0, 0);
Collisions
Circles
Bounding Box
Tile Based
Grouping
Resolution
Newish and Upcoming Stuff
Game Engines
Excalibur Start
// index.ts
import { Engine, Loader } from "excalibur";
const game = new Engine({
width: 800,
height: 600
});
game.setAntialiasing(false);
const loader = new Loader();
// Add things to loader
// Make a game!
game.start(loader);
npm install excalibur --save-exact
<script src="https://unpkg.com/excalibur@latest/dist/excalibur.min.js" />
Resources
// index.ts
import { Resources } from "./resources";
// ...
const loader = new Loader();
// Add things to loader
for (let r in Resources) {
loader.addResource(Resources[r]);
}
// Make a game!
game.start(loader);
// resources.ts
import { Texture } from "excalibur";
import botImage from "./img/excalibot.png";
import blockImage from "./img/block.png";
import jumpSound from './snd/jump.wav';
import coinSound from './snd/coin.wav';
export const Resources = {
bot: new Texture(botImage),
block: new Texture(blockImage),
jump: new Sound(jumpSound),
coin: new Sound(coinSound)
};
Scenes & Actors
const game = new Engine(...)
const scene = new Scene(game);
const actor = new Actor({
x: 50,
y: 50,
width: 32,
height: 32
});
scene.add(actor);
// Tranistion to the scene
game.add("level", scene);
game.goToScene("level");
export class Player extends Actor {
constructor(x, y, width, height) {
super({x, y, width, height});
}
onInitialize(engine: Engine) {
// initialize logic
}
onPostUpdate(engine: Engine, delta: number) {
// do stuff every update
}
onPostDraw(ctx: CanvasRenderingContext2D) {
// custom drawing
}
}
Animation
const spriteSheet = new SpriteSheet(Resources.bot, 8, 1, 32, 32);
const idleAnimation = spriteSheet.getAnimationByIndices(
engine,
[2, 3],
800
);
actor.addDrawing("idle", idleAnimation);
actor.setDrawing("idle");
Input
// actor.ts
onPostUpdate(engine: Engine, delta: number) {
// Player input
if(engine.input.keyboard.isHeld(Input.Keys.Left)) {
this.vel.x = -150;
}
if(engine.input.keyboard.isHeld(Input.Keys.Right)) {
this.vel.x = 150;
}
if(engine.input.keyboard.isHeld(Input.Keys.Up) && this.onGround) {
this.vel.y = -400;
this.onGround = false;
}
}
Collision
Prevent | Passive | Active | Fixed | |
---|---|---|---|---|
Prevent | None | None | None | None |
Passive | None | Event Only | Event Only | Event Only |
Active | None | Event Only | Resolution | Resolution |
Fixed | None | Event Only | Resolution | None |
// Default
actor.body.collider.type = CollisionType.PreventCollision;
actor.body.collider.type = CollisionType.Passive;
actor.body.collider.type = CollisionType.Active;
actor.body.collider.type = CollisionType.Prevent;
Audio
import { Resources } from './resources';
// Play the sound
Resources.jump.play();
// Pause the sound do not rewind
Resources.jump.pause();
// Stop the sound and rewind
Resources.jump.stop();
Demo!
Resources