diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml new file mode 100644 index 0000000..ca60d46 --- /dev/null +++ b/.github/workflows/doc.yml @@ -0,0 +1,38 @@ +name: Build Docs + +on: + push: + branches: + - master + +jobs: + build: + name: Build docs + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v2 + + - name: Setup Lua + uses: leafo/gh-actions-lua@v8 + with: + luaVersion: 5.4 + + - name: Install Luarocks + uses: leafo/gh-actions-luarocks@v4 + + - name: Install LDoc + run: luarocks install ldoc + + - name: Show + run: luarocks show ldoc + + - name: Build docs + run: ldoc . + + - name: Deploy + uses: JamesIves/github-pages-deploy-action@4.1.5 + with: + branch: gh-pages + folder: docs \ No newline at end of file diff --git a/.gitignore b/.gitignore index 51b8439..989d364 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,7 @@ luac.out *.app *.i*86 *.x86_64 -*.hex \ No newline at end of file +*.hex + +# VSCode +.vscode/ \ No newline at end of file diff --git a/.luacheckrc b/.luacheckrc index b557738..59fc4be 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -1 +1,2 @@ +---@diagnostic disable: lowercase-global std="love+luajit" diff --git a/README.md b/README.md index 2f79dcf..6669d8e 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ With Concord it is possibile to easily write fast and clean code. This readme will explain how to use Concord. Additionally all of Concord is documented using the LDoc format. -Auto generated docs for Concord can be found in `docs` folder, or on the [Github page](https://tjakka5.github.io/Concord/). +Auto generated docs for Concord can be found in `docs` folder, or on the [GitHub page](https://keyslam-group.github.io/Concord/). --- @@ -133,16 +133,21 @@ Concord does a few things that might not be immediately clear. This segment shou Since you'll have lots of Components and Systems in your game Concord makes it a bit easier to load things in. ```lua --- Loads all files in the directory, and puts the return value in the table Systems. The key is their filename without any extension -local Systems = {} -Concord.utils.loadNamespace("path/to/systems", Systems) - -print(Systems.systemName) - -- Loads all files in the directory. Components automatically register into Concord.components, so loading them into a namespace isn't necessary. Concord.utils.loadNamespace("path/to/components") print(Concord.components.componentName) + +-- Loads all files in the directory, and puts the return value in the table Systems. The key is their filename without any extension +local Systems = {} +Concord.utils.loadNamespace("path/to/systems", Systems) + +myWorld:addSystems( + Systems.healthSystem + Systems.damageSystem, + Systems.moveSystem, + -- etc +) ``` #### Method chaining @@ -170,7 +175,7 @@ When defining a ComponentClass you need to pass in a name and usually a `populat -- Create the position class with a populate function -- The component variable is the actual Component given to an Entity -- The x and y variables are values we pass in when we create the Component -Concord.component("position" function(component, x, y) +Concord.component("position", function(component, x, y) component.x = x or 0 component.y = y or 0 end) @@ -266,10 +271,10 @@ Systems can have multiple pools. ```lua -- Create a System -local mySystemClass = Concord.system(pool = {"position"}) -- Pool named 'pool' will contain all Entities with a position Component +local mySystemClass = Concord.system({pool = {"position"}}) -- Pool named 'pool' will contain all Entities with a position Component -- Create a System with multiple pools -local mySystemClass = Concord.system( +local mySystemClass = Concord.system({ pool = { -- This pool will be named 'pool' "position", "velocity", @@ -278,7 +283,7 @@ local mySystemClass = Concord.system( "health", "damageable", } -) +}) ``` ```lua @@ -294,14 +299,14 @@ end -- Defining a function function mySystemClass:update(dt) -- Iterate over all entities in the Pool - for _, e in ipairs(self.pool) + for _, e in ipairs(self.pool) do -- Do something with the Components e.position.x = e.position.x + e.velocity.x * dt e.position.y = e.position.y + e.velocity.y * dt end -- Iterate over all entities in the second Pool - for _, e in ipairs(self.secondPool) + for _, e in ipairs(self.secondPool) do -- Do something end end diff --git a/concord/builtins/init.lua b/concord/builtins/init.lua new file mode 100644 index 0000000..e3ab37b --- /dev/null +++ b/concord/builtins/init.lua @@ -0,0 +1,6 @@ +local PATH = (...):gsub("(%.init)$", "") + +return { + serializable = require(PATH..".serializable"), + key = require(PATH..".key"), +} diff --git a/concord/builtins/key.lua b/concord/builtins/key.lua new file mode 100644 index 0000000..7b23275 --- /dev/null +++ b/concord/builtins/key.lua @@ -0,0 +1,41 @@ +local PATH = (...):gsub('%.builtins%.[^%.]+$', '') + +local Component = require(PATH..".component") + +local getKey = function (self, key) + local entity = self.__entity + + if not entity:inWorld() then + error("entity needs to belong to a world") + end + + local world = entity:getWorld() + + return world:__assignKey(entity, key) +end + +local Key = Component("key", function (self, key) + self.value = getKey(self, key) +end) + +function Key:deserialize (data) + self.value = getKey(self, data) +end + +function Key.__mt:__call() + return self.value +end + +function Key:removed (replaced) + if not replaced then + local entity = self.__entity + + if entity:inWorld() then + local world = entity:getWorld() + + return world:__clearKey(entity) + end + end +end + +return Key diff --git a/concord/builtins/serializable.lua b/concord/builtins/serializable.lua new file mode 100644 index 0000000..125f76b --- /dev/null +++ b/concord/builtins/serializable.lua @@ -0,0 +1,12 @@ +local PATH = (...):gsub('%.builtins%.[^%.]+$', '') + +local Component = require(PATH..".component") + +local Serializable = Component("serializable") + +function Serializable:serialize () + -- Don't serialize this Component + return nil +end + +return Serializable \ No newline at end of file diff --git a/concord/component.lua b/concord/component.lua index d8678f0..981b999 100644 --- a/concord/component.lua +++ b/concord/component.lua @@ -16,15 +16,19 @@ Component.__mt = { -- @treturn Component A new ComponentClass function Component.new(name, populate) if (type(name) ~= "string") then - error("bad argument #1 to 'Component.new' (string expected, got "..type(name)..")", 2) + Utils.error(2, "bad argument #1 to 'Component.new' (string expected, got %s)", type(name)) + end + + if (string.match(name, Components.__REJECT_MATCH) ~= "") then + Utils.error(2, "bad argument #1 to 'Component.new' (Component names can't start with '%s', got %s)", Components.__REJECT_PREFIX, name) end if (rawget(Components, name)) then - error("bad argument #1 to 'Component.new' (ComponentClass with name '"..name.."' was already registerd)", 2) -- luacheck: ignore + Utils.error(2, "bad argument #1 to 'Component.new' (ComponentClass with name '%s' was already registerd)", name) -- luacheck: ignore end if (type(populate) ~= "function" and type(populate) ~= "nil") then - error("bad argument #1 to 'Component.new' (function/nil expected, got "..type(populate)..")", 2) + Utils.error(2, "bad argument #1 to 'Component.new' (function/nil expected, got %s)", type(populate)) end local componentClass = setmetatable({ @@ -43,31 +47,40 @@ function Component.new(name, populate) return componentClass end --- Internal: Populates a Component with values +-- Internal: Populates a Component with values. function Component:__populate() -- luacheck: ignore end +-- Callback: When the Component gets removed or replaced in an Entity. +function Component:removed() -- luacheck: ignore +end + +-- Callback: When the Component gets serialized as part of an Entity. function Component:serialize() local data = Utils.shallowCopy(self, {}) --This values shouldn't be copied over - data.__componentClass = nil - data.__isComponent = nil + data.__componentClass = nil + data.__entity = nil + data.__isComponent = nil data.__isComponentClass = nil return data end +-- Callback: When the Component gets deserialized from serialized data. function Component:deserialize(data) Utils.shallowCopy(data, self) end -- Internal: Creates a new Component. +-- @param entity The Entity that will receive this Component. -- @return A new Component -function Component:__new() +function Component:__new(entity) local component = setmetatable({ __componentClass = self, + __entity = entity, __isComponent = true, __isComponentClass = false, }, self.__mt) @@ -76,11 +89,13 @@ function Component:__new() end -- Internal: Creates and populates a new Component. +-- @param entity The Entity that will receive this Component. -- @param ... Varargs passed to the populate function -- @return A new populated Component -function Component:__initialize(...) - local component = self:__new() +function Component:__initialize(entity, ...) + local component = self:__new(entity) + ---@diagnostic disable-next-line: redundant-parameter self.__populate(component, ...) return component diff --git a/concord/components.lua b/concord/components.lua index bf20d2d..b2743e7 100644 --- a/concord/components.lua +++ b/concord/components.lua @@ -1,12 +1,11 @@ --- Container for registered ComponentClasses -- @module Components -local PATH = (...):gsub('%.[^%.]+$', '') - -local Type = require(PATH..".type") - local Components = {} +Components.__REJECT_PREFIX = "!" +Components.__REJECT_MATCH = "^(%"..Components.__REJECT_PREFIX.."?)(.+)" + --- Returns true if the containter has the ComponentClass with the specified name -- @string name Name of the ComponentClass to check -- @treturn boolean @@ -14,22 +13,43 @@ function Components.has(name) return rawget(Components, name) and true or false end +--- Prefix a component's name with the currently set Reject Prefix +-- @string name Name of the ComponentClass to reject +-- @treturn string +function Components.reject(name) + local ok, err = Components.try(name) + + if not ok then error(err, 2) end + + return Components.__REJECT_PREFIX..name +end + --- Returns true and the ComponentClass if one was registered with the specified name -- or false and an error otherwise -- @string name Name of the ComponentClass to check +-- @boolean acceptRejected Whether to accept names prefixed with the Reject Prefix. -- @treturn boolean -- @treturn Component or error string -function Components.try(name) +-- @treturn true if acceptRejected was true and the name had the Reject Prefix, false otherwise. +function Components.try(name, acceptRejected) if type(name) ~= "string" then return false, "ComponentsClass name is expected to be a string, got "..type(name)..")" end + local rejected = false + if acceptRejected then + local prefix + prefix, name = string.match(name, Components.__REJECT_MATCH) + + rejected = prefix ~= "" and name + end + local value = rawget(Components, name) if not value then return false, "ComponentClass '"..name.."' does not exist / was not registered" end - return true, value + return true, value, rejected end --- Returns the ComponentClass with the specified name diff --git a/concord/entity.lua b/concord/entity.lua index 854fa8c..29145fa 100644 --- a/concord/entity.lua +++ b/concord/entity.lua @@ -6,8 +6,16 @@ local PATH = (...):gsub('%.[^%.]+$', '') local Components = require(PATH..".components") local Type = require(PATH..".type") +local Utils = require(PATH..".utils") + +-- Initialize built-in Components (as soon as possible) +local Builtins = require(PATH..".builtins.init") --luacheck: ignore +-- Builtins is unused but the require already registers the Components + +local Entity = { + SERIALIZE_BY_DEFAULT = true, +} -local Entity = {} Entity.__mt = { __index = Entity, } @@ -17,12 +25,11 @@ Entity.__mt = { -- @treturn Entity A new Entity function Entity.new(world) if (world ~= nil and not Type.isWorld(world)) then - error("bad argument #1 to 'Entity.new' (world/nil expected, got "..type(world)..")", 2) + Utils.error(2, "bad argument #1 to 'Entity.new' (world/nil expected, got %s)", type(world)) end local e = setmetatable({ __world = nil, - __components = {}, __isEntity = true, }, Entity.__mt) @@ -31,23 +38,86 @@ function Entity.new(world) world:addEntity(e) end + if Entity.SERIALIZE_BY_DEFAULT then + e:give("serializable") + end + return e end -local function give(e, name, componentClass, ...) - local component = componentClass:__initialize(...) +local function createComponent(e, name, componentClass, ...) + local component = componentClass:__initialize(e, ...) + local hadComponent = not not e[name] + + if hadComponent then + e[name]:removed(true) + end e[name] = component - e.__components[name] = component - e:__dirty() + if not hadComponent then + e:__dirty() + end end -local function remove(e, name, componentClass) - e[name] = nil - e.__components[name] = nil +local function deserializeComponent(e, name, componentData) + local componentClass = Components[name] + local hadComponent = not not e[name] - e:__dirty() + if hadComponent then + e[name]:removed(true) + end + + local component = componentClass:__new(e) + component:deserialize(componentData) + + e[name] = component + + if not hadComponent then + e:__dirty() + end +end + +local function giveComponent(e, ensure, name, ...) + local component + if Type.isComponent(name) then + component = name + name = component:getName() + end + + if ensure and e[name] then + return e + end + + local ok, componentClass = Components.try(name) + + if not ok then + Utils.error(3, "bad argument #1 to 'Entity:%s' (%s)", ensure and 'ensure' or 'give', componentClass) + end + + if component then + local data = component:deserialize() + if data == nil then + Utils.error(3, "bad argument #1 to 'Entity:$s' (Component '%s' couldn't be deserialized)", ensure and 'ensure' or 'give', name) + end + + deserializeComponent(e, name, data) + else + createComponent(e, name, componentClass, ...) + end + + return e +end + + +local function removeComponent(e, name) + if e[name] then + e[name]:removed(false) + + e[name] = nil + + e:__dirty() + end end --- Gives an Entity a Component. @@ -56,15 +126,7 @@ end -- @param ... additional arguments to pass to the Component's populate function -- @treturn Entity self function Entity:give(name, ...) - local ok, componentClass = Components.try(name) - - if not ok then - error("bad argument #1 to 'Entity:get' ("..componentClass..")", 2) - end - - give(self, name, componentClass, ...) - - return self + return giveComponent(self, false, name, ...) end --- Ensures an Entity to have a Component. @@ -73,19 +135,7 @@ end -- @param ... additional arguments to pass to the Component's populate function -- @treturn Entity self function Entity:ensure(name, ...) - local ok, componentClass = Components.try(name) - - if not ok then - error("bad argument #1 to 'Entity:get' ("..componentClass..")", 2) - end - - if self[name] then - return self - end - - give(self, name, componentClass, ...) - - return self + return giveComponent(self, true, name, ...) end --- Removes a Component from an Entity. @@ -95,10 +145,10 @@ function Entity:remove(name) local ok, componentClass = Components.try(name) if not ok then - error("bad argument #1 to 'Entity:get' ("..componentClass..")", 2) + Utils.error(2, "bad argument #1 to 'Entity:remove' (%s)", componentClass) end - remove(self, name, componentClass) + removeComponent(self, name) return self end @@ -109,7 +159,7 @@ end -- @treturn Entity self function Entity:assemble(assemblage, ...) if type(assemblage) ~= "function" then - error("bad argument #1 to 'Entity:assemble' (function expected, got "..type(assemblage)..")") + Utils.error(2, "bad argument #1 to 'Entity:assemble' (function expected, got %s)", type(assemblage)) end assemblage(self, ...) @@ -145,7 +195,7 @@ function Entity:has(name) local ok, componentClass = Components.try(name) if not ok then - error("bad argument #1 to 'Entity:has' ("..componentClass..")", 2) + Utils.error(2, "bad argument #1 to 'Entity:has' (%s)", componentClass) end return self[name] and true or false @@ -158,7 +208,7 @@ function Entity:get(name) local ok, componentClass = Components.try(name) if not ok then - error("bad argument #1 to 'Entity:get' ("..componentClass..")", 2) + Utils.error(2, "bad argument #1 to 'Entity:get' (%s)", componentClass) end return self[name] @@ -168,8 +218,13 @@ end -- Warning: Do not modify this table. -- Use Entity:give/ensure/remove instead -- @treturn table Table of all Components the Entity has -function Entity:getComponents() - return self.__components +function Entity:getComponents(output) + output = output or {} + local components = Utils.shallowCopy(self, output) + components.__world = nil + components.__isEntity = nil + + return components end --- Returns true if the Entity is in a World. @@ -184,11 +239,17 @@ function Entity:getWorld() return self.__world end -function Entity:serialize() +function Entity:serialize(ignoreKey) local data = {} - for _, component in pairs(self.__components) do - if component.__name then + for name, component in pairs(self) do + -- The key component needs to be treated separately. + if name == "key" and component.__name == "key" then + if not ignoreKey then + data.key = component.value + end + --We only care about components that were properly given to the entity + elseif Type.isComponent(component) and (component.__name == name) then local componentData = component:serialize() if componentData ~= nil then @@ -206,18 +267,10 @@ function Entity:deserialize(data) local componentData = data[i] if (not Components.has(componentData.__name)) then - error("bad argument #1 to 'Entity:deserialize' (ComponentClass '"..tostring(componentData.__name).."' wasn't yet loaded)") -- luacheck: ignore + Utils.error(2, "bad argument #1 to 'Entity:deserialize' (ComponentClass '%s' wasn't yet loaded)", tostring(componentData.__name)) -- luacheck: ignore end - local componentClass = Components[componentData.__name] - - local component = componentClass:__new() - component:deserialize(componentData) - - self[componentData.__name] = component - self.__components[componentData.__name] = component - - self:__dirty() + deserializeComponent(self, componentData.__name, componentData) end end diff --git a/concord/filter.lua b/concord/filter.lua new file mode 100644 index 0000000..0c4dabf --- /dev/null +++ b/concord/filter.lua @@ -0,0 +1,199 @@ +--- Used to filter Entities with specific Components +-- A Filter has an associated Pool that can contain any amount of Entities. +-- @classmod Filter + +local PATH = (...):gsub('%.[^%.]+$', '') + +local List = require(PATH..".list") +local Type = require(PATH..".type") +local Utils = require(PATH..".utils") +local Components = require(PATH..".components") + + +local Filter = {} +Filter.__mt = { + __index = Filter, +} + +--- Validates a Filter Definition to make sure every component is valid. +-- @string name Name for the Filter. +-- @tparam table definition Table containing the Filter Definition +-- @tparam onComponent Optional function, called when a component is valid. +function Filter.validate (errorLevel, name, def, onComponent) + local filter = "World:query filter" + if name then + filter = ("filter '%s'"):format(name) + end + + if type(def) ~= 'table' then + Utils.error(3 + errorLevel, "invalid component list for %s (table expected, got %s)", filter, type(def)) + end + + if not onComponent and def.constructor and not Type.isCallable(def.constructor) then + Utils.error(3 + errorLevel, "invalid pool constructor for %s (callable expected, got %s)", filter, type(def.constructor)) + end + + for n, component in ipairs(def) do + local ok, err, reject = Components.try(component, true) + + if not ok then + Utils.error(3 + errorLevel, "invalid component for %s at position #%d (%s)", filter, n, err) + end + + if onComponent then + onComponent(component, reject) + end + end +end + +--- Parses the Filter Defintion into two tables +-- required: An array of all the required component names. +-- rejected: An array of all the components that will be rejected. +-- @string name Name for the Filter. +-- @tparam table definition Table containing the Filter Definition +-- @treturn table required +-- @treturn table rejected +function Filter.parse (name, def) + local filter = {} + + Filter.validate(1, name, def, function (component, reject) + if reject then + table.insert(filter, reject) + table.insert(filter, false) + else + table.insert(filter, component) + table.insert(filter, true) + end + end) + + return filter +end + +function Filter.match (e, filter) + for i=#filter, 2, -2 do + local match = filter[i - 0] + local name = filter[i - 1] + + if (not e[name]) == match then return false end + end + + return true +end + +local REQUIRED_METHODS = {"add", "remove", "has", "clear"} +local VALID_POOL_TYPES = {table=true, userdata=true, lightuserdata=true, cdata=true} + +function Filter.isValidPool (name, pool) + local poolType = type(pool) + --Check that pool is not nil + if not VALID_POOL_TYPES[poolType] then + Utils.error(3, "invalid value returned by pool '%s' constructor (table expected, got %s).", name, type(pool)) + end + + --Check if methods are callables + for _, method in ipairs(REQUIRED_METHODS) do + if not Type.isCallable(pool[method]) then + Utils.error(3, "invalid :%s method on pool '%s' (callable expected, got %s).", method, name, type(pool[method])) + end + end +end + +--- Creates a new Filter +-- @string name Name for the Filter. +-- @tparam table definition Table containing the Filter Definition +-- @treturn Filter The new Filter +-- @treturn Pool The associated Pool +function Filter.new (name, def) + local pool + + if def.constructor then + pool = def.constructor(def) + Filter.isValidPool(name, pool) + else + pool = List() + end + + local filter = Filter.parse(name, def) + + local filter = setmetatable({ + pool = pool, + + __filter = filter, + __name = name, + + __isFilter = true, + }, Filter.__mt) + + return filter, pool +end + +--- Checks if an Entity fulfills the Filter requirements. +-- @tparam Entity e Entity to check +-- @treturn boolean +function Filter:eligible(e) + return Filter.match(e, self.__filter) +end + +function Filter:evaluate (e) + local has = self.pool:has(e) + local eligible = self:eligible(e) + + if not has and eligible then + self.pool:add(e) + elseif has and not eligible then + self.pool:remove(e) + end + + return self +end + + +-- Adds an Entity to the Pool, if it passes the Filter. +-- @param e Entity to add +-- @param bypass Whether to bypass the Filter or not. +-- @treturn Filter self +-- @treturn boolean Whether the entity was added or not. +function Filter:add (e, bypass) + if not bypass and not self:eligible(e) then + return self, false + end + + self.pool:add(e) + + return self, true +end + +-- Remove an Entity from the Pool associated to this Filter. +-- @param e Entity to remove +-- @treturn Filter self +function Filter:remove (e) + self.pool:remove(e) + return self +end + +-- Clear the Pool associated to this Filter. +-- @param e Entity to remove +-- @treturn Filter self +function Filter:clear (e) + self.pool:clear(e) + return self +end + +-- Check if the Pool bound to this System contains the passed Entity +-- @param e Entity to check +-- @treturn boolean Whether the Entity exists. +function Filter:has (e) + return self.pool:has(e) +end + +--- Gets the name of the Filter +-- @treturn string +function Filter:getName() + return self.__name +end + +return setmetatable(Filter, { + __call = function(_, ...) + return Filter.new(...) + end, +}) \ No newline at end of file diff --git a/concord/list.lua b/concord/list.lua index 1263a72..7f10c74 100644 --- a/concord/list.lua +++ b/concord/list.lua @@ -16,7 +16,7 @@ end --- Adds an object to the List. -- Object must be of reference type --- Object may not be the string 'size' +-- Object may not be the string 'size', 'onAdded' or 'onRemoved' -- @param obj Object to add -- @treturn List self function List:add(obj) @@ -26,6 +26,7 @@ function List:add(obj) self[obj] = size self.size = size + if self.onAdded then self:onAdded(obj) end return self end @@ -51,6 +52,7 @@ function List:remove(obj) self[obj] = nil self.size = size - 1 + if self.onRemoved then self:onRemoved(obj) end return self end @@ -94,6 +96,30 @@ function List:indexOf(obj) return self[obj] end +--- Sorts the List in place, using the order function. +-- The order function is passed to table.sort internally so documentation on table.sort can be used as reference. +-- @param order Function that takes two Entities (a and b) and returns true if a should go before than b. +-- @treturn List self +function List:sort(order) + table.sort(self, order) + + for key, obj in ipairs(self) do + self[obj] = key + end + + return self +end + +--- Callback for when an item is added to the List. +-- @param obj Object that was added +function List:onAdded (obj) --luacheck: ignore +end + +--- Callback for when an item is removed to the List. +-- @param obj Object that was removed +function List:onRemoved (obj) --luacheck: ignore +end + return setmetatable(List, { __call = function() return List.new() diff --git a/concord/pool.lua b/concord/pool.lua deleted file mode 100644 index f1c80eb..0000000 --- a/concord/pool.lua +++ /dev/null @@ -1,111 +0,0 @@ ---- Used to iterate over Entities with a specific Components --- A Pool contain a any amount of Entities. --- @classmod Pool - -local PATH = (...):gsub('%.[^%.]+$', '') - -local List = require(PATH..".list") - -local Pool = {} -Pool.__mt = { - __index = Pool, -} - ---- Creates a new Pool --- @string name Name for the Pool. --- @tparam table filter Table containing the required BaseComponents --- @treturn Pool The new Pool -function Pool.new(name, filter) - local pool = setmetatable(List(), Pool.__mt) - - pool.__name = name - pool.__filter = filter - - pool.__isPool = true - - return pool -end - ---- Checks if an Entity is eligible for the Pool. --- @tparam Entity e Entity to check --- @treturn boolean -function Pool:eligible(e) - for i=#self.__filter, 1, -1 do - local component = self.__filter[i].__name - - if not e[component] then return false end - end - - return true -end - --- Adds an Entity to the Pool, if it can be eligible. --- @param e Entity to add --- @treturn Pool self --- @treturn boolean Whether the entity was added or not -function Pool:add(e, bypass) - if not bypass and not self:eligible(e) then - return self, false - end - - List.add(self, e) - self:onEntityAdded(e) - - return self, true -end - --- Remove an Entity from the Pool. --- @param e Entity to remove --- @treturn Pool self -function Pool:remove(e) - List.remove(self, e) - self:onEntityRemoved(e) - - return self -end - ---- Evaluate whether an Entity should be added or removed from the Pool. --- @param e Entity to add or remove --- @treturn Pool self -function Pool:evaluate(e) - local has = self:has(e) - local eligible = self:eligible(e) - - if not has and eligible then - self:add(e, true) --Bypass the check cause we already checked - elseif has and not eligible then - self:remove(e) - end - - return self -end - ---- Gets the name of the Pool --- @treturn string -function Pool:getName() - return self.__name -end - ---- Gets the filter of the Pool. --- Warning: Do not modify this filter. --- @return Filter of the Pool. -function Pool:getFilter() - return self.__filter -end - ---- Callback for when an Entity is added to the Pool. --- @tparam Entity e Entity that was added. -function Pool:onEntityAdded(e) -- luacheck: ignore -end - --- Callback for when an Entity is removed from the Pool. --- @tparam Entity e Entity that was removed. -function Pool:onEntityRemoved(e) -- luacheck: ignore -end - -return setmetatable(Pool, { - __index = List, - __call = function(_, ...) - return Pool.new(...) - end, -}) diff --git a/concord/system.lua b/concord/system.lua index 756b124..e9d45d6 100644 --- a/concord/system.lua +++ b/concord/system.lua @@ -5,9 +5,8 @@ local PATH = (...):gsub('%.[^%.]+$', '') -local Pool = require(PATH..".pool") +local Filter = require(PATH..".filter") local Utils = require(PATH..".utils") -local Components = require(PATH..".components") local System = { ENABLE_OPTIMIZATION = true, @@ -19,7 +18,7 @@ System.mt = { local system = setmetatable({ __enabled = true, - __pools = {}, + __filters = {}, __world = world, __isSystem = true, @@ -33,11 +32,11 @@ System.mt = { Utils.shallowCopy(systemClass, system) end - for name, filter in pairs(systemClass.__filter) do - local pool = Pool(name, filter) + for name, def in pairs(systemClass.__definition) do + local filter, pool = Filter(name, Utils.shallowCopy(def, {})) system[name] = pool - system.__pools[#system.__pools + 1] = pool + table.insert(system.__filters, filter) end system:init(world) @@ -45,44 +44,23 @@ System.mt = { return system end, } - -local validateFilters = function (baseFilters) - local filters = {} - - for name, componentsList in pairs(baseFilters) do - if type(name) ~= 'string' then - error("invalid name for filter (string key expected, got "..type(name)..")", 3) - end - - if type(componentsList) ~= 'table' then - error("invalid component list for filter '"..name.."' (table expected, got "..type(componentsList)..")", 3) - end - - local filter = {} - for n, component in ipairs(componentsList) do - local ok, componentClass = Components.try(component) - - if not ok then - error("invalid component for filter '"..name.."' at position #"..n.." ("..componentClass..")", 3) - end - - filter[#filter + 1] = componentClass - end - - filters[name] = filter - end - - return filters -end - --- Creates a new SystemClass. -- @param table filters A table containing filters (name = {components...}) -- @treturn System A new SystemClass -function System.new(filters) - local systemClass = setmetatable({ - __filter = validateFilters(filters), +function System.new(definition) + definition = definition or {} + + for name, def in pairs(definition) do + if type(name) ~= 'string' then + Utils.error(2, "invalid name for filter (string key expected, got %s)", type(name)) + end + + Filter.validate(0, name, def) + end + + local systemClass = setmetatable({ + __definition = definition, - __name = nil, __isSystemClass = true, }, System.mt) systemClass.__index = systemClass @@ -101,8 +79,8 @@ end -- @param e The Entity to check -- @treturn System self function System:__evaluate(e) - for _, pool in ipairs(self.__pools) do - pool:evaluate(e) + for _, filter in ipairs(self.__filters) do + filter:evaluate(e) end return self @@ -112,9 +90,9 @@ end -- @param e The Entity to remove -- @treturn System self function System:__remove(e) - for _, pool in ipairs(self.__pools) do - if pool:has(e) then - pool:remove(e) + for _, filter in ipairs(self.__filters) do + if filter:has(e) then + filter:remove(e) end end @@ -124,8 +102,8 @@ end -- Internal: Clears all Entities from the System. -- @treturn System self function System:__clear() - for i = 1, #self.__pools do - self.__pools[i]:clear() + for _, filter in ipairs(self.__filters) do + filter:clear() end return self @@ -158,18 +136,6 @@ function System:getWorld() return self.__world end ---- Returns true if the System has a name. --- @treturn boolean -function System:hasName() - return self.__name and true or false -end - ---- Returns the name of the System. --- @treturn string -function System:getName() - return self.__name -end - --- Callbacks -- @section Callbacks diff --git a/concord/type.lua b/concord/type.lua index 157a8d4..bf7f52a 100644 --- a/concord/type.lua +++ b/concord/type.lua @@ -3,6 +3,15 @@ local Type = {} +function Type.isCallable(t) + if type(t) == "function" then return true end + + local meta = getmetatable(t) + if meta and type(meta.__call) == "function" then return true end + + return false +end + --- Returns if object is an Entity. -- @param t Object to check -- @treturn boolean @@ -45,4 +54,11 @@ function Type.isWorld(t) return type(t) == "table" and t.__isWorld or false end +--- Returns if object is a Filter. +-- @param t Object to check +-- @treturn boolean +function Type.isFilter(t) + return type(t) == "table" and t.__isFilter or false +end + return Type diff --git a/concord/utils.lua b/concord/utils.lua index da83c4d..0802586 100644 --- a/concord/utils.lua +++ b/concord/utils.lua @@ -3,6 +3,10 @@ local Utils = {} +function Utils.error(level, str, ...) + error(string.format(str, ...), level + 1) +end + --- Does a shallow copy of a table and appends it to a target table. -- @param orig Table to copy -- @param target Table to append to @@ -21,39 +25,43 @@ end -- @param namespace A table that will hold the required files -- @treturn table The namespace table function Utils.loadNamespace(pathOrFiles, namespace) - if (type(pathOrFiles) ~= "string" and type(pathOrFiles) ~= "table") then - error("bad argument #1 to 'loadNamespace' (string/table of strings expected, got "..type(pathOrFiles)..")", 2) + if type(pathOrFiles) ~= "string" and type(pathOrFiles) ~= "table" then + Utils.error(2, "bad argument #1 to 'loadNamespace' (string/table of strings expected, got %s)", type(pathOrFiles)) end - if (type(pathOrFiles) == "string") then + if type(pathOrFiles) == "string" then local info = love.filesystem.getInfo(pathOrFiles) -- luacheck: ignore - if (info == nil or info.type ~= "directory") then - error("bad argument #1 to 'loadNamespace' (path '"..pathOrFiles.."' not found)", 2) + if info == nil or info.type ~= "directory" then + Utils.error(2, "bad argument #1 to 'loadNamespace' (path '%s' not found)", pathOrFiles) end local files = love.filesystem.getDirectoryItems(pathOrFiles) for _, file in ipairs(files) do - local name = file:sub(1, #file - 4) - local path = pathOrFiles.."."..name + local isFile = love.filesystem.getInfo(pathOrFiles .. "/" .. file).type == "file" - local value = require(path) - if namespace then namespace[name] = value end + if isFile and string.match(file, '%.lua$') ~= nil then + local name = file:sub(1, #file - 4) + local path = pathOrFiles.."."..name + + local value = require(path:gsub("%/", ".")) + if namespace then namespace[name] = value end + end end - elseif (type(pathOrFiles == "table")) then + elseif type(pathOrFiles) == "table" then for _, path in ipairs(pathOrFiles) do - if (type(path) ~= "string") then - error("bad argument #2 to 'loadNamespace' (string/table of strings expected, got table containing "..type(path)..")", 2) -- luacheck: ignore + if type(path) ~= "string" then + Utils.error(2, "bad argument #2 to 'loadNamespace' (string/table of strings expected, got table containing %s)", type(path)) -- luacheck: ignore end local name = path local dotIndex, slashIndex = path:match("^.*()%."), path:match("^.*()%/") - if (dotIndex or slashIndex) then + if dotIndex or slashIndex then name = path:sub((dotIndex or slashIndex) + 1) end - local value = require(path) + local value = require(path:gsub("%/", ".")) if namespace then namespace[name] = value end end end @@ -61,4 +69,4 @@ function Utils.loadNamespace(pathOrFiles, namespace) return namespace end -return Utils \ No newline at end of file +return Utils diff --git a/concord/world.lua b/concord/world.lua index e874881..7b31a6e 100644 --- a/concord/world.lua +++ b/concord/world.lua @@ -6,10 +6,12 @@ local PATH = (...):gsub('%.[^%.]+$', '') -local Entity = require(PATH..".entity") -local Type = require(PATH..".type") -local List = require(PATH..".list") -local Utils = require(PATH..".utils") +local Filter = require(PATH..".filter") +local Entity = require(PATH..".entity") +local Components = require(PATH..".components") +local Type = require(PATH..".type") +local List = require(PATH..".list") +local Utils = require(PATH..".utils") local World = { ENABLE_OPTIMIZATION = true, @@ -18,6 +20,12 @@ World.__mt = { __index = World, } +local defaultGenerator = function (state) + local current = state + state = state +1 + return string.format("%d", current), state +end + --- Creates a new World. -- @treturn World The new World function World.new() @@ -28,14 +36,24 @@ function World.new() __events = {}, __emitSDepth = 0, + __resources = {}, + + __hash = { + state = -2^53, + generator = defaultGenerator, + keys = {}, + entities = {} + }, + __added = List(), __backAdded = List(), __removed = List(), __backRemoved = List(), __dirty = List(), __backDirty = List(), __systemLookup = {}, - __name = nil, __isWorld = true, + + __ignoreEmits = false }, World.__mt) -- Optimization: We deep copy the World class into our instance of a world. @@ -53,7 +71,7 @@ end -- @treturn World self function World:addEntity(e) if not Type.isEntity(e) then - error("bad argument #1 to 'World:addEntity' (Entity expected, got "..type(e)..")", 2) + Utils.error(2, "bad argument #1 to 'World:addEntity' (Entity expected, got %s)", type(e)) end if e.__world then @@ -66,12 +84,47 @@ function World:addEntity(e) return self end +--- Creates a new Entity and adds it to the World. +-- @treturn Entity e the new Entity +function World:newEntity() + return Entity(self) +end + +function World:query(def, onMatch) + local filter = Filter.parse(nil, def) + + local list = nil + if not Type.isCallable(onMatch) then + list = type(onMatch) == "table" and onMatch or {} + end + + for _, e in ipairs(self.__entities) do + if Filter.match(e, filter) then + if list then + table.insert(list, e) + else + onMatch(e) + end + end + end + + return list +end + --- Removes an Entity from the World. -- @tparam Entity e Entity to remove -- @treturn World self function World:removeEntity(e) if not Type.isEntity(e) then - error("bad argument #1 to 'World:removeEntity' (Entity expected, got "..type(e)..")", 2) + Utils.error(2, "bad argument #1 to 'World:removeEntity' (Entity expected, got %s)", type(e)) + end + + if e.__world ~= self then + error("trying to remove an Entity from a World it doesn't belong to", 2) + end + + if e:has("key") then + e:remove("key") end self.__removed:add(e) @@ -208,7 +261,7 @@ function World:addSystem(systemClass) local ok, err = tryAddSystem(self, systemClass) if not ok then - error("bad argument #1 to 'World:addSystem' ("..err..")", 2) + Utils.error(2, "bad argument #1 to 'World:addSystem' (%s)", err) end return self @@ -226,7 +279,7 @@ function World:addSystems(...) local ok, err = tryAddSystem(self, systemClass) if not ok then - error("bad argument #"..i.." to 'World:addSystems' ("..err..")", 2) + Utils.error(2, "bad argument #%d to 'World:addSystems' (%s)", i, err) end end @@ -238,7 +291,7 @@ end -- @treturn boolean function World:hasSystem(systemClass) if not Type.isSystemClass(systemClass) then - error("bad argument #1 to 'World:getSystem' (systemClass expected, got "..type(systemClass)..")", 2) + Utils.error(2, "bad argument #1 to 'World:hasSystem' (SystemClass expected, got %s)", type(systemClass)) end return self.__systemLookup[systemClass] and true or false @@ -249,7 +302,7 @@ end -- @treturn System System to get function World:getSystem(systemClass) if not Type.isSystemClass(systemClass) then - error("bad argument #1 to 'World:getSystem' (systemClass expected, got "..type(systemClass)..")", 2) + Utils.error(2, "bad argument #1 to 'World:getSystem' (SystemClass expected, got %s)", type(systemClass)) end return self.__systemLookup[systemClass] @@ -262,14 +315,21 @@ end -- @treturn World self function World:emit(functionName, ...) if not functionName or type(functionName) ~= "string" then - error("bad argument #1 to 'World:emit' (String expected, got "..type(functionName)..")") + Utils.error(2, "bad argument #1 to 'World:emit' (String expected, got %s)", type(functionName)) end local shouldFlush = self.__emitSDepth == 0 self.__emitSDepth = self.__emitSDepth + 1 - local listeners = self.__events[functionName] + local listeners = self.__events[functionName] + + if not self.__ignoreEmits and Type.isCallable(self.beforeEmit) then + self.__ignoreEmits = true + local preventDefaults = self:beforeEmit(functionName, listeners, ...) + self.__ignoreEmits = false + if preventDefaults then return end + end if listeners then for i = 1, #listeners do @@ -285,6 +345,12 @@ function World:emit(functionName, ...) end end + if not self.__ignoreEmits and Type.isCallable(self.afterEmit) then + self.__ignoreEmits = true + self:afterEmit(functionName, listeners, ...) + self.__ignoreEmits = false + end + self.__emitSDepth = self.__emitSDepth - 1 return self @@ -316,49 +382,103 @@ function World:getSystems() return self.__systems end -function World:serialize() +function World:serialize(ignoreKeys) self:__flush() - local data = {} + local data = { generator = self.__hash.state } for i = 1, self.__entities.size do local entity = self.__entities[i] - local entityData = entity:serialize() - - data[i] = entityData + if entity.serializable then + local entityData = entity:serialize(ignoreKeys) + table.insert(data, entityData) + end end return data end -function World:deserialize(data, append) - if (not append) then +function World:deserialize(data, startClean, ignoreGenerator) + if startClean then self:clear() end + if (not ignoreGenerator) then + self.__hash.state = data.generator + end + + local entities = {} + for i = 1, #data do - local entityData = data[i] + local entity = Entity(self) - local entity = Entity() - entity:deserialize(entityData) + if data[i].key then + local component = Components.key:__new(entity) + component:deserialize(data[i].key) + entity.key = component - self:addEntity(entity) + entity:__dirty() + end + + entities[i] = entity + end + + for i = 1, #data do + entities[i]:deserialize(data[i]) end self:__flush() + + return self end ---- Returns true if the World has a name. --- @treturn boolean -function World:hasName() - return self.__name and true or false +function World:setKeyGenerator(generator, initialState) + if not Type.isCallable(generator) then + Utils.error(2, "bad argument #1 to 'World:setKeyGenerator' (function expected, got %s)", type(generator)) + end + + self.__hash.generator = generator + self.__hash.state = initialState + + return self end ---- Returns the name of the World. --- @treturn string -function World:getName() - return self.__name +function World:__clearKey(e) + local key = self.__hash.keys[e] + + if key then + self.__hash.keys[e] = nil + self.__hash.entities[key] = nil + end + + return self +end + +function World:__assignKey(e, key) + local hash = self.__hash + + if not key then + key = hash.keys[e] + if key then return key end + + key, hash.state = hash.generator(hash.state) + end + + if hash.entities[key] and hash.entities[key] ~= e then + Utils.error(4, "Trying to assign a key that is already taken (key: '%s').", key) + elseif hash.keys[e] and hash.keys[e] ~= key then + Utils.error(4, "Trying to assign more than one key to an Entity. (old: '%s', new: '%s')", hash.keys[e], key) + end + + hash.keys[e] = key + hash.entities[key] = e + + return key +end + +function World:getEntityByKey(key) + return self.__hash.entities[key] end --- Callback for when an Entity is added to the World. @@ -371,8 +491,25 @@ end function World:onEntityRemoved(e) -- luacheck: ignore end +--- Sets a named resource in the world +-- @string name Name of the resource +-- @tparam Any resource Resource to set +-- @treturn World self +function World:setResource(name, resource) + self.__resources[name] = resource + return self +end + +--- Gets a named resource from the world +-- @string name Name of the resource +-- @treturn Any resource +function World:getResource(name) + return self.__resources[name] +end + return setmetatable(World, { __call = function(_, ...) + ---@diagnostic disable-next-line: redundant-parameter return World.new(...) end, })