Restricting Player Movement

Crossy Road with Three.js ● Chapter 6

In the previous chapter, we added event handlers and logic to move the player around the map. But so far, the player can walk across trees and vehicles and go outside the map. In this chapter, we are going to make sure that the player can’t take an invalid position.

We will check if a move is valid by calculating where it would take the player. If the player would end up outside the map or on a tile occupied by a tree, we will ignore that move.

Calculating Where the Player ends up

First, we need to calculate where the player would end up if they made a move. Whenever we add a new move to the queue, we need to calculate where the player would end up if they made all the moves in the movesQueue and then take the current movement command.

We create a utility function that takes the player’s current position and an array of moves and returns the player’s final position. We will use this information to check if the player can make the move.

For instance, if the current position is { rowIndex: 0, tileIndex: 0 } and the moves are ["forward", "left"], the final position would be { rowIndex: 1, tileIndex: -1 }.

src/utilities/calculateFinalPosition.ts
import type { MoveDirection } from "../types";
export function calculateFinalPosition(
currentPosition: { rowIndex: number; tileIndex: number },
moves: MoveDirection[]
) {
return moves.reduce((position, direction) => {
if (direction === "forward")
return {
rowIndex: position.rowIndex + 1,
tileIndex: position.tileIndex,
};
if (direction === "backward")
return {
rowIndex: position.rowIndex - 1,
tileIndex: position.tileIndex,
};
if (direction === "left")
return {
rowIndex: position.rowIndex,
tileIndex: position.tileIndex - 1,
};
if (direction === "right")
return {
rowIndex: position.rowIndex,
tileIndex: position.tileIndex + 1,
};
return position;
}, currentPosition);
}
src/utilities/calculateFinalPosition.js
export function calculateFinalPosition(currentPosition, moves) {
return moves.reduce((position, direction) => {
if (direction === "forward")
return {
rowIndex: position.rowIndex + 1,
tileIndex: position.tileIndex,
};
if (direction === "backward")
return {
rowIndex: position.rowIndex - 1,
tileIndex: position.tileIndex,
};
if (direction === "left")
return {
rowIndex: position.rowIndex,
tileIndex: position.tileIndex - 1,
};
if (direction === "right")
return {
rowIndex: position.rowIndex,
tileIndex: position.tileIndex + 1,
};
return position;
}, currentPosition);
}

Evaluating if a Move is Valid

Then, we create another utility function to calculate whether the player would end up in a valid position after making a series of moves.

We use the calculateFinalPosition function to calculate where the player would end up after a move. Then, we check if the player would end up outside the map or on a tile occupied by a tree. If the move is invalid, we return false.

First, we check if the final position is before the starting row or if the tile number is outside the range of the tiles. Then, we check the metadata of the row the player ends up in. Here, the index is off by one because the row metadata does not include the starting row. If we end up in a forest, we check whether the tile we would move to is occupied by a tree. If any of this is true, we return false.

src/utilities/endsUpInValidPosition.ts
import type { MoveDirection } from "../types";
import { calculateFinalPosition } from "./calculateFinalPosition";
import { minTileIndex, maxTileIndex } from "../constants";
import { metadata as rows } from "../components/Map";
export function endsUpInValidPosition(
currentPosition: { rowIndex: number; tileIndex: number },
moves: MoveDirection[]
) {
// Calculate where the player would end up after the move
const finalPosition = calculateFinalPosition(
currentPosition,
moves
);
// Detect if we hit the edge of the board
if (
finalPosition.rowIndex === -1 ||
finalPosition.tileIndex === minTileIndex - 1 ||
finalPosition.tileIndex === maxTileIndex + 1
) {
// Invalid move, ignore move command
return false;
}
// Detect if we hit a tree
const finalRow = rows[finalPosition.rowIndex - 1];
if (
finalRow &&
finalRow.type === "forest" &&
finalRow.trees.some(
(tree) => tree.tileIndex === finalPosition.tileIndex
)
) {
// Invalid move, ignore move command
return false;
}
return true;
}
src/utilities/endsUpInValidPosition.js
import { calculateFinalPosition } from "./calculateFinalPosition";
import { minTileIndex, maxTileIndex } from "../constants";
import { metadata as rows } from "../components/Map";
export function endsUpInValidPosition(currentPosition, moves) {
// Calculate where the player would end up after the move
const finalPosition = calculateFinalPosition(
currentPosition,
moves
);
// Detect if we hit the edge of the board
if (
finalPosition.rowIndex === -1 ||
finalPosition.tileIndex === minTileIndex - 1 ||
finalPosition.tileIndex === maxTileIndex + 1
) {
// Invalid move, ignore move command
return false;
}
// Detect if we hit a tree
const finalRow = rows[finalPosition.rowIndex - 1];
if (
finalRow &&
finalRow.type === "forest" &&
finalRow.trees.some(
(tree) => tree.tileIndex === finalPosition.tileIndex
)
) {
// Invalid move, ignore move command
return false;
}
return true;
}

Updating the Player

Finally, we extend the player’s queueMove function with the endsUpInValidPosition to check if a move is valid.

If it returns false, we return from the function before the move command is added to the queue. This way, only valid moves are added to the movesQueue.

src/components/Player.ts
import * as THREE from "three";
import { endsUpInValidPosition } from "../utilities/endsUpInValidPosition";
import type { MoveDirection } from "../types";
41 collapsed lines
export const player = Player();
function Player() {
const player = new THREE.Group();
const body = new THREE.Mesh(
new THREE.BoxGeometry(15, 15, 20),
new THREE.MeshLambertMaterial({
color: "white",
flatShading: true,
})
);
body.castShadow = true;
body.receiveShadow = true;
body.position.z = 10;
player.add(body);
const cap = new THREE.Mesh(
new THREE.BoxGeometry(2, 4, 2),
new THREE.MeshLambertMaterial({
color: 0xf0619a,
flatShading: true,
})
);
cap.position.z = 21;
cap.castShadow = true;
cap.receiveShadow = true;
player.add(cap);
return player;
}
export const position: {
currentRow: number;
currentTile: number;
} = {
currentRow: 0,
currentTile: 0,
};
export const movesQueue: MoveDirection[] = [];
export function queueMove(direction: MoveDirection) {
const isValidMove = endsUpInValidPosition(
{
rowIndex: position.currentRow,
tileIndex: position.currentTile,
},
[...movesQueue, direction]
);
if (!isValidMove) return;
movesQueue.push(direction);
}
8 collapsed lines
export function stepCompleted() {
const direction = 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;
}
src/components/Player.js
import * as THREE from "three";
import { endsUpInValidPosition } from "../utilities/endsUpInValidPosition";
36 collapsed lines
export const player = Player();
function Player() {
const player = new THREE.Group();
const body = new THREE.Mesh(
new THREE.BoxGeometry(15, 15, 20),
new THREE.MeshLambertMaterial({
color: "white",
flatShading: true,
})
);
body.castShadow = true;
body.receiveShadow = true;
body.position.z = 10;
player.add(body);
const cap = new THREE.Mesh(
new THREE.BoxGeometry(2, 4, 2),
new THREE.MeshLambertMaterial({
color: 0xf0619a,
flatShading: true,
})
);
cap.position.z = 21;
cap.castShadow = true;
cap.receiveShadow = true;
player.add(cap);
return player;
}
export const position = {
currentRow: 0,
currentTile: 0,
};
export const movesQueue = [];
export function queueMove(direction) {
const isValidMove = endsUpInValidPosition(
{
rowIndex: position.currentRow,
tileIndex: position.currentTile,
},
[...movesQueue, direction]
);
if (!isValidMove) return;
movesQueue.push(direction);
}
8 collapsed lines
export function stepCompleted() {
const direction = 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;
}
Previous:
Moving the Player
Next:
Following the Player