Temitope Olajide
Temitope's blog

Temitope's blog

Building an Endless Runner Game with Three.js, Mixamo, Vite, and Planetscale (Part Three)

Temitope Olajide's photo
Temitope Olajide
·Aug 4, 2022·

11 min read

Featured on Hashnode
Building an Endless Runner Game with Three.js, Mixamo, Vite, and Planetscale (Part Three)

Table of contents

Introduction

Hello again, this article is the third part of a series on building an endless runner game with Threejs, Mixamo, Netlify, and PlanetScale. I named it Cave Runner because It is based on a 3D character running endlessly in a wooden cave while evading obstacles that are being spawned at it and collecting coins that may later be used to unlock other characters.

This series was divided into the following sections:

In the previous section, I explained the long process of building the Runner Scene. I imported the default character, animated it, moved it left and right, and made it jump and slide. I also explained how to detect collisions using the Axis-Aligned Bounding Box (AABB). I used the AABB method to check if the player intersects with the coins and obstacles.

In this part of the series, I will discuss how to build the Main Menu Scene. The Main Menu Scene is the first scene that will appear once the game has finished loading. The Player can choose to go to the Running Scene or Character Selection Scene from the Main Menu Scene. Also, they can exit from the Running Scene or Character Selection back to the Main Menu Scene.

GitHub Repository

The GitHub repository containing the finished code of this game is here: github.com/tope-olajide/cave-runner, and the game can be played here: cave-runner.netlify.app.

Note that I divided this project into different branches on GitHub. The source code for this section is available here: github.com/tope-olajide/cave-runner/tree/ft... With Netlify, each branch in this repository can be deployed, and the code for this particular branch was deployed here: deploy-preview-5--cave-runner.netlify.app.

Setting Up the Main Menu Scene

We will create an empty class called MainMenuScene with four methods:

import {
  Scene
} from 'three';

import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader';

export default class MainMenuScene extends Scene {
 async load() {}
 initialize() {}
 update() {}
 hide() {}
}

In the previous section, I explained what the four method does and how we will use them in this game.

Next, we will import the wooden cave.

Importing The Wooden Cave and Lights into the scene

The wooden cave will not be moving this time; we are using it as a background for the Main Menu Scene.

import {
  Scene, Object3D, AmbientLight, DirectionalLight,
} from 'three';

import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader';

Let's create two new private properties called fbxLoader and woodenCave

  private fbxLoader = new FBXLoader();

  private woodenCave = new Object3D();

Inside the load() method, we will import the wooden cave and add the lights:

    this.woodenCave = await this.fbxLoader.loadAsync('./assets/models/wooden-cave.fbx');
    this.woodenCave.position.set(0, 0, -500);
    this.woodenCave.scale.set(0.055, 0.055, 0.055);
    this.add(this.woodenCave);

    const ambient = new AmbientLight(0xFFFFFF, 2.5);
    this.add(ambient);

    const light = new DirectionalLight(0xFFFFFF, 2.5);

    light.position.set(0, 40, -10);
    this.add(light);

Importing The Character and Its Dancing Animation

In the Main Menu Scene, we will import the game's default character and play its dancing animation on it.

Let's import the Clock, AnimationMixer, and AnimationAction objects from threejs

import {
  Scene, Object3D, AmbientLight, DirectionalLight, Clock, AnimationMixer, AnimationAction,
} from 'three';

Next we will create five new private properties namely: player, delta, clock, AnimationMixer and dancingAnimation

 private player = new Object3D();

  private delta = 0;

  private clock = new Clock();

  private AnimationMixer!: AnimationMixer;

  private dancingAnimation!: AnimationAction;

Finally, we can import the player and play its dancing animation on it:

    this.player = await this.fbxLoader.loadAsync('../../assets/characters/xbot.fbx');
    this.player.position.z = -110;
    this.player.position.y = -35;
    this.player.scale.set(0.1, 0.1, 0.1);
    this.player.rotation.y = 180 * (Math.PI / 180);
    this.add(this.player);

    const dancingAnimationObject = await this.fbxLoader.loadAsync('../../assets/animations/xbot@dancing.fbx');
    this.AnimationMixer = new AnimationMixer(this.player);
    this.dancingAnimation = this.AnimationMixer.clipAction(dancingAnimationObject.animations[0]);
    this.dancingAnimation.play();

To make the animation work, let's add this in the update method.

  update() {
    if (this.AnimationMixer) {
      this.delta = this.clock.getDelta();
      this.AnimationMixer.update(this.delta);
    }
  }

Our character should be dancing now

Updating The User Interface

We are going to add some texts to the main menu. We will display the high score and the total coins of the player at the top-right on the main menu. After that, we will add three buttons at the bottom of the main menu scene. The Buttons are:

  • Play Button
  • Characters Button and
  • Settings Button (Which I later changed to the "About" Button)

Later in this section, we will make the game switch to the Running Scene once the Player clicks on the Play Button. And when the Player is in the Running Scene, we will make the game switch back to the Main Menu when the "Quit Game" button is clicked.

Let's update the designs by launching the index.html file and overwriting it with the following code.:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="favicon.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="stylesheet" href="./src/style.css" />
    <link
      rel="stylesheet"
      href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"
    />
    <title>Cave Runner</title>
  </head>

  <body>
    <div class="coins-container">
      <h3>Coins: <span class="coins-count">0</span></h3>
    </div>
    <div class="scores-container">
      <h3>Scores: <span class="scores-count">0</span></h3>
    </div>

    <div class="pause-button">
      <button class="btn-icon-only">
        <span class="btn-icon"><i class="fa-solid fa-pause"></i></span>
      </button>
    </div>

    <div id="game-paused-modal" class="modal">
      <div class="modal-body" id="">
        <br />
        <h1>Game Paused!</h1>
        <br />
        <button class="btn" id="resume-button">
          <span class="btn-icon"><i class="fa-solid fa-refresh"></i></span>
          Resume</button
        ><br />
        <button class="btn" id="quit-button">
          <span class="btn-icon"><i class="fa-solid fa-times"></i></span>
          Quit Game</button
        ><br />
      </div>
    </div>

    <div id="game-over-modal" class="modal">
      <div class="modal-body" id="game-over-modal">
        <h1>Game Over</h1>
        <p>Your Scores:<span id="current-score"></span></p>
        <p>Coins:<span id="current-coins"></span></p>
        <button class="btn" id="restart-button">
          <span class="btn-icon"><i class="fa-solid fa-refresh"></i></span>
          Restart
        </button>
        <button class="btn" id="game-over-quit-button">
          <span class="btn-icon"><i class="fa-solid fa-times"></i></span>
          Quit Game
        </button>
      </div>
    </div>

    <!-- Main menu buttons  -->
<section id="main-menu-buttons">
  <button id="play-game-button" class="btn">
    <span class="btn-icon"><i class="fa-solid fa-play"></i></span
    > Play
  </button>
  <button class="btn" id="Characters-selection-button">
    <span class="btn-icon"><i class="fa-solid fa-users"></i></span
    >Characters
  </button>
  <button class="btn">
    <span class="btn-icon"><i class="fa-solid fa-gear"></i></span
    >Settings
  </button>
</section>

<!-- Coins and Highscores section -->
<div class="high-score-container"><h3>High Score: <span class="high-score">0</span> </h3></div>
<div class="total-coins-container"><h3> Total Coins: <span class="total-coins">0</span></h3></div>


    <div class="loading-container">
      <img src="./public/assets/images/loader.gif" />
    </div>
    <div class="disable-touch"></div>
    <div></div>
    <canvas id="app"> </canvas>

    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

And the style.css file, let's update it with the following code:

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

#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  width: 100%;
  height: 100%;
  display: block;
}
.high-score-container,
.total-coins-container,
.coins-container,
.scores-container {
    position: absolute;
    top: 0;
    color: #F2E205;
    right: 0;
    padding: 0px 50px ;
    background-color: #00000063;
    display: none;
}
.high-score-container,
.coins-container {
  margin-top: 40px;
}

.coins-container h3,
.scores-container h3 {
margin:.5rem;
}
.pause-button {
  position: absolute;
  left: 0;
  top: 0;
  margin: 20px 10px;
  display: none;
}

.modal {
  display: none;
  position: fixed;
  z-index: 1;
  padding-top: 100px;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  overflow: auto;
  background-color: rgba(0, 0, 0, 0.4);
}

.modal-body {
  background-color: #BA4D31;
  margin: 50px auto;
  padding: 10px;
  border: 10px solid #2C0604;
  width: 300px;
  opacity: 0.8;
  color: #2C0604;
}

.btn, .btn-icon-only {
background-color: #2C0604;
border: none;
color: #BA4D31;
padding: 12px 12px 12px 0;
font-size: 16px;
cursor: pointer;
margin: 5px;
}

.btn-icon-only {
  padding: 0px;
  color:#F2E205;

}

.btn-icon {
  background-color: #4e2828;
  padding: 12px;
}

.btn:hover {
  opacity: .9;
}
#game-paused-modal {
  display: none;
  text-align: center;
}


#game-over-modal {
  text-align: center;
}
#game-over-modal h1 {
  font-size: 1.5rem;
  margin: 0;
}
#game-over-modal p {
  font-size: 1.3rem;
  margin: 0.8rem;
}

.disable-touch {
  width: 100%;
  height: 100%;
  position: absolute;
  display: none;
}

.loading-container{
  width: 100%;
  height: 100%;
  position: absolute;
  display: flex;
  align-items: center;
  justify-content: center;
}
.loading-container img{
  width: 200px;
}

/* Main Menu buttons */
#main-menu-buttons {
  position: absolute;
  bottom: 0;
  margin: 0 auto;
  left: 0;
  right: 0;
  display: flex;
  text-align: center;
  display: none;
}
#main-menu-buttons button {
  margin-bottom: 20px;
}

Hiding The Main Menu Scene

Once we are done using the Main Menu Scene, we must stop everything running inside it and hide it before rendering the next scene to the screen. If we don't do this, all the scenes will overlap, thereby drawing more CPU power because everything in the Main Menu Scene, Running Scene, and the Character Selection Scene will be running altogether simultaneously. This could cause the game to lag or crash.

  hide() {
    this.visible = false;
    this.clock.stop();
    (document.querySelector('#main-menu-buttons') as HTMLInputElement).style.display = 'none';
    (document.querySelector('.high-score-container') as HTMLInputElement).style.display = 'none';
    (document.querySelector('.total-coins-container') as HTMLInputElement).style.display = 'none';
  }

The this keyword in JavaScript can be tricky because it can refer to different objects depending on your use. In our case, this in every method refers to the class object. The this.visible = false means hide MainMenuScene class. The this.clock.stop(); stops the dancing animation and everything else in the scene that depends on the clock. I also hide all the main menu buttons, the high score, and total coins that appear on the top right of the main menu screen because I don't want them to show up in the next scene

The Main Menu Initialize Method

The initialize method is the first method that will be invoked when you switch to a new scene. This is where to display everything that was hidden and restart everything that was stopped in the hide method. Let's update the Main Menu initialize method with the following code:

  initialize() {
    (document.querySelector('#main-menu-buttons') as HTMLInputElement).style.display = 'block';
    (document.querySelector('.high-score-container') as HTMLInputElement).style.display = 'block';
    (document.querySelector('.total-coins-container') as HTMLInputElement).style.display = 'block';

    (document.querySelector('.high-score') as HTMLInputElement).innerHTML = JSON.parse(localStorage.getItem('highScore')!) || 0;
    (document.querySelector('.total-coins') as HTMLInputElement).innerHTML = JSON.parse(localStorage.getItem('totalCoins')!) || 0;

    if (!this.visible) {
      this.visible = true;
    }
    if (!this.clock.running) {
      this.clock.start();
    }
  }

First, we displayed all the text elements that were hidden in the hide method, then we display the scene and start the clock so our character can start dancing again immediately after we switch back to the Main Menu.

Updating The Running Scene Hide Method

When we are through with the Running Scene, we need to hide anything that needs to be hidden and stopped everything that needs to be stopped just like we did with the Main Menu Scene.

Let's open up the RunningScene.ts file and update the hide method inside it with the following code:

  hide() {
    (document.querySelector('.disable-touch') as HTMLInputElement).style.display = 'none';

    this.isGameOver = false;

    this.coins = 0;

    this.scores = 0;

    (document.getElementById('game-paused-modal') as HTMLInputElement).style.display = 'none';

    (document.querySelector('.scores-container') as HTMLInputElement).style.display = 'none';

    (document.querySelector('.coins-container') as HTMLInputElement).style.display = 'none';

    (document.querySelector('.pause-button') as HTMLInputElement).style.display = 'none';

    this.visible = false;

    this.currentObstacleOne.position.z = -1200;
    this.currentObstacleTwo.position.z = -1500;

    this.activeCoinsGroup.position.z = -1200;
    this.currentAnimation.stop();

    this.clock.stop();
  }

Switching Between The Main Menu Scene And Running Scene

Let's open up the main.ts file in the scr directory.

First, we will import the MainMenuScene into it:

import MainMenuScene from './scenes/MainMenuScene';

After that, we will create a new variable called currentScene.

let currentScene: MainMenuScene | RunningScene;

We will use the currentScene variable to store the scene that is currently being rendered to the screen. That is why it has two types: the MainMenuScene and RunningScene. I used the pipe symbol (|) to specify that. The pipe symbol is called union types in TypeScript, it allows us to define a variable as two or more types. So if any other object that is not of MainMenuScene or RunningScene type gets assigned or re-assigned to our currentScene variable, TypeScript will scream at us.

After that, we will create a new variable called mainMenuScene and instantiates theMainMenuScene() class into it.

const mainMenuScene = new MainMenuScene();

Next, We will create two new functions called switchToRunningScene and switchToMainMenuScene

const switchToRunningScene = () => {
  currentScene.hide();
  currentScene = runningScene;
  currentScene.initialize();
};

const switchToMainMenuScene = () => {
  currentScene.hide();
  currentScene = mainMenuScene;
  currentScene.initialize();
};

Anytime the switchToRunningScene function is invoked, it will hide the currentScene by calling the hide() method of the current scene that is being rendered first, then re-assign the runningScene to the currentScene variable (meaning that the runningScene is now the currentScene ). After that, it will call the initialize() method of the currentScene.

Same logic applies to the switchToMainMenuScene function.

Now let's call the two functions anytime the buttons assigned to them are clicked:

(document.getElementById('play-game-button')as HTMLInputElement).onclick = () => {
  switchToRunningScene();
};
(document.querySelector('#quit-button')as HTMLInputElement).onclick = () => {
  (document.getElementById('game-over-modal')as HTMLInputElement).style.display = 'none';
  switchToMainMenuScene();
};

(document.querySelector('#game-over-quit-button')as HTMLInputElement).onclick = () => {
  (document.getElementById('game-over-modal')as HTMLInputElement).style.display = 'none';
  switchToMainMenuScene();
};

One more thing, by default, when the game has finished loading we want the Main Menu Scene to be the first scene that will be rendered to the screen. Let's do that by assigning the mainMenuScene to the currentScene variable:

currentScene = mainMenuScene;

We're not done yet, let's update the render method to always call the update method of the currentScene at every frame rate.

const render = () => {
  currentScene.update();
  renderer.render(currentScene, mainCamera);
  requestAnimationFrame(render);
};

Finally, let's update the main method to call the runningScene and mainMenuScene's load() method.

const main = async () => {
  await runningScene.load();
  await mainMenuScene.load();
  (document.querySelector('.loading-container') as HTMLInputElement).style.display = 'none';
  currentScene.initialize();
  render();
};

I made a loading-container section in the html file and made its class visible in the CSS file. It is a temporary loading container that displays a loading gif image while the scene assets is being loaded. Once they have finished loading I used this part: (document.querySelector('.loading-container') as HTMLInputElement).style.display = 'none'; to hide the loading container then this method: currentScene.initialize(); will call the initialize method of the current scene(Main Menu Scene in this case).

Summary and next steps

In this part of the series, I explained how I created the Main Menu Scene. I also explained how to switch between the Main Menu Scene and the Running Scene.

In the next part of this series I'm going to explain how to:

  • display all the characters available in the game
  • buy and unlock a character
  • use the unlocked character in every scene
 
Share this