Repository: mlange-42/arche
Pull request #77 (2023-02-14 19:37:06)

Change notifications
* Adds the option to register a function to get notified on entity or component changes


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9e77463..3b11128 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@
 * `Query` and `World` have a method `Mask(Entity)` to access archetype masks (#65)
 * Added method `World.Stats()` for inspecting otherwise inaccessible world statistics (#67)
 * Entities can be initialized with components, via ID as well as using generics (#76)
+* A listener function can be registered to the world, for notification on entity changes (#77)
 
 ### Other
 
diff --git a/ecs/archetype.go b/ecs/archetype.go
index aedff73..8636e80 100644
--- a/ecs/archetype.go
+++ b/ecs/archetype.go
@@ -23,7 +23,9 @@ var entityType = reflect.TypeOf(Entity{})
 // Init initializes an archetype
 func (a *archetype) Init(capacityIncrement int, components ...componentType) {
 	var mask BitMask
-	a.Ids = make([]ID, len(components))
+	if len(components) > 0 {
+		a.Ids = make([]ID, len(components))
+	}
 	comps := make([]storage, len(components))
 
 	prev := -1
diff --git a/ecs/world.go b/ecs/world.go
index 286a07d..723747d 100644
--- a/ecs/world.go
+++ b/ecs/world.go
@@ -1,12 +1,27 @@
 package ecs
 
 import (
+	"fmt"
 	"reflect"
 	"unsafe"
 
 	"github.com/mlange-42/arche/ecs/stats"
 )
 
+// ChangeEvent contains information about component changes.
+//
+// To receive change events, register a function func(e ChangeEvent) with [World.RegisterListener].
+type ChangeEvent struct {
+	// The entity that was changed.
+	Entity Entity
+	// The old and new component masks.
+	OldMask, NewMask BitMask
+	// Components added, removed, and after the change.
+	Added, Removed, Current []ID
+	// Whether the entity itself was added (> 0), removed (< 0), or only changed (= 0).
+	AddedRemoved int
+}
+
 // ComponentID returns the ID for a component type via generics. Registers the type if it is not already registered.
 func ComponentID[T any](w *World) ID {
 	tp := reflect.TypeOf((*T)(nil)).Elem()
@@ -27,6 +42,7 @@ type World struct {
 	bitPool    bitPool
 	registry   componentRegistry
 	locks      BitMask
+	listener   func(e ChangeEvent)
 }
 
 // NewWorld creates a new [World]
@@ -44,6 +60,7 @@ func FromConfig(conf Config) World {
 		registry:   newComponentRegistry(),
 		archetypes: pagedArr32[archetype]{},
 		locks:      BitMask(0),
+		listener:   nil,
 	}
 	w.createArchetype(0)
 	return w
@@ -76,6 +93,10 @@ func (w *World) NewEntity(comps ...ID) Entity {
 	} else {
 		w.entities[entity.id] = entityIndex{arch: arch, index: idx}
 	}
+
+	if w.listener != nil {
+		w.listener(ChangeEvent{entity, 0, arch.Mask, comps, nil, arch.Ids, 1})
+	}
 	return entity
 }
 
@@ -117,6 +138,9 @@ func (w *World) NewEntityWith(comps ...Component) Entity {
 		w.copyTo(entity, c.ID, c.Component)
 	}
 
+	if w.listener != nil {
+		w.listener(ChangeEvent{entity, 0, arch.Mask, ids, nil, arch.Ids, 1})
+	}
 	return entity
 }
 
@@ -129,6 +153,11 @@ func (w *World) RemEntity(entity Entity) {
 
 	index := w.entities[entity.id]
 	oldArch := index.arch
+
+	if w.listener != nil {
+		w.listener(ChangeEvent{entity, oldArch.Mask, oldArch.Mask, nil, nil, oldArch.Ids, -1})
+	}
+
 	swapped := oldArch.Remove(index.index)
 
 	w.entityPool.Recycle(entity)
@@ -202,7 +231,7 @@ func (w *World) Mask(entity Entity) BitMask {
 //
 // See also the generic variants [github.com/mlange-42/arche/generic.Add1], [github.com/mlange-42/arche/generic.Add2], [github.com/mlange-42/arche/generic.Add3], ...
 func (w *World) Add(entity Entity, comps ...ID) {
-	w.Exchange(entity, comps, []ID{})
+	w.Exchange(entity, comps, nil)
 }
 
 // Assign assigns a component to an [Entity], using a given pointer for the content.
@@ -218,7 +247,7 @@ func (w *World) Add(entity Entity, comps ...ID) {
 //
 // See also the generic variants [github.com/mlange-42/arche/generic.Assign1], [github.com/mlange-42/arche/generic.Assign2], [github.com/mlange-42/arche/generic.Assign3], ...
 func (w *World) Assign(entity Entity, id ID, comp interface{}) unsafe.Pointer {
-	w.Exchange(entity, []ID{id}, []ID{})
+	w.Exchange(entity, []ID{id}, nil)
 	return w.copyTo(entity, id, comp)
 }
 
@@ -254,7 +283,7 @@ func (w *World) AssignN(entity Entity, comps ...Component) {
 	for i, c := range comps {
 		ids[i] = c.ID
 	}
-	w.Exchange(entity, ids, []ID{})
+	w.Exchange(entity, ids, nil)
 	for _, c := range comps {
 		w.copyTo(entity, c.ID, c.Component)
 	}
@@ -268,7 +297,7 @@ func (w *World) AssignN(entity Entity, comps ...Component) {
 //
 // See also the generic variants [github.com/mlange-42/arche/generic.Remove1], [github.com/mlange-42/arche/generic.Remove2], [github.com/mlange-42/arche/generic.Remove3], ...
 func (w *World) Remove(entity Entity, comps ...ID) {
-	w.Exchange(entity, []ID{}, comps)
+	w.Exchange(entity, nil, comps)
 }
 
 // Exchange adds and removes components in one pass
@@ -286,14 +315,15 @@ func (w *World) Exchange(entity Entity, add []ID, rem []ID) {
 	index := w.entities[entity.id]
 	oldArch := index.arch
 	mask := oldArch.Mask
+	oldMask := mask
 	for _, comp := range add {
-		if mask.Get(comp) {
+		if oldMask.Get(comp) {
 			panic("entity already has this component, can't add")
 		}
 		mask.Set(comp, true)
 	}
 	for _, comp := range rem {
-		if !mask.Get(comp) {
+		if !oldMask.Get(comp) {
 			panic("entity does not have this component, can't remove")
 		}
 		mask.Set(comp, false)
@@ -306,21 +336,15 @@ func (w *World) Exchange(entity Entity, add []ID, rem []ID) {
 			keepIDs = append(keepIDs, id)
 		}
 	}
-	addIDs := make([]ID, 0, len(add))
-	for _, id := range add {
-		if mask.Get(id) {
-			addIDs = append(addIDs, id)
-		}
-	}
 
-	arch := w.findOrCreateArchetype(oldArch, addIDs, rem)
+	arch := w.findOrCreateArchetype(oldArch, add, rem)
 
-	allComps := make([]componentPointer, 0, len(keepIDs)+len(addIDs))
+	allComps := make([]componentPointer, 0, len(keepIDs)+len(add))
 	for _, id := range keepIDs {
 		comp := oldArch.Get(index.index, id)
 		allComps = append(allComps, componentPointer{ID: id, Pointer: comp})
 	}
-	for _, id := range addIDs {
+	for _, id := range add {
 		allComps = append(allComps, componentPointer{ID: id, Pointer: nil})
 	}
 
@@ -332,6 +356,10 @@ func (w *World) Exchange(entity Entity, add []ID, rem []ID) {
 		w.entities[swapEntity.id].index = index.index
 	}
 	w.entities[entity.id] = entityIndex{arch: arch, index: newIndex}
+
+	if w.listener != nil {
+		w.listener(ChangeEvent{entity, oldMask, arch.Mask, add, rem, arch.Ids, 0})
+	}
 }
 
 // IsLocked returns whether the world is locked by any queries.
@@ -339,6 +367,20 @@ func (w *World) IsLocked() bool {
 	return w.locks != 0
 }
 
+// RegisterListener registers a func(e ChangeEvent) to the world.
+// The listener function is immediately called on every entity change.
+//
+// Events notified are entity creation, removal and changes to the component composition.
+//
+// Returns an error if there is already a listener registered.
+func (w *World) RegisterListener(listener func(e ChangeEvent)) error {
+	if w.listener != nil {
+		return fmt.Errorf("the world already has a change listener registered")
+	}
+	w.listener = listener
+	return nil
+}
+
 // Stats reports statistics for inspecting the World.
 func (w *World) Stats() *stats.WorldStats {
 	entities := stats.EntityStats{


Test patch:
diff --git a/benchmark/add/arche_test.go b/benchmark/add/arche_test.go
index b052737..447e603 100644
--- a/benchmark/add/arche_test.go
+++ b/benchmark/add/arche_test.go
@@ -17,8 +17,7 @@ func addArcheWorld(b *testing.B, count int) {
 		b.StartTimer()
 
 		for i := 0; i < count; i++ {
-			entity := world.NewEntity()
-			world.Add(entity, posID, rotID)
+			_ = world.NewEntity(posID, rotID)
 		}
 	}
 }
@@ -30,8 +29,7 @@ func addArcheGeneric(b *testing.B, count int) {
 		b.StartTimer()
 
 		for i := 0; i < count; i++ {
-			entity := world.NewEntity()
-			generic.Add1[position](&world, entity)
+			_, _, _ = generic.NewEntity2[position, rotation](&world)
 		}
 	}
 }
diff --git a/benchmark/iterate/arche_test.go b/benchmark/iterate/arche_test.go
index 6c9d3a9..ae79833 100644
--- a/benchmark/iterate/arche_test.go
+++ b/benchmark/iterate/arche_test.go
@@ -16,8 +16,7 @@ func runArcheQuery(b *testing.B, count int) {
 	rotID := ecs.ComponentID[rotation](&world)
 
 	for i := 0; i < count; i++ {
-		entity := world.NewEntity()
-		world.Add(entity, posID, rotID)
+		_ = world.NewEntity(posID, rotID)
 	}
 	b.StartTimer()
 
@@ -40,8 +39,7 @@ func runArcheFilter(b *testing.B, count int) {
 	rotID := ecs.ComponentID[rotation](&world)
 
 	for i := 0; i < count; i++ {
-		entity := world.NewEntity()
-		world.Add(entity, posID, rotID)
+		_ = world.NewEntity(posID, rotID)
 	}
 	b.StartTimer()
 
@@ -64,8 +62,7 @@ func runArcheQueryGeneric(b *testing.B, count int) {
 	rotID := ecs.ComponentID[rotation](&world)
 
 	for i := 0; i < count; i++ {
-		entity := world.NewEntity()
-		world.Add(entity, posID, rotID)
+		_ = world.NewEntity(posID, rotID)
 	}
 	query := generic.NewFilter2[position, rotation]()
 	b.StartTimer()
@@ -92,8 +89,7 @@ func runArcheQuery5C(b *testing.B, count int) {
 	id4 := ecs.ComponentID[testStruct4](&world)
 
 	for i := 0; i < count; i++ {
-		entity := world.NewEntity()
-		world.Add(entity, id0, id1, id2, id3, id4)
+		_ = world.NewEntity(id0, id1, id2, id3, id4)
 	}
 	b.StartTimer()
 
@@ -123,8 +119,7 @@ func runArcheQueryGeneric5C(b *testing.B, count int) {
 	id4 := ecs.ComponentID[testStruct4](&world)
 
 	for i := 0; i < count; i++ {
-		entity := world.NewEntity()
-		world.Add(entity, id0, id1, id2, id3, id4)
+		_ = world.NewEntity(id0, id1, id2, id3, id4)
 	}
 
 	query := generic.NewFilter5[testStruct0, testStruct1, testStruct2, testStruct3, testStruct4]()
@@ -226,8 +221,7 @@ func runArcheWorld(b *testing.B, count int) {
 	rotID := ecs.ComponentID[rotation](&world)
 
 	for i := 0; i < count; i++ {
-		entity := world.NewEntity()
-		world.Add(entity, posID, rotID)
+		_ = world.NewEntity(posID, rotID)
 	}
 	b.StartTimer()
 
@@ -253,8 +247,7 @@ func runArcheWorldGeneric(b *testing.B, count int) {
 	get := generic.NewMap[position](&world)
 
 	for i := 0; i < count; i++ {
-		entity := world.NewEntity()
-		world.Add(entity, posID, rotID)
+		_ = world.NewEntity(posID, rotID)
 	}
 	b.StartTimer()
 
diff --git a/ecs/world_test.go b/ecs/world_test.go
index 1818a58..f583447 100644
--- a/ecs/world_test.go
+++ b/ecs/world_test.go
@@ -420,6 +420,90 @@ func TestArchetypeGraph(t *testing.T) {
 	assert.Equal(t, archEmpty, archEmpty3)
 }
 
+func TestWorldListener(t *testing.T) {
+	events := []ChangeEvent{}
+	listen := func(e ChangeEvent) {
+		events = append(events, e)
+	}
+
+	w := NewWorld()
+
+	err := w.RegisterListener(listen)
+	assert.Nil(t, err)
+
+	err = w.RegisterListener(listen)
+	assert.NotNil(t, err)
+
+	posID := ComponentID[position](&w)
+	velID := ComponentID[velocity](&w)
+	rotID := ComponentID[rotation](&w)
+
+	e0 := w.NewEntity()
+	assert.Equal(t, 1, len(events))
+	assert.Equal(t, ChangeEvent{
+		Entity: e0, AddedRemoved: 1,
+	}, events[len(events)-1])
+
+	w.RemEntity(e0)
+	assert.Equal(t, 2, len(events))
+	assert.Equal(t, ChangeEvent{
+		Entity: e0, AddedRemoved: -1,
+	}, events[len(events)-1])
+
+	e0 = w.NewEntity(posID, velID)
+	assert.Equal(t, 3, len(events))
+	assert.Equal(t, ChangeEvent{
+		Entity:       e0,
+		NewMask:      NewBitMask(posID, velID),
+		Added:        []ID{posID, velID},
+		Current:      []ID{posID, velID},
+		AddedRemoved: 1,
+	}, events[len(events)-1])
+
+	w.RemEntity(e0)
+	assert.Equal(t, 4, len(events))
+	assert.Equal(t, ChangeEvent{
+		Entity:       e0,
+		OldMask:      NewBitMask(posID, velID),
+		NewMask:      NewBitMask(posID, velID),
+		Current:      []ID{posID, velID},
+		AddedRemoved: -1,
+	}, events[len(events)-1])
+
+	e0 = w.NewEntityWith(Component{posID, &position{}}, Component{velID, &velocity{}})
+	assert.Equal(t, 5, len(events))
+	assert.Equal(t, ChangeEvent{
+		Entity:       e0,
+		NewMask:      NewBitMask(posID, velID),
+		Added:        []ID{posID, velID},
+		Current:      []ID{posID, velID},
+		AddedRemoved: 1,
+	}, events[len(events)-1])
+
+	w.Add(e0, rotID)
+	assert.Equal(t, 6, len(events))
+	assert.Equal(t, ChangeEvent{
+		Entity:       e0,
+		OldMask:      NewBitMask(posID, velID),
+		NewMask:      NewBitMask(posID, velID, rotID),
+		Added:        []ID{rotID},
+		Current:      []ID{posID, velID, rotID},
+		AddedRemoved: 0,
+	}, events[len(events)-1])
+
+	w.Remove(e0, posID)
+	assert.Equal(t, 7, len(events))
+	assert.Equal(t, ChangeEvent{
+		Entity:       e0,
+		OldMask:      NewBitMask(posID, velID, rotID),
+		NewMask:      NewBitMask(velID, rotID),
+		Removed:      []ID{posID},
+		Current:      []ID{velID, rotID},
+		AddedRemoved: 0,
+	}, events[len(events)-1])
+
+}
+
 func Test1000Archetypes(t *testing.T) {
 	_ = testStruct0{1}
 	_ = testStruct1{1}