--- ECS (entity compenent system) library -- -- Entity Component System library, inspired by the excellent tiny-ecs. Main differences include: -- -- * ability to nest systems; -- * instanciation of systems for each world (no shared state); -- * adding and removing entities is done instantaneously -- * ability to add and remove components from entities after they were added to the world. -- -- No mandatory dependency. -- Optional dependency: `ubiquitousse.scene`, to allow quick creation of ECS-based scenes. -- -- The module returns a table that contains several functions, `world` or `scene` are starting points -- to create your world. -- -- @module ecs -- @usage TODO local loaded, scene = pcall(require, (...):match("^(.-)ecs").."scene") if not loaded then scene = nil end let ecs -- TODO: Implement a skip list for faster search. -- better control over system order: process, draw, methods? (for lag reasons and dependencies) -- more generic events? -- TODO: :reorder (like refresh but update order) -- TODO: clarify documentation on entity tables and instanciated system table --- Entity table. -- TODO -- @section Entity --- Entity system table. -- @doc entity --- @table entity -- @field .. d --- Recursively remove subsystems from a system. let recDestroySystems = (system) for i=#system.systems, 1, -1 do let s = system.systems[i] recDestroySystems(s) s:onDestroy() system.systems[i] = nil if s.name then system.world.s[s.name] = nil end end end --- Recursively call :clear and :onRemoveFromWorld to a list of systems in a world. let recCallOnRemoveFromWorld = (world, systems) for _, s in ipairs(systems) do s:clear() recCallOnRemoveFromWorld(world, s.systems) s:onRemoveFromWorld(world) end end --- Iterate through the next entity, based on state s: { previousLinkedListItem } let nextEntity = (s) if s[1] then let var = s[1][1] s[1] = s[1][2] return var else return nil end end --- Recursively copy content of a into b if it isn't already present. No cycle detection. let copy = (a, b) for k, v in pairs(a) do if type(v) == "table" then if b[k] == nil then b[k] = {} copy(v, b[k]) elseif b[k] == "table" then copy(v, b[k]) end elseif b[k] == nil then b[k] = v end end end --- System fields and methods. -- -- When they are added to a world, a new, per-world self table is created and used for every method call (which we call "instancied system"). -- Instancied systems can be retrieved in `system.s` or `system.systems`. -- -- The "world" is just the top-level system, behaving in exactly the same way as other systems. -- -- Every field defined below is optional and can be accessed or redefined at any time, unless written otherwise. Though you would typically set them -- when creating your system. -- @type System let system_mt = { --- Modifiable fields -- @doc modifiable --- Name of the system. -- Used to create a field with the system's name in `world.s` and into each entity (the "entity's system table") that's in this system. -- If not set, the entity will not have a system table. -- -- Do not change after system instanciation. -- @ftype string -- @ftype nil if no name name = nil, --- List of subsystems. -- On a instancied system, this is a list of the same subsystems, but instancied for this world. -- -- Do not change after system instanciation. -- @ftype table -- @ftype nil if no subsystem systems = nil, --- If not false, the system will only update every interval seconds. -- `false` by default. -- @ftype number interval of time between each update -- @ftype false to disable interval = false, --- The system and its susbsystems will only update if this is true. -- `true` by default. -- @ftype boolean active = true, --- The system and its subsystems will only draw if this is true. -- `true` by default. -- @ftype boolean visible = true, --- Defaults value to put into the entities's system table when they are added. Will recursively fill missing values. -- -- When an entity is added to a system, a `.entity` field is always set in the system table, referring to the full entity table. -- -- Changing this will not affect entities already in the system. -- @ftype table -- @ftype nil if no default default = nil, --- Defaults methods to assign to the entities's system table when they are added. -- -- When calling the methods with `entity.systemName:method(...)`, the method will actually receive the -- arguments method(system, `system table, ...)`. Methamethods are accepted. New methods can be -- created anytime. -- @ftype table -- @ftype nil if no methods methods = nil, --- Callbacks. -- -- Functions that are called when something happens in the system. -- Redefine them to change system behaviour. -- @doc callbacks --- Called when checking if an entity should be added to this system. -- Returns true if the entity should be added to this system (and therefore its subsystems). -- -- If this is a string or a table, it will be converted to a filter function on instanciation using ecs.any. -- -- If this true, will accept every entity; if false, reject every entity. -- -- Will only test entities when they are added; changing this after system creation will not affect entities already in the system. -- -- By default, rejects everything. -- @callback -- @tparam table e entity table to check -- @treturn boolean true if entity should be added filter = :(e) return false end, --- Called when adding an entity to this system determining its order. -- Returns true if e1 <= e2. e1 and e2 are two entities. -- -- Used to place the entity in the sorted entity list when it is added; changing this after system creation -- will not change the order of entities already in the system. -- -- By default, entities are in the same order they were inserted. -- @callback -- @tparam table e1 entity table to check for inferiority -- @tparam table e2 entity table to check for superiority -- @treturn boolean true if e1 <= e2 compare = :(e1, e2) return true end, --- Called when adding an entity to the system. -- @callback -- @tparam table s the entity's system table onAdd = :(s) end, --- Called when removing an entity from the system. -- @callback -- @tparam table s the entity's system table onRemove = :(s) end, --- Called when the system is instancied, before any call to `System:onAddToWorld` (including other systems in the world). -- @callback onInstance = :() end, --- Called when the system is added to a world. -- @callback -- @tparam System world world system onAddToWorld = :(world) end, --- Called when the system is removed from a world (i.e., the world is destroyed). -- @callback -- @tparam System world world system onRemoveFromWorld = :(world) end, --- Called when the world is destroyed, after every call to `System:onRemoveFromWorld` (including other systems in the world). -- @callback onDestroy = :() end, --- Called when updating the system. -- @callback -- @number dt delta-time since last update onUpdate = :(dt) end, --- Called when drawing the system. -- @callback onDraw = :() end, --- Called when updating the system, for every entity the system contains. Called after `System:onUpdate` was called on the system. -- @callback -- @tparam table s the entity's system table -- @number dt delta-time since last update process = :(s, dt) end, --- Called when drawing the system, for every entity the system contains. Called after `System:onDraw` was called on the system. -- @callback -- @tparam table s the entity's system table render = :(s) end, --- Read-only fields -- @doc ro --- The world the system belongs to. -- @ftype System world -- @ro world = nil, --- Number of entities in the system. -- @ftype integer -- @ro entityCount = 0, --- Map of all named systems in the world (not only subsystems). Same for every system from the same world. -- @ftype table {[system.name]=instanciedSystem, ...} -- @ro s = nil, --- Private fields --- --- First element of the linked list of entities: { entity, next_element }. -- @local _first = nil, --- Associative map of entities in the system and their previous linked list element (or true if first element). -- This make the list effectively a doubly linked list, but with easy access to the previous element using this map (and therefore O(1) deletion). -- @local _previous = nil, --- Amount of time waited since last update (if interval is set). -- @local _waited = 0, --- Metatable of entities' system table. -- Contains the methods defined in the methods field, wrapped to be called with the correct arguments, as well as a -- __index field (if not redefined in methods). -- @local _methods_mt = nil, --- Methods. -- -- Methods available on instancied system table. -- @doc smethods --- Add entities to the system and its subsystems. -- -- Will skip entities that are already in the system. -- -- Entities are added to subsystems after they were succesfully added to their parent system. -- -- If this is called on a subsystem instead of the world, be warned that this will bypass all the parent's systems filters. -- -- Since `System:remove` will not search for entities in systems where they should have been filtered out, the added entities will not be removed -- when calling :remove on a parent system or the world. The entity can be removed by calling `System:remove` on the system `System:add` was called on. -- -- Complexity: O(1) per unordered system, O(entityCount) per ordered system. -- @tparam table e entity to add -- @tparam table... ... other entities to add -- @treturn e,... the function arguments add = :(e, ...) if e ~= nil and not @_previous[e] and @filter(e) then -- setup entity if @name then if not e[@name] e[@name] = {} if @default copy(@default, e[@name]) if @methods setmetatable(e[@name], @_methods_mt) e[@name].entity = e end -- add to linked list if @_first == nil then @_first = { e, nil } @_previous[e] = true elseif @compare(e, @_first[1]) then let nxt = @_first @_first = { e, nxt } @_previous[e] = true @_previous[nxt[1]] = @_first else let entity = @_first while entity[2] ~= nil do if @compare(e, entity[2][1]) then let nxt = entity[2] entity[2] = { e, nxt } @_previous[e] = entity @_previous[nxt[1]] = entity[2] break end entity = entity[2] end if entity[2] == nil then entity[2] = { e, nil } @_previous[e] = entity end end -- notify addition @entityCount += 1 @onAdd(e[@name]) -- add to subsystems for _, s in ipairs(@systems) do s:add(e) end end if ... then return e, @add(...) else return e end end, --- Refresh an entity's systems. -- -- Behave similarly to `System:add`, but if the entity is already in the system, instead of skipping it, it -- will check for new and removed components and add and remove from (sub)systems accordingly. -- -- Complexity: O(1) per system + add/remove complexity. -- @tparam table e entity to refresh -- @tparam table... ... other entities to refresh -- @treturn e,... the function arguments refresh = :(e, ...) if e ~= nil then if not @_previous[e] then @add(e) elseif @_previous[e] then if not @filter(e) then @remove(e) else for _, s in ipairs(@systems) do s:refresh(e) end end end end if ... then return e, @refresh(...) else return e end end, --- Remove entities to the system and its subsystems. -- -- Will skip entities that are not in the system. -- -- Entities are removed from subsystems before they are removed from their parent system. -- -- If you intend to call this on a subsystem instead of the world, please read the warning in `System:add`. -- -- Returns all removed entities. -- -- Complexity: O(1) per system. -- @tparam table e entity to remove -- @tparam table... ... other entities to remove -- @treturn e,... the function arguments remove = :(e, ...) if e ~= nil then if @_previous[e] then -- remove from subsystems for _, s in ipairs(@systems) do s:remove(e) end end if @_previous[e] then -- recheck in case it was removed already from a subsystem onRemove callback -- remove from linked list let prev = @_previous[e] if prev == true then @_first = @_first[2] if @_first then @_previous[@_first[1]] = true end else prev[2] = prev[2][2] if prev[2] then @_previous[prev[2][1]] = prev end end -- notify removal @_previous[e] = nil @entityCount -= 1 @onRemove(e[@name]) end end if ... then return e, @remove(...) else return e end end, --- Returns true if every entity is in the system. -- -- Complexity: O(1). -- @tparam table e entity that may be in the system -- @tparam table... ... other entities that may be in the system -- @treturn boolean true if every entity is in the system has = :(e, ...) let has = e == nil or not not @_previous[e] if ... then return has and @has(...) else return has end end, --- Returns an iterator that iterate through the entties in this system. -- @treturn iterator iterator over the entities in this system, in order iter = :() return nextEntity, { @_first } end, --- Remove every entity from the system and its subsystems. clear = :() for e in @iter() do @remove(e) end for _, s in ipairs(@systems) do s:clear() end end, --- Try to update the system and its subsystems. Should be called on every game update. -- -- Subsystems are updated after their parent system. -- @number dt delta-time since last update update = :(dt) if @active then if @interval then @_waited += dt if @_waited < @interval then return end end @onUpdate(dt) if @process ~= system_mt.process then for e in @iter() do @process(e[@name], dt) end end for _, s in ipairs(@systems) do s:update(dt) end if @interval then @_waited -= @interval end end end, --- Try to draw the system and its subsystems. Should be called on every game draw. -- -- Subsystems are drawn after their parent system. draw = :() if @visible then @onDraw() if @render ~= system_mt.render then for e in @iter() do @render(e[@name]) end end for _, s in ipairs(@systems) do s:draw() end end end, --- Remove all the entities and subsystems in this system. destroy = :() recCallOnRemoveFromWorld(@world, { @ }) recDestroySystems({ systems = { @ } }) end } --- Self descriptive let alwaysTrue = () return true end let alwaysFalse = () return true end --- Recursively instanciate a list of systems for a world: -- * create their self table with instance fields set -- * create a field with their name in world.s (if name defined) let recInstanciateSystems = (world, systems) let t = {} for _, s in ipairs(systems) do let system -- setup method table let methods_mt = {} if s.methods then methods_mt.__index = methods_mt for k, v in pairs(s.methods) do methods_mt[k] = :(...) return v(system, @, ...) end end setmetatable(s.methods, { __newindex = :(k, v) rawset(@, k, v) methods_mt[k] = :(...) return v(system, @, ...) end end }) end -- instanciate system system = setmetatable({ systems = recInstanciateSystems(world, s.systems or {}), world = world, s = world.s, _previous = {}, _methods_mt = methods_mt }, { __index = :(k) if s[k] ~= nil then return s[k] else return system_mt[k] end end }) if type(s.filter) == "string" then system.filter = (_, e) return e[s.filter] ~= nil end elseif type(s.filter) == "table" then system.filter = ecs.any(unpack(s.filter)) elseif type(s.filter) == "boolean" then if s.filter then system.filter = alwaysTrue else system.filter = alwaysFalse end end -- add system table.insert(t, system) if s.name then world.s[s.name] = system end system:onInstance() end return t end --- Recursively call :onAddToWorld to a list of systems in a world. let recCallOnAddToWorld = (world, systems) for _, s in ipairs(systems) do recCallOnAddToWorld(world, s.systems) s:onAddToWorld(world) end end --- ECS module. -- @section end ecs = { --- Create and returns a world system based on a list of systems. -- The systems will be instancied for this world. -- @tparam table,... ... list of (uninstancied) system tables -- @treturn System the world system world = (...) let world = setmetatable({ filter = ecs.all(), s = {}, _previous = {} }, { __index = system_mt }) world.world = world world.systems = recInstanciateSystems(world, {...}) recCallOnAddToWorld(world, world.systems) return world end, --- Returns a filter that returns true if, for every argument, a field with the same name exists in the entity. -- @tparam string,... ... list of field names that must be in entity -- @treturn function(e) that returns true if e has all the fields all = (...) if ... then let l = {...} return function(s, e) for _, k in ipairs(l) do if e[k] == nil then return false end end return true end else return alwaysTrue end end, --- Returns a filter that returns true if one of the arguments if the name of a field in the entity. -- @tparam string,... ... list of field names that may be in entity -- @treturn function(e) that returns true if e has at leats one of the fields any = (...) if ... then let l = {...} return function(s, e) for _, k in ipairs(l) do if e[k] ~= nil then return true end end return false end else return alwaysFalse end end, --- If `uqt.scene` is available, returns a new scene that will consist of a ECS world with the specified systems and entities. -- @require ubiquitousse.scene -- @string name the name of the new scene -- @tparam[opt={}] table systems list of systems to add to the world -- @tparam[opt={}] table entities list of entities to add to the world -- @treturn scene the new scene scene = (name, systems={}, entities={}) assert(scene, "ubiquitousse.scene unavailable") let s = scene.new(name) let w function s:enter() w = ecs.world(unpack(systems)) w:add(unpack(entities)) end function s:exit() w:destroy() end function s:update(dt) w:update(dt) end function s:draw() w:draw() end return s end } return ecs