From 6a08d8d7f48939cbbd88c63980176b2e291ad08f Mon Sep 17 00:00:00 2001 From: marked Date: Tue, 12 Nov 2024 16:33:33 +0100 Subject: [PATCH] Initial push --- .gitignore | 1 + .luaurc | 8 + LICENSE | 21 + README.md | 65 ++ init.luau | 1889 ++++++++++++++++++++++++++++++++++++++++++++++++++++ pesde.toml | 13 + pull.luau | 113 ++++ rokit.toml | 8 + 8 files changed, 2118 insertions(+) create mode 100644 .gitignore create mode 100644 .luaurc create mode 100644 LICENSE create mode 100644 README.md create mode 100644 init.luau create mode 100644 pesde.toml create mode 100644 pull.luau create mode 100644 rokit.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c2ad09 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/.luaurc b/.luaurc new file mode 100644 index 0000000..2747dd7 --- /dev/null +++ b/.luaurc @@ -0,0 +1,8 @@ +{ + "aliases": { + "jecs": "src", + "testkit": "testkit", + "mirror": "mirror", + }, + "languageMode": "strict" +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..605eef8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 jecs authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..539213b --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ + +

+ + +

+ +[![License: Apache 2.0](https://img.shields.io/badge/License-Apache-blue.svg?style=for-the-badge)](LICENSE-APACHE) +[![Wally](https://img.shields.io/github/v/tag/ukendio/jecs?&style=for-the-badge)](https://wally.run/package/ukendio/jecs) + +jecs is Just a stupidly fast Entity Component System + +- Entity Relationships as first class citizens +- Iterate 800,000 entities at 60 frames per second +- Type-safe [Luau](https://luau-lang.org/) API +- Zero-dependency package +- Optimized for column-major operations +- Cache friendly archetype/SoA storage +- Unit tested for stability + +### Example + +```lua +local world = jecs.World.new() +local pair = jecs.pair + +local ChildOf = world:component() +local Name = world:component() + +local function parent(entity) + return world:target(entity, ChildOf) +end +local function getName(entity) + return world:get(entity, Name) +end + +local alice = world:entity() +world:set(alice, Name, "alice") + +local bob = world:entity() +world:add(bob, pair(ChildOf, alice)) +world:set(bob, Name, "bob") + +local sara = world:entity() +world:add(sara, pair(ChildOf, alice)) +world:set(sara, Name, "sara") + +print(getName(parent(sara))) + +for e in world:query(pair(ChildOf, alice)) do + print(getName(e), "is the child of alice") +end + +-- Output +-- "alice" +-- bob is the child of alice +-- sara is the child of alice +``` + +21,000 entities 125 archetypes 4 random components queried. +![Queries](image-3.png) +Can be found under /benches/visual/query.luau + +Inserting 8 components to an entity and updating them over 50 times. +![Insertions](image-4.png) +Can be found under /benches/visual/insertions.luau diff --git a/init.luau b/init.luau new file mode 100644 index 0000000..4223eed --- /dev/null +++ b/init.luau @@ -0,0 +1,1889 @@ +--!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, + refs: 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): i24? + if index == nil then + index = 0 + end + 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 }, ty, prev: i53?): Archetype + 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 = {}, refs = {} :: GraphEdge }, + 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, ty) +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: GraphEdge, id, to) + archetype_init_edge(archetype, edge, id, to) + archetype_ensure_edge(world, archetype.node.add, id) + if archetype ~= to then + local to_refs = to.node.refs + local next_edge = to_refs.next + + to_refs.next = edge + edge.prev = to_refs + edge.next = next_edge + + if next_edge then + next_edge.prev = edge + end + 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_refs = to.node.refs + local prev_edge = to_refs.prev + + to_refs.prev = edge + edge.next = to_refs + edge.prev = prev_edge + + if prev_edge then + prev_edge.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.remove, 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 + +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 + local node_refs = node.refs + for id, edge in add do + archetype_disconnect_edge(edge) + add[id] = nil + end + for id, edge in remove do + archetype_disconnect_edge(edge) + remove[id] = nil + end + + local cur = node_refs.next + while cur do + local edge = cur + local next_edge = edge.next + archetype_remove_edge(edge.from.node.add, edge.id, edge) + cur = next_edge + end + + cur = node_refs.prev + while cur do + local edge = cur + local next_edge = edge.prev + archetype_remove_edge(edge.from.node.remove, edge.id, edge) + cur = next_edge + end + + node_refs.next = nil + node_refs.prev = nil +end + +local function archetype_destroy(world: World, archetype: Archetype) + + if archetype == world.ROOT_ARCHETYPE then + return + end + + 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) + local archetypes = world.archetypes + + for _, archetype in archetypes do + if #archetype.entities == 0 then + archetype_destroy(world, archetype) + end + end + + local new_archetypes = table.create(#archetypes) + local new_archetype_map = {} + + for index, archetype in archetypes do + new_archetypes[index] = archetype + new_archetype_map[archetype.type] = archetype + end + + world.archetypes = new_archetypes + world.archetypeIndex = new_archetype_map +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 sparse_array = entityIndex.sparse + + local record = sparse_array[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_t_archetype = archetypes[archetype_id] + + local idr_t_types = idr_t_archetype.types + + for _, child in idr_t_archetype.entities do + table.insert(children, child) + end + + for _, id in idr_t_types do + if not ECS_IS_PAIR(id) then + continue + end + local object = ECS_ENTITY_T_LO(id) + if object == delete then + local id_record = component_index[id] + 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 + + break + else + for _, child in children do + world_remove(world, child, id) + end + end + end + end + + + archetype_destroy(world, idr_t_archetype) + end + end + + record.archetype = nil :: any + sparse_array[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 +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, index: 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/pesde.toml b/pesde.toml new file mode 100644 index 0000000..a879951 --- /dev/null +++ b/pesde.toml @@ -0,0 +1,13 @@ +name = "marked/jecs_pesde" +description = "A minimal copy of jecs intended for adding as a Luau git dependency on pesde projects" +version = "0.3.2" +repository = "https://git.devmarked.win/marked/jecs-pesde" + +includes = ["init.luau", "pesde.toml", "README.md", "LICENSE", ".luaurc"] + +[target] +environment = "luau" +lib = "init.luau" + +[indices] +default = "https://github.com/daimond113/pesde-index" \ No newline at end of file diff --git a/pull.luau b/pull.luau new file mode 100644 index 0000000..c4dd703 --- /dev/null +++ b/pull.luau @@ -0,0 +1,113 @@ +--!strict +local task = require("@lune/task") +local process = require("@lune/process") +local fs = require("@lune/fs") +local net = require("@lune/net") +local serde = require("@lune/serde") + +print("-- 🟧 Starting pull of latest release...") + +type wally_manifest = { + package: { + name: string, + version: string, + registry: string, + realm: string, + license: string?, + exclude: { string }?, + include: { string }?, + }, + dependencies: { + [string]: string, + }, +} + +print("🟧 Fetching github token...") +local github_token: string = process.args[1] + +if not github_token then + local env_exists = fs.metadata(".env").exists + if not env_exists then + error("Usage: lune run download-jecs [GITHUB_PAT]\nAlternatively, put the PAT in an .env file under GITHUB_PAT") + end + + local env = serde.decode("toml", fs.readFile(".env")) + local pat = env.GITHUB_PAT or error("❌ Couldn't read GITHUB_PAT from .env") + github_token = pat +end +print("✅ Got github token.") + +--local manifest_contents = fs.readFile("wally.toml") or error("Couldn't read manifest.") +print("🟧 Fetching latest jecs wally manifest...") +local pull_manifest_res = net.request("https://raw.githubusercontent.com/Ukendio/jecs/refs/heads/main/wally.toml") +if not pull_manifest_res.ok then + error(`❌ Couldn't download jecs manifest: {pull_manifest_res.statusCode} {pull_manifest_res.statusMessage}`) +end +local manifest_contents = pull_manifest_res.body +local manifest: wally_manifest = serde.decode("toml", manifest_contents) or error("Couldn't decode manifest.") +local jecs_version = manifest.package.version +print(`✅ Got latest manifest. Version: {jecs_version}`) + +type gh_api_tag = { + ref: string, + node_id: string, + url: string, + object: { + sha: string, + type: string, + url: string, + }, +} + +print("🟧 Getting tag associated with version in manifest...") +local response = net.request({ + url = `https://api.github.com/repos/ukendio/jecs/git/refs/tags/v{jecs_version}`, + method = "GET", + headers = { + Accept = "application/vnd.github+json", + Authorization = `Bearer {github_token}`, + ["X-GitHub-Api-Version"] = "2022-11-28", + }, +}) + +if not response.ok then + error(`❌ Github api response not ok:\n{response.statusCode} @ {response.statusMessage}\n{response.body}`) +end + +local gh_api_tag: gh_api_tag = serde.decode("json", response.body) +print(`✅ Got associated tag: {gh_api_tag.object.sha}`) + +local URL = `https://raw.githubusercontent.com/Ukendio/jecs/{gh_api_tag.object.sha}/` +local function download(path: string, output: string) + process.spawn("curl", { URL .. path, "-o", output }) +end + +local function download_list(list: { { path: string, output: string } }) + local n = #list + print(`🟧 Downloading {n} files...`) + + for idx = 1, n do + local file = list[idx] + + print(`Downloading file {idx}/{n} - {file.path} -> {file.output}...`) + download(file.path, file.output) + end + + print(`✅ Downloaded {n} files.`) +end + +download_list({ + { path = "src/init.luau", output = "init.luau" }, + { path = ".luaurc", output = ".luaurc" }, + { path = "LICENSE", output = "LICENSE" }, + { path = "README.md", output = "README.md" }, +}) + +print("🟧 Writing pesde manifest...") +local pesde_manifest = + `name = "marked/jecs_pesde"\ndescription = "A minimal copy of jecs intended for adding as a Luau git dependency on pesde projects"\nversion = "{jecs_version}"\nrepository = "https://git.devmarked.win/marked/jecs-pesde"\n\nincludes = ["init.luau", "pesde.toml", "README.md", "LICENSE", ".luaurc"]\n\n[target]\nenvironment = "luau"\nlib = "init.luau"\n\n[indices]\ndefault = "https://github.com/daimond113/pesde-index"` + +fs.writeFile("pesde.toml", pesde_manifest) +print("✅ Wrote pesde manifest.") + +print("-- ✅ Finished pulling latest release.") diff --git a/rokit.toml b/rokit.toml new file mode 100644 index 0000000..61eb181 --- /dev/null +++ b/rokit.toml @@ -0,0 +1,8 @@ +# This file lists tools managed by Rokit, a toolchain manager for Roblox projects. +# For more information, see https://github.com/rojo-rbx/rokit + +# New tools can be added by running `rokit add ` in a terminal. + +[tools] +lune = "lune-org/lune@0.8.9" +pesde = "daimond113/pesde@0.5.0-rc.7"