Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
implement deferred component and entity removal. renamed emptyListene…
…rs to cleanup. fixes #6
  • Loading branch information
Mike Reinstein committed Jul 4, 2020
1 parent e548521 commit 9f8d970
Show file tree
Hide file tree
Showing 8 changed files with 139 additions and 60 deletions.
4 changes: 2 additions & 2 deletions README.md
Expand Up @@ -110,8 +110,8 @@ function gameLoop () {
// run onUpdate for all added systems
ECS.update(world, frameTime)

// necessary cleanup step at the end of the loop
ECS.emptyListeners(world)
// necessary cleanup step at the end of each frame loop
ECS.cleanup(world)

requestAnimationFrame(gameLoop)
}
Expand Down
131 changes: 82 additions & 49 deletions ecs.js
@@ -1,4 +1,5 @@
import removeItems from 'remove-array-items'
import orderedInsert from './ordered-insert.js'
import removeItems from 'remove-array-items'


function createWorld () {
Expand All @@ -9,6 +10,10 @@ function createWorld () {
listeners: {
added: { }, // key is the filter, value is the array of entities added this frame
removed: { } // key is the filter, value is the array of entities removed this frame
},
removals: {
entities: [ ], // indexes into entities array, sorted from highest to lowest
components: [ ] // [ entity index, component name ] pairs sorted from highest to lowest
}
}
}
Expand Down Expand Up @@ -57,30 +62,36 @@ function removeComponentFromEntity (world, entity, componentName) {
// get list of all remove listeners that we match
const matchingRemoveListeners = [ ]
for (const filterId in world.listeners.removed) {
if (_matchesFilter(filterId, entity))
matchingRemoveListeners.push(filterId)
// if an entity matches a remove filter, but then no longer matches the filter after a component
// is removed, it should be flagged as removed in listeners.removed
if (_matchesFilter(filterId, entity) && !_matchesFilter(filterId, entity, [ componentName ]))
world.listeners.removed[filterId].push(entity)
}

delete entity[componentName]
// add this component to the list of deferred removals
const idx = world.entities.indexOf(entity)
world.removals.components.push(idx, componentName)
}

// find any of the filters that no longer match as a result of removing
//the component and add them to the removed list
for (const filterId of matchingRemoveListeners) {
if (!_matchesFilter(filterId, entity))
world.listeners.removed[filterId].push(entity)
}

function removeEntity (world, entity) {
const idx = world.entities.indexOf(entity)
if (idx < 0)
return

// remove this entity from any filters that no longer match
for (const filterId in world.filters) {
if (filterId.indexOf(componentName) >= 0) {
// this filter contains the removed component
const filter = world.filters[filterId]
const filterIdx = filter.indexOf(entity)
if (filterIdx >= 0)
removeItems(filter, filterIdx, 1)
// add the entity to all matching remove listener lists
for (const filterId in world.listeners.removed) {
const matches = _matchesFilter(filterId, entity)

// if the entity matches the filter and isn't already in the removed list, add it
const list = world.listeners.removed[filterId]
if (matches && list.indexOf(entity) < 0) {
list.push(entity)
}
}

// add this entity to the list of deferred removals
orderedInsert(world.removals.entities, idx)
}


Expand Down Expand Up @@ -116,42 +127,19 @@ function getEntities (world, componentNames, listenerType) {


// returns true if an entity contains all the components that match the filter
function _matchesFilter (filterId, entity) {
function _matchesFilter (filterId, entity, componentIgnoreList=[]) {
const componentIds = filterId.split(',')
// if the entity lacks any components in the filter, it's not in the filter
for (const componentId of componentIds)
if (!entity[componentId])
for (const componentId of componentIds) {
const isIgnored = componentIgnoreList.indexOf(componentId) >= 0
if (isIgnored)
return false

return true
}


function removeEntity (world, entity) {
const idx = world.entities.indexOf(entity)
if (idx < 0)
return

// add the entity to all matching remove listener lists
for (const filterId in world.listeners.removed) {
const matches = _matchesFilter(filterId, entity)

// if the entity matches the filter and isn't already in the added list, add it
const list = world.listeners.removed[filterId]
if (matches && list.indexOf(entity) < 0) {
list.push(entity)
}
if (!entity[componentId])
return false
}

removeItems(world.entities, idx, 1)

// update all filters that match this
for (const filterId in world.filters) {
const filter = world.filters[filterId]
const idx = filter.indexOf(entity)
if (idx >= 0)
removeItems(filter, idx, 1)
}
return true
}


Expand Down Expand Up @@ -209,5 +197,50 @@ function emptyListeners (world) {
}


function cleanup (world) {
emptyListeners(world)

// process all entity components marked for deferred removal
for (let i=0; i < world.removals.components.length; i+=2) {
const entityIdx = world.removals.components[i];
const componentName = world.removals.components[i+1]

const entity = world.entities[entityIdx]
delete entity[componentName]

// remove this entity from any filters that no longer match
for (const filterId in world.filters) {
if (filterId.indexOf(componentName) >= 0) {
// this filter contains the removed component
const filter = world.filters[filterId]
const filterIdx = filter.indexOf(entity)
if (filterIdx >= 0)
removeItems(filter, filterIdx, 1)
}
}
}

world.removals.components.length = 0


// process all entities marked for deferred removal
for (const entityIdx of world.removals.entities) {
const entity = world.entities[entityIdx]

removeItems(world.entities, entityIdx, 1)

// update all filters that match this
for (const filterId in world.filters) {
const filter = world.filters[filterId]
const idx = filter.indexOf(entity)
if (idx >= 0)
removeItems(filter, idx, 1)
}
}

world.removals.entities.length = 0
}


export default { createWorld, createEntity, addComponentToEntity, removeComponentFromEntity, getEntities,
removeEntity, addSystem, fixedUpdate, update, preUpdate, postUpdate, emptyListeners }
removeEntity, addSystem, fixedUpdate, update, preUpdate, postUpdate, cleanup }
11 changes: 11 additions & 0 deletions ordered-insert.js
@@ -0,0 +1,11 @@
// insert the new item such that the array stays ordered from highest to lowest
export default function orderedInsert (arr, val) {
for (let i=0; i < arr.length; i++) {
if (arr[i] <= val) {
arr.splice(i, 0, val)
return
}
}

arr.push(val)
}
4 changes: 4 additions & 0 deletions test/createWorld.js
Expand Up @@ -11,5 +11,9 @@ tap.same(w, {
listeners: {
added: { },
removed: { }
},
removals: {
entities: [ ],
components: [ ]
}
})
3 changes: 2 additions & 1 deletion test/listeners.js
Expand Up @@ -18,7 +18,8 @@ tap.same(r2, [ e ], 'setting up an added listener includes entities already matc
tap.same(w.listeners.added, { "a,b,c": [ e ], "c": [ e ] }, 'clearing listeners should empty out the lists')


ECS.emptyListeners(w)
ECS.cleanup(w)


tap.same(w.listeners.added, { "a,b,c": [], "c": [] }, 'emptying listeners should empty out the lists')

Expand Down
24 changes: 24 additions & 0 deletions test/orderedInsert.js
@@ -0,0 +1,24 @@
import orderedInsert from '../ordered-insert.js'
import tap from 'tap'


function randomInt (min, max) {
const d = max - min
return min + Math.floor(d * Math.random())
}


const a = [ ]


for (let i=0; i < 10; i++) {
const val = randomInt(1, 100)
orderedInsert(a, val)
}


let last = a[0]
for (let i=1; i < a.length; i++) {
tap.assert(last >= a[i], 'sorts highest to lowest')
last = a[i]
}
9 changes: 4 additions & 5 deletions test/removeComponentFromEntity.js
Expand Up @@ -20,6 +20,7 @@ tap.equal(entities.length, 1)
tap.equal(entities2.length, 1)

ECS.removeComponentFromEntity(w, e, 'position')
ECS.cleanup(w)

const entities3 = ECS.getEntities(w, [ 'position' ])
const entities4 = ECS.getEntities(w, [ 'position', 'health' ])
Expand All @@ -31,8 +32,8 @@ tap.equal(entities5.length, 2)



// while iterating over entities, removing a component from an unvisited entity
// that causes it to no longer match the filter prevents it from being processed
// while iterating over entities, removing a component from an unvisited entity still gets processed
// because the removal is defferred until the cleanup step

const w2 = ECS.createWorld()

Expand All @@ -58,6 +59,4 @@ for (const entity of ECS.getEntities(w2, [ 'position'])) {
i++
}

tap.same(processed, { 'e3': true, 'e5': true }, 'e4 was not processed because the position component was removed.')


tap.same(processed, { e3: true, e4: true, e5: true }, 'all entities processed because of deferred component removal')
13 changes: 10 additions & 3 deletions test/removeEntity.js
Expand Up @@ -14,6 +14,11 @@ ECS.removeEntity(w, someNonEntity)
tap.equal(w.entities.length, 1, 'removing some other random object doesnt have any effect')

ECS.removeEntity(w, e)


tap.same(w.removals.entities, [ 0 ], 'entity is added to deferred entity remove list')

ECS.cleanup(w) // process deferred removals
tap.equal(w.entities.length, 0, 'entity gets removed from the world')


Expand All @@ -30,13 +35,15 @@ tap.equal(w.filters['a'].length, 1)
tap.equal(w.filters['a,b'].length, 1)

ECS.removeEntity(w, e2)
ECS.cleanup(w) // process deferred removals

tap.equal(w.filters['a'].length, 0, 'removing entities removes them from all matching filters')
tap.equal(w.filters['a,b'].length, 0, 'removing entities removes them from all matching filters')



// while iterating over entities, removing an unvisited entity prevents it from being processed
// while iterating over entities, removing an unvisited entity still gets processed
// because the removal is defferred until the cleanup step

const w2 = ECS.createWorld()

Expand All @@ -52,7 +59,7 @@ ECS.addComponentToEntity(w2, e5, 'position', 'e5')
let i = 0
const processed = { }

for (const entity of ECS.getEntities(w2, [ 'position'])) {
for (const entity of ECS.getEntities(w2, [ 'position' ])) {
processed[entity.position] = true

// while processing the first entity in the list, remove the 2nd entity
Expand All @@ -62,4 +69,4 @@ for (const entity of ECS.getEntities(w2, [ 'position'])) {
i++
}

tap.same(processed, { 'e3': true, 'e5': true }, 'e4 was not processed because it was removed')
tap.same(processed, { e3: true, e4: true, e5: true }, 'all entities processed because of deferred removal')

0 comments on commit 9f8d970

Please sign in to comment.