Do you like my tutorials?

Then consider supporting me on Ko-fi

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

Welcome to the third step of Mini Archer tutorial series. In previous step I added a running girl so now it’s time to add the bow.

All posts in this tutorial series:

Step 1: Creation of an endless terrain with infinite randonly generated targets.

Step 2: Adding a running character with more animations.

Step 3: Adding a bow using a Graphics GameObject.

Step 4: Adding the arrow.

Step 5: Firing the arrow.

Step 6: Splitting the code into classes.

Unfortunately I did not find any interesing way to add a bow to girl’s sprite sheet, so I decided to add a little cloud generating a rainbow.

Rainbow will act as a bow.

Have a look at the result:

Upper cloud is constantly floating up and down thanks to a tween, and there is also a path to move it a bit to another spot where the girls moves onto next target.

Rainbow is a graphics game object.

There is some work to do in order to optimize the script, but this is the commented source code: we have 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: #011025;    
}

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.

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

export const GameOptions = {

    // terrain start, in screen height ratio, where 0 = top, 1 = bottom
    terrainStart : 0.6,

    // girl x position, in screen width ratio, where 0 = left, 1 = right
    girlPosition : 0.15,

    // target position range, in screen width ratio, where 0 = left, 1 = right
    targetPositionRange : {
        from : 0.5,
        to : 0.9
    },

    // target height range, in pixels
    targetHeightRange : {
        from : 150,
        to : 350
    },

    // number of rings
    rings : 5,

    // ring ratio, to make target look oval, this is the ratio of width compared to height
    ringRatio : 0.8,

    // ring colors, from external to internal
    ringColor : [0xffffff, 0x5cb6f8, 0xe34d46, 0xf2aa3c, 0x95a53c],

    // ring radii, from external to internal, in pixels
    ringRadius : [45, 35, 35, 25, 15],

    // tolerance of ring radius, can be up to this ratio bigger or smaller 
    ringRadiusTolerance : 0.5,

    // rainbow colors
    rainbowColors : [0xe8512e, 0xfbb904, 0xffef02, 0x65b33b, 0x00aae5, 0x3c4395, 0x6c4795],

    // rainbow rings width, in pixels
    rainbowWidth : 5  

}

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 { PreloadAssets } from './preloadAssets';
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 : 540,
    height : 960
}

// game configuration object
const configObject : Phaser.Types.Core.GameConfig = {
    type : Phaser.AUTO,
    backgroundColor : 0x5df4f0,
    scale : scaleObject,
    scene : [PreloadAssets, 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 execute during class preloading
    preload() : void {

        // this is how we preload a bitmap font
        this.load.image('circle', 'assets/sprites/circle.png');
        this.load.image('grasstile', 'assets/sprites/grasstile.png');
        this.load.image('dirttile', 'assets/sprites/dirttile.png');
        this.load.image('pole', 'assets/sprites/pole.png');
        this.load.image('poletop', 'assets/sprites/poletop.png');
        this.load.image('cloud', 'assets/sprites/cloud.png');
        this.load.spritesheet('idlegirl', 'assets/sprites/idlegirl.png', {
            frameWidth : 119,
            frameHeight : 130
        });
        this.load.spritesheet('runninggirl', 'assets/sprites/runninggirl.png', {
            frameWidth : 119,
            frameHeight : 130
        });
    }
 
    // method to be called once the instance has been created
    create() : void {
 
        // call PlayGame class
        this.scene.start('PlayGame');
    }
}

playGame.ts

Main game file, all game logic is stored here.

// THE GAME ITSELF

import { GameOptions } from './gameOptions';

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

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

    // terrain
    terrain : Phaser.GameObjects.TileSprite;

    // dirt below the terrain
    dirt : Phaser.GameObjects.TileSprite;

    // pole
    pole : Phaser.GameObjects.TileSprite;

    // topmost part of the pole
    poleTop : Phaser.GameObjects.Sprite;

    // pole shadow
    poleShadow : Phaser.GameObjects.Sprite;

    // target shadow
    targetShadow : Phaser.GameObjects.Sprite;

    // target rigns
    targetRings : Phaser.GameObjects.Sprite[];

    // girl
    girl : Phaser.GameObjects.Sprite;

    // rainbow
    rainbow : Phaser.GameObjects.Graphics;

    // clouds
    clouds : Phaser.GameObjects.Sprite[];

    // method to be executed when the scene has been created
    create() : void {

        // define idle animation
        this.anims.create({
            key : 'idle',
            frames: this.anims.generateFrameNumbers('idlegirl', {
                start: 0,
                end: 15
            }),
            frameRate: 15,
            repeat: -1
        });

        // define running animation
        this.anims.create({
            key : 'run',
            frames: this.anims.generateFrameNumbers('runninggirl', {
                start: 0,
                end: 19
            }),
            frameRate: 15,
            repeat: -1
        });

        // add terrain
        let terrainStartY : number = this.game.config.height as number * GameOptions.terrainStart;
        this.terrain = this.add.tileSprite(0, terrainStartY, this.game.config.width as number + 256, 256, 'grasstile');
        this.terrain.setOrigin(0, 0);

        // add dirt, the graphics below the terrain
        let dirtStartY : number = terrainStartY + 256; 
        this.dirt = this.add.tileSprite(0, dirtStartY, this.terrain.width, this.game.config.height as number - dirtStartY, 'dirttile');
        this.dirt.setOrigin(0, 0);

        // add a circle which represents target shadow
        this.targetShadow = this.add.sprite(0, 0, 'circle');
        this.targetShadow.setTint(0x676767);

        // add pole shadow
        let poleYPos : number = terrainStartY + 38;
        this.poleShadow = this.add.sprite(0, poleYPos, 'circle');
        this.poleShadow.setTint(0x000000);
        this.poleShadow.setAlpha(0.2);
        this.poleShadow.setDisplaySize(90, 20);

        // add pole
        this.pole = this.add.tileSprite(0, poleYPos, 32, 0, 'pole');
        this.pole.setOrigin(0.5, 1); 

        // add pole top
        this.poleTop = this.add.sprite(0, 0, 'poletop');
        this.poleTop.setOrigin(0.5, 1);

        // add circles which represent the various target circles
        this.targetRings = [];
        for (let i : number = 0; i < GameOptions.rings; i ++) {  
            this.targetRings[i] = this.add.sprite(0, 0, 'circle');
        }

        // girl start position
        let girlXPos : number = this.game.config.width as number * GameOptions.girlPosition;

        // add girl shadow
        let girlShadow : Phaser.GameObjects.Sprite = this.add.sprite(girlXPos + 5, poleYPos, 'circle');
        girlShadow.setTint(0x000000);
        girlShadow.setAlpha(0.2);
        girlShadow.setDisplaySize(60, 20);
        
        // add girl
        this.girl = this.add.sprite(girlXPos, poleYPos, 'girl');
        this.girl.setOrigin(0.5, 1);
        this.girl.anims.play('idle');  
        
        // add rainbow
        this.rainbow = this.add.graphics();

        // add clouds
        this.clouds = [
            this.add.sprite(0, 0, 'cloud'),
            this.add.sprite(0, 0, 'cloud')    
        ]

        // set a custom property to top cloud
        this.clouds[1].setData('posY', this.girl.getBounds().top - 50)
      
        // add a tween to move a bit clouds up and down
        this.tweens.addCounter({
            from : 0,
            to : 1,
            duration : 1000,
            callbackScope : this,
            onUpdate : (tween : Phaser.Tweens.Tween) => {
                this.clouds[1].y =  this.clouds[1].getData('posY') + 5 * Math.cos(Math.PI * tween.getValue())
                this.clouds[0].y =  this.clouds[0].getData('posY') + 5 * Math.cos(Math.PI * tween.getValue())
            },
            yoyo : true,
            repeat : -1
        })

        // place a random target at current position
        this.placeTarget(this.game.config.width as number * 2, this.pole.y);

        // tween the target to a random position
        this.tweenTarget(this.getRandomPosition());
    }

    // simple metod to get a random target position
    getRandomPosition() : number {
        return Math.round(Phaser.Math.FloatBetween(GameOptions.targetPositionRange.from, GameOptions.targetPositionRange.to) * (this.game.config.width as number));
    }

    // method to draw the rainbow
    drawRainbow() : void {

        // make a line representing rainbow radius
        let rainbowRadius : Phaser.Geom.Line = new Phaser.Geom.Line(this.girl.x, this.girl.getBounds().centerY, this.clouds[1].x, this.clouds[1].getData('posY'));
        
        // get radius length
        let rainbowRadiusLength : number = Phaser.Geom.Line.Length(rainbowRadius) - GameOptions.rainbowColors.length / 2 * GameOptions.rainbowWidth;
        
        // get radius angle, which is random start angle
        let rainbowStartAngle : number = Phaser.Geom.Line.Angle(rainbowRadius);

        // get a random rainbow arc length
        let rainbowLength : number = Math.PI / 4 * 3 + Phaser.Math.FloatBetween(0, Math.PI / 4);
        
        // hide the lower cloud
        this.clouds[0].setVisible(true);      
        
        // generic tween of a value from 0 to 1, to make rainbow appear
        this.tweens.addCounter({
            from : 0,
            to : 1,
            
            // tween duration according to deltaX
            duration : 200,

            // tween callback scope
            callbackScope : this,

            // method to be called at each tween update
            onUpdate : (tween : Phaser.Tweens.Tween) => {
               
                // get current angle according to rainbow length and tween value
                let angle : number = rainbowLength * tween.getValue();

                // clear rainbow graphics
                this.rainbow.clear();

                // loop through all rainbow colors
                GameOptions.rainbowColors.forEach((item : number, index : number) => {
                    
                    // set line style
                    this.rainbow.lineStyle(GameOptions.rainbowWidth, item, 1);
                    
                    // draw the arc
                    this.rainbow.beginPath();
                    this.rainbow.arc(this.girl.x, this.girl.getBounds().centerY, rainbowRadiusLength + index * GameOptions.rainbowWidth, rainbowStartAngle, rainbowStartAngle + angle, false);
                    this.rainbow.strokePath();
                });

                // set posY property of lower cloud 
                this.clouds[0].setData('posY', this.girl.getBounds().centerY + rainbowRadiusLength * Math.sin(rainbowStartAngle + angle) + GameOptions.rainbowColors.length / 2 * GameOptions.rainbowWidth);
                
                // set x position of lower cloud
                this.clouds[0].setX(this.girl.x + rainbowRadiusLength * Math.cos(rainbowStartAngle + angle)) ;
            },

            // method to be called when the tween completes
            onComplete : () => {
                
                // add a time event
                this.time.addEvent({

                    // wait 1 second
                    delay : 2000,

                    // tween callback scope
                    callbackScope : this,

                    // callback function
                    callback : () => {

                        // generic tween of a value from 0 to 1, to make rainbow appear
                        this.tweens.addCounter({
                            from : 0,
                            to : 1,
                            
                            // tween duration according to deltaX
                            duration : 200,
                
                            // tween callback scope
                            callbackScope : this,
                
                            // method to be called at each tween update
                            onUpdate : (tween : Phaser.Tweens.Tween) => {
                               
                                // get current angle according to rainbow length and tween value
                                let angle : number = rainbowLength - rainbowLength * tween.getValue();

                                // clear rainbow graphics
                                this.rainbow.clear();

                                // loop through all rainbow colors
                                GameOptions.rainbowColors.forEach((item : number, index : number) => {

                                    // set line style
                                    this.rainbow.lineStyle(GameOptions.rainbowWidth, item, 1);

                                    // draw the arc
                                    this.rainbow.beginPath();
                                    this.rainbow.arc(this.girl.x, this.girl.getBounds().centerY, rainbowRadiusLength + index * GameOptions.rainbowWidth, rainbowStartAngle, rainbowStartAngle + angle, false);
                                    this.rainbow.strokePath();
                                });

                                // set posY property of lower cloud 
                                this.clouds[0].setData('posY', this.girl.getBounds().centerY + rainbowRadiusLength * Math.sin(rainbowStartAngle + angle) + GameOptions.rainbowColors.length / 2 * GameOptions.rainbowWidth)
                                
                                // set x position of lower cloud
                                this.clouds[0].setX(this.girl.x + rainbowRadiusLength * Math.cos(rainbowStartAngle + angle)) ;      
                            },
                            onComplete : () => {
                                this.clouds[0].setVisible(false)
                            }
                        })

                        // tween the new target
                        this.tweenTarget(this.getRandomPosition());
                    }
                });  
            }
        })        
    }

    // method to place the target at (posX, posY)
    placeTarget(posX : number, posY : number) : void {

        // array where to store radii values
        let ringRadii : number[] = [];

        // determine radii values according to default radius size and tolerance
        for (let i : number = 0; i < GameOptions.rings; i ++) {
            ringRadii[i] = Math.round(GameOptions.ringRadius[i] + (GameOptions.ringRadius[i] * Phaser.Math.FloatBetween(0, GameOptions.ringRadiusTolerance) * Phaser.Math.RND.sign()));
        }

        // get the sum of all radii, this will be the size of the target
        let radiiSum : number = ringRadii.reduce((sum, value) => sum + value, 0);

        // determine target height
        let targetHeight : number = posY - Phaser.Math.Between(GameOptions.targetHeightRange.from, GameOptions.targetHeightRange.to) 
        
        // set pole shadow x poisition
        this.poleShadow.setX(posX);

        // set pole x position
        this.pole.setX(posX);

        // set pole height
        this.pole.height = posY - targetHeight;

        // set pole top position
        this.poleTop.setPosition(posX, this.pole.y - this.pole.displayHeight - radiiSum / 2 + 10);
       
        // set shadow size
        this.targetShadow.setDisplaySize(radiiSum * GameOptions.ringRatio, radiiSum);

        // set target shadow position
        this.targetShadow.setPosition(posX + 5, targetHeight);

        // loop through all rings
        for (let i : number = 0; i < GameOptions.rings; i ++) {  
            
            // set ring position
            this.targetRings[i].setPosition(posX, targetHeight);

            // set ring tint
            this.targetRings[i].setTint(GameOptions.ringColor[i]);

            // set ring diplay size
            this.targetRings[i].setDisplaySize(radiiSum * GameOptions.ringRatio, radiiSum);

            // decrease radiiSum to get the radius of next ring
            radiiSum -= ringRadii[i];
        }    
    }

    // method to tween the target to posX
    tweenTarget(posX : number) : void {

        // array with all target related stuff to move
        let stuffToMove : any[] = [this.pole, this.poleTop, this.poleShadow, this.targetShadow, this.terrain, this.dirt].concat(this.targetRings);
        
        // delta X between current target position and destination position
        let deltaX : number = this.game.config.width as number * 2 - posX;

        // variable to save previous value
        let previousValue : number = 0;

        // variable to save the amount of pixels already travelled
        let totalTravelled : number = 0;

        // next cloud X position
        let nextCloudX : number = this.girl.x - 50 + Phaser.Math.Between(0, 100); 
      
        // next cloud y position
        let nextCloudY : number = this.girl.getBounds().top - Phaser.Math.Between(50, 100); 
     
        // object which will follow a path
        let follower : any = {
            t: 0,
            vec: new Phaser.Math.Vector2()
        };

        // define cloud movement line
        let movementLine : Phaser.Curves.Line = new Phaser.Curves.Line([this.clouds[1].x, this.clouds[1].getData('posY'), nextCloudX, nextCloudY]);
        
        // add a path
        var path : Phaser.Curves.Path = this.add.path(0, 0);
        
        // add movement line to path
        path.add(movementLine);

        // move the cloud along the path
        this.tweens.add({
            targets: follower,
            t: 1,
            ease: 'Linear',
            duration : deltaX * 3,
            callbackScope : this,
            onUpdate : () => {
                var point = path.getPoint(follower.t, follower.vec)
                this.clouds[1].setX(point.x)
                this.clouds[1].setData('posY', point.y);
            }
        });

        // play girl's "run" animation
        this.girl.anims.play('run');

        // tween a number from 0 to 1
        this.tweens.addCounter({
            from : 0,
            to : 1,
            
            // tween duration according to deltaX
            duration : deltaX * 3,

            // tween callback scope
            callbackScope : this,

            // method to be called at each tween update
            onUpdate : (tween : Phaser.Tweens.Tween) => {
                
                // delta between previous and current value
                let delta : number = tween.getValue() - previousValue;

                // update previous value to current value
                previousValue = tween.getValue();

                // determine the amount of pixels travelled
                totalTravelled += delta * deltaX;

                // move all stuff
                stuffToMove.forEach((item : any) => {
                    item.x -= delta * deltaX;
                })

                // adjust the seamless terrain when it goes too much outside the screen
                if (this.terrain.x < -256) {
                    this.terrain.x += 256;
                }

                // adjust the seamless dirt when it goes too much outside the screen
                if (this.dirt.x < -256) {
                    this.dirt.x += 256;
                }

                // if the target left the canvas from the left side...
                if (this.targetShadow.getBounds().right < 0) {

                    // reposition it on the right side
                    this.placeTarget(this.game.config.width as number * 2 - totalTravelled, this.pole.y);     
                }
            },

            // method to be called when the tween completes
            onComplete : () => {

                // play girl's "idle" animation
                this.girl.anims.play('idle');

                // draw the rainbow
                this.drawRainbow();
            }
        })
    }
}

This latest piece of code need heavy optimization, but at least I have a bow. Next time, I am going to add the arrow and make the code a bit more readable, meanwhile download the source code.

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