Skip to content

Engine Features

Ruin0x11 edited this page Apr 7, 2020 · 18 revisions

This page contains various pieces of information on features or conventions of the engine.

Map Objects

Game objects in OpenNefia are represented by an X and Y coordinate on the map grid. Characters, items, and map features are all game objects.

Each map object comes with a uid field, which is a unique, monotonically increasing integer used for uniquely identifying the object. It is extremely important that this field is not manually modified, since it is used for bookkeeping throughout the engine.

All game objects are backed by an entry of a data type, such as base.chara, base.item or base.feat, and also use methods from an interface, such as IChara, IItem or IFeat. These interfaces also include IMapObject, which include some common methods shared across all game objects.

Here are some important methods of IMapObject:

  • IMapObject:set_pos(x, y): Sets the position of an object. This function must be called instead of setting the object's position directly (object.x = 42), because this updates data structures used for querying objects at a specific position in the map. (This might need to be redesigned at some point.)
  • IMapObject:remove_ownership(): Removes this object from the location containing it, making it garbage collectable. Use this function to delete game objects. See Object Ownership and Locations below for details.
  • IMapObject.location: Current location of this object. This field should not be changed to something else directly (see below).
  • IMapObject:current_map(): If the object's current location is a map, returns it, otherwise returns nil.
  • IMapObject:produce_memory(): Produces the data needed to display the item in the map. This data is freeform and interpreted by the rendering code. Important fields of this data include:
    • show (boolean): If true, show this object in the map, otherwise hide it.
  • IMapObject:refresh(): Refreshes this object, clearing and recaculating its temporary values. See Temporary Values below for more information.
  • IMapObject:clone(): Creates a copy of this object, assigning it a new UID.

Map objects are created with Chara.create(), Item.create(), or Feat.create(). Their syntax is as follows:

local object = Chara.create(chara_id, x, y, options, location)
  • chara_id: ID of the base.chara prototype to use.
  • x/y: Optional. X/Y coordinate on map. If omitted, selects a random open position.
  • options: Optional. List of options affecting creation.
  • location: Optional. Location to place this object in (see below). If omitted, defaults to the current global map.

You can iterate the objects in a map using Chara.iter() or similar:

for _, chara in Chara.iter() do
  Gui.mes(chara.name)
end

Object Ownership and Locations

Game objects can be contained in a "location". This is a data structure that fulfills the ILocation interface, and stores multiple game objects of a single type or multiple different types. The location can then be queried for a list of objects it contains. This is how game objects are spawned into the map.

Here are a few important methods of ILocation:

  • ILocation:take_object(object, x, y): Attempts to move an object into a location, removing it from its current location if it's already being owned.
  • ILocation:can_take_object(object, x, y): Returns true if the location can accept this object. This behavior is usually overridden by the object implementing ILocation, for example to handle inventory size constraints.
  • ILocation:move_object(object, x, y): Moves an object contained in this location to a new position.
  • ILocation:remove_object(object): Removes an object contained in this location.
  • ILocation:objects_at_pos(x, y): Returns an iterator of the objects at the given position.
  • ILocation:iter(): Returns an iterator of all the objects in the location.

For convenience, many functions for creating game objects (like Chara.create()) have an extra parameter specifying which location should receive the object, defaulting to the current global map (Map.current()) if omitted.

-- Create a character in the current global map.
Chara.create("elona.putit", 5, 5)

-- Create a character in a different map.
local map = InstancedMap:new(10, 10)
Chara.create("elona.putit", 5, 5, {}, map)

Due to this implicitness, it's important you remember to include the location parameter if you're passed a map object from elsewhere, to avoid accidentally modifying or deleting things in an entirely different map. (In the future there could be a "locking" mechanism which will throw an error if you accidentally do this during important events like map generation.)

If you don't want to associate a game object with a location, you can pass the ownerless parameter to the options of these functions, which will create a game object not attached to any location. This is useful if you're creating an object inside a menu that gets spawned after the player modifies it.

local putit = Chara.create("elona.putit", 5, 5, { ownerless = true })

Note: Characters, which use the IChara interface, also fulfill ILocation, and accept items. This represents the character's inventory (not their equipment, which is contained in the equip field). This way you can do something like Item.create("elona.putitoro", nil, nil, {}, Chara.player()) and have it work as you'd expect.

To delete objects from the game world, you'll end up calling IMapObject:remove_ownership() at some point. Internally, this calls ILocation:remove_object(). This method notifies the object's current location of its removal and updates some bookkeeping structures accordingly. After this is done, the object can be garbage collected once it goes out of scope, as long as it isn't referenced by any other data structure.

Temporary Values

There are many things that can temporarily affect the parameters of a game object, such as buffs or status effects. To keep track of these changes, each game object has a table, named temp, which contains a set of calculated values for every modified parameter, separate from the "base" values of the object. When a buff is applied, the temp table is cleared and all the values are recalculated. This is the same way temporary values are handled in vanilla Elona, except in OpenNefia any parameter can be modified in this way, not just a hardcoded set like DV/PV or stats.

This system is implemented using the IModdable interface, which is included in IMapObject. Here are some important methods of IModdable:

  • IModdable:mod(field_name, value[, method]): Modifies the temporary value of a field. This change will be cleared when :refresh() is called on the object. Essentially the same as object.temp[field_name] = value.
  • IModdable:mod_base(field_name, value[, method]): Modifies the base value of a field. Essentially the same as object[field_name] = value.
  • IModdable:reset(field_name, value): Clears a temporary value, setting its base value to the specified value.
  • IModdable:calc(field_name): Returns the temporary value of a field. You should use this instead of directly accessing the field on the object inside calculations to make it compatible with buffs/status effects.

It's possible to use the method argument of :mod() to change how the value is updated. For example, passing "add" will add to the value in temp instead of replacing it. This way, if you want to temporarily boost a character's DV by 20, irrespective of any previously applied buffs, you do chara:mod("dv", 20, "add"). Then, to retrieve this buffed value, you'd do local dv = chara:calc("dv").

Demonstration

To demonstrate how this works, let's try temporarily modifying the player's field of view. In-game, open the REPL and run this code:

Chara.player():calc("fov")

This should print 15, the default radius. Next, let's change this value temporarily using :mod():

Chara.player():mod("fov", 100)

This sets the temporary value of fov on the player to 100. As you can see, this lets the player see the entire map. Verify this calling Chara.player():calc("fov"), which should print 100.

Next, try to set the player's fov normally:

Chara.player().fov = 5

Notice that nothing changes. That's because you've previously set a temporary value for fov using :mod(), so the temporary value overrides the normal, unmodified one. For the purpose of terminology, regular table access/assignment changes the "base" value. In this case the base value for fov is now 5, and the temporary value is 100. Which one of the two is set determines the value that :calc() will return:

  • If both a temporary and base value are set, the temporary value is returned.
  • If a temporary value is not set, the base value is returned, if any.
  • If neither are set, then nil is returned.

Note that a temporary value cannot be nil, and setting a temporary value of nil is equivalent to removing it from the object.

Now let's refresh the player character:

Chara.player():refresh()

The player's FOV is now 5. Try setting the base value of the player and notice that this time it changes, since there's no temporary value in effect anymore:

Chara.player().fov = 100

It's possible to imagine creating a new item like a potion which applies a buff modifying the player's field of view using this system.

Using Temporary Values

So if you're building a mod, how would you use this? When you call :refresh() on a game object, it emits an event like base.on_chara_refreshed. Mods that want to modify something should bind an event handler to this event and set the appropriate temporary values using :mod() or similar. Then, if the values become outdated due to something like the character's equipment changing, call :refresh() on the object to recalculate all the changed values.

Remember that to access a temporary value, you must call :calc("some_value") instead of accessing the value as a property like player.some_value. The reason that the player's FOV was updated to the temporary value was because the internal code that handles FOV calculation calls player:calc("fov") instead of accessing it like player.fov:

self.map:calc_screen_sight(player.x, player.y, player:calc("fov") or 15)

As a result it is the developer's responsibility to use :calc() when appropriate. If a regular table access like player.fov is used instead of calling player:calc("fov"), then temporary values will not be used. This may be unintended if you want the property to changed based on things like buffs or traits.

The temporary values system was designed this way to reduce the amount of "magic" involved when trying to access a variable. Making temporary value access seamless by setting the __index metamethod on each game object led to too much confusion as to when a temporary value or a base value was being used. Also, this would ultimately mean you'd have to call a hypothetical function like :base() to get the "real", non-temporary value stored on the object anyways. To reduce confusion and make everything consistent, this means making :calc("fov") signify that temporary values apply, whereas property access like player.fov means the same thing it ought to mean in standard Lua - a plain table access.

Error Handling

When an uncaught error is thrown somewhere in the code, OpenNefia will attempt to print the error message and stack traceback in the game window.

This is normal, and in some cases you can resume game execution by pressing Enter without the need to restart the engine. Additionally, if you hotload any code while this message is displayed, the engine will clear it and resume executing. This can be quite useful if you make a minor mistake and want to fix the code interactively, and somewhat reduces the pain of breaking things.

Of course, support for this is flaky, and there still is much room for improvement. Even so, only being able to catch errors like this on the occasion can still save a lot of time that would have been spent restarting the engine from scratch.

Debug Breakpoints

You can pause the game at certain points to debug things by using the global pause() function. This will suspend the game at the point it is called and start a special REPL with all local variables in scope bound to the its environment. If you change any local variables bound in this way in the REPL, the changes you make will be reflected when you exit out. This can be useful for quickly debugging issues.

However, you should only use this function inside the :update() portion of UI layers, to avoid freezes and instability.

Draw Layers

OpenNefia uses a system where multiple game interface layers can be drawn on top of each other, and one layer at a time is focused and receives game input. When playing the game normally, the drawing order of layers usually looks like this:

  • Map renderer
  • HUD overlay
  • Menus, prompts, etc.
  • REPL (if active)

Any draw layer that can receive input implements the IUiLayer interface. This interface has a method named :query(), which will push the layer onto the stack and focus it for input. Here is an example, using Prompt, an IUiLayer for making a choice from several options:

local prompt = Prompt:new({"Yes", "No"})
local choice, canceled = prompt:query()

if canceled then return end
if choice.index == 1 then
   Gui.mes("You picked 'Yes'.")
end

:query() returns two values: a result value which differs depending on the draw layer, and a field indicating if the query action was canceled out of. This allows game logic to handle the case that a menu was canceled, and return early if so.

The internals of UI layers are covered in considerably more detail in Creating User Interfaces.

Additionally, the map renderer contains its own set of draw layers, where the main logic for drawing map objects on the screen is contained. In the codebase, these are found under internal/layer/.

There is currently a very early interface to add custom draw layers to the map renderer, but it is subject to a lot of change. This is used in a test mod, damage_popups, for showing damage popups.

UI Widgets

There is currently an early feature for adding "widgets" to the screen. This allows you to do things like display logs or status bars in an extensible manner. All the HUD components of the original Elona, like the clock and message window, are supported by this system. The idea is that mods should be able to add new HUD elements and rearrange things programatically without the need for hardcoding things.

You can see an example of this system by looking at mod/tools/api/gui/LogWidget.lua. This is a simple widget that prints any log messages recorded to the screen. The actual registering of the widget takes place in mod/tools/exec/widgets.lua.

Save Data

Mods can serialize data to the current save file using the global save table. This is a freeform table that stores data related to each mod, under a subtable for each one. For example, save.base stores data related to the base mod. This is so data can be easily transferred between different versions of mods and different save files.

To serialize data in a mod, simply save it in save[_MOD_NAME]:

save.my_mod.end_of_the_world = World.date():hours()

-- ...

if save.my_mod.end_of_the_world <= World.date():hours() then
   end_the_world()
end

Currently there are no limits on which mods can access other mods' data.

Next Steps

Proceed to Creating User Interfaces.