Skip to content

Commit

Permalink
Archetype Invariants (#4)
Browse files Browse the repository at this point in the history
  • Loading branch information
Ukendio authored Apr 14, 2024
1 parent 60f10ea commit 83338bb
Show file tree
Hide file tree
Showing 3 changed files with 262 additions and 230 deletions.
302 changes: 244 additions & 58 deletions lib/World.lua
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ 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"

--[=[
Expand Down Expand Up @@ -194,9 +192,27 @@ function World:_newQueryArchetype(queryArchetype)

for _, storage in self._storages do
for entityArchetype in storage do
if areArchetypesCompatible(queryArchetype, entityArchetype) then
self._queryCache[queryArchetype][entityArchetype] = true
local archetypes = string.split(queryArchetype, "x")
local baseArchetype = table.remove(archetypes, 1)

if not areArchetypesCompatible(baseArchetype, entityArchetype) then
continue
end

local skip = false

for _, exclude in archetypes do
if areArchetypesCompatible(exclude, entityArchetype) then
skip = true
break
end
end

if skip then
continue
end

self._queryCache[queryArchetype][entityArchetype] = true
end
end
end
Expand Down Expand Up @@ -380,13 +396,235 @@ local noopQuery = setmetatable({
without = function(self)
return self
end,
view = noop,
view = {
get = noop,
contains = noop,
},
}, {
__iter = function()
return noop
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.new(world, expand, queryArchetype, compatibleArchetypes)
return setmetatable({
world = world,
seenEntities = {},
currentCompatibleArchetype = next(compatibleArchetypes),
compatibleArchetypes = compatibleArchetypes,
storageIndex = 1,
_expand = expand,
_queryArchetype = queryArchetype,
}, QueryResult)
end

local function nextItem(query)
local world = query.world
local currentCompatibleArchetype = query.currentCompatibleArchetype
local seenEntities = query.seenEntities
local compatibleArchetypes = query.compatibleArchetypes

local entityId, entityData

local storages = world._storages
repeat
local nextStorage = storages[query.storageIndex]
local currently = nextStorage[currentCompatibleArchetype]
if currently then
entityId, entityData = next(currently, query.lastEntityId)
end

while entityId == nil do
currentCompatibleArchetype = next(compatibleArchetypes, currentCompatibleArchetype)

if currentCompatibleArchetype == nil then
query.storageIndex += 1

nextStorage = storages[query.storageIndex]

if nextStorage == nil or next(nextStorage) == nil then
return
end

currentCompatibleArchetype = nil

if world._pristineStorage == nextStorage then
world:_markStorageDirty()
end

continue
elseif nextStorage[currentCompatibleArchetype] == nil then
continue
end

entityId, entityData = next(nextStorage[currentCompatibleArchetype])
end

query.lastEntityId = entityId

until seenEntities[entityId] == nil

query.currentCompatibleArchetype = currentCompatibleArchetype

seenEntities[entityId] = true

return entityId, entityData
end

function QueryResult:__iter()
return function()
return self._expand(nextItem(self))
end
end

function QueryResult:__call()
return self._expand(nextItem(self))
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(nextItem(self))
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 nextItem(self)
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 world = self.world
local filter = string.gsub(archetypeOf(...), "_", "x")

local negativeArchetype = `{self._queryArchetype}x{filter}`

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

--[=[
Performs a query against the entities in this World. Returns a [QueryResult](/api/QueryResult), which iterates over
the results of the query.
Expand Down Expand Up @@ -464,63 +702,11 @@ function World:query(...)
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

local storages = self._storages
repeat
local nextStorage = storages[storageIndex]
local currently = nextStorage[currentCompatibleArchetype]
if nextStorage[currentCompatibleArchetype] then
entityId, entityData = next(currently, lastEntityId)
end

while entityId == nil do
currentCompatibleArchetype = next(compatibleArchetypes, currentCompatibleArchetype)

if currentCompatibleArchetype == nil then
storageIndex += 1

nextStorage = storages[storageIndex]

if nextStorage == nil or next(nextStorage) == nil then
return
end

currentCompatibleArchetype = nil

if self._pristineStorage == nextStorage then
self:_markStorageDirty()
end

continue
elseif nextStorage[currentCompatibleArchetype] == nil then
continue
end

entityId, entityData = next(nextStorage[currentCompatibleArchetype])
end

lastEntityId = entityId

until seenEntities[entityId] == nil

seenEntities[entityId] = true

return entityId, entityData
end

return QueryResult.new(expand, nextItem)
return QueryResult.new(self, expand, archetype, compatibleArchetypes)
end

local function cleanupQueryChanged(hookState)
Expand Down
18 changes: 18 additions & 0 deletions lib/World.spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,24 @@ return function()
expect(world:size()).to.equal(1)
end)

it("should not find any entities", 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, Shirley) do
withoutCount += 1
end

expect(withoutCount).to.equal(0)
end)

it("should be queryable", function()
local world = World.new()

Expand Down
Loading

0 comments on commit 83338bb

Please sign in to comment.