NEONengine Devlog 4 - Text Layout

Reading time: 6 min

Hello World!

It’s been a hot minute since our last NEONengine devlog. This time, we will dive deep into the world of text layout. Before we begin, let’s go over every requirement we have:

  • Must be able to print text left-, center-, and right-justified.
  • Given a maximum column width, it must be able to wrap text.
  • Given only an x-coordinate, it must be able to center the text vertically
  • It must be able to draw a panel using a nine-patch.
  • It must be able to change the text color. Eventually, we’ll get fancy, but for now, it’s good enough to change the text color for a whole run of text, like in the NEONnoir dialogues.
An example of NEONnoir's dialogue system

We also have some requirements on how the API gets used:

  • It should print a string from a string ID
  • It should print a regular string (albeit without any special layout).
  • It should let us build text one string at a time.

The first two are pretty self-explanatory; give it a string, and it displays a string. The last one is how NEONnoir shows its more complex text systems. Take the above images as an example. If we ignore the speaker’s portrait, the dialogue is comprised of red text that is being spoken, followed by several lines, one for each dialog option, all framed by the nine-patch.

We build this by starting a text drawing context, repeatedly calling a function to put the text, then ending the context. At that point, we calculate the text size, draw the frame (if requested), then finally draw the text.

The Text Frame

I’ve just described the text frame that most of the text in NEONnoir displays. Before we try to create a new API for it, let’s use, as a starting point, the original Blitz Basic API.

NEWTYPE .TX_context
  max_width.w
  y_position.w
  add_frame.b
  justify.b
  center_vertical.b
  padding.b
End NEWTYPE

Statement TX_begin{context.l, add_frame.b, center_vertical.b}
Statement TX_end{context.l, x.w, y.w, should_wait.b}
Function .l TX_put_text{context.l, id.w, line_max.w}

It all begins with, well, TX_begin. We pass an empty context that gets filled out by the function. We can define whether we want a frame around it and whether it should be centered vertically. Justification is not part of the function call and must be set separately before any call to TX_put so that justification can change between each run of text. I did not need this level of flexibility, but I will keep it around as it’ll come in handy.

Next, we call TX_put for every string id, each with its maximum line width. The reasoning for this is to allow me to “mold” text around an image, as in the introduction sequence. With every string drawn, the context keeps track of the maximum width and updates the Y-position for subsequent strings.

The color change from CRT green to dull red was a hack. I would call a Blitz Basic function to change the blitting mode before calling TX_put and then switch it back for the dialogue options. Manually increasing the y_position increases the vertical spacing between strings.

The function returned a 32-bit value containing the maximum width of the text and the height, giving us the information we needed to create clickable regions over the text.

Finally, we call TX_end, supplying the frame’s coordinates, ignoring the y-coordinate if centering vertically. The last parameter, should_wait, causes the resulting text frame to wait for user input before clearing the text. Otherwise, the text has to be manually unbuffered. This last bit came in handy in the intro and credit scenes, where I didn’t have to worry about clearing the text from the screen.

“Modernizing” that API for C gives us something like this:

typedef struct _UwPoint
{
    UWORD uwX;
    UWORD uwY;
} UwPoint;

typedef struct _UwSize
{
    UWORD uwWidth;
    UWORD uwHeight;
} UwSize;

/**
 * @brief Defines how the text should be justified
 */
typedef enum _TextJustify
{
    TX_LEFT_JUSTIFY,
    TX_CENTER_JUSTIFY,
    TX_RIGHT_JUSTIFY
} TextJustify;

/**
 * @brief Contains information for the current text frame
 */
typedef struct _TextContext
{
    UWORD uwMaxWidth;
    UWORD uwY;
    UBYTE ubAddFrame;
    UBYTE ubCenterVertical;
} TextContext;

/**
 * @brief Starts a new text frame
 *
 * @param pContext Pointer to the context to initialize.
 * @param ubAddFrame if TRUE, a frame will be draw around the text
 * @param ubCenterVertical if TRUE, the text frame will be centered vertically.
 *
 * @see textEnd()
 * @see textPut()
 */
void textBegin(TextContext *pContext, UBYTE ubAddFrame, UBYTE ubCenterVertical);

/**
 * @brief Displays the text frame
 *
 * @param pContext The text context.
 * @param uwXY Coordinate to draw the text frame
 * @param ubShouldWait If TRUE, waits for user input and then clears the text.
 *
 * @see textBegin()
 * @see textPut()
 */
void textEnd(TextContext *pContext, UwPoint uwXY, UBYTE ubShouldWait);

/**
 * @brief Adds a string (by string id) to the text frame.
 *
 * @param pContext The text context.
 * @param uwStringId The id of the string to display.
 * @param uwMaxLength The maximum length of the line, causing the text to wrap
 *                  if necessary.
 * @param justification Left, center, or right text justification.
 * @param ubColorIdx Palette color index to use when drawing the text.
 * @return The size, of the displayed text.
 *
 * @see textBegin()
 * @see textEnd()
 * @see UwSize
 * @see TextJustify
 */
UwSize textPut(
        TextContext *pContext,
        UWORD uwStringId,
        UWORD uwMaxLength,
        TextJustify justification,
        UBYTE ubColorIdx);

/**
 * @brief Adds a number of new lines to the frame.
 *
 * @param pContext The text context.
 * @param uwCount The number of new lines to add.
 *
 * @see TextContext
 * @see textBegin()
 * @see textPut()
 */
void textPutNewLine(TextContext *pContext, UWORD uwCount);

The TextContext should be a stack variable initialized by textBegin(...). Note that the justification is no longer part of the context and is instead supplied to the textPut(...) function, along with a color index to use.

textPutNewLine(...) lets us add new lines without directly messing with the context.

One Thing Missing

The astute among my imaginary readers will have noticed we are still missing a few features. Namely, this is a lot of overhead if we want to print a simple non-wrapping line or do so with an arbitrary string rather than string id. So let’s add those bad boys.

/**
 * @brief Draw a string at the given coordinate.
 *
 * @param uwStringId The id of the string to draw.
 * @param uwXY The coordinate of the string.
 * @param justification The text justification.
 * @param ubColorIdx The text color.
 */
void textDraw(
        UWORD uwStringId,
        UwPoint uxXY,
        TextJustify justification,
        UBYTE ubColorIdx);

/**
 * @brief Draw a string at the given coordinate.
 *
 * @param pString Pointer to the string to draw.
 * @param uwXY The coordinate of the string.
 * @param justification The text justification.
 * @param ubColorIdx The text color.
 */
 void textDrawString(
        const char *pString,
        UwPoint uxXY,
        TextJustify justification,
        UBYTE ubColorIdx);

And voila! That should be all. Now let’s see if this documentation survives the implementation!

See you next game!