1
0
Fork 0
mirror of https://github.com/Reuh/ubiquitousse.git synced 2025-10-27 17:19:31 +00:00
ubiquitousse/ecs/ecs.can

647 lines
18 KiB
Text

--- 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.
-- Don't copy keys, will preserve metatable but not copy them.
let copy = (a, b, cache={})
for k, v in pairs(a) do
if type(v) == "table" then
if b[k] == nil then
if cache[v] then
b[k] = cache[v]
else
cache[v] = {}
b[k] = cache[v]
copy(v, b[k], cache)
setmetatable(b[k], getmetatable(v))
end
elseif b[k] == "table" then
copy(v, b[k], cache)
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.
-- Metatables will be preserved during the copy but not copied themselves.
--
-- 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