--- Entity management for Lua. -- @module nata local nata = { _VERSION = 'Nata v0.3.3', _DESCRIPTION = 'Entity management for Lua.', _URL = 'https://github.com/tesselode/nata', _LICENSE = [[ MIT License Copyright (c) 2020 Andrew Minnich Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ]] } -- gets the error level needed to make an error appear -- in the user's code, not the library code local function getUserErrorLevel() local source = debug.getinfo(1).source local level = 1 while debug.getinfo(level).source == source do level = level + 1 end --[[ we return level - 1 here and not just level because the level was calculated one function deeper than the function that will actually use this value. if we produced an error *inside* this function, level would be correct, but for the function calling this function, level - 1 is correct. ]] return level - 1 end -- gets the name of the function that the user called -- that eventually caused an error local function getUserCalledFunctionName() return debug.getinfo(getUserErrorLevel() - 1).name end local function checkCondition(condition, message) if condition then return end error(message, getUserErrorLevel()) end -- changes a list of types into a human-readable phrase -- i.e. string, table, number -> "string, table, or number" local function getAllowedTypesText(...) local numberOfArguments = select('#', ...) if numberOfArguments >= 3 then local text = '' for i = 1, numberOfArguments - 1 do text = text .. string.format('%s, ', select(i, ...)) end text = text .. string.format('or %s', select(numberOfArguments, ...)) return text elseif numberOfArguments == 2 then return string.format('%s or %s', select(1, ...), select(2, ...)) end return select(1, ...) end -- checks if an argument is of the correct type, and if not, -- throws a "bad argument" error consistent with the ones -- lua and love produce local function checkArgument(argumentIndex, argument, ...) for i = 1, select('#', ...) do -- allow tables with the __call metamethod to be treated like functions if select(i, ...) == 'function' then if type(argument) == 'table' and getmetatable(argument).__call then return end end if type(argument) == select(i, ...) then return end end error( string.format( "bad argument #%i to '%s' (expected %s, got %s)", argumentIndex, getUserCalledFunctionName(), getAllowedTypesText(...), type(argument) ), getUserErrorLevel() ) end local function checkOptionalArgument(argumentIndex, argument, ...) if argument == nil then return end checkArgument(argumentIndex, argument, ...) end local function removeByValue(t, v) for i = #t, 1, -1 do if t[i] == v then table.remove(t, i) end end end local function entityHasKeys(entity, keys) for _, key in ipairs(keys) do if not entity[key] then return false end end return true end local function filterEntity(entity, filter) if type(filter) == 'table' then return entityHasKeys(entity, filter) elseif type(filter) == 'function' then return filter(entity) end return true end --- Defines the behaviors of a system. -- -- There's no constructor for SystemDefinitions. Rather, you simply -- define a table with functions that correspond to events. These -- events can be named anything you like. Below are the built-in events -- that the pool will automatically call. -- @type SystemDefinition --- Called when the pool is first created. -- @function SystemDefinition:init -- @param ... additional arguments that were passed to `nata.new`. --- Called when an entity is added to the pool. -- @function SystemDefinition:add -- @tparam table e the entity that was added --- Called when an entity is removed from the pool. -- @function SystemDefinition:remove -- @tparam table e the entity that was removed --- Called when an entity is added to a group. -- @function SystemDefinition:addToGroup -- @string groupName the name of the group that the entity was added to -- @tparam table e the entity that was added --- Called when an entity is removed from a group. -- @function SystemDefinition:removeFromGroup -- @string groupName the name of the group that the entity was removed from -- @tparam table e the entity that was removed --- Responds to events in a pool. -- -- Systems are not created directly. They're created by the @{Pool} -- according to the @{SystemDefinition}s passed to `nata.new`. -- Each system instance inherits all of the functions of its -- @{SystemDefinition}. -- @type System --- The @{Pool} that this system is running on. -- @tfield Pool pool --- Manages a subset of entities. -- @type Group --- The filter that defines which entities are added to this group. -- Can be either: -- -- - A list of required keys -- - A function that takes the entity as the first argument -- and returns true if the entity should be added to the group -- @tfield[opt] table|function filter --- A function that specifies how the entities in this group should be sorted. -- Has the same requirements as the function argument to Lua's built-in `table.sort`. -- @tfield[opt] function sort --- A list of all the entities in the group. -- @tfield table entities --- A set of all the entities in the group. -- @tfield table hasEntity -- @usage -- print(pool.groups.physical.hasEntity[e]) -- prints "true" if the entity is in the "physical" group, or "nil" if not --- Manages entities in a game world. -- @type Pool local Pool = {} Pool.__index = Pool --- A list of all the entities in the pool. -- @tfield table entities --- A set of all the entities in the pool. -- @tfield table hasEntity -- @usage -- print(pool.hasEntity[e]) -- prints "true" if the entity is in the pool, or "nil" if not --- A dictionary of the @{Group}s in the pool. -- @tfield table groups --- A field containing any data you want. -- @field data --- function Pool:_validateOptions(options) checkOptionalArgument(1, options, 'table') if not options then return end if options.groups then checkCondition(type(options.groups) == 'table', "groups must be a table") for groupName, groupOptions in pairs(options.groups) do checkCondition(type(groupOptions) == 'table', string.format("options for group '$s' must be a table", groupName)) local filter = groupOptions.filter if filter ~= nil then checkCondition(type(filter) == 'table' or type(filter) == 'function', string.format("filter for group '%s' must be a table or function", groupName)) end local sort = groupOptions.sort if sort ~= nil then checkCondition(type(sort) == 'function', string.format("sort for group '%s' must be a function", groupName)) end end end if options.systems then checkCondition(type(options.systems) == 'table', "systems must be a table") for _, system in ipairs(options.systems) do checkCondition(type(system) == 'table', "all systems must be tables") end end end function Pool:_init(options, ...) self:_validateOptions(options) options = options or {} -- entities that will be added to the pool on the next flush self._queue = {} -- a temporary table for entities that will be added to the pool -- on the current flush (see Pool.flush for more details) self._entitiesToFlush = {} self.entities = {} self.hasEntity = {} self.groups = {} self._systems = {} self._events = {} self.data = options.data or {} local groups = options.groups or {} local systems = options.systems or { nata.oop() } for groupName, groupOptions in pairs(groups) do self.groups[groupName] = { filter = groupOptions.filter, sort = groupOptions.sort, entities = {}, hasEntity = {}, } end for _, systemDefinition in ipairs(systems) do local system = setmetatable({ pool = self, }, { __index = systemDefinition }) table.insert(self._systems, system) end self:emit('init', ...) end --- Queues an entity to be added to the pool. -- @tparam table entity the entity to add -- @treturn table the queued entity function Pool:queue(entity) table.insert(self._queue, entity) return entity end --- Adds the queued entities to the pool. Entities are added -- in the order they were queued. function Pool:flush() --[[ Move the currently queued entities to a temporary table. This way, if an add/addToGroup/removeToGroup event emission leads to another entity being queued, it will be saved for the next flush, rather than adding entities to the table we're in the middle of iterating over, which would lead to an array with holes and screw everything up. ]] for i = 1, #self._queue do local entity = self._queue[i] self._entitiesToFlush[i] = entity self._queue[i] = nil end for i = 1, #self._entitiesToFlush do local entity = self._entitiesToFlush[i] -- check if the entity belongs in each group and -- add it to/remove it from the group as needed for groupName, group in pairs(self.groups) do if filterEntity(entity, group.filter) then if not group.hasEntity[entity] then table.insert(group.entities, entity) group.hasEntity[entity] = true self:emit('addToGroup', groupName, entity) end if group.sort then group._needsResort = true end elseif group.hasEntity[entity] then removeByValue(group.entities, entity) group.hasEntity[entity] = nil self:emit('removeFromGroup', groupName, entity) end end -- add the entity to the pool if it hasn't been added already if not self.hasEntity[entity] then table.insert(self.entities, entity) self.hasEntity[entity] = true self:emit('add', entity) end self._entitiesToFlush[i] = nil end -- re-sort groups for _, group in pairs(self.groups) do if group._needsResort then table.sort(group.entities, group.sort) group._needsResort = nil end end end --- Removes entities from the pool. -- @tparam function f the condition upon which an entity should be -- removed. The function should take an entity as the first argument -- and return `true` if the entity should be removed. function Pool:remove(f) checkArgument(1, f, 'function') for groupName, group in pairs(self.groups) do for i = #group.entities, 1, -1 do local entity = group.entities[i] if f(entity) then self:emit('removeFromGroup', groupName, entity) table.remove(group.entities, i) group.hasEntity[entity] = nil end end end for i = #self.entities, 1, -1 do local entity = self.entities[i] if f(entity) then self:emit('remove', entity) table.remove(self.entities, i) self.hasEntity[entity] = nil end end end --- Registers a function to be called when an event is emitted. -- @string event the event to listen for -- @tparam function f the function to call -- @treturn function the function that was registered function Pool:on(event, f) checkCondition(event ~= nil, "event cannot be nil") checkArgument(2, f, 'function') self._events[event] = self._events[event] or {} table.insert(self._events[event], f) return f end --- Unregisters a function from an event. -- @string event the event to unregister the function from -- @tparam function f the function to unregister function Pool:off(event, f) checkCondition(event ~= nil, "event cannot be nil") checkArgument(2, f, 'function') if self._events[event] then removeByValue(self._events[event], f) end end --- Emits an event. The `system[event]` function will be called -- for each system that has it, and functions registered -- to the event will be called as well. -- @string event the event to emit -- @param ... additional arguments to pass to the functions that are called function Pool:emit(event, ...) checkCondition(event ~= nil, "event cannot be nil") for _, system in ipairs(self._systems) do if type(system[event]) == 'function' then system[event](system, ...) end end if self._events[event] then for _, f in ipairs(self._events[event]) do f(...) end end end --- Gets this pool's instance of a system. -- @tparam SystemDefinition systemDefinition the system class to get the instance of -- @treturn System the instance of the system running in this pool function Pool:getSystem(systemDefinition) checkArgument(1, systemDefinition, 'table') for _, system in ipairs(self._systems) do if getmetatable(system).__index == systemDefinition then return system end end end --- -- @section end local function validateOopOptions(options) checkOptionalArgument(1, options, 'table') if not options then return end if options.include then checkCondition(type(options.include) == 'table', "include must be a table") end if options.exclude then checkCondition(type(options.exclude) == 'table', "exclude must be a table") end end --- Defines the behavior of an OOP system. -- @type OopOptions -- @see nata.oop --- A list of events to forward to entities. If not defined, -- the system will forward all events to entities (except -- for the ones in the exclude list). -- @tfield table include --- A list of events *not* to forward to entities. -- @tfield table exclude --- The name of the group of entities to forward events to. -- If not defined, the system will forward events to all entities. -- @tfield string group --- -- @section end --- Creates a new OOP system definition. -- An OOP system, upon receiving an event, will call -- the function of the same name on each entity it monitors -- (if it exists). This facilitates a more traditional, OOP-style -- entity management, where you loop over a table of entities and -- call update and draw functions on them. -- @tparam[opt] OopOptions options how to set up the OOP system -- @treturn SystemDefinition the new OOP system definition function nata.oop(options) validateOopOptions(options) local group = options and options.group local include, exclude if options and options.include then include = {} for _, event in ipairs(options.include) do include[event] = true end end if options and options.exclude then exclude = {} for _, event in ipairs(options.exclude) do exclude[event] = true end end return setmetatable({ _cache = {} }, { __index = function(t, event) t._cache[event] = t._cache[event] or function(self, ...) local shouldCallEvent = true if include and not include[event] then shouldCallEvent = false end if exclude and exclude[event] then shouldCallEvent = false end if shouldCallEvent then local entities -- not using ternary here because if the group doesn't exist, -- i'd rather it cause an error than just silently falling back -- to the main entity pool if group then entities = self.pool.groups[group].entities else entities = self.pool.entities end for _, entity in ipairs(entities) do if type(entity[event]) == 'function' then entity[event](entity, ...) end end end end return t._cache[event] end }) end --- Defines the filter and sort function for a @{Group}. -- @type GroupOptions --- The filter that defines which entities are added to this group. -- Can be either: -- -- - A list of required keys -- - A function that takes the entity as the first argument -- and returns true if the entity should be added to the group -- @tfield[opt] table|function filter --- A function that specifies how the entities in this group should be sorted. -- Has the same requirements as the function argument to Lua's built-in `table.sort`. -- @tfield[opt] function sort --- Defines the groups and systems for a @{Pool}. -- @type PoolOptions --- A dictionary of groups for the pool to have. -- Each key is the name of the group, and each value -- should be a @{GroupOptions} table. -- @tfield[opt={}] table groups --- A list of @{SystemDefinition}s for the pool to use. -- @tfield[opt={nata.oop()}] table systems --- An initial value to set @{Pool.data} to. -- @field[opt={}] data --- -- @section end --- Creates a new @{Pool}. -- @tparam[opt] PoolOptions options how to set up the pool -- @param[opt] ... additional arguments to pass to the pool's init event -- @treturn Pool the new pool function nata.new(options, ...) local pool = setmetatable({}, Pool) pool:_init(options, ...) return pool end return nata