Phaser 3 Scale Manager is here. Exploring Phaser 3 with 3.16.1 version now

I just checked Phaser release log and have been pleasantly surprised to see the new Scale Manager available now with version 3.16.1 so we are now going to update our Peg Solitaire code to make it responsive and use the newly released scale manager.

One thing I would like to do away with is to stop using absolute size values and calculate those values depending upon display area available for the game (just like we did it in Phaser 2).

The first thing we need to change with 3.16.x version is to add scale property in game configuration as following

    let config = {
        type: Phaser.AUTO,
        scale: {
            parent: 'mygame',
            mode: Phaser.Scale.FIT,
            autoCenter: Phaser.Scale.CENTER_BOTH,
            width: 500,
            height: 500
        },
        backgroundColor: 0xFF0000,
        scene: TheGame
    }

    let game = new Phaser.Game(config);

Another change I did was to remove css for canvas element which we had earlier added to center align our game. Our app.css file now looks like this

body {
    padding: 0px;
    margin: 0px;
    background: #000;
    overflow-x: hidden;
    font-family:cursive;
}

You can also see that we have declared ‘parent’ attribute as ‘mygame’ in our scale configuration so we are going to add a div in our html with id ‘mygame’. Our updated html is as following now

<!DOCTYPE html>
<html>
    <head>
        <title>Peg Solitaire</title>
        <meta http-equiv="Content-type" content="text/html; charset=utf-8">
        <meta name="viewport" content="width=device-width, minimum-scale=1, initial-scale=1, user-scalable=no">
        <link rel="stylesheet" href="css/app.css">

        <script src="js/phaser.min.js"></script>
        <script src="js/game.js"></script>
    </head>
    <body>
        <div id="mygame"></div>
    </body>
</html>

Now the game is resized to fit in the available space (it fits in the screen according to the aspect ratio for declared width and height). This can be compared to SHOW_ALL scale mode in Phaser 2 with additional advantage of automatic resizing of the game in available space. We are going to make further changes to scale properties and use entire screen area for our game so there won’t be fixed width and height for the game. We will take width and height as the available width and height of the window object.

    let config = {
        type: Phaser.AUTO,
        scale: {
            parent: 'mygame',
            mode: Phaser.Scale.FIT,
            autoCenter: Phaser.Scale.CENTER_BOTH,
            width: window.innerWidth,
            height: window.innerHeight
        },
        backgroundColor: 0xFF0000,
        scene: TheGame
    }

    let game = new Phaser.Game(config);

With this change when the game is loaded, it takes full screen space. We will be calculating size for rest of the components in the game now which makes it work in a responsive manner. Once the game with FIT scale is loaded in the browser and then you resize it, it will resize in the same aspect ratio as the initial load. If we want the game to always take entire available space and components in the game to re-align themseleves accordingly, then we need to use RESIZE scale mode (which is similar to RESIZE scale mode from Phaser 2). We will look at that as well in later articles. For now we are going to focus on using FIT scale mode and making our game responsive for initial load. For most of the games this works well since aspect ratio won’t be changing for games unless it is played on desktop where we can resize browser window or, on mobile when we are switching between full screen or normal screen mode which changes the aspect ratio of the game.

We are going to define a LocalScaleManager for our game which will take care of scaling our sprites and texts in available area. This code I picked from my previous Phaser 2 game and just made changes to use Phaser 3 api which does pretty much same thing in a different way.

function LocalScaleManager() {
}

LocalScaleManager.prototype = {
    scaleSprite: function (sprite, availableSpaceWidth, availableSpaceHeight, padding, scaleMultiplier, isFullScale) {
        let scale = this.getSpriteScale(sprite.frame.width, sprite.frame.height, availableSpaceWidth, availableSpaceHeight, padding, isFullScale);
        sprite.setScale(scale * scaleMultiplier);
        return scale;
    },
    scaleSpriteTo: function (sprite, scale) {
        sprite.setScale(scale);
    },
    scaleText: function (sprite, availableSpaceWidth, availableSpaceHeight, padding, scaleMultiplier, isFullScale) {
        let originalWidth = sprite.width;
        let originalHeight = sprite.height;
        let scale = this.getSpriteScale(originalWidth, originalHeight, availableSpaceWidth, availableSpaceHeight, padding, isFullScale);
        sprite.setScale(scale * scaleMultiplier);
    },
    getSpriteScale: function (spriteWidth, spriteHeight, availableSpaceWidth, availableSpaceHeight, minPadding, isFullScale) {
        let ratio = 1;
        let currentDevicePixelRatio = window.devicePixelRatio;
        // Sprite needs to fit in either width or height
        let widthRatio = (spriteWidth * currentDevicePixelRatio + 2 * minPadding) / availableSpaceWidth;
        let heightRatio = (spriteHeight * currentDevicePixelRatio + 2 * minPadding) / availableSpaceHeight;
        if (widthRatio > 1 || heightRatio > 1) {
            ratio = 1 / Math.max(widthRatio, heightRatio);
        } else {
            if (isFullScale)
                ratio = 1 / Math.max(widthRatio, heightRatio);
        }
        return ratio * currentDevicePixelRatio;
    }
};

let localScaleManager = new LocalScaleManager;

We have instantiated localScaleManager which will be used by our scenes to scale its components.

In Phaser 3 cameras are created by default with the same size as the game so we can find out our game’s width and height as following

var gameWidth = this.cameras.main.width;
var gameHeight = this.cameras.main.height;

We will make a change to our code and add components (sprite, text etc) outside main view area and then position components in “positionControls” method which takes the available game width and height. Below is the updated code for TheGame class

class TheGame extends Phaser.Scene {

    constructor() {
        super("TheGame");
    }

    preload() {
        this.load.spritesheet("pegs", "images/pegs.png", {
            frameWidth: 60,
            frameHeight: 60
        });
    }

    create() {
        this.boardDef = [
            [-1, -1, 1, 1, 1, -1, -1],
            [-1, -1, 1, 1, 1, -1, -1],
            [1, 1, 1, 1, 1, 1, 1],
            [1, 1, 1, 0, 1, 1, 1],
            [1, 1, 1, 1, 1, 1, 1],
            [-1, -1, 1, 1, 1, -1, -1],
            [-1, -1, 1, 1, 1, -1, -1]
        ];

        //  If a Game Object is clicked on, this event is fired.
        //  We can use it to emit the 'clicked' event on the game object itself.
        this.input.on('gameobjectup', function (pointer, gameObject) {
            gameObject.emit('clicked', gameObject);
        }, this);

        // add our sprites
        this.board = [];
        this.selectedPeg = null;
        this.movesCount = 0;
        this.isMoving = false;

        for (let i = 0, len = this.boardDef.length; i < len; i++) {
            let r = this.boardDef[i];
            let row = [];
            this.board.push(row);
            for (let j = 0, cnt = r.length; j < cnt; j++) {
                let c = r[j];
                if (c >= 0) {
                    let cell = this.add.image(-900, -900, "pegs");
                    cell.setFrame(c > 0 ? 1 : 0);
                    cell.setOrigin(0);

                    // enable input events
                    cell.setInteractive();
                    cell.on('clicked', this.clickPeg, this);
                    cell.gridX = i;
                    cell.gridY = j;
                    row.push(cell);
                } else {
                    row.push(null);
                }
            }
        }
        this.movesLabel = this.add.text(-900, -900, 'Moves: ' + this.movesCount, { fontFamily: "Arial Black", fontSize: 40, color: "#fff" });
        this.movesLabel.setShadow(2, 2, 'rgba(0, 0, 0, 0.5)', 2);

        this.tempPeg = this.add.sprite(-200, -200, "pegs");
        this.tempPeg.setFrame(1);
        this.tempPeg.setOrigin(0);

        var gameWidth = this.cameras.main.width;
        var gameHeight = this.cameras.main.height;
        this.positionControls(gameWidth, gameHeight);
    }

    positionControls(width, height) {
        // 7 pegs + leave space equivalent for 1 peg on each side
        var pegSize = Math.min(width / 9, height / 9);
        var pegScale = localScaleManager.scaleSprite(this.tempPeg, pegSize, pegSize, 0, 1, true);
        var horizontalMargin = (width - 7 * pegSize) / 2;
        var verticalMargin = (height - 7 * pegSize) / 2;

        let colsCount = this.board.length;
        for (let i = 0; i < colsCount; i++) {
            let col = this.board[i];
            for (let j = 0, cnt = col.length; j < cnt; j++) {
                let c = col[j];
                if (c) {
                    localScaleManager.scaleSpriteTo(c, pegScale);
                    c.setPosition(horizontalMargin + i * pegSize, verticalMargin + j * pegSize);
                }
            }
        }

        localScaleManager.scaleText(this.movesLabel, width, pegSize, Math.min(width, pegSize * 0.2), 1, true);
        this.movesLabel.setPosition(width / 2 - this.movesLabel.displayWidth / 2, 0);
        this.pegSize = pegSize;
    }

    updateMoves(movesCount) {
        var width = this.cameras.main.width;
        this.movesLabel.setText('Moves: ' + movesCount);
        this.movesLabel.setPosition(width / 2 - this.movesLabel.displayWidth / 2, 0);
    }

    gameOver() {
        this.registry.set('gamedata', { movesCount: this.movesCount, remainingPegs: this.remainingPegs() });
        this.cameras.main.fade(500);
        this.time.delayedCall(500, function () {
            let gameOver = new GameOver('GameOver');
            this.scene.add('GameOver', gameOver, true);
            this.scene.remove('TheGame');
        }, [], this)
    }

    isAnyValidMove() {
        let colsCount = this.board.length;
        for (let i = 0; i < colsCount; i++) {
            let col = this.board[i];
            for (let j = 0, endIndex = col.length - 3; j <= endIndex; j++) {
                let c1 = col[j];
                let c2 = col[j + 1];
                let c3 = col[j + 2];

                if (c1 && c2 && c3) {
                    if (c1.frame.name !== 0 && c2.frame.name !== 0 && c3.frame.name === 0) return true;
                    if (c1.frame.name === 0 && c2.frame.name !== 0 && c3.frame.name !== 0) return true;
                }
            }
        }

        let rowsCount = this.board[0].length;
        for (let i = 0, len = colsCount - 3; i <= len; i++) {
            let r1 = this.board[i];
            let r2 = this.board[i + 1];
            let r3 = this.board[i + 2];
            for (let j = 0; j < rowsCount; j++) {
                let c1 = r1[j];
                let c2 = r2[j];
                let c3 = r3[j];

                if (c1 && c2 && c3) {
                    if (c1.frame.name !== 0 && c2.frame.name !== 0 && c3.frame.name === 0) return true;
                    if (c1.frame.name === 0 && c2.frame.name !== 0 && c3.frame.name !== 0) return true;
                }
            }
        }
        return false;
    }

    remainingPegs() {
        let pegs = 0;
        for (let i = 0, len = this.board.length; i < len; i++) {
            let row = this.board[i];
            for (let j = 0, cnt = row.length; j < cnt; j++) {
                let cell = row[j];
                if (cell && cell.frame.name !== 0) {
                    pegs++
                }
            }
        }
        return pegs;
    }

    clickPeg(peg) {
        if (this.isMoving) return;

        if (peg.frame.name === 0) {
            // if we have not selected a peg to jump then no need to move any further
            if (!this.selectedPeg)
                return;

            let clickedX = peg.gridX;
            let clickedY = peg.gridY;
            let selectedX = this.selectedPeg.gridX;
            let selectedY = this.selectedPeg.gridY;

            if ((clickedX + 2 === selectedX || clickedX - 2 === selectedX) && clickedY === selectedY) {
                // move horizontal
                let pegToRemove = this.board[(selectedX + clickedX) / 2][clickedY];
                if (pegToRemove.frame.name === 0)
                    return;

                this.updateMoves(++this.movesCount);
                this.removePeg(this.tempPeg, this.selectedPeg, peg, pegToRemove);

                this.selectedPeg.setFrame(0);
                this.selectedPeg = null;

            } else if ((clickedY + 2 === selectedY || clickedY - 2 === selectedY) && clickedX === selectedX) {
                // move vertical
                let pegToRemove = this.board[clickedX][(selectedY + clickedY) / 2];
                if (pegToRemove.frame.name === 0)
                    return;

                this.updateMoves(++this.movesCount);
                this.removePeg(this.tempPeg, this.selectedPeg, peg, pegToRemove);

                this.selectedPeg.setFrame(0);
                this.selectedPeg = null;
            }

        } else {
            if (this.selectedPeg) {
                if (peg === this.selectedPeg) {
                    peg.setFrame(1);
                    this.selectedPeg = null;
                } else {
                    this.selectedPeg.setFrame(1);
                    this.selectedPeg = peg;
                    peg.setFrame(2);
                }
            } else {
                this.selectedPeg = peg;
                peg.setFrame(2);
            }
        }
    }

    removePeg(tempPeg, selectedPeg, targetPeg, pegToRemove) {
        tempPeg.setPosition(selectedPeg.x, selectedPeg.y);
        tempPeg.targetPeg = targetPeg;
        tempPeg.removePeg = pegToRemove;
        tempPeg.visible = true;
        var self = this;
        this.isMoving = true;
        this.pegTween = this.tweens.add({
            targets: tempPeg,
            x: targetPeg.x,
            y: targetPeg.y,
            duration: 200,
            delay: 50,
            onStart: function (tween) {
                let sprite = tween.targets[0];
                sprite.removePeg.setFrame(0);
            },
            onComplete: function (tween) {
                self.isMoving = false;
                let sprite = tween.targets[0];
                sprite.targetPeg.setFrame(1);
                sprite.visible = false;
                if (!self.isAnyValidMove()) {
                    self.cameras.main.shake(2000, 0.005); // second parameter is just the shake intensity
                    let timedEvent = self.time.addEvent({
                        delay: 2000,
                        callbackScope: this,
                        callback: function () {
                            self.gameOver();
                        }
                    });
                }
            }
        });
    }
}

I  made few changes to the code from previous articles. I have updated gameover code to add some camera effects while transitioning to GameOver scene, updated text styling, some additional checks in the game.

GameOver scene code is also updated. Below is the updated GameOver scene code

class GameOver extends Phaser.Scene {
 
    constructor() {
        super("GameOver");
    }

    preload() {
        this.load.image("restart", "images/restart.png");
    }
 
    create() {
        let gamedata = this.registry.get('gamedata');

        this.messageText = this.add.text(-900, -900, 'Game Over', { fontFamily: "Arial Black", fontSize: 40, color: "#fff" });
        this.movesText = this.add.text(-900, -900, 'Moves: ' + gamedata.movesCount, { fontFamily: "Arial Black", fontSize: 40, color: "#fff" });
        if (gamedata.remainingPegs > 1) {
            this.remainingPegsText = this.add.text(-900, -900, 'Remaining Pegs: ' + gamedata.remainingPegs, { fontFamily: "Arial Black", fontSize: 40, color: "#ffff00" });
        }

        let btn = this.add.image(-900, -900, 'restart');
        btn.setInteractive();
        btn.on('pointerup', this.startGame, this);
        this.restartButton = btn;

        let gameWidth = this.cameras.main.width;
        let gameHeight = this.cameras.main.height;
        this.positionControls(gameWidth, gameHeight);
    }

    positionControls(width, height) {
        // 20% height for messageText
        // 20% for movesText
        // 20% for remainingPegsText
        // 25% for restartButton
        localScaleManager.scaleText(this.messageText, width, height * 0.20, Math.min(width, height * 0.20) * 0.1, 1, false);
        this.messageText.setPosition(width / 2 - this.messageText.displayWidth / 2, height * 0.15);

        localScaleManager.scaleText(this.movesText, width, height * 0.20, Math.min(width, height * 0.20) * 0.1, 1, false);
        this.movesText.setPosition(width / 2 - this.movesText.displayWidth / 2, height * 0.35);

        if (this.remainingPegsText) {
            localScaleManager.scaleText(this.remainingPegsText, width, height * 0.20, Math.min(width, height * 0.20) * 0.1, 1, false);
            this.remainingPegsText.setPosition(width / 2 - this.remainingPegsText.displayWidth / 2, height * 0.55);
        }

        localScaleManager.scaleSprite(this.restartButton, width, height * 0.25, Math.min(width, height * 0.20) * 0.1, 1, true);
        this.restartButton.setPosition(width / 2, height * 0.825);
    }

    startGame() {
        this.time.delayedCall(100, function () {
            let theGame = new TheGame('TheGame');
            this.scene.add('TheGame', theGame, true);
            this.scene.remove('GameOver');
        }, [], this)
    }
}

Check out the game in your browser window here. Try resizing it in your browser window to see scaling at work.

You can play the game here


Leave A Comment

Your email address will not be published.