Monday, May 4, 2015

Phaser tutorial: adding 9-patch image support to Phaser

  





Previous Phaser tutorials:
Phaser tutorial: DronShooter - simple game in Typescript - Part 1
Phaser tutorial: custom easing functions for tweening and easing functions with parameters
Phaser tutorial: sprites and custom properties for atlas frames
Phaser tutorial: manage different screen sizes
Phaser tutorial: How to wrap bitmap text


 If you ever tackle with menus for your game you know the problem: you need lot of boxes similar in appearance, but different in size. These boxes are usually large in size and storing them all in all needed sizes is just wasting precious space. You can store menu box in one size and then stretch it, but this is not solution as stretched images may not look good. More, your menu box may have some borders and you want to keep them unstretched.

 This is where 9-patch images comes to help you. 9-patch image is image that is split with two horizontal and vertical lines into grid with 9 cells. When you need to draw big rectangular box, parts are repeated many times instead of stretching:


 As you see, there must be some additional data for computer to know, how wide is left, right part or how high is top and bottom part. Solutions are:
  • fixed amount in pixels inside engine that support 9-images. Not flexible at all,
  • some metadata drawn directly into image. This is for example Android approach,
  • store these data along with other sprite data. This is Unity 9-sliced image, where data are stored in sprite border,
  • provide data at runtime - this is approach I used.

 As there is no built-in support for 9-images in Phaser, I had to code it myself. Here are examples of achieved results (graphics is taken from our Shards - the Brick Breaker and Deadly Abyss 2 games):




First attempt


 I wanted to make my future class for 9-images flexible, so I decided it can be called with:
  • target size of box on screen and code will calculate and draw number of repeats to fit into,
  • requested number of repeats of central part of 9-image horizontally and vertically (imagine famous Windows Mines clone with various grid sizes and border around it - how easy to paint board with 9-image like this!)

 My first attempt was to create Typescript class that derives from Phaser.Group and contains as many sprites as necessary to fill requested box dimensions. I passed texture frame to it along with 9-image metadata and class did everything for me. This everything means:
  • it created additional atlas frames for all 9 parts,
  • it calculated width and height of last central part, which may not be draw fully,
  • it created lot of sprites to cover requested area with 9-image parts (croping it where necessary)
 This class was called like this:

            var nineImageDef: Helper.NineImageDef = { top: 14, left: 14, bottom: 13, right: 13, width: 4, height: 4, repeats: true};
            var nineimage: Helper.NineImage = new Helper.NineImage(this.game, 200, 25, "test", "test", nineImageDef, this.world);

 So far all ok. But problems started when I wanted to scale it like:

            nineimage.scale.setTo(2, 2);

 It may seem strange: why scaling when we just created 9-image to prevent stretching? But imagine you want to animate your 9-image and let it pop for example. The problem was that there were visual artifacts between individual 9-image parts. Very noticeable and ugly. I did not managed to remove it at any cost.


Hard way with better results


 But, hey! Phaser has TileMap support. There must be the same problem when scaled. No, everything there was OK. So I dived into source and found that TileMapLayer is drawn with 1 x 1 scale into canvas. This canvas is then turned into texture and this texture is then drawn on the screen. If any scaling is required, it is done with texture so there are no rounding / interpolation problems between tiles.

 From Phaser 2.3.0 it is using component model, where each gameobject is very simple by itself, but it is enriched with components on engine start. So, I decided to create my own Phaser gameobject and do it similarly to TileMapLayer: I will create texture with menu box from 9-image.

 I am usually working in Typescript, but unfortunately, I did not find a way how to work with Phaser components in it. I will be very glad if someone knows and can provide example...

 Complete listing of nineimage.js source is in the bottom. It works great as you can see from this live example:



 This is minimal example how to use new 9-image gameobject:

var game = new Phaser.Game(640, 400, Phaser.AUTO, 'content', { preload: preload, create: create });

function preload() {
    game.load.image('bg', 'bg1.jpg');
    game.load.image('9image', '9TextBox.png');
}

function create() {

    // bg image
    game.add.sprite(0, 0, 'bg');

    // 9-image
    var sprite = new Phaser.NineImage(this.game, 20, 20, 250, 180, "9image", 0, 17, 8, 30, 25, false);
    this.world.add(sprite);
    
    // 9- image
    sprite = new Phaser.NineImage(this.game, 300, 20, 100, 180, "9image", 0, 17, 8, 30, 25, false);
    this.world.add(sprite);

    // 9- image
    sprite = new Phaser.NineImage(this.game, 20, 250, 8, 1, "9image", 0, 17, 8, 30, 25, true);
    this.world.add(sprite);
}

 Call to new NineImage object has these parameters:
  • game,
  • x and y position of top left corner,
  • width and height - either in pixels or number of repeats of central part of 9-image (see last parameter),
  • key and frame,
  • top, left, bottom, right - borders of 9-image in pixels,
  • repeats - how to interpret width and height. True = repeats of central part
 Get whole project here 9image.zip.


Conclusion


 I am very happy with the result. I am not 100% sure, whether I did everything OK, as this was first time I created new Phaser gameobject. But it works and created objects also responds to things like anchors! Now, it is time to place some text on it. If you are using Phaser's BitmapText, you know, that it lacks wrapping feature. You can use my TextWrapper class that does this (more, it also splits long text into several pages, you can then easily list!!!)

 Here is full listing of nineimage.js source:

Phaser.NineImage = function (game, x, y, width, height, key, frame, top, left, bottom, right, repeats) {

    x |= 0;
    y |= 0;
    width |= 0;
    height |= 0;
    top |= 0;
    left |= 0;
    bottom |= 0;
    right |= 0;

    PIXI.Sprite.call(this, PIXI.TextureCache['__default']);

    Phaser.Component.Core.init.call(this, game, x, y, null, null);


    // image
    this.nineImage = this.game.cache.getImage(key);

    // get frame
    if (typeof frame === "string") {
        this.nineImageFrame = this.game.cache.getFrameByName(key, frame);
    } else {
        this.nineImageFrame = this.game.cache.getFrameByIndex(key, frame);
    }

    // calculate all necessary metrics to render nine image
    this.calculateNineImage(width, height, top, left, bottom, right, repeats);


    // The canvas to which this NineImage draws.
    this.canvas = Phaser.Canvas.create(this.nineImageWidth, this.nineImageHeight);
    // The 2d context of the canvas.
    this.context = this.canvas.getContext('2d');
    // Required Pixi var.
    this.baseTexture = new PIXI.BaseTexture(this.canvas);
    // Required Pixi var.
    this.texture = new PIXI.Texture(this.baseTexture);

    // Dimensions of the renderable area.
    this.textureFrame = new Phaser.Frame(0, 0, 0, this.nineImageWidth, this.nineImageHeight, 'nineimage', game.rnd.uuid());
    // The const type of this object.
    this.type = Phaser.NineImage;
    // The const physics body type of this object.
    this.physicsType = Phaser.NineImage;

    // Settings that control standard (non-diagnostic) rendering.
    this.renderSettings = {
        overdrawRatio: 0.20,
        copyCanvas: null
    };

    // Controls if the core game loop and physics update this game object or not.
    this.exists = true;

    if (!game.device.canvasBitBltShift)
    {
        this.renderSettings.copyCanvas = Phaser.NineImage.ensureSharedCopyCanvas();
    }

    this.fixedToCamera = true;

    // render to canvas
    this.renderNineImage();
};

Phaser.NineImage.prototype = Object.create(PIXI.Sprite.prototype);
Phaser.NineImage.prototype.constructor = Phaser.NineImage;

Phaser.Component.Core.install.call(Phaser.NineImage.prototype, [
    'Bounds',
    'Destroy',
    'FixedToCamera',
    'Reset',
    'Smoothed'
]);


// The shared double-copy canvas, created as needed.
Phaser.NineImage.sharedCopyCanvas = null;

// Create if needed (and return) a shared copy canvas that is shared across all NineImages.
// Code that uses the canvas is responsible to ensure the dimensions and save/restore state as appropriate.
Phaser.NineImage.ensureSharedCopyCanvas = function () {
    if (!this.sharedCopyCanvas)
    {
        this.sharedCopyCanvas = Phaser.Canvas.create(2, 2);
    }

    return this.sharedCopyCanvas;
};


Phaser.NineImage.prototype.calculateNineImage = function (width, height, top, left, bottom, right, repeats) {
    var frame = this.nineImageFrame;

    this.centralWidth = frame.width - left - right;
    this.centralHeight = frame.height - top - bottom;
    
    if (repeats) {
        this.horizontalRepeats = width;
        this.verticalRepeats = height;

        this.nineImageWidth = left + right + this.centralWidth * width;
        this.nineImageHeight = top + bottom + this.centralHeight * height;

        this.lastWidth = 0;
        this.lastHeight = 0;
    } else {
        var w = width - left - right;
        this.horizontalRepeats = Math.floor(w / this.centralWidth);
        this.lastWidth = w % this.centralWidth;

        var h = height - top - bottom;
        this.verticalRepeats = Math.floor(h / this.centralHeight);
        this.lastHeight = h % this.centralHeight;

        this.nineImageWidth = width;
        this.nineImageHeight = height;
    }

    this.leftWidth = left;
    this.rightWidth = right;
    this.topHeight = top;
    this.bottomHeight = bottom;
};


Phaser.NineImage.prototype.renderNineImage = function () {
    this.context.save();

    var sourceY = this.nineImageFrame.y;
    var destY = 0;

    // top row
    if (this.topHeight > 0) {
        this.renderNineImageRow(this.nineImage, sourceY, destY, this.topHeight);
        sourceY += this.topHeight;
        destY += this.topHeight;
    }

    // centrals
    for (var i = 0; i < this.verticalRepeats; i++) {
        this.renderNineImageRow(this.nineImage, sourceY, destY, this.centralHeight);
        destY += this.centralHeight;
    }

    // last height
    if (this.lastHeight > 0) {
        this.renderNineImageRow(this.nineImage, sourceY, destY, this.lastHeight);
        destY += this.lastHeight;
    }

    sourceY += this.centralHeight;

    // bottom
    if (this.bottomHeight > 0) {
        this.renderNineImageRow(this.nineImage, sourceY, destY, this.bottomHeight);
    }

    this.baseTexture.dirty();

    this.context.restore();
};

Phaser.NineImage.prototype.renderNineImageRow = function (image, sourceY, destY, height) {
    var sourceX = this.nineImageFrame.x;
    var destX = 0;
    
    // left
    if (this.leftWidth > 0) {
        this.context.drawImage(image, sourceX, sourceY, this.leftWidth, height, destX, destY, this.leftWidth, height);
        destX += this.leftWidth;
        sourceX += this.leftWidth;
    }

    // centrals
    for (var i = 0; i < this.horizontalRepeats; i++) {
        this.context.drawImage(image, sourceX, sourceY, this.centralWidth, height, destX, destY, this.centralWidth, height);
        destX += this.centralWidth;
    }

    // last width
    if (this.lastWidth > 0) {
        this.context.drawImage(image, sourceX, sourceY, this.lastWidth, height, destX, destY, this.lastWidth, height);
        destX += this.lastWidth;
    }

    sourceX += this.centralWidth;

    // right
    if (this.rightWidth > 0) {
        this.context.drawImage(image, sourceX, sourceY, this.rightWidth, height, destX, destY, this.rightWidth, height);
    }
};







No comments:

Post a Comment