/Luvent

Simple Event Library for Lua

Primary LanguageLuaMIT LicenseMIT

Luvent: A Simple Event Library for Lua

Travis-CI-Badge

Luvent is a library for Lua, written entirely in Lua, which helps support event-driven programming. Luvent lets you create events, which are objects with any number of associated functions. Whenever you trigger an event the library will execute all functions attached to that event. You may trigger an event multiple times and can provide different arguments to that event’s functions each time.

There are great alternatives to Luvent listed below. However, most of those libraries do not have an explicit license. I created Luvent to use in a commercial game, built on the LÖVE framework, and so I could not use any library that did not explicitly grant legal permission to use the code in that situation. Luvent may not be that different from existing libraries in terms of its functionality; but it is and will always be free for developers to use in any program whether it is commercial or not.

Requirements

Luvent requires one of the following Lua implementations:

These are the versions we use to test Luvent. It should work with later versions of each, and possibly older versions as well.

Optional

The following programs are not necessary in order to use Luvent but you will need them to run the unit tests, generate API documentation, and so on.

Installation

All you need to do is place src/Luvent.lua in a directory that is part of package.path so that Lua can find and load it. Since the entire library is that one file you can also simply copy src/Luvent.lua into the directory alongside the rest of your code and require() it from there. If you have Busted then you can run make tests first to ensure that Luvent behaves as intended.

Documentation

Running the command make docs will populate the docs/ directory with HTML documents that describe Luvent’s API.

Terminology

  • Event: An object which you can trigger(...) in order to invoke all actions associated with the event.

  • Action: Any of the following qualify as actions that you can associate with events:

    1. A function.
    2. A coroutine.
    3. A table that supports the __call() metamethod.
  • Action ID: An object that represents an action. The method addAction() will return an action ID that you can save to later use with methods such as disableAction(). Instead of the ID you can also use the action itself, e.g. if the action is a function then any method that asks for an ‘action or ID’ will accept that function itself or the ID that addAction() returned when given the function. Action IDs are always true in a boolean context. If two IDs are equal then they represent the same action. Those are the only two properties that the API provides; if you rely on anything else about action IDs (e.g. their type) then your code may suddenly break in the future.

Basic Example

You create new events with the newEvent() function. Note well that you must never rely on the properties of the event objects. Anything that is not a method is not part of the API and may change at any time.

Once you have an event object you can begin to add ‘actions’ to it via its addAction() method. To invoke those actions you ‘trigger’ the event by calling its trigger() method. Every action associated with the event will receive any parameters you give to trigger().

Below is a lengthy example that demonstrates the basics of creating and triggering events, and adding and removing actions.

-- In this example you will pretend you are implementing a module in a
-- game that creates and manages enemies.  To simplfy the example you
-- use Enrique García Cota's terrific MiddleClass library in order
-- to make the class and objects for enemies.
--
--     https://github.com/kikito/middleclass
--
local class = require "middleclass"

local Luvent = require "Luvent"
local Enemy = class("Enemy")

-- This hash contains a reference to all living enemies.
Enemy.static.LIVING = {}

function Enemy:initialize(family, maxHP)
    self.family = family
    self.maxHP = maxHP
    self.HP = maxHP
    table.insert(Enemy.LIVING, self)
end

-- This is the event you trigger any time an enemy dies.
Enemy.static.onDie = Luvent.newEvent()

-- This method applies damage to an enemy and will trigger its 'onDie'
-- event if the enemy's hit points reach zero or less.
function Enemy:damage(damage)
    self.HP = self.HP - damage
    if self.HP <= 0 then
        Enemy.onDie:trigger(self)
    end
end

-- Now you can start associating actions with the 'onDie' event.  First
-- you start by removing the enemy from the table of living enemies.
Enemy.onDie:addAction(
    function (enemy)
        for index,living_enemy in ipairs(Enemy.LIVING) do
            if enemy == living_enemy then
                table.remove(Enemy.LIVING, index)
                return
            end
        end
    end)

-- For debugging you want to see on the console when an enemy dies, so
-- you add that as a separate action.  This time you save the return
-- value of addAction() so that later you can use that to remove the
-- action when you want to stop printing debugging output.
local debugAction = Enemy.onDie:addAction(
    function (enemy)
        print(string.format("Enemy %s died", enemy.family))
    end)

-- Now you make some enemies and kill them to demonstrate how the
-- trigger() method used in Enemy:damage() invokes the actions.

local bee = Enemy:new("Bee", 10)
local ladybug = Enemy:new("Ladybug", 1)

-- This will print "2"
print(#Enemy.LIVING)

-- This kills the enemy so the program invokes the two actions above,
-- meaning it will print "Enemy Ladbug died" to the console and will
-- remove it from Enemy.LIVING.
ladybug:damage(100)
print(#Enemy.LIVING)    -- Prints "1"

-- Now you turn off the debugging output by removing that action.  As a
-- result you will see no output after killing the bee.
Enemy.onDie:removeAction(debugAction)
bee:damage(50)
print(#Enemy.LIVING)    -- Prints "0"

Note: Luvent discards all return values from action functions or anything that coroutines yield.

Getting Information About Actions

Luvent provides two methods for gathering information about the relationship between an event an actions.

  1. The method getActionCount() tells you the number of actions associated with an event. This is not necessarily the number of actions that trigger() will invoke. The method only tells you the number of unique actions associated with the event via its addAction() method. Luvent allows you to temporarily disable actions and to delay their execution, which means trigger() will not call those actions even though they are still associated with the event. That is why you cannot rely on getActionCount() to tell you the exact number of actions an event will run.

  2. The method hasAction(action_or_id) accepts an action or an action ID (i.e. the return value of addAction()) and returns a boolean indicating whether or not the action is part of the event. However, if hasAction() returns true that is not a guarantee that the event will call that action, for the same reasons that affect getActionCount().

Enabling and Disabling Actions

In the example above you removed an action. Calling the getActionCount() of an event will tell us how many actions it has. However, this is not necessarily the number of actions it will invoke if you trigger the event.

When you add an action Luvent enables it by default. You can disable actions though. For example:

local debugAction = Enemy.onDie:addAction(
    function (enemy)
        print(string.format("Enemy %s died", enemy.family))
    end)

-- ...Later in the code...

Enemy.onDie:disableAction(debugAction)

The difference between this method and removeAction() is that this method only turns-off the action temporarily. Later you could call enableAction() to turn the action back on. When you use removeAction(), however, it is like deleting the action from the event.

You can also think of the methods in pairs.

  1. removeAction() is the opposite of addAction().

  2. disableAction() is the opposite of enableAction().

The first pair of methods affect the return value of getActionCount() and hasAction(). The second pair does not.

Action Intervals

Events may have actions with intervals, i.e. guaranteed delays in the amount of time that must pass before the action will run again. Luvent allows us to define intervals in terms of seconds. For example:

-- In this example you have a game where the AI has an 'onThink' event.
-- You want the AI to do many things on that event, but some of them
-- may be expensive in terms of performance.  So there may be actions
-- which you only want to run every so many seconds.

local function someSlowFunction(ai)
    -- You do something with the AI here that can take a while and so
    -- you do not want to always run this action.
end

AI.onThink:addAction(someSlowFunction)

-- Now you can tell the event to execute the action every ten seconds.
AI.onThink:setActionInterval(someSlowFunction, 10)

-- No matter how often you trigger the event, someSlowFunction() will
-- only run once per ten seconds.
while true do
    AI.onThink:trigger()
end

Actions have no interval by default. You can use the method removeActionInterval() to take away any interval from an action, even if that action has no interval in the first place.

Prioritizing Actions

By default Luvent makes no guarantees about the order in which an event will execute actions. But you can create such guarantees by assigning numeric priorities to actions. In that case Luvent will invoke actions based on the order of the priority, from highest to lowest, for example:

-- Let's say you are writing an AI for a board game.  You have an
-- 'onMove' event which triggers a variety of actions.  The functions
-- have stub implementations for the sake of brevity.

AI.onMove = Luvent.newEvent()

local function makeMove(player, board) end
local function analyzeCurrentPosition(player, board) end
local function searchPatternDatabase(player, board) end
local function estimateScore(player, board) end

AI.onMove:addAction(makeMove)
AI.onMove:addAction(analyzeCurrentPosition)
AI.onMove:addAction(searchPatternDatabase)
AI.onMove:addAction(estimateScore)

-- At this point you have given no action any explicit priority.  So if
-- you trigger the event now then there is no guarantee about the order
-- in which Luvent will call each action.  You cannot even rely on the
-- event to call the actions in the order you added them.

AI.onMove:setActionPriority(makeMove, 2)
AI.onMove:setActionPriority(analyzeCurrentPosition, 4)
AI.onMove:setActionPriority(searchPatternDatabase, 3)
AI.onMove:setActionPriority(estimateScore, 1)

After you set the priorities at the end then when you call AI.onMove:trigger() it will invoke the actions in this order:

  1. analyzeCurrentPosition()
  2. searchPatternDatabase()
  3. makeMove()
  4. estimateScore()

You can use removeActionPriority() on any of these actions to place them back at the bottom of the list, which is Luvent’s default behavior. Any action without an explicit priority will run last. If more than one action has the same priority then there is no guarantee about the order in which Luvent will call those actions.

Action Limits

There are situations where you may want to limit the amount of times an event will invoke a specific action. You can control this by setting the ‘limit’ for the action. For example:

-- In this example you are working with an 'onDeath' event for players
-- in a game.  The first time the player dies you want to save his or
-- her score.  But the player can continue after that and you do not
-- want to record scores after the first continue.  And so you want
-- the action for saving the score to run only once.

Game.onDeath = Luvent.newEvent()

local function saveScore(player) end
local function promptForContinue(player) end

Game.onDeath:addAction(saveScore)
Game.onDeath:addAction(promptForContinue)

-- This tells Luvent the limit for the action, i.e. the number of
-- times to invoke the action before automatically removing it.  This
-- specific example causes the event to call saveScore() only once and
-- then it will remove the action from 'onDeath'.
Game.onDeath:setActionLimit(saveScore, 1)

-- And for sanity this makes sure to save the score first by giving it
-- a higher priority than promptForContinue().  The number ten here is
-- an arbitrary choice; it just needs to be a number greater than zero
-- since there is no explicit priority for the other action.
Game.onDeath:setActionPriority(saveScore, 10)

The first call to Game.onDeath:trigger() will invoke saveScore() and then promptForContinue(). All future trigger() calls will only invoke the second action. Once an action reaches its limit then Luvent effectively calls removeAction(), meaning you would have to manually re-add the action before the event would use it again.

Getters

Luvent gives you three getters for obtaining information about actions:

  1. getActionTriggerLimit(action_or_id)
  2. getActionInterval(action_or_id)
  3. getActionPriority(action_or_id)

This methods complement the setters above and help you gain insight into the behavior of an action, e.g. how many times it will be triggered before being disabled.

Looping Over Actions

The API provides two methods for looping through all of the actions associated with an event. The first is allActions(). It returns an iterator to use in a for loop, e.g.

-- Continuing the previous example, this loop temporarily disables all
-- actions attached to the event.
for action in Game.onDie:allActions() do
    Game.onDie:disableAction(action)
end

If you only need to call one function or method for each action, as in the above example, then you can use forEachAction(). It accepts one argument, a function which is called once for each action. That function receives two arguments per invocation:

  1. The Event object containing the actions.

  2. The ID of the current action.

This requirement makes it possible to call a method for each action by passing that method to forEachAction(). For example, you can rewrite the previous loop like so:

Game.onDie:forEachAction(Luvent.disableAction)

It is an error to add or remove actions during iteration. You cannot call addAction(), removeAction(), or removeAllActions() during a loop using allActions() or via forEachAction().

Complete List of the Public API

You create events with the function Luvent.newEvent(). The function returns an object with the following methods:

  • trigger(...)
  • addAction(action)
  • removeAction(action_or_id)
  • removeAllActions()
  • getActionCount()
  • hasAction(action_or_id)
  • isActionEnabled(action_or_id)
  • enableAction(action_or_id)
  • disableAction(action_or_id)
  • setActionPriority(action_or_id, integer)
  • removeActionPriority(action_or_id)
  • setActionTriggerLimit(action_or_id, integer)
  • removeActionTriggerLimit(action_or_id)
  • setActionInterval(action_or_id, integer)
  • removeActionInterval(action_or_id)
  • allActions()
  • forEachAction(callable)
  • getActionTriggerLimit(action_or_id)
  • getActionInterval(action_or_id)
  • getActionPriority(action_or_id)

Acknowledgments and Alternatives

EventLib by Elijah Frederickson is the major inspiration for the design and implementation of Luvent. The Luvent API also owes a debt of ideas and names to Node.js by Ryan Dahl et al. The following is a list of alternatives to Luvent for the sake of comparison, as some may be better suited for some developers or projects:

License

The MIT License

Copyright 2013–2015 Eric James Michael Ritz