Phaser. Unit 9. Drawing maps from plain text

Introduction

In this unit we will define a simple Phaser game that can be used as a template to define an unlimited number of levels, enemies and objects. The map will be drawn from a simple array of tiles where several characters will be used to choose the positions of each item.

We will also use npm and webpack to provide a friendly development environment, so that the application will be automatically compiled and restarted each time a file is changed. A web server is also provided by webpack to test the application, and a production version of the code can be also obtained to upload it to any public server.

Some features

These are some of the features to be implemented:

  • Show several types of animated objects that can be collected individually.
  • Keep several counters with the number of the items collected.
  • Show several types of animated enemies that can be killed just by jumping over them.
  • Move automatically the enemies from left to right and viceversa using spritesheets that contain only a single direction.
  • Draw the map using plain characters that are matched againt tiles, objects, enemies, player, and goal.
  • Easily define an unlimited number of levels and let the player jump from one level to the other just by reaching a goal in the map.
  • Display a minimap automatically generated from the real sized map.
  • Play a different background music for each level, and a different sound when either objects are collected or enemies are killed.
  • Allow the user to play the game on mobile phones and tablets with touch controls to jump and also to activate both left and right movements.
  • Implement PWA features to let the users install the application in mobile devices and also to lock the screen in landscape mode.
  • Adjust the game automatically to the size of the window where the game is displayed, and also after the window is resized on desktop computers.

Loading the assets (src/scripts/scenes/preloadScene.ts)

Inside this file we will load all the assets (both audio and images) so that they are ready to be played or displayed after the game is started:

import Levels from "../components/levels/levels"

export default class PreloadScene extends Phaser.Scene {
  constructor() {
    super({ key: 'PreloadScene' })
  }

  preload() {
    const images = ['tile-left', 'tile-middle', 'tile-right', 'tile-single', 'controls', 'background', 'goal']
    images.forEach(img => {
      this.load.image(img, `assets/img/${img}.png`)
    })

    this.load.spritesheet('player', 'assets/img/player.png', { frameHeight: 165, frameWidth: 120 })
    this.load.spritesheet('coin', 'assets/img/coin.png', { frameHeight: 42, frameWidth: 42 })
    this.load.spritesheet('key', 'assets/img/key.png', { frameHeight: 176, frameWidth: 176 })
    this.load.spritesheet('bee', 'assets/img/bee.png', { frameHeight: 100, frameWidth: 128 })
    this.load.spritesheet('slime', 'assets/img/slime.png', { frameHeight: 68, frameWidth: 112 })
    // @ts-ignore
    this.load.spine('boy', 'assets/spine/boy.json', 'assets/spine/boy.atlas')

    const audios = ['bee', 'slime', 'coin', 'key', 'door', 'hurt', 'music']
    audios.forEach(audio => {
      this.load.audio(audio, `assets/audio/${audio}.mp3`)
    })

    for(let i=0; i<Levels.length; i++) {
      this.load.audio(`level${i}`, `assets/audio/level${i}.mp3`)
    }
  }

  create() {
    this.scene.start('MainScene')
  }
}

Drawing the map and placing everything on it

We will use plain characters to represent the player, the goal, the objects, the enemies and the ground:

  • P: Player
  • G: Goal (the door to jump to the next level)
  • Objects:
    • C: Coin
    • K: Key (at least 1 key is required to open the door to the next level)
  • Enemies:
    • B: Bee (a flying enemy)
    • S: Slime (an enemy crawling on the ground)
  • [//////]: The ground

Defining the levels (src/scripts/components/levels/levels.ts)

As it can be seen in the “levels.ts” file, the map of each level can be easily defined using simple characters. We can also place all the enemies, objects, player and goal at any position in the map:

type Level = string[]

// prettier-ignore
const level0: Level = [
  '   K    S       ',
  '  [/////]       ',
  '                ',
  '   P         G  ',
  '[/////]   [////]',
]

// prettier-ignore
const level1: Level = [
  '               B  K                   ',
  '               [/////]                ',
  '              C     C                 ',
  '   S        B            S    S    G  ',
  '[//]       [////]   [////]   [//////] ',
  '    C   C                             ',
  ' P        B                           ',
  '[///]  [//]                           ',
]

// prettier-ignore
const level2: Level = [
  ' P                     [//]       G   ',
  '[/]    C   B        C           [/]   ',
  '         [//]     [//]                ',
  '      S                     B  B      ',
  ' C  [//]      B            [///]      ',
  '            [///]     []  C        C  ',
  '[]        K                           ',
  '         [/]            [/]      [/]  '
]

// prettier-ignore
const level3: Level = [
  '       C                                    ',
  '                      C                 C   ',
  '      [///]                 C      [////]   ',
  '           [///]       S  [//]  K           ',
  '                   [///]      [///]         ',
  '             S                              ',
  '         [///]           C              G   ',
  '      C            C               [////]   ',
  '  P                     S  B                ',
  '[//////]       [///]  [////]                '
]

// prettier-ignore
const level4: Level = [
  '                        C  C         S                                  ',
  '                                 [//////]      K     B               G  ',
  '                  C    [/////]                [///////]          [////] ',
  '                                                                        ',
  '  P      S     B    B              C       S     S         S            ',
  '[////////]  [///////]             [/]    [////////////////////]         '
]

const Levels: Level[] = [level0, level1, level2, level3, level4]
export default Levels

Matching characters to textures (src/scripts/components/map/map.ts)

In case we want to add new enemies or objects, we will have to match a new character against the new texture:

import Levels from '../levels/levels'
export class Map {
  info: TilesConfig[]
  size: MapSize

  public static calcCurrentLevel(currentLevel: number) {
    const MAX_LEVELS = Levels.length
    return currentLevel % MAX_LEVELS
  }

  constructor(currentLevel: number) {
    const TILE_SIZE = 96
    const config: any = {
      '[': {
        type: 'tile',
        texture: 'tile-left'
      },
      '/': {
        type: 'tile',
        texture: 'tile-middle'
      },
      ']': {
        type: 'tile',
        texture: 'tile-right'
      },
      'G': {
        type: 'goal',
        texture: 'goal'
      },
      'C': {
        type: 'object',
        texture: 'coin'
      },
      'K': {
        type: 'object',
        texture: 'key'
      },      
      'S': {
        type: 'enemy',
        texture: 'slime'
      },
      'B': {
        type: 'enemy',
        texture: 'bee'
      },
      'P': {
        type: 'player',
        texture: 'player'
      }
    }

    const map = Levels[Map.calcCurrentLevel(currentLevel)]

    // the player can jump a bit higher than the map's height
    const paddingTop = 4 * TILE_SIZE

    this.size = {
      x: 0,
      y: 0,
      width: map[0].length * TILE_SIZE,
      height: map.length * TILE_SIZE + paddingTop
    }
    this.info = []

    map.forEach((row, y) => {
      for (let i = 0; i < row.length; i++) {
        const tile = row.charAt(i)
        const x = i
        if (tile !== ' ') {
          let info = { ...config[tile.toString()], x: x * TILE_SIZE, y: y * TILE_SIZE + paddingTop }
          this.info.push(info)
        }
      }
    })
  }
}

Enemies (src/scripts/components/enemies/enemiesGroup.ts)

All the tiles in the map are read and the enemies are created depending on the texture field:

import BeeSprite from './bee'
import SlimeSprite from './slime'

export default class EnemiesGroup extends Phaser.GameObjects.Group {
  tiles: TilesConfig[]
  TILE_SIZE = 96
  constructor(scene: Phaser.Scene, tilesConfig: TilesConfig[]) {
    super(scene)

    this.tiles = tilesConfig.filter(tile => tile.type === 'tile')
    let enemyTypes = tilesConfig.filter(tile => tile.type === 'enemy')

    let enemies: Array<BeeSprite | SlimeSprite> = []
    enemyTypes.forEach(enemy => {
      switch (enemy.texture) {
        case 'bee':
          enemies.push(new BeeSprite(scene, enemy.x, enemy.y))
          break
        case 'slime':
          enemies.push(new SlimeSprite(scene, enemy.x, enemy.y))
          break
      }
    })
    this.addMultiple(enemies)
  }

  update() {
    // check if the enemy should change its direction
    // @ts-ignore
    this.children.iterate((enemy: BeeSprite | SlimeSprite) => {
      if (enemy.dead) return

      let enemyIsMovingRight = enemy.body.velocity.x >= 0

      let hasGroundDetection = this.tiles.filter(tile => {
        let enemyPositionX = enemyIsMovingRight ? enemy.body.right : enemy.body.left
        let x = enemyPositionX + 32 > tile.x && enemyPositionX - 32 < tile.x + this.TILE_SIZE
        let y =
          enemy.body.bottom + this.TILE_SIZE / 2 > tile.y &&
          enemy.body.bottom + this.TILE_SIZE / 2 < tile.y + this.TILE_SIZE
        return x && y
      })

      if (hasGroundDetection.length === 0) {
        //@ts-ignore
        enemy.body.setVelocityX(enemy.body.velocity.x * -1)
        enemy.setFlipX(!enemyIsMovingRight)
      }
    }, null)
  }
}

The bee (src/scripts/components/enemies/bee.ts)

Frames 0 and 1 represent the usual movement, while frame number 2 is used when the bee is dead:

Bee enemy (assets/img/bee.png)
Dead bee (assets/audio/bee.mp3)
import EnemyClass from './enemyClass'

export default class BeeSprite extends EnemyClass {

  constructor(scene: Phaser.Scene, x: number, y: number) {
    super(scene, x, y, 'bee')
    scene.add.existing(this)
    scene.physics.add.existing(this)

    scene.anims.create({
      key: 'fly',
      frames: scene.anims.generateFrameNumbers('bee', { start: 0, end: 1 }),
      frameRate: 8,
      repeat: -1
    })
    this.play('fly')

    //@ts-ignore
    this.body.setVelocityX(-120)
    this.setOrigin(0.5, 1)
    this.body.setSize(80, 135)
    this.body.setOffset((this.width - 80) / 2, 30)

    this.audio = scene.sound.add('bee', { volume: 1.0 })
  }

  update() { }

  kill() {
    if (this.dead) return

    this.body.setSize(80, 40)

    this.removeEnemy(2) // Frame number 2 is used when the bee is dead
  }
}

The slime (src/scripts/components/enemies/slime.ts)

Frames from 0 to 4 represent the usual movement, while frame number 5 is used when the slime is dead:

Slime enemy (assets/img/slime.png)
Dead slime (assets/audio/slime.mp3)
import EnemyClass from './enemyClass'

export default class SlimeSprite extends EnemyClass {

  constructor(scene: Phaser.Scene, x: number, y: number) {
    super(scene, x, y, 'slime')
    scene.add.existing(this)
    scene.physics.add.existing(this)

    scene.anims.create({
      key: 'crawl',
      frames: scene.anims.generateFrameNumbers('slime', { start: 0, end: 4 }),
      frameRate: 6,
      yoyo: true,
      repeat: -1
    })
    this.play('crawl')

    //@ts-ignore
    this.body.setVelocityX(-60)
    this.setOrigin(0.5, 1)
    this.setScale(1)
    this.body.setSize(this.width - 40, this.height - 20)
    this.body.setOffset(20, 20)

    this.audio = scene.sound.add('slime', { volume: 1.0 })
  }

  update() { }

  kill() {
    if (this.dead) return

    this.removeEnemy(5) // Frame number 5 is used when the bee is dead
  }
}

Killing the enemies (src/scripts/scenes/mainScene.ts)

The player kills an enemy when it jumps over it. As a reward, it will get 5 points:

... 
   this.physics.add.overlap(this.player, this.enemiesGroup, (player: Player, enemy: EnemySprite) => {
      if (enemy.dead) return
      if (enemy.body.touching.up && player.body.touching.down) {
        this.score += 5
        scoreText.update(this.score, this.keys)
        player.killEnemy()
        enemy.kill()
      } else {
        player.kill()
      }
    })
...

Objects (src/scripts/components/objects/objectsGroup.ts)

All the tiles in the map are read and the objects are created depending on the texture field:

import CoinSprite from './coin'
import KeySprite from './key'

export default class ObjectsGroup extends Phaser.GameObjects.Group {
  constructor(scene: Phaser.Scene, tilesConfig: TilesConfig[]) {
    super(scene)

    let objectTypes = tilesConfig.filter(tile => tile.type === 'object')

    let objects: Array<CoinSprite | KeySprite> = []
    objectTypes.forEach(object => {
      switch (object.texture) {
        case 'coin':
          objects.push(new CoinSprite(scene, object.x, object.y))
          break
        case 'key':
          objects.push(new KeySprite(scene, object.x, object.y))
          break
      }
    })

    this.addMultiple(objects)
  }
}

The coin (src/scripts/components/objects/coin.ts)

All the frames are selected to create the animation:

The coin (assets/img/coin.png)
Collecting coin (assets/audio/coin.mp3)
import ObjectClass from "../objects/objectClass";

export default class CoinSprite extends ObjectClass {

  constructor(scene: Phaser.Scene, x: number, y: number) {
    super(scene, x, y, 'coin')
    scene.add.existing(this)
    scene.physics.add.existing(this)

    this.setImmovable()
    this.setScale(1.5)
    // @ts-ignore
    this.body.setAllowGravity(false)

    scene.anims.create({
      key: 'spincoin',
      frames: scene.anims.generateFrameNames('coin'),
      frameRate: 16,
      repeat: -1
    })
    this.play('spincoin')

    this.audio = scene.sound.add('coin', { volume: 1.0 })
  }
}

The key (src/scripts/components/objects/key.ts)

All the frames are selected to create the animation:

The key (assets/img/key.png)
Collecting key (assets/audio/key.mp3)
import ObjectClass from "./objectClass";

export default class CoinSprite extends ObjectClass {

  constructor(scene: Phaser.Scene, x: number, y: number) {
    super(scene, x, y, 'key')
    scene.add.existing(this)
    scene.physics.add.existing(this)

    this.setImmovable()
    this.setScale(0.7)
    // @ts-ignore
    this.body.setAllowGravity(false)

    scene.anims.create({
      key: 'spinkey',
      frames: scene.anims.generateFrameNames('key'),
      frameRate: 10,
      repeat: -1
    })
    this.play('spinkey')

    this.audio = scene.sound.add('key', { volume: 1.0 })
  }
}

Collecting objects (src/scripts/scenes/mainScene.ts)

The counters of coins and keys are incremented when any of these objects is collected:

...
    this.physics.add.overlap(this.player, objectsGroup, (player: Player, object: ObjectClass) => {
      if (object.collecting) return
      if (object instanceof CoinSprite) this.score++
      else if (object instanceof KeySprite) this.keys++
      scoreText.update(this.score, this.keys)
      object.collect()
    })
...

The player (src/scripts/components/player/player.ts)

The get a more realistic movement, the player is animated using the “spine” plugin. The full documentation about this plugin can be found here, and some examples can be found here.

Player animation using spine plugin (assets/spine/boy.png)
Player killed (assets/audio/hurt.mp3)
import Controls from '../controls/controls'
import PlayerSpine from './playerSpine'

export default class Player extends Phaser.Physics.Arcade.Sprite {
  private _dead: boolean = false
  private _halt: boolean = false
  private mapSize: MapSize
  playerSpine: PlayerSpine
  audioHurt: any

  constructor(scene: Phaser.Scene, player: TilesConfig, mapSize: MapSize, level: number) {
    super(scene, player.x, player.y, player.texture)
    scene.add.existing(this)
    scene.physics.add.existing(this)

    this.scene = scene
    this.mapSize = mapSize

    this.setVisible(false)

    this.setOrigin(0, 1)
    this.setDragX(1500)
    this.body.setSize(70, 132)
    this.body.setOffset(25, 24)

    let theSkin = level % 2 == 0 ? 'blue' : 'green'
    this.playerSpine = new PlayerSpine(scene, this.body.center.x, this.body.bottom)
    this.playerSpine.setSkin(theSkin)

    this.audioHurt = scene.sound.add('hurt', { volume: 1.0 })
  }

  kill() {
    this._dead = true

    this.audioHurt.play()

    // animate the camera if the player dies
    this.scene.cameras.main.shake(500, 0.025)
    this.scene.time.addEvent({
      delay: 500,
      callback: () => this.scene.scene.restart()
    })
  }

  killEnemy() {
    this.playerSpine.spine.customParams.isKilling = true
    this.setVelocityY(-600)
  }

  halt() {
    this.body.enable = false
    this._halt = true
  }

  update(cursors: any, controls: Controls) {
    if (this._halt || this._dead) return

    // check if out of camera and kill
    if (this.body.right < this.mapSize.x || this.body.left > this.mapSize.width || this.body.top > this.mapSize.height)
      this.kill()

    // controls left & right
    if (cursors.left.isDown || controls.leftIsDown) {
      this.setVelocityX(-500)
      this.playerSpine.spine.setScale(-1, 1)
    } else if (cursors.right.isDown || controls.rightIsDown) {
      this.setVelocityX(550)
      this.playerSpine.spine.setScale(1, 1)
    }
    // controls up
    if ((cursors.up.isDown || cursors.space.isDown || controls.upIsDown) && this.body.blocked.down) {
      this.setVelocityY(-1250)
    }

    // update spine animation
    this.playerSpine.update(this)
  }
}

The goal (src/scripts/components/goal/goalSprite.ts)

When the user collides with the door, an achievement sound will be played, and it will jump to the next level.

The goal (assets/img/goal.png)
Next level (assets/audio/door.mp3)
export default class GoalSprite extends Phaser.Physics.Arcade.Sprite {
  private _loadNextLevel: boolean = false;
  private audio: any;

  constructor(scene: Phaser.Scene, tilesConfig: TilesConfig) {
    super(scene, tilesConfig.x, tilesConfig.y + 14, 'goal')
    scene.add.existing(this)
    scene.physics.add.existing(this)

    this.setImmovable(true)
    // @ts-ignore
    this.body.setAllowGravity(false)
    this.setOrigin(0, 0.5)

    this.audio = scene.sound.add('door', { volume: 1.0 })
  }

  get loadNextLevel() {
    return this._loadNextLevel
  }

  nextLevel(scene: Phaser.Scene, level: number, score: number, keys: number) {
    if (this._loadNextLevel) return
    this._loadNextLevel = true

    this.audio.play()

    scene.cameras.main.fadeOut()
    scene.time.addEvent({
      delay: 2000,
      callback: () => {
        scene.scene.restart({ level: level += 1, score: score, keys: keys })
      }
    })
  }
}

Jumping to the next level (src/scripts/scenes/mainScene.ts)

After colliding with the door, the user will only jump to the next level in case it collected previously at least one key:

...
    this.physics.add.overlap(this.player, this.goal, (player: Player, goal: GoalSprite) => {
      if (!this.keys) return
      player.halt()
      this.keys--
      goal.nextLevel(this, this.level, this.score, this.keys)
    })
...

Progressive Web App (PWA)

This game is 100% PWA ready, so that it can be installed and executed in your mobile device as it was an app from the Play Store. You can easily personalize its settings by following these steps:

  • Replace both icons in /pwa/icons with your own.
    • One is 512×512 the other 192×192.
  • Add your own favicon.ico to /src.
  • Adjust these parameters in the manifest.json file in /pwa:
    • short_name: Max. 12 characters.
    • name: The full game name.
    • orientation: “landscape” or “portrait”.
    • background_color: color of the splash screen.
    • theme_color: color of the navbar – has to match the theme-color in the index.html file.
  • You can leave the sw.js (serviceWorker) file in /pwa as it is.
  • Change the gameName in /webpack/webpack.common.js

Read more about PWA on developers.google.com.

Running the game

You can get the game up and running just following these steps:

  1. Install nodejs (in case you do not have installed it yet). Select the version recommended for most users and just click next on each option to use the default settings.
  2. Download the code. You can download from this link a single zip file containing the full game template and all the assets.
  3. Uncompress the zip file, and open a terminal from inside the project folder.
  4. Execute “npm install” to install all the dependencies requested by the project.
  5. Execute “npm run start” to compile and execute the code. After this command is completed, a new tab will be opened in your default browser. The game will also be refreshed automatically when the source code changes.
  6. Execute “npm run build” to get a production version. After executing this command, all the html, css and js files, and also all the assets required to run the game will be put inside the “dist” folder, ready to be uploaded to any public web server. For example, you could make your game available to anyone just by creating a new project in https://replit.com and uploading all the files inside “dist” to the root folder of the replit project.

Play the game on your browser

You have also a version ready to be played online here.

Install the game in your phone

Since all the PWA functionality is included in this project, you can install your application in any mobile device (Chrome browser and Android operating system are the preferred options for this purpose). You will get the same behaviour as the apps in the play store: an icon will be created after carrying out the installation from your browser, a loading screen will be shown when the game is started, and the game will run in fullscreen and landscape mode.

When you open the address of the game in your browser, a message to install the application is displayed after a few seconds:

Installing the app

Or you can also install the game manually just by selecting the option “Instalar aplicación” from the browser menu:

Installing the app

Bibliography

More information about the template used to build this game can be found on github.