Game architecture
on Canvas + SVG
Speaker: Dzmitry Tsebruk
Canvas
Part 1
How does Canvas work?
Do you remember Windows 98 frezzzzzzzzzzzzzes?
Paths to clear Canvas
2. clearRect(0, 0, canvas.width, canvas.height)
1. canvas.width = canvas.width;
3. drawImage(image, dx, dy, dWidth, dHeight);
class Battle {
}
constructor() {
// ...
this.then = Date.now();
this.update = this._update.bind(this);
}
_update() {
this.setDelta();
updateAnimations(this.delta);
this.renderImages();
this.animationFrameId = requestAnimationFrame(this.update);
}
setDelta() {
this.now = Date.now();
this.delta = (this.now - this.then) / 1000;
this.then = this.now;
}
Canvas loop
Battle
update
Canvas loop
class Battle {
}
constructor() {
// ...
this.images = [];
this.player = new Player();
this.monster = new Monster(...MonsterGenerator.getRandomBodyPartsNumbers());
this.images.push(new CanvasImage({
path: background,
size: { width: this.canvas.width, height: this.canvas.height },
}));
this.images = concat(
this.images,
this.player.getImages(),
this.monster.getImages(),
);
// ...
}
Initialization
Stack of images
Battle
update
Canvas loop
setCanvasScaleStyles() {
if (window.innerWidth / window.innerHeight >= this.aspectRatio) {
this.canvas.classList.add('battle__canvas_scaled-by-width');
this.canvas.classList.remove('battle__canvas_scaled-by-height');
} else {
this.canvas.classList.remove('battle__canvas_scaled-by-width');
this.canvas.classList.add('battle__canvas_scaled-by-height');
}
}
Canvas adaptive size lifehack
<canvas class="battle__canvas" width="1920" height="1080" id="canvas"></canvas>
&__canvas {
display: block;
&_scaled-by-height {
width: 100vw;
}
&_scaled-by-width {
height: 100vh;
}
}
Characters creation
Part 2
Battle
update
Canvas loop
Character
import Character from '../Character';
class Player extends Character {
constructor() {
const config = {
// ...
};
super(config);
}
}
export default Player;
Character configuration
const config = {
order: ['leftArm', 'wand', 'legs', 'body', 'head', 'scarf', 'rightArm', ...],
bodyParts: {
legs: { coordinates: [90, 370] },
leftArm: { coordinates: [195, 260, 22, 34] },
wand: { coordinates: [0, 0, 0, 0, 110, 12] },
body: { coordinates: [52, 240] },
head: { coordinates: [-5, -10, 135, 275] },
scarf: { coordinates: [-10, 240, 145, 30] },
rightArm: { coordinates: [10, 255, 30, 30] },
hair: { coordinates: [0, 0, 0, 0, 2, 2] },
eyes: { coordinates: [0, 0, 0, 30, 177, 175] },
glasses: { coordinates: [0, 0, 0, 0, 130, 155] },
scare: { coordinates: [0, 0, 0, 0, 160, 105] },
monument: { coordinates: [60, 185], visibility: false },
},
bindScheme: [
['wand', 'leftArm'],
['hair', 'head'],
['eyes', 'head'],
['glasses', 'head'],
['scare', 'head'],
],
// ...
};
Character configuration
const config = {
// ...
animations: {
avadaKedavra: {
duration: 1500,
isRepeated: true,
slowDownFactor: 1,
bindObjectName: 'leftArm',
colors: ['rgba(114, 206, 17, 0.7)', 'rgb(78, 249, 90)', 'rgba(103, ...'],
coordinates: [0, 0, 0, 0, -130, -125],
},
// ...
},
breatheScheme: ['leftArm', 'rightArm', 'head', 'scarf'],
globalPosition: [300, 600],
blink: true,
};
Character configuration
class Character {
constructor({
order,
blink,
bodyParts,
bindScheme,
breatheScheme,
globalPosition,
animations = {},
}) {
}
}
this.images = [];
order.forEach((name) => {
this.images.push(new CharacterPart(upperFirst(name), bodyParts[name]));
});
bindScheme.forEach(([target, bindTo]) => {
const targetIndex = order.indexOf(target);
const bindToIndex = order.indexOf(bindTo);
this.images[targetIndex].bindTo(this.images[bindToIndex]);
});
// ...
Character initialization
class Battle {
_update() {
this.setDelta();
updateAnimations(this.delta);
this.renderImages();
this.animationFrameId = requestAnimationFrame(this.update);
}
}
Do you remember Canvas loop?
updateBreath(this.delta, 5);
updateBlink(this.delta, 4);
updateHandAnimations(this.delta);
updateHitAnimation(this.delta);
updateDieAnimation(this.delta);
updateMonumentInstallation(this.delta);
class Character {
}
// ...
updateBreath(delta, breathMaxValue) {
if (delta < 0.2) {
if (this.breathDirection === 1) {
this.breathAmount -= this.breathIncrement * delta;
if (this.breathAmount < -breathMaxValue) {
this.breathDirection = -1;
}
} else {
this.breathAmount += this.breathIncrement * delta;
if (this.breathAmount > breathMaxValue) {
this.breathDirection = 1;
}
}
this.breathIndices.forEach((index) => {
this.images[index].setAnimationOffset(0, this.breathAmount);
});
}
}
// ...
Update animation example
Battle
update
Canvas loop
Character
update
animations
SVG
Part 3
Canvas vs. SVG
<canvas>
<svg>
VS
+
class CanvasImage {
constructor(config) {
}
}
this.readyState = false;
// ...
this.loadImage(path);
const {
path,
size: { width, height },
coordinates = [],
isMirrored = false,
visibility = true,
} = config;
Image Creating
const [
posX = 0, posY = 0,
pivotX = width / 2,
pivotY = height / 2,
bindPointX = 0,
bindPointY = 0,
] = coordinates;
class CanvasImage {
// ...
loadImage(path) {
this.image = new Image();
this.image.onload = () => {
this.readyState = true;
};
this.image.src = `data:image/svg+xml;base64,${btoa(path)}`;
}
// ...
}
Image Loading
{
test: /\.svg$/,
loader: 'svg-inline-loader?classPrefix',
},
Battle
update
Canvas loop
Character
update
animations
<svg>
<svg>
Oh my god, a have a lot of images...
import * as bodyImages from './body';
import * as eyesImages from './eyes';
import * as glassesImages from './glasses';
import * as hairImages from './hair';
import * as headImages from './head';
import * as leftArmImages from './left-arm';
import * as legsImages from './legs';
import * as monumentImages from './monument';
import * as rightArmImages from './right-arm';
import * as scareImages from './scare';
import * as scarfImages from './scarf';
import * as wandImages from './wand';
Import with wildcard
// babel.config.js
const plugins = [
['wildcard', {
nostrip: true,
exts: ['js', 'svg'],
}],
];
module.exports = { plugins };
const images = {
// ...
head: {
images: headImages,
colors: [
[['#ffae36'], ['#f7e1c4']],
[['#f7e1c4']],
[['#f3f3f2', '#c95b45', '#000000']],
[['#777674']],
[['#85bd6f']],
[['#999898', '#d35243']],
[['#9e7456', '#ffffff', '#d24a45']],
[['#d9b7d6', '#efe94b']],
[['#81a8db']],
[['#bfd55d', '#d76443']],
],
},
// ...
}
Setting up images
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 327 278.4">
<style type="text/css">
.st0{fill:color1;stroke:#000000;stroke-width:5;stroke-miterlimit:10;}
.st1{fill:color1;stroke:#000000;stroke-width:5.8948;stroke-miterlimit:10;}
</style>
<path class="st0" d="M29.7 165.7c-11.1-28.4 53.3 12.7 53.3 12.7
-23.3-26-14-125.3 79-114.7s139 154.7 139 154.7l-49.5 7 -59.3 50L77.8
238.7l0.1-14C77.9 224.7 38.9 189.3 29.7 165.7z"/>
<path class="st0" d="M151.4 111.7c0 0 34-12.7 47.8-10"/>
<path class="st0" d="M161.2 133.7c0 0 37-12.7 42.7-8"/>
<path class="st1" d="M156.9 120.6c0 0 41.3-10.8 47-8.3"/>
</svg>
Colorize them
#F5DFC4
colors.forEach((color, index) => {
this.path = this.path.replace(
new RegExp(`color${index + 1}`, 'g'), `${color}`
);
});
rgb(245, 223, 196)
rgba(245, 223, 196, 1)
Image transforms
Part 4
How to rotate a hand with wand?
- We can rotate them both with method rotate.
- But we also need to change wand coordinates.
- How to make it?
It is a time for Math
class CanvasImage {
// ...
static calculateNewCoordsWhenRotate(angle, [x, y], [x0, y0]) {
const alpha = CanvasImage.toRadians(angle);
const sinA = Math.sin(alpha);
const cosA = Math.cos(alpha);
const dX = x - x0;
const dY = y - y0;
const x1 = x0 + dX * cosA - dY * sinA;
const y1 = y0 + dX * sinA + dY * cosA;
return [x1, y1];
}
// ...
}
Code
Bind
point
Pivot of
parent
image
class Battle {
_update() {
this.setDelta();
updateAnimations(this.delta);
this.renderImages();
this.animationFrameId = requestAnimationFrame(this.update);
}
// ...
}
Do you remember Canvas loop?
renderImages() {
this.images.forEach(image => CanvasImage.render(this.ctx, image));
// ...
}
class CanvasImage {
// ...
static render(ctx, canvasImage) {
// ...
// There is some pre-rendering stuff here.
// We get coords from CanvasImage instance,
// get new coords for binded images, and so on.
//
// Sorry, but I didn't so much time to refactor it well.
// ...
const args = [image, posX, posY, width, height];
CanvasImage.drawImage(
ctx, angle, [pivotX, pivotY], [scaleX, scaleY], opacity, isMirrored, args,
);
}
}
// ...
}
Code
static drawImage(ctx, angle, pivot, scale, opacity, isMirrored, args) {
// ...
// Contstants here
if (isNotChanged) {
ctx.drawImage(...args);
} else {
const [image, posX, posY, width, height] = args;
ctx.save();
if (isTransparent) ctx.globalAlpha = opacity;
ctx.translate(posX + pivotX, posY + pivotY);
if (isScaled && !isMirrored) ctx.scale(scaleX, scaleY);
if (isMirrored) ctx.scale(-scaleX, scaleY);
if (isRotated) ctx.rotate(CanvasImage.toRadians(angle));
ctx.translate(-posX - pivotX, -posY - pivotY);
ctx.drawImage(image, posX, posY, width, height);
ctx.restore();
}
}
Code
default
Summary
Battle
Player
Monster
Character
CharacterPart
CanvasImage
(has)
(extends)
(extends)
(has many)
(generate)
methods for work with images
(has)
methods for
work with a group
of images
Canvas loop
(has)
(uses many instances)
Useful links
Questions?
Thank you!
Game architecture
By Dzmitry Tsebruk
Game architecture
My way to build game architecture using Canvas + SVG
- 281