Code Snippet Monday: Implementing a menu system in Pico–8

pico8 #gamedev #patreon #snippet-monday

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.

Step 1: Everybody needs OO

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

Step 2: Draw a box

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

Step 3: Populating our menu

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")

Step 4: Selecting the current entry

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

Step 5 - Adding numerical menu entries

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)

Step 6 - Adding functionality to the numerical sliders

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!