onlytomorrow void

writing into the void!

"Love2D: fancy formatted text tooltips using an RPG textbox library"

Official void Idlenet log #1, and it's about UI. Boring.

posted on 2024-08-02 by: kirby | 12 min read


Preface

This is the first blog post (development log?) for my work-in-progress game Idlenet, made with the Love2D framework in Lua. The bulk of working on the game’s “graphics” is making the layout not look like a complete mess at first glance like most idle games. I most recently finalized some “stylish” tooltips1 with formatted text using a handy library — SYSL-Text (slog-text), to replace the richtext library I had used previously. slog-text is still maintained and has a lot of useful features, such as a custom way of rendering fonts as bold or italic, a more robust tag system for formatting strings, and better wrap + image support.

richtext worked well for a while, but the lack of support for a smooth use of bold or italic fonts was a bit of a dealbreaker. As you will see later, the input and output using slog-text is more easier to use.

The situation of formatted text in Love2D

By default, on the surface, Love2D’s formatted text support through love.graphics.print() is very limited. The font rendering is poor,2 and more often than not the support for pixel-perfect bitmap-style fonts is also limited. However, it is a good foundational base for getting text quickly written on the screen.

love.graphics.printf

Enter love.graphics.printf(): a more “advanced” function, allowing you to assign colors to specific chunks of the string, for example (from the L2D wiki):

love.graphics.printf( coloredtext, x, y, limit, align, angle, sx, sy, ox, oy, kx, ky )

Where coloredtext is a table in the form of {color1, string1, color2, string2, ...}, where color is a table and string is a string. The function also allows for wrapping text with the limit parameter, and basic text alignment with align.

Unfortunately, having text where specific words could be bolded or italicized is not exactly smooth using only these functions. If we’d want to make a word bold, we need to make a separate function call, after setting the current Font to a bold one. For example:

tamzen_13r = love.graphics.newFont('./res/Tamzen7x13r.ttf')
tamzen_13b = love.graphics.newFont('./res/Tamzen7x13b.ttf')

love.graphics.setFont(tamzen_13r)
local reg_text = "Regular text, but I want to make"
love.graphics.printf(reg_text, x, y)

love.graphics.setFont(tamzen_13b)
love.graphics.printf(" this text bold", x+tamzen_13r:getWidth(reg_text), y)

And so on, but you get the idea - a lot of extra lines and extra math for finding out where to place the bold text after the regular text. I’m not even sure if that above example would display correctly; it doesn’t account for spaces, word wrapping, or text alignment. Even at first glance it would look like this would not scale well for the kinds of elaborate tooltips I’d like for the game. I could waste my time writing a sloppy implementation, so I opted to look for libraries.

Libraries

For a while, I was using gvx’s richtext library for styled text. This worked well for a while, all I really wanted was simple tags and color. At first, I was willing to accept that bold and italics couldn’t work, and that I would work around the implementations and look for something more clever in presentation.3 For the sake of readability, I opted for colouring certain keywords in the text for signifying emphases or side notes in tooltips. But this was quickly looking ugly and like colour vomit, especially for the sharp contrasting colours I desired for actual keywords. There’s the possibility of fatiguing a reader especially if you’re throwing these tooltips at their faces in the moment-to-moment “gameplay”.

richtext wasn’t good enough

The richtext library was last updated almost 6 years ago, and there is a single pull request for fixing a case of misbehaving word wrapping. The word wrapping especially was a pain point for me, as it didn’t appear to cut off properly with how I was calculating tooltip box widths. This got on my nerves really fast, and I tried to come up with some hacky solutions for getting it correct: by taking the original string (with its tags) and making my own word wrap calculation simply to get the height correct.

With that in mind, I went into richtext.lua and added a function to grab the height of a post-formatted string. I had not many choices with this library since I needed to call doRender() correctly and grab the appropriate object.

function rich:getHeight()
    local renderWidth = self.width or math.huge
    local lines = doRender(self.parsedtext, renderWidth, self.hardwrap)
    -- add half height of last line to the bottom of height, ensuring tails of y's, g's, etc to fit
    return self:calcHeight(lines) + math.floor((line[#lines].height / 2) + 0.5)
end

Then, when I needed to draw the rectangle, I had this function run during the draw event:

local function string_split(str)
    local lines = {}
    for line in str:gmatch("[^\r\n]+") do table.insert(lines, line) end
    return lines
end

function inet_theme.drawTooltip(opt)
    -- ... mouse pos code here
    local tooltip_width = window.tooltip_width
    love.graphics.setFont(Fonts.tamzen_13r)
    local text = richtext:new( { opt.tooltip, tooltip_width, Fonts } )
    local raw_tooltip = string.gsub(opt.tooltip, "{%a+}", "")
    local lines = string_split(raw_tooltip)
    local largest_width = tooltip_width
    if (#lines > 1) then
        for i,line in ipairs(lines) do
            local width = Fonts.tamzen_13r:getWidth(line)
            if width > largest_width then largest_width = width end
        end
    end
    local line_count = math.ceil(#lines + (largest_width / tooltip_width))
    local tooltip_height = Fonts.tamzen_13r:getHeight() * line_count
    tooltip_width = tooltip_width + (text_padding + (largest_width / tooltip_width))
    if ((ox+tooltip_width) > love.graphics.getWidth()) then
        local diff = (ox+tooltip_width) - love.graphics.getWidth()
        ox = ox - (diff + window.padding)
        oy = oy + window.padding
    end
    love.graphics.setColor(.25,.25,.25)
    love.graphics.rectangle("fill", ox+2, oy-2, tooltip_width-2, tooltip_height-2)
    love.graphics.setColor(1,1,1,1)
    love.graphics.rectangle("line", ox+1, oy-1, tooltip_width-1, tooltip_height-1)

    text:draw(ox+text_padding, oy+text_padding)
end

Basically:

  1. We grab the raw tooltip without its tags using string.gsub.
  2. Split all of the lines by newlines and count how many lines there are total in the string.
  3. Get the height of the current font with font:getHeight and multiply with line_count.
  4. ???
  5. Profit.

Disclaimer: it will be more obvious later, but I scrapped this system (drawTooltip()) entirely after switching to SYSL-Text, since its wrapping is far more polished. Though the rectangle drawing will be adapted later.

In essence, this has the richtext object calculate its own height twice, which wasn’t sitting well with me. Not only that, but if a tooltip had excessive tags (such as {green} to change the text colour to green), these characters would for some reason screw up all of the height calculation, and there would be an off chance of some characters getting cut off past the box’s width. With all of that being said, this wasn’t going to work, and I wasn’t in the mood to try and open another PR or issue on a library that has long been abandoned.

RPG textboxes

Enter SYSL-Text. This is an extensive library for rendering text boxes for RPG games, with options to render the text character-by-character, with its own sound, much like Undertale and its derivatives and influences.

slog-text-style

Obviously, I was never going to use it this way. The library assumes you’re only going to use one textbox for the entire game, which was completely understandable for that use case, but I was going to have the textbox appear relative to wherever the mouse is, and updating the text on the fly in response to changes in game state.

slog-text

In the above Mother-style textbox example, the git repo creates the text boxes in love.load():

example2box = Text.new("left", 
{ 
    color = {1,1,1,1},
    shadow_color = {0.5,0.5,1,0.4},
    font = Fonts.golden_apple,
    character_sound = true,
    adjust_line_height = -3
})
example2box:send("• Do you like eggs?[newline]• I think they are [pad=6]eggzelent![audio=sfx=laugh]", 100, false)

The intended usecase was still slightly possible, as the text boxes have update(dt) events for use in love.update(). With that in mind, Idlenet initializes two text boxes in love.load():

player.mouse.tooltip_box = Text.new(...)
player.mouse.held_tooltip_box = Text.new(...)

The reasoning is this: the player can “hold” items when <LMB> is being held down — so we need a tooltip to signify that; hence held_tooltip_box. Of course, there is the generic tooltip on item hover, which is tooltip_box.

Sending text

Regular buttons in Idlenet have an opt.tooltip string variable. Basically, any time the mouse is hovered:

function inet_suit_theme.Button(text, opt, x,y,w,h)
    -- ... love.draw() code here
    if opt.state == "hovered" and opt.tooltip then
        Idlenet.player.mouse.pause_reg_tooltips = false
        Idlenet.player.mouse.tooltip_box:send(opt.tooltip, tooltip_w, true)
    end
end

Similar code runs when we are holding an item for held_tooltip_box. If you need further context, Idlenet uses the SUIT library for UI, which tracks states for mouse/keyboard input. I’ve hooked into the library’s theme.lua to provide a custom widget over the default Button for situations like these.

In the player’s update() function, the respective tooltip text boxes are updated only when the above requirements are met.

function player:update(dt)
    -- ... other updates here
    if self.mouse.any_hovered then self.mouse.tooltip_box:update(dt) end
    if self.mouse.tooltip ~= "" then self.mouse.held_tooltip_box:update(dt) end
end

I admit I could be checking something more readable than self.mouse.tooltip ~= "" but if it ain’t broke, don’t fix it.

Now, we have a similar check in love.draw() to make sure empty boxes aren’t drawn:

function theme.draw_tipboxes()
    -- ... local variables, offset setup
    local tooltip_width = game.window.ui.tooltip_w -- default value
    if ((ox + tooltip_width) > game.window.width) then
        local diff = (ox + tooltip_width) - game.window.width
        ox = ox 0 (diff + padding)
    end
    if mouse.tooltip ~= "" then
        oy = oy - held_tipbox.get.height - padding
        ui.Tipbox(...) -- draw the rectangle under the text
        held_tipbox:draw(ox + padding, oy + padding)
    end
    if mouse.any_hovered and not mouse.pause_reg_tooltips then 
        ui.Tipbox(...)
        reg_tipbox:draw(ox + padding, oy + padding)
    end
end

mouse.pause_reg_tooltips is set to true when no items are hovered. This had to be added because while held_tipbox would listen and not draw itself, there was consistently an empty box for reg_tipbox when nothing was hovered. This solution was the path of least resistance.

The result?

Now I can format my text in the style reminiscent of BBcode!

local tooltip = string.format("Item type: Compiled [color=3][b]Tool[/b][/color] file\n"..
    "Version [color=3]%d[/color]\n Size: %.2f KB", ...) -- you get the idea

Gradient

With SYSL-Text, I can get all this formatting done for a tooltip box with one (concatenated) string:

tooltip

In this screenshot, held_tipbox is above reg_tipbox, and held_tipbox’s oy is set with the line height calculation shown earlier.

But how does the gradient background work?

In order of draw events, the following happens:

  1. love.load(): we define the background colours and create a gradient:
-- col_a and col_b are tables with r,g,b (0..1) values
-- the gradient goes from top (a) to bottom (b)
function new_gradient(col_a, col_b, alpha)
    local a_r, a_g, a_b = unpack(col_a)
    local b_r, b_g, b_b = unpack(col_b)
    local img_data = love.image.newImageData(1, 2)
    img_data:setPixel(0, 0, a_r, a_g, a_b, alpha)
    img_data:setPixel(0, 1, b_r, b_g, b_b, alpha)
    local img = love.graphics.newImage(img_data)
    img:setFilter("linear", "linear")
    return img, alpha
end

I found this approach while looking through the Love2D forums for creating simple gradients. It essentially creates a 2 pixel image that we scale later according to our background box’s dimensions. With the linear filter it appears as if it’s a gradient.

  1. The ui.Tipbox() function actually works like this:
function theme.Tipbox(ox, oy, line_col, line_alpha, box, padding)
    local ui = Idlenet.ui_theme
    local l_r, l_g, l_b = unpack(line_col)
    local w, h = Idlenet.window.ui.tooltip_w + padding*2, box.get.height + padding
    local drop_shadow_offset = 4
    local gradient, a = ui.reg_tipbox_gradient, ui.reg_tipbox_gradient_a
    if box == Idlenet.player.mouse.held_tooltip_box then
        gradient, a = ui.held_tipbox_gradient, ui.held_tipbox_gradient_a
    end
    -- drop shadow
    love.graphics.setColor(love.math.colorFromBytes(10, 10, 10, 150))
    love.graphics.rectangle("fill", ox+drop_shadow_offset, oy+drop_shadow_offset, w+drop_shadow_offset, h+drop_shadow_offset*2)
    -- outline
    love.graphics.setColor(l_r, l_g, l_b, line_alpha)
    love.graphics.setLineWidth(3)
    love.graphics.rectangle("line", ox, oy, w, h)
    love.graphics.setLineWidth(1)
    love.graphics.setColor(1,1,1,a) -- reset alpha
    -- gradient
    love.graphics.draw(gradient, ox, oy, 0, w, h/2)
end

My first game-related programming language GameMaker Language always had these kinds of functions with a billion arguments that just drew a thing, so this was very comfy for me and I felt right at home.4 I wanted the held tipbox and regular tipbox to have the gradient kind of “merge” together to show that the two items can go together and be “mergable” ie. drag and drop to get a bigger number. Blah blah blah user experience and responsiveness. This is the core of the layer-1 gameplay of Idlenet, so having tooltips be managed like this is like managing a combat state or player movement, if that makes any sense. That’s all for this post.


Footnotes

  1. Listen, tooltips are an important feature in Idlenet, and they’re one of my favourite aspects of number-go-up games. When I’m playing Kittens Game and want to look at how much power I’m generating with hundreds of uranium reactors, I’ll hover over the button and see huge green numbers. When I’m playing Diablo or another ARPG, I’ll hover over the item and gaze at the colourful text, plus and percent signs, and big numbers.

  2. You can get around this by including love.graphics.setDefaultFilter("nearest", "nearest") before your game starts,5 and setting a font’s hinting to "none" when creating it. For example, love.graphics.newFont("tamzen_7x13b.ttf", 13, "none"). More information on the L2D wiki.

  3. I think the best games can come from limitations of technology at the time — I adore the PS2 library for this reason. It was the right amount of horsepower to push great graphics while having the limitations (aside from the console just straight up being a PITA to develop for) to force developers and designers to think outside of the box. I originally wasn’t going to use bolds and italics and make the game look more “terminal UI”-like in its overall UX presentation, but that was not going to scale well with the planned game mechanics.

  4. Really, I apologize for the verbosity in the functions and variable names. Using snake_case is a habit of mine (thanks to GameMaker), and the Lua manual examples also used snake_case, so I’m using it too even though Love functions use camelCase. I plan to make the game open source, and easily “moddable” in a way, and personally I’d prefer the extensive shit like Idlenet.player.mouse.tooltip_box to make it more clear6 in a hierarchical sense somehow. This all makes more sense in my head, and when I write it down it doesn’t really make any sense other than that it’s my personal preference. Deal with it.

  5. This pretty much disables any kind of anti-aliasing, but since my game is lower res (natively 720p) and uses fake-bitmap .ttf fonts, I’d prefer it that way. I’ll write a post on how window scaling all works soon.

  6. >readable, clear >uses a language that has 1-based arrays and defaults to the global scope

learn html flashpoint pm me plsss stay down chrome sux vegan nekoscape