diff --git a/.github/workflows/golangci_lint.yml b/.github/workflows/golangci_lint.yml index 363bfff..b553b17 100644 --- a/.github/workflows/golangci_lint.yml +++ b/.github/workflows/golangci_lint.yml @@ -13,6 +13,6 @@ jobs: with: go-version: stable - name: golangci-lint - uses: golangci/golangci-lint-action@v6.1.1 + uses: golangci/golangci-lint-action@v9 with: - version: v1.64.5 \ No newline at end of file + version: v2.13.2 diff --git a/README.md b/README.md index 409ca84..94e0202 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,14 @@ There is many ways to write an ECS, and Volt is based on the Archetype paradigm. ## Knowledge ### Entity An entity is the end object in a game (e.g. a character). It is only defined by -its identifier called EntityId. This identifier is generated, its type uint64 avoiding to generate twice the same id. -When an entity is removed, this identifier can be used again for a new one. +its identifier called EntityId: a uint64 handle packing the entity's slot (index, low 32 bits) +and the generation of that slot (high 32 bits, starting at 1). + +When an entity is removed, its slot can be used again for a new one, but the generation of the slot is bumped: +a handle kept after its entity was removed is dead, and never refers to the slot's new occupant +(`Exists` reports false, `GetComponent` returns nil, `AddComponent` returns an error). +The zero value `EntityId(0)` is the null handle, which never refers to a live entity: an unset field is null. +`Index()`, `Generation()` and `IsNull()` expose the parts of a handle. Looking at the benchmark, a scene can handle between 100.000 to 1.000.000 depending on your machine and the complexity of the project. But of course, the lower the better, as it will allow the project to run on slower computers. diff --git a/archetype.go b/archetype.go index 160bf9e..26decaf 100644 --- a/archetype.go +++ b/archetype.go @@ -30,7 +30,7 @@ func (world *World) setArchetype(entityRecord entityRecord, archetype *archetype entityRecord.key = len(archetype.entities) - 1 entityRecord.archetypeId = archetype.Id - world.entities[entityRecord.Id] = entityRecord + world.entities[entityRecord.Id.Index()] = entityRecord } func (world *World) getArchetypeForComponentsIds(componentsIds ...ComponentId) *archetype { diff --git a/archetype_test.go b/archetype_test.go index dc4f998..900610b 100644 --- a/archetype_test.go +++ b/archetype_test.go @@ -71,14 +71,14 @@ func TestArchetypeGraph_EdgesAreReused(t *testing.T) { if err := AddComponent(world, e, testComponent2{}); err != nil { t.Fatalf("%s", err.Error()) } - base := world.entities[e].archetypeId + base := world.entities[e.Index()].archetypeId var withC1 archetypeId for i := 0; i < 5; i++ { if err := AddComponent(world, e, testComponent1{}); err != nil { t.Fatalf("add iteration %d: %s", i, err.Error()) } - with := world.entities[e].archetypeId + with := world.entities[e.Index()].archetypeId if i == 0 { withC1 = with } else if with != withC1 { @@ -88,7 +88,7 @@ func TestArchetypeGraph_EdgesAreReused(t *testing.T) { if err := RemoveComponent[testComponent1](world, e); err != nil { t.Fatalf("remove iteration %d: %s", i, err.Error()) } - if back := world.entities[e].archetypeId; back != base { + if back := world.entities[e.Index()].archetypeId; back != base { t.Fatalf("iteration %d: entity did not return to base archetype (%d != %d)", i, back, base) } } diff --git a/benchmark/volt_test.go b/benchmark/volt_test.go index 77c35ab..77ff19e 100644 --- a/benchmark/volt_test.go +++ b/benchmark/volt_test.go @@ -154,3 +154,26 @@ func BenchmarkRemoveVolt(b *testing.B) { b.ReportAllocs() } + +// BenchmarkCreateRemoveVolt measures a full recycle cycle: every entity is +// removed then recreated with the same components, so the id pool is exercised +// on both sides (free then reuse) on every iteration. +func BenchmarkCreateRemoveVolt(b *testing.B) { + world := volt.CreateWorld(ENTITIES_COUNT) + volt.RegisterComponent[testTransform](world, &volt.ComponentConfig[testTransform]{}) + volt.RegisterComponent[testTag](world, &volt.ComponentConfig[testTag]{}) + + entities := make([]volt.EntityId, ENTITIES_COUNT) + for i := range entities { + entities[i], _ = volt.CreateEntityWithComponents2(world, testTransform{}, testTag{}) + } + + for b.Loop() { + for i, entityId := range entities { + world.RemoveEntity(entityId) + entities[i], _ = volt.CreateEntityWithComponents2(world, testTransform{}, testTag{}) + } + } + + b.ReportAllocs() +} diff --git a/component.go b/component.go index 3499560..f7ec1e4 100644 --- a/component.go +++ b/component.go @@ -49,7 +49,7 @@ func AddComponent[T ComponentInterface](world *World, entityId EntityId, compone if !world.Exists(entityId) { return fmt.Errorf("entity %v does not exist", entityId) } - entityRecord := world.entities[entityId] + entityRecord := world.entities[entityId.Index()] componentId := component.GetComponentId() if world.hasComponents(entityRecord, componentId) { @@ -79,7 +79,7 @@ func AddComponents2[A, B ComponentInterface](world *World, entityId EntityId, a if !world.Exists(entityId) { return fmt.Errorf("entity %v does not exist", entityId) } - entityRecord := world.entities[entityId] + entityRecord := world.entities[entityId.Index()] return addComponents2(world, entityRecord, a, b) } @@ -115,7 +115,7 @@ func AddComponents3[A, B, C ComponentInterface](world *World, entityId EntityId, if !world.Exists(entityId) { return fmt.Errorf("entity %v does not exist", entityId) } - entityRecord := world.entities[entityId] + entityRecord := world.entities[entityId.Index()] return addComponents3(world, entityRecord, a, b, c) } @@ -153,7 +153,7 @@ func AddComponents4[A, B, C, D ComponentInterface](world *World, entityId Entity if !world.Exists(entityId) { return fmt.Errorf("entity %v does not exist", entityId) } - entityRecord := world.entities[entityId] + entityRecord := world.entities[entityId.Index()] return addComponents4(world, entityRecord, a, b, c, d) } @@ -192,7 +192,7 @@ func AddComponents5[A, B, C, D, E ComponentInterface](world *World, entityId Ent if !world.Exists(entityId) { return fmt.Errorf("entity %v does not exist", entityId) } - entityRecord := world.entities[entityId] + entityRecord := world.entities[entityId.Index()] return addComponents5(world, entityRecord, a, b, c, d, e) } @@ -232,7 +232,7 @@ func AddComponents6[A, B, C, D, E, F ComponentInterface](world *World, entityId if !world.Exists(entityId) { return fmt.Errorf("entity %v does not exist", entityId) } - entityRecord := world.entities[entityId] + entityRecord := world.entities[entityId.Index()] return addComponents6(world, entityRecord, a, b, c, d, e, f) } @@ -273,7 +273,7 @@ func AddComponents7[A, B, C, D, E, F, G ComponentInterface](world *World, entity if !world.Exists(entityId) { return fmt.Errorf("entity %v does not exist", entityId) } - entityRecord := world.entities[entityId] + entityRecord := world.entities[entityId.Index()] return addComponents7(world, entityRecord, a, b, c, d, e, f, g) } @@ -315,7 +315,7 @@ func AddComponents8[A, B, C, D, E, F, G, H ComponentInterface](world *World, ent if !world.Exists(entityId) { return fmt.Errorf("entity %v does not exist", entityId) } - entityRecord := world.entities[entityId] + entityRecord := world.entities[entityId.Index()] return addComponents8(world, entityRecord, a, b, c, d, e, f, g, h) } @@ -357,7 +357,7 @@ func (world *World) AddComponent(entityId EntityId, componentId ComponentId, con if !world.Exists(entityId) { return fmt.Errorf("entity %v does not exist", entityId) } - entityRecord := world.entities[entityId] + entityRecord := world.entities[entityId.Index()] if world.hasComponents(entityRecord, componentId) { return fmt.Errorf("the entity %d already owns the component %d", entityId, componentId) @@ -388,7 +388,7 @@ func (world *World) AddComponents(entityId EntityId, componentsIdsConfs ...Compo if !world.Exists(entityId) { return fmt.Errorf("entity %v does not exist", entityId) } - entityRecord := world.entities[entityId] + entityRecord := world.entities[entityId.Index()] var componentsIds []ComponentId for _, componentIdConf := range componentsIdsConfs { @@ -426,7 +426,7 @@ func RemoveComponent[T ComponentInterface](world *World, entityId EntityId) erro if !world.Exists(entityId) { return fmt.Errorf("entity %v does not exist", entityId) } - entityRecord := world.entities[entityId] + entityRecord := world.entities[entityId.Index()] if !world.hasComponents(entityRecord, componentId) { return fmt.Errorf("the entity %d doesn't own the component %d", entityId, componentId) @@ -449,7 +449,7 @@ func (world *World) RemoveComponent(entityId EntityId, componentId ComponentId) if !world.Exists(entityId) { return fmt.Errorf("entity %v does not exist", entityId) } - entityRecord := world.entities[entityId] + entityRecord := world.entities[entityId.Index()] if !world.hasComponents(entityRecord, componentId) { return fmt.Errorf("the entity %d doesn't own the component %d", entityId, componentId) @@ -489,7 +489,7 @@ func (world *World) HasComponents(entityId EntityId, componentsIds ...ComponentI if !world.Exists(entityId) { return false } - entityRecord := world.entities[entityId] + entityRecord := world.entities[entityId.Index()] return world.hasComponents(entityRecord, componentsIds...) } @@ -517,7 +517,7 @@ func GetComponent[T ComponentInterface](world *World, entityId EntityId) *T { if s == nil { return nil } - entityRecord := world.entities[entityId] + entityRecord := world.entities[entityId.Index()] if !s.hasArchetype(entityRecord.archetypeId) { return nil @@ -537,7 +537,7 @@ func (world *World) GetComponent(entityId EntityId, componentId ComponentId) (an if !world.Exists(entityId) { return nil, fmt.Errorf("entity %v does not exist", entityId) } - entityRecord := world.entities[entityId] + entityRecord := world.entities[entityId.Index()] s, err := world.getStorageForComponentId(componentId) if err != nil { return nil, err @@ -558,8 +558,8 @@ func addComponentsToArchetype1[A ComponentInterface](world *World, entityRecord return fmt.Errorf("no storage found for component %d", componentId) } - // If the entity has no component, simply add it the archetype - if entityRecord.archetypeId == 0 { + // An unplaced entity (fresh slot) is listed in no archetype yet: just place it. + if entityRecord.key < 0 { world.setArchetype(entityRecord, archetype) } else { oldArchetype := world.getArchetype(entityRecord) @@ -582,8 +582,8 @@ func addComponentsToArchetype2[A, B ComponentInterface](world *World, entityReco return fmt.Errorf("no storage found for component %v", componentsIds) } - // If the entity has no component, simply add it the archetype - if entityRecord.archetypeId == 0 { + // An unplaced entity (fresh slot) is listed in no archetype yet: just place it. + if entityRecord.key < 0 { world.setArchetype(entityRecord, archetype) } else { oldArchetype := world.getArchetype(entityRecord) @@ -609,8 +609,8 @@ func addComponentsToArchetype3[A, B, C ComponentInterface](world *World, entityR return fmt.Errorf("no storage found for components %v", componentsIds) } - // If the entity has no component, simply add it the archetype - if entityRecord.archetypeId == 0 { + // An unplaced entity (fresh slot) is listed in no archetype yet: just place it. + if entityRecord.key < 0 { world.setArchetype(entityRecord, archetype) } else { oldArchetype := world.getArchetype(entityRecord) @@ -638,8 +638,8 @@ func addComponentsToArchetype4[A, B, C, D ComponentInterface](world *World, enti return fmt.Errorf("no storage found for components %v", componentsIds) } - // If the entity has no component, simply add it the archetype - if entityRecord.archetypeId == 0 { + // An unplaced entity (fresh slot) is listed in no archetype yet: just place it. + if entityRecord.key < 0 { world.setArchetype(entityRecord, archetype) } else { oldArchetype := world.getArchetype(entityRecord) @@ -669,8 +669,8 @@ func addComponentsToArchetype5[A, B, C, D, E ComponentInterface](world *World, e return fmt.Errorf("no storage found for components %v", componentsIds) } - // If the entity has no component, simply add it the archetype - if entityRecord.archetypeId == 0 { + // An unplaced entity (fresh slot) is listed in no archetype yet: just place it. + if entityRecord.key < 0 { world.setArchetype(entityRecord, archetype) } else { oldArchetype := world.getArchetype(entityRecord) @@ -702,8 +702,8 @@ func addComponentsToArchetype6[A, B, C, D, E, F ComponentInterface](world *World return fmt.Errorf("no storage found for components %v", componentsIds) } - // If the entity has no component, simply add it the archetype - if entityRecord.archetypeId == 0 { + // An unplaced entity (fresh slot) is listed in no archetype yet: just place it. + if entityRecord.key < 0 { world.setArchetype(entityRecord, archetype) } else { oldArchetype := world.getArchetype(entityRecord) @@ -737,8 +737,8 @@ func addComponentsToArchetype7[A, B, C, D, E, F, G ComponentInterface](world *Wo return fmt.Errorf("no storage found for components %v", componentsIds) } - // If the entity has no component, simply add it the archetype - if entityRecord.archetypeId == 0 { + // An unplaced entity (fresh slot) is listed in no archetype yet: just place it. + if entityRecord.key < 0 { world.setArchetype(entityRecord, archetype) } else { oldArchetype := world.getArchetype(entityRecord) @@ -774,8 +774,8 @@ func addComponentsToArchetype8[A, B, C, D, E, F, G, H ComponentInterface](world return fmt.Errorf("no storage found for components %v", componentsIds) } - // If the entity has no component, simply add it the archetype - if entityRecord.archetypeId == 0 { + // An unplaced entity (fresh slot) is listed in no archetype yet: just place it. + if entityRecord.key < 0 { world.setArchetype(entityRecord, archetype) } else { oldArchetype := world.getArchetype(entityRecord) @@ -820,9 +820,9 @@ func moveComponentsToArchetype(world *World, entityRecord entityRecord, oldArche lastEntityKey = len(oldArchetype.entities) - 1 lastEntityId := oldArchetype.entities[lastEntityKey] - lastEntity := world.entities[lastEntityId] + lastEntity := world.entities[lastEntityId.Index()] lastEntity.key = entityRecord.key - world.entities[lastEntityId] = lastEntity + world.entities[lastEntityId.Index()] = lastEntity oldArchetype.entities[entityRecord.key] = lastEntityId oldArchetype.entities = oldArchetype.entities[:lastEntityKey] diff --git a/generational_test.go b/generational_test.go new file mode 100644 index 0000000..64c7d01 --- /dev/null +++ b/generational_test.go @@ -0,0 +1,164 @@ +package volt + +import ( + "math" + "testing" +) + +// TestNullHandle: the zero EntityId is the null handle; it never exists, and no +// live entity is ever bound to it (generations start at 1). +func TestNullHandle(t *testing.T) { + world := CreateWorld(16) + + var null EntityId + if !null.IsNull() { + t.Fatal("the zero EntityId should be the null handle") + } + if world.Exists(null) { + t.Fatal("the null handle should not exist") + } + + e := world.CreateEntity() + if e.IsNull() { + t.Fatal("the first created entity should not be the null handle") + } + if e.Index() != 0 || e.Generation() != 1 { + t.Fatalf("the first entity should be slot 0 generation 1, got slot %d generation %d", e.Index(), e.Generation()) + } +} + +// TestGenerationalHandles: a handle kept after RemoveEntity is dead, even +// once its slot is reused by a new entity: every API refuses it and never +// answers for the slot's new occupant. +func TestGenerationalHandles(t *testing.T) { + world := CreateWorld(16) + RegisterComponent[testComponent1](world, &ComponentConfig[testComponent1]{}) + + e := world.CreateEntity() + if err := AddComponent[testComponent1](world, e, testComponent1{}); err != nil { + t.Fatalf("%s", err.Error()) + } + world.RemoveEntity(e) + + f := world.CreateEntity() + if f.Index() != e.Index() { + t.Fatalf("the freed slot should be recycled: got slot %d, want %d", f.Index(), e.Index()) + } + if f == e { + t.Fatal("a recycled slot must yield a distinct handle") + } + if f.Generation() != e.Generation()+1 { + t.Fatalf("recycling should bump the generation: got %d, want %d", f.Generation(), e.Generation()+1) + } + if err := AddComponent[testComponent1](world, f, testComponent1{}); err != nil { + t.Fatalf("%s", err.Error()) + } + + // The dead handle answers nothing about the new occupant. + if world.Exists(e) { + t.Fatal("a dead handle should not exist") + } + if world.HasComponents(e, testComponent1Id) { + t.Fatal("a dead handle should not report the new occupant's components") + } + if GetComponent[testComponent1](world, e) != nil { + t.Fatal("GetComponent through a dead handle should return nil") + } + if _, err := world.GetComponent(e, testComponent1Id); err == nil { + t.Fatal("World.GetComponent through a dead handle should return an error") + } + if err := AddComponent[testComponent1](world, e, testComponent1{}); err == nil { + t.Fatal("AddComponent through a dead handle should return an error") + } + if err := RemoveComponent[testComponent1](world, e); err == nil { + t.Fatal("RemoveComponent through a dead handle should return an error") + } + if err := world.AddTag(TAGS_INDICES, e); err == nil { + t.Fatal("AddTag through a dead handle should return an error") + } + if world.HasTag(TAGS_INDICES, e) { + t.Fatal("a dead handle should not report tags") + } + if err := world.RemoveTag(TAGS_INDICES, e); err == nil { + t.Fatal("RemoveTag through a dead handle should return an error") + } + + // Removing a dead handle is a no-op: the new occupant is untouched. + world.RemoveEntity(e) + if !world.Exists(f) { + t.Fatal("removing a dead handle must not remove the slot's new occupant") + } + if GetComponent[testComponent1](world, f) == nil { + t.Fatal("the new occupant should still own its component") + } + if world.Count() != 1 { + t.Fatalf("world should count 1 entity, got %d", world.Count()) + } +} + +// TestGenerationCycles: a million remove/create cycles on the same slot +// never hand out a handle colliding with a previous one, and never revive a +// dead handle. +func TestGenerationCycles(t *testing.T) { + world := CreateWorld(16) + + e := world.CreateEntity() + for i := range 1_000_000 { + world.RemoveEntity(e) + if world.Exists(e) { + t.Fatalf("cycle %d: the removed handle still exists", i) + } + + f := world.CreateEntity() + if f == e || f.Index() != e.Index() || f.Generation() != e.Generation()+1 { + t.Fatalf("cycle %d: expected slot %d generation %d, got slot %d generation %d", i, e.Index(), e.Generation()+1, f.Index(), f.Generation()) + } + if world.Exists(e) { + t.Fatalf("cycle %d: the dead handle was revived by the slot's new occupant", i) + } + + e = f + } + + if world.Count() != 1 { + t.Fatalf("world should count 1 entity, got %d", world.Count()) + } +} + +// TestGenerationWrapSkipsNull: bumping the last generation wraps to 1, not 0, +// so the null handle can never be minted. +func TestGenerationWrapSkipsNull(t *testing.T) { + if got := nextGeneration(1); got != 2 { + t.Fatalf("nextGeneration(1) = %d, want 2", got) + } + if got := nextGeneration(math.MaxUint32); got != 1 { + t.Fatalf("nextGeneration(MaxUint32) = %d, want 1 (0 is reserved for the null handle)", got) + } +} + +// TestFailedCreationGivesBackTheSlot: a creation that fails leaves no +// half-placed entity behind, returns the null handle, and its slot is reused. +func TestFailedCreationGivesBackTheSlot(t *testing.T) { + world := CreateWorld(16) + // testComponent2 is not registered: the creation cannot find its storage. + RegisterComponent[testComponent1](world, &ComponentConfig[testComponent1]{}) + + e, err := CreateEntityWithComponents2(world, testComponent1{}, testComponent2{}) + if err == nil { + t.Fatal("creating with an unregistered component should fail") + } + if !e.IsNull() { + t.Fatalf("a failed creation should return the null handle, got %d", e) + } + if world.Count() != 0 { + t.Fatalf("a failed creation should leave the world empty, got %d", world.Count()) + } + + f := world.CreateEntity() + if f.Index() != 0 { + t.Fatalf("the discarded slot should be reused, got slot %d", f.Index()) + } + if !world.Exists(f) { + t.Fatal("the entity created in the reused slot should exist") + } +} diff --git a/placement_test.go b/placement_test.go new file mode 100644 index 0000000..8d4baa6 --- /dev/null +++ b/placement_test.go @@ -0,0 +1,62 @@ +package volt + +import "testing" + +// TestEmptyArchetypeReleasesEntityOnAddComponent: an entity created bare is +// listed in the empty archetype; adding a component must move it out through a +// swap-remove, not leave a stale entry behind. A stale entry leaks and is +// visible to any query whose filter matches the empty archetype (e.g. a query +// with only optional components), which then reports the entity twice. +func TestEmptyArchetypeReleasesEntityOnAddComponent(t *testing.T) { + world := CreateWorld(16) + RegisterComponent[testComponent1](world, &ComponentConfig[testComponent1]{}) + + e := world.CreateEntity() + if got := len(world.archetypes[0].entities); got != 1 { + t.Fatalf("bare entity should be listed once in the empty archetype, got %d", got) + } + + if err := AddComponent[testComponent1](world, e, testComponent1{}); err != nil { + t.Fatalf("%s", err.Error()) + } + + if got := len(world.archetypes[0].entities); got != 0 { + t.Fatalf("empty archetype still lists %d entity(ies) after the component was added: stale entry", got) + } + + query := CreateQuery1[testComponent1](world, QueryConfiguration{OptionalComponents: []OptionalComponent{testComponent1Id}}) + if n := query.Count(); n != 1 { + t.Fatalf("optional-only query counted %d results for a single entity (stale entry in the empty archetype)", n) + } +} + +// TestStaleEntryCorruptsLiveEntity: the stale entry is worse than a leak. When +// the empty archetype later swap-removes one of its entities, the stale entry +// may be the one moved, and the swap rewrites the key of the entity it names — +// an entity that now lives in another archetype, whose row then aliases a +// neighbour's. +func TestStaleEntryCorruptsLiveEntity(t *testing.T) { + world := CreateWorld(16) + RegisterComponent[testComponent1](world, &ComponentConfig[testComponent1]{}) + + a := world.CreateEntity() + b := world.CreateEntity() + if err := AddComponent[testComponent1](world, b, testComponent1{}); err != nil { + t.Fatal(err) + } + c := world.CreateEntity() + if err := AddComponent[testComponent1](world, c, testComponent1{}); err != nil { + t.Fatal(err) + } + // {C1}: [b, c]. With a stale entry, the empty archetype would still list b and c + // behind a, and removing a would move the stale c onto a's row, rewriting c's key. + world.RemoveEntity(a) + + if got := world.entities[c.Index()].key; got != 1 { + t.Fatalf("live entity c should still be at key 1 in its archetype, got key %d", got) + } + GetComponent[testComponent1](world, c).x = 42 + if GetComponent[testComponent1](world, b).x == 42 { + t.Fatal("writing c's component wrote into b's: rows are aliased") + } +} diff --git a/pool.go b/pool.go index b418877..c6ff568 100644 --- a/pool.go +++ b/pool.go @@ -1,27 +1,34 @@ package volt +// pool hands out entity slots. A slot is the index part of an EntityId; the +// generation that keeps a recycled slot's handle unique lives in the entity +// record, not here. Freed slots are reused LIFO before any new slot is opened. type pool struct { - ids []EntityId - next EntityId + free []uint32 + next uint32 } -func (pool *pool) Get() EntityId { - var entityId EntityId - if len(pool.ids) > 0 { - entityId = pool.ids[len(pool.ids)-1] - pool.ids = pool.ids[:len(pool.ids)-1] - } else { - entityId = pool.next - pool.next++ +// Get returns a slot, and whether it is recycled (previously freed) or brand new. +func (pool *pool) Get() (index uint32, recycled bool) { + if n := len(pool.free); n > 0 { + index = pool.free[n-1] + pool.free = pool.free[:n-1] + + return index, true } - return entityId + index = pool.next + pool.next++ + + return index, false } -func (pool *pool) Recycle(id EntityId) { - pool.ids = append(pool.ids, id) +// Recycle gives a slot back, to be reused by the next Get. +func (pool *pool) Recycle(index uint32) { + pool.free = append(pool.free, index) } +// Count returns the number of freed slots waiting to be recycled. func (pool *pool) Count() int { - return len(pool.ids) + return len(pool.free) } diff --git a/register.go b/register.go index dca8abf..5a6f4a6 100644 --- a/register.go +++ b/register.go @@ -35,7 +35,7 @@ func (componentConfig *ComponentConfig[T]) addComponent(world *World, entityId E var t T componentConfig.builderFn(&t, configuration) - entityRecord := world.entities[entityId] + entityRecord := world.entities[entityId.Index()] archetype := world.getNextArchetype(entityRecord, componentConfig.id) err := addComponentsToArchetype1[T](world, entityRecord, archetype, t) diff --git a/tag.go b/tag.go index 31e5550..8de6cb5 100644 --- a/tag.go +++ b/tag.go @@ -26,7 +26,7 @@ func (world *World) AddTag(tagId TagId, entityId EntityId) error { return fmt.Errorf("the entity %d already owns the tag %d", entityId, tagId) } - entityRecord := world.entities[entityId] + entityRecord := world.entities[entityId.Index()] archetype := world.getNextArchetype(entityRecord, tagId) oldArchetype := world.getArchetype(entityRecord) @@ -43,7 +43,7 @@ func (world *World) HasTag(tagId TagId, entityId EntityId) bool { if !world.Exists(entityId) { return false } - entityRecord := world.entities[entityId] + entityRecord := world.entities[entityId.Index()] return world.hasComponents(entityRecord, tagId) } @@ -56,7 +56,7 @@ func (world *World) RemoveTag(tagId TagId, entityId EntityId) error { if !world.Exists(entityId) { return fmt.Errorf("the entity %d does not exist", entityId) } - entityRecord := world.entities[entityId] + entityRecord := world.entities[entityId.Index()] if !world.HasTag(tagId, entityId) { return fmt.Errorf("the entity %d doesn't own the tag %d", entityId, tagId) diff --git a/tag_test.go b/tag_test.go index f451178..4f5332e 100644 --- a/tag_test.go +++ b/tag_test.go @@ -142,12 +142,12 @@ func TestRemoveTag(t *testing.T) { t.Errorf("entities should not have the tag %d", TAG_1) } - err := world.RemoveTag(TAG_1, 0) - if err == nil { - t.Errorf("we should not be able to remove the tag %d from an unexisting entity %d", TAG_1, entities[0]) + // EntityId(0) is the null handle: it never refers to a live entity. + if err := world.RemoveTag(TAG_1, 0); err == nil { + t.Fatalf("we should not be able to remove the tag %d from the null handle", TAG_1) } - err = world.RemoveTag(TAG_1, entities[0]) + err := world.RemoveTag(TAG_1, entities[0]) if err == nil { t.Errorf("we should not be able to remove the tag %d from the entity %d that does not own it", TAG_1, entities[0]) } diff --git a/world.go b/world.go index e2a8d3b..e527c71 100644 --- a/world.go +++ b/world.go @@ -7,9 +7,51 @@ type smallId uint16 // uint64 identifier, for big scoped data. type id uint64 -// Entity identifier in the world. +// Entity identifier in the world: a handle packing the entity's slot (index, +// low 32 bits) and the generation of that slot (high 32 bits). Generations +// start at 1 and are bumped every time the slot is freed by RemoveEntity, so a +// handle kept after its entity was removed never aliases the slot's next +// occupant: it is simply dead. The zero value is the null handle, which never +// refers to a live entity. type EntityId id +const ( + entityIndexBits = 32 + entityIndexMask = EntityId(1)<> entityIndexBits) +} + +// IsNull reports whether entityId is the null handle, which never refers to a +// live entity. It is the zero value of EntityId, so an unset field is null. +func (entityId EntityId) IsNull() bool { + return entityId == 0 +} + +func newEntityId(index uint32, generation uint32) EntityId { + return EntityId(generation)<= 0 { lastEntityId := world.archetypes[archetype.Id].entities[lastEntityKey] - lastEntity := world.entities[lastEntityId] - if lastEntity.key > entityRecord.key { - lastEntity.key = entityRecord.key - world.entities[lastEntityId] = lastEntity - archetype.entities[entityRecord.key] = lastEntityId + lastEntity := world.entities[lastEntityId.Index()] + if lastEntity.key > record.key { + lastEntity.key = record.key + world.entities[lastEntityId.Index()] = lastEntity + archetype.entities[record.key] = lastEntityId } archetype.entities = archetype.entities[:lastEntityKey] world.archetypes[archetype.Id] = archetype } - // Tombstone the slot: a negative key marks the id as free until it is - // recycled, so Has/Get/Exists no longer report stale data for it. - world.entities[entityId].key = -1 - world.pool.Recycle(entityId) + // Free the slot: the tombstone (negative key) and the bumped generation + // together guarantee that no handle, kept or forged, resolves to it until a + // new entity is created there with the new generation. + world.entities[index] = entityRecord{ + Id: newEntityId(index, nextGeneration(entityId.Generation())), + key: -1, + } + world.pool.Recycle(index) } // Exists reports whether entityId refers to a live entity of the World. // -// It returns false for ids that were never created, or that have been removed -// and not yet recycled into a new entity. A negative key is the tombstone left -// behind by RemoveEntity. +// It returns false for the null handle, for slots never opened, and for dead +// handles: entities removed, whether or not their slot has since been recycled +// by a new entity (the generation tells them apart). func (world *World) Exists(entityId EntityId) bool { - return int(entityId) < len(world.entities) && world.entities[entityId].key >= 0 + index := entityId.Index() + if int(index) >= len(world.entities) { + return false + } + + record := &world.entities[index] + + return record.Id == entityId && record.key >= 0 } // Count returns the number of entities in World. diff --git a/world_test.go b/world_test.go index 84c4661..79905ef 100644 --- a/world_test.go +++ b/world_test.go @@ -256,10 +256,10 @@ func TestWorld_RemoveEntity(t *testing.T) { world.RemoveEntity(entities[TEST_ENTITY_NUMBER/2]) world.RemoveEntity(entities[TEST_ENTITY_NUMBER-1]) - // Check if the entities are correctly removed of the world - for _, id := range []EntityId{0, TEST_ENTITY_NUMBER / 2, TEST_ENTITY_NUMBER - 1} { - if !slices.Contains(world.pool.ids, id) { - t.Errorf("Entity %d was not removed", entities[id]) + // Check if the entities are correctly removed of the world: their slots are back in the pool. + for _, i := range []int{0, TEST_ENTITY_NUMBER / 2, TEST_ENTITY_NUMBER - 1} { + if !slices.Contains(world.pool.free, entities[i].Index()) { + t.Errorf("Entity %d was not removed", entities[i]) } } } @@ -305,13 +305,13 @@ func TestEntityLiveness(t *testing.T) { t.Fatal("removed entity should not report owning a component") } if GetComponent[testComponent1](world, e) != nil { - t.Fatal("removed entity should not return a component pointer") + t.Fatal("GetComponent through a dead handle should return nil") } if err := AddComponent[testComponent1](world, e, testComponent1{}); err == nil { - t.Fatal("adding a component to a removed entity should error") + t.Fatal("AddComponent through a dead handle should return an error") } if err := RemoveComponent[testComponent1](world, e); err == nil { - t.Fatal("removing a component from a removed entity should error") + t.Fatal("RemoveComponent through a dead handle should return an error") } // Never-created ids (within and beyond the preallocated capacity) don't exist.