From 38f189b54dca84a7289fd984f4f44367e831f3cc Mon Sep 17 00:00:00 2001 From: Ukendio Date: Tue, 22 Apr 2025 04:10:45 +0200 Subject: [PATCH] Initial commit --- addons/observers.luau | 19 +-- benches/visual/insertion.bench.luau | 5 +- benches/visual/spawn.bench.luau | 26 ++-- jecs.luau | 196 +++++++++++++++++++++++----- test/addons/observers.luau | 19 +++ test/tests.luau | 66 +++++++++- tools/lifetime_tracker.luau | 5 +- tools/testkit.luau | 18 +-- 8 files changed, 281 insertions(+), 73 deletions(-) diff --git a/addons/observers.luau b/addons/observers.luau index cffce81..29c261f 100644 --- a/addons/observers.luau +++ b/addons/observers.luau @@ -104,12 +104,13 @@ local function observers_add(world: jecs.World & { [string]: any }): PatchedWorl if not listeners then listeners = {} signals.added[component] = listeners - local idr = jecs.id_record_ensure(world, component) - idr.hooks.on_add = function(entity) + + local function on_add(entity: number, id: number, value: any) for _, listener in listeners do - listener(entity, component) + listener(entity, id, value) end end + world:set(component, jecs.OnAdd, on_add) end table.insert(listeners, fn) end @@ -119,12 +120,12 @@ local function observers_add(world: jecs.World & { [string]: any }): PatchedWorl if not listeners then listeners = {} signals.emplaced[component] = listeners - local idr = jecs.id_record_ensure(world, component) - idr.hooks.on_change = function(entity, value) + local function on_change(entity: number, id: number, value: any) for _, listener in listeners do - listener(entity, component, value) + listener(entity, id, value) end end + world:set(component, jecs.OnChange, on_change) end table.insert(listeners, fn) end @@ -134,12 +135,12 @@ local function observers_add(world: jecs.World & { [string]: any }): PatchedWorl if not listeners then listeners = {} signals.removed[component] = listeners - local idr = jecs.id_record_ensure(world, component) - idr.hooks.on_remove = function(entity) + local function on_remove(entity: number, id: number, value: any) for _, listener in listeners do - listener(entity, component) + listener(entity, id, value) end end + world:set(component, jecs.OnRemove, on_remove) end table.insert(listeners, fn) end diff --git a/benches/visual/insertion.bench.luau b/benches/visual/insertion.bench.luau index f33821d..802b406 100644 --- a/benches/visual/insertion.bench.luau +++ b/benches/visual/insertion.bench.luau @@ -4,10 +4,9 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") local Matter = require(ReplicatedStorage.DevPackages.Matter) local ecr = require(ReplicatedStorage.DevPackages.ecr) -local jecs = require(ReplicatedStorage.Lib) -local newWorld = Matter.World.new() +local jecs = require(ReplicatedStorage.Lib:Clone()) local ecs = jecs.World.new() -local mirror = require(ReplicatedStorage.mirror) +local mirror = require(ReplicatedStorage.mirror:Clone()) local mcs = mirror.World.new() local A1 = Matter.component() diff --git a/benches/visual/spawn.bench.luau b/benches/visual/spawn.bench.luau index 5dc2b84..393407c 100644 --- a/benches/visual/spawn.bench.luau +++ b/benches/visual/spawn.bench.luau @@ -2,35 +2,25 @@ --!native local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Matter = require(ReplicatedStorage.DevPackages.Matter) -local ecr = require(ReplicatedStorage.DevPackages.ecr) -local jecs = require(ReplicatedStorage.Lib) -local newWorld = Matter.World.new() +local jecs = require(ReplicatedStorage.Lib:Clone()) +local mirror = require(ReplicatedStorage.mirror:Clone()) + local ecs = jecs.World.new() +local mcs = mirror.world() return { ParameterGenerator = function() - local registry2 = ecr.registry() - - return registry2 end, Functions = { - Matter = function() - for i = 1, 1000 do - newWorld:spawn() + Mirror = function() + for i = 1000, 1100 do + mcs:entity(i) end end, - - ECR = function(_, registry2) - for i = 1, 1000 do - registry2.create() - end - end, - Jecs = function() for i = 1, 1000 do - ecs:entity() + ecs:entity(i) end end, }, diff --git a/jecs.luau b/jecs.luau index b7dae17..458e3c2 100644 --- a/jecs.luau +++ b/jecs.luau @@ -45,9 +45,9 @@ type ecs_id_record_t = { flags: number, size: number, hooks: { - on_add: ((entity: i53, data: any?) -> ())?, - on_change: ((entity: i53, data: any) -> ())?, - on_remove: ((entity: i53) -> ())?, + on_add: ((entity: i53, id: i53, data: any?) -> ())?, + on_change: ((entity: i53, id: i53, data: any) -> ())?, + on_remove: ((entity: i53, id: i53) -> ())?, }, } @@ -62,6 +62,8 @@ type ecs_entity_index_t = { sparse_array: Map, alive_count: number, max_id: number, + range_begin: number?, + range_end: number? } type ecs_query_data_t = { @@ -118,6 +120,7 @@ local ECS_GENERATION_MASK = bit32.lshift(1, 16) local NULL_ARRAY = table.freeze({}) :: Column local NULL = newproxy(false) +local NULL_RECORD = table.freeze({ dense = 0 }) :: ecs_record_t local ECS_INTERNAL_ERROR = [[ This is an internal error, please file a bug report via the following link: @@ -144,7 +147,7 @@ end local function ECS_META(id: i53, ty: i53, value: any?) local bundle = ecs_metadata[id] - if bundle then + if bundle == nil then bundle = {} ecs_metadata[id] = bundle end @@ -185,6 +188,10 @@ local function ECS_ENTITY_T_LO(e: i53): i24 return e % ECS_ENTITY_MASK end +local function ECS_ID(e: i53) + return e % ECS_ENTITY_MASK +end + local function ECS_GENERATION(e: i53) return e // ECS_ENTITY_MASK end @@ -249,10 +256,10 @@ local function entity_index_is_alive(entity_index: ecs_entity_index_t, entity: i return entity_index_try_get(entity_index, entity) ~= nil end -local function entity_index_get_alive(index: ecs_entity_index_t, entity: i53): i53? - local r = entity_index_try_get_any(index, entity) +local function entity_index_get_alive(entity_index: ecs_entity_index_t, entity: i53): i53? + local r = entity_index_try_get_any(entity_index, entity) if r then - return index.dense_array[r.dense] + return entity_index.dense_array[r.dense] end return nil end @@ -283,8 +290,10 @@ end local function entity_index_new_id(entity_index: ecs_entity_index_t): i53 local dense_array = entity_index.dense_array local alive_count = entity_index.alive_count + local sparse_array = entity_index.sparse_array local max_id = entity_index.max_id - if alive_count ~= max_id then + + if alive_count < max_id then alive_count += 1 entity_index.alive_count = alive_count local id = dense_array[alive_count] @@ -292,11 +301,15 @@ local function entity_index_new_id(entity_index: ecs_entity_index_t): i53 end local id = max_id + 1 + local range_end = entity_index.range_end + if range_end then + assert(id < range_end) + end entity_index.max_id = id alive_count += 1 entity_index.alive_count = alive_count dense_array[alive_count] = id - entity_index.sparse_array[id] = { dense = alive_count } :: ecs_record_t + sparse_array[id] = { dense = alive_count } :: ecs_record_t return id end @@ -500,6 +513,14 @@ local function world_has_one_inline(world: ecs_world_t, entity: i53, id: i53): b return records[id] ~= nil end +local function ecs_is_tag(world: ecs_world_t, entity: i53): boolean + local idr = world.component_index[entity] + if idr then + return bit32.band(idr.flags, ECS_ID_IS_TAG) ~= 0 + end + return not world_has_one_inline(world, entity, EcsComponent) +end + local function world_has(world: ecs_world_t, entity: i53, a: i53, b: i53?, c: i53?, d: i53?, e: i53?): boolean @@ -711,8 +732,117 @@ local function archetype_create(world: ecs_world_t, id_types: { i24 }, ty, prev: return archetype end -local function world_entity(world: ecs_world_t): i53 - return entity_index_new_id(world.entity_index) +local function world_range(world: ecs_world_t, range_begin: number, range_end: number?) + local entity_index = world.entity_index + if range_end then + range_end += 1 + end + + entity_index.range_begin = range_begin + entity_index.range_end = range_end + + local max_id = entity_index.max_id + local dense_array = entity_index.dense_array + local sparse_array = entity_index.sparse_array + + if range_begin > max_id then + for i = max_id, range_begin - 1 do + dense_array[i] = 0 + sparse_array[i] = NULL_RECORD + end + sparse_array[range_begin] = { dense = 0 } :: ecs_record_t + end + + entity_index.max_id = range_begin + entity_index.alive_count = range_begin +end + +local function world_entity(world: ecs_world_t, entity: i53?): i53 + local entity_index = world.entity_index + if entity then + local index = ECS_ID(entity) + local range_begin = entity_index.range_begin + if range_begin then + assert(index > range_begin) + end + local max_id = entity_index.max_id + local sparse_array = entity_index.sparse_array + local dense_array = entity_index.dense_array + local alive_count = entity_index.alive_count + local r = sparse_array[index] + if r then + local dense = r.dense + if not dense or dense == 0 then + dense = index + end + local any = dense_array[dense] + if any == entity then + if alive_count > dense then + return entity + end + local e_swap = dense_array[alive_count] + local r_swap = sparse_array[alive_count] + r_swap.dense = dense + r.dense = alive_count + dense_array[alive_count] = entity + dense_array[dense] = e_swap + return entity + end + + if any ~= 0 then + local e_swap = dense_array[alive_count] + local r_swap = sparse_array[alive_count] + + if dense > alive_count then + r_swap.dense = dense + r.dense = alive_count + dense_array[alive_count] = any + dense_array[dense] = e_swap + else + r_swap.dense = dense + -- alive_count += 1 + alive_count += 1 + entity_index.alive_count = alive_count + r.dense = alive_count + dense_array[alive_count] = any + dense_array[dense] = e_swap + end + return any + else + error("should never happen") + end + else + local range_end = entity_index.range_end + if range_end then + assert(index < range_end) + end + for i = max_id + 1, index do + sparse_array[i] = { dense = i } :: ecs_record_t + dense_array[i] = i + end + entity_index.max_id = index + + local e_swap = dense_array[alive_count] + local r_swap = sparse_array[alive_count] + r_swap.dense = index + + alive_count += 1 + entity_index.alive_count = alive_count + + r = sparse_array[index] + + r.dense = alive_count + + sparse_array[index] = r + + dense_array[index] = e_swap + dense_array[alive_count] = entity + + + return entity + end + end + return entity_index_new_id(entity_index, entity) end local function world_parent(world: ecs_world_t, entity: i53) @@ -849,7 +979,7 @@ local function world_add( local on_add = idr.hooks.on_add if on_add then - on_add(entity) + on_add(entity, id) end end @@ -874,7 +1004,7 @@ local function world_set(world: ecs_world_t, entity: i53, id: i53, data: unknown -- and just set the data directly. local on_change = idr_hooks.on_change if on_change then - on_change(entity, data) + on_change(entity, id, data) end return @@ -897,7 +1027,7 @@ local function world_set(world: ecs_world_t, entity: i53, id: i53, data: unknown local on_add = idr_hooks.on_add if on_add then - on_add(entity, data) + on_add(entity, id, data) end end @@ -929,7 +1059,7 @@ local function world_remove(world: ecs_world_t, entity: i53, id: i53) local idr = world.component_index[id] local on_remove = idr.hooks.on_remove if on_remove then - on_remove(entity) + on_remove(entity, id) end local to = archetype_traverse_remove(world, id, record.archetype) @@ -981,7 +1111,7 @@ local function archetype_delete(world: ecs_world_t, archetype: ecs_archetype_t, local idr = component_index[id] local on_remove = idr.hooks.on_remove if on_remove then - on_remove(delete) + on_remove(delete, id) end end @@ -1041,7 +1171,7 @@ local function world_clear(world: ecs_world_t, entity: i53) continue end if not ids then - ids = {} + ids = {} :: { [i53]: boolean } end ids[id] = true removal_queued = true @@ -1052,7 +1182,7 @@ local function world_clear(world: ecs_world_t, entity: i53) end if not queue then - queue = {} + queue = {} :: { i53 } end local n = #entities @@ -1242,7 +1372,7 @@ local function world_delete(world: ecs_world_t, entity: i53) break else if not ids then - ids = {} + ids = {} :: { [i53]: boolean } end ids[id] = true removal_queued = true @@ -1253,7 +1383,7 @@ local function world_delete(world: ecs_world_t, entity: i53) continue end if not children then - children = {} + children = {} :: { i53 } end local n = #entities table.move(entities, 1, n, count + 1, children) @@ -2303,6 +2433,8 @@ export type EntityIndex = { sparse_array: Map, alive_count: number, max_id: number, + range_begin: number?, + range_end: number? } local World = {} @@ -2324,6 +2456,7 @@ World.contains = world_contains World.cleanup = world_cleanup World.each = world_each World.children = world_children +World.range = world_range local function world_new() local entity_index = { @@ -2364,16 +2497,6 @@ local function world_new() entity_index_new_id(entity_index) end - for i, bundle in ecs_metadata do - for ty, value in bundle do - if value == NULL then - world_add(self, i, ty) - else - world_set(self, i, ty, value) - end - end - end - world_add(self, EcsName, EcsComponent) world_add(self, EcsOnChange, EcsComponent) world_add(self, EcsOnAdd, EcsComponent) @@ -2396,6 +2519,16 @@ local function world_new() world_add(self, EcsChildOf, ECS_PAIR(EcsOnDeleteTarget, EcsDelete)) + for i, bundle in ecs_metadata do + for ty, value in bundle do + if value == NULL then + world_add(self, i, ty) + else + world_set(self, i, ty, value) + end + end + end + return self end @@ -2523,6 +2656,7 @@ return { component = (ECS_COMPONENT :: any) :: () -> Entity, tag = (ECS_TAG :: any) :: () -> Entity, meta = (ECS_META :: any) :: (id: Entity, id: Id, value: T) -> Entity, + is_tag = (ecs_is_tag :: any) :: (World, Id) -> boolean, OnAdd = EcsOnAdd :: Entity<(entity: Entity) -> ()>, OnRemove = EcsOnRemove :: Entity<(entity: Entity) -> ()>, @@ -2549,6 +2683,8 @@ return { ECS_META_RESET = ECS_META_RESET, IS_PAIR = (ECS_IS_PAIR :: any) :: (pair: Pair) -> boolean, + ECS_PAIR_FIRST = ECS_PAIR_FIRST, + ECS_PAIR_SECOND = ECS_PAIR_SECOND, pair_first = (ecs_pair_first :: any) :: (world: World, pair: Pair) -> Id

, pair_second = (ecs_pair_second :: any) :: (world: World, pair: Pair) -> Id, entity_index_get_alive = entity_index_get_alive, diff --git a/test/addons/observers.luau b/test/addons/observers.luau index cf5e10a..26461c7 100644 --- a/test/addons/observers.luau +++ b/test/addons/observers.luau @@ -82,6 +82,25 @@ TEST("addons/observers", function() world:set(e, A, true) CHECK(count == 3) end + + do CASE "Call on pairs" + local A = world:component() + + local callcount = 0 + world:added(A, function(entity) + callcount += 1 + end) + world:added(A, function(entity) + callcount += 1 + end) + + local e = world:entity() + local e1 = world:entity() + + world:add(e1, jecs.pair(A, e)) + world:add(e, jecs.pair(A, e1)) + CHECK(callcount == 4) + end end) return FINISH() diff --git a/test/tests.luau b/test/tests.luau index 1aea9e1..c1630ee 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -22,6 +22,7 @@ type Entity = jecs.Entity type Id = jecs.Id local entity_visualiser = require("@tools/entity_visualiser") +local lifetime_tracker_add = require("@tools/lifetime_tracker") local dwi = entity_visualiser.stringify TEST("world:add()", function() @@ -246,6 +247,8 @@ TEST("world:component()", function() end) TEST("world:contains()", function() + + local tag = jecs.tag() local world = jecs.world() local id = world:entity() CHECK(world:contains(id)) @@ -255,6 +258,10 @@ TEST("world:contains()", function() world:delete(id) CHECK(not world:contains(id)) end + + + CHECK(world:contains(tag)) + jecs.ECS_META_RESET() end) TEST("world:delete()", function() @@ -626,8 +633,65 @@ TEST("world:each()", function() end end) +FOCUS() TEST("world:entity()", function() + do CASE "range" + local world = jecs.world() + world = lifetime_tracker_add(world, {}) + world:range(1000, 2000) + + world:entity(1590) + CHECK_EXPECT_ERR(function() + + world:entity(5000) + end) + + CHECK(world:contains(1590)) + world:set(591, jecs.Name, "9888") + CHECK(not world:contains(591)) + CHECK(not world:contains(5000)) + CHECK(not world:contains(988)) + end + do CASE "desired id" + local world = jecs.world() + world = lifetime_tracker_add(world, {}) + local id = world:entity() + local e = world:entity(id + 5) + CHECK(e == id + 5) + CHECK(world:contains(e)) + local e2 = world:entity() + CHECK(world:contains(e2)) + + -- world:print_entity_index() + local e3 = world:entity(275) + + CHECK(e3 == 275) + CHECK(world:contains(e3)) + + CHECK(e3 == world:entity(e3)) + + world:delete(e3) + + print("---------------------------") + local e3v1 = world:entity(275) + CHECK(not world:contains(275)) + CHECK(jecs.ECS_GENERATION(e3v1) == 1) + CHECK(jecs.ECS_ID(e3v1) == 275) + + + CHECK(world:contains(e3v1)) + -- world:print_entity_index() + print("--------begin") + world:entity(e3) + print("-----end") + + world:print_entity_index() + + world:entity(e3) + world:entity(275) + end local N = 2^8 + do CASE "unique IDs" local world = jecs.world() local set = {} @@ -1427,8 +1491,6 @@ TEST("#adding a recycled target", function() end) - - TEST("#repro2", function() local world = jecs.world() local Lifetime = world:component() :: Id diff --git a/tools/lifetime_tracker.luau b/tools/lifetime_tracker.luau index 62a5887..fa486a4 100644 --- a/tools/lifetime_tracker.luau +++ b/tools/lifetime_tracker.luau @@ -28,7 +28,7 @@ end local padding_enabled = false local function pad() if padding_enabled then - print("") + print("------------------") end end @@ -64,7 +64,8 @@ local function lifetime_tracker_add(world: jecs.World, opt): PatchedWorld world.print_entity_index = function() local max_id = entity_index.max_id local alive_count = entity_index.alive_count - local alive = table.move(dense_array, 1 + jecs.Rest :: any, alive_count, 1, {}) + local range_begin = entity_index.range_begin or jecs.Rest + 1 + local alive = table.move(dense_array, range_begin :: any, alive_count, 1, {}) local dead = table.move(dense_array, alive_count + 1, max_id, 1, {}) local sep = "|--------|" diff --git a/tools/testkit.luau b/tools/testkit.luau index 223eb0c..89a6c4a 100644 --- a/tools/testkit.luau +++ b/tools/testkit.luau @@ -226,8 +226,9 @@ local function CHECK(value: T, stack: number?): T? return value end -local function TEST(name: string, fn: () -> ()) +local test_focused = false +local function TEST(name: string, fn: () -> ()) test = { name = name, cases = {}, @@ -235,21 +236,20 @@ local function TEST(name: string, fn: () -> ()) focus = false, fn = fn } - + local t = test + if check_for_focused and not test_focused then + test.focus = true + test_focused = true + end + table.insert(tests, t) end local function FOCUS() - assert(test, "no active test") - check_for_focused = true - if test.case then - test.case.focus = true - else - test.focus = true - end + test_focused = false end local function FINISH(): number