Do you like my tutorials?

Then consider supporting me on Ko-fi

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

Here we go with the second step in the building of a HTML5 Zuma game using Phaser and TypeScript.

In previous step I showed you how to build the game field with a path and make gems follow it.

Now it’s time to fire a gem and add it to the chain.

For optimal chain management, given its structure, I will no longer use arrays as in the first step but my doubly linked list class. Yes, there is always a reason why I develop something.

I don’t know how collisions are handled in the original game, but here’s how I will do it: once the projectile gem is fired, I will calculate the distance between the projectile and each gem until one of these distances is less than the diameter of the gem.

This means that the bullet and that specific gem are colliding.

At that point, should the projectile be inserted before or after the gem with which it collides?

I will decide that according on the angle of impact.

Look at the prototype, I have added debug texts to better make the whole process easy to understand:

Click or tap to fire the bullet, then follow the instructions on the left side. You can also play on a new window at this link.

And this is the completely commented source code, which consists in one HTML file, one CSS file and five TypeScript files:

index.html

The web page which hosts the game, to be run inside thegame element.

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

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

TypeScript
// CONFIGURABLE GAME OPTIONS
// changing these values will affect gameplay

export const GameOptions : any = {

    gameSize : {
        width               : 1920,     // width of the game, in pixels
        height              : 1080      // height of the game, in pixels
    },

    gemColor                : [         // gem colors
        0xff0000,
        0x00ff00,
        0x0000ff,
        0xff00ff,
        0xffff00,
        0x00ffff
    ],

    gameBackgroundColor     : 0x222222, // game background color

    path                    : '{"type":"Path","x":0,"y":0,"autoClose":false,"curves":[{"type":"LineCurve","points":[1460,-50,1460,540]},{"type":"EllipseCurve","x":960,"y":540,"xRadius":500,"yRadius":500,"startAngle":0,"endAngle":180,"clockwise":false,"rotation":0},{"type":"EllipseCurve","x":910,"y":540,"xRadius":450,"yRadius":450,"startAngle":180,"endAngle":0,"clockwise":false,"rotation":0},{"type":"EllipseCurve","x":960,"y":540,"xRadius":400,"yRadius":400,"startAngle":0,"endAngle":180,"clockwise":false,"rotation":0},{"type":"EllipseCurve","x":910,"y":540,"xRadius":350,"yRadius":350,"startAngle":180,"endAngle":0,"clockwise":false,"rotation":0},{"type":"EllipseCurve","x":960,"y":540,"xRadius":300,"yRadius":300,"startAngle":0,"endAngle":180,"clockwise":false,"rotation":0},{"type":"EllipseCurve","x":910,"y":540,"xRadius":250,"yRadius":250,"startAngle":180,"endAngle":0,"clockwise":false,"rotation":0},{"type":"EllipseCurve","x":960,"y":540,"xRadius":200,"yRadius":200,"startAngle":0,"endAngle":180,"clockwise":false,"rotation":0},{"type":"EllipseCurve","x":910,"y":540,"xRadius":150,"yRadius":150,"startAngle":180,"endAngle":0,"clockwise":false,"rotation":0},{"type":"EllipseCurve","x":960,"y":540,"xRadius":100,"yRadius":100,"startAngle":0,"endAngle":180,"clockwise":false,"rotation":0}]}',
    gemSpeed                : 200,      // gem speed, in pixels per second
    gemRadius               : 24,       // gem radius, in pixels
    bulletSpeed             : 400       // bullet speed, in pixels per second

}

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';                            // Phaser
import { PreloadAssets } from './scenes/preloadAssets'; // preloadAssets scene
import { PlayGame } from './scenes/playGame';           // playGame scene
import { GameOptions } from './gameOptions';            // game options

// object to initialize the Scale Manager
const scaleObject : Phaser.Types.Core.ScaleConfig = {
    mode        : Phaser.Scale.FIT,                     // adjust size to automatically fit in the window
    autoCenter  : Phaser.Scale.CENTER_BOTH,             // center the game horizontally and vertically
    parent      : 'thegame',                            // DOM id where to render the game
    width       : GameOptions.gameSize.width,           // game width, in pixels
    height      : GameOptions.gameSize.height           // game height, in pixels
}

// game configuration object
const configObject : Phaser.Types.Core.GameConfig = { 
    type            : Phaser.AUTO,                      // game renderer
    backgroundColor : GameOptions.gameBackgroundColor,  // game background color
    scale           : scaleObject,                      // scale settings
    scene           : [                                 // array with game scenes
        PreloadAssets,                                  // PreloadAssets scene
        PlayGame                                        // PlayGame scene
    ]
}

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

scenes > preloadAssets.ts

Here we preload all assets to be used in the game.

TypeScript
// CLASS TO PRELOAD ASSETS

// PreloadAssets class extends Phaser.Scene class
export class PreloadAssets extends Phaser.Scene {
  
    // constructor    
    constructor() {
        super({
            key : 'PreloadAssets'
        });
    }
  
    // method to be called during class preloading
    preload() : void {
 
        // load image
        this.load.image('gem', 'assets/sprites/gem.png');
        
    }
  
    // method to be executed when the scene is created
    create() : void {

        // start PlayGame scene
        this.scene.start('PlayGame');
    }
}

scenes > playGame.ts

Main game file, all game logic is stored here.

TypeScript
// THE GAME ITSELF

// modules to import
import { GameOptions } from '../gameOptions';
import { LinkedList } from '../linkedList';

// various game modes
enum gameMode {
    IDLE,   // waiting for player input
    FIRING, // player fired the bullet gem
    HIT,    // when a bullet gem collides with anoter gem
    STOP    // simply does nothing, useful to debug
}

// PlayGame class extends Phaser.Scene class
export class PlayGame extends Phaser.Scene {

    constructor() {
        super({
            key : 'PlayGame'
        });
    }

    graphics    : Phaser.GameObjects.Graphics;  // graphics object where to render the path
    path        : Phaser.Curves.Path;           // the path
    gems        : LinkedList;                   // linked list with all gems
    gemBullet   : Phaser.GameObjects.Sprite;    // the gem the player fires
    debugText   : Phaser.GameObjects.Text;      // just a text object to display debug information

    // method to be called once the instance has been created
    create() : void {

        // initialize gems list
        this.gems = new LinkedList();

        // create the path and load curves from a JSON string
        this.path = new Phaser.Curves.Path(0, 0);
        this.path.fromJSON(JSON.parse(GameOptions.path)); 
        
        // get path length, in pixels
        this.data.set('pathLength', this.path.getLength());
        this.data.set('gameMode', gameMode.IDLE);

        // add the graphic object and draw the path on it
        this.graphics = this.add.graphics();
        this.graphics.lineStyle(2, 0xffffff, 1);
        this.path.draw(this.graphics);

        // add a gem
        this.addGem(0, 0, Phaser.Math.RND.pick(GameOptions.gemColor));

        // add gem bullet
        this.gemBullet = this.add.sprite(this.game.config.width as number / 2, this.game.config.height as number / 2, 'gem');
        this.gemBullet.setTint(Phaser.Math.RND.pick(GameOptions.gemColor));

        // event to be triggered when the player clicks or taps the screen
        this.input.on('pointerdown', (pointer : Phaser.Input.Pointer) => {

            // check game mode
            switch (this.data.get('gameMode')) {
                
                // idle, waiting for player input
                case gameMode.IDLE : 

                    // game mode now is "firing"
                    this.data.set('gameMode', gameMode.FIRING);

                    // write the debug message
                    this.debugText.setText('FIRING');

                    // check the line of fire between bullet and input position
                    const lineOfFire : Phaser.Geom.Line = new Phaser.Geom.Line(this.gemBullet.x, this.gemBullet.y, pointer.x, pointer.y);
                    
                    // save the line of fire as a custom data in gem bullet
                    this.gemBullet.setData('angle', Phaser.Geom.Line.Angle(lineOfFire));
                    break;

                // stop, when the game is paused    
                case gameMode.STOP : 

                    // game mode now is "hit"
                    this.data.set('gameMode', gameMode.HIT);
                    break;
            }
        })

        // add the debug text
        this.debugText = this.add.text(32, 32, 'CLICK OR TAP TO FIRE', {
            color       : '#00ff00',    // text color
            fontSize    : 32            // font size
        });
    }

    // method to add a gem
    // t : time relative to path, from 0 to 1, where 0 = at the beginning of the path, and 1 = at the end of the path
    // index : the index of gems list where to insert the gem
    // color : gem color
    addGem(t : number, index : number, color : number) : void {

        // get gem start point
        const startPoint : Phaser.Math.Vector2 = this.path.getPoint(t);
        
        // create a sprite at gem start point 
        const gemSprite : Phaser.GameObjects.Sprite = this.add.sprite(startPoint.x, startPoint.y, 'gem');

        // tint the sprite
        gemSprite.setTint(color);
        
        // set a custom "t" property to save the time relative to path
        gemSprite.setData('t', t);

        // add gem sprite to gemSprite list by appending it or inserting at a given position
        if (index == this.gems.size) {        
            this.gems.append(gemSprite);
        }
        else {
            this.gems.insertAt(gemSprite, index);
        }
    }

    // metod to be called at each frame
    // time : time passed since the beginning, in milliseconds
    // deltaTime : time passed since last frame, in milliseconds
    update(time : number, deltaTime : number) {

        // if game mode is "stop", the game is paused so we can exit right now
        if (this.data.get('gameMode') == gameMode.STOP) {
            return;
        } 

        // determine delta t movement according to delta time and path length
        const deltaT : number = deltaTime / 1000 * GameOptions.gemSpeed / this.data.get('pathLength');

        // is the player firing?
        if (this.data.get('gameMode') == gameMode.FIRING) {

            // update bullet x and y position according to speed, delta time and angle of fire
            this.gemBullet.x += GameOptions.bulletSpeed * deltaTime / 1000 * Math.cos(this.gemBullet.getData('angle'));
            this.gemBullet.y += GameOptions.bulletSpeed * deltaTime / 1000 * Math.sin(this.gemBullet.getData('angle'));

            // is the bullet outside the screen?
            if (this.gemBullet.x < -GameOptions.gemRadius || this.gemBullet.y < -GameOptions.gemRadius || this.gemBullet.x > (this.game.config.width as number) + GameOptions.gemRadius || this.gemBullet.y > (this.game.config.height as number) + GameOptions.gemRadius) {
                
                // place the bullet in the middle of the screen again
                this.gemBullet.setPosition(this.game.config.width as number / 2, this.game.config.height as number / 2);

                // give the bullet a new random color
                this.gemBullet.setTint(Phaser.Math.RND.pick(GameOptions.gemColor));

                // set game mode to "idle", waiting for player input
                this.data.set('gameMode', gameMode.IDLE);

                // update debug text
                this.debugText.setText('CLICK OR TAP TO FIRE')
            }
        }

        // loop through all gems
        this.gems.forEach((gem : Phaser.GameObjects.Sprite, index : number) => {

            // set gem fully opaque
            gem.setAlpha(1);
            
            // update gem's t data
            gem.setData('t', gem.getData('t') + deltaT);

            // if the gem reached the end of the path...
            if (gem.getData('t') > 1) {

                // restart the game
                this.scene.start('PlayGame');
            }

            // if the gem did not reach the end of the path...
            else {

                // get new gem path point
                const pathPoint : Phaser.Math.Vector2 = this.path.getPoint(gem.getData('t'));
                
                // move the gem to new path point
                gem.setPosition(pathPoint.x, pathPoint.y);

                // get the tangent to path at gem's position
                const vector : Phaser.Math.Vector2 = this.path.getTangent(gem.getData('t'));

                // rotate the gem accordingly
                gem.setRotation(vector.angle())

                // is the player firing?
                if (this.data.get('gameMode') == gameMode.FIRING) {

                    // get the distance between gem and bullet
                    const distance : number = Phaser.Math.Distance.Squared(gem.x, gem.y, this.gemBullet.x, this.gemBullet.y);

                    // is the distance smaller than gem diameter?
                    if (distance < GameOptions.gemRadius * 4 * GameOptions.gemRadius) {
                        
                        // game mode must be set to "hit"
                        this.data.set('gameMode', gameMode.HIT);

                        // highlight the gem by making is semi transparent
                        gem.alpha = 0.5;

                        // get the angle between gem center and bullet center
                        const angle : number = Phaser.Math.RadToDeg(Phaser.Math.Angle.Between(this.gemBullet.x, this.gemBullet.y, gem.x, gem.y));

                        // get the relative angle taking into account gem rotation
                        const relativeAngle : number = Phaser.Math.Angle.WrapDegrees(angle - gem.angle);

                        // at this time the bullet should become a gem to be inserted at index-th place or index+1-th place according to relative angle
                        this.data.set('insertInPlace', relativeAngle < -90 ? index : index + 1);

                        // update debug text to explain everything
                        this.debugText.setText('COLLISION\n\nItem number ' + index + '\n\nAngle between\nbullet and item: ' + Math.round(angle) + '\n\nAngle of\npath tangent: ' + Math.round(gem.angle) + '\n\nRelative angle: ' + Math.round(relativeAngle) + '\n\nMust be placed\n' + ((relativeAngle < -90) ? 'BEFORE' : 'AFTER') + '\nat position ' + this.data.get('insertInPlace') + '\n\nFIRE TO CONTINUE')
                        
                        // stop the game
                        this.data.set('gameMode', gameMode.STOP);
                    }
                }

                // get travelled distance, in pixels
                const travelledDistance : number = this.data.get('pathLength') * gem.getData('t');

                // if this is the last gem and there's enough space for another gem
                if (index == this.gems.size - 1 && travelledDistance > GameOptions.gemRadius * 2) {

                    // add a gem right behind it
                    this.addGem((travelledDistance - GameOptions.gemRadius * 2) / this.data.get('pathLength'), this.gems.size, Phaser.Math.RND.pick(GameOptions.gemColor));
                }
            }
        })

        // is game mode set to "hit"?
        if (this.data.get('gameMode') == gameMode.HIT) {

            // convert gem diameter, in pixels to "t", the time in the path from 0 to 1
            const gemT : number = GameOptions.gemRadius * 2 / this.data.get('pathLength');
            
            // loop through all gems from the first one to the one to be inserted
            for (let i : number = 0; i < this.data.get('insertInPlace'); i ++) {

                // increase "t" data of the gem to move it forward by one gem
                this.gems.getAt(i).setData('t', this.gems.getAt(i).getData('t') + gemT);

                // get the new path point
                const pathPoint : Phaser.Math.Vector2 = this.path.getPoint(this.gems.getAt(i).getData('t'));

                // move gem sprite forward
                this.gems.getAt(i).setPosition(pathPoint.x, pathPoint.y);
            }

            // add the new gem 
            this.addGem(this.gems.getAt(this.data.get('insertInPlace')).data.get('t') + gemT, this.data.get('insertInPlace'), this.gemBullet.tint);

            // place the gem bullet in the center of the screen
            this.gemBullet.setPosition(this.game.config.width as number / 2, this.game.config.height as number / 2);

            // give the gem bullet a new random color
            this.gemBullet.setTint(Phaser.Math.RND.pick(GameOptions.gemColor));

            // game mode is now "idle", waiting for player input
            this.data.set('gameMode', gameMode.IDLE);  

            // update debug text
            this.debugText.setText('CLICK OR TAP TO FIRE');
        }
    }
}

linkedList.ts

Custom class to handle a doubly linked list, find more information at this link.

TypeScript
export class Node {
    data : any;
    next : Node | null;
    prev : Node | null;
    constructor(data : any) {
        this.data = data;
        this.next = null;
        this.prev = null;
    }
}

export class LinkedList {
    head : Node | null;
    tail : Node | null;
    size : number;
    constructor() {
        this.head = null;
        this.tail = null;
        this.size = 0;
    }

    // add a data to the end of the list (TAIL)
    append(data : any) : void {
        const newNode : Node = new Node(data);
        if (this.tail) {
            this.tail.next = newNode;
            newNode.prev = this.tail;
            this.tail = newNode;
        } else {
            this.head = newNode;
            this.tail = newNode;
        }
        this.size ++;
    }

    // add data to the start of the list (HEAD)
    prepend(data : any) : void {
        const newNode : Node = new Node(data);
        if (this.head) {
            this.head.prev = newNode;
            newNode.next = this.head;
            this.head = newNode;
        } else {
            this.head = newNode;
            this.tail = newNode;
        }
        this.size ++;
    }

    // insert data at a specific position
    insertAt(data : any, position : number) : void {
        if (position < 0 || position > this.size - 1) {
            throw Error('Position ' + position + ' is not in the list');
        }
        if (position == 0) {
            this.prepend(data);
            return;
        }
        if (position == this.size) {
            this.append(data);
            return;
        }
        let currentNode : Node = this.head as Node;
        let index : number = 0;
        while (index < position) {
            currentNode = currentNode.next as Node;
            index ++;
        }
        const newNode : Node = new Node(data);
        newNode.next = currentNode;
        newNode.prev = currentNode.prev;
        (currentNode.prev as Node).next = newNode;
        currentNode.prev = newNode;
        this.size ++;
    }

    // remove a node at a specific position ad return its data
    removeAt(position : number) : any {
        if (position < 0 || position > this.size - 1) {
            throw Error('Position ' + position + ' is not in the list');
        }
        if (position == 0) {
            return this.removeHead();
        }
        if (position == this.size - 1) {
            return this.removeTail();
        } 
        let current : Node = this.head as Node;
        let index : number = 0;
        while (index < position) {
            current = current.next as Node;
            index ++;
        }
        (current.prev as Node).next = current.next;
        (current.next as Node).prev = current.prev;
        this.size --;
        return current.data;
    }

    // return node data at a specific position
    getAt(position : number) : any {
        if (position < 0 || position >= this.size) {
            throw Error('Position ' + position + ' is not in the list');
        }
        let current = this.head;
        let index = 0;
        while (index < position) {
            current = (current as Node).next;
            index ++;
        }
        return (current as Node).data;
    }

    // return head data
    getHead() : any {
        if (this.size == 0) {
            throw Error('No head in an empty list');
        }
        return (this.head as Node).data;
    }

    // return tail data
    getTail() : any {
        if (this.size == 0) {
            throw Error('No tail in an empty list');
        }
        return (this.tail as Node).data;
    }

    // remove list head and return its data
    removeHead() : any {
        if (this.size == 0) {
            throw Error('No head in an empty list');
        }
        const data : any = (this.head as Node).data; 
        this.head = (this.head as Node).next;
        if (this.head) {
            this.head.prev = null;
        } else {
            this.tail = null;
        }
        this.size --; 
        return data;  
    }

    // remove list tail and return its data
    removeTail() : any {
        if (this.size == 0) {
            throw Error('No tail in an empty list');
        }
        const data : any = (this.tail as Node).data; 
        this.tail = (this.tail as Node).prev;
        if (this.tail) {
            this.tail.next = null;
        } else {
            this.head = null;
        }
        this.size --;
        return data;
    }

    // forEach method to apply a callback function to each element
    forEach(callback : (value : any, index : number) => void) : void {
        let current : Node | null = this.head;
        let index : number = 0;
        while (current !== null) {
            callback(current.data, index);
            current = current.next;
            index ++;
        }
    }

    // print list content in the console
    log() : void {
        if (this.size == 0) {
            console.log('Empty list');
            return;
        }
        let current : Node = this.head as Node;
        let result : any[] = [];
        for (let i : number = 0; i < this.size; i ++) {
            result.push(current.data);
            if (current.next) {
                current = current.next;
            }
        }
        console.log(result.join(' <-> '));
    }
}

Now we are able to add gems to the chain. In next step I will show you how to deal with matches. Meanwhile, get the source code along with the entire wepack project.

Don’t know how to start developing with Phaser and TypeScript? I’ll explain it to you step by step in this free minibook.

Never miss an update! Subscribe, and I will bother you by email only when a new game or full source code comes out.