Moving the player on the map is more complex than moving the vehicles. The player can move in all directions, bump into trees, or be hit by cars, and it shouldn’t be able to move outside the map.
In this chapter, we are focusing on two parts: collecting user input and executing the movement commands. Player movement is not instant. We need to collect the movement commands into a queue and execute them one by one.
Collecting User Inputs
We are going to collect user inputs and put them into a queue. We collect both click events from control buttons on the screen and keyboard events.
Defining the Possible Move Directions
The player can move in four directions: forward, backward, left, and right. We extend our types file with a new type, MoveDirection, representing these directions.
To track the movement commands, we extend the Player component with state. We keep track the player’s position and movement queue.
Let’s add a new object to contain the player’s current position and the moves queue. The player starts at the middle of the first row, and the moves queue is initially empty.
We will also export two functions. The queueMove function adds a movement command to the end of the moves queue, and the stepCompleted function removes the first movement command from the queue and updates the player’s position accordingly.
if (direction ==="forward") position.currentRow +=1;
if (direction ==="backward") position.currentRow -=1;
if (direction ==="left") position.currentTile -=1;
if (direction ==="right") position.currentTile +=1;
}
src/components/Player.js
import*as THREE from"three";
exportconstplayer=Player();
functionPlayer() {
constbody=newTHREE.Mesh(
newTHREE.BoxGeometry(15, 15, 20),
newTHREE.MeshLambertMaterial({
color: "white",
flatShading: true,
})
);
body.position.z =10;
return body;
}
exportconstposition= {
currentRow: 0,
currentTile: 0,
};
exportconstmovesQueue= [];
exportfunctionqueueMove(direction) {
movesQueue.push(direction);
}
exportfunctionstepCompleted() {
constdirection= movesQueue.shift();
if (direction ==="forward") position.currentRow +=1;
if (direction ==="backward") position.currentRow -=1;
if (direction ==="left") position.currentTile -=1;
if (direction ==="right") position.currentTile +=1;
}
Setting up the Control Buttons
It’s time to add some controls to the screen. We add four buttons to move the player forward, left, backward, and right. Let’s define them in our HTML file.
The styling for the buttons is as follows. In the React component, we wrapped the buttons into two div elements. We use the outer div to position the controls at the bottom of the screen. The inner div has a grid layout to show the buttons like this. The forward button spans all columns to make it wider.
src/style.css
body {
margin: 0;
display: flex;
}
#controls {
position: absolute;
bottom: 20px;
min-width: 100%;
display: flex;
align-items: flex-end;
justify-content: center;
}
#controlsdiv {
display: grid;
grid-template-columns: 50px50px50px;
gap: 10px;
}
#controlsbutton {
width: 100%;
height: 40px;
background-color: white;
border: 1pxsolidlightgray;
box-shadow: 3px5px0px0pxrgba(0, 0, 0, 0.75);
cursor: pointer;
outline: none;
}
#controlsbutton:first-of-type {
grid-column: 1/-1;
}
Adding Event Listeners
Next, we add event listeners to the buttons on the screen and keyboard event listeners to the arrow keys. They all call the player’s queueMove function with the corresponding direction. Note that we prevent the default behavior of the arrow keys to avoid scrolling the page.
Now that we have a movesQueue array and we are already collecting move commands, it’s time to execute them one by one and animate the player.
Animating the Player
Now that we started collecting user inputs, it’s time to execute the player’s movements. Let’s create a new function called animatePlayer. Its main goal is to take each move command from the movesQueue one by one, calculate the player’s progress towards executing a step, and position the player accordingly.
This function animates the player frame by frame. It will also be part of the main animate function. We use a separate moveClock that measures each step individually. We pass on false to the clock constructor so it doesn’t start automatically. The clock starts at the beginning of a step. At each animation frame, first, we check if there are any more steps to take, and if there are and we don’t currently process a step, we start the clock.
Once the clock is ticking, we animate the player from tile to tile with each step. We use the moveClock to calculate the progress between the two tiles. The progress indicator can be between 0 and 1. Zero means the player is at the beginning of a step, and one means it arrived at its new position.
At each animation frame, we call the setPosition and setRotation functions to set the player according to the progress.
Once we finish a step, we call the stepCompleted function to update the player’s current position and stop the clock. If there are more move commands in the movesQueue, the clock will restart in the following animation frame.
Now that we know how to calculate the progress for each step let’s look into how to set the player’s position and rotation based on it.
The player will jump from tile to tile. Let’s break this down into two parts: the horizontal and vertical components of the movement.
The player moves from the current tile to the next tile in the direction of the move command. We calculate the player’s start and end positions based on the current tile and the direction of the move command. We use the THREE.MathUtils.lerp function to interpolate between the start and end positions based on the progress.
For the vertical component of the movement, we use a sine function to make the curved path. The sine function can take an input between 0 and 2 pi. Our progress is a value between 0 and 1. If we multiply the progress with pi, we map the progress value to the first half of a sine wave. The sine function will return a value between -1 and 1, or in this case 0 and 1. To make the jump higher we multiply this value with 8. This way, the highest point of the jump, in the middle of the step, will be at 8.
The player also turns in the direction of the move command. We use linear interpolation again to calculate the player’s rotation based on progress.
src/animatePlayer.ts
import*as THREE from"three";
import {
player,
position,
movesQueue,
stepCompleted,
} from"./components/Player";
import { tileSize } from"./constants";
19 collapsed lines
constmoveClock=newTHREE.Clock(false);
exportfunctionanimatePlayer() {
if (!movesQueue.length) return;
if (!moveClock.running) moveClock.start();
conststepTime=0.2; // Seconds it takes to take a step
Now, if you start moving around, you notice the player sinks halfway into the ground after the first step. This is because the animation assumes that the player’s resting position is at zero. We position boxes by their center, and this box should be above the ground by half its size.
To fix this, let’s wrap the player into a group. The group’s resting position will be at zero, and the box inside of it will be elevated relative to the group.
Now that we rotate the player in its direction, it’s also handy to have a visual indicator of the direction the player is facing. We add a new mesh that serves as a little cap on top of the body.
If we did everything right, the player should be able to move around the game board. The player should move forward, backward, left, and right and rotate accordingly. However, we haven’t added any hit detection so far. The player can move through trees, vehicles, and even off the game board. We will fix this in the next chapters.