diff --git a/jecs.luau b/jecs.luau index 94402a4c..832763cb 100644 --- a/jecs.luau +++ b/jecs.luau @@ -38,7 +38,7 @@ export type Archetype = { records: { ArchetypeRecord }, } & GraphNode -type Record = { +export type Record = { archetype: Archetype, row: number, dense: i24, @@ -1554,6 +1554,43 @@ local function world_query(world: World, ...) return q end +local function world_each(world: World, id): () -> () + local idr = world.componentIndex[id] + if not idr then + return NOOP + end + + local idr_cache = idr.cache + local archetypes = world.archetypes + local archetype_id = next(idr_cache, nil) :: number + local archetype = archetypes[archetype_id] + if not archetype then + return NOOP + end + + local entities = archetype.entities + local row = #entities + + return function(): any + local entity = entities[row] + while not entity do + archetype_id = next(idr_cache, archetype_id) + if not archetype_id then + return + end + archetype = archetypes[archetype_id] + entities = archetype.entities + row = #entities + end + row -= 1 + return entity + end +end + +local function world_children(world, parent) + return world_each(world, ECS_PAIR(EcsChildOf, parent)) +end + local World = {} World.__index = World @@ -1571,15 +1608,19 @@ World.target = world_target World.parent = world_parent World.contains = world_contains World.cleanup = world_cleanup +World.each = world_each +World.children = world_children if _G.__JECS_DEBUG then - -- taken from https://github.com/centau/ecr/blob/main/src/ecr.luau - -- error but stack trace always starts at first callsite outside of this file + local function dbg_info(n: number): any + return debug.info(n, "s") + end local function throw(msg: string) local s = 1 + local root = dbg_info(1) repeat s += 1 - until debug.info(s, "s") ~= debug.info(1, "s") + until dbg_info(s) ~= root if warn then error(msg, s) else @@ -1594,15 +1635,18 @@ if _G.__JECS_DEBUG then throw(msg) end - local function get_name(world, id): string - local name: string | nil + local function get_name(world, id) + return world_get_one_inline(world, id, EcsName) + end + + local function bname(world: World, id): string + local name: string if ECS_IS_PAIR(id) then - name = `pair({get_name(world, ECS_ENTITY_T_HI(id))}, {get_name(world, ECS_ENTITY_T_LO(id))})` + local first = get_name(world, ecs_pair_first(world, id)) + local second = get_name(world, ecs_pair_second(world, id)) + name = `pair({first}, {second})` else - local _1 = world_get_one_inline(world, id, EcsName) - if _1 then - name = `${_1}` - end + return get_name(world, id) end if name then return name @@ -1626,14 +1670,14 @@ if _G.__JECS_DEBUG then World.set = function(world: World, entity: i53, id: i53, value: any): () local is_tag = ID_IS_TAG(world, id) if is_tag and value == nil then - local _1 = get_name(world, entity) - local _2 = get_name(world, id) + local _1 = bname(world, entity) + local _2 = bname(world, id) local why = "cannot set component value to nil" throw(why) return elseif value ~= nil and is_tag then - local _1 = get_name(world, entity) - local _2 = get_name(world, id) + local _1 = bname(world, entity) + local _2 = bname(world, id) local why = `cannot set a component value because {_2} is a tag` why ..= `\n[jecs] note: consider using "world:add({_1}, {_2})" instead` throw(why) @@ -1645,8 +1689,8 @@ if _G.__JECS_DEBUG then World.add = function(world: World, entity: i53, id: i53, value: any) if value ~= nil then - local _1 = get_name(world, entity) - local _2 = get_name(world, id) + local _1 = bname(world, entity) + local _2 = bname(world, id) throw("You provided a value when none was expected. " .. `Did you mean to use "world:add({_1}, {_2})"`) end @@ -1747,7 +1791,7 @@ export type Pair = _Pair type Item = (self: Query) -> (Entity, T...) -export type Entity = number & { __T: T } +export type Entity = number & { __T: T } type Iter = (query: Query) -> () -> (Entity, T...) @@ -1807,6 +1851,10 @@ export type World = { --- Checks if the world contains the given entity contains: (self: World, entity: Entity) -> boolean, + each: (self: World, id: Id) -> () -> Entity, + + children: (self: World, id: Id) -> () -> Entity, + --- Searches the world for entities that match a given query query: ((self: World, Id) -> Query) & ((self: World, Id, Id) -> Query) @@ -1897,4 +1945,10 @@ return { entity_index_try_get_fast = entity_index_try_get_fast, entity_index_is_alive = entity_index_is_alive, entity_index_new_id = entity_index_new_id, + + query_iter = query_iter, + query_iter_init = query_iter_init, + query_with = query_with, + query_without = query_without, + query_archetypes = query_archetypes, } diff --git a/test/testkit.luau b/test/testkit.luau index 96396b7e..d52484b7 100644 --- a/test/testkit.luau +++ b/test/testkit.luau @@ -289,7 +289,7 @@ local function FINISH(): boolean return success, table.clear(tests) end -local function SKIP(name: string) +local function SKIP() skip = true end diff --git a/test/tests.luau b/test/tests.luau index 14633a8c..12d60b13 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -12,26 +12,19 @@ local ecs_pair_second = jecs.pair_second local entity_index_try_get_any = jecs.entity_index_try_get_any local entity_index_get_alive = jecs.entity_index_get_alive local entity_index_is_alive = jecs.entity_index_is_alive +local ChildOf = jecs.ChildOf local world_new = jecs.World.new local TEST, CASE, CHECK, FINISH, SKIP, FOCUS = 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 = 2 ^ 8 -type World = jecs.WorldShim +type World = jecs.World +type Entity = jecs.Entity -local function debug_world_inspect(world) - local function record(e) - return entity_index_try_get_any(world.entity_index, e) +local function debug_world_inspect(world: World) + local function record(e): jecs.Record + return entity_index_try_get_any(world.entity_index, e) :: any end local function tbl(e) return record(e).archetype @@ -69,10 +62,6 @@ local function debug_world_inspect(world) } end -local function name(world, e) - return world:get(e, jecs.Name) -end - TEST("archetype", function() local archetype_append_to_records = jecs.archetype_append_to_records local id_record_ensure = jecs.id_record_ensure @@ -117,6 +106,7 @@ TEST("world:cleanup()", function() world:set(e2, A, true) world:set(e2, B, true) + world:set(e3, A, true) world:set(e3, B, true) world:set(e3, C, true) @@ -135,19 +125,19 @@ TEST("world:cleanup()", function() archetypeIndex = world.archetypeIndex - CHECK(archetypeIndex["1"] == nil) - CHECK(archetypeIndex["1_2"] == nil) - CHECK(archetypeIndex["1_2_3"] == nil) + CHECK((archetypeIndex["1"] :: jecs.Archetype?) == nil) + CHECK((archetypeIndex["1_2"] :: jecs.Archetype?) == nil) + CHECK((archetypeIndex["1_2_3"] :: jecs.Archetype?) == nil) local e4 = world:entity() world:set(e4, A, true) CHECK(#archetypeIndex["1"].entities == 1) - CHECK(archetypeIndex["1_2"] == nil) - CHECK(archetypeIndex["1_2_3"] == nil) + CHECK((archetypeIndex["1_2"] :: jecs.Archetype?) == nil) + CHECK((archetypeIndex["1_2_3"] :: jecs.Archetype?) == nil) world:set(e4, B, true) CHECK(#archetypeIndex["1"].entities == 0) CHECK(#archetypeIndex["1_2"].entities == 1) - CHECK(archetypeIndex["1_2_3"] == nil) + CHECK((archetypeIndex["1_2_3"] :: jecs.Archetype?) == nil) world:set(e4, C, true) CHECK(#archetypeIndex["1"].entities == 0) CHECK(#archetypeIndex["1_2"].entities == 0) @@ -169,7 +159,7 @@ TEST("world:entity()", function() CASE("generations") local world = jecs.World.new() local e = world:entity() - CHECK(ECS_ID(e) == 1 + jecs.Rest) + CHECK(ECS_ID(e) == 1 + jecs.Rest :: number) CHECK(ECS_GENERATION(e) == 0) -- 0 e = ECS_GENERATION_INC(e) CHECK(ECS_GENERATION(e) == 1) -- 1 @@ -213,7 +203,7 @@ TEST("world:entity()", function() do CASE "Recycling max generation" local world = world_new() - local pin = jecs.Rest + 1 + local pin = jecs.Rest::number + 1 for i = 1, 2^16-1 do local e = world:entity() world:delete(e) @@ -368,13 +358,12 @@ TEST("world:add()", function() end) TEST("world:query()", function() - do - CASE("multiple iter") + do CASE("multiple iter") local world = jecs.World.new() local A = world:component() local B = world:component() local e = world:entity() - world:add(e, A, "a") + world:add(e, A) world:add(e, B) local q = world:query(A, B) local counter = 0 @@ -874,6 +863,52 @@ TEST("world:query()", function() end end) +TEST("world:each", function() + local world = world_new() + local A = world:component() + local B = world:component() + local C = world:component() + + local e1 = world:entity() + local e2 = world:entity() + local e3 = world:entity() + + world:set(e1, A, true) + + world:set(e2, A, true) + world:set(e2, B, true) + + world:set(e3, A, true) + world:set(e3, B, true) + world:set(e3, C, true) + + for entity in world:each(A) do + if entity == e1 or entity == e2 or entity == e3 then + CHECK(true) + continue + end + CHECK(false) + end +end) + +TEST("world:children", function() + local world = world_new() + local e1 = world:entity() + local e2 = world:entity() + local e3 = world:entity() + + world:add(e2, pair(ChildOf, e1)) + world:add(e3, pair(ChildOf, e1)) + + for entity in world:children(pair(ChildOf, e1)) do + if entity == e2 or entity == e3 then + CHECK(true) + continue + end + CHECK(false) + end +end) + TEST("world:clear()", function() do CASE("should remove its components") @@ -914,18 +949,18 @@ TEST("world:clear()", function() CHECK(archetype_entities[1] == _e) CHECK(archetype_entities[2] == _e1) - local e_record = entity_index_try_get_any( - world.entity_index, e) - local e1_record = entity_index_try_get_any( - world.entity_index, e1) + local e_record: jecs.Record = entity_index_try_get_any( + world.entity_index, e) :: any + local e1_record: jecs.Record = entity_index_try_get_any( + world.entity_index, e1) :: any CHECK(e_record.archetype == archetype) CHECK(e1_record.archetype == archetype) CHECK(e1_record.row == 2) world:clear(e) - CHECK(e_record.archetype == nil) - CHECK(e_record.row == nil) + CHECK((e_record.archetype :: jecs.Archetype?) == nil) + CHECK((e_record.row :: number?) == nil) CHECK(e1_record.archetype == archetype) CHECK(e1_record.row == 1) @@ -981,15 +1016,14 @@ TEST("world:component()", function() CHECK(not world:has(e, jecs.Component)) end - do - CASE("tag") + do CASE("tag") local world = jecs.World.new() :: World local A = world:component() local B = world:entity() local C = world:entity() local e = world:entity() world:set(e, A, "test") - world:add(e, B, "test") + world:add(e, B) world:set(e, C, 11) CHECK(world:has(e, A)) @@ -1253,276 +1287,9 @@ TEST("world:contains", function() CHECK(not world:contains(id)) end end) -type Tracker = { - track: ( - world: World, - fn: ( - changes: { - added: () -> () -> (number, T), - removed: () -> () -> number, - changed: () -> () -> (number, T, T), - } - ) -> () - ) -> (), -} - -type Entity = number & { __nominal_type_dont_use: T } - -local function diff(a, b) - local size = 0 - for k, v in a do - if b[k] ~= v then - return true - end - size += 1 - end - for k, v in b do - size -= 1 - end - - if size ~= 0 then - return true - end - - return false -end - -local function ChangeTracker(world, T: Entity): Tracker - local PreviousT = jecs.pair(jecs.Rest, T) - local add = {} - local added - local removed - local is_trivial - - local function changes_added() - added = true - local it = world:query(T):without(PreviousT):iter() - return function() - local id, data = it() - if not id then - return nil - end - - is_trivial = typeof(data) ~= "table" - - add[id] = data - - return id, data - end - end - - local function changes_changed() - local it = world:query(T, PreviousT):iter() - - return function() - local id, new, old = it() - while true do - if not id then - return nil - end - - if not is_trivial then - if diff(new, old) then - break - end - elseif new ~= old then - break - end - - id, new, old = it() - end - - add[id] = new - - return id, old, new - end - end - - local function changes_removed() - removed = true - - local it = world:query(PreviousT):without(T):iter() - return function() - local id = it() - if id then - world:remove(id, PreviousT) - end - return id - end - end - - local changes = { - added = changes_added, - changed = changes_changed, - removed = changes_removed, - } - - local function track(fn) - added = false - removed = false - - fn(changes) - - if not added then - for _ in changes_added() do - end - end - - if not removed then - for _ in changes_removed() do - end - end - - for e, data in add do - world:set(e, PreviousT, if is_trivial then data else table.clone(data)) - end - end - - local tracker = { track = track } - - return tracker -end -TEST("changetracker:track()", function() - local world = jecs.World.new() - - do - CASE("added") - local Test = world:component() :: Entity<{ foo: number }> - local TestTracker = ChangeTracker(world, Test) - - local e1 = world:entity() - local data = { foo = 11 } - world:set(e1, Test, data) - - TestTracker.track(function(changes) - local added = 0 - for e, test in changes.added() do - added += 1 - CHECK(test == data) - end - for e, old, new in changes.changed() do - CHECK(false) - end - for e in changes.removed() do - CHECK(false) - end - CHECK(added == 1) - end) - end - do - CASE("changed") - local Test = world:component() :: Entity<{ foo: number }> - local TestTracker = ChangeTracker(world, Test) - - local data = { foo = 11 } - local e1 = world:entity() - world:set(e1, Test, data) - - TestTracker.track(function(changes) end) - - data.foo += 1 - - TestTracker.track(function(changes) - for _ in changes.added() do - CHECK(false) - end - local changed = 0 - for e, old, new in changes.changed() do - CHECK(e == e1) - CHECK(new == data) - CHECK(old ~= new) - CHECK(diff(new, old)) - changed += 1 - end - CHECK(changed == 1) - end) - end - do - CASE("removed") - local Test = world:component() :: Entity<{ foo: number }> - local TestTracker = ChangeTracker(world, Test) - - local data = { foo = 11 } - local e1 = world:entity() - world:set(e1, Test, data) - - TestTracker.track(function(changes) end) - - world:remove(e1, Test) - - TestTracker.track(function(changes) - for _ in changes.added() do - CHECK(false) - end - for _ in changes.changed() do - CHECK(false) - end - local removed = 0 - for e in changes.removed() do - removed += 1 - CHECK(e == e1) - end - CHECK(removed == 1) - end) - end - - do - CASE("multiple change trackers") - local A = world:component() - local B = world:component() - local trackerA = ChangeTracker(world, A) - local trackerB = ChangeTracker(world, B) - - local e1 = world:entity() - world:set(e1, A, "a1") - local e2 = world:entity() - world:set(e2, B, "b1") - - trackerA.track(function() end) - trackerB.track(function() end) - - world:set(e2, B, "b2") - trackerA.track(function(changes) - for _, old, new in changes.changed() do - end - end) - trackerB.track(function(changes) - for _, old, new in changes.changed() do - CHECK(new == "b2") - end - end) - end -end) - -local function create_cache(hook) - local columns = setmetatable({}, { - __index = function(self, component) - local column = {} - self[component] = column - return column - end, - }) - - return function(world, component, fn) - local column = columns[component] - table.insert(column, fn) - world:set(component, hook, function(entity, value) - for _, callback in column do - callback(entity, value) - end - end) - end -end - -local hooks = { - OnSet = create_cache(jecs.OnSet), - OnAdd = create_cache(jecs.OnAdd), - OnRemove = create_cache(jecs.OnRemove), -} TEST("Hooks", function() - do - CASE("OnAdd") + do CASE "OnAdd" local world = jecs.World.new() local Transform = world:component() local e1 = world:entity() @@ -1532,18 +1299,12 @@ TEST("Hooks", function() world:add(e1, Transform) end - do - CASE("OnSet") + do CASE "OnSet" local world = jecs.World.new() local Number = world:component() local e1 = world:entity() - hooks.OnSet(world, Number, function(entity, data) - CHECK(e1 == entity) - CHECK(data == world:get(entity, Number)) - CHECK(data == 1) - end) - hooks.OnSet(world, Number, function(entity, data) + world:set(Number, jecs.OnSet, function(entity, data) CHECK(e1 == entity) CHECK(data == world:get(entity, Number)) CHECK(data == 1) @@ -1551,8 +1312,7 @@ TEST("Hooks", function() world:set(e1, Number, 1) end - do - CASE("OnRemove") + do CASE("OnRemove") do -- basic local world = jecs.World.new() @@ -1585,9 +1345,41 @@ TEST("Hooks", function() CHECK(not world:get(e, B)) end end +end) - do - CASE("the filip incident") +TEST("repro", function() + do CASE "#1" + local world = world_new() + local reproEntity = world:component() + local components = { Cooldown = world:component() :: jecs.Id } + world:set(reproEntity, components.Cooldown, 2) + + local function updateCooldowns(dt: number) + local toRemove = {} + + for id, cooldown in world:query(components.Cooldown):iter() do + cooldown -= dt + + if cooldown <= 0 then + table.insert(toRemove, id) + print("removing") + -- world:remove(id, components.Cooldown) + else + world:set(id, components.Cooldown, cooldown) + end + end + + for _, id in toRemove do + world:remove(id, components.Cooldown) + CHECK(not world:get(id, components.Cooldown)) + end + end + + updateCooldowns(1.5) + updateCooldowns(1.5) + end + + do CASE "#2" local world = jecs.World.new() export type Iterator = () -> (Entity, T?, T?) @@ -1634,7 +1426,7 @@ TEST("Hooks", function() return cachedChangeSets[component] end - local function ChangeTracker(component): (Iterator, Destructor) + local function ChangeTracker(component: jecs.Id): (Iterator, Destructor) local values: ValuesMap = {} local changeSet: ChangeSet = {} @@ -1647,7 +1439,7 @@ TEST("Hooks", function() changeSets.Changed[changeSet] = true changeSets.Removed[changeSet] = true - local id: Entity? = nil + local id: jecs.Id? = nil local iter: Iterator = function() id = next(changeSet) if id then @@ -1687,286 +1479,4 @@ TEST("Hooks", function() CHECK(counter == 1) end end) - -TEST("scheduler", function() - type System = { - callback: (world: World) -> (), - } - type Systems = { System } - - type Events = { - RenderStepped: Systems, - Heartbeat: Systems, - } - - local scheduler_new: ( - w: World - ) -> { - components: { - Disabled: Entity, - System: Entity, - Phase: Entity, - DependsOn: Entity, - }, - - collect: { - under_event: (event: Entity) -> Systems, - all: () -> Events, - }, - - systems: { - run: (events: Events) -> (), - new: (callback: (world: World) -> (), phase: Entity) -> Entity, - }, - - phases: { - RenderStepped: Entity, - Heartbeat: Entity, - }, - - phase: (after: Entity) -> Entity, - } - - do - local world - local Disabled - local System - local DependsOn - local Phase - local Event - local RenderStepped - local Heartbeat - local Name - - local function scheduler_systems_run(events) - for _, system in events[RenderStepped] do - system.callback() - end - for _, system in events[Heartbeat] do - system.callback() - end - end - - local function scheduler_collect_systems_under_phase_recursive(systems, phase) - for _, system in world:query(System):with(pair(DependsOn, phase)) do - table.insert(systems, system) - end - for dependant in world:query(Phase):with(pair(DependsOn, phase)) do - scheduler_collect_systems_under_phase_recursive(systems, dependant) - end - end - - local function scheduler_collect_systems_under_event(event) - local systems = {} - scheduler_collect_systems_under_phase_recursive(systems, event) - return systems - end - - local function scheduler_collect_systems_all() - local systems = {} - for phase in world:query(Phase, Event) do - systems[phase] = scheduler_collect_systems_under_event(phase) - end - return systems - end - - local function scheduler_phase_new(after) - local phase = world:entity() - world:add(phase, Phase) - local dependency = pair(DependsOn, after) - world:add(phase, dependency) - return phase - end - - local function scheduler_systems_new(callback, phase) - local system = world:entity() - world:set(system, System, { callback = callback }) - world:add(system, pair(DependsOn, phase)) - return system - end - - function scheduler_new(w) - world = w - Disabled = world:component() - System = world:component() - Phase = world:component() - DependsOn = world:component() - Event = world:component() - - RenderStepped = world:component() - Heartbeat = world:component() - - world:add(RenderStepped, Phase) - world:add(RenderStepped, Event) - world:add(Heartbeat, Phase) - world:add(Heartbeat, Event) - - return { - phase = scheduler_phase_new, - - phases = { - RenderStepped = RenderStepped, - Heartbeat = Heartbeat, - }, - - world = world, - - components = { - DependsOn = DependsOn, - Disabled = Disabled, - Heartbeat = Heartbeat, - Phase = Phase, - RenderStepped = RenderStepped, - System = System, - }, - - collect = { - under_event = scheduler_collect_systems_under_event, - all = scheduler_collect_systems_all, - }, - - systems = { - new = scheduler_systems_new, - run = scheduler_systems_run, - }, - } - end - end - - do - CASE("event dependant phase") - - local world = jecs.World.new() - local scheduler = scheduler_new(world) - local components = scheduler.components - local phases = scheduler.phases - local Heartbeat = phases.Heartbeat - local DependsOn = components.DependsOn - - local Physics = scheduler.phase(Heartbeat) - CHECK(world:target(Physics, DependsOn, 0) == Heartbeat) - end - - do - CASE("user-defined sub phases") - local world = jecs.World.new() - local scheduler = scheduler_new(world) - local components = scheduler.components - local phases = scheduler.phases - local DependsOn = components.DependsOn - - local A = scheduler.phase(phases.Heartbeat) - local B = scheduler.phase(A) - - CHECK(world:target(B, DependsOn, 0) == A) - end - - do - CASE("phase order") - local world = jecs.World.new() - local scheduler = scheduler_new(world) - - local phases = scheduler.phases - local Physics = scheduler.phase(phases.Heartbeat) - local Collisions = scheduler.phase(Physics) - - local order = "BEGIN" - - local function move() - order ..= "->move" - end - - local function hit() - order ..= "->hit" - end - - local createSystem = scheduler.systems.new - - createSystem(hit, Collisions) - createSystem(move, Physics) - - local events = scheduler.collect.all() - scheduler.systems.run(events) - - order ..= "->END" - - CHECK(order == "BEGIN->move->hit->END") - end - - do - CASE("collect only systems under phase recursive") - local world = jecs.World.new() - local scheduler = scheduler_new(world) - local phases = scheduler.phases - local Heartbeat = phases.Heartbeat - local RenderStepped = phases.RenderStepped - local Render = scheduler.phase(RenderStepped) - local Physics = scheduler.phase(Heartbeat) - local Collisions = scheduler.phase(Physics) - - local function move() end - - local function hit() end - - local function camera() end - - local createSystem = scheduler.systems.new - - createSystem(hit, Collisions) - createSystem(move, Physics) - createSystem(camera, Render) - - local systems = scheduler.collect.under_event(Collisions) - - CHECK(#systems == 1) - CHECK(systems[1].callback == hit) - - systems = scheduler.collect.under_event(Physics) - - CHECK(#systems == 2) - - systems = scheduler.collect.under_event(Heartbeat) - - CHECK(#systems == 2) - - systems = scheduler.collect.under_event(Render) - - CHECK(#systems == 1) - CHECK(systems[1].callback == camera) - end -end) - -TEST("repro", function() - do - CASE("") - local world = world_new() - local reproEntity = world:component() - local components = { Cooldown = world:component() } - world:set(reproEntity, components.Cooldown, 2) - - local function updateCooldowns(dt: number) - local toRemove = {} - - for id, cooldown in world:query(components.Cooldown):iter() do - cooldown -= dt - - if cooldown <= 0 then - table.insert(toRemove, id) - print("removing") - -- world:remove(id, components.Cooldown) - else - world:set(id, components.Cooldown, cooldown) - end - end - - for _, id in toRemove do - world:remove(id, components.Cooldown) - CHECK(not world:get(id, components.Cooldown)) - end - end - - updateCooldowns(1.5) - updateCooldowns(1.5) - end -end) FINISH()