Do you like my tutorials?

Then consider supporting me on Ko-fi

Talking about Watermelon Game game, Box2D, Game development, HTML5, Javascript, Phaser and TypeScript.

In this third step of the Watermelon Game tutorial series I am going to add more room for customization, adding new options in gameOptions.ts file, such as ball and particle size, as well as game field size.


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.


Then, I am also adding a particle effect when two balls merge into a bigger one.

This is the prototype in action:

Now the area where balls fall is defined in the game options, and it’s not made anymore with three boxes but with a polygon.

The particle effect is always kept in front of the canvas using setDepth method.

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 even 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.

HTML
<!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.

CSS
* {
    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.

TypeScript
// CONFIGURABLE GAME OPTIONS
// changing these values will affect gameplay
 
export const GameOptions : any = {
    
    // world gravity
    gravity : 8,

    // pixels / meters ratio
    worldScale : 30,

    // field size
    field : {
        width : 600,
        height : 400,
        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 }
    ],

    // 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.

TypeScript
// 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 : 0x000000,
    scale : scaleObject,
    scene : [PlayGame]
}

// the game itself
new Phaser.Game(configObject);

playGame.ts

Main game file, all game logic is stored here.

TypeScript
// THE GAME ITSELF

import Planck, { 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[];
    emitters : Phaser.GameObjects.Particles.ParticleEmitter[];
  
    // method to be called once the instance has been created
    create() : void {

        // initialize global variables
        this.ids = [];
        this.ballsAdded = 0;
        this.contactManagement = [];
        this.emitters = [];   

        // 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 a time event which calls createBall method every 1000 milliseconds, looping forever
        this.time.addEvent({
            delay : 1000,
            callback : () => {
                this.createBall(Phaser.Math.Between(baseStartX + 1, baseEndX - 1), baseEndY - GameOptions.bodies[0].size, 0);
            },
            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) {
                
                // 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();

        // 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.

TypeScript
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;
}

Now that explosions are working, in next step I’ll turn this prototype into a playable game, with improved graphics and user interaction. 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.