Talking about Watermelon Game game, Box2D, Game development, HTML5, Javascript, Phaser and TypeScript.
Suika Game, also known as Watermelon Game, is a Japanese puzzle video game which combines the elements of falling and merging puzzle games, and rose to glory recently with a series of games and apps using the same gameplay.
It’s an interesting opportunity to test Phaser in conjunction with the new release of Planck.js to simulate rigid body physics using Box2D.
In this first prototype, a physics ball falls from the top of the canvas every 300ms. Then two balls of the same size get in contact, they merge to form a bigger ball.
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.
Actually balls do not merge, they are destroyed and a new bigger ball is created with its center in the collision point.
Look at the prototype:
There is no interactivity, but it works although I need to add little explosions when two balls merge.
Explosions in Box2D will require a separate blog post, at the moment enjoy merging balls.
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.
The source code is partially commented and 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 : 8,
// pixels / meters ratio
worldScale : 30,
// colors of various balls
colors : [0x0078ff, 0xbd00ff, 0xff9a00, 0x01ff1f, 0xe3ff00, 0xff0000, 0xffffff, 0x00ecff, 0xff00e7, 0x888888]
}
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 : 800,
height : 600
}
// game configuration object
const configObject : Phaser.Types.Core.GameConfig = {
type : Phaser.AUTO,
backgroundColor : 0x222222,
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, { Box, Circle } from 'planck';
import { GameOptions } from './gameOptions';
import { toMeters, toPixels } from './planckUtils';
enum bodyType {
Ball,
Wall
}
// this class extends Scene class
export class PlayGame extends Phaser.Scene {
constructor() {
super({
key : 'PlayGame'
});
}
world : Planck.World;
contactManagement : any[];
ballsAdded : number;
ids : number[];
// method to be called once the instance has been created
create() : void {
// initialize global variables
this.ids = [];
this.ballsAdded = 0;
this.contactManagement = [];
// create a Box2D world with gravity
this.world = new Planck.World(new Planck.Vec2(0, GameOptions.gravity));
// create three walls
this.createWall(this.game.config.width as number / 2, this.game.config.height as number - 10, this.game.config.width as number, 10);
this.createWall(10, this.game.config.height as number / 2, 10, this.game.config.height as number);
this.createWall(this.game.config.width as number - 10, this.game.config.height as number / 2, 10, this.game.config.height as number);
// create a time event which calls createBall method every 300 milliseconds, looping forever
this.time.addEvent({
delay : 300,
callback : () => {
this.createBall(Phaser.Math.Between(30, this.game.config.width as number - 30), -10, 1);
},
loop : true
});
// 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) {
// both balls must have the same value
if (userDataA.value == userDataB.value) {
// 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 create a ball
createBall(posX : number, posY : number, value : number) : void {
const circle : Phaser.GameObjects.Arc = this.add.circle(posX, posY, value * 10, GameOptions.colors[value - 1], 0.5);
circle.setStrokeStyle(1, GameOptions.colors[value - 1]);
const ball : Planck.Body = this.world.createDynamicBody({
position : new Planck.Vec2(toMeters(posX), toMeters(posY))
});
ball.createFixture({
shape : new Circle(toMeters(value * 10)),
density : 1,
friction : 0.3,
restitution : 0.1
});
ball.setUserData({
sprite : circle,
type : bodyType.Ball,
value : value,
id : this.ballsAdded
})
this.ballsAdded ++;
}
// method to create a wall
createWall(posX : number, posY : number, width : number, height : number) : void {
const rectangle : Phaser.GameObjects.Rectangle = this.add.rectangle(posX, posY, width * 2, height * 2, 0xffffff);
const floor : Planck.Body = this.world.createBody({
position : new Planck.Vec2(toMeters(posX), toMeters(posY))
});
floor.createFixture({
shape : new Box(toMeters(width), toMeters(height)),
filterGroupIndex : 1
})
floor.setUserData({
sprite : rectangle,
type : bodyType.Wall
})
}
// method to destroy a ball
destroyBall(ball : Planck.Body, id : number) : void {
const userData : any = ball.getUserData();
userData.sprite.destroy();
this.world.destroyBody(ball);
this.ids.splice(this.ids.indexOf(id), 1);
}
// method to be executed at each frame
update(totalTime : number, deltaTime : number) : void {
// advance the simulation
this.world.step(deltaTime / 1000, 10, 8);
this.world.clearForces();
// loop through all contacts
this.contactManagement.forEach((contact : any) => {
// destroy the balls
this.destroyBall(contact.body1, contact.id1);
this.destroyBall(contact.body2, contact.id2);
// create a new ball
this.createBall(toPixels(contact.point.x), toPixels(contact.point.y), contact.value);
})
this.contactManagement = [];
// adjust balls position
for (let body : Planck.Body = this.world.getBodyList() as Planck.Body; body; body = body.getNext() as Planck.Body) {
const bodyPosition : Planck.Vec2 = body.getPosition();
const bodyAngle : number = body.getAngle();
const userData : any = body.getUserData();
userData.sprite.x = toPixels(bodyPosition.x);
userData.sprite.y = toPixels(bodyPosition.y);
userData.sprite.rotation = 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;
}
Next time I will talk about explosions, meanwhile download the source code and play with it.
Never miss an update! Subscribe, and I will bother you by email only when a new game or full source code comes out.