-
Notifications
You must be signed in to change notification settings - Fork 18
Creating User Interfaces
OpenNefia allows you to create new user interfaces. This page will walk you through how to do this and some common pitfalls.
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?
- We've imported
IUiLayer
, the interface required for all interactive UI menus and similar. - We've created a new class,
MyMenu
, which will implementIUiLayer
. - We've added
init()
, the method called when instantiating a new instance ofMyMenu
. - We've added the
relayout()
,draw()
, andupdate()
methods, which are required for classes implementingIUiLayer
. - At the end of the file we make sure to return the
MyMenu
table thatclass.class
created for us, so we canrequire("mod.ui_example.api.MyMenu")
later.
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.
TODO