Moving the Player

Crossy Road with React Three Fiber ● Chapter 5

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.

src/types.ts
26 collapsed lines
import * as THREE from "three";
export type RowType = "forest" | "car" | "truck";
export type Row =
| {
type: "forest";
trees: { tileIndex: number; height: number }[];
}
| {
type: "car";
direction: boolean;
speed: number;
vehicles: {
initialTileIndex: number;
color: THREE.ColorRepresentation;
}[];
}
| {
type: "truck";
direction: boolean;
speed: number;
vehicles: {
initialTileIndex: number;
color: THREE.ColorRepresentation;
}[];
};
export type MoveDirection = "forward" | "backward" | "left" | "right";
src/types.js
1 collapsed line
export {};

The Player Store

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.

src/stores/player.ts
import type { MoveDirection } from "../types";
export const state: {
currentRow: number;
currentTile: number;
movesQueue: MoveDirection[];
} = {
currentRow: 0,
currentTile: 0,
movesQueue: [],
};
export function queueMove(direction: MoveDirection) {
state.movesQueue.push(direction);
}
export function stepCompleted() {
const direction = 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;
}
src/stores/player.js
export const state = {
currentRow: 0,
currentTile: 0,
movesQueue: [],
};
export function queueMove(direction) {
state.movesQueue.push(direction);
}
export function stepCompleted() {
const direction = 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.

src/components/Controls.tsx
import { queueMove } from "../stores/player";
import useEventListeners from "../hooks/useEventListeners";
import "./Controls.css";
export function Controls() {
useEventListeners();
return (
<div id="controls">
<div>
<button onClick={() => queueMove("forward")}>▲</button>
<button onClick={() => queueMove("left")}>◀</button>
<button onClick={() => queueMove("backward")}>▼</button>
<button onClick={() => queueMove("right")}>▶</button>
</div>
</div>
);
}
src/components/Controls.jsx
import { queueMove } from "../stores/player";
import useEventListeners from "../hooks/useEventListeners";
import "./Controls.css";
export function Controls() {
useEventListeners();
return (
<div id="controls">
<div>
<button onClick={() => queueMove("forward")}>▲</button>
<button onClick={() => queueMove("left")}>◀</button>
<button onClick={() => queueMove("backward")}>▼</button>
<button onClick={() => queueMove("right")}>▶</button>
</div>
</div>
);
}

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;
}
#controls div {
display: grid;
grid-template-columns: 50px 50px 50px;
gap: 10px;
}
#controls button {
width: 100%;
height: 40px;
background-color: white;
border: 1px solid lightgray;
box-shadow: 3px 5px 0px 0px rgba(0, 0, 0, 0.75);
cursor: pointer;
outline: none;
}
#controls button: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.

src/hooks/useEventListeners.ts
import { useEffect } from "react";
import { queueMove } from "../stores/player";
export default function useEventListeners() {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "ArrowUp") {
event.preventDefault();
queueMove("forward");
} else if (event.key === "ArrowDown") {
event.preventDefault();
queueMove("backward");
} else if (event.key === "ArrowLeft") {
event.preventDefault();
queueMove("left");
} else if (event.key === "ArrowRight") {
event.preventDefault();
queueMove("right");
}
};
window.addEventListener("keydown", handleKeyDown);
// Cleanup function to remove the event listener
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, []);
}
src/hooks/useEventListeners.js
import { useEffect } from "react";
import { queueMove } from "../stores/player";
export default function useEventListeners() {
useEffect(() => {
const handleKeyDown = (event) => {
if (event.key === "ArrowUp") {
event.preventDefault();
queueMove("forward");
} else if (event.key === "ArrowDown") {
event.preventDefault();
queueMove("backward");
} else if (event.key === "ArrowLeft") {
event.preventDefault();
queueMove("left");
} else if (event.key === "ArrowRight") {
event.preventDefault();
queueMove("right");
}
};
window.addEventListener("keydown", handleKeyDown);
// Cleanup function to remove the event listener
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, []);
}

Add Control Buttons to the Game

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";
export default function Game() {
return (
<div className="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";
export default function Game() {
return (
<div className="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.

src/Game.css
@import url("https://fonts.googleapis.com/css?family=Press+Start+2P");
.game {
position: relative;
width: 100%;
height: 100%;
font-family: "Press Start 2P", cursive;
}

Executing Movement Commands

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.

src/hooks/usePlayerAnimation.ts
import * as THREE from "three";
import { useFrame } from "@react-three/fiber";
import { state, stepCompleted } from "../stores/player";
import { tileSize } from "../constants";
export default function usePlayerAnimation(
ref: React.RefObject<THREE.Group | null>
) {
const moveClock = new THREE.Clock(false);
useFrame(() => {
if (!ref.current) return;
if (!state.movesQueue.length) return;
const player = ref.current;
if (!moveClock.running) moveClock.start();
const stepTime = 0.2; // Seconds it takes to take a step
const progress = Math.min(
1,
moveClock.getElapsedTime() / stepTime
);
setPosition(player, progress);
setRotation(player, progress);
// Once a step has ended
if (progress >= 1) {
stepCompleted();
moveClock.stop();
}
});
}
src/hooks/usePlayerAnimation.js
import * as THREE from "three";
import { useFrame } from "@react-three/fiber";
import { state, stepCompleted } from "../stores/player";
import { tileSize } from "../constants";
export default function usePlayerAnimation(ref) {
const moveClock = new THREE.Clock(false);
useFrame(() => {
if (!ref.current) return;
if (!state.movesQueue.length) return;
const player = ref.current;
if (!moveClock.running) moveClock.start();
const stepTime = 0.2; // Seconds it takes to take a step
const progress = 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.

src/hooks/usePlayerAnimation.ts
33 collapsed lines
import * as THREE from "three";
import { useFrame } from "@react-three/fiber";
import { state, stepCompleted } from "../stores/player";
import { tileSize } from "../constants";
export default function usePlayerAnimation(
ref: React.RefObject<THREE.Group | null>
) {
const moveClock = new THREE.Clock(false);
useFrame(() => {
if (!ref.current) return;
if (!state.movesQueue.length) return;
const player = ref.current;
if (!moveClock.running) moveClock.start();
const stepTime = 0.2; // Seconds it takes to take a step
const progress = Math.min(
1,
moveClock.getElapsedTime() / stepTime
);
setPosition(player, progress);
setRotation(player, progress);
// Once a step has ended
if (progress >= 1) {
stepCompleted();
moveClock.stop();
}
});
}
function setPosition(player: THREE.Group, progress: number) {
const startX = state.currentTile * tileSize;
const startY = state.currentRow * tileSize;
let endX = startX;
let endY = startY;
if (state.movesQueue[0] === "left") endX -= tileSize;
if (state.movesQueue[0] === "right") endX += tileSize;
if (state.movesQueue[0] === "forward") endY += tileSize;
if (state.movesQueue[0] === "backward") endY -= tileSize;
player.position.x = THREE.MathUtils.lerp(startX, endX, progress);
player.position.y = THREE.MathUtils.lerp(startY, endY, progress);
player.position.z = Math.sin(progress * Math.PI) * 8;
}
function setRotation(player: THREE.Group, progress: number) {
let endRotation = 0;
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
);
}
src/hooks/usePlayerAnimation.js
31 collapsed lines
import * as THREE from "three";
import { useFrame } from "@react-three/fiber";
import { state, stepCompleted } from "../stores/player";
import { tileSize } from "../constants";
export default function usePlayerAnimation(ref) {
const moveClock = new THREE.Clock(false);
useFrame(() => {
if (!ref.current) return;
if (!state.movesQueue.length) return;
const player = ref.current;
if (!moveClock.running) moveClock.start();
const stepTime = 0.2; // Seconds it takes to take a step
const progress = Math.min(
1,
moveClock.getElapsedTime() / stepTime
);
setPosition(player, progress);
setRotation(player, progress);
// Once a step has ended
if (progress >= 1) {
stepCompleted();
moveClock.stop();
}
});
}
function setPosition(player, progress) {
const startX = state.currentTile * tileSize;
const startY = state.currentRow * tileSize;
let endX = startX;
let endY = startY;
if (state.movesQueue[0] === "left") endX -= tileSize;
if (state.movesQueue[0] === "right") endX += tileSize;
if (state.movesQueue[0] === "forward") endY += tileSize;
if (state.movesQueue[0] === "backward") endY -= tileSize;
player.position.x = THREE.MathUtils.lerp(startX, endX, progress);
player.position.y = THREE.MathUtils.lerp(startY, endY, progress);
player.position.z = Math.sin(progress * Math.PI) * 8;
}
function setRotation(player, progress) {
let endRotation = 0;
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.

src/components/Player.tsx
import * as THREE from "three";
import { Bounds } from "@react-three/drei";
import { useRef } from "react";
import usePlayerAnimation from "../hooks/usePlayerAnimation";
export function Player() {
const player = useRef<THREE.Group>(null);
usePlayerAnimation(player);
return (
<Bounds fit clip observe margin={10}>
<group ref={player}>
<mesh position={[0, 0, 10]} castShadow receiveShadow>
<boxGeometry args={[15, 15, 20]} />
<meshLambertMaterial color={0xffffff} flatShading />
</mesh>
<mesh position={[0, 0, 21]} castShadow receiveShadow>
<boxGeometry args={[2, 4, 2]} />
<meshLambertMaterial color={0xf0619a} flatShading />
</mesh>
</group>
</Bounds>
);
}
src/components/Player.jsx
import { Bounds } from "@react-three/drei";
import { useRef } from "react";
import usePlayerAnimation from "../hooks/usePlayerAnimation";
export function Player() {
const player = useRef(null);
usePlayerAnimation(player);
return (
<Bounds fit clip observe margin={10}>
<group ref={player}>
<mesh position={[0, 0, 10]} castShadow receiveShadow>
<boxGeometry args={[15, 15, 20]} />
<meshLambertMaterial color={0xffffff} flatShading />
</mesh>
<mesh position={[0, 0, 21]} castShadow receiveShadow>
<boxGeometry args={[2, 4, 2]} />
<meshLambertMaterial color={0xf0619a} flatShading />
</mesh>
</group>
</Bounds>
);
}
Previous:
Animating the Vehicles
Next:
Restricting Player Movement