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? 

  1. We can rotate them both with method rotate.
  2. But we also need to change wand coordinates.
  3. How to make it?

It is a time for Math

x1 = xcos(α) - ycos(α)
x1=xcos(α)−ycos(α)x1 = xcos(α) - ycos(α)
y1 = xsin(α) + ycos(α)
y1=xsin(α)+ycos(α)y1 = xsin(α) + ycos(α)
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

  • 299