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

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

The Character Selection Scene

Introduction

Hi everyone,

This is the fouth part of a series titled: Building an endless runner game with Threejs, Mixamo, Netlify, and PlanetScale.

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 described how to build the MainMenuScene and add buttons and UI text to the screen. We also learned how to switch between the Running Scene and the Main Menu Scene, and how to hide the scenes when we are finished using them.

In this part of the series, we will create the CharacterSelectionScene. This scene will function like a marketplace, allowing the player to buy, unlock, and activate additional characters.

In this scene, we will import the remaining two characters, lock them by default and allow the player to unlock them once they have enough coins. Once a character has been unlocked, we will activate it and set it as the active character in RunningScene and MainMenuScene

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: https://github.com/tope-olajide/cave-runner/tree/character-selection-scene with Netlify, each branch in this repository can be deployed, and the code for this particular branch was deployed here: https://github.com/tope-olajide/cave-runner/tree/character-selection-scene

Creating the JSON file for all the characters' objects

We will create a JSON object that will store all the player's data. To do this, navigate to the src directory and create a new TypeScript file called allCharacters.ts.

Then we'll add the following code:

export default [{
  name: 'Xbot',
  model: '../assets/characters/xbot.fbx',
  isActive: true,
  price: 0,
  isLocked: false,
  danceAnimation: './assets/animations/xbot@dancing.fbx',
  runAnimation: '../assets/animations/xbot@running.fbx',
  slideAnimation: '../assets/animations/xbot@sliding.fbx',
  stumbleAnimation: '../assets/animations/xbot@stumbling.fbx',
  jumpAnimation: '../assets/animations/xbot@jumping.fbx',
},
{
  name: 'Jolleen',
  model: '../assets/characters/jolleen.fbx',
  isActive: false,
  price: 2000,
  c: true,
  danceAnimation: '../assets/animations/jolleen@dancing.fbx',
  runAnimation: '../assets/animations/jolleen@running.fbx',
  slideAnimation: '../assets/animations/jolleen@sliding.fbx',
  stumbleAnimation: '../assets/animations/jolleen@stumbling.fbx',
  jumpAnimation: '../assets/animations/jolleen@jumping.fbx',
},
{
  name: 'Peasant Girl',
  model: '../assets/characters/peasant-girl.fbx',
  isActive: false,
  price: 4000,
  isLocked: true,
  danceAnimation: '../assets/animations/peasant-girl@dancing.fbx',
  runAnimation: '../assets/animations/peasant-girl@running.fbx',
  slideAnimation: '../assets/animations/peasant-girl@sliding.fbx',
  stumbleAnimation: '../assets/animations/peasant-girl@stumbling.fbx',
  jumpAnimation: '../assets/animations/peasant-girl@jumping.fbx',
},
];

The allCharacters.ts is an array of JSON objects, each object in the array contains the data on each character used in this game.

Below is what they are:

  • name: name of the character

  • model: The 3D character model's URL

  • isActive: boolean value to indicate whether or not a character is active.

  • price: the price of the character

  • isLocked: used to determine whether or not a character is locked

  • danceAnimation: the URL to the character's dance animation

  • runAnimation: the URL of the character's run animation

  • slideAnimation: the URL of the character's slide animation

  • jumpAnimation: the character's jumping animation URL

Creating the CharacterSelectionScene

To create the CharacterSelectionScene, navigate to the scenes directory in the src folder and create a new file called CharacterSelectionScene.ts. Open it in your editor and create a new class called CharacterSelectionScene. Import the FBXLoader and allCharacters files into it:

import {
  Scene,
} from 'three';

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

import allCharacters from '../allCharacters';

export default class CharacterSelectionScene extends Scene {


}

To avoid too many repetitions with the IallGameCharacters interface, we will create a new file called types.ts in the src folder, and add the interface to it:

export interface IallGameCharacters {
    name: string
    model: string
    isActive: boolean
    price: number
    isLocked: boolean
    danceAnimation: string
    runAnimation: string
    slideAnimation: string
    stumbleAnimation: string
    jumpAnimation: string
}

Let's import the interface into the CharacterSelectionScene :

import { IallGameCharacters } from '../types'; }

Importing the wooden cave and Adding the Lights

We will import the wooden cave and use it as the background of the scene, similar to what we did in the previous section. Then we will add the lights.

Let's import the DirectionalLight, AmbientLight, and Object3D from threejs:

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

Next, we'll create a new property called woodenCave:

private woodenCave = new Object3D();

The wooden-cave model will be imported, scaled, and placed in the 'load' method:

  async load() {
    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);
}

Let's add some lights:

async load() { 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 all the characters and their animations into the Scene

Let's create some private properties:

  private allGameCharacters: IallGameCharacters[] = [];

  private animationMixer!: AnimationMixer;

  private dancingAnimation!: AnimationAction;

  private delta = 0;

  private clock = new Clock();

  private xbot = new Object3D();

  private jolleen = new Object3D();

  private peasantGirl = new Object3D();

  private xbotAnimation!: Object3D;

  private jolleenAnimation!: Object3D;

  private peasantGirlAnimation!: Object3D;

  private charactersContainer: Object3D[] = [];

  private animationsContainer: Object3D[] = [];

  private activeCharacter = new Object3D();

  private activeCharacterAnimation!: Object3D;

  private activeIndexNumber = 0;

Here's what we are using them for:

  • allGameCharacters: to store the allCharacters object that will be fetched from the local storage

  • dancingAnimation: to store the dancing animation of the active character that is being displayed

  • delta to store the completion time in seconds since the last frame. Multiplying the speed value with the delta value in our game makes the game run at a uniform speed on all devices. It doesn't matter whether the game is being played on a high-end core i9 desktop device with a very high FPS or a low-end MediaTek chip on a mobile device, the speed in the game world will remain the same on every device.

  • clock : To store the clock object

  • xbot : To store the x-bot character object

  • jolleen : To store jolleen 3D character object

  • peasantGirl : To store the peasant girl 3D character object

  • xbotAnimation : To store the x-bot dancing animation object

  • jolleenAnimation: To store Jolleen's dancing animation object

  • peasantGirlAnimation: To store the Peasant girl's dancing animation object

  • charactersContainer: to store all the game characters

  • animationsContainer: To store all the dancing animations of each character

  • activeCharacter : To store the current 3D character that is being displayed on the screen

  • activeCharacterAnimation: to store the animation of the current character that is being displayed on the screen.

  • activeIndexNumber: to store the index number of the current character that is being displayed

After completing the above steps, we will check if the allCharacters object exists in the local storage. If it does not exist we will create it in the load method:

    if (!JSON.parse(localStorage.getItem('allGameCharacters') !)) {
      localStorage.setItem('allGameCharacters', JSON.stringify(allCharacters));
    }

The characters, along with their animations, will be imported into the scene. Once they have been imported, we will hide all of them.

 this.xbot = await this.fbxLoader.loadAsync(this.allGameCharacters[0].model); this.jolleen = await this.fbxLoader.loadAsync(this.allGameCharacters[1].model); this.peasantGirl = await this.fbxLoader.loadAsync(this.allGameCharacters[2].model);

this.xbotAnimation = await this.fbxLoader.loadAsync(this.allGameCharacters[0].danceAnimation); this.jolleenAnimation = await this.fbxLoader.loadAsync(this.allGameCharacters[1] .danceAnimation); this.peasantGirlAnimation = await this.fbxLoader.loadAsync(this.allGameCharacters[2] .danceAnimation);

this.xbot.visible = false; this.jolleen.visible = false; this.peasantGirl.visible = false;

this.add(this.xbot); this.add(this.jolleen); this.add(this.peasantGirl);

We will store all the characters in the charactersContainer and their dance animations in the animationsContainer. Afterwards, we will invoke the hide method that we will create shortly.

    this.charactersContainer.push(this.xbot, this.jolleen, this.peasantGirl);
    this.animationsContainer.push(
      this.xbotAnimation,
      this.jolleenAnimation,
      this.peasantGirlAnimation,
    );

    this.hide();

Creating the UI Texts and buttons for the CharacterSelectionScene

Two texts will be displayed in the scene: one at the middle-bottom showing the name of the current character, and the other at the top right displaying the total number of coins the user has. Five buttons will also be added to the scene: a home button at the top left, a next button, a previous button, a buy button showing the character's price, and a select button used to activate the character, all located at the bottom center.

Let's open up the index.html and overwrite 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>

  <!--Character Selection Section -->
  <div class="home-menu">
    <button class="btn-icon-only">
      <span class="btn-icon"><i class="fa-solid fa-home"></i></span>
    </button>
  </div>
  <div id="character-selection-container">
    <h1 class="character-name">Character Name</h1>
    <section class="character-selection-buttons">
      <button class="btn" id="prev-btn">
        <span class="btn-icon"><i class="fa-solid fa-angles-left"></i></span>
        Prev
      </button>
      <div>
        <button class="btn" id="select-character-btn">
          <span class="btn-icon"><i class="fa-solid fa-check"></i></span>
          <span id="select-button-text">Select</span>
        </button>
        <button class="btn" id="character-price-button">
          <span class="btn-icon"> <i class="fa-solid fa-coins"></i> </span> <span id="character-price-text">
            10,000</span>
        </button>
      </div>
      <button class="btn-two" id="next-btn">
        Next <span class="btn-icon"><i class="fa-solid fa-angles-right"></i></span>
      </button>
    </section>
  </div>

  <!-- 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 Update the style.css file in the src folder:

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, .home-menu {
  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, .btn-two {
background-color: #2C0604;
border: none;
color: #BA4D31;
padding: 12px 12px 12px 0;
font-size: 16px;
cursor: pointer;
margin: 5px;
}
.btn-two {
  padding: 12px 0 12px 12px;
}
.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;
}

#character-price-button {
  display: none;
}

.character-selection-buttons {
  position: absolute;
  right: 0;
  bottom: 0;
  display: none;
  justify-content: space-evenly;
  width: 100%;
  margin-bottom: 20px;
  align-items: center;
  display: flex;
}

.character-name {
  text-align: center;
  color: #F2E205;
  font-size: 1.5rem;
  position: absolute;
  bottom: 0;
  width: 100%;
  margin-bottom: 80px;
  display: block;
}

#character-price-button {
  display: none;
}

#character-selection-container {
  display: none;
}

Creating the Initialize Method

Let's create an empty initialize method in the CharacterSelectionClass:

 initialize() {

}

Next, we will display the first character in the `charactersContainer` array, scale and position it, and then play its dancing animation on it. Let's update the `initialize` method with following snippets:

this.activeCharacter = this.charactersContainer[this.activeIndexNumber]; this.activeCharacterAnimation = this.animationsContainer[this.activeIndexNumber]; this.activeCharacter.scale.set(0.1, 0.1, 0.1); this.activeCharacter.position.set(0, -35, -110); this.activeCharacter.visible = true; this.animationMixer = new AnimationMixer(this.activeCharacter); this.dancingAnimation = this.animationMixer .clipAction(this.activeCharacterAnimation.animations[0]); this.dancingAnimation.play();

After that, we will display the UI texts and buttons that were created earlier:

(document.querySelector('.total-coins-container') as HTMLInputElement).style.display = 'block';
    (document.querySelector('#character-selection-container') as HTMLInputElement).style.display = 'block';
    (document.querySelector('.home-menu') as HTMLInputElement).style.display = 'block';

We will make the scene visible if it is not already and start the clock if it is not already running:

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

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

Creating the Update Method

To make the character dance, we will create a a new method called update and invoke the animationMixer's update method on it, just like this:

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

Creating the Next and Previous Method

We will create methods in this section that allow the player to navigate between characters in the CharacterSelectionScene. If the player presses the next button, the next character will be displayed, and if they press the prev button, the previous character will be shown.

Creating the nextCharacter method

This method will be used to move to the next character in the allGameCharacters array, but only if the active character is not the last character in the array. If the active character is the last character in the array, this method will not have any effect when called.

Let's create the nextCharacter method and add the following code to it:


  private nextCharacter() {
    if (this.activeIndexNumber + 1 !== this.allGameCharacters.length) {
      this.active character.visible = false;
      this.activeIndexNumber += 1;
      this.activeCharacter = this.charactersContainer[this.activeIndexNumber];
      this.activeCharacterAnimation = this.animationsContainer[this.activeIndexNumber];
      this.activeCharacter.scale.set(0.1, 0.1, 0.1);
      this.activeCharacter.position.set(0, -35, -110);
      this.activeCharacter.visible = true;
      this.animationMixer = new AnimationMixer(this.activeCharacter);
      this.dancingAnimation = this.animationMixer
        .clipAction(this.activeCharacterAnimation.animations[0]);
      this.dancingAnimation.play();
    }
  }

here's how the code works:

  • This conditional statement: if (this.activeIndexNumber + 1 !== this.allGameCharacters.length) { constrain the user from going past the total number of characters in the allGameCharacters array.

  • The this.activeCharacter.visible = false; makes the current character invisible.

  • This part this.activeIndexNumber += 1; switch to the next character that we want to display

  • this.activeCharacter = this.charactersContainer[this.activeIndexNumber]; stores the next character into the this.activeCharacter property. Now, the next character is the active character.

  • this.activeCharacterAnimation = this.animationsContainer[this.activeIndexNumber]; stores the active character's dance animation into the this.activeCharacterAnimation property.

  • this.activeCharacter.scale.set(0.1, 0.1, 0.1); scales the active character.

  • this.activeCharacter.position.set(0, -35, -110); set the position of the active character inside the cave and in front of the camera.

  • this.activeCharacter.visible = true; displays the next character.

  • this.animationMixer = new AnimationMixer(this.activeCharacter); Creates a new animation mixer with the active character

  • this.dancingAnimation = this.animationMixer .clipAction(this.activeCharacterAnimation.animations[0]); stores the dancing animation of the active user into this.dancingAnimation property.

  • this.dancingAnimation.play(); plays the dancing animation of the active character

Creating the previousCharacter method

The prevCharacter method works almost like the nextCharacter but instead of moving to the next character in the charactersContainer array, it moves to the previous one. And it only moves if the active character is not the first item in the charactersContainer.

Let's create the prevCharactermethod:

private prevCharacter() {
  if (this.activeIndexNumber !== 0) {
    this.activeCharacter.visible = false;
    this.activeIndexNumber -= 1;
    this.activeCharacter = this.charactersContainer[this.activeIndexNumber];
    this.activeCharacterAnimation = this.animationsContainer[this.activeIndexNumber];
    this.activeCharacter.scale.set(0.1, 0.1, 0.1);
    this.activeCharacter.position.set(0, -35, -110);
    this.activeCharacter.visible = true;
    this.animationMixer = new AnimationMixer(this.activeCharacter);
    this.dancingAnimation = this.animationMixer .clipAction(this.activeCharacterAnimation.animations[0]);
    this.dancingAnimation.play();
  }
}

Activating The Character

We will create a new method called activateCharacter. This method will make the selected character active in every scene. It works by changing the isActive's boolean value of the character to true and setting the other character's value to false, then saving the updated allGameCharacters into the localstorage. At the beginning of every scene, the initialize method will check the localstorage for the character whose isActive boolean value is true, then set it as the main character in that scene.

The activateCharacter will only be invoked only when the character has been purchased.

activateCharacter() {
  const savedPlayerData: IallGameCharacters[] = JSON.parse(localStorage.getItem('allGameCharacters')!);
  const updatedPlayerData = savedPlayerData.map((playerInfo, index: number) => {
    if (this.activeIndexNumber === index) {
      return {
        ...playerInfo,
        isActive: true,
        price: 0,
        isLocked: false,
      };
    }
    return { ...playerInfo, isActive: false };
  });
  localStorage.setItem('allGameCharacters', JSON.stringify(updatedPlayerData));
  this.allGameCharacters = updatedPlayerData;
}

Purchasing The Character

The purchaseCharacter method compares the player's totalCoins with the character's price. If the totalCoins are greater than or equal to the character's price, the character's price is subtracted from the totalCoins and the remainingCoins are saved. The character is then unlocked, the price is set to 0, and the activateCharacter method is called to save and activate the character in local storage.

  purchaseCharacter() {
    const savedPlayerData = JSON.parse(localStorage.getItem('allGameCharacters')!);
    const totalCoins = Number(localStorage.getItem('total-coins'));
    if (totalCoins >= this.allGameCharacters[this.activeIndexNumber].price) {
      const remainingCoins = totalCoins - Number(this.allGameCharacters[this.activeIndexNumber]
        .price);
      localStorage.setItem('total-coins', remainingCoins.toString()!);
      savedPlayerData[this.activeIndexNumber].isLocked = false;
      savedPlayerData[this.activeIndexNumber].price = 0;
      this.activateCharacter();
      (document.querySelector('.total-coins') as HTMLInputElement).innerHTML = `${remainingCoins}`;
    }
  }

In the initialize method, we will invoke the nextCharacter, prevCharacter, purchaseCharacter, and activateCharacter methods anytime the button assigned to them is clicked.

(document.getElementById('next-btn') as HTMLInputElement).onclick = () => {
  this.nextCharacter();
};

(document.getElementById('prev-btn') as HTMLInputElement).onclick = () => {
  this.prevCharacter();
};

(document.getElementById('character-price-button') as HTMLInputElement).onclick = () => {
  this.purchaseCharacter();
};

(document.getElementById('select-character-btn') as HTMLInputElement).onclick = () => {
  this.activateCharacter();
};

Modifying The Update and Hide Method To Display The Active Character

Let's display the name of the active character inside the update method:

 (document.querySelector('.character-name') as HTMLInputElement).innerHTML = this.allGameCharacters[this.activeIndexNumber].name;

If the active character is locked, the select-character-btn will be hidden to prevent the user from activating a character that has not been bought. However, the button to purchase the character will be displayed with the character's price.

    if (this.allGameCharacters[this.activeIndexNumber].isLocked) {
      (document.getElementById('select-character-btn') as HTMLInputElement).style.display = 'none';
      (document.getElementById('character-price-button') as HTMLInputElement).style.display = 'block';
      (document.getElementById('character-price-text') as HTMLInputElement).innerHTML = `${this.allGameCharacters[this.activeIndexNumber].price}`;
    }

If a character has been unlocked and activated, the price button will be hidden and the select button will be displayed with the text Selected written on it.

    if (this.allGameCharacters[this.activeIndexNumber].isActive) {
      (document.getElementById('select-character-btn') as HTMLInputElement).style.display = 'block';
      (document.getElementById('character-price-button') as HTMLInputElement).style.display = 'none';
      (document.getElementById('select-button-text') as HTMLInputElement).innerHTML = 'Selected';
    }

If the character has been unlocked but has not been set as the active character, the price button will be hidden, the select button will be displayed, but the button will have Select text written on it.

if (!this.allGameCharacters[this.activeIndexNumber] .isLocked && !this.allGameCharacters[this.activeIndexNumber].isActive) {
  (document.getElementById('select-character-btn') as HTMLInputElement).style.display = 'block';
  (document.getElementById('character-price-button') as HTMLInputElement).style.display = 'none';
  (document.getElementById('select-button-text') as HTMLInputElement).innerText = 'Select';
}

Updating the Hide method

Once we are done with the CharacterSelectionScene, we will hide the scene, UI texts and buttons, and then stop the clock in the hide method:

  hide() {
    this.visible = false;
    (document.querySelector('#character-selection-container') as HTMLInputElement).style.display = 'none';
    (document.querySelector('.home-menu') as HTMLInputElement).style.display = 'none';
    (document.querySelector('.total-coins-container') as HTMLInputElement).style.display = 'none';
    this.clock.stop();
  }

Making the Selected Character Active in the Running Scene

Let's open up the RunningScene.ts file, we need to delete some stuff.

Up until now, the only character that could be used in the RunningScene class was the default character, the x-bot. However, after building the Character Selection Scene, we want the activated character to be used in the 'Running Scene' and the 'Main Menu Scene'. To do this, we will first delete the default x-bot character and its animations that we have been using previously.

We'll start by deleting the 'Running Scene' character first. In the async load method let's remove the following line of codes:

   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 runningAnimationObject = await this.fbxLoader.loadAsync('./assets/animations/xbot@running.fbx');    
    this.animationMixer = new AnimationMixer(this.player);    
    this.runningAnimation = this.animationMixer.clipAction(runningAnimationObject.animations[0]);    
    this.runningAnimation.play();

And delete it animations too:

 this.currentAnimation = this.runningAnimation; const jumpingAnimationObject = await this.fbxLoader.loadAsync('./assets/animations/xbot@jumping.fbx'); this.jumpingAnimation = this.animationMixer.clipAction(jumpingAnimationObject.animations[0]); const slidingAnimationObject = await this.fbxLoader.loadAsync('./assets/animations/xbot@sliding.fbx'); // remove the animation track that makes the player move forward when sliding slidingAnimationObject.animations[0].tracks.shift(); this.slidingAnimation = this.animationMixer.clipAction(slidingAnimationObject.animations[0]);

Including the playerbox:

 this.playerBox.scale.set(50, 200, 20);    
    this.playerBox.position.set(0, 90, 0);    
    this.player.add(this.playerBox);    
    this.playerBox.visible = false;

Delete the stumbling animations too:

 const stumblingAnimationObject = await this.fbxLoader.loadAsync('../../assets/animations/xbot@stumbling.fbx');    
    this.stumbleAnimation = this.animationMixer.clipAction(stumblingAnimationObject.animations[0]);

In the initialize method, delete this line of code too:

    this.player.position.z = -110;

We have completed deleting the old character and its animations. Next, we will import all the characters and their animations into the RunningScene. Initially, we will hide all of them and only display the one that was selected in the CharacterSelectionScene.

Let's import the allCharacters object and IallGameCharacters interface into the RunningScene:

    import allCharacters from '../allCharacters';
    import { IallGameCharacters } from '../types';

Create the following private properties:

private xbot = new Object3D();
private xbotRunningAnimation = new Object3D();
private xbotJumpingAnimation = new Object3D();
private xbotSlidingAnimation = new Object3D();
private xbotStumbleAnimation = new Object3D();
private jolleen = new Object3D();
private jolleenRunningAnimation = new Object3D();
private jolleenJumpingAnimation = new Object3D();
private jolleenSlidingAnimation = new Object3D();
private jolleenStumbleAnimation = new Object3D();
private peasantGirl = new Object3D();
private peasantGirlRunningAnimation = new Object3D();
private peasantGirlJumpingAnimation = new Object3D();
private peasantGirlSlidingAnimation = new Object3D();
private peasantGirlStumbleAnimation = new Object3D();

private allGameCharacters: IallGameCharacters[] = [];

private charactersContainer: Object3D[] = [];
private runningAnimationsContainer: Object3D[] = [];
private jumpingAnimationsContainer: Object3D[] = [];
private slidingAnimationsContainer: Object3D[] = [];
private stumbleAnimationsContainer: Object3D[] = [];

private activePlayerIndex = 0;

In the load method, we will verify if allGameCharacters has been saved. If not, we will create a new one. Afterwards, we will import all the characters and their animations and hide them:

    if (!JSON.parse(localStorage.getItem('allGameCharacters')!)) {
      localStorage.setItem('allGameCharacters', JSON.stringify(allCharacters));
    }
    this.allGameCharacters = (JSON.parse(localStorage.getItem('allGameCharacters')!));
    this.xbot = await this.fbxLoader.loadAsync(this.allGameCharacters[0].model);
    this.xbotRunningAnimation = await this.fbxLoader.loadAsync(this.allGameCharacters[0]
      .runAnimation);
    this.xbotJumpingAnimation = await this.fbxLoader.loadAsync(this.allGameCharacters[0]
      .jumpAnimation);
    this.xbotSlidingAnimation = await this.fbxLoader.loadAsync(this.allGameCharacters[0]
      .slideAnimation);
    this.xbotStumbleAnimation = await this.fbxLoader.loadAsync(this.allGameCharacters[0]
      .stumbleAnimation);
    this.xbotSlidingAnimation.animations[0].tracks.shift();
    this.jolleen = await this.fbxLoader.loadAsync(this.allGameCharacters[1].model);
    this.jolleenRunningAnimation = await this.fbxLoader.loadAsync(this.allGameCharacters[1]
      .runAnimation);
    this.jolleenJumpingAnimation = await this.fbxLoader.loadAsync(this.allGameCharacters[1]
      .jumpAnimation);
    this.jolleenSlidingAnimation = await this.fbxLoader.loadAsync(this.allGameCharacters[1]
      .slideAnimation);
    this.jolleenStumbleAnimation = await this.fbxLoader.loadAsync(this.allGameCharacters[1]
      .stumbleAnimation);
    this.jolleenSlidingAnimation.animations[0].tracks.shift();
    this.peasantGirl = await this.fbxLoader.loadAsync(this.allGameCharacters[2].model);
    this.peasantGirlRunningAnimation = await this.fbxLoader.loadAsync(this.allGameCharacters[2]
      .runAnimation);
    this.peasantGirlJumpingAnimation = await this.fbxLoader.loadAsync(this.allGameCharacters[2]
      .jumpAnimation);
    this.peasantGirlSlidingAnimation = await this.fbxLoader.loadAsync(this.allGameCharacters[2]
      .slideAnimation);
    this.peasantGirlStumbleAnimation = await this.fbxLoader.loadAsync(this.allGameCharacters[2]
      .stumbleAnimation);
    this.peasantGirlSlidingAnimation.animations[0].tracks.shift();
    this.xbot.visible = false;
    this.jolleen.visible = false;
    this.peasantGirl.visible = false;
    this.charactersContainer.push(this.xbot, this.jolleen, this.peasantGirl);
    this.add(this.xbot);
    this.add(this.jolleen);
    this.add(this.peasantGirl);
    this.runningAnimationsContainer.push(
      this.xbotRunningAnimation,
      this.jolleenRunningAnimation,
      this.peasantGirlRunningAnimation,
    );
    this.jumpingAnimationsContainer.push(
      this.xbotJumpingAnimation,
      this.jolleenJumpingAnimation,
      this.peasantGirlJumpingAnimation,
    );
    this.slidingAnimationsContainer.push(
      this.xbotSlidingAnimation,
      this.jolleenSlidingAnimation,
      this.peasantGirlSlidingAnimation,
    );
    this.stumbleAnimationsContainer.push(
      this.xbotStumbleAnimation,
      this.jolleenStumbleAnimation,
      this.peasantGirlStumbleAnimation,
    );

The initialize method stores all the game characters in the local storage in the allGameCharacters variable. It then loops through them to find the index of the active character. This index is used to fetch the active character from the charactersContainer and its corresponding animation objects (running, jumping, sliding, stumbling). The active player is scaled and positioned, the running animation is played on it, and the runningAnimation is set as the currentAnimation.

Let's add the following code to the initialize method:

 this.allGameCharacters = (JSON.parse(localStorage.getItem('allGameCharacters')!));
    this.activePlayerIndex = this.allGameCharacters
      .findIndex((character) => character.isActive === true);
    this.player = this.charactersContainer[this.activePlayerIndex];
    this.player.position.z = -110;
    this.player.position.y = -35;
    this.player.position.x = 0;
    this.player.scale.set(0.1, 0.1, 0.1);
    this.player.rotation.y = 180 * (Math.PI / 180);
    this.player.visible = true;
    this.playerBox.visible = false;
    this.playerBox.scale.set(50, 200, 20);
    this.playerBox.position.set(0, 90, 0);
    this.player.add(this.playerBox);
    this.animationMixer = new AnimationMixer(this.player);
    const runningAnimationObject = this.runningAnimationsContainer[this.activePlayerIndex];
    this.runningAnimation = this.animationMixer.clipAction(runningAnimationObject.animations[0]);
    this.currentAnimation = this.runningAnimation;
    this.current animation.reset();
    this.currentAnimation.play();
    const jumpingAnimationObject = this.jumpingAnimationsContainer[this.activePlayerIndex];
    this.jumpingAnimation = this.animationMixer.clipAction(jumpingAnimationObject.animations[0]);
    const slidingAnimationObject = this.slidingAnimationsContainer[this.activePlayerIndex];
    this.slidingAnimation = this.animationMixer.clipAction(slidingAnimationObject.animations[0]);
    const stumblingAnimationObject = this.stumbleAnimationsContainer[this.activePlayerIndex];
    this.stumbleAnimation = this.animationMixer.clipAction(stumblingAnimationObject.animations[0]);

Inside the initialize method, we will display the scene if it's not visible and starts the clock if it's not running :

if (!this.visible) { this.visible = true; } if (!this.clock.running) { this.clock.start(); this.speed = 220; this.player.position.x = 0; }

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


   (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.clock.stop();
    this.player.rotation.x = 0;

Additionally, we will modify the gameOver method to save the score and coins and reset the player rotation. Within the setTimeout function inside the gameOver method, I reset the stumbleAnimation after 3 seconds. If the stumbleAnimation is not reset, the active character's slidingAnimation will malfunction after returning to the RunningScene from the MainMenuScene. However, resetting the stumbleAnimation causes the character to stand up after the game is over.

To make the character appear to be lying on its back as if it is dead, I rotated it 90 degrees on the x-axis.

private gameOver() {
  this.isGameOver = true;
  this.speed = 0;
  (document.querySelector('.pause-button') as HTMLInputElement).style.display = 'none';
  setTimeout(() => {
    this.clock.stop();
    (document.getElementById('game-over-modal') as HTMLInputElement).style.display = 'block';
    (document.querySelector('#current-score') as HTMLInputElement).innerHTML = this.scores.toString();
    (document.querySelector('#current-coins') as HTMLInputElement).innerHTML = this.coins.toString();
    this.stumbleAnimation.reset();
    this.player.rotation.x = (90 * (Math.PI / 180));
  }, 3000);
  this.stumbleAnimation.reset();
  this.stumbleAnimation.setLoop(1, 1);
  this.stumbleAnimation.clampWhenFinished = true;
  this.currentAnimation.crossFadeTo(this.stumbleAnimation, 0.1, false).play();
  this.currentAnimation = this.stumbleAnimation;
  this.currentObstacleOne.position.z -= 5;
  this.currentObstacleTwo.position.z -= 5;
  this.isPlayerHeadStart = false;
  (document.querySelector('.disable-touch') as HTMLInputElement).style.display = 'block';
  this.saveCoins();
  this.saveHighScore();
}

In the restartGame method, we will reset the player's position on the x-axis back to 0, so the character can stand upright again after being made to lie down in the gameOver method:

this.player.rotation.x = 0;

In the saveCoins method, the coins are now saved as 'total-coins' instead of 'coins', which was used previously:

  private saveCoins() {
    const prevTotalCoins = localStorage.getItem('total-coins') || 0;
    const totalCoins = Number(prevTotalCoins) + this.coins;
    localStorage.setItem('total-coins', totalCoins.toString());
  }

Making the Selected Character Active in the MainMenuScene

Here, we'll set the activated character as the MainMenuScene's default dancer. First, we'll remove the previous character and its dancing animation. Then we'll import all the characters with just their dancing animations into the MainMenuScene, and set their visibility to false. After that, we will search the localStorage for the active character and set its visibility true, then plays its dancing animation on it.

Firstly, let's remove the previous character and its animations by deleting the following lines of code in the MainMenuScene:

private player = new Object3D();

Delete the following code too:

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();

One we are done, we will import the allCharacters object and IallGameCharacters interface into the MainMenuScene.

import allCharacters from '../allCharacters';
import { IallGameCharacters } from '../types';

Next, we'll create the following private properties:

 private xbot = new Object3D();
  private jolleen = new Object3D();
  private peasantGirl = new Object3D();
  private xbotAnimation!: Object3D;
  private jolleenAnimation!: Object3D;
  private peasantGirlAnimation!: Object3D;
  private charactersContainer: Object3D[] = [];
  private animationsContainer: Object3D[] = [];
  private allGameCharacters: IallGameCharacters[] = [];
  private activeCharacter = new Object3D();
  private activeCharacterAnimation!: Object3D;
  private activeIndexNumber = 0;

Then in the load method, we'll import all the characters with their dancing animations into the MainMenuScene, and set their visibility to false:

    this.allGameCharacters = (JSON.parse(localStorage.getItem('allGameCharacters')!));
    this.xbot = await this.fbxLoader.loadAsync(this.allGameCharacters[0].model);
    this.jolleen = await this.fbxLoader.loadAsync(this.allGameCharacters[1].model);
    this.peasantGirl = await this.fbxLoader.loadAsync(this.allGameCharacters[2].model);
    this.xbotAnimation = await this.fbxLoader
      .loadAsync(this.allGameCharacters[0].danceAnimation);
    this.jolleenAnimation = await this.fbxLoader
      .loadAsync(this.allGameCharacters[1].danceAnimation);
    this.peasantGirlAnimation = await this.fbxLoader
      .loadAsync(this.allGameCharacters[2].danceAnimation);
    this.xbot.visible = false;
    this.jolleen.visible = false;
    this.peasantGirl.visible = false;
    this.charactersContainer.push(
      this.xbot,
      this.jolleen,
      this.peasantGirl,
    );
    this.animationsContainer.push(
      this.xbotAnimation,
      this.jolleenAnimation,
      this.peasantGirlAnimation,
    );
    this.add(this.xbot);
    this.add(this.jolleen);
    this.add(this.peasantGirl);
    this.hide();

Inside the Initialize method we will display the high-scores and the total coins:

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

Inside the initialize method, we will look for the active character, display it and play it's dancing animation on it: javascript

this.allGameCharacters = (JSON.parse(localStorage.getItem('allGameCharacters')!)); this.activeIndexNumber = this.allGameCharacters .findIndex((character) => character.isActive === true); this.activeCharacter = this.charactersContainer[this.activeIndexNumber]; this.activeCharacterAnimation = this.animationsContainer[this.activeIndexNumber]; this.activeCharacter.scale.set(0.1, 0.1, 0.1); this.activeCharacter.position.set(0, -35, -110); this.activeCharacter.visible = true; this.animationMixer = new AnimationMixer(this.activeCharacter); this.dancingAnimation = this.animationMixer .clipAction(this.activeCharacterAnimation.animations[0]); this.dancingAnimation.play();

Let's update the hide method to conceal the activeCharacter once we are done with the scene:

  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';
    this.activeCharacter.visible = false;

Switching between CharacterSelectionScene and the MainMenuScene

We will make the game switch from the MainMenuScene to CharacterSelectionScene when the "Characters" button is clicked and make it switch back to the MainMenuScene when the home button at the top left of the screen in the MainMenuScene is clicked.

Open up the main.ts file and import the CharacterSelectionScene :

import CharacterSelectionScene from './scenes/CharacterSelectionScene';

Let's update the currentScene types by adding the CharacterSelectionScene object

let currentScene:MainMenuScene | RunningScene | CharacterSelectionScene;

Next, we'll create a new instance of CharacterSelectionScene:

const characterSelectionScene = new CharacterSelectionScene();

After that, we'll add a new function called switchToCharacterSelectionScene, anytime this function is invoked it will switch from the currentScene to CharacterSelectionScene:

const switchToCharacterSelectionScene = () => { currentScene.hide(); currentScene = characterSelectionScene; currentScene.initialize(); }

Let's call the switchToCharacterSelectionScene and switchToMainMenuScene whenever the button assigned to them is clicked:

(document.querySelector('#Characters-selection-button')as HTMLInputElement).onclick = () => {
  switchToCharacterSelectionScene();
};
(document.querySelector('.home-menu')as HTMLInputElement).onclick = () => {
  switchToMainMenuScene();
};

Finally, let's call the characterSelectionScene load method inside the main function javascript

await characterSelectionScene.load();

So that the main function will look like this: javascript

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

Summary And Next Steps

In this part of the series, the CharacterSelectionScene was created. This scene functions as a marketplace where the player can use their collected coins to buy and unlock additional characters. The activated character is also made available for use in the RunningScene and MainMenuScene.

In the next part of this series, I'm going to explain how to setup PlanetScale with Netlify functions to:

  • Register a new user

  • Sign in a user

  • Save the user's scores and coins

  • Save the user's characters

  • Fetch top ten high-scores and

  • Logout a user