The menu system in action
As part of a future project, I needed a UI to tweak numerical values and toggle various booleans on and off. While pico–8 provides a way to hook into the pause menu by using the menuitem
function call, this wasn’t enough. But writing your own menu system is not hard, and this article will walk you through my implementation. This first part shows how to implement the standard menu entries. A second post will detail how the numerical entry widgets were built.
I am a C++ programmer by day, and think in classes. My first step is thus to paste in my favorite OO boilerplate. This allows me to group common behavior under a class name, and makes it easier to avoid passing self
to every second function. I don’t use inheritance all that often (I prefer composition), but UI programming is one domain where it works out well. In this simple menu system, we won’t actually use it, but I’m providing the small subclass
function as reference.
function class (init)
local c = {}
c.__index = c
c._ctr=init
function c.init (...)
local self = setmetatable({},c)
c._ctr(self,...)
return self
end
return c
end
function subclass(parent,init)
local c=class(init)
return setmetatable(c,{__index=parent})
end
We are now ready to draw the menu itself. We create a class cls_menu
, with a draw
and an update
method that get called from the respective main functions. We hardcode a width and a height for the menu, and draw a refined gray and white rectangle.
cls_menu=class(function(self) end)
function cls_menu:update() end
function cls_menu:draw()
local w=64
local h=32
local left=64-w/2
local right=64+w/2
local top=64-h/2
local bottom=64+h/2
rectfill(left,top,right,bottom,5)
rect(left,top,right,bottom,7)
end
local menu=cls_menu.init()
function _update()
menu:update()
end
function _draw()
cls()
menu:draw()
end
Our empty box looks very sad. It is time to populate it with some text entries. The more entries, the higher the box should get. We also want to preserve a small border to avoid having a cramped menu. We store our menu entries as simple strings in a table entry called entries
. Some iteration and some offset magic, and our menu can now display strings.
cls_menu=class(function(self)
self.entries={}
end)
function cls_menu:update() end
function cls_menu:draw()
local h=8
for entry in all(self.entries) do h+=8 end
local w=64
local left=64-w/2
local right=64+w/2
local top=64-h/2
local bottom=64+h/2
rectfill(left,top,right,bottom,5)
rect(left,top,right,bottom,7)
top+=6
local y=top
for entry in all(self.entries) do
print(entry,left+10,y)
y+=8
end
end
function cls_menu:add(text)
add(self.entries,text)
end
local menu=cls_menu.init()
menu:add("entry 1")
menu:add("entry 2")
menu:add("entry 3")
Our menu is quite useless, as we can’t even select an entry. We now introduce a new class, called cls_menuentry
. This class contains the text, the size and a callback for each menu entry. Instead of using a raw string, we store instances of this class in self.entries
. This allows us to set callbacks, and to have entries of varying sizes, which will come in handy in the future.
cls_menuentry=class(function(self,text,callback)
self.text=text
self.callback=callback
end)
function cls_menuentry:draw(x,y)
print(self.text,x,y,7)
end
function cls_menuentry:size()
return 8
end
function cls_menuentry:activate()
if (self.callback!=nil) self.callback(self)
end
function cls_menuentry:update()
end
When the X button is pressed, the callback is activated. To illustrate the callback mechanism, we use it to control a red circle traveling the background of our screen. Activating the menu entry will display/hide the circle, and toggle the entry text.
Further more, when the arrow keys are pressed, we change the current entry.
cls_menu=class(function(self)
self.entries={}
self.current_entry=1
end)
function cls_menu:update()
local e=self.current_entry
local n=#self.entries
self.current_entry=btnp(3) and tidx_inc(e,n) or (btnp(2) and tidx_dec(e,n)) or e
if (btnp(5)) self.entries[self.current_entry]:activate()
self.entries[self.current_entry]:update()
end
All this input handling is nice, but without feedback to show the user which entry is selected, our code is useless. After drawing a beautiful arrow sprite in pico8’s sprite editor, we can now display a slightly offset current entry, and use our new cls_menuentry:size()
method.
function cls_menu:draw()
local h=8
for entry in all(self.entries) do
h+=entry:size()
end
local w=64
local left=64-w/2
local right=64+w/2
local top=64-h/2
local bottom=64+h/2
rectfill(left,top,right,bottom,5)
rect(left,top,right,bottom,7)
top+=6
local y=top
for i,entry in pairs(self.entries) do
local off=0
if i==self.current_entry then
off+=1
spr(2,left+3,y-2)
end
entry:draw(left+10+off,y)
y+=entry:size()
end
end
Putting it altogether, with our circle drawing code, we get:
local radius=1
local spd=2
local pos_x=1
local draw_circle=true
local menu=cls_menu.init()
menu:add("hide circle",
function(self)
if draw_circle then
draw_circle=false
self.text="draw circle"
else
draw_circle=true
self.text="hide circle"
end
end)
menu:add("entry 2")
menu:add("entry 3")
function _update()
menu:update()
pos_x=(pos_x+spd)%128
end
function _draw()
cls()
if (draw_circle) circfill(pos_x,64,radius,8)
menu:draw()
end
We don’t only want to toggle values, but we also want to adjust numerical values. I decided to copy the slider used in the sprite editor. We are going to use duck typing here. Instead of having the numerical slider be a subclass of the text menu entry, we are going to implement the same methods: draw
, size
, update
and activate
.
A numerical slider has a text and a callback, just like a normal menu entry. The text entry on activation expands to show a numerical slider. This changes the size of the entry. We store the state of the entry (if it is open or not) in a local variable state
.
cls_menu_numberentry=class(function(self,text,callback)
self.text=text
self.callback=callback
self.state=0 -- 0=close, 1=open
end)
Activating the entry unfolds it, which makes it bigger.
function cls_menu_numberentry:size()
return self.state==0 and 8 or 18
end
function cls_menu_numberentry:activate()
if self.state==0 then
self.state=1
else
self.state=0
end
end
We then draw a couple of of lines and a new pointer sprite to draw the slider. Currently, our slider stays at 0.
function cls_menu_numberentry:draw(x,y)
if self.state==0 then
print(self.text,x,y,7)
else
print(self.text,x,y,7)
local off=10
local w=24
local left=x
local right=x+w
line(left,y+off,right,y+off,13)
line(left,y+off,left,y+off+1)
line(right,y+off,right,y+off+1)
line(left+1,y+off+2,right-1,y+off+2,6)
local pct=0
print(0,right+5,y+off-2,7)
spr(1,left-2+pct*w,y+off-2)
end
end
All that is left is to add a couple of numerical entries to the menu.
local e=cls_menu_numberentry.init("radius", function() end)
add(menu.entries,e)
local e=cls_menu_numberentry.init("spd", function() end)
add(menu.entries,e)
Our sliders are pretty, but they don’t really do much. We want the slider to go from a minimum value to a maximum value, in increments of a certain size. Using the arrow keys will call the callback on every change. The code changes required are minimal. We add the relevant values as member variables. Note that we call the callback on initialization to ensure that our callback is synchronized with the actual slider value.
cls_menu_numberentry=class(function(self,text,callback,value,min,max,inc)
self.text=text
self.callback=callback
self.value=value
self.min=min or 0
self.max=max or 10
self.inc=inc or 1
self.state=0 -- 0=close, 1=open
if (self.callback!=nil) self.callback(self.value,self)
end)
We also initialize our menu entries accordingly.
local e=cls_menu_numberentry.init(
"radius",
function(v) radius=v end,
1,1,10)
add(menu.entries,e)
e=cls_menu_numberentry.init(
"spd",
function(v) spd=v end,
10,1,20)
add(menu.entries,e)
The drawing code needs to be updated to draw the arrow pointer at the right location, and display the current value.
local pct=(self.value-self.min)/(self.max-self.min)
print(tostr(self.value),right+5,y+off-2,7)
spr(1,left-2+pct*w,y+off-2)
The only missing part of the puzzle is actually changing the slider value!
function cls_menu_numberentry:update()
if (btnp(0)) self.value=max(self.min,self.value-self.inc)
if (btnp(1)) self.value=min(self.max,self.value+self.inc)
if (self.callback!=nil) self.callback(self.value)
end
Now going on your merry ways tweaking values in game, and get hyped for the next code snippet monday!