From 9a845772a9fc636e439f607e372a21f8dc1c03cf Mon Sep 17 00:00:00 2001 From: Ukendio Date: Wed, 20 Dec 2023 06:15:40 +0100 Subject: [PATCH 01/28] Add check for config --- lib/debugger/hookWidgets.lua | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/debugger/hookWidgets.lua b/lib/debugger/hookWidgets.lua index 2f425e4..b3eb498 100644 --- a/lib/debugger/hookWidgets.lua +++ b/lib/debugger/hookWidgets.lua @@ -34,7 +34,10 @@ local dummyHandles = { }, slider = function(config) - return config.initial or 0 + if type(config) == "table" then + config = config.initial + end + return config end, window = { From 89c3f2c1aedae900a8bd01b10e153c33bf106eb0 Mon Sep 17 00:00:00 2001 From: Ukendio Date: Wed, 20 Dec 2023 18:30:20 +0100 Subject: [PATCH 02/28] Prototyped implementation --- lib/World.lua | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/lib/World.lua b/lib/World.lua index 91f5f33..2a69da6 100644 --- a/lib/World.lua +++ b/lib/World.lua @@ -533,6 +533,55 @@ function QueryResult:without(...) end end +local viewHandle = { + + __iter = function() + local i = 0 + return function() + i += 1 + + local data = self[i] + + if data then + return unpack(data, 1, data.n) + end + return + end + end, +} + +function QueryResult:view(entity) + local viewHandle = {} + viewHandle.__index = viewHandle + + local function iter() + return self._next() + end + + local view = {} + + for entityId, entityData in iter do + if entityId then + -- We start at 2 since we don't need to return the eentity id. Might be a better + view[entityId] = table.pack(select(2, self._expand(entityId, entityData))) + end + end + + function view:get(entity) + if not self:contains(entity) then + return + end + + return unpack(self[entity], 1, self[entity].n) + end + + function view:contains(entity) + return self[entity] ~= nil + end + + return setmetatable(view, viewwHandle) +end + --[=[ Performs a query against the entities in this World. Returns a [QueryResult](/api/QueryResult), which iterates over the results of the query. From 3b8d5d775655287b2d02038ac52b72bd7bdbc93c Mon Sep 17 00:00:00 2001 From: Ukendio Date: Wed, 20 Dec 2023 18:33:23 +0100 Subject: [PATCH 03/28] Remove whitspace --- lib/World.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/World.lua b/lib/World.lua index 2a69da6..d807a26 100644 --- a/lib/World.lua +++ b/lib/World.lua @@ -534,7 +534,6 @@ function QueryResult:without(...) end local viewHandle = { - __iter = function() local i = 0 return function() From 28e1c9e2e68432469d07478a32b63910168d5b61 Mon Sep 17 00:00:00 2001 From: Ukendio Date: Thu, 21 Dec 2023 00:54:58 +0100 Subject: [PATCH 04/28] Added unit tests --- lib/World.spec.lua | 56 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/lib/World.spec.lua b/lib/World.spec.lua index d4f9a4a..e84cb75 100644 --- a/lib/World.spec.lua +++ b/lib/World.spec.lua @@ -505,9 +505,10 @@ return function() }) ) - local snapshot = world:query(Health, Player):snapshot() + local query = world:query(Health, Player) + local snapshot = query:snapshot() - for entityId, health, player in world:query(Health, Player):snapshot() do + for entityId, health, player in snapshot do expect(type(entityId)).to.equal("number") expect(type(player.name)).to.equal("string") expect(type(health.value)).to.equal("number") @@ -523,6 +524,57 @@ return function() end end) + it("should allow viewing a query", function() + local Parent = component("Parent") + local Transform = component("Transform") + + local world = World.new() + + local root = world:spawn(Transform({ pos = Vector2.new(3, 4) })) + local otherRoot = world:spawn(Transform({ pos = Vector2.new(1, 2) })) + + local child = world:spawn( + Parent({ + entity = root, + fromChild = Transform({ pos = Vector2.one }), + }), + Transform.new({ pos = Vector2.zero }) + ) + + local otherChild = world:spawn( + Parent({ + entity = root, + fromChild = Transform({ pos = Vector2.new(0, 0) }), + }), + Transform.new({ pos = Vector2.zero }) + ) + + local grandChild = world:spawn( + Parent({ + entity = child, + fromChild = Transform({ pos = Vector3.new(-1, 0) }), + }), + Transform.new({ pos = Vector2.zero }) + ) + + local parents = world:query(Transform, Parent):view() + + expect(parents:contains(root)).to.equal(false) + + local orderOfIteration = {} + + for id in world:query(Transform, Parent) do + table.insert(orderOfIteration, id) + end + + local view = world:query(Transform, Parent):view() + local i = 0 + for id in view do + i += 1 + expect(orderOfIteration[i]).to.equal(id) + end + end) + it("should not invalidate iterators", function() local world = World.new() local A = component() From 7a93fd71afdea419d3d17f5ede47ff0eab39a57d Mon Sep 17 00:00:00 2001 From: Ukendio Date: Thu, 21 Dec 2023 00:55:09 +0100 Subject: [PATCH 05/28] Preserve order of query traversal --- lib/World.lua | 68 +++++++++++++++++++++++++++------------------------ 1 file changed, 36 insertions(+), 32 deletions(-) diff --git a/lib/World.lua b/lib/World.lua index d807a26..cff19c2 100644 --- a/lib/World.lua +++ b/lib/World.lua @@ -533,52 +533,56 @@ function QueryResult:without(...) end end -local viewHandle = { - __iter = function() - local i = 0 - return function() - i += 1 +local View = {} +View.__index = View - local data = self[i] +function View.new() + return setmetatable({ + entities = {}, + items = {}, + }, View) +end - if data then - return unpack(data, 1, data.n) - end - return - end - end, -} +function View:__iter() + local i = 0 + return function() + i += 1 -function QueryResult:view(entity) - local viewHandle = {} - viewHandle.__index = viewHandle + local entity = self.entities[i] + return entity, self:get(entity) + end +end +function View:get(entity) + if not self:contains(entity) then + return + end + + local item = self.items[entity] + + return unpack(item, 1, item.n) +end + +function View:contains(entity) + return self.items[entity] ~= nil +end + +function QueryResult:view(entity) local function iter() return self._next() end - local view = {} + local view = View.new() for entityId, entityData in iter do if entityId then - -- We start at 2 since we don't need to return the eentity id. Might be a better - view[entityId] = table.pack(select(2, self._expand(entityId, entityData))) - end - end - - function view:get(entity) - if not self:contains(entity) then - return + table.insert(view.entities, entityId) + -- We start 2 on Select since we don't need to return the eentity id. Might be a better + view.items[entityId] = table.pack(select(2, self._expand(entityId, entityData))) end - - return unpack(self[entity], 1, self[entity].n) - end - - function view:contains(entity) - return self[entity] ~= nil end - return setmetatable(view, viewwHandle) + return view end --[=[ From aa3d0f4b4888bc08816223dff13f2f738670f7ae Mon Sep 17 00:00:00 2001 From: Ukendio Date: Thu, 21 Dec 2023 03:23:00 +0100 Subject: [PATCH 06/28] Adding comments --- lib/World.lua | 44 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/lib/World.lua b/lib/World.lua index cff19c2..87ece91 100644 --- a/lib/World.lua +++ b/lib/World.lua @@ -533,6 +533,20 @@ function QueryResult:without(...) end end +--[=[ + @class View + + Provides random access to the results of a query. + + Calling the table is equivalent iterating a query. + + ```lua + for id, player, health, poison in world:query(Player, Health, Poison):view() do + -- Do something + end + ``` +]=] + local View = {} View.__index = View @@ -553,6 +567,11 @@ function View:__iter() end end +--[=[ + Retrieve the query results to corresponding `entity` + @param entity number - the entity ID + @return ...ComponentInstance +]=] function View:get(entity) if not self:contains(entity) then return @@ -563,10 +582,33 @@ function View:get(entity) return unpack(item, 1, item.n) end +--[=[ + Equivalent to `world:contains()` + @param entity number - the entity ID + @return boolean +]=] + function View:contains(entity) return self.items[entity] ~= nil end +--[=[ + Creates a View of the query and does all of the iterator tasks at once at an amortized cost. + This is used for many repeated random access to an entity. If you only need to iterate, just use a query. + + ```lua + for id, player, health, poison in world:query(Player, Health, Poison):view() do + -- Do something + end + + local dyingPeople = world:query(Player, Health, Poison):view() + local remainingHealth = dyingPeople:get(entity) + ``` + + @param ... Component - The component types to query. Only entities with *all* of these components will be returned. + @return View See [View](/api/View) docs. +]=] + function QueryResult:view(entity) local function iter() return self._next() @@ -577,7 +619,7 @@ function QueryResult:view(entity) for entityId, entityData in iter do if entityId then table.insert(view.entities, entityId) - -- We start 2 on Select since we don't need to return the eentity id. Might be a better + -- We start at 2 on Select since we don't need want to pack the entity id. view.items[entityId] = table.pack(select(2, self._expand(entityId, entityData))) end end From 4868ba6ade1d1b7dc9b1ba182689529264d563e1 Mon Sep 17 00:00:00 2001 From: Ukendio Date: Thu, 21 Dec 2023 03:41:50 +0100 Subject: [PATCH 07/28] Remove entity param from view --- lib/World.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/World.lua b/lib/World.lua index 87ece91..d5e13d5 100644 --- a/lib/World.lua +++ b/lib/World.lua @@ -609,7 +609,7 @@ end @return View See [View](/api/View) docs. ]=] -function QueryResult:view(entity) +function QueryResult:view() local function iter() return self._next() end @@ -620,7 +620,7 @@ function QueryResult:view(entity) if entityId then table.insert(view.entities, entityId) -- We start at 2 on Select since we don't need want to pack the entity id. - view.items[entityId] = table.pack(select(2, self._expand(entityId, entityData))) + view.items[entityId] = table.pack(select(2, s._expand(entityId, entityData))) end end From 7f67d1d021d2ce60b0f97ca49f56fb17867f099c Mon Sep 17 00:00:00 2001 From: Ukendio Date: Thu, 21 Dec 2023 03:42:25 +0100 Subject: [PATCH 08/28] Oops. --- lib/World.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/World.lua b/lib/World.lua index d5e13d5..feabec1 100644 --- a/lib/World.lua +++ b/lib/World.lua @@ -620,7 +620,7 @@ function QueryResult:view() if entityId then table.insert(view.entities, entityId) -- We start at 2 on Select since we don't need want to pack the entity id. - view.items[entityId] = table.pack(select(2, s._expand(entityId, entityData))) + view.items[entityId] = table.pack(select(2, self._expand(entityId, entityData))) end end From e70a590761ca8866c48d7fb40900475ae0ca0a56 Mon Sep 17 00:00:00 2001 From: Ukendio Date: Thu, 21 Dec 2023 03:43:11 +0100 Subject: [PATCH 09/28] Add "_" to unused variables --- lib/World.spec.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/World.spec.lua b/lib/World.spec.lua index e84cb75..601d8aa 100644 --- a/lib/World.spec.lua +++ b/lib/World.spec.lua @@ -541,7 +541,7 @@ return function() Transform.new({ pos = Vector2.zero }) ) - local otherChild = world:spawn( + local _otherChild = world:spawn( Parent({ entity = root, fromChild = Transform({ pos = Vector2.new(0, 0) }), @@ -549,7 +549,7 @@ return function() Transform.new({ pos = Vector2.zero }) ) - local grandChild = world:spawn( + local _grandChild = world:spawn( Parent({ entity = child, fromChild = Transform({ pos = Vector3.new(-1, 0) }), From 84e1c36d125e9b6d46ddec35c527a94746f874da Mon Sep 17 00:00:00 2001 From: Ukendio Date: Thu, 21 Dec 2023 03:43:37 +0100 Subject: [PATCH 10/28] Change otherRoot name in tests --- lib/World.spec.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/World.spec.lua b/lib/World.spec.lua index 601d8aa..03107bb 100644 --- a/lib/World.spec.lua +++ b/lib/World.spec.lua @@ -531,7 +531,7 @@ return function() local world = World.new() local root = world:spawn(Transform({ pos = Vector2.new(3, 4) })) - local otherRoot = world:spawn(Transform({ pos = Vector2.new(1, 2) })) + local _otherRoot = world:spawn(Transform({ pos = Vector2.new(1, 2) })) local child = world:spawn( Parent({ From 817d1dfc6d92e167cf22251089663db6195679cd Mon Sep 17 00:00:00 2001 From: Ukendio Date: Thu, 21 Dec 2023 12:17:11 +0100 Subject: [PATCH 11/28] Put keys in a linked list --- lib/World.lua | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/lib/World.lua b/lib/World.lua index feabec1..8bf1177 100644 --- a/lib/World.lua +++ b/lib/World.lua @@ -552,18 +552,20 @@ View.__index = View function View.new() return setmetatable({ - entities = {}, - items = {}, + fetches = {}, }, View) end function View:__iter() - local i = 0 + local current = self.head return function() - i += 1 + if current then + local entity = current.entity + local fetch = self.fetches[entity] + current = current.next - local entity = self.entities[i] - return entity, self:get(entity) + return entity, unpack(fetch, 1, fetch.n) + end end end @@ -577,9 +579,9 @@ function View:get(entity) return end - local item = self.items[entity] + local fetch = self.fetches[entity] - return unpack(item, 1, item.n) + return unpack(fetch, 1, fetch.n) end --[=[ @@ -589,7 +591,7 @@ end ]=] function View:contains(entity) - return self.items[entity] ~= nil + return self.fetches[entity] ~= nil end --[=[ @@ -618,9 +620,19 @@ function QueryResult:view() for entityId, entityData in iter do if entityId then - table.insert(view.entities, entityId) -- We start at 2 on Select since we don't need want to pack the entity id. - view.items[entityId] = table.pack(select(2, self._expand(entityId, entityData))) + local fetch = table.pack(select(2, self._expand(entityId, entityData))) + local node = { entity = entityId, next = nil } + view.fetches[entityId] = fetch + if not view.head then + view.head = node + else + local current = view.head + while current.next do + current = current.next + end + current.next = node + end end end From e7889b965a578b614a9cdb8e309bd54f12711ac0 Mon Sep 17 00:00:00 2001 From: Ukendio Date: Thu, 21 Dec 2023 12:49:35 +0100 Subject: [PATCH 12/28] Add checks for views:get() --- lib/World.spec.lua | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/lib/World.spec.lua b/lib/World.spec.lua index 03107bb..53c4ba0 100644 --- a/lib/World.spec.lua +++ b/lib/World.spec.lua @@ -527,11 +527,12 @@ return function() it("should allow viewing a query", function() local Parent = component("Parent") local Transform = component("Transform") + local Root = component("Root") local world = World.new() - local root = world:spawn(Transform({ pos = Vector2.new(3, 4) })) - local _otherRoot = world:spawn(Transform({ pos = Vector2.new(1, 2) })) + local root = world:spawn(Transform({ pos = Vector2.new(3, 4) }), Root()) + local _otherRoot = world:spawn(Transform({ pos = Vector2.new(1, 2) }), Root()) local child = world:spawn( Parent({ @@ -552,12 +553,13 @@ return function() local _grandChild = world:spawn( Parent({ entity = child, - fromChild = Transform({ pos = Vector3.new(-1, 0) }), + fromChild = Transform({ pos = Vector2.new(-1, 0) }), }), Transform.new({ pos = Vector2.zero }) ) - local parents = world:query(Transform, Parent):view() + local parents = world:query(Parent):view() + local roots = world:query(Transform, Root):view() expect(parents:contains(root)).to.equal(false) @@ -573,6 +575,23 @@ return function() i += 1 expect(orderOfIteration[i]).to.equal(id) end + + for id, absolute, parent in world:query(Transform, Parent) do + local relative = parent.fromChild.pos + local ancestor = parent.entity + local current = parents:get(ancestor) + while current do + relative = current.fromChild.pos * relative + ancestor = current.entity + current = parents:get(ancestor) + end + + local pos = roots:get(ancestor).pos + + world:insert(id, absolute:patch({ pos = Vector2.new(pos.x + relative.x, pos.y + relative.y) })) + end + + expect(world:get(child, Transform).pos).to.equal(Vector2.new(4, 5)) end) it("should not invalidate iterators", function() From 09bd25183b96373ee2c990613728c816634cce12 Mon Sep 17 00:00:00 2001 From: Ukendio Date: Fri, 22 Dec 2023 18:34:52 +0100 Subject: [PATCH 13/28] Initial Commit --- lib/World.lua | 259 +------------------------------------------ lib/World.spec.lua | 75 ++++++++++++- lib/query.lua | 266 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 342 insertions(+), 258 deletions(-) create mode 100644 lib/query.lua diff --git a/lib/World.lua b/lib/World.lua index 91f5f33..1f9414a 100644 --- a/lib/World.lua +++ b/lib/World.lua @@ -7,6 +7,8 @@ local assertValidComponent = Component.assertValidComponent local archetypeOf = archetypeModule.archetypeOf local areArchetypesCompatible = archetypeModule.areArchetypesCompatible +local QueryResult = require(script.Parent.query) + local ERROR_NO_ENTITY = "Entity doesn't exist, use world:contains to check if needed" --[=[ @@ -370,169 +372,6 @@ function World:get(id, ...) return unpack(components, 1, length) end ---[=[ - @class QueryResult - - A result from the [`World:query`](/api/World#query) function. - - Calling the table or the `next` method allows iteration over the results. Once all results have been returned, the - QueryResult is exhausted and is no longer useful. - - ```lua - for id, enemy, charge, model in world:query(Enemy, Charge, Model) do - -- Do something - end - ``` -]=] -local QueryResult = {} -QueryResult.__index = QueryResult - -function QueryResult:__call() - return self._expand(self._next()) -end - -function QueryResult:__iter() - return function() - return self._expand(self._next()) - end -end - ---[=[ - Returns the next set of values from the query result. Once all results have been returned, the - QueryResult is exhausted and is no longer useful. - - :::info - This function is equivalent to calling the QueryResult as a function. When used in a for loop, this is implicitly - done by the language itself. - ::: - - ```lua - -- Using world:query in this position will make Lua invoke the table as a function. This is conventional. - for id, enemy, charge, model in world:query(Enemy, Charge, Model) do - -- Do something - end - ``` - - If you wanted to iterate over the QueryResult without a for loop, it's recommended that you call `next` directly - instead of calling the QueryResult as a function. - ```lua - local id, enemy, charge, model = world:query(Enemy, Charge, Model):next() - local id, enemy, charge, model = world:query(Enemy, Charge, Model)() -- Possible, but unconventional - ``` - - @return id -- Entity ID - @return ...ComponentInstance -- The requested component values -]=] -function QueryResult:next() - return self._expand(self._next()) -end - -local snapshot = { - __iter = function(self): any - local i = 0 - return function() - i += 1 - - local data = self[i] - - if data then - return unpack(data, 1, data.n) - end - return - end - end, -} - ---[=[ - Creates a "snapshot" of this query, draining this QueryResult and returning a list containing all of its results. - - By default, iterating over a QueryResult happens in "real time": it iterates over the actual data in the ECS, so - changes that occur during the iteration will affect future results. - - By contrast, `QueryResult:snapshot()` creates a list of all of the results of this query at the moment it is called, - so changes made while iterating over the result of `QueryResult:snapshot` do not affect future results of the - iteration. - - Of course, this comes with a cost: we must allocate a new list and iterate over everything returned from the - QueryResult in advance, so using this method is slower than iterating over a QueryResult directly. - - The table returned from this method has a custom `__iter` method, which lets you use it as you would use QueryResult - directly: - - ```lua - for entityId, health, player in world:query(Health, Player):snapshot() do - - end - ``` - - However, the table itself is just a list of sub-tables structured like `{entityId, component1, component2, ...etc}`. - - @return {{entityId: number, component: ComponentInstance, component: ComponentInstance, component: ComponentInstance, ...}} -]=] -function QueryResult:snapshot() - local list = setmetatable({}, snapshot) - - local function iter() - return self._next() - end - - for entityId, entityData in iter do - if entityId then - table.insert(list, table.pack(self._expand(entityId, entityData))) - end - end - - return list -end - ---[=[ - Returns an iterator that will skip any entities that also have the given components. - - :::tip - This is essentially equivalent to querying normally, using `World:get` to check if a component is present, - and using Lua's `continue` keyword to skip this iteration (though, using `:without` is faster). - - This means that you should avoid queries that return a very large amount of results only to filter them down - to a few with `:without`. If you can, always prefer adding components and making your query more specific. - ::: - - @param ... Component -- The component types to filter against. - @return () -> (id, ...ComponentInstance) -- Iterator of entity ID followed by the requested component values - - ```lua - for id in world:query(Target):without(Model) do - -- Do something - end - ``` -]=] -function QueryResult:without(...) - local metatables = { ... } - return function(): any - while true do - local entityId, entityData = self._next() - - if not entityId then - break - end - - local skip = false - for _, metatable in ipairs(metatables) do - if entityData[metatable] then - skip = true - break - end - end - - if skip then - continue - end - - return self._expand(entityId, entityData) - end - return - end -end - --[=[ Performs a query against the entities in this World. Returns a [QueryResult](/api/QueryResult), which iterates over the results of the query. @@ -553,99 +392,7 @@ end @return QueryResult -- See [QueryResult](/api/QueryResult) docs. ]=] function World:query(...) - debug.profilebegin("World:query") - assertValidComponent((...), 1) - - local metatables = { ... } - local queryLength = select("#", ...) - - local archetype = archetypeOf(...) - - if self._queryCache[archetype] == nil then - self:_newQueryArchetype(archetype) - end - - local compatibleArchetypes = self._queryCache[archetype] - - debug.profileend() - - if next(compatibleArchetypes) == nil then - -- If there are no compatible storages avoid creating our complicated iterator - return setmetatable({ - _expand = function() end, - _next = function() end, - }, QueryResult) - end - - local queryOutput = table.create(queryLength) - - local function expand(entityId, entityData) - if not entityId then - return - end - - for i, metatable in ipairs(metatables) do - queryOutput[i] = entityData[metatable] - end - - return entityId, unpack(queryOutput, 1, queryLength) - end - - local currentCompatibleArchetype = next(compatibleArchetypes) - local lastEntityId - local storageIndex = 1 - - if self._pristineStorage == self._storages[1] then - self:_markStorageDirty() - end - - local seenEntities = {} - - local function nextItem() - local entityId, entityData - - repeat - if self._storages[storageIndex][currentCompatibleArchetype] then - entityId, entityData = next(self._storages[storageIndex][currentCompatibleArchetype], lastEntityId) - end - - while entityId == nil do - currentCompatibleArchetype = next(compatibleArchetypes, currentCompatibleArchetype) - - if currentCompatibleArchetype == nil then - storageIndex += 1 - - local nextStorage = self._storages[storageIndex] - - if nextStorage == nil or next(nextStorage) == nil then - return - end - - currentCompatibleArchetype = nil - - if self._pristineStorage == nextStorage then - self:_markStorageDirty() - end - - continue - elseif self._storages[storageIndex][currentCompatibleArchetype] == nil then - continue - end - - entityId, entityData = next(self._storages[storageIndex][currentCompatibleArchetype]) - end - lastEntityId = entityId - - until seenEntities[entityId] == nil - - seenEntities[entityId] = true - return entityId, entityData - end - - return setmetatable({ - _expand = expand, - _next = nextItem, - }, QueryResult) + return QueryResult.new(self, ...) end local function cleanupQueryChanged(hookState) diff --git a/lib/World.spec.lua b/lib/World.spec.lua index d4f9a4a..53c4ba0 100644 --- a/lib/World.spec.lua +++ b/lib/World.spec.lua @@ -505,9 +505,10 @@ return function() }) ) - local snapshot = world:query(Health, Player):snapshot() + local query = world:query(Health, Player) + local snapshot = query:snapshot() - for entityId, health, player in world:query(Health, Player):snapshot() do + for entityId, health, player in snapshot do expect(type(entityId)).to.equal("number") expect(type(player.name)).to.equal("string") expect(type(health.value)).to.equal("number") @@ -523,6 +524,76 @@ return function() end end) + it("should allow viewing a query", function() + local Parent = component("Parent") + local Transform = component("Transform") + local Root = component("Root") + + local world = World.new() + + local root = world:spawn(Transform({ pos = Vector2.new(3, 4) }), Root()) + local _otherRoot = world:spawn(Transform({ pos = Vector2.new(1, 2) }), Root()) + + local child = world:spawn( + Parent({ + entity = root, + fromChild = Transform({ pos = Vector2.one }), + }), + Transform.new({ pos = Vector2.zero }) + ) + + local _otherChild = world:spawn( + Parent({ + entity = root, + fromChild = Transform({ pos = Vector2.new(0, 0) }), + }), + Transform.new({ pos = Vector2.zero }) + ) + + local _grandChild = world:spawn( + Parent({ + entity = child, + fromChild = Transform({ pos = Vector2.new(-1, 0) }), + }), + Transform.new({ pos = Vector2.zero }) + ) + + local parents = world:query(Parent):view() + local roots = world:query(Transform, Root):view() + + expect(parents:contains(root)).to.equal(false) + + local orderOfIteration = {} + + for id in world:query(Transform, Parent) do + table.insert(orderOfIteration, id) + end + + local view = world:query(Transform, Parent):view() + local i = 0 + for id in view do + i += 1 + expect(orderOfIteration[i]).to.equal(id) + end + + for id, absolute, parent in world:query(Transform, Parent) do + local relative = parent.fromChild.pos + local ancestor = parent.entity + local current = parents:get(ancestor) + while current do + relative = current.fromChild.pos * relative + ancestor = current.entity + current = parents:get(ancestor) + end + + local pos = roots:get(ancestor).pos + + world:insert(id, absolute:patch({ pos = Vector2.new(pos.x + relative.x, pos.y + relative.y) })) + end + + expect(world:get(child, Transform).pos).to.equal(Vector2.new(4, 5)) + end) + it("should not invalidate iterators", function() local world = World.new() local A = component() diff --git a/lib/query.lua b/lib/query.lua new file mode 100644 index 0000000..53315b2 --- /dev/null +++ b/lib/query.lua @@ -0,0 +1,266 @@ +local archetypeModule = require(script.Parent.archetype) +local Component = require(script.Parent.component) + +local assertValidComponent = Component.assertValidComponent +local archetypeOf = archetypeModule.archetypeOf + +--[=[ + @class QueryResult + + A result from the [`World:query`](/api/World#query) function. + + Calling the table or the `next` method allows iteration over the results. Once all results have been returned, the + QueryResult is exhausted and is no longer useful. + + ```lua + for id, enemy, charge, model in world:query(Enemy, Charge, Model) do + -- Do something + end + ``` +]=] +local QueryResult = {} +QueryResult.__index = QueryResult + +function QueryResult:__call() + return self:_expand(self:_next()) +end + +--[=[ + Returns the next set of values from the query result. Once all results have been returned, the + QueryResult is exhausted and is no longer useful. + + :::info + This function is equivalent to calling the QueryResult as a function. When used in a for loop, this is implicitly + done by the language itself. + ::: + + ```lua + -- Using world:query in this position will make Lua invoke the table as a function. This is conventional. + for id, enemy, charge, model in world:query(Enemy, Charge, Model) do + -- Do something + end + ``` + + If you wanted to iterate over the QueryResult without a for loop, it's recommended that you call `next` directly + instead of calling the QueryResult as a function. + ```lua + local id, enemy, charge, model = world:query(Enemy, Charge, Model):next() + local id, enemy, charge, model = world:query(Enemy, Charge, Model)() -- Possible, but unconventional + ``` + + @return id -- Entity ID + @return ...ComponentInstance -- The requested component values +]=] +function QueryResult:next() + return self:_expand(self:_next()) +end + +local snapshot = { + __iter = function(self): any + local i = 0 + return function() + i += 1 + + local data = self[i] + + if data then + return unpack(data, 1, data.n) + end + return + end + end, +} + +--[=[ + Creates a "snapshot" of this query, draining this QueryResult and returning a list containing all of its results. + + By default, iterating over a QueryResult happens in "real time": it iterates over the actual data in the ECS, so + changes that occur during the iteration will affect future results. + + By contrast, `QueryResult:snapshot()` creates a list of all of the results of this query at the moment it is called, + so changes made while iterating over the result of `QueryResult:snapshot` do not affect future results of the + iteration. + + Of course, this comes with a cost: we must allocate a new list and iterate over everything returned from the + QueryResult in advance, so using this method is slower than iterating over a QueryResult directly. + + The table returned from this method has a custom `__iter` method, which lets you use it as you would use QueryResult + directly: + + ```lua + for entityId, health, player in world:query(Health, Player):snapshot() do + + end + ``` + + However, the table itself is just a list of sub-tables structured like `{entityId, component1, component2, ...etc}`. + + @return {{entityId: number, component: ComponentInstance, component: ComponentInstance, component: ComponentInstance, ...}} +]=] +function QueryResult:snapshot() + local list = setmetatable({}, snapshot) + + local function iter() + return self:_next() + end + + for entityId, entityData in iter do + if entityId then + table.insert(list, table.pack(self:_expand(entityId, entityData))) + end + end + + return list +end + +--[=[ + Returns an iterator that will skip any entities that also have the given components. + + :::tip + This is essentially equivalent to querying normally, using `World:get` to check if a component is present, + and using Lua's `continue` keyword to skip this iteration (though, using `:without` is faster). + + This means that you should avoid queries that return a very large amount of results only to filter them down + to a few with `:without`. If you can, always prefer adding components and making your query more specific. + ::: + + @param ... Component -- The component types to filter against. + @return () -> (id, ...ComponentInstance) -- Iterator of entity ID followed by the requested component values + + ```lua + for id in world:query(Target):without(Model) do + -- Do something + end + ``` +]=] + +function QueryResult:_next() + local world = self.world + local currentCompatibleArchetype = self.currentCompatibleArchetype + local storageIndex = self.storageIndex + local seenEntities = self.seenEntities + local compatibleArchetypes = self.compatibleArchetypes + + local entityId, entityData + + repeat + if world._storages[storageIndex][currentCompatibleArchetype] then + entityId, entityData = next(world._storages[storageIndex][currentCompatibleArchetype], self.lastEntityId) + end + + while entityId == nil do + currentCompatibleArchetype = next(compatibleArchetypes, currentCompatibleArchetype) + + if currentCompatibleArchetype == nil then + storageIndex += 1 + + local nextStorage = world._storages[storageIndex] + + if nextStorage == nil or next(nextStorage) == nil then + return + end + + currentCompatibleArchetype = nil + + if world._pristineStorage == nextStorage then + world:_markStorageDirty() + end + + continue + elseif world._storages[storageIndex][currentCompatibleArchetype] == nil then + continue + end + + entityId, entityData = next(world._storages[storageIndex][currentCompatibleArchetype]) + end + + self.lastEntityId = entityId + + until seenEntities[entityId] == nil + + self.currentCompatibleArchetype = currentCompatibleArchetype + + seenEntities[entityId] = true + + for metatable in entityData do + if table.find(self._filter, metatable) then + return self:_next() + end + end + return entityId, entityData +end + +function QueryResult:without(...) + self._filter = { ... } + + return self +end + +function QueryResult:transform() end + +function QueryResult:_expand(entityId, entityData) + local metatables = self.metatables + local queryLength = #metatables + local queryOutput = table.create(queryLength) + + if not entityId then + return + end + + for i, metatable in ipairs(metatables) do + queryOutput[i] = entityData[metatable] + end + + return entityId, unpack(queryOutput, 1, queryLength) +end + +function QueryResult:__iter() + return function() + return self:_expand(self:_next()) + end +end + +function QueryResult:_transform(phantomData) end + +function QueryResult.new(world, ...) + debug.profilebegin("World:query") + assertValidComponent((...), 1) + + local metatables = { ... } + local archetype = archetypeOf(...) + + if world._queryCache[archetype] == nil then + world:_newQueryArchetype(archetype) + end + + local compatibleArchetypes = world._queryCache[archetype] + if next(compatibleArchetypes) == nil then + -- If there are no compatible storages avoid creating our complicated iterator + end + + debug.profileend() + + local currentCompatibleArchetype = next(compatibleArchetypes) + + local lastEntityId + local storageIndex = 1 + + if world._pristineStorage == world._storages[1] then + world:_markStorageDirty() + end + + local seenEntities = {} + + return setmetatable({ + world = world, + metatables = metatables, + seenEntities = seenEntities, + currentCompatibleArchetype = currentCompatibleArchetype, + compatibleArchetypes = compatibleArchetypes, + lastEntityId = lastEntityId, + storageIndex = storageIndex, + _filter = {}, + }, QueryResult) +end + +return QueryResult From ad24560e03196faf0a9089cbf2374dd8df54d29c Mon Sep 17 00:00:00 2001 From: Ukendio Date: Fri, 22 Dec 2023 19:14:15 +0100 Subject: [PATCH 14/28] iterate self._filter instead of entityData --- lib/World.spec.lua | 70 +--------------------------------------------- lib/query.lua | 5 ++-- 2 files changed, 4 insertions(+), 71 deletions(-) diff --git a/lib/World.spec.lua b/lib/World.spec.lua index 53c4ba0..b1d6dfd 100644 --- a/lib/World.spec.lua +++ b/lib/World.spec.lua @@ -522,76 +522,8 @@ return function() else expect(snapshot[2][1]).to.equal(1) end - end) - - it("should allow viewing a query", function() - local Parent = component("Parent") - local Transform = component("Transform") - local Root = component("Root") - - local world = World.new() - - local root = world:spawn(Transform({ pos = Vector2.new(3, 4) }), Root()) - local _otherRoot = world:spawn(Transform({ pos = Vector2.new(1, 2) }), Root()) - - local child = world:spawn( - Parent({ - entity = root, - fromChild = Transform({ pos = Vector2.one }), - }), - Transform.new({ pos = Vector2.zero }) - ) - - local _otherChild = world:spawn( - Parent({ - entity = root, - fromChild = Transform({ pos = Vector2.new(0, 0) }), - }), - Transform.new({ pos = Vector2.zero }) - ) - - local _grandChild = world:spawn( - Parent({ - entity = child, - fromChild = Transform({ pos = Vector2.new(-1, 0) }), - }), - Transform.new({ pos = Vector2.zero }) - ) - - local parents = world:query(Parent):view() - local roots = world:query(Transform, Root):view() - - expect(parents:contains(root)).to.equal(false) - - local orderOfIteration = {} - - for id in world:query(Transform, Parent) do - table.insert(orderOfIteration, id) - end - - local view = world:query(Transform, Parent):view() - local i = 0 - for id in view do - i += 1 - expect(orderOfIteration[i]).to.equal(id) - end - - for id, absolute, parent in world:query(Transform, Parent) do - local relative = parent.fromChild.pos - local ancestor = parent.entity - local current = parents:get(ancestor) - while current do - relative = current.fromChild.pos * relative - ancestor = current.entity - current = parents:get(ancestor) - end - - local pos = roots:get(ancestor).pos - - world:insert(id, absolute:patch({ pos = Vector2.new(pos.x + relative.x, pos.y + relative.y) })) - end - expect(world:get(child, Transform).pos).to.equal(Vector2.new(4, 5)) + expect(#world:query(Player):without(Poison):snapshot()).to.equal(1) end) it("should not invalidate iterators", function() diff --git a/lib/query.lua b/lib/query.lua index 53315b2..f095a1b 100644 --- a/lib/query.lua +++ b/lib/query.lua @@ -182,11 +182,12 @@ function QueryResult:_next() seenEntities[entityId] = true - for metatable in entityData do - if table.find(self._filter, metatable) then + for _, metatable in self._filter do + if entityData[metatable] then return self:_next() end end + return entityId, entityData end From ebb1f85359aac602ee9e5f5acb166aa97a5887cb Mon Sep 17 00:00:00 2001 From: Ukendio Date: Fri, 22 Dec 2023 19:18:15 +0100 Subject: [PATCH 15/28] Create a NOOP --- lib/query.lua | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/query.lua b/lib/query.lua index f095a1b..e64fe34 100644 --- a/lib/query.lua +++ b/lib/query.lua @@ -197,8 +197,6 @@ function QueryResult:without(...) return self end -function QueryResult:transform() end - function QueryResult:_expand(entityId, entityData) local metatables = self.metatables local queryLength = #metatables @@ -237,6 +235,9 @@ function QueryResult.new(world, ...) local compatibleArchetypes = world._queryCache[archetype] if next(compatibleArchetypes) == nil then -- If there are no compatible storages avoid creating our complicated iterator + local noopQuery = setmetatable({}, QueryResult) + noopQuery._expand = function() end + noopQuery._next = function() end end debug.profileend() From 03a31b5beafb65f0b8118b730beffb1b7a8aef4c Mon Sep 17 00:00:00 2001 From: Ukendio Date: Fri, 22 Dec 2023 19:19:02 +0100 Subject: [PATCH 16/28] Remove unused function --- lib/query.lua | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/query.lua b/lib/query.lua index e64fe34..9a62f8b 100644 --- a/lib/query.lua +++ b/lib/query.lua @@ -219,8 +219,6 @@ function QueryResult:__iter() end end -function QueryResult:_transform(phantomData) end - function QueryResult.new(world, ...) debug.profilebegin("World:query") assertValidComponent((...), 1) From e0ed459346fd272ab85a715e002ed659409a43b0 Mon Sep 17 00:00:00 2001 From: Ukendio Date: Fri, 22 Dec 2023 20:15:38 +0100 Subject: [PATCH 17/28] Moved functions under corresponding comments --- lib/query.lua | 246 +++++++++++++++++++++++++------------------------- 1 file changed, 123 insertions(+), 123 deletions(-) diff --git a/lib/query.lua b/lib/query.lua index 9a62f8b..1d9d471 100644 --- a/lib/query.lua +++ b/lib/query.lua @@ -21,6 +21,129 @@ local archetypeOf = archetypeModule.archetypeOf local QueryResult = {} QueryResult.__index = QueryResult +function QueryResult.new(world, ...) + debug.profilebegin("World:query") + assertValidComponent((...), 1) + + local metatables = { ... } + local archetype = archetypeOf(...) + + if world._queryCache[archetype] == nil then + world:_newQueryArchetype(archetype) + end + + local compatibleArchetypes = world._queryCache[archetype] + if next(compatibleArchetypes) == nil then + -- If there are no compatible storages avoid creating our complicated iterator + local noopQuery = setmetatable({}, QueryResult) + noopQuery._expand = function() end + noopQuery._next = function() end + end + + debug.profileend() + + local currentCompatibleArchetype = next(compatibleArchetypes) + + local lastEntityId + local storageIndex = 1 + + if world._pristineStorage == world._storages[1] then + world:_markStorageDirty() + end + + local seenEntities = {} + + return setmetatable({ + world = world, + metatables = metatables, + seenEntities = seenEntities, + currentCompatibleArchetype = currentCompatibleArchetype, + compatibleArchetypes = compatibleArchetypes, + lastEntityId = lastEntityId, + storageIndex = storageIndex, + _filter = {}, + }, QueryResult) +end + +function QueryResult:_next() + local world = self.world + local currentCompatibleArchetype = self.currentCompatibleArchetype + local storageIndex = self.storageIndex + local seenEntities = self.seenEntities + local compatibleArchetypes = self.compatibleArchetypes + + local entityId, entityData + + repeat + if world._storages[storageIndex][currentCompatibleArchetype] then + entityId, entityData = next(world._storages[storageIndex][currentCompatibleArchetype], self.lastEntityId) + end + + while entityId == nil do + currentCompatibleArchetype = next(compatibleArchetypes, currentCompatibleArchetype) + + if currentCompatibleArchetype == nil then + storageIndex += 1 + + local nextStorage = world._storages[storageIndex] + + if nextStorage == nil or next(nextStorage) == nil then + return + end + + currentCompatibleArchetype = nil + + if world._pristineStorage == nextStorage then + world:_markStorageDirty() + end + + continue + elseif world._storages[storageIndex][currentCompatibleArchetype] == nil then + continue + end + + entityId, entityData = next(world._storages[storageIndex][currentCompatibleArchetype]) + end + + self.lastEntityId = entityId + + until seenEntities[entityId] == nil + + self.currentCompatibleArchetype = currentCompatibleArchetype + + seenEntities[entityId] = true + + for _, metatable in self._filter do + if entityData[metatable] then + return self:_next() + end + end + + return entityId, entityData +end + +function QueryResult:_expand(entityId, entityData) + local metatables = self.metatables + local queryLength = #metatables + local queryOutput = table.create(queryLength) + + if not entityId then + return + end + + for i, metatable in ipairs(metatables) do + queryOutput[i] = entityData[metatable] + end + + return entityId, unpack(queryOutput, 1, queryLength) +end + +function QueryResult:__iter() + return function() + return self:_expand(self:_next()) + end +end + function QueryResult:__call() return self:_expand(self:_next()) end @@ -134,133 +257,10 @@ end ``` ]=] -function QueryResult:_next() - local world = self.world - local currentCompatibleArchetype = self.currentCompatibleArchetype - local storageIndex = self.storageIndex - local seenEntities = self.seenEntities - local compatibleArchetypes = self.compatibleArchetypes - - local entityId, entityData - - repeat - if world._storages[storageIndex][currentCompatibleArchetype] then - entityId, entityData = next(world._storages[storageIndex][currentCompatibleArchetype], self.lastEntityId) - end - - while entityId == nil do - currentCompatibleArchetype = next(compatibleArchetypes, currentCompatibleArchetype) - - if currentCompatibleArchetype == nil then - storageIndex += 1 - - local nextStorage = world._storages[storageIndex] - - if nextStorage == nil or next(nextStorage) == nil then - return - end - - currentCompatibleArchetype = nil - - if world._pristineStorage == nextStorage then - world:_markStorageDirty() - end - - continue - elseif world._storages[storageIndex][currentCompatibleArchetype] == nil then - continue - end - - entityId, entityData = next(world._storages[storageIndex][currentCompatibleArchetype]) - end - - self.lastEntityId = entityId - - until seenEntities[entityId] == nil - - self.currentCompatibleArchetype = currentCompatibleArchetype - - seenEntities[entityId] = true - - for _, metatable in self._filter do - if entityData[metatable] then - return self:_next() - end - end - - return entityId, entityData -end - function QueryResult:without(...) self._filter = { ... } return self end -function QueryResult:_expand(entityId, entityData) - local metatables = self.metatables - local queryLength = #metatables - local queryOutput = table.create(queryLength) - - if not entityId then - return - end - - for i, metatable in ipairs(metatables) do - queryOutput[i] = entityData[metatable] - end - - return entityId, unpack(queryOutput, 1, queryLength) -end - -function QueryResult:__iter() - return function() - return self:_expand(self:_next()) - end -end - -function QueryResult.new(world, ...) - debug.profilebegin("World:query") - assertValidComponent((...), 1) - - local metatables = { ... } - local archetype = archetypeOf(...) - - if world._queryCache[archetype] == nil then - world:_newQueryArchetype(archetype) - end - - local compatibleArchetypes = world._queryCache[archetype] - if next(compatibleArchetypes) == nil then - -- If there are no compatible storages avoid creating our complicated iterator - local noopQuery = setmetatable({}, QueryResult) - noopQuery._expand = function() end - noopQuery._next = function() end - end - - debug.profileend() - - local currentCompatibleArchetype = next(compatibleArchetypes) - - local lastEntityId - local storageIndex = 1 - - if world._pristineStorage == world._storages[1] then - world:_markStorageDirty() - end - - local seenEntities = {} - - return setmetatable({ - world = world, - metatables = metatables, - seenEntities = seenEntities, - currentCompatibleArchetype = currentCompatibleArchetype, - compatibleArchetypes = compatibleArchetypes, - lastEntityId = lastEntityId, - storageIndex = storageIndex, - _filter = {}, - }, QueryResult) -end - return QueryResult From bdae1c48c5823dc868f698c9675d5d721a34def8 Mon Sep 17 00:00:00 2001 From: Ukendio Date: Fri, 22 Dec 2023 23:58:08 +0100 Subject: [PATCH 18/28] Initial commit --- lib/World.lua | 12 +++++++++++- lib/World.spec.lua | 18 ++++++++++++++++++ lib/query.lua | 18 +++++++++++------- 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/lib/World.lua b/lib/World.lua index 1f9414a..4690327 100644 --- a/lib/World.lua +++ b/lib/World.lua @@ -194,7 +194,17 @@ function World:_newQueryArchetype(queryArchetype) for _, storage in self._storages do for entityArchetype in storage do - if areArchetypesCompatible(queryArchetype, entityArchetype) then + local archetype = string.split(queryArchetype, "|") + local negatedArchetype = archetype[1] + local exclude = archetype[2] + + if exclude then + if areArchetypesCompatible(exclude, entityArchetype) then + continue + end + end + + if areArchetypesCompatible(negatedArchetype, entityArchetype) then self._queryCache[queryArchetype][entityArchetype] = true end end diff --git a/lib/World.spec.lua b/lib/World.spec.lua index b1d6dfd..b35e543 100644 --- a/lib/World.spec.lua +++ b/lib/World.spec.lua @@ -138,6 +138,24 @@ return function() expect(world:size()).to.equal(1) end) + it("without prototype", function() + local world = World.new() + + local Hello = component() + local Bob = component() + local Shirley = component() + + local _helloBob = world:spawn(Hello(), Bob()) + local _helloShirley = world:spawn(Hello(), Shirley()) + + local withoutCount = 0 + for _ in world:query(Hello):without(Bob) do + withoutCount += 1 + end + + expect(withoutCount).to.equal(1) + end) + it("should be queryable", function() local world = World.new() diff --git a/lib/query.lua b/lib/query.lua index 1d9d471..e5ca125 100644 --- a/lib/query.lua +++ b/lib/query.lua @@ -62,6 +62,7 @@ function QueryResult.new(world, ...) lastEntityId = lastEntityId, storageIndex = storageIndex, _filter = {}, + _archetype = archetype, }, QueryResult) end @@ -113,12 +114,6 @@ function QueryResult:_next() seenEntities[entityId] = true - for _, metatable in self._filter do - if entityData[metatable] then - return self:_next() - end - end - return entityId, entityData end @@ -258,8 +253,17 @@ end ]=] function QueryResult:without(...) - self._filter = { ... } + local world = self.world + local negativeArchetype = `{self._archetype}|{archetypeOf(...)}` + + if world._queryCache[negativeArchetype] == nil then + world:_newQueryArchetype(negativeArchetype) + end + + local compatibleArchetypes = world._queryCache[negativeArchetype] + self.compatibleArchetypes = compatibleArchetypes + self.currentCompatibleArchetype = next(compatibleArchetypes) return self end From fe4ec1d64a825c24321869c5e5d639fa912295ed Mon Sep 17 00:00:00 2001 From: Ukendio Date: Sat, 23 Dec 2023 00:11:14 +0100 Subject: [PATCH 19/28] Expand should be a closure for inlining --- lib/query.lua | 44 +++++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/lib/query.lua b/lib/query.lua index 1d9d471..5fa9446 100644 --- a/lib/query.lua +++ b/lib/query.lua @@ -26,6 +26,8 @@ function QueryResult.new(world, ...) assertValidComponent((...), 1) local metatables = { ... } + local queryLength = select("#", ...) + local archetype = archetypeOf(...) if world._queryCache[archetype] == nil then @@ -33,6 +35,9 @@ function QueryResult.new(world, ...) end local compatibleArchetypes = world._queryCache[archetype] + + debug.profileend() + if next(compatibleArchetypes) == nil then -- If there are no compatible storages avoid creating our complicated iterator local noopQuery = setmetatable({}, QueryResult) @@ -40,7 +45,19 @@ function QueryResult.new(world, ...) noopQuery._next = function() end end - debug.profileend() + local queryOutput = table.create(queryLength) + + local function expand(entityId, entityData) + if not entityId then + return + end + + for i, metatable in ipairs(metatables) do + queryOutput[i] = entityData[metatable] + end + + return entityId, unpack(queryOutput, 1, queryLength) + end local currentCompatibleArchetype = next(compatibleArchetypes) @@ -54,6 +71,7 @@ function QueryResult.new(world, ...) local seenEntities = {} return setmetatable({ + _expand = expand, world = world, metatables = metatables, seenEntities = seenEntities, @@ -122,30 +140,14 @@ function QueryResult:_next() return entityId, entityData end -function QueryResult:_expand(entityId, entityData) - local metatables = self.metatables - local queryLength = #metatables - local queryOutput = table.create(queryLength) - - if not entityId then - return - end - - for i, metatable in ipairs(metatables) do - queryOutput[i] = entityData[metatable] - end - - return entityId, unpack(queryOutput, 1, queryLength) -end - function QueryResult:__iter() return function() - return self:_expand(self:_next()) + return self._expand(self:_next()) end end function QueryResult:__call() - return self:_expand(self:_next()) + return self._expand(self:_next()) end --[=[ @@ -175,7 +177,7 @@ end @return ...ComponentInstance -- The requested component values ]=] function QueryResult:next() - return self:_expand(self:_next()) + return self._expand(self:_next()) end local snapshot = { @@ -229,7 +231,7 @@ function QueryResult:snapshot() for entityId, entityData in iter do if entityId then - table.insert(list, table.pack(self:_expand(entityId, entityData))) + table.insert(list, table.pack(self._expand(entityId, entityData))) end end From fad7489d33029a45c4457b49349a8669410025d8 Mon Sep 17 00:00:00 2001 From: Ukendio Date: Sat, 23 Dec 2023 01:11:48 +0100 Subject: [PATCH 20/28] Make :_next into a closure instead --- lib/query.lua | 43 +++++++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/lib/query.lua b/lib/query.lua index 5fa9446..3cba9de 100644 --- a/lib/query.lua +++ b/lib/query.lua @@ -1,6 +1,6 @@ local archetypeModule = require(script.Parent.archetype) local Component = require(script.Parent.component) - +local topoRuntime = require(script.Parent.topoRuntime) local assertValidComponent = Component.assertValidComponent local archetypeOf = archetypeModule.archetypeOf @@ -83,18 +83,21 @@ function QueryResult.new(world, ...) }, QueryResult) end -function QueryResult:_next() - local world = self.world - local currentCompatibleArchetype = self.currentCompatibleArchetype - local storageIndex = self.storageIndex - local seenEntities = self.seenEntities - local compatibleArchetypes = self.compatibleArchetypes +local function nextItem(query) + local world = query.world + local currentCompatibleArchetype = query.currentCompatibleArchetype + local storageIndex = query.storageIndex + local seenEntities = query.seenEntities + local compatibleArchetypes = query.compatibleArchetypes local entityId, entityData + local storages = world._storages repeat - if world._storages[storageIndex][currentCompatibleArchetype] then - entityId, entityData = next(world._storages[storageIndex][currentCompatibleArchetype], self.lastEntityId) + local nextStorage = storages[storageIndex] + local currently = nextStorage[currentCompatibleArchetype] + if currently then + entityId, entityData = next(currently, query.lastEntityId) end while entityId == nil do @@ -103,7 +106,7 @@ function QueryResult:_next() if currentCompatibleArchetype == nil then storageIndex += 1 - local nextStorage = world._storages[storageIndex] + nextStorage = storages[storageIndex] if nextStorage == nil or next(nextStorage) == nil then return @@ -116,24 +119,24 @@ function QueryResult:_next() end continue - elseif world._storages[storageIndex][currentCompatibleArchetype] == nil then + elseif nextStorage[currentCompatibleArchetype] == nil then continue end - entityId, entityData = next(world._storages[storageIndex][currentCompatibleArchetype]) + entityId, entityData = next(nextStorage[currentCompatibleArchetype]) end - self.lastEntityId = entityId + query.lastEntityId = entityId until seenEntities[entityId] == nil - self.currentCompatibleArchetype = currentCompatibleArchetype + query.currentCompatibleArchetype = currentCompatibleArchetype seenEntities[entityId] = true - for _, metatable in self._filter do + for _, metatable in query._filter do if entityData[metatable] then - return self:_next() + return nextItem(query) end end @@ -142,12 +145,12 @@ end function QueryResult:__iter() return function() - return self._expand(self:_next()) + return self._expand(nextItem(self)) end end function QueryResult:__call() - return self._expand(self:_next()) + return self._expand(nextItem(self)) end --[=[ @@ -177,7 +180,7 @@ end @return ...ComponentInstance -- The requested component values ]=] function QueryResult:next() - return self._expand(self:_next()) + return self._expand(nextItem(self)) end local snapshot = { @@ -226,7 +229,7 @@ function QueryResult:snapshot() local list = setmetatable({}, snapshot) local function iter() - return self:_next() + return nextItem(self) end for entityId, entityData in iter do From a7fe37ab3f81196fbe2259a7231720ba3e556aa5 Mon Sep 17 00:00:00 2001 From: Ukendio Date: Sat, 23 Dec 2023 01:20:55 +0100 Subject: [PATCH 21/28] remove topoRuntime import --- lib/query.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/query.lua b/lib/query.lua index 3cba9de..7ddd404 100644 --- a/lib/query.lua +++ b/lib/query.lua @@ -1,6 +1,5 @@ local archetypeModule = require(script.Parent.archetype) local Component = require(script.Parent.component) -local topoRuntime = require(script.Parent.topoRuntime) local assertValidComponent = Component.assertValidComponent local archetypeOf = archetypeModule.archetypeOf From 1cc5144c5d7b11c814febfa0eb5b8d1b37ab582a Mon Sep 17 00:00:00 2001 From: Ukendio Date: Sat, 23 Dec 2023 01:42:03 +0100 Subject: [PATCH 22/28] Make struct smaller --- lib/query.lua | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/lib/query.lua b/lib/query.lua index 7ddd404..f06e8fe 100644 --- a/lib/query.lua +++ b/lib/query.lua @@ -58,26 +58,17 @@ function QueryResult.new(world, ...) return entityId, unpack(queryOutput, 1, queryLength) end - local currentCompatibleArchetype = next(compatibleArchetypes) - - local lastEntityId - local storageIndex = 1 - if world._pristineStorage == world._storages[1] then world:_markStorageDirty() end - local seenEntities = {} - return setmetatable({ - _expand = expand, world = world, - metatables = metatables, - seenEntities = seenEntities, - currentCompatibleArchetype = currentCompatibleArchetype, + seenEntities = {}, + currentCompatibleArchetype = next(compatibleArchetypes), compatibleArchetypes = compatibleArchetypes, - lastEntityId = lastEntityId, - storageIndex = storageIndex, + storageIndex = 1, + _expand = expand, _filter = {}, }, QueryResult) end From a53ce69005df30e0ae6bf9f8a4330dd30a534a88 Mon Sep 17 00:00:00 2001 From: Ukendio Date: Sat, 23 Dec 2023 02:11:39 +0100 Subject: [PATCH 23/28] Change split to || --- lib/World.lua | 2 +- lib/query.lua | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/World.lua b/lib/World.lua index 4690327..164aba3 100644 --- a/lib/World.lua +++ b/lib/World.lua @@ -194,7 +194,7 @@ function World:_newQueryArchetype(queryArchetype) for _, storage in self._storages do for entityArchetype in storage do - local archetype = string.split(queryArchetype, "|") + local archetype = string.split(queryArchetype, "||") local negatedArchetype = archetype[1] local exclude = archetype[2] diff --git a/lib/query.lua b/lib/query.lua index 69e0e71..0434a0d 100644 --- a/lib/query.lua +++ b/lib/query.lua @@ -249,7 +249,7 @@ end function QueryResult:without(...) local world = self.world - local negativeArchetype = `{self._archetype}|{archetypeOf(...)}` + local negativeArchetype = `{self._archetype}||{archetypeOf(...)}` if world._queryCache[negativeArchetype] == nil then world:_newQueryArchetype(negativeArchetype) From 4e9a28bfdb296fc27549a5951dc6be701b99b0c5 Mon Sep 17 00:00:00 2001 From: Ukendio Date: Sun, 24 Dec 2023 06:18:39 +0100 Subject: [PATCH 24/28] Moving back some of the query logic --- lib/World.lua | 46 +++++++++++++++++++++++++++++++++++++++++++++- lib/query.lua | 50 +++----------------------------------------------- 2 files changed, 48 insertions(+), 48 deletions(-) diff --git a/lib/World.lua b/lib/World.lua index 164aba3..163d603 100644 --- a/lib/World.lua +++ b/lib/World.lua @@ -401,8 +401,52 @@ end @param ... Component -- The component types to query. Only entities with *all* of these components will be returned. @return QueryResult -- See [QueryResult](/api/QueryResult) docs. ]=] + +local function noop() end + function World:query(...) - return QueryResult.new(self, ...) + debug.profilebegin("World:query") + assertValidComponent((...), 1) + + local metatables = { ... } + local queryLength = select("#", ...) + + local archetype = archetypeOf(...) + + if self._queryCache[archetype] == nil then + self:_newQueryArchetype(archetype) + end + + local compatibleArchetypes = self._queryCache[archetype] + + debug.profileend() + + if next(compatibleArchetypes) == nil then + -- If there are no compatible storages avoid creating our complicated iterator + local noopQuery = setmetatable({}, QueryResult) + noopQuery._expand = noop + noopQuery._next = noop + end + + local queryOutput = table.create(queryLength) + + local function expand(entityId, entityData) + if not entityId then + return + end + + for i, metatable in ipairs(metatables) do + queryOutput[i] = entityData[metatable] + end + + return entityId, unpack(queryOutput, 1, queryLength) + end + + if self._pristineStorage == self._storages[1] then + self:_markStorageDirty() + end + + return QueryResult.new(self, expand, archetype, compatibleArchetypes) end local function cleanupQueryChanged(hookState) diff --git a/lib/query.lua b/lib/query.lua index 6da3082..581e4e6 100644 --- a/lib/query.lua +++ b/lib/query.lua @@ -1,6 +1,4 @@ local archetypeModule = require(script.Parent.archetype) -local Component = require(script.Parent.component) -local assertValidComponent = Component.assertValidComponent local archetypeOf = archetypeModule.archetypeOf --[=[ @@ -20,48 +18,7 @@ local archetypeOf = archetypeModule.archetypeOf local QueryResult = {} QueryResult.__index = QueryResult -function QueryResult.new(world, ...) - debug.profilebegin("World:query") - assertValidComponent((...), 1) - - local metatables = { ... } - local queryLength = select("#", ...) - - local archetype = archetypeOf(...) - - if world._queryCache[archetype] == nil then - world:_newQueryArchetype(archetype) - end - - local compatibleArchetypes = world._queryCache[archetype] - - debug.profileend() - - if next(compatibleArchetypes) == nil then - -- If there are no compatible storages avoid creating our complicated iterator - local noopQuery = setmetatable({}, QueryResult) - noopQuery._expand = function() end - noopQuery._next = function() end - end - - local queryOutput = table.create(queryLength) - - local function expand(entityId, entityData) - if not entityId then - return - end - - for i, metatable in ipairs(metatables) do - queryOutput[i] = entityData[metatable] - end - - return entityId, unpack(queryOutput, 1, queryLength) - end - - if world._pristineStorage == world._storages[1] then - world:_markStorageDirty() - end - +function QueryResult.new(world, expand, queryArchetype, compatibleArchetypes) return setmetatable({ world = world, seenEntities = {}, @@ -69,8 +26,7 @@ function QueryResult.new(world, ...) compatibleArchetypes = compatibleArchetypes, storageIndex = 1, _expand = expand, - _filter = {}, - _archetype = archetype, + _queryArchetype = queryArchetype, }, QueryResult) end @@ -249,7 +205,7 @@ end function QueryResult:without(...) local world = self.world - local negativeArchetype = `{self._archetype}||{archetypeOf(...)}` + local negativeArchetype = `{self._queryArchetype}||{archetypeOf(...)}` if world._queryCache[negativeArchetype] == nil then world:_newQueryArchetype(negativeArchetype) From c744828b70a9180b3290a95ed17788e6b4d9a715 Mon Sep 17 00:00:00 2001 From: Ukendio Date: Sun, 24 Dec 2023 06:42:55 +0100 Subject: [PATCH 25/28] Return static noopQuery --- lib/World.lua | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/World.lua b/lib/World.lua index 163d603..3d1fd08 100644 --- a/lib/World.lua +++ b/lib/World.lua @@ -404,6 +404,15 @@ end local function noop() end +local noopQuery = setmetatable({ + next = noop, + snapshot = noop, + without = noop, + view = noop, +}, { + __iter = noop, +}) + function World:query(...) debug.profilebegin("World:query") assertValidComponent((...), 1) @@ -423,9 +432,7 @@ function World:query(...) if next(compatibleArchetypes) == nil then -- If there are no compatible storages avoid creating our complicated iterator - local noopQuery = setmetatable({}, QueryResult) - noopQuery._expand = noop - noopQuery._next = noop + return noopQuery end local queryOutput = table.create(queryLength) From 90ef6db72cc94b3f0a9d2779cb5bfca9ca0b8ddd Mon Sep 17 00:00:00 2001 From: Ukendio Date: Sun, 24 Dec 2023 17:20:26 +0100 Subject: [PATCH 26/28] Handle filter iteratively --- lib/World.lua | 16 ++++++++++++---- lib/World.spec.lua | 6 +++--- lib/query.lua | 4 +++- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/lib/World.lua b/lib/World.lua index 3d1fd08..daa65b8 100644 --- a/lib/World.lua +++ b/lib/World.lua @@ -196,14 +196,20 @@ function World:_newQueryArchetype(queryArchetype) for entityArchetype in storage do local archetype = string.split(queryArchetype, "||") local negatedArchetype = archetype[1] - local exclude = archetype[2] + local filter = { unpack(archetype, 2, #archetype) } - if exclude then + local skip = false + for _, exclude in filter do if areArchetypesCompatible(exclude, entityArchetype) then - continue + skip = true + break end end + if skip then + continue + end + if areArchetypesCompatible(negatedArchetype, entityArchetype) then self._queryCache[queryArchetype][entityArchetype] = true end @@ -410,7 +416,9 @@ local noopQuery = setmetatable({ without = noop, view = noop, }, { - __iter = noop, + __iter = function() + return noop + end, }) function World:query(...) diff --git a/lib/World.spec.lua b/lib/World.spec.lua index 1e70740..036d7a2 100644 --- a/lib/World.spec.lua +++ b/lib/World.spec.lua @@ -138,7 +138,7 @@ return function() expect(world:size()).to.equal(1) end) - it("without prototype", function() + it("should not find any entities", function() local world = World.new() local Hello = component() @@ -149,11 +149,11 @@ return function() local _helloShirley = world:spawn(Hello(), Shirley()) local withoutCount = 0 - for _ in world:query(Hello):without(Bob) do + for _ in world:query(Hello):without(Bob, Shirley) do withoutCount += 1 end - expect(withoutCount).to.equal(1) + expect(withoutCount).to.equal(0) end) it("should be queryable", function() diff --git a/lib/query.lua b/lib/query.lua index 581e4e6..1703bd9 100644 --- a/lib/query.lua +++ b/lib/query.lua @@ -205,7 +205,9 @@ end function QueryResult:without(...) local world = self.world - local negativeArchetype = `{self._queryArchetype}||{archetypeOf(...)}` + local filter = table.concat(string.split(archetypeOf(...), "_"), "||") + + local negativeArchetype = `{self._queryArchetype}||{filter}` if world._queryCache[negativeArchetype] == nil then world:_newQueryArchetype(negativeArchetype) From 1304db472daada22578182e8f79a5991f01ae88e Mon Sep 17 00:00:00 2001 From: Ukendio Date: Sun, 24 Dec 2023 18:01:48 +0100 Subject: [PATCH 27/28] Pull in upstream change from query-iter --- lib/World.lua | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/World.lua b/lib/World.lua index 3d1fd08..4212a82 100644 --- a/lib/World.lua +++ b/lib/World.lua @@ -407,10 +407,14 @@ local function noop() end local noopQuery = setmetatable({ next = noop, snapshot = noop, - without = noop, + without = function(self) + return self + end, view = noop, }, { - __iter = noop, + __iter = function() + return noopQuery + end, }) function World:query(...) From d65ac2086e34775b445d5aa2692e18e5263e1873 Mon Sep 17 00:00:00 2001 From: Ukendio Date: Sun, 24 Dec 2023 18:39:24 +0100 Subject: [PATCH 28/28] Update storage index on empty storage --- lib/query.lua | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/query.lua b/lib/query.lua index 1703bd9..2ddbd54 100644 --- a/lib/query.lua +++ b/lib/query.lua @@ -33,7 +33,6 @@ end local function nextItem(query) local world = query.world local currentCompatibleArchetype = query.currentCompatibleArchetype - local storageIndex = query.storageIndex local seenEntities = query.seenEntities local compatibleArchetypes = query.compatibleArchetypes @@ -41,7 +40,7 @@ local function nextItem(query) local storages = world._storages repeat - local nextStorage = storages[storageIndex] + local nextStorage = storages[query.storageIndex] local currently = nextStorage[currentCompatibleArchetype] if currently then entityId, entityData = next(currently, query.lastEntityId) @@ -51,9 +50,9 @@ local function nextItem(query) currentCompatibleArchetype = next(compatibleArchetypes, currentCompatibleArchetype) if currentCompatibleArchetype == nil then - storageIndex += 1 + query.storageIndex += 1 - nextStorage = storages[storageIndex] + nextStorage = storages[query.storageIndex] if nextStorage == nil or next(nextStorage) == nil then return