GameMaker draw_wrapped_colored_text() optimization (The mother of all textboxes)

Tsa05

Member
I've written a script to answer the following issues:
  • How do I change color in the middle of text?
  • How do I change fonts in the middle of text?
  • How do I word wrap my text inside a box?
  • How do I do typewriter text?
  • How do I make my text look like <gameName>'s text?
  • How do I center my word-wrapped text?
  • What is the average air speed velocity of a laden swallow, if it were expressed as a GML scripted textbox?

Figure 1: Texbox Script

The script is fun for the whole family, but...there's a problem. The more text you draw, the slower it gets. Especially when you center the text.
You have to expect that, since it sings and dances on every character you display, but that doesn't mean we can't tryyyy, right?

I've already taken a first stab at it. After discovering that calls to string_char_at() occupied 80% of the script's processing time, I replaced them with array lookups and sped things up considerably--though at the expense of some readability and sanity. I left the string_char_at statements commented out so you could see my folly.

I have a few more ideas, too, which I'll be incorporating into my own visual novel engine, but I'd like to get a discussion going on ways to optimize what I've already got here. Some optimizations that I could make are better done outside the script.

But What can I/we do to make this guy run a little bit smoother? Suggestions! Tips! Strategies!

Useage:
Code:
retVal = draw_wrapped_colored_text(x,y, text, textWidth, textAmt*, textHeight*, alignment*);
Important! This script calls itself once. So, if you name it something else, look around line 120 and fix it there, too. Or just leave the name alone.
So, you provide a set of coords, the full text to draw, and a width to wrap within. Optionally, you also provide:
  • An amount to draw (typewriter effect will draw just a subset)
  • A height to constrain to (text outside the height is truncated)
  • The alignment of the text (default is left-align. 0=left, 1=center. Right-align not added yet).
The script draws your text and returns an array, which contains:
  • The index number of the last character that was printed
    Reasons. As you'll soon see, this script accepts [inline parameters]. So, you might say "Draw only the first 15 characters of 'This is [font=ft_chiller]Halloween' and the script will actually draw past character 15 in the input string. Your code might need to know that the text has advanced past where you *think* it was.
  • Whether the text was truncated due to height (1) or not (0)
    If this script didn't show all the characters you asked for, you need to know.
  • Whether the text is "done" drawing or not
    Since the "index" of the character can change based on inline parameters, you can't be sure whether the text thinks it's done drawing yet...until it tells you
The script handles several "codes." First, the | character is used to process a pause in playback (for typewriter effect). If your calling instance has a variable called typewriterDelay, and uses an alarm event to advance the text, then | will double the delay.
All other codes are contained within [brackets]:
  • [hexVal]: 6-digit hexadecimal color code. Text after this tag will be this color.
  • [f=fontName] or [f=fontName]: Name of a font resource. Text after this tag uses this font.
  • [r:name]: Kinda weird, but demonstrates use of custom codes. This tag displays a basic custom text.
    In my own engine, [r:name] tags are replaced with values looked up in a ds_map before text is sent off for drawing. So, any remaining [r:name] tags in the string were values not found in the map. The script shows something in its place, to indicate that the tag was there.
Gimme the goods
Code:
/// @description draw_wrapped_colored_text(x, y, text, width, [length], [height], [align, mode])
/// @arg {real}        x        X-position
/// @arg {real}        y        Y-position
/// @arg {string}    text    Text to draw
///    @arg {real}        width    Width to wrap within
/// @arg {real}        length*    Number of characters to draw
///    @arg {real}        height*    Height in pixels to draw within
///    @arg {real}        align*    0=left, 1=center
/// @arg {real}        [mode]    Mode is used internally
/*
*
*   Draws text at position x,y. Limits text drawn to [length] characters
*   If text height exceeds [height], truncates.
*
*   This script uses web hex code colors instead of
*   c_color constants to keep me sane.
*
*    [FFFFFF] Hex sets color
*   [f=fontID] changes the font
*   [r:index] draws index value
* 
*   REQUIRES:
*   variable typewriterDelay, if | appears in the string
*
*   RETURNS:
*   An array containing three values:
*   0: The number of the last character printed
*   1: Whether the text was truncated due to height (1) or not (0);
*    2: Whether the box is "done."
*
*    RETURNS [if mode is 1]:
*    An array containing the width of each line of text
*
*    Created by Tsa05 on the GMC forums, possibly with help from GMCers
*    Terms: Use at will. Do not sell as a textbox script.
*    Feel free to sell your own assets that happen to use this for the text part.
*
*/
var typewriterAlarm = 1;  // The alarm number used in your object to advance typewriter text
var debug=0;
draw_set_halign(fa_left);
draw_set_valign(fa_top);

var x1 = argument[0];
var y1 = argument[1];
var text = argument[2];
var boxW = argument[3];
var x2 = x1+boxW;
var font = -1;

var maxLength = 0;

if(!is_undefined(text)){
    if(is_string(text)){
        maxLength = string_length(text);
    }else if(is_array(text)){
        maxLength = array_length_1d(text)-1;
    }
}

var maxHeight = -1;
if(argument_count>5){
    maxHeight = argument[5];
}
var length = maxLength;
var retval = 0;
retval[0]=0; retval[1]=0; retval[2]=0;

if(argument_count>4){
    if(argument[4]>=0){
        length = argument[4];
    }
}
var align = 1;
if(argument_count>6){
    align = argument[6];
}
var mode = 0;
if(argument_count>7){
    mode = argument[7];
}
var lines = 0;
var currentLine = 0;
var line_height = string_height("ABCDEFGHIJKLMNOPQRSTUVWXYZ");
var cc = draw_get_colour();
var cx = x1;
var cy = y1;
var skip = false;
var c;

var newLine = chr(10);
var    i = 1;
var removal = 0;
brackets = false; // Instance variable; calling instance can indicate tag

/*
*    Since this function must draw 1 character at a time,
*    string_char_at becomes a significant source of lag.
*    An array is created to accomodate the problem.
*    This method is 14% faster than using string_char_at in testing.
*/
var textArray = 0;
if(is_array(mode)){
    textArray = mode;
    mode = 1;
}else{
    if(is_array(text)){
        // If text was provided as a character array...
        textArray = text;
    }else{
        for(var z=1;z<=maxLength;z+=1){
            textArray[z]=string_char_at(text,z); //Slower
            //textArray[z]=chr(string_byte_at(text,z)); //Faster, less character support
        }
    }
}
if(align==1 && mode==0){
    // If centering, run this script once for spacing
    // textArray is passed because why not save computation time
    lines = draw_wrapped_colored_text(x1, y1, text, boxW, -1, maxHeight, align, textArray); // Mode 1
}

while (i<=length){
  
    if(debug) show_debug_message("mode:"+string(mode)+" i:"+string(i));
  
    //c = string_char_at(text,i);
    c = textArray[i];
  
    var nextChar = ""; var prevChar = "";
    if(i<array_length_1d(textArray)-1){
        //var nextChar = string_char_at(text,i+1);
        nextChar = textArray[i+1]
    }
    if(i>1){
        //var prevChar = string_char_at(text,i-1);
        prevChar = textArray[i-1];
    }
  
    if(debug) show_debug_message("i:"+string(i)+" prevChar:"+prevChar+" nextChar:"+nextChar);
  
    // ANALYSE CURRENT CHARACTER //
  
    if (c == "\\"){
        if(debug) show_debug_message("escape character or pause");
        if ( nextChar == "[" ||
            nextChar == "|" ){
          
            i += 1;
            //if (i > string_length(text)){
            if (i > maxLength){
                break;
            }
        }
    }else if (c == newLine){
        // IF LINE-BREAK CHARACTER
        if(debug) show_debug_message("newline character");
        if (prevChar != "\\"){
            if(debug) show_debug_message("newline committed");
            if(mode){
                lines[currentLine] = cx-x1;
            }
            currentLine+=1;
            cy += line_height;
            cx = x1;
            //if (i > string_length(text)) break;
            if (i > maxLength) break;
          
        }else{
            if(debug) show_debug_message("newline was escaped");
            c = newLine; // In case you escaped newline?
        }
    }else if (c == "["){
        if(debug) show_debug_message("tag begins");
        // IF THE START OF A TAG
        if (prevChar == "\\"){
            if(debug) show_debug_message("tag escaped");
            c = c;
        }else{
            brackets = true;
            code = "";
            i += 1;
            //c = string_char_at(text,i);
            if(i<array_length_1d(textArray)){
                c = textArray[i];
            }else{ c=""; }
          
            if(debug) show_debug_message("Within tag, i:"+string(i)+" c:"+string(c));
          
            while (c != "]"){
                code += c;
                i += 1;
              
                if(debug) show_debug_message("code:"+code+" i:"+string(i));
              
                //if (i > string_length(text)) break;
                if (i > maxLength) break;
              
                //c = string_char_at(text,i);
                if(i<array_length_1d(textArray)){
                    c = textArray[i];
                }else{ c=""; }
                if(debug) show_debug_message("next character of code:"+c);
            }
          
            if (c == "]"){
                brackets = false;
                c="";
                // Analyze code
                if(string_pos("f=",code)==1||string_pos("font=",code)==1){
                    var fontName = string_delete(code,1,2);
                    if(asset_get_index(fontName)!=-1){
                        if(asset_get_type(fontName)==asset_font){
                            font = asset_get_index(fontName);
                        }
                    }
                }else if(string_pos("r:",code)==1){
                    var num = string_delete(code,1,2);
                    var txt = "[Register "+string(num)+"]";
                    var tag = "[r"+string(num)+"]";
                    c=txt;

                    length+=string_length(txt)-string_length(tag);
                }else{
                    if(mode==0){ //Don't swap colors when measuring
                        // Convert...
                        code = string_upper(code);
                        var dec = 0;
                       var h = "0123456789ABCDEF";
                       for (var p=1; p<=string_length(code); p+=1) {
                           dec = dec << 4 | (string_pos(string_char_at(code, p), h) - 1);
                       }
                        var cc = (dec & 16711680) >> 16 | (dec & 65280) | (dec & 255) << 16;
                        ///////////////////////////
                      
                    }
                }
                // Increase length to display, since we're not
                // Showing these characters.
                length+=string_length(code)+2; //Plus 2 for brackets
                code = "";
            }else{
                // if no end bracket was found, don't draw this
                c="";
            }
        }
    }else
    // IF PAUSE CHARACTER
    if (c == "|"){
        //if (i==1 || string_char_at(text,i-1) != "\\"){
        if (i==1 || textArray[i-1] != "\\"){
            i += 1;
            c="";
            if(i==length && alarm[typewriterAlarm]==-1){
                // Add a delay...
                if(variable_instance_exists(id,"typewriterDelay")){
                    alarm[typewriterAlarm]=2*typewriterDelay;
                }
            }
        }
    }
 
    // IF BEGINNING OF NEW WORD //
    while(i>array_length_1d(textArray)){
        if(debug) show_debug_message("trimming i:"+string(i));
        i-=1;
    }
    prevChar = "";
    if(i>1 ){
        //var prevChar = string_char_at(text,i-1);
        prevChar = textArray[i-1];
    }
  
    /* The string_char_at version... */
    /*
    if( (i == 1) or
    ( (string_char_at(text,i-1) == " ") || (string_char_at(text,i-1) == "-") ) or
    ( (string_char_at(text,i-1) == newLine) && (string_char_at(text,i - 2) != "\\") ))
    */
  
    if( (i == 1) or
    ( (prevChar == " ") || (prevChar == "-") ) or
    ( (prevChar == newLine) && ( array_length_1d(textArray)>3 && textArray[i - 2] != "\\") ) )
    {      
        // CHECK WIDTH //
        if(debug) show_debug_message("determine if linebreak is needed");
        var w  = 0;
        var ii = i;
        var n  = 0;
        var ci  = c;
      
        while ((ci != " ") && (ci != "-") && ci != newLine){
            if (ci == "[") n = 1;
            //if ((ci == "\\") && (string_char_at(text,ii + 1) == newLine))
            if ((ci == "\\") && (textArray[ii + 1] == newLine))
                {
                ii += 1;
                //if (ii > string_length(text)) break;
                if(debug) show_debug_message("ii:"+string(ii));
                if (ii > maxLength){
                    if(debug) show_debug_message("breaking");
                    break;
                }
                continue;
              
                }
          
            if ((!n) && (ci != "\\")) w += string_width(ci);
          
            if (cx + w > x2){
                if(debug) show_debug_message("new line found");
                if(i!=1){
                    if(mode){
                        lines[currentLine]=cx-x1;
                    }
                    currentLine+=1;
                    cy+=line_height;
                    cx=x1;
                }
                w=0;
            }
          
            ii += 1;
            //if (ii > string_length(text)) break;
            if (ii > maxLength) {
                if(debug) show_debug_message("breaking here:"+string(ii));
                break;
            }
          
            //ci  = string_char_at(text,ii);
            ci = textArray[ii];
            if(debug) show_debug_message("ci:"+string(ci));
            if (ci == "]") n = 0;
        }
    }
  
    var cW = string_width(c);
    if(cx+cW>x2){  
        // Check the width again, in case word wrap found no words
        // This means your "word" is bigger than the whole box.
        if(mode){
            lines[currentLine]=cx-x1;
        }
        currentLine+=1;
        cy+=line_height;
        cx=x1;
    }
  
    if(maxHeight && cy+line_height>y1+maxHeight){
      
        retval[0] = i-removal-1;
        retval[1] = 1;
        if(mode) retval = lines;
        return retval;
        exit;
    }else{
        draw_set_color(cc);
        if(font_exists(font)) draw_set_font(font);
        var newX = cx;
        if(mode==0){
            if(align==1){
                if(is_array(lines)&&array_length_1d(lines)>=currentLine){
                    var lineW = lines[currentLine];
                    var d = cx-x1;
                    var centerX = x1+boxW/2;
                    var newX = centerX-lineW/2+d;
                }
            }
            draw_text(newX,cy,c);
        }
        draw_set_halign(fa_left);
        cx += cW;
      
        i += 1;
      
        //if (i > string_length(text)){
        if (i > maxLength){
            retval[0] = maxLength;
            retval[1] = 0;
            retval[2] = 1;
            if(mode){
                lines[currentLine]=cx-x1;
                retval = lines;
            }
            return retval;
            break;
        }
    }   
}

retval[0] = i;
if(mode) retval=lines;
return retval;
Edit: Added a w=0; in there. Little bug--if a single word was so long that it spanned a text region 3 times without a space, the script would mess up.
 
Last edited:

Tthecreator

Your Creator!
This might depend on the font, but I'm pretty sure you can use:
Code:
var line_height = string_height("|");
instead of
Code:
var line_height = string_height("ABCDEFGHIJKLMNOPQRSTUVWXYZ");
It's weird that string_char_at is apparently so slow since I expected it to be a low level operation.
I guess this has to do with it having to start at the beginning of the file to ensure the right encoding.
If you know a thing or two about encoding you know that some characters take up more bytes.
This means that the function can't just guess a place in the string it thinks the character might be at, it must start at the beginning to count the characters.
string_char_at doesn't have any context compared to previous string_char_at operations so it's probably starting at the beginning over and over again.
What if you used string_byte_at instead and somehow manually stitched those bytes together once you get a character?
I'm having some problem finding out what exact encoding GameMaker uses.
So I'm going to do some tests, and get back to you.
Let me know if this was useful to you.

EDIT 1: here's my test of char_at vs byte_at:
EDIT 1.1: further tests:
as string I used the entire lyrics of "namae no nai kaibutsu" in both kanji, romanji and english pasted after each other with newlines removed, giving me 2890 characters.
If you look at call count you can see that string_byte_at is called way more than string_char_at
 
Last edited:

Tsa05

Member
@Tthecreator Ah, I used the uppercase alphabet string for a couple of reasons: First, it's possible that the pipe character might not be included in the font that someone's using, especially if it's a particularly themed font. Second, pipe is tall--quite a bit taller even than all of the other uppercase characters, which would make line spacing look very generous. Third, since pipe is rare to use in on-screen text, it's used as a "pause" character for the typewriter effect (unless escaped with \), so it's unlikely that line heights will ever need to be quite that tall! I used a whole string of uppercase characters, since custom fonts will vary greatly in the height of characters, though uppercase should, as a rule, be the taller set. Kind of a balance between what the script is likely to see and what would be thorough. :D

This byte_at thingie.....now that's extremely interesting and helpful!!!! Less than 1/3 of the processing... Will investigate that line some more.
 

Tthecreator

Your Creator!
I'm starting to feel even more that my hypothesis on what is happening might be true based on this graph:
(Don't mind the all the numbers, those aren't representative of the current place the graph is in. I'm drawing it to a surface and the black box on the side is just a part where the surface isn't drawn.)

If you don't use any kind of weird unicode characters like the japanese I was using in my tests, you can be easily done using string_byte_at. If you do you will need to make some custom encoding things.
 

Tsa05

Member
@Tthecreator I think you're entirely correct, as long as the characters are Unicode...right? Check out this sample in a Step event:
Code:
/// @description Insert description here
// You can write your code in this editor

str = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc ultricies, felis sed interdum porttitor, metus enim porta ligula, ut ultricies ante odio sed nunc. Etiam in dictum sem. Pellentesque volutpat eleifend nisl non eleifend. Pellentesque mi ipsum, consectetur quis enim eget, euismod dignissim velit. Pellentesque id turpis at nibh vehicula rhoncus a non nisi. Integer lacinia rutrum pharetra. Fusce cursus efficitur risus non euismod. Aliquam in lacus ornare, laoreet mauris eu, tempus nisl. Vivamus id vulputate dui, sit amet fringilla nulla. Cras congue semper sem, nec molestie nunc iaculis nec. Maecenas consectetur mauris vel bibendum facilisis. Morbi dignissim neque dolor, ac semper neque dignissim id. Quisque dapibus non massa a placerat.";
var L = string_length(str);

for(var z=1;z<=L;z+=1){
    var c = string_char_at(str,z);
    textArray0[z] = c;
}

for(var z=1;z<=string_length(str);z+=1){
    var c = string_byte_at(str,z);
    textArray1[z] = chr(c);
}
Two loops, adding all characters to an array. One requires string_char_at(), and the other requires string_byte_at() and also chr() to turn it back into a character.

Looks like byte_at plus chr is less than char_at!!!

I'll plug it into the larger context this evening and see what I save there. :D Let's see what else we can streamline!
 

Tthecreator

Your Creator!
Well as long as you keep using ascii characters you should be fine, anything besides that will potentially give you weird problems, based on how you string is stitched together in the end again.
 

Tsa05

Member
Happily, it won't be stitched back--each character is drawn individually with considerations for font and color changes.
Simply switching
Code:
textArray[z]=string_char_at(text,z);
to
Code:
textArray[z]=chr(string_byte_at(text,z));
Is giving me a 10 fps increase on a simple test :D Seems to work; I'll update the script!!
 

Tsa05

Member
Does it count as a bump if I'm posting someone's suggestion from a PM? :D

@Dmi7ry wrote to me to suggest using 2 scripts, and that's a really important point!!!
To increase performance, you could use 2 scripts. First script parses string (prepares an array with tokens, each token contains info: size in pixels, color, font, text, etc - any what you want) and second draws text using the array. The first script is called only once (for example, in Create or Room Start event).
If you're using this script for large portions of centered text, you'll definitely want to do that--and the script was designed to allow this. It accepts either a string or an array as the "text" argument; if it gets a string, it turns that into an array. Better to parse once per textbox than once per draw event!
 
Last edited:
S

Smarty

Guest
I'd rather not go into improving the actual script, but drop a few suggestions for different approaches.

You'd benefit from being able to use RegEx to parse the string, making it faster to filter out text rendering instructions and other special characters. You'll need to pull in an extension for this, though.

Second, if you're not doing it already you want to draw the text to a surface so you have to call it just once rather then every step in the draw event (for typewriter script, you want to keep a cursor into the text to see where you need to continue drawing to screen).

And last, I would put all texts to draw in external files. In the initialization stage of your game you parse the strings and remove all rendering instructions, and for each string you keep a separate (byte) array telling you which text rendering instruction begins on what string position. This way you can draw parts of the string in one go using the correct rendering directives, rather than fetch characters one by one and parse them on the fly (you can use placeholders to fill them with variables where required). You can put all text strings into maps, and give them short index names to identify them by, in code. As an added bonus, since the texts are external it becomes easier to translate your game towards other languages.
 
Last edited by a moderator:
C

CedSharp

Guest
Does it count as a bump if I'm posting someone's suggestion from a PM? :D

@Dmi7ry wrote to me to suggest using 2 scripts, and that's a really important point!!!

If you're using this script for large portions of centered text, you'll definitely want to do that--and the script was designed to allow this. It accepts either a string or an array as the "text" argument; if it gets a string, it turns that into an array. Better to parse once per textbox than once per draw event!
as mentioned, creating an array of pre-formatted information (color, position, font, etc) makes drawing a breeze. that's how i tackled it in ctb textbox.

The workflow then becomes "compile message to tokens" then "draw compiled message" so you'd need 2 scripts at least. or one script that's intelligent enough to cache the message.
 

Tsa05

Member
@Smarty and @CedSharp This is some really cool idea stuffs--I think I've probably got to implement an example of it just because it seems rather advantageous.

There's a few tricksy things to work out, though, that would need a bit of a compromise. One thing that this all-in-one script does rather particularly nicely is markup tags. It's very easy, on-the-fly, for the script to pick up anything inside of [brackets] and to "do something." Included in the script above, it's possible to change the color and the font, and to make a text substitution in real-time. I think all of these can be done fairly similarly through pre-chewed data, but I'm trying to visualize whether there's other things that would be affected. Tags, done in this particular way, can do some pretty wild things, including adding embedded pictures with padded word wrapping, playing sounds or screen effects, creating objects... By adding another if into the script, new kinds of tags are a breeze; any pre-formatting would have to undergo some surgery. But it might be worth it!
 
C

CedSharp

Guest
@Smarty and @CedSharp This is some really cool idea stuffs--I think I've probably got to implement an example of it just because it seems rather advantageous.

There's a few tricksy things to work out, though, that would need a bit of a compromise. One thing that this all-in-one script does rather particularly nicely is markup tags. It's very easy, on-the-fly, for the script to pick up anything inside of [brackets] and to "do something." Included in the script above, it's possible to change the color and the font, and to make a text substitution in real-time. I think all of these can be done fairly similarly through pre-chewed data, but I'm trying to visualize whether there's other things that would be affected. Tags, done in this particular way, can do some pretty wild things, including adding embedded pictures with padded word wrapping, playing sounds or screen effects, creating objects... By adding another if into the script, new kinds of tags are a breeze; any pre-formatting would have to undergo some surgery. But it might be worth it!
You can see a pre-compilation example with my asset on the market place called ctb. it's free and requires no credits so you can look at the code and get inspiration maybe c:

The way i built it you can add colors, fonts, change the speed, play a sound, and even add your own custom tags.

I added an example custom tag that allows having an avatar image in the textbox for example.

The textbox also supports automatically continuing on a new page if the text doesn't fit.

It's a complex code but i think you'd learn how to modify your script from a working example.
 
S

Smarty

Guest
@Smarty and @CedSharp
There's a few tricksy things to work out, though, that would need a bit of a compromise. One thing that this all-in-one script does rather particularly nicely is markup tags. It's very easy, on-the-fly, for the script to pick up anything inside of [brackets] and to "do something." Included in the script above, it's possible to change the color and the font, and to make a text substitution in real-time. I think all of these can be done fairly similarly through pre-chewed data, but I'm trying to visualize whether there's other things that would be affected.
Pre-processing doesn't change that. You simply have two different representations of the data: one that benefits humans (the tagged texts), and one that benefits your computer when drawing (by organizing the data in a much more efficient manner). Text substitutions should also be thought of as tags, with a reference to the variable to print. Usually you'd keep such variables in a ds_map structure so you can reference by name. The same could be done for images, sounds, and objects.

Tags, done in this particular way, can do some pretty wild things, including adding embedded pictures with padded word wrapping, playing sounds or screen effects, creating objects... By adding another if into the script, new kinds of tags are a breeze; any pre-formatting would have to undergo some surgery. But it might be worth it!
I do not see scenarios that cannot be done by using some pre-processing. But here are a few other reasons to do it (apart from performance and translation convenience):
  • By pre-processing your text strings, you actually validate that strings contain valid tags (no typos in tag names, tags properly closed) at startup time. This is much better than running into unexpected errors at game time. Assuming you have a pre-filled ds_map with all accessible variables, you can even make sure that variable references point to valid entries in the map.
  • You can split off and simplify your scripts, making them easier to read and change.
For sure, every new tag requires changes in two places, but overall the entire system becomes more robust, faster to execute, and easier to maintain.
 
Last edited by a moderator:
Top