Building an Endless Runner Game with Three.js, Mixamo, Vite, and PlanetScale (Part Four)
The Character Selection Scene
Table of contents
- Introduction
- GitHub Repository
- Creating the JSON file for all the characters' objects
- Creating the CharacterSelectionScene
- Importing the wooden cave and Adding the Lights
- Importing all the characters and their animations into the Scene
- Creating the UI Texts and buttons for the CharacterSelectionScene
- Creating the Initialize Method
- Creating the Update Method
- Creating the Next and Previous Method
- Activating The Character
- Purchasing The Character
- Modifying The Update and Hide Method To Display The Active Character
- Making the Selected Character Active in the Running Scene
- Making the Selected Character Active in the MainMenuScene
- Switching between CharacterSelectionScene and the MainMenuScene
- Summary And Next Steps
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:
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 charactermodel
: The 3D character model's URLisActive
: boolean value to indicate whether or not a character is active.price
: the price of the characterisLocked
: used to determine whether or not a character is lockeddanceAnimation
: the URL to the character's dance animationrunAnimation
: the URL of the character's run animationslideAnimation
: the URL of the character's slide animationjumpAnimation
: 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 theallCharacters
object that will be fetched from the local storagedancingAnimation
: to store the dancing animation of the active character that is being displayeddelta
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 objectxbot
: To store the x-bot character objectjolleen
: To store jolleen 3D character objectpeasantGirl
: To store the peasant girl 3D character objectxbotAnimation
: To store the x-bot dancing animation objectjolleenAnimation
: To store Jolleen's dancing animation objectpeasantGirlAnimation
: To store the Peasant girl's dancing animation objectcharactersContainer
: to store all the game charactersanimationsContainer
: To store all the dancing animations of each characteractiveCharacter
: To store the current 3D character that is being displayed on the screenactiveCharacterAnimation
: 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 theallGameCharacters
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 displaythis.activeCharacter = this.charactersContainer[this.activeIndexNumber];
stores the next character into thethis.activeCharacter
property. Now, the next character is the active character.this.activeCharacterAnimation = this.animationsContainer[this.activeIndexNumber];
stores the active character's dance animation into thethis.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 characterthis.dancingAnimation = this.animationMixer .clipAction(this.activeCharacterAnimation.animations[0]);
stores the dancing animation of the active user intothis.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 prevCharacter
method:
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