diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..b485f17 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,73 @@ +name: Release + +on: + push: + tags: ["v*"] + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout Project + uses: actions/checkout@v3 + + - name: Install Aftman + uses: ok-nick/setup-aftman@v0.3.0 + + - name: Install Dependencies + run: wally install + + - name: Build + run: rojo build --output build.rbxm default.project.json + + - name: Upload Build Artifact + uses: actions/upload-artifact@v3 + with: + name: build + path: build.rbxm + + release: + name: Release + needs: [build] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout Project + uses: actions/checkout@v3 + + - name: Download Jecs Build + uses: actions/download-artifact@v3 + with: + name: build + path: build + + - name: Rename Build + run: mv build/build.rbxm jecs.rbxm + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + name: Matter ${{ github.ref_name }} + body: | + Matter ${{ github.ref_name }} is now available! + files: | + jecs.rbxm + + publish: + name: Publish + needs: [release] + runs-on: ubuntu-latest + steps: + - name: Checkout Project + uses: actions/checkout@v3 + + - name: Install Aftman + uses: ok-nick/setup-aftman@v0.3.0 + + - name: Wally Login + run: wally login --token ${{ secrets.WALLY_AUTH_TOKEN }} + + - name: Publish + run: wally publish \ No newline at end of file diff --git a/README.md b/README.md index 96f82a8..c5ece64 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@

- + +

[![License: Apache 2.0](https://img.shields.io/badge/License-Apache-blue.svg?style=for-the-badge)](LICENSE-APACHE) @@ -10,44 +11,51 @@ Just an ECS jecs is a stupidly fast Entity Component System (ECS). -- Process tens of thousands of entities with ease every frame -- Zero-dependency Luau package +- Entity Relationships as first class citizens +- Iterate 350,000 entities at 60 frames per second +- 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 world = jecs.World.new() +local pair = jecs.pair -local Health = world:component() -local Damage = world:component() -local Position = world:component() +local ChildOf = world:component() +local Name = world:component() -local player = world:entity() -local opponent = world:entity() - -world:set(player, Health, 100) -world:set(player, Damage, 8) -world:set(player, Position, Vector3.new(0, 5, 0)) - -world:set(opponent, Health, 100) -world:set(opponent, Damage, 21) -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 - if (playerPosition - opponentPosition).Magnitude < 5 then - totalDamage += damage - end - end - - world:set(playerId, Health, health - totalDamage) +local function parent(entity) + return world:target(entity, ChildOf) +end +local function getName(entity) + return world:get(entity, Name) end -assert(world:get(playerId, Health) == 79) -assert(world:get(opponentId, Health) == 92) +local alice = world:entity() +world:set(alice, Name, "alice") + +local bob = world:entity() +world:add(bob, pair(ChildOf, alice)) +world:set(bob, Name, "bob") + +local sara = world:entity() +world:add(sara, pair(ChildOf, alice)) +world:set(sara, Name, "sara") + +print(getName(parent(sara))) + +for e in world:query(pair(ChildOf, alice)) do + print(getName(e), "is the child of alice") +end + +-- Output +-- "alice" +-- bob is the child of alice +-- sara is the child of alice ``` 125 archetypes, 4 random components queried. diff --git a/aftman.toml b/aftman.toml index 73e1123..56cddbd 100644 --- a/aftman.toml +++ b/aftman.toml @@ -1,6 +1,6 @@ [tools] -wally = "upliftgames/wally@0.3.1" +wally = "upliftgames/wally@0.3.2" rojo = "rojo-rbx/rojo@7.4.1" stylua = "johnnymorganz/stylua@0.19.1" selene = "kampfkarren/selene@0.26.1" -wally-patch-package="Barocena/wally-patch-package@1.2.1" \ No newline at end of file +wally-patch-package = "Barocena/wally-patch-package@1.2.1" diff --git a/bench.project.json b/bench.project.json new file mode 100644 index 0000000..e55b3ec --- /dev/null +++ b/bench.project.json @@ -0,0 +1,31 @@ +{ + "name": "jecs-test", + "tree": { + "$className": "DataModel", + "StarterPlayer": { + "$className": "StarterPlayer", + "StarterPlayerScripts": { + "$className": "StarterPlayerScripts", + "$path": "tests" + } + }, + "ReplicatedStorage": { + "$className": "ReplicatedStorage", + "Lib": { + "$path": "lib" + }, + "rgb": { + "$path": "rgb.lua" + }, + "benches": { + "$path": "benches" + }, + "mirror": { + "$path": "mirror" + }, + "DevPackages": { + "$path": "benches/visual/DevPackages" + } + } + } +} \ No newline at end of file diff --git a/benches/query.lua b/benches/query.lua index de60944..34b63de 100644 --- a/benches/query.lua +++ b/benches/query.lua @@ -1,299 +1,246 @@ --!optimize 2 --!native -local testkit = require('../testkit') +local testkit = require("../testkit") local BENCH, START = testkit.benchmark() local function TITLE(title: string) - print() - print(testkit.color.white(title)) + print() + 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") - -local newMatter = require("../newMatter") type i53 = number -do TITLE (testkit.color.white_underline("Jecs query")) - local ecs = jecs.World.new() - do TITLE "one component in common" +do + TITLE(testkit.color.white_underline("Jecs query")) + local ecs = jecs.World.new() + do + TITLE("one component in common") - local function view_bench( - world: jecs.World, - A: i53, B: i53, C: i53, D: i53, E: i53, F: i53, G: i53, H: i53 - ) + 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("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("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("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) - BENCH("8 component", function() - for _ in world:query(A, B, C, D, E, F, G, H) do 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) - 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() + 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 function flip() - return math.random() >= 0.15 - 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 added = 0 + 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() + for i = 1, 2 ^ 16 - 2 do + local entity = ecs:entity() - local combination = "" + 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}) + 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 - end - - if #combination == 7 then - added += 1 - ecs:set(entity, D1, { value = true}) - end + if #combination == 7 then + added += 1 + ecs:set(entity, D1, {value = true}) + end archetypes[combination] = true - end + end local a = 0 - for _ in archetypes do a+= 1 end + for _ in archetypes do + a += 1 + end - view_bench(ecs, D1, D2, D3, D4, D5, D6, D7, D8) - end + view_bench(ecs, D1, D2, D3, D4, D5, D6, D7, D8) + end end -do TITLE(testkit.color.white_underline("OldMatter query")) +do + TITLE(testkit.color.white_underline("Mirror query")) + local ecs = mirror.World.new() + do + TITLE("one component in common") - local ecs = oldMatter.World.new() - local component = oldMatter.component + 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) - 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("2 component", function() + for _ in world:query(A, B) do + end + end) - BENCH("1 component", function() - for _ in world:query(A) do end - end) + BENCH("4 component", function() + for _ in world:query(A, B, C, D) do + end + end) - BENCH("2 component", function() - for _ in world:query(A, B) do end - end) + BENCH("8 component", function() + for _ in world:query(A, B, C, D, E, F, G, H) do + end + end) - BENCH("4 component", function() - for _ in world:query(A, B, C, D) 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("8 component", function() - for _ in world:query(A, B, C, D, E, F, G, H) do end - end) - end + 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 = component() - local D2 = component() - local D3 = component() - local D4 = component() - local D5 = component() - local D6 = component() - local D7 = component() - local D8 = component() + 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 function flip() + return math.random() >= 0.15 + end - local added = 0 + local added = 0 local archetypes = {} - for i = 1, 2^16-2 do - local entity = ecs:spawn() + for i = 1, 2 ^ 16 - 2 do + local entity = ecs:entity() - local combination = "" + local combination = "" - if flip() then - combination ..= "B" - ecs:insert(entity, D2({value = true})) - end - if flip() then - combination ..= "C" - ecs:insert(entity, D3({value = true})) - end - if flip() then - combination ..= "D" - ecs:insert(entity, D4({value = true})) - end - if flip() then - combination ..= "E" - ecs:insert(entity, D5({value = true})) - end - if flip() then - combination ..= "F" - ecs:insert(entity, D6({value = true})) + 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 - end - if flip() then - combination ..= "G" - ecs:insert(entity, D7({value = true})) - - end - if flip() then - combination ..= "H" - ecs:insert(entity, D8({value = true})) - end - - if #combination == 7 then - added += 1 - ecs:insert(entity, D1({value = true})) - - end + if #combination == 7 then + added += 1 + ecs:set(entity, D1, {value = true}) + end archetypes[combination] = true - end + end local a = 0 - for _ in archetypes do a+= 1 end - - view_bench(ecs, D1, D2, D3, D4, D5, D6, D7, D8) - end - -end - -do TITLE(testkit.color.white_underline("NewMatter query")) - - local ecs = newMatter.World.new() - local component = newMatter.component - - do TITLE "one component in common" - local function view_bench( - world: jecs.World, - A: i53, B: i53, C: i53, D: i53, E: i53, F: i53, G: i53, H: i53 - ) - - BENCH("1 component", function() - for _ in world:query(A) do end - end) - - BENCH("2 component", function() - for _ in world:query(A, B) do end - end) - - BENCH("4 component", function() - for _ in world:query(A, B, C, D) do - end - end) - - BENCH("8 component", function() - for _ in world:query(A, B, C, D, E, F, G, H) do end - end) - end - - local D1 = component() - local D2 = component() - local D3 = component() - local D4 = component() - local D5 = component() - local D6 = component() - local D7 = component() - local D8 = component() - - local function flip() - return math.random() >= 0.15 - end - - local added = 0 - local archetypes = {} - for i = 1, 2^16-2 do - local entity = ecs:spawn() - - local combination = "" - - if flip() then - combination ..= "B" - ecs:insert(entity, D2({value = true})) - end - if flip() then - combination ..= "C" - ecs:insert(entity, D3({value = true})) - end - if flip() then - combination ..= "D" - ecs:insert(entity, D4({value = true})) - end - if flip() then - combination ..= "E" - ecs:insert(entity, D5({value = true})) - end - if flip() then - combination ..= "F" - ecs:insert(entity, D6({value = true})) - - end - if flip() then - combination ..= "G" - ecs:insert(entity, D7({value = true})) - - end - if flip() then - combination ..= "H" - ecs:insert(entity, D8({value = true})) - end - - if #combination == 7 then - added += 1 - ecs:insert(entity, D1({value = true})) - - end - archetypes[combination] = true - end - - local a = 0 - for _ in archetypes do a+= 1 end - - view_bench(ecs, D1, D2, D3, D4, D5, D6, D7, D8) - end + for _ in archetypes do + a += 1 + end + view_bench(ecs, D1, D2, D3, D4, D5, D6, D7, D8) + end end \ No newline at end of file diff --git a/benches/visual/insertion.bench.lua b/benches/visual/insertion.bench.lua index 3f7415a..8e24f29 100644 --- a/benches/visual/insertion.bench.lua +++ b/benches/visual/insertion.bench.lua @@ -2,12 +2,13 @@ --!native local ReplicatedStorage = game:GetService("ReplicatedStorage") -local rgb = require(ReplicatedStorage.rgb) local Matter = require(ReplicatedStorage.DevPackages.Matter) 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 +36,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 +54,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 +70,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 +88,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 +99,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 diff --git a/benches/visual/spawn.bench.lua b/benches/visual/spawn.bench.lua index 962064e..c5e6aef 100644 --- a/benches/visual/spawn.bench.lua +++ b/benches/visual/spawn.bench.lua @@ -2,41 +2,37 @@ --!native local ReplicatedStorage = game:GetService("ReplicatedStorage") -local rgb = require(ReplicatedStorage.rgb) local Matter = require(ReplicatedStorage.DevPackages.Matter) -local jecs = require(ReplicatedStorage.Lib) local ecr = require(ReplicatedStorage.DevPackages.ecr) +local jecs = require(ReplicatedStorage.Lib) +local rgb = require(ReplicatedStorage.rgb) local newWorld = Matter.World.new() local ecs = jecs.World.new() - return { ParameterGenerator = function() - local registry2 = ecr.registry() + local registry2 = ecr.registry() return registry2 - end, + end; Functions = { - Matter = function() - for i = 1, 1000 do - newWorld:spawn() - end - end, + Matter = function() + for i = 1, 1000 do + newWorld:spawn() + end + end; + ECR = function(_, registry2) + for i = 1, 1000 do + registry2.create() + end + end; - ECR = function(_, registry2) - for i = 1, 1000 do - registry2.create() - end - end, - - - Jecs = function() - for i = 1, 1000 do - ecs:entity() - end - end - - }, + Jecs = function() + for i = 1, 1000 do + ecs:entity() + end + end; + }; } diff --git a/benches/visual/wally.toml b/benches/visual/wally.toml new file mode 100644 index 0000000..cb0f731 --- /dev/null +++ b/benches/visual/wally.toml @@ -0,0 +1,11 @@ +[package] +name = "private/private" +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 = ["**"] + +[dev-dependencies] +Matter = "matter-ecs/matter@0.8.0" +ecr = "centau/ecr@0.8.0" \ No newline at end of file diff --git a/docs/api-types.md b/docs/api-types.md new file mode 100644 index 0000000..cce3856 --- /dev/null +++ b/docs/api-types.md @@ -0,0 +1,45 @@ +# World + +A World contains all ECS data +Games can have multiple worlds, although typically only one is necessary. These worlds are isolated from each other, meaning they donot share the same entities nor component IDs. + +--- + +# Entity + +An unique id. + +Entities consist out of a number unique to the entity in the lower 32 bits, and a counter used to track entity liveliness in the upper 32 bits. When an id is recycled, its generation count is increased. This causes recycled ids to be very large (>4 billion), which is normal. + +--- + +# QueryIter + +A result from the `World:query` function. + +Queries are used to iterate over entities that match against the set collection of components. + +Calling it in a loop will allow iteration over the results. + +```lua +for id, enemy, charge, model in world:query(Enemy, Charge, Model) do + -- Do something +end +``` + +### QueryIter.without + +QueryIter.without(iter: QueryIter + ...: [Entity](#Entity)): QueryIter + + +Create a new Query Iterator from the filter + +#### Parameters + world The world. + ... The collection of components to filter archetypes against. + +#### Returns + +The new query iterator. + diff --git a/docs/api/world.md b/docs/api/world.md new file mode 100644 index 0000000..0a019c3 --- /dev/null +++ b/docs/api/world.md @@ -0,0 +1,187 @@ +# World + +### World.new + +World.new(): [World](../api-types.md#World) + +Create a new world. + +#### Returns +A new world + +--- + +### World.entity + +World.entity(world: [World](../api-types.md#World)): [Entity](../api-types.md#Entity) + +Creates an entity in the world. + +#### Returns +A new entiity id + +--- + +### World.target + +World.target(world: [World](../api-types.md#World), + entity: [Entity](../api-types.md#Entity), + rel: [Entity](../api-types.md#Entity)): [Entity](../api-types.md#Entity) + +Get the target of a relationship. + +This will return a target (second element of a pair) of the entity for the specified relationship. + +#### Parameters + world The world. + entity The entity. + rel The relationship between the entity and the target. + +#### Returns + +The first target for the relationship + +--- + +### World.add + +World.add(world: [World](../api-types.md#World), + entity: [Entity](../api-types.md#Entity), + id: [Entity](../api-types.md#Entity)): [Entity](..#api-types.md#Entity) + +Add a (component) id to an entity. + +This operation adds a single (component) id to an entity. +If the entity already has the id, this operation will have no side effects. + +#### Parameters + world The world. + entity The entity. + id The id to add. + +--- + +### World.remove + +World.remove(world: [World](../api-types#World), + entity: [Entity](../api-types#Entity), + id: [Entity](../api-types#Entity)): [Entity](../api-types#Entity) + +Remove a (component) id to an entity. + +This operation removes a single (component) id to an entity. +If the entity already has the id, this operation will have no side effects. + +#### Parameters + world The world. + entity The entity. + id The id to add. + +--- + +### World.get + +World.get(world: [World](../api-types.md#World), + entity: [Entity](../api-types.md#Entity), + id: [Entity](../api-types.md#Entity)): any + +Gets the component data. + +#### Parameters + world The world. + entity The entity. + id The id of component to get. + +#### Returns +The component data, nil if the entity does not have the componnet. + +--- + +### World.set + +World.set(world: [World](../api-types.md#World), + entity: [Entity](../api-types.md#Entity), + id: [Entity](../api-types.md#Entity) + data: any) + +Set the value of a component. + +#### Parameters + world The world. + entity The entity. + id The id of the componment set. + data The data to the component. + +--- + +### World.query + +World.query(world: [World](../api-types.md#World), + ...: [Entity](../api-types.mdEntity)): [QueryIter](../api-types.md#QueryIter) + +Create a QueryIter from the list of filters. + +#### Parameters + world The world. + ... The collection of components to match entities against. + +#### Returns + +The query iterator. + +--- + +# Pair + +### pair + +pair(first: [Entity](../api-types#Entity), second: [Entity](../api-types#Entity)): [Entity](../api-types#Entity) + +Creates a composite key. + +#### Parameters + first The first element. + second The second element. + +#### Returns + +The pair of the two elements + +--- + +### IS_PAIR + +jecs.IS_PAIR(id: [Entity](../api-types#Entity)): boolean + +Creates a composite key. + +#### Parameters + id The id to check. + +#### Returns + +If id is a pair. + +--- + +# Constants + +### OnAdd + +--- + +### OnRemove + +--- + +### Rest + +--- + +### OnSet + +--- + +### Wildcard + +Matches any id, returns all matches. diff --git a/docs/tutorials/quick-start/getting-started.md b/docs/tutorials/quick-start/getting-started.md new file mode 100644 index 0000000..bd702d2 --- /dev/null +++ b/docs/tutorials/quick-start/getting-started.md @@ -0,0 +1,19 @@ +# Getting Started +This section will provide a walk through setting up your development environment and a quick overview of the different features and concepts in Jecs with short examples. + +## Installing Jecs + +To use Jecs, you will need to add the library to your project's source folder. + +## Installing as standalone +Head over to the [Releases](https://github.com/ukendio/jecs/releases/latest) page and install the rbxm file. +![jecs.rbxm](rbxm.png) + +## Installing with Wally +Jecs is available as a package on [wally.run](https://wally.run/package/ukendio/jecs) + +Add it to your project's Wally.toml like this: +```toml +[dependencies] +jecs = "0.1.0" # Make sure this is the latest version +``` \ No newline at end of file diff --git a/docs/tutorials/quick-start/rbxm.png b/docs/tutorials/quick-start/rbxm.png new file mode 100644 index 0000000..ad8f38d Binary files /dev/null and b/docs/tutorials/quick-start/rbxm.png differ diff --git a/jecs_darkmode.svg b/jecs_darkmode.svg new file mode 100644 index 0000000..f64b173 --- /dev/null +++ b/jecs_darkmode.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/jecs_lightmode.svg b/jecs_lightmode.svg new file mode 100644 index 0000000..dbcd08c --- /dev/null +++ b/jecs_lightmode.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/lib/init.lua b/lib/init.lua index 70f235f..e1f4549 100644 --- a/lib/init.lua +++ b/lib/init.lua @@ -14,13 +14,13 @@ type Column = { any } type Archetype = { id: number, edges: { - [i24]: { + [i53]: { add: Archetype, remove: Archetype, }, }, types: Ty, - type: string | number, + type: string | number, entities: { number }, columns: { Column }, records: {}, @@ -29,25 +29,146 @@ type Archetype = { type Record = { archetype: Archetype, row: number, + dense: i24, + componentRecord: ArchetypeMap, } -type EntityIndex = { [i24]: Record } -type ComponentIndex = { [i24]: ArchetypeMap} +type EntityIndex = { dense: { [i24]: i53 }, sparse: { [i53]: Record } } type ArchetypeRecord = number -type ArchetypeMap = { sparse: { [ArchetypeId]: ArchetypeRecord } , size: number } +--[[ +TODO: +{ + index: number, + count: number, + column: number +} + +]] + +type ArchetypeMap = { + cache: { [number]: ArchetypeRecord }, + first: ArchetypeMap, + second: ArchetypeMap, + parent: ArchetypeMap, + size: number, +} + +type ComponentIndex = { [i24]: ArchetypeMap } + type Archetypes = { [ArchetypeId]: Archetype } - + type ArchetypeDiff = { added: Ty, 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 ECS_COMBINE(source: number, target: number): i53 + local e = source * 268435456 + target * ECS_ID_FLAGS_MASK + return e +end + +local function ECS_IS_PAIR(e: number) + return (e % 2 ^ 4) // FLAGS_PAIR ~= 0 +end + +-- HIGH 24 bits LOW 24 bits +local function ECS_GENERATION(e: i53) + e = e // 0x10 + return e % ECS_GENERATION_MASK +end + +local function ECS_GENERATION_INC(e: i53) + local flags = e // 0x10 + local id = flags // ECS_ENTITY_MASK + local generation = flags % ECS_GENERATION_MASK + + return ECS_COMBINE(id, generation + 1) + flags +end + +-- FIRST gets the high ID +local function ECS_ENTITY_T_HI(e: i53): i24 + e = e // 0x10 + return e % ECS_ENTITY_MASK +end + +-- SECOND +local function ECS_ENTITY_T_LO(e: i53) + e = e // 0x10 + return e // ECS_ENTITY_MASK +end + +local function ECS_PAIR(pred: i53, obj: i53): i53 + local first + local second: number = WILDCARD + + if pred == WILDCARD then + first = obj + elseif obj == WILDCARD then + first = pred + else + first = obj + second = ECS_ENTITY_T_LO(pred) + end + + return ECS_COMBINE(ECS_ENTITY_T_LO(first), second) + addFlags(--[[isPair]] true) +end + +local function getAlive(entityIndex: EntityIndex, id: i24) + local entityId = entityIndex.dense[id] + return entityId +end + +-- ECS_PAIR_FIRST, gets the relationship target / obj / HIGH bits +local function ECS_PAIR_RELATION(entityIndex, e) + return getAlive(entityIndex, ECS_ENTITY_T_HI(e)) +end + +-- ECS_PAIR_SECOND gets the relationship / pred / LOW bits +local function ECS_PAIR_OBJECT(entityIndex, e) + return getAlive(entityIndex, ECS_ENTITY_T_LO(e)) +end + +local function nextEntityId(entityIndex, index: i24): i53 + local id = ECS_COMBINE(index, 0) + entityIndex.sparse[id] = { + dense = index, + } :: Record + entityIndex.dense[index] = id + + return id +end local function transitionArchetype( entityIndex: EntityIndex, @@ -64,42 +185,51 @@ local function transitionArchetype( local types = from.types 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. local targetColumn = destinationColumns[tr[types[i]]] -- Sometimes target column may not exist, e.g. when you remove a component. - if targetColumn then + if targetColumn then targetColumn[destinationRow] = column[sourceRow] end -- If the entity is the last row in the archetype then swapping it would be meaningless. local last = #column - if sourceRow ~= last then + if sourceRow ~= last then -- Swap rempves columns to ensure there are no holes in the archetype. column[sourceRow] = column[last] end column[last] = nil end - -- Move the entity from the source to the destination archetype. - destinationEntities[destinationRow] = sourceEntities[sourceRow] - entityIndex[sourceEntities[sourceRow]].row = destinationRow + 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 - if sourceRow ~= movedAway then - sourceEntities[sourceRow] = sourceEntities[movedAway] - entityIndex[sourceEntities[movedAway]].row = sourceRow + local e1 = sourceEntities[sourceRow] + local e2 = sourceEntities[movedAway] + + if sourceRow ~= movedAway then + 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: 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) @@ -109,7 +239,7 @@ local function newEntity(entityId: i53, record: Record, archetype: Archetype) return record end -local function moveEntity(entityIndex, entityId: i53, record: Record, to: Archetype) +local function moveEntity(entityIndex: EntityIndex, entityId: i53, record: Record, to: Archetype) local sourceRow = record.row local from = record.archetype local destinationRow = archetypeAppend(entityId, to) @@ -122,93 +252,202 @@ 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 destinationIds = to.types +local function ensureComponentRecord( + componentIndex: ComponentIndex, + archetypeId: number, + componentId: number, + i: number +): ArchetypeMap + local archetypesMap = componentIndex[componentId] - for i = 1, destinationCount do - local destinationId = destinationIds[i] - - if not componentIndex[destinationId] then - componentIndex[destinationId] = { size = 0, sparse = {} } - end - - local archetypesMap = componentIndex[destinationId] - archetypesMap.sparse[to.id] = i - to.records[destinationId] = i + if not archetypesMap then + archetypesMap = { size = 0, cache = {}, first = {}, second = {} } :: ArchetypeMap + componentIndex[componentId] = archetypesMap end + + archetypesMap.cache[archetypeId] = i + archetypesMap.size += 1 + + return archetypesMap end -local function archetypeOf(world: World, types: { i24 }, prev: Archetype?): Archetype +local function ECS_ID_IS_WILDCARD(e) + assert(ECS_IS_PAIR(e)) + local first = ECS_ENTITY_T_HI(e) + local second = ECS_ENTITY_T_LO(e) + return first == WILDCARD or second == WILDCARD +end + +local function archetypeOf(world: any, 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) + local componentIndex = world.componentIndex - for _ in types do - table.insert(columns, {}) + local records = {} + for i, componentId in types do + ensureComponentRecord(componentIndex, id, componentId, i) + records[componentId] = i + if ECS_IS_PAIR(componentId) then + local relation = ECS_PAIR_RELATION(world.entityIndex, componentId) + local object = ECS_PAIR_OBJECT(world.entityIndex, componentId) + + local idr_r = ECS_PAIR(relation, WILDCARD) + ensureComponentRecord(componentIndex, id, idr_r, i) + records[idr_r] = i + + local idr_t = ECS_PAIR(WILDCARD, object) + ensureComponentRecord(componentIndex, id, idr_t, i) + records[idr_t] = i + end + columns[i] = {} end local archetype = { - id = id, - types = types, - type = ty, columns = columns, - entities = {}, edges = {}, - records = {}, + entities = {}, + id = id, + records = records, + type = ty, + types = types, } world.archetypeIndex[ty] = archetype world.archetypes[id] = archetype - if #types > 0 then - createArchetypeRecords(world.componentIndex, archetype, prev) - end return archetype end local World = {} World.__index = World -function World.new() +function World.new() local self = setmetatable({ - entityIndex = {}, - componentIndex = {}, - archetypes = {}, archetypeIndex = {}, - ROOT_ARCHETYPE = (nil :: any) :: Archetype, - nextEntityId = 0, - nextComponentId = 0, - nextArchetypeId = 0, + archetypes = {} :: Archetypes, + componentIndex = {} :: ComponentIndex, + entityIndex = { + dense = {}, + sparse = {}, + } :: EntityIndex, hooks = { - [ON_ADD] = {} - } + [ON_ADD] = {}, + }, + nextArchetypeId = 0, + nextComponentId = 0, + nextEntityId = 0, + ROOT_ARCHETYPE = (nil :: any) :: Archetype, }, World) - return self + self.ROOT_ARCHETYPE = archetypeOf(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 - }) -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, - }) +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 + +function World.entity(world: World) + local entityId = world.nextEntityId + 1 + world.nextEntityId = entityId + return nextEntityId(world.entityIndex, entityId + REST) +end + +-- TODO: +-- should have an additional `index` parameter which selects the nth target +-- this is important when an entity can have multiple relationships with the same target +function World.target(world: World, entity: i53, relation: i24): i24? + local entityIndex = world.entityIndex + local record = entityIndex.sparse[entity] + local archetype = record.archetype + if not archetype then + return nil + end + local componentRecord = world.componentIndex[ECS_PAIR(relation, WILDCARD)] + if not componentRecord then + return nil + end + + local archetypeRecord = componentRecord.cache[archetype.id] + if not archetypeRecord then + return nil + end + + return ECS_PAIR_OBJECT(entityIndex, archetype.types[archetypeRecord]) +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(world: World, id: i53) + local componentIndex = world.componentIndex + local archetypesMap = componentIndex[id] + local archetypes = world.archetypes + if archetypesMap then + for archetypeId in archetypesMap.cache do + for _, entity in archetypes[archetypeId].entities do + world:remove(entity, id) + end + end + + componentIndex[id] = nil + end +end + +function World.delete(world: World, entityId: i53) + local record = world.entityIndex.sparse[entityId] + if not record then + return + end + local entityIndex = world.entityIndex + local sparse, dense = entityIndex.sparse, entityIndex.dense + local archetype = record.archetype + local row = record.row + + archetypeDelete(world, entityId) + -- TODO: should traverse linked )component records to pairs including entityId + archetypeDelete(world, ECS_PAIR(entityId, WILDCARD)) + archetypeDelete(world, ECS_PAIR(WILDCARD, entityId)) + + if archetype then + local entities = archetype.entities + local last = #entities + + if row ~= last then + local entityToMove = entities[last] + dense[record.dense] = entityToMove + sparse[entityToMove] = record + end + + entities[row], entities[last] = entities[last], nil + + local columns = archetype.columns + + destructColumns(columns, last, row) + end + + sparse[entityId] = nil + dense[#dense] = nil end export type World = typeof(World.new()) @@ -217,7 +456,7 @@ local function ensureArchetype(world: World, types, prev) if #types < 1 then return world.ROOT_ARCHETYPE end - + local ty = hash(types) local archetype = world.archetypeIndex[ty] if archetype then @@ -228,9 +467,7 @@ local function ensureArchetype(world: World, types, prev) end local function findInsert(types: { i53 }, toAdd: i53) - local count = #types - for i = 1, count do - local id = types[i] + for i, id in types do if id == toAdd then return -1 end @@ -238,69 +475,75 @@ 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) local types = node.types -- Component IDs are added incrementally, so inserting and sorting - -- them each time would be expensive. Instead this insertion sort can find the insertion + -- 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 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 - end + from = from or world.ROOT_ARCHETYPE + local edge = ensureEdge(from, componentId) - - if not edge.add then - -- Save an edge using the component ID to the archetype to allow + 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] = {} +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 entityIndex[id] :: Record end -function World.set(world: World, entityId: i53, componentId: i53, data: unknown) - local record = ensureRecord(world.entityIndex, entityId) +-- Symmetric like `World.add` but idempotent +function World.set(world: World, entityId: i53, componentId: i53, data: unknown) + local record = world.entityIndex.sparse[entityId] local from = record.archetype local to = archetypeTraverseAdd(world, componentId, from) - if from == to then - -- If the archetypes are the same it can avoid moving the entity - -- and just set the data directly. + if from == to then + -- If the archetypes are the same it can avoid moving the entity + -- and just set the data directly. local archetypeRecord = to.records[componentId] from.columns[archetypeRecord][record.row] = data -- Should fire an OnSet event here. @@ -308,13 +551,12 @@ function World.set(world: World, entityId: i53, componentId: i53, data: unknown) end 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) else 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 @@ -322,33 +564,42 @@ 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) - - if not edge.remove then - local to = table.clone(from.types) - table.remove(to, table.find(to, componentId)) - edge.remove = ensureArchetype(world, to, from) + local remove = edge.remove + if not remove then + local to = table.clone(from.types) + 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 - return edge.remove + return remove end -function World.remove(world: World, entityId: i53, componentId: i53) - local record = ensureRecord(world.entityIndex, entityId) +function World.remove(world: World, entityId: i53, componentId: i53) + local entityIndex = world.entityIndex + local record = entityIndex.sparse[entityId] local sourceArchetype = record.archetype local destinationArchetype = archetypeTraverseRemove(world, componentId, sourceArchetype) - if sourceArchetype and not (sourceArchetype == destinationArchetype) then - moveEntity(world.entityIndex, entityId, record, destinationArchetype) + if sourceArchetype and not (sourceArchetype == destinationArchetype) then + 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 + if not archetype then + return nil + end + local archetypeRecord = archetype.records[componentId] if not archetypeRecord then @@ -360,172 +611,177 @@ 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] + local record = world.entityIndex.sparse[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, ...): () -> () + return actualNoOperation :: any end local EmptyQuery = { __iter = noop, - without = noop + without = noop, } EmptyQuery.__index = EmptyQuery setmetatable(EmptyQuery, EmptyQuery) export type Query = typeof(EmptyQuery) -function World.query(world: World, ...: i53): Query +function World.query(world: World, ...): Query + -- breaking? + if (...) == nil then + error("Missing components") + end + local compatibleArchetypes = {} + local length = 0 + local components = { ... } local archetypes = world.archetypes local queryLength = #components - if queryLength == 0 then - error("Missing components") - end - 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 end - if firstArchetypeMap == nil or map.size < firstArchetypeMap.size then + if firstArchetypeMap == nil or map.size < firstArchetypeMap.size then firstArchetypeMap = map end end - for id in firstArchetypeMap.sparse do + for id in firstArchetypeMap.cache do local archetype = archetypes[id] local archetypeRecords = archetype.records - local indices = {} + + local indices = {} local skip = false - - for i, componentId in components do + + for i, componentId in components do local index = archetypeRecords[componentId] - if not index then + if not index then skip = true break end - indices[i] = archetypeRecords[componentId] + -- index should be index.offset + indices[i] = index end - if skip then + if skip then continue end - table.insert(compatibleArchetypes, { archetype, indices }) + + length += 1 + compatibleArchetypes[length] = { + archetype = archetype, + indices = indices, + } end local lastArchetype, compatibleArchetype = next(compatibleArchetypes) - if not lastArchetype then + if not lastArchetype then return EmptyQuery end - + local preparedQuery = {} preparedQuery.__index = preparedQuery - function preparedQuery:without(...) - local components = { ... } - for i = #compatibleArchetypes, 1, -1 do - local archetype = compatibleArchetypes[i][1] + function preparedQuery:without(...) + local withoutComponents = { ... } + for i = #compatibleArchetypes, 1, -1 do + local archetype = compatibleArchetypes[i].archetype + 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 + + if shouldRemove then table.remove(compatibleArchetypes, i) end - end + end lastArchetype, compatibleArchetype = next(compatibleArchetypes) - if not lastArchetype then + if not lastArchetype then return EmptyQuery end - + return self end local lastRow local queryOutput = {} - - function preparedQuery:__iter() - return function() - local archetype = compatibleArchetype[1] - local row = next(archetype.entities, lastRow) - while row == nil do + function preparedQuery:__iter() + return function() + 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 + 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] - - if queryLength == 1 then + local tr = compatibleArchetype.indices + + if queryLength == 1 then return entityId, columns[tr[1]][row] - elseif queryLength == 2 then + 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] - elseif queryLength == 4 then - return entityId, - columns[tr[1]][row], - columns[tr[2]][row], - columns[tr[3]][row], - columns[tr[4]][row] - elseif queryLength == 5 then - return entityId, + elseif queryLength == 3 then + 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] + elseif queryLength == 5 then + return entityId, columns[tr[1]][row], columns[tr[2]][row], columns[tr[3]][row], columns[tr[4]][row], columns[tr[5]][row] - elseif queryLength == 6 then - return entityId, + elseif queryLength == 6 then + return entityId, columns[tr[1]][row], columns[tr[2]][row], columns[tr[3]][row], columns[tr[4]][row], columns[tr[5]][row], columns[tr[6]][row] - elseif queryLength == 7 then - return entityId, + elseif queryLength == 7 then + return entityId, columns[tr[1]][row], columns[tr[2]][row], columns[tr[3]][row], @@ -533,8 +789,8 @@ function World.query(world: World, ...: i53): Query columns[tr[5]][row], columns[tr[6]][row], columns[tr[7]][row] - elseif queryLength == 8 then - return entityId, + elseif queryLength == 8 then + return entityId, columns[tr[1]][row], columns[tr[2]][row], columns[tr[3]][row], @@ -545,8 +801,8 @@ function World.query(world: World, ...: i53): Query columns[tr[8]][row] end - for i in components do - queryOutput[i] = tr[i][row] + for i in components do + queryOutput[i] = columns[tr[i]][row] end return entityId, unpack(queryOutput, 1, queryLength) @@ -556,165 +812,122 @@ 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.") - end - world.nextComponentId = componentId - return componentId -end +function World.__iter(world: World): () -> (number?, unknown?) + local dense = world.entityIndex.dense + local sparse = world.entityIndex.sparse + local last -function World.entity(world: World) - world.nextEntityId += 1 - return world.nextEntityId + REST -end - -function World.delete(world: World, entityId: i53) - local entityIndex = world.entityIndex - local record = entityIndex[entityId] - moveEntity(entityIndex, entityId, record, world.ROOT_ARCHETYPE) - -- Since we just appended an entity to the ROOT_ARCHETYPE we have to remove it from - -- the entities array and delete the record. We know there won't be the hole since - -- we are always removing the last row. - --world.ROOT_ARCHETYPE.entities[record.row] = nil - --entityIndex[entityId] = nil -end - -function World.observer(world: World, ...) - local componentIds = { ... } - - return { - event = function(event) - local hook = world.hooks[event] - world.hooks[event] = nil - - local last, change - return function() - last, change = next(hook, last) - if not last then - return - end - - local matched = false - - while not matched do - local skip = false - for _, id in change.ids do - if not table.find(componentIds, id) then - skip = true - break - end - end - - if skip then - last, change = next(hook, last) - continue - end - - matched = true - end - - local queryOutput = {} - 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]) - end - - return archetype.entities[row], unpack(queryOutput, 1, #queryOutput) - end + 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 -export type Component = { - __nominal_type_only_dont_use_or_you_will_be_fired: T -} +-- __nominal_type_dont_use could not be any or T as it causes a type error +-- or produces a union +export type Component = T & {__nominal_type_dont_use: never} +export type Entity = Component +export type Relationship = Component +type ctype = Component -type It = typeof(setmetatable({} :: { - without: (self: It, U...) -> It -}, {} :: { - __iter: (self: It) -> (number, T...), -})) +export type QueryShim = typeof(setmetatable( + {} :: { + --- Excludes the given selection from the query + without: (QueryShim, U...) -> QueryShim + }, + {} :: { + __iter: (QueryShim) -> () -> (Entity, T...) + } +)) -type WorldShim = { - observer: (self: WorldShim, T...) -> { event: (event: number) -> () -> (number, T...) }, - new: () -> WorldShim, - component: (self: WorldShim) -> Component, - entity: (self: WorldShim) -> number, - delete: (self: WorldShim, entity: number) -> (), - set: ((self: WorldShim, entity: number, component: Component, data: T) -> ()) - & ((self: WorldShim, entity: number, component: number, data: unknown) -> ()), - get: ((self: WorldShim, entity: number, component: Component) -> T) - & ((self: WorldShim, entity: number, component: number) -> any), - remove: ((self: WorldShim, entity: number, component: Component) -> T) - & ((self: WorldShim, entity: number, component: number) -> any), - query: (self: WorldShim, T...) -> It - --[[ - ((self: WorldShim, a: Component) -> () -> (number, a)) - & ((self: WorldShim, a: Component, b: Component) -> () -> (number, a, b)) - & (( - self: WorldShim, - a: Component, - b: Component, - c: Component - ) -> (number, a, b, c)) - & (( - self: WorldShim, - a: Component, - b: Component, - c: Component, - d: Component - ) -> (number, a, b, c, d)) - & (( - self: WorldShim, - a: Component, - b: Component, - c: Component, - d: Component, - e: Component - ) -> (number, a, b, c, d, e)) - & (( - self: WorldShim, - a: Component, - b: Component, - c: Component, - d: Component, - e: Component, - f: Component - ) -> (number, a, b, c, d, e, f)) - & (( - self: WorldShim, - a: Component, - b: Component, - c: Component, - d: Component, - e: Component, - f: Component, - g: Component - ) -> (number, a, b, c, d, e, f, g)) - & (( - self: WorldShim, - a: Component, - b: Component, - c: Component, - d: Component, - e: Component, - f: Component, - g: Component, - g: Component - ) -> (number, a, b, c, d, e, f, g, h)) - ]]-- -} +export type WorldShim = typeof(setmetatable( + {} :: { + + --- Creates a new entity + entity: (WorldShim) -> Entity, + --- Creates a new entity located in the first 256 ids. + --- These should be used for static components for fast access. + component: (WorldShim) -> Component, + --- Gets the target of an relationship. For example, when a user calls + --- `world:target(id, ChildOf(parent))`, you will obtain the parent entity. + target: (WorldShim, id: Entity, relation: Relationship) -> Entity?, + --- Deletes an entity and all it's related components and relationships. + delete: (WorldShim, id: Entity) -> (), + + --- Adds a component to the entity with no value + add: (WorldShim, id: Entity, component: Component) -> (), + --- Assigns a value to a component on the given entity + set: (WorldShim, id: Entity, component: Component, data: T) -> (), + --- Removes a component from the given entity + remove: (WorldShim, id: Entity, component: Component) -> (), + --- Retrieves the value of up to 4 components. These values may be nil. + get: + ((WorldShim, id: any, ctype) -> A) + & ((WorldShim, id: Entity, ctype, ctype) -> (A, B)) + & ((WorldShim, id: Entity, ctype, ctype, ctype) -> (A, B, C)) + & (WorldShim, id: Entity, ctype, ctype, ctype, ctype) -> (A, B, C, D), + + --- Searches the world for entities that match a given query + query: + ((WorldShim, ctype) -> QueryShim) + & ((WorldShim, ctype, ctype) -> QueryShim) + & ((WorldShim, ctype, ctype, ctype) -> QueryShim) + & ((WorldShim, ctype, ctype, ctype, ctype) -> QueryShim) + & ((WorldShim, ctype, ctype, ctype, ctype, ctype) -> QueryShim) + & ((WorldShim, ctype, ctype, ctype, ctype, ctype, ctype) -> QueryShim) + & ((WorldShim, ctype, ctype, ctype, ctype, ctype, ctype, ctype) -> QueryShim) + & ((WorldShim, ctype, ctype, ctype, ctype, ctype, ctype, ctype, ctype) -> QueryShim) + & ((WorldShim, ctype, ctype, ctype, ctype, ctype, ctype, ctype, ctype, ctype) -> QueryShim) + & ((WorldShim, ctype, ctype, ctype, ctype, ctype, ctype, ctype, ctype, ctype, ctype) -> QueryShim) + & ((WorldShim, ctype, ctype, ctype, ctype, ctype, ctype, ctype, ctype, ctype, ctype, ctype, ...ctype) -> QueryShim) + + }, + {} :: { + __iter: (world: WorldShim) -> () -> (number, {[unknown]: unknown?}) + } + +)) return table.freeze({ - World = (World :: any) :: WorldShim, - ON_ADD = ON_ADD, - ON_REMOVE = ON_REMOVE, - ON_SET = ON_SET, + World = (World :: any) :: {new: () -> WorldShim}, + + OnAdd = (ON_ADD :: any) :: Component, + OnRemove = (ON_REMOVE :: any) :: Component, + OnSet = (ON_SET :: any) :: Component, + Wildcard = (WILDCARD :: any) :: Component, + w = (WILDCARD :: any) :: Component, + Rest = REST, + + IS_PAIR = ECS_IS_PAIR, + ECS_ID = ECS_ENTITY_T_LO, + ECS_PAIR = ECS_PAIR, + ECS_GENERATION_INC = ECS_GENERATION_INC, + ECS_GENERATION = ECS_GENERATION, + ECS_PAIR_RELATION = ECS_PAIR_RELATION, + ECS_PAIR_OBJECT = ECS_PAIR_OBJECT, + + pair = (ECS_PAIR :: any) :: (pred: Entity, obj: Entity) -> Relationship, + getAlive = getAlive }) diff --git a/lib/init.spec.lua b/lib/init.spec.lua deleted file mode 100644 index 0820ca3..0000000 --- a/lib/init.spec.lua +++ /dev/null @@ -1,321 +0,0 @@ ---!strict - -local jecs = require(script.Parent) -local world = jecs.World.new() - -type Component = jecs.Component - -local A, B, C, D = world:entity(), world:entity(), world:entity(), world:entity() -local E, F, G, H = world:entity(), world:entity(), world:entity(), world:entity() - -print("A", A) -print("B", B) -print("C", C) -print("D", D) -print("E", E) -print("F", F) -print("G", G) -print("H", H) - -local common = 0 -local N = 2^16-2 -local archetypes = {} -local function flip() - return math.random() >= 0.5 -end - -local amountOfCombination = 0 -for i = 1, N do - local entity = world:entity() - local combination = "" - - if flip() then - combination ..= "2_" - world:set(entity, B, { value = true}) - end - if flip() then - combination ..= "3_" - world:set(entity, C, { value = true}) - end - if flip() then - combination ..= "4_" - world:set(entity, D, { value = true}) - end - if flip() then - combination ..= "5_" - world:set(entity, E, { value = true}) - end - if flip() then - combination ..= "6_" - world:set(entity, F, { value = true}) - end - if flip() then - combination ..= "7_" - world:set(entity, G, { value = true}) - end - if flip() then - combination ..= "8" - world:set(entity, H, { value = true}) - end - - if #combination == 7 then - combination = "1_" .. combination - common += 1 - world:set(entity, A, { value = true}) - end - - if combination:find("2") - and combination:find("3") - and combination:find("4") - and combination:find("6") - then - amountOfCombination += 1 - end - archetypes[combination] = true -end - -return function() - describe("World", function() - it("should add component", function() - local id = world:entity() - world:set(id, A, true) - world:set(id, B, 1) - local s = world:component() :: jecs.Component - world:set(id, s, true) - - local id1 = world:entity() - world:set(id1, A, "hello") - expect(world:get(id, A)).to.equal(true) - expect(world:get(id, B)).to.equal(1) - expect(world:get(id1, A)).to.equal("hello") - end) - - it("should remove component", function() - local Tag = world:entity() - local entities = {} - for i = 1, 10 do - local entity = world:entity() - entities[i] = entity - world:set(entity, Tag) - end - - for i = 1, 10 do - local entity = entities[i] - expect(world:get(entity, Tag)).to.equal(nil) - world:remove(entity, Tag) - end - - end) - - it("should override component data", function() - - local id = world:entity() - world:set(id, A, true) - expect(world:get(id, A)).to.equal(true) - - world:set(id, A, false) - expect(world:get(id, A)).to.equal(false) - - end) - - it("should not query a removed component", function() - local Tag = (world:entity() :: any) :: jecs.Component - local AnotherTag = world:entity() - - local entity = world:entity() - world:set(entity, Tag) - world:set(entity, AnotherTag) - world:remove(entity, AnotherTag) - - local added = 0 - for e, t in world:query(Tag) do - added += 1 - end - expect(added).to.equal(0) - end) - - it("should query correct number of compatible archetypes", function() - local added = 0 - for _ in world:query(B, C, D, F) do - added += 1 - end - expect(added).to.equal(amountOfCombination) - end) - - it("should not query poisoned players", function() - local Player = world:entity() - local Health = world:entity() - local Poison = world:entity() - - local one = world:entity() - world:set(one, Player, { name = "alice"}) - world:set(one, Health, 100) - world:set(one, Poison) - - local two = world:entity() - world:set(two, Player, { name = "bob"}) - world:set(two, Health, 90) - - local withoutCount = 0 - for _id, _player in world:query(Player):without(Poison) do - withoutCount += 1 - end - - expect(withoutCount).to.equal(1) - end) - - it("should allow calling world:entity before world:component", function() - for _ = 1, 256 do - world:entity() - end - expect(world:component()).to.be.ok() - end) - - it("should skip iteration", function() - local Position, Velocity = world:entity(), world:entity() - local e = world:entity() - world:set(e, Position, Vector3.zero) - world:set(e, Velocity, Vector3.one) - local added = 0 - for i in world:query(Position):without(Velocity) do - added += 1 - end - 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() - local A = world:component() - local B = world:component() - - local entities = {} - for i = 1, N do - local id = world:entity() - - - world:set(id, A, true) - if i > 5 then world:set(id, B, true) end - entities[i] = id - end - - for id in world:query(A) do - local i = table.find(entities, id) - expect(i).to.be.ok() - table.remove(entities, i) - end - - expect(#entities).to.equal(0) - end) - - it("should query all matching entities when irrelevant component is removed", function() - - - local world = jecs.World.new() - local A = world:component() - local B = world:component() :: jecs.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) end - entities[i] = id - end - - local added = 0 - for id in world:query(A) do - added += 1 - local i = table.find(entities, id) - expect(i).to.be.ok() - table.remove(entities, i) - end - - expect(added).to.equal(N) - end) - - it("should query all entities without B", function() - local world = jecs.World.new() - local A = world:component() - local B = world:component() - - local entities = {} - for i = 1, N do - local id = world:entity() - - world:set(id, A, true) - if i < 5 then - entities[i] = id - else - world:set(id, B, true) - end - - end - - for id in world:query(A):without(B) do - local i = table.find(entities, id) - expect(i).to.be.ok() - table.remove(entities, i) - end - - expect(#entities).to.equal(0) - end) - - it("should allow setting components in arbitrary order", function() - local world = jecs.World.new() - - local Health = world:entity() - local Poison = world:component() - - local id = world:entity() - world:set(id, Poison, 5) - world:set(id, Health, 50) - - expect(world:get(id, Poison)).to.equal(5) - end) - - it("Should allow deleting components", function() - local world = jecs.World.new() - - local Health = world:entity() - local Poison = world:component() - - local id = world:entity() - world:set(id, Poison, 5) - world:set(id, Health, 50) - world:delete(id) - - expect(world:get(id, Poison)).to.never.be.ok() - expect(world:get(id, Health)).to.never.be.ok() - end) - - it("try types", function() - - local test = world:component() :: Component - - for id, t in world:query(test) do - print(t) - end - - - end) - end) -end \ No newline at end of file diff --git a/logo.png b/logo_old.png similarity index 100% rename from logo.png rename to logo_old.png diff --git a/mirror/init.lua b/mirror/init.lua index e10d9de..6d9c1fe 100644 --- a/mirror/init.lua +++ b/mirror/init.lua @@ -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,13 +31,13 @@ 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, removed: Ty, @@ -51,38 +51,58 @@ local REST = HI_COMPONENT_ID + 4 local function transitionArchetype( entityIndex: EntityIndex, - destinationArchetype: Archetype, + to: Archetype, destinationRow: i24, - sourceArchetype: Archetype, + from: Archetype, sourceRow: i24 ) - local columns = sourceArchetype.columns - local sourceEntities = sourceArchetype.entities - local destinationEntities = destinationArchetype.entities - local destinationColumns = destinationArchetype.columns + local columns = from.columns + local sourceEntities = from.entities + local destinationEntities = to.entities + local destinationColumns = to.columns + local tr = to.records + local types = from.types - for componentId, column in columns do - local targetColumn = destinationColumns[componentId] - if targetColumn then + for i, column in columns do + -- Retrieves the new column index from the source archetype's record from each component + -- We have to do this because the columns are tightly packed and indexes may not correspond to each other. + local targetColumn = destinationColumns[tr[types[i]]] + + -- Sometimes target column may not exist, e.g. when you remove a component. + if targetColumn then targetColumn[destinationRow] = column[sourceRow] end - column[sourceRow] = column[#column] - column[#column] = nil + -- If the entity is the last row in the archetype then swapping it would be meaningless. + local last = #column + if sourceRow ~= last then + -- Swap rempves columns to ensure there are no holes in the archetype. + column[sourceRow] = column[last] + end + column[last] = nil end - destinationEntities[destinationRow] = sourceEntities[sourceRow] - entityIndex[sourceEntities[sourceRow]].row = destinationRow + -- Move the entity from the source to the destination archetype. + 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 - sourceEntities[sourceRow] = sourceEntities[movedAway] - entityIndex[sourceEntities[movedAway]].row = sourceRow + if sourceRow ~= movedAway then + 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) @@ -105,102 +125,104 @@ 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 - createArchetypeRecords(world.componentIndex, archetype, prev) + if length > 0 then + createArchetypeRecords(world.componentIndex, archetype, prev) + end return archetype end local World = {} World.__index = World -function World.new() +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 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 +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 - export type World = typeof(World.new()) local function ensureArchetype(world: World, types, prev) if #types < 1 then return world.ROOT_ARCHETYPE end + local ty = hash(types) local archetype = world.archetypeIndex[ty] if archetype then @@ -210,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 @@ -221,13 +241,18 @@ 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) local types = node.types + -- Component IDs are added incrementally, so inserting and sorting + -- them each time would be expensive. Instead this insertion sort can find the insertion + -- point in the types array. local at = findInsert(types, componentId) if at == -1 then + -- If it finds a duplicate, it just means it is the same archetype so it can return it + -- directly instead of needing to hash types for a lookup to the archetype. return node end @@ -237,88 +262,108 @@ 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 not world.ROOT_ARCHETYPE then - local ROOT_ARCHETYPE = archetypeOf(world, {}, nil) - world.ROOT_ARCHETYPE = ROOT_ARCHETYPE - end - from = world.ROOT_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 + local edge = ensureEdge(from, componentId) - - if not edge.add then - edge.add = findArchetypeWith(world, 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. + 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) +function World.set(world: World, entityId: i53, componentId: i53, data: unknown) local record = ensureRecord(world.entityIndex, entityId) - local sourceArchetype = record.archetype - local destinationArchetype = archetypeTraverseAdd(world, componentId, sourceArchetype) + local from = record.archetype + local to = archetypeTraverseAdd(world, componentId, from) - if sourceArchetype == destinationArchetype then - local archetypeRecord = destinationArchetype.records[componentId] - destinationArchetype.columns[archetypeRecord][record.row] = data + if from == to then + -- If the archetypes are the same it can avoid moving the entity + -- and just set the data directly. + local archetypeRecord = to.records[componentId] + from.columns[archetypeRecord][record.row] = data + -- Should fire an OnSet event here. return end - if sourceArchetype then - moveEntity(world.entityIndex, entityId, record, destinationArchetype) + if from then + -- If there was a previous archetype, then the entity needs to move the archetype + moveEntity(world.entityIndex, entityId, record, to) else - if #destinationArchetype.types > 0 then - newEntity(entityId, record, destinationArchetype) - onNotifyAdd(world, destinationArchetype, sourceArchetype, record.row, { componentId }) + if #to.types > 0 then + -- When there is no previous archetype it should create the archetype + newEntity(entityId, record, to) + onNotifyAdd(world, to, from, record.row, {componentId}) end end - local archetypeRecord = destinationArchetype.records[componentId] - destinationArchetype.columns[archetypeRecord][record.row] = data + local archetypeRecord = to.records[componentId] + to.columns[archetypeRecord][record.row] = data end local function archetypeTraverseRemove(world: World, componentId: i53, archetype: Archetype?): Archetype local from = (archetype or world.ROOT_ARCHETYPE) :: Archetype local edge = ensureEdge(from, componentId) - - if not edge.remove then - local to = table.clone(from.types) + 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) +function World.remove(world: World, entityId: i53, componentId: i53) + 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) + if sourceArchetype and not (sourceArchetype == destinationArchetype) then + moveEntity(entityIndex, entityId, record, destinationArchetype) end end -local function get(componentIndex: { [i24]: ArchetypeMap }, record: Record, componentId: i24) +-- Keeping the function as small as possible to enable inlining +local function get(record: Record, componentId: i24) local archetype = record.archetype - local archetypeRecord = componentIndex[componentId].sparse[archetype.id] + local archetypeRecord = archetype.records[componentId] if not archetypeRecord then return nil @@ -329,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) @@ -365,138 +410,136 @@ 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 end - if firstArchetypeMap == nil or map.size < firstArchetypeMap.size then + if firstArchetypeMap == nil or map.size < firstArchetypeMap.size then firstArchetypeMap = map end end - local i = 0 for id in firstArchetypeMap.sparse do local archetype = archetypes[id] local archetypeRecords = archetype.records - local indices = {} + local indices = {} local skip = false - - for j, componentId in components do + + for i, componentId in components do local index = archetypeRecords[componentId] - if not index then + if not index then skip = true break end - indices[j] = archetypeRecords[componentId] + indices[i] = index end - if skip then + if skip then continue end - i += 1 - table.insert(compatibleArchetypes, { archetype, indices }) + + length += 1 + compatibleArchetypes[length] = {archetype, indices} end local lastArchetype, compatibleArchetype = next(compatibleArchetypes) - if not lastArchetype then + if not lastArchetype then return EmptyQuery end - + local preparedQuery = {} preparedQuery.__index = preparedQuery - function preparedQuery:without(...) - local components = { ... } - for i = #compatibleArchetypes, 1, -1 do + function preparedQuery:without(...) + 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 + + if shouldRemove then table.remove(compatibleArchetypes, i) end - end + end lastArchetype, compatibleArchetype = next(compatibleArchetypes) - if not lastArchetype then + if not lastArchetype then return EmptyQuery end - + return self end local lastRow local queryOutput = {} - - function preparedQuery:__iter() - return function() + function preparedQuery:__iter() + return function() local archetype = compatibleArchetype[1] local row = next(archetype.entities, lastRow) - while row == nil do + while row == nil do lastArchetype, compatibleArchetype = next(compatibleArchetypes, lastArchetype) - if lastArchetype == nil then - return + if lastArchetype == nil then + return end archetype = compatibleArchetype[1] row = next(archetype.entities, row) end lastRow = row - + local entityId = archetype.entities[row :: number] local columns = archetype.columns local tr = compatibleArchetype[2] - if queryLength == 1 then + if queryLength == 1 then return entityId, columns[tr[1]][row] - elseif queryLength == 2 then + 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] - elseif queryLength == 4 then - return entityId, - columns[tr[1]][row], - columns[tr[2]][row], - columns[tr[3]][row], - columns[tr[4]][row] - elseif queryLength == 5 then - return entityId, + elseif queryLength == 3 then + 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] + elseif queryLength == 5 then + return entityId, columns[tr[1]][row], columns[tr[2]][row], columns[tr[3]][row], columns[tr[4]][row], columns[tr[5]][row] - elseif queryLength == 6 then - return entityId, + elseif queryLength == 6 then + return entityId, columns[tr[1]][row], columns[tr[2]][row], columns[tr[3]][row], columns[tr[4]][row], columns[tr[5]][row], columns[tr[6]][row] - elseif queryLength == 7 then - return entityId, + elseif queryLength == 7 then + return entityId, columns[tr[1]][row], columns[tr[2]][row], columns[tr[3]][row], @@ -504,8 +547,8 @@ function World.query(world: World, ...: i53): Query columns[tr[5]][row], columns[tr[6]][row], columns[tr[7]][row] - elseif queryLength == 8 then - return entityId, + elseif queryLength == 8 then + return entityId, columns[tr[1]][row], columns[tr[2]][row], columns[tr[3]][row], @@ -516,8 +559,8 @@ function World.query(world: World, ...: i53): Query columns[tr[8]][row] end - for i in components do - queryOutput[i] = tr[i][row] + for i in components do + queryOutput[i] = columns[tr[i]][row] end return entityId, unpack(queryOutput, 1, queryLength) @@ -527,72 +570,90 @@ 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 - error("Too many components") +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 componentId 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) + 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 componentIds = {...} + local idsCount = #componentIds + local hooks = world.hooks + return { - event = function(event) - local hook = world.hooks[event] - world.hooks[event] = nil + event = function(event) + local hook = hooks[event] + hooks[event] = nil local last, change - return function() + return function() last, change = next(hook, last) - if not last then + if not last then return end local matched = false - - while not matched do + local ids = change.ids + + while not matched do local skip = false - for _, id in change.ids do - if not table.find(componentIds, id) then + for _, id in ids do + if not table.find(componentIds, id) then skip = true break end end - - if skip then + + 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; }) diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..1a47333 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,186 @@ +site_name: Jecs +site_url: jecs.github.io/jecs +repo_name: ukendio/jecs +repo_url: https://github.com/ukendio/jecs + +extra: + version: + provider: mike + +theme: + name: material + custom_dir: docs/assets/overrides + logo: assets/logo + favicon: assets/logo-dark.svg + palette: + - media: "(prefers-color-scheme: dark)" + scheme: fusiondoc-dark + toggle: + icon: octicons/sun-24 + title: Switch to light theme + - media: "(prefers-color-scheme: light)" + scheme: fusiondoc-light + toggle: + icon: octicons/moon-24 + title: Switch to dark theme + font: + text: Plus Jakarta Sans + code: JetBrains Mono + features: + - navigation.tabs + - navigation.top + - navigation.sections + - navigation.instant + - navigation.indexes + - search.suggest + - search.highlight + icon: + repo: octicons/mark-github-16 + +extra_css: + - assets/theme/fusiondoc.css + - assets/theme/colours.css + - assets/theme/code.css + - assets/theme/paragraph.css + - assets/theme/page.css + - assets/theme/admonition.css + - assets/theme/404.css + - assets/theme/api-reference.css + - assets/theme/dev-tools.css + +extra_javascript: + - assets/scripts/smooth-scroll.js + +nav: + - Home: index.md + - Tutorials: + - Get Started: tutorials/index.md + - Installing Fusion: tutorials/get-started/installing-fusion.md + - Developer Tools: tutorials/get-started/developer-tools.md + - Getting Help: tutorials/get-started/getting-help.md + - Fundamentals: + - Scopes: tutorials/fundamentals/scopes.md + - Values: tutorials/fundamentals/values.md + - Observers: tutorials/fundamentals/observers.md + - Computeds: tutorials/fundamentals/computeds.md + - Tables: + - ForValues: tutorials/tables/forvalues.md + - ForKeys: tutorials/tables/forkeys.md + - ForPairs: tutorials/tables/forpairs.md + - Animation: + - Tweens: tutorials/animation/tweens.md + - Springs: tutorials/animation/springs.md + - Roblox: + - Hydration: tutorials/roblox/hydration.md + - New Instances: tutorials/roblox/new-instances.md + - Parenting: tutorials/roblox/parenting.md + - Events: tutorials/roblox/events.md + - Change Events: tutorials/roblox/change-events.md + - Outputs: tutorials/roblox/outputs.md + - References: tutorials/roblox/references.md + - Best Practices: + - Components: tutorials/best-practices/components.md + - Instance Handling: tutorials/best-practices/instance-handling.md + - Callbacks: tutorials/best-practices/callbacks.md + - State: tutorials/best-practices/state.md + - Sharing Values: tutorials/best-practices/sharing-values.md + - Error Safety: tutorials/best-practices/error-safety.md + - Optimisation: tutorials/best-practices/optimisation.md + + - Examples: + - Home: examples/index.md + - Cookbook: + - examples/cookbook/index.md + - Player List: examples/cookbook/player-list.md + - Animated Computed: examples/cookbook/animated-computed.md + - Fetch Data From Server: examples/cookbook/fetch-data-from-server.md + - Light & Dark Theme: examples/cookbook/light-and-dark-theme.md + - Button Component: examples/cookbook/button-component.md + - Loading Spinner: examples/cookbook/loading-spinner.md + - Drag & Drop: examples/cookbook/drag-and-drop.md + - API Reference: + - api-reference/index.md + - General: + - Errors: api-reference/general/errors.md + - Types: + - Contextual: api-reference/general/types/contextual.md + - Version: api-reference/general/types/version.md + - Members: + - Contextual: api-reference/general/members/contextual.md + - Safe: api-reference/general/members/safe.md + - version: api-reference/general/members/version.md + - Memory: + - Types: + - Scope: api-reference/memory/types/scope.md + - ScopedObject: api-reference/memory/types/scopedobject.md + - Task: api-reference/memory/types/task.md + - Members: + - deriveScope: api-reference/memory/members/derivescope.md + - doCleanup: api-reference/memory/members/docleanup.md + - scoped: api-reference/memory/members/scoped.md + - State: + - Types: + - UsedAs: api-reference/state/types/usedas.md + - Computed: api-reference/state/types/computed.md + - Dependency: api-reference/state/types/dependency.md + - Dependent: api-reference/state/types/dependent.md + - For: api-reference/state/types/for.md + - Observer: api-reference/state/types/observer.md + - StateObject: api-reference/state/types/stateobject.md + - Use: api-reference/state/types/use.md + - Value: api-reference/state/types/value.md + - Members: + - Computed: api-reference/state/members/computed.md + - ForKeys: api-reference/state/members/forkeys.md + - ForPairs: api-reference/state/members/forpairs.md + - ForValues: api-reference/state/members/forvalues.md + - Observer: api-reference/state/members/observer.md + - peek: api-reference/state/members/peek.md + - Value: api-reference/state/members/value.md + - Roblox: + - Types: + - Child: api-reference/roblox/types/child.md + - PropertyTable: api-reference/roblox/types/propertytable.md + - SpecialKey: api-reference/roblox/types/specialkey.md + - Members: + - Attribute: api-reference/roblox/members/attribute.md + - AttributeChange: api-reference/roblox/members/attributechange.md + - AttributeOut: api-reference/roblox/members/attributeout.md + - Children: api-reference/roblox/members/children.md + - Hydrate: api-reference/roblox/members/hydrate.md + - New: api-reference/roblox/members/new.md + - OnChange: api-reference/roblox/members/onchange.md + - OnEvent: api-reference/roblox/members/onevent.md + - Out: api-reference/roblox/members/out.md + - Ref: api-reference/roblox/members/ref.md + - Animation: + - Types: + - Animatable: api-reference/animation/types/animatable.md + - Spring: api-reference/animation/types/spring.md + - Tween: api-reference/animation/types/tween.md + - Members: + - Tween: api-reference/animation/members/tween.md + - Spring: api-reference/animation/members/spring.md + - Extras: + - Home: extras/index.md + - Backgrounds: extras/backgrounds.md + - Brand Guidelines: extras/brand-guidelines.md + +markdown_extensions: + - admonition + - attr_list + - meta + - md_in_html + - pymdownx.superfences + - pymdownx.betterem + - pymdownx.details + - pymdownx.tabbed: + alternate_style: true + - pymdownx.inlinehilite + - toc: + permalink: true + - pymdownx.highlight: + guess_lang: false + - pymdownx.emoji: + emoji_index: !!python/name:materialx.emoji.twemoji + emoji_generator: !!python/name:materialx.emoji.to_svg diff --git a/newMatter.lua b/newMatter.lua deleted file mode 100644 index 150bbb8..0000000 --- a/newMatter.lua +++ /dev/null @@ -1,1421 +0,0 @@ ---!optimize 2 ---!native ---!strict - -local None = {} - -local function merge(one, two) - local new = table.clone(one) - - for key, value in two do - if value == None then - new[key] = nil - else - new[key] = value - end - end - - return new -end - --- https://github.com/freddylist/llama/blob/master/src/List/toSet.lua -local function toSet(list) - local set = {} - - for _, v in ipairs(list) do - set[v] = true - end - - return set -end - --- https://github.com/freddylist/llama/blob/master/src/Dictionary/values.lua -local function values(dictionary) - local valuesList = {} - - local index = 1 - - for _, value in pairs(dictionary) do - valuesList[index] = value - index = index + 1 - end - - return valuesList -end - -local stack = {} - -local function newStackFrame(node) - return { - node = node, - accessedKeys = {}, - } -end - -local function cleanup() - local currentFrame = stack[#stack] - - for baseKey, state in pairs(currentFrame.node.system) do - for key, value in pairs(state.storage) do - if not currentFrame.accessedKeys[baseKey] or not currentFrame.accessedKeys[baseKey][key] then - local cleanupCallback = state.cleanupCallback - - if cleanupCallback then - local shouldAbortCleanup = cleanupCallback(value) - - if shouldAbortCleanup then - continue - end - end - - state.storage[key] = nil - end - end - end -end - -local function start(node, fn) - table.insert(stack, newStackFrame(node)) - fn() - cleanup() - table.remove(stack, #stack) -end - -local function withinTopoContext() - return #stack ~= 0 -end - -local function useFrameState() - return stack[#stack].node.frame -end - -local function useCurrentSystem() - if #stack == 0 then - return - end - - return stack[#stack].node.currentSystem -end - - ---[=[ - @within Matter - - :::tip - **Don't use this function directly in your systems.** - - This function is used for implementing your own topologically-aware functions. It should not be used in your - systems directly. You should use this function to implement your own utilities, similar to `useEvent` and - `useThrottle`. - ::: - - `useHookState` does one thing: it returns a table. An empty, pristine table. Here's the cool thing though: - it always returns the *same* table, based on the script and line where *your function* (the function calling - `useHookState`) was called. - - ### Uniqueness - - If your function is called multiple times from the same line, perhaps within a loop, the default behavior of - `useHookState` is to uniquely identify these by call count, and will return a unique table for each call. - - However, you can override this behavior: you can choose to key by any other value. This means that in addition to - script and line number, the storage will also only return the same table if the unique value (otherwise known as the - "discriminator") is the same. - - ### Cleaning up - As a second optional parameter, you can pass a function that is automatically invoked when your storage is about - to be cleaned up. This happens when your function (and by extension, `useHookState`) ceases to be called again - next frame (keyed by script, line number, and discriminator). - - Your cleanup callback is passed the storage table that's about to be cleaned up. You can then perform cleanup work, - like disconnecting events. - - *Or*, you could return `true`, and abort cleaning up altogether. If you abort cleanup, your storage will stick - around another frame (even if your function wasn't called again). This can be used when you know that the user will - (or might) eventually call your function again, even if they didn't this frame. (For example, caching a value for - a number of seconds). - - If cleanup is aborted, your cleanup function will continue to be called every frame, until you don't abort cleanup, - or the user actually calls your function again. - - ### Example: useThrottle - - This is the entire implementation of the built-in `useThrottle` function: - - ```lua - local function cleanup(storage) - return os.clock() < storage.expiry - end - - local function useThrottle(seconds, discriminator) - local storage = useHookState(discriminator, cleanup) - - if storage.time == nil or os.clock() - storage.time >= seconds then - storage.time = os.clock() - storage.expiry = os.clock() + seconds - return true - end - - return false - end - ``` - - A lot of talk for something so simple, right? - - @param discriminator? any -- A unique value to additionally key by - @param cleanupCallback (storage: {}) -> boolean? -- A function to run when the storage for this hook is cleaned up -]=] -local function useHookState(discriminator, cleanupCallback): {} - local file, line = debug.info(3, "sl") - local fn = debug.info(2, "f") - - local baseKey = string.format("%s:%s:%d", tostring(fn), file, line) - - local currentFrame = stack[#stack] - - if currentFrame == nil then - error("Attempt to access topologically-aware storage outside of a Loop-system context.", 3) - end - - if not currentFrame.accessedKeys[baseKey] then - currentFrame.accessedKeys[baseKey] = {} - end - - local accessedKeys = currentFrame.accessedKeys[baseKey] - - local key = #accessedKeys - - if discriminator ~= nil then - if type(discriminator) == "number" then - discriminator = tostring(discriminator) - end - - key = discriminator - end - - accessedKeys[key] = true - - if not currentFrame.node.system[baseKey] then - currentFrame.node.system[baseKey] = { - storage = {}, - cleanupCallback = cleanupCallback, - } - end - - local storage = currentFrame.node.system[baseKey].storage - - if not storage[key] then - storage[key] = {} - end - - return storage[key] -end - -local topoRuntime = { - start = start, - useHookState = useHookState, - useFrameState = useFrameState, - useCurrentSystem = useCurrentSystem, - withinTopoContext = withinTopoContext, -} - - ---[=[ - @class Component - - A component is a named piece of data that exists on an entity. - Components are created and removed in the [World](/api/World). - - In the docs, the terms "Component" and "ComponentInstance" are used: - - **"Component"** refers to the base class of a specific type of component you've created. - This is what [`Matter.component`](/api/Matter#component) returns. - - **"Component Instance"** refers to an actual piece of data that can exist on an entity. - The metatable of a component instance table is its respective Component table. - - Component instances are *plain-old data*: they do not contain behaviors or methods. - - Since component instances are immutable, one helper function exists on all component instances, `patch`, - which allows reusing data from an existing component instance to make up for the ergonomic loss of mutations. -]=] - ---[=[ - @within Component - @type ComponentInstance {} - - The `ComponentInstance` type refers to an actual piece of data that can exist on an entity. - The metatable of the component instance table is set to its particular Component table. - - A component instance can be created by calling the Component table: - - ```lua - -- Component: - local MyComponent = Matter.component("My component") - - -- component instance: - local myComponentInstance = MyComponent({ - some = "data" - }) - - print(getmetatable(myComponentInstance) == MyComponent) --> true - ``` -]=] - --- This is a special value we set inside the component's metatable that will allow us to detect when --- a Component is accidentally inserted as a Component Instance. --- It should not be accessible through indexing into a component instance directly. -local DIAGNOSTIC_COMPONENT_MARKER = {} - -local nextId = 0 -local function newComponent(name, defaultData) - name = name or debug.info(2, "s") .. "@" .. debug.info(2, "l") - assert( - defaultData == nil or type(defaultData) == "table", - "if component default data is specified, it must be a table" - ) - - local component = {} - component.__index = component - - function component.new(data) - data = data or {} - - if defaultData then - data = merge(defaultData, data) - end - - return table.freeze(setmetatable(data, component)) - end - - --[=[ - @within Component - - ```lua - for id, target in world:query(Target) do - if shouldChangeTarget(target) then - world:insert(id, target:patch({ -- modify the existing component - currentTarget = getNewTarget() - })) - end - end - ``` - - A utility function used to immutably modify an existing component instance. Key/value pairs from the passed table - will override those of the existing component instance. - - As all components are immutable and frozen, it is not possible to modify the existing component directly. - - You can use the `Matter.None` constant to remove a value from the component instance: - - ```lua - target:patch({ - currentTarget = Matter.None -- sets currentTarget to nil - }) - ``` - - @param partialNewData {} -- The table to be merged with the existing component data. - @return ComponentInstance -- A copy of the component instance with values from `partialNewData` overriding existing values. - ]=] - function component:patch(partialNewData) - local patch = getmetatable(self).new(merge(self, partialNewData)) - return patch - end - - nextId += 1 - local id = nextId - - setmetatable(component, { - __call = function(_, ...) - return component.new(...) - end, - __tostring = function() - return name - end, - __len = function() - return id - end, - [DIAGNOSTIC_COMPONENT_MARKER] = true, - }) - - return component -end - -local function assertValidType(value, position) - if typeof(value) ~= "table" then - error(string.format("Component #%d is invalid: not a table", position), 3) - end - - local metatable = getmetatable(value) - - if metatable == nil then - error(string.format("Component #%d is invalid: has no metatable", position), 3) - end -end - -local function assertValidComponent(value, position) - assertValidType(value, position) - - local metatable = getmetatable(value) - - if getmetatable(metatable) ~= nil and getmetatable(metatable)[DIAGNOSTIC_COMPONENT_MARKER] then - error( - string.format( - "Component #%d is invalid: Component Instance %s was passed instead of the Component itself!", - position, - tostring(metatable) - ), - 3 - ) - end -end - -local function assertValidComponentInstance(value, position) - assertValidType(value, position) - - if getmetatable(value)[DIAGNOSTIC_COMPONENT_MARKER] ~= nil then - error( - string.format( - "Component #%d is invalid: passed a Component instead of a Component instance; " - .. "did you forget to call it as a function?", - position - ), - 3 - ) - end -end - -local ERROR_NO_ENTITY = "Entity doesn't exist, use world:contains to check if needed" -local ERROR_DUPLICATE_ENTITY = - "The world already contains an entity with ID %d. Use World:replace instead if this is intentional." -local ERROR_NO_COMPONENTS = "Missing components" - -type i53 = number -type i24 = number - -type Component = { [any]: any } -type ComponentInstance = Component - -type Ty = { i53 } -type ArchetypeId = number - -type Column = { any } - -type Archetype = { - -- Unique identifier of this archetype - id: number, - edges: { - [i24]: { - add: Archetype, - remove: Archetype, - }, - }, - types: Ty, - type: string | number, - entities: { number }, - columns: { Column }, - records: {}, -} - -type Record = { - archetype: Archetype, - row: number, -} - -type EntityIndex = { [i24]: Record } -type ComponentIndex = { [i24]: ArchetypeMap } - -type ArchetypeRecord = number -type ArchetypeMap = { sparse: { [ArchetypeId]: ArchetypeRecord }, size: number } -type Archetypes = { [ArchetypeId]: Archetype } - -local function transitionArchetype( - entityIndex: EntityIndex, - to: Archetype, - destinationRow: i24, - from: Archetype, - sourceRow: i24 -) - -- local columns = sourceArchetype.columns - -- local sourceEntities = sourceArchetype.entities - -- local destinationEntities = destinationArchetype.entities - -- local destinationColumns = destinationArchetype.columns - - local columns = from.columns - local sourceEntities = from.entities - local destinationEntities = to.entities - local destinationColumns = to.columns - local tr = to.records - local types = from.types - - for componentId, column in columns do - local targetColumn = destinationColumns[tr[types[componentId]]] - if targetColumn then - targetColumn[destinationRow] = column[sourceRow] - end - - if sourceRow ~= #column then - column[sourceRow] = column[#column] - column[#column] = nil - end - end - - destinationEntities[destinationRow] = sourceEntities[sourceRow] - entityIndex[sourceEntities[sourceRow]].row = destinationRow - - local movedAway = #sourceEntities - if sourceRow ~= movedAway then - sourceEntities[sourceRow] = sourceEntities[movedAway] - entityIndex[sourceEntities[movedAway]].row = sourceRow - end - - sourceEntities[movedAway] = nil -end - -local function archetypeAppend(entity: i53, archetype: Archetype): i24 - local entities = archetype.entities - table.insert(entities, entity) - return #entities -end - -local function newEntity(entityId: i53, record: Record, archetype: Archetype) - local row = archetypeAppend(entityId, archetype) - record.archetype = archetype - record.row = row - return record -end - -local function moveEntity(entityIndex, entityId: i53, record: Record, to: Archetype) - local sourceRow = record.row - local from = record.archetype - local destinationRow = archetypeAppend(entityId, to) - transitionArchetype(entityIndex, to, destinationRow, from, sourceRow) - record.archetype = to - record.row = destinationRow -end - -local function hash(arr): string | number - return table.concat(arr, "_") -end - -local function createArchetypeRecords(componentIndex: ComponentIndex, to: Archetype) - local destinationCount = #to.types - local destinationIds = to.types - - for i = 1, destinationCount do - local destinationId = destinationIds[i] - - if not componentIndex[destinationId] then - componentIndex[destinationId] = { sparse = {}, size = 0 } - end - componentIndex[destinationId].sparse[to.id] = i - to.records[destinationId] = i - end -end - -local function archetypeOf(world: World, types: { i24 }, prev: Archetype?): Archetype - local ty = hash(types) - - world.nextArchetypeId = (world.nextArchetypeId :: number) + 1 - local id = world.nextArchetypeId - - local columns = {} :: { any } - - for _ in types do - table.insert(columns, {}) - end - - local archetype = { - id = id, - types = types, - type = ty, - columns = columns, - entities = {}, - edges = {}, - records = {}, - } - - world.archetypeIndex[ty] = archetype - world.archetypes[id] = archetype - - if #types > 0 then - createArchetypeRecords(world.componentIndex, archetype, prev) - end - - return archetype -end - -local World = {} -World.__index = World - -function World.new() - local self = setmetatable({ - entityIndex = {}, - componentIndex = {}, - archetypes = {}, - archetypeIndex = {}, - nextId = 0, - nextArchetypeId = 0, - _size = 0, - _changedStorage = {}, - }, World) - - self.ROOT_ARCHETYPE = archetypeOf(self, {}, nil) - return self -end - -type World = typeof(World.new()) - -local function ensureArchetype(world: World, types, prev) - if #types < 1 then - return world.ROOT_ARCHETYPE - end - - local ty = hash(types) - local archetype = world.archetypeIndex[ty] - if archetype then - return archetype - end - - return archetypeOf(world, types, prev) -end - -local function findInsert(types: { i53 }, toAdd: i53) - local count = #types - for i = 1, count do - local id = types[i] - if id == toAdd then - return -1 - end - if id > toAdd then - return i - end - end - return count + 1 -end - -local function findArchetypeWith(world: World, node: Archetype, componentId: i53) - local types = node.types - local at = findInsert(types, componentId) - if at == -1 then - return node - end - - local destinationType = table.clone(node.types) - table.insert(destinationType, at, componentId) - return ensureArchetype(world, destinationType, node) -end - -local function ensureEdge(archetype: Archetype, componentId: i53) - if not archetype.edges[componentId] then - archetype.edges[componentId] = {} :: any - end - return archetype.edges[componentId] -end - -local function archetypeTraverseAdd(world: World, componentId: i53, archetype: Archetype?): Archetype - local from = (archetype or world.ROOT_ARCHETYPE) :: Archetype - local edge = ensureEdge(from, componentId) - - if not edge.add then - edge.add = findArchetypeWith(world, from, componentId) - end - - return edge.add -end - -local function componentAdd(world: World, entityId: i53, componentInstance) - local componentId = #getmetatable(componentInstance) - - local record = world:ensureRecord(entityId) - local sourceArchetype = record.archetype - local destinationArchetype = archetypeTraverseAdd(world, componentId, sourceArchetype) - - if sourceArchetype == destinationArchetype then - local archetypeRecord = destinationArchetype.records[componentId] - destinationArchetype.columns[archetypeRecord][record.row] = componentInstance - return - end - - if sourceArchetype then - moveEntity(world.entityIndex, entityId, record, destinationArchetype) - else - -- if it has any components, then it wont be the root archetype - if #destinationArchetype.types > 0 then - newEntity(entityId, record, destinationArchetype) - end - end - - local archetypeRecord = destinationArchetype.records[componentId] - destinationArchetype.columns[archetypeRecord][record.row] = componentInstance -end - -function World.ensureRecord(world: World, entityId: i53) - local entityIndex = world.entityIndex - local id = entityId - if not entityIndex[id] then - entityIndex[id] = {} :: Record - end - return entityIndex[id] -end - -local function archetypeTraverseRemove(world: World, componentId: i53, archetype: Archetype?): Archetype - local from = (archetype or world.ROOT_ARCHETYPE) :: Archetype - local edge = ensureEdge(from, componentId) - - if not edge.remove then - local to = table.clone(from.types) - table.remove(to, table.find(to, componentId)) - edge.remove = ensureArchetype(world, to, from) - end - - return edge.remove -end - -local function get(componentIndex: ComponentIndex, record: Record, componentId: i24): ComponentInstance? - local archetype = record.archetype - if archetype == nil then - return nil - end - - local archetypeRecord = archetype.records[componentId] - if not archetypeRecord then - return nil - end - - return archetype.columns[archetypeRecord][record.row] -end - -local function componentRemove(world: World, entityId: i53, component: Component) - local componentId = #component - local record = world:ensureRecord(entityId) - local sourceArchetype = record.archetype - local destinationArchetype = archetypeTraverseRemove(world, componentId, sourceArchetype) - - -- TODO: - -- There is a better way to get the component for returning - local componentInstance = get(world.componentIndex, record, componentId) - if sourceArchetype and not (sourceArchetype == destinationArchetype) then - moveEntity(world.entityIndex, entityId, record, destinationArchetype) - end - - return componentInstance -end - ---[=[ - Removes a component (or set of components) from an existing entity. - - ```lua - local removedA, removedB = world:remove(entityId, ComponentA, ComponentB) - ``` - - @param entityId number -- The entity ID - @param ... Component -- The components to remove - @return ...ComponentInstance -- Returns the component instance values that were removed in the order they were passed. -]=] -function World.remove(world: World, entityId: i53, ...) - if not world:contains(entityId) then - error(ERROR_NO_ENTITY, 2) - end - - local length = select("#", ...) - local removed = {} - for i = 1, length do - table.insert(removed, componentRemove(world, entityId, select(i, ...))) - end - - return unpack(removed, 1, length) -end - -function World.get( - world: World, - entityId: i53, - a: Component, - b: Component?, - c: Component?, - d: Component?, - e: Component? -): any - local componentIndex = world.componentIndex - local record = world.entityIndex[entityId] - if not record then - return nil - end - - local va = get(componentIndex, record, #a) - - if b == nil then - return va - elseif c == nil then - return va, get(componentIndex, record, #b) - elseif d == nil then - return va, get(componentIndex, record, #b), get(componentIndex, record, #c) - elseif e == nil then - return va, get(componentIndex, record, #b), get(componentIndex, record, #c), get(componentIndex, record, #d) - else - error("args exceeded") - end -end - -function World.insert(world: World, entityId: i53, ...) - if not world:contains(entityId) then - error(ERROR_NO_ENTITY, 2) - end - - for i = 1, select("#", ...) do - local newComponent = select(i, ...) - assertValidComponentInstance(newComponent, i) - - local metatable = getmetatable(newComponent) - local oldComponent = world:get(entityId, metatable) - componentAdd(world, entityId, newComponent) - - world:_trackChanged(metatable, entityId, oldComponent, newComponent) - end -end - -function World.replace(world: World, entityId: i53, ...: ComponentInstance) - error("Replace is unimplemented") - - if not world:contains(entityId) then - error(ERROR_NO_ENTITY, 2) - end - - --moveEntity(entityId, record, world.ROOT_ARCHETYPE) - for i = 1, select("#", ...) do - local newComponent = select(i, ...) - assertValidComponentInstance(newComponent, i) - end -end - -function World.entity(world: World) - world.nextId += 1 - return world.nextId -end - -function World:__iter() - return error("NOT IMPLEMENTED YET") -end - -function World._trackChanged(world: World, metatable, id, old, new) - if not world._changedStorage[metatable] then - return - end - - if old == new then - return - end - - local record = table.freeze({ - old = old, - new = new, - }) - - for _, storage in ipairs(world._changedStorage[metatable]) do - -- If this entity has changed since the last time this system read it, - -- we ensure that the "old" value is whatever the system saw it as last, instead of the - -- "old" value we have here. - if storage[id] then - storage[id] = table.freeze({ old = storage[id].old, new = new }) - else - storage[id] = record - end - end -end - ---[=[ - Spawns a new entity in the world with a specific entity ID and given components. - - The next ID generated from [World:spawn] will be increased as needed to never collide with a manually specified ID. - - @param entityId number -- The entity ID to spawn with - @param ... ComponentInstance -- The component values to spawn the entity with. - @return number -- The same entity ID that was passed in -]=] -function World.spawnAt(world: World, entityId: i53, ...: ComponentInstance) - if world:contains(entityId) then - error(string.format(ERROR_DUPLICATE_ENTITY, entityId), 2) - end - - if entityId >= world.nextId then - world.nextId = entityId + 1 - end - - world._size += 1 - world:ensureRecord(entityId) - - local components = {} - for i = 1, select("#", ...) do - local component = select(i, ...) - assertValidComponentInstance(component, i) - - local metatable = getmetatable(component) - if components[metatable] then - error(("Duplicate component type at index %d"):format(i), 2) - end - - world:_trackChanged(metatable, entityId, nil, component) - - components[metatable] = component - componentAdd(world, entityId, component) - end - - return entityId -end - ---[=[ - Spawns a new entity in the world with the given components. - - @param ... ComponentInstance -- The component values to spawn the entity with. - @return number -- The new entity ID. -]=] -function World.spawn(world: World, ...: ComponentInstance) - return world:spawnAt(world.nextId, ...) -end - -function World.despawn(world: World, entityId: i53) - local entityIndex = world.entityIndex - local record = entityIndex[entityId] - moveEntity(entityIndex, entityId, record, world.ROOT_ARCHETYPE) - world.ROOT_ARCHETYPE.entities[record.row] = nil - entityIndex[entityId] = nil - world._size -= 1 -end - -function World.clear(world: World) - world.entityIndex = {} - world.componentIndex = {} - world.archetypes = {} - world.archetypeIndex = {} - world._size = 0 - world.ROOT_ARCHETYPE = archetypeOf(world, {}, nil) -end - -function World.size(world: World) - return world._size -end - -function World.contains(world: World, entityId: i53) - return world.entityIndex[entityId] ~= nil -end - -local function noop(): any - return function() end -end - -local emptyQueryResult = setmetatable({ - next = function() end, - snapshot = function() - return {} - end, - without = function(self) - return self - end, - view = function() - return { - get = function() end, - contains = function() end, - } - end, -}, { - __iter = noop, - __call = noop, -}) - -local function queryResult(compatibleArchetypes, components: { number }, queryLength, ...): any - local a: any, b: any, c: any, d: any, e: any = ... - local lastArchetype, archetype = next(compatibleArchetypes) - if not lastArchetype then - return emptyQueryResult - end - - local lastRow - local queryOutput = {} - local function iterate() - local row = next(archetype.entities, lastRow) - while row == nil do - lastArchetype, archetype = next(compatibleArchetypes, lastArchetype) - if lastArchetype == nil then - return - end - row = next(archetype.entities, row) - end - - lastRow = row - - local columns = archetype.columns - local entityId = archetype.entities[row :: number] - local archetypeRecords = archetype.records - - if queryLength == 1 then - return entityId, columns[archetypeRecords[a]][row] - elseif queryLength == 2 then - return entityId, columns[archetypeRecords[a]][row], columns[archetypeRecords[b]][row] - elseif queryLength == 3 then - return entityId, - columns[archetypeRecords[a]][row], - columns[archetypeRecords[b]][row], - columns[archetypeRecords[c]][row] - elseif queryLength == 4 then - return entityId, - columns[archetypeRecords[a]][row], - columns[archetypeRecords[b]][row], - columns[archetypeRecords[c]][row], - columns[archetypeRecords[d]][row] - elseif queryLength == 5 then - return entityId, - columns[archetypeRecords[a]][row], - columns[archetypeRecords[b]][row], - columns[archetypeRecords[c]][row], - columns[archetypeRecords[d]][row], - columns[archetypeRecords[e]][row] - end - - for i, componentId in components do - queryOutput[i] = columns[archetypeRecords[componentId]][row] - end - - return entityId, unpack(queryOutput, 1, queryLength) - end - --[=[ - @class QueryResult - - A result from the [`World:query`](/api/World#query) function. - - Calling the table or the `next` method allows iteration over the results. Once all results have been returned, the - QueryResult is exhausted and is no longer useful. - - ```lua - for id, enemy, charge, model in world:query(Enemy, Charge, Model) do - -- Do something - end - ``` - ]=] - local QueryResult = {} - QueryResult.__index = QueryResult - - -- TODO: - -- remove in matter 1.0 - function QueryResult:__call() - return iterate() - end - - function QueryResult:__iter() - return function() - return iterate() - end - end - - --[=[ - Returns an iterator that will skip any entities that also have the given components. - - @param ... Component -- The component types to filter against. - @return () -> (id, ...ComponentInstance) -- Iterator of entity ID followed by the requested component values - - ```lua - for id in world:query(Target):without(Model) do - -- Do something - end - ``` - ]=] - function QueryResult:without(...) - local components = { ... } - for i, component in components do - components[i] = #component - end - - local compatibleArchetypes = compatibleArchetypes - for i = #compatibleArchetypes, 1, -1 do - local archetype = compatibleArchetypes[i] - local shouldRemove = false - for _, componentId in components do - if archetype.records[componentId] then - shouldRemove = true - break - end - end - - if shouldRemove then - table.remove(compatibleArchetypes, i) - end - end - - lastArchetype, archetype = next(compatibleArchetypes) - if not lastArchetype then - return emptyQueryResult - end - - return self - end - - --[=[ - Returns the next set of values from the query result. Once all results have been returned, the - QueryResult is exhausted and is no longer useful. - - :::info - This function is equivalent to calling the QueryResult as a function. When used in a for loop, this is implicitly - done by the language itself. - ::: - - ```lua - -- Using world:query in this position will make Lua invoke the table as a function. This is conventional. - for id, enemy, charge, model in world:query(Enemy, Charge, Model) do - -- Do something - end - ``` - - If you wanted to iterate over the QueryResult without a for loop, it's recommended that you call `next` directly - instead of calling the QueryResult as a function. - ```lua - local id, enemy, charge, model = world:query(Enemy, Charge, Model):next() - local id, enemy, charge, model = world:query(Enemy, Charge, Model)() -- Possible, but unconventional - ``` - - @return id -- Entity ID - @return ...ComponentInstance -- The requested component values - ]=] - function QueryResult:next() - return iterate() - end - - local function drain() - local entry = table.pack(iterate()) - return if entry.n > 0 then entry else nil - end - - local Snapshot = { - __iter = function(self): any - local i = 0 - return function() - i += 1 - - local data = self[i] :: any - - if data then - return unpack(data, 1, data.n) - end - - return - end - end, - } - - function QueryResult:snapshot() - local list = setmetatable({}, Snapshot) :: any - for entry in drain do - table.insert(list, entry) - end - - return list - end - - --[=[ - Creates a View of the query and does all of the iterator tasks at once at an amortized cost. - This is used for many repeated random access to an entity. If you only need to iterate, just use a query. - - ```lua - local inflicting = world:query(Damage, Hitting, Player):view() - for _, source in world:query(DamagedBy) do - local damage = inflicting:get(source.from) - end - - for _ in world:query(Damage):view() do end -- You can still iterate views if you want! - ``` - - @return View See [View](/api/View) docs. - ]=] - function QueryResult:view() - local fetches = {} - local list = {} :: any - - local View = {} - View.__index = View - - function View:__iter() - local current = list.head - return function() - if not current then - return - end - local entity = current.entity - local fetch = fetches[entity] - current = current.next - - return entity, unpack(fetch, 1, fetch.n) - end - end - - --[=[ - @within View - Retrieve the query results to corresponding `entity` - @param entity number - the entity ID - @return ...ComponentInstance - ]=] - function View:get(entity) - if not self:contains(entity) then - return - end - - local fetch = fetches[entity] - local queryLength = fetch.n - - if queryLength == 1 then - return fetch[1] - elseif queryLength == 2 then - return fetch[1], fetch[2] - elseif queryLength == 3 then - return fetch[1], fetch[2], fetch[3] - elseif queryLength == 4 then - return fetch[1], fetch[2], fetch[3], fetch[4] - elseif queryLength == 5 then - return fetch[1], fetch[2], fetch[3], fetch[4], fetch[5] - end - - return unpack(fetch, 1, fetch.n) - end - - --[=[ - @within View - Equivalent to `world:contains()` - @param entity number - the entity ID - @return boolean - ]=] - function View:contains(entity) - return fetches[entity] ~= nil - end - - for entry in drain do - local entityId = entry[1] - local fetch = table.pack(select(2, unpack(entry))) - local node = { entity = entityId, next = nil } - fetches[entityId] = fetch - - if not list.head then - list.head = node - else - local current = list.head - while current.next do - current = current.next - end - current.next = node - end - end - - return setmetatable({}, View) - end - - return setmetatable({}, QueryResult) -end - ---[=[ - Performs a query against the entities in this World. Returns a [QueryResult](/api/QueryResult), which iterates over - the results of the query. - - Order of iteration is not guaranteed. - - ```lua - for id, enemy, charge, model in world:query(Enemy, Charge, Model) do - -- Do something - end - - for id in world:query(Target):without(Model) do - -- Again, with feeling - end - ``` - - @param ... Component -- The component types to query. Only entities with *all* of these components will be returned. - @return QueryResult -- See [QueryResult](/api/QueryResult) docs. -]=] -function World.query(world: World, ...: Component): any - local compatibleArchetypes = {} - local components = { ... } - local archetypes = world.archetypes - local queryLength = select("#", ...) - local a: any, b: any, c: any, d: any, e: any = ... - - if queryLength == 0 then - return emptyQueryResult - end - - if queryLength == 1 then - a = #a - components = { a } - -- local archetypesMap = world.componentIndex[a] - -- components = { a } - -- local function single() - -- local id = next(archetypesMap) - -- local archetype = archetypes[id :: number] - -- local lastRow - - -- return function(): any - -- local row, entity = next(archetype.entities, lastRow) - -- while row == nil do - -- id = next(archetypesMap, id) - -- if id == nil then - -- return - -- end - -- archetype = archetypes[id] - -- row = next(archetype.entities, row) - -- end - -- lastRow = row - - -- return entity, archetype.columns[archetype.records[a]] - -- end - -- end - -- return single() - elseif queryLength == 2 then - --print("iter double") - a = #a - b = #b - components = { a, b } - - -- --print(a, b, world.componentIndex) - -- --[[local archetypesMap = world.componentIndex[a] - -- for id in archetypesMap do - -- local archetype = archetypes[id] - -- if archetype.records[b] then - -- table.insert(compatibleArchetypes, archetype) - -- end - -- end - - -- local function double(): () -> (number, any, any) - -- local lastArchetype, archetype = next(compatibleArchetypes) - -- local lastRow - - -- return function() - -- local row = next(archetype.entities, lastRow) - -- while row == nil do - -- lastArchetype, archetype = next(compatibleArchetypes, lastArchetype) - -- if lastArchetype == nil then - -- return - -- end - - -- row = next(archetype.entities, row) - -- end - -- lastRow = row - - -- local entity = archetype.entities[row :: number] - -- local columns = archetype.columns - -- local archetypeRecords = archetype.records - -- return entity, columns[archetypeRecords[a]], columns[archetypeRecords[b]] - -- end - -- end - -- return double() - elseif queryLength == 3 then - a = #a - b = #b - c = #c - components = { a, b, c } - elseif queryLength == 4 then - a = #a - b = #b - c = #c - d = #d - - components = { a, b, c, d } - elseif queryLength == 5 then - a = #a - b = #b - c = #c - d = #d - e = #e - - components = { a, b, c, d, e } - else - for i, component in components do - components[i] = (#component) :: any - end - end - - local firstArchetypeMap - local componentIndex = world.componentIndex - for _, componentId in (components :: any) :: { number } do - local map = componentIndex[componentId] - if not map then - return emptyQueryResult - end - - if firstArchetypeMap == nil or map.size < firstArchetypeMap.size then - firstArchetypeMap = map - end - end - - for id in firstArchetypeMap.sparse do - local archetype = archetypes[id] - local archetypeRecords = archetype.records - local matched = true - for _, componentId in components do - if not archetypeRecords[componentId] then - matched = false - break - end - end - - if matched then - table.insert(compatibleArchetypes, archetype) - end - end - - return queryResult(compatibleArchetypes, components :: any, queryLength, a, b, c, d, e) -end - -local function cleanupQueryChanged(hookState) - local world = hookState.world - local componentToTrack = hookState.componentToTrack - - for index, object in world._changedStorage[componentToTrack] do - if object == hookState.storage then - table.remove(world._changedStorage[componentToTrack], index) - break - end - end - - if next(world._changedStorage[componentToTrack]) == nil then - world._changedStorage[componentToTrack] = nil - end -end - -function World.queryChanged(world: World, componentToTrack, ...: nil) - if ... then - error("World:queryChanged does not take any additional parameters", 2) - end - - local hookState = topoRuntime.useHookState(componentToTrack, cleanupQueryChanged) :: any - if hookState.storage then - return function(): any - local entityId, record = next(hookState.storage) - - if entityId then - hookState.storage[entityId] = nil - - return entityId, record - end - return - end - end - - if not world._changedStorage[componentToTrack] then - world._changedStorage[componentToTrack] = {} - end - - local storage = {} - hookState.storage = storage - hookState.world = world - hookState.componentToTrack = componentToTrack - - table.insert(world._changedStorage[componentToTrack], storage) - - local queryResult = world:query(componentToTrack) - - return function(): any - local entityId, component = queryResult:next() - - if entityId then - return entityId, table.freeze({ new = component }) - end - return - end -end - -return { - World = World, - component = newComponent -} diff --git a/oldMatter.lua b/oldMatter.lua deleted file mode 100644 index 0baf7a7..0000000 --- a/oldMatter.lua +++ /dev/null @@ -1,1567 +0,0 @@ - -local None = {} - -local function merge(one, two) - local new = table.clone(one) - - for key, value in two do - if value == None then - new[key] = nil - else - new[key] = value - end - end - - return new -end - --- https://github.com/freddylist/llama/blob/master/src/List/toSet.lua -local function toSet(list) - local set = {} - - for _, v in ipairs(list) do - set[v] = true - end - - return set -end - --- https://github.com/freddylist/llama/blob/master/src/Dictionary/values.lua -local function values(dictionary) - local valuesList = {} - - local index = 1 - - for _, value in pairs(dictionary) do - valuesList[index] = value - index = index + 1 - end - - return valuesList -end - -local valueIds = {} -local nextValueId = 0 -local compatibilityCache = {} -local archetypeCache = {} - -local function getValueId(value) - local valueId = valueIds[value] - if valueId == nil then - valueIds[value] = nextValueId - valueId = nextValueId - nextValueId += 1 - end - - return valueId -end - -function archetypeOf(...) - local length = select("#", ...) - - local currentNode = archetypeCache - - for i = 1, length do - local nextNode = currentNode[select(i, ...)] - - if not nextNode then - nextNode = {} - currentNode[select(i, ...)] = nextNode - end - - currentNode = nextNode - end - - if currentNode._archetype then - return currentNode._archetype - end - - local list = table.create(length) - - for i = 1, length do - list[i] = getValueId(select(i, ...)) - end - - table.sort(list) - - local archetype = table.concat(list, "_") - - currentNode._archetype = archetype - - return archetype -end - -function negateArchetypeOf(...) - return string.gsub(archetypeOf(...), "_", "x") -end - -function areArchetypesCompatible(queryArchetype, targetArchetype) - local archetypes = string.split(queryArchetype, "x") - local baseArchetype = table.remove(archetypes, 1) - - local cachedCompatibility = compatibilityCache[queryArchetype .. "-" .. targetArchetype] - if cachedCompatibility ~= nil then - return cachedCompatibility - end - - local queryIds = string.split(baseArchetype, "_") - local targetIds = toSet(string.split(targetArchetype, "_")) - local excludeIds = toSet(archetypes) - - for _, queryId in ipairs(queryIds) do - if targetIds[queryId] == nil then - compatibilityCache[queryArchetype .. "-" .. targetArchetype] = false - return false - end - end - - for excludeId in excludeIds do - if targetIds[excludeId] then - compatibilityCache[queryArchetype .. "-" .. targetArchetype] = false - return false - end - end - - compatibilityCache[queryArchetype .. "-" .. targetArchetype] = true - - return true - -end - -local stack = {} - -local function newStackFrame(node) - return { - node = node, - accessedKeys = {}, - } -end - -local function cleanup() - local currentFrame = stack[#stack] - - for baseKey, state in pairs(currentFrame.node.system) do - for key, value in pairs(state.storage) do - if not currentFrame.accessedKeys[baseKey] or not currentFrame.accessedKeys[baseKey][key] then - local cleanupCallback = state.cleanupCallback - - if cleanupCallback then - local shouldAbortCleanup = cleanupCallback(value) - - if shouldAbortCleanup then - continue - end - end - - state.storage[key] = nil - end - end - end -end - -local function start(node, fn) - table.insert(stack, newStackFrame(node)) - fn() - cleanup() - table.remove(stack, #stack) -end - -local function withinTopoContext() - return #stack ~= 0 -end - -local function useFrameState() - return stack[#stack].node.frame -end - -local function useCurrentSystem() - if #stack == 0 then - return - end - - return stack[#stack].node.currentSystem -end - - ---[=[ - @within Matter - - :::tip - **Don't use this function directly in your systems.** - - This function is used for implementing your own topologically-aware functions. It should not be used in your - systems directly. You should use this function to implement your own utilities, similar to `useEvent` and - `useThrottle`. - ::: - - `useHookState` does one thing: it returns a table. An empty, pristine table. Here's the cool thing though: - it always returns the *same* table, based on the script and line where *your function* (the function calling - `useHookState`) was called. - - ### Uniqueness - - If your function is called multiple times from the same line, perhaps within a loop, the default behavior of - `useHookState` is to uniquely identify these by call count, and will return a unique table for each call. - - However, you can override this behavior: you can choose to key by any other value. This means that in addition to - script and line number, the storage will also only return the same table if the unique value (otherwise known as the - "discriminator") is the same. - - ### Cleaning up - As a second optional parameter, you can pass a function that is automatically invoked when your storage is about - to be cleaned up. This happens when your function (and by extension, `useHookState`) ceases to be called again - next frame (keyed by script, line number, and discriminator). - - Your cleanup callback is passed the storage table that's about to be cleaned up. You can then perform cleanup work, - like disconnecting events. - - *Or*, you could return `true`, and abort cleaning up altogether. If you abort cleanup, your storage will stick - around another frame (even if your function wasn't called again). This can be used when you know that the user will - (or might) eventually call your function again, even if they didn't this frame. (For example, caching a value for - a number of seconds). - - If cleanup is aborted, your cleanup function will continue to be called every frame, until you don't abort cleanup, - or the user actually calls your function again. - - ### Example: useThrottle - - This is the entire implementation of the built-in `useThrottle` function: - - ```lua - local function cleanup(storage) - return os.clock() < storage.expiry - end - - local function useThrottle(seconds, discriminator) - local storage = useHookState(discriminator, cleanup) - - if storage.time == nil or os.clock() - storage.time >= seconds then - storage.time = os.clock() - storage.expiry = os.clock() + seconds - return true - end - - return false - end - ``` - - A lot of talk for something so simple, right? - - @param discriminator? any -- A unique value to additionally key by - @param cleanupCallback (storage: {}) -> boolean? -- A function to run when the storage for this hook is cleaned up -]=] -local function useHookState(discriminator, cleanupCallback): {} - local file, line = debug.info(3, "sl") - local fn = debug.info(2, "f") - - local baseKey = string.format("%s:%s:%d", tostring(fn), file, line) - - local currentFrame = stack[#stack] - - if currentFrame == nil then - error("Attempt to access topologically-aware storage outside of a Loop-system context.", 3) - end - - if not currentFrame.accessedKeys[baseKey] then - currentFrame.accessedKeys[baseKey] = {} - end - - local accessedKeys = currentFrame.accessedKeys[baseKey] - - local key = #accessedKeys - - if discriminator ~= nil then - if type(discriminator) == "number" then - discriminator = tostring(discriminator) - end - - key = discriminator - end - - accessedKeys[key] = true - - if not currentFrame.node.system[baseKey] then - currentFrame.node.system[baseKey] = { - storage = {}, - cleanupCallback = cleanupCallback, - } - end - - local storage = currentFrame.node.system[baseKey].storage - - if not storage[key] then - storage[key] = {} - end - - return storage[key] -end - -local topoRuntime = { - start = start, - useHookState = useHookState, - useFrameState = useFrameState, - useCurrentSystem = useCurrentSystem, - withinTopoContext = withinTopoContext, -} - - - -local ERROR_NO_ENTITY = "Entity doesn't exist, use world:contains to check if needed" - ---[=[ - @class World - - A World contains entities which have components. - The World is queryable and can be used to get entities with a specific set of components. - Entities are simply ever-increasing integers. -]=] -local World = {} -World.__index = World - ---[=[ - Creates a new World. -]=] -function World.new() - local firstStorage = {} - - return setmetatable({ - -- List of maps from archetype string --> entity ID --> entity data - _storages = { firstStorage }, - -- The most recent storage that has not been dirtied by an iterator - _pristineStorage = firstStorage, - - -- Map from entity ID -> archetype string - _entityArchetypes = {}, - - -- Cache of the component metatables on each entity. Used for generating archetype. - -- Map of entity ID -> array - _entityMetatablesCache = {}, - - -- Cache of what query archetypes are compatible with what component archetypes - _queryCache = {}, - - -- Cache of what entity archetypes have ever existed in the game. This is used for knowing - -- when to update the queryCache. - _entityArchetypeCache = {}, - - -- The next ID that will be assigned with World:spawn - _nextId = 1, - - -- The total number of active entities in the world - _size = 0, - - -- Storage for `queryChanged` - _changedStorage = {}, - }, World) -end - --- Searches all archetype storages for the entity with the given archetype --- Returns the storage that the entity is in if it exists, otherwise nil -function World:_getStorageWithEntity(archetype, id) - for _, storage in self._storages do - local archetypeStorage = storage[archetype] - if archetypeStorage then - if archetypeStorage[id] then - return storage - end - end - end - return nil -end - -function World:_markStorageDirty() - local newStorage = {} - table.insert(self._storages, newStorage) - self._pristineStorage = newStorage - - if topoRuntime.withinTopoContext() then - local frameState = topoRuntime.useFrameState() - - frameState.dirtyWorlds[self] = true - end -end - -function World:_getEntity(id) - local archetype = self._entityArchetypes[id] - local storage = self:_getStorageWithEntity(archetype, id) - - return storage[archetype][id] -end - -function World:_next(last) - local entityId, archetype = next(self._entityArchetypes, last) - - if entityId == nil then - return nil - end - - local storage = self:_getStorageWithEntity(archetype, entityId) - - return entityId, storage[archetype][entityId] -end - ---[=[ - Iterates over all entities in this World. Iteration returns entity ID followed by a dictionary mapping - Component to Component Instance. - - **Usage:** - - ```lua - for entityId, entityData in world do - print(entityId, entityData[Components.Example]) - end - ``` - - @return number - @return {[Component]: ComponentInstance} -]=] -function World:__iter() - return World._next, self -end - ---[=[ - Spawns a new entity in the world with the given components. - - @param ... ComponentInstance -- The component values to spawn the entity with. - @return number -- The new entity ID. -]=] -function World:spawn(...) - return self:spawnAt(self._nextId, ...) -end - ---[=[ - @class Component - - A component is a named piece of data that exists on an entity. - Components are created and removed in the [World](/api/World). - - In the docs, the terms "Component" and "ComponentInstance" are used: - - **"Component"** refers to the base class of a specific type of component you've created. - This is what [`Matter.component`](/api/Matter#component) returns. - - **"Component Instance"** refers to an actual piece of data that can exist on an entity. - The metatable of a component instance table is its respective Component table. - - Component instances are *plain-old data*: they do not contain behaviors or methods. - - Since component instances are immutable, one helper function exists on all component instances, `patch`, - which allows reusing data from an existing component instance to make up for the ergonomic loss of mutations. -]=] - ---[=[ - @within Component - @type ComponentInstance {} - - The `ComponentInstance` type refers to an actual piece of data that can exist on an entity. - The metatable of the component instance table is set to its particular Component table. - - A component instance can be created by calling the Component table: - - ```lua - -- Component: - local MyComponent = Matter.component("My component") - - -- component instance: - local myComponentInstance = MyComponent({ - some = "data" - }) - - print(getmetatable(myComponentInstance) == MyComponent) --> true - ``` -]=] - --- This is a special value we set inside the component's metatable that will allow us to detect when --- a Component is accidentally inserted as a Component Instance. --- It should not be accessible through indexing into a component instance directly. -local DIAGNOSTIC_COMPONENT_MARKER = {} - -local function newComponent(name, defaultData) - name = name or debug.info(2, "s") .. "@" .. debug.info(2, "l") - - assert( - defaultData == nil or type(defaultData) == "table", - "if component default data is specified, it must be a table" - ) - - local component = {} - component.__index = component - - function component.new(data) - data = data or {} - - if defaultData then - data = merge(defaultData, data) - end - - return table.freeze(setmetatable(data, component)) - end - - --[=[ - @within Component - - ```lua - for id, target in world:query(Target) do - if shouldChangeTarget(target) then - world:insert(id, target:patch({ -- modify the existing component - currentTarget = getNewTarget() - })) - end - end - ``` - - A utility function used to immutably modify an existing component instance. Key/value pairs from the passed table - will override those of the existing component instance. - - As all components are immutable and frozen, it is not possible to modify the existing component directly. - - You can use the `Matter.None` constant to remove a value from the component instance: - - ```lua - target:patch({ - currentTarget = Matter.None -- sets currentTarget to nil - }) - ``` - - @param partialNewData {} -- The table to be merged with the existing component data. - @return ComponentInstance -- A copy of the component instance with values from `partialNewData` overriding existing values. - ]=] - function component:patch(partialNewData) - local patch = getmetatable(self).new(merge(self, partialNewData)) - return patch - end - - setmetatable(component, { - __call = function(_, ...) - return component.new(...) - end, - __tostring = function() - return name - end, - [DIAGNOSTIC_COMPONENT_MARKER] = true, - }) - - return component -end - -local function assertValidType(value, position) - if typeof(value) ~= "table" then - error(string.format("Component #%d is invalid: not a table", position), 3) - end - - local metatable = getmetatable(value) - - if metatable == nil then - error(string.format("Component #%d is invalid: has no metatable", position), 3) - end -end - -local function assertValidComponent(value, position) - assertValidType(value, position) - - local metatable = getmetatable(value) - - if getmetatable(metatable) ~= nil and getmetatable(metatable)[DIAGNOSTIC_COMPONENT_MARKER] then - error( - string.format( - "Component #%d is invalid: Component Instance %s was passed instead of the Component itself!", - position, - tostring(metatable) - ), - 3 - ) - end -end - -local function assertValidComponentInstance(value, position) - assertValidType(value, position) - - if getmetatable(value)[DIAGNOSTIC_COMPONENT_MARKER] ~= nil then - error( - string.format( - "Component #%d is invalid: passed a Component instead of a Component instance; " - .. "did you forget to call it as a function?", - position - ), - 3 - ) - end -end - ---[=[ - Spawns a new entity in the world with a specific entity ID and given components. - - The next ID generated from [World:spawn] will be increased as needed to never collide with a manually specified ID. - - @param id number -- The entity ID to spawn with - @param ... ComponentInstance -- The component values to spawn the entity with. - @return number -- The same entity ID that was passed in -]=] -function World:spawnAt(id, ...) - if self:contains(id) then - error( - string.format( - "The world already contains an entity with ID %d. Use World:replace instead if this is intentional.", - id - ), - 2 - ) - end - - self._size += 1 - - if id >= self._nextId then - self._nextId = id + 1 - end - - local components = {} - local metatables = {} - - for i = 1, select("#", ...) do - local newComponent = select(i, ...) - - assertValidComponentInstance(newComponent, i) - - local metatable = getmetatable(newComponent) - - if components[metatable] then - error(("Duplicate component type at index %d"):format(i), 2) - end - - self:_trackChanged(metatable, id, nil, newComponent) - - components[metatable] = newComponent - table.insert(metatables, metatable) - end - - self._entityMetatablesCache[id] = metatables - - self:_transitionArchetype(id, components) - - return id -end - -function World:_newQueryArchetype(queryArchetype) - if self._queryCache[queryArchetype] == nil then - self._queryCache[queryArchetype] = {} - else - return -- Archetype isn't actually new - end - - for _, storage in self._storages do - for entityArchetype in storage do - if areArchetypesCompatible(queryArchetype, entityArchetype) then - self._queryCache[queryArchetype][entityArchetype] = true - end - end - end -end - -function World:_updateQueryCache(entityArchetype) - for queryArchetype, compatibleArchetypes in pairs(self._queryCache) do - if areArchetypesCompatible(queryArchetype, entityArchetype) then - compatibleArchetypes[entityArchetype] = true - end - end -end - -function World:_transitionArchetype(id, components) - local newArchetype = nil - local oldArchetype = self._entityArchetypes[id] - local oldStorage - - if oldArchetype then - oldStorage = self:_getStorageWithEntity(oldArchetype, id) - - if not components then - oldStorage[oldArchetype][id] = nil - end - end - - if components then - newArchetype = archetypeOf(unpack(self._entityMetatablesCache[id])) - - if oldArchetype ~= newArchetype then - if oldStorage then - oldStorage[oldArchetype][id] = nil - end - - if self._pristineStorage[newArchetype] == nil then - self._pristineStorage[newArchetype] = {} - end - - if self._entityArchetypeCache[newArchetype] == nil then - self._entityArchetypeCache[newArchetype] = true - self:_updateQueryCache(newArchetype) - end - self._pristineStorage[newArchetype][id] = components - else - oldStorage[newArchetype][id] = components - end - end - - self._entityArchetypes[id] = newArchetype -end - ---[=[ - Replaces a given entity by ID with an entirely new set of components. - Equivalent to removing all components from an entity, and then adding these ones. - - @param id number -- The entity ID - @param ... ComponentInstance -- The component values to spawn the entity with. -]=] -function World:replace(id, ...) - if not self:contains(id) then - error(ERROR_NO_ENTITY, 2) - end - - local components = {} - local metatables = {} - local entity = self:_getEntity(id) - - for i = 1, select("#", ...) do - local newComponent = select(i, ...) - - assertValidComponentInstance(newComponent, i) - - local metatable = getmetatable(newComponent) - - if components[metatable] then - error(("Duplicate component type at index %d"):format(i), 2) - end - - self:_trackChanged(metatable, id, entity[metatable], newComponent) - - components[metatable] = newComponent - table.insert(metatables, metatable) - end - - for metatable, component in pairs(entity) do - if not components[metatable] then - self:_trackChanged(metatable, id, component, nil) - end - end - - self._entityMetatablesCache[id] = metatables - - self:_transitionArchetype(id, components) -end - ---[=[ - Despawns a given entity by ID, removing it and all its components from the world entirely. - - @param id number -- The entity ID -]=] -function World:despawn(id) - local entity = self:_getEntity(id) - - for metatable, component in pairs(entity) do - self:_trackChanged(metatable, id, component, nil) - end - - self._entityMetatablesCache[id] = nil - self:_transitionArchetype(id, nil) - - self._size -= 1 -end - ---[=[ - Removes all entities from the world. - - :::caution - Removing entities in this way is not reported by `queryChanged`. - ::: -]=] -function World:clear() - local firstStorage = {} - self._storages = { firstStorage } - self._pristineStorage = firstStorage - self._entityArchetypes = {} - self._entityMetatablesCache = {} - self._size = 0 - self._changedStorage = {} -end - ---[=[ - Checks if the given entity ID is currently spawned in this world. - - @param id number -- The entity ID - @return bool -- `true` if the entity exists -]=] -function World:contains(id) - return self._entityArchetypes[id] ~= nil -end - ---[=[ - Gets a specific component (or set of components) from a specific entity in this world. - - @param id number -- The entity ID - @param ... Component -- The components to fetch - @return ... -- Returns the component values in the same order they were passed in -]=] -function World:get(id, ...) - if not self:contains(id) then - error(ERROR_NO_ENTITY, 2) - end - - local entity = self:_getEntity(id) - - local length = select("#", ...) - - if length == 1 then - assertValidComponent((...), 1) - return entity[...] - end - - local components = {} - for i = 1, length do - local metatable = select(i, ...) - assertValidComponent(metatable, i) - components[i] = entity[metatable] - end - - return unpack(components, 1, length) -end - -local function noop() end - -local noopQuery = setmetatable({ - next = noop, - snapshot = noop, - without = function(self) - return self - end, - view = { - get = noop, - contains = noop, - }, -}, { - __iter = function() - return noop - end, -}) - ---[=[ - @class QueryResult - - A result from the [`World:query`](/api/World#query) function. - - Calling the table or the `next` method allows iteration over the results. Once all results have been returned, the - QueryResult is exhausted and is no longer useful. - - ```lua - for id, enemy, charge, model in world:query(Enemy, Charge, Model) do - -- Do something - end - ``` -]=] - -local QueryResult = {} -QueryResult.__index = QueryResult - -function QueryResult.new(world, expand, queryArchetype, compatibleArchetypes) - return setmetatable({ - world = world, - seenEntities = {}, - currentCompatibleArchetype = next(compatibleArchetypes), - compatibleArchetypes = compatibleArchetypes, - storageIndex = 1, - _expand = expand, - _queryArchetype = queryArchetype, - }, QueryResult) -end - -local function nextItem(query) - local world = query.world - local currentCompatibleArchetype = query.currentCompatibleArchetype - local seenEntities = query.seenEntities - local compatibleArchetypes = query.compatibleArchetypes - - local entityId, entityData - - local storages = world._storages - repeat - local nextStorage = storages[query.storageIndex] - local currently = nextStorage[currentCompatibleArchetype] - if currently then - entityId, entityData = next(currently, query.lastEntityId) - end - - while entityId == nil do - currentCompatibleArchetype = next(compatibleArchetypes, currentCompatibleArchetype) - - if currentCompatibleArchetype == nil then - query.storageIndex += 1 - - nextStorage = storages[query.storageIndex] - - if nextStorage == nil or next(nextStorage) == nil then - return - end - - currentCompatibleArchetype = nil - - if world._pristineStorage == nextStorage then - world:_markStorageDirty() - end - - continue - elseif nextStorage[currentCompatibleArchetype] == nil then - continue - end - - entityId, entityData = next(nextStorage[currentCompatibleArchetype]) - end - - query.lastEntityId = entityId - - until seenEntities[entityId] == nil - - query.currentCompatibleArchetype = currentCompatibleArchetype - - seenEntities[entityId] = true - - return entityId, entityData -end - -function QueryResult:__iter() - return function() - return self._expand(nextItem(self)) - end -end - -function QueryResult:__call() - return self._expand(nextItem(self)) -end - ---[=[ - Returns the next set of values from the query result. Once all results have been returned, the - QueryResult is exhausted and is no longer useful. - - :::info - This function is equivalent to calling the QueryResult as a function. When used in a for loop, this is implicitly - done by the language itself. - ::: - - ```lua - -- Using world:query in this position will make Lua invoke the table as a function. This is conventional. - for id, enemy, charge, model in world:query(Enemy, Charge, Model) do - -- Do something - end - ``` - - If you wanted to iterate over the QueryResult without a for loop, it's recommended that you call `next` directly - instead of calling the QueryResult as a function. - ```lua - local id, enemy, charge, model = world:query(Enemy, Charge, Model):next() - local id, enemy, charge, model = world:query(Enemy, Charge, Model)() -- Possible, but unconventional - ``` - - @return id -- Entity ID - @return ...ComponentInstance -- The requested component values -]=] -function QueryResult:next() - return self._expand(nextItem(self)) -end - -local snapshot = { - __iter = function(self): any - local i = 0 - return function() - i += 1 - - local data = self[i] - - if data then - return unpack(data, 1, data.n) - end - return - end - end, -} - ---[=[ - Creates a "snapshot" of this query, draining this QueryResult and returning a list containing all of its results. - - By default, iterating over a QueryResult happens in "real time": it iterates over the actual data in the ECS, so - changes that occur during the iteration will affect future results. - - By contrast, `QueryResult:snapshot()` creates a list of all of the results of this query at the moment it is called, - so changes made while iterating over the result of `QueryResult:snapshot` do not affect future results of the - iteration. - - Of course, this comes with a cost: we must allocate a new list and iterate over everything returned from the - QueryResult in advance, so using this method is slower than iterating over a QueryResult directly. - - The table returned from this method has a custom `__iter` method, which lets you use it as you would use QueryResult - directly: - - ```lua - for entityId, health, player in world:query(Health, Player):snapshot() do - - end - ``` - - However, the table itself is just a list of sub-tables structured like `{entityId, component1, component2, ...etc}`. - - @return {{entityId: number, component: ComponentInstance, component: ComponentInstance, component: ComponentInstance, ...}} -]=] -function QueryResult:snapshot() - local list = setmetatable({}, snapshot) - - local function iter() - return nextItem(self) - end - - for entityId, entityData in iter do - if entityId then - table.insert(list, table.pack(self._expand(entityId, entityData))) - end - end - - return list -end - ---[=[ - Returns an iterator that will skip any entities that also have the given components. - - :::tip - This is essentially equivalent to querying normally, using `World:get` to check if a component is present, - and using Lua's `continue` keyword to skip this iteration (though, using `:without` is faster). - - This means that you should avoid queries that return a very large amount of results only to filter them down - to a few with `:without`. If you can, always prefer adding components and making your query more specific. - ::: - - @param ... Component -- The component types to filter against. - @return () -> (id, ...ComponentInstance) -- Iterator of entity ID followed by the requested component values - - ```lua - for id in world:query(Target):without(Model) do - -- Do something - end - ``` -]=] - -function QueryResult:without(...) - local world = self.world - local filter = negateArchetypeOf(...) - - local negativeArchetype = `{self._queryArchetype}x{filter}` - - if world._queryCache[negativeArchetype] == nil then - world:_newQueryArchetype(negativeArchetype) - end - - local compatibleArchetypes = world._queryCache[negativeArchetype] - - self.compatibleArchetypes = compatibleArchetypes - self.currentCompatibleArchetype = next(compatibleArchetypes) - return self -end - ---[=[ - @class View - - Provides random access to the results of a query. - - Calling the View is equivalent to iterating a query. - - ```lua - for id, player, health, poison in world:query(Player, Health, Poison):view() do - -- Do something - end - ``` -]=] - ---[=[ - Creates a View of the query and does all of the iterator tasks at once at an amortized cost. - This is used for many repeated random access to an entity. If you only need to iterate, just use a query. - - ```lua - local inflicting = world:query(Damage, Hitting, Player):view() - for _, source in world:query(DamagedBy) do - local damage = inflicting:get(source.from) - end - - for _ in world:query(Damage):view() do end -- You can still iterate views if you want! - ``` - - @return View See [View](/api/View) docs. -]=] - -function QueryResult:view() - local function iter() - return nextItem(self) - end - - local fetches = {} - local list = {} :: any - - local View = {} - View.__index = View - - function View:__iter() - local current = list.head - return function() - if not current then - return - end - local entity = current.entity - local fetch = fetches[entity] - current = current.next - - return entity, unpack(fetch, 1, fetch.n) - end - end - - --[=[ - @within View - Retrieve the query results to corresponding `entity` - @param entity number - the entity ID - @return ...ComponentInstance - ]=] - function View:get(entity) - if not self:contains(entity) then - return - end - - local fetch = fetches[entity] - local queryLength = fetch.n - - if queryLength == 1 then - return fetch[1] - elseif queryLength == 2 then - return fetch[1], fetch[2] - elseif queryLength == 3 then - return fetch[1], fetch[2], fetch[3] - elseif queryLength == 4 then - return fetch[1], fetch[2], fetch[3], fetch[4] - elseif queryLength == 5 then - return fetch[1], fetch[2], fetch[3], fetch[4], fetch[5] - end - - return unpack(fetch, 1, fetch.n) - end - - --[=[ - @within View - Equivalent to `world:contains()` - @param entity number - the entity ID - @return boolean - ]=] - - function View:contains(entity) - return fetches[entity] ~= nil - end - - for entityId, entityData in iter do - if entityId then - -- We start at 2 on Select since we don't need want to pack the entity id. - local fetch = table.pack(select(2, self._expand(entityId, entityData))) - local node = { entity = entityId, next = nil } - - fetches[entityId] = fetch - - if not list.head then - list.head = node - else - local current = list.head - while current.next do - current = current.next - end - current.next = node - end - end - end - - return setmetatable({}, View) -end - ---[=[ - Performs a query against the entities in this World. Returns a [QueryResult](/api/QueryResult), which iterates over - the results of the query. - - Order of iteration is not guaranteed. - - ```lua - for id, enemy, charge, model in world:query(Enemy, Charge, Model) do - -- Do something - end - - for id in world:query(Target):without(Model) do - -- Again, with feeling - end - ``` - - @param ... Component -- The component types to query. Only entities with *all* of these components will be returned. - @return QueryResult -- See [QueryResult](/api/QueryResult) docs. -]=] - -function World:query(...) - assertValidComponent((...), 1) - - local metatables = { ... } - local queryLength = select("#", ...) - - local archetype = archetypeOf(...) - - if self._queryCache[archetype] == nil then - self:_newQueryArchetype(archetype) - end - - local compatibleArchetypes = self._queryCache[archetype] - - if next(compatibleArchetypes) == nil then - -- If there are no compatible storages avoid creating our complicated iterator - return noopQuery - end - - local queryOutput = table.create(queryLength) - - local function expand(entityId, entityData) - if not entityId then - return - end - - if queryLength == 1 then - return entityId, entityData[metatables[1]] - elseif queryLength == 2 then - return entityId, entityData[metatables[1]], entityData[metatables[2]] - elseif queryLength == 3 then - return entityId, entityData[metatables[1]], entityData[metatables[2]], entityData[metatables[3]] - elseif queryLength == 4 then - return entityId, - entityData[metatables[1]], - entityData[metatables[2]], - entityData[metatables[3]], - entityData[metatables[4]] - elseif queryLength == 5 then - return entityId, - entityData[metatables[1]], - entityData[metatables[2]], - entityData[metatables[3]], - entityData[metatables[4]], - entityData[metatables[5]] - end - - for i, metatable in ipairs(metatables) do - queryOutput[i] = entityData[metatable] - end - - return entityId, unpack(queryOutput, 1, queryLength) - end - - if self._pristineStorage == self._storages[1] then - self:_markStorageDirty() - end - - return QueryResult.new(self, expand, archetype, compatibleArchetypes) -end - -local function cleanupQueryChanged(hookState) - local world = hookState.world - local componentToTrack = hookState.componentToTrack - - for index, object in world._changedStorage[componentToTrack] do - if object == hookState.storage then - table.remove(world._changedStorage[componentToTrack], index) - break - end - end - - if next(world._changedStorage[componentToTrack]) == nil then - world._changedStorage[componentToTrack] = nil - end -end - ---[=[ - @interface ChangeRecord - @within World - .new? ComponentInstance -- The new value of the component. Nil if just removed. - .old? ComponentInstance -- The former value of the component. Nil if just added. -]=] - ---[=[ - :::info Topologically-aware function - This function is only usable if called within the context of [`Loop:begin`](/api/Loop#begin). - ::: - - Queries for components that have changed **since the last time your system ran `queryChanged`**. - - Only one changed record is returned per entity, even if the same entity changed multiple times. The order - in which changed records are returned is not guaranteed to be the order that the changes occurred in. - - It should be noted that `queryChanged` does not have the same iterator invalidation concerns as `World:query`. - - :::tip - The first time your system runs (i.e., on the first frame), all existing entities in the world that match your query - are returned as "new" change records. - ::: - - :::info - Calling this function from your system creates storage internally for your system. Then, changes meeting your - criteria are pushed into your storage. Calling `queryChanged` again each frame drains this storage. - - If your system isn't called every frame, the storage will continually fill up and does not empty unless you drain - it. - - If you stop calling `queryChanged` in your system, changes will stop being tracked. - ::: - - ### Returns - `queryChanged` returns an iterator function, so you call it in a for loop just like `World:query`. - - The iterator returns the entity ID, followed by a [`ChangeRecord`](#ChangeRecord). - - The `ChangeRecord` type is a table that contains two fields, `new` and `old`, respectively containing the new - component instance, and the old component instance. `new` and `old` will never be the same value. - - `new` will be nil if the component was removed (or the entity was despawned), and `old` will be nil if the - component was just added. - - The `old` field will be the value of the component the last time this system observed it, not - necessarily the value it changed from most recently. - - The `ChangeRecord` table is potentially shared with multiple systems tracking changes for this component, so it - cannot be modified. - - ```lua - for id, record in world:queryChanged(Model) do - if record.new == nil then - -- Model was removed - - if enemy.type == "this is a made up example" then - world:remove(id, Enemy) - end - end - end - ``` - - @param componentToTrack Component -- The component you want to listen to changes for. - @return () -> (id, ChangeRecord) -- Iterator of entity ID and change record -]=] -function World:queryChanged(componentToTrack, ...: nil) - if ... then - error("World:queryChanged does not take any additional parameters", 2) - end - - local hookState = topoRuntime.useHookState(componentToTrack, cleanupQueryChanged) - - if hookState.storage then - return function(): any - local entityId, record = next(hookState.storage) - - if entityId then - hookState.storage[entityId] = nil - - return entityId, record - end - return - end - end - - if not self._changedStorage[componentToTrack] then - self._changedStorage[componentToTrack] = {} - end - - local storage = {} - hookState.storage = storage - hookState.world = self - hookState.componentToTrack = componentToTrack - - table.insert(self._changedStorage[componentToTrack], storage) - - local queryResult = self:query(componentToTrack) - - return function(): any - local entityId, component = queryResult:next() - - if entityId then - return entityId, table.freeze({ new = component }) - end - return - end -end - -function World:_trackChanged(metatable, id, old, new) - if not self._changedStorage[metatable] then - return - end - - if old == new then - return - end - - local record = table.freeze({ - old = old, - new = new, - }) - - for _, storage in ipairs(self._changedStorage[metatable]) do - -- If this entity has changed since the last time this system read it, - -- we ensure that the "old" value is whatever the system saw it as last, instead of the - -- "old" value we have here. - if storage[id] then - storage[id] = table.freeze({ old = storage[id].old, new = new }) - else - storage[id] = record - end - end -end - ---[=[ - Inserts a component (or set of components) into an existing entity. - - If another instance of a given component already exists on this entity, it is replaced. - - ```lua - world:insert( - entityId, - ComponentA({ - foo = "bar" - }), - ComponentB({ - baz = "qux" - }) - ) - ``` - - @param id number -- The entity ID - @param ... ComponentInstance -- The component values to insert -]=] -function World:insert(id, ...) - if not self:contains(id) then - error(ERROR_NO_ENTITY, 2) - end - - local entity = self:_getEntity(id) - - local wasNew = false - for i = 1, select("#", ...) do - local newComponent = select(i, ...) - - assertValidComponentInstance(newComponent, i) - - local metatable = getmetatable(newComponent) - - local oldComponent = entity[metatable] - - if not oldComponent then - wasNew = true - - table.insert(self._entityMetatablesCache[id], metatable) - end - - self:_trackChanged(metatable, id, oldComponent, newComponent) - - entity[metatable] = newComponent - end - - if wasNew then -- wasNew - self:_transitionArchetype(id, entity) - end -end - ---[=[ - Removes a component (or set of components) from an existing entity. - - ```lua - local removedA, removedB = world:remove(entityId, ComponentA, ComponentB) - ``` - - @param id number -- The entity ID - @param ... Component -- The components to remove - @return ...ComponentInstance -- Returns the component instance values that were removed in the order they were passed. -]=] -function World:remove(id, ...) - if not self:contains(id) then - error(ERROR_NO_ENTITY, 2) - end - - local entity = self:_getEntity(id) - - local length = select("#", ...) - local removed = {} - - for i = 1, length do - local metatable = select(i, ...) - - assertValidComponent(metatable, i) - - local oldComponent = entity[metatable] - - removed[i] = oldComponent - - self:_trackChanged(metatable, id, oldComponent, nil) - - entity[metatable] = nil - end - - -- Rebuild entity metatable cache - local metatables = {} - - for metatable in pairs(entity) do - table.insert(metatables, metatable) - end - - self._entityMetatablesCache[id] = metatables - - self:_transitionArchetype(id, entity) - - return unpack(removed, 1, length) -end - ---[=[ - Returns the number of entities currently spawned in the world. -]=] -function World:size() - return self._size -end - ---[=[ - :::tip - [Loop] automatically calls this function on your World(s), so there is no need to call it yourself if you're using - a Loop. - ::: - - If you are not using a Loop, you should call this function at a regular interval (i.e., once per frame) to optimize - the internal storage for queries. - - This is part of a strategy to eliminate iterator invalidation when modifying the World while inside a query from - [World:query]. While inside a query, any changes to the World are stored in a separate location from the rest of - the World. Calling this function combines the separate storage back into the main storage, which speeds things up - again. -]=] -function World:optimizeQueries() - if #self._storages == 1 then - return - end - - local firstStorage = self._storages[1] - - for i = 2, #self._storages do - local storage = self._storages[i] - - for archetype, entities in storage do - if firstStorage[archetype] == nil then - firstStorage[archetype] = entities - else - for entityId, entityData in entities do - if firstStorage[archetype][entityId] then - error("Entity ID already exists in first storage...") - end - firstStorage[archetype][entityId] = entityData - end - end - end - end - - table.clear(self._storages) - - self._storages[1] = firstStorage - self._pristineStorage = firstStorage -end - -return { - World = World, - component = newComponent -} diff --git a/selene.toml b/selene.toml new file mode 100644 index 0000000..54227d9 --- /dev/null +++ b/selene.toml @@ -0,0 +1,4 @@ +std = "roblox" + +[lints] +global_usage = "allow" diff --git a/stylua.toml b/stylua.toml new file mode 100644 index 0000000..83e5807 --- /dev/null +++ b/stylua.toml @@ -0,0 +1,5 @@ +column_width = 120 +quote_style = "ForceDouble" + +[sort_requires] +enabled = true diff --git a/test.project.json b/test.project.json index b931a84..0a3901a 100644 --- a/test.project.json +++ b/test.project.json @@ -11,9 +11,6 @@ }, "ReplicatedStorage": { "$className": "ReplicatedStorage", - "DevPackages": { - "$path": "DevPackages" - }, "Lib": { "$path": "lib" }, @@ -26,15 +23,6 @@ "mirror": { "$path": "mirror" } - }, - "TestService": { - "$properties": { - "ExecuteWithStudioRun": true - }, - "$className": "TestService", - "run": { - "$path": "tests.server.lua" - } } } } diff --git a/testez-companion.toml b/testez-companion.toml new file mode 100644 index 0000000..4de0c23 --- /dev/null +++ b/testez-companion.toml @@ -0,0 +1,3 @@ +roots = ["ServerStorage"] + +[extraOptions] diff --git a/tests.server.lua b/tests.server.lua deleted file mode 100644 index 683913d..0000000 --- a/tests.server.lua +++ /dev/null @@ -1,9 +0,0 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -require(ReplicatedStorage.DevPackages.TestEZ).TestBootstrap:run({ - ReplicatedStorage.Lib, - nil, - { - noXpcallByDefault = true, - }, -}) diff --git a/tests/test1.lua b/tests/test1.lua deleted file mode 100644 index 0b031d3..0000000 --- a/tests/test1.lua +++ /dev/null @@ -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() \ No newline at end of file diff --git a/tests/world.lua b/tests/world.lua new file mode 100644 index 0000000..79a2686 --- /dev/null +++ b/tests/world.lua @@ -0,0 +1,353 @@ +local jecs = require("../lib/init") +local testkit = require("../testkit") +local __ = jecs.Wildcard +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_PAIR_RELATION = jecs.ECS_PAIR_RELATION +local ECS_PAIR_OBJECT = jecs.ECS_PAIR_OBJECT + +local TEST, CASE, CHECK, FINISH, SKIP = testkit.test() +local function CHECK_NO_ERR(s: string, fn: (T...) -> (), ...: T...) + local ok, err: string? = pcall(fn, ...) + + if not CHECK(not ok, 2) then + local i = string.find(err :: string, " ") + assert(i) + local msg = string.sub(err :: string, i + 1) + CHECK(msg == s, 2) + end +end +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_PAIR_RELATION(world.entityIndex, pair) == e2) + CHECK(ECS_PAIR_OBJECT(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 + + do + CASE("should only relate alive entities") + + 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, Apples, "apples") + world:set(bob, ECS_PAIR(Eats, Apples), "bob eats apples") + world:set(alice, ECS_PAIR(Eats, Oranges), "alice eats oranges") + + world:delete(Apples) + local Wildcard = jecs.Wildcard + + local count = 0 + for _, data in world:query(ECS_PAIR(Wildcard, Apples)) do + count += 1 + end + + world:delete(ECS_PAIR(Eats, Apples)) + + CHECK(count == 0) + CHECK(world:get(bob, ECS_PAIR(Eats, Apples)) == nil) + end + + do + CASE("should error when setting invalid pair") + local world = jecs.World.new() + local Eats = world:entity() + local Apples = world:entity() + local bob = world:entity() + + world:delete(Apples) + + world:set(bob, ECS_PAIR(Eats, Apples), "bob eats apples") + end + + do + CASE("should find target for ChildOf") + local world = jecs.World.new() + + local ChildOf = world:component() + local Name = world:component() + + local function parent(entity) + return world:target(entity, ChildOf) + end + + local bob = world:entity() + local alice = world:entity() + local sara = world:entity() + + world:add(bob, ECS_PAIR(ChildOf, alice)) + world:set(bob, Name, "bob") + world:add(sara, ECS_PAIR(ChildOf, alice)) + world:set(sara, Name, "sara") + CHECK(parent(bob) == alice) -- O(1) + + local count = 0 + for _, name in world:query(Name, ECS_PAIR(ChildOf, alice)) do + print(name) + count += 1 + end + CHECK(count == 2) + end +end) + +FINISH() + diff --git a/wally.toml b/wally.toml index c4b5be7..a19b86f 100644 --- a/wally.toml +++ b/wally.toml @@ -1,15 +1,7 @@ [package] name = "ukendio/jecs" -version = "0.0.0-prototype.rc.3" +version = "0.1.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" -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" - - - +include = ["default.project.json", "lib/**", "lib", "wally.toml", "README.md"] +exclude = ["**"] \ No newline at end of file