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 create a store for the player. The store will keep track of the player’s position and movement queue.
Why don’t we use zustand or some other state management library?
One of the main benefits of using a store like zustand is that the state inside is reactive. If you change the state, all components that are using the state will re-render.
With React Three Fiber, we try to avoid re-rendering components for performance reasons and manipulate the 3D objects directly. For the player, we will proactively get the state from the store in each frame instead of relying on reactivity.
For our purposes, we can simply create a file that exports our state variables and a few functions that manipulate the state.
Let’s define a player store inside a new folder called stores. This file will export a state object with 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 ==="backward") state.currentRow -=1;
if (direction ==="left") state.currentTile -=1;
if (direction ==="right") state.currentTile +=1;
}
src/stores/player.js
exportconststate= {
currentRow: 0,
currentTile: 0,
movesQueue: [],
};
exportfunctionqueueMove(direction) {
state.movesQueue.push(direction);
}
exportfunctionstepCompleted() {
constdirection= state.movesQueue.shift();
if (direction ==="forward") state.currentRow +=1;
if (direction ==="backward") state.currentRow -=1;
if (direction ==="left") state.currentTile -=1;
if (direction ==="right") state.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.
We use the queueMove function from the player store we just defined. This only collects the movement commands into a queue. The actual movement will be implemented in the next section.
We also add a new hook useEventListeners that listens to keyboard events so that the player can also move using the arrow keys.
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/components/Controls.css
#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;
}
Listening to Keyboard Events
The useEventListeners hook listens to the arrow keys and calls the queueMove function of the player store with the corresponding direction. Note that we prevent the arrow keys’ default behavior to avoid scrolling the page.
Finally, we add the Controls component to the Game component. Since the control buttons are not part of the 3D scene, we add a new container div that contains both the scene and the controls.
src/Game.tsx
import { Scene } from"./components/Scene";
import { Player } from"./components/Player";
import { Controls } from"./components/Controls";
import"./Game.css";
exportdefaultfunctionGame() {
return (
<divclassName="game">
<Scene>
<Player />
<Map />
</Scene>
<Controls />
</div>
);
}
src/Game.jsx
import { Scene } from"./components/Scene";
import { Player } from"./components/Player";
import { Controls } from"./components/Controls";
import"./Game.css";
exportdefaultfunctionGame() {
return (
<divclassName="game">
<Scene>
<Player />
<Map />
</Scene>
<Controls />
</div>
);
}
We style this new div to ensure the game fills the available space and use position: relative. This way, the controls can be positioned absolutely within the game container.
While we are not currently using it, now that we write this file, we also load the Press Start 2P font from Google Fonts and set it as our font. This font will be visible on the score indicator and the result screen.
Now that we have a movesQueue array in our player store and are already collecting move commands, it’s time to execute them one by one and animate the player.
Animating the Player
Now that we set up the player store and started collecting user inputs, it’s time to execute the player’s movements. Let’s create a new hook called usePlayerAnimation. 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 hook animates the player frame by frame. It uses the useFrame hook, just like the useVehicleAnimation hook. This time, however, 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 in the store 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.
conststepTime=0.2; // Seconds it takes to take a step
constprogress= Math.min(
1,
moveClock.getElapsedTime() / stepTime
);
setPosition(player, progress);
setRotation(player, progress);
// Once a step has ended
if (progress >=1) {
stepCompleted();
moveClock.stop();
}
});
}
The setPosition and setRotation Functions
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.
if (state.movesQueue[0] =="forward") endRotation =0;
if (state.movesQueue[0] =="left") endRotation = Math.PI/2;
if (state.movesQueue[0] =="right") endRotation =-Math.PI/2;
if (state.movesQueue[0] =="backward") endRotation = Math.PI;
player.rotation.z =THREE.MathUtils.lerp(
player.rotation.z,
endRotation,
progress
);
}
Using the Animation Hook
It’s finally time to update the Player component to make it all come together.
Now that we rotate the player in its direction, it’s handy to have a visual indicator of the direction the player is facing. We wrap the player in a group element and extend it with a little cap on top.
Then, we create a new reference with useRef and assign it to the group element. Finally, we pass this reference to the usePlayerAnimation hook we just implemented.
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.