Talking about Watermelon Game game, Box2D, Game development, HTML5, Javascript, Phaser and TypeScript.
This is a massive update to my Watermelon Game prototype and includes a lot of new stuff, such as fancy graphics, score, game over, “next” icon and an animated background.
All posts in this tutorial series:
Step 1: setup and basic game mechanics.
Step 2: delays and explosions.
Step 3: particle effects and more space for customization.
Step 4: how to handle user input.
Step 5: scrolling background, “next” icon and game over condition.
Step 6: saving best score with local storage and using object pooling to save resources.
Step 7: square and pentagon bodies added. Get the full source code on Gumroad.
For the first time in the blog, I am using the nine-slice game object and I have to say it’s awesome when you have to create panels and UI, but I will talk about it in a separate blog post.
Now let’s have a look at the prototype:
Drop and merge balls and enjoy.
Now the prototype is fully playable, but I will also add a couple of sounds effects and the high score in the final version.
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.
I also added more comments to source code, which consists in 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. I also grouped the variables to keep them more organized.
// CONFIGURABLE GAME OPTIONS
// changing these values will affect gameplay
export const GameOptions : any = {
// world gravity
gravity : 20,
// pixels / meters ratio
worldScale : 30,
// field size
field : {
width : 500,
height : 700,
distanceFromBottom : 70
},
// set of bodies
bodies : [
{ size : 30, color : 0xf5221c, particleSize : 10, score : 1 },
{ size : 40, color : 0x26e24e, particleSize : 20, score : 2 },
{ size : 50, color : 0xfeec27, particleSize : 30, score : 3 },
{ size : 60, color : 0xff49b2, particleSize : 40, score : 4 },
{ size : 70, color : 0x1ac6f9, particleSize : 50, score : 5 },
{ size : 80, color : 0x7638c8, particleSize : 60, score : 6 },
{ size : 90, color : 0x925f2c, particleSize : 70, score : 7 },
{ size : 100, color : 0xff8504, particleSize : 80, score : 8 },
{ size : 110, color : 0xf2f2f2, particleSize : 90, score : 9 },
{ size : 120, color : 0x888888, particleSize : 100, score : 10 },
{ size : 130, color : 0x2f2f2d, particleSize : 100, score : 11 }
],
// idle time after the player dropped the ball
idleTime : 1000,
// maximum size of the ball to be dropped
maxStartBallSize : 4,
// blast radius. Actually is not a radius, but it works. In pixels.
blastRadius : 100,
// blast force applied
blastImpulse : 2
}
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 { PlayGame } from './playGame';
import { PreloadAssets } from './preloadAssets';
import { MainGame } from './mainGame';
import { AnimatedBackground } from './animatedBackground';
// object to initialize the Scale Manager
const scaleObject : Phaser.Types.Core.ScaleConfig = {
mode : Phaser.Scale.FIT,
autoCenter : Phaser.Scale.CENTER_BOTH,
parent : 'thegame',
width : 1920,
height : 1080
}
// game configuration object
const configObject : Phaser.Types.Core.GameConfig = {
type : Phaser.AUTO,
backgroundColor : 0xaee2ff,
scale : scaleObject,
scene : [PreloadAssets, MainGame, AnimatedBackground, PlayGame]
}
// the game itself
new Phaser.Game(configObject);
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 an image
this.load.image('ball', 'assets/sprites/ball.png');
this.load.image('background', 'assets/sprites/background.png');
this.load.image('angle', 'assets/sprites/angle.png');
this.load.image('line', 'assets/sprites/line.png');
this.load.image('vertical', 'assets/sprites/vertical.png');
this.load.image('panel', 'assets/sprites/panel.png');
// this is how to load a sprite sheet
this.load.spritesheet('faces', 'assets/sprites/faces.png', {
frameWidth : 45,
frameHeight : 25
});
// 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 MainGameScene
this.scene.start('MainGame');
}
}
mainGame.ts
Main game scene, which calls all game scenes, int his case AnimatedBackground and PlayGame.
// MAIN GAME SCENE
// this class extends Scene class
export class MainGame extends Phaser.Scene {
constructor() {
super({
key : 'MainGame'
});
}
create() : void {
// how to launch two scenes simultaneously
this.scene.launch('AnimatedBackground');
this.scene.launch('PlayGame');
}
}
animatedBackground.ts
Class to show the animated background. I decided to keep the animated background in a separate scene to allow players to restart the game without resetting background animation.
For more information about this technique, check this post.
// THE ANIMATED BACKGROUND
// this class extends Scene class
export class AnimatedBackground extends Phaser.Scene {
constructor() {
super({
key : 'AnimatedBackground'
});
}
create() : void {
// place a big tilesprite
const background : Phaser.GameObjects.TileSprite = this.add.tileSprite(0, -128, this.game.config.width as number + 128, this.game.config.height as number + 128, 'background');
background.setOrigin(0);
// slowly continuously tween the tilesprite, then place it in its original position, repeat forever
this.tweens.add({
targets : background,
x : -128,
y : 0,
duration : 5000,
onComplete : () => {
background.setPosition(0, -128);
},
repeat : -1
})
}
}
playGame.ts
Main game file, all game logic is stored here.
// THE GAME ITSELF
import Planck, { Circle } from 'planck';
import { GameOptions } from './gameOptions';
import { toMeters, toPixels } from './planckUtils';
// body type, can be a ball or a wall
enum bodyType {
BALL,
WALL
}
// game state, can be idle (nothing to do), moving (when you move the ball) or gameover, when the game is over
enum gameState {
IDLE,
MOVING,
GAMEOVER
}
// this class extends Scene class
export class PlayGame extends Phaser.Scene {
constructor() {
super({
key : 'PlayGame'
});
}
world : Planck.World;
contactManagement : any[];
ballsAdded : number;
currentBall : number;
score : number;
nextBall : number;
ids : number[];
emitters : Phaser.GameObjects.Particles.ParticleEmitter[];
dummyBalls : Phaser.GameObjects.Sprite[];
nextBallSprites : Phaser.GameObjects.Sprite[];
currentState : gameState;
scoreText : Phaser.GameObjects.BitmapText;
// method to be called once the instance has been created
create() : void {
// initialize global variables
this.ids = [];
this.ballsAdded = 0;
this.contactManagement = [];
this.emitters = [];
this.dummyBalls = [];
this.nextBallSprites = [];
this.currentState = gameState.MOVING;
this.currentBall = Phaser.Math.Between(0, GameOptions.maxStartBallSize);
this.nextBall = Phaser.Math.Between(0, GameOptions.maxStartBallSize);
this.score = 0;
// build particle emitters
this.buildEmitters();
// create a Box2D world with gravity
this.world = new Planck.World(new Planck.Vec2(0, GameOptions.gravity));
// set some variables to build walls and various stuff
const baseStartX : number = this.game.config.width as number / 2 - GameOptions.field.width / 2;
const baseEndX : number = baseStartX + GameOptions.field.width;
const baseStartY : number = this.game.config.height as number - GameOptions.field.distanceFromBottom;
const baseEndY : number = baseStartY - GameOptions.field.height;
const leftPanelCenter : number = baseStartX / 2;
const leftPanelWidth : number = 400;
// Box2D polygon where to make ball fall into
this.createPolygon(baseStartX, baseStartY, baseEndX, baseEndY);
// score panel
this.add.nineslice(leftPanelCenter, 100, 'panel', 0, leftPanelWidth, 120, 33, 33, 33, 33);
this.scoreText = this.add.bitmapText(leftPanelCenter + 180, 132, 'font', '0', 90);
this.scoreText.setOrigin(1)
this.scoreText.setTint(0x3d3d3d);
// "next" panel
this.add.nineslice(leftPanelCenter, 200, 'panel', 0, leftPanelWidth, 150 + GameOptions.bodies[GameOptions.maxStartBallSize].size * 2, 33, 33, 33, 33).setOrigin(0.5, 0);
this.add.bitmapText(leftPanelCenter, 210, 'font', 'NEXT', 90).setTint(0x3d3d3d).setOrigin(0.5, 0);
// game field
this.add.tileSprite(baseStartX, baseStartY, GameOptions.field.width, 50, 'line').setOrigin(0);
this.add.sprite(baseStartX - 32, baseStartY, 'angle').setOrigin(0);
this.add.sprite(baseEndX, baseStartY, 'angle').setOrigin(0).setFlipX(true);
this.add.tileSprite(baseStartX - 32, baseEndY, 32, GameOptions.field.height, 'vertical').setOrigin(0);
this.add.tileSprite(baseEndX, baseEndY, 32, GameOptions.field.height, 'vertical').setOrigin(0);
this.add.rectangle(baseStartX, baseEndY, GameOptions.field.width, GameOptions.field.height, 0x000000, 0.5).setOrigin(0);
// create dummy and "next" balls. These aren't physics bodies, just sprites to be moved according to user input
for (let i : number = 0; i <= GameOptions.maxStartBallSize; i ++) {
const ball : Phaser.GameObjects.Sprite = this.add.sprite(baseStartX, baseEndY - GameOptions.bodies[i].size, 'ball');
ball.setAlpha(0.7);
ball.setVisible(false);
ball.setDisplaySize(GameOptions.bodies[i].size * 2, GameOptions.bodies[i].size * 2);
ball.setTint(GameOptions.bodies[i].color);
this.dummyBalls.push(ball);
const nextBall : Phaser.GameObjects.Sprite = this.add.sprite(leftPanelCenter, 320 + GameOptions.bodies[GameOptions.maxStartBallSize].size, 'ball');
nextBall.setVisible(false);
nextBall.setTint(GameOptions.bodies[i].color);
nextBall.setDisplaySize(GameOptions.bodies[i].size * 2, GameOptions.bodies[i].size * 2);
this.nextBallSprites.push(nextBall);
}
this.dummyBalls[this.currentBall].setVisible(true);
this.nextBallSprites[this.nextBall].setVisible(true);
// when the player releases the input...
this.input.on('pointerup', (pointer : Phaser.Input.Pointer) => {
// are we moving?
if (this.currentState == gameState.MOVING) {
// hide dummy ball
this.dummyBalls[this.currentBall].setVisible(false);
// create a new physics ball
this.createBall(Phaser.Math.Clamp(pointer.x, baseStartX + GameOptions.bodies[this.currentBall].size, baseEndX - GameOptions.bodies[this.currentBall].size), baseEndY - GameOptions.bodies[this.currentBall].size, this.currentBall);
// set the game state to IDLE
this.currentState = gameState.IDLE;
// wait some time before adding a new ball
this.time.addEvent({
delay: GameOptions.idleTime,
callback: (() => {
this.currentState = gameState.MOVING;
this.currentBall = this.nextBall;
this.nextBall = Phaser.Math.Between(0, GameOptions.maxStartBallSize);
this.dummyBalls[this.currentBall].setVisible(true);
this.nextBallSprites[this.currentBall].setVisible(false);
this.nextBallSprites[this.nextBall].setVisible(true);
this.dummyBalls[this.currentBall].setX(Phaser.Math.Clamp(pointer.x, baseStartX + GameOptions.bodies[this.currentBall].size, baseEndX - GameOptions.bodies[this.currentBall].size));
})
})
}
}, this);
// when the player moves the input
this.input.on('pointermove', (pointer : Phaser.Input.Pointer) => {
if (this.currentState == gameState.MOVING) {
this.dummyBalls[this.currentBall].setX(Phaser.Math.Clamp(pointer.x, baseStartX + GameOptions.bodies[this.currentBall].size, baseEndX - GameOptions.bodies[this.currentBall].size));
}
})
// this is the collision listener used to process contacts
this.world.on('pre-solve', (contact : Planck.Contact) => {
// get both bodies user data
const userDataA : any = contact.getFixtureA().getBody().getUserData();
const userDataB : any = contact.getFixtureB().getBody().getUserData();
// get the contact point
const worldManifold : Planck.WorldManifold = contact.getWorldManifold(null) as Planck.WorldManifold;
const contactPoint : Planck.Vec2Value = worldManifold.points[0] as Planck.Vec2Value;
// three nested "if" just to improve readability, to check for a collision we need:
// 1 - both bodies must be balls
if (userDataA.type == bodyType.BALL && userDataB.type == bodyType.BALL) {
// 2 - both balls must have the same value
if (userDataA.value == userDataB.value) {
// 3 - balls ids must not be already present in the array of ids
if (this.ids.indexOf(userDataA.id) == -1 && this.ids.indexOf(userDataB.id) == -1) {
// add bodies ids to ids array
this.ids.push(userDataA.id)
this.ids.push(userDataB.id)
// add a contact management item with both bodies to remove, the contact point, the new value of the ball and both ids
this.contactManagement.push({
body1 : contact.getFixtureA().getBody(),
body2 : contact.getFixtureB().getBody(),
point : contactPoint,
value : userDataA.value + 1,
id1 : userDataA.id,
id2 : userDataB.id
})
}
}
}
});
}
// method to build emitters
buildEmitters() : void {
// loop through each ball
GameOptions.bodies.forEach((body : any, index : number) => {
// build particle graphics as a graphic object turned into a sprite
const particleGraphics : Phaser.GameObjects.Graphics = this.make.graphics({
x : 0,
y : 0
}, false);
particleGraphics.fillStyle(0xffffff);
particleGraphics.fillCircle(body.particleSize, body.particleSize, body.particleSize);
particleGraphics.generateTexture('particle_' + index.toString(), body.particleSize * 2, body.particleSize * 2);
// create the emitter
let emitter : Phaser.GameObjects.Particles.ParticleEmitter = this.add.particles(0, 0, 'particle_' + index.toString(), {
lifespan : 500,
speed : {
min : 0,
max : 50
},
scale : {
start : 1,
end : 0
},
emitting : false
});
// set the emitter zone as the circle area
emitter.addEmitZone({
source : new Phaser.Geom.Circle(0, 0, body.size),
type : 'random',
quantity : 1
});
// set emitter z-order to 1, to always bring explosions on top
emitter.setDepth(1);
// add the emitter to emitters array
this.emitters.push(emitter)
})
}
// method to create a physics ball
createBall(posX : number, posY : number, value : number) : void {
// add ball sprite
const ballSprite : Phaser.GameObjects.Sprite = this.add.sprite(posX, posY, 'ball');
ballSprite.setDisplaySize(GameOptions.bodies[value].size * 2, GameOptions.bodies[value].size * 2);
ballSprite.setTint(GameOptions.bodies[value].color)
// add face sprite
const faceFrame : number = Phaser.Math.Between(0, 8);
const face : Phaser.GameObjects.Sprite = this.add.sprite(posX, posY, 'faces', faceFrame)
// create a dynamic body
const ball : Planck.Body = this.world.createDynamicBody({
position : new Planck.Vec2(toMeters(posX), toMeters(posY))
});
// attach a fixture to the body
ball.createFixture({
shape : new Circle(toMeters(GameOptions.bodies[value].size)),
density : 1,
friction : 0.3,
restitution : 0.1
});
// set some custom user data
ball.setUserData({
sprite : ballSprite,
type : bodyType.BALL,
value : value,
id : this.ballsAdded,
face : face
})
// keep counting how many balls we added so far
this.ballsAdded ++;
}
// method to create a physics polygon
createPolygon(startX : number, startY : number, endX : number, endY : number) : void {
// create a static body
const walls : Planck.Body = this.world.createBody({
position : new Planck.Vec2(toMeters(0), toMeters(0))
});
// attach a fixture to the body
walls.createFixture(Planck.Chain([Planck.Vec2(toMeters(startX), toMeters(endY)), Planck.Vec2(toMeters(startX), toMeters(startY)), Planck.Vec2(toMeters(endX), toMeters(startY)), Planck.Vec2(toMeters(endX), toMeters(endY))]));
// set some custom user data
walls.setUserData({
type : bodyType.WALL
})
}
// method to destroy a ball
destroyBall(ball : Planck.Body) : void {
// get ball user data
const userData : any = ball.getUserData();
// destroy the sprites
userData.sprite.destroy(true);
userData.face.destroy(true);
// destroy the physics body
this.world.destroyBody(ball);
// remove body id from ids array
this.ids.splice(this.ids.indexOf(userData.id), 1);
}
updateScore(n : number) : void {
this.score += GameOptions.bodies[n].score;
this.scoreText.setText(this.score.toString());
}
// method to be executed at each frame
update(totalTime : number, deltaTime : number) : void {
// advance world simulation
this.world.step(deltaTime / 1000, 10, 8);
this.world.clearForces();
// os there any contact to manage?
if (this.contactManagement.length > 0) {
// loop through all contacts
this.contactManagement.forEach((contact : any) => {
// set the emitters to explode
this.emitters[contact.value - 1].explode(50 * contact.value,toPixels(contact.body1.getPosition().x), toPixels(contact.body1.getPosition().y));
this.emitters[contact.value - 1].explode(50 * contact.value,toPixels(contact.body2.getPosition().x), toPixels(contact.body2.getPosition().y));
// destroy the balls after some delay, useful to display explosions or whatever
this.time.addEvent({
delay : 10,
callback : (() => {
this.updateScore(contact.value - 1);
this.destroyBall(contact.body1);
this.destroyBall(contact.body2);
})
})
// determining blast radius, which is actually a square, but who cares?
const query : Planck.AABB = new Planck.AABB(
new Planck.Vec2(contact.point.x - toMeters(GameOptions.blastRadius), contact.point.y - toMeters(GameOptions.blastRadius)),
new Planck.Vec2(contact.point.x + toMeters(GameOptions.blastRadius), contact.point.y + toMeters(GameOptions.blastRadius))
);
// query the world for fixtures inside the square, aka "radius"
this.world.queryAABB(query, function(fixture : Planck.Fixture) {
const body : Planck.Body = fixture.getBody();
const bodyPosition : Planck.Vec2 = body.getPosition();
// just in case you need the body distance from the center of the blast. I am not using it.
const bodyDistance : number = Math.sqrt(Math.pow(bodyPosition.x - contact.point.x, 2) + Math.pow(bodyPosition.y - contact.point.y, 2));
const angle : number = Math.atan2(bodyPosition.y - contact.point.y, bodyPosition.x - contact.point.x);
// the explosion effect itself is just a linear velocity applied to bodies
body.setLinearVelocity(new Planck.Vec2(GameOptions.blastImpulse * Math.cos(angle), GameOptions.blastImpulse * Math.sin(angle)));
// true = keep querying the world
return true;
});
// little delay before creating next ball, be used for a spawn animation
this.time.addEvent({
delay: 200,
callback: (() => {
console.log(contact.value);
if (contact.value < GameOptions.bodies.length) {
this.createBall(toPixels(contact.point.x), toPixels(contact.point.y), contact.value);
}
})
})
})
this.contactManagement = [];
}
// 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();
// is it a ball?
if (userData.type == bodyType.BALL) {
// 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);
userData.face.setPosition(toPixels(bodyPosition.x), toPixels(bodyPosition.y));
userData.face.setRotation(bodyAngle);
// if a ball fall off the screen...
if (this.game.config.height as number < userData.sprite.y && this.currentState != gameState.GAMEOVER) {
// ... it's game over
this.currentState = gameState.GAMEOVER;
// set dummy ball to invisible
this.dummyBalls[this.currentBall].setVisible(false);
// remove all balls with a timer event
const gameOverTimer : Phaser.Time.TimerEvent = this.time.addEvent({
delay : 100,
callback : ((event : Phaser.Time.TimerEvent) => {
let body : Planck.Body = this.world.getBodyList() as Planck.Body;
const userData : any = body.getUserData();
if (userData.type == bodyType.BALL) {
this.destroyBall(body)
}
else {
gameOverTimer.remove();
// restart game scene
this.scene.start('PlayGame')
}
}),
loop : true
})
}
}
}
}
}
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
export function toMeters(n : number) : number {
return n / GameOptions.worldScale;
}
// simple function to convert meters to pixels
export function toPixels(n : number) : number {
return n * GameOptions.worldScale;
}
Next time you’ll see even more features, meanwhile 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.