Phaser. Unit 8. Dino game clone (part II). Improvements in scenery

Introduction

In the previous unit, we developed a clone of Google Chrome’s “secret” Dino game using simple web technologies such as HTML, CSS, JavaScript, and Phaser 3. This game can be run on any device using any browser (not only Chrome). The result is a game that closely resembles the original with less than 10% of the code lines.

Now we will add some improvements to the original game:

  • Adding colors:
    • Blue sky: The game will feature a blue sky background to create a more visually appealing and natural environment.
    • Brown ground: The ground will be colored brown to simulate a desert-like terrain, enhancing the thematic consistency of the game.
  • Using the whole screen:
    • Sky at the top: The sky background will extend from the top of the screen, providing a continuous and immersive visual experience.
    • Ground at the bottom: The ground will stretch to the bottom of the screen, ensuring that the game environment fully occupies the available screen space.
  • Adding more items:
    • Additional clouds: More clouds will be introduced to the game, floating at different heights to add depth and variety to the sky.
    • Mountains: Mountains will be added in the background, enhancing the scenery and creating a more dynamic landscape.
    • Nests with eggs: Nests containing eggs will be placed throughout the game, serving as potential collectibles or decorative elements to enrich gameplay.
  • Varying speeds based on distance:
    • Parallax effect: Items such as clouds, mountains, obstacles, and eggs will move at different speeds relative to their distance from the player. This creates a parallax effect, making closer items appear to move faster than those further away, adding depth and a sense of realism to the game.
Gameplay

CSS code (styles.css)

No changes are needed for the CSS code, so it will remain as before:

html, body {
  margin: 0px;
  padding: 0px;
  overflow: hidden;
}

HTML code (index.html)

No changes are needed for the HTML code, so it will also remain as before:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="utf-8">
  <meta name="description" content="Dino game clone using Phaser">
  <meta name="keywords" content="HTML, CSS, JavaScript, Phaser">
  <meta name="author" content="Fernando Ruiz Rico">
  
  <title>Dino Phaser</title>

  <link href="styles.css" rel="stylesheet">
</head>

<body>
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/phaser.min.js"></script>
  <script src="preload.js"></script>
  <script src="create.js"></script>
  <script src="update.js"></script>
  <script src="config.js"></script>
</body>

</html>

Improving text styling (config.js)

To improve the visual quality of the text in the Dino game clone, we will reduce pixelation by decreasing the text resolution, resulting in smoother and clearer text edges. Additionally, we will add a white shadow effect with slight offsets and blur, enhancing the contrast and readability. This combination ensures that the text stands out against varying backgrounds, making it easier for players to read scores and instructions quickly and accurately, thus providing a more polished and professional look to the game.

Changes

We will update the TEXT_STYLE configuration to enhance readability and visual appeal by using a bold font, reducing pixelation, and adding a white shadow to improve contrast and visibility:

const TEXT_STYLE = { 
  fill: "#535353", font: '900 35px Courier', resolution: 2,
  shadow: { offsetX: 2, offsetY: 2, blur: 2, color: '#FFFFFF', fill: true }
};
  • Resolution Adjustment: The text resolution is decreased from 10 to 2. This will make the text appear with a lower level of pixelation.
  • Text Shadow Addition:
    • White Color: The shadow is white (#FFFFFF), which helps it stand out against various backgrounds.
    • Offset: The shadow is slightly offset by 2 pixels to the right (offsetX: 2) and 2 pixels down (offsetY: 2), creating a subtle but effective drop shadow.
    • Blur Radius: A small blur radius of 2 pixels (blur: 2) is applied to soften the shadow edges, enhancing the text’s readability.
    • Fully Filled: The shadow effect is fully filled (fill: true), ensuring that the shadow covers the entire text uniformly, thereby improving the overall visual quality and making the text easier to read.

File contents

// Fill the whole width and height of the screen
const WIDTH = window.innerWidth;
const HEIGHT = window.innerHeight;

// Time in milliseconds for each obsticle (lower = faster)
const OBSTICLE_TIME = 750;

// Text style to be used when printing texts (scores, etc.)
const TEXT_STYLE = { 
  fill: "#535353", font: '900 35px Courier', resolution: 2,
  shadow: { offsetX: 2, offsetY: 2, blur: 2, color: '#FFFFFF', fill: true }
};

// Variables to be initialized each time the game is restarted
function initVariables() {
  this.isGameRunning = false;
  this.gameSpeed = 10;
  this.respawnTime = 0;
  this.score = 0;
}

// Phaser initialization
let game = new Phaser.Game({
  type: Phaser.AUTO,
  width: WIDTH,
  height: HEIGHT,
  pixelArt: true,
  transparent: true,
  physics: {
    default: 'arcade',
    arcade: {
      debug: false
    }
  },
  scene: { preload: preload, create: create, update: update }
});

// Reload the game when the device orientation changes or the user resizes the window
let resizing = false;
window.addEventListener('resize', () => {
  if (resizing) clearTimeout(resizing);
  resizing = setTimeout(() => window.location.reload(), 500);
});

The new assets

We will add mountains in the background and some eggs in the foreground. You can find both images below, and you can also download a zip file containing all the assets here.

Mountain
Eggs

Loading the new assets (preload.js)

By adding mountains and eggs, the game gains additional scenery elements, making the game environment more detailed and engaging. To achieve this, only a couple of changes are required inside the “preload.js” file since we are adding only two new images (mountains and eggs).

Changes

The following lines will incorporate the mountain and eggs images into the game’s assets:

function preload() {
    ...
    this.load.image('mountain', 'assets/mountain.png');
    this.load.image('eggs', 'assets/eggs.png');
    ...
}
  • By loading the mountain image, the game can render mountains at various positions and scales to enhance the visual aesthetics.
  • By loading the eggs image, the game can place eggs at various locations, adding an additional layer of interactivity or visual interest.

File contents

// Load all the audios, images and sprites
function preload() {
  this.load.audio('jump', 'assets/jump.m4a');
  this.load.audio('hit', 'assets/hit.m4a');
  this.load.audio('reach', 'assets/reach.m4a');

  this.load.image('ground', 'assets/ground.png');
  this.load.image('mountain', 'assets/mountain.png');
  this.load.image('cloud', 'assets/cloud.png');
  this.load.image('eggs', 'assets/eggs.png');
  this.load.image('dino-idle', 'assets/dino-idle.png');
  this.load.image('dino-hurt', 'assets/dino-hurt.png');
  this.load.image('restart', 'assets/restart.png');
  this.load.image('game-over', 'assets/game-over.png');

  this.load.image('obsticle-1', 'assets/cactuses_small_1.png');
  this.load.image('obsticle-2', 'assets/cactuses_small_2.png');
  this.load.image('obsticle-3', 'assets/cactuses_small_3.png');
  this.load.image('obsticle-4', 'assets/cactuses_big_1.png');
  this.load.image('obsticle-5', 'assets/cactuses_big_2.png');
  this.load.image('obsticle-6', 'assets/cactuses_big_3.png');

  this.load.spritesheet('dino', 'assets/dino-run.png', { frameWidth: 88, frameHeight: 94 });
  this.load.spritesheet('enemy-bird', 'assets/enemy-bird.png', { frameWidth: 92, frameHeight: 77 });
}

// Set the volume of each audio
function initSounds() {
  this.jumpSound = this.sound.add('jump', { volume: 0.75 });
  this.hitSound = this.sound.add('hit', { volume: 0.75 });
  this.reachSound = this.sound.add('reach', { volume: 0.75 });
}

// Define all the animations from the previously loaded spritesheets
function createAnims() {
  this.anims.create({
    key: 'dino-waiting',
    frames: this.anims.generateFrameNumbers('dino', { start: 0, end: 1 }),
    frameRate: 1,
    repeat: -1
  })

  this.anims.create({
    key: 'dino-run',
    frames: this.anims.generateFrameNumbers('dino', { start: 2, end: 3 }),
    frameRate: 10,
    repeat: -1
  })

  this.anims.create({
    key: 'enemy-bird-anim',
    frames: this.anims.generateFrameNumbers('enemy-bird', { start: 0, end: 1 }),
    frameRate: 6,
    repeat: -1
  })
}

Adding sky and ground colors, and some mountains (create.js)

The new version of the game includes adding colors to the sky and the ground, enhancing the overall visual appeal and creating a more immersive environment. We will also introduce a new function to add mountains to the game, calling it from the main create function immediately after creating the clouds to ensure the mountains appear in a higher layer.

Changes

The createWorld function will add color to the sky and ground by specifying color codes as parameters when creating the rectangles:

function createWorld() {
  this.sky = this.add.rectangle(0, 0, WIDTH, HEIGHT, 0x87CEEB).setOrigin(0, 0);
  this.belowGround = this.add.rectangle(0, HEIGHT / 1.5, WIDTH, HEIGHT / 3, 0xD2B48C).setOrigin(0, 0);
  ...
}

The sky will be a blue rectangle (0x87CEEB) spanning the entire screen, positioned at the top (setOrigin(0, 0)), while the ground will be made of a brown rectangle (0xD2B48C) occupying the lower third of the screen, starting at half the screen’s height.

The next step involves displaying the mountains by introducing the createMountains function and integrating it into the main create function. These mountains will serve as background elements to enhance the visual appeal and depth of the game environment. The function will position the mountains randomly along the x-axis and consistently along the y-axis, while also scaling them variably, contributing to a dynamic and immersive gaming experience:

function createMountains() {
  this.mountains = this.add.group();
  for (let i = 0; i < 3; i++) {
    const x = Phaser.Math.Between(0, WIDTH);
    const y = HEIGHT / 1.5;
    const scale = Phaser.Math.FloatBetween(1.0, 4.0);
    this.mountains.add(this.add.image(x, y, 'mountain').setScale(scale).setOrigin(0, 1));
  }
}
  • Group creation: The function begins by creating a new group to hold all the mountain images. This allows for easier management and manipulation of the mountains as a single entity.
  • Mountain positioning and scaling:
    • Random x position: Each mountain is positioned at a random location along the x-axis (Phaser.Math.Between(0, WIDTH)), which ensures that the mountains are spread across the screen.
    • Fixed y position: All mountains are placed at a fixed y-coordinate (HEIGHT / 1.5), which situates them in the lower half of the screen, simulating a horizon.
    • Random scaling: Each mountain is assigned a random scale (Phaser.Math.FloatBetween(1.0, 4.0)), adding visual diversity and depth to the scene. Larger mountains appear closer to the player, while smaller ones seem further away.
  • Image Addition: The mountains are added to the group with their respective positions and scales, and their origin is set to the bottom-left corner (setOrigin(0, 1)), ensuring they appear grounded at the correct height.

The last change inside the create.js file will consist of integrating createMountains into the main create function:

function create() {
  ...
  createWorld.call(this);
  createClouds.call(this);
  createMountains.call(this);
  ...
}

The order of the functions calls (createWorld, createClouds, and createMountains) is crucial for maintaining a coherent and visually appealing game environment. Each step builds upon the previous one, ensuring that all elements are correctly layered and contribute to a dynamic and immersive game world. By establishing the foundation first, enhancing the background second, and adding depth third, the game achieves a realistic and engaging visual structure that enhances the player’s experience:

  • createWorld: Sets up the basic game world, including the sky, ground, and obstacle framework. This is the first step because it establishes the fundamental structure of the game. The sky and ground serve as the canvas upon which all other game elements are placed. Without this setup, there would be no context for the other elements to exist within.
  • createClouds: Adds clouds to the sky. Adding clouds after the basic world setup ensures that they are correctly layered above the sky but below the ground and obstacles. This layering is essential for maintaining the visual hierarchy and realism in the game. Clouds enhance the background without interfering with the main gameplay elements.
  • createMountains: This new function call integrates the mountains into the game, adding visual depth and enhancing the background scenery. Integrating mountains after the clouds ensures that they are correctly layered. Mountains should appear behind the ground but in front of the sky and clouds. This order maintains the natural layering one would expect in a real-world scene. Mountains add depth to the environment, making the game world feel more expansive and visually rich.

File contents

// Score text on the right side of the screen
function createScore() {
  this.scoreText = this.add.text(WIDTH - 25, 25, "", TEXT_STYLE).setOrigin(1, 0);
  this.highScoreText = this.add.text(0, 25, "", TEXT_STYLE).setOrigin(1, 0).setAlpha(0.75);
}

// Five clouds at random positions
function createClouds() {
  this.clouds = this.add.group();
  for (let i = 0; i < 5; i++) {
    const x = Phaser.Math.Between(0, WIDTH);
    const y = Phaser.Math.Between(0, HEIGHT / 2);
    const scale = Phaser.Math.FloatBetween(1.0, 3.0);
    this.clouds.add(this.add.image(x, y, 'cloud').setScale(scale).setOrigin(0, 0));
  }
}

// Three mountains at random X positions
function createMountains() {
  this.mountains = this.add.group();
  for (let i = 0; i < 3; i++) {
    const x = Phaser.Math.Between(0, WIDTH);
    const y = HEIGHT / 1.5;
    const scale = Phaser.Math.FloatBetween(1.0, 4.0);
    this.mountains.add(this.add.image(x, y, 'mountain').setScale(scale).setOrigin(0, 1));
  }
}

// Dino starts running and the the ground is created
function createGround() {
  this.startText.destroy();
  this.dino.setVelocityX(100);
  this.dino.play('dino-run', 1);

  const interval = setInterval(() => {
    this.ground.width += (WIDTH / 100);
    if (this.ground.width >= WIDTH) {
      clearInterval(interval);
      this.dino.setVelocityX(0);
      this.scoreText.setAlpha(1);
      this.isGameRunning = true;
    }
  }, 50);
}

// Dino waiting animation, keyboard initilization and starting text
function createDino() {
  this.dino = this.physics.add.sprite(0, HEIGHT / 1.5 - 16, 'dino-idle');
  this.dino.setSize(50, 75).setGravityY(5000).setOrigin(0, 1);
  this.dino.play('dino-waiting', true);

  this.physics.add.collider(this.dino, this.belowGround);
  this.physics.add.collider(this.dino, this.obsticles, () => stopGame.call(this));

  this.cursors = this.input.keyboard.createCursorKeys();
  this.spaceBar = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);

  this.startText = this.add.text(WIDTH - 15, HEIGHT / 1.5, "Press to play", TEXT_STYLE).setOrigin(1);
}

// Game over screen and pause until player starts the game again
function createGameOverScreen() {
  this.gameOverShadow = this.add.image(2, 2, 'game-over').setTintFill(0xFFFFFF);
  this.gameOverText = this.add.image(0, 0, 'game-over');
  
  this.restart = this.add.image(0, 50, 'restart').setInteractive();
  this.gameOverScreen = this.add.container(WIDTH / 2, HEIGHT / 2 - 50);
  this.gameOverScreen.add([this.gameOverShadow, this.gameOverText, this.restart]);

  this.input.keyboard.on('keydown_ENTER', () => startGame.call(this));
  this.restart.on('pointerdown', () => startGame.call(this));
}

// Define the physical ground and obsticles group
function createWorld() {
  this.sky = this.add.rectangle(0, 0, WIDTH, HEIGHT, 0x87CEEB).setOrigin(0, 0);
  this.belowGround = this.add.rectangle(0, HEIGHT / 1.5, WIDTH, HEIGHT / 3, 0xD2B48C).setOrigin(0, 0);
  this.ground = this.add.tileSprite(0, HEIGHT / 1.5, 88, 16, 'ground').setOrigin(0, 0);
  this.physics.add.existing(this.belowGround);
  this.belowGround.body.setImmovable();
  this.obsticles = this.physics.add.group();
  this.eggs = this.physics.add.group();
}

// Create all the elements in the game and initilize the score
function create() {
  initVariables.call(this);
  initSounds.call(this);
  createAnims.call(this);
  createWorld.call(this);
  createClouds.call(this);
  createMountains.call(this);
  createScore.call(this);
  createDino.call(this);

  setInterval(() => updateScore.call(this), 100);
}

Adding the eggs and moving all the items (update.js)

The update.js file has been expanded to include new game mechanics such as handling eggs and dynamically moving items across the screen at different speeds.

Changes

We will use a new function placeEgg that will randomly position eggs on the screen:

function placeEgg() {
  const x = Phaser.Math.Between(WIDTH, WIDTH * 2);
  const y = Phaser.Math.Between(HEIGHT / 1.5 + 50, HEIGHT);
  const scale = (y / HEIGHT) * 1.5; // The closer to the bottom of the screen, the bigger the egg
  this.eggs.add(this.add.image(x, y, 'eggs').setScale(scale).setOrigin(0, 1));
}
  • x is set to a random position between the current screen width and twice the width, ensuring eggs appear off-screen to the right initially.
  • y is a random position between just below the ground level and the bottom of the screen.
  • The scale of the egg depends on its y position: eggs closer to the bottom are scaled larger.
  • Adds the egg image to the eggs group with the specified scale and origin, in order to move all the eggs at the same time when the game is running.

Because we will have more items on the screen to be moved, we will use a new function moveItems to handle the movement of each group of items (like clouds, obstacles, or eggs) across the screen:

function moveItems(items, speed, destroy) {
  items.getChildren().forEach(item => {
    item.x -= speed;
    if (item.getBounds().right < 0) {
      if (destroy) item.destroy();
      else item.x = WIDTH;
    }
  });
}
  • items.getChildren().forEach(item => {...}) iterates through each item in the group.
  • Each item’s x position is decremented by speed, making it move to the left.
  • If an item moves off-screen (its right boundary is less than 0), it either gets destroyed (if destroy is true) or is repositioned to the right edge of the screen (if destroy is false).

The update function is responsible for continuously updating the screen while the game is running. It displays the eggs at the same time obstacles appear and moves all items (clouds, mountains, obstacles, and eggs) at different speeds to create a sense of depth. This makes items appear closer or farther away from the player, enhancing the gameplay experience:

  • Displaying Eggs and Obstacles: This function will control the timing of when eggs and obstacles appear on the screen. Both will be placed at intervals to create challenges for the player.
  • Moving Items at Different Speeds: The function will move different items (clouds, mountains, obstacles, and eggs) across the screen at varying speeds. This will create a parallax effect, making items appear closer or farther away from the player based on their speed. For instance, clouds might move slowly to appear in the distance, while obstacles and eggs will move faster to simulate being on the ground.
function update() {
    if (this.respawnTime >= OBSTICLE_TIME) {
      ...
      placeEgg.call(this);
    }

    moveItems(this.clouds, this.gameSpeed / 20, false);
    moveItems(this.mountains, this.gameSpeed / 10, false);
    moveItems(this.obsticles, this.gameSpeed, true);
    moveItems(this.eggs, this.gameSpeed, true);
    ...
}

The code above will create a sense of depth and immersion by simply adjusting the speeds at which different items move:

  • The respawnTime variable determines when new obstacles and eggs are placed on the screen. When respawnTime reaches a certain threshold (e.g., 750), the function calls placeObsticle and placeEgg to add new items to the game.
  • Moving Items:
    • Clouds: Moved slowly (e.g., cloud.x -= 0.5) to give a distant background effect.
    • Mountains: Moved at a moderate speed to appear mid-distance.
    • Obstacles and Eggs: Moved at the game speed (this.gameSpeed) to appear on the ground and provide immediate challenges for the player.

File contents

// Restart the game after dino is hurt
function startGame() {
  this.dino.setVelocityY(0);
  this.physics.resume();
  this.anims.resumeAll();
  this.obsticles.clear(true, true);
  this.gameOverScreen.destroy();
  this.isGameRunning = true;
}

// On game over a sound is played and all physics are paused
// The game over screen will be shown and mobile devices will vibrate
function stopGame() {
  initVariables.call(this);
  updateHighScore.call(this);
  this.hitSound.play();
  this.physics.pause();
  this.anims.pauseAll();
  this.dino.setTexture('dino-hurt');
  createGameOverScreen.call(this);
  if (window.navigator.vibrate) window.navigator.vibrate(50);
}

// Update the score and increase the game speed
function updateScore() {
  if (!this.isGameRunning) return;

  this.score++;
  this.gameSpeed += 0.01;

  if (this.score % 100 == 0) {
    this.reachSound.play();

    this.tweens.add({
      targets: this.scoreText,
      duration: 100,
      repeat: 3,
      alpha: 0,
      yoyo: true
    })
  }

  this.scoreText.setText(("0000" + this.score).slice(-5));
}

// Update the high score in case is higher than the previous one
function updateHighScore() {
  this.highScoreText.x = this.scoreText.x - this.scoreText.width - 30;

  const highScore = this.highScoreText.text.substr(this.highScoreText.text.length - 5);
  const newScore = Number(this.scoreText.text) > Number(highScore) ? this.scoreText.text : highScore;

  this.highScoreText.setText(`HI ${newScore}`);
}

// Place a new obsticle on the screen (either cactuses or birds)
function placeObsticle() {
  let obsticle;
  const obsticleNum = Phaser.Math.Between(1, 7);

  if (obsticleNum > 6) {
    obsticle = this.obsticles.create(WIDTH, HEIGHT / 1.5 - Phaser.Math.Between(20, 50), 'enemy-bird');
    obsticle.body.height /= 1.5
    obsticle.play('enemy-bird-anim', 1);
  } else {
    obsticle = this.obsticles.create(WIDTH, HEIGHT / 1.5 + 16, `obsticle-${obsticleNum}`)
  }

  obsticle.setOrigin(0, 1).setImmovable();
}

// Three eggs hidden at the beginning
function placeEgg() {
  const x = Phaser.Math.Between(WIDTH, WIDTH * 2);
  const y = Phaser.Math.Between(HEIGHT / 1.5 + 50, HEIGHT);
  const scale = (y / HEIGHT) * 1.5; // The closest to the bottom of the screen, the bigger the egg
  this.eggs.add(this.add.image(x, y, 'eggs').setScale(scale).setOrigin(0, 1));
}

// Move all the images on the screen belonging to a specific group of items
function moveItems(items, speed, destroy) {
  items.getChildren().forEach(item => {
    item.x -= speed;
    if (item.getBounds().right < 0) {
      if (destroy) item.destroy();
      else item.x = WIDTH;
    }
  })
}

// Update the screen (obsticles, ground, clouds and dino) and check the keyboard and mouse (or touch screen) continuously
// Dino will start running and jump if the user taps the screen or presses either the arrow up key or the spacebar  
function update() {
  let jump = this.cursors.up.isDown || this.spaceBar.isDown || this.input.activePointer.isDown;

  if (this.isGameRunning) {
    this.ground.tilePositionX += this.gameSpeed;

    this.respawnTime += this.gameSpeed;
    if (this.respawnTime >= OBSTICLE_TIME) {
      this.respawnTime = 0;
      placeObsticle.call(this);
      placeEgg.call(this);
    }

    moveItems(this.clouds, this.gameSpeed / 20, false);
    moveItems(this.mountains, this.gameSpeed / 10, false);
    moveItems(this.obsticles, this.gameSpeed, true);
    moveItems(this.eggs, this.gameSpeed, true);

    if (this.dino.body.onFloor()) {
      if (jump) {
        this.jumpSound.play();
        this.dino.anims.stop();
        this.dino.setVelocityY(-1600);
        this.dino.setTexture('dino', 0);
      }
      else {
        this.dino.play('dino-run', true);
      }
    }
  }
  else if (jump && (this.ground.width < WIDTH)) {
    createGround.call(this);
  }
}

Proposed exercise: Using your own assets

Create your own improved version of the Dino game using your own assets (images, sprites and sounds). You can also change any other things you like.

Do not forget to create all the files required to run the code in this unit:

  • styles.css
  • index.html
  • preload.js
  • create.js
  • update.js
  • config.js
  • assets folder

You may find many free assets in OpenGameArt, and some new versions of the Dino game in github:

Enjoy the game!

You can enjoy playing this wonderful game online here.

Phaser. Unit 12. Super Mario Bros recreation with random level generation

Introduction

Super Mario Phaser is a modern web-based recreation of the classic Super Mario Bros game, built using the Phaser framework. This project is designed to bring the beloved nostalgic experience of the original game to today’s web browsers, allowing players to enjoy it directly online.

One of the standout features of Super Mario Phaser is its random level generation. This means that every time you play, the levels are different, providing a fresh and challenging experience with each playthrough. This dynamic element ensures that the game remains exciting and unpredictable, offering new adventures and obstacles for players to overcome every time they start a new game.

Additionally, we will provide options to customize the control keys, allowing players to configure their preferred inputs. We will also include settings to adjust or mute the sound, as well as control the volume of both music and sound effects, enhancing the overall gaming experience.

For more information about the original project, you can visit its GitHub repository.

Level generation

In this unit, we will create a simplified version of the original Super Mario Phaser project. Our goal is to enable easy customization of level generation, even for those without extensive programming knowledge.

To achieve this, we will use a matrix template where each cell specifies a type of block. By implementing a nested loop, we can efficiently render all the cells in the matrix, generating the level layout. This approach allows for straightforward modification and understanding of how different blocks are placed within the game environment.

For example, if we want to build a platform consisting of several blocks with some coins, we can use a matrix to define the layout. Each cell will represent a specific element (‘B’ stands for ‘block’ and ‘C’ stands for ‘coin’). Here is an example of how the matrix might look:

[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
[' ', ' ', 'C', 'C', 'C', 'C', 'C', 'C', ' ', ' '],
[' ', ' ', 'B', 'B', 'B', 'B', 'B', 'B', ' ', ' '],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],

In this matrix, we have a design where coins are positioned directly above a row of blocks. Specifically, the fifth row of the matrix places coins in the middle columns, while the sixth row directly beneath it contains a continuous platform of blocks. This layout allows for a straightforward and clear way to design and visualize the level structure. The empty spaces around the blocks and coins indicate that these elements are floating in the air, creating a typical platforming challenge for the player.

The assets

/assets/scenery/sign.png
/assets/others/loading.gif
/assets/entities/mario.png
/assets/entities/mario-grown.png
/assets/entities/mario-fire.png
/assets/scenery/overworld/cloud1.png
/assets/scenery/castle.png
/assets/scenery/overworld/bush1.png
/assets/scenery/overworld/fence.png
/assets/scenery/overworld/floor-bricks.png
/assets/scenery/underground/floor-bricks.png
/assets/entities/overworld/goomba.png
La imagen tiene un atributo ALT vacío; su nombre de archivo es goomba.png
/assets/entities/underground/goomba.png
/assets/blocks/overworld/mistery-block.png
/assets/blocks/underground/mistery-block.png
/assets/collectibles/overworld/fire-flower.png
/assets/collectibles/underground/fire-flower.png
/assets/collectibles/coin.png
/assets/collectibles/underground/ground-coin.png

Loading the assets (js/main/preload.js)

The provided code inside the “preload.js” file includes functions for preloading assets, initializing sounds, and creating animations.

The preload function is responsible for loading all necessary game assets before the game starts. It sets up a visual progress bar and percentage text to inform the user about the loading progress. Various assets such as images, spritesheets, fonts, and sound files are loaded. The function also includes logic to determine the style of the level (overworld or underground) randomly and loads the appropriate assets accordingly. Additionally, it loads several plugins required for the game.

The initSounds function initializes the game’s audio elements. It iterates over a list of sound assets, creates audio objects for each sound, and assigns them to the game instance. This function ensures that all sounds are ready to be played during the game, with background music starting to loop once the sounds are initialized.

The createAnimations function defines various animations for the game’s characters and objects. It creates both single-frame and multi-frame animations using the loaded spritesheets. Single-frame animations are static poses, while multi-frame animations include movements like running, walking, and item interactions. These animations are essential for bringing the game characters and elements to life, providing visual feedback and enhancing the gaming experience.

Overall, the following code prepares the game environment by loading all assets, initializing audio, and setting up animations:

function preload() {
    // Setup progress box, bar, and percent text
    const progressBox = this.add.graphics().fillStyle(0x222222, 1)
        .fillRoundedRect(screenWidth / 2.48, screenHeight / 2 * 1.05, screenWidth / 5.3, screenHeight / 20.7, 10);
    const progressBar = this.add.graphics();
    const percentText = this.make.text({
        x: this.cameras.main.width / 2,
        y: this.cameras.main.height / 2 * 1.25,
        text: '0%',
        style: {
            font: `${screenWidth / 96}px pixel_nums`,
            fill: '#ffffff'
        }
    }).setOrigin(0.5, 0.5);

    // Update progress bar and text on progress
    this.load.on('progress', (value) => {
        percentText.setText(value * 99 >= 99 ? 'Generating world...' : `Loading... ${parseInt(value * 99)}%`);
        progressBar.clear().fillStyle(0xffffff, 1)
            .fillRoundedRect(screenWidth / 2.45, screenHeight / 2 * 1.07, screenWidth / 5.6 * value, screenHeight / 34.5, 5);
    });

    // Clean up on load complete
    this.load.on('complete', () => {
        [progressBar, progressBox, percentText].forEach(item => item.destroy());
        loadingGif.forEach(gif => gif.style.display = 'none');
    });

    // Load Fonts and Plugins
    this.load.bitmapFont('carrier_command', 'assets/fonts/carrier_command.png', 'assets/fonts/carrier_command.xml');
    const plugins = ['rexvirtualjoystickplugin', 'rexcheckboxplugin', 'rexsliderplugin', 'rexkawaseblurpipelineplugin'];
    plugins.forEach(plugin => {
        this.load.plugin(plugin, `https://raw.githubusercontent.com/rexrainbow/phaser3-rex-notes/master/dist/${plugin}.min.js`, true);
    });

    // Determine level style
    isLevelOverworld = Phaser.Math.Between(0, 100) <= 50;
    const levelStyle = isLevelOverworld ? 'overworld' : 'underground';

    // Utility functions for loading assets
    const loadSpriteSheet = (path, frameWidth, frameHeight) => {
        this.load.spritesheet(path.split('/').pop(), `assets/${path}.png`, { frameWidth, frameHeight });
    };
    const loadImage = (path) => {
        this.load.image(path.split('/').pop(), `assets/${path}.png`);
    };

    // Load entity sprites
    [{ path: 'mario', frameWidth: 18, frameHeight: 16 },
    { path: 'mario-grown', frameWidth: 18, frameHeight: 32 },
    { path: 'mario-fire', frameWidth: 18, frameHeight: 32 },
    { path: `${levelStyle}/goomba`, frameWidth: 16, frameHeight: 16 },
    { path: 'koopa', frameWidth: 16, frameHeight: 24 },
    { path: 'shell', frameWidth: 16, frameHeight: 15 },
    { path: 'fireball', frameWidth: 8, frameHeight: 8 },
    { path: 'fireball-explosion', frameWidth: 16, frameHeight: 16 }]
        .forEach(item => loadSpriteSheet(`entities/${item.path}`, item.frameWidth, item.frameHeight));

    // Load collectibles
    [{ path: 'coin', frameWidth: 16, frameHeight: 16 },
    { path: 'underground/ground-coin', frameWidth: 10, frameHeight: 14 },
    { path: `${levelStyle}/fire-flower`, frameWidth: 16, frameHeight: 16 }]
        .forEach(item => loadSpriteSheet(`collectibles/${item.path}`, item.frameWidth, item.frameHeight));

    // Load animated blocks
    [{ path: `${levelStyle}/brick-debris`, frameWidth: 8, frameHeight: 8 },
    { path: `${levelStyle}/mistery-block`, frameWidth: 16, frameHeight: 16 },
    { path: 'overworld/custom-block', frameWidth: 16, frameHeight: 16 }]
        .forEach(item => loadSpriteSheet(`blocks/${item.path}`, item.frameWidth, item.frameHeight));

    // Load animated hud
    [{ path: 'npc', frameWidth: 16, frameHeight: 24 }]
        .forEach(hud => loadSpriteSheet(`hud/${hud.path}`, hud.frameWidth, hud.frameHeight));

    // Load mushrooms
    ['live', 'super']
        .forEach(item => loadImage(`collectibles/${item}-mushroom`));

    // Load normal scenery
    ['castle', 'flag-mast', 'final-flag', 'sign']
        .forEach(item => loadImage(`scenery/${item}`));

    // Load overworld scenery
    ['cloud1', 'cloud2', 'mountain1', 'mountain2', 'fence', 'bush1', 'bush2']
        .forEach(item => loadImage(`scenery/overworld/${item}`));

    // Load specific scenery
    ['floor-bricks']
        .forEach(item => loadImage(`scenery/${levelStyle}/${item}`));
    ['start-floor-bricks']
        .forEach(item => loadImage(`scenery/overworld/${item}`));

    // Load tubes
    ['horizontal', 'horizontal-final', 'vertical-small', 'vertical-medium', 'vertical-large', 'vertical-extra-large']
        .forEach(item => loadImage(`scenery/${item}-tube`));

    // Load HUD
    ['gear', 'settings-bubble']
        .forEach(item => loadImage(`hud/${item}`));

    // Load static blocks
    ['block', 'empty-block', 'immovable-block']
        .forEach(item => loadImage(`blocks/${levelStyle}/${item}`));
    ['construction-block']
        .forEach(item => loadImage(`blocks/underground/${item}`));
    

    // Load sounds (music and effects)
    this.sounds = [
        { path: 'music/overworld/overworld.mp3', volume: 0.15 },
        { path: 'music/underground/underground.mp3', volume: 0.15 },
        { path: `music/${levelStyle}/hurry-up.mp3`, volume: 0.15 },
        { path: 'music/game-over.mp3', volume: 0.3 },
        { path: 'music/win.wav', volume: 0.3 },
        { path: 'effects/jump.mp3', volume: 0.1 },
        { path: 'effects/coin.mp3', volume: 0.2 },
        { path: 'effects/power-up-appears.mp3', volume: 0.2 },
        { path: 'effects/consume-power-up.mp3', volume: 0.2 },
        { path: 'effects/power-down.mp3', volume: 0.3 },
        { path: 'effects/goomba-stomp.wav', volume: 1 },
        { path: 'effects/flagpole.mp3', volume: 0.3 },
        { path: 'effects/fireball.mp3', volume: 0.3 },
        { path: 'effects/kick.mp3', volume: 0.3 },
        { path: 'effects/time-warning.mp3', volume: 0.2 },
        { path: 'effects/pause.wav', volume: 0.17 },
        { path: 'effects/block-bump.wav', volume: 0.3 },
        { path: 'effects/break-block.wav', volume: 0.5 },
        { path: Phaser.Math.Between(0, 100) < 98 ? 'effects/here-we-go.mp3' : 'effects/cursed/here-we-go.mp3', volume: 0.17 }
    ];

    const generateKey = path => path.split('/').pop().split('.')[0].replace(/-./g, match => match.charAt(1).toUpperCase()) + 'Sound';
    this.sounds.forEach(sound => {
        sound.key = generateKey(sound.path);
        this.load.audio(sound.key, `assets/sound/${sound.path}`);
    });
}

function initSounds() {
    this.sounds.forEach(sound => {
        this[sound.key] = this.sound.add(sound.key, { volume: sound.volume });
    });
    this.overworldSound.play({ loop: -1 });
}

function createAnimations() {
    const singleFrameAnimations = [
        { key: 'idle', target: 'mario', frame: 0 },
        { key: 'hurt', target: 'mario', frame: 4 },
        { key: 'jump', target: 'mario', frame: 5 },
        { key: 'grown-mario-idle', target: 'mario-grown', frame: 0 },
        { key: 'grown-mario-crouch', target: 'mario-grown', frame: 4 },
        { key: 'grown-mario-jump', target: 'mario-grown', frame: 5 },
        { key: 'fire-mario-idle', target: 'mario-fire', frame: 0 },
        { key: 'fire-mario-crouch', target: 'mario-fire', frame: 4 },
        { key: 'fire-mario-jump', target: 'mario-fire', frame: 5 },
        { key: 'fire-mario-throw', target: 'mario-fire', frame: 6 },
        { key: 'goomba-idle', target: 'goomba', frame: 1 },
        { key: 'goomba-hurt', target: 'goomba', frame: 2 },
        { key: 'koopa-idle', target: 'koopa', frame: 1 },
        { key: 'koopa-hurt', target: 'koopa', frame: 0 },
        { key: 'koopa-shell', target: 'koopa', frame: 1 },
        { key: 'fireball-left-down', target: 'fireball', frame: 0 },
        { key: 'fireball-left-up', target: 'fireball', frame: 1 },
        { key: 'fireball-right-down', target: 'fireball', frame: 2 },
        { key: 'fireball-right-up', target: 'fireball', frame: 3 },
        { key: 'fireball-explosion-1', target: 'fireball-explosion', frame: 0 },
        { key: 'fireball-explosion-2', target: 'fireball-explosion', frame: 1 },
        { key: 'fireball-explosion-3', target: 'fireball-explosion', frame: 2 },
    ];

    const multiFrameAnimations = [
        { key: 'run', target: 'mario', start: 3, end: 1, frameRate: 12, repeat: -1 },
        { key: 'grown-mario-run', target: 'mario-grown', start: 3, end: 1, frameRate: 12, repeat: -1 },
        { key: 'fire-mario-run', target: 'mario-fire', start: 3, end: 1, frameRate: 12, repeat: -1 },
        { key: 'goomba-walk', target: 'goomba', start: 0, end: 1, frameRate: 8, repeat: -1 },
        { key: 'koopa-walk', target: 'koopa', start: 0, end: 1, frameRate: 8, repeat: -1 },
        { key: 'coin-default', target: 'coin', start: 0, end: 3, frameRate: 10, repeat: -1 },
        { key: 'ground-coin-default', target: 'ground-coin', start: 2, end: 0, frameRate: 5, repeat: -1, repeatDelay: 5 },
        { key: 'mistery-block-default', target: 'mistery-block', start: 2, end: 0, frameRate: 5, repeat: -1, repeatDelay: 5 },
        { key: 'custom-block-default', target: 'custom-block', start: 2, end: 0, frameRate: 5, repeat: -1, repeatDelay: 5 },
        { key: 'brick-debris-default', target: 'brick-debris', start: 0, end: 3, frameRate: 4, repeat: -1 },
        { key: 'fire-flower-default', target: 'fire-flower', start: 0, end: 3, frameRate: 10, repeat: -1 },
        { key: 'npc-default', target: 'npc', start: 0, end: 1, frameRate: 2, repeat: -1, repeatDelay: 10 },
    ];

    singleFrameAnimations.forEach(({ key, target, frame }) => {
        this.anims.create({
            key,
            frames: [{ key: target, frame }]
        });
    });

    multiFrameAnimations.forEach(({ key, target, start, end, frameRate, repeat, repeatDelay = 0 }) => {
        this.anims.create({
            key,
            frames: this.anims.generateFrameNumbers(target, { start, end }),
            frameRate,
            repeat,
            repeatDelay
        });
    });
}

Drawing the blocks and coins (js/game/structures.js)

The function generateStructure is designed to create and display different elements (blocks, coins, etc.) on the screen based on a predefined matrix layout. The key steps and components are as follows:

  1. Element addition functions: Several helper functions (addBlock, addConstructionBlock, addImmovableBlock, addMisteryBlock, addCoin) are defined to add different types of game elements (blocks, mystery blocks, immovable blocks, coins) to their respective groups and scale them appropriately.
  2. Level layouts: The levels object contains predefined matrices for different types of levels (overworld and underworld). Each matrix defines the placement of blocks (‘B’), mystery blocks (‘M’), immovable blocks (‘I’), coins (‘C’), and construction blocks (‘X’).
  3. Random level selection: A random matrix is selected from the levels object based on whether the level is overworld or underworld.
  4. Drawing the matrix: The drawMatrix function iterates over the selected matrix, calculates the position for each element, and uses the helper functions to add the elements to the screen.

In the following source code, each matrix is used to define the layout of game elements in a clear and structured way, and the function dynamically generates the game level based on these definitions:

function generateStructure(pieceStart) {
    const scale = screenHeight / 345;

    const addBlock = (x, y) =>
        this.blocksGroup.add(this.add.image(x, y, 'block').setScale(scale));

    const addConstructionBlock = (x, y) =>
        this.constructionBlocksGroup.add(this.add.image(x, y, 'construction-block').setScale(scale));

    const addImmovableBlock = (x, y) =>
        this.immovableBlocksGroup.add(this.add.image(x, y, 'immovable-block').setScale(scale));

    const addMisteryBlock = (x, y) =>
        this.misteryBlocksGroup.add(this.add.sprite(x, y, 'mistery-block').setScale(scale));

    const addCoin = (x, y) =>
        this.groundCoinsGroup.add(this.physics.add.sprite(x, y, 'ground-coin').setScale(scale));

    const levels = {
        overworld: [
            [
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', 'B', 'M', 'B', ' ', ' ', ' ', ' ', ' ', 'B', 'M', 'B', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', 'B', 'B', 'M', 'B', 'B', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
            ],
            [
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', 'B', 'M', 'B', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', 'B', 'M', 'B', ' ', ' ', ' ', 'B', 'M', 'B', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
            ],
            [
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', 'M', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', 'B', 'M', 'B', 'M', 'B', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
            ],
            [
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', 'B', 'M', 'M', 'B', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', 'B', 'B', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
            ],
            [
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', 'M', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', 'M', ' ', ' ', 'M', ' ', ' ', 'M', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
            ],
            [
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', 'B', 'B', 'M', 'B', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
            ],
        ],
        underworld: [
            [
                [' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', 'M', 'M', 'M', 'M', 'M', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' '],
            ],
            [
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', 'I', ' ', ' ', ' ', 'M'],
                [' ', ' ', ' ', ' ', 'I', ' ', 'I', ' ', 'I', ' ', ' '],
                [' ', ' ', 'I', ' ', 'I', ' ', 'I', ' ', 'I', ' ', ' '],
                ['I', ' ', 'I', ' ', 'I', ' ', 'I', ' ', 'I', ' ', ' '],
            ],
            [
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '], 
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '], 
                [' ', ' ', ' ', 'C', 'C', 'C', 'C', ' ', ' ', ' '], 
                [' ', ' ', ' ', 'X', 'X', 'X', 'X', ' ', 'X', ' '], 
                [' ', ' ', 'C', 'X', ' ', ' ', 'X', 'C', 'X', ' '], 
                [' ', 'X', 'X', 'X', ' ', ' ', 'X', 'X', 'X', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '], 
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '], 
            ],
            [
                [' ', ' ', 'M', 'M', ' ', ' ', ' ', ' ', ' ', ' ', ' '], 
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '], 
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '], 
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '], 
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '], 
                [' ', ' ', 'M', 'M', ' ', ' ', 'M', 'M', 'M', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '], 
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '], 
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '], 
            ],
            [
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', 'C', 'C', 'C', 'C', ' ', ' ', ' '],
                [' ', ' ', ' ', 'B', 'B', 'B', 'B', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
            ],
            [
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', 'C', 'C', 'C', 'C', 'C', 'C', 'C', 'C', ' '],
                [' ', 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'B', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
            ],
        ]
    };

    const random = Phaser.Math.Between(0, 5);
    const getMatrix = () => isLevelOverworld ? levels.overworld[random] : levels.underworld[random];
    
    const drawMatrix = (matrix) => {
        const blockSize = 16 * scale;
        matrix.forEach((row, rowIndex) => {
            const y = (screenHeight - platformHeight) + (rowIndex * blockSize) - (8.5 * blockSize);
            row.forEach((cell, colIndex) => {
                const x = pieceStart + (colIndex * blockSize) - (4.5 * blockSize);
                
                if (cell === 'B') addBlock(x, y);
                if (cell === 'M') addMisteryBlock(x, y);
                if (cell === 'I') addImmovableBlock(x, y);
                if (cell === 'C') addCoin(x, y);
                if (cell === 'X') addConstructionBlock(x, y);
            });
        });
    };

    const matrix = getMatrix();
    drawMatrix(matrix);

    return isLevelOverworld ? Phaser.Math.Between(1, 3) : 1;
}

Download the source code and the assets

Inside this ZIP file, you will find all the files containing the source code and all the necessary assets to run the game on your own. The structure of the ZIP file is organized as follows:

  • index.html: This is the main HTML file that serves as the entry point for the game.
  • css: This folder includes the CSS file used for styling the HTML elements of the game interface.
  • js: This folder contains all the JavaScript files that comprise the game’s source code, including:
    • main/preload.js: Handles the loading of all game assets, such as images and sprite sheets.
    • game/structures.js: Defines the structure and layout of the game levels.
    • Other JavaScript files that implement the game logic and mechanics.
  • assets: This folder contains all the graphical and audio assets needed for the game, such as images, sprite sheets, and sound files.

This organization ensures that you have a clear structure for all components of the game, making it easy to understand, modify, and run the game on your own system.

Proposed exercise: Customizing the Mario character

You may easily customize the Mario character by simply overwriting the images and defining the new sprite sizes (if changed). You need to update the relevant parts of the preload.js file (under the js/main folder). The source code remains mostly the same unless you need to adjust frame-specific size and animations, which might require changes based on your custom sprite sheet. Here are the specific steps you need to follow:

  1. Update the images: Change the following images. You may replace them by a new ones, or you can update the ones provided using Pixelorama:
  • /assets/entities/mario.png
  • /assets/entities/mario-grown.png
  • /assets/entities/mario-fire.png
  1. Update frame dimensions inside preload.js file: Replace the width and height of the Mario sprites with the new frame dimensions (if changed):
// Load entity sprites
[{ path: 'mario', frameWidth: 18, frameHeight: 16 },
 { path: 'mario-grown', frameWidth: 18, frameHeight: 32 },
 { path: 'mario-fire', frameWidth: 18, frameHeight: 32 },
 ...
  1. Update animations inside preload.js file: Ensure that the animation definitions use the new sprites correctly. You may change the start and end frames, and also the frameRate:
const multiFrameAnimations = [
   { key: 'run', target: 'mario', start: 3, end: 1, frameRate: 12, repeat: -1 },
   { key: 'grown-mario-run', target: 'mario-grown', start: 3, end: 1, frameRate: 12, repeat: -1 },
   { key: 'fire-mario-run', target: 'mario-fire', start: 3, end: 1, frameRate: 12, repeat: -1 },
   ...

By updating these lines, you can ensure that your custom Mario sprite is loaded with the new dimensions and integrated into the game’s existing animations.

Proposed exercise: Defining your own levels

To define your own levels, you need to update the structures.js file (under the js/game folder) to include your custom level designs. Here are the steps and the specific lines of code to add new levels or update the existing ones:

  1. Define new levels: Add new level arrays to the levels.overworld and levels.underworld arrays.
  2. Adjust block types: Use ‘B’ for normal blocks, ‘M’ for mystery blocks, ‘I’ for immovable blocks, ‘C’ for coins, and ‘X’ for construction blocks.
  3. Update random selector: If you add more than six levels, adjust the Phaser.Math.Between(0, 5) to match the number of levels you have.

This is the code you should modify inside the structures.js file:

function generateStructure(pieceStart) {
    // Existing code...

    const levels = {
        overworld: [
            // Existing level definitions
            [
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', 'C', 'M', 'C', ' ', ' ', ' ', ' ', ' ', 'B', 'M', 'B', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', 'B', 'B', 'M', 'B', 'B', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
            ],
            // Add more custom levels as needed...
        ],
        underworld: [
            // Existing level definitions
            [
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', 'C', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', 'M', 'B', 'M', ' ', 'M', 'B', 'M', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
                [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
            ],
            // Add more custom levels as needed...
        ]
    };

    // Existing code to select and draw the level
    const random = Phaser.Math.Between(0, 5);
    ...
}

By following these instructions, you can easily define and customize your own levels in the Mario game.

Enjoy the game!

You can enjoy playing this game online here.

Phaser. Unit 11. Emoji generator

Introduction

The main goal of this unit is to provide a fun and simple game where users can add and see their favorite emojis on the screen interacting with them in a dynamic way. These will be the main features:

  1. Emoji Selection: Players can choose from different categories of emojis (like smileys, animals, food, etc.).
  2. Adding Emojis: By clicking on the screen, players can place random emojis from the selected category onto the game area.
  3. Interactive Movement: The emojis have physics applied to them, meaning they can bounce around the screen and respond to movements.
  4. Device Motion Interaction: If played on a device with motion sensors (like a smartphone), the game can respond to tilts and shakes, making the emojis move accordingly.

CSS code (style.css)

The CSS file styles the web page for the game as follows:

  • Full-Screen Game Display (html, body): Sets the width and height to 100%, removes any margins and padding, hides scrollbars, sets the font to Arial, and the text color to white.
  • Top-Positioned Elements: (#reload, #numEmojis, #categorySelect): Positions the reload button, emoji counter, and category dropdown at the top of the page with a small top margin.
  • Specific Element Positioning:
    • #reload: Places the reload button at the top left.
    • #numEmojis: Centers the emoji counter horizontally at the top.
    • #categorySelect: Positions the category dropdown at the top right.
  • Centered Instructions (#instructions): Centers the instructions text both horizontally and vertically in the middle of the screen.

This CSS code ensures the game takes up the entire screen, with the controls easily accessible at the top and the instructions clearly visible in the center:

/* Remove any margin and padding and disable scrollbars to fill the screen with the game */
html, body {
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 0;
  overflow: hidden;
  font-family: arial;
  color: white;
}

/* The reload button, the counter and the dropdown are positioned at the top */
#reload, #numEmojis, #categorySelect {
  position: absolute;
  top: 5px;
  padding: 2px 4px; /* Add some padding for better readability */
  border-radius: 4px; /* Add rounded corners */
}

/* The reload button is positioned at the left */
#reload {
  left: 5px;
}

/* The counter of emojis is positioned at the center */
#numEmojis {
  left: 50%;
  transform: translateX(-50%);
  background-color: rgba(0, 0, 0, 0.75); /* Add a semi-transparent black background */
}

/* The select dropdown is positioned at the right side */
#categorySelect {
  right: 5px;
}

/* The instructions are positioned at the center of the screen */
#instructions {
  position: absolute;
  left: 50%;
  top: 50%;
  text-align: center;
  transform: translate(-50%, -50%);
}

HTML code (index.html)

The HTML code sets up the web page, including a reload button, an emoji counter, a category selection dropdown, and an instruction display. It uses the Phaser game framework for the interactive game elements and includes links to the necessary JavaScript and CSS files:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Emojis</title>
  <link rel="stylesheet" href="style.css">
</head>

<body>
  <!-- 🔄 Button to reload the game -->
  <button id="reload" onclick="reload()">Reload</button>
  
  <!-- 🔢 Display the number of emojis added -->
  <div id="numEmojis">0</div>
  
  <!-- 🔽 Dropdown to select emoji categories -->
  <select id="categorySelect"></select>
  
  <!-- ℹ️ Instructions for the user -->
  <div id="instructions">Click to add emojis 🤪</div>
  
  <!-- 🎮 Container for the game -->
  <div id="gameContainer"></div>
  
  <!-- 📜 Load Phaser library -->
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/phaser.min.js"></script>
  
  <!-- 📜 Load game scripts -->
  <script src="create.js"></script>
  <script src="update.js"></script>
  <script src="config.js"></script>
</body>

</html>

Constants and Phaser configuration (config.js)

Inside the config.js file we will define the various categories of emojis, each one containing a specific list. Those categories will be displayed using a dropdown menu to allow user selection.

We will also set up the game configuration, specifying the parent container, the scaling mode to ensure the game resizes correctly, and the physics engine to be used (Matter.js), to achieve a real movement and bouncing of the emojis around the screen.

Also inside this file we will link the game’s lifecycle methods (create and update) to their respective functions defined in other JavaScript files:

// 🎭 Define emoji categories with different types of emojis
const emojiCategories = {
  Smileys: ['😀', '🤩', '😄', '😁', '😆', '😅', '🥰', '🤣', '😊', '😇'],
  Body: ['👄', '👌', '🫀', '🦴', '🫁', '🦷', '🤳', '👋', '👍', '👀'],
  People: ['💂', '🥷', '🧙', '🧛', '🧜', '🧝', '🧟', '🕵', '👲', '🎅'],
  Animals: ['🪰', '🐁', '🐂', '🦂', '🐄', '🐅', '🐝', '🐇', '🐈', '🐉'],
  Travel: ['🚀', '🚁', '🚂', '🚃', '⛵', '🛸', '🚲', '🚒', '🚗', '✈'],
  Activities: ['🏓', '🎯', '⚽', '🪁', '🎲', '🏀', '🎷', '🎸', '🎹', '🎺'],
  Food: ['🌭', '🍕', '🍔', '🍟', '🍗', '🍩', '🍬', '🍜', '🥗', '🧀'],
  Objects: ['☎', '🔔', '👓', '🔍', '💡', '📕', '🪜', '📷', '💎', '🩻']
};

// 📑 Get the category select element from the DOM
let categorySelect = document.getElementById('categorySelect');

// 🎮 Initialize the Phaser game with the specified configuration
let game = new Phaser.Game({
  parent: 'gameContainer', // Container for the game
  type: Phaser.AUTO, // Automatically choose WebGL or Canvas
  scale: {
    mode: Phaser.Scale.RESIZE, // Adjust the game scale when resizing
  },
  physics: {
    default: 'matter' // Use Matter.js physics engine
  },
  scene: {
    create: create, // Call the create function to set up the game
    update: update, // Call the update function to handle game logic
  },
});

Setting up category select dropdown and the game boundaries (create.js)

The create.js file defines the setup function for the emoji game, responsible for initializing various game elements. It creates a group to hold the emoji objects and populates a dropdown menu with all the emoji categories. It sets the boundaries for the physics world to ensure that emojis stay within the game area. The file also includes an event listener for device motion, allowing the game to respond to accelerometer data for added interactivity. This setup ensures the game is ready to handle user input, display emojis, and respond dynamically to both user actions and device movements:

function create() {
  // 👥 Create a group to hold the emoji sprites
  this.emojis = this.add.group();

  // 🏷️ Populate the category select dropdown with emoji categories
  for (const category in emojiCategories) {
    const option = document.createElement('option');
    option.value = option.textContent = category;
    categorySelect.appendChild(option);
  }

  // 📐 Set the boundaries of the game world and
  // 📏 adjust the boundaries when the game is resized
  this.matter.world.setBounds();
  this.scale.on('resize', () => this.matter.world.setBounds());

  // 📱 Handle device motion events to move emojis (special code is added for Apple devices)
  if (DeviceMotionEvent.requestPermission) {
    document.getElementById("instructions").innerHTML +=
      `<br><button onclick="DeviceMotionEvent.requestPermission().then(permission => { 
        if (permission === 'granted') {
          window.addEventListener('devicemotion', (event) => handleMotion.call(game.scene.keys.default, event, -1));
          this.remove();
        }})">Allow device motion</button>`;
  } else {
    window.addEventListener('devicemotion', (event) => handleMotion.call(this, event));
  }
}

Adding emojis and handling device movement (update.js)

The update.js file defines the main update loop and event handling for the emoji game. It includes functionality for detecting when the user clicks or touches the screen, which adds a randomly selected emoji from the chosen category to the game area. Each added emoji has physics properties applied, allowing it to move and interact within the game world. The file also includes a function to handle device motion, applying forces to the emojis based on accelerometer data. Additionally, it provides a function to reload the page, ensuring the game can be reset easily. This file is crucial for managing user interactions and the dynamic behavior of the emojis during gameplay:

function update() {
  const pointer = this.input.activePointer;

  // 🖱️ Check if the mouse button is pressed
  if (pointer.isDown) {
    // 📳 Vibrate the device if supported
    if (navigator.vibrate) navigator.vibrate(25);

    // 🎲 Get a random emoji from the selected category
    const emojisInCategory = emojiCategories[categorySelect.value];
    const randomEmoji = emojisInCategory[Phaser.Math.Between(0, emojisInCategory.length - 1)];

    // 🎨 Add the emoji to the game at the pointer position
    const textEmoji = this.add.text(pointer.worldX, pointer.worldY, randomEmoji, { fontSize: 32 }).setPadding(4);
    const circleEmoji = this.matter.add.circle(pointer.worldX, pointer.worldY, 16, { restitution: 1, friction: 0.25 });

    // 🌀 Add physics to the emoji
    this.matter.add.gameObject(textEmoji, circleEmoji);
    this.matter.setVelocity(circleEmoji, Phaser.Math.Between(-10, 10), Phaser.Math.Between(-10, 10));

    // 🏷️ Add the emoji to the emojis group
    this.emojis.add(textEmoji);
    document.getElementById('numEmojis').innerText++;
  }
}

function handleMotion(event, factor = 1.0) {
  const { x, y } = event.accelerationIncludingGravity;

  // 🏃 Apply motion forces to the emojis based on device movement
  this.emojis.children.iterate(emoji => {
    const body = emoji.body;
    const force = 0.00025 * body.mass * factor;

    this.matter.body.applyForce(body, { x: body.position.x, y: body.position.y }, { x: -x * force, y: y * force });
  });
}

// 🔄 Reload the page
function reload() {
  if (confirm("Reload the page?")) location.reload();
}

Proposed exercise: Using your preferred emojis

Create a new version of the game using your preferred emojis. You can change the existing emojis, or add more emojis to each category or even insert new categories (i.e. drinks, flags, zodiac signs, etc.). Additionally, you can add background music or make any other changes you like.

Do not forget to create all the files required to run the code in this unit:

  • style.css
  • index.html
  • create.js
  • update.js
  • config.js

You may find the whole list of emojis at OpenMoji, and you can just copy and paste any emoji you like into the config.js file.

Enjoy the game!

You can enjoy playing this simple and funny game online here.

Phaser. Unit 10. Halloween merge game

Introduction

In this unit we will develop a game to merge objects inspired by the Suika Game, also known as the Watermelon Game. Although our game will be quite similar, it will feature a Halloween theme, from this specific Suika Clone version:

Halloween

CSS code (styles.css)

We are going to insert some CSS code to remove any margin and padding, as well as the scrollbars, to ensure that the maximum window size is used when the game starts. We will also display an image as the background for the game and we will define some border-radius to smooth the corners of the headstone:

body {
  height: 100vh;
  margin: 0;
  overflow: hidden;
  background-image: url(assets/background.jpg);
  background-size: cover;
}

canvas {
  border-radius: 20px;
}

HTML code (index.html)

The HTML source code inside the “index.html” file must be structured to link all necessary resources and scripts required to run the game:

  • preload.js will be responsible for loading all assets (images, sounds, etc.) before the game starts.
  • create.js will set up initial game objects and game states.
  • update.js will handle the logic that updates the state of the game when something happens (dropper movement, collisions, game over, etc).
  • config.js will contain configuration settings for the game, such as screen dimensions, physics settings, and global variables.

This HTML setup ensures that when the page is loaded in a browser, all necessary components are initialized in the correct order, starting with the loading of the Phaser framework, followed by specific game scripts that define and control the gameplay. This structure provides a clean and organized way to manage different aspects of game development:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Suika Clone</title>
  <link rel="stylesheet" href="style.css">
</head>

<body>
  <div id="gameContainer"></div>
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/phaser.min.js"></script>
  <script src="preload.js"></script>
  <script src="create.js"></script>
  <script src="update.js"></script>
  <script src="config.js"></script>
</body>

</html>

Constants and Phaser configuration (config.js)

Inside the “config.js” file we will first define a list of game objects, each with a name and a specific radius. These objects include various Halloween-themed items like candies, spiders, and pumpkins. They will be used as the objects to be merged in the game, each one bigger than the previous one.

In the second part of the file, a new Phaser game instance is configured with specific settings such as canvas size, scaling to fit the container while maintaining aspect ratio, and center alignment. The game is set to be transparent and uses the Matter physics engine to manage physical interactions. Essential game functions like preload for asset loading and create for setting up initial game states are also defined:

const objects = [
  { name: "candy", radius: 30 },
  { name: "spider", radius: 35 },
  { name: "eye", radius: 40 },
  { name: "candle", radius: 50 },
  { name: "apple", radius: 65 },
  { name: "hat", radius: 70 },
  { name: "skull", radius: 80 },
  { name: "pumpkin", radius: 90 },
  { name: "cat", radius: 100 },
  { name: "ghost", radius: 110 },
  { name: "cauldron", radius: 120 },
];

new Phaser.Game({
  width: 600,
  height: 1000,
  parent: "gameContainer",
  scale: {
    mode: Phaser.Scale.ScaleModes.FIT,
    autoCenter: Phaser.Scale.Center.CENTER_BOTH
  },
  transparent: true,
  physics: {
    default: "matter",
    matter: {
      debug: false,
    },
  },
  scene: {
    preload: preload,
    create: create
  },
});

Images, music and sounds (assets folder)

We will need some images and sounds. You may use any assets you like, but we are providing the original ones so that you can easily develop the same game. Some examples are shown below, and also a zip file containing all the assets can be downloaded here.

Background and headstone images

Background
Headstone

Score numbers

Objects

Candy
Spider
Eye
Candle
Apple
Hat
Skull
Pumpkin
Cat
Ghost
Cauldron

Background music

Sounds

Match sound (when two objects are merged)
Game over sound (when the stack of objects reaches the ceiling)

Loading the assets (preload.js)

The provided preload function is part of a Phaser game’s lifecycle and is crucial for setting up the initial state of the game as well as loading all necessary assets before the gameplay can begin. This function ensures that all graphical and audio components required for the game are loaded into memory and are accessible during the game to avoid delays and enhance the gaming experience.

In this function, the game starts by initializing essential game state variables such as the score, which is set to zero, and a boolean flag gameOver, set to false, indicating the game is currently active. These are fundamental for tracking gameplay progress and status.

The function then proceeds to load various types of assets:

  1. Images: It loads specific images like the “headstone” and “newgame” graphics from predefined paths within the assets directory. Additionally, it loads a series of images representing numbers (0-9), used for displaying the score on the game interface. Each object defined in the objects array ensures that all visual elements related to gameplay objects are available.
  2. Audio: Three types of audio files are loaded: background music, a sound effect for matching items (used during successful gameplay interactions), and a game-over sound (used to signify the end of the game). This enhances the feedback for the player, contributing to the overall immersive experience of the game.

Overall, the preload function is essential for managing the game’s resources, loading everything needed. This setup allows the game to run more efficiently and provides a seamless player experience by having all necessary assets ready at game start:

function preload() {
  this.score = 0;
  this.gameOver = false;

  this.load.image("headstone", "assets/headstone.png");
  this.load.image("newgame", "assets/new-game.png");

  for (let i = 0; i <= 9; i++) {
    this.load.image(`${i}`, `assets/numbers/${i}.png`);
  }

  for (const object of objects) {
    this.load.image(`${object.name}`, `assets/objects/${object.name}.png`);
  }

  this.load.audio("background-music", "assets/background-music.mp3");
  this.load.audio("match-sound", "assets/match-sound.mp3");
  this.load.audio("game-over-sound", "assets/game-over-sound.mp3");
}

Setting up the headstone with the score, the dropper, and particles and light effects (create.js)

The code inside “create.js” will focus on initializing the game components and configuring interactions in a structured manner within the create function. This function serves as the central setup hub where multiple functions are called to configure specific aspects of the game environment and mechanics.

The game begins with setting up audio elements to enrich the gameplay experience with background music and sound effects for specific actions like matches or game over events. Visual aspects are addressed next, including dynamic lighting effects that follow the cursor, creating an engaging and responsive atmosphere. Additionally, particle systems are configured to visually represent interactions such as collisions, adding a layer of polish to the game dynamics.

Game objects and physical boundaries are established through functions like setupHeadstone, which also configures the visual representation and interaction boundaries within the gameplay area. A crucial gameplay mechanic is introduced with setupCeiling and setupDropper, where objects interact within set limits, and a dropper mechanism is used for object placement, enhancing the interactive component of the game.

Other functionalities include a restart button, allowing players to easily reset the game, thereby improving user experience and engagement. The setupEvents function ties all interactive elements together, enabling the game to respond to user inputs and collisions, ensuring a seamless and dynamic gameplay experience. Overall, this code snippet is fundamental in laying down the mechanics and aesthetic elements of the game, making it ready for engaging and responsive play:

function create() {
  setupAudio.call(this);
  setupLight.call(this);
  setupParticles.call(this);
  setupHeadstone.call(this);
  drawScore.call(this);
  setupCeiling.call(this);
  setupDropper.call(this);
  setupRestartButton.call(this);
  setupEvents.call(this);
}

function setupAudio() {
  this.backgroundMusic = this.sound.add('background-music', { volume: 0.75 }).play({ loop: -1 });
  this.matchSound = this.sound.add('match-sound', { volume: 1.00 });
  this.gameOverSound = this.sound.add('game-over-sound', { volume: 1.00 });
}

// Setup the light at the cursor
function setupLight() {
  this.light = this.lights
    .addLight(this.input.activePointer.x, this.input.activePointer.y, 1000, 0x99ffff, 0.75)
    .setScrollFactor(0);
  this.lights.enable().setAmbientColor(0xdddddd);
}

// Define the particles to be created when a pair of objects collide
function setupParticles() {
  this.particles = this.add.particles(0, 0, objects[0].name, {
    lifespan: 1000,
    speed: { min: 200, max: 350 },
    scale: { start: 0.1, end: 0 },
    rotate: { start: 0, end: 360 },
    alpha: { start: 1, end: 0 },
    gravityY: 200,
    emitting: false
  });
}

function setupHeadstone() {
  // Draw the image of the headstone
  this.add
    .nineslice(0, 0, "headstone")
    .setOrigin(0)
    .setDisplaySize(+this.game.config.width, +this.game.config.height)
    .setPipeline("Light2D")
    .setDepth(-2);

  // Define a box where the score will be drawn at the top of the headstone
  this.scoreBox = this.add.renderTexture(
    +this.game.config.width / 2, 150,
    +this.game.config.width, 100
  ).setScale(0.8);

  // Create a grup of objects to put all the falling objects
  this.objects = this.add.group();

  // Define the limits of the headstone where the objects will be located
  this.matter.world.setBounds(
    65, 0,
    +this.game.config.width - 130, +this.game.config.height - 1
  );
}

// Draw a line to check when the objects reach the top of the headstone
function setupCeiling() {
  this.ceiling = this.matter.add.rectangle(
    +this.game.config.width / 2, 100,
    +this.game.config.width, 200
  );
  this.ceiling.isStatic = true;

  const line = this.add
    .rectangle(160, 200, +this.game.config.width - 320, 2, 0xccccff)
    .setOrigin(0)
    .setAlpha(0.1)
    .setDepth(-2);
  line.postFX.addShine();
  line.postFX.addGlow();
}

// Configure the dropper with the first object and add a glow effect
function setupDropper() {
  this.dropper = this.add.image(this.input.activePointer.x, 0, objects[0].name);
  const glow = this.dropper.postFX.addGlow(0x99ddff);
  this.tweens.addCounter({
    yoyo: true, repeat: -1, from: 1, to: 3, duration: 1000,
    onUpdate: tween => glow.outerStrength = tween.getValue()
  });
  updateDropper.call(this, objects[0]);
}

// When the game is over a button is shown to restart the game
function setupRestartButton() {
  const centerX = this.game.config.width / 2;
  const centerY = this.game.config.height / 2;
  const button = this.add.image(centerX, centerY, "newgame")
    .setScale(0.4)
    .setInteractive({ useHandCursor: true })
    .setVisible(false);

  button.postFX.addGlow(0x000000, 0.75);

  const tweenOptions = scale => ({ targets: button, scale, ease: "Linear", duration: 100 });

  button.on("pointerover", () => this.tweens.add(tweenOptions(0.5)));
  button.on("pointerout", () => this.tweens.add(tweenOptions(0.4)));
  button.on("pointerup", () => restart.call(this));
  this.restartButton = button;
}

// Configure all the events that will be used in the game (dropper and objects collisions)
function setupEvents() {
  this.input.on("pointermove", pointer => moveDropper.call(this, pointer));
  this.input.on("pointerdown", pointer => moveDropper.call(this, pointer));
  this.input.on("pointerup", () => nextObject.call(this));
  this.matter.world.on('collisionstart', event => handleCollisions.call(this, event));
}

Updating the dropper and controlling the objects collisions (update.js)

The “update.js” file will include all the functions to define the dropper mechanism and to implement controls for managing object collisions in the game.

The functions setDropperX and moveDropper handle the positioning of a draggable game object, referred to as the “dropper,” which players can move horizontally. The position is carefully constrained within predefined game boundaries to ensure it does not move outside the playable area. The updateDropper function updates the visual representation of the dropper based on game events, such as selecting different objects to be dropped next, and also includes visual effects like shining to highlight active elements.

Gameplay interaction is further expanded through the addObject function, which introduces new game objects into the scene at specified positions, simulating physical properties like friction and bounce. The nextObject function controls the sequence of objects being introduced, ensuring that gameplay progresses logically and consistently. The scoring system is integrated into various game actions, with the drawScore function rendering the current score visually in a dedicated area, providing immediate feedback on player performance.

Collision handling is crucial in this game setup, as seen in the handleCollisions function. This function manages the effects when two objects of the same type collide, including updating the score, playing sound effects, and possibly escalating to a higher level of complexity by introducing new, more challenging objects. The game’s flow is controlled by the gameOver function, which triggers when certain game-ending conditions are met, handling tasks such as displaying restart options and stopping all ongoing game sounds.

Finally, the restart function allows players to reset the game to its initial state, providing a fresh start and ensuring that the game can be played repeatedly without needing to reload the entire game.

Overall, these functions collectively manage the core mechanics of the game, including object manipulation, scoring, event handling, and game state transitions, creating a dynamic and interactive gaming experience:

function setDropperX(x) {
  const padding = 65;
  const radius = this.dropper.displayWidth / 2;
  const maxWidth = this.game.config.width - radius - padding;
  x = Math.max(radius + padding, Math.min(x, maxWidth));
  this.dropper.setX(x);
}

// Moves the dropper when the event "pointermove" happens
function moveDropper(pointer) {
  setDropperX.call(this, pointer.x);
  this.light.setPosition(pointer.x, pointer.y);
}

// Updates the image of the dropper when the event "pointerup" happens
function updateDropper(object) {
  this.dropper
    .setTexture(object.name)
    .setName(object.name)
    .setDisplaySize(object.radius * 2, object.radius * 2)
    .setY(object.radius + 205);
  setDropperX.call(this, this.input.activePointer.x);

  // Check which object is the same as the one in the dropper and make it shine
  this.objects.getChildren().forEach((gameObject) => {
    if (gameObject instanceof Phaser.GameObjects.Image) {
      gameObject.postFX.clear();

      if (gameObject.name === object.name) gameObject.postFX.addShine();
    }
  });
}

// Renders the score inside the box defined previously
function drawScore() {
  this.scoreBox.clear();
  const chars = this.score.toString().split("");

  const textWidth = chars.reduce((acc, c) => acc + this.textures.get(c).get().width, 0);

  let x = (this.scoreBox.width - textWidth) / 2;

  chars.forEach(char => {
    this.scoreBox.drawFrame(char, undefined, x, 0);
    x += this.textures.get(char).get().width;
  });
}

function addObject(x, y, object) {
  this.objects.add(this.matter.add
    .image(x, y, object.name)
    .setName(object.name)
    .setDisplaySize(object.radius * 2, object.radius * 2)
    .setCircle(object.radius)
    .setFriction(0.005)
    .setBounce(0.2)
    .setDepth(-1)
    .setOnCollideWith(this.ceiling, () => gameOver.call(this)));
}

// Creates and puts a new object on the headstone
function nextObject() {
  if (!this.dropper.visible || this.gameOver) return;

  this.dropper.setVisible(false);
  this.time.delayedCall(500, () => this.dropper.setVisible(!this.gameOver));

  addObject.call(this,
    this.dropper.x, this.dropper.y,
    objects.find(object => object.name === this.dropper.name)
  );

  updateDropper.call(this, objects[Math.floor(Math.random() * 5)]);
}

// Function executed when two objects of the same type collide
function handleCollisions(event) {
  for (const { bodyA, bodyB } of event.pairs) {
    if (bodyA.gameObject?.name === bodyB.gameObject?.name) {
      if (navigator.vibrate) navigator.vibrate(50);
      
      const objectIndex = objects.findIndex(object => object.name === bodyA.gameObject.name);
      if (objectIndex === -1) return;

      this.score += (objectIndex + 1) * 2;
      drawScore.call(this);

      bodyA.gameObject.destroy();
      bodyB.gameObject.destroy();

      this.particles
        .setTexture(objects[objectIndex].name)
        .emitParticleAt(bodyB.position.x, bodyB.position.y, 10);

      this.matchSound.play();
      
      const newObject = objects[objectIndex + 1];
      if (!newObject) return;

      addObject.call(this, bodyB.position.x, bodyB.position.y, newObject);
      return;
    }
  }
}

function gameOver() {
  this.gameOver = true;
  this.restartButton.setVisible(true);
  this.dropper.setVisible(false);
  this.game.sound.stopAll();
  this.gameOverSound.play({ loop: -1 });
}

function restart() {
  this.score = 0;
  this.gameOver = false;
  this.game.sound.stopAll();
  this.scene.restart();
}

Proposed exercise: Using your own assets

Create a new version of the game using your own assets (images, and sounds), and even including more objects. You can also change any other things you like.

Do not forget to create all the files required to run the code in this unit:

  • styles.css
  • index.html
  • preload.js
  • create.js
  • update.js
  • config.js
  • assets folder

You may find many free assets in OpenGameArt.

Enjoy the game!

You can enjoy playing this wonderful game online here.

Phaser 3 game examples written in TypeScript

Play the games

Alpha adjust

Alpha adjust

Asteroid

Asteroid

Blockade

Blockade

Blocks

Blocks

Breakout

Breakout

Candy Crush

Candy Crush

Clocks

Clocks

Coin runner

Coin runner

Endless runner

Endless runner

Flappy Bird

Flappy Bird

Snake

Snake

Space Invaders

Space Invaders

Super Mario Land

Super Mario Land

Tank

Tank

Run the games in your computer

Since all the games are open source, you can run all the games in your own computer. Just follow these steps:

  1. Install Nodejs (in case you don’t have it yet).
  2. Download the zip file containing the source code of the game you like.
  3. Open a terminal inside the project folder.
  4. Execute “npm install”.
  5. Execute “npm run dev”.
  6. Open the address “http://localhost:8080/” on your browser.

You can download the source code from the following links:

More information

You will find more information on github.

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.

Phaser. Unit 7. Dino game clone (part I)

Introduction

In this unit we will build a clone of Google Chrome “secret” Dino game (the original game can be revealed by entering “chrome://dino” in the address bar). We will use simple web technologies such as HTML, CSS, JavaScript and Phaser 3, so that any user can play on any device just using any browser (not only Chrome).

The result is a game which looks almost the same as the original one using less than 10% lines of code. As it can be seen from the Chromium source code, the original game is made of more than 4000 lines of code, whereas the one in this unit has been developed using around 300 lines of code. This means that Phaser could be a much better option than simple JavaScript when thinking about game development.

Some features

This version of the game clones almost every feature of the original one:

  • The speed of the game is increased gradually.
  • Both cactuses and birds will appear randomly to hurt you.
  • Two scores are shown: current and highest.
  • Each time the user reaches 100 points, the score blinks and a sound is played.
  • It can be played on desktop computers using both the mouse and the keyboard:
    • By clicking on the screen to jump or to restart the game.
    • Using the up arrow key or the space bar to jump, the down arrow key to duck under the enemy, and the enter key to restart the game.
  • It can be played on mobile devices by touching the screen, the same way as any single button game.
  • The game is automatically adjusted to fit the screen size, even if it changes after the page is loaded (for example, in case the user rotates the device).
  • Vibration is activated on mobile devices when the game over screen appears.

CSS code (styles.css)

We are going to insert some CSS code to remove any margin and padding, and also to remove the scrollbars, so that the whole window size is used when the game is started:

html, body {
  margin: 0px;
  padding: 0px;
  overflow: hidden;
}

HTML code (index.html)

We are going to use a single HTML file to link all the files in the project. This is going to be the “index.html” file:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="utf-8">
  <meta name="description" content="Dino game clone using Phaser">
  <meta name="keywords" content="HTML, CSS, JavaScript, Phaser">
  <meta name="author" content="Fernando Ruiz Rico">
  
  <title>Dino Phaser</title>

  <link href="styles.css" rel="stylesheet">
</head>

<body>
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/phaser.min.js"></script>
  <script src="preload.js"></script>
  <script src="create.js"></script>
  <script src="update.js"></script>
  <script src="config.js"></script>
</body>

</html>

Constants and Phaser configuration (config.js)

We are going to define some constants (i.e. screen width and height and text styles) so that we can use them inside every JavaScript file. We will also define a function to set the initial values of some variables before the game is started, together with the usual Phaser configuration, which must be performed as usual.

Moreover we are adding a piece of code to check whether the screen is being resized (i.e. when the device is rotated or the window size is adjusted) so that the whole game is reloaded to fit the new values of the screen width and height.

// Fill the whole width and height of the screen
const WIDTH = window.innerWidth;
const HEIGHT = window.innerHeight;

// Time in milliseconds for each obsticle (lower = faster)
const OBSTICLE_TIME = 750;

// Text style to be used when printing texts (scores, etc.)
const TEXT_STYLE = { fill: "#535353", font: '900 35px Courier', resolution: 10 };

// Variables to be initialized each time the game is restarted
function initVariables() {
  this.isGameRunning = false;
  this.gameSpeed = 10;
  this.respawnTime = 0;
  this.score = 0;
}

// Phaser initialization
let game = new Phaser.Game({
  type: Phaser.AUTO,
  width: WIDTH,
  height: HEIGHT,
  pixelArt: true,
  transparent: true,
  physics: {
    default: 'arcade',
    arcade: {
      debug: false
    }
  },
  scene: { preload: preload, create: create, update: update }
});

// Reload the game when the device orientation changes or the user resizes the window
let resizing = false;
window.addEventListener('resize', () => {
  if (resizing) clearTimeout(resizing);
  resizing = setTimeout(() => window.location.reload(), 500);
});

The assets

We will need some images, sprites and sounds. You may use any assets you like, but we are providing the original ones so that you can easily develop the same game as the one available in Google Chrome. Some examples are shown below, and also a zip file containing all the assets can be downloaded here.

Images

Cloud
Cactuses
Game over
Restart

Sprites

Dino waiting and running
Bird enemy

Sounds

Hit
Jump
Reach

Loading all the assets (preload.js)

So that we can use all those images, sprites and sounds, they have to be loaded previously. That is the purpose of the “preload()” function.

Inside the “initSounds()” and “createAnims()” we will set the volume of the sounds and the frames of the animations respectively.

// Load all the audios, images and sprites
function preload() {
  this.load.audio('jump', 'assets/jump.m4a');
  this.load.audio('hit', 'assets/hit.m4a');
  this.load.audio('reach', 'assets/reach.m4a');

  this.load.image('ground', 'assets/ground.png');
  this.load.image('dino-idle', 'assets/dino-idle.png');
  this.load.image('dino-hurt', 'assets/dino-hurt.png');
  this.load.image('restart', 'assets/restart.png');
  this.load.image('game-over', 'assets/game-over.png');
  this.load.image('cloud', 'assets/cloud.png');

  this.load.image('obsticle-1', 'assets/cactuses_small_1.png');
  this.load.image('obsticle-2', 'assets/cactuses_small_2.png');
  this.load.image('obsticle-3', 'assets/cactuses_small_3.png');
  this.load.image('obsticle-4', 'assets/cactuses_big_1.png');
  this.load.image('obsticle-5', 'assets/cactuses_big_2.png');
  this.load.image('obsticle-6', 'assets/cactuses_big_3.png');

  this.load.spritesheet('dino', 'assets/dino-run.png', { frameWidth: 88, frameHeight: 94 });
  this.load.spritesheet('enemy-bird', 'assets/enemy-bird.png', { frameWidth: 92, frameHeight: 77 });
}

// Set the volume of each audio
function initSounds() {
  this.jumpSound = this.sound.add('jump', { volume: 0.75 });
  this.hitSound = this.sound.add('hit', { volume: 0.75 });
  this.reachSound = this.sound.add('reach', { volume: 0.75 });
}

// Define all the animations from the previously loaded spritesheets
function createAnims() {
  this.anims.create({
    key: 'dino-waiting',
    frames: this.anims.generateFrameNumbers('dino', { start: 0, end: 1 }),
    frameRate: 1,
    repeat: -1
  })

  this.anims.create({
    key: 'dino-run',
    frames: this.anims.generateFrameNumbers('dino', { start: 2, end: 3 }),
    frameRate: 10,
    repeat: -1
  })

  this.anims.create({
    key: 'enemy-bird-anim',
    frames: this.anims.generateFrameNumbers('enemy-bird', { start: 0, end: 1 }),
    frameRate: 6,
    repeat: -1
  })
}

Creating the scores, the clouds, the dino, and the game over message (create.js)

  • Scores: Two boxes are used to print both the current and highest scores on the right hand side of the screen.
  • Clouds: A loop will be used to create 3 clouds at once printing the same image on random positions.
  • Dino: The dino will collide with the ground below, and it will jump using the keys defined here (in our case we will use both the up cursor key and the space bar). The mouse functionality is already available by default.
  • Game over: A message will be shown when the user collides with either the birds or the cactuses. After the user clicks on it, this message will be hidden using the “setAlpha(0)” function call.
// Score text on the right side of the screen
function createScore() {
  this.scoreText = this.add.text(WIDTH - 25, HEIGHT / 3, "", TEXT_STYLE).setOrigin(1, 1);
  this.highScoreText = this.add.text(0, HEIGHT / 3, "", TEXT_STYLE).setOrigin(1, 1).setAlpha(0.75);
}

// Three clouds hidden at the beginning
function createClouds() {
  this.clouds = this.add.group();
  for (let i = 0; i < 3; i++) {
    const x = Phaser.Math.Between(0, WIDTH);
    const y = Phaser.Math.Between(HEIGHT / 3, HEIGHT / 2);
    this.clouds.add(this.add.image(x, y, 'cloud'));
  }
  this.clouds.setAlpha(0);
}

// Dino starts running and the the ground is created
function createGround() {
  this.startText.destroy();
  this.dino.setVelocityX(100);
  this.dino.play('dino-run', 1);

  const interval = setInterval(() => {
    this.ground.width += (WIDTH / 100);
    if (this.ground.width >= WIDTH) {
      clearInterval(interval);
      this.dino.setVelocityX(0);
      this.scoreText.setAlpha(1);
      this.clouds.setAlpha(1);
      this.isGameRunning = true;
    }
  }, 50);
}

// Dino waiting animation, keyboard initilization and starting text
function createDino() {
  this.dino = this.physics.add.sprite(0, HEIGHT / 1.5, 'dino-idle').setSize(50).setGravityY(5000).setOrigin(0, 1);
  this.dino.play('dino-waiting', true);

  this.physics.add.collider(this.dino, this.belowGround);
  this.physics.add.collider(this.dino, this.obsticles, () => stopGame.call(this));

  this.cursors = this.input.keyboard.createCursorKeys();
  this.spaceBar = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);

  this.startText = this.add.text(WIDTH - 15, HEIGHT / 1.5, "Press to play", TEXT_STYLE).setOrigin(1);
}

// Game over screen and pause until player starts the game again
function createGameOverScreen() {
  this.gameOverText = this.add.image(0, 0, 'game-over');
  this.restart = this.add.image(0, 50, 'restart').setInteractive();
  this.gameOverScreen = this.add.container(WIDTH / 2, HEIGHT / 2 - 50);
  this.gameOverScreen.add([this.gameOverText, this.restart]);

  this.input.keyboard.on('keydown_ENTER', () => startGame.call(this));
  this.restart.on('pointerdown', () => startGame.call(this));
}

// Define the physical ground and obsticles group
function createWorld() {
  this.ground = this.add.tileSprite(0, HEIGHT / 1.5, 88, 26, 'ground').setOrigin(0, 1);
  this.belowGround = this.add.rectangle(0, HEIGHT / 1.5, WIDTH, HEIGHT / 3).setOrigin(0, 0);
  this.physics.add.existing(this.belowGround);
  this.belowGround.body.setImmovable();
  this.obsticles = this.physics.add.group();
}

// Create all the elements in the game and initilize the score
function create() {
  initVariables.call(this);
  initSounds.call(this);
  createAnims.call(this);
  createWorld.call(this);
  createClouds.call(this);
  createScore.call(this);
  createDino.call(this);

  setInterval(() => updateScore.call(this), 100);
}

Controlling the player and displaying the birds and the cactuses (update.js)

  • Starting the game and building the ground: The “createGround()” method will build the ground progresively, just once, when the game is started the very first time (after the user taps the screen or presses either the up arrow key or the space bar).
  • Restarting the game: After the game over message appears, the user may click on the screen to start a new game. This is done inside the “startGame()” method, which clears the message using the “destroy()” method call, restarts the score and resumes all the physics.
  • Updating the scores: The function “updateScore()” will not only update the current score, but it will also increment the game speed, and it will play a sound each time the user reaches 100 points, making the numbers blink at the same time. The “updateHighScore()” function will keep a record of the highest score each time a game is over.
  • Placing and moving the birds and the cactuses: The “placeObsticle()” function will create either birds or cactuses randomly, and the “update()” function will move everything to the left except the dino, which will always remain in the same position.
// Restart the game after dino is hurt
function startGame() {
  this.dino.setVelocityY(0);
  this.physics.resume();
  this.anims.resumeAll();
  this.obsticles.clear(true, true);
  this.gameOverScreen.destroy();
  this.isGameRunning = true;
}

// On game over a sound is played and all physics are paused
// The game over screen will be shown and mobile devices will vibrate
function stopGame() {
  initVariables.call(this);
  updateHighScore.call(this);
  this.hitSound.play();
  this.physics.pause();
  this.anims.pauseAll();
  this.dino.setTexture('dino-hurt');
  createGameOverScreen.call(this);
  if (window.navigator.vibrate) window.navigator.vibrate(50);
}

// Update the score and increase the game speed
function updateScore() {
  if (!this.isGameRunning) return;

  this.score++;
  this.gameSpeed += 0.01;

  if (this.score % 100 == 0) {
    this.reachSound.play();

    this.tweens.add({
      targets: this.scoreText,
      duration: 100,
      repeat: 3,
      alpha: 0,
      yoyo: true
    })
  }

  this.scoreText.setText(("0000" + this.score).slice(-5));
}

// Update the high score in case is higher than the previous one
function updateHighScore() {
  this.highScoreText.x = this.scoreText.x - this.scoreText.width - 30;

  const highScore = this.highScoreText.text.substr(this.highScoreText.text.length - 5);
  const newScore = Number(this.scoreText.text) > Number(highScore) ? this.scoreText.text : highScore;

  this.highScoreText.setText(`HI ${newScore}`);
}

// Place a new obsticle on the screen (either cactuses or birds)
function placeObsticle() {
  let obsticle;
  const obsticleNum = Phaser.Math.Between(1, 7);

  if (obsticleNum > 6) {
    obsticle = this.obsticles.create(WIDTH, HEIGHT / 1.5 - Phaser.Math.Between(20, 50), 'enemy-bird');
    obsticle.body.height /= 1.5
    obsticle.play('enemy-bird-anim', 1);
  } else {
    obsticle = this.obsticles.create(WIDTH, HEIGHT / 1.5, `obsticle-${obsticleNum}`)
  }

  obsticle.setOrigin(0, 1).setImmovable();
}

// Update the screen (obsticles, ground, clouds and dino) and check the keyboard and mouse (or touch screen) continuously
// Dino will start running and jump if the user taps the screen or presses either the arrow up key or the spacebar  
function update() {
  let jump = this.cursors.up.isDown || this.spaceBar.isDown || this.input.activePointer.isDown;

  if (this.isGameRunning) {
    this.ground.tilePositionX += this.gameSpeed;

    this.respawnTime += this.gameSpeed;
    if (this.respawnTime >= OBSTICLE_TIME) {
      this.respawnTime = 0;
      placeObsticle.call(this);
    }

    this.obsticles.getChildren().forEach(obsticle => {
      obsticle.x -= this.gameSpeed;
      if (obsticle.getBounds().right < 0) {
        obsticle.destroy();
      }
    })

    this.clouds.getChildren().forEach(cloud => {
      cloud.x -= 0.5;
      if (cloud.getBounds().right < 0) {
        cloud.x = WIDTH;
      }
    })

    if (this.dino.body.onFloor()) {
      if (jump) {
        this.jumpSound.play();
        this.dino.anims.stop();
        this.dino.setVelocityY(-1600);
        this.dino.setTexture('dino', 0);
      }
      else {
        this.dino.play('dino-run', true);
      }
    }
  }
  else if (jump && (this.ground.width < WIDTH)) {
    createGround.call(this);
  }
}

Proposed exercise: Using your own assets

Create a new version of the Dino game using your own assets (images, sprites and sounds). You can also change any other things you like.

Do not forget to create all the files required to run the code in this unit:

  • styles.css
  • index.html
  • preload.js
  • create.js
  • update.js
  • config.js
  • assets folder

You may find many free assets in OpenGameArt, and some new versions of the Dino game in github:

Enjoy the game!

You can enjoy playing this wonderful game online here.

Phaser. Unit 6. Using tilemaps to build a space shooter game

The source code and the assets

You can download this zip file to get the source code and all the assets.

Updating the map

As done previously, you can use the tile map editor to update the map. In case you have not done it yet, you can download the editor from this link and install it on your computer.

To update the map you have to go the assets folder and open the file “town.tmx” which contains the map of the example in this unit. Select a layer (on the right corner, at the top) and change anything you like (by adding or removing some objects to or from the map). When you finish, export the map to JSON format using the option “File” -> “Export as…”, and overwrite the file “town.json”, located inside the “assets” folder.

Finally check the results using your browser and move the player to go to each part of the map. You will notice that the camera will follow you on each movement. Do not forget to fully refresh the contents of the browser by pressing Ctrl+F5, since the map and some other contents are usually cached by the browser. Also move the player and check that the map has been updated with the changes you have made.

Frames per second (FPS)

You can update the number of frames per second to choose the best renderization performance for your computer:

const MAX_FPS = 25;

Using the keyboard and the mouse

In this game we are using the keys A (left), S (down), D (right), W (up) to move the player on desktop computers and the joystick for mobile devices:

  let vX = 0, vY = 0;

  // Horizontal movement
  if (wsadKeys.A.isDown || joyStick.left) vX = -speed;
  else if (wsadKeys.D.isDown || joyStick.right) vX = speed;

  // Vertical movement
  if (wsadKeys.W.isDown || joyStick.up) vY = -speed;
  else if (wsadKeys.S.isDown || joyStick.down) vY = speed;

The mouse is used for shooting on desktop computers:

  let pointer = this.input.activePointer;
  if (pointer.isDown && this.sys.game.device.os.desktop) createBullet.call(this, pointer.worldX - player.x, pointer.worldY - player.y);

In case you do not have any mouse, you may use the cursor keys on desktop computers, or the second joystick on mobile devices, to shoot the bullets and to choose the shooting direction:

  let vX = 0, vY = 0;

  // Horizontal bullets
  if (cursors.left.isDown || joyStick2.left) vX = -speed;
  else if (cursors.right.isDown || joyStick2.right) vX = speed;

  // Vertical bullets
  if (cursors.up.isDown || joyStick2.up) vY = -speed;
  else if (cursors.down.isDown || joyStick2.down) vY = speed;

  if (vX || vY) createBullet.call(this, vX, vY);

Enjoy the game!

You may enjoy playing this wonderful game online here.

Phaser. Unit 5. Using tilemaps to build a shooter game in a huge world

A preview of the map

You can have a look at this preview of the whole map.

The source code and the assets

You can download this zip file to get the source code and all the assets.

Updating the map

As done previously, you can use the tile map editor to update the map. In case you have not done it yet, you can download the editor from this link and install it on your computer.

To update the map you have to go the assets folder and open the file “town.tmx” which contains the map of the example in this unit. Select a layer (on the right corner, at the top) and change anything you like (by adding or removing some objects to or from the map). When you finish, export the map to JSON format using the option “File” -> “Export as…”, and overwrite the file “town.json”, located inside the “assets” folder.

Since we are working with a lot of tilesets, in this case we are setting the collisions with all the objects in some of the layers: “Terrain_3”, “Buildings_1” and “Buildings_2”. The player and the enemies will collide with all the objects that you drawn in these three layers.

Finally check the results using your browser and move the player to go to each part of the map. You will notice that the camera will follow you on each movement. Do not forget to fully refresh the contents of the browser by pressing Ctrl+F5, since the map and some other contents are usually cached by the browser. Also move the player and check that the map has been updated with the changes you have made.

Renderization problems

Due to the large amount of resources this game is going to use from your computer, you might need to adjust a couple of parameters. It seems it works fine on Chrome browser, but if you are using another browser, or in case you find any problems, you can try updating the following parameters.

Phaser renderer

I have selected “Phaser.CANVAS” inside the phaser configuration settings, which seems to work much better on Chrome browser, but you may try “Phaser.AUTO” instead:

const config = {
  type: Phaser.AUTO
  ...
}

Frames per second (FPS)

You can also change the number of frames per second to choose the right renderization performance:

const MAX_FPS = 25;

Using the keyboard and the mouse

In this game we are using the keys A (left), S (down), D (right), W (up) to move the player on desktop computers and the joystick for mobile devices:

  let vX = 0, vY = 0;

  // Horizontal movement
  if (wsadKeys.A.isDown || joyStick.left) vX = -speed;
  else if (wsadKeys.D.isDown || joyStick.right) vX = speed;

  // Vertical movement
  if (wsadKeys.W.isDown || joyStick.up) vY = -speed;
  else if (wsadKeys.S.isDown || joyStick.down) vY = speed;

The mouse is used for shooting on desktop computers:

  let pointer = this.input.activePointer;
  if (pointer.isDown && this.sys.game.device.os.desktop) createBullet.call(this, pointer.worldX - player.x, pointer.worldY - player.y);

In case you do not have any mouse, you may use the cursor keys on desktop computers, or the second joystick on mobile devices, to shoot the bullets and to choose the shooting direction:

  let vX = 0, vY = 0;

  // Horizontal bullets
  if (cursors.left.isDown || joyStick2.left) vX = -speed;
  else if (cursors.right.isDown || joyStick2.right) vX = speed;

  // Vertical bullets
  if (cursors.up.isDown || joyStick2.up) vY = -speed;
  else if (cursors.down.isDown || joyStick2.down) vY = speed;

  if (vX || vY) createBullet.call(this, vX, vY);

Enjoy the game!

You may enjoy playing this wonderful game online here.

Phaser. Unit 4. Using tilemaps to build a pacman game

Introduction

In this unit we are going to use Phaser together with a tilemap editor to build a maze. You may find more information at the official page of the Tiled editor and you may also have a look at some interesting demonstrations at the Phaser’s examples page, and also performing some search on the latest examples.

HTML and CSS code

The first thing we should do is linking the Phaser library. We will use the last version at the time this unit has been written:

<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/phaser.min.js"></script>

After that we are going to insert some CSS code to remove any margin and padding, and also to remove the scrollbars, so that all the window size is used when the game is started:

html, body {
    margin: 0px;
    padding: 0px;
    overflow: hidden;
    height: 100%;
} 

And finally we are going to use a single file to put all the code inside. This is going to be the basic structure of the “.html” file:

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8" />
    <title>First game with Phaser 3</title>
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/phaser.min.js"></script>
    <style type="text/css">
        html, body {
            margin: 0px;
            padding: 0px;
            overflow: hidden;
            height: 100%;
        }
    </style>
</head>
<body>
    <script type="text/javascript">
        ...
    </script>
</body>
</html>

Constants

We are going to define some constants at the beginning of the code so that we can easily change the number of coins, fruits and ghosts that will be created:

const numCoins = 10;
const numFruits = 5;
const numGhosts = 5;

Phaser configuration and other variables

Following the constants we are going to create some variables so keep all the information about the game and also to start the initialization of Phaser:

const config = {
    type: Phaser.AUTO,
    pixelArt: true,
    scale: {
        mode: Phaser.Scale.ENVELOP,
        width: window.innerWidth,
        height: window.innerHeight
    },
    physics: {
        default: "arcade",
        arcade: {
            gravity: { y: 0 }
        }
    },
    scene: {
        preload: preload,
        create: create,
        update: update
    }
};

const game = new Phaser.Game(config);
let speed = 60, vX = 0, vY = 0, prevX = 0, prevY = 0, prevTime = 0;
let belowLayer, worldLayer, aboveLayer, tileset, emptyTiles;
let map, player, cursors, bell, bell2, dead, hurt, timeout;
let joyStick = joyStick2 = { up: false, down: false, left: false, right: false };
let score = 0, lives = 5, scoreText = null, gameOver = false;

The assets (images, sprites and sounds)

We will need some images, sprites and sounds. You may use any assets you like, but we are also providing some examples so that you can easily start testing (they can also be downloaded here):

Images

Tileset containing almost every image needed to build the game
Restart button

Sounds and music

When the player gets a coin
When the user collects a fruit
When the user gets hurt by a ghost
When the user kills a ghost
Background music always playing

Loading all the assets

So that we can use all those images, sprites and music, they have to be loaded previously. That is the purpose of the “preload()” function. We will also include here the initialization of the joystick so that we can use it in another section of this unit.

Proposed exercise: Game initialization

Create a specific folder for your game in your domain to put all the code and assets inside. After that, create an “index.html” inside that folder with the code below, and also create an “assets” folder to put all the images and sounds, which can be downloaded here. After uploading everything to your domain, test the game using the url of that folder in your browser, and you should get a full black screen. Click on it and the background music should start playing.

You can see the result here (you can also look at the source code by pressing Ctrl+U).
<!doctype html>
<html lang="en">

<head>
    <meta charset="UTF-8" />
    <title>First game with Phaser 3</title>
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/phaser.min.js"></script>
    <style type="text/css">
        html,
        body {
            margin: 0px;
            padding: 0px;
            overflow: hidden;
            height: 100%;
        }
    </style>
</head>

<body>
    <div id="game-container"></div>
    <script type="text/javascript">
        const numCoins = 10;
        const numFruits = 5;
        const numGhosts = 5;

        const config = {
            type: Phaser.AUTO,
            pixelArt: true,
            scale: {
                mode: Phaser.Scale.ENVELOP,
                width: window.innerWidth,
                height: window.innerHeight
            },
            physics: {
                default: "arcade",
                arcade: {
                    gravity: { y: 0 }
                }
            },
            scene: {
                preload: preload,
                create: create,
                update: update
            }
        };

        const game = new Phaser.Game(config);
        let speed = 60, vX = 0, vY = 0, prevX = 0, prevY = 0, prevTime = 0;
        let belowLayer, worldLayer, aboveLayer, tileset, emptyTiles;
        let map, player, cursors, bell, bell2, dead, hurt, timeout;
        let joyStick = joyStick2 = { up: false, down: false, left: false, right: false };
        let score = 0, lives = 5, scoreText = null, gameOver = false;

        function preload() {
            this.load.image("restart", "assets/restart.png");
            this.load.image("tiles", "assets/tileset.png");
            this.load.tilemapTiledJSON("map", "assets/map.json");
            this.load.spritesheet("sprites", "assets/tileset.png", { frameWidth: 20, frameHeight: 20, margin: 1, spacing: 1 });

            this.load.audio("bell", "assets/ding.mp3");
            this.load.audio("bell2", "assets/ding2.mp3");
            this.load.audio("dead", "assets/dead.mp3");
            this.load.audio("hurt", "assets/hurt.mp3");
            this.load.audio("music", "assets/music.mp3");

            this.load.plugin('rexvirtualjoystickplugin', 'https://cdn.jsdelivr.net/npm/[email protected]/dist/rexvirtualjoystickplugin.min.js', true);
        }

        function initSounds() {
            bell = this.sound.add('bell', { volume: 0.2 });
            bell2 = this.sound.add('bell2', { volume: 0.8 });
            dead = this.sound.add('dead', { volume: 0.7 });
            hurt = this.sound.add('hurt', { volume: 0.9 });
            this.sound.add('music', { volume: 0.4 }).play({ loop: -1 });
        }

        function create() {
            initSounds.call(this);
        }

        function update(time, delta) {
        }
    </script>
</body>

</html>

Proposed exercise: Using your own music

Download any other music file you like to use it as a background music and change the JavaScript code inside the “preload()” function to set the file name accordingly. Also in the “initSounds()” function you will find that you may change the volume of the music (volume:0.4). Try changing that value from 0.1 to 1 and you will notice that the volume of the music will also change.

You may find in the following sites some free images and sounds which you can download and use for your own game:

The maze

Once we have initialized Phaser and after loading all the assets, we can go ahead and display the maze on the screen.

Proposed exercise: Displaying the maze

Add the function “createWorld()” (provided below) to your code, and modify the calling function “create()” so that it is called when the game is started (also shown below). Finally test everything in your browser from your domain.

About the music, do not forget to click on the screen after loading the game (the music will not start playing unless the browser gets the focus). Also, you can see the result here (you can look at the source code by pressing Ctrl+U).
  1. This is the new function you have to insert in your code to create the world:
function createWorld() {
    map = this.make.tilemap({ key: "map" });

    // Parameters are the name you gave the tileset in Tiled and then the key of the tileset image in
    // Phaser's cache (i.e. the name you used in preload)
    tileset = map.addTilesetImage("tileset", "tiles");

    // Parameters: layer name (or index) from Tiled, tileset, x, y
    belowLayer = map.createLayer("Below Player", tileset, 0, 0).setDepth(1);
    worldLayer = map.createLayer("World", tileset, 0, 0).setDepth(2);
    aboveLayer = map.createLayer("Above Player", tileset, 0, 0).setDepth(3);

    worldLayer.setCollisionByProperty({ collides: true });

    this.physics.world.setBounds(0, 0, map.widthInPixels, map.heightInPixels);

    // Find empty tiles where new zombies, coins or health can be created
    emptyTiles = worldLayer.filterTiles(tile => (tile.index === -1));

    this.scale.resize(map.widthInPixels, map.heightInPixels).refresh();
}
  1. Do not forget to update the “create()” function to create the world. This is the only line you should add to that function:
function create() {
    ...
    createWorld.call(this);            
}

Proposed exercises: Modifying the maze

Download the tile map editor from this link and install it on your computer (if you don’t have installed it yet). Go to the assets folder and open the file “map.tmx” which contains the map of the example explained in this unit. Select the layer “World” (on the right corner, at the top) and change anything you like (by adding or removing some objects to or from the map). When you finish, export the map to JSON format using the option “File” -> “Export as…”, and overwrite the file “map.json”, located inside the “assets” folder. Finally check the results using your browser.

Do not forget to fully refresh the contents of the browser by pressing Ctrl+F5, since the map and some other contents are usually cached by the browser. Then you can check that the map has been updated with the changes you have made.

The animations (sprites)

We will use sprites to create the player and the animated objects. Let’s have a look at the the picture containing all the sprites:

Tileset containing all the sprites

We may appreciate that we have several groups of images. This way, we may easily create the animations:

  • The coin: Pictures from 0 to 7
  • The point: Picture 8
  • The ghosts:
    • Blue: Pictures from 17 to 20
    • Pink: Pictures from 21 to 24
    • Yellow: Pictures from 25 to 28
    • Green: Pictures from 34 to 37
    • Orange: Pictures from 38 to 41
    • Red: Pictures from 42 to 45
  • The player:
    • Left: Pictures from 51 to 58
    • Right: Pictures from 68 to 75
    • Down: Pictures from 85 to 92
    • Up: Pictures from 102 to 109
  • The fruits: Pictures 46, 47, 114, 143

Controlling the player’s animations

So that this game can be used in both desktop and mobile devices, we have to initialize both the cursor keys and the joystick. Once this is done, we must change the player’s velocity and the current animation when the user presses the keys or moves the joystick. In the next exercise we will first define the animations inside the “createAnimations()” function, and after that we will go ahead with the “createPlayer()” function, where we will display the player. We will finally include in the “update()” function some conditions to adjust the player’s speed and current animation.

Proposed exercise: Adding the player

Add the code below to your game and check both the player animations and movements. After that, look for another sprite and change the code accordingly to use your own sprite. Finally, change the values of the constant “speed” (at the top of your code) and check the results.

You can see the result here (you can look at the source code by pressing Ctrl+U). Also, as listed above, you may find many free assets in OpenGameArt and choose other sprites you like.
  1. These are the new functions you have to insert in your code to create the animations and initialize everything related to the player:
function newAnimation(name, rate, sprites, context) {
    context.anims.create({
        key: name, frameRate: rate, repeat: -1,
        frames: context.anims.generateFrameNumbers('sprites', sprites),
    });
}

function createAnimations() {
    // Player
    newAnimation("left", 10, { start: 51, end: 58 }, this);
    newAnimation("right", 10, { start: 68, end: 75 }, this);
    newAnimation("down", 10, { start: 85, end: 92 }, this);
    newAnimation("up", 10, { start: 102, end: 109 }, this);
    // Point
    newAnimation("point", 1, { start: 8, end: 8 }, this);
    // Coin
    newAnimation("spinning", 10, { start: 0, end: 7 }, this);
    // Fruits
    newAnimation("fruits", 2, { frames: [46, 47, 114, 143] }, this);
    // Ghosts
    newAnimation("blue", 5, { start: 17, end: 20 }, this);
    newAnimation("pink", 5, { start: 21, end: 24 }, this);
    newAnimation("yellow", 5, { start: 25, end: 28 }, this);
    newAnimation("green", 5, { start: 34, end: 37 }, this);
    newAnimation("orange", 5, { start: 38, end: 41 }, this);
    newAnimation("red", 5, { start: 42, end: 45 }, this);
}

function createPlayer() {
    // Object layers in Tiled let you embed extra info into a map - like a spawn point or custom
    // collision shapes. In the tmx file, there's an object layer with a point named "Spawn Point"
    const spawnPoint = map.findObject("Objects", obj => obj.name === "Spawn Point");

    // Create a sprite with physics enabled via the physics system
    player = this.physics.add.sprite(spawnPoint.x, spawnPoint.y, "sprites").setDepth(2);

    // Watch the player and worldLayer for collisions, for the duration of the scene
    this.physics.add.collider(player, worldLayer);

    // Show points on empty tiles
    emptyTiles.forEach(tile => {
        let point = this.physics.add.sprite(tile.pixelX, tile.pixelY, 'sprites').setOrigin(0).anims.play('point', true);
        this.physics.add.overlap(player, point, collectPoint, null, this);
    });

    cursors = this.input.keyboard.createCursorKeys();

    // Show the joysticks only in mobile devices
    if (!this.sys.game.device.os.desktop) {
        joyStick = this.plugins.get('rexvirtualjoystickplugin').add(this, {
            x: 380, y: 420, radius: 20,
            base: this.add.circle(0, 0, 20, 0x888888).setAlpha(0.5).setDepth(4),
            thumb: this.add.circle(0, 0, 20, 0xcccccc).setAlpha(0.5).setDepth(4)
        }).on('update', update, this);

        joyStick2 = this.plugins.get('rexvirtualjoystickplugin').add(this, {
            x: 40, y: 420, radius: 20,
            base: this.add.circle(0, 0, 20, 0x888888).setAlpha(0.5).setDepth(4),
            thumb: this.add.circle(0, 0, 20, 0xcccccc).setAlpha(0.5).setDepth(4)
        }).on('update', update, this);
    }
}

function showScore() {
    if (!scoreText) scoreText = this.add.text(map.widthInPixels/2, 4, '', { fontSize: (18) + 'px', fill: '#FFF' }).setOrigin(0.5, 0).setScrollFactor(0).setDepth(4);
    scoreText.setText('Score:' + score + ' / Lives:' + lives);
}

function collectPoint(player, point) {
    bell.play();
    point.destroy();

    score += 5;
    showScore();
}
  1. Also you have to insert some code inside the “update()” function to respond to the cursor keys and the joystick:
function update(time, delta) {
    if (gameOver) return;

    // Choose the right animation depending on the player's direction
    if (player.x > prevX) player.anims.play('right', true);
    else if (player.x < prevX) player.anims.play('left', true);
    else if (player.y > prevY) player.anims.play('down', true);
    else if (player.y < prevY) player.anims.play('up', true);

    // If the player goes outside the map
    if (player.x < 0) player.x = map.widthInPixels - 20;
    else if (player.x > map.widthInPixels) player.x = 0;

    let key = player.anims.currentAnim.key;
    let blocked = player.body.blocked;

    // Reset the velocity when the player touches a wall
    if (key == 'right' && blocked.right || key == 'left' && blocked.left) vX = 0;
    if (key == 'up' && blocked.up || key == 'down' && blocked.down) vY = 0;

    // Horizontal movement
    if (cursors.left.isDown || joyStick.left || joyStick2.left) vX = -speed;
    else if (cursors.right.isDown || joyStick.right || joyStick2.right) vX = speed;

    // Vertical movement
    if (cursors.up.isDown || joyStick.up || joyStick2.up) vY = -speed;
    else if (cursors.down.isDown || joyStick.down || joyStick2.down) vY = speed;

    player.setVelocity(vX, vY);

    if ((time - prevTime) > 100) {
        prevX = player.x;
        prevY = player.y;
        prevTime = time;
    }
}
  1. And finally do not forget to update the “create()” function to create the animations, the player and the score. You just have to insert some new lines:
function create() {
    ...
    createAnimations.call(this); 
    createPlayer.call(this);
    showScore.call(this);
}

Coins, fruits, and ghosts

We will create many different objects at once using the images from the same tileset. We will just use a loop to print as many objects as we like.

Proposed exercises: Adding the coins

Add the code below to your file and check the results. After that, change the value of the constant “numCoins” (at the top of your code) to check how easily you can customize your game. Refresh the page several times and you will notice that the generated coins appear at random positions. Finally, look for another sprite sheet (it may be a coin or any other object you like) and change the code accordingly to use your own animated object.

You can see the result here (you can look at the source code by pressing Ctrl+U). Also, as listed above, you may find many free assets in OpenGameArt and choose the images you like.
function newObject(animation, context) {
    const tile = Phaser.Utils.Array.GetRandom(emptyTiles);
    return context.physics.add.sprite(tile.pixelX, tile.pixelY, 'sprites').setOrigin(0).anims.play(animation, true);
}

function createCoin() {
    let coin = newObject('spinning', this);
    coin.body.setAllowGravity(false);
    this.physics.add.overlap(player, coin, collectCoin, null, this);
}

function protect(color) {
    player.setTint(color);
    if (timeout) clearTimeout(timeout);
    timeout = setTimeout(() => { timeout = false; player.clearTint() }, 5000);
}

function collectCoin(player, coin) {
    bell2.play();
    coin.destroy();
    createCoin.call(this);

    score += 10;
    showScore();

    protect(0xFFFF00);
}
function create() {
    ...
    for (i = 0; i < numCoins; i++) setTimeout(() => createCoin.call(this), Phaser.Math.Between(0, 5000));
}

Proposed exercises: Adding the fruits

Add the code below to your game and check the results. After that, change the value of the constants “numFruits” (at the top of your code) to check how easily you can customize your game. Finally, look for another sprite and change the code accordingly to use your own object to provide the player with extra lives and points.

You may click here to see how the game looks like. As listed above, you may find many free assets in OpenGameArt.
function createFruits() {
    let fruits = newObject('fruits', this);
    fruits.body.setAllowGravity(false);
    this.physics.add.overlap(player, fruits, collectFruits, null, this);
}

function collectFruits(player, fruits) {
    bell2.play();
    fruits.destroy();
    createFruits.call(this);

    score += 15;
    showScore();
}
function create() {
    ...
    for (i = 0; i < numFruits; i++) setTimeout(() => createFruits.call(this), Phaser.Math.Between(0, 5000));
}

Proposed exercises: Adding the ghosts

Add the code below to your game and check the results. After that, change the value of the constants “numGhosts” (at the top of your code) to check how easily you can customize your game. Finally, look for another sprite and change the code accordingly to display your own ghosts.

You may click here to see how the game looks like. As listed above, you may find many free assets in OpenGameArt.
function newGhost(animation, context) {
    const spawnPoint = map.findObject("Objects", obj => obj.name === "Spawn Point 2");
    return context.physics.add.sprite(spawnPoint.x, spawnPoint.y, 'sprites').setOrigin(0).anims.play(animation, true).setDepth(2);
}

function createGhost() {
    const colors = ['blue', 'pink', 'yellow', 'green', 'orange', 'red'];
    let ghost = newGhost(colors[Phaser.Math.Between(0, colors.length - 1)], this).setCollideWorldBounds(true).setBounce(1);
    ghost.setVelocity(Phaser.Math.Between(speed / 3, speed / 2), Phaser.Math.Between(-speed / 2, -speed / 3)).body.setAllowGravity(false);
    this.physics.add.collider(ghost, worldLayer);
    this.physics.add.overlap(player, ghost, hitGhost, null, this);
}

function hitGhost(player, ghost) {
    // If the player is already hurt, it cannot be hurt again for a while
    if (player.tintTopLeft == 0xFF00FF) return;

    if (timeout) {
        score += 15;
        dead.play();
    }
    else {
        lives--;
        hurt.play();
        protect(0xFF00FF);
    }

    showScore();

    if (lives == 0) {
        this.physics.pause();
        gameOver = true;
        this.add.image(210, 230, 'restart').setScale(2).setScrollFactor(0).setDepth(4)
            .setInteractive().on('pointerdown', () => location.reload());
    }
    else {
        ghost.destroy();
        createGhost.call(this);
    }
}  
function create() {
    ...
    for (i = 0; i < numGhosts; i++) setTimeout(() => createGhost.call(this), Phaser.Math.Between(0, 15000)); 
}

Enjoy the game!

You may enjoy playing this wonderful game online here.