diff --git a/.github/workflows/unit-testing.yaml b/.github/workflows/unit-testing.yaml index 5e18765..01a9981 100644 --- a/.github/workflows/unit-testing.yaml +++ b/.github/workflows/unit-testing.yaml @@ -13,7 +13,10 @@ jobs: uses: actions/checkout@v4 - name: Install Luau - uses: encodedvenom/install-luau@v2.1 + uses: encodedvenom/install-luau@v4.2 + with: + version: '0.651' + verbose: 'true' - name: Run Unit Tests id: run_tests diff --git a/package-lock.json b/package-lock.json index 9d7e068..3eda984 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@rbxts/jecs", - "version": "0.3.3", + "version": "0.4.0-rc.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@rbxts/jecs", - "version": "0.3.3", + "version": "0.4.0-rc.0", "license": "MIT", "devDependencies": { "@rbxts/compiler-types": "^2.3.0-types.1", diff --git a/package.json b/package.json index d64c35b..5c2cccc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/jecs", - "version": "0.3.3", + "version": "0.4.0-rc.0", "description": "Stupidly fast Entity Component System", "main": "src", "repository": { diff --git a/src/index.d.ts b/src/index.d.ts index 53d436c..f6e5e7e 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -2,7 +2,7 @@ * A unique identifier in the world, entity. * The generic type T defines the data type when this entity is used as a component */ -export type Entity = number & { __jecs_value: T }; +export type Entity = number & { __jecs_value: T }; /** * An entity with no associated data when used as a component diff --git a/src/init.luau b/src/init.luau index 7cb467d..d9b4434 100644 --- a/src/init.luau +++ b/src/init.luau @@ -44,11 +44,6 @@ type Record = { dense: i24, } -type EntityIndex = { - dense: Map, - sparse: Map, -} - type ArchetypeRecord = { count: number, column: number, @@ -74,6 +69,13 @@ type ArchetypeDiff = { removed: Ty, } +type EntityIndex = { + dense_array: Map, + sparse_array: Map, + alive_count: number, + max_id: number, +} + local HI_COMPONENT_ID = _G.__JECS_HI_COMPONENT_ID or 256 -- stylua: ignore start local EcsOnAdd = HI_COMPONENT_ID + 1 @@ -89,8 +91,8 @@ local EcsRemove = HI_COMPONENT_ID + 10 local EcsName = HI_COMPONENT_ID + 11 local EcsRest = HI_COMPONENT_ID + 12 -local ECS_PAIR_FLAG = 0x8 -local ECS_ID_FLAGS_MASK = 0x10 +local ECS_PAIR_FLAG = 0x8 +local ECS_ID_FLAGS_MASK = 0x10 local ECS_ENTITY_MASK = bit32.lshift(1, 24) local ECS_GENERATION_MASK = bit32.lshift(1, 16) @@ -141,7 +143,12 @@ local function ECS_GENERATION_INC(e: i53) local id = flags // ECS_ENTITY_MASK local generation = flags % ECS_GENERATION_MASK - return ECS_COMBINE(id, generation + 1) + flags + local next_gen = generation + 1 + if next_gen > ECS_GENERATION_MASK then + return id + end + + return ECS_COMBINE(id, next_gen) end return ECS_COMBINE(e, 1) end @@ -164,49 +171,83 @@ local function ECS_PAIR(pred: i53, obj: i53): i53 return ECS_COMBINE(ECS_ENTITY_T_LO(obj), ECS_ENTITY_T_LO(pred)) + FLAGS_ADD(--[[isPair]] true) :: i53 end -local ERROR_ENTITY_NOT_ALIVE = "Entity is not alive" -local ERROR_GENERATION_INVALID = "INVALID GENERATION" - -local function entity_index_get_alive(index: EntityIndex, e: i24): i53 - local denseArray = index.dense - local id = denseArray[ECS_ENTITY_T_LO(e)] - - if id then - local currentGeneration = ECS_GENERATION(id) - local gen = ECS_GENERATION(e) - if gen == currentGeneration then - return id - end - - error(ERROR_GENERATION_INVALID) +local function entity_index_try_get_any(entity_index: EntityIndex, entity: number): Record? + local r = entity_index.sparse_array[ECS_ENTITY_T_LO(entity)] + if not r then + return nil end - error(ERROR_ENTITY_NOT_ALIVE) + if not r or r.dense == 0 then + return nil + end + + return r end -local function _entity_index_sparse_get(entityIndex, id) - return entityIndex.sparse[entity_index_get_alive(entityIndex, id)] +local function entity_index_try_get(entity_index: EntityIndex, entity: number): Record? + local r = entity_index_try_get_any(entity_index, entity) + if r then + local r_dense = r.dense + if r_dense > entity_index.alive_count then + return nil + end + if entity_index.dense_array[r_dense] ~= entity then + return nil + end + end + return r +end + +local function entity_index_try_get_fast(entity_index: EntityIndex, entity: number): Record? + local r = entity_index.sparse_array[ECS_ENTITY_T_LO(entity)] + if r then + if entity_index.dense_array[r.dense] ~= entity then + return nil + end + end + return r +end + +local function entity_index_get_alive(index: EntityIndex, e: i24): i53 + local r = entity_index_try_get_any(index, e) + if r then + return index.dense_array[r.dense] + end + return 0 +end + +local function entity_index_is_alive(entity_index: EntityIndex, entity: number) + return entity_index_try_get(entity_index, entity) ~= nil +end + +local function entity_index_new_id(entity_index: EntityIndex, data): i53 + local dense_array = entity_index.dense_array + local alive_count = entity_index.alive_count + if alive_count ~= #dense_array then + alive_count += 1 + entity_index.alive_count = alive_count + local id = dense_array[alive_count] + return id + end + + local id = entity_index.max_id + 1 + 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 } :: Record + + return id end -- ECS_PAIR_FIRST, gets the relationship target / obj / HIGH bits local function ecs_pair_first(world, e) - return entity_index_get_alive(world.entityIndex, ECS_ENTITY_T_HI(e)) + return entity_index_get_alive(world.entity_index, ECS_ENTITY_T_HI(e)) end -- ECS_PAIR_SECOND gets the relationship / pred / LOW bits local function ecs_pair_second(world, e) - return entity_index_get_alive(world.entityIndex, ECS_ENTITY_T_LO(e)) -end - -local function entity_index_new_id(entityIndex: EntityIndex, index: i24): i53 - --local id = ECS_COMBINE(index, 0) - local id = index - entityIndex.sparse[id] = { - dense = index, - } :: Record - entityIndex.dense[index] = id - - return id + return entity_index_get_alive(world.entity_index, ECS_ENTITY_T_LO(e)) end local function archetype_move(entity_index: EntityIndex, to: Archetype, dst_row: i24, from: Archetype, src_row: i24) @@ -239,7 +280,6 @@ local function archetype_move(entity_index: EntityIndex, to: Archetype, dst_row: column[last] = nil end - local sparse = entity_index.sparse local moved = #src_entities -- Move the entity from the source to the destination archetype. @@ -255,9 +295,10 @@ local function archetype_move(entity_index: EntityIndex, to: Archetype, dst_row: src_entities[moved] = nil :: any dst_entities[dst_row] = e1 - local record1 = sparse[e1] - local record2 = sparse[e2] + local sparse_array = entity_index.sparse_array + local record1 = sparse_array[ECS_ENTITY_T_LO(e1)] + local record2 = sparse_array[ECS_ENTITY_T_LO(e2)] record1.row = dst_row record2.row = src_row end @@ -307,7 +348,7 @@ do end function world_get(world: World, entity: i53, a: i53, b: i53?, c: i53?, d: i53?, e: i53?): ...any - local record = world.entityIndex.sparse[entity] + local record = entity_index_try_get_fast(world.entity_index, entity) if not record then return nil end @@ -338,7 +379,7 @@ do end local function world_get_one_inline(world: World, entity: i53, id: i53): any - local record = world.entityIndex.sparse[entity] + local record = entity_index_try_get_fast(world.entity_index, entity) if not record then return nil end @@ -356,7 +397,7 @@ local function world_get_one_inline(world: World, entity: i53, id: i53): any end local function world_has_one_inline(world: World, entity: number, id: i53): boolean - local record = world.entityIndex.sparse[entity] + local record = entity_index_try_get_fast(world.entity_index, entity) if not record then return false end @@ -372,7 +413,7 @@ local function world_has_one_inline(world: World, entity: number, id: i53): bool end local function world_has(world: World, entity: number, ...: i53): boolean - local record = world.entityIndex.sparse[entity] + local record = entity_index_try_get_fast(world.entity_index, entity) if not record then return false end @@ -395,7 +436,11 @@ end local function world_target(world: World, entity: i53, relation: i24, index: number?): i24? local nth = index or 0 - local record = world.entityIndex.sparse[entity] + local record = entity_index_try_get_fast(world.entity_index, entity) + if not record then + return nil + end + local archetype = record.archetype if not archetype then return nil @@ -437,7 +482,10 @@ local function id_record_ensure(world: World, id: number): IdRecord if not idr then local flags = ECS_ID_MASK - local relation = ECS_ENTITY_T_HI(id) + local relation = id + if ECS_IS_PAIR(id) then + relation = ecs_pair_first(world, id) + end local cleanup_policy = world_target(world, relation, EcsOnDelete, 0) local cleanup_policy_target = world_target(world, relation, EcsOnDeleteTarget, 0) @@ -543,9 +591,7 @@ local function archetype_create(world: World, types: { i24 }, ty, prev: i53?): A end local function world_entity(world: World): i53 - local entityId = (world.nextEntityId :: number) + 1 - world.nextEntityId = entityId - return entity_index_new_id(world.entityIndex, entityId + EcsRest) + return entity_index_new_id(world.entity_index) end local function world_parent(world: World, entity: i53) @@ -701,15 +747,19 @@ local function invoke_hook(action, entity, data) end local function world_add(world: World, entity: i53, id: i53): () - local entityIndex = world.entityIndex - local record = entityIndex.sparse[entity] + local entity_index = world.entity_index + local record = entity_index_try_get_fast(entity_index, entity) + if not record then + return + end + local from = record.archetype local to = archetype_traverse_add(world, id, from) if from == to then return end if from then - entity_move(entityIndex, entity, record, to) + entity_move(entity_index, entity, record, to) else if #to.types > 0 then new_entity(entity, record, to) @@ -725,8 +775,12 @@ local function world_add(world: World, entity: i53, id: i53): () end local function world_set(world: World, entity: i53, id: i53, data: unknown): () - local entityIndex = world.entityIndex - local record = entityIndex.sparse[entity] + local entity_index = world.entity_index + local record = entity_index_try_get_fast(entity_index, entity) + if not record then + return + end + local from: Archetype = record.archetype local to: Archetype = archetype_traverse_add(world, id, from) local idr = world.componentIndex[id] @@ -741,7 +795,8 @@ local function world_set(world: World, entity: i53, id: i53, data: unknown): () -- If the archetypes are the same it can avoid moving the entity -- and just set the data directly. local tr = to.records[id] - from.columns[tr.column][record.row] = data + local column = from.columns[tr.column] + column[record.row] = data local on_set = idr_hooks.on_set if on_set then on_set(entity, data) @@ -752,7 +807,7 @@ local function world_set(world: World, entity: i53, id: i53, data: unknown): () if from then -- If there was a previous archetype, then the entity needs to move the archetype - entity_move(entityIndex, entity, record, to) + entity_move(entity_index, entity, record, to) else if #to.types > 0 then -- When there is no previous archetype it should create the archetype @@ -788,15 +843,18 @@ local function world_component(world: World): i53 error("Too many components, consider using world:entity() instead to create components.") end world.nextComponentId = componentId - local id = entity_index_new_id(world.entityIndex, componentId) - world_add(world, id, EcsComponent) - return id + + return componentId end local function world_remove(world: World, entity: i53, id: i53) - local entity_index = world.entityIndex - local record = entity_index.sparse[entity] + local entity_index = world.entity_index + local record = entity_index_try_get_fast(entity_index, entity) + if not record then + return + end local from = record.archetype + if not from then return end @@ -831,7 +889,7 @@ local function archetype_fast_delete(columns: { Column }, column_count: number, end local function archetype_delete(world: World, archetype: Archetype, row: number, destruct: boolean?) - local entityIndex = world.entityIndex + local entityIndex = world.entity_index local columns = archetype.columns local types = archetype.types local entities = archetype.entities @@ -844,7 +902,7 @@ local function archetype_delete(world: World, archetype: Archetype, row: number, if row ~= last then -- TODO: should be "entity_index_sparse_get(entityIndex, move)" - local record_to_move = entityIndex.sparse[move] + local record_to_move = entity_index_try_get_any(entityIndex, move) if record_to_move then record_to_move.row = row end @@ -868,7 +926,7 @@ end local function world_clear(world: World, entity: i53) --TODO: use sparse_get (stashed) - local record = world.entityIndex.sparse[entity] + local record = entity_index_try_get(world.entity_index, entity) if not record then return end @@ -983,10 +1041,8 @@ end local world_delete: (world: World, entity: i53, destruct: boolean?) -> () do function world_delete(world: World, entity: i53, destruct: boolean?) - local entityIndex = world.entityIndex - local sparse_array = entityIndex.sparse - - local record = sparse_array[entity] + local entity_index = world.entity_index + local record = entity_index_try_get(entity_index, entity) if not record then return end @@ -1044,7 +1100,7 @@ do if not ECS_IS_PAIR(id) then continue end - local object = ECS_ENTITY_T_LO(id) + local object = ecs_pair_second(world, id) if object == delete then local id_record = component_index[id] local flags = id_record.flags @@ -1067,13 +1123,25 @@ do end end + local dense_array = entity_index.dense_array + local index_of_deleted_entity = record.dense + local index_of_last_alive_entity = entity_index.alive_count + entity_index.alive_count = index_of_last_alive_entity - 1 + + local last_alive_entity = dense_array[index_of_last_alive_entity] + local r_swap = entity_index_try_get_any(entity_index, last_alive_entity) :: Record + r_swap.dense = index_of_deleted_entity record.archetype = nil :: any - sparse_array[entity] = nil :: any + record.row = nil :: any + record.dense = index_of_last_alive_entity + + dense_array[index_of_deleted_entity] = last_alive_entity + dense_array[index_of_last_alive_entity] = ECS_GENERATION_INC(entity) end end local function world_contains(world: World, entity): boolean - return world.entityIndex.sparse[entity] ~= nil + return entity_index_is_alive(world.entity_index, entity) end local function NOOP() end @@ -1543,8 +1611,11 @@ if _G.__JECS_DEBUG then end end - local function ID_IS_TAG(world, id) - return not world_has_one_inline(world, ECS_ENTITY_T_HI(id), EcsComponent) + local function ID_IS_TAG(world: World, id) + if ECS_IS_PAIR(id) then + id = ecs_pair_first(world, id) + end + return not world_has_one_inline(world, id, EcsComponent) end World.query = function(world: World, ...) @@ -1555,14 +1626,12 @@ 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 - world_add(world, entity, id) local _1 = get_name(world, entity) local _2 = get_name(world, id) local why = "cannot set component value to nil" throw(why) return elseif value ~= nil and is_tag then - world_add(world, entity, id) local _1 = get_name(world, entity) local _2 = get_name(world, id) local why = `cannot set a component value because {_2} is a tag` @@ -1579,7 +1648,6 @@ if _G.__JECS_DEBUG then local _1 = get_name(world, entity) local _2 = get_name(world, id) throw("You provided a value when none was expected. " .. `Did you mean to use "world:add({_1}, {_2})"`) - return end world_add(world, entity, id) @@ -1609,14 +1677,17 @@ if _G.__JECS_DEBUG then end function World.new() + local entity_index: EntityIndex = { + dense_array = {} :: { [i24]: i53 }, + sparse_array = {} :: { [i53]: Record }, + alive_count = 0, + max_id = 0, + } local self = setmetatable({ archetypeIndex = {} :: { [string]: Archetype }, archetypes = {} :: Archetypes, componentIndex = {} :: ComponentIndex, - entityIndex = { - dense = {} :: { [i24]: i53 }, - sparse = {} :: { [i53]: Record }, - } :: EntityIndex, + entity_index = entity_index, nextArchetypeId = 0 :: number, nextComponentId = 0 :: number, nextEntityId = 0 :: number, @@ -1625,9 +1696,14 @@ function World.new() self.ROOT_ARCHETYPE = archetype_create(self, {}, "") + for i = 1, HI_COMPONENT_ID do + local e = entity_index_new_id(entity_index) + world_add(self, e, EcsComponent) + end + for i = HI_COMPONENT_ID + 1, EcsRest do -- Initialize built-in components - entity_index_new_id(self.entityIndex, i) + entity_index_new_id(entity_index) end world_add(self, EcsName, EcsComponent) @@ -1692,7 +1768,7 @@ export type World = { archetypeIndex: { [string]: Archetype }, archetypes: Archetypes, componentIndex: ComponentIndex, - entityIndex: EntityIndex, + entity_index: EntityIndex, ROOT_ARCHETYPE: Archetype, nextComponentId: number, @@ -1791,7 +1867,7 @@ return { Name = EcsName :: Entity, Rest = EcsRest :: Entity, - pair = ECS_PAIR, + pair = ECS_PAIR :: (first: P, second: O) -> Pair, -- Inwards facing API for testing ECS_ID = ECS_ENTITY_T_LO, @@ -1819,4 +1895,9 @@ return { create_edge_for_remove = create_edge_for_remove, archetype_traverse_add = archetype_traverse_add, archetype_traverse_remove = archetype_traverse_remove, + + entity_index_try_get = entity_index_try_get, + entity_index_try_get_any = entity_index_try_get_any, + entity_index_is_alive = entity_index_is_alive, + entity_index_new_id = entity_index_new_id, } diff --git a/test/gen.luau b/test/gen.luau new file mode 100644 index 0000000..bf13dbf --- /dev/null +++ b/test/gen.luau @@ -0,0 +1,183 @@ +type i53 = number +type i24 = number + +type Ty = { i53 } +type ArchetypeId = number + +type Column = { any } + +type Map = { [K]: V } + +type GraphEdge = { + from: Archetype, + to: Archetype?, + prev: GraphEdge?, + next: GraphEdge?, + id: number, +} + +type GraphEdges = Map + +type GraphNode = { + add: GraphEdges, + remove: GraphEdges, + refs: GraphEdge, +} + +type ArchetypeRecord = { + count: number, + column: number, +} + +export type Archetype = { + id: number, + node: GraphNode, + types: Ty, + type: string, + entities: { number }, + columns: { Column }, + records: { ArchetypeRecord }, +} +type Record = { + archetype: Archetype, + row: number, + dense: i24, +} + +type EntityIndex = { + dense_array: Map, + sparse_array: Map, + sparse_count: number, + alive_count: number, + max_id: number, +} + +local ECS_PAIR_FLAG = 0x8 +local ECS_ID_FLAGS_MASK = 0x10 +local ECS_ENTITY_MASK = bit32.lshift(1, 24) +local ECS_GENERATION_MASK = bit32.lshift(1, 16) + +-- HIGH 24 bits LOW 24 bits +local function ECS_GENERATION(e: i53): i24 + return if e > ECS_ENTITY_MASK then (e // ECS_ID_FLAGS_MASK) % ECS_GENERATION_MASK else 0 +end + +local function ECS_COMBINE(source: number, target: number): i53 + return (source * 268435456) + (target * ECS_ID_FLAGS_MASK) +end + +local function ECS_GENERATION_INC(e: i53) + if e > ECS_ENTITY_MASK then + local flags = e // ECS_ID_FLAGS_MASK + local id = flags // ECS_ENTITY_MASK + local generation = flags % ECS_GENERATION_MASK + print(generation) + return ECS_COMBINE(id, generation + 1) + end + return ECS_COMBINE(e, 1) +end +local function ECS_ENTITY_T_LO(e: i53): i24 + return if e > ECS_ENTITY_MASK then (e // ECS_ID_FLAGS_MASK) // ECS_ENTITY_MASK else e +end + +local function entity_index_try_get_any(entity_index: EntityIndex, entity: number): Record? + local r = entity_index.sparse_array[ECS_ENTITY_T_LO(entity)] + + if not r or r.dense == 0 then + return nil + end + + return r +end + +local function entity_index_try_get(entity_index: EntityIndex, entity: number): Record? + local r = entity_index_try_get_any(entity_index, entity) + if r then + local r_dense = r.dense + if r_dense > entity_index.alive_count then + return nil + end + if entity_index.dense_array[r_dense] ~= entity then + return nil + end + end + return r +end + +local function entity_index_get_alive(entity_index: EntityIndex, entity: number): number + local r = entity_index_try_get_any(entity_index, entity) + if r then + return entity_index.dense_array[r.dense] + end + return 0 +end + +local function entity_index_remove(entity_index: EntityIndex, entity: number) + local r = entity_index_try_get(entity_index, entity) + if not r then + return + end + local dense_array = entity_index.dense_array + local index_of_deleted_entity = r.dense + local last_entity_alive_at_index = entity_index.alive_count + entity_index.alive_count -= 1 + + local last_alive_entity = dense_array[last_entity_alive_at_index] + local r_swap = entity_index_try_get_any(entity_index, last_alive_entity) :: Record + r_swap.dense = index_of_deleted_entity + r.archetype = nil :: any + r.row = nil :: any + r.dense = last_entity_alive_at_index + + dense_array[index_of_deleted_entity] = last_alive_entity + dense_array[last_entity_alive_at_index] = ECS_GENERATION_INC(entity) +end + +local function entity_index_new_id(entity_index: EntityIndex): i53 + local dense_array = entity_index.dense_array + if entity_index.alive_count ~= #dense_array then + entity_index.alive_count += 1 + local id = dense_array[entity_index.alive_count] + return id + end + + entity_index.max_id += 1 + local id = entity_index.max_id + entity_index.alive_count += 1 + + dense_array[entity_index.alive_count] = id + entity_index.sparse_array[id] = { + dense = entity_index.alive_count, + } :: Record + + return id +end + +local function entity_index_is_alive(entity_index: EntityIndex, entity: number) + return entity_index_try_get(entity_index, entity) ~= nil +end + +local eidx = { + alive_count = 0, + max_id = 0, + sparse_array = {} :: { Record }, + sparse_count = 0, + dense_array = {} :: { i53 }, +} +local e1v0 = entity_index_new_id(eidx, "e1v0") +local e2v0 = entity_index_new_id(eidx, "e2v0") +local e3v0 = entity_index_new_id(eidx, "e3v0") +local e4v0 = entity_index_new_id(eidx, "e4v0") +local e5v0 = entity_index_new_id(eidx, "e5v0") + +local e6v0 = entity_index_new_id(eidx) +entity_index_remove(eidx, e6v0) +local e6v1 = entity_index_new_id(eidx) +entity_index_remove(eidx, e6v1) +local e6v2 = entity_index_new_id(eidx) +print(ECS_ENTITY_T_LO(e6v2), ECS_GENERATION(e6v2)) + +print("-----") +local e2 = ECS_GENERATION_INC(ECS_GENERATION_INC(269)) +print("-----") +print(ECS_ENTITY_T_LO(e2), ECS_GENERATION(e2)) diff --git a/test/lol.luau b/test/lol.luau new file mode 100644 index 0000000..cbf50dd --- /dev/null +++ b/test/lol.luau @@ -0,0 +1,157 @@ +local c = { + white_underline = function(s: any) + return `\27[1;4m{s}\27[0m` + end, + + white = function(s: any) + return `\27[37;1m{s}\27[0m` + end, + + green = function(s: any) + return `\27[32;1m{s}\27[0m` + end, + + red = function(s: any) + return `\27[31;1m{s}\27[0m` + end, + + yellow = function(s: any) + return `\27[33;1m{s}\27[0m` + end, + + red_highlight = function(s: any) + return `\27[41;1;30m{s}\27[0m` + end, + + green_highlight = function(s: any) + return `\27[42;1;30m{s}\27[0m` + end, + + gray = function(s: any) + return `\27[30;1m{s}\27[0m` + end, +} + + +local ECS_PAIR_FLAG = 0x8 +local ECS_ID_FLAGS_MASK = 0x10 +local ECS_ENTITY_MASK = bit32.lshift(1, 24) +local ECS_GENERATION_MASK = bit32.lshift(1, 16) + +type i53 = number +type i24 = number + +local function ECS_ENTITY_T_LO(e: i53): i24 + return if e > ECS_ENTITY_MASK then (e // ECS_ID_FLAGS_MASK) // ECS_ENTITY_MASK else e +end + +local function ECS_GENERATION(e: i53): i24 + return if e > ECS_ENTITY_MASK then (e // ECS_ID_FLAGS_MASK) % ECS_GENERATION_MASK else 0 +end + +local ECS_ID = ECS_ENTITY_T_LO + +local function ECS_COMBINE(source: number, target: number): i53 + return (source * 268435456) + (target * ECS_ID_FLAGS_MASK) +end + +local function ECS_GENERATION_INC(e: i53) + if e > ECS_ENTITY_MASK then + local flags = e // ECS_ID_FLAGS_MASK + local id = flags // ECS_ENTITY_MASK + local generation = flags % ECS_GENERATION_MASK + + local next_gen = generation + 1 + if next_gen > ECS_GENERATION_MASK then + return id + end + + return ECS_COMBINE(id, next_gen) + flags + end + return ECS_COMBINE(e, 1) +end + +local function bl() + print("") +end + +local function pe(e) + local gen = ECS_GENERATION(e) + return c.green(`e{ECS_ID(e)}`)..c.yellow(`v{gen}`) +end + +local function dprint(tbl: { [number]: number }) + bl() + print("--------") + for i, e in tbl do + print("| "..pe(e).." |") + print("--------") + end + bl() +end + +local max_id = 0 +local alive_count = 0 +local dense = {} +local sparse = {} +local function alloc() + if alive_count ~= #dense then + alive_count += 1 + print("*recycled", pe(dense[alive_count])) + return dense[alive_count] + end + max_id += 1 + local id = max_id + alive_count += 1 + dense[alive_count] = id + sparse[id] = { + dense = alive_count + } + print("*allocated", pe(id)) + return id +end + +local function remove(entity) + local id = ECS_ID(entity) + local r = sparse[id] + local index_of_deleted_entity = r.dense + local last_entity_alive_at_index = alive_count -- last entity alive + alive_count -= 1 + local last_alive_entity = dense[last_entity_alive_at_index] + local r_swap = sparse[ECS_ID(last_alive_entity)] + r_swap.dense = r.dense + r.dense = last_entity_alive_at_index + dense[index_of_deleted_entity] = last_alive_entity + dense[last_entity_alive_at_index] = ECS_GENERATION_INC(entity) +end + +local function alive(e) + local r = sparse[ECS_ID(e)] + + return dense[r.dense] == e +end + +local function pa(e) + print(`{pe(e)} is {if alive(e) then "alive" else "not alive"}`) +end + +local tprint = require("@testkit").print +local e1v0 = alloc() +local e2v0 = alloc() +local e3v0 = alloc() +local e4v0 = alloc() +local e5v0 = alloc() +pa(e1v0) +pa(e4v0) +remove(e5v0) +pa(e5v0) + +local e5v1 = alloc() +pa(e5v0) +pa(e5v1) +pa(e2v0) +print(ECS_ID(e2v0)) + +dprint(dense) +remove(e2v0) +dprint(dense) diff --git a/test/tests.luau b/test/tests.luau index b9375ef..a74b5f3 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -1,4 +1,4 @@ -local jecs: typeof(require("../jecs/src")) = require("@jecs") +local jecs = require("@jecs") local testkit = require("@testkit") local BENCH, START = testkit.benchmark() @@ -7,9 +7,11 @@ 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 pair = jecs.pair -local getAlive = jecs.entity_index_get_alive local ecs_pair_first = jecs.pair_first 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 world_new = jecs.World.new local TEST, CASE, CHECK, FINISH, SKIP, FOCUS = testkit.test() @@ -29,7 +31,7 @@ type World = jecs.WorldShim local function debug_world_inspect(world) local function record(e) - return world.entityIndex.sparse[e] + return entity_index_try_get_any(world.entity_index, e) end local function tbl(e) return record(e).archetype @@ -168,7 +170,6 @@ TEST("world:entity()", function() 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 @@ -190,6 +191,36 @@ TEST("world:entity()", function() CHECK(ecs_pair_first(world, pair) == e2) CHECK(ecs_pair_second(world, pair) == e3) end + + do CASE "Recycling" + local world = world_new() + local e = world:entity() + world:delete(e) + local e1 = world:entity() + world:delete(e1) + local e2 = world:entity() + CHECK(ECS_ID(e2) == e) + CHECK(ECS_GENERATION(e2) == 2) + CHECK(world:contains(e2)) + CHECK(not world:contains(e1)) + CHECK(not world:contains(e)) + end + + do CASE "Recycling max generation" + local world = world_new() + local pin = jecs.Rest + 1 + for i = 1, 2^16-1 do + local e = world:entity() + world:delete(e) + end + local e = world:entity() + CHECK(ECS_ID(e) == pin) + CHECK(ECS_GENERATION(e) == 2^16-1) + world:delete(e) + e = world:entity() + CHECK(ECS_ID(e) == pin) + CHECK(ECS_GENERATION(e) == 0) + end end) TEST("world:set()", function() @@ -878,9 +909,10 @@ TEST("world:clear()", function() CHECK(archetype_entities[1] == _e) CHECK(archetype_entities[2] == _e1) - local sparse_array = world.entityIndex.sparse - local e_record = sparse_array[e] - local e1_record = sparse_array[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) CHECK(e_record.archetype == archetype) CHECK(e1_record.archetype == archetype) CHECK(e1_record.row == 2) @@ -1085,6 +1117,7 @@ TEST("world:delete", function() for i, friend in friends do CHECK(not world:has(friend, pair(FriendsWith, e))) CHECK(world:has(friend, Health)) + CHECK(world:contains(friend)) end end diff --git a/wally.toml b/wally.toml index e20bfd8..c9f012d 100644 --- a/wally.toml +++ b/wally.toml @@ -1,6 +1,6 @@ [package] name = "ukendio/jecs" -version = "0.3.2" +version = "0.4.0-rc.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" license = "MIT"