Merge branch 'main' of https://github.com/Ukendio/jecs into mirror-matter

This commit is contained in:
Ukendio 2024-05-13 21:03:56 +02:00
commit 99ce25a5d6
15 changed files with 1478 additions and 535 deletions

3
.gitignore vendored
View file

@ -50,6 +50,3 @@ WallyPatches
roblox.toml
sourcemap.json
drafts/*.lua
*.code-workspace
roblox.yml

View file

@ -1,6 +1,7 @@
<p align="center">
<img src="logo.png" />
<img src="jecs_darkmode.svg#gh-dark-mode-only" width=50%/>
<img src="jecs_lightmode.svg#gh-light-mode-only" width=50%/>
</p>
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache-blue.svg?style=for-the-badge)](LICENSE-APACHE)
@ -10,23 +11,29 @@ Just an ECS
jecs is a stupidly fast Entity Component System (ECS).
- Entity Relationships as first class citizens
- Process tens of thousands of entities with ease every frame
- Zero-dependency Luau package
- Type-safe [Luau](https://luau-lang.org/) API
- Zero-dependency package
- Optimized for column-major operations
- Cache friendly archetype/SoA storage
- Unit tested for stability
### Example
```lua
local world = Jecs.World.new()
local Health = world:component()
local Damage = world:component()
local Position = world:component()
local world = World.new()
local player = world:entity()
local opponent = world:entity()
local Health = world:component()
local Position = world:component()
-- Notice how components can just be entities as well?
-- It allows you to model relationships easily!
local Damage = world:entity()
local DamagedBy = world:entity()
world:set(player, Health, 100)
world:set(player, Damage, 8)
world:set(player, Position, Vector3.new(0, 5, 0))
@ -37,17 +44,25 @@ world:set(opponent, Position, Vector3.new(0, 5, 3))
for playerId, playerPosition, health in world:query(Position, Health) do
local totalDamage = 0
for _, opponentPosition, damage in world:query(Position, Damage) do
for opponentId, opponentPosition, damage in world:query(Position, Damage) do
if playerId == opponentId then
continue
end
if (playerPosition - opponentPosition).Magnitude < 5 then
totalDamage += damage
end
-- We create a pair between the relation component `DamagedBy` and the entity id of the opponent.
-- This will allow us to specifically query for damage exerted by a specific opponent.
world:set(playerId, ECS_PAIR(DamagedBy, opponentId), totalDamage)
end
world:set(playerId, Health, health - totalDamage)
end
assert(world:get(playerId, Health) == 79)
assert(world:get(opponentId, Health) == 92)
-- Gets the damage inflicted by our specific opponent!
for playerId, health, inflicted in world:query(Health, ECS_PAIR(DamagedBy, opponent)) do
world:set(playerId, health - inflicted)
end
assert(world:get(player, Health) == 79)
```
125 archetypes, 4 random components queried.

372
benches/exhaustive.lua Normal file
View file

@ -0,0 +1,372 @@
local testkit = require("../testkit")
local jecs = require("../lib/init")
local ecr = require("../DevPackages/_Index/centau_ecr@0.8.0/ecr/src/ecr")
local BENCH, START = testkit.benchmark()
local function TITLE(title: string)
print()
print(testkit.color.white(title))
end
local N = 2^16-2
type i53 = number
do TITLE "create"
BENCH("entity", function()
local world = jecs.World.new()
for i = 1, START(N) do
world:entity()
end
end)
end
--- component benchmarks
--todo: perform the same benchmarks for multiple components.?
-- these kind of operations only support 1 component at a time, which is
-- a shame, especially for archetypes where moving components is expensive.
do TITLE "set"
BENCH("add 1 component", function()
local world = jecs.World.new()
local entities = {}
local A = world:component()
for i = 1, N do
entities[i] = world:entity()
end
for i = 1, START(N) do
world:set(entities[i], A, i)
end
end)
BENCH("change 1 component", function()
local world = jecs.World.new()
local entities = {}
local A = world:component()
local e = world:entity()
world:set(e, A, 1)
for i = 1, START(N) do
world:set(e, A, 2)
end
end)
end
do TITLE "remove"
BENCH("1 component", function()
local world = jecs.World.new()
local entities = {}
local A = world:component()
for i = 1, N do
local id = world:entity()
entities[i] = id
world:set(id, A, true)
end
for i = 1, START(N) do
world:remove(entities[i], A)
end
end)
end
do TITLE "get"
BENCH("1 component", function()
local world = jecs.World.new()
local entities = {}
local A = world:component()
for i = 1, N do
local id = world:entity()
entities[i] = id
world:set(id, A, true)
end
for i = 1, START(N) do
-- ? curious why the overhead is roughly 80 ns.
world:get(entities[i], A)
end
end)
BENCH("2 component", function()
local world = jecs.World.new()
local entities = {}
local A = world:component()
local B = world:component()
for i = 1, N do
local id = world:entity()
entities[i] = id
world:set(id, A, true)
world:set(id, B, true)
end
for i = 1, START(N) do
world:get(entities[i], A, B)
end
end)
BENCH("3 component", function()
local world = jecs.World.new()
local entities = {}
local A = world:component()
local B = world:component()
local C = world:component()
for i = 1, N do
local id = world:entity()
entities[i] = id
world:set(id, A, true)
world:set(id, B, true)
world:set(id, C, true)
end
for i = 1, START(N) do
world:get(entities[i], A, B, C)
end
end)
BENCH("4 component", function()
local world = jecs.World.new()
local entities = {}
local A = world:component()
local B = world:component()
local C = world:component()
local D = world:component()
for i = 1, N do
local id = world:entity()
entities[i] = id
world:set(id, A, true)
world:set(id, B, true)
world:set(id, C, true)
world:set(id, D, true)
end
for i = 1, START(N) do
world:get(entities[i], A, B, C, D)
end
end)
end
do TITLE (testkit.color.white_underline("Jecs query"))
local function count(query: () -> ())
local n = 0
for _ in query do
n += 1
end
return n
end
local function flip()
return math.random() > 0.5
end
local function view_bench(
world: jecs.World,
A: i53, B: i53, C: i53, D: i53, E: i53, F: i53, G: i53, H: i53, I: i53
)
BENCH("1 component", function()
START(count(world:query(A)))
for _ in world:query(A) do end
end)
BENCH("2 component", function()
START(count(world:query(A, B)))
for _ in world:query(A, B) do end
end)
BENCH("4 component", function()
START(count(world:query(A, B, C, D)))
for _ in world:query(A, B, C, D) do end
end)
BENCH("8 component", function()
START(count(world:query(A, B, C, D, E, F, G, H)))
for _ in world:query(A, B, C, D, E, F, G, H) do end
end)
end
do TITLE "random components"
local world = jecs.World.new()
local A = world:component()
local B = world:component()
local C = world:component()
local D = world:component()
local E = world:component()
local F = world:component()
local G = world:component()
local H = world:component()
local I = world:component()
for i = 1, N do
local id = world:entity()
if flip() then world:set(id, A, true) end
if flip() then world:set(id, B, true) end
if flip() then world:set(id, C, true) end
if flip() then world:set(id, D, true) end
if flip() then world:set(id, E, true) end
if flip() then world:set(id, F, true) end
if flip() then world:set(id, G, true) end
if flip() then world:set(id, H, true) end
if flip() then world:set(id, I, true) end
end
view_bench(world, A, B, C, D, E, F, G, H, I)
end
do TITLE "one component in common"
local world = jecs.World.new()
local A = world:component()
local B = world:component()
local C = world:component()
local D = world:component()
local E = world:component()
local F = world:component()
local G = world:component()
local H = world:component()
local I = world:component()
for i = 1, N do
local id = world:entity()
local a = true
if flip() then world:set(id, B, true) else a = false end
if flip() then world:set(id, C, true) else a = false end
if flip() then world:set(id, D, true) else a = false end
if flip() then world:set(id, E, true) else a = false end
if flip() then world:set(id, F, true) else a = false end
if flip() then world:set(id, G, true) else a = false end
if flip() then world:set(id, H, true) else a = false end
if flip() then world:set(id, I, true) else a = false end
if a then world:set(id, A, true) end
end
view_bench(world, A, B, C, D, E, F, G, H, I)
end
end
do TITLE (testkit.color.white_underline("ECR query"))
local A = ecr.component()
local B = ecr.component()
local C = ecr.component()
local D = ecr.component()
local E = ecr.component()
local F = ecr.component()
local G = ecr.component()
local H = ecr.component()
local I = ecr.component()
local function count(query: () -> ())
local n = 0
for _ in query do
n += 1
end
return n
end
local function flip()
return math.random() > 0.5
end
local function view_bench(
world: ecr.Registry,
A: i53, B: i53, C: i53, D: i53, E: i53, F: i53, G: i53, H: i53, I: i53
)
BENCH("1 component", function()
START(count(world:view(A)))
for _ in world:view(A) do end
end)
BENCH("2 component", function()
START(count(world:view(A, B)))
for _ in world:view(A, B) do end
end)
BENCH("4 component", function()
START(count(world:view(A, B, C, D)))
for _ in world:view(A, B, C, D) do end
end)
BENCH("8 component", function()
START(count(world:view(A, B, C, D, E, F, G, H)))
for _ in world:view(A, B, C, D, E, F, G, H) do end
end)
end
do TITLE "random components"
local world = ecr.registry()
for i = 1, N do
local id = world.create()
if flip() then world:set(id, A, true) end
if flip() then world:set(id, B, true) end
if flip() then world:set(id, C, true) end
if flip() then world:set(id, D, true) end
if flip() then world:set(id, E, true) end
if flip() then world:set(id, F, true) end
if flip() then world:set(id, G, true) end
if flip() then world:set(id, H, true) end
if flip() then world:set(id, I, true) end
end
view_bench(world, A, B, C, D, E, F, G, H, I)
end
do TITLE "one component in common"
local world = ecr.registry()
for i = 1, N do
local id = world.create()
local a = true
if flip() then world:set(id, B, true) else a = false end
if flip() then world:set(id, C, true) else a = false end
if flip() then world:set(id, D, true) else a = false end
if flip() then world:set(id, E, true) else a = false end
if flip() then world:set(id, F, true) else a = false end
if flip() then world:set(id, G, true) else a = false end
if flip() then world:set(id, H, true) else a = false end
if flip() then world:set(id, I, true) else a = false end
if a then world:set(id, A, true) end
end
view_bench(world, A, B, C, D, E, F, G, H, I)
end
end

View file

@ -8,7 +8,8 @@ local function TITLE(title: string)
print(testkit.color.white(title))
end
local jecs = require("../mirror/init")
local jecs = require("../lib/init")
local mirror = require("../mirror/init")
local oldMatter = require("../oldMatter")
@ -41,6 +42,145 @@ do
for _ in world:query(A, B, C, D, E, F, G, H) do
end
end)
local e = world:entity()
world:set(e, A, true)
world:set(e, B, true)
world:set(e, C, true)
world:set(e, D, true)
world:set(e, E, true)
world:set(e, F, true)
world:set(e, G, true)
world:set(e, H, true)
BENCH("Update Data", function()
for _ = 1, 100 do
world:set(e, A, false)
world:set(e, B, false)
world:set(e, C, false)
world:set(e, D, false)
world:set(e, E, false)
world:set(e, F, false)
world:set(e, G, false)
world:set(e, H, false)
end
end)
end
local D1 = ecs:component()
local D2 = ecs:component()
local D3 = ecs:component()
local D4 = ecs:component()
local D5 = ecs:component()
local D6 = ecs:component()
local D7 = ecs:component()
local D8 = ecs: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:entity()
local combination = ""
if flip() then
combination ..= "B"
ecs:set(entity, D2, {value = true})
end
if flip() then
combination ..= "C"
ecs:set(entity, D3, {value = true})
end
if flip() then
combination ..= "D"
ecs:set(entity, D4, {value = true})
end
if flip() then
combination ..= "E"
ecs:set(entity, D5, {value = true})
end
if flip() then
combination ..= "F"
ecs:set(entity, D6, {value = true})
end
if flip() then
combination ..= "G"
ecs:set(entity, D7, {value = true})
end
if flip() then
combination ..= "H"
ecs:set(entity, D8, {value = true})
end
if #combination == 7 then
added += 1
ecs:set(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("Mirror query"))
local ecs = mirror.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)
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)
local e = world:entity()
world:set(e, A, true)
world:set(e, B, true)
world:set(e, C, true)
world:set(e, D, true)
world:set(e, E, true)
world:set(e, F, true)
world:set(e, G, true)
world:set(e, H, true)
BENCH("Update Data", function()
for _ = 1, 100 do
world:set(e, A, false)
world:set(e, B, false)
world:set(e, C, false)
world:set(e, D, false)
world:set(e, E, false)
world:set(e, F, false)
world:set(e, G, false)
world:set(e, H, false)
end
end)
end
local D1 = ecs:component()

View file

@ -8,6 +8,8 @@ local jecs = require(ReplicatedStorage.Lib)
local ecr = require(ReplicatedStorage.DevPackages.ecr)
local newWorld = Matter.World.new()
local ecs = jecs.World.new()
local mirror = require(ReplicatedStorage.mirror)
local mcs = mirror.World.new()
local A1 = Matter.component()
local A2 = Matter.component()
@ -35,6 +37,15 @@ local C5 = ecs:entity()
local C6 = ecs:entity()
local C7 = ecs:entity()
local C8 = ecs:entity()
local E1 = mcs:entity()
local E2 = mcs:entity()
local E3 = mcs:entity()
local E4 = mcs:entity()
local E5 = mcs:entity()
local E6 = mcs:entity()
local E7 = mcs:entity()
local E8 = mcs:entity()
local registry2 = ecr.registry()
return {
@ -44,7 +55,7 @@ return {
Functions = {
Matter = function()
for i = 1, 50 do
for i = 1, 500 do
newWorld:spawn(
A1({ value = true }),
A2({ value = true }),
@ -60,8 +71,8 @@ return {
ECR = function()
for i = 1, 50 do
local e = registry2.create()
local e = registry2.create()
for i = 1, 500 do
registry2:set(e, B1, {value = false})
registry2:set(e, B2, {value = false})
registry2:set(e, B3, {value = false})
@ -78,7 +89,7 @@ return {
local e = ecs:entity()
for i = 1, 50 do
for i = 1, 500 do
ecs:set(e, C1, {value = false})
ecs:set(e, C2, {value = false})
@ -89,6 +100,23 @@ return {
ecs:set(e, C7, {value = false})
ecs:set(e, C8, {value = false})
end
end,
Mirror = function()
local e = ecs:entity()
for i = 1, 500 do
mcs:set(e, E1, {value = false})
mcs:set(e, E2, {value = false})
mcs:set(e, E3, {value = false})
mcs:set(e, E4, {value = false})
mcs:set(e, E5, {value = false})
mcs:set(e, E6, {value = false})
mcs:set(e, E7, {value = false})
mcs:set(e, E8, {value = false})
end
end

6
jecs_darkmode.svg Normal file
View file

@ -0,0 +1,6 @@
<svg width="47" height="18" viewBox="0 0 47 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 14C5.8 14 6 13.3333 6 13V4H0V0H6H10V13C10 17 6.66667 18 5 18H0V14H5Z" fill="white"/>
<path d="M46.5 4V0H39C37.1667 0 33.5 1.1 33.5 5.5C33.5 9.9 36.8333 11 38.5 11H41C41.5 11 42.5 11.3 42.5 12.5C42.5 13.7 41.5 14 41 14H33.5V18H41.5C43.1667 18 46.5 16.9 46.5 12.5C46.5 8.1 43.1667 7 41.5 7H39C38.5 7 37.5 6.7 37.5 5.5C37.5 4.3 38.5 4 39 4H46.5Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M32.5 0V4H30.5C28.5 4 24.5 5 24.5 9C24.5 11.0835 25.5853 12.3531 26.9078 13.0914L22.4606 14.661C21.2893 13.3156 20.5 11.4775 20.5 9C20.5 1.8 27.1667 0 30.5 0H32.5ZM24.4656 16.3357C26.5037 17.5803 28.8905 18 30.5 18H32.5V14H31.0833L24.4656 16.3357Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M25.3793 0C24.766 0.241156 24.1568 0.53354 23.571 0.885014C22.1712 1.72492 20.9038 2.91123 20.0606 4.5H11V0H25.3793ZM25.5 4.39421C25.445 4.42876 25.3906 4.46402 25.3368 4.5H25.5V4.39421ZM20.0606 13.5C20.9038 15.0888 22.1712 16.2751 23.571 17.115C24.1568 17.4665 24.766 17.7588 25.3793 18H11V13.5H20.0606ZM19.1854 7C19.0649 7.62348 19 8.28956 19 9C19 9.71044 19.0649 10.3765 19.1854 11H11V7H19.1854Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

6
jecs_lightmode.svg Normal file
View file

@ -0,0 +1,6 @@
<svg width="47" height="18" viewBox="0 0 47 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 14C5.8 14 6 13.3333 6 13V4H0V0H6H10V13C10 17 6.66667 18 5 18H0V14H5Z" fill="black"/>
<path d="M46.5 4V0H39C37.1667 0 33.5 1.1 33.5 5.5C33.5 9.9 36.8333 11 38.5 11H41C41.5 11 42.5 11.3 42.5 12.5C42.5 13.7 41.5 14 41 14H33.5V18H41.5C43.1667 18 46.5 16.9 46.5 12.5C46.5 8.1 43.1667 7 41.5 7H39C38.5 7 37.5 6.7 37.5 5.5C37.5 4.3 38.5 4 39 4H46.5Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M32.5 0V4H30.5C28.5 4 24.5 5 24.5 9C24.5 11.0835 25.5853 12.3531 26.9078 13.0914L22.4606 14.661C21.2893 13.3156 20.5 11.4775 20.5 9C20.5 1.8 27.1667 0 30.5 0H32.5ZM24.4656 16.3357C26.5037 17.5803 28.8905 18 30.5 18H32.5V14H31.0833L24.4656 16.3357Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M25.3793 0C24.766 0.241156 24.1568 0.53354 23.571 0.885014C22.1712 1.72492 20.9038 2.91123 20.0606 4.5H11V0H25.3793ZM25.5 4.39421C25.445 4.42876 25.3906 4.46402 25.3368 4.5H25.5V4.39421ZM20.0606 13.5C20.9038 15.0888 22.1712 16.2751 23.571 17.115C24.1568 17.4665 24.766 17.7588 25.3793 18H11V13.5H20.0606ZM19.1854 7C19.0649 7.62348 19 8.28956 19 9C19 9.71044 19.0649 10.3765 19.1854 11H11V7H19.1854Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -29,9 +29,10 @@ type Archetype = {
type Record = {
archetype: Archetype,
row: number,
dense: i24,
}
type EntityIndex = {[i24]: Record}
type EntityIndex = {dense: {[i24]: i53}, sparse: {[i53]: Record}}
type ComponentIndex = {[i24]: ArchetypeMap}
type ArchetypeRecord = number
@ -43,11 +44,118 @@ type ArchetypeDiff = {
removed: Ty,
}
local FLAGS_PAIR = 0x8
local HI_COMPONENT_ID = 256
local ON_ADD = HI_COMPONENT_ID + 1
local ON_REMOVE = HI_COMPONENT_ID + 2
local ON_SET = HI_COMPONENT_ID + 3
local REST = HI_COMPONENT_ID + 4
local WILDCARD = HI_COMPONENT_ID + 4
local REST = HI_COMPONENT_ID + 5
local ECS_ID_FLAGS_MASK = 0x10
local ECS_ENTITY_MASK = bit32.lshift(1, 24)
local ECS_GENERATION_MASK = bit32.lshift(1, 16)
local function addFlags(isPair: boolean)
local typeFlags = 0x0
if isPair then
typeFlags = bit32.bor(typeFlags, FLAGS_PAIR) -- HIGHEST bit in the ID.
end
if false then
typeFlags = bit32.bor(typeFlags, 0x4) -- Set the second flag to true
end
if false then
typeFlags = bit32.bor(typeFlags, 0x2) -- Set the third flag to true
end
if false then
typeFlags = bit32.bor(typeFlags, 0x1) -- LAST BIT in the ID.
end
return typeFlags
end
local function newId(source: number, target: number)
local e = source * 2^28 + target * ECS_ID_FLAGS_MASK
return e
end
local function ECS_IS_PAIR(e: number)
return (e % 2^4) // FLAGS_PAIR ~= 0
end
function separate(entity: number)
local _typeFlags = entity % 0x10
entity //= ECS_ID_FLAGS_MASK
return entity // ECS_ENTITY_MASK, entity % ECS_GENERATION_MASK, _typeFlags
end
-- HIGH 24 bits LOW 24 bits
local function ECS_GENERATION(e: i53)
e //= 0x10
return e % ECS_GENERATION_MASK
end
local function ECS_ID(e: i53)
e //= 0x10
return e // ECS_ENTITY_MASK
end
local function ECS_GENERATION_INC(e: i53)
local id, generation, flags = separate(e)
return newId(id, generation + 1) + flags
end
-- gets the high ID
local function ECS_PAIR_FIRST(entity: i53): i24
entity //= 0x10
local first = entity % ECS_ENTITY_MASK
return first
end
-- gets the low ID
local ECS_PAIR_SECOND = ECS_ID
local function ECS_PAIR(first: number, second: number)
local target = WILDCARD
local relation
if first == WILDCARD then
relation = second
elseif second == WILDCARD then
relation = first
else
relation = second
target = ECS_PAIR_SECOND(first)
end
return newId(
ECS_PAIR_SECOND(relation), target) + addFlags(--[[isPair]] true)
end
local function getAlive(entityIndex: EntityIndex, id: i53)
return entityIndex.dense[id]
end
local function ecs_get_source(entityIndex, e)
assert(ECS_IS_PAIR(e))
return getAlive(entityIndex, ECS_PAIR_FIRST(e))
end
local function ecs_get_target(entityIndex, e)
assert(ECS_IS_PAIR(e))
return getAlive(entityIndex, ECS_PAIR_SECOND(e))
end
local function nextEntityId(entityIndex, index: i24)
local id = newId(index, 0)
entityIndex.sparse[id] = {
dense = index
} :: Record
entityIndex.dense[index] = id
return id
end
local function transitionArchetype(
entityIndex: EntityIndex,
@ -81,21 +189,27 @@ local function transitionArchetype(
column[last] = nil
end
-- Move the entity from the source to the destination archetype.
local atSourceRow = sourceEntities[sourceRow]
destinationEntities[destinationRow] = atSourceRow
entityIndex[atSourceRow].row = destinationRow
local sparse = entityIndex.sparse
local movedAway = #sourceEntities
-- Move the entity from the source to the destination archetype.
-- Because we have swapped columns we now have to update the records
-- corresponding to the entities' rows that were swapped.
local movedAway = #sourceEntities
local e1 = sourceEntities[sourceRow]
local e2 = sourceEntities[movedAway]
if sourceRow ~= movedAway then
local atMovedAway = sourceEntities[movedAway]
sourceEntities[sourceRow] = atMovedAway
entityIndex[atMovedAway].row = sourceRow
sourceEntities[sourceRow] = e2
end
sourceEntities[movedAway] = nil
destinationEntities[destinationRow] = e1
local record1 = sparse[e1]
local record2 = sparse[e2]
record1.row = destinationRow
record2.row = sourceRow
end
local function archetypeAppend(entity: number, archetype: Archetype): number
@ -125,22 +239,14 @@ local function hash(arr): string | number
return table.concat(arr, "_")
end
local function createArchetypeRecords(componentIndex: ComponentIndex, to: Archetype, _from: Archetype?)
local destinationIds = to.types
local records = to.records
local id = to.id
local function createArchetypeRecord(componentIndex, id, componentId, i)
local archetypesMap = componentIndex[componentId]
for i, destinationId in destinationIds do
local archetypesMap = componentIndex[destinationId]
if not archetypesMap then
archetypesMap = {size = 0, sparse = {}}
componentIndex[destinationId] = archetypesMap
end
archetypesMap.sparse[id] = i
records[destinationId] = i
if not archetypesMap then
archetypesMap = {size = 0, sparse = {}}
componentIndex[componentId] = archetypesMap
end
archetypesMap.sparse[id] = i
end
local function archetypeOf(world: World, types: {i24}, prev: Archetype?): Archetype
@ -150,10 +256,26 @@ local function archetypeOf(world: World, types: {i24}, prev: Archetype?): Archet
world.nextArchetypeId = id
local length = #types
local columns = table.create(length) :: {any}
local columns = table.create(length)
for index in types do
columns[index] = {}
local records = {}
local componentIndex = world.componentIndex
local entityIndex = world.entityIndex
for i, componentId in types do
createArchetypeRecord(componentIndex, id, componentId, i)
records[componentId] = i
columns[i] = {}
if ECS_IS_PAIR(componentId) then
local first = ecs_get_source(entityIndex, componentId)
local second = ecs_get_target(entityIndex, componentId)
local firstPair = ECS_PAIR(first, WILDCARD)
local secondPair = ECS_PAIR(WILDCARD, second)
createArchetypeRecord(componentIndex, id, firstPair, i)
createArchetypeRecord(componentIndex, id, secondPair, i)
records[firstPair] = i
records[secondPair] = i
end
end
local archetype = {
@ -161,15 +283,12 @@ local function archetypeOf(world: World, types: {i24}, prev: Archetype?): Archet
edges = {};
entities = {};
id = id;
records = {};
records = records;
type = ty;
types = types;
}
world.archetypeIndex[ty] = archetype
world.archetypes[id] = archetype
if length > 0 then
createArchetypeRecords(world.componentIndex, archetype, prev)
end
return archetype
end
@ -179,9 +298,12 @@ World.__index = World
function World.new()
local self = setmetatable({
archetypeIndex = {};
archetypes = {};
componentIndex = {};
entityIndex = {};
archetypes = {} :: Archetypes;
componentIndex = {} :: ComponentIndex;
entityIndex = {
dense = {},
sparse = {}
} :: EntityIndex;
hooks = {
[ON_ADD] = {};
};
@ -190,32 +312,78 @@ function World.new()
nextEntityId = 0;
ROOT_ARCHETYPE = (nil :: any) :: Archetype;
}, World)
self.ROOT_ARCHETYPE = archetypeOf(self, {})
return self
end
local function emit(world, eventDescription)
local event = eventDescription.event
table.insert(world.hooks[event], {
archetype = eventDescription.archetype;
ids = eventDescription.ids;
offset = eventDescription.offset;
otherArchetype = eventDescription.otherArchetype;
})
function World.component(world: World)
local componentId = world.nextComponentId + 1
if componentId > HI_COMPONENT_ID then
-- 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 nextEntityId(world.entityIndex, componentId)
end
local function onNotifyAdd(world, archetype, otherArchetype, row: number, added: Ty)
if #added > 0 then
emit(world, {
archetype = archetype;
event = ON_ADD;
ids = added;
offset = row;
otherArchetype = otherArchetype;
})
function World.entity(world: World)
local entityId = world.nextEntityId + 1
world.nextEntityId = entityId
return nextEntityId(world.entityIndex, entityId + REST)
end
-- should reuse this logic in World.set instead of swap removing in transition archetype
local function destructColumns(columns, count, row)
if row == count then
for _, column in columns do
column[count] = nil
end
else
for _, column in columns do
column[row] = column[count]
column[count] = nil
end
end
end
local function archetypeDelete(entityIndex, record: Record, entityId: i53, destruct: boolean)
local sparse, dense = entityIndex.sparse, entityIndex.dense
local archetype = record.archetype
local row = record.row
local entities = archetype.entities
local last = #entities
local entityToMove = entities[last]
if row ~= last then
dense[record.dense] = entityToMove
sparse[entityToMove] = record
end
sparse[entityId] = nil
dense[#dense] = nil
entities[row], entities[last] = entities[last], nil
local columns = archetype.columns
if not destruct then
return
end
destructColumns(columns, last, row)
end
function World.delete(world: World, entityId: i53)
local entityIndex = world.entityIndex
local record = entityIndex.sparse[entityId]
if not record then
return
end
archetypeDelete(entityIndex, record, entityId, true)
end
export type World = typeof(World.new())
local function ensureArchetype(world: World, types, prev)
@ -249,15 +417,16 @@ local function findArchetypeWith(world: World, node: Archetype, componentId: i53
-- 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 destinationType = table.clone(node.types)
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
local destinationType = table.clone(node.types)
table.insert(destinationType, at, componentId)
return ensureArchetype(world, destinationType, node)
end
@ -272,15 +441,7 @@ local function ensureEdge(archetype: Archetype, componentId: i53)
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
local ROOT_ARCHETYPE = world.ROOT_ARCHETYPE
if not ROOT_ARCHETYPE then
ROOT_ARCHETYPE = archetypeOf(world, {}, nil)
world.ROOT_ARCHETYPE = ROOT_ARCHETYPE :: never
end
from = ROOT_ARCHETYPE
end
from = from or world.ROOT_ARCHETYPE
local edge = ensureEdge(from, componentId)
local add = edge.add
@ -294,19 +455,23 @@ local function archetypeTraverseAdd(world: World, componentId: i53, from: Archet
return add
end
local function ensureRecord(entityIndex, entityId: i53): Record
local record = entityIndex[entityId]
if not record then
record = {}
entityIndex[entityId] = record
function World.add(world: World, entityId: i53, componentId: i53)
local entityIndex = world.entityIndex
local record = entityIndex.sparse[entityId]
local from = record.archetype
local to = archetypeTraverseAdd(world, componentId, from)
if from and not (from == world.ROOT_ARCHETYPE) then
moveEntity(entityIndex, entityId, record, to)
else
if #to.types > 0 then
newEntity(entityId, record, to)
end
end
return record :: Record
end
-- Symmetric like `World.add` but idempotent
function World.set(world: World, entityId: i53, componentId: i53, data: unknown)
local record = ensureRecord(world.entityIndex, entityId)
local record = world.entityIndex.sparse[entityId]
local from = record.archetype
local to = archetypeTraverseAdd(world, componentId, from)
@ -326,7 +491,6 @@ function World.set(world: World, entityId: i53, componentId: i53, data: unknown)
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
@ -334,14 +498,17 @@ function World.set(world: World, entityId: i53, componentId: i53, data: unknown)
to.columns[archetypeRecord][record.row] = data
end
local function archetypeTraverseRemove(world: World, componentId: i53, archetype: Archetype?): Archetype
local from = (archetype or world.ROOT_ARCHETYPE) :: Archetype
local function archetypeTraverseRemove(world: World, componentId: i53, from: Archetype): Archetype
local edge = ensureEdge(from, componentId)
local remove = edge.remove
if not remove then
local to = table.clone(from.types)
table.remove(to, table.find(to, componentId))
local at = table.find(to, componentId)
if not at then
return from
end
table.remove(to, at)
remove = ensureArchetype(world, to, from)
edge.remove = remove :: never
end
@ -351,7 +518,7 @@ end
function World.remove(world: World, entityId: i53, componentId: i53)
local entityIndex = world.entityIndex
local record = ensureRecord(entityIndex, entityId)
local record = entityIndex.sparse[entityId]
local sourceArchetype = record.archetype
local destinationArchetype = archetypeTraverseRemove(world, componentId, sourceArchetype)
@ -374,7 +541,7 @@ end
function World.get(world: World, entityId: i53, a: i53, b: i53?, c: i53?, d: i53?, e: i53?)
local id = entityId
local record = world.entityIndex[id]
local record = world.entityIndex.sparse[id]
if not record then
return nil
end
@ -456,7 +623,10 @@ function World.query(world: World, ...: i53): Query
end
length += 1
compatibleArchetypes[length] = {archetype, indices}
compatibleArchetypes[length] = {
archetype = archetype,
indices = indices
}
end
local lastArchetype, compatibleArchetype = next(compatibleArchetypes)
@ -470,7 +640,7 @@ function World.query(world: World, ...: i53): Query
function preparedQuery:without(...)
local withoutComponents = {...}
for i = #compatibleArchetypes, 1, -1 do
local archetype = compatibleArchetypes[i][1]
local archetype = compatibleArchetypes[i].archetype
local records = archetype.records
local shouldRemove = false
@ -499,21 +669,21 @@ function World.query(world: World, ...: i53): Query
function preparedQuery:__iter()
return function()
local archetype = compatibleArchetype[1]
local row = next(archetype.entities, lastRow)
local archetype = compatibleArchetype.archetype
local row: number = next(archetype.entities, lastRow) :: number
while row == nil do
lastArchetype, compatibleArchetype = next(compatibleArchetypes, lastArchetype)
if lastArchetype == nil then
return
end
archetype = compatibleArchetype[1]
row = next(archetype.entities, row)
archetype = compatibleArchetype.archetype
row = next(archetype.entities, row) :: number
end
lastRow = row
local entityId = archetype.entities[row :: number]
local columns = archetype.columns
local tr = compatibleArchetype[2]
local tr = compatibleArchetype.indices
if queryLength == 1 then
return entityId, columns[tr[1]][row]
@ -560,7 +730,7 @@ function World.query(world: World, ...: i53): Query
end
for i in components do
queryOutput[i] = tr[i][row]
queryOutput[i] = columns[tr[i]][row]
end
return entityId, unpack(queryOutput, 1, queryLength)
@ -570,90 +740,57 @@ function World.query(world: World, ...: i53): Query
return setmetatable({}, preparedQuery) :: any
end
function World.component(world: World)
local componentId = world.nextComponentId + 1
if componentId > HI_COMPONENT_ID then
-- 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.")
function World.__iter(world: World): () -> (number?, unknown?)
local dense = world.entityIndex.dense
local sparse = world.entityIndex.sparse
local last
return function()
local lastEntity, entityId = next(dense, last)
if not lastEntity then
return
end
last = lastEntity
local record = sparse[entityId]
local archetype = record.archetype
if not archetype then
-- Returns only the entity id as an entity without data should not return
-- data and allow the user to get an error if they don't handle the case.
return entityId
end
local row = record.row
local types = archetype.types
local columns = archetype.columns
local entityData = {}
for i, column in columns do
-- We use types because the key should be the component ID not the column index
entityData[types[i]] = column[row]
end
return entityId, entityData
end
world.nextComponentId = componentId
return componentId
end
function World.entity(world: World)
local nextEntityId = world.nextEntityId + 1
world.nextEntityId = nextEntityId
return 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 = {...}
local idsCount = #componentIds
local hooks = world.hooks
return {
event = function(event)
local hook = hooks[event]
hooks[event] = nil
local last, change
return function()
last, change = next(hook, last)
if not last then
return
end
local matched = false
local ids = change.ids
while not matched do
local skip = false
for _, id in ids do
if not table.find(componentIds, id) then
skip = true
break
end
end
if skip then
last, change = next(hook, last)
ids = change.ids
continue
end
matched = true
end
local queryOutput = table.create(idsCount)
local row = change.offset
local archetype = change.archetype
local columns = archetype.columns
local archetypeRecords = archetype.records
for index, id in componentIds do
queryOutput[index] = columns[archetypeRecords[id]][row]
end
return archetype.entities[row], unpack(queryOutput, 1, idsCount)
end
end;
}
end
return table.freeze({
World = World;
ON_ADD = ON_ADD;
ON_REMOVE = ON_REMOVE;
ON_SET = ON_SET;
OnAdd = ON_ADD;
OnRemove = ON_REMOVE;
OnSet = ON_SET;
Wildcard = WILDCARD,
w = WILDCARD,
Rest = REST,
ECS_ID = ECS_ID,
IS_PAIR = ECS_IS_PAIR,
ECS_PAIR = ECS_PAIR,
ECS_GENERATION_INC = ECS_GENERATION_INC,
ECS_GENERATION = ECS_GENERATION,
ecs_get_target = ecs_get_target,
ecs_get_source = ecs_get_source,
pair = ECS_PAIR,
getAlive = getAlive,
})

View file

@ -176,22 +176,6 @@ return function()
expect(added).to.equal(0)
end)
it("track changes", function()
local Position = world:entity()
local moving = world:entity()
world:set(moving, Position, Vector3.new(1, 2, 3))
local count = 0
for e, position in world:observer(Position).event(jecs.ON_ADD) do
count += 1
expect(e).to.equal(moving)
expect(position).to.equal(Vector3.new(1, 2, 3))
end
expect(count).to.equal(1)
end)
it("should query all matching entities", function()
local world = jecs.World.new()
@ -299,5 +283,100 @@ return function()
expect(world:get(id, Poison)).to.never.be.ok()
expect(world:get(id, Health)).to.never.be.ok()
end)
it("should allow iterating the whole world", function()
local world = jecs.World.new()
local A, B = world:entity(), world:entity()
local eA = world:entity()
world:set(eA, A, true)
local eB = world:entity()
world:set(eB, B, true)
local eAB = world:entity()
world:set(eAB, A, true)
world:set(eAB, B, true)
local count = 0
for id, data in world do
count += 1
if id == eA then
expect(data[A]).to.be.ok()
expect(data[B]).to.never.be.ok()
elseif id == eB then
expect(data[B]).to.be.ok()
expect(data[A]).to.never.be.ok()
elseif id == eAB then
expect(data[A]).to.be.ok()
expect(data[B]).to.be.ok()
end
end
expect(count).to.equal(5)
end)
it("should allow querying for relations", function()
local world = jecs.World.new()
local Eats = world:entity()
local Apples = world:entity()
local bob = world:entity()
world:set(bob, jecs.pair(Eats, Apples), true)
for e, bool in world:query(jecs.pair(Eats, Apples)) do
expect(e).to.equal(bob)
expect(bool).to.equal(bool)
end
end)
it("should allow wildcards in queries", function()
local world = jecs.World.new()
local Eats = world:entity()
local Apples = world:entity()
local bob = world:entity()
world:set(bob, jecs.pair(Eats, Apples), "bob eats apples")
for e, data in world:query(jecs.pair(Eats, jecs.w)) do
expect(e).to.equal(bob)
expect(data).to.equal("bob eats apples")
end
for e, data in world:query(jecs.pair(jecs.w, Apples)) do
expect(e).to.equal(bob)
expect(data).to.equal("bob eats apples")
end
end)
it("should match against multiple pairs", function()
local world = jecs.World.new()
local pair = jecs.pair
local Eats = world:entity()
local Apples = world:entity()
local Oranges =world:entity()
local bob = world:entity()
local alice = world:entity()
world:set(bob, pair(Eats, Apples), "bob eats apples")
world:set(alice, pair(Eats, Oranges), "alice eats oranges")
local w = jecs.Wildcard
local count = 0
for e, data in world:query(pair(Eats, w)) do
count += 1
if e == bob then
expect(data).to.equal("bob eats apples")
else
expect(data).to.equal("alice eats oranges")
end
end
expect(count).to.equal(2)
count = 0
for e, data in world:query(pair(w, Apples)) do
count += 1
expect(data).to.equal("bob eats apples")
end
expect(count).to.equal(1)
end)
end)
end

View file

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

View file

@ -6,10 +6,10 @@
type i53 = number
type i24 = number
type Ty = { i53 }
type Ty = {i53}
type ArchetypeId = number
type Column = { any }
type Column = {any}
type Archetype = {
id: number,
@ -20,9 +20,9 @@ type Archetype = {
},
},
types: Ty,
type: string | number,
entities: { number },
columns: { Column },
type: string | number,
entities: {number},
columns: {Column},
records: {},
}
@ -31,12 +31,12 @@ type Record = {
row: number,
}
type EntityIndex = { [i24]: Record }
type ComponentIndex = { [i24]: ArchetypeMap}
type EntityIndex = {[i24]: Record}
type ComponentIndex = {[i24]: ArchetypeMap}
type ArchetypeRecord = number
type ArchetypeMap = { sparse: { [ArchetypeId]: ArchetypeRecord } , size: number }
type Archetypes = { [ArchetypeId]: Archetype }
type ArchetypeMap = {sparse: {[ArchetypeId]: ArchetypeRecord}, size: number}
type Archetypes = {[ArchetypeId]: Archetype}
type ArchetypeDiff = {
added: Ty,
@ -82,24 +82,27 @@ local function transitionArchetype(
end
-- Move the entity from the source to the destination archetype.
destinationEntities[destinationRow] = sourceEntities[sourceRow]
entityIndex[sourceEntities[sourceRow]].row = destinationRow
local atSourceRow = sourceEntities[sourceRow]
destinationEntities[destinationRow] = atSourceRow
entityIndex[atSourceRow].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
if sourceRow ~= movedAway then
sourceEntities[sourceRow] = sourceEntities[movedAway]
entityIndex[sourceEntities[movedAway]].row = sourceRow
local atMovedAway = sourceEntities[movedAway]
sourceEntities[sourceRow] = atMovedAway
entityIndex[atMovedAway].row = sourceRow
end
sourceEntities[movedAway] = nil
end
local function archetypeAppend(entity: i53, archetype: Archetype): i24
local function archetypeAppend(entity: number, archetype: Archetype): number
local entities = archetype.entities
table.insert(entities, entity)
return #entities
local length = #entities + 1
entities[length] = entity
return length
end
local function newEntity(entityId: i53, record: Record, archetype: Archetype)
@ -122,47 +125,49 @@ local function hash(arr): string | number
return table.concat(arr, "_")
end
local function createArchetypeRecords(componentIndex: ComponentIndex, to: Archetype, from: Archetype?)
local destinationCount = #to.types
local function createArchetypeRecords(componentIndex: ComponentIndex, to: Archetype, _from: Archetype?)
local destinationIds = to.types
local records = to.records
local id = to.id
for i = 1, destinationCount do
local destinationId = destinationIds[i]
for i, destinationId in destinationIds do
local archetypesMap = componentIndex[destinationId]
if not componentIndex[destinationId] then
componentIndex[destinationId] = { size = 0, sparse = {} }
if not archetypesMap then
archetypesMap = {size = 0, sparse = {}}
componentIndex[destinationId] = archetypesMap
end
local archetypesMap = componentIndex[destinationId]
archetypesMap.sparse[to.id] = i
to.records[destinationId] = i
archetypesMap.sparse[id] = i
records[destinationId] = i
end
end
local function archetypeOf(world: World, types: { i24 }, prev: Archetype?): Archetype
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 id = world.nextArchetypeId + 1
world.nextArchetypeId = id
local columns = {} :: { any }
local length = #types
local columns = table.create(length) :: {any}
for _ in types do
table.insert(columns, {})
for index in types do
columns[index] = {}
end
local archetype = {
id = id,
types = types,
type = ty,
columns = columns,
entities = {},
edges = {},
records = {},
columns = columns;
edges = {};
entities = {};
id = id;
records = {};
type = ty;
types = types;
}
world.archetypeIndex[ty] = archetype
world.archetypes[id] = archetype
if #types > 0 then
if length > 0 then
createArchetypeRecords(world.componentIndex, archetype, prev)
end
@ -173,40 +178,40 @@ local World = {}
World.__index = World
function World.new()
local self = setmetatable({
entityIndex = {},
componentIndex = {},
archetypes = {},
archetypeIndex = {},
ROOT_ARCHETYPE = (nil :: any) :: Archetype,
nextEntityId = 0,
nextComponentId = 0,
nextArchetypeId = 0,
archetypeIndex = {};
archetypes = {};
componentIndex = {};
entityIndex = {};
hooks = {
[ON_ADD] = {}
}
[ON_ADD] = {};
};
nextArchetypeId = 0;
nextComponentId = 0;
nextEntityId = 0;
ROOT_ARCHETYPE = (nil :: any) :: Archetype;
}, World)
return self
return self
end
local function emit(world, eventDescription)
local event = eventDescription.event
table.insert(world.hooks[event], {
ids = eventDescription.ids,
archetype = eventDescription.archetype,
otherArchetype = eventDescription.otherArchetype,
offset = eventDescription.offset
archetype = eventDescription.archetype;
ids = eventDescription.ids;
offset = eventDescription.offset;
otherArchetype = eventDescription.otherArchetype;
})
end
local function onNotifyAdd(world, archetype, otherArchetype, row: number, added: Ty)
if #added > 0 then
emit(world, {
event = ON_ADD,
ids = added,
archetype = archetype,
otherArchetype = otherArchetype,
offset = row,
archetype = archetype;
event = ON_ADD;
ids = added;
offset = row;
otherArchetype = otherArchetype;
})
end
end
@ -227,10 +232,8 @@ local function ensureArchetype(world: World, types, prev)
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]
local function findInsert(types: {i53}, toAdd: i53)
for i, id in types do
if id == toAdd then
return -1
end
@ -238,7 +241,7 @@ local function findInsert(types: { i53 }, toAdd: i53)
return i
end
end
return count + 1
return #types + 1
end
local function findArchetypeWith(world: World, node: Archetype, componentId: i53)
@ -259,38 +262,47 @@ local function findArchetypeWith(world: World, node: Archetype, componentId: i53
end
local function ensureEdge(archetype: Archetype, componentId: i53)
if not archetype.edges[componentId] then
archetype.edges[componentId] = {} :: any
local edges = archetype.edges
local edge = edges[componentId]
if not edge then
edge = {} :: any
edges[componentId] = edge
end
return archetype.edges[componentId]
return edge
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
end
from = world.ROOT_ARCHETYPE
local ROOT_ARCHETYPE = world.ROOT_ARCHETYPE
if not ROOT_ARCHETYPE then
ROOT_ARCHETYPE = archetypeOf(world, {}, nil)
world.ROOT_ARCHETYPE = ROOT_ARCHETYPE :: never
end
from = ROOT_ARCHETYPE
end
local edge = ensureEdge(from, componentId)
if not edge.add then
local edge = ensureEdge(from, componentId)
local add = edge.add
if not 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)
add = findArchetypeWith(world, from, componentId)
edge.add = add :: never
end
return edge.add
return add
end
local function ensureRecord(entityIndex, entityId: i53): Record
local id = entityId
if not entityIndex[id] then
entityIndex[id] = {}
local record = entityIndex[entityId]
if not record then
record = {}
entityIndex[entityId] = record
end
return entityIndex[id] :: Record
return record :: Record
end
function World.set(world: World, entityId: i53, componentId: i53, data: unknown)
@ -314,7 +326,7 @@ function World.set(world: World, entityId: i53, componentId: i53, data: unknown)
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 })
onNotifyAdd(world, to, from, record.row, {componentId})
end
end
@ -326,28 +338,30 @@ local function archetypeTraverseRemove(world: World, componentId: i53, archetype
local from = (archetype or world.ROOT_ARCHETYPE) :: Archetype
local edge = ensureEdge(from, componentId)
if not edge.remove then
local remove = edge.remove
if not remove then
local to = table.clone(from.types)
table.remove(to, table.find(to, componentId))
edge.remove = ensureArchetype(world, to, from)
remove = ensureArchetype(world, to, from)
edge.remove = remove :: never
end
return edge.remove
return remove
end
function World.remove(world: World, entityId: i53, componentId: i53)
local record = ensureRecord(world.entityIndex, entityId)
local entityIndex = world.entityIndex
local record = ensureRecord(entityIndex, entityId)
local sourceArchetype = record.archetype
local destinationArchetype = archetypeTraverseRemove(world, componentId, sourceArchetype)
if sourceArchetype and not (sourceArchetype == destinationArchetype) then
moveEntity(world.entityIndex, entityId, record, destinationArchetype)
moveEntity(entityIndex, entityId, record, destinationArchetype)
end
end
-- Keeping the function as small as possible to enable inlining
local function get(componentIndex: { [i24]: ArchetypeMap }, record: Record, componentId: i24)
local function get(record: Record, componentId: i24)
local archetype = record.archetype
local archetypeRecord = archetype.records[componentId]
@ -360,35 +374,35 @@ end
function World.get(world: World, entityId: i53, a: i53, b: i53?, c: i53?, d: i53?, e: i53?)
local id = entityId
local componentIndex = world.componentIndex
local record = world.entityIndex[id]
if not record then
return nil
end
local va = get(componentIndex, record, a)
local va = get(record, a)
if b == nil then
return va
elseif c == nil then
return va, get(componentIndex, record, b)
return va, get(record, b)
elseif d == nil then
return va, get(componentIndex, record, b), get(componentIndex, record, c)
return va, get(record, b), get(record, c)
elseif e == nil then
return va, get(componentIndex, record, b), get(componentIndex, record, c), get(componentIndex, record, d)
return va, get(record, b), get(record, c), get(record, d)
else
error("args exceeded")
end
end
local function noop(self: Query, ...: i53): () -> (number, ...any)
return function()
end :: any
-- the less creation the better
local function actualNoOperation() end
local function noop(_self: Query, ...: i53): () -> (number, ...any)
return actualNoOperation :: any
end
local EmptyQuery = {
__iter = noop,
without = noop
__iter = noop;
without = noop;
}
EmptyQuery.__index = EmptyQuery
setmetatable(EmptyQuery, EmptyQuery)
@ -396,19 +410,22 @@ setmetatable(EmptyQuery, EmptyQuery)
export type Query = typeof(EmptyQuery)
function World.query(world: World, ...: i53): Query
local compatibleArchetypes = {}
local components = { ... }
local archetypes = world.archetypes
local queryLength = #components
if queryLength == 0 then
-- breaking?
if (...) == nil then
error("Missing components")
end
local compatibleArchetypes = {}
local length = 0
local components = {...}
local archetypes = world.archetypes
local queryLength = #components
local firstArchetypeMap
local componentIndex = world.componentIndex
for i, componentId in components do
for _, componentId in components do
local map = componentIndex[componentId]
if not map then
return EmptyQuery
@ -422,7 +439,7 @@ function World.query(world: World, ...: i53): Query
for id in firstArchetypeMap.sparse do
local archetype = archetypes[id]
local archetypeRecords = archetype.records
local indices = {}
local indices = {}
local skip = false
for i, componentId in components do
@ -431,13 +448,15 @@ function World.query(world: World, ...: i53): Query
skip = true
break
end
indices[i] = archetypeRecords[componentId]
indices[i] = index
end
if skip then
continue
end
table.insert(compatibleArchetypes, { archetype, indices })
length += 1
compatibleArchetypes[length] = {archetype, indices}
end
local lastArchetype, compatibleArchetype = next(compatibleArchetypes)
@ -449,16 +468,19 @@ function World.query(world: World, ...: i53): Query
preparedQuery.__index = preparedQuery
function preparedQuery:without(...)
local components = { ... }
local withoutComponents = {...}
for i = #compatibleArchetypes, 1, -1 do
local archetype = compatibleArchetypes[i][1]
local records = archetype.records
local shouldRemove = false
for _, componentId in components do
if archetype.records[componentId] then
for _, componentId in withoutComponents do
if records[componentId] then
shouldRemove = true
break
end
end
if shouldRemove then
table.remove(compatibleArchetypes, i)
end
@ -475,7 +497,6 @@ function World.query(world: World, ...: i53): Query
local lastRow
local queryOutput = {}
function preparedQuery:__iter()
return function()
local archetype = compatibleArchetype[1]
@ -499,16 +520,9 @@ function World.query(world: World, ...: i53): Query
elseif queryLength == 2 then
return entityId, columns[tr[1]][row], columns[tr[2]][row]
elseif queryLength == 3 then
return entityId,
columns[tr[1]][row],
columns[tr[2]][row],
columns[tr[3]][row]
return entityId, columns[tr[1]][row], columns[tr[2]][row], columns[tr[3]][row]
elseif queryLength == 4 then
return entityId,
columns[tr[1]][row],
columns[tr[2]][row],
columns[tr[3]][row],
columns[tr[4]][row]
return entityId, columns[tr[1]][row], columns[tr[2]][row], columns[tr[3]][row], columns[tr[4]][row]
elseif queryLength == 5 then
return entityId,
columns[tr[1]][row],
@ -546,7 +560,7 @@ function World.query(world: World, ...: i53): Query
end
for i in components do
queryOutput[i] = tr[i][row]
queryOutput[i] = columns[tr[i]][row]
end
return entityId, unpack(queryOutput, 1, queryLength)
@ -568,8 +582,9 @@ function World.component(world: World)
end
function World.entity(world: World)
world.nextEntityId += 1
return world.nextEntityId + REST
local nextEntityId = world.nextEntityId + 1
world.nextEntityId = nextEntityId
return nextEntityId + REST
end
function World.delete(world: World, entityId: i53)
@ -584,12 +599,14 @@ function World.delete(world: World, entityId: i53)
end
function World.observer(world: World, ...)
local componentIds = { ... }
local componentIds = {...}
local idsCount = #componentIds
local hooks = world.hooks
return {
event = function(event)
local hook = world.hooks[event]
world.hooks[event] = nil
local hook = hooks[event]
hooks[event] = nil
local last, change
return function()
@ -599,10 +616,11 @@ function World.observer(world: World, ...)
end
local matched = false
local ids = change.ids
while not matched do
local skip = false
for _, id in change.ids do
for _, id in ids do
if not table.find(componentIds, id) then
skip = true
break
@ -611,30 +629,31 @@ function World.observer(world: World, ...)
if skip then
last, change = next(hook, last)
ids = change.ids
continue
end
matched = true
end
local queryOutput = {}
local queryOutput = table.create(idsCount)
local row = change.offset
local archetype = change.archetype
local columns = archetype.columns
local archetypeRecords = archetype.records
for _, id in componentIds do
table.insert(queryOutput, columns[archetypeRecords[id]][row])
for index, id in componentIds do
queryOutput[index] = columns[archetypeRecords[id]][row]
end
return archetype.entities[row], unpack(queryOutput, 1, #queryOutput)
return archetype.entities[row], unpack(queryOutput, 1, idsCount)
end
end
end;
}
end
return table.freeze({
World = World,
ON_ADD = ON_ADD,
ON_REMOVE = ON_REMOVE,
ON_SET = ON_SET
World = World;
ON_ADD = ON_ADD;
ON_REMOVE = ON_REMOVE;
ON_SET = ON_SET;
})

View file

@ -11,9 +11,6 @@
},
"ReplicatedStorage": {
"$className": "ReplicatedStorage",
"DevPackages": {
"$path": "DevPackages"
},
"Lib": {
"$path": "lib"
},
@ -25,6 +22,9 @@
},
"mirror": {
"$path": "mirror"
},
"DevPackages": {
"$path": "DevPackages"
}
},
"TestService": {

View file

@ -1,115 +0,0 @@
local testkit = require("../testkit")
local jecs = require("../lib/init")
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()
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
table.remove(entities, CHECK(table.find(entities, id)))
end
CHECK(#entities == 0)
end
do CASE "should query all matching entities when irrelevant component is removed"
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
table.remove(entities, CHECK(table.find(entities, id)))
end
CHECK(added == N)
end
do CASE "should query all entities without B"
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
table.remove(entities, CHECK(table.find(entities, id)))
end
CHECK(#entities == 0)
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()

261
tests/world.lua Normal file
View file

@ -0,0 +1,261 @@
local testkit = require("../testkit")
local jecs = require("../lib/init")
local ECS_ID, ECS_GENERATION = jecs.ECS_ID, jecs.ECS_GENERATION
local ECS_GENERATION_INC = jecs.ECS_GENERATION_INC
local IS_PAIR = jecs.IS_PAIR
local ECS_PAIR = jecs.ECS_PAIR
local getAlive = jecs.getAlive
local ecs_get_source = jecs.ecs_get_source
local ecs_get_target = jecs.ecs_get_target
local TEST, CASE, CHECK, FINISH, SKIP = testkit.test()
local N = 10
TEST("world", function()
do CASE "should be iterable"
local world = jecs.World.new()
local A = world:component()
local B = world:component()
local eA = world:entity()
world:set(eA, A, true)
local eB = world:entity()
world:set(eB, B, true)
local eAB = world:entity()
world:set(eAB, A, true)
world:set(eAB, B, true)
local count = 0
for id, data in world do
count += 1
if id == eA then
CHECK(data[A] == true)
CHECK(data[B] == nil)
elseif id == eB then
CHECK(data[A] == nil)
CHECK(data[B] == true)
elseif id == eAB then
CHECK(data[A] == true)
CHECK(data[B] == true)
end
end
-- components are registered in the entity index as well
-- so this test has to add 2 to account for them
CHECK(count == 3 + 2)
end
do CASE "should query all matching entities"
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
table.remove(entities, CHECK(table.find(entities, id)))
end
CHECK(#entities == 0)
end
do CASE "should query all matching entities when irrelevant component is removed"
local world = jecs.World.new()
local A = world:component()
local B = world:component()
local C = world:component()
local entities = {}
for i = 1, N do
local id = world:entity()
-- specifically put them in disorder to track regression
-- https://github.com/Ukendio/jecs/pull/15
world:set(id, B, true)
world:set(id, A, true)
if i > 5 then world:remove(id, B) end
entities[i] = id
end
local added = 0
for id in world:query(A) do
added += 1
table.remove(entities, CHECK(table.find(entities, id)))
end
CHECK(added == N)
end
do CASE "should query all entities without B"
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
table.remove(entities, CHECK(table.find(entities, id)))
end
CHECK(#entities == 0)
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)
local id1 = world:entity()
world:set(id1, Poison, 500)
world:set(id1, Health, 50)
world:delete(id)
CHECK(world:get(id, Poison) == nil)
CHECK(world:get(id, Health) == nil)
CHECK(world:get(id1, Poison) == 500)
CHECK(world:get(id1, Health) == 50)
end
do CASE "should allow remove that doesn't exist on entity"
local world = jecs.World.new()
local Health = world:entity()
local Poison = world:component()
local id = world:entity()
world:set(id, Health, 50)
world:remove(id, Poison)
CHECK(world:get(id, Poison) == nil)
CHECK(world:get(id, Health) == 50)
end
do CASE "should increment generation"
local world = jecs.World.new()
local e = world:entity()
CHECK(ECS_ID(e) == 1 + jecs.Rest)
CHECK(getAlive(world.entityIndex, ECS_ID(e)) == e)
CHECK(ECS_GENERATION(e) == 0) -- 0
e = ECS_GENERATION_INC(e)
CHECK(ECS_GENERATION(e) == 1) -- 1
end
do CASE "should get alive from index in the dense array"
local world = jecs.World.new()
local _e = world:entity()
local e2 = world:entity()
local e3 = world:entity()
CHECK(IS_PAIR(world:entity()) == false)
local pair = ECS_PAIR(e2, e3)
CHECK(IS_PAIR(pair) == true)
CHECK(ecs_get_source(world.entityIndex, pair) == e2)
CHECK(ecs_get_target(world.entityIndex, pair) == e3)
end
do CASE "should allow querying for relations"
local world = jecs.World.new()
local Eats = world:entity()
local Apples = world:entity()
local bob = world:entity()
world:set(bob, ECS_PAIR(Eats, Apples), true)
for e, bool in world:query(ECS_PAIR(Eats, Apples)) do
CHECK(e == bob)
CHECK(bool)
end
end
do CASE "should allow wildcards in queries"
local world = jecs.World.new()
local Eats = world:entity()
local Apples = world:entity()
local bob = world:entity()
world:set(bob, ECS_PAIR(Eats, Apples), "bob eats apples")
local w = jecs.Wildcard
for e, data in world:query(ECS_PAIR(Eats, w)) do
CHECK(e == bob)
CHECK(data == "bob eats apples")
end
for e, data in world:query(ECS_PAIR(w, Apples)) do
CHECK(e == bob)
CHECK(data == "bob eats apples")
end
end
do CASE "should match against multiple pairs"
local world = jecs.World.new()
local Eats = world:entity()
local Apples = world:entity()
local Oranges =world:entity()
local bob = world:entity()
local alice = world:entity()
world:set(bob, ECS_PAIR(Eats, Apples), "bob eats apples")
world:set(alice, ECS_PAIR(Eats, Oranges), "alice eats oranges")
local w = jecs.Wildcard
local count = 0
for e, data in world:query(ECS_PAIR(Eats, w)) do
count += 1
if e == bob then
CHECK(data == "bob eats apples")
else
CHECK(data == "alice eats oranges")
end
end
CHECK(count == 2)
count = 0
for e, data in world:query(ECS_PAIR(w, Apples)) do
count += 1
CHECK(data == "bob eats apples")
end
CHECK(count == 1)
end
end)
FINISH()

View file

@ -1,12 +1,10 @@
[package]
name = "ukendio/jecs"
version = "0.0.0-prototype.rc.3"
version = "0.1.0-rc.6"
registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
include = ["default.project.json", "lib/**", "lib", "wally.toml", "README.md"]
exclude = ["**"]
include = ["default.project.json", "lib", "wally.toml", "README.md"]
[dev-dependencies]
TestEZ = "roblox/testez@0.4.1"
Matter = "matter-ecs/matter@0.8.0"
ecr = "centau/ecr@0.8.0"