Tuesday, February 10, 2015

Phaser tutorial: How to wrap bitmap text





 In past month I started exploring world of HTML5 and Javascript games. Finally I ended with Phaser game engine by Photonstorm and Typescript (in Visual Studio 2013).

 During making our first games I was really missing ability to wrap bitmap text. So, I created helper class TextWraper, that is doing text wrap for me.

 Why you need such a class? You can choose between bitmap and true type fonts. While true type fonts are more flexible, bitmap fonts are more visually appaeling. Unfortunately, Phaser's bitmapText cannot wrap text. If you tune your text screen with "hard coded" new lines and then you change few words or translate it into different language, you have to tune it again...
 
 The final TextWrapper helper not only can wrap the text, but it also splits it into multiple pages returning string[], where every single element is one page of text. You just tell to it what is requested width and height of your text area.

 In example below you can see the TextWrapper class in action (press buttons to change screens). All original new line characters are preserved and other that are needed to wrap long paragraphs are added. Font for the demo was created in Littera.



 Using the class is as simple as this:

        var text: string = 'WRAPPED BITMAP TEXT\n' +
            '-------------------------------------------\n\n' +
            ' This example demonstrates TextWrapper helper class for Phaser game engine, that allows you to easily wrap bitmap text.\n' +
            ' Not only it can wrap long paragraphs into lines but it also splits whole text into multiple pages - ' +
            'touch buttons bellow to go to next or previous page.\n' +
            ' The TextWrapper preserves all original new line characters and ads others, that are necessary to wrap text correctly. ' +
            'After calling wrapText() method string array is returned where each array element is single text page with all necessary new line characters.\n\n' +
            ' By the way, if you like background image it is from our game Shards - the brickbreaker. ' +
            'It is available for Android as well as for iOS. Give it a try! Not a bad way how to create long text and make publicity to our game at once :-)';

        var pages: string[] = Helper.TextWrapper.wrapText(text, 520, 260, 'Font', 25);
        var currentPage: number = 0;
        var bitmapText = this.add.bitmapText(60, 30, 'Font', pages[currentPage], 25);

 As can be seen from the code, the class takes these parameters:
  • text - text to wrap,
  • width - width of target text area,
  • height - height of target text area,
  • fontName - name of the font,
  • size (optional) - to calculate font scale. If omitted then scale = 1;

 The TextWrapper class itself is rather long as it has to handle lot of wrapping issues. For example, it is splitting by words, but in case some word is too wide and would not fit into requested width then this word has to be split in the middle.

 Whole listing for the TextWrapper class is here:

module Helper {
    enum eCharType {
        UNDEFINED = -1,
        SPACE = 1,
        NEWLINE = 2,
        CHARACTER = 3,
        //SPECIAL = 4 // for future
    }

    export class TextWrapper {
        static mText: string;
        static mTextPosition: number;
        static mFontData: any;

        // -------------------------------------------------------------------------
        private static hasNext(): boolean {
            return TextWrapper.mTextPosition < TextWrapper.mText.length;
        }

        // -------------------------------------------------------------------------
        private static getChar(): string {
            return TextWrapper.mText.charAt(TextWrapper.mTextPosition++);
        }

        // -------------------------------------------------------------------------
        private static peekChar(): string {
            return TextWrapper.mText.charAt(TextWrapper.mTextPosition);
        }

        // -------------------------------------------------------------------------
        private static getPosition(): number {
            return TextWrapper.mTextPosition;
        }

        // -------------------------------------------------------------------------
        private static setPosition(aPosition: number): void {
            TextWrapper.mTextPosition = aPosition;
        }

        // -------------------------------------------------------------------------
        private static getCharAdvance(aCharCode: number, aPrevCharCode: number): number {
            var charData = TextWrapper.mFontData.chars[aCharCode];
            
            // width
            var advance: number = charData.xAdvance;
            
            // kerning
            if (aPrevCharCode > 0 && charData.kerning[aPrevCharCode])
                advance += charData.kerning[aPrevCharCode];

            return advance;
        }

        // -------------------------------------------------------------------------
        private static getCharType(aChar: string): eCharType {
            if (aChar === ' ')
                return eCharType.SPACE;
            else if (/(?:\r\n|\r|\n)/.test(aChar))
                return eCharType.NEWLINE;
            else
                return eCharType.CHARACTER;
        }

        // -------------------------------------------------------------------------
        static wrapText(aText: string, aWidth: number, aHeight:number, aFontName: string, aSize? : number): string[] {
            // set vars for text processing
            TextWrapper.mText = aText;
            TextWrapper.setPosition(0);
            // font data
            TextWrapper.mFontData = PIXI.BitmapText.fonts[aFontName];

            // if size not defined then take default size
            if (aSize === undefined)
                aSize = TextWrapper.mFontData.size;

            var scale: number = aSize / TextWrapper.mFontData.size;
            // height of line scaled
            var lineHeight: number = TextWrapper.mFontData.lineHeight * scale;
            // instead of scaling every single character we will scale line in opposite direction
            var lineWidth: number = aWidth / scale;

            // result
            var mLineStart: number[] = [];
            var mLineChars: number[] = [];
            var mPageStart: number[] = [];
            var mMaxLine: number = 0;
            var firstLineOnPage: boolean = true;
            var pageCounter: number = 0;

            // char position in text
            var currentPosition: number = 0;
            // first line position
            mLineStart[mMaxLine] = currentPosition;
            // first page
            mPageStart[pageCounter++] = 0;
            // remaining height of current page
            var remainingHeight: number = aHeight;

            
            // whole text
            while (TextWrapper.hasNext()) {
                var charCount: number = 0;
                // saves number of chars before last space
                var saveSpaceCharCount:number = 0;
                var saveCharPosition:number = -1;
                // (previous) type of character
                var type: eCharType = eCharType.UNDEFINED;
                var previousType: eCharType = eCharType.UNDEFINED;
                // remaining width will decrease with words read
                var remainingWidth: number = lineWidth;
                // previous char code
                var prevCharCode: number = -1;

                // single line
                while (TextWrapper.hasNext()) {
                    currentPosition = TextWrapper.getPosition();
                    // read char and move in text by 1 character forward
                    var char: string = TextWrapper.getChar();
                    // get type and code
                    type = TextWrapper.getCharType(char);
                    var charCode: number = char.charCodeAt(0);

                    // process based on type
                    if (type === eCharType.SPACE) {
                        if (previousType != eCharType.SPACE)
                            saveSpaceCharCount = charCount;

                        ++charCount;
                        remainingWidth -= TextWrapper.getCharAdvance(charCode, prevCharCode);
                    }
                    else if (type === eCharType.CHARACTER) {
                        if (previousType !== eCharType.CHARACTER)
                            saveCharPosition = currentPosition;

                        remainingWidth -= TextWrapper.getCharAdvance(charCode, prevCharCode);

                        if (remainingWidth < 0)
                            break;

                        ++charCount;
                    }
                    else if (type === eCharType.NEWLINE) {
                        var breakLoop: boolean = false;

                         // if there is no more text then ignore new line
                        if (TextWrapper.hasNext()) {
                            breakLoop = true;
                            saveSpaceCharCount = charCount;
                            saveCharPosition = TextWrapper.getPosition();
                            currentPosition = saveCharPosition;
                            // simulate normal width overflow
                            remainingWidth = -1;
                            type = eCharType.CHARACTER;
                        }

                        if (breakLoop)
                            break;
                    }

                    previousType = type;
                    prevCharCode = charCode;
                }


                // lines / pages
                remainingHeight -= lineHeight;
                // set new page if not enough remaining height
                if (remainingHeight < 0)
                    mPageStart[pageCounter++] = mMaxLine;

                if (remainingWidth < 0 && type === eCharType.CHARACTER) {
                    if (saveSpaceCharCount != 0)
                        mLineChars[mMaxLine] = saveSpaceCharCount;
                    else // for too long words that do not fit into one line (and Chinese texts)
                        mLineChars[mMaxLine] = charCount;

                    // does new line still fits into current page?
                    firstLineOnPage = false;

                    // set new page
                    if (remainingHeight < 0) {
                        firstLineOnPage = true;
                        remainingHeight = aHeight - lineHeight;
                    }

                    if (saveSpaceCharCount != 0) {
                        mLineStart[++mMaxLine] = saveCharPosition;
                        TextWrapper.setPosition(saveCharPosition);
                    } else {
                        mLineStart[++mMaxLine] = currentPosition;
                        TextWrapper.setPosition(currentPosition);
                    }
                } else if (!TextWrapper.hasNext()) {
                    if (type === eCharType.CHARACTER) {
                        mLineChars[mMaxLine] = charCount;
                    } else if (type === eCharType.SPACE) {
                        mLineChars[mMaxLine] = saveSpaceCharCount;
                    }
                }
            }

            mPageStart[pageCounter] = mMaxLine + 1;


            // lines into string[]
            var result: string[] = [];

            for (var i = 1; i <= pageCounter; i++) {
                var firstLine: number = mPageStart[i - 1];
                var lastLine: number = mPageStart[i];

                var pageText: string[] = [];
                for (var l = firstLine; l < lastLine; l++) {
                    pageText.push(TextWrapper.mText.substr(mLineStart[l], mLineChars[l]));
                }

                result.push(pageText.join("\n"));
            }

            return result;
        }
    }
}

 You can download the example with Typescript source files here.

 By the way, in last days we released our new mobile game "Flat Jewels Match 3" for iOS and Android - give it try!