Game Workshop 2019 | Crit Russell

September 17, 2019

Game Workshop 2019

Follow the instructions here to get the base for the code: Repo.

Open your browser and use the local webserver of choice from the Repo instructions to load the web page.

Open up the developer console on your browser. We will be checking this console for errors while we develop the game.

Your game in the browser should look similar to this (with the developer console open):

Step-0

Asset Manager

Lets get an asset manager implemented real quick.

Create a new file: engine/assets.js.

class Assets {
  constructor() {
    this.success = 0
    this.error = 0
    this.paths = []
    this.cache = {}
  }

  add(paths) {
    this.paths = this.paths.concat(paths)
  }

  get(path) {
    return this.cache[path]
  }

  loaded() {
    return this.paths.length === (this.success + this.error)
  }

  download(callback) {
    if (!callback) callback = () => {}

    this.paths.forEach(path => {
      const image = new Image()

      image.addEventListener('load', () => {
        this.success += 1
        if (this.loaded()) callback()
      })

      image.addEventListener('error', () => {
        this.error += 1
        if (this.loaded()) callback()
      })

      image.src = path
      this.cache[path] = image
    })
  }
}

Add assets.js to index.html:

<!-- Game Engine: Dependencies -->
<script src="assets/tilesets.js"></script>
<script src="engine/assets.js"></script> <!-- Add This Line -->

Add our assets to our asset manager in index.html:

<!-- Run The Game -->
<script>
  const assets = new Assets() // Add These Lines
  assets.add([
    'assets/player.png',
    'assets/sandwater.png',
    'assets/skeleton.png',
    'assets/plasmaball.png',
  ])

  const game = new Game({
    stage: document.getElementById('stage'),
    music: document.getElementById('audio-music'),
    cast: document.getElementById('audio-shoot'),
    death: document.getElementById('audio-death'),
    hit: document.getElementById('audio-hit'),
    level: localStorage.getItem('current-level') || 'square1'
})
</script>

Entities

Now for one of the core pieces of an ECS (Entity, Component, System) engine.

Create a new file: engine/entity.js

class Entity {
  constructor() {
    this.id = (+new Date()).toString(16) + (Math.random() * 100000000 | 0).toString(16)
    this._components = {}
  }

  set(component) {
    this._components[component.name] = component
    return this
  }

  com(name) {
    return this._components[name]
  }

  delete(name) {
    delete this._components[name]
    return this
  }

  has(names = []) {
    const found = []
    names.forEach(name => {
      if (this._components.hasOwnProperty(name)) found.push(name)
    })

    return found.length === names.length
  }

  missing(names = []) {
    return !this.has(names)
  }

  is(name) {
    if (this.missing(['type'])) return false
    return this.com('type').value === name
  }
}

Add entity.js to index.html

<!-- Game Engine: ECS -->
<script src="engine/game.js"></script>
<script src="engine/entity.js"></script> <!-- Add This Line -->

Add a place to track entities in engine/game.js

  this.showHitboxes = false

  this.entities = [] // Add This Line

  const element = config.stage

Game System Loop and Renderer

Before we draw characters or maps on the screen, we need a way to draw to the screen in a systematic way.

Remove this section from the engine/game.js constructor:

    // Temporary Hello World
    this.stage.fillStyle = '#ecf0f1'
    this.stage.textAlign = 'center'
    this.stage.font = '24px sans-serif'
    this.stage.fillText('Stage Set And Ready To Work!', this.width / 2, this.height / 2)
    this.stage.fillStyle = '#95a5a6'
    this.stage.font = '16px sans-serif'
    this.stage.fillText('If everything loaded correctly, we can get started.', this.width / 2, this.height / 2 + 32)

Add these new methods to engine/game.js:

  loop() {
    this.entities.forEach((entity, i) => {
      // todo: movement, animation, etc systems
    })
    
    this.render()

    window.requestAnimationFrame(() => this.loop())
  }

  render() {
    const updates = []
    
    this.entities.forEach(entity => {
        if (entity.has(['active', 'renderer']) updates.push(entity.com('renderer'))
    })
    
    if (!updates.length) return
    
    this.stage.clearRect(0, 0, this.width, this.height)
    updates.forEach(update => update.run(this.stage))
  }

Add loop to the engine/game.js constructor:

    this.stage = element.getContext('2d')

    assets.download(() => this.loop()) // Add This Line
  }

Refresh your game in the browser and make sure no errors show up in the console.

First Components

Components are the C in ECS and we’ve already referenced active and renderer in previous code. Lets add those:

Create a new file: engine/components.js.

class Components {
  static active() {
    return {
      name: 'active'
    }
  }

  static renderer(callback) {
    return {
      name: 'renderer',
      run: callback
    }
  }
}

Add components to index.html:

<!-- Game Engine: ECS -->
<script src="engine/entity.js"></script>
<script src="engine/components.js"></script> <!-- Add This Line -->

Refresh in the browser and check for errors.

Character

Lets get our player character on the screen. The core of this is adding components to an entity. And to make this easy, we make an assemblage (something that returns the entity with all the components attached).

Create a new file engine/characters.js.

class Characters {
  static entity(x = 0, y = 0, imgPath) {
    const entity = new Entity()

    entity.set(Components.active())
    entity.set(Components.position(x, y))
    entity.set(Components.size(64, 64))
    entity.set(Components.type('character'))
    // todo: add velocity
    // todo: add health

    entity.set(Components.renderer(context => {
      const pos = entity.com('position')
      const size = entity.com('size')
      const image = assets.get(imgPath)
      const frame = {x: 0, y: 64 * 2}

      context.drawImage(image, frame.x, frame.y, size.w, size.h, pos.x, pos.y, size.w, size.h)
    }))

    // todo: add hitbox
    // todo: add collision
    // todo: add death handler

    return entity
  }

  static Player(x = 0, y = 0) {
    const entity = Characters.entity(x, y, 'assets/player.png')

    entity.set(Components.type('player'))
    // todo: Add walk down animation

    return entity
  }
}

We need to add these new components in engine/components.js:

  static type(value) {
    return {
      name: 'type',
      value: value
    }
  }

  static position(x = 0, y = 0) {
    return {
      name: 'position',
      x: x,
      y: y
    }
  }

  static size(w = 0, h = 0) {
    return {
      name: 'size',
      w: w,
      h: h
    }
  }

Add the new file to index.html:

<!-- Game Engine: Component Assemblies -->
<script src="engine/characters.js"></script> <!-- Add This Line -->

Next, we add this character to engine/game.js:

  this.stage = element.getContext('2d')

  this.player = Characters.Player(32, 32) // Add These Line
  this.entities.push(this.player)

  assets.download(() => this.loop())

Refresh your game in the browser and see your character on screen (or fix any errors).

Movement

Let’s get our character responding to keyboard input.

First, our character entity needs a velocity component in engine/components.js:

  static velocity(vx = 0, vy = 0) {
    return {
      name: 'velocity',
      vx: vx,
      vy: vy
    }
  }

Replace our todo with velocity in our character assembly code in engine/characters.js:

  entity.set(Components.size(64, 64))
  entity.set(Components.type('character'))
  entity.set(Components.velocity(0, 0)) // Add This Line

Create a new file to bind input keys: engine/input.js:

class Input {
  constructor(entity, game) {
    this.game = game
    this.entity = entity
    this.bindMovement()
  }

  bind(codes = []) {
    const key = {
      codes: codes,
      isDown: false,
      isUp: true,
      press: undefined,
      release: undefined
    }

    window.addEventListener('keydown', (e) => {
      if (e.defaultPrevented) return
      if (!key.codes.includes(e.code)) return
      if (key.isUp && key.press) key.press()
      key.isDown = true
      key.isUp = false
      e.preventDefault()
    }, false)

    window.addEventListener('keyup', (e) => {
      if (e.defaultPrevented) return
      if (!key.codes.includes(e.code)) return
      if (key.isDown && key.release) key.release()
      key.isDown = false
      key.isUp = true
      e.preventDefault()
    }, false)

    return key
  }

  bindMovement() {
    if (this.entity.missing(['velocity', 'active'])) return

    const up = this.bind(['ArrowUp', 'KeyW']),
      down = this.bind(['ArrowDown', 'KeyS']),
      left = this.bind(['ArrowLeft', 'KeyA']),
      right = this.bind(['ArrowRight', 'KeyD'])

    up.press = () => {
      this.entity.com('velocity').vx = 0
      this.entity.com('velocity').vy = -3
      // todo: set walk up animation
    }

    up.release = () => {
      if (down.isDown) return
      this.entity.com('velocity').vy = 0
    }

    down.press = () => {
      this.entity.com('velocity').vx = 0
      this.entity.com('velocity').vy = 3
      // todo: set walk down animation
    }

    down.release = () => {
      if (up.isDown) return
      this.entity.com('velocity').vy = 0
    }

    left.press = () => {
      this.entity.com('velocity').vx = -3;
      this.entity.com('velocity').vy = 0;
      // todo: set walk left animation
    }

    left.release = () => {
      if (right.isDown) return
      this.entity.com('velocity').vx = 0;
    }

    right.press = () => {
      this.entity.com('velocity').vx = 3;
      this.entity.com('velocity').vy = 0;
      // todo: set walk right animation
    }

    right.release = () => {
      if (left.isDown) return
      this.entity.com('velocity').vx = 0;
    }
  }
}

Add it to index.html:

<!-- Input Controls -->
<script src="engine/input.js"></script> <!-- Add This Line -->

Give our player entity to input in engine/game.js:

  this.player = Characters.Player(32, 32)
  this.entities.push(this.player)

  this.input = new Input(this.player, this) // Add This Line

  assets.download(() => this.loop())

And, add our first game system in engine/game.js inside the loop() method:

  this.entities.forEach((entity, i) => {
    // todo: movement, animation, damage etc
    if (entity.has(['active', 'position', 'velocity'])) this.move(entity) // Add This Line
  })

Create our movement system in engine/game.js:

  move(entity) {
    if (!entity.com('velocity').vx && !entity.com('velocity').vy) return // not moving

    // todo: collision detection with interactions

    entity.com('position').x += entity.com('velocity').vx
    entity.com('position').y += entity.com('velocity').vy
  }

Finally, refresh your game in the browser and move the character around the stage.

Maps & Levels

Lets add maps and levels to our game.

First we need a way to interpret assets/tilesets.js and turn it into a x, y coordinates. Create a the file engine/utils.js:

const decoder = {a: 0, b: 32, c: 64, d: 96, e: 128, f: 160}

function decode(letters) {
  const x = letters.charAt(0), y = letters.charAt(1)

  return {x: decoder[x], y: decoder[y]}
}

Create a new file engine/maps.js:

class Maps {
  static tile(x = 0, y = 0, frame) {
    const entity = new Entity()

    entity.set(Components.position(x, y))
    entity.set(Components.size(32,32))
    entity.set(Components.type('tile'))
    entity.set(Components.active())

    entity.set(Components.renderer((context) => {
      const pos = entity.com('position')
      const size = entity.com('size')
      const image = assets.get(frame.src)

      context.drawImage(image, frame.x, frame.y, size.w, size.h, pos.x, pos.y, size.w, size.h)
    }))

    return entity
  }

  static parse(data) {
    const entities = []

    const get = (col, row) => {
      return data.tiles[row * data.cols + col]
    }

    for (let col = 0; col < data.cols; col++) {
      for (let row = 0; row < data.rows; row++) {
        const letters = get(col, row)
        const pos = decode(letters)

        entities.push(Maps.tile(col * data.size, row * data.size, {
          src: data.src, x: pos.x, y: pos.y
        }))
      }
    }

    return entities
  }

  static SquareIsland() {
    const entities = Maps.parse({
      src: 'assets/sandwater.png',
      cols: 16,
      rows: 16,
      size: 32,
      tiles: tilesets.square1
    })

    // todo: boundaries

    return entities
  }

  static WhyIsland() {
    const entities = Maps.parse({
      src: 'assets/sandwater.png',
      cols: 16,
      rows: 16,
      size: 32,
      tiles: tilesets.why1
    })

    // todo: boundaries

    return entities
  }
}

And that goes into a level at the new file engine/levels.js:

class Levels {
  constructor(player) {
    this.player = player

    this.items = {
      'square1': Levels.SquareIsland1,
      'why1': Levels.WhyIsland1
    }
  }

  select(name) {
    const level = this.items[name]

    if (!level) throw `unknown level ${name}`

    return level(this.player)
  }

  static SquareIsland1(player) {
    const entities = Maps.SquareIsland()

    // todo: add enemies to the level

    player.com('position').x = 32
    player.com('position').y = 32
    // todo: reset player animations
    
    entities.push(player)

    return entities
  }

  static WhyIsland1(player) {
    const entities = Maps.WhyIsland()

    // todo: add enemies to the level

    player.com('position').x = 13 * 32
    player.com('position').y = 32
    // todo: reset player animations
    
    entities.push(player)
    
    return entities
  }
}

Add our new files to index.html:

<!-- Game Engine: Dependencies -->
<script src="assets/tilesets.js"></script>
<script src="engine/assets.js"></script>
<script src="engine/utils.js"></script> <!-- Add This Line -->
<!-- Game Engine: Component Assemblies -->
<script src="engine/characters.js"></script>
<script src="engine/maps.js"></script>  <!-- Add This Line -->
<script src="engine/levels.js"></script>  <!-- Add This Line -->

In engine/game.js change constructor() with the following code:

  this.player = Characters.Player(32, 32)
  this.input = new Input(this.player, this)

  this.selectLevel = config.level // Add These Two Lines
  this.levels = new Levels(this.player)

  assets.download(() => this.loop())

And finally, add this new method to allow our dev controls to change the levels:

  changeLevel() {
    if (!this.selectLevel) return

    this.entities = this.levels.select(this.selectLevel)
    this.selectLevel = undefined
  }

Refresh the browser and click on the Dev Controls Load Level buttons to switch between the two layouts.

Hit Box and Hit Box Outline

Right now the character can move “off” screen since there is nothing to collide with. Before we make our simple collision system we need a hitbox mechanism.

Add hitbox method to engine/components.js:

  static hitbox(callback) {
    return {
      name: 'hitbox',
      run: callback
    }
  }

We want to be able to see the hitboxes to confirm that they work correctly, so add a drawHitboxes method to engine/game.js:

  drawHitboxes() {
    this.stage.strokeStyle = '#f44336'
    this.entities.forEach(entity => {
      if (entity.missing(['hitbox', 'active'])) return
      
      const hitbox = entity.com('hitbox').run()
      this.stage.strokeRect(hitbox.x, hitbox.y, hitbox.w, hitbox.h)
    })
  }

And at the bottom of our loop() method add the call to drawHitboxes in engine/game.js:

loop() {
  this.changeLevel()

  // ...

  if (this.showHitboxes) this.drawHitboxes()
  window.requestAnimationFrame(() => this.loop())
}

Lets give our player character a hitbox in engine/characters.js:

class Characters {
  static entity(x = 0, y = 0, imgPath) {
    // ...

    entity.set(Components.hitbox(() => {
      return {
        x: entity.com('position').x + 15,
        y: entity.com('position').y + 15,
        w: entity.com('size').w - 30,
        h: entity.com('size').h - 15
      }
    }))

    // todo: add collision
    // todo: add death handler

    return entity
  }

  // ...
}

If we refresh the browser and hit the “Toggle Hit Box Outlines” in Dev Controls, we should see an outline around our character:

Collision System

Load the “SquareIsland1” Level from Dev Controls and make sure that your hitbox outline is on:

First thing, add a collision method to engine/components.js:

  static collision(callback) {
    return {
      name: 'collision',
      run: callback
    }
  }

Then, add a collision component to our character in engine/characters.js:

class Characters {
  static entity(x = 0, y = 0, imgPath) {
    // ...

    entity.set(Components.collision(target => {
      if (target.is('wall')) return true // collided
      if (target.is('enemy')) return true // collided

      return false
    }))

    // todo: add death handler

    return entity
  }

  // ...
}

We need a way to detect when two rectangles collide in our game so add this function to engine/utils.js:

// a rect is {x, y, w, h}
function collides(rect1, rect2) {
  return rect1.x < rect2.x + rect2.w &&
    rect1.x + rect1.w > rect2.x &&
    rect1.y < rect2.y + rect2.h &&
    rect1.y + rect1.h > rect2.y
}

Our game needs to know how to handle collisions so lets add that to move() in engine/game.js:

  move(entity) {
    if (!entity.com('velocity').vx && !entity.com('velocity').vy) return // not moving

    const hitbox = entity.com('hitbox').run()
    const future = {
      x: hitbox.x,
      y: hitbox.y,
      w: hitbox.w,
      h: hitbox.h
    }

    // get where the entity would be if we applied the current velocity values
    future.x += entity.com('velocity').vx
    future.y += entity.com('velocity').vy

    for (let i = 0; i < this.entities.length; i++) {
      const target = this.entities[i]
      if (target.id === entity.id) continue
      if (target.missing(['hitbox', 'active'])) continue
      if (!collides(future, target.com('hitbox').run())) continue

      const stop = entity.com('collision').run(target) // interactions

      if (stop) return // collision told us not to move the entity
    }

    entity.com('position').x += entity.com('velocity').vx
    entity.com('position').y += entity.com('velocity').vy
  }

And add “collision” and “hitbox” component checks to loop():

  loop() {
    this.changeLevel()

    this.entities.forEach((entity, i) => {
      // todo: movement, animation, etc
      if (entity.has(['active', 'velocity', 'position', 'collision', 'hitbox'])) this.move(entity)
    })

    // ...
  }

To test our hitbox and collision add the following temporary code to our SquareIsland1 level in engine/levels.js:

static SquareIsland1(game) {
    const entities = Maps.SquareIsland()

    // todo: add enemies to the level

    // temporary wall
    const wall = new Entity()
    wall.set(Components.active())
    wall.set(Components.type('wall'))
    wall.set(Components.hitbox(() => {
      return {
        x: game.width / 2 - 32,
        y: game.height / 2 - 32,
        w: 64,
        h: 64
      }
    }))
    entities.push(wall)

    game.player.com('position').x = 32
    game.player.com('position').y = 32
    // todo: reset player animations

    entities.push(game.player)

    return entities
  }

Refresh the browser and you should see a red square in the middle of the map that your character cannot move through.

Delete the temporary code we added from engine/levels.js:

static SquareIsland1(game) {
    const entities = Maps.SquareIsland()

    // todo: add enemies to the level

    game.player.com('position').x = 32
    game.player.com('position').y = 32
    // todo: reset player animations

    entities.push(game.player)

    return entities
  }

Walls as Boundaries

To keep our characters on land, we will use walls as boundaries.

Add a wall method to engine/maps.js:

  static wall(x = 0, y = 0, w = 0, h = 0) {
    const entity = new Entity()

    entity.set(Components.position(x, y))
    entity.set(Components.size(w, h))
    entity.set(Components.active())
    entity.set(Components.type('wall'))

    entity.set(Components.hitbox(() => {
      return {
        x: entity.com('position').x,
        y: entity.com('position').y,
        w: entity.com('size').w,
        h: entity.com('size').h
      }
    }))

    return entity
  }

I’ve precalculated the values needed to surround “SquareIsland1” in walls so add these values to engine/maps.js:

  static SquareIsland() {
    const entities = Maps.parse({
      src: 'assets/sandwater.png',
      cols: 16,
      rows: 16,
      size: 32,
      tiles: tilesets.square1
    })

    // walls
    entities.push(
      Maps.wall(0, 0, 512, 5), // top
      Maps.wall(0, 0, 15, 512), // left
      Maps.wall(0, 490, 512, 15), // bottom
      Maps.wall(497, 0, 15, 512), // right
    )

    return entities
  }

Refresh the game in the browser and you should see hit boxes surrounding “SquareIsland1”:

And here are the values needed to do “WhyIsland1”:

  static WhyIsland() {
    const entities = Maps.parse({
      src: 'assets/sandwater.png',
      cols: 16,
      rows: 16,
      size: 32,
      tiles: tilesets.why1
    })

    // walls
    entities.push(
      Maps.wall(0, 0, 512, 5), // top
      Maps.wall(0, 0, 15, 512), // left
      Maps.wall(0, 497, 512, 15), // bottom
      Maps.wall(497, 0, 15, 512), // right

      // Lower Left water
      Maps.wall(0, 268, 110, 224),

      // upper middle water
      Maps.wall(118, 0, 278, 160),
      Maps.wall(160, 160, 128, 29),

      // lower right water
      Maps.wall(403, 332, 96, 148),
    )

    return entities
  }

If you are curious about how the maps are created from their sprite sheet, check out this companion post.

Animations

Animations should make this more interesting.

Add these methods to engine/components.js:

  static animation(row = 1, frameRange = [0, 0], speed = 70, size = 64, next) {
    if (typeof frameRange === 'number') frameRange = [0, frameRange]

    const component = {
      name: 'animation',
      tick: 0,
      frame: 0,
      frames: [],
      speed: speed,
      next: next
    }

    const animationRow = (row - 1) * size

    for (let i = frameRange[0]; i < frameRange[1]; i++) {
      component.frames.push({x: i * size, y: animationRow})
    }

    return component
  }

  static direction(value) {
    return {
      name: 'direction',
      value: value
    }
  }

  static repeat() {
    return {
      name: 'repeat'
    }
  }

Add this construction method to engine/characters.js:

  static walk(dir) {
    switch (dir) {
      case 'up':
        return Components.animation(9, [1, 8], 70, 64, Components.repeat())
      case 'down':
        return Components.animation(11, [1, 8], 70, 64, Components.repeat())
      case 'left':
        return Components.animation(10, [1, 8], 70, 64, Components.repeat())
      case 'right':
        return Components.animation(12, [1, 8], 70, 64, Components.repeat())
    }

    throw `Characters.walk unknown direction: ${dir}`
  }

Update the character to have an animation at Player() in engine/characters.js:

  static Player(x = 0, y = 0) {
    const entity = Characters.entity(x, y, 'assets/player.png')

    entity.set(Components.type('player'))
    entity.set(Characters.walk('down')) // Add This Line

    return entity
  }

Update our renderer at entity() in engine/characters.js:

  static entity(...) {
    // ...
    
    entity.set(Components.renderer(context => {
      const pos = entity.com('position')
      const size = entity.com('size')
      const image = assets.get(imgPath)
      const animation = entity.com('animation') // Add This Line
      const frame = animation.frames[animation.frame] // Change This Line

      context.drawImage(image, frame.x, frame.y, size.w, size.h, pos.x, pos.y, size.w, size.h)
    }))

    // ...
  }

Add an animate() method to engine/game.js:

  animate(entity) {
    const animation = entity.com('animation')

    animation.tick += 0.1

    if (animation.tick * 100 >= animation.delay) {
      animation.frame++
      animation.tick = 0
    }

    if (animation.frame >= animation.frames.length) {
      if (!animation.next) return
      if (typeof animation.next === 'function') return animation.next()
      if (animation.next.name === 'repeat') animation.frame = 0
      if (animation.next.name === 'animation') entity.set(animation.next)
    }
  }

Animation Changes

Add our different animation directions to our movement binding in engine/input.js

up.press = () => {
  this.entity.com('velocity').vx = 0
  this.entity.com('velocity').vy = -3
  this.entity.set(Characters.walk('up')) // Add This Line
}
// ...
down.press = () => {
  this.entity.com('velocity').vx = 0
  this.entity.com('velocity').vy = 3
  this.entity.set(Characters.walk('down')) // Add This Line
}
// ...
left.press = () => {
  this.entity.com('velocity').vx = -3;
  this.entity.com('velocity').vy = 0;
  this.entity.set(Characters.walk('left')) // Add This Line
}
// ...
right.press = () => {
  this.entity.com('velocity').vx = 3;
  this.entity.com('velocity').vy = 0;
  this.entity.set(Characters.walk('right')) // Add This Line
}

And let’s reset the animation of our player when the level changes. In engine/levels.js:

  static SquareIsland1(game) {
    const entities = Maps.SquareIsland()

    // todo: add enemies to the level

    game.player.com('position').x = 32
    game.player.com('position').y = 32
    game.player.set(Characters.walk('down')) // Add This Line

    entities.push(game.player)

    return entities
  }

  static WhyIsland1(game) {
    const entities = Maps.WhyIsland()

    // todo: add enemies to the level

    game.player.com('position').x = 13 * 32
    game.player.com('position').y = 32
    game.player.set(Characters.walk('down')) // Add This Line

    entities.push(game.player)

    return entities
  }

Test in the browser that the game actually animates the character on direction change. Move the character left or right and then change the level and make sure the animation resets to down.

Enemies

The goal is to have our skeleton enemies bounce between the boundaries and each other. In order to do that we need to record which direction the skeleton is moving so add this method to engine/components.js:

  static direction(value) {
    return {
      name: 'direction',
      value: value
    }
  }

Next lets make our skeleton assembler. In engine/characters.js:

  static Skeleton(dir, x, y) {
    const entity = Characters.entity(x, y, 'assets/skeleton.png')

    entity.set(Components.type('enemy'))
    entity.set(Components.direction(dir))

    // set initial movement direction and animation
    switch (dir) {
      case 'left':
        entity.set(Components.velocity(-1, 0))
        entity.set(Characters.walk('left'))
        break
      case 'right':
        entity.set(Components.velocity(1, 0))
        entity.set(Characters.walk('right'))
        break
      case 'up':
        entity.set(Components.velocity(0, -1))
        entity.set(Characters.walk('up'))
        break
      case 'down':
        entity.set(Components.velocity(0, 1))
        entity.set(Characters.walk('down'))
        break
    }

    entity.set(Components.collision(target => {
      const redirect = target.is('wall') || target.is('enemy') || target.is('player')

      if (!redirect) return false // no collision

      // change direction and animation
      switch (entity.com('direction').value) {
        case 'left':
          entity.set(Components.direction('right'))
          entity.set(Components.velocity(1, 0))
          entity.set(Characters.walk('right'))
          break
        case 'right':
          entity.set(Components.direction('left'))
          entity.set(Components.velocity(-1, 0))
          entity.set(Characters.walk('left'))
          break
        case 'up':
          entity.set(Components.direction('down'))
          entity.set(Components.velocity(0, 1))
          entity.set(Characters.walk('down'))
          break
        case 'down':
          entity.set(Components.direction('up'))
          entity.set(Components.velocity(0, -1))
          entity.set(Characters.walk('up'))
          break
      }

      return true // collided
    }))

    return entity
  }

And finally, add them to our levels in engine/levels.js:

  static SquareIsland1(game) {
    const entities = Maps.SquareIsland()

    entities.push( // Add These Lines
      Characters.Skeleton('down', 256, 32),
      Characters.Skeleton('left', 32, 256)
    )

    game.player.com('position').x = 32
    game.player.com('position').y = 32
    game.player.set(Characters.walk('down'))

    entities.push(game.player)

    return entities
  }

  static WhyIsland1(game) {
    const entities = Maps.WhyIsland()

    entities.push( // Add These Lines
      Characters.Skeleton('down', 32, 32),
      Characters.Skeleton('down', 160, 224),
      Characters.Skeleton('left', 128, 400),
    )

    game.player.com('position').x = 13 * 32
    game.player.com('position').y = 32
    game.player.set(Characters.walk('down'))

    entities.push(game.player)

    return entities
  }

Refresh the game in the browser and run around blocking the skeletons:

Health Bars

To measure interactions between our player and the enemies we add health bars.

Create a new method in engine/components.js:

  static health(value) {
    return {
      name: 'health',
      value: value
    }
  }

Give our characters health and adjust the renderer to create a visual bar in engine/characters.js:

static entity(x = 0, y = 0, imgPath) {
    // ...
    entity.set(Components.velocity(0, 0))
    entity.set(Components.health(100)) // Add This Line

    entity.set(Components.renderer(context => {
      const pos = entity.com('position')
      const size = entity.com('size')
      const image = assets.get(imgPath)
      const animation = entity.com('animation')
      const frame = animation.frames[animation.frame]

      context.drawImage(image, frame.x, frame.y, size.w, size.h, pos.x, pos.y, size.w, size.h)

      context.strokeStyle = 'darkred' // Add These Lines
      context.fillStyle = 'red'
      const width = (entity.com('health').value / 100) * 30
      context.strokeRect(pos.x + 16, pos.y, 32, 5)
      context.fillRect(pos.x + 17, pos.y + 1, width, 4)
    }))

    // ...

    return entity
  }

Refresh the game in the browser and we should see red bars floating above the characters’ heads:

Next Time

My next post on this project will include the following:

  • Hit Detection
  • Death Animations
  • Audio Manager
  • Music Key Binding
  • Hit Audio
  • Death Audio
  • Game Over Conditions
  • Game Over Screen
  • Title Screen
  • Options Menu

See you then!

© Crit Russell