diff --git a/README.md b/README.md index 6bb4607..8740429 100644 --- a/README.md +++ b/README.md @@ -22,47 +22,38 @@ jecs is a stupidly fast Entity Component System (ECS). ### Example ```lua -local world = World.new() +local world = jecs.World.new() +local pair = jecs.pair -local player = world:entity() -local opponent = world:entity() +local ChildOf = world:component() +local Name = world:component() -local Health = world:component() -local Position = world:component() --- Notice how components can just be entities as well? --- It allows you to model relationships easily! -local Damage = world:entity() -local DamagedBy = world:entity() +local function parent(entity) + return world:target(entity, ChildOf) +end +local function name() -world:set(player, Health, 100) -world:set(player, Damage, 8) -world:set(player, Position, Vector3.new(0, 5, 0)) +local alice = world:entity() +world:set(alice, Name, "alice") -world:set(opponent, Health, 100) -world:set(opponent, Damage, 21) -world:set(opponent, Position, Vector3.new(0, 5, 3)) +local bob = world:entity() +world:add(bob, pair(ChildOf, alice)) +world:set(bob, Name, "bob") -for playerId, playerPosition, health in world:query(Position, Health) do - local totalDamage = 0 - for opponentId, opponentPosition, damage in world:query(Position, Damage) do - if playerId == opponentId then - continue - end - if (playerPosition - opponentPosition).Magnitude < 5 then - totalDamage += damage - end - -- We create a pair between the relation component `DamagedBy` and the entity id of the opponent. - -- This will allow us to specifically query for damage exerted by a specific opponent. - world:set(playerId, ECS_PAIR(DamagedBy, opponentId), totalDamage) - end +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 --- Gets the damage inflicted by our specific opponent! -for playerId, health, inflicted in world:query(Health, ECS_PAIR(DamagedBy, opponent)) do - world:set(playerId, health - inflicted) -end - -assert(world:get(player, Health) == 79) +-- Output +-- "alice" +-- bob is the child of alice +-- sara is the child of alice ``` 125 archetypes, 4 random components queried. diff --git a/lib/init.lua b/lib/init.lua index aabfbed..5f11982 100644 --- a/lib/init.lua +++ b/lib/init.lua @@ -14,7 +14,7 @@ type Column = {any} type Archetype = { id: number, edges: { - [i24]: { + [i53]: { add: Archetype, remove: Archetype, }, @@ -26,17 +26,37 @@ type Archetype = { records: {}, } + type Record = { archetype: Archetype, row: number, dense: i24, + componentRecord: ArchetypeMap } type EntityIndex = {dense: {[i24]: i53}, sparse: {[i53]: Record}} -type ComponentIndex = {[i24]: ArchetypeMap} type ArchetypeRecord = number -type ArchetypeMap = {sparse: {[ArchetypeId]: ArchetypeRecord}, size: number} +--[[ +TODO: +{ + index: number, + count: number, + column: number +} + +]] + +type ArchetypeMap = { + cache: {[number]: ArchetypeRecord}, + first: ArchetypeMap, + second: ArchetypeMap, + parent: ArchetypeMap, + size: number +} + +type ComponentIndex = {[i24]: ArchetypeMap} + type Archetypes = {[ArchetypeId]: Archetype} type ArchetypeDiff = { @@ -96,6 +116,7 @@ local function ECS_GENERATION(e: i53) return e % ECS_GENERATION_MASK end +-- SECOND local function ECS_ENTITY_T_LO(e: i53) e //= 0x10 return e // ECS_ENTITY_MASK @@ -107,7 +128,7 @@ local function ECS_GENERATION_INC(e: i53) return ECS_COMBINE(id, generation + 1) + flags end --- gets the high ID +-- FIRST gets the high ID local function ECS_ENTITY_T_HI(entity: i53): i24 entity //= 0x10 local first = entity % ECS_ENTITY_MASK @@ -131,8 +152,13 @@ local function ECS_PAIR(pred: number, obj: number) ECS_ENTITY_T_LO(first), second) + addFlags(--[[isPair]] true) end -local function getAlive(entityIndex: EntityIndex, id: i53) - return entityIndex.dense[id] +local function getAlive(entityIndex: EntityIndex, id: i24) + local entityId = entityIndex.dense[id] + local record = entityIndex.sparse[entityIndex.dense[id]] + if not record then + error(id.." is not alive") + end + return entityId end -- ECS_PAIR_FIRST, gets the relationship target / obj / HIGH bits @@ -239,17 +265,29 @@ local function hash(arr): string | number return table.concat(arr, "_") end -local function createArchetypeRecord(componentIndex, id, componentId, i) +local function ensureComponentRecord(componentIndex: ComponentIndex, archetypeId, componentId, i): ArchetypeMap local archetypesMap = componentIndex[componentId] if not archetypesMap then - archetypesMap = {size = 0, sparse = {}} + archetypesMap = {size = 0, cache = {}, first = {}, second = {}} :: ArchetypeMap componentIndex[componentId] = archetypesMap end - archetypesMap.sparse[id] = i + + archetypesMap.cache[archetypeId] = i + archetypesMap.size += 1 + + return archetypesMap end -local function archetypeOf(world: World, types: {i24}, prev: Archetype?): Archetype +local function ECS_ID_IS_WILDCARD(e) + assert(ECS_IS_PAIR(e)) + local first = ECS_ENTITY_T_HI(e) + local second = ECS_ENTITY_T_LO(e) + return first == WILDCARD or second == WILDCARD +end + + +local function archetypeOf(world: any, types: {i24}, prev: Archetype?): Archetype local ty = hash(types) local id = world.nextArchetypeId + 1 @@ -257,25 +295,27 @@ local function archetypeOf(world: World, types: {i24}, prev: Archetype?): Archet local length = #types local columns = table.create(length) + local componentIndex = world.componentIndex local records = {} - local componentIndex = world.componentIndex - local entityIndex = world.entityIndex for i, componentId in types do - createArchetypeRecord(componentIndex, id, componentId, i) + ensureComponentRecord(componentIndex, id, componentId, i) records[componentId] = i - columns[i] = {} - if ECS_IS_PAIR(componentId) then - local pred = ECS_PAIR_RELATION(entityIndex, componentId) - local obj = ECS_PAIR_OBJECT(entityIndex, componentId) - local first = ECS_PAIR(pred, WILDCARD) - local second = ECS_PAIR(WILDCARD, obj) - createArchetypeRecord(componentIndex, id, first, i) - createArchetypeRecord(componentIndex, id, second, i) - records[first] = i - records[second] = i + local relation = ECS_PAIR_RELATION(world.entityIndex, componentId) + local object = ECS_PAIR_OBJECT(world.entityIndex, componentId) + + local idr_r = ECS_PAIR(relation, WILDCARD) + ensureComponentRecord( + componentIndex, id, idr_r, i) + records[idr_r] = i + + local idr_t = ECS_PAIR(WILDCARD, object) + ensureComponentRecord( + componentIndex, id, idr_t, i) + records[idr_t] = i end + columns[i] = {} end local archetype = { @@ -333,6 +373,29 @@ function World.entity(world: World) return nextEntityId(world.entityIndex, entityId + REST) end +-- TODO: +-- should have an additional `index` parameter which selects the nth target +-- this is important when an entity can have multiple relationships with the same target +function World.target(world: World, entity: i53, relation: i24): i24? + local entityIndex = world.entityIndex + local record = entityIndex.sparse[entity] + local archetype = record.archetype + if not archetype then + return nil + end + local componentRecord = world.componentIndex[ECS_PAIR(relation, WILDCARD)] + if not componentRecord then + return nil + end + + local archetypeRecord = componentRecord.cache[archetype.id] + if not archetypeRecord then + return nil + end + + return ECS_PAIR_OBJECT(entityIndex, archetype.types[archetypeRecord]) +end + -- should reuse this logic in World.set instead of swap removing in transition archetype local function destructColumns(columns, count, row) if row == count then @@ -347,41 +410,54 @@ local function destructColumns(columns, count, row) end end -local function archetypeDelete(entityIndex, record: Record, entityId: i53, destruct: boolean) +local function archetypeDelete(world: World, id: i53) + local componentIndex = world.componentIndex + local archetypesMap = componentIndex[id] + local archetypes = world.archetypes + if archetypesMap then + for archetypeId in archetypesMap.cache do + for _, entity in archetypes[archetypeId].entities do + world:remove(entity, id) + end + end + + componentIndex[id] = nil + end +end + +function World.delete(world: World, entityId: i53) + local record = world.entityIndex.sparse[entityId] + if not record then + return + end + local entityIndex = world.entityIndex local sparse, dense = entityIndex.sparse, entityIndex.dense local archetype = record.archetype local row = record.row - local entities = archetype.entities - local last = #entities - local entityToMove = entities[last] + archetypeDelete(world, entityId) + archetypeDelete(world, ECS_PAIR(entityId, WILDCARD)) + archetypeDelete(world, ECS_PAIR(WILDCARD, entityId)) - if row ~= last then - dense[record.dense] = entityToMove - sparse[entityToMove] = record + if archetype then + local entities = archetype.entities + local last = #entities + + if row ~= last then + local entityToMove = entities[last] + dense[record.dense] = entityToMove + sparse[entityToMove] = record + end + + entities[row], entities[last] = entities[last], nil + + local columns = archetype.columns + + destructColumns(columns, last, row) end sparse[entityId] = nil dense[#dense] = nil - - entities[row], entities[last] = entities[last], nil - - local columns = archetype.columns - - if not destruct then - return - end - - destructColumns(columns, last, row) -end - -function World.delete(world: World, entityId: i53) - local entityIndex = world.entityIndex - local record = entityIndex.sparse[entityId] - if not record then - return - end - archetypeDelete(entityIndex, record, entityId, true) end export type World = typeof(World.new()) @@ -530,6 +606,10 @@ end -- Keeping the function as small as possible to enable inlining local function get(record: Record, componentId: i24) local archetype = record.archetype + if not archetype then + return nil + end + local archetypeRecord = archetype.records[componentId] if not archetypeRecord then @@ -575,7 +655,7 @@ EmptyQuery.__index = EmptyQuery setmetatable(EmptyQuery, EmptyQuery) export type Query = typeof(EmptyQuery) - +local testkit = require("../testkit") function World.query(world: World, ...: i53): Query -- breaking? if (...) == nil then @@ -603,9 +683,10 @@ function World.query(world: World, ...: i53): Query end end - for id in firstArchetypeMap.sparse do + for id in firstArchetypeMap.cache do local archetype = archetypes[id] local archetypeRecords = archetype.records + local indices = {} local skip = false @@ -615,6 +696,7 @@ function World.query(world: World, ...: i53): Query skip = true break end + -- index should be index.offset indices[i] = index end diff --git a/tests/world.lua b/tests/world.lua index cf7f47f..2d4bb6d 100644 --- a/tests/world.lua +++ b/tests/world.lua @@ -1,5 +1,6 @@ local testkit = require("../testkit") local jecs = require("../lib/init") +local __ = jecs.Wildcard local ECS_ID, ECS_GENERATION = jecs.ECS_ID, jecs.ECS_GENERATION local ECS_GENERATION_INC = jecs.ECS_GENERATION_INC local IS_PAIR = jecs.IS_PAIR @@ -9,7 +10,16 @@ local ECS_PAIR_RELATION = jecs.ECS_PAIR_RELATION local ECS_PAIR_OBJECT = jecs.ECS_PAIR_OBJECT local TEST, CASE, CHECK, FINISH, SKIP = testkit.test() +local function CHECK_NO_ERR(s: string, fn: (T...) -> (), ...: T...) + local ok, err: string? = pcall(fn, ...) + if not CHECK(not ok, 2) then + local i = string.find(err :: string, " ") + assert(i) + local msg = string.sub(err :: string, i+1) + CHECK(msg == s, 2) + end +end local N = 10 TEST("world", function() @@ -256,6 +266,70 @@ TEST("world", function() end CHECK(count == 1) end + + do CASE "should only relate alive entities" + + local world = jecs.World.new() + local Eats = world:entity() + local Apples = world:entity() + local Oranges = world:entity() + local bob = world:entity() + local alice = world:entity() + + world:set(bob, ECS_PAIR(Eats, Apples), "bob eats apples") + world:set(alice, ECS_PAIR(Eats, Oranges), "alice eats oranges") + + world:delete(Apples) + local Wildcard = jecs.Wildcard + + local count = 0 + for _, data in world:query(ECS_PAIR(Wildcard, Apples)) do + count += 1 + end + + CHECK(count == 0) + end + + do CASE "should error when setting invalid pair" + local world = jecs.World.new() + local Eats = world:entity() + local Apples = world:entity() + local bob = world:entity() + + world:delete(Apples) + + CHECK_NO_ERR("Apples should be dead", function() + world:set(bob, ECS_PAIR(Eats, Apples), "bob eats apples") + end) + end + + do CASE "should find target for ChildOf" + local world = jecs.World.new() + + local ChildOf = world:component() + local Name = world:component() + + local function parent(entity) + return world:target(entity, ChildOf) + end + + local bob = world:entity() + local alice = world:entity() + local sara = world:entity() + + world:add(bob, ECS_PAIR(ChildOf, alice)) + world:set(bob, Name, "bob") + world:add(sara, ECS_PAIR(ChildOf, alice)) + world:set(sara, Name, "sara") + CHECK(parent(bob) == alice) -- O(1) + + local count = 0 + for _, name in world:query(Name, ECS_PAIR(ChildOf, alice)) do + print(name) + count += 1 + end + CHECK(count == 2) + end end) FINISH() \ No newline at end of file