Talking about Serious Scramblers game, Game development, HTML5, Javascript, Phaser and TypeScript.
A lot of readers enjoyed my HTML5 Serious Scramblers prototype, but on oldest mobile devices there was an issue with collisions, since Arcade physics does not feature continuous collision detection.
So I started studying a bit about continuous collision detection, and apart from being a quite CPU expensive task, it can be simplified in some particular cases, like platform games, using Swept AABB collision detection.
Then I made two post about Swept AABB collision detection: understanding physics continuous collision detection using swept AABB method and Minkowski sum and understanding physics continuous collision detection using swept AABB method and Minkowski sum – Part 2: both bodies are moving.
Now it’s time to put these concepts into an actual game prototype, like the one you are about to see.
Before we start, remember this is not a physics engine and it’s not meant to replace your favourite physics engine.
It’s just another approach to simple collision detection, useful for you to learn something light and new.
Let’s see this first example:
Focus on the canvas, then move the yellow box with LEFT and RIGHT arrow keys, don’t fall down and don’t let platforms bring you to the top of the screen.
Everything is sooooooo slow, just to let you see collision detection works.
Now, let’s speed up a lot the whole stuff:
Try to play this one: apart from being almost unplayable, collision detection at this speed would be impossible without continuous collision detection, but I managed to do it with Swept AABB.
Finally, a playable prototype, the possible base for future improvements:
Did you see it? A continuous collision detection written in a few lines. Let’s see the source code:
index.html
The web page which hosts the game, just the bare bones of HTML and main.ts
is called. No changes have been made.
Also look at the thegame
div, this is where the game runs.
<!DOCTYPE html>
<html>
<head>
<style>
body {
margin: 0px;
}
</style>
<script src = "main.js"></script>
</head>
<body>
<div id = "thegame"></div>
</body>
</html>
main.ts
The main TypeScript file, the one called by index.html
.
Here we import most of the game libraries and define both Scale Manager object and Physics object.
Here we also initialize the game itself.
// MAIN GAME FILE
// modules to import
import Phaser from 'phaser';
import { PreloadAssets } from './preloadAssets';
import { PlayGame} from './playGame';
// object to initialize the Scale Manager
const scaleObject: Phaser.Types.Core.ScaleConfig = {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,
parent: 'thegame',
width: 750,
height: 1334
}
// game configuration object
const configObject: Phaser.Types.Core.GameConfig = {
type: Phaser.AUTO,
backgroundColor:0x444444,
scale: scaleObject,
scene: [PreloadAssets, PlayGame]
}
// the game itself
new Phaser.Game(configObject);
gameOptions.ts
Game options which can be changed to tune the gameplay are stored in a separate module, ready to be reused.
// CONFIGURABLE GAME OPTIONS
export const GameOptions = {
// first platform vertical position. 0 = top of the screen, 1 = bottom of the screen
firstPlatformPosition: 4 / 10,
// game gravity, which only affects the hero
gameGravity: 600,
// hero speed, in pixels per second
heroSpeed: 80,
// platform speed, in pixels per second
platformSpeed: 20,
// platform length range, in pixels
platformLengthRange: [150, 250],
// platform horizontal distance range from the center of the stage, in pixels
platformHorizontalDistanceRange: [0, 250],
// platform vertical distance range, in pixels
platformVerticalDistanceRange: [150, 300]
}
preloadAssets.ts
Class to preload all assets used in the game.
// CLASS TO PRELOAD ASSETS
// this class extends Scene class
export class PreloadAssets extends Phaser.Scene {
// constructor
constructor() {
super({
key: 'PreloadAssets'
});
}
// preloading assets, the good old way
preload(): void {
this.load.image('hero', 'assets/hero.png');
this.load.image('platform', 'assets/platform.png');
}
// method to be called once the instance has been created
create(): void {
// call PlayGame class
this.scene.start('PlayGame');
}
}
physicsBox.ts
Class which extends Sprite class and adds physics properties such as velocity.
// PhysicsBox class extends Phaser Sprite class
export default class PhysicsBox extends Phaser.GameObjects.Sprite {
// vector containing x and y velocity
velocity: Phaser.Math.Vector2;
// constructor - arguments: the scene, x and y position and texture key
constructor(scene: Phaser.Scene, x: number, y: number, key: string) {
super(scene, x, y, key);
scene.add.existing(this);
// physics object has no velocity at the beginning
this.velocity = new Phaser.Math.Vector2(0, 0);
}
// set body velocity - arguments: x and y velocity
setVelocity(x: number, y: number): void {
// update velocity property
this.velocity.x = x;
this.velocity.y = y;
}
}
playerSprite.ts
Player extends PhysicsBox which extends Sprite:
// PLAYER SPRITE CLASS
import PhysicsBox from "./physicsBox";
// player sprite extends PhysicsBox class
export default class PlayerSprite extends PhysicsBox {
// is the first time player is moving
firstMove: Boolean = true;
// constructor
constructor(scene: Phaser.Scene, x: number, y: number, key: string) {
super(scene, x, y, key);
// add the player to the scnee
scene.add.existing(this);
}
}
platformSprite.ts
Same thing for the platforms: they extend PhysicsBox class which extends Sprite:
// PLATFORM SPRITE CLASS
import PhysicsBox from "./physicsBox";
// platform sprite extends PhysicsBox class
export default class PlatformSprite extends PhysicsBox {
// constructor
constructor(scene: Phaser.Scene, x: number, y: number, key: string) {
super(scene, x, y, key);
// add the platform to the scnee
scene.add.existing(this);
}
}
playGame.ts
The game itself, the biggest class, game logic is stored here.
// THE GAME ITSELF
// modules to import
import { GameOptions } from './gameOptions';
import PlayerSprite from './playerSprite';
import PlatformSprite from './platformSprite';
import SweptAABB from './sweptAABB';
import SweptAABBCollisionInfo from './sweptAABBCollisionInfo';
// this class extends Scene class
export class PlayGame extends Phaser.Scene {
// group which will contain all platforms
platformGroup: Phaser.GameObjects.Group;
// the hero of the game
hero: PlayerSprite;
// here we store game width once for all
gameWidth: number;
// here we store game height once for all
gameHeight: number;
// instance of arrow keys to control the player
arrowKeys: Phaser.Types.Input.Keyboard.CursorKeys;
// our Swept AABB class
AABB: SweptAABB;
// flag to check if any left button has been pressed
leftPressed: boolean;
// flag to check if any right button has been pressed
rightPressed: boolean;
// constructor
constructor() {
super({
key: 'PlayGame'
});
}
// method to be called once the class has been created
create(): void {
// new arrow keys instance
this.arrowKeys = this.input.keyboard.createCursorKeys();
// new Swept AABB instance
this.AABB = new SweptAABB();
// save game width value
this.gameWidth = this.game.config.width as number;
// save game height value
this.gameHeight = this.game.config.height as number;
// create a new physics group
this.platformGroup = this.add.group();
// create starting platform
let platform: PlatformSprite = new PlatformSprite(this, this.gameWidth / 2, this.gameHeight * GameOptions.firstPlatformPosition, "platform");
// add platform to platform group
this.platformGroup.add(platform);
// add the hero
this.hero = new PlayerSprite(this, this.gameWidth / 2, 0, "hero");
// place the hero on top of the platform
this.AABB.placeOnTop(this.hero, platform)
// we are going to create 10 more platforms which we'll reuse to save resources
for(let i = 0; i < 10; i ++) {
// platform creation, as a member of platformGroup physics group
let platform = new PlatformSprite(this, 0, 0, "platform");
// add platform to platform group
this.platformGroup.add(platform);
// position the platform
this.positionPlatform(platform);
}
}
// method to position a platform
positionPlatform(platform: PlatformSprite):void {
// vertical position
platform.y = this.getLowestPlatform() + this.randomValue(GameOptions.platformVerticalDistanceRange);
// horizontal position
platform.x = this.gameWidth / 2 + this.randomValue(GameOptions.platformHorizontalDistanceRange) * Phaser.Math.RND.sign();
// platform width
platform.displayWidth = this.randomValue(GameOptions.platformLengthRange);
}
// method to get the lowest platform, returns the position of the lowest platform, in pixels
getLowestPlatform(): number {
// lowest platform value is initially set to zero
let lowestPlatform = 0;
// get all platforms
let platforms: PlatformSprite[] = this.platformGroup.getChildren() as PlatformSprite[];
// loop through all platforms
for (let platform of platforms) {
// get the highest value between lowestPlatform and platform y coordinate
lowestPlatform = Math.max(lowestPlatform, platform.y);
};
// return lowest platform coordinate
return lowestPlatform;
}
// method to toss a random value between two elements in an array
randomValue(a: number[]): number {
// return a random integer between the first and the second item of the array
return Phaser.Math.Between(a[0], a[1]);
}
// method to be executed at each frame
update(t: number, dt: number): void {
// left and right arrow keys aren't been pressed, yet
this.leftPressed = false;
this.rightPressed = false;
// is left arrow key down?
if (this.arrowKeys.left.isDown) {
// left button is being pressed
this.leftPressed = true;
}
// is right arrow key down?
if (this.arrowKeys.right.isDown) {
// right button has been pressed
this.rightPressed = true;
}
// is hero first move and was there an input?
if (this.hero.firstMove && (this.leftPressed || this.rightPressed)) {
// it's no longer the first move
this.hero.firstMove = false;
// get all platforms
let platforms: PlatformSprite[] = this.platformGroup.getChildren() as PlatformSprite[];
// loop through all platforms
for (let platform of platforms) {
// start to move the platforms
platform.setVelocity(0, GameOptions.platformSpeed * -1);
}
// start to apply the gravity to hero
this.hero.setVelocity(0, GameOptions.gameGravity);
}
// isn't hero first move?
if (!this.hero.firstMove) {
// set hero horizontal velocity according to arrow keys pressed
this.hero.velocity.x = this.leftPressed ? -GameOptions.heroSpeed : (this.rightPressed ? GameOptions.heroSpeed : 0);
// check colliding platform, if any, once the hero moved
let collidingPlatform: (SweptAABBCollisionInfo | null) = this.AABB.moveActor(this.hero, this.hero.velocity.x, this.hero.velocity.y, this.platformGroup, dt);
// get all platforms
let platforms: PlatformSprite[] = this.platformGroup.getChildren() as PlatformSprite[];
// loop through all platforms
for (let platform of platforms) {
// move the platform
this.AABB.moveObject(platform, platform.velocity.x, platform.velocity.y, dt);
// if current platform is the colliding platform...
if (collidingPlatform && platform == collidingPlatform.collisionActor) {
// check which side of the platform collide
switch(collidingPlatform.collisionSide) {
// top side
case this.AABB.TOP_SIDE:
// place hero on top of the platform
this.AABB.placeOnTop(this.hero, platform);
break;
// left side
case this.AABB.LEFT_SIDE:
// if the hero is moving right...
if (this.hero.velocity.x > 0) {
// ... place the hero on the left side of the platform
this.AABB.placeOnLeft(this.hero, platform);
}
break;
// right side
case this.AABB.RIGHT_SIDE:
// if the hero is moving left...
if (this.hero.velocity.x < 0) {
// ... place the hero on the right side of the platform
this.AABB.placeOnRight(this.hero, platform);
}
break;
}
}
// if a platform leaves the stage to the upper side...
if (platform.getBounds().bottom < 0) {
// ... recycle the platform
this.positionPlatform(platform);
}
}
// if the hero falls down or leaves the stage from the top...
if(this.hero.y > this.gameHeight || this.hero.y < 0) {
// restart the scene
this.scene.start("PlayGame");
}
}
}
}
sweptAABB.ts
Swept AABB logic is stored here. The concepts explained in understanding physics continuous collision detection using swept AABB method and Minkowski sum and understanding physics continuous collision detection using swept AABB method and Minkowski sum – Part 2: both bodies are moving are applied here.
// SWEPT AABB CLASS
// modules to import
import PhysicsBox from "./physicsBox";
import SweptAABBCollisionInfo from "./sweptAABBCollisionInfo";
// class to handle Swept AABB physics
export default class SweptAABB {
// a series of constants to
TOP_SIDE: number = 0;
RIGHT_SIDE: number = 1;
BOTTOM_SIDE: number = 2;
LEFT_SIDE: number = 3;
// method to place a physics object on top of another physics object
// arguments: the two physics objects
placeOnTop(s1: Phaser.GameObjects.Sprite, s2: Phaser.GameObjects.Sprite): void {
s1.y = s2.getBounds().top - s1.displayHeight * (1 - s1.originY);
}
// method to place a physics object on the left of another physics object
// arguments: the two physics objects
placeOnLeft(s1: Phaser.GameObjects.Sprite, s2: Phaser.GameObjects.Sprite): void {
s1.x = s2.getBounds().left - s1.displayWidth * (1 - s1.originX);
}
// method to place a physics object on the right of another physics object
// arguments: the two physics objects
placeOnRight(s1: Phaser.GameObjects.Sprite, s2: Phaser.GameObjects.Sprite): void {
s1.x = s2.getBounds().right + s1.displayWidth * (1 - s1.originX);
}
// method to move an object
// arguments: the sprite, the horizontal speed, the vertical speed and the amount of time, in milliseconds
moveObject(s: Phaser.GameObjects.Sprite, xSpeed: number, ySpeed: number, ms: number): void {
s.x += xSpeed * ms / 1000;
s.y += ySpeed * ms / 1000;
}
// method to move an actor. Actors, unlike objects, may collide with other objects
// argument: the main sprite, horizontal and vertical speed, collision group and the amount of time, in milliseconds
moveActor(s: PhysicsBox, xSpeed: number, ySpeed: number, collisionGroup: Phaser.GameObjects.Group, ms: number): SweptAABBCollisionInfo | null {
// get all possible collisions
let possibleCollisions: PhysicsBox[] = collisionGroup.getChildren() as PhysicsBox[];
// minimum collision time is 1 (no collision)
let minimumCollisionTime: number = 1;
// current colliding body is null at the moment, since there isn't any collision yet
let collidingBody: SweptAABBCollisionInfo | null = null;
// loop through all possible collisions
for (let possibleCollision of possibleCollisions) {
// get collision information
let collisionInfo: SweptAABBCollisionInfo | null = this.collisionInformation(s, possibleCollision, ms);
// if there is a collision and its collision time is less than current minumum collision time...
if (collisionInfo && collisionInfo.collisionTime < minimumCollisionTime) {
// update minimum collision time
minimumCollisionTime = collisionInfo.collisionTime;
// update colliding body
collidingBody = collisionInfo;
}
}
// move the actor
this.moveObject(s, xSpeed, ySpeed, ms);
// return the colliding body, if any, or null
return (minimumCollisionTime < 1) ? collidingBody : null;
}
// method to check if two bodies collide within a certain time - arguments: the two bodies and a time
collisionInformation(sprite1: PhysicsBox, sprite2: PhysicsBox, time: number): SweptAABBCollisionInfo | null {
// determine relative speed subtracting the two speed vectors
let relativeSpeed: Phaser.Math.Vector2 = new Phaser.Math.Vector2(sprite1.velocity.x, sprite1.velocity.y).subtract(new Phaser.Math.Vector2(sprite2.velocity.x, sprite2.velocity.y));
// get movement line, from box origin to box relative destination
let movementLine: Phaser.Geom.Line = new Phaser.Geom.Line(sprite1.x, sprite1.y, sprite1.x + relativeSpeed.x * (time / 1000), sprite1.y + relativeSpeed.y * (time / 1000));
// Minkowski rectangle built inflating the sprite bodies
let minkowskiRectangle: Phaser.Geom.Rectangle = this.minkowskiSum(sprite1, sprite2);
// array to store all intersection points between movement line and Minkowski rectangle
let intersectionPoints: Phaser.Geom.Point[] = [];
// get all intersection points between movement line and Minkowski rectangle, then store them into intersectionPoints array
Phaser.Geom.Intersects.GetLineToRectangle(movementLine, minkowskiRectangle, intersectionPoints);
// here we will store collision line
let collisionLine: Phaser.Geom.Line;
// here we will store collision side
let collisionSide: number;
// here we will store collision time
let collisionTime: number;
// different cases according to intersection points vector length
switch (intersectionPoints.length) {
// no intersection points: return 1, that is objects can move for the entire interval
case 0:
return null;
// only one intersection point: this is the collision point
case 1:
// get the collision line
collisionLine = new Phaser.Geom.Line(sprite1.x, sprite1.y, intersectionPoints[0].x, intersectionPoints[0].y);
// get the collision side
collisionSide = this.getCollisionSide(minkowskiRectangle, intersectionPoints[0]);
// get the collision time
collisionTime = Phaser.Geom.Line.Length(collisionLine) / Phaser.Geom.Line.Length(movementLine);
// return the ratio between collision line and movement line
return new SweptAABBCollisionInfo(sprite2, collisionTime, intersectionPoints[0], collisionSide);
// more than one intersection point: collision point is the closest to moving body
default:
// set the minimum distance to Infinity, the highest number
let minDistance: number = Infinity;
// set minimum index to zero (first element)
let minIndex: number = 0;
// looping through all instersection points
for (let i: number = 0; i < intersectionPoints.length; i ++) {
// get distance between body and points
let distance: number = Phaser.Math.Distance.Between(sprite1.x, sprite1.y, intersectionPoints[i].x, intersectionPoints[i].y);
// is distance less then minimum distance?
if (distance < minDistance) {
// update minimum index
minIndex = i;
// update minimum distance
minDistance = distance;
}
}
// get the collision line
collisionLine = new Phaser.Geom.Line(sprite1.x, sprite1.y, intersectionPoints[minIndex].x, intersectionPoints[minIndex].y);
// get the collision side
collisionSide = this.getCollisionSide(minkowskiRectangle, intersectionPoints[minIndex]);
// get the collision time
collisionTime = Phaser.Geom.Line.Length(collisionLine) / Phaser.Geom.Line.Length(movementLine);
// return the ratio between collision line and movement line
return new SweptAABBCollisionInfo(sprite2, collisionTime, intersectionPoints[minIndex], collisionSide);
}
}
// method to get the collision side
// arguments: the minkowsky rectangle and a point
getCollisionSide(minkowsky: Phaser.Geom.Rectangle, point: Phaser.Geom.Point): number {
// is the point along top side?
if (minkowsky.top == point.y) {
return this.TOP_SIDE;
}
// is the point along bottom side?
if (minkowsky.bottom == point.y) {
return this.BOTTOM_SIDE;
}
// is the point along left side?
if (minkowsky.left == point.x) {
return this.LEFT_SIDE;
}
// is the point along right side?
if (minkowsky.right == point.x) {
return this.RIGHT_SIDE;
}
// no collision side, should be impossible to reach this line
return -1;
}
// method to perform the Minkowski sum between two Sprites - argument: the sprites
minkowskiSum(sprite1: Phaser.GameObjects.Sprite, sprite2: Phaser.GameObjects.Sprite): Phaser.Geom.Rectangle {
// get bounding boxes
let spriteBounds1: Phaser.Geom.Rectangle = sprite1.getBounds();
let spriteBounds2: Phaser.Geom.Rectangle = sprite2.getBounds();
// new rectangle leftmost point
let newLeft: number = spriteBounds2.left - spriteBounds1.width / 2;
// new rectangle upper point
let newTop: number = spriteBounds2.top - spriteBounds1.height / 2;
// new rectangle width
let newWidth: number = spriteBounds1.width + spriteBounds2.width;
// new rectangle height
let newHeight: number = spriteBounds1.height + spriteBounds2.height;
// return the inflated rectangle
return new Phaser.Geom.Rectangle(newLeft, newTop, newWidth, newHeight);
}
}
sweptAABBCollisionInfo.ts
Just a simple class to store collision information:
// modules to import
import PhysicsBox from "./physicsBox";
// class to handle Swept AABB collision information
export default class SweptAABBCollisionInfo {
// colliding actor
collisionActor: PhysicsBox;
// collision time, from 0 (beginning of the frame, already colliding) to 1 (no collision)
collisionTime: number;
// collision point
collisionPoint: Phaser.Geom.Point;
// collision side
collisionSide: number;
// constructor - arguments: the actor, the time, the point and the side
constructor(actor: PhysicsBox, time: number, point: Phaser.Geom.Point, side: number) {
this.collisionActor = actor;
this.collisionTime = time;
this.collisionPoint = point;
this.collisionSide = side;
}
}
And that’s it. It may not be the easier way to build a simple platformer, but learning the basics of AABB collision detection will help you in future development in case you won’t be able to rely on physics engines. Download the source code of the entire project.
Never miss an update! Subscribe, and I will bother you by email only when a new game or full source code comes out.