-
Notifications
You must be signed in to change notification settings - Fork 18
Object Oriented Programming in OpenNefia
OpenNefia adds an OOP layer on top of Lua. It is a heavily modified version of 30log with support for interfaces, (broken) delegation and the hotloading system.
Everything related to making new classes or interfaces (mixins) is contained in the class
module, which is globally included, so you don't have to require
it.
Here are the basics.
Registers a new class. Classes are kept track of internally by the class
module, so each call to class.class()
will register a new one. You should provide the same name
to this function as you use for the name of the file you keep the class in.
At the end of the file you should return the table that class.class
returns as a top-level return. This will allow it to be compatible with the hotloading system, because the Lua table to hotload onto is taken from this return value:
-- in MyClass.lua
local MyClass = class.class("MyClass")
function MyClass:init()
end
-- (define other methods...)
return MyClass
Some methods on objects using the metatable that `class.class` returns have special meaning:
The constructor. Assign variables to `self` in this function that you would like to keep when this object is instantiated.
function MyClass:init(num, str)
self.num = num
self.str = str
self:some_other_method()
end
Autogenerated static method that calls init
internally. You should not define this function yourself.
local MyClass = require("mod.example.api.MyClass")
local instance = MyClass:new(1234, "hello")
print(instance.num, instance.str)
Method called before this class gets serialized to save data.
Method called after this class gets deserialized from save data.
Function called when this class is hotloaded. The default implementation calls class.hotload
, which handles things like propagating default interface methods to all classes that use the interface. If you want to add custom logic when the class gets hotloaded, you can do this:
function MyClass.on_hotload(old, new)
-- Run your custom logic here.
-- Call class.hotload to update the internal class bookkeeping structures.
class.hotload(old, new)
end
Note that this is a function, not a method (it does not use colon syntax).
Any metamethod that is supported by LuaJIT is also supported by classes, like __tostring
or __eq
.
function MyClass:__tostring()
return ("My name is %s."):format(self.name)
end
function MyClass:__eq(other)
return self.name == other.name
end
Delegation system. This static method allows you to call a method or access a field on this object by accessing the same named function/field on a field of an instance of this class. To demonstrate:
local Child = class.class("Child")
function Child:init()
self.my_value = 42
end
function Child:child_method()
print("Calling the child's method")
end
local Parent = class.class("Parent")
function Parent:init()
self.the_child = Child:new()
end
Parent:delegate("the_child", { "my_value", "child_method" })
local parent = Parent:new()
print(parent.my_value) -- prints 42
parent:child_method() -- prints "Calling the child's method"
Note however that if both the parent and the child contain the same named field, the parent's field is used instead. (This is due to the fact that delegation uses the __index
metamethod.)
local Child = class.class("Child")
function Child:init()
end
function Child:the_method()
print("Calling the child's method")
end
local Parent = class.class("Parent")
function Parent:init()
self.the_child = Child:new()
end
function Parent:the_method()
print("Calling the parent's method")
end
Parent:delegate("the_child", { "the_method" })
local parent = Parent:new()
parent:the_method() -- prints "Calling the parent's method"
Delegates are global across all instances of the class.
When a delegated method gets called, the self
that gets passed to it is the delegate field, not the class instance with the delegation. This can lead to confusion if you were intending to replace a method defined on the child field instead. The reason for this is if the parent is passed as self
, then fields that exist in the child but are nil
on the parent may end up being accessed by the child's method that was delegated to, which will cause errors. If the child is passed, then all its internal state can still be used by the delegated method.
You can also use delegate
with an interface:
local IChild = class.interface("IChild", { my_value = "number", child_method = "function" })
Parent:delegate("the_child", IChild)
This is equivalent to:
Parent:delegate("the_child", { "my_value", "child_method" })
Creates a new interface. Interfaces cannot be instantiated, but can be instead added to a class to impose additional requirements on its implementation or add default methods. This allows you to easily test if an object obeys a contract, and answer questions like "can I put items into this object" or "is this object a map/character/item".
local IPositional = class.interface("IPositional", { x = "number", y = "number" })
local Putit = class.class("Putit", IPositional)
We enforce the usage of I[A-Z]
as a prefix for interface names to be able to identify them easily.
You should keep each interface in a separate file, like for classes, and return the table this function creates at the top level.
-- in IExample.lua
local IExample = class.interface("IExample", { value = "number" })
return IExample
Interfaces are checked at runtime, each time an object is instantiated. If the instantiated object does not obey the contract, an error is thrown.
local IPositional = class.interface("IPositional", { x = "number", y = "number" })
local Putit = class.class("Putit", IPositional)
function Putit:init(x, y)
self.x = x
self.y = y
end
local bad = Putit:new(10, "Scut!") -- error!
local good = Putit:new(10, 20) -- OK
You can attach multiple interfaces to a class at once, like so:
local IPositional = class.interface("IPositional", { x = "number", y = "number" })
local INamed = class.interface("INamed", { name = "string" })
local Putit = class.class("Putit", { IPositional, INamed })
You can combine multiple interfaces into a single interface, while adding its own requirements:
local IPositional = class.interface("IPositional", { x = "number", y = "number" })
local INamed = class.interface("INamed", { name = "string" })
local IAlive = class.interface("IAlive", { is_alive = "boolean" }, { IPositional, INamed })
local Putit = class.class("Putit", IAlive)
You can also add default implementations of functions on interfaces, to save boilerplate when the function implementation will not change the vast majority of the time. (In a sense this makes the interface system closer to abstract classes than true OOP interfaces.)
local IQuotable = class.interface("IQuotable", { say_quote = "function" })
function IQuotable:say_quote()
print("Did you *really* eat that?")
end
local Lomias = class.class("Lomias", IQuotable)
The above feature can be used to implement "super" methods:
local Larnneire = class.class("Larnneire", IQuotable)
function Larnneire:say_quote()
IQuotable.say_quote(self) -- call "super" method
print("No, I did not.")
end
When an interface is hotloaded, all methods defined like this will be recursively copied to all class instances that use the interface, including game objects.
Tests if obj
is either an instance of a concrete class, or uses an interface.
local INamed = class.interface("INamed", { name = "string" })
local MyClass = class.class("MyClass", INamed)
function MyClass:init(name)
self.name = name
end
local instance = MyClass:new("Lomias")
assert(class.is_an(MyClass, instance))
assert(class.is_an(INamed, instance))
If the instance is modified such that an interface's requirements are no longer satisfied after the initial validation from calling new
(like setting a field to nil
or the wrong type), then the check still will fail.
Say you want to modify some functionality of a class, but do not want to use delegation. As an example, let's say you want to draw a putit's sprite next to every entry in a UiList
. The standard UiList
is drawn like so:
Instead of using subclasses, the recommended way of accomplishing this is using prototype-based modification. To do this, create a new function that captures the self
of the object you're instantiating. Then, call that function after you instantiate the "base class" of the object you want to modify and merge the functions it returns onto the object's instance using table.merge
. Here is an example of how this is done:
local ListExtExample = class.class("ListExtExample", IUiLayer)
-- This function takes the instance of the UI layer we will instantiate and returns a table of methods to merge into a UiList.
local UiListExt = function(parent_menu)
local E = {}
function E:draw_select_key(item, i, key_name, x, y)
-- Draw the putit sprite. We load this in the constructor of our UI layer, and the layer gets captured as the `parent_menu` parameter.
parent_menu.putit_sprite:draw(x - 12, y - 12, 24, 24)
-- What we do here is call the "super" method of `UiList`, passing in all the parameters and `self`. By doing this you have a lot of flexibility in how to draw things.
UiList.draw_select_key(self, item, i, key_name, x, y)
end
return E
end
function ListExtExample:init()
self.putit_sprite = atlas:copy("elona.chara_putit")
-- Instantiate a fresh copy of a UiList.
self.list = UiList:new({ "apples", "bananas", "oranges" })
-- Generate the methods to override on the list instance. Pass `self` in order to be able to use `self.putit_sprite` within them.
local methods = UiListExt(self)
-- Merge the new methods onto the list instance.
table.merge(self.list, methods)
end
Remember: don't be afraid to go poking around in the source code to understand how things work internally, like the code for UiList
- this is useful for understanding how to write new prototype methods like the ones returned by UiListExt
in this example.
Note that classes "extended" in this way still count as the original class for purposes of typechecking with class.is_an
.
Proceed to Engine Features.