Talking about Block it game, Game development, HTML5, Javascript, Phaser and TypeScript.
I was sure was able to handle Block It physics without using any physics engine, and now that’s it.
Not only I am not using Arcade Physics, but I also work with rotating walls which would require Box2D and bullets to work.
And I only used the script about continuous collision detection between a moving circle and one or more static line segments.
Actually, a simplified version of the script since I do not check for vertices collisions. Useless? Did I need to reinvent the wheel? Maybe yes, maybe not, but it’s always interesting to see how you can achieve simple results without using third party libraries, even if you end up by using one of them.
Look at the game:
Tap or click to start and to activate the upper and lower walls at the right time, to make the ball bounce. If the ball flies off the screen, it’s game over. Look how walls rotate.
The black lines you see once you start the game are the lines used for the collision detection.
The game is split into one html file, one css file and 10 TypeScript files, not every file has been commented yet because I need to complete and optimize the game, anyway here is the source code:
index.html
The web page which hosts the game, to be run inside thegame element.
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="initial-scale=1, maximum-scale=1">
<link rel="stylesheet" href="style.css">
</style>
<script src="main.js"></script>
</head>
<body>
<div id="thegame"></div>
</body>
</html>
style.css
The cascading style sheets of the main web page.
* {
padding : 0;
margin : 0;
}
canvas {
touch-action : none;
-ms-touch-action : none;
}
gameOptions.ts
Configurable game options. It’s a good practice to place all configurable game options, if possible, in a single and separate file, for a quick tuning of the game.
// CONFIGURABLE GAME OPTIONS
export const GameOptions = {
// duration of the wall, in milliseconds
wallDuration : 100,
// ball start speed, in pixels/second
ballStartSpeed : 500,
// ball speed increase at each successful bounce, in pixels/second
ballSpeedIncrease : 5,
// ball radius, in pixels
ballRadius : 25,
// wall thickness, in pixels
wallSize : 16,
// wall padding from game canvas, in pixels
wallPadding : 60,
// wall cooldown, in milliseconds
coolDown : 150,
// extra wall width, to make wall longer than they are
extraWallWidth : 200,
// wall rotation range, in degrees
wallRotationRange : [-20, 20]
}
main.ts
This is where the game is created, with all Phaser related options.
// 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 : 480,
height : 640
}
// game configuration object
const configObject : Phaser.Types.Core.GameConfig = {
type : Phaser.AUTO,
backgroundColor : 0xfe5430,
scale : scaleObject,
scene : [PreloadAssets, PlayGame]
}
// the game itself
new Phaser.Game(configObject);
preloadAssets.ts
Here we preload all assets to be used in the game, such as the sprites used for the ball and the walls.
// CLASS TO PRELOAD ASSETS
// this class extends Scene class
export class PreloadAssets extends Phaser.Scene {
// constructor
constructor() {
super({
key : 'PreloadAssets'
});
}
// method to be execute during class preloading
preload(): void {
// this is how we preload an image
this.load.image('ball', 'assets/ball.png');
this.load.image('wall', 'assets/wall.png');
}
// method to be called once the instance has been created
create(): void {
// call PlayGame class
this.scene.start('PlayGame');
}
}
playGame.ts
Main game file, all game logic is stored here.
// THE GAME ITSELF
import { GameOptions } from './gameOptions';
import WallSprite from './wallSprite';
import BallSprite from './ballSprite';
import { CollisionResult } from './collisionResult';
import { CollisionManagement } from './collisionManagement';
import { wallPosition } from './wallPosition';
// this class extends Scene class
export class PlayGame extends Phaser.Scene {
// is the game over?
gameOver : boolean;
// is the game started?
gameStarted : boolean;
// can we activate the wall by clicking/tapping on the canvas?
canActivateWalls : boolean;
// the ball itself
theBall : BallSprite;
// game score
score : number;
// text object to display the score
scoreText : Phaser.GameObjects.Text;
// instance to the class to handle collisions
collisionManagement : CollisionManagement;
// game walls
wallArray : WallSprite[];
// are all walls active? True: all walls are active, false: only left and right walls are active
allWallsAreActive : boolean;
// graphics objects to render the physics walls, as debug
simulationGraphics : Phaser.GameObjects.Graphics
// constructor
constructor() {
super({
key: 'PlayGame'
});
}
// method to be executed when the scene has been created
create() : void {
// create a new collision management
this.collisionManagement = new CollisionManagement();
// set start score to zero
this.score = 0;
// game is not started yet
this.gameStarted = false;
// game is not over
this.gameOver = false;
// just a couple of variables to store game width and height
let gameWidth : number = this.game.config.width as number;
let gameHeight : number = this.game.config.height as number;
// add score text
this.addScoreText(gameWidth / 2, gameHeight / 2);
// add the ball to the game
this.theBall = new BallSprite(this, gameWidth / 2, gameHeight / 2, GameOptions.ballRadius, GameOptions.ballStartSpeed);
// determine width of horizontal walls
let wallWidth : number = gameWidth - GameOptions.wallPadding * 2 - GameOptions.wallSize * 2;
// determine height of vertical walls
let wallHeight : number = gameHeight - GameOptions.wallPadding * 2;
// total spacing from canvas border to wall
let totalSpacing : number = GameOptions.wallPadding + GameOptions.wallSize;
// these are the four walls
this.wallArray = [];
this.wallArray[wallPosition.Up] = new WallSprite(this, totalSpacing - GameOptions.extraWallWidth, GameOptions.wallPadding, wallWidth + 2 * GameOptions.extraWallWidth, GameOptions.wallSize, wallPosition.Up);
this.wallArray[wallPosition.Right] = new WallSprite(this, gameWidth - totalSpacing, GameOptions.wallPadding - GameOptions.extraWallWidth, GameOptions.wallSize, wallHeight + 2 * GameOptions.extraWallWidth, wallPosition.Right);
this.wallArray[wallPosition.Down] = new WallSprite(this, totalSpacing - GameOptions.extraWallWidth, gameHeight - totalSpacing, wallWidth + 2 * GameOptions.extraWallWidth, GameOptions.wallSize, wallPosition.Down);
this.wallArray[wallPosition.Left] = new WallSprite(this, GameOptions.wallPadding, GameOptions.wallPadding - GameOptions.extraWallWidth, GameOptions.wallSize, wallHeight + 2 * GameOptions.extraWallWidth, wallPosition.Left);
// add simulation graphics
this.simulationGraphics = this.add.graphics();
// add event listener waiting for pointer down
this.input.on('pointerdown', this.activateWall, this);
}
// method to add the score text
addScoreText(posX : number, posY : number) : void {
this.scoreText = this.add.text(posX, posY, '0', {
fontFamily : 'Arial',
fontStyle : 'bold',
fontSize : '192px',
color : '#ffffff'
});
this.scoreText.setOrigin(0.5);
this.scoreText.setAlpha(0.3);
}
// method to activate the walls
activateWall() : void {
// has the game started?
if (!this.gameStarted) {
// the game hasn't started yet, so se the ball to a random speed
this.theBall.setRandomSpeed();
// turn off the upper and lower walls
this.wallArray[wallPosition.Up].turnOff();
this.wallArray[wallPosition.Down].turnOff();
// all walls aren't active
this.allWallsAreActive = false;
// now the game has started
this.gameStarted = true;
// now we can activate the walls
this.canActivateWalls = true;
return;
}
// can we activate the walls?
if (this.canActivateWalls) {
// we can't activate walls anymore
this.canActivateWalls = false;
// turn on upper and lower walls
this.wallArray[wallPosition.Up].turnOn();
this.wallArray[wallPosition.Down].turnOn();
// all walls are active
this.allWallsAreActive = true;
// add a time event
this.time.addEvent({
// delay of the event: wall duration
delay: GameOptions.wallDuration,
// scope of the event callback function
callbackScope: this,
// callback function
callback : () => {
// turn off the walls
this.wallArray[wallPosition.Up].turnOff();
this.wallArray[wallPosition.Down].turnOff();
// all walls aren't active
this.allWallsAreActive = false;
// add another time event
this.time.addEvent({
// delay of the event: wall cooldown
delay : GameOptions.coolDown,
// scope of the event callback function
callbackScope : this,
// callback function
callback : () => {
// we can activate walls again
this.canActivateWalls = true;
}
})
}
});
}
}
// method to be called at each frame
update(time : number, deltaTime : number) : void {
// has the game started?
if (this.gameStarted) {
// these are the lines to b checked against collision
let solidWalls : Phaser.Geom.Line[] = [
this.wallArray[wallPosition.Up].getCollisionLine(),
this.wallArray[wallPosition.Right].getCollisionLine(),
this.wallArray[wallPosition.Down].getCollisionLine(),
this.wallArray[wallPosition.Left].getCollisionLine()
]
// just some debug graphics showing the collision lines
this.simulationGraphics.clear();
this.simulationGraphics.lineStyle(1, 0x000000);
this.simulationGraphics.strokeLineShape(solidWalls[wallPosition.Up]);
this.simulationGraphics.strokeLineShape(solidWalls[wallPosition.Right]);
this.simulationGraphics.strokeLineShape(solidWalls[wallPosition.Left]);
this.simulationGraphics.strokeLineShape(solidWalls[wallPosition.Down]);
// collision result between moving circle and static lines
let collisionResult : CollisionResult[] = this.collisionManagement.checkCollision(this.theBall.circleShape, this.theBall.ballMovementeVector(deltaTime), (this.allWallsAreActive) ? solidWalls : [solidWalls[wallPosition.Right], solidWalls[wallPosition.Left]]);
// final ball destination
let destinationPoint : Phaser.Geom.Point = collisionResult[collisionResult.length - 1].point;
// update ball position
this.theBall.updatePosition(destinationPoint);
// collision result has a length greater than 1, that is, did the ball collide against at least a wall?
if (collisionResult.length > 1) {
// increase the score
this.score += collisionResult.length - 1;
// show updated score
this.scoreText.setText(this.score.toString());
// we get the last normalized velocty, this will be new ball's direction
let lastNormalizedVelocity : Phaser.Math.Vector2 = collisionResult[collisionResult.length - 2].velocity.normalize();
// update ball velocity
this.theBall.updateVelocity(lastNormalizedVelocity, GameOptions.ballSpeedIncrease);
// loop through all collisions
collisionResult.forEach((collision : CollisionResult) => {
// if the collision index is different than -1 (no collision)
if (collision.index != -1) {
// set the opposite wall to a random angle
this.wallArray[(collision.index + 2) % 4].randomAngle();
}
})
}
}
// if the ball flies off the canvas and it's not game over yet...
if ((this.theBall.y > (this.game.config.height as number) || this.theBall.y < 0) && !this.gameOver) {
// now it's game over
this.gameOver = true;
// shake the camera
this.cameras.main.shake(800, 0.05);
// add a time event
this.time.addEvent({
// delay, in milliseconds
delay: 800,
// scope of the event callback function
callbackScope: this,
// callback function
callback: () => {
// restart "PlayGame" scene
this.scene.start('PlayGame');
}
});
}
}
}
wallSprite.ts
Custom class for the walls, extending Phaser.GameObjects.Sprite.
import { wallPosition, deltaAngles } from './wallPosition';
import { GameOptions } from './gameOptions';
export default class WallSprite extends Phaser.GameObjects.Sprite {
scene : Phaser.Scene;
position : wallPosition;
constructor(scene : Phaser.Scene, posX : number, posY : number, width : number, height : number, position : wallPosition) {
super(scene, posX + width / 2, posY + height / 2, 'wall');
this.setDisplaySize(width, height);
this.randomAngle();
scene.add.existing(this);
this.scene = scene;
this.position = position;
}
turnOff() : void {
this.setAlpha(0.1);
}
turnOn() : void {
this.setAlpha(1);
}
randomAngle() : void {
let newAngle : number = Phaser.Math.Between(GameOptions.wallRotationRange[0] * 10, GameOptions.wallRotationRange[1] * 10) / 10;
this.scene.tweens.add({
targets : this,
angle : newAngle,
delay: 100
})
}
getCollisionLine() : Phaser.Geom.Line {
let halfWallSize : number = GameOptions.wallSize / 2;
let lineWidth : number = (this.position == wallPosition.Up || this.position == wallPosition.Down) ? this.displayWidth : this.displayHeight;
let startAngle : number = this.rotation + deltaAngles[this.position];
let directionAngle : number = this.rotation + ((this.position == wallPosition.Up || this.position == wallPosition.Down) ? 0 : Math.PI / 2);
return new Phaser.Geom.Line(
this.x + halfWallSize * Math.cos(startAngle) - lineWidth * Math.cos(directionAngle),
this.y + halfWallSize * Math.sin(startAngle) - lineWidth * Math.sin(directionAngle),
this.x + halfWallSize * Math.cos(startAngle) + lineWidth * Math.cos(directionAngle),
this.y + halfWallSize * Math.sin(startAngle) + lineWidth * Math.sin(directionAngle)
)
}
}
wallPosition.ts
Just a custom enum to have a better readability when handling with wall position, and some delta angles to rotate walls according to their position.
export enum wallPosition {
Up,
Right,
Down,
Left
}
export const deltaAngles : number[] = [Math.PI / 2, - Math.PI, - Math.PI / 2, 0];
ballSprite.ts
Custom class for the ball, extending Phaser.GameObjects.Sprite.
export default class ballSprite extends Phaser.GameObjects.Sprite {
velocity : Phaser.Math.Vector2;
circleShape : Phaser.Geom.Circle;
speed : number;
constructor(scene : Phaser.Scene, posX : number, posY : number, radius : number, speed : number) {
super(scene, posX, posY, 'ball');
scene.add.existing(this);
let ballSize : number = radius * 2
this.setDisplaySize(ballSize, ballSize);
this.circleShape = new Phaser.Geom.Circle(posX, posY, radius);
this.speed = speed;
}
setRandomSpeed() : void {
let randomAngle : number = Phaser.Math.Angle.Random();
this.velocity = new Phaser.Math.Vector2(this.speed * Math.cos(randomAngle), this.speed * Math.sin(randomAngle));
}
ballMovementeVector(time : number) : Phaser.Math.Vector2 {
let milliseconds : number = time / 1000;
return new Phaser.Math.Vector2(this.velocity.x * milliseconds, this.velocity.y * milliseconds);
}
updatePosition(point : Phaser.Geom.Point) : void {
this.setPosition(point.x, point.y);
this.circleShape.setPosition(point.x, point.y);
}
updateVelocity(normal : Phaser.Math.Vector2, speed : number) : void {
this.speed += speed;
this.velocity.setTo(this.speed * normal.x, this.speed * normal.y);
}
}
collisionManagement.ts
Class to handle the collision, mostly the same seen and explained in this post.
import { intersectionType, Intersection } from './intersection';
import { CollisionResult } from './collisionResult';
export class CollisionManagement {
getIntersectionPoint(line1 : Phaser.Geom.Line, line2 : Phaser.Geom.Line) : Intersection {
if ((line1.x1 == line1.x2 && line1.y1 == line1.y2) || (line2.x1 == line2.x2 && line2.y1 == line2.y2)) {
return new Intersection(intersectionType.None);
}
let denominator : number = ((line2.y2 - line2.y1) * (line1.x2 - line1.x1) - (line2.x2 - line2.x1) * (line1.y2 - line1.y1));
if (denominator == 0) {
return new Intersection(intersectionType.None);
}
let ua : number = ((line2.x2 - line2.x1) * (line1.y1 - line2.y1) - (line2.y2 - line2.y1) * (line1.x1 - line2.x1)) / denominator;
let ub : number = ((line1.x2 - line1.x1) * (line1.y1 - line2.y1) - (line1.y2 - line1.y1) * (line1.x1 - line2.x1)) / denominator;
let outsideSegments : boolean = (ua < 0 || ua > 1 || ub < 0 || ub > 1)
let x : number = line1.x1 + ua * (line1.x2 - line1.x1);
let y : number = line1.y1 + ua * (line1.y2 - line1.y1);
return new Intersection(outsideSegments ? intersectionType.Simple : intersectionType.Strict, new Phaser.Geom.Point(x, y));
}
checkCollision(movingCircle : Phaser.Geom.Circle, circleVelocity : Phaser.Math.Vector2, staticLines : Phaser.Geom.Line[]) : CollisionResult[] {
let velocityLine : Phaser.Geom.Line = new Phaser.Geom.Line(movingCircle.x, movingCircle.y, movingCircle.x + circleVelocity.x, movingCircle.y + circleVelocity.y);
let gotCollision : boolean = false;
let closestCircleDistance : number = Infinity;
let collisionResult : CollisionResult = new CollisionResult(new Phaser.Geom.Point(movingCircle.x + circleVelocity.x, movingCircle.y + circleVelocity.y), new Phaser.Math.Vector2(0, 0), -1);
staticLines.forEach((staticLine : Phaser.Geom.Line, index : number) => {
let extendedLine : Phaser.Geom.Line = Phaser.Geom.Line.Clone(staticLine);
Phaser.Geom.Line.Extend(extendedLine, movingCircle.radius);
let velocityToSegmentIntersection : Intersection = this.getIntersectionPoint(velocityLine, extendedLine);
let destinationCircle : Phaser.Geom.Circle = new Phaser.Geom.Circle(velocityLine.x2, velocityLine.y2, movingCircle.radius);
let destinationCircleIntersectsBarrier : boolean = Phaser.Geom.Intersects.LineToCircle(staticLine, destinationCircle);
if (velocityToSegmentIntersection.type == intersectionType.Strict || destinationCircleIntersectsBarrier) {
let shortestDistancePoint : Phaser.Geom.Point = Phaser.Geom.Line.GetNearestPoint(staticLine, new Phaser.Geom.Point(movingCircle.x, movingCircle.y));
let shortestDistanceLine : Phaser.Geom.Line = new Phaser.Geom.Line(movingCircle.x, movingCircle.y, shortestDistancePoint.x, shortestDistancePoint.y);
let shortestDistanceLineLength : number = Phaser.Geom.Line.Length(shortestDistanceLine);
let movementLine : Phaser.Geom.Line = new Phaser.Geom.Line(movingCircle.x, movingCircle.y, velocityToSegmentIntersection.point.x, velocityToSegmentIntersection.point.y);
let ratioonmovement : number = movingCircle.radius / shortestDistanceLineLength;
let newCenter: Phaser.Geom.Point = Phaser.Geom.Line.GetPoint(movementLine, 1 - ratioonmovement);
let closestPoint : Phaser.Geom.Point = Phaser.Geom.Line.GetNearestPoint(staticLine, new Phaser.Geom.Point(newCenter.x, newCenter.y))
let distanceFromNewCenterToCircle : number = Phaser.Math.Distance.Between(movingCircle.x, movingCircle.y, newCenter.x, newCenter.y);
if (closestPoint.x >= staticLine.left && closestPoint.x <= staticLine.right && closestPoint.y >= staticLine.top && closestPoint.y <= staticLine.bottom && distanceFromNewCenterToCircle < closestCircleDistance) {
gotCollision = true;
closestCircleDistance = distanceFromNewCenterToCircle;
let reflectionAngle : number = Phaser.Geom.Line.ReflectAngle(velocityLine, staticLine);
let remainingVelocity : number = Phaser.Math.Distance.Between(newCenter.x, newCenter.y, velocityLine.x2, velocityLine.y2);
let finalIndex = staticLines.length == 4 ? index : 1 + 2 * index;
collisionResult = new CollisionResult(new Phaser.Geom.Point(newCenter.x, newCenter.y), new Phaser.Math.Vector2(remainingVelocity * Math.cos(reflectionAngle), remainingVelocity * Math.sin(reflectionAngle)), finalIndex);
}
}
})
if (!gotCollision) {
return [collisionResult];
}
else {
return [collisionResult].concat(this.checkCollision(new Phaser.Geom.Circle(collisionResult.point.x, collisionResult.point.y, movingCircle.radius), new Phaser.Math.Vector2(collisionResult.velocity.x, collisionResult.velocity.y), staticLines));
}
}
}
collisionResult.ts
Just a custom class to handle a collision, which is made by a point (the new center of the circle), a vector (the new circle velocity), and a reference to the line which collided.
export class CollisionResult {
point : Phaser.Geom.Point;
velocity : Phaser.Math.Vector2;
index : number;
constructor(point : Phaser.Geom.Point, velocity : Phaser.Math.Vector2, index : number) {
this.point = point;
this.velocity = velocity;
this.index = index;
}
}
intersection.ts
Another custom class to define the intersection type of two line segments, which can be None if line segments do not intersect, Simple if they would intersect if they were infinite or Strict if they intersect.
export enum intersectionType {
None,
Simple,
Strict
}
export class Intersection {
type : intersectionType;
point : Phaser.Geom.Point;
constructor(type : intersectionType, point? : Phaser.Geom.Point) {
this.type = type;
if (point !== undefined) {
this.point = point;
}
}
}
And that’s it. Interested in seeing a Box2D version? I’ll build it next week. Stay tuned, and meanwhile download the source code.
Never miss an update! Subscribe, and I will bother you by email only when a new game or full source code comes out.