Making of a responsive game in Phaser: Part 2

In part one of this series we did a “Main Menu” screen design for our game which can be read here. Now its time to dive into the code.

In order to reach to the “Main Menu” screen, we are going to use 3 states. The first state will be “Boot” state which is going to set the scale mode to RESIZE. Its also going to load an image which will be displayed while rest of the assets are loaded in the next state which will be called “Loading” state. Once all assets are loaded in the “Loading” state, we move to “Main Menu” state where all action happens.

var TheGame = {
};

TheGame.Params = {
	baseWidth: 1920,
	baseHeight: 1080,
	iconSize: 364
};

TheGame.Boot = function (game) { };

TheGame.Boot.prototype =  {
    init: function () {
        this.scale.scaleMode = Phaser.ScaleManager.RESIZE;
    },
    preload: function () {
        this.load.image("loading", "loading.png");
    },
    create: function () {
        this.state.start("Loading");
    }	
};

TheGame.Loading = function (game) {
};

TheGame.Loading.prototype = {
    init: function () {
    },
    preload: function () {
        this.stage.backgroundColor = 0x222222;
        var loadingBar = this.add.sprite(this.world.centerX, this.world.centerY, "loading");
        loadingBar.anchor.setTo(0.5);
        this.load.setPreloadSprite(loadingBar);

        this.load.image("gametitle", "gametitle.png");
        this.load.spritesheet("settings", "settings.png", 364, 364);
	this.load.image("background", "background.png");
    },
    create: function () {
       this.state.start("GameTitle");
    }
};

TheGame.MainMenu = function (game) {
};

TheGame.MainMenu.prototype = {
    create: function () {
        this.background = this.add.image(0, 0, "background");
        this.background.height = this.game.height;
        this.background.width = this.game.width;
		
        this.title = this.game.add.image(this.world.centerX, this.world.centerY - this.game.height / 3, "gametitle");
        this.title.anchor.setTo(0.5);
        this.scaleSprite(this.title, this.game.width, this.game.height / 3, 50, 1);
			
        this.playButton = this.game.add.button(this.world.centerX, this.world.centerY, "settings", this.playTheGame, this);
        this.playButton.anchor.setTo(0.5);
        this.playButton.frame = 0;
        this.playButton.clicked = false;
        this.scaleSprite(this.playButton, this.game.width, this.game.height / 3, 50, 1);

        this.infoButton = this.game.add.button(this.world.centerX - TheGame.Params.iconSize / 2 , this.world.centerY + this.game.height / 3, "settings", this.viewGameHelp, this);
        this.infoButton.anchor.setTo(0.5);
        this.infoButton.frame = 4;
        this.infoButton.clicked = false;
        this.scaleSprite(this.infoButton, this.game.width, this.game.height / 3, 50, 0.5);
        this.infoButton.x = this.world.centerX - this.infoButton.width / 2;
		
        this.audioButton = this.game.add.button(this.world.centerX + TheGame.Params.iconSize / 2 , this.world.centerY + this.game.height / 3, "settings", this.setAudio, this);
        this.audioButton.anchor.setTo(0.5);
        this.audioButton.frame = 2;
        this.audioButton.clicked = false;
        this.scaleSprite(this.audioButton, this.game.width, this.game.height / 3, 50, 0.5);
        this.audioButton.x = this.world.centerX + this.infoButton.width / 2;
       },
	scaleSprite: function (sprite, availableSpaceWidth, availableSpaceHeight, padding, scaleMultiplier) {
		var scale = this.getSpriteScale(sprite._frame.width, sprite._frame.height, availableSpaceWidth, availableSpaceHeight, padding);
		sprite.scale.x = scale * scaleMultiplier;
		sprite.scale.y = scale * scaleMultiplier;
	},
	getSpriteScale: function (spriteWidth, spriteHeight, availableSpaceWidth, availableSpaceHeight, minPadding) {
		var ratio = 1;
		var currentDevicePixelRatio = window.devicePixelRatio;
		// Sprite needs to fit in either width or height
		var widthRatio = (spriteWidth * currentDevicePixelRatio + 2 * minPadding) / availableSpaceWidth;
		var heightRatio = (spriteHeight * currentDevicePixelRatio + 2 * minPadding) / availableSpaceHeight;
		if(widthRatio > 1 || heightRatio > 1){
			ratio = 1 / Math.max(widthRatio, heightRatio);
		} 
		return ratio * currentDevicePixelRatio;	
	},
	resize: function (width, height) {
		this.background.height = height;
		this.background.width = width;

		this.scaleSprite(this.title, width, height / 3, 50, 1);
		this.title.x = this.world.centerX;
		this.title.y = this.world.centerY - height / 3;

		this.scaleSprite(this.playButton, width, height / 3, 50, 1);
		this.playButton.x = this.world.centerX;
		this.playButton.y = this.world.centerY ;

		this.scaleSprite(this.infoButton, width, height / 3, 50, 0.5);
		this.infoButton.x = this.world.centerX - this.infoButton.width / 2;
		this.infoButton.y = this.world.centerY + height / 3;

		this.scaleSprite(this.audioButton, width, height / 3, 50, 0.5);
		this.audioButton.x = this.world.centerX + this.audioButton.width / 2;
		this.audioButton.y = this.world.centerY + height / 3;

	},
    playTheGame: function (button) {
        if (!button.clicked) {
            button.clicked = true;
        }
    },
    viewGameHelp: function (button) {
        if (!button.clicked) {
            button.clicked = true;
        }
    },
    setAudio: function (button) {
        if (!button.clicked) {
            button.clicked = true;
        }
    }
};

var mygame;
window.onload = function () {
	mygame = new Phaser.Game(TheGame.Params.baseWidth, TheGame.Params.height, Phaser.AUTO);	
	mygame.state.add("Boot", TheGame.Boot);
	mygame.state.add("Loading", TheGame.Loading);
	mygame.state.add("GameTitle", TheGame.MainMenu);
	mygame.state.start("Boot");
}

The results from the code above for Portrait and Landscape orientations are shown below. You can check demo on your device here.

Making of a responsive game in Phaser

Making of a responsive game in Phaser

Now let us analyze important pieces in the code.

Background

First thing we do in the “Main Menu” state is to set a background. It’s up to you to choose an optimized background. The important thing to note here is that after adding background at the top left corner we set width and height of the background to the game’s width and height. This ensures that we do not have any blank space in the game if it is opened in a resolution higher than the base resolution we chose for the game. Equally important is to select a background which can easily blend with other elements on the screen without getting distorted. If we choose to use a background which will get distorted when stretched or squeezed then we must have multiple background images and should load the one best fit according to the device resolution. Here we have simplified that work by simply choosing a background which will look fine even when stretched or squeezed.

this.background = this.add.image(0, 0, "background");
this.background.height = this.game.height;
this.background.width = this.game.width;

Scaling of assets

Remember how we divided the total available space and allocated to individual elements. We planned all assets’ sizes in proportion to the base game resolution. We don’t want to apply same scale to all assets since on mobile devices we want to keep the elements as big as it can fit into the allocated space so for different aspect ratios individual elements may take different space and look as big as they can to the user. We are going simply add elements to the game and then scale it accordingly. Let us look at getSpriteScale method which does pretty much everything we need to do. It takes original element sizes and available space to fit that element in and then calculates a scale. We look at both height and width of the element to see which part is exceeding the available space, take that scale and apply to both width and height to scale entire element in proportion. If element can fit in the space available then there is no need to scale. Just use the element in its original size and put it in its space.

There is a devicePixelRatio which comes into the picture for mobile devices. We will take a look at that in a separate article. I will just summarize the need to use it here. Mobile devices actually have more pixels than the logical resolution we define in our code so if we are coding for 200px, a device with devicePixelRatio of 2 would display it using 400px. For pc devicePixelRatio is 1 so desktops are fine with using logical pixels. Mobile devices having devicePixelRatio greater than 1 automatically convert logical value accordingly to the devicePixelRatio. We should consider this value in calculation of the scale so that we have maximum utilization of the available space in terms of pixels.

	getSpriteScale: function (spriteWidth, spriteHeight, availableSpaceWidth, availableSpaceHeight, minPadding) {
		var ratio = 1;
		var currentDevicePixelRatio = window.devicePixelRatio;
		// Sprite needs to fit in either width or height
		var widthRatio = (spriteWidth * currentDevicePixelRatio + 2 * minPadding) / availableSpaceWidth;
		var heightRatio = (spriteHeight * currentDevicePixelRatio + 2 * minPadding) / availableSpaceHeight;
		if(widthRatio > 1 || heightRatio > 1){
			ratio = 1 / Math.max(widthRatio, heightRatio);
		} 
		return ratio * currentDevicePixelRatio;	
	},

Resizing

Another important consideration is how the game should be scaled when resized or when the orientation is changed on mobile devices. Look at the code below. RESIZE option in Phaser has a special method which gets called every time the parent container changes in size. This method gets new width and height available for the game. All we do here is to calculate new scale values for our assets and position those assets to their new (x, y) coordinates.

	resize: function (width, height) {
		this.background.height = height;
		this.background.width = width;

		this.scaleSprite(this.title, width, height / 3, 50, 1);
		this.title.x = this.world.centerX;
		this.title.y = this.world.centerY - height / 3;

		this.scaleSprite(this.playButton, width, height / 3, 50, 1);
		this.playButton.x = this.world.centerX;
		this.playButton.y = this.world.centerY ;

		this.scaleSprite(this.infoButton, width, height / 3, 50, 0.5);
		this.infoButton.x = this.world.centerX - this.infoButton.width / 2;
		this.infoButton.y = this.world.centerY + height / 3;

		this.scaleSprite(this.audioButton, width, height / 3, 50, 0.5);
		this.audioButton.x = this.world.centerX + this.audioButton.width / 2;
		this.audioButton.y = this.world.centerY + height / 3;

	},

You need to take a note of  how we are calculating values from the game center using game world’s centerX and centerY properties. For “Main Menu” screen there are not many elements to manage and I think it is much safer to start from the center and then calculate position of individual elements relative to that. You can of course start the calculation from top left corner whichever is easier for you to visualize your game.

Next

For “Main Menu” screen we chose the same layout for all orientations. In next article we will be looking at the actual game screen and will use different layouts for different orientations.

Making of a responsive game in Phaser: Part 3

 


9 thoughts on “Making of a responsive game in Phaser: Part 2

  1. Hello!

    I really appreciate your article, but I am confused about the getSpriteScale method. You never go in depth and explain why the widthRatio or heightRatio formulas are structured the way they are.

    I’ve written them out and drawn them to understand how they work, and there’s some logic that I get, but I just don’t understand all of it.

    Could you walk us through how you got to that formula?

    Also, why do you multiply the final ratio by the DPR at the end, if you’ve already multiplied your widthRatio/heightRatio by the DPR?

    Thanks a lot!

    1. Resize method is automatically called when rotate your device or when you resize the browser. You don’t have to specifically listen to this event.

      1. I copied your code into a text editor (brackets) and put a debug line at the top of your resize function (console.log(‘Resizing!’);) to tell me when the resizing method is being called and it doesn’t get invoked when I rotate or resize the screen in the google chrome inspector.

        Do you have any idea why?

        Thanks.

    1. Each state has its own resize method so there cannot be one resize method across different states. If your resize method does something which can be used across states then you can define a common method (different js?) and call it from your resize method in each state.

Leave a Reply to NG Cancel reply

Your email address will not be published.