writing into the void!
posted on 2024-08-02 by: kirby | 12 min read
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.
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.
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.
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”.
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:
string.gsub
.font:getHeight
and multiply with line_count
.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.
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.
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.
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.
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
With SYSL-Text, I can get all this formatting done for a tooltip box with one (concatenated) string:
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:
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.
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.
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. ↩
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. ↩
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. ↩
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. ↩
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. ↩
>readable, clear >uses a language that has 1-based arrays and defaults to the global scope ↩