Talking about Watermelon Game game, Box2D, Game development, HTML5, Javascript, Phaser and TypeScript.
Here we go with the fourth step of the Watermelon Game prototype series. It’s time to handle user input, so I added some dummy, non physics, sprites just to let the player move them and select where to drop them.
Once the player releases the input, dummy sprites are set to invisible and an actual physics sprite is properly added to the game.
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.
After a while, dummy sprites return visible. This way I don’t even need to continuously create new sprites. At the moment I am not recycling physics sprites, but I will.
Look at the prototype:
Now you can decide where to drop balls. At the moment we have only three main missing features: the “next” icon, a game over condition and something to do when the player matches the two biggest balls.
All these features will be added in next step.
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 am also adding more and more comments to source code, which consists in one HTML file, one CSS file and four 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 : 400,
height : 500,
distanceFromBottom : 30
},
// set of bodies
bodies : [
{ size : 10, color : 0x0078ff, particleSize : 10 },
{ size : 20, color : 0xbd00ff, particleSize : 20 },
{ size : 30, color : 0xff9a00, particleSize : 30 },
{ size : 40, color : 0x01ff1f, particleSize : 40 },
{ size : 50, color : 0xe3ff00, particleSize : 50 },
{ size : 60, color : 0xff0000, particleSize : 60 },
{ size : 70, color : 0xffffff, particleSize : 70 },
{ size : 80, color : 0x00ecff, particleSize : 80 },
{ size : 90, color : 0xff00e7, particleSize : 90 },
{ size : 100, color : 0x888888, particleSize : 100 }
],
// idle time after the player dropped the ball
idleTime : 1000,
// maximum size of the ball to be dropped
maxStartBallSize : 2,
// 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';
// object to initialize the Scale Manager
const scaleObject : Phaser.Types.Core.ScaleConfig = {
mode : Phaser.Scale.FIT,
autoCenter : Phaser.Scale.CENTER_BOTH,
parent : 'thegame',
width : 500,
height : 600
}
// game configuration object
const configObject : Phaser.Types.Core.GameConfig = {
type : Phaser.AUTO,
backgroundColor : 0x000000,
scale : scaleObject,
scene : [PlayGame]
}
// the game itself
new Phaser.Game(configObject);
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';
enum bodyType {
BALL,
WALL
}
enum gameState {
IDLE,
MOVING
}
// this class extends Scene class
export class PlayGame extends Phaser.Scene {
constructor() {
super({
key : 'PlayGame'
});
}
world : Planck.World;
contactManagement : any[];
ballsAdded : number;
ids : number[];
emitters : Phaser.GameObjects.Particles.ParticleEmitter[];
dummyBalls : Phaser.GameObjects.Arc[];
currentState : gameState;
currentBall : number;
// 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.currentState = gameState.MOVING;
this.currentBall = Phaser.Math.Between(0, GameOptions.maxStartBallSize);
// build particle emitters
this.buildEmitters();
// create a Box2D world with gravity
this.world = new Planck.World(new Planck.Vec2(0, GameOptions.gravity));
// create the walls
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;
this.createPolygon(baseStartX, baseStartY, baseEndX, baseEndY);
// create dummy balls. These aren't physics bodies, just sprites to be moved according to user input
for (let i : number = 0; i <= GameOptions.maxStartBallSize; i ++) {
let ball : Phaser.GameObjects.Arc = this.add.circle(baseStartX, baseEndY - GameOptions.bodies[i].size, GameOptions.bodies[i].size, GameOptions.bodies[i].color, 0.5);
ball.setStrokeStyle(1, GameOptions.bodies[i].color);
ball.setVisible(false);
this.dummyBalls.push(ball);
}
this.dummyBalls[this.currentBall].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 = Phaser.Math.Between(0, GameOptions.maxStartBallSize);
this.dummyBalls[this.currentBall].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) => {
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 {
// create a circular game object
const circle : Phaser.GameObjects.Arc = this.add.circle(posX, posY, GameOptions.bodies[value].size, GameOptions.bodies[value].color, 0.5);
circle.setStrokeStyle(1, GameOptions.bodies[value].color);
// 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 : circle,
type : bodyType.BALL,
value : value,
id : this.ballsAdded
})
// 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 polygonal game object
const polygon : Phaser.GameObjects.Polygon = this.add.polygon(0, 0, [[startX, endY], [startX, startY], [endX, startY], [endX, endY]], 0xffffff, 0.1);
polygon.setOrigin(0);
// 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, id : number) : void {
// get ball user data
const userData : any = ball.getUserData();
// destroy the sprite
userData.sprite.destroy(true);
// destroy the physics body
this.world.destroyBody(ball);
// remove body id from ids array
this.ids.splice(this.ids.indexOf(id), 1);
}
// 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.destroyBall(contact.body1, contact.id1);
this.destroyBall(contact.body2, contact.id2);
})
})
// 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: (() => {
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);
}
}
}
}
plankUtils.ts
Useful functions to be used in Planck, just to convert pixels to meters and meters to pixels.
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;
}
We are approaching the realization of the full prototype, 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.