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

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

Introduction

Hello again, this is the third part of a series titled: Building an endless runner game with Threejs, Mixamo, Netlify, and PlanetScale. The game is called "Cave Runner" and it features a 3D character running through a wooden cave, avoiding obstacles and collecting coins. The coins can be used to unlock other characters.

This series was divided into five sections:

  1. Initial Setup

  2. The running scene

  3. Main Menu Scene

  4. The Character selection scene

  5. Saving The Game Data Online

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: https://github.com/tope-olajide/cave-runner, and the game can be played here: https://cave-runner.netlify.app.

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

Setting up the Main Menu scene

Let's 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 described the functions of the four methods and how they will be used in the game.

Importing The Wooden Cave and Lights into the scene

The wooden cave will not be moving this time. It will be used as a stationary background for the Main Menu Scene.

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

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

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

  private fbxLoader = new FBXLoader();

  private woodenCave = new Object3D();

In the load() method, we will import the wooden cave and add lights to the scene:

    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 default character for the game and play its dancing animation.

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

 javascript 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:

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 to the update method:

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

Our 3D 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 (I later changed this to the 'About' button.)

In this section, we will make the game switch to the Running Scene when the player clicks the Play button. 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 design by launching the index.html file and replacing the code with the following:

<!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>

Let's update the style.css file 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 finished using the Main Menu Scene, we need to stop all processes running within it and hide it before rendering the next scene. If we do not do this, all the scenes will overlap, causing increased strain on the CPU as everything in the Main Menu Scene, Running Scene, and Character Selection Scene will be running at the same time. This could cause the game to lag or crash.

Let's update the hide() method with the following code:

  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 the 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 make the previously hidden text elements visible. Then we set the 'high-score' and 'total-coins' values to those stored in local storage, or 0 if no value is present. Finally, we make the scene visible again and start the clock to allow the character to immediately begin dancing again when switching back to the Main Menu.

Updating the running scene side method

When we are finished with the Running Scene, we need to hide and stop any relevant elements just as 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 the main.ts file in the src directory.

First, we will import the MainMenuSceneinto 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 yell at us.

After that, we will create a new variable called mainMenuScene and instantiates the MainMenuScene() 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:

By default, we want the Main Menu Scene to be the first scene that is rendered when the game finishes loading. To do this, we can assign the mainMenuScene to the currentScene variable:

currentScene = mainMenuScene;

Let's update the render method to 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 and how to switch between it and the Running Scene.

In the next part of the series, I will explain how to:

  • Display all the available characters in the game

  • Buy and unlock a character

  • Use the unlocked character in every scene