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 roblox.toml
sourcemap.json sourcemap.json
drafts/*.lua drafts/*.lua
*.code-workspace
roblox.yml

View file

@ -1,6 +1,7 @@
<p align="center"> <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> </p>
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache-blue.svg?style=for-the-badge)](LICENSE-APACHE) [![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). 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 - 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 - Optimized for column-major operations
- Cache friendly archetype/SoA storage - Cache friendly archetype/SoA storage
- Unit tested for stability
### Example ### Example
```lua ```lua
local world = Jecs.World.new() local world = World.new()
local Health = world:component()
local Damage = world:component()
local Position = world:component()
local player = world:entity() local player = world:entity()
local opponent = 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, Health, 100)
world:set(player, Damage, 8) world:set(player, Damage, 8)
world:set(player, Position, Vector3.new(0, 5, 0)) 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 for playerId, playerPosition, health in world:query(Position, Health) do
local totalDamage = 0 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 if (playerPosition - opponentPosition).Magnitude < 5 then
totalDamage += damage totalDamage += damage
end 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 end
world:set(playerId, Health, health - totalDamage)
end end
assert(world:get(playerId, Health) == 79) -- Gets the damage inflicted by our specific opponent!
assert(world:get(opponentId, Health) == 92) 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. 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)) print(testkit.color.white(title))
end end
local jecs = require("../mirror/init") local jecs = require("../lib/init")
local mirror = require("../mirror/init")
local oldMatter = require("../oldMatter") local oldMatter = require("../oldMatter")
@ -41,6 +42,145 @@ do
for _ in world:query(A, B, C, D, E, F, G, H) do for _ in world:query(A, B, C, D, E, F, G, H) do
end end
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 end
local D1 = ecs:component() local D1 = ecs:component()

View file

@ -8,6 +8,8 @@ local jecs = require(ReplicatedStorage.Lib)
local ecr = require(ReplicatedStorage.DevPackages.ecr) local ecr = require(ReplicatedStorage.DevPackages.ecr)
local newWorld = Matter.World.new() local newWorld = Matter.World.new()
local ecs = jecs.World.new() local ecs = jecs.World.new()
local mirror = require(ReplicatedStorage.mirror)
local mcs = mirror.World.new()
local A1 = Matter.component() local A1 = Matter.component()
local A2 = Matter.component() local A2 = Matter.component()
@ -35,6 +37,15 @@ local C5 = ecs:entity()
local C6 = ecs:entity() local C6 = ecs:entity()
local C7 = ecs:entity() local C7 = ecs:entity()
local C8 = 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() local registry2 = ecr.registry()
return { return {
@ -44,7 +55,7 @@ return {
Functions = { Functions = {
Matter = function() Matter = function()
for i = 1, 50 do for i = 1, 500 do
newWorld:spawn( newWorld:spawn(
A1({ value = true }), A1({ value = true }),
A2({ value = true }), A2({ value = true }),
@ -60,8 +71,8 @@ return {
ECR = function() 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, B1, {value = false})
registry2:set(e, B2, {value = false}) registry2:set(e, B2, {value = false})
registry2:set(e, B3, {value = false}) registry2:set(e, B3, {value = false})
@ -78,7 +89,7 @@ return {
local e = ecs:entity() local e = ecs:entity()
for i = 1, 50 do for i = 1, 500 do
ecs:set(e, C1, {value = false}) ecs:set(e, C1, {value = false})
ecs:set(e, C2, {value = false}) ecs:set(e, C2, {value = false})
@ -89,6 +100,23 @@ return {
ecs:set(e, C7, {value = false}) ecs:set(e, C7, {value = false})
ecs:set(e, C8, {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
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 = { type Record = {
archetype: Archetype, archetype: Archetype,
row: number, row: number,
dense: i24,
} }
type EntityIndex = {[i24]: Record} type EntityIndex = {dense: {[i24]: i53}, sparse: {[i53]: Record}}
type ComponentIndex = {[i24]: ArchetypeMap} type ComponentIndex = {[i24]: ArchetypeMap}
type ArchetypeRecord = number type ArchetypeRecord = number
@ -43,11 +44,118 @@ type ArchetypeDiff = {
removed: Ty, removed: Ty,
} }
local FLAGS_PAIR = 0x8
local HI_COMPONENT_ID = 256 local HI_COMPONENT_ID = 256
local ON_ADD = HI_COMPONENT_ID + 1 local ON_ADD = HI_COMPONENT_ID + 1
local ON_REMOVE = HI_COMPONENT_ID + 2 local ON_REMOVE = HI_COMPONENT_ID + 2
local ON_SET = HI_COMPONENT_ID + 3 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( local function transitionArchetype(
entityIndex: EntityIndex, entityIndex: EntityIndex,
@ -81,21 +189,27 @@ local function transitionArchetype(
column[last] = nil column[last] = nil
end end
-- Move the entity from the source to the destination archetype. local sparse = entityIndex.sparse
local atSourceRow = sourceEntities[sourceRow] local movedAway = #sourceEntities
destinationEntities[destinationRow] = atSourceRow
entityIndex[atSourceRow].row = destinationRow
-- Move the entity from the source to the destination archetype.
-- Because we have swapped columns we now have to update the records -- Because we have swapped columns we now have to update the records
-- corresponding to the entities' rows that were swapped. -- corresponding to the entities' rows that were swapped.
local movedAway = #sourceEntities local e1 = sourceEntities[sourceRow]
if sourceRow ~= movedAway then local e2 = sourceEntities[movedAway]
local atMovedAway = sourceEntities[movedAway]
sourceEntities[sourceRow] = atMovedAway if sourceRow ~= movedAway then
entityIndex[atMovedAway].row = sourceRow sourceEntities[sourceRow] = e2
end end
sourceEntities[movedAway] = nil sourceEntities[movedAway] = nil
destinationEntities[destinationRow] = e1
local record1 = sparse[e1]
local record2 = sparse[e2]
record1.row = destinationRow
record2.row = sourceRow
end end
local function archetypeAppend(entity: number, archetype: Archetype): number local function archetypeAppend(entity: number, archetype: Archetype): number
@ -125,22 +239,14 @@ local function hash(arr): string | number
return table.concat(arr, "_") return table.concat(arr, "_")
end end
local function createArchetypeRecords(componentIndex: ComponentIndex, to: Archetype, _from: Archetype?) local function createArchetypeRecord(componentIndex, id, componentId, i)
local destinationIds = to.types local archetypesMap = componentIndex[componentId]
local records = to.records
local id = to.id
for i, destinationId in destinationIds do if not archetypesMap then
local archetypesMap = componentIndex[destinationId] archetypesMap = {size = 0, sparse = {}}
componentIndex[componentId] = archetypesMap
if not archetypesMap then
archetypesMap = {size = 0, sparse = {}}
componentIndex[destinationId] = archetypesMap
end
archetypesMap.sparse[id] = i
records[destinationId] = i
end end
archetypesMap.sparse[id] = i
end end
local function archetypeOf(world: World, types: {i24}, prev: Archetype?): Archetype 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 world.nextArchetypeId = id
local length = #types local length = #types
local columns = table.create(length) :: {any} local columns = table.create(length)
for index in types do local records = {}
columns[index] = {} 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 end
local archetype = { local archetype = {
@ -161,15 +283,12 @@ local function archetypeOf(world: World, types: {i24}, prev: Archetype?): Archet
edges = {}; edges = {};
entities = {}; entities = {};
id = id; id = id;
records = {}; records = records;
type = ty; type = ty;
types = types; types = types;
} }
world.archetypeIndex[ty] = archetype world.archetypeIndex[ty] = archetype
world.archetypes[id] = archetype world.archetypes[id] = archetype
if length > 0 then
createArchetypeRecords(world.componentIndex, archetype, prev)
end
return archetype return archetype
end end
@ -179,9 +298,12 @@ World.__index = World
function World.new() function World.new()
local self = setmetatable({ local self = setmetatable({
archetypeIndex = {}; archetypeIndex = {};
archetypes = {}; archetypes = {} :: Archetypes;
componentIndex = {}; componentIndex = {} :: ComponentIndex;
entityIndex = {}; entityIndex = {
dense = {},
sparse = {}
} :: EntityIndex;
hooks = { hooks = {
[ON_ADD] = {}; [ON_ADD] = {};
}; };
@ -190,32 +312,78 @@ function World.new()
nextEntityId = 0; nextEntityId = 0;
ROOT_ARCHETYPE = (nil :: any) :: Archetype; ROOT_ARCHETYPE = (nil :: any) :: Archetype;
}, World) }, World)
self.ROOT_ARCHETYPE = archetypeOf(self, {})
return self return self
end end
local function emit(world, eventDescription) function World.component(world: World)
local event = eventDescription.event local componentId = world.nextComponentId + 1
if componentId > HI_COMPONENT_ID then
table.insert(world.hooks[event], { -- IDs are partitioned into ranges because component IDs are not nominal,
archetype = eventDescription.archetype; -- so it needs to error when IDs intersect into the entity range.
ids = eventDescription.ids; error("Too many components, consider using world:entity() instead to create components.")
offset = eventDescription.offset; end
otherArchetype = eventDescription.otherArchetype; world.nextComponentId = componentId
}) return nextEntityId(world.entityIndex, componentId)
end end
local function onNotifyAdd(world, archetype, otherArchetype, row: number, added: Ty) function World.entity(world: World)
if #added > 0 then local entityId = world.nextEntityId + 1
emit(world, { world.nextEntityId = entityId
archetype = archetype; return nextEntityId(world.entityIndex, entityId + REST)
event = ON_ADD; end
ids = added;
offset = row; -- should reuse this logic in World.set instead of swap removing in transition archetype
otherArchetype = otherArchetype; 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
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()) export type World = typeof(World.new())
local function ensureArchetype(world: World, types, prev) 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 -- Component IDs are added incrementally, so inserting and sorting
-- them each time would be expensive. Instead this insertion sort can find the insertion -- them each time would be expensive. Instead this insertion sort can find the insertion
-- point in the types array. -- point in the types array.
local destinationType = table.clone(node.types)
local at = findInsert(types, componentId) local at = findInsert(types, componentId)
if at == -1 then if at == -1 then
-- If it finds a duplicate, it just means it is the same archetype so it can return it -- 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. -- directly instead of needing to hash types for a lookup to the archetype.
return node return node
end end
local destinationType = table.clone(node.types)
table.insert(destinationType, at, componentId) table.insert(destinationType, at, componentId)
return ensureArchetype(world, destinationType, node) return ensureArchetype(world, destinationType, node)
end end
@ -272,15 +441,7 @@ local function ensureEdge(archetype: Archetype, componentId: i53)
end end
local function archetypeTraverseAdd(world: World, componentId: i53, from: Archetype): Archetype local function archetypeTraverseAdd(world: World, componentId: i53, from: Archetype): Archetype
if not from then from = from or world.ROOT_ARCHETYPE
-- 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
local edge = ensureEdge(from, componentId) local edge = ensureEdge(from, componentId)
local add = edge.add local add = edge.add
@ -294,19 +455,23 @@ local function archetypeTraverseAdd(world: World, componentId: i53, from: Archet
return add return add
end end
local function ensureRecord(entityIndex, entityId: i53): Record function World.add(world: World, entityId: i53, componentId: i53)
local record = entityIndex[entityId] local entityIndex = world.entityIndex
local record = entityIndex.sparse[entityId]
if not record then local from = record.archetype
record = {} local to = archetypeTraverseAdd(world, componentId, from)
entityIndex[entityId] = record 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 end
return record :: Record
end end
-- Symmetric like `World.add` but idempotent
function World.set(world: World, entityId: i53, componentId: i53, data: unknown) 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 from = record.archetype
local to = archetypeTraverseAdd(world, componentId, from) 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 if #to.types > 0 then
-- When there is no previous archetype it should create the archetype -- When there is no previous archetype it should create the archetype
newEntity(entityId, record, to) newEntity(entityId, record, to)
onNotifyAdd(world, to, from, record.row, {componentId})
end end
end end
@ -334,14 +498,17 @@ function World.set(world: World, entityId: i53, componentId: i53, data: unknown)
to.columns[archetypeRecord][record.row] = data to.columns[archetypeRecord][record.row] = data
end end
local function archetypeTraverseRemove(world: World, componentId: i53, archetype: Archetype?): Archetype local function archetypeTraverseRemove(world: World, componentId: i53, from: Archetype): Archetype
local from = (archetype or world.ROOT_ARCHETYPE) :: Archetype
local edge = ensureEdge(from, componentId) local edge = ensureEdge(from, componentId)
local remove = edge.remove local remove = edge.remove
if not remove then if not remove then
local to = table.clone(from.types) 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) remove = ensureArchetype(world, to, from)
edge.remove = remove :: never edge.remove = remove :: never
end end
@ -351,7 +518,7 @@ end
function World.remove(world: World, entityId: i53, componentId: i53) function World.remove(world: World, entityId: i53, componentId: i53)
local entityIndex = world.entityIndex local entityIndex = world.entityIndex
local record = ensureRecord(entityIndex, entityId) local record = entityIndex.sparse[entityId]
local sourceArchetype = record.archetype local sourceArchetype = record.archetype
local destinationArchetype = archetypeTraverseRemove(world, componentId, sourceArchetype) 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?) function World.get(world: World, entityId: i53, a: i53, b: i53?, c: i53?, d: i53?, e: i53?)
local id = entityId local id = entityId
local record = world.entityIndex[id] local record = world.entityIndex.sparse[id]
if not record then if not record then
return nil return nil
end end
@ -456,7 +623,10 @@ function World.query(world: World, ...: i53): Query
end end
length += 1 length += 1
compatibleArchetypes[length] = {archetype, indices} compatibleArchetypes[length] = {
archetype = archetype,
indices = indices
}
end end
local lastArchetype, compatibleArchetype = next(compatibleArchetypes) local lastArchetype, compatibleArchetype = next(compatibleArchetypes)
@ -470,7 +640,7 @@ function World.query(world: World, ...: i53): Query
function preparedQuery:without(...) function preparedQuery:without(...)
local withoutComponents = {...} local withoutComponents = {...}
for i = #compatibleArchetypes, 1, -1 do for i = #compatibleArchetypes, 1, -1 do
local archetype = compatibleArchetypes[i][1] local archetype = compatibleArchetypes[i].archetype
local records = archetype.records local records = archetype.records
local shouldRemove = false local shouldRemove = false
@ -499,21 +669,21 @@ function World.query(world: World, ...: i53): Query
function preparedQuery:__iter() function preparedQuery:__iter()
return function() return function()
local archetype = compatibleArchetype[1] local archetype = compatibleArchetype.archetype
local row = next(archetype.entities, lastRow) local row: number = next(archetype.entities, lastRow) :: number
while row == nil do while row == nil do
lastArchetype, compatibleArchetype = next(compatibleArchetypes, lastArchetype) lastArchetype, compatibleArchetype = next(compatibleArchetypes, lastArchetype)
if lastArchetype == nil then if lastArchetype == nil then
return return
end end
archetype = compatibleArchetype[1] archetype = compatibleArchetype.archetype
row = next(archetype.entities, row) row = next(archetype.entities, row) :: number
end end
lastRow = row lastRow = row
local entityId = archetype.entities[row :: number] local entityId = archetype.entities[row :: number]
local columns = archetype.columns local columns = archetype.columns
local tr = compatibleArchetype[2] local tr = compatibleArchetype.indices
if queryLength == 1 then if queryLength == 1 then
return entityId, columns[tr[1]][row] return entityId, columns[tr[1]][row]
@ -560,7 +730,7 @@ function World.query(world: World, ...: i53): Query
end end
for i in components do for i in components do
queryOutput[i] = tr[i][row] queryOutput[i] = columns[tr[i]][row]
end end
return entityId, unpack(queryOutput, 1, queryLength) return entityId, unpack(queryOutput, 1, queryLength)
@ -570,90 +740,57 @@ function World.query(world: World, ...: i53): Query
return setmetatable({}, preparedQuery) :: any return setmetatable({}, preparedQuery) :: any
end end
function World.component(world: World) function World.__iter(world: World): () -> (number?, unknown?)
local componentId = world.nextComponentId + 1 local dense = world.entityIndex.dense
if componentId > HI_COMPONENT_ID then local sparse = world.entityIndex.sparse
-- IDs are partitioned into ranges because component IDs are not nominal, local last
-- so it needs to error when IDs intersect into the entity range.
error("Too many components, consider using world:entity() instead to create components.") 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 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 end
return table.freeze({ return table.freeze({
World = World; World = World;
ON_ADD = ON_ADD;
ON_REMOVE = ON_REMOVE; OnAdd = ON_ADD;
ON_SET = ON_SET; 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) expect(added).to.equal(0)
end) 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() it("should query all matching entities", function()
local world = jecs.World.new() local world = jecs.World.new()
@ -299,5 +283,100 @@ return function()
expect(world:get(id, Poison)).to.never.be.ok() expect(world:get(id, Poison)).to.never.be.ok()
expect(world:get(id, Health)).to.never.be.ok() expect(world:get(id, Health)).to.never.be.ok()
end) 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)
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 i53 = number
type i24 = number type i24 = number
type Ty = { i53 } type Ty = {i53}
type ArchetypeId = number type ArchetypeId = number
type Column = { any } type Column = {any}
type Archetype = { type Archetype = {
id: number, id: number,
@ -20,9 +20,9 @@ type Archetype = {
}, },
}, },
types: Ty, types: Ty,
type: string | number, type: string | number,
entities: { number }, entities: {number},
columns: { Column }, columns: {Column},
records: {}, records: {},
} }
@ -31,13 +31,13 @@ type Record = {
row: number, row: number,
} }
type EntityIndex = { [i24]: Record } type EntityIndex = {[i24]: Record}
type ComponentIndex = { [i24]: ArchetypeMap} type ComponentIndex = {[i24]: ArchetypeMap}
type ArchetypeRecord = number type ArchetypeRecord = number
type ArchetypeMap = { sparse: { [ArchetypeId]: ArchetypeRecord } , size: number } type ArchetypeMap = {sparse: {[ArchetypeId]: ArchetypeRecord}, size: number}
type Archetypes = { [ArchetypeId]: Archetype } type Archetypes = {[ArchetypeId]: Archetype}
type ArchetypeDiff = { type ArchetypeDiff = {
added: Ty, added: Ty,
removed: Ty, removed: Ty,
@ -64,17 +64,17 @@ local function transitionArchetype(
local types = from.types local types = from.types
for i, column in columns do for i, column in columns do
-- Retrieves the new column index from the source archetype's record from each component -- 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. -- 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]]] local targetColumn = destinationColumns[tr[types[i]]]
-- Sometimes target column may not exist, e.g. when you remove a component. -- Sometimes target column may not exist, e.g. when you remove a component.
if targetColumn then if targetColumn then
targetColumn[destinationRow] = column[sourceRow] targetColumn[destinationRow] = column[sourceRow]
end end
-- If the entity is the last row in the archetype then swapping it would be meaningless. -- If the entity is the last row in the archetype then swapping it would be meaningless.
local last = #column local last = #column
if sourceRow ~= last then if sourceRow ~= last then
-- Swap rempves columns to ensure there are no holes in the archetype. -- Swap rempves columns to ensure there are no holes in the archetype.
column[sourceRow] = column[last] column[sourceRow] = column[last]
end end
@ -82,24 +82,27 @@ local function transitionArchetype(
end end
-- Move the entity from the source to the destination archetype. -- Move the entity from the source to the destination archetype.
destinationEntities[destinationRow] = sourceEntities[sourceRow] local atSourceRow = sourceEntities[sourceRow]
entityIndex[sourceEntities[sourceRow]].row = destinationRow destinationEntities[destinationRow] = atSourceRow
entityIndex[atSourceRow].row = destinationRow
-- Because we have swapped columns we now have to update the records -- Because we have swapped columns we now have to update the records
-- corresponding to the entities' rows that were swapped. -- corresponding to the entities' rows that were swapped.
local movedAway = #sourceEntities local movedAway = #sourceEntities
if sourceRow ~= movedAway then if sourceRow ~= movedAway then
sourceEntities[sourceRow] = sourceEntities[movedAway] local atMovedAway = sourceEntities[movedAway]
entityIndex[sourceEntities[movedAway]].row = sourceRow sourceEntities[sourceRow] = atMovedAway
entityIndex[atMovedAway].row = sourceRow
end end
sourceEntities[movedAway] = nil sourceEntities[movedAway] = nil
end end
local function archetypeAppend(entity: i53, archetype: Archetype): i24 local function archetypeAppend(entity: number, archetype: Archetype): number
local entities = archetype.entities local entities = archetype.entities
table.insert(entities, entity) local length = #entities + 1
return #entities entities[length] = entity
return length
end end
local function newEntity(entityId: i53, record: Record, archetype: Archetype) local function newEntity(entityId: i53, record: Record, archetype: Archetype)
@ -122,47 +125,49 @@ local function hash(arr): string | number
return table.concat(arr, "_") return table.concat(arr, "_")
end end
local function createArchetypeRecords(componentIndex: ComponentIndex, to: Archetype, from: Archetype?) local function createArchetypeRecords(componentIndex: ComponentIndex, to: Archetype, _from: Archetype?)
local destinationCount = #to.types
local destinationIds = to.types local destinationIds = to.types
local records = to.records
local id = to.id
for i = 1, destinationCount do for i, destinationId in destinationIds do
local destinationId = destinationIds[i] local archetypesMap = componentIndex[destinationId]
if not componentIndex[destinationId] then if not archetypesMap then
componentIndex[destinationId] = { size = 0, sparse = {} } archetypesMap = {size = 0, sparse = {}}
componentIndex[destinationId] = archetypesMap
end end
local archetypesMap = componentIndex[destinationId] archetypesMap.sparse[id] = i
archetypesMap.sparse[to.id] = i records[destinationId] = i
to.records[destinationId] = i
end end
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) local ty = hash(types)
world.nextArchetypeId = (world.nextArchetypeId::number)+ 1 local id = world.nextArchetypeId + 1
local id = world.nextArchetypeId world.nextArchetypeId = id
local columns = {} :: { any } local length = #types
local columns = table.create(length) :: {any}
for _ in types do for index in types do
table.insert(columns, {}) columns[index] = {}
end end
local archetype = { local archetype = {
id = id, columns = columns;
types = types, edges = {};
type = ty, entities = {};
columns = columns, id = id;
entities = {}, records = {};
edges = {}, type = ty;
records = {}, types = types;
} }
world.archetypeIndex[ty] = archetype world.archetypeIndex[ty] = archetype
world.archetypes[id] = archetype world.archetypes[id] = archetype
if #types > 0 then if length > 0 then
createArchetypeRecords(world.componentIndex, archetype, prev) createArchetypeRecords(world.componentIndex, archetype, prev)
end end
@ -171,42 +176,42 @@ end
local World = {} local World = {}
World.__index = World World.__index = World
function World.new() function World.new()
local self = setmetatable({ local self = setmetatable({
entityIndex = {}, archetypeIndex = {};
componentIndex = {}, archetypes = {};
archetypes = {}, componentIndex = {};
archetypeIndex = {}, entityIndex = {};
ROOT_ARCHETYPE = (nil :: any) :: Archetype,
nextEntityId = 0,
nextComponentId = 0,
nextArchetypeId = 0,
hooks = { hooks = {
[ON_ADD] = {} [ON_ADD] = {};
} };
nextArchetypeId = 0;
nextComponentId = 0;
nextEntityId = 0;
ROOT_ARCHETYPE = (nil :: any) :: Archetype;
}, World) }, World)
return self return self
end end
local function emit(world, eventDescription) local function emit(world, eventDescription)
local event = eventDescription.event local event = eventDescription.event
table.insert(world.hooks[event], { table.insert(world.hooks[event], {
ids = eventDescription.ids, archetype = eventDescription.archetype;
archetype = eventDescription.archetype, ids = eventDescription.ids;
otherArchetype = eventDescription.otherArchetype, offset = eventDescription.offset;
offset = eventDescription.offset otherArchetype = eventDescription.otherArchetype;
}) })
end end
local function onNotifyAdd(world, archetype, otherArchetype, row: number, added: Ty) local function onNotifyAdd(world, archetype, otherArchetype, row: number, added: Ty)
if #added > 0 then if #added > 0 then
emit(world, { emit(world, {
event = ON_ADD, archetype = archetype;
ids = added, event = ON_ADD;
archetype = archetype, ids = added;
otherArchetype = otherArchetype, offset = row;
offset = row, otherArchetype = otherArchetype;
}) })
end end
end end
@ -217,7 +222,7 @@ local function ensureArchetype(world: World, types, prev)
if #types < 1 then if #types < 1 then
return world.ROOT_ARCHETYPE return world.ROOT_ARCHETYPE
end end
local ty = hash(types) local ty = hash(types)
local archetype = world.archetypeIndex[ty] local archetype = world.archetypeIndex[ty]
if archetype then if archetype then
@ -227,10 +232,8 @@ local function ensureArchetype(world: World, types, prev)
return archetypeOf(world, types, prev) return archetypeOf(world, types, prev)
end end
local function findInsert(types: { i53 }, toAdd: i53) local function findInsert(types: {i53}, toAdd: i53)
local count = #types for i, id in types do
for i = 1, count do
local id = types[i]
if id == toAdd then if id == toAdd then
return -1 return -1
end end
@ -238,13 +241,13 @@ local function findInsert(types: { i53 }, toAdd: i53)
return i return i
end end
end end
return count + 1 return #types + 1
end end
local function findArchetypeWith(world: World, node: Archetype, componentId: i53) local function findArchetypeWith(world: World, node: Archetype, componentId: i53)
local types = node.types local types = node.types
-- Component IDs are added incrementally, so inserting and sorting -- Component IDs are added incrementally, so inserting and sorting
-- them each time would be expensive. Instead this insertion sort can find the insertion -- them each time would be expensive. Instead this insertion sort can find the insertion
-- point in the types array. -- point in the types array.
local at = findInsert(types, componentId) local at = findInsert(types, componentId)
if at == -1 then if at == -1 then
@ -259,48 +262,57 @@ local function findArchetypeWith(world: World, node: Archetype, componentId: i53
end end
local function ensureEdge(archetype: Archetype, componentId: i53) local function ensureEdge(archetype: Archetype, componentId: i53)
if not archetype.edges[componentId] then local edges = archetype.edges
archetype.edges[componentId] = {} :: any local edge = edges[componentId]
if not edge then
edge = {} :: any
edges[componentId] = edge
end end
return archetype.edges[componentId] return edge
end end
local function archetypeTraverseAdd(world: World, componentId: i53, from: Archetype): Archetype local function archetypeTraverseAdd(world: World, componentId: i53, from: Archetype): Archetype
if not from then if not from then
-- If there was no source archetype then it should return the ROOT_ARCHETYPE -- If there was no source archetype then it should return the ROOT_ARCHETYPE
if not world.ROOT_ARCHETYPE then local ROOT_ARCHETYPE = world.ROOT_ARCHETYPE
local ROOT_ARCHETYPE = archetypeOf(world, {}, nil) if not ROOT_ARCHETYPE then
world.ROOT_ARCHETYPE = ROOT_ARCHETYPE ROOT_ARCHETYPE = archetypeOf(world, {}, nil)
end world.ROOT_ARCHETYPE = ROOT_ARCHETYPE :: never
from = world.ROOT_ARCHETYPE end
from = ROOT_ARCHETYPE
end end
local edge = ensureEdge(from, componentId) local edge = ensureEdge(from, componentId)
local add = edge.add
if not edge.add then if not add then
-- Save an edge using the component ID to the archetype to allow -- Save an edge using the component ID to the archetype to allow
-- faster traversals to adjacent archetypes. -- faster traversals to adjacent archetypes.
edge.add = findArchetypeWith(world, from, componentId) add = findArchetypeWith(world, from, componentId)
edge.add = add :: never
end end
return edge.add return add
end end
local function ensureRecord(entityIndex, entityId: i53): Record local function ensureRecord(entityIndex, entityId: i53): Record
local id = entityId local record = entityIndex[entityId]
if not entityIndex[id] then
entityIndex[id] = {} if not record then
record = {}
entityIndex[entityId] = record
end end
return entityIndex[id] :: Record
return record :: Record
end end
function World.set(world: World, entityId: i53, componentId: i53, data: unknown) function World.set(world: World, entityId: i53, componentId: i53, data: unknown)
local record = ensureRecord(world.entityIndex, entityId) local record = ensureRecord(world.entityIndex, entityId)
local from = record.archetype local from = record.archetype
local to = archetypeTraverseAdd(world, componentId, from) local to = archetypeTraverseAdd(world, componentId, from)
if from == to then if from == to then
-- If the archetypes are the same it can avoid moving the entity -- If the archetypes are the same it can avoid moving the entity
-- and just set the data directly. -- and just set the data directly.
local archetypeRecord = to.records[componentId] local archetypeRecord = to.records[componentId]
from.columns[archetypeRecord][record.row] = data from.columns[archetypeRecord][record.row] = data
-- Should fire an OnSet event here. -- Should fire an OnSet event here.
@ -308,13 +320,13 @@ function World.set(world: World, entityId: i53, componentId: i53, data: unknown)
end end
if from then if from then
-- If there was a previous archetype, then the entity needs to move the archetype -- If there was a previous archetype, then the entity needs to move the archetype
moveEntity(world.entityIndex, entityId, record, to) moveEntity(world.entityIndex, entityId, record, to)
else else
if #to.types > 0 then if #to.types > 0 then
-- When there is no previous archetype it should create the archetype -- When there is no previous archetype it should create the archetype
newEntity(entityId, record, to) newEntity(entityId, record, to)
onNotifyAdd(world, to, from, record.row, { componentId }) onNotifyAdd(world, to, from, record.row, {componentId})
end end
end end
@ -326,28 +338,30 @@ local function archetypeTraverseRemove(world: World, componentId: i53, archetype
local from = (archetype or world.ROOT_ARCHETYPE) :: Archetype local from = (archetype or world.ROOT_ARCHETYPE) :: Archetype
local edge = ensureEdge(from, componentId) local edge = ensureEdge(from, componentId)
local remove = edge.remove
if not edge.remove then if not remove then
local to = table.clone(from.types) local to = table.clone(from.types)
table.remove(to, table.find(to, componentId)) table.remove(to, table.find(to, componentId))
edge.remove = ensureArchetype(world, to, from) remove = ensureArchetype(world, to, from)
edge.remove = remove :: never
end end
return edge.remove return remove
end end
function World.remove(world: World, entityId: i53, componentId: i53) 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 sourceArchetype = record.archetype
local destinationArchetype = archetypeTraverseRemove(world, componentId, sourceArchetype) local destinationArchetype = archetypeTraverseRemove(world, componentId, sourceArchetype)
if sourceArchetype and not (sourceArchetype == destinationArchetype) then if sourceArchetype and not (sourceArchetype == destinationArchetype) then
moveEntity(world.entityIndex, entityId, record, destinationArchetype) moveEntity(entityIndex, entityId, record, destinationArchetype)
end end
end end
-- Keeping the function as small as possible to enable inlining -- 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 archetype = record.archetype
local archetypeRecord = archetype.records[componentId] 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?) function World.get(world: World, entityId: i53, a: i53, b: i53?, c: i53?, d: i53?, e: i53?)
local id = entityId local id = entityId
local componentIndex = world.componentIndex
local record = world.entityIndex[id] local record = world.entityIndex[id]
if not record then if not record then
return nil return nil
end end
local va = get(componentIndex, record, a) local va = get(record, a)
if b == nil then if b == nil then
return va return va
elseif c == nil then elseif c == nil then
return va, get(componentIndex, record, b) return va, get(record, b)
elseif d == nil then 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 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 else
error("args exceeded") error("args exceeded")
end end
end end
local function noop(self: Query, ...: i53): () -> (number, ...any) -- the less creation the better
return function() local function actualNoOperation() end
end :: any local function noop(_self: Query, ...: i53): () -> (number, ...any)
return actualNoOperation :: any
end end
local EmptyQuery = { local EmptyQuery = {
__iter = noop, __iter = noop;
without = noop without = noop;
} }
EmptyQuery.__index = EmptyQuery EmptyQuery.__index = EmptyQuery
setmetatable(EmptyQuery, EmptyQuery) setmetatable(EmptyQuery, EmptyQuery)
@ -396,25 +410,28 @@ setmetatable(EmptyQuery, EmptyQuery)
export type Query = typeof(EmptyQuery) export type Query = typeof(EmptyQuery)
function World.query(world: World, ...: i53): Query function World.query(world: World, ...: i53): Query
local compatibleArchetypes = {} -- breaking?
local components = { ... } if (...) == nil then
local archetypes = world.archetypes
local queryLength = #components
if queryLength == 0 then
error("Missing components") error("Missing components")
end end
local compatibleArchetypes = {}
local length = 0
local components = {...}
local archetypes = world.archetypes
local queryLength = #components
local firstArchetypeMap local firstArchetypeMap
local componentIndex = world.componentIndex local componentIndex = world.componentIndex
for i, componentId in components do for _, componentId in components do
local map = componentIndex[componentId] local map = componentIndex[componentId]
if not map then if not map then
return EmptyQuery return EmptyQuery
end end
if firstArchetypeMap == nil or map.size < firstArchetypeMap.size then if firstArchetypeMap == nil or map.size < firstArchetypeMap.size then
firstArchetypeMap = map firstArchetypeMap = map
end end
end end
@ -422,110 +439,107 @@ function World.query(world: World, ...: i53): Query
for id in firstArchetypeMap.sparse do for id in firstArchetypeMap.sparse do
local archetype = archetypes[id] local archetype = archetypes[id]
local archetypeRecords = archetype.records local archetypeRecords = archetype.records
local indices = {} local indices = {}
local skip = false local skip = false
for i, componentId in components do for i, componentId in components do
local index = archetypeRecords[componentId] local index = archetypeRecords[componentId]
if not index then if not index then
skip = true skip = true
break break
end end
indices[i] = archetypeRecords[componentId] indices[i] = index
end end
if skip then if skip then
continue continue
end end
table.insert(compatibleArchetypes, { archetype, indices })
length += 1
compatibleArchetypes[length] = {archetype, indices}
end end
local lastArchetype, compatibleArchetype = next(compatibleArchetypes) local lastArchetype, compatibleArchetype = next(compatibleArchetypes)
if not lastArchetype then if not lastArchetype then
return EmptyQuery return EmptyQuery
end end
local preparedQuery = {} local preparedQuery = {}
preparedQuery.__index = preparedQuery preparedQuery.__index = preparedQuery
function preparedQuery:without(...) function preparedQuery:without(...)
local components = { ... } local withoutComponents = {...}
for i = #compatibleArchetypes, 1, -1 do for i = #compatibleArchetypes, 1, -1 do
local archetype = compatibleArchetypes[i][1] local archetype = compatibleArchetypes[i][1]
local records = archetype.records
local shouldRemove = false local shouldRemove = false
for _, componentId in components do
if archetype.records[componentId] then for _, componentId in withoutComponents do
if records[componentId] then
shouldRemove = true shouldRemove = true
break break
end end
end end
if shouldRemove then
if shouldRemove then
table.remove(compatibleArchetypes, i) table.remove(compatibleArchetypes, i)
end end
end end
lastArchetype, compatibleArchetype = next(compatibleArchetypes) lastArchetype, compatibleArchetype = next(compatibleArchetypes)
if not lastArchetype then if not lastArchetype then
return EmptyQuery return EmptyQuery
end end
return self return self
end end
local lastRow local lastRow
local queryOutput = {} local queryOutput = {}
function preparedQuery:__iter()
function preparedQuery:__iter() return function()
return function()
local archetype = compatibleArchetype[1] local archetype = compatibleArchetype[1]
local row = next(archetype.entities, lastRow) local row = next(archetype.entities, lastRow)
while row == nil do while row == nil do
lastArchetype, compatibleArchetype = next(compatibleArchetypes, lastArchetype) lastArchetype, compatibleArchetype = next(compatibleArchetypes, lastArchetype)
if lastArchetype == nil then if lastArchetype == nil then
return return
end end
archetype = compatibleArchetype[1] archetype = compatibleArchetype[1]
row = next(archetype.entities, row) row = next(archetype.entities, row)
end end
lastRow = row lastRow = row
local entityId = archetype.entities[row :: number] local entityId = archetype.entities[row :: number]
local columns = archetype.columns local columns = archetype.columns
local tr = compatibleArchetype[2] local tr = compatibleArchetype[2]
if queryLength == 1 then if queryLength == 1 then
return entityId, columns[tr[1]][row] return entityId, columns[tr[1]][row]
elseif queryLength == 2 then elseif queryLength == 2 then
return entityId, columns[tr[1]][row], columns[tr[2]][row] return entityId, columns[tr[1]][row], columns[tr[2]][row]
elseif queryLength == 3 then elseif queryLength == 3 then
return entityId, return entityId, columns[tr[1]][row], columns[tr[2]][row], columns[tr[3]][row]
columns[tr[1]][row], elseif queryLength == 4 then
columns[tr[2]][row], return entityId, columns[tr[1]][row], columns[tr[2]][row], columns[tr[3]][row], columns[tr[4]][row]
columns[tr[3]][row] elseif queryLength == 5 then
elseif queryLength == 4 then return entityId,
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], columns[tr[1]][row],
columns[tr[2]][row], columns[tr[2]][row],
columns[tr[3]][row], columns[tr[3]][row],
columns[tr[4]][row], columns[tr[4]][row],
columns[tr[5]][row] columns[tr[5]][row]
elseif queryLength == 6 then elseif queryLength == 6 then
return entityId, return entityId,
columns[tr[1]][row], columns[tr[1]][row],
columns[tr[2]][row], columns[tr[2]][row],
columns[tr[3]][row], columns[tr[3]][row],
columns[tr[4]][row], columns[tr[4]][row],
columns[tr[5]][row], columns[tr[5]][row],
columns[tr[6]][row] columns[tr[6]][row]
elseif queryLength == 7 then elseif queryLength == 7 then
return entityId, return entityId,
columns[tr[1]][row], columns[tr[1]][row],
columns[tr[2]][row], columns[tr[2]][row],
columns[tr[3]][row], columns[tr[3]][row],
@ -533,8 +547,8 @@ function World.query(world: World, ...: i53): Query
columns[tr[5]][row], columns[tr[5]][row],
columns[tr[6]][row], columns[tr[6]][row],
columns[tr[7]][row] columns[tr[7]][row]
elseif queryLength == 8 then elseif queryLength == 8 then
return entityId, return entityId,
columns[tr[1]][row], columns[tr[1]][row],
columns[tr[2]][row], columns[tr[2]][row],
columns[tr[3]][row], columns[tr[3]][row],
@ -545,8 +559,8 @@ function World.query(world: World, ...: i53): Query
columns[tr[8]][row] columns[tr[8]][row]
end end
for i in components do for i in components do
queryOutput[i] = tr[i][row] queryOutput[i] = columns[tr[i]][row]
end end
return entityId, unpack(queryOutput, 1, queryLength) return entityId, unpack(queryOutput, 1, queryLength)
@ -556,23 +570,24 @@ function World.query(world: World, ...: i53): Query
return setmetatable({}, preparedQuery) :: any return setmetatable({}, preparedQuery) :: any
end end
function World.component(world: World) function World.component(world: World)
local componentId = world.nextComponentId + 1 local componentId = world.nextComponentId + 1
if componentId > HI_COMPONENT_ID then if componentId > HI_COMPONENT_ID then
-- IDs are partitioned into ranges because component IDs are not nominal, -- IDs are partitioned into ranges because component IDs are not nominal,
-- so it needs to error when IDs intersect into the entity range. -- so it needs to error when IDs intersect into the entity range.
error("Too many components, consider using world:entity() instead to create components.") error("Too many components, consider using world:entity() instead to create components.")
end end
world.nextComponentId = componentId world.nextComponentId = componentId
return componentId return componentId
end end
function World.entity(world: World) function World.entity(world: World)
world.nextEntityId += 1 local nextEntityId = world.nextEntityId + 1
return world.nextEntityId + REST world.nextEntityId = nextEntityId
return nextEntityId + REST
end end
function World.delete(world: World, entityId: i53) function World.delete(world: World, entityId: i53)
local entityIndex = world.entityIndex local entityIndex = world.entityIndex
local record = entityIndex[entityId] local record = entityIndex[entityId]
moveEntity(entityIndex, entityId, record, world.ROOT_ARCHETYPE) moveEntity(entityIndex, entityId, record, world.ROOT_ARCHETYPE)
@ -584,57 +599,61 @@ function World.delete(world: World, entityId: i53)
end end
function World.observer(world: World, ...) function World.observer(world: World, ...)
local componentIds = { ... } local componentIds = {...}
local idsCount = #componentIds
local hooks = world.hooks
return { return {
event = function(event) event = function(event)
local hook = world.hooks[event] local hook = hooks[event]
world.hooks[event] = nil hooks[event] = nil
local last, change local last, change
return function() return function()
last, change = next(hook, last) last, change = next(hook, last)
if not last then if not last then
return return
end end
local matched = false local matched = false
local ids = change.ids
while not matched do
while not matched do
local skip = false local skip = false
for _, id in change.ids do for _, id in ids do
if not table.find(componentIds, id) then if not table.find(componentIds, id) then
skip = true skip = true
break break
end end
end end
if skip then if skip then
last, change = next(hook, last) last, change = next(hook, last)
ids = change.ids
continue continue
end end
matched = true matched = true
end end
local queryOutput = {} local queryOutput = table.create(idsCount)
local row = change.offset local row = change.offset
local archetype = change.archetype local archetype = change.archetype
local columns = archetype.columns local columns = archetype.columns
local archetypeRecords = archetype.records local archetypeRecords = archetype.records
for _, id in componentIds do for index, id in componentIds do
table.insert(queryOutput, columns[archetypeRecords[id]][row]) queryOutput[index] = columns[archetypeRecords[id]][row]
end end
return archetype.entities[row], unpack(queryOutput, 1, #queryOutput) return archetype.entities[row], unpack(queryOutput, 1, idsCount)
end end
end end;
} }
end end
return table.freeze({ return table.freeze({
World = World, World = World;
ON_ADD = ON_ADD, ON_ADD = ON_ADD;
ON_REMOVE = ON_REMOVE, ON_REMOVE = ON_REMOVE;
ON_SET = ON_SET ON_SET = ON_SET;
}) })

View file

@ -11,9 +11,6 @@
}, },
"ReplicatedStorage": { "ReplicatedStorage": {
"$className": "ReplicatedStorage", "$className": "ReplicatedStorage",
"DevPackages": {
"$path": "DevPackages"
},
"Lib": { "Lib": {
"$path": "lib" "$path": "lib"
}, },
@ -25,6 +22,9 @@
}, },
"mirror": { "mirror": {
"$path": "mirror" "$path": "mirror"
},
"DevPackages": {
"$path": "DevPackages"
} }
}, },
"TestService": { "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] [package]
name = "ukendio/jecs" name = "ukendio/jecs"
version = "0.0.0-prototype.rc.3" version = "0.1.0-rc.6"
registry = "https://github.com/UpliftGames/wally-index" registry = "https://github.com/UpliftGames/wally-index"
realm = "shared" realm = "shared"
include = ["default.project.json", "lib/**", "lib", "wally.toml", "README.md"]
exclude = ["**"] exclude = ["**"]
include = ["default.project.json", "lib", "wally.toml", "README.md"]
[dev-dependencies] [dev-dependencies]
TestEZ = "roblox/testez@0.4.1" TestEZ = "roblox/testez@0.4.1"
Matter = "matter-ecs/matter@0.8.0"
ecr = "centau/ecr@0.8.0"