Talking about Space is Key game, Game development, HTML5, Javascript, Phaser and TypeScript.
Okay, the title is a bit misleading, what is the point of creating a game using Arcade Physics by removing Arcade Physics?
In this case, because we use so little Arcade physics functionality that it is more interesting to remove it and manage the square movement on our own.
All posts in this tutorial series:
Step 1: First TypeScript prototype using Arcade physics and tweens.
Step 2: Creation of some kind of proprietary engine to manage any kind of level
Step 3: Introducing pixel perfect collisions and text messages.
Step 4: Removing Arcade physics and tweens, only using delta time between frames.
Step 5: Using Tiled to draw levels.
When we calculated pixel perfect collisions we already created our own routine instead of using Arcade physics, this is an opportunity to do everything on our own just based on the delta time between frames.
At this point, the tween used for the square animation was also removed, always using the delta time between frames.
This is the result, and you should see no difference between this prototype and the prototype using Arcade physics.
Jump by clicking or tapping on the canvas (no space key at the moment, ironically), do not hit obstacles. Look at the texts and enjoy pixel perfect collisions without any physics engine.
The code has been a bit rearranged and split into more classes, one extending Sprite class to handle physics and another to keep all collision related methods, so now we have one HTML file, one CSS file and seven TypeScript files.
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">
<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;
}
body {
background-color: #000000;
}
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. There aren’t that much game options here, because most of them are stored in level configuration file: levels.ts.
// CONFIGURABLE GAME OPTIONS
// changing these values will affect gameplay
export const GameOptions : any = {
level : {
width : 800,
height : 600
}
}
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 : 800,
height : 600
}
// game configuration object
const configObject : Phaser.Types.Core.GameConfig = {
type : Phaser.AUTO,
backgroundColor : 0x000000,
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. Actually, just the bitmap font and the tile I am resizing and tinting when needed.
// CLASS TO PRELOAD ASSETS
// this class extends Scene class
export class PreloadAssets extends Phaser.Scene {
// constructor
constructor() {
super({
key : 'PreloadAssets'
});
}
// method to be called during class preloading
preload() : void {
// this is how to load an image
this.load.image('tile', 'assets/sprites/tile.png');
// this is how to load a bitmap font
this.load.bitmapFont('font', 'assets/fonts/font.png', 'assets/fonts/font.fnt');
}
// 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 { Levels } from './levels';
import { PhysicsSquare } from './physicsSquare';
import { CollisionUtils } from './collisionUtils'
// this class extends Scene class
export class PlayGame extends Phaser.Scene {
constructor() {
super({
key : 'PlayGame'
});
}
level : number;
floorLevel : number;
theSquare : PhysicsSquare
backgroundGroup : Phaser.GameObjects.Group;
obstacleGroup : Phaser.GameObjects.Group;
emitter : Phaser.GameObjects.Particles.ParticleEmitter;
levelTexts : Phaser.GameObjects.BitmapText[];
// method to be called once the instance has been created
create() : void {
this.backgroundGroup = this.add.group();
this.obstacleGroup = this.add.group();
this.level = 0;
this.floorLevel = 0;
this.drawLevel();
this.emitter = this.add.particles(0, 0, 'tile', {
gravityY : 20,
speed : {
min : 20,
max : 50
},
scale : {
min : 0.05,
max : 0.1
},
lifespan : 800,
alpha : {
start : 1,
end: 0
},
emitting : false
});
this.emitter.setDepth(2);
this.theSquare = new PhysicsSquare(this);
this.theSquare.setDepth(1);
this.placeSquare();
this.input.on('pointerdown', this.squareJump, this);
}
update(totalTime : number, deltaTime : number) : void {
this.theSquare.move(deltaTime / 1000);
// get square vertices
let squareVertices : Phaser.Geom.Point[] = CollisionUtils.getRotatedRectangleVertices(this.theSquare.x, this.theSquare.y, this.theSquare.displayWidth, this.theSquare.displayHeight, this.theSquare.angle)
// check collision between the square and the obstacles
this.obstacleGroup.getChildren().forEach((obstacle : any) => {
let obstacleVertices : Phaser.Geom.Point[] = CollisionUtils.getRotatedRectangleVertices(obstacle.x, obstacle.y, obstacle.displayWidth, obstacle.displayHeight, obstacle.angle)
// if a collision is detected, then set the square to be destroyed next frame
if (CollisionUtils.doPolygonsIntersect(squareVertices, obstacleVertices)) {
this.destroySquare();
}
// check if the square left the screen from the left or from the right according to floor number
if ((this.theSquare.x > GameOptions.level.width && this.floorLevel % 2 == 0) || (this.theSquare.x < 0 && this.floorLevel % 2 == 1)) {
this.moveToNextFloor();
}
})
}
// method to destroy the square
destroySquare() : void {
this.emitter.x = this.theSquare.x;
this.emitter.y = this.theSquare.y;
this.emitter.explode(32);
this.emitter.forEachAlive((particle : Phaser.GameObjects.Particles.Particle) => {
particle.tint = this.theSquare.tintTopLeft;
}, this);
this.placeSquare();
}
// method to move the square onto next floor
moveToNextFloor() : void {
this.floorLevel ++;
if (this.floorLevel == Levels[this.level].floors.length) {
this.floorLevel = 0;
this.level ++;
if (this.level == Levels.length) {
this.level = 0;
}
this.drawLevel();
}
this.placeSquare();
}
// method to draw a level
drawLevel() : void {
this.levelTexts = [];
this.backgroundGroup.clear(true, true);
this.obstacleGroup.clear(true, true);
let floorHeight : number = GameOptions.level.height / Levels[this.level].floors.length;
for (let i : number = 0; i < Levels[this.level].floors.length; i ++) {
let background : Phaser.GameObjects.TileSprite = this.add.tileSprite(0, floorHeight * i, GameOptions.level.width, floorHeight, 'tile');
background.setTint(Levels[this.level].floors[i].colors.background);
background.setOrigin(0);
this.backgroundGroup.add(background);
if (Levels[this.level].floors[i].obstacles) {
for (let j : number = 0; j < Levels[this.level].floors[i].obstacles.length; j ++) {
let obstacleX : number = (i % 2 == 0) ? Levels[this.level].floors[i].obstacles[j].start : this.game.config.width as number - Levels[this.level].floors[i].obstacles[j].start;
let obstacleY : number = floorHeight * i + floorHeight - Levels[this.level].floors[i].obstacles[j].ground - Levels[this.level].floors[i].obstacles[j].height / 2;
let spike : Phaser.GameObjects.TileSprite = this.add.tileSprite(obstacleX, obstacleY, Levels[this.level].floors[i].obstacles[j].width, Levels[this.level].floors[i].obstacles[j].height, 'tile');
spike.setTint(Levels[this.level].floors[i].colors.foreground);
this.obstacleGroup.add(spike);
}
}
if (Levels[this.level].floors[i].text == undefined) {
Levels[this.level].floors[i].text = '';
}
let floorText : Phaser.GameObjects.BitmapText = this.add.bitmapText(0, 0, 'font', Levels[this.level].floors[i].text, 30);
floorText.setX((i % 2 == 0) ? 5 : this.game.config.width as number);
floorText.setY(floorHeight * i + 4);
floorText.setOrigin((i % 2 == 0) ? 0 : 1, 0);
floorText.setTint(Levels[this.level].floors[i].colors.foreground);
floorText.setVisible(false);
this.levelTexts.push(floorText);
}
}
// method to place the square on a floor
placeSquare() : void {
this.levelTexts[this.floorLevel].setVisible(true);
this.theSquare.placeOnFloor(Levels[this.level], this.floorLevel);
}
// method to make the square jump
squareJump() : void {
this.theSquare.jump();
}
}
physicsSquare.ts
This class extends Sprite class and adds some basic physics features to a sprite.
// PHYSICSQUARE CLASS EXTENDS PHASER.GAMEOBJECTS.SPRITE
import { GameOptions } from './gameOptions';
export class PhysicsSquare extends Phaser.GameObjects.Sprite {
velocity : Phaser.Math.Vector2;
speed : number;
jumpForce : number;
gravity : number;
floorY : number;
canJump : boolean;
jumpTween : Phaser.Tweens.Tween;
rotationDirection : number;
constructor(scene : Phaser.Scene) {
super(scene, 0, 0, 'tile');
scene.add.existing(this);
}
placeOnFloor(levelData : any, floorLevel : number) : void {
this.displayWidth = levelData.floors[floorLevel].square.size;
this.displayHeight = levelData.floors[floorLevel].square.size;
this.setTint(levelData.floors[floorLevel].colors.foreground);
this.setX((floorLevel % 2 == 0) ? 0 - levelData.floors[floorLevel].square.size : GameOptions.level.width + levelData.floors[floorLevel].square.size);
this.setY(GameOptions.level.height / levelData.floors.length * (floorLevel + 1) - levelData.floors[floorLevel].square.size / 2);
this.jumpForce = levelData.floors[floorLevel].square.jumpForce;
this.gravity = levelData.floors[floorLevel].square.gravity;;
this.velocity = new Phaser.Math.Vector2(levelData.floors[floorLevel].square.speed * ((floorLevel % 2 == 0) ? 1 : -1), 0);
this.floorY = this.y;
this.canJump = true;
this.angle = 0;
this.rotationDirection = floorLevel % 2 == 0 ? 1 : -1;
}
jump() : void {
if (this.canJump) {
this.canJump = false;
this.velocity.y = this.jumpForce;
}
}
move(seconds : number) : void {
this.x += this.velocity.x * seconds;
this.y -= this.velocity.y * seconds;
if (this.y < this.floorY) {
this.angle += (180 / (this.jumpForce / this.gravity * 2)) * seconds * this.rotationDirection;
this.velocity.y -= this.gravity * seconds;
}
this.y = Math.min(this.y, this.floorY);
if (this.y == this.floorY) {
this.canJump = true;
this.angle = 0;
}
}
}
collisionUtils.ts
A simple collection of utilities to manage collisions through geometry, like a function to get rectangle vertices or check if two polygons overlap.
export class CollisionUtils {
// method to check if two polygons intersect
// adapted from https://stackoverflow.com/questions/10962379/how-to-check-intersection-between-2-rotated-rectangles
static doPolygonsIntersect(a : Phaser.Geom.Point[], b : Phaser.Geom.Point[]) : boolean {
let polygons : Phaser.Geom.Point[][] = [a, b];
for (let i : number = 0; i < 2; i ++) {
let polygon : Phaser.Geom.Point[] = polygons[i];
for (let j : number = 0; j < polygon.length; j ++) {
// grab 2 vertices to create an edge
let secondVertex : number = (j + 1) % polygon.length;
var p1 = polygon[j];
var p2 = polygon[secondVertex];
// find the line perpendicular to this edge
let normal : Phaser.Geom.Point = new Phaser.Geom.Point(p2.y - p1.y, p1.x - p2.x);
// for each vertex in the first shape, project it onto the line perpendicular to the edge
// and keep track of the min and max of these values
let minA : number | undefined = undefined;
let maxA : number | undefined = undefined;
for (let k : number = 0; k < a.length; k ++) {
let projected : number = normal.x * a[k].x + normal.y * a[k].y;
if (minA == undefined || projected < minA) {
minA = projected;
}
if (maxA == undefined || projected > maxA) {
maxA = projected;
}
}
// for each vertex in the second shape, project it onto the line perpendicular to the edge
// and keep track of the min and max of these values
let minB : number | undefined = undefined;
let maxB : number | undefined = undefined;
for (let k : number = 0; k < b.length; k ++) {
let projected : number = normal.x * b[k].x + normal.y * b[k].y;
if (minB == undefined || projected < minB) {
minB = projected;
}
if (maxB == undefined || projected > maxB) {
maxB = projected;
}
}
// if there is no overlap between the projects, the edge we are looking at separates the two
// polygons, and we know there is no overlap
if ((maxA as number) < (minB as number) || (maxB as number) < (minA as number)) {
return false;
}
}
}
return true;
}
// method to ge the vertices of any rectangle
static getRotatedRectangleVertices(centerX : number, centerY : number, width : number, height : number, angle : number) : Phaser.Geom.Point[] {
let halfWidth : number = width / 2;
let halfHeight : number = height / 2;
let halfWidthSin : number = halfWidth * Math.sin(angle);
let halfWidthCos : number = halfWidth * Math.cos(angle);
let halfHeightSin : number = halfHeight * Math.sin(angle);
let halfHeightCos : number = halfHeight * Math.cos(angle);
return [
new Phaser.Geom.Point(centerX + halfWidthCos - halfHeightSin, centerY + halfWidthSin + halfHeightCos),
new Phaser.Geom.Point(centerX - halfWidthCos - halfHeightSin, centerY - halfWidthSin + halfHeightCos),
new Phaser.Geom.Point(centerX - halfWidthCos + halfHeightSin, centerY - halfWidthSin - halfHeightCos),
new Phaser.Geom.Point(centerX + halfWidthCos + halfHeightSin, centerY + halfWidthSin - halfHeightCos)
]
}
}
levels.ts
I am storing levels information in a separate file.
export const Levels : any = [
// level 1
{
floors : [
// floor 0
{
text : 'Look at this text',
square : {
size : 24,
speed : 170,
gravity : 200,
jumpForce : 200
},
colors : {
foreground : 0x6598fd,
background : 0x003232
},
obstacles : [
{
ground : 0,
start : 400,
width : 30,
height : 60
},
{
ground : 0,
start : 300,
width : 30,
height : 40
},
{
ground : 0,
start : 500,
width : 30,
height : 40
}
]
},
// floor 1
{
text : 'You can edit it and even\nuse more lines',
square : {
size : 30,
speed : 310,
gravity : 450,
jumpForce : 210
},
colors : {
foreground : 0x003232,
background : 0x6598fd
},
obstacles : [
{
ground : 0,
start : 560,
width : 40,
height : 40
},
{
ground : 0,
start : 240,
width : 40,
height : 40
}
]
},
// floor 2
{
text : 'or just leave floors\nwithout any text, like next ones',
square : {
size : 24,
speed : 170,
gravity : 650,
jumpForce : 410
},
colors : {
foreground : 0x6598fd,
background : 0x003232
},
obstacles : [
{
ground : 0,
start : 400,
width : 40,
height : 60
},
{
ground : 100,
start : 350,
width : 10,
height : 10
},
{
ground : 100,
start : 450,
width : 10,
height : 10
}
]
}
]
},
// level 2
{
floors : [
// floor 0
{
square : {
size : 40,
speed : 100,
gravity : 400,
jumpForce : 200
},
colors : {
foreground : 0x323332,
background : 0x973263
},
obstacles : [
{
ground : 0,
start : 200,
width : 20,
height : 20
},
{
ground : 0,
start : 400,
width : 20,
height : 20
},
{
ground : 0,
start : 600,
width : 20,
height : 20
}
]
},
// floor 1
{
square : {
size : 24,
speed : 500,
gravity : 450,
jumpForce : 210
},
colors : {
foreground : 0x973263,
background : 0x323332
},
obstacles : [
{
ground : 0,
start : 400,
width : 40,
height : 40
}
]
},
// floor 2
{
square : {
size : 24,
speed : 170,
gravity : 650,
jumpForce : 300
},
colors : {
foreground : 0x323332,
background : 0x973263
},
obstacles : [
{
ground : 0,
start : 225,
width : 80,
height : 30
},
{
ground : 0,
start : 400,
width : 70,
height : 40
},
{
ground : 0,
start : 575,
width : 80,
height : 30
},
]
},
// floor 3
{
square : {
size : 24,
speed : 170,
gravity : 100,
jumpForce : 100
},
colors : {
foreground : 0x973263,
background : 0x323332
},
obstacles : [
{
ground : 0,
start : 400,
width : 180,
height : 30
},
{
ground : 80,
start : 400,
width : 70,
height : 70
}
]
}
]
},
// level 3
{
floors : [
// floor 0
{
square : {
size : 60,
speed : 120,
gravity : 100,
jumpForce : 200
},
colors : {
foreground : 0x3363fc,
background : 0xfb96c7
},
obstacles : [
{
ground : 0,
start : 400,
width : 300,
height : 60
},
{
ground : 0,
start : 400,
width : 60,
height : 120
}
]
},
// floor 1
{
square : {
size : 20,
speed : 160,
gravity : 1200,
jumpForce : 600
},
colors : {
foreground : 0xfb96c7,
background : 0x3363fc
},
obstacles : [
{
ground : 0,
start : 200,
width : 20,
height : 40
},
{
ground : 0,
start : 300,
width : 20,
height : 40
},
{
ground : 0,
start : 500,
width : 20,
height : 40
},
{
ground : 0,
start : 600,
width : 20,
height : 40
}
]
}
]
}
]
Now we have our Space is Key prototype with no Physics engine, but I still want to add one more feature: importing levels from Tiled. I will cover this topic next time, meanwhile download the source code of this project.
Never miss an update! Subscribe, and I will bother you by email only when a new game or full source code comes out.