diff --git a/benches/query.lua b/benches/query.lua index 364661b..de60944 100644 --- a/benches/query.lua +++ b/benches/query.lua @@ -9,13 +9,16 @@ local function TITLE(title: string) end local jecs = require("../mirror/init") -local ecs = jecs.World.new() +local oldMatter = require("../oldMatter") +local newMatter = require("../newMatter") type i53 = number -do TITLE (testkit.color.white_underline("query")) +do TITLE (testkit.color.white_underline("Jecs query")) + local ecs = jecs.World.new() do TITLE "one component in common" + local function view_bench( world: jecs.World, A: i53, B: i53, C: i53, D: i53, E: i53, F: i53, G: i53, H: i53 @@ -61,37 +64,37 @@ do TITLE (testkit.color.white_underline("query")) if flip() then combination ..= "B" - ecs:set(entity, D2, true) + ecs:set(entity, D2, {value = true}) end if flip() then combination ..= "C" - ecs:set(entity, D3, true) + ecs:set(entity, D3, { value = true }) end if flip() then combination ..= "D" - ecs:set(entity, D4, true) + ecs:set(entity, D4, { value = true}) end if flip() then combination ..= "E" - ecs:set(entity, D5, true) + ecs:set(entity, D5, { value = true}) end if flip() then combination ..= "F" - ecs:set(entity, D6, true) + ecs:set(entity, D6, {value = true}) end if flip() then combination ..= "G" - ecs:set(entity, D7, true) + ecs:set(entity, D7, { value = true}) end if flip() then combination ..= "H" - ecs:set(entity, D8, true) + ecs:set(entity, D8, {value = true}) end if #combination == 7 then added += 1 - ecs:set(entity, D1, true) + ecs:set(entity, D1, { value = true}) end archetypes[combination] = true end @@ -101,4 +104,196 @@ do TITLE (testkit.color.white_underline("query")) view_bench(ecs, D1, D2, D3, D4, D5, D6, D7, D8) end +end + +do TITLE(testkit.color.white_underline("OldMatter query")) + + local ecs = oldMatter.World.new() + local component = oldMatter.component + + do TITLE "one component in common" + local function view_bench( + world: jecs.World, + A: i53, B: i53, C: i53, D: i53, E: i53, F: i53, G: i53, H: i53 + ) + + BENCH("1 component", function() + for _ in world:query(A) do end + end) + + BENCH("2 component", function() + for _ in world:query(A, B) do end + end) + + BENCH("4 component", function() + for _ in world:query(A, B, C, D) do + end + end) + + BENCH("8 component", function() + for _ in world:query(A, B, C, D, E, F, G, H) do end + end) + end + + local D1 = component() + local D2 = component() + local D3 = component() + local D4 = component() + local D5 = component() + local D6 = component() + local D7 = component() + local D8 = component() + + local function flip() + return math.random() >= 0.15 + end + + local added = 0 + local archetypes = {} + for i = 1, 2^16-2 do + local entity = ecs:spawn() + + local combination = "" + + if flip() then + combination ..= "B" + ecs:insert(entity, D2({value = true})) + end + if flip() then + combination ..= "C" + ecs:insert(entity, D3({value = true})) + end + if flip() then + combination ..= "D" + ecs:insert(entity, D4({value = true})) + end + if flip() then + combination ..= "E" + ecs:insert(entity, D5({value = true})) + end + if flip() then + combination ..= "F" + ecs:insert(entity, D6({value = true})) + + end + if flip() then + combination ..= "G" + ecs:insert(entity, D7({value = true})) + + end + if flip() then + combination ..= "H" + ecs:insert(entity, D8({value = true})) + end + + if #combination == 7 then + added += 1 + ecs:insert(entity, D1({value = true})) + + end + archetypes[combination] = true + end + + local a = 0 + for _ in archetypes do a+= 1 end + + view_bench(ecs, D1, D2, D3, D4, D5, D6, D7, D8) + end + +end + +do TITLE(testkit.color.white_underline("NewMatter query")) + + local ecs = newMatter.World.new() + local component = newMatter.component + + do TITLE "one component in common" + local function view_bench( + world: jecs.World, + A: i53, B: i53, C: i53, D: i53, E: i53, F: i53, G: i53, H: i53 + ) + + BENCH("1 component", function() + for _ in world:query(A) do end + end) + + BENCH("2 component", function() + for _ in world:query(A, B) do end + end) + + BENCH("4 component", function() + for _ in world:query(A, B, C, D) do + end + end) + + BENCH("8 component", function() + for _ in world:query(A, B, C, D, E, F, G, H) do end + end) + end + + local D1 = component() + local D2 = component() + local D3 = component() + local D4 = component() + local D5 = component() + local D6 = component() + local D7 = component() + local D8 = component() + + local function flip() + return math.random() >= 0.15 + end + + local added = 0 + local archetypes = {} + for i = 1, 2^16-2 do + local entity = ecs:spawn() + + local combination = "" + + if flip() then + combination ..= "B" + ecs:insert(entity, D2({value = true})) + end + if flip() then + combination ..= "C" + ecs:insert(entity, D3({value = true})) + end + if flip() then + combination ..= "D" + ecs:insert(entity, D4({value = true})) + end + if flip() then + combination ..= "E" + ecs:insert(entity, D5({value = true})) + end + if flip() then + combination ..= "F" + ecs:insert(entity, D6({value = true})) + + end + if flip() then + combination ..= "G" + ecs:insert(entity, D7({value = true})) + + end + if flip() then + combination ..= "H" + ecs:insert(entity, D8({value = true})) + end + + if #combination == 7 then + added += 1 + ecs:insert(entity, D1({value = true})) + + end + archetypes[combination] = true + end + + local a = 0 + for _ in archetypes do a+= 1 end + + view_bench(ecs, D1, D2, D3, D4, D5, D6, D7, D8) + end + end \ No newline at end of file diff --git a/lib/init.lua b/lib/init.lua index 7103b07..35a9b9c 100644 --- a/lib/init.lua +++ b/lib/init.lua @@ -51,31 +51,48 @@ local REST = HI_COMPONENT_ID + 4 local function transitionArchetype( entityIndex: EntityIndex, - destinationArchetype: Archetype, + to: Archetype, destinationRow: i24, - sourceArchetype: Archetype, + from: Archetype, sourceRow: i24 ) - local columns = sourceArchetype.columns - local sourceEntities = sourceArchetype.entities - local destinationEntities = destinationArchetype.entities - local destinationColumns = destinationArchetype.columns + local columns = from.columns + local sourceEntities = from.entities + local destinationEntities = to.entities + local destinationColumns = to.columns + local tr = to.records + local types = from.types - for componentId, column in columns do - local targetColumn = destinationColumns[componentId] + for i, column in columns do + -- Retrieves the new column index from the source archetype's record from each component + -- We have to do this because the columns are tightly packed and indexes may not correspond to each other. + local targetColumn = destinationColumns[tr[types[i]]] + + -- Sometimes target column may not exist, e.g. when you remove a component. if targetColumn then targetColumn[destinationRow] = column[sourceRow] end - column[sourceRow] = column[#column] - column[#column] = nil + -- If the entity is the last row in the archetype then swapping it would be meaningless. + local last = #column + if sourceRow ~= last then + -- Swap rempves columns to ensure there are no holes in the archetype. + column[sourceRow] = column[last] + end + column[last] = nil end + -- Move the entity from the source to the destination archetype. destinationEntities[destinationRow] = sourceEntities[sourceRow] entityIndex[sourceEntities[sourceRow]].row = destinationRow + -- Because we have swapped columns we now have to update the records + -- corresponding to the entities' rows that were swapped. local movedAway = #sourceEntities - sourceEntities[sourceRow] = sourceEntities[movedAway] - entityIndex[sourceEntities[movedAway]].row = sourceRow + if sourceRow ~= movedAway then + sourceEntities[sourceRow] = sourceEntities[movedAway] + entityIndex[sourceEntities[movedAway]].row = sourceRow + end + sourceEntities[movedAway] = nil end @@ -145,7 +162,9 @@ local function archetypeOf(world: World, types: { i24 }, prev: Archetype?): Arch } world.archetypeIndex[ty] = archetype world.archetypes[id] = archetype - createArchetypeRecords(world.componentIndex, archetype, prev) + if #types > 0 then + createArchetypeRecords(world.componentIndex, archetype, prev) + end return archetype end @@ -180,8 +199,6 @@ local function emit(world, eventDescription) }) end - - local function onNotifyAdd(world, archetype, otherArchetype, row: number, added: Ty) if #added > 0 then emit(world, { @@ -194,13 +211,13 @@ local function onNotifyAdd(world, archetype, otherArchetype, row: number, added: end end - export type World = typeof(World.new()) local function ensureArchetype(world: World, types, prev) if #types < 1 then return world.ROOT_ARCHETYPE end + local ty = hash(types) local archetype = world.archetypeIndex[ty] if archetype then @@ -226,8 +243,13 @@ end local function findArchetypeWith(world: World, node: Archetype, componentId: i53) local types = node.types + -- Component IDs are added incrementally, so inserting and sorting + -- them each time would be expensive. Instead this insertion sort can find the insertion + -- point in the types array. local at = findInsert(types, componentId) if at == -1 then + -- If it finds a duplicate, it just means it is the same archetype so it can return it + -- directly instead of needing to hash types for a lookup to the archetype. return node end @@ -245,6 +267,7 @@ end local function archetypeTraverseAdd(world: World, componentId: i53, from: Archetype): Archetype if not from then + -- If there was no source archetype then it should return the ROOT_ARCHETYPE if not world.ROOT_ARCHETYPE then local ROOT_ARCHETYPE = archetypeOf(world, {}, nil) world.ROOT_ARCHETYPE = ROOT_ARCHETYPE @@ -254,6 +277,8 @@ local function archetypeTraverseAdd(world: World, componentId: i53, from: Archet local edge = ensureEdge(from, componentId) if not edge.add then + -- Save an edge using the component ID to the archetype to allow + -- faster traversals to adjacent archetypes. edge.add = findArchetypeWith(world, from, componentId) end @@ -270,26 +295,31 @@ end function World.set(world: World, entityId: i53, componentId: i53, data: unknown) local record = ensureRecord(world.entityIndex, entityId) - local sourceArchetype = record.archetype - local destinationArchetype = archetypeTraverseAdd(world, componentId, sourceArchetype) + local from = record.archetype + local to = archetypeTraverseAdd(world, componentId, from) - if sourceArchetype == destinationArchetype then - local archetypeRecord = destinationArchetype.records[componentId] - destinationArchetype.columns[archetypeRecord][record.row] = data + if from == to then + -- If the archetypes are the same it can avoid moving the entity + -- and just set the data directly. + local archetypeRecord = to.records[componentId] + from.columns[archetypeRecord][record.row] = data + -- Should fire an OnSet event here. return end - if sourceArchetype then - moveEntity(world.entityIndex, entityId, record, destinationArchetype) + if from then + -- If there was a previous archetype, then the entity needs to move the archetype + moveEntity(world.entityIndex, entityId, record, to) else - if #destinationArchetype.types > 0 then - newEntity(entityId, record, destinationArchetype) - onNotifyAdd(world, destinationArchetype, sourceArchetype, record.row, { componentId }) + if #to.types > 0 then + -- When there is no previous archetype it should create the archetype + newEntity(entityId, record, to) + onNotifyAdd(world, to, from, record.row, { componentId }) end end - local archetypeRecord = destinationArchetype.records[componentId] - destinationArchetype.columns[archetypeRecord][record.row] = data + local archetypeRecord = to.records[componentId] + to.columns[archetypeRecord][record.row] = data end local function archetypeTraverseRemove(world: World, componentId: i53, archetype: Archetype?): Archetype @@ -316,9 +346,10 @@ function World.remove(world: World, entityId: i53, componentId: i53) end end +-- Keeping the function as small as possible to enable inlining local function get(componentIndex: { [i24]: ArchetypeMap }, record: Record, componentId: i24) local archetype = record.archetype - local archetypeRecord = componentIndex[componentId].sparse[archetype.id] + local archetypeRecord = archetype.records[componentId] if not archetypeRecord then return nil @@ -462,7 +493,7 @@ function World.query(world: World, ...: i53): Query local entityId = archetype.entities[row :: number] local columns = archetype.columns local tr = compatibleArchetype[2] - + if queryLength == 1 then return entityId, columns[tr[1]][row] elseif queryLength == 2 then @@ -528,7 +559,9 @@ end function World.component(world: World) local componentId = world.nextComponentId + 1 if componentId > HI_COMPONENT_ID then - error("Too many components") + -- IDs are partitioned into ranges because component IDs are not nominal, + -- so it needs to error when IDs intersect into the entity range. + error("Too many components, consider using world:entity() instead to create components.") end world.nextComponentId = componentId return componentId @@ -539,6 +572,17 @@ function World.entity(world: World) return world.nextEntityId + REST end +function World.delete(world: World, entityId: i53) + local entityIndex = world.entityIndex + local record = entityIndex[entityId] + moveEntity(entityIndex, entityId, record, world.ROOT_ARCHETYPE) + -- Since we just appended an entity to the ROOT_ARCHETYPE we have to remove it from + -- the entities array and delete the record. We know there won't be the hole since + -- we are always removing the last row. + --world.ROOT_ARCHETYPE.entities[record.row] = nil + --entityIndex[entityId] = nil +end + function World.observer(world: World, ...) local componentIds = { ... } diff --git a/lib/init.spec.lua b/lib/init.spec.lua index 387a750..fdc8331 100644 --- a/lib/init.spec.lua +++ b/lib/init.spec.lua @@ -191,5 +191,113 @@ return function() end expect(count).to.equal(1) end) + + it("should query all matching entities", function() + + local world = jecs.World.new() + local A = world:component() + local B = world:component() + + local entities = {} + for i = 1, N do + local id = world:entity() + + + world:set(id, A, true) + if i > 5 then world:set(id, B, true) end + entities[i] = id + end + + for id in world:query(A) do + local i = table.find(entities, id) + expect(i).to.be.ok() + table.remove(entities, i) + end + + expect(#entities).to.equal(0) + end) + + it("should query all matching entities when irrelevant component is removed", function() + + + local world = jecs.World.new() + local A = world:component() + local B = world:component() + + local entities = {} + for i = 1, N do + local id = world:entity() + + world:set(id, A, true) + world:set(id, B, true) + if i > 5 then world:remove(id, B, true) end + entities[i] = id + end + + local added = 0 + for id in world:query(A) do + added += 1 + local i = table.find(entities, id) + expect(i).to.be.ok() + table.remove(entities, i) + end + + expect(added).to.equal(N) + end) + + it("should query all entities without B", function() + local world = jecs.World.new() + local A = world:component() + local B = world:component() + + local entities = {} + for i = 1, N do + local id = world:entity() + + world:set(id, A, true) + if i < 5 then + entities[i] = id + else + world:set(id, B, true) + end + + end + + for id in world:query(A):without(B) do + local i = table.find(entities, id) + expect(i).to.be.ok() + table.remove(entities, i) + end + + expect(#entities).to.equal(0) + end) + + it("should allow setting components in arbitrary order", function() + local world = jecs.World.new() + + local Health = world:entity() + local Poison = world:component() + + local id = world:entity() + world:set(id, Poison, 5) + world:set(id, Health, 50) + + expect(world:get(id, Poison)).to.equal(5) + end) + + it("Should allow deleting components", function() + local world = jecs.World.new() + + local Health = world:entity() + local Poison = world:component() + + local id = world:entity() + world:set(id, Poison, 5) + world:set(id, Health, 50) + world:delete(id) + + expect(world:get(id, Poison)).to.never.be.ok() + expect(world:get(id, Health)).to.never.be.ok() + end) end) end \ No newline at end of file diff --git a/newMatter.lua b/newMatter.lua new file mode 100644 index 0000000..4d54f53 --- /dev/null +++ b/newMatter.lua @@ -0,0 +1,1664 @@ +--!optimize 2 +--!native +--!strict + +local None = {} + +local function merge(one, two) + local new = table.clone(one) + + for key, value in two do + if value == None then + new[key] = nil + else + new[key] = value + end + end + + return new +end + +-- https://github.com/freddylist/llama/blob/master/src/List/toSet.lua +local function toSet(list) + local set = {} + + for _, v in ipairs(list) do + set[v] = true + end + + return set +end + +-- https://github.com/freddylist/llama/blob/master/src/Dictionary/values.lua +local function values(dictionary) + local valuesList = {} + + local index = 1 + + for _, value in pairs(dictionary) do + valuesList[index] = value + index = index + 1 + end + + return valuesList +end + +local valueIds = {} +local nextValueId = 0 +local compatibilityCache = {} +local archetypeCache = {} + +local function getValueId(value) + local valueId = valueIds[value] + if valueId == nil then + valueIds[value] = nextValueId + valueId = nextValueId + nextValueId += 1 + end + + return valueId +end + +function archetypeOf(...) + local length = select("#", ...) + + local currentNode = archetypeCache + + for i = 1, length do + local nextNode = currentNode[select(i, ...)] + + if not nextNode then + nextNode = {} + currentNode[select(i, ...)] = nextNode + end + + currentNode = nextNode + end + + if currentNode._archetype then + return currentNode._archetype + end + + local list = table.create(length) + + for i = 1, length do + list[i] = getValueId(select(i, ...)) + end + + table.sort(list) + + local archetype = table.concat(list, "_") + + currentNode._archetype = archetype + + return archetype +end + +local stack = {} + +local function newStackFrame(node) + return { + node = node, + accessedKeys = {}, + } +end + +local function cleanup() + local currentFrame = stack[#stack] + + for baseKey, state in pairs(currentFrame.node.system) do + for key, value in pairs(state.storage) do + if not currentFrame.accessedKeys[baseKey] or not currentFrame.accessedKeys[baseKey][key] then + local cleanupCallback = state.cleanupCallback + + if cleanupCallback then + local shouldAbortCleanup = cleanupCallback(value) + + if shouldAbortCleanup then + continue + end + end + + state.storage[key] = nil + end + end + end +end + +local function start(node, fn) + table.insert(stack, newStackFrame(node)) + fn() + cleanup() + table.remove(stack, #stack) +end + +local function withinTopoContext() + return #stack ~= 0 +end + +local function useFrameState() + return stack[#stack].node.frame +end + +local function useCurrentSystem() + if #stack == 0 then + return + end + + return stack[#stack].node.currentSystem +end + + +--[=[ + @within Matter + + :::tip + **Don't use this function directly in your systems.** + + This function is used for implementing your own topologically-aware functions. It should not be used in your + systems directly. You should use this function to implement your own utilities, similar to `useEvent` and + `useThrottle`. + ::: + + `useHookState` does one thing: it returns a table. An empty, pristine table. Here's the cool thing though: + it always returns the *same* table, based on the script and line where *your function* (the function calling + `useHookState`) was called. + + ### Uniqueness + + If your function is called multiple times from the same line, perhaps within a loop, the default behavior of + `useHookState` is to uniquely identify these by call count, and will return a unique table for each call. + + However, you can override this behavior: you can choose to key by any other value. This means that in addition to + script and line number, the storage will also only return the same table if the unique value (otherwise known as the + "discriminator") is the same. + + ### Cleaning up + As a second optional parameter, you can pass a function that is automatically invoked when your storage is about + to be cleaned up. This happens when your function (and by extension, `useHookState`) ceases to be called again + next frame (keyed by script, line number, and discriminator). + + Your cleanup callback is passed the storage table that's about to be cleaned up. You can then perform cleanup work, + like disconnecting events. + + *Or*, you could return `true`, and abort cleaning up altogether. If you abort cleanup, your storage will stick + around another frame (even if your function wasn't called again). This can be used when you know that the user will + (or might) eventually call your function again, even if they didn't this frame. (For example, caching a value for + a number of seconds). + + If cleanup is aborted, your cleanup function will continue to be called every frame, until you don't abort cleanup, + or the user actually calls your function again. + + ### Example: useThrottle + + This is the entire implementation of the built-in `useThrottle` function: + + ```lua + local function cleanup(storage) + return os.clock() < storage.expiry + end + + local function useThrottle(seconds, discriminator) + local storage = useHookState(discriminator, cleanup) + + if storage.time == nil or os.clock() - storage.time >= seconds then + storage.time = os.clock() + storage.expiry = os.clock() + seconds + return true + end + + return false + end + ``` + + A lot of talk for something so simple, right? + + @param discriminator? any -- A unique value to additionally key by + @param cleanupCallback (storage: {}) -> boolean? -- A function to run when the storage for this hook is cleaned up +]=] +local function useHookState(discriminator, cleanupCallback): {} + local file, line = debug.info(3, "sl") + local fn = debug.info(2, "f") + + local baseKey = string.format("%s:%s:%d", tostring(fn), file, line) + + local currentFrame = stack[#stack] + + if currentFrame == nil then + error("Attempt to access topologically-aware storage outside of a Loop-system context.", 3) + end + + if not currentFrame.accessedKeys[baseKey] then + currentFrame.accessedKeys[baseKey] = {} + end + + local accessedKeys = currentFrame.accessedKeys[baseKey] + + local key = #accessedKeys + + if discriminator ~= nil then + if type(discriminator) == "number" then + discriminator = tostring(discriminator) + end + + key = discriminator + end + + accessedKeys[key] = true + + if not currentFrame.node.system[baseKey] then + currentFrame.node.system[baseKey] = { + storage = {}, + cleanupCallback = cleanupCallback, + } + end + + local storage = currentFrame.node.system[baseKey].storage + + if not storage[key] then + storage[key] = {} + end + + return storage[key] +end + +local topoRuntime = { + start = start, + useHookState = useHookState, + useFrameState = useFrameState, + useCurrentSystem = useCurrentSystem, + withinTopoContext = withinTopoContext, +} + + +--[=[ + @class Component + + A component is a named piece of data that exists on an entity. + Components are created and removed in the [World](/api/World). + + In the docs, the terms "Component" and "ComponentInstance" are used: + - **"Component"** refers to the base class of a specific type of component you've created. + This is what [`Matter.component`](/api/Matter#component) returns. + - **"Component Instance"** refers to an actual piece of data that can exist on an entity. + The metatable of a component instance table is its respective Component table. + + Component instances are *plain-old data*: they do not contain behaviors or methods. + + Since component instances are immutable, one helper function exists on all component instances, `patch`, + which allows reusing data from an existing component instance to make up for the ergonomic loss of mutations. +]=] + +--[=[ + @within Component + @type ComponentInstance {} + + The `ComponentInstance` type refers to an actual piece of data that can exist on an entity. + The metatable of the component instance table is set to its particular Component table. + + A component instance can be created by calling the Component table: + + ```lua + -- Component: + local MyComponent = Matter.component("My component") + + -- component instance: + local myComponentInstance = MyComponent({ + some = "data" + }) + + print(getmetatable(myComponentInstance) == MyComponent) --> true + ``` +]=] + +-- This is a special value we set inside the component's metatable that will allow us to detect when +-- a Component is accidentally inserted as a Component Instance. +-- It should not be accessible through indexing into a component instance directly. +local DIAGNOSTIC_COMPONENT_MARKER = {} + +local function newComponent(name, defaultData) + name = name or debug.info(2, "s") .. "@" .. debug.info(2, "l") + + assert( + defaultData == nil or type(defaultData) == "table", + "if component default data is specified, it must be a table" + ) + + local component = {} + component.__index = component + + function component.new(data) + data = data or {} + + if defaultData then + data = merge(defaultData, data) + end + + return table.freeze(setmetatable(data, component)) + end + + --[=[ + @within Component + + ```lua + for id, target in world:query(Target) do + if shouldChangeTarget(target) then + world:insert(id, target:patch({ -- modify the existing component + currentTarget = getNewTarget() + })) + end + end + ``` + + A utility function used to immutably modify an existing component instance. Key/value pairs from the passed table + will override those of the existing component instance. + + As all components are immutable and frozen, it is not possible to modify the existing component directly. + + You can use the `Matter.None` constant to remove a value from the component instance: + + ```lua + target:patch({ + currentTarget = Matter.None -- sets currentTarget to nil + }) + ``` + + @param partialNewData {} -- The table to be merged with the existing component data. + @return ComponentInstance -- A copy of the component instance with values from `partialNewData` overriding existing values. + ]=] + function component:patch(partialNewData) + local patch = getmetatable(self).new(merge(self, partialNewData)) + return patch + end + + setmetatable(component, { + __call = function(_, ...) + return component.new(...) + end, + __tostring = function() + return name + end, + [DIAGNOSTIC_COMPONENT_MARKER] = true, + }) + + return component +end + +local function assertValidType(value, position) + if typeof(value) ~= "table" then + error(string.format("Component #%d is invalid: not a table", position), 3) + end + + local metatable = getmetatable(value) + + if metatable == nil then + error(string.format("Component #%d is invalid: has no metatable", position), 3) + end +end + +local function assertValidComponent(value, position) + assertValidType(value, position) + + local metatable = getmetatable(value) + + if getmetatable(metatable) ~= nil and getmetatable(metatable)[DIAGNOSTIC_COMPONENT_MARKER] then + error( + string.format( + "Component #%d is invalid: Component Instance %s was passed instead of the Component itself!", + position, + tostring(metatable) + ), + 3 + ) + end +end + +local function assertValidComponentInstance(value, position) + assertValidType(value, position) + + if getmetatable(value)[DIAGNOSTIC_COMPONENT_MARKER] ~= nil then + error( + string.format( + "Component #%d is invalid: passed a Component instead of a Component instance; " + .. "did you forget to call it as a function?", + position + ), + 3 + ) + end +end + +local ERROR_NO_ENTITY = "Entity doesn't exist, use world:contains to check if needed" +local ERROR_DUPLICATE_ENTITY = + "The world already contains an entity with ID %d. Use World:replace instead if this is intentional." +local ERROR_NO_COMPONENTS = "Missing components" + +type i53 = number +type i24 = number + +type Component = { [any]: any } +type ComponentInstance = Component + +type Ty = { i53 } +type ArchetypeId = number + +type Column = { any } + +type Archetype = { + -- Unique identifier of this archetype + id: number, + edges: { + [i24]: { + add: Archetype, + remove: Archetype, + }, + }, + types: Ty, + type: string | number, + entities: { number }, + columns: { Column }, + records: {}, +} + +type Record = { + archetype: Archetype, + row: number, +} + +type EntityIndex = { [i24]: Record } +type ComponentIndex = { [i24]: ArchetypeMap } + +type ArchetypeRecord = number +type ArchetypeMap = { sparse: { [ArchetypeId]: ArchetypeRecord }, size: number } +type Archetypes = { [ArchetypeId]: Archetype } + +local function transitionArchetype( + entityIndex: EntityIndex, + to: Archetype, + destinationRow: i24, + from: Archetype, + sourceRow: i24 +) + -- local columns = sourceArchetype.columns + -- local sourceEntities = sourceArchetype.entities + -- local destinationEntities = destinationArchetype.entities + -- local destinationColumns = destinationArchetype.columns + + local columns = from.columns + local sourceEntities = from.entities + local destinationEntities = to.entities + local destinationColumns = to.columns + local tr = to.records + local types = from.types + + for componentId, column in columns do + local targetColumn = destinationColumns[tr[types[componentId]]] + if targetColumn then + targetColumn[destinationRow] = column[sourceRow] + end + + if sourceRow ~= #column then + column[sourceRow] = column[#column] + column[#column] = nil + end + end + + destinationEntities[destinationRow] = sourceEntities[sourceRow] + entityIndex[sourceEntities[sourceRow]].row = destinationRow + + local movedAway = #sourceEntities + if sourceRow ~= movedAway then + sourceEntities[sourceRow] = sourceEntities[movedAway] + entityIndex[sourceEntities[movedAway]].row = sourceRow + end + + sourceEntities[movedAway] = nil +end + +local function archetypeAppend(entity: i53, archetype: Archetype): i24 + local entities = archetype.entities + table.insert(entities, entity) + return #entities +end + +local function newEntity(entityId: i53, record: Record, archetype: Archetype) + local row = archetypeAppend(entityId, archetype) + record.archetype = archetype + record.row = row + return record +end + +local function moveEntity(entityIndex, entityId: i53, record: Record, to: Archetype) + local sourceRow = record.row + local from = record.archetype + local destinationRow = archetypeAppend(entityId, to) + transitionArchetype(entityIndex, to, destinationRow, from, sourceRow) + record.archetype = to + record.row = destinationRow +end + +local function hash(arr): string | number + return table.concat(arr, "_") +end + +local function createArchetypeRecords(componentIndex: ComponentIndex, to: Archetype) + local destinationCount = #to.types + local destinationIds = to.types + + for i = 1, destinationCount do + local destinationId = destinationIds[i] + + if not componentIndex[destinationId] then + componentIndex[destinationId] = { sparse = {}, size = 0 } + end + componentIndex[destinationId].sparse[to.id] = i + to.records[destinationId] = i + end +end + +local function archetypeOf(world: World, types: { i24 }, prev: Archetype?): Archetype + local ty = hash(types) + + world.nextArchetypeId = (world.nextArchetypeId :: number) + 1 + local id = world.nextArchetypeId + + local columns = {} :: { any } + + for _ in types do + table.insert(columns, {}) + end + + local archetype = { + id = id, + types = types, + type = ty, + columns = columns, + entities = {}, + edges = {}, + records = {}, + } + + world.archetypeIndex[ty] = archetype + world.archetypes[id] = archetype + + if #types > 0 then + createArchetypeRecords(world.componentIndex, archetype, prev) + end + + return archetype +end + +local World = {} +World.__index = World + +function World.new() + local self = setmetatable({ + entityIndex = {}, + componentIndex = {}, + archetypes = {}, + archetypeIndex = {}, + nextId = 0, + nextArchetypeId = 0, + _size = 0, + _changedStorage = {}, + }, World) + + self.ROOT_ARCHETYPE = archetypeOf(self, {}, nil) + return self +end + +type World = typeof(World.new()) + +local function ensureArchetype(world: World, types, prev) + if #types < 1 then + return world.ROOT_ARCHETYPE + end + + local ty = hash(types) + local archetype = world.archetypeIndex[ty] + if archetype then + return archetype + end + + return archetypeOf(world, types, prev) +end + +local function findInsert(types: { i53 }, toAdd: i53) + local count = #types + for i = 1, count do + local id = types[i] + if id == toAdd then + return -1 + end + if id > toAdd then + return i + end + end + return count + 1 +end + +local function findArchetypeWith(world: World, node: Archetype, componentId: i53) + local types = node.types + local at = findInsert(types, componentId) + if at == -1 then + return node + end + + local destinationType = table.clone(node.types) + table.insert(destinationType, at, componentId) + return ensureArchetype(world, destinationType, node) +end + +local function ensureEdge(archetype: Archetype, componentId: i53) + if not archetype.edges[componentId] then + archetype.edges[componentId] = {} :: any + end + return archetype.edges[componentId] +end + +local function archetypeTraverseAdd(world: World, componentId: i53, archetype: Archetype?): Archetype + local from = (archetype or world.ROOT_ARCHETYPE) :: Archetype + local edge = ensureEdge(from, componentId) + + if not edge.add then + edge.add = findArchetypeWith(world, from, componentId) + end + + return edge.add +end + +local function componentAdd(world: World, entityId: i53, componentInstance) + local componentId = #getmetatable(componentInstance) + + local record = world:ensureRecord(entityId) + local sourceArchetype = record.archetype + local destinationArchetype = archetypeTraverseAdd(world, componentId, sourceArchetype) + + if sourceArchetype == destinationArchetype then + local archetypeRecord = destinationArchetype.records[componentId] + destinationArchetype.columns[archetypeRecord][record.row] = componentInstance + return + end + + if sourceArchetype then + moveEntity(world.entityIndex, entityId, record, destinationArchetype) + else + -- if it has any components, then it wont be the root archetype + if #destinationArchetype.types > 0 then + newEntity(entityId, record, destinationArchetype) + end + end + + local archetypeRecord = destinationArchetype.records[componentId] + destinationArchetype.columns[archetypeRecord][record.row] = componentInstance +end + +function World.ensureRecord(world: World, entityId: i53) + local entityIndex = world.entityIndex + local id = entityId + if not entityIndex[id] then + entityIndex[id] = {} :: Record + end + return entityIndex[id] +end + +local function archetypeTraverseRemove(world: World, componentId: i53, archetype: Archetype?): Archetype + local from = (archetype or world.ROOT_ARCHETYPE) :: Archetype + local edge = ensureEdge(from, componentId) + + if not edge.remove then + local to = table.clone(from.types) + table.remove(to, table.find(to, componentId)) + edge.remove = ensureArchetype(world, to, from) + end + + return edge.remove +end + +local function get(componentIndex: ComponentIndex, record: Record, componentId: i24): ComponentInstance? + local archetype = record.archetype + if not archetype then + return + end + local map = componentIndex[componentId] + if map == nil then + return nil + end + + local archetypeRecord = map.sparse[archetype.id] + if not archetypeRecord then + return nil + end + + return archetype.columns[archetypeRecord][record.row] +end + +local function componentRemove(world: World, entityId: i53, component: Component) + local componentId = #component + local record = world:ensureRecord(entityId) + local sourceArchetype = record.archetype + local destinationArchetype = archetypeTraverseRemove(world, componentId, sourceArchetype) + + -- TODO: + -- There is a better way to get the component for returning + local componentInstance = get(world.componentIndex, record, componentId) + if sourceArchetype and not (sourceArchetype == destinationArchetype) then + moveEntity(world.entityIndex, entityId, record, destinationArchetype) + end + + return componentInstance +end + +--[=[ + Removes a component (or set of components) from an existing entity. + + ```lua + local removedA, removedB = world:remove(entityId, ComponentA, ComponentB) + ``` + + @param entityId number -- The entity ID + @param ... Component -- The components to remove + @return ...ComponentInstance -- Returns the component instance values that were removed in the order they were passed. +]=] +function World.remove(world: World, entityId: i53, ...) + if not world:contains(entityId) then + error(ERROR_NO_ENTITY, 2) + end + + local length = select("#", ...) + local removed = {} + for i = 1, length do + table.insert(removed, componentRemove(world, entityId, select(i, ...))) + end + + return unpack(removed, 1, length) +end + +function World.get( + world: World, + entityId: i53, + a: Component, + b: Component?, + c: Component?, + d: Component?, + e: Component? +): any + local componentIndex = world.componentIndex + local record = world.entityIndex[entityId] + if not record then + return nil + end + + local va = get(componentIndex, record, #a) + + if b == nil then + return va + elseif c == nil then + return va, get(componentIndex, record, #b) + elseif d == nil then + return va, get(componentIndex, record, #b), get(componentIndex, record, #c) + elseif e == nil then + return va, get(componentIndex, record, #b), get(componentIndex, record, #c), get(componentIndex, record, #d) + else + error("args exceeded") + end +end + +function World.insert(world: World, entityId: i53, ...) + if not world:contains(entityId) then + error(ERROR_NO_ENTITY, 2) + end + + for i = 1, select("#", ...) do + local newComponent = select(i, ...) + assertValidComponentInstance(newComponent, i) + + local metatable = getmetatable(newComponent) + local oldComponent = world:get(entityId, metatable) + componentAdd(world, entityId, newComponent) + + world:_trackChanged(metatable, entityId, oldComponent, newComponent) + end +end + +function World.replace(world: World, entityId: i53, ...: ComponentInstance) + error("Replace is unimplemented") + + if not world:contains(entityId) then + error(ERROR_NO_ENTITY, 2) + end + + --moveEntity(entityId, record, world.ROOT_ARCHETYPE) + for i = 1, select("#", ...) do + local newComponent = select(i, ...) + assertValidComponentInstance(newComponent, i) + end +end + +function World.entity(world: World) + world.nextId += 1 + return world.nextId +end + +function World:__iter() + return error("NOT IMPLEMENTED YET") +end + +function World._trackChanged(world: World, metatable, id, old, new) + if not world._changedStorage[metatable] then + return + end + + if old == new then + return + end + + local record = table.freeze({ + old = old, + new = new, + }) + + for _, storage in ipairs(world._changedStorage[metatable]) do + -- If this entity has changed since the last time this system read it, + -- we ensure that the "old" value is whatever the system saw it as last, instead of the + -- "old" value we have here. + if storage[id] then + storage[id] = table.freeze({ old = storage[id].old, new = new }) + else + storage[id] = record + end + end +end + +--[=[ + Spawns a new entity in the world with a specific entity ID and given components. + + The next ID generated from [World:spawn] will be increased as needed to never collide with a manually specified ID. + + @param entityId number -- The entity ID to spawn with + @param ... ComponentInstance -- The component values to spawn the entity with. + @return number -- The same entity ID that was passed in +]=] +function World.spawnAt(world: World, entityId: i53, ...: ComponentInstance) + if world:contains(entityId) then + error(string.format(ERROR_DUPLICATE_ENTITY, entityId), 2) + end + + if entityId >= world.nextId then + world.nextId = entityId + 1 + end + + world._size += 1 + world:ensureRecord(entityId) + + local components = {} + for i = 1, select("#", ...) do + local component = select(i, ...) + assertValidComponentInstance(component, i) + + local metatable = getmetatable(component) + if components[metatable] then + error(("Duplicate component type at index %d"):format(i), 2) + end + + world:_trackChanged(metatable, entityId, nil, component) + + components[metatable] = component + componentAdd(world, entityId, component) + end + + return entityId +end + +--[=[ + Spawns a new entity in the world with the given components. + + @param ... ComponentInstance -- The component values to spawn the entity with. + @return number -- The new entity ID. +]=] +function World.spawn(world: World, ...: ComponentInstance) + return world:spawnAt(world.nextId, ...) +end + +function World.despawn(world: World, entityId: i53) + local entityIndex = world.entityIndex + local record = entityIndex[entityId] + moveEntity(entityIndex, entityId, record, world.ROOT_ARCHETYPE) + world.ROOT_ARCHETYPE.entities[record.row] = nil + entityIndex[entityId] = nil + world._size -= 1 +end + +function World.clear(world: World) + world.entityIndex = {} + world.componentIndex = {} + world.archetypes = {} + world.archetypeIndex = {} + world._size = 0 + world.ROOT_ARCHETYPE = archetypeOf(world, {}, nil) +end + +function World.size(world: World) + return world._size +end + +function World.contains(world: World, entityId: i53) + return world.entityIndex[entityId] ~= nil +end + +local function noop(): any + return function() end +end + +local emptyQueryResult = setmetatable({ + next = function() end, + snapshot = function() + return {} + end, + without = function(self) + return self + end, + view = function() + return { + get = function() end, + contains = function() end, + } + end, +}, { + __iter = noop, + __call = noop, +}) + +local function queryResult(compatibleArchetypes, components: { number }, queryLength, ...): any + local a: any, b: any, c: any, d: any, e: any = ... + local lastArchetype, archetype = next(compatibleArchetypes) + if not lastArchetype then + return emptyQueryResult + end + + local lastRow + local queryOutput = {} + local function iterate() + local row = next(archetype.entities, lastRow) + while row == nil do + lastArchetype, archetype = next(compatibleArchetypes, lastArchetype) + if lastArchetype == nil then + return + end + row = next(archetype.entities, row) + end + + lastRow = row + + local columns = archetype.columns + local entityId = archetype.entities[row :: number] + local archetypeRecords = archetype.records + + if queryLength == 1 then + return entityId, columns[archetypeRecords[a]][row] + elseif queryLength == 2 then + return entityId, columns[archetypeRecords[a]][row], columns[archetypeRecords[b]][row] + elseif queryLength == 3 then + return entityId, + columns[archetypeRecords[a]][row], + columns[archetypeRecords[b]][row], + columns[archetypeRecords[c]][row] + elseif queryLength == 4 then + return entityId, + columns[archetypeRecords[a]][row], + columns[archetypeRecords[b]][row], + columns[archetypeRecords[c]][row], + columns[archetypeRecords[d]][row] + elseif queryLength == 5 then + return entityId, + columns[archetypeRecords[a]][row], + columns[archetypeRecords[b]][row], + columns[archetypeRecords[c]][row], + columns[archetypeRecords[d]][row], + columns[archetypeRecords[e]][row] + end + + for i, componentId in components do + queryOutput[i] = columns[archetypeRecords[componentId]][row] + end + + return entityId, unpack(queryOutput, 1, queryLength) + 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 + + -- TODO: + -- remove in matter 1.0 + function QueryResult:__call() + return iterate() + end + + function QueryResult:__iter() + return function() + return iterate() + end + end + + --[=[ + Returns an iterator that will skip any entities that also have the given components. + + @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 components = { ... } + for i, component in components do + components[i] = #component + end + + local compatibleArchetypes = compatibleArchetypes + for i = #compatibleArchetypes, 1, -1 do + local archetype = compatibleArchetypes[i] + local shouldRemove = false + for _, componentId in components do + if archetype.records[componentId] then + shouldRemove = true + break + end + end + + if shouldRemove then + table.remove(compatibleArchetypes, i) + end + end + + lastArchetype, archetype = next(compatibleArchetypes) + if not lastArchetype then + return emptyQueryResult + end + + return 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 iterate() + end + + local function drain() + local entry = table.pack(iterate()) + return if entry.n > 0 then entry else nil + end + + local Snapshot = { + __iter = function(self): any + local i = 0 + return function() + i += 1 + + local data = self[i] :: any + + if data then + return unpack(data, 1, data.n) + end + + return + end + end, + } + + function QueryResult:snapshot() + local list = setmetatable({}, Snapshot) :: any + for entry in drain do + table.insert(list, entry) + end + + return list + 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 + local inflicting = world:query(Damage, Hitting, Player):view() + for _, source in world:query(DamagedBy) do + local damage = inflicting:get(source.from) + end + + for _ in world:query(Damage):view() do end -- You can still iterate views if you want! + ``` + + @return View See [View](/api/View) docs. + ]=] + function QueryResult:view() + local fetches = {} + local list = {} :: any + + local View = {} + View.__index = View + + function View:__iter() + local current = list.head + return function() + if not current then + return + end + local entity = current.entity + local fetch = fetches[entity] + current = current.next + + return entity, unpack(fetch, 1, fetch.n) + end + end + + --[=[ + @within View + 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 + end + + local fetch = fetches[entity] + local queryLength = fetch.n + + if queryLength == 1 then + return fetch[1] + elseif queryLength == 2 then + return fetch[1], fetch[2] + elseif queryLength == 3 then + return fetch[1], fetch[2], fetch[3] + elseif queryLength == 4 then + return fetch[1], fetch[2], fetch[3], fetch[4] + elseif queryLength == 5 then + return fetch[1], fetch[2], fetch[3], fetch[4], fetch[5] + end + + return unpack(fetch, 1, fetch.n) + end + + --[=[ + @within View + Equivalent to `world:contains()` + @param entity number - the entity ID + @return boolean + ]=] + function View:contains(entity) + return fetches[entity] ~= nil + end + + for entry in drain do + local entityId = entry[1] + local fetch = table.pack(select(2, unpack(entry))) + local node = { entity = entityId, next = nil } + fetches[entityId] = fetch + + if not list.head then + list.head = node + else + local current = list.head + while current.next do + current = current.next + end + current.next = node + end + end + + return setmetatable({}, View) + end + + return setmetatable({}, QueryResult) +end + +--[=[ + Performs a query against the entities in this World. Returns a [QueryResult](/api/QueryResult), which iterates over + the results of the query. + + Order of iteration is not guaranteed. + + ```lua + for id, enemy, charge, model in world:query(Enemy, Charge, Model) do + -- Do something + end + + for id in world:query(Target):without(Model) do + -- Again, with feeling + 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. +]=]function World.query(world: World, ...: Component): any + local compatibleArchetypes = {} + local components = { ... } + local archetypes = world.archetypes + local queryLength = select("#", ...) + local a: any, b: any, c: any, d: any, e: any = ... + + if queryLength == 0 then + -- TODO: + -- return noop query + warn("TODO noop query") + end + + if queryLength == 1 then + a = #a + components = { a } + -- local archetypesMap = world.componentIndex[a] + -- components = { a } + -- local function single() + -- local id = next(archetypesMap) + -- local archetype = archetypes[id :: number] + -- local lastRow + + -- return function(): any + -- local row, entity = next(archetype.entities, lastRow) + -- while row == nil do + -- id = next(archetypesMap, id) + -- if id == nil then + -- return + -- end + -- archetype = archetypes[id] + -- row = next(archetype.entities, row) + -- end + -- lastRow = row + + -- return entity, archetype.columns[archetype.records[a]] + -- end + -- end + -- return single() + elseif queryLength == 2 then + --print("iter double") + a = #a + b = #b + components = { a, b } + + -- --print(a, b, world.componentIndex) + -- --[[local archetypesMap = world.componentIndex[a] + -- for id in archetypesMap do + -- local archetype = archetypes[id] + -- if archetype.records[b] then + -- table.insert(compatibleArchetypes, archetype) + -- end + -- end + + -- local function double(): () -> (number, any, any) + -- local lastArchetype, archetype = next(compatibleArchetypes) + -- local lastRow + + -- return function() + -- local row = next(archetype.entities, lastRow) + -- while row == nil do + -- lastArchetype, archetype = next(compatibleArchetypes, lastArchetype) + -- if lastArchetype == nil then + -- return + -- end + + -- row = next(archetype.entities, row) + -- end + -- lastRow = row + + -- local entity = archetype.entities[row :: number] + -- local columns = archetype.columns + -- local archetypeRecords = archetype.records + -- return entity, columns[archetypeRecords[a]], columns[archetypeRecords[b]] + -- end + -- end + -- return double() + elseif queryLength == 3 then + a = #a + b = #b + c = #c + components = { a, b, c } + elseif queryLength == 4 then + a = #a + b = #b + c = #c + d = #d + + components = { a, b, c, d } + elseif queryLength == 5 then + a = #a + b = #b + c = #c + d = #d + e = #e + + components = { a, b, c, d, e } + else + for i, component in components do + components[i] = (#component) :: any + end + end + + local firstArchetypeMap + local componentIndex = world.componentIndex + for _, componentId in (components :: any) :: { number } do + local map = componentIndex[componentId] + if not map then + -- TODO: + -- see what upstream does in this case + -- currently replicating jecs + error(tostring(componentId) .. " has not been added to an entity") + end + + if firstArchetypeMap == nil or map.size < firstArchetypeMap.size then + firstArchetypeMap = map + end + end + + for id in firstArchetypeMap.sparse do + local archetype = archetypes[id] + local archetypeRecords = archetype.records + local matched = true + for _, componentId in components do + if not archetypeRecords[componentId] then + matched = false + break + end + end + + if matched then + table.insert(compatibleArchetypes, archetype) + end + end + + -- Only want to include archetype selection? + + local lastArchetype, archetype = next(compatibleArchetypes) + if not lastArchetype then + return noop() + end + + local lastRow + local queryOutput = {} + local function iterate() + local row = next(archetype.entities, lastRow) + while row == nil do + lastArchetype, archetype = next(compatibleArchetypes, lastArchetype) + if lastArchetype == nil then + return + end + row = next(archetype.entities, row) + end + + lastRow = row + + local columns = archetype.columns + local entityId = archetype.entities[row :: number] + local archetypeRecords = archetype.records + + if queryLength == 1 then + return entityId, columns[archetypeRecords[a]][row] + elseif queryLength == 2 then + return entityId, columns[archetypeRecords[a]][row], columns[archetypeRecords[b]][row] + elseif queryLength == 3 then + return entityId, + columns[archetypeRecords[a]][row], + columns[archetypeRecords[b]][row], + columns[archetypeRecords[c]][row] + elseif queryLength == 4 then + return entityId, + columns[archetypeRecords[a]][row], + columns[archetypeRecords[b]][row], + columns[archetypeRecords[c]][row], + columns[archetypeRecords[d]][row] + elseif queryLength == 5 then + return entityId, + columns[archetypeRecords[a]][row], + columns[archetypeRecords[b]][row], + columns[archetypeRecords[c]][row], + columns[archetypeRecords[d]][row], + columns[archetypeRecords[e]][row] + end + + for i, componentId in components do + queryOutput[i] = columns[archetypeRecords[componentId]][row] + end + + return entityId, unpack(queryOutput, 1, queryLength) + 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 iterate() + end + + function QueryResult:__iter() + return function() + return iterate() + 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 iterate() + 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, + } + + function QueryResult:snapshot() + local list = setmetatable({}, Snapshot) :: any + + local function iter() + --local entry = table.pack(iterate()) + --return if #entry == 0 then nil else entry + return "x" + end + + for data in iter :: any do + if data[1] then + table.insert(list, data) + end + end + + return list + end + + --[=[ + Returns an iterator that will skip any entities that also have the given components. + + @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 components = { ... } + for i, component in components do + components[i] = #component + end + + local compatibleArchetypes = compatibleArchetypes + for i = #compatibleArchetypes, 1, -1 do + local archetype = compatibleArchetypes[i] + local shouldRemove = false + for _, componentId in components do + if archetype.records[componentId] then + shouldRemove = true + break + end + end + + if shouldRemove then + table.remove(compatibleArchetypes, i) + end + end + + lastArchetype, archetype = next(compatibleArchetypes) + if not lastArchetype then + return noop() + end + + return self + end + + return setmetatable({}, QueryResult) +end + +local function cleanupQueryChanged(hookState) + local world = hookState.world + local componentToTrack = hookState.componentToTrack + + for index, object in world._changedStorage[componentToTrack] do + if object == hookState.storage then + table.remove(world._changedStorage[componentToTrack], index) + break + end + end + + if next(world._changedStorage[componentToTrack]) == nil then + world._changedStorage[componentToTrack] = nil + end +end + +function World.queryChanged(world: World, componentToTrack, ...: nil) + if ... then + error("World:queryChanged does not take any additional parameters", 2) + end + + local hookState = topoRuntime.useHookState(componentToTrack, cleanupQueryChanged) :: any + if hookState.storage then + return function(): any + local entityId, record = next(hookState.storage) + + if entityId then + hookState.storage[entityId] = nil + + return entityId, record + end + return + end + end + + if not world._changedStorage[componentToTrack] then + world._changedStorage[componentToTrack] = {} + end + + local storage = {} + hookState.storage = storage + hookState.world = world + hookState.componentToTrack = componentToTrack + + table.insert(world._changedStorage[componentToTrack], storage) + + local queryResult = world:query(componentToTrack) + + return function(): any + local entityId, component = queryResult:next() + + if entityId then + return entityId, table.freeze({ new = component }) + end + return + end +end + +return { + World = World, + component = newComponent +} diff --git a/oldMatter.lua b/oldMatter.lua new file mode 100644 index 0000000..0baf7a7 --- /dev/null +++ b/oldMatter.lua @@ -0,0 +1,1567 @@ + +local None = {} + +local function merge(one, two) + local new = table.clone(one) + + for key, value in two do + if value == None then + new[key] = nil + else + new[key] = value + end + end + + return new +end + +-- https://github.com/freddylist/llama/blob/master/src/List/toSet.lua +local function toSet(list) + local set = {} + + for _, v in ipairs(list) do + set[v] = true + end + + return set +end + +-- https://github.com/freddylist/llama/blob/master/src/Dictionary/values.lua +local function values(dictionary) + local valuesList = {} + + local index = 1 + + for _, value in pairs(dictionary) do + valuesList[index] = value + index = index + 1 + end + + return valuesList +end + +local valueIds = {} +local nextValueId = 0 +local compatibilityCache = {} +local archetypeCache = {} + +local function getValueId(value) + local valueId = valueIds[value] + if valueId == nil then + valueIds[value] = nextValueId + valueId = nextValueId + nextValueId += 1 + end + + return valueId +end + +function archetypeOf(...) + local length = select("#", ...) + + local currentNode = archetypeCache + + for i = 1, length do + local nextNode = currentNode[select(i, ...)] + + if not nextNode then + nextNode = {} + currentNode[select(i, ...)] = nextNode + end + + currentNode = nextNode + end + + if currentNode._archetype then + return currentNode._archetype + end + + local list = table.create(length) + + for i = 1, length do + list[i] = getValueId(select(i, ...)) + end + + table.sort(list) + + local archetype = table.concat(list, "_") + + currentNode._archetype = archetype + + return archetype +end + +function negateArchetypeOf(...) + return string.gsub(archetypeOf(...), "_", "x") +end + +function areArchetypesCompatible(queryArchetype, targetArchetype) + local archetypes = string.split(queryArchetype, "x") + local baseArchetype = table.remove(archetypes, 1) + + local cachedCompatibility = compatibilityCache[queryArchetype .. "-" .. targetArchetype] + if cachedCompatibility ~= nil then + return cachedCompatibility + end + + local queryIds = string.split(baseArchetype, "_") + local targetIds = toSet(string.split(targetArchetype, "_")) + local excludeIds = toSet(archetypes) + + for _, queryId in ipairs(queryIds) do + if targetIds[queryId] == nil then + compatibilityCache[queryArchetype .. "-" .. targetArchetype] = false + return false + end + end + + for excludeId in excludeIds do + if targetIds[excludeId] then + compatibilityCache[queryArchetype .. "-" .. targetArchetype] = false + return false + end + end + + compatibilityCache[queryArchetype .. "-" .. targetArchetype] = true + + return true + +end + +local stack = {} + +local function newStackFrame(node) + return { + node = node, + accessedKeys = {}, + } +end + +local function cleanup() + local currentFrame = stack[#stack] + + for baseKey, state in pairs(currentFrame.node.system) do + for key, value in pairs(state.storage) do + if not currentFrame.accessedKeys[baseKey] or not currentFrame.accessedKeys[baseKey][key] then + local cleanupCallback = state.cleanupCallback + + if cleanupCallback then + local shouldAbortCleanup = cleanupCallback(value) + + if shouldAbortCleanup then + continue + end + end + + state.storage[key] = nil + end + end + end +end + +local function start(node, fn) + table.insert(stack, newStackFrame(node)) + fn() + cleanup() + table.remove(stack, #stack) +end + +local function withinTopoContext() + return #stack ~= 0 +end + +local function useFrameState() + return stack[#stack].node.frame +end + +local function useCurrentSystem() + if #stack == 0 then + return + end + + return stack[#stack].node.currentSystem +end + + +--[=[ + @within Matter + + :::tip + **Don't use this function directly in your systems.** + + This function is used for implementing your own topologically-aware functions. It should not be used in your + systems directly. You should use this function to implement your own utilities, similar to `useEvent` and + `useThrottle`. + ::: + + `useHookState` does one thing: it returns a table. An empty, pristine table. Here's the cool thing though: + it always returns the *same* table, based on the script and line where *your function* (the function calling + `useHookState`) was called. + + ### Uniqueness + + If your function is called multiple times from the same line, perhaps within a loop, the default behavior of + `useHookState` is to uniquely identify these by call count, and will return a unique table for each call. + + However, you can override this behavior: you can choose to key by any other value. This means that in addition to + script and line number, the storage will also only return the same table if the unique value (otherwise known as the + "discriminator") is the same. + + ### Cleaning up + As a second optional parameter, you can pass a function that is automatically invoked when your storage is about + to be cleaned up. This happens when your function (and by extension, `useHookState`) ceases to be called again + next frame (keyed by script, line number, and discriminator). + + Your cleanup callback is passed the storage table that's about to be cleaned up. You can then perform cleanup work, + like disconnecting events. + + *Or*, you could return `true`, and abort cleaning up altogether. If you abort cleanup, your storage will stick + around another frame (even if your function wasn't called again). This can be used when you know that the user will + (or might) eventually call your function again, even if they didn't this frame. (For example, caching a value for + a number of seconds). + + If cleanup is aborted, your cleanup function will continue to be called every frame, until you don't abort cleanup, + or the user actually calls your function again. + + ### Example: useThrottle + + This is the entire implementation of the built-in `useThrottle` function: + + ```lua + local function cleanup(storage) + return os.clock() < storage.expiry + end + + local function useThrottle(seconds, discriminator) + local storage = useHookState(discriminator, cleanup) + + if storage.time == nil or os.clock() - storage.time >= seconds then + storage.time = os.clock() + storage.expiry = os.clock() + seconds + return true + end + + return false + end + ``` + + A lot of talk for something so simple, right? + + @param discriminator? any -- A unique value to additionally key by + @param cleanupCallback (storage: {}) -> boolean? -- A function to run when the storage for this hook is cleaned up +]=] +local function useHookState(discriminator, cleanupCallback): {} + local file, line = debug.info(3, "sl") + local fn = debug.info(2, "f") + + local baseKey = string.format("%s:%s:%d", tostring(fn), file, line) + + local currentFrame = stack[#stack] + + if currentFrame == nil then + error("Attempt to access topologically-aware storage outside of a Loop-system context.", 3) + end + + if not currentFrame.accessedKeys[baseKey] then + currentFrame.accessedKeys[baseKey] = {} + end + + local accessedKeys = currentFrame.accessedKeys[baseKey] + + local key = #accessedKeys + + if discriminator ~= nil then + if type(discriminator) == "number" then + discriminator = tostring(discriminator) + end + + key = discriminator + end + + accessedKeys[key] = true + + if not currentFrame.node.system[baseKey] then + currentFrame.node.system[baseKey] = { + storage = {}, + cleanupCallback = cleanupCallback, + } + end + + local storage = currentFrame.node.system[baseKey].storage + + if not storage[key] then + storage[key] = {} + end + + return storage[key] +end + +local topoRuntime = { + start = start, + useHookState = useHookState, + useFrameState = useFrameState, + useCurrentSystem = useCurrentSystem, + withinTopoContext = withinTopoContext, +} + + + +local ERROR_NO_ENTITY = "Entity doesn't exist, use world:contains to check if needed" + +--[=[ + @class World + + A World contains entities which have components. + The World is queryable and can be used to get entities with a specific set of components. + Entities are simply ever-increasing integers. +]=] +local World = {} +World.__index = World + +--[=[ + Creates a new World. +]=] +function World.new() + local firstStorage = {} + + return setmetatable({ + -- List of maps from archetype string --> entity ID --> entity data + _storages = { firstStorage }, + -- The most recent storage that has not been dirtied by an iterator + _pristineStorage = firstStorage, + + -- Map from entity ID -> archetype string + _entityArchetypes = {}, + + -- Cache of the component metatables on each entity. Used for generating archetype. + -- Map of entity ID -> array + _entityMetatablesCache = {}, + + -- Cache of what query archetypes are compatible with what component archetypes + _queryCache = {}, + + -- Cache of what entity archetypes have ever existed in the game. This is used for knowing + -- when to update the queryCache. + _entityArchetypeCache = {}, + + -- The next ID that will be assigned with World:spawn + _nextId = 1, + + -- The total number of active entities in the world + _size = 0, + + -- Storage for `queryChanged` + _changedStorage = {}, + }, World) +end + +-- Searches all archetype storages for the entity with the given archetype +-- Returns the storage that the entity is in if it exists, otherwise nil +function World:_getStorageWithEntity(archetype, id) + for _, storage in self._storages do + local archetypeStorage = storage[archetype] + if archetypeStorage then + if archetypeStorage[id] then + return storage + end + end + end + return nil +end + +function World:_markStorageDirty() + local newStorage = {} + table.insert(self._storages, newStorage) + self._pristineStorage = newStorage + + if topoRuntime.withinTopoContext() then + local frameState = topoRuntime.useFrameState() + + frameState.dirtyWorlds[self] = true + end +end + +function World:_getEntity(id) + local archetype = self._entityArchetypes[id] + local storage = self:_getStorageWithEntity(archetype, id) + + return storage[archetype][id] +end + +function World:_next(last) + local entityId, archetype = next(self._entityArchetypes, last) + + if entityId == nil then + return nil + end + + local storage = self:_getStorageWithEntity(archetype, entityId) + + return entityId, storage[archetype][entityId] +end + +--[=[ + Iterates over all entities in this World. Iteration returns entity ID followed by a dictionary mapping + Component to Component Instance. + + **Usage:** + + ```lua + for entityId, entityData in world do + print(entityId, entityData[Components.Example]) + end + ``` + + @return number + @return {[Component]: ComponentInstance} +]=] +function World:__iter() + return World._next, self +end + +--[=[ + Spawns a new entity in the world with the given components. + + @param ... ComponentInstance -- The component values to spawn the entity with. + @return number -- The new entity ID. +]=] +function World:spawn(...) + return self:spawnAt(self._nextId, ...) +end + +--[=[ + @class Component + + A component is a named piece of data that exists on an entity. + Components are created and removed in the [World](/api/World). + + In the docs, the terms "Component" and "ComponentInstance" are used: + - **"Component"** refers to the base class of a specific type of component you've created. + This is what [`Matter.component`](/api/Matter#component) returns. + - **"Component Instance"** refers to an actual piece of data that can exist on an entity. + The metatable of a component instance table is its respective Component table. + + Component instances are *plain-old data*: they do not contain behaviors or methods. + + Since component instances are immutable, one helper function exists on all component instances, `patch`, + which allows reusing data from an existing component instance to make up for the ergonomic loss of mutations. +]=] + +--[=[ + @within Component + @type ComponentInstance {} + + The `ComponentInstance` type refers to an actual piece of data that can exist on an entity. + The metatable of the component instance table is set to its particular Component table. + + A component instance can be created by calling the Component table: + + ```lua + -- Component: + local MyComponent = Matter.component("My component") + + -- component instance: + local myComponentInstance = MyComponent({ + some = "data" + }) + + print(getmetatable(myComponentInstance) == MyComponent) --> true + ``` +]=] + +-- This is a special value we set inside the component's metatable that will allow us to detect when +-- a Component is accidentally inserted as a Component Instance. +-- It should not be accessible through indexing into a component instance directly. +local DIAGNOSTIC_COMPONENT_MARKER = {} + +local function newComponent(name, defaultData) + name = name or debug.info(2, "s") .. "@" .. debug.info(2, "l") + + assert( + defaultData == nil or type(defaultData) == "table", + "if component default data is specified, it must be a table" + ) + + local component = {} + component.__index = component + + function component.new(data) + data = data or {} + + if defaultData then + data = merge(defaultData, data) + end + + return table.freeze(setmetatable(data, component)) + end + + --[=[ + @within Component + + ```lua + for id, target in world:query(Target) do + if shouldChangeTarget(target) then + world:insert(id, target:patch({ -- modify the existing component + currentTarget = getNewTarget() + })) + end + end + ``` + + A utility function used to immutably modify an existing component instance. Key/value pairs from the passed table + will override those of the existing component instance. + + As all components are immutable and frozen, it is not possible to modify the existing component directly. + + You can use the `Matter.None` constant to remove a value from the component instance: + + ```lua + target:patch({ + currentTarget = Matter.None -- sets currentTarget to nil + }) + ``` + + @param partialNewData {} -- The table to be merged with the existing component data. + @return ComponentInstance -- A copy of the component instance with values from `partialNewData` overriding existing values. + ]=] + function component:patch(partialNewData) + local patch = getmetatable(self).new(merge(self, partialNewData)) + return patch + end + + setmetatable(component, { + __call = function(_, ...) + return component.new(...) + end, + __tostring = function() + return name + end, + [DIAGNOSTIC_COMPONENT_MARKER] = true, + }) + + return component +end + +local function assertValidType(value, position) + if typeof(value) ~= "table" then + error(string.format("Component #%d is invalid: not a table", position), 3) + end + + local metatable = getmetatable(value) + + if metatable == nil then + error(string.format("Component #%d is invalid: has no metatable", position), 3) + end +end + +local function assertValidComponent(value, position) + assertValidType(value, position) + + local metatable = getmetatable(value) + + if getmetatable(metatable) ~= nil and getmetatable(metatable)[DIAGNOSTIC_COMPONENT_MARKER] then + error( + string.format( + "Component #%d is invalid: Component Instance %s was passed instead of the Component itself!", + position, + tostring(metatable) + ), + 3 + ) + end +end + +local function assertValidComponentInstance(value, position) + assertValidType(value, position) + + if getmetatable(value)[DIAGNOSTIC_COMPONENT_MARKER] ~= nil then + error( + string.format( + "Component #%d is invalid: passed a Component instead of a Component instance; " + .. "did you forget to call it as a function?", + position + ), + 3 + ) + end +end + +--[=[ + Spawns a new entity in the world with a specific entity ID and given components. + + The next ID generated from [World:spawn] will be increased as needed to never collide with a manually specified ID. + + @param id number -- The entity ID to spawn with + @param ... ComponentInstance -- The component values to spawn the entity with. + @return number -- The same entity ID that was passed in +]=] +function World:spawnAt(id, ...) + if self:contains(id) then + error( + string.format( + "The world already contains an entity with ID %d. Use World:replace instead if this is intentional.", + id + ), + 2 + ) + end + + self._size += 1 + + if id >= self._nextId then + self._nextId = id + 1 + end + + local components = {} + local metatables = {} + + for i = 1, select("#", ...) do + local newComponent = select(i, ...) + + assertValidComponentInstance(newComponent, i) + + local metatable = getmetatable(newComponent) + + if components[metatable] then + error(("Duplicate component type at index %d"):format(i), 2) + end + + self:_trackChanged(metatable, id, nil, newComponent) + + components[metatable] = newComponent + table.insert(metatables, metatable) + end + + self._entityMetatablesCache[id] = metatables + + self:_transitionArchetype(id, components) + + return id +end + +function World:_newQueryArchetype(queryArchetype) + if self._queryCache[queryArchetype] == nil then + self._queryCache[queryArchetype] = {} + else + return -- Archetype isn't actually new + end + + for _, storage in self._storages do + for entityArchetype in storage do + if areArchetypesCompatible(queryArchetype, entityArchetype) then + self._queryCache[queryArchetype][entityArchetype] = true + end + end + end +end + +function World:_updateQueryCache(entityArchetype) + for queryArchetype, compatibleArchetypes in pairs(self._queryCache) do + if areArchetypesCompatible(queryArchetype, entityArchetype) then + compatibleArchetypes[entityArchetype] = true + end + end +end + +function World:_transitionArchetype(id, components) + local newArchetype = nil + local oldArchetype = self._entityArchetypes[id] + local oldStorage + + if oldArchetype then + oldStorage = self:_getStorageWithEntity(oldArchetype, id) + + if not components then + oldStorage[oldArchetype][id] = nil + end + end + + if components then + newArchetype = archetypeOf(unpack(self._entityMetatablesCache[id])) + + if oldArchetype ~= newArchetype then + if oldStorage then + oldStorage[oldArchetype][id] = nil + end + + if self._pristineStorage[newArchetype] == nil then + self._pristineStorage[newArchetype] = {} + end + + if self._entityArchetypeCache[newArchetype] == nil then + self._entityArchetypeCache[newArchetype] = true + self:_updateQueryCache(newArchetype) + end + self._pristineStorage[newArchetype][id] = components + else + oldStorage[newArchetype][id] = components + end + end + + self._entityArchetypes[id] = newArchetype +end + +--[=[ + Replaces a given entity by ID with an entirely new set of components. + Equivalent to removing all components from an entity, and then adding these ones. + + @param id number -- The entity ID + @param ... ComponentInstance -- The component values to spawn the entity with. +]=] +function World:replace(id, ...) + if not self:contains(id) then + error(ERROR_NO_ENTITY, 2) + end + + local components = {} + local metatables = {} + local entity = self:_getEntity(id) + + for i = 1, select("#", ...) do + local newComponent = select(i, ...) + + assertValidComponentInstance(newComponent, i) + + local metatable = getmetatable(newComponent) + + if components[metatable] then + error(("Duplicate component type at index %d"):format(i), 2) + end + + self:_trackChanged(metatable, id, entity[metatable], newComponent) + + components[metatable] = newComponent + table.insert(metatables, metatable) + end + + for metatable, component in pairs(entity) do + if not components[metatable] then + self:_trackChanged(metatable, id, component, nil) + end + end + + self._entityMetatablesCache[id] = metatables + + self:_transitionArchetype(id, components) +end + +--[=[ + Despawns a given entity by ID, removing it and all its components from the world entirely. + + @param id number -- The entity ID +]=] +function World:despawn(id) + local entity = self:_getEntity(id) + + for metatable, component in pairs(entity) do + self:_trackChanged(metatable, id, component, nil) + end + + self._entityMetatablesCache[id] = nil + self:_transitionArchetype(id, nil) + + self._size -= 1 +end + +--[=[ + Removes all entities from the world. + + :::caution + Removing entities in this way is not reported by `queryChanged`. + ::: +]=] +function World:clear() + local firstStorage = {} + self._storages = { firstStorage } + self._pristineStorage = firstStorage + self._entityArchetypes = {} + self._entityMetatablesCache = {} + self._size = 0 + self._changedStorage = {} +end + +--[=[ + Checks if the given entity ID is currently spawned in this world. + + @param id number -- The entity ID + @return bool -- `true` if the entity exists +]=] +function World:contains(id) + return self._entityArchetypes[id] ~= nil +end + +--[=[ + Gets a specific component (or set of components) from a specific entity in this world. + + @param id number -- The entity ID + @param ... Component -- The components to fetch + @return ... -- Returns the component values in the same order they were passed in +]=] +function World:get(id, ...) + if not self:contains(id) then + error(ERROR_NO_ENTITY, 2) + end + + local entity = self:_getEntity(id) + + local length = select("#", ...) + + if length == 1 then + assertValidComponent((...), 1) + return entity[...] + end + + local components = {} + for i = 1, length do + local metatable = select(i, ...) + assertValidComponent(metatable, i) + components[i] = entity[metatable] + end + + return unpack(components, 1, length) +end + +local function noop() end + +local noopQuery = setmetatable({ + next = noop, + snapshot = noop, + without = function(self) + return self + end, + 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 = negateArchetypeOf(...) + + 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 + +--[=[ + @class View + + Provides random access to the results of a query. + + Calling the View is equivalent to iterating a query. + + ```lua + for id, player, health, poison in world:query(Player, Health, Poison):view() do + -- Do something + 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 + local inflicting = world:query(Damage, Hitting, Player):view() + for _, source in world:query(DamagedBy) do + local damage = inflicting:get(source.from) + end + + for _ in world:query(Damage):view() do end -- You can still iterate views if you want! + ``` + + @return View See [View](/api/View) docs. +]=] + +function QueryResult:view() + local function iter() + return nextItem(self) + end + + local fetches = {} + local list = {} :: any + + local View = {} + View.__index = View + + function View:__iter() + local current = list.head + return function() + if not current then + return + end + local entity = current.entity + local fetch = fetches[entity] + current = current.next + + return entity, unpack(fetch, 1, fetch.n) + end + end + + --[=[ + @within View + 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 + end + + local fetch = fetches[entity] + local queryLength = fetch.n + + if queryLength == 1 then + return fetch[1] + elseif queryLength == 2 then + return fetch[1], fetch[2] + elseif queryLength == 3 then + return fetch[1], fetch[2], fetch[3] + elseif queryLength == 4 then + return fetch[1], fetch[2], fetch[3], fetch[4] + elseif queryLength == 5 then + return fetch[1], fetch[2], fetch[3], fetch[4], fetch[5] + end + + return unpack(fetch, 1, fetch.n) + end + + --[=[ + @within View + Equivalent to `world:contains()` + @param entity number - the entity ID + @return boolean + ]=] + + function View:contains(entity) + return fetches[entity] ~= nil + end + + for entityId, entityData in iter do + if entityId then + -- We start at 2 on Select since we don't need want to pack the entity id. + local fetch = table.pack(select(2, self._expand(entityId, entityData))) + local node = { entity = entityId, next = nil } + + fetches[entityId] = fetch + + if not list.head then + list.head = node + else + local current = list.head + while current.next do + current = current.next + end + current.next = node + end + end + end + + return setmetatable({}, View) +end + +--[=[ + Performs a query against the entities in this World. Returns a [QueryResult](/api/QueryResult), which iterates over + the results of the query. + + Order of iteration is not guaranteed. + + ```lua + for id, enemy, charge, model in world:query(Enemy, Charge, Model) do + -- Do something + end + + for id in world:query(Target):without(Model) do + -- Again, with feeling + 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. +]=] + +function 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] + + if next(compatibleArchetypes) == nil then + -- If there are no compatible storages avoid creating our complicated iterator + return noopQuery + end + + local queryOutput = table.create(queryLength) + + local function expand(entityId, entityData) + if not entityId then + return + end + + if queryLength == 1 then + return entityId, entityData[metatables[1]] + elseif queryLength == 2 then + return entityId, entityData[metatables[1]], entityData[metatables[2]] + elseif queryLength == 3 then + return entityId, entityData[metatables[1]], entityData[metatables[2]], entityData[metatables[3]] + elseif queryLength == 4 then + return entityId, + entityData[metatables[1]], + entityData[metatables[2]], + entityData[metatables[3]], + entityData[metatables[4]] + elseif queryLength == 5 then + return entityId, + entityData[metatables[1]], + entityData[metatables[2]], + entityData[metatables[3]], + entityData[metatables[4]], + entityData[metatables[5]] + 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) + local world = hookState.world + local componentToTrack = hookState.componentToTrack + + for index, object in world._changedStorage[componentToTrack] do + if object == hookState.storage then + table.remove(world._changedStorage[componentToTrack], index) + break + end + end + + if next(world._changedStorage[componentToTrack]) == nil then + world._changedStorage[componentToTrack] = nil + end +end + +--[=[ + @interface ChangeRecord + @within World + .new? ComponentInstance -- The new value of the component. Nil if just removed. + .old? ComponentInstance -- The former value of the component. Nil if just added. +]=] + +--[=[ + :::info Topologically-aware function + This function is only usable if called within the context of [`Loop:begin`](/api/Loop#begin). + ::: + + Queries for components that have changed **since the last time your system ran `queryChanged`**. + + Only one changed record is returned per entity, even if the same entity changed multiple times. The order + in which changed records are returned is not guaranteed to be the order that the changes occurred in. + + It should be noted that `queryChanged` does not have the same iterator invalidation concerns as `World:query`. + + :::tip + The first time your system runs (i.e., on the first frame), all existing entities in the world that match your query + are returned as "new" change records. + ::: + + :::info + Calling this function from your system creates storage internally for your system. Then, changes meeting your + criteria are pushed into your storage. Calling `queryChanged` again each frame drains this storage. + + If your system isn't called every frame, the storage will continually fill up and does not empty unless you drain + it. + + If you stop calling `queryChanged` in your system, changes will stop being tracked. + ::: + + ### Returns + `queryChanged` returns an iterator function, so you call it in a for loop just like `World:query`. + + The iterator returns the entity ID, followed by a [`ChangeRecord`](#ChangeRecord). + + The `ChangeRecord` type is a table that contains two fields, `new` and `old`, respectively containing the new + component instance, and the old component instance. `new` and `old` will never be the same value. + + `new` will be nil if the component was removed (or the entity was despawned), and `old` will be nil if the + component was just added. + + The `old` field will be the value of the component the last time this system observed it, not + necessarily the value it changed from most recently. + + The `ChangeRecord` table is potentially shared with multiple systems tracking changes for this component, so it + cannot be modified. + + ```lua + for id, record in world:queryChanged(Model) do + if record.new == nil then + -- Model was removed + + if enemy.type == "this is a made up example" then + world:remove(id, Enemy) + end + end + end + ``` + + @param componentToTrack Component -- The component you want to listen to changes for. + @return () -> (id, ChangeRecord) -- Iterator of entity ID and change record +]=] +function World:queryChanged(componentToTrack, ...: nil) + if ... then + error("World:queryChanged does not take any additional parameters", 2) + end + + local hookState = topoRuntime.useHookState(componentToTrack, cleanupQueryChanged) + + if hookState.storage then + return function(): any + local entityId, record = next(hookState.storage) + + if entityId then + hookState.storage[entityId] = nil + + return entityId, record + end + return + end + end + + if not self._changedStorage[componentToTrack] then + self._changedStorage[componentToTrack] = {} + end + + local storage = {} + hookState.storage = storage + hookState.world = self + hookState.componentToTrack = componentToTrack + + table.insert(self._changedStorage[componentToTrack], storage) + + local queryResult = self:query(componentToTrack) + + return function(): any + local entityId, component = queryResult:next() + + if entityId then + return entityId, table.freeze({ new = component }) + end + return + end +end + +function World:_trackChanged(metatable, id, old, new) + if not self._changedStorage[metatable] then + return + end + + if old == new then + return + end + + local record = table.freeze({ + old = old, + new = new, + }) + + for _, storage in ipairs(self._changedStorage[metatable]) do + -- If this entity has changed since the last time this system read it, + -- we ensure that the "old" value is whatever the system saw it as last, instead of the + -- "old" value we have here. + if storage[id] then + storage[id] = table.freeze({ old = storage[id].old, new = new }) + else + storage[id] = record + end + end +end + +--[=[ + Inserts a component (or set of components) into an existing entity. + + If another instance of a given component already exists on this entity, it is replaced. + + ```lua + world:insert( + entityId, + ComponentA({ + foo = "bar" + }), + ComponentB({ + baz = "qux" + }) + ) + ``` + + @param id number -- The entity ID + @param ... ComponentInstance -- The component values to insert +]=] +function World:insert(id, ...) + if not self:contains(id) then + error(ERROR_NO_ENTITY, 2) + end + + local entity = self:_getEntity(id) + + local wasNew = false + for i = 1, select("#", ...) do + local newComponent = select(i, ...) + + assertValidComponentInstance(newComponent, i) + + local metatable = getmetatable(newComponent) + + local oldComponent = entity[metatable] + + if not oldComponent then + wasNew = true + + table.insert(self._entityMetatablesCache[id], metatable) + end + + self:_trackChanged(metatable, id, oldComponent, newComponent) + + entity[metatable] = newComponent + end + + if wasNew then -- wasNew + self:_transitionArchetype(id, entity) + end +end + +--[=[ + Removes a component (or set of components) from an existing entity. + + ```lua + local removedA, removedB = world:remove(entityId, ComponentA, ComponentB) + ``` + + @param id number -- The entity ID + @param ... Component -- The components to remove + @return ...ComponentInstance -- Returns the component instance values that were removed in the order they were passed. +]=] +function World:remove(id, ...) + if not self:contains(id) then + error(ERROR_NO_ENTITY, 2) + end + + local entity = self:_getEntity(id) + + local length = select("#", ...) + local removed = {} + + for i = 1, length do + local metatable = select(i, ...) + + assertValidComponent(metatable, i) + + local oldComponent = entity[metatable] + + removed[i] = oldComponent + + self:_trackChanged(metatable, id, oldComponent, nil) + + entity[metatable] = nil + end + + -- Rebuild entity metatable cache + local metatables = {} + + for metatable in pairs(entity) do + table.insert(metatables, metatable) + end + + self._entityMetatablesCache[id] = metatables + + self:_transitionArchetype(id, entity) + + return unpack(removed, 1, length) +end + +--[=[ + Returns the number of entities currently spawned in the world. +]=] +function World:size() + return self._size +end + +--[=[ + :::tip + [Loop] automatically calls this function on your World(s), so there is no need to call it yourself if you're using + a Loop. + ::: + + If you are not using a Loop, you should call this function at a regular interval (i.e., once per frame) to optimize + the internal storage for queries. + + This is part of a strategy to eliminate iterator invalidation when modifying the World while inside a query from + [World:query]. While inside a query, any changes to the World are stored in a separate location from the rest of + the World. Calling this function combines the separate storage back into the main storage, which speeds things up + again. +]=] +function World:optimizeQueries() + if #self._storages == 1 then + return + end + + local firstStorage = self._storages[1] + + for i = 2, #self._storages do + local storage = self._storages[i] + + for archetype, entities in storage do + if firstStorage[archetype] == nil then + firstStorage[archetype] = entities + else + for entityId, entityData in entities do + if firstStorage[archetype][entityId] then + error("Entity ID already exists in first storage...") + end + firstStorage[archetype][entityId] = entityData + end + end + end + end + + table.clear(self._storages) + + self._storages[1] = firstStorage + self._pristineStorage = firstStorage +end + +return { + World = World, + component = newComponent +} diff --git a/tests/test1.lua b/tests/test1.lua index b982532..0b031d3 100644 --- a/tests/test1.lua +++ b/tests/test1.lua @@ -6,7 +6,6 @@ local TEST, CASE, CHECK, FINISH, SKIP = testkit.test() local N = 10 TEST("world:query", function() - do CASE "should query all matching entities" local world = jecs.World.new() @@ -83,6 +82,34 @@ TEST("world:query", function() end + do CASE "should allow setting components in arbitrary order" + local world = jecs.World.new() + + local Health = world:entity() + local Poison = world:component() + + local id = world:entity() + world:set(id, Poison, 5) + world:set(id, Health, 50) + + CHECK(world:get(id, Poison) == 5) + end + + do CASE "Should allow deleting components" + local world = jecs.World.new() + + local Health = world:entity() + local Poison = world:component() + + local id = world:entity() + world:set(id, Poison, 5) + world:set(id, Health, 50) + world:delete(id) + + CHECK(world:get(id, Poison) == nil) + CHECK(world:get(id, Health) == nil) + end + end) FINISH() \ No newline at end of file