Skip to main content

Hold It Now, Hit It

Last time, I added coarse "box" collision to the game. Box collision is fast, but it's not accurate enough for a shoot-em-up. What I need is pixel perfect collision. This works using "collision maps" for the two objects. A collision map is basically the same as a sprite image (which is sometimes called a pixel map), but instead of storing a colour value for each pixel, I store a true or false value - true if the pixel is set, false if not. When it comes to checking for collision, I walk across the two collision maps and check for any pixel where both maps are set to true. If that happens then the two objects are colliding.


In the following image, you can see the difference between the two types of collision:



If we were to just consider box collision, then it looks like the bullet has hit the player object - you will have noticed that in the last step where the collision felt really unfair. But if we compare the actual pixels of the two images for the two objects then we can see that there is no collision. From a conceptual point of view this is pretty straight-forward, but one extra thing that you'll see in shoot-em-ups is that the image you see on screen is not the image that is used for the collision map. The maps used for collision are usually much smaller than the sprites themselves, and this is what makes it possible for a skilled player to slip through holes that seem far too small to fit through.

In the case of our player, the collision map is just the size of their ship's cockpit - this is a pretty common trick in shoot-em-ups. The bullet collision map is also slightly smaller than the sprite that it represents. Here's another image to show the difference between what you see and what the game uses for collision tests:


Onto The Code

So how does this work in code? I only needed to add write two new chunks of code: one function to generate collision maps from the source images, and then an update to the isColliding() routine.

First up, here's the generation code. This code takes a source image and draws it to a temporary Canvas object. From there I can grab the pixels (in Red, Green, Blue, Alpha format) that make up the sprite. This happens on line 8, where the pixel data is grabbed into a linear array of bytes. The first four bytes are the RGBA values of the first pixel, then the next four bytes for the next pixel, etc.. I step through this array, one pixel at a time, and check the alpha value. If the pixel is transparent then I consider that to be a false value in the collision map, otherwise it's a true value. It follows that the collision map is defined by any part of the source image that is completely transparent. Finally, I return an object which contains the width, height and collision map data.

function makeCollisionMap(image) {
let tempCanvas = document.createElement('canvas');
tempCanvas.width = image.width;
tempCanvas.height = image.height;
let tempContext = tempCanvas.getContext('2d');
tempContext.drawImage(image, 0, 0);
const imageData = tempContext.getImageData(0, 0, tempCanvas.width, tempCanvas.height).data;
let data = new Uint8Array(tempCanvas.width * tempCanvas.height);
for (let i = 0; i < imageData.length; i += 4) {
const alpha = imageData[i + 3];
data[i>>2] = alpha == 0 ? 0 : 255;
}
return {
width: tempCanvas.width,
height: tempCanvas.height,
data: data
};
}
Secondly, here are the changes to the isColliding0 function:

function isColliding(objectA, objectB) {
// Quick and simple box collision
if (objectA.xPos + objectA.collisionMap.width >= objectB.xPos &&
objectA.xPos < objectB.xPos + objectB.collisionMap.width &&
objectA.yPos + objectA.collisionMap.height >= objectB.yPos &&
objectA.yPos < objectB.yPos + objectB.collisionMap.height) {
// If that indicates a collision then use the collision maps
for (let y = 0; y < objectA.collisionMap.height; y++) {
const yOther = Math.floor(objectA.yPos) - Math.floor(objectB.yPos) + y;
for (let x = 0; x < objectA.collisionMap.width; x++) {
const bitThis = objectA.collisionMap.data[x + y * objectA.collisionMap.width];
const xOther = Math.floor(objectA.xPos) - Math.floor(objectB.xPos) + x;
if (xOther >= 0 && xOther < objectB.collisionMap.width &&
yOther >= 0 && yOther < objectB.collisionMap.height) {
var bitOther = objectB.collisionMap.data[xOther + yOther * objectB.collisionMap.width];
if (bitThis & bitOther) {
// Collision detected
return true;
}
}
}
}
}
return false;
}

You'll see a couple of loops in there where I walk across the collision maps of one of the objects. For each bit of the mask I then figure out the overlapping part of the second object's collision map. Finally, if both parts of both maps are true then it means that collision has happened.

Plumbing

There are a couple of other small bits of plumbing required to get this all working, the first one being the creation of the collision maps in init():

playerCollisionMap = makeCollisionMap(playerCollisionSprite);
bulletCollisionMap = makeCollisionMap(bulletCollisionSprite);
spawnerCollisionMap = makeCollisionMap(spawnerSprite);
..and then I need to store those maps in the objects, so I added a new argument to the base object constructor:

class Object {
constructor(xPos, yPos, image, collisionMap, type) {
this.xPos = xPos - image.width / 2;
this.yPos = yPos - image.height / 2;
this.image = image;
this.collisionMap = collisionMap;
this.type = type;
}
// Rest of object code..
}

Wrapping Up

I talked about the need for pixel perfect collision, why the collision maps are not what they seem, and then implemented some collision routines that are based on sprite images. This creates a flexible and accurate collision system that can be used for all object types.

The code is in BitBucket and the playable online version is here.

Next Time..

What's next? There are a couple of options.. but I think I'll go for adding font and text routines so that I can start displaying information on the screen. If you have any ideas about what you'd like to see next, please get in touch through the comments.

Comments

Popular posts from this blog

World In Motion

In the last post I touched on how easy it is to score in this game - you can just camp out on the score bullet spawner and watch the points rack up. I'm going to add a little delay to the score bullets so that they start out in a deactivated and uncollectable state, and then change to active and collectable after a short time - say, half a second. To make it clear what state they are in I'm going to add code to let me change the look of Objects and allow them to be animated.

Phony Game

Up until now I've been mostly focusing on the game technology and neglecting the game play . I'm going to address that a little bit by adding scoring to the game. I want to add some risk/reward to the gamplay - score higher by taking more risks. From now on I'm going to try to make sure that I remember to add gameplay features as well as tech features. So how do you score in a shoot-em-up where you can't actually shoot? There are a few possible ways that come to mind: Score bullets: Fired from spawners like normal bullets, but instead of hurting you when you collide with them they give you score Bullet grazing: Score points by flying very close to bullets  but not close enough to be killed Score zones: Marked areas of the screen that award you points when you fly over them I like all three of these scoring methods and I'll probably implement them all over time. If you have any more good scoring ideas that you'd like to see in the game, please let me know...

Give Me Just A Little More Time

In this post I want to make the game time limited. By doing this, I make it more of a challenge to beat the high score. I'm going to set the time limit for a level to 30 seconds - I think that's a good amount. Also, I don't want to just dump the player straight into the action unprepared, so and I'm going to add a couple of bits of  "ceremony" before and after the gameplay to make it a nicer experience. There are a few main things that need to be added to make this happen: Add "sub" states to the Game state: intro, game and outro Intro state needs to show a countdown Game state will run the game as normal, but with a visible "timer bar" to show how long is left Outro state will show a summary of the game - score, high score, etc. Let's start with defining these states: