Talking about Teeter Up game, Box2D, Game development, HTML5, Javascript, Phaser and TypeScript.
In this second step of the Teeter (Up) series, it’s time to add deadly holes and the goal.
Since both holes and goal are circles which do not affect ball movement, there’s no need to add them as Box2D physics objects, and I am going to check if the ball falls in a hole or reaches the goal simply checking for the distance.
I also added a background using a pattern you can find on Kenney’s site.
Let’s see the prototype in action:
Tap left and right to raise the bar. Don’t make the ball fall off the screen or inside the holes. Try to reach the goal.
To use Box2D powered by Planck.js you should install this package with:
npm install –save planck
If you don’t know how to install a npm package or set up a project this way, I wrote a free minibook explaining everything you need to know to get started.
Here is the completely commented source code, as you can see the game is very easy to build.
index.html
The web page which hosts the game, to be run inside thegame element.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, 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.
/* remove margin and padding from all elements */
* {
padding : 0;
margin : 0;
}
/* set body background color */
body {
background-color : #000000;
}
/* Disable browser handling of all panning and zooming gestures. */
canvas {
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. I also grouped the variables to keep them more organized.
// CONFIGURABLE GAME OPTIONS
// changing these values will affect gameplay
export const GameOptions : any = {
// game canvas size and color
game : {
// width of the game, in pixels
width : 1080,
// height of the game, in pixels
height : 1920,
// game background color
bgColor : 0x444444
},
// Box2D related options
Box2D : {
// gravity, in meters per second squared
gravity : 10,
// pixels per meters
worldScale : 120
},
// bar properties
bar : {
// bar width, in pixels
width : 1000,
// bar height, in pixels
height : 40,
// bar Y start coordinate, in pixels
startY : 1800,
// rotation speed, in degrees per second
speed : 45
},
// ball radius, in pixels
ballRadius : 25,
// goal to be reached, with position and diameter
goal : {
x : 540,
y : 200,
diameter : 256
},
// deadly holes, with position and diameter
holes : [{
x : 730,
y : 600,
diameter : 150
},
{
x : 350,
y : 600,
diameter : 150
},
{
x : 730,
y : 1400,
diameter : 150
},
{
x : 350,
y : 1400,
diameter : 150
},
{
x : 880,
y : 1000,
diameter : 100
},
{
x : 200,
y : 1000,
diameter : 100
},{
x : 540,
y : 1000,
diameter : 200
}]
}
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 './scenes/preloadAssets';
import { PlayGame } from './scenes/playGame';
import { GameOptions } from './gameOptions';
// object to initialize the Scale Manager
const scaleObject : Phaser.Types.Core.ScaleConfig = {
mode : Phaser.Scale.FIT,
autoCenter : Phaser.Scale.CENTER_BOTH,
parent : 'thegame',
width : GameOptions.game.width,
height : GameOptions.game.height
}
// game configuration object
const configObject : Phaser.Types.Core.GameConfig = {
type : Phaser.AUTO,
backgroundColor : GameOptions.game.bgColor,
scale : scaleObject,
scene : [PreloadAssets, PlayGame]
}
// the game itself
new Phaser.Game(configObject);
scenes > preloadAssets.ts
Here we preload all assets to be used in the game.
// 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 images
this.load.image('ball', 'assets/sprites/ball.png');
this.load.image('bar', 'assets/sprites/bar.png');
this.load.image('background', 'assets/sprites/background.png');
this.load.image('hole', 'assets/sprites/hole.png');
this.load.image('goal', 'assets/sprites/goal.png');
this.load.image('flag', 'assets/sprites/flag.png');
}
// method to be called once the instance has been created
create() : void {
// call PlayGame scene
this.scene.start('PlayGame');
}
}
scenes >playGame.ts
Main game file, all game logic is stored here.
// THE GAME ITSELF
// modules to import
import Planck, { Box, Circle } from 'planck';
import { toMeters, toPixels } from '../planckUtils';
import { GameOptions } from '../gameOptions';
// enum to represent bar side
enum barSide {
LEFT,
RIGHT,
NONE
}
// this class extends Scene class
export class PlayGame extends Phaser.Scene {
constructor() {
super({
key : 'PlayGame'
});
}
world : Planck.World; // the Box2D world
liftingSide : barSide; // the lifting side of the bar
bar : Planck.Body; // the bar
ball : Planck.Body; // the ball
// method to be called once the instance has been created
create() : void {
// add background
this.add.tileSprite(0, 0, this.game.config.width as number, this.game.config.height as number, 'background').setOrigin(0);
// when we start playing, no bar side is lifting
this.liftingSide = barSide.NONE;
// create a Box2D world with gravity
this.world = new Planck.World(new Planck.Vec2(0, GameOptions.Box2D.gravity));
// place holes
GameOptions.holes.forEach((hole : any) => {
this.add.sprite(hole.x, hole.y, 'hole').setDisplaySize(hole.diameter, hole.diameter);
});
// place goal hole
this.add.sprite(GameOptions.goal.x, GameOptions.goal.y, 'hole').setDisplaySize(GameOptions.goal.diameter, GameOptions.goal.diameter);
this.add.sprite(GameOptions.goal.x, GameOptions.goal.y, 'goal').setDisplaySize(GameOptions.goal.diameter, GameOptions.goal.diameter);
this.add.sprite(GameOptions.goal.x, GameOptions.goal.y, 'flag');
// set bar and ball sprites
const barSprite : Phaser.GameObjects.TileSprite = this.add.tileSprite(this.game.config.width as number / 2, GameOptions.bar.startY, GameOptions.bar.width, GameOptions.bar.height, 'bar');
const ballSprite : Phaser.GameObjects.Sprite = this.add.sprite(this.game.config.width as number / 2, GameOptions.bar.startY - GameOptions.bar.height / 2 - GameOptions.ballRadius, 'ball');
ballSprite.setDisplaySize(GameOptions.ballRadius * 2, GameOptions.ballRadius * 2);
// create the bar kinematic body
this.bar = this.world.createKinematicBody({
position : new Planck.Vec2(toMeters(barSprite.x), toMeters(barSprite.y))
})
// attach a fixture to bar body
this.bar.createFixture({
shape : new Box(toMeters(GameOptions.bar.width / 2), toMeters(GameOptions.bar.height / 2)),
density : 1,
friction : 0,
restitution : 0
})
// set custom bar body user data
this.bar.setUserData({
sprite : barSprite
})
// create ball dynamic body
this.ball = this.world.createDynamicBody({
position : new Planck.Vec2(toMeters(ballSprite.x), toMeters(ballSprite.y))
})
// attach a fixture to ball body
this.ball.createFixture({
shape : new Circle(toMeters(GameOptions.ballRadius)),
density : 1,
friction : 0,
restitution : 0
})
// set custom ball body user data
this.ball.setUserData({
sprite : ballSprite
})
// listener waiting for pointer to be down (pressed)
this.input.on('pointerdown', (pointer : Phaser.Input.Pointer) => {
// check lifting side according to pointer x position
this.liftingSide = Math.floor(pointer.x / (this.game.config.width as number / 2));
});
// listener waiting for pointer to be up (released)
this.input.on('pointerup', (pointer : Phaser.Input.Pointer) => {
// bar is not lifting
this.liftingSide = barSide.NONE;
})
}
// method to be executed at each frame
// totalTime : time, in milliseconds, since the game started
// deltaTime : time, in milliseconds, passed since previous "update" call
update(totalTime : number, deltaTime : number) : void {
// advance world simulation
this.world.step(deltaTime / 1000, 10, 8);
this.world.clearForces();
// do we have to lift the bar?
if (this.liftingSide != barSide.NONE) {
// determine delta angle
const deltaAngle : number = Phaser.Math.DegToRad(GameOptions.bar.speed) * deltaTime / 1000;
// given the angle, determine bar movement
const barMovement : number = toMeters((GameOptions.bar.width / 2) * Math.sin(deltaAngle));
// get bar position
const barPosition : Planck.Vec2 = this.bar.getPosition();
// set new bar angle according to lifting side
this.bar.setAngle(this.bar.getAngle() + deltaAngle * (this.liftingSide == barSide.LEFT ? 1 : -1));
// set new bar position
this.bar.setPosition(new Planck.Vec2(barPosition.x, barPosition.y - barMovement));
}
// loop through all bodies
for (let body : Planck.Body = this.world.getBodyList() as Planck.Body; body; body = body.getNext() as Planck.Body) {
// get body user data
const userData : any = body.getUserData();
// get body position
const bodyPosition : Planck.Vec2 = body.getPosition();
// get body angle
const bodyAngle : number = body.getAngle();
// update sprite position and rotation accordingly
userData.sprite.setPosition(toPixels(bodyPosition.x), toPixels(bodyPosition.y));
userData.sprite.setRotation(bodyAngle);
}
// determine ball position
const ballPosition : Planck.Vec2 = this.ball.getPosition();
// restart if the ball flies off the screen
if (ballPosition.x < 0 || toPixels(ballPosition.x) > GameOptions.game.width || toPixels(ballPosition.y) > GameOptions.game.height) {
this.scene.start('PlayGame');
}
// check if the player is inside a hole
GameOptions.holes.forEach((hole : any) => {
// determine distance between ball and hole center
const distance : number = Phaser.Math.Distance.Between(hole.x, hole.y, toPixels(ballPosition.x), toPixels(ballPosition.y));
// consider the ball to be inside a hole when ball center is inside the hole,
// this means when the distance is less than hole radius
if (distance < hole.diameter / 2) {
this.scene.start('PlayGame');
}
});
// determine distance between ball and hole center
const distance : number = Phaser.Math.Distance.Between(GameOptions.goal.x, GameOptions.goal.y, toPixels(ballPosition.x), toPixels(ballPosition.y));
// consider the ball to be inside the goal when ball center is inside the goal,
// this means when the distance is less than goal radius
if (distance < GameOptions.goal.diameter / 2) {
this.scene.start('PlayGame');
}
}
}
planckUtils.ts
Useful functions to be used in Planck, just to convert pixels to meters and meters to pixels.
// PLANCK UTILITIES
import { GameOptions } from './gameOptions';
// simple function to convert pixels to meters
// pixels : amount of pixels to convert
export function toMeters(pixels : number) : number {
return pixels / GameOptions.Box2D.worldScale;
}
// simple function to convert meters to pixels
// meters : amount of meters to convert
export function toPixels(meters : number) : number {
return meters * GameOptions.Box2D.worldScale;
}
The creation of a basic Teeter (Up) prototype was easy, next time I am going to add more levels and scrolling, meanwhile download the source code of the entire project, Node package included.
Never miss an update! Subscribe, and I will bother you by email only when a new game or full source code comes out.