SECRET OF CSS

Improving Item Collection in a JavaScript Game | by Nevin Katz | Aug, 2022


Let’s make our collision detection pixel-accurate.

1*prgc4ZWjyMdDiy y6vbH A
Image by pch.vector on freepik

Over the past few months, I have been gradually building a JavaScript dungeon crawler that features a player moves through an auto-generated maze of caverns. While contending with enemies, the player can collect health potions and weapons — and with danger afoot, the player must grab them fast.

Helping the player grab an item as quickly as possible depends on effective collision detection. In an early version, the player had to be right on top of an item before collecting it.

Tile-based collision detection

After improving the code, I finally got the game to the point where the player could collect an item simply by touching a small part of it.

Pixel-level collision detection

This article focuses on how to improve the accuracy of the collision detection in a tile-based game with vanilla JavaScript.

The game is always made up of tiles, and while the player can move smoothly between tiles, each item is perfectly aligned with the tile it’s on. Here we use this to our advantage, detecting what tiles the player is on and whether there is an item on those tiles.

The game map is a 2D array of numeric codes. Each code corresponds to a specific tile according to the key below.

const WALL_CODE = 0;
const FLOOR_CODE = 1;
const POTION_CODE = 4;
const WEAPON_CODE = 5;

Each item in the 2D array has a specific index, which can be thought of as its (x,y) coordinate in the tile map. In the example below, the first wall tile, symbolized by 0, is at the coordinate (0,0). The second wall tile is at coordinate (2,2).

(0,0)
|
[[0,1,1]
[1,1,1]
[1,1,0] <-- (2,2)

In my most recent game, each tile has a length and width of 32 pixels. I store this number in a constant called TILE_DIM, which I use to translate between tile indices of the map and coordinates in pixels. We’ll see how this works in the next example.

The player object is a coords property, which stores its (x,y) coordinates in pixels. We can find the array indices of the tile the player is most aligned with using a simple method below. This is known as the player’s current tile.

Current tile.

Now couldn’t a player be placed evenly across four tiles? Sure. In that case, due to how the player’s coordinates round up, the bottom-right tile becomes the current one.

1*dtRycTWfYXbHYmqheXUR5g

Now that we can identify the tile the player is on, let’s see how we can get it to check for collision with surrounding tiles.

At any given time, the player is typically surrounded by eight other tiles.

1*9IwA1vegrmMSzI2jgGYs5A
The player and surrounding tiles.

Below, the player starts to move off of the tile it has been residing on, which is in yellow. Because it still overlaps with this tile the most, the game considers it the player’s current tile.

The player is starting to move off of its current tile.

Suppose there is an item above the player and to the right.

1*E2XKoZqJVfYnrFOqoH4OLA
The player touches a tile with an item.

Here, the goal is to check for any overlap between the player and the item. We can do this by checking the tiles around the player. Let’s label them with coordinates

The relative coordinates of each surrounding tile.

Because these coordinates are relative to the player’s position, let’s call them relative coordinates. To get the array indices of a neighboring tile, we can use the method below, adjacentTileIndices, which takes a set of relative coordinates as a parameter.

Player.prototype.adjacentTileIndices = function({x,y}) {
let tile = this.curTileCoords();
tile.x += x;
tile.y += y;
return tile;
}

So to get the array indices of the tile on the upper right, we call the following:

player.adjacentTileIndices({x:1,y:-1})

Because the game doesn’t store where the items are, it’s best to check the surrounding tiles for items as the player moves. Given the relative coordinates below…

1*6K KrF7Adn1EcblQyNjGQQ
Relative coordinates

…we could come up with a range of numbers to test on the x and y axes.

const range = [-1,0,1];

And in theory, we could then iterate through all nine sets of tile coordinates using a nested loop.

1*LJsCH3NrUIeAcQ 7nacn9g
We could hypothetically do this.

But wait! We should never have to check all nine tiles since the maximum number of tiles the player can be on is four.

1*M8wKKpCrORN1Qluh3LDpFg
There are five tiles the player is not on.

So a more efficient way to do this is to identify the tiles the player is on, iterate through only those tiles, and see if there is an item on each one.

To achieve this, let’s write some helper methods that check on whether a player is perfectly aligned to its current tile. If it is not perfectly aligned, we identify which tiles it is encroaching upon. (And if those tiles have items, then the player collects the item.)

Now let’s use the helpers we have to find out how far the layer has wandered off its current tile.

Finding the current tile distance.

In the method above, we first get either the player’s x or y coordinates depending on the axis parameter.

let coord = this.coords[axis];

We then divide by TILE_DIM and round to get its x or y index in the game map array.

let tileCoords = Math.round(coord / TILE_DIM);

Now, let’s multiply it by TILE_DIM to get the x or y coordinate of the tile in pixels.

let pixelTileCoord = tileCoord*TILE_DIM;

Once done, we find the difference in pixels between the player’s position and the tile’s position.

const diff = coord - pixelTileCoords;return diff;

What side are you on?

For each axis, we can call this helper below to see which side of the tile the player is on. If the number of pixels is non-zero, that means the player is wandering onto a neighboring tile.

Player.prototype.tileSide = function(axis) {
let dist = this.curTileDist(axis);
return (dist == 0) ? dist : Math.round(dist/Math.abs(dist));
}

Let’s take a close look at the method above. First, we get the distance of the player from its current tile.

let dist = this.curTileDist(axis);

Because dividing by zero would be disastrous, we simply return dist if it’s zero. If it’s non-zero, we make it 1 or -1 depending on its sign.

return (dist == 0) ? dist : Math.round(dist/Math.abs(dist));

So for the x axis, the tileSide method will return one of three values:

  • -1 if the player is overlapping the tile on the left
  • 0 if the player is horizontally aligned to its current tile
  • 1 if the tile is overlapping the tile on the right

And for the y axis, tileSide will return one of the following:

  • -1 if the player is overlapping the tile above
  • 0 if the player is vertically aligned to its current tile
  • 1 if the tile is overlapping the tile below

This tileSide method is then used in the method below, which gets the player’s positions relative to a tile and adds the relative coordinates of tiles we should check.

This method adds a coordinate of 1 or -1 if the player is not aligned to its tile on the given axis.

For both x and y, the arr variable starts out containing 0 because we should always check the relative coordinate (0,0), which is the tile the player is on.

let arr = [0];

Notice that the return value of tileSide is sideVal.

let sideVal = this.tileSide(axis);
  • on the x axis, sideVal is -1 is left and 1 is right.
  • on the y axis, sideVal is -1 is above and 1 is below.

Now let’s see how tilePositions gets used to check on the player’s neighboring tiles.

The tilePositions method is then used in the “big picture” function below that finds the tiles the player is on and checks for items on them. Notice that it is called twice — once for each axis.

The checkSurround method

Let’s break this one down. First, we get the tile coordinates of the player.

const cur = this.curTileCoords();

We then define an range object which stores the positions to check on for each axis.

let range = {
x:this.tilePositions('x'),
y:this.tilePositions('y')
};

If the player is perfectly aligned, the range is{x:[0] ,y:[0]} since there is only one tile to check.

But if the player is in the position below…

1*YO0z 9uHi8CRYy 7N jUXg

…the range object produced by the two calls to tilePositions looks like the following:

{
x:[0, 1],
y:[0,-1]
}

As a result, only the four tiles the player is on get checked: (0, 0), (1, 0), (1, -1), and (0, -1).

Before diving into the loop, we establish the types of item codes we should check in the 2D map array.

const itemCodes = [POTION_CODE, WEAPON_CODE];

We can then set up our nested loop:

for (let y of range.y) {   for (let x of range.x) {

// check the tile at (x,y)
// if it's an item, collect it

}
}

Within the loop, we get the array indices of the tile being checked.

const coords = this.adjacentTileIndices({x,y});

We then identify the tile in the game map.

let tileCode = game.map[coords.y][coords.x];

Remember the itemCodes array?

[POTION_CODE, WEAPON_CODE];

If the tileCode is included in the itemCodes array, we call a grabItem function and collect it. This causes the item to augment the player’s health

if (itemCodes.includes(tileCode)) {
grabItem(tileCode, coords);
}

All you need to know about grabItem is that it changes the player’s properties and removes the item from the game map. If it’s a health potion, the player’s health goes up until it’s maxed out. If it’s a weapon, the player acquires it.

You can see this logic at work in this demo.

Below are some key takeaways for you to keep in mind:

  • The TILE_DIM constant allows me to switch between 2D array indices and pixel coordinates.
  • The player is typically surrounded by eight tiles.
  • We build up little helper functions for figuring out which tiles the player is on.
  • Using a range object, we collect the coordinates of the tiles to check.
  • If any of these tiles turn out to be an item, the item gets collected.

For items that are perfectly aligned with tiles, we can take advantage of the player’s degree of tile alignment to identify the tiles that should be checked. This helps to keep collision detection efficient.



News Credit

%d bloggers like this: