diff --git a/.darklua.json b/.darklua.json index 05f17e8..1630968 100644 --- a/.darklua.json +++ b/.darklua.json @@ -5,7 +5,7 @@ "current": { "name": "path", "sources": { - "@pkg": "Packages/" + "@jecs": "lib/jecs" } }, "target": { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a95a95..c7a6e39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,13 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} - - name: Run tests + - name: Run Unit Tests run: | - lune run test/runner.luau + output=$(luau test/tests.luau) + echo "$output" + if [[ "$output" == *"0 fails"* ]]; then + echo "Unit Tests Passed" + else + echo "Error: One or More Unit Tests Failed." + exit 1 + fi diff --git a/.luaurc b/.luaurc index 77287ab..6809eec 100644 --- a/.luaurc +++ b/.luaurc @@ -2,6 +2,7 @@ "languageMode": "strict", "aliases": { "jecs_utils": "lib", - "testkit": "test/testkit" + "testkit": "test/testkit", + "jecs": "lib/jecs" } } diff --git a/.lune/analyze.luau b/.lune/analyze.luau index 80baca3..b9a2a4b 100644 --- a/.lune/analyze.luau +++ b/.lune/analyze.luau @@ -9,9 +9,9 @@ local function start_process(cmd: string) process.spawn(command, arguments, { stdio = "forward" }) end -start_process("lune run install-packages.luau") +--start_process("lune run install-packages.luau") --start_process("curl -O https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/scripts/globalTypes.d.luau") start_process("rojo sourcemap dev.project.json -o sourcemap.json") start_process( - "luau-lsp analyze --base-luaurc=.luaurc --sourcemap=sourcemap.json --settings=luau_lsp_settings.json --no-strict-dm-types --ignore Packages/**/*.lua --ignore Packages/**/*.luau lib/" + "luau-lsp analyze --base-luaurc=.luaurc --sourcemap=sourcemap.json --settings=luau_lsp_settings.json --no-strict-dm-types --ignore Packages/**/*.lua --ignore Packages/**/*.luau --ignore lib/jecs.luau lib/" ) diff --git a/.lune/check.luau b/.lune/check.luau index 38fe2ca..7bb3126 100644 --- a/.lune/check.luau +++ b/.lune/check.luau @@ -12,3 +12,4 @@ end start_process("lune run analyze") start_process("stylua lib/") start_process("selene lib/") +start_process("luau test/tests.luau") diff --git a/.zed/settings.json b/.zed/settings.json index 53f2598..c3dd6e9 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -21,10 +21,8 @@ "mode": "relativeToFile", "fileAliases": { "@jecs_utils": "lib", + "@jecs": "lib/jecs", "@testkit": "test/testkit" - }, - "directoryAliases": { - "@pkg": "Packages/" } } }, diff --git a/dev.project.json b/dev.project.json index 1a17592..8a1bf5a 100644 --- a/dev.project.json +++ b/dev.project.json @@ -5,7 +5,7 @@ "ReplicatedStorage": { "Packages": { - "$path": "Packages", + "$className": "Folder", "jecs_utils": { "$path": "lib" } diff --git a/lib/collect.luau b/lib/collect.luau index d1c01b1..e5629ef 100644 --- a/lib/collect.luau +++ b/lib/collect.luau @@ -61,7 +61,9 @@ local function collect(event: signal_like): (() -> (number, T. end end - local disconnect = event:Connect(function(...) + local connect = event.Connect or event.connect + assert(connect ~= nil, "Signal is missing a Connect function - is it really a signal?") + local disconnect = connect(event, function(...) table.insert(storage, { ... }) mt.__iter = iter :: any end) diff --git a/lib/command_buffer.luau b/lib/command_buffer.luau index 25415a5..d3aa898 100644 --- a/lib/command_buffer.luau +++ b/lib/command_buffer.luau @@ -1,10 +1,10 @@ --!strict --!optimize 2 -local jecs = require("@pkg/jecs") +local jecs = require("./jecs") type entity = jecs.Entity type id = jecs.Id -local world = require("./world").get() +local WORLD = require("./world").get --- `map>` local add_commands: { [id]: { entity } } = {} @@ -15,7 +15,7 @@ local remove_commands: { [id]: { entity } } = {} --- `array` local delete_commands: { entity } = {} -type command_buffer = { +export type command_buffer = { --- Execute all buffered commands and clear the buffer flush: () -> (), @@ -30,48 +30,46 @@ type command_buffer = { } local function flush() - local adds = add_commands - local sets = set_commands - local removes = remove_commands - local deletes = delete_commands + local world = WORLD() + + for _, entity in delete_commands do + world:delete(entity) + end + + for component, entities in add_commands do + for _, entity in entities do + if delete_commands[entity] then + continue + end + + world:add(entity, component) + end + end table.clear(add_commands) + + for component, entities in set_commands do + for entity, value in entities do + if delete_commands[entity] then + continue + end + + world:set(entity, component, value) + end + end table.clear(set_commands) + + for component, entities in remove_commands do + for _, entity in entities do + if delete_commands[entity] then + continue + end + + world:remove(entity, component) + end + end table.clear(remove_commands) + table.clear(delete_commands) - - for _, id in deletes do - world:delete(id) - end - - for component, ids in adds do - for _, id in ids do - if deletes[id] then - continue - end - - world:add(id, component) - end - end - - for component, ids in sets do - for id, value in ids do - if deletes[id] then - continue - end - - world:set(id, component, value) - end - end - - for component, ids in removes do - for _, id in ids do - if deletes[id] then - continue - end - - world:remove(id, component) - end - end end local function add(entity: entity, component: id) diff --git a/lib/handle.luau b/lib/handle.luau index fe36d12..8369f1a 100644 --- a/lib/handle.luau +++ b/lib/handle.luau @@ -1,10 +1,10 @@ --!strict --!optimize 2 -local jecs = require("@pkg/jecs") -export type entity = jecs.Entity -export type id = entity | jecs.Pair +local jecs = require("./jecs") +type entity = jecs.Entity +type id = entity | jecs.Pair -local world = require("./world").get() +local world = require("./world").get type interface = { __index: interface, @@ -44,34 +44,34 @@ function handle.new(entity: entity) end function handle:has(...: id): boolean - return world:has(self.entity, ...) + return world():has(self.entity, ...) end handle.get = function(self: handle, a: id, b: id?, c: id?, d: id?) - return world:get(self.entity, a, b :: any, c :: any, d :: any) + return world():get(self.entity, a, b :: any, c :: any, d :: any) end :: any function handle:add(id: id): handle - world:add(self.entity, id) + world():add(self.entity, id) return self end function handle:set(id: id, value: T): handle - world:set(self.entity, id, value) + world():set(self.entity, id, value) return self end function handle:remove(id: id): handle - world:remove(self.entity, id) + world():remove(self.entity, id) return self end function handle:delete() - world:delete(self.entity) + world():delete(self.entity) end function handle:id(): entity return self.entity end -return handle +return handle.new diff --git a/lib/init.luau b/lib/init.luau index 3374e60..67d06da 100644 --- a/lib/init.luau +++ b/lib/init.luau @@ -1,12 +1,19 @@ --!strict --!optimize 2 +local jecs = require("./jecs") + local WORLD = require("./world") local collect = require("./collect") +export type collect_signal_like = collect.signal_like +export type collect_verbose_signal_like = collect.signal_like local command_buffer = require("./command_buffer") +export type command_buffer = command_buffer.command_buffer local handle = require("./handle") -local jecs = require("@pkg/jecs") +export type handle = handle.handle local ref = require("./ref") local replicator = require("./replicator") +export type replicator = replicator.replicator +export type changes = replicator.changes --- Set the world for all utilities. --- Should be called once per context before any utility is used. diff --git a/lib/jecs.luau b/lib/jecs.luau new file mode 100644 index 0000000..35f2682 --- /dev/null +++ b/lib/jecs.luau @@ -0,0 +1,1895 @@ +--!optimize 2 +--!native +--!strict +--draft 4 + +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, + add_ref: GraphEdge?, + remove_ref: GraphEdge?, +} + +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: Map, + sparse: Map, +} + +type ArchetypeRecord = { + count: number, + column: number, +} + +type IdRecord = { + cache: { ArchetypeRecord }, + flags: number, + size: number, +} + +type ComponentIndex = Map + +type Archetypes = { [ArchetypeId]: Archetype } + +type ArchetypeDiff = { + added: Ty, + removed: Ty, +} + +local HI_COMPONENT_ID = _G.__JECS_HI_COMPONENT_ID or 256 + +local EcsOnAdd = HI_COMPONENT_ID + 1 +local EcsOnRemove = HI_COMPONENT_ID + 2 +local EcsOnSet = HI_COMPONENT_ID + 3 +local EcsWildcard = HI_COMPONENT_ID + 4 +local EcsChildOf = HI_COMPONENT_ID + 5 +local EcsComponent = HI_COMPONENT_ID + 6 +local EcsOnDelete = HI_COMPONENT_ID + 7 +local EcsOnDeleteTarget = HI_COMPONENT_ID + 8 +local EcsDelete = HI_COMPONENT_ID + 9 +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_ENTITY_MASK = bit32.lshift(1, 24) +local ECS_GENERATION_MASK = bit32.lshift(1, 16) + +local ECS_ID_DELETE = 0b0000_0001 +local ECS_ID_IS_TAG = 0b0000_0010 +local ECS_ID_HAS_ON_ADD = 0b0000_0100 +local ECS_ID_HAS_ON_SET = 0b0000_1000 +local ECS_ID_HAS_ON_REMOVE = 0b0001_0000 +local ECS_ID_MASK = 0b0000_0000 + +local NULL_ARRAY = table.freeze({}) + +local function FLAGS_ADD(is_pair: boolean): number + local flags = 0x0 + + if is_pair then + flags = bit32.bor(flags, ECS_PAIR_FLAG) -- HIGHEST bit in the ID. + end + if false then + flags = bit32.bor(flags, 0x4) -- Set the second flag to true + end + if false then + flags = bit32.bor(flags, 0x2) -- Set the third flag to true + end + if false then + flags = bit32.bor(flags, 0x1) -- LAST BIT in the ID. + end + + return flags +end + +local function ECS_COMBINE(source: number, target: number): i53 + return (source * 268435456) + (target * ECS_ID_FLAGS_MASK) +end + +local function ECS_IS_PAIR(e: number): boolean + return if e > ECS_ENTITY_MASK then (e % ECS_ID_FLAGS_MASK) // ECS_PAIR_FLAG ~= 0 else false +end + +-- 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_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 + + return ECS_COMBINE(id, generation + 1) + flags + end + return ECS_COMBINE(e, 1) +end + +-- FIRST gets the high ID +local function ECS_ENTITY_T_HI(e: i53): i24 + return if e > ECS_ENTITY_MASK then (e // ECS_ID_FLAGS_MASK) % ECS_ENTITY_MASK else e +end + +-- SECOND +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 _STRIP_GENERATION(e: i53): i24 + return ECS_ENTITY_T_LO(e) +end + +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) + end + + error(ERROR_ENTITY_NOT_ALIVE) +end + +local function _entity_index_sparse_get(entityIndex, id) + return entityIndex.sparse[entity_index_get_alive(entityIndex, 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)) +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 +end + +local function archetype_move(entity_index: EntityIndex, to: Archetype, dst_row: i24, from: Archetype, src_row: i24) + local src_columns = from.columns + local dst_columns = to.columns + local dst_entities = to.entities + local src_entities = from.entities + + local last = #src_entities + local types = from.types + local records = to.records + + for i, column in src_columns do + if column == NULL_ARRAY then + continue + end + -- 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 tr = records[types[i]] + + -- Sometimes target column may not exist, e.g. when you remove a component. + if tr then + dst_columns[tr.column][dst_row] = column[src_row] + end + -- If the entity is the last row in the archetype then swapping it would be meaningless. + if src_row ~= last then + -- Swap rempves columns to ensure there are no holes in the archetype. + column[src_row] = column[last] + end + column[last] = nil + end + + local sparse = entity_index.sparse + local moved = #src_entities + + -- 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 e1 = src_entities[src_row] + local e2 = src_entities[moved] + + if src_row ~= moved then + src_entities[src_row] = e2 + end + + src_entities[moved] = nil :: any + dst_entities[dst_row] = e1 + + local record1 = sparse[e1] + local record2 = sparse[e2] + + record1.row = dst_row + record2.row = src_row +end + +local function archetype_append(entity: number, archetype: Archetype): number + local entities = archetype.entities + local length = #entities + 1 + entities[length] = entity + return length +end + +local function new_entity(entityId: i53, record: Record, archetype: Archetype): Record + local row = archetype_append(entityId, archetype) + record.archetype = archetype + record.row = row + return record +end + +local function entity_move(entity_index: EntityIndex, entityId: i53, record: Record, to: Archetype) + local sourceRow = record.row + local from = record.archetype + local dst_row = archetype_append(entityId, to) + archetype_move(entity_index, to, dst_row, from, sourceRow) + record.archetype = to + record.row = dst_row +end + +local function hash(arr: { number }): string + return table.concat(arr, "_") +end + +local world_get: (world: World, entityId: i53, a: i53, b: i53?, c: i53?, d: i53?, e: i53?) -> ...any +do + -- Keeping the function as small as possible to enable inlining + local records + local columns + local row + + local function fetch(id) + local tr = records[id] + + if not tr then + return nil + end + + return columns[tr.column][row] + 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] + if not record then + return nil + end + + local archetype = record.archetype + if not archetype then + return nil + end + + records = archetype.records + columns = archetype.columns + row = record.row + + local va = fetch(a) + + if not b then + return va + elseif not c then + return va, fetch(b) + elseif not d then + return va, fetch(b), fetch(c) + elseif not e then + return va, fetch(b), fetch(c), fetch(d) + else + error("args exceeded") + end + end +end + +local function world_get_one_inline(world: World, entity: i53, id: i53) + local record = world.entityIndex.sparse[entity] + if not record then + return nil + end + + local archetype = record.archetype + if not archetype then + return nil + end + + local tr = archetype.records[id] + if not tr then + return nil + end + return archetype.columns[tr.column][record.row] +end + +local function world_has_one_inline(world: World, entity: number, id: i53): boolean + local record = world.entityIndex.sparse[entity] + if not record then + return false + end + + local archetype = record.archetype + if not archetype then + return false + end + + local records = archetype.records + + return records[id] ~= nil +end + +local function world_has(world: World, entity: number, ...: i53): boolean + local record = world.entityIndex.sparse[entity] + if not record then + return false + end + + local archetype = record.archetype + if not archetype then + return false + end + + local records = archetype.records + + for i = 1, select("#", ...) do + if not records[select(i, ...)] then + return false + end + end + + return true +end + +local function world_target(world: World, entity: i53, relation: i24, index: number): i24? + local record = world.entityIndex.sparse[entity] + local archetype = record.archetype + if not archetype then + return nil + end + + local idr = world.componentIndex[ECS_PAIR(relation, EcsWildcard)] + if not idr then + return nil + end + + local tr = idr.cache[archetype.id] + if not tr then + return nil + end + + local count = tr.count + if index >= count then + index = index + count + 1 + end + + local nth = archetype.types[index + tr.column] + + if not nth then + return nil + end + + return ecs_pair_second(world, nth) +end + +local function ECS_ID_IS_WILDCARD(e: i53): boolean + local first = ECS_ENTITY_T_HI(e) + local second = ECS_ENTITY_T_LO(e) + return first == EcsWildcard or second == EcsWildcard +end + +local function id_record_ensure(world: World, id: number): IdRecord + local componentIndex = world.componentIndex + local idr = componentIndex[id] + + if not idr then + local flags = ECS_ID_MASK + local relation = ECS_ENTITY_T_HI(id) + + local cleanup_policy = world_target(world, relation, EcsOnDelete, 0) + local cleanup_policy_target = world_target(world, relation, EcsOnDeleteTarget, 0) + + local has_delete = false + + if cleanup_policy == EcsDelete or cleanup_policy_target == EcsDelete then + has_delete = true + end + + local on_add, on_set, on_remove = world_get(world, relation, EcsOnAdd, EcsOnSet, EcsOnRemove) + + local is_tag = not world_has_one_inline(world, relation, EcsComponent) + + flags = bit32.bor( + flags, + if on_add then ECS_ID_HAS_ON_ADD else 0, + if on_remove then ECS_ID_HAS_ON_REMOVE else 0, + if on_set then ECS_ID_HAS_ON_SET else 0, + if has_delete then ECS_ID_DELETE else 0, + if is_tag then ECS_ID_IS_TAG else 0 + ) + + idr = { + size = 0, + cache = {}, + flags = flags, + } :: IdRecord + componentIndex[id] = idr + end + + return idr +end + +local function archetype_append_to_records( + idr: IdRecord, + archetype_id: number, + records: Map, + id: number, + index: number +) + local tr = idr.cache[archetype_id] + if not tr then + tr = { column = index, count = 1 } + idr.cache[archetype_id] = tr + idr.size += 1 + records[id] = tr + else + tr.count += 1 + end +end + +local function archetype_create(world: World, types: { i24 }, prev: i53?): Archetype + local ty = hash(types) + + local archetype_id = (world.nextArchetypeId :: number) + 1 + world.nextArchetypeId = archetype_id + + local length = #types + local columns = (table.create(length) :: any) :: { Column } + + local records: { ArchetypeRecord } = {} + for i, componentId in types do + local idr = id_record_ensure(world, componentId) + archetype_append_to_records(idr, archetype_id, records, componentId, i) + + if ECS_IS_PAIR(componentId) then + local relation = ecs_pair_first(world, componentId) + local object = ecs_pair_second(world, componentId) + + local r = ECS_PAIR(relation, EcsWildcard) + local idr_r = id_record_ensure(world, r) + archetype_append_to_records(idr_r, archetype_id, records, r, i) + + local t = ECS_PAIR(EcsWildcard, object) + local idr_t = id_record_ensure(world, t) + archetype_append_to_records(idr_t, archetype_id, records, t, i) + end + if bit32.band(idr.flags, ECS_ID_IS_TAG) == 0 then + columns[i] = {} + else + columns[i] = NULL_ARRAY + end + end + + local archetype: Archetype = { + columns = columns, + node = { add = {}, remove = {} }, + entities = {}, + id = archetype_id, + records = records, + type = ty, + types = types, + } + + world.archetypeIndex[ty] = archetype + world.archetypes[archetype_id] = archetype + + return archetype +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) +end + +local function world_parent(world: World, entity: i53) + return world_target(world, entity, EcsChildOf, 0) +end + +local function archetype_ensure(world: World, types): Archetype + 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 archetype_create(world, types) +end + +local function find_insert(types: { i53 }, toAdd: i53): number + for i, id in types do + if id == toAdd then + return -1 + end + if id > toAdd then + return i + end + end + return #types + 1 +end + +local function find_archetype_with(world: World, node: Archetype, id: i53): Archetype + 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 dst = table.clone(node.types) :: { i53 } + local at = find_insert(types, id) + 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 + table.insert(dst, at, id) + + return archetype_ensure(world, dst) +end + +local function find_archetype_without(world: World, node: Archetype, id: i53): Archetype + local types = node.types + local at = table.find(types, id) + if at == nil then + return node + end + + local dst = table.clone(types) + table.remove(dst, at) + + return archetype_ensure(world, dst) +end + +local function archetype_init_edge(archetype: Archetype, edge: GraphEdge, id: i53, to: Archetype) + edge.from = archetype + edge.to = to + edge.id = id +end + +local function archetype_ensure_edge(world, edges, id): GraphEdge + local edge = edges[id] + if not edge then + edge = { + from = nil :: any, + to = nil :: any, + id = id, + prev = nil, + next = nil, + } :: GraphEdge + edges[id] = edge + end + + return edge +end + +local function init_edge_for_add(world, archetype, edge, id, to) + archetype_init_edge(archetype, edge, id, to) + archetype_ensure_edge(world, archetype.node.add, id) + if archetype ~= to then + local to_add_ref = to.node.add_ref + edge.next = to_add_ref + edge.prev = nil + if to_add_ref then + to_add_ref.prev = edge + end + to.node.add_ref = edge + end +end + +local function init_edge_for_remove(world, archetype, edge, id, to) + archetype_init_edge(archetype, edge, id, to) + archetype_ensure_edge(world, archetype.node.remove, id) + if archetype ~= to then + local to_remove_ref = to.node.remove_ref + local prev + if to_remove_ref then + prev = to_remove_ref.prev + to_remove_ref.prev = edge + edge.next = to_remove_ref + else + to.node.remove_ref = edge + edge.next = nil + end + + edge.prev = prev + if prev then + prev.next = edge + end + end +end + +local function create_edge_for_add(world: World, node: Archetype, edge: GraphEdge, id: i53): Archetype + local to = find_archetype_with(world, node, id) + init_edge_for_add(world, node, edge, id, to) + return to +end + +local function create_edge_for_remove(world: World, node: Archetype, edge: GraphEdge, id: i53): Archetype + local to = find_archetype_without(world, node, id) + init_edge_for_remove(world, node, edge, id, to) + return to +end + +local function archetype_traverse_add(world: World, id: i53, from: Archetype): Archetype + from = from or world.ROOT_ARCHETYPE + local edge = archetype_ensure_edge(world, from.node.add, id) + + local to = edge.to + if not to then + to = create_edge_for_add(world, from, edge, id) + end + + return to :: Archetype +end + +local function archetype_traverse_remove(world: World, id: i53, from: Archetype): Archetype + from = from or world.ROOT_ARCHETYPE + + local edge = archetype_ensure_edge(world, from.node.add, id) + + local to = edge.to + if not to then + to = create_edge_for_remove(world, from, edge, id) + end + + return to :: Archetype +end + +local function invoke_hook(world: World, hook_id: number, id: i53, entity: i53, data: any?) + local hook = world_get_one_inline(world, id, hook_id) + if hook then + hook(entity, data) + end +end + +local function world_add(world: World, entity: i53, id: i53): () + local entityIndex = world.entityIndex + local record = entityIndex.sparse[entity] + 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) + else + if #to.types > 0 then + new_entity(entity, record, to) + end + end + + local idr = world.componentIndex[id] + local has_on_add = bit32.band(idr.flags, ECS_ID_HAS_ON_ADD) ~= 0 + + if has_on_add then + invoke_hook(world, EcsOnAdd, id, entity) + end +end + +-- Symmetric like `World.add` but idempotent +local function world_set(world: World, entity: i53, id: i53, data: unknown): () + local entityIndex = world.entityIndex + local record = entityIndex.sparse[entity] + local from = record.archetype + local to = archetype_traverse_add(world, id, from) + local idr = world.componentIndex[id] + local flags = idr.flags + local is_tag = bit32.band(flags, ECS_ID_IS_TAG) ~= 0 + local has_on_set = bit32.band(flags, ECS_ID_HAS_ON_SET) ~= 0 + + if from == to then + if is_tag then + return + end + -- 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 + if has_on_set then + invoke_hook(world, EcsOnSet, id, entity, data) + end + + return + end + + if from then + -- If there was a previous archetype, then the entity needs to move the archetype + entity_move(entityIndex, entity, record, to) + else + if #to.types > 0 then + -- When there is no previous archetype it should create the archetype + new_entity(entity, record, to) + end + end + + local has_on_add = bit32.band(flags, ECS_ID_HAS_ON_ADD) ~= 0 + + if has_on_add then + invoke_hook(world, EcsOnAdd, id, entity) + end + + if is_tag then + return + end + + local tr = to.records[id] + local column = to.columns[tr.column] + + column[record.row] = data + + if has_on_set then + invoke_hook(world, EcsOnSet, id, entity, data) + end +end + +local function world_component(world: World): i53 + local componentId = (world.nextComponentId :: number) + 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 + local id = entity_index_new_id(world.entityIndex, componentId) + world_add(world, id, EcsComponent) + return id +end + +local function world_remove(world: World, entity: i53, id: i53) + local entity_index = world.entityIndex + local record = entity_index.sparse[entity] + local from = record.archetype + if not from then + return + end + local to = archetype_traverse_remove(world, id, from) + + if from and not (from == to) then + local idr = world.componentIndex[id] + local flags = idr.flags + local has_on_remove = bit32.band(flags, ECS_ID_HAS_ON_REMOVE) ~= 0 + if has_on_remove then + invoke_hook(world, EcsOnRemove, id, entity) + end + + entity_move(entity_index, entity, record, to) + end +end + +local function world_clear(world: World, entity: i53) + --TODO: use sparse_get (stashed) + local record = world.entityIndex.sparse[entity] + if not record then + return + end + + local ROOT_ARCHETYPE = world.ROOT_ARCHETYPE + local archetype = record.archetype + + if archetype == nil or archetype == ROOT_ARCHETYPE then + return + end + + entity_move(world.entityIndex, entity, record, ROOT_ARCHETYPE) +end + +local function archetype_fast_delete_last(columns: { Column }, column_count: number, types: { i53 }, entity: i53) + for i, column in columns do + if column ~= NULL_ARRAY then + column[column_count] = nil + end + end +end + +local function archetype_fast_delete(columns: { Column }, column_count: number, row, types, entity) + for i, column in columns do + if column ~= NULL_ARRAY then + column[row] = column[column_count] + column[column_count] = nil + end + end +end + +local function archetype_disconnect_edge(edge: GraphEdge) + local edge_next = edge.next + local edge_prev = edge.prev + if edge_next then + edge_next.prev = edge_prev + end + if edge_prev then + edge_prev.next = edge_next + end +end + +local function archetype_remove_edge(edges: Map, id: i53, edge: GraphEdge) + archetype_disconnect_edge(edge) + edges[id] = nil +end + +local function archetype_clear_edges(archetype: Archetype) + local node = archetype.node + local add = node.add + local remove = node.remove + for _, edge in add do + archetype_disconnect_edge(edge) + end + for _, edge in remove do + archetype_disconnect_edge(edge) + end + local node_add_ref = node.add_ref + if node_add_ref then + local current = node_add_ref.next + while current do + local edge = current + current = current.next + local node_add = edge.from.node.add + if node_add then + archetype_remove_edge(node_add, edge.id, edge) + end + end + end + + local node_remove_ref = node.remove_ref + if node_remove_ref then + local current = node_remove_ref.prev + while current do + local edge = current + current = current.prev + local node_remove = edge.from.node.remove + if node_remove then + archetype_remove_edge(node_remove, edge.id, edge) + end + end + end + + node.add = nil :: any + node.remove = nil :: any + node.add_ref = nil :: any + node.remove_ref = nil :: any +end + +local function archetype_destroy(world: World, archetype: Archetype) + local component_index = world.componentIndex + archetype_clear_edges(archetype) + local archetype_id = archetype.id + world.archetypes[archetype_id] = nil + world.archetypeIndex[archetype.type] = nil + local records = archetype.records + + for id in records do + local idr = component_index[id] + idr.cache[archetype_id] = nil + idr.size -= 1 + records[id] = nil + if idr.size == 0 then + component_index[id] = nil + end + end +end + +local function world_cleanup(world) + for _, archetype in world.archetypes do + if #archetype.entities == 0 then + archetype_destroy(world, archetype) + end + end +end + +local world_delete: (world: World, entity: i53, destruct: boolean?) -> () +do + local function archetype_delete(world: World, archetype: Archetype, row: number, destruct: boolean?) + local entityIndex = world.entityIndex + local columns = archetype.columns + local types = archetype.types + local entities = archetype.entities + local column_count = #entities + local last = #entities + local move = entities[last] + local delete = entities[row] + entities[row] = move + entities[last] = nil + + if row ~= last then + -- TODO: should be "entity_index_sparse_get(entityIndex, move)" + local record_to_move = entityIndex.sparse[move] + if record_to_move then + record_to_move.row = row + end + end + + -- TODO: if last == 0 then deactivate table + + for _, id in types do + invoke_hook(world, EcsOnRemove, id, delete) + end + + if row == last then + archetype_fast_delete_last(columns, column_count, types, delete) + else + archetype_fast_delete(columns, column_count, row, types, delete) + end + end + + function world_delete(world: World, entity: i53, destruct: boolean?) + local entityIndex = world.entityIndex + + local record = entityIndex.sparse[entity] + if not record then + return + end + + local archetype = record.archetype + local row = record.row + + if archetype then + -- In the future should have a destruct mode for + -- deleting archetypes themselves. Maybe requires recycling + archetype_delete(world, archetype, row, destruct) + end + + local delete = entity + local component_index = world.componentIndex + local archetypes = world.archetypes + local tgt = ECS_PAIR(EcsWildcard, delete) + local idr_t = component_index[tgt] + local idr = component_index[delete] + + if idr then + local children = {} + for archetype_id in idr.cache do + local idr_archetype = archetypes[archetype_id] + + for i, child in idr_archetype.entities do + table.insert(children, child) + end + end + local flags = idr.flags + if bit32.band(flags, ECS_ID_DELETE) ~= 0 then + for _, child in children do + -- Cascade deletion to children + world_delete(world, child) + end + else + for _, child in children do + world_remove(world, child, delete) + end + end + end + + if idr_t then + for archetype_id in idr_t.cache do + local children = {} + local idr_o_archetype = archetypes[archetype_id] + + local idr_o_types = idr_o_archetype.types + + for _, child in idr_o_archetype.entities do + table.insert(children, child) + end + + for _, id in idr_o_types do + if not ECS_IS_PAIR(id) then + continue + end + + local id_record = component_index[id] + + if id_record then + local flags = id_record.flags + if bit32.band(flags, ECS_ID_DELETE) ~= 0 then + for _, child in children do + -- Cascade deletions of it has Delete as component trait + world_delete(world, child, destruct) + end + else + local object = ECS_ENTITY_T_LO(id) + if object == delete then + for _, child in children do + world_remove(world, child, id) + end + end + end + end + end + end + end + + record.archetype = nil :: any + entityIndex.sparse[entity] = nil + end +end + +local function world_contains(world: World, entity): boolean + return world.entityIndex.sparse[entity] ~= nil +end + +local function NOOP() end + +local function ARM(query, ...) + return query +end + +local EMPTY_LIST = {} +local EMPTY_QUERY = { + __iter = function() + return NOOP + end, + iter = function() + return NOOP + end, + drain = ARM, + next = NOOP, + replace = NOOP, + with = ARM, + without = ARM, + archetypes = function() + return EMPTY_LIST + end, +} + +setmetatable(EMPTY_QUERY, EMPTY_QUERY) + +local function query_iter(query) + local world_query_iter_next + + if query.should_drain then + world_query_iter_next = query.iter_next + if world_query_iter_next then + return world_query_iter_next + end + end + + local compatible_archetypes = query.compatible_archetypes + local lastArchetype = 1 + local archetype = compatible_archetypes[1] + if not archetype then + return EMPTY_QUERY + end + local columns = archetype.columns + local entities = archetype.entities + local i = #entities + local records = archetype.records + + local ids = query.ids + local A, B, C, D, E, F, G, H, I = unpack(ids) + local a, b, c, d, e, f, g, h + + if not B then + a = columns[records[A].column] + elseif not C then + a = columns[records[A].column] + b = columns[records[B].column] + elseif not D then + a = columns[records[A].column] + b = columns[records[B].column] + c = columns[records[C].column] + elseif not E then + a = columns[records[A].column] + b = columns[records[B].column] + c = columns[records[C].column] + d = columns[records[D].column] + elseif not F then + a = columns[records[A].column] + b = columns[records[B].column] + c = columns[records[C].column] + d = columns[records[D].column] + e = columns[records[E].column] + elseif not G then + a = columns[records[A].column] + b = columns[records[B].column] + c = columns[records[C].column] + d = columns[records[D].column] + e = columns[records[E].column] + f = columns[records[F].column] + elseif not H then + a = columns[records[A].column] + b = columns[records[B].column] + c = columns[records[C].column] + d = columns[records[D].column] + e = columns[records[E].column] + f = columns[records[F].column] + g = columns[records[G].column] + elseif not I then + a = columns[records[A].column] + b = columns[records[B].column] + c = columns[records[C].column] + d = columns[records[D].column] + e = columns[records[E].column] + f = columns[records[F].column] + g = columns[records[G].column] + h = columns[records[H].column] + end + + if not B then + function world_query_iter_next(): any + local entityId = entities[i] + while entityId == nil do + lastArchetype += 1 + archetype = compatible_archetypes[lastArchetype] + if not archetype then + return nil + end + + entities = archetype.entities + i = #entities + if i == 0 then + continue + end + entityId = entities[i] + columns = archetype.columns + local records = archetype.records + a = columns[records[A].column] + end + + local row = i + i -= 1 + + return entityId, a[row] + end + elseif not C then + function world_query_iter_next(): any + local entityId = entities[i] + while entityId == nil do + lastArchetype += 1 + archetype = compatible_archetypes[lastArchetype] + if not archetype then + return nil + end + + entities = archetype.entities + i = #entities + if i == 0 then + continue + end + entityId = entities[i] + columns = archetype.columns + local records = archetype.records + a = columns[records[A].column] + b = columns[records[B].column] + end + + local row = i + i -= 1 + + return entityId, a[row], b[row] + end + elseif not D then + function world_query_iter_next(): any + local entityId = entities[i] + while entityId == nil do + lastArchetype += 1 + archetype = compatible_archetypes[lastArchetype] + if not archetype then + return nil + end + + entities = archetype.entities + i = #entities + if i == 0 then + continue + end + entityId = entities[i] + columns = archetype.columns + local records = archetype.records + a = columns[records[A].column] + b = columns[records[B].column] + c = columns[records[C].column] + end + + local row = i + i -= 1 + + return entityId, a[row], b[row], c[row] + end + elseif not E then + function world_query_iter_next(): any + local entityId = entities[i] + while entityId == nil do + lastArchetype += 1 + archetype = compatible_archetypes[lastArchetype] + if not archetype then + return nil + end + + entities = archetype.entities + i = #entities + if i == 0 then + continue + end + entityId = entities[i] + columns = archetype.columns + local records = archetype.records + a = columns[records[A].column] + b = columns[records[B].column] + c = columns[records[C].column] + d = columns[records[D].column] + end + + local row = i + i -= 1 + + return entityId, a[row], b[row], c[row], d[row] + end + else + local queryOutput = {} + function world_query_iter_next(): any + local entityId = entities[i] + while entityId == nil do + lastArchetype += 1 + archetype = compatible_archetypes[lastArchetype] + if not archetype then + return nil + end + + entities = archetype.entities + i = #entities + if i == 0 then + continue + end + entityId = entities[i] + columns = archetype.columns + local records = archetype.records + + if not F then + a = columns[records[A].column] + b = columns[records[B].column] + c = columns[records[C].column] + d = columns[records[D].column] + e = columns[records[E].column] + elseif not G then + a = columns[records[A].column] + b = columns[records[B].column] + c = columns[records[C].column] + d = columns[records[D].column] + e = columns[records[E].column] + f = columns[records[F].column] + elseif not H then + a = columns[records[A].column] + b = columns[records[B].column] + c = columns[records[C].column] + d = columns[records[D].column] + e = columns[records[E].column] + f = columns[records[F].column] + g = columns[records[G].column] + elseif not I then + a = columns[records[A].column] + b = columns[records[B].column] + c = columns[records[C].column] + d = columns[records[D].column] + e = columns[records[E].column] + f = columns[records[F].column] + g = columns[records[G].column] + h = columns[records[H].column] + end + end + + local row = i + i -= 1 + + if not F then + return entityId, a[row], b[row], c[row], d[row], e[row] + elseif not G then + return entityId, a[row], b[row], c[row], d[row], e[row], f[row] + elseif not H then + return entityId, a[row], b[row], c[row], d[row], e[row], f[row], g[row] + elseif not I then + return entityId, a[row], b[row], c[row], d[row], e[row], f[row], g[row], h[row] + end + + local records = archetype.records + for j, id in ids do + queryOutput[j] = columns[records[id].column][row] + end + + return entityId, unpack(queryOutput) + end + end + + query.iter_next = world_query_iter_next + return world_query_iter_next +end + +local function query_drain(query) + local query_iter_next = query_iter(query) + query.next = query_iter_next + query.should_drain = true + return query +end + +local function query_next(query) + error("Did you forget to call drain?") +end + +local function query_without(query, ...) + local compatible_archetypes = query.compatible_archetypes + local N = select("#", ...) + for i = #compatible_archetypes, 1, -1 do + local archetype = compatible_archetypes[i] + local records = archetype.records + local shouldRemove = false + + for j = 1, N do + local id = select(j, ...) + if records[id] then + shouldRemove = true + break + end + end + + if shouldRemove then + local last = #compatible_archetypes + if last ~= i then + compatible_archetypes[i] = compatible_archetypes[last] + end + compatible_archetypes[last] = nil + end + end + + if #compatible_archetypes == 0 then + return EMPTY_QUERY + end + + return query +end + +local function query_with(query, ...) + local compatible_archetypes = query.compatible_archetypes + local N = select("#", ...) + for i = #compatible_archetypes, 1, -1 do + local archetype = compatible_archetypes[i] + local records = archetype.records + local shouldRemove = false + + for j = 1, N do + local id = select(j, ...) + if not records[id] then + shouldRemove = true + break + end + end + + if shouldRemove then + local last = #compatible_archetypes + if last ~= i then + compatible_archetypes[i] = compatible_archetypes[last] + end + compatible_archetypes[last] = nil + end + end + if #compatible_archetypes == 0 then + return EMPTY_QUERY + end + return query +end + +local function columns_replace_values(row, columns, ...) + for i, column in columns do + column[row] = select(i, ...) + end +end + +local function query_replace(query, fn: (...any) -> ...any) + local compatible_archetypes = query.compatible_archetypes + local ids = query.ids + local A, B, C, D, E = unpack(ids, 1, 5) + local queryOutput = {} + for i, archetype in compatible_archetypes do + local columns = archetype.columns + local records = archetype.records + for row in archetype.entities do + if not B then + local va = columns[records[A].column] + local pa = fn(va[row]) + + va[row] = pa + elseif not C then + local va = columns[records[A].column] + local vb = columns[records[B].column] + + va[row], vb[row] = fn(va[row], vb[row]) + elseif not D then + local va = columns[records[A].column] + local vb = columns[records[B].column] + local vc = columns[records[C].column] + + va[row], vb[row], vc[row] = fn(va[row], vb[row], vc[row]) + elseif not E then + local va = columns[records[A].column] + local vb = columns[records[B].column] + local vc = columns[records[C].column] + local vd = columns[records[D].column] + + va[row], vb[row], vc[row], vd[row] = fn(va[row], vb[row], vc[row], vd[row]) + else + for j, id in ids do + local tr = records[id] + queryOutput[j] = columns[tr.column][row] + end + columns_replace_values(row, columns, fn(unpack(queryOutput))) + end + end + end +end + +-- Meant for directly iterating over archetypes to minimize +-- function call overhead. Should not be used unless iterating over +-- hundreds of thousands of entities in bulk. +local function query_archetypes(query) + return query.compatible_archetypes +end + +local Query = {} +Query.__index = Query +Query.__iter = query_iter +Query.iter = query_iter +Query.without = query_without +Query.with = query_with +Query.archetypes = query_archetypes +Query.drain = query_drain +Query.next = query_next +Query.replace = query_replace + +local function world_query(world: World, ...) + local compatible_archetypes = {} + local length = 0 + + local ids = { ... } + + local archetypes = world.archetypes + + local idr: IdRecord + local componentIndex = world.componentIndex + + for _, id in ids do + local map = componentIndex[id] + if not map then + return EMPTY_QUERY + end + + if idr == nil or map.size < idr.size then + idr = map + end + end + + for archetype_id in idr.cache do + local compatibleArchetype = archetypes[archetype_id] + if #compatibleArchetype.entities == 0 then + continue + end + local records = compatibleArchetype.records + + local skip = false + + for i, id in ids do + local tr = records[id] + if not tr then + skip = true + break + end + end + + if skip then + continue + end + + length += 1 + compatible_archetypes[length] = compatibleArchetype + end + + if length == 0 then + return EMPTY_QUERY + end + + local q = setmetatable({ + compatible_archetypes = compatible_archetypes, + ids = ids, + }, Query) :: any + + return q +end + +local World = {} +World.__index = World + +World.entity = world_entity +World.query = world_query +World.remove = world_remove +World.clear = world_clear +World.delete = world_delete +World.component = world_component +World.add = world_add +World.set = world_set +World.get = world_get +World.has = world_has +World.target = world_target +World.parent = world_parent +World.contains = world_contains +World.cleanup = world_cleanup + +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 throw(msg: string) + local s = 1 + repeat + s += 1 + until debug.info(s, "s") ~= debug.info(1, "s") + if warn then + error(msg, s) + else + print(`[jecs] error: {msg}\n`) + end + end + + local function ASSERT(v: T, msg: string) + if v then + return + end + throw(msg) + end + + local function get_name(world, id): string + local name: string | nil + if ECS_IS_PAIR(id) then + name = `pair({get_name(world, ECS_ENTITY_T_HI(id))}, {get_name(world, ECS_ENTITY_T_LO(id))})` + else + local _1 = world_get_one_inline(world, id, EcsName) + if _1 then + name = `${_1}` + end + end + if name then + return name + else + return `${id}` + end + end + + local function ID_IS_TAG(world, id) + return not world_has_one_inline(world, ECS_ENTITY_T_HI(id), EcsComponent) + end + + local original_invoke_hook = invoke_hook + local invoked_hook = false + invoke_hook = function(...) + invoked_hook = true + original_invoke_hook(...) + invoked_hook = false + end + + World.query = function(world: World, ...) + ASSERT((...), "Requires at least a single component") + return world_query(world, ...) + end + + 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` + why ..= `\n[jecs] note: consider using "world:add({_1}, {_2})" instead` + throw(why) + return + end + + if world_has_one_inline(world, entity, id) then + if invoked_hook then + local file, line = debug.info(2, "sl") + local hook_fn = `{file}::{line}` + local why = `cannot call world:set inside {hook_fn} because it adds the component {get_name(world, id)}` + why ..= `\n[jecs note]: consider handling this logic inside of a system` + throw(why) + return + end + end + + world_set(world, entity, id, value) + end + + World.add = function(world: World, entity: i53, id: i53, value: nil) + if value ~= nil 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 + + if invoked_hook then + local hook_fn = debug.info(2, "sl") + throw(`Cannot call world:add when the hook {hook_fn} is in process`) + end + world_add(world, entity, id) + end + + World.get = function(world: World, entity: i53, ...) + local length = select("#", ...) + ASSERT(length < 5, "world:get does not support more than 4 components") + local _1 + for i = 1, length do + local id = select(i, ...) + local id_is_tag = not world_has(world, id, EcsComponent) + if id_is_tag then + local name = get_name(world, id) + if not _1 then + _1 = get_name(world, entity) + end + throw( + `cannot get (#{i}) component {name} value because it is a tag.` + .. `\n[jecs] note: If this was intentional, use "world:has({_1}, {name}) instead"` + ) + end + end + + return world_get(world, entity, ...) + end + + World.target = function(world, entity, relation, index) + if index == nil then + local _1 = get_name(world, entity) + local _2 = get_name(world, relation) + + throw( + "We have changed the function call to require an index parameter," + .. ` please use world:target({_1}, {_2}, 0)` + ) + end + return world_target(world, entity, relation, index) + end + + World.remove = function() end +end + +function World.new() + local self = setmetatable({ + archetypeIndex = {} :: { [string]: Archetype }, + archetypes = {} :: Archetypes, + componentIndex = {} :: ComponentIndex, + entityIndex = { + dense = {} :: { [i24]: i53 }, + sparse = {} :: { [i53]: Record }, + } :: EntityIndex, + nextArchetypeId = 0 :: number, + nextComponentId = 0 :: number, + nextEntityId = 0 :: number, + ROOT_ARCHETYPE = (nil :: any) :: Archetype, + }, World) :: any + + self.ROOT_ARCHETYPE = archetype_create(self, {}) + + for i = HI_COMPONENT_ID + 1, EcsRest do + -- Initialize built-in components + entity_index_new_id(self.entityIndex, i) + end + + world_add(self, EcsName, EcsComponent) + world_add(self, EcsOnSet, EcsComponent) + world_add(self, EcsOnAdd, EcsComponent) + world_add(self, EcsOnRemove, EcsComponent) + world_add(self, EcsWildcard, EcsComponent) + world_add(self, EcsRest, EcsComponent) + + world_set(self, EcsOnAdd, EcsName, "jecs.OnAdd") + world_set(self, EcsOnRemove, EcsName, "jecs.OnRemove") + world_set(self, EcsOnSet, EcsName, "jecs.OnSet") + world_set(self, EcsWildcard, EcsName, "jecs.Wildcard") + world_set(self, EcsChildOf, EcsName, "jecs.ChildOf") + world_set(self, EcsComponent, EcsName, "jecs.Component") + world_set(self, EcsOnDelete, EcsName, "jecs.OnDelete") + world_set(self, EcsOnDeleteTarget, EcsName, "jecs.OnDeleteTarget") + world_set(self, EcsDelete, EcsName, "jecs.Delete") + world_set(self, EcsRemove, EcsName, "jecs.Remove") + world_set(self, EcsName, EcsName, "jecs.Name") + world_set(self, EcsRest, EcsRest, "jecs.Rest") + + world_add(self, EcsChildOf, ECS_PAIR(EcsOnDeleteTarget, EcsDelete)) + + return self +end + +export type Id = Entity | Pair + +export type Pair = number + +type Item = (self: Query) -> (Entity, T...) + +export type Entity = number & { __T: T } + +type Iter = (query: Query) -> () -> (Entity, T...) + +type Query = typeof(setmetatable({}, { + __iter = (nil :: any) :: Iter, +})) & { + iter: Iter, + next: Item, + drain: (self: Query) -> Query, + with: (self: Query, ...i53) -> Query, + without: (self: Query, ...i53) -> Query, + replace: (self: Query, (T...) -> U...) -> (), + archetypes: () -> { Archetype }, +} + +export type World = { + archetypeIndex: { [string]: Archetype }, + archetypes: Archetypes, + componentIndex: ComponentIndex, + entityIndex: EntityIndex, + ROOT_ARCHETYPE: Archetype, + + nextComponentId: number, + nextEntityId: number, + nextArchetypeId: number, +} & { + --- Creates a new entity + entity: (self: World) -> Entity, + --- Creates a new entity located in the first 256 ids. + --- These should be used for static components for fast access. + component: (self: World) -> Entity, + --- Gets the target of an relationship. For example, when a user calls + --- `world:target(id, ChildOf(parent), 0)`, you will obtain the parent entity. + target: (self: World, id: Entity, relation: Entity, nth: number) -> Entity?, + --- Deletes an entity and all it's related components and relationships. + delete: (self: World, id: Entity) -> (), + + --- Adds a component to the entity with no value + add: (self: World, id: Entity, component: Id) -> (), + --- Assigns a value to a component on the given entity + set: (self: World, id: Entity, component: Id, data: T) -> (), + + -- Clears an entity from the world + clear: (self: World, id: Entity) -> (), + --- Removes a component from the given entity + remove: (self: World, id: Entity, component: Id) -> (), + --- Retrieves the value of up to 4 components. These values may be nil. + get: ((self: World, id: any, Id) -> A?) + & ((self: World, id: Entity, Id, Id) -> (A?, B?)) + & ((self: World, id: Entity, Id, Id, Id) -> (A?, B?, C?)) + & (self: World, id: Entity, Id, Id, Id, Id) -> (A?, B?, C?, D?), + + --- Returns whether the entity has the ID. + has: (self: World, entity: Entity, ...Id) -> boolean, + + --- Get parent (target of ChildOf relationship) for entity. If there is no ChildOf relationship pair, it will return nil. + parent: (self: World, entity: Entity) -> Entity, + + --- Checks if the world contains the given entity + contains: (self: World, entity: Entity) -> boolean, + + --- Searches the world for entities that match a given query + query: ((self: World, Id) -> Query) + & ((self: World, Id, Id) -> Query) + & ((self: World, Id, Id, Id) -> Query) + & ((self: World, Id, Id, Id, Id) -> Query) + & ((self: World, Id, Id, Id, Id, Id) -> Query) + & (( + self: World, + Id, + Id, + Id, + Id, + Id, + Id + ) -> Query) + & (( + self: World, + Id, + Id, + Id, + Id, + Id, + Id, + Id + ) -> Query) + & (( + self: World, + Id, + Id, + Id, + Id, + Id, + Id, + Id, + Id, + ...Id + ) -> Query), +} + +return { + World = World :: { new: () -> World }, + + OnAdd = EcsOnAdd :: Entity<(entity: Entity) -> ()>, + OnRemove = EcsOnRemove :: Entity<(entity: Entity) -> ()>, + OnSet = EcsOnSet :: Entity<(entity: Entity, data: any) -> ()>, + ChildOf = EcsChildOf :: Entity, + Component = EcsComponent :: Entity, + Wildcard = EcsWildcard :: Entity, + w = EcsWildcard :: Entity, + OnDelete = EcsOnDelete :: Entity, + OnDeleteTarget = EcsOnDeleteTarget :: Entity, + Delete = EcsDelete :: Entity, + Remove = EcsRemove :: Entity, + Name = EcsName :: Entity, + Rest = EcsRest :: Entity, + + pair = ECS_PAIR, + + -- Inwards facing API for testing + ECS_ID = ECS_ENTITY_T_LO, + ECS_GENERATION_INC = ECS_GENERATION_INC, + ECS_GENERATION = ECS_GENERATION, + ECS_ID_IS_WILDCARD = ECS_ID_IS_WILDCARD, + + IS_PAIR = ECS_IS_PAIR, + pair_first = ecs_pair_first, + pair_second = ecs_pair_second, + entity_index_get_alive = entity_index_get_alive, +} diff --git a/lib/ref.luau b/lib/ref.luau index ae0f6ba..6c462e0 100644 --- a/lib/ref.luau +++ b/lib/ref.luau @@ -1,7 +1,7 @@ --!strict --!optimize 2 local handle = require("./handle") -local world = require("./world").get() +local WORLD = require("./world").get local refs = {} @@ -11,8 +11,9 @@ local refs = {} --- @param key any --- @return handle local function ref(key: any): handle.handle + local world = WORLD() if not key then - return handle.new(world:entity()) + return handle(world:entity()) end local entity = refs[key] @@ -21,7 +22,7 @@ local function ref(key: any): handle.handle refs[key] = entity end - return handle.new(entity) + return handle(entity) end return ref diff --git a/lib/replicator.luau b/lib/replicator.luau index dd9de5c..7890732 100644 --- a/lib/replicator.luau +++ b/lib/replicator.luau @@ -1,11 +1,11 @@ --!strict --!optimize 2 -local jecs = require("@pkg/jecs") +local jecs = require("./jecs") type entity = jecs.Entity type i53 = number local ref = require("./ref") -local world = require("./world").get() +local WORLD = require("./world").get --- A replicator keeps track of all entities with the passed components and their values - --- whenever a component is changed (add, change, remove) and the replicator listens to it, it's also changed within the contained raw data.\ @@ -122,6 +122,7 @@ export type changes = { --- @param ... entity --- @return replicator local function replicator(...: entity): replicator + local world = WORLD() local components = { ... } -- don't index a changes table start diff --git a/lib/world.luau b/lib/world.luau index d431198..1f2ee25 100644 --- a/lib/world.luau +++ b/lib/world.luau @@ -1,6 +1,6 @@ --!strict --!optimize 2 -local jecs = require("@pkg/jecs") +local jecs = require("./jecs") local WORLD: jecs.World diff --git a/luau_lsp_settings.json b/luau_lsp_settings.json index 5ec3d6a..79d0f9f 100644 --- a/luau_lsp_settings.json +++ b/luau_lsp_settings.json @@ -3,7 +3,7 @@ "LuauTinyControlFlowAnalysis": "true" }, "luau-lsp.require.mode": "relativeToFile", - "luau-lsp.require.directoryAliases": { - "@pkg": "Packages/" + "luau-lsp.require.fileAliases": { + "@jecs": "lib/jecs" } } diff --git a/selene.toml b/selene.toml index 9afc306..d0a4c7f 100644 --- a/selene.toml +++ b/selene.toml @@ -1 +1,2 @@ std = "selene_definitions" +exclude = ["lib/jecs.luau"] diff --git a/test/runner.luau b/test/runner.luau deleted file mode 100644 index e69de29..0000000 diff --git a/test/signal.luau b/test/signal.luau new file mode 100644 index 0000000..b508fae --- /dev/null +++ b/test/signal.luau @@ -0,0 +1,96 @@ +-- licensed under MIT +-- @author jackdotink +-- https://github.com/red-blox/Util/blob/main/libs/Signal/Signal.luau +-- adapted to work in pure luau + +type node = { + next: node?, + callback: (T...) -> (), +} + +export type signal = { + root: node?, + + connect: (self: signal, Callback: (T...) -> ()) -> () -> (), + wait: (self: signal) -> T..., + once: (self: signal, Callback: (T...) -> ()) -> () -> (), + fire: (self: signal, T...) -> (), + disconnect_all: (self: signal) -> (), +} + +local Signal = {} +Signal.__index = Signal + +-- Extracted this function from Connect as it results in the closure +-- made in Connect using less memory because this function can be static +local function disconnect(self: signal, Node: node) + if self.root == Node then + self.root = Node.next + else + local Current = self.root + + while Current do + if Current.next == Node then + Current.next = Node.next + break + end + + Current = Current.next + end + end +end + +function Signal.connect(self: signal, Callback: (T...) -> ()): () -> () + local node = { + next = self.root, + callback = Callback, + } + + self.root = node + + return function() + disconnect(self, node) + end +end + +function Signal.wait(self: signal): T... + local Thread = coroutine.running() + local Disconnect + + Disconnect = self:connect(function(...) + Disconnect() + coroutine.resume(Thread, ...) + end) + + return coroutine.yield() +end + +function Signal.once(self: signal, Callback: (T...) -> ()): () -> () + local Disconnect + + Disconnect = self:connect(function(...) + Disconnect() + Callback(...) + end) + + return Disconnect +end + +function Signal.fire(self: signal, ...: T...) + local Current = self.root + + while Current do + Current.callback(...) + Current = Current.next + end +end + +function Signal.disconnect_all(self: signal) + self.root = nil +end + +return function(): signal + return setmetatable({ + root = nil, + }, Signal) :: any +end diff --git a/test/tests.luau b/test/tests.luau new file mode 100644 index 0000000..528bd78 --- /dev/null +++ b/test/tests.luau @@ -0,0 +1,237 @@ +--!strict +-- stylua: ignore start +local jecs = require("@jecs") +local jecs_utils = require("@jecs_utils") +local testkit = require("@testkit") + +local collect = jecs_utils.collect +local handle = jecs_utils.handle +local replicator = jecs_utils.replicator +local ref = jecs_utils.ref +local command_buffer = jecs_utils.command_buffer + +local signal = require("./signal") + +local BENCH, START = testkit.benchmark() + +local TEST, CASE, CHECK, FINISH, SKIP, FOCUS = testkit.test() + +TEST("jecs_utils.collect()", function() + do CASE "collects" + local sig: signal.signal = signal() + local flush = collect(sig) + local should = {} + + for idx = 100, 1, -1 do + local n = math.random() + should[idx] = n + sig:fire(n) + end + + for idx, n in flush do + CHECK(should[idx] == n) + end + end +end) + +TEST("jecs_utils.handle()", function() + do CASE "has" + local world = jecs.World.new() + jecs_utils.initialize(world) + + local entity = world:entity() + local tag = world:entity() + + world:add(entity, tag) + CHECK(handle(entity):has(tag)) + end + + do CASE "get" + local world = jecs.World.new() + jecs_utils.initialize(world) + + local entity = world:entity() + local component = world:component() + + world:set(entity, component, 50) + CHECK(handle(entity):get(component) == 50) + end + + do CASE "add" + local world = jecs.World.new() + jecs_utils.initialize(world) + + local entity = world:entity() + local tag = world:entity() + + handle(entity):add(tag) + CHECK(world:has(entity, tag)) + end + + do CASE "set" + local world = jecs.World.new() + jecs_utils.initialize(world) + + local entity = world:entity() + local component = world:component() + + handle(entity):set(component, 50) + CHECK(world:get(entity, component) == 50) + end + + do CASE "remove" + local world = jecs.World.new() + jecs_utils.initialize(world) + + local entity = world:entity() + local component = world:component() + + handle(entity):set(component, 50) + CHECK(world:get(entity, component) == 50) + end + + do CASE "delete" + local world = jecs.World.new() + jecs_utils.initialize(world) + + local entity = world:entity() + handle(entity):delete() + CHECK(not world:contains(entity)) + end +end) + +TEST("jecs_utils.ref()", function() + do CASE "ref(abc) == ref(abc)" + local world = jecs.World.new() + jecs_utils.initialize(world) + + local a: number = ref(1234):id() + local b: number = ref(1234):id() + CHECK(a == b) + end +end) + +TEST("jecs_utils.replicator()", function() + do CASE "propagates difference" + local world = jecs.World.new() + local tag = world:entity() + local component: jecs.Entity = world:component() + + local entity1 = world:entity() + local entity2 = world:entity() + + jecs_utils.initialize(world) + local rep = replicator(component, tag) + + world:add(entity1, tag) + world:set(entity2, component, 50) + + local difference: jecs_utils.changes = rep.calculate_difference() :: any + CHECK(difference ~= nil) + + local world2 = jecs.World.new() + local component2: jecs.Entity = world2:component() + local tag2 = world2:entity() + + jecs_utils.initialize(world2) + local rep2 = replicator(component2, tag2) + + rep2.apply_difference(difference) + + CHECK(ref(`replicated-{entity1}`):has(tag2)) + CHECK(ref(`replicated-{entity2}`):get(component2) == 50) + end + + do CASE "propagates full data" + local world = jecs.World.new() + local tag = world:entity() + local component: jecs.Entity = world:component() + + local entity1 = world:entity() + local entity2 = world:entity() + + jecs_utils.initialize(world) + local rep = replicator(component, tag) + + world:add(entity1, tag) + world:set(entity2, component, 50) + + local full_data = rep.get_full_data() + CHECK(full_data ~= nil) + + local world2 = jecs.World.new() + local component2: jecs.Entity = world2:component() + local tag2 = world2:entity() + + jecs_utils.initialize(world2) + local rep2 = replicator(component2, tag2) + + rep2.apply_difference(full_data) + + CHECK(ref(`replicated-{entity1}`):has(tag2)) + CHECK(ref(`replicated-{entity2}`):get(component2) == 50) + end +end) + +TEST("jecs_utils.command_buffer", function() + do CASE "add" + local world = jecs.World.new() + jecs_utils.initialize(world) + + local tag = world:entity() + local entity = world:entity() + command_buffer.add(entity, tag) + + CHECK(not world:has(entity, tag)) + + command_buffer.flush() + + CHECK(world:has(entity, tag)) + end + + do CASE "set" + local world = jecs.World.new() + jecs_utils.initialize(world) + + local component = world:component() + local entity = world:entity() + command_buffer.set(entity, component, 50) + + CHECK(not world:has(entity, component)) + + command_buffer.flush() + + CHECK(world:get(entity, component) == 50) + end + + do CASE "remove" + local world = jecs.World.new() + jecs_utils.initialize(world) + + local component = world:component() + local entity = world:entity() + world:set(entity, component, 50) + command_buffer.remove(entity, component) + + CHECK(world:has(entity, component)) + + command_buffer.flush() + + CHECK(not world:has(entity, component)) + end + + do CASE "delete" + local world = jecs.World.new() + jecs_utils.initialize(world) + + local entity = world:entity() + command_buffer.delete(entity) + + command_buffer.flush() + + CHECK(not world:contains(entity)) + end +end) + +FINISH() +-- stylua: ignore end diff --git a/wally.toml b/wally.toml index d319ce3..f6c2e9e 100644 --- a/wally.toml +++ b/wally.toml @@ -15,4 +15,3 @@ include = [ ] [dependencies] -jecs = "ukendio/jecs@0.2.10"