Skip to content

Creating User Interfaces

Ruin0x11 edited this page Apr 7, 2020 · 4 revisions

OpenNefia allows you to create new user interfaces. This page will walk you through how to do this and some common pitfalls.

Creating a Menu

Let's create a new UI menu, using OpenNefia's interactive programming features. You should be familiar with the Object-Oriented Programming overview, so please read it first if you haven't done so.

First, start OpenNefia and quickstart the game. What we will do is write the code for our UI menu and load it into the game at runtime, then see the changes we've made instantly.

Create a new mod manifest and save it in src/mod/ui_example:

return {
   id = "ui_example",
   dependencies = {
      elona_sys = ">= 0",
   }
}

We will be using elona_sys, as it contains all the reusable components in Elona's UI. Hotload this file, and you should see some log output indicating that a new mod was loaded:

We have just created and loaded a new mod, without the need for any restarts.

Next, create a new file named src/mod/ui_example/api/MyMenu.lua, and copy the following code into it:

local IUiLayer = require("api.gui.IUiLayer")
local MyMenu = class.class("MyMenu", IUiLayer)

function MyMenu:init()
end

function MyMenu:relayout(x, y, width, height)
end

function MyMenu:draw()
end

function MyMenu:update()
end

return MyMenu

What's going on here?

  1. We've imported IUiLayer, the interface required for all interactive UI menus and similar.
  2. We've created a new class, MyMenu, which will implement IUiLayer.
  3. We've added init(), the method called when instantiating a new instance of MyMenu.
  4. We've added the relayout(), draw(), and update() methods, which are required for classes implementing IUiLayer.
  5. At the end of the file we make sure to return the MyMenu table that class.class created for us, so we can require("mod.ui_example.api.MyMenu") later.

:init()

:relayout(x, y, width, height)

:draw()

:update(dt)

However, we haven't satisfied IUiLayer's requirements yet. If you check the source of IUiLayer under src/mod/elona_sys/api/gui/IUiLayer.lua, you'll see that it requires the IDrawable and IInput interfaces. IDrawable is satisfied since we added draw() and update() methods. To satisfy IInput, we'll create a new instance of an InputHandler and tell our menu to delegate all keyboard and mouse input to it.

local IUiLayer = require("api.gui.IUiLayer")
local IInput = require("api.gui.IInput")
local InputHandler = require("api.gui.InputHandler")

local MyMenu = class.class("MyMenu", IUiLayer)

MyMenu:delegate("input", IInput)

function MyMenu:init()
  self.input = InputHandler:new()
end

function MyMenu:relayout(x, y, width, height)
end

function MyMenu:draw()
end

function MyMenu:update()
end

return MyMenu

We create a field called input in the constructor and delegate all the requirements of IInput to it (since InputHandler implements IInput).

One more thing that we should do is make sure we have some way of cancelling out of this menu, so we don't get stuck inside it. To do so, bind some keys to self.input in the constructor.

function MyMenu:init()
  self.canceled = false
  self.input = InputHandler:new()
  self.input:bind_keys {
    shift = function() self.canceled = true end
  }
end

Then, change the update function to look like this (we'll go into more detail about this shortly):

function MyMenu:update()
  if self.canceled then
    return nil, "canceled"
  end
end

At this point this is all we need to actually open this menu as a proper UI layer in-game. So, let's go ahead and do so. Open up the REPL by pressing ` (that's the grave key) and load the menu's class.

> MyMenu = require("mod.ui_example.api.MyMenu")
nil

Now let's query the menu. Every class implementing IUiLayer will get a method called query(), which will push the layer onto the stack of focused layers.

> MyMenu:new():query()

If all goes well, you should see... nothing. That's because we didn't put anything in the draw() method, and IUiLayer will make no assumptions about what ought to be drawn. You'll also note that no keypresses will do anything except for Shift, which will stop querying the menu. Try pressing Shift to return to the REPL, then call MyMenu:new():query() again.

Now let's start drawing things. Without exiting the menu or the game, modify MyMenu.lua to import the Draw API:

local Draw = require("api.Draw")

Then, modify the draw() method of MyMenu like so:

function MyMenu:draw()
  Draw.set_color(255, 255, 255)
  Draw.filled_rect(100, 100, 200, 200)
end

With your changes in place, hotload MyMenu.lua. If all goes well, you should see a white square being drawn at absolute coordinates (100, 100).

Suppose something goes wrong and an error appears on the screen. Try adding a call to error("blah") somewhere in draw() or update() and hotload the file again.

In most cases, all you have to do to correct the problem is to edit your code and hotload the file again. Save for cases that use global or local state that can't be hotloaded, most of the time you should be able to pick up execution right where you left off. If not, you can press Backspace to pop off the draw layer at the top of the stack (in this case, the instance of MyMenu we created) and return to the REPL.

All functions in Draw operate in absolute screen space. Play around with this a bit by changing the color passed to Draw.set_color or the rectangle's coordinates/size and hotloading the file again. You can also check out functions like Draw.line(x1, y1, x2, y2), Draw.line_rect(x, y, width, height) or Draw.text(text, x, y, color, size). Do note, however, that these functions in the Draw API must be called in the draw() method, not update().

Of course, this is fairly low level, and you probably don't want to have to implement things like window drawing or positioning all by yourself. That's why elona_sys comes with a set of pre-made UI components used by Elona in various places. To properly use these components, we have to understand a bit more about the draw-and-update cycle of UI layers.

The UI Lifecycle

TODO