Talking about Space is Key game, Game development, HTML5, Javascript, Phaser and TypeScript.
An important aspect of level design, when levels are crafted by hand, is to take advantage of the most appropriate tool to draw them.
Tiled is the most advanced map editor and it’s free, so I wanted to modify my HTML5 “Space is Key” prototype to be able to read levels built with Tiled.
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.
The first thing to do is to determine a standard set of rules to draw the levels. I decided to use objects with custom properties, and to distribute levels through layers.
You can use Tiled as you prefer, but the source code you are about to see only works with my way of drawing levels.
In the above case, the level is divided in three floors, each one with its properties such as foreground color, background color, gravity and so on.
With this in mind, it was a matter of minutes to build a minigame like this one:
Jump by clicking or tapping on the canvas (no space key at the moment, ironically), do not hit obstacles. And if you reach level 5, you will notice rotated obstacles, a feature that was never present in the original game.
All in just 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 created with Tiled.
// 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, the bitmap font and the levels file generated by Tiled
// 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');
// this is how to load a JSON file
this.load.json('levels', 'assets/levels/levels.tmx');
}
// 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 { PhysicsSquare } from './physicsSquare';
import { CollisionUtils } from './collisionUtils'
import { LevelUtils } from './levelUtils';
// 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[];
levelFloors : any[];
levelData : any;
// method to be called once the instance has been created
create() : void {
this.levelData = this.cache.json.get('levels');
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);
}
// method to be executed at each frame
update(totalTime : number, deltaTime : number) : void {
// move the square
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 == this.levelFloors.length) {
this.floorLevel = 0;
this.level ++;
if (this.level == this.levelData.layers.length) {
this.level = 0;
}
this.drawLevel();
}
this.placeSquare();
}
// method to draw a level
drawLevel() : void {
this.levelTexts = [];
this.levelFloors = [];
this.backgroundGroup.clear(true, true);
this.obstacleGroup.clear(true, true);
this.levelData.layers[this.level].objects.sort((objA : any, objB : any) => objA.y - objB.y);
console.log(this.levelData.layers[this.level].objects)
let foregroundColor : number;
this.levelData.layers[this.level].objects.forEach((object : any, index : number) => {
switch (object.type) {
case 'Background' :
let background : Phaser.GameObjects.TileSprite = this.add.tileSprite(object.x, object.y, object.width, object.height, 'tile');
background.setOrigin(0);
background.setTint(LevelUtils.getColor(object, 'Background'));
let floorText : Phaser.GameObjects.BitmapText = this.add.bitmapText(this.game.config.width as number / 2, object.y + 4, 'font', LevelUtils.getProperty(object, 'Title'), LevelUtils.getProperty(object, 'TitleSize'), Phaser.GameObjects.BitmapText.ALIGN_CENTER);
floorText.setOrigin(0.5, 0);
foregroundColor = LevelUtils.getColor(object, 'Foreground')
floorText.setTint(foregroundColor);
floorText.setVisible(false);
this.levelTexts.push(floorText);
this.levelFloors.push(object);
break;
case 'Obstacle' :
let center : Phaser.Geom.Point = LevelUtils.getCenter(object);
let obstacle : Phaser.GameObjects.TileSprite = this.add.tileSprite(center.x, center.y, object.width, object.height, 'tile');
obstacle.setTint(foregroundColor);
obstacle.setAngle(object.rotation);
this.obstacleGroup.add(obstacle);
break;
}
})
}
// method to place the square on a floor
placeSquare() : void {
//console.log(this.levelTexts)
this.levelTexts[this.floorLevel].setVisible(true);
this.theSquare.placeOnFloor(this.floorLevel, this.levelFloors[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';
import { LevelUtils } from './levelUtils';
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(floorLevel : number, floorData : any) : void {
let squareSize : number = LevelUtils.getProperty(floorData, 'SquareSize');
this.displayWidth = squareSize;
this.displayHeight = squareSize;
this.setTint(LevelUtils.getColor(floorData, 'Foreground'));
this.setX((floorLevel % 2 == 0) ? 0 - squareSize : GameOptions.level.width + squareSize);
this.setY(floorData.y + floorData.height - squareSize / 2);
this.jumpForce = LevelUtils.getProperty(floorData, 'JumpForce');
this.gravity = LevelUtils.getProperty(floorData, 'Gravity');
this.velocity = new Phaser.Math.Vector2(LevelUtils.getProperty(floorData, 'Velocity') * ((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)
]
}
}
levelUtils.ts
A simple collection of utilities to manage the JSON generated by Tiled
export class LevelUtils {
static getProperty(floorData : any, propertyName : string) : any {
return floorData.properties.find((item : any) => item.name == propertyName).value;
}
static getColor(floorData : any, colorName : string) : number {
return parseInt(floorData.properties.find((item : any) => item.name == colorName).value.substr(3, 6), 16);
}
static getCenter(objectData : any) : Phaser.Geom.Point {
let angle : number = Phaser.Math.DegToRad(objectData.rotation);
let center : Phaser.Geom.Point = new Phaser.Geom.Point(objectData.x + objectData.width / 2 * Math.cos(angle), objectData.y + objectData.width / 2 * Math.sin(angle));
center.x += objectData.height / 2 * Math.cos(angle + Math.PI / 2);
center.y += objectData.height / 2 * Math.sin(angle + Math.PI / 2);
return center;
}
}
And now you can really do whatever you want with this prototype, thanks to the power of Phaser and Tiled. 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.