From 8b1ed9156885c267b0dd677e6b2d97fe8489ab8b Mon Sep 17 00:00:00 2001 From: Martin Lange <44003176+mlange-42@users.noreply.github.com> Date: Tue, 16 Jan 2024 15:54:42 +0100 Subject: [PATCH] Listener subscriptions (#334) * Replaces some fields of `EntityEvent` by a bitmask of triggered event types * Allows filtering events by a subscriptions mask * Introduces a `Listener` interface * Splits out subscriptions and default `Listener` implementation into packages # Commits * implement simple listener with subscription for different events * use a bit mask to encode event types and subscriptions * remove redundant event fields * move subscriptions and default listener to separate packages * run CI on merge requests against any branch * tweak listener description, add example * add benchmarks for having a listener that does not subscribe * update changelog * replace EntityEvent getters by a Contains method, tweak EntityEvent docs * add a dispatched listener implementation --- .github/workflows/tests.yml | 2 - CHANGELOG.md | 3 +- doc.go | 1 + ecs/batch.go | 6 + ecs/event.go | 66 +++-- ecs/event/event.go | 45 ++++ ecs/event/event_test.go | 18 ++ ecs/event_test.go | 50 ++-- ecs/util.go | 26 ++ ecs/world.go | 149 ++++++++---- ecs/world_benchmark_test.go | 128 +++++++++- ecs/world_examples_test.go | 35 ++- ecs/world_test.go | 398 +++++++++++++++---------------- examples/change_listener/main.go | 19 +- listener/callback.go | 32 +++ listener/callback_test.go | 38 +++ listener/dispatched.go | 44 ++++ listener/dispatched_test.go | 70 ++++++ listener/doc.go | 4 + 19 files changed, 822 insertions(+), 312 deletions(-) create mode 100644 ecs/event/event.go create mode 100644 ecs/event/event_test.go create mode 100644 listener/callback.go create mode 100644 listener/callback_test.go create mode 100644 listener/dispatched.go create mode 100644 listener/dispatched_test.go create mode 100644 listener/doc.go diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4fd42d25..ec6a5fec 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,8 +5,6 @@ on: branches: - main pull_request: - branches: - - main jobs: diff --git a/CHANGELOG.md b/CHANGELOG.md index 60091c34..a22f7a39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ * Arche supports full world serialization and deserialization, in conjunction with [arche-serde](https://github.com/mlange-42/arche-serde) (#319) * Supports 256 instead of 128 component types as well as resource types and engine locks (#313) * Generic API supports up to 12 instead of 8 component types (#324) +* Reworked event system with independent subscription for different event types (#333, #334) ### Breaking changes @@ -16,7 +17,7 @@ This change was necessary to get the same performance as before, despite the more heavyweight implementation of the now 256 bits `Mask`. * Component and resource IDs are now opaque types instead of type aliases for `uint8` (#329) * Restructures `EntityEvent` to remove redundant information and better handle relation changes (#333) -* World event listener function signature changed from `func(*EntityEvent)` to `func(EntityEvent)` (#333) +* World event listener changed from a simple function to a `Listener` interface (#334) ### Features diff --git a/doc.go b/doc.go index c590b609..3b38b96a 100644 --- a/doc.go +++ b/doc.go @@ -4,5 +4,6 @@ // - Core API -- [github.com/mlange-42/arche/ecs] // - Generic queries -- [github.com/mlange-42/arche/generic] // - Advanced filters -- [github.com/mlange-42/arche/filter] +// - Event listeners -- [github.com/mlange-42/arche/listener] // - Usage examples -- [github.com/mlange-42/arche/examples] package arche diff --git a/ecs/batch.go b/ecs/batch.go index 0d4e94a1..e708a742 100644 --- a/ecs/batch.go +++ b/ecs/batch.go @@ -55,6 +55,9 @@ func (b *Batch) RemoveQ(filter Filter, comps ...ID) Query { // SetRelation sets the [Relation] target for many entities, matching a filter. // +// Entities that match the filter but already have the desired target entity are not processed, +// and no events are emitted for them. +// // Panics: // - when called for a missing component. // - when called for a component that is not a relation. @@ -68,6 +71,9 @@ func (b *Batch) SetRelation(filter Filter, comp ID, target Entity) { // SetRelationQ sets the [Relation] target for many entities, matching a filter. // It returns a query over the affected entities. // +// Entities that match the filter but already have the desired target entity are not processed, +// not included in the query, and no events are emitted for them. +// // Panics: // - when called for a missing component. // - when called for a component that is not a relation. diff --git a/ecs/event.go b/ecs/event.go index e974a8e0..ebfa0a73 100644 --- a/ecs/event.go +++ b/ecs/event.go @@ -1,10 +1,18 @@ package ecs +import "github.com/mlange-42/arche/ecs/event" + // EntityEvent contains information about component and relation changes to an [Entity]. // -// To receive change events, register a function func(e *EntityEvent) with [World.SetListener]. +// To receive change events, register a [Listener] with [World.SetListener]. +// +// Events notified are entity creation and removal, component addition and removal, +// and change of relations and their targets. +// +// Event types that are subscribed are determined by [Listener.Subscriptions]. +// Events that cover multiple types (e.g. entity creation and component addition) are only notified once. +// Field EventTypes contains the [event.Subscription] bits of covered event types. // -// Events notified are entity creation, removal, changes to the component composition and change of relation targets. // Events are emitted immediately after the change is applied. // // Except for removed entities, events are always fired when the [World] is in an unlocked state. @@ -16,22 +24,48 @@ package ecs // For batch methods that return a [Query], events are fired after the [Query] is closed (or fully iterated). // This allows the [World] to be in an unlocked state, and notifies after potential entity initialization. type EntityEvent struct { - Entity Entity // The entity that was changed. - OldMask Mask // The old component masks. Get the new mask with [World.Mask]. - Added, Removed []ID // Components added and removed. DO NOT MODIFY! Get the current components with [World.Ids]. - OldRelation, NewRelation *ID // Old and new relation component ID. No relation is indicated by nil. - OldTarget Entity // Old relation target entity. Get the new target with [World.Relations] and [Relations.Get]. - AddedRemoved int8 // Whether the entity itself was added (> 0), removed (< 0), or only changed (= 0). - RelationChanged bool // Whether the relation component has changed. - TargetChanged bool // Whether the relation target has changed. Will be false if the relation component changes, but the target does not. + Entity Entity // The entity that was changed. + OldMask Mask // The old component masks. Get the new mask with [World.Mask]. + Added, Removed []ID // Components added and removed. DO NOT MODIFY! Get the current components with [World.Ids]. + OldRelation, NewRelation *ID // Old and new relation component ID. No relation is indicated by nil. + OldTarget Entity // Old relation target entity. Get the new target with [World.Relations] and [Relations.Get]. + EventTypes event.Subscription // Bit mask of event types. See [Subscription]. +} + +// Contains returns whether the event's types contain the given type/subscription bit. +func (e *EntityEvent) Contains(bit event.Subscription) bool { + return e.EventTypes.Contains(bit) +} + +// Listener interface for listening to [EntityEvent]s. +// See [EntityEvent] for details. +type Listener interface { + // Notify the listener about a subscribed event. + Notify(evt EntityEvent) + // Subscriptions to event types. + Subscriptions() event.Subscription +} + +// testListener for [EntityEvent]s. +type testListener struct { + Callback func(e EntityEvent) + Subscribe event.Subscription +} + +// newTestListener creates a new [CallbackListener] that subscribes to all event types. +func newTestListener(callback func(e EntityEvent)) testListener { + return testListener{ + Callback: callback, + Subscribe: event.EntityCreated | event.EntityRemoved | event.ComponentAdded | event.ComponentRemoved | event.RelationChanged | event.TargetChanged, + } } -// EntityAdded reports whether the entity was newly added. -func (e *EntityEvent) EntityAdded() bool { - return e.AddedRemoved > 0 +// Notify the listener +func (l *testListener) Notify(e EntityEvent) { + l.Callback(e) } -// EntityRemoved reports whether the entity was removed. -func (e *EntityEvent) EntityRemoved() bool { - return e.AddedRemoved < 0 +// Subscriptions of the listener +func (l *testListener) Subscriptions() event.Subscription { + return l.Subscribe } diff --git a/ecs/event/event.go b/ecs/event/event.go new file mode 100644 index 00000000..010e7d34 --- /dev/null +++ b/ecs/event/event.go @@ -0,0 +1,45 @@ +// Package event contains a mask type and bit switches for listener subscriptions. +// +// See also ecs.Listener and ecs.EntityEvent. +package event + +// Subscription bits for individual events +const ( + // EntityCreated subscription bit + EntityCreated Subscription = 0b00000001 + // EntityRemoved subscription bit + EntityRemoved Subscription = 0b00000010 + // ComponentAdded subscription bit + ComponentAdded Subscription = 0b00000100 + // ComponentRemoved subscription bit + ComponentRemoved Subscription = 0b000001000 + // RelationChanged subscription bit + RelationChanged Subscription = 0b000010000 + // TargetChanged subscription bit + TargetChanged Subscription = 0b000100000 +) + +// Subscription bits for groups of events +const ( + // Entities subscription for entity creation or removal + Entities Subscription = EntityCreated | EntityRemoved + // Components subscription for component addition or removal + Components Subscription = ComponentAdded | ComponentRemoved + // Relations subscription for relation and target changes + Relations Subscription = RelationChanged | TargetChanged + // All subscriptions + All Subscription = Entities | Components | Relations +) + +// Subscription bits for an ecs.Listener +type Subscription uint8 + +// Contains checks whether all the argument's bits are contained in this Subscription. +func (s Subscription) Contains(bits Subscription) bool { + return (bits & s) == bits +} + +// ContainsAny checks whether any of the argument's bits are contained in this Subscription. +func (s Subscription) ContainsAny(bits Subscription) bool { + return (bits & s) != 0 +} diff --git a/ecs/event/event_test.go b/ecs/event/event_test.go new file mode 100644 index 00000000..09731f6d --- /dev/null +++ b/ecs/event/event_test.go @@ -0,0 +1,18 @@ +package event_test + +import ( + "testing" + + "github.com/mlange-42/arche/ecs/event" + "github.com/stretchr/testify/assert" +) + +func TestSubscriptions(t *testing.T) { + m1 := event.EntityCreated | event.TargetChanged + + assert.True(t, m1.Contains(event.EntityCreated)) + assert.False(t, m1.Contains(event.EntityRemoved)) + + assert.True(t, m1.ContainsAny(event.ComponentAdded|event.TargetChanged)) + assert.False(t, m1.Contains(event.ComponentAdded|event.RelationChanged)) +} diff --git a/ecs/event_test.go b/ecs/event_test.go index 3483327c..54d02119 100644 --- a/ecs/event_test.go +++ b/ecs/event_test.go @@ -5,24 +5,25 @@ import ( "testing" "github.com/mlange-42/arche/ecs" + "github.com/mlange-42/arche/ecs/event" "github.com/stretchr/testify/assert" ) func TestEntityEvent(t *testing.T) { - e := ecs.EntityEvent{AddedRemoved: 0} + e := ecs.EntityEvent{EventTypes: event.ComponentAdded} - assert.False(t, e.EntityAdded()) - assert.False(t, e.EntityRemoved()) + assert.False(t, e.Contains(event.EntityCreated)) + assert.False(t, e.Contains(event.EntityRemoved)) - e = ecs.EntityEvent{AddedRemoved: 1} + e = ecs.EntityEvent{EventTypes: event.EntityCreated | event.ComponentAdded} - assert.True(t, e.EntityAdded()) - assert.False(t, e.EntityRemoved()) + assert.True(t, e.Contains(event.EntityCreated)) + assert.False(t, e.Contains(event.EntityRemoved)) - e = ecs.EntityEvent{AddedRemoved: -1} + e = ecs.EntityEvent{EventTypes: event.EntityRemoved | event.ComponentRemoved} - assert.False(t, e.EntityAdded()) - assert.True(t, e.EntityRemoved()) + assert.False(t, e.Contains(event.EntityCreated)) + assert.True(t, e.Contains(event.EntityRemoved)) } type eventHandler struct { @@ -49,7 +50,7 @@ func BenchmarkEntityEventCreate(b *testing.B) { b.StartTimer() for i := 0; i < b.N; i++ { - event = ecs.EntityEvent{Entity: e, OldMask: mask, Added: added, Removed: nil, AddedRemoved: 0} + event = ecs.EntityEvent{Entity: e, OldMask: mask, Added: added, Removed: nil} } b.StopTimer() _ = event @@ -67,7 +68,7 @@ func BenchmarkEntityEventHeapPointer(b *testing.B) { b.StartTimer() for i := 0; i < b.N; i++ { - event = &ecs.EntityEvent{Entity: e, OldMask: mask, Added: added, Removed: nil, AddedRemoved: 0} + event = &ecs.EntityEvent{Entity: e, OldMask: mask, Added: added, Removed: nil} } b.StopTimer() _ = event @@ -77,13 +78,13 @@ func BenchmarkEntityEventCopy(b *testing.B) { handler := eventHandler{} for i := 0; i < b.N; i++ { - handler.ListenCopy(ecs.EntityEvent{Entity: ecs.Entity{}, OldMask: ecs.Mask{}, Added: nil, Removed: nil, AddedRemoved: 0}) + handler.ListenCopy(ecs.EntityEvent{Entity: ecs.Entity{}, OldMask: ecs.Mask{}, Added: nil, Removed: nil}) } } func BenchmarkEntityEventCopyReuse(b *testing.B) { handler := eventHandler{} - event := ecs.EntityEvent{Entity: ecs.Entity{}, OldMask: ecs.Mask{}, Added: nil, Removed: nil, AddedRemoved: 0} + event := ecs.EntityEvent{Entity: ecs.Entity{}, OldMask: ecs.Mask{}, Added: nil, Removed: nil} for i := 0; i < b.N; i++ { handler.ListenCopy(event) @@ -94,13 +95,13 @@ func BenchmarkEntityEventPointer(b *testing.B) { handler := eventHandler{} for i := 0; i < b.N; i++ { - handler.ListenPointer(&ecs.EntityEvent{Entity: ecs.Entity{}, OldMask: ecs.Mask{}, Added: nil, Removed: nil, AddedRemoved: 0}) + handler.ListenPointer(&ecs.EntityEvent{Entity: ecs.Entity{}, OldMask: ecs.Mask{}, Added: nil, Removed: nil}) } } func BenchmarkEntityEventPointerReuse(b *testing.B) { handler := eventHandler{} - event := ecs.EntityEvent{Entity: ecs.Entity{}, OldMask: ecs.Mask{}, Added: nil, Removed: nil, AddedRemoved: 0} + event := ecs.EntityEvent{Entity: ecs.Entity{}, OldMask: ecs.Mask{}, Added: nil, Removed: nil} for i := 0; i < b.N; i++ { handler.ListenPointer(&event) @@ -110,11 +111,20 @@ func BenchmarkEntityEventPointerReuse(b *testing.B) { func ExampleEntityEvent() { world := ecs.NewWorld() - listener := func(evt ecs.EntityEvent) { - fmt.Println(evt) - } - world.SetListener(listener) + listener := NewTestListener( + func(evt ecs.EntityEvent) { fmt.Println(evt) }, + ) + world.SetListener(&listener) world.NewEntity() - // Output: {{1 0} {[0 0 0 0]} [] [] {0 0} 1 false false} + // Output: {{1 0} {[0 0 0 0]} [] [] {0 0} 1} +} + +func ExampleEntityEvent_Contains() { + mask := event.EntityCreated | event.EntityRemoved + + fmt.Println(mask.Contains(event.EntityRemoved)) + fmt.Println(mask.Contains(event.RelationChanged)) + // Output: true + // false } diff --git a/ecs/util.go b/ecs/util.go index e8f6b994..fa44944d 100644 --- a/ecs/util.go +++ b/ecs/util.go @@ -3,6 +3,8 @@ package ecs import ( "fmt" "strings" + + "github.com/mlange-42/arche/ecs/event" ) // Page size of pagedSlice type @@ -39,6 +41,30 @@ func capacityU32(size, increment uint32) uint32 { return cap } +// Creates an [event.Subscription] mask from the given booleans. +func subscription(entityCreated, entityRemoved, componentAdded, componentRemoved, relationChanged, targetChanged bool) event.Subscription { + var bits event.Subscription = 0 + if entityCreated { + bits |= event.EntityCreated + } + if entityRemoved { + bits |= event.EntityRemoved + } + if componentAdded { + bits |= event.ComponentAdded + } + if componentRemoved { + bits |= event.ComponentRemoved + } + if relationChanged { + bits |= event.RelationChanged + } + if targetChanged { + bits |= event.TargetChanged + } + return bits +} + func maskToTypes(mask Mask, reg *componentRegistry) []componentType { count := int(mask.TotalBitsSet()) types := make([]componentType, count) diff --git a/ecs/world.go b/ecs/world.go index d184b804..a2768d15 100644 --- a/ecs/world.go +++ b/ecs/world.go @@ -5,6 +5,7 @@ import ( "reflect" "unsafe" + "github.com/mlange-42/arche/ecs/event" "github.com/mlange-42/arche/ecs/stats" ) @@ -114,7 +115,7 @@ func AddResource[T any](w *World, res *T) ResID { // [World.Batch], [World.Cache] and [Builder]. type World struct { config Config // World configuration. - listener func(e EntityEvent) // Component change listener. + listener Listener // EntityEvent listener. resources Resources // World resources. entities []entityIndex // Mapping from entities to archetype and index. targetEntities bitSet // Whether entities are potential relation targets. @@ -212,7 +213,10 @@ func (w *World) NewEntity(comps ...ID) Entity { if arch.HasRelationComponent { newRel = &arch.RelationComponent } - w.listener(EntityEvent{entity, Mask{}, comps, nil, nil, newRel, Entity{}, 1, newRel != nil, false}) + bits := subscription(true, false, len(comps) > 0, false, newRel != nil, false) + if w.listener.Subscriptions().ContainsAny(bits) { + w.listener.Notify(EntityEvent{entity, Mask{}, comps, nil, nil, newRel, Entity{}, bits}) + } } return entity } @@ -257,7 +261,10 @@ func (w *World) NewEntityWith(comps ...Component) Entity { if arch.HasRelationComponent { newRel = &arch.RelationComponent } - w.listener(EntityEvent{entity, Mask{}, ids, nil, nil, newRel, Entity{}, 1, newRel != nil, false}) + bits := subscription(true, false, len(comps) > 0, false, newRel != nil, false) + if w.listener.Subscriptions().ContainsAny(bits) { + w.listener.Notify(EntityEvent{entity, Mask{}, ids, nil, nil, newRel, Entity{}, bits}) + } } return entity } @@ -284,7 +291,10 @@ func (w *World) newEntityTarget(targetID ID, target Entity, comps ...ID) Entity } if w.listener != nil { - w.listener(EntityEvent{entity, Mask{}, comps, nil, nil, &targetID, Entity{}, 1, true, !target.IsZero()}) + bits := subscription(true, false, len(comps) > 0, false, true, !target.IsZero()) + if w.listener.Subscriptions().ContainsAny(bits) { + w.listener.Notify(EntityEvent{entity, Mask{}, comps, nil, nil, &targetID, Entity{}, bits}) + } } return entity } @@ -317,7 +327,10 @@ func (w *World) newEntityTargetWith(targetID ID, target Entity, comps ...Compone } if w.listener != nil { - w.listener(EntityEvent{entity, Mask{}, ids, nil, nil, &targetID, Entity{}, 1, true, !target.IsZero()}) + bits := subscription(true, false, len(comps) > 0, false, true, !target.IsZero()) + if w.listener.Subscriptions().ContainsAny(bits) { + w.listener.Notify(EntityEvent{entity, Mask{}, ids, nil, nil, &targetID, Entity{}, bits}) + } } return entity } @@ -332,13 +345,15 @@ func (w *World) newEntities(count int, targetID ID, hasTarget bool, target Entit if arch.HasRelationComponent { newRel = &arch.RelationComponent } - - cnt := uint32(count) - var i uint32 - for i = 0; i < cnt; i++ { - idx := startIdx + i - entity := arch.GetEntity(idx) - w.listener(EntityEvent{entity, Mask{}, comps, nil, nil, newRel, Entity{}, 1, newRel != nil, !target.IsZero()}) + bits := subscription(true, false, len(comps) > 0, false, newRel != nil, !target.IsZero()) + if w.listener.Subscriptions().ContainsAny(bits) { + cnt := uint32(count) + var i uint32 + for i = 0; i < cnt; i++ { + idx := startIdx + i + entity := arch.GetEntity(idx) + w.listener.Notify(EntityEvent{entity, Mask{}, comps, nil, nil, newRel, Entity{}, bits}) + } } } @@ -374,13 +389,15 @@ func (w *World) newEntitiesWith(count int, targetID ID, hasTarget bool, target E if arch.HasRelationComponent { newRel = &arch.RelationComponent } - - var i uint32 - cnt := uint32(count) - for i = 0; i < cnt; i++ { - idx := startIdx + i - entity := arch.GetEntity(idx) - w.listener(EntityEvent{entity, Mask{}, ids, nil, nil, newRel, Entity{}, 1, newRel != nil, !target.IsZero()}) + bits := subscription(true, false, len(comps) > 0, false, newRel != nil, !target.IsZero()) + if w.listener.Subscriptions().ContainsAny(bits) { + var i uint32 + cnt := uint32(count) + for i = 0; i < cnt; i++ { + idx := startIdx + i + entity := arch.GetEntity(idx) + w.listener.Notify(EntityEvent{entity, Mask{}, ids, nil, nil, newRel, Entity{}, bits}) + } } } @@ -429,9 +446,12 @@ func (w *World) RemoveEntity(entity Entity) { oldIds = oldArch.node.Ids } - lock := w.lock() - w.listener(EntityEvent{entity, oldArch.Mask, nil, oldIds, oldRel, nil, oldArch.RelationTarget, -1, oldRel != nil, !oldArch.RelationTarget.IsZero()}) - w.unlock(lock) + bits := subscription(false, true, false, len(oldIds) > 0, oldRel != nil, !oldArch.RelationTarget.IsZero()) + if w.listener.Subscriptions().ContainsAny(bits) { + lock := w.lock() + w.listener.Notify(EntityEvent{entity, oldArch.Mask, nil, oldIds, oldRel, nil, oldArch.RelationTarget, bits}) + w.unlock(lock) + } } swapped := oldArch.Remove(index.index) @@ -463,6 +483,9 @@ func (w *World) removeEntities(filter Filter) int { lock := w.lock() + var bits event.Subscription + var listen bool + var count uint32 arches := w.getArchetypes(filter) @@ -477,19 +500,24 @@ func (w *World) removeEntities(filter Filter) int { count += ln + var oldRel *ID + var oldIds []ID + if w.listener != nil { + if arch.HasRelationComponent { + oldRel = &arch.RelationComponent + } + if len(arch.node.Ids) > 0 { + oldIds = arch.node.Ids + } + bits = subscription(false, true, false, len(oldIds) > 0, oldRel != nil, !arch.RelationTarget.IsZero()) + listen = w.listener.Subscriptions().ContainsAny(bits) + } + var j uint32 for j = 0; j < ln; j++ { entity := arch.GetEntity(j) - if w.listener != nil { - var oldRel *ID - if arch.HasRelationComponent { - oldRel = &arch.RelationComponent - } - var oldIds []ID - if len(arch.node.Ids) > 0 { - oldIds = arch.node.Ids - } - w.listener(EntityEvent{entity, arch.Mask, nil, oldIds, oldRel, nil, Entity{}, -1, oldRel != nil, !arch.RelationTarget.IsZero()}) + if listen { + w.listener.Notify(EntityEvent{entity, arch.Mask, nil, oldIds, oldRel, nil, Entity{}, bits}) } index := &w.entities[entity.id] index.arch = nil @@ -739,7 +767,12 @@ func (w *World) exchange(entity Entity, add []ID, rem []ID, relation ID, hasRela if oldRel != nil || newRel != nil { relChanged = (oldRel == nil) != (newRel == nil) || *oldRel != *newRel } - w.listener(EntityEvent{entity, oldMask, add, rem, oldRel, newRel, oldTarget, 0, relChanged, oldTarget != arch.RelationTarget}) + targChanged := oldTarget != arch.RelationTarget + + bits := subscription(false, false, len(add) > 0, len(rem) > 0, relChanged, targChanged) + if w.listener.Subscriptions().ContainsAny(bits) { + w.listener.Notify(EntityEvent{entity, oldMask, add, rem, oldRel, newRel, oldTarget, bits}) + } } } @@ -936,8 +969,8 @@ func (w *World) setRelation(entity Entity, comp ID, target Entity) { oldTarget := oldArch.RelationTarget w.cleanupArchetype(oldArch) - if w.listener != nil { - w.listener(EntityEvent{entity, arch.Mask, nil, nil, &comp, &comp, oldTarget, 0, false, true}) + if w.listener != nil && w.listener.Subscriptions().Contains(event.TargetChanged) { + w.listener.Notify(EntityEvent{entity, arch.Mask, nil, nil, &comp, &comp, oldTarget, event.TargetChanged}) } } @@ -945,7 +978,7 @@ func (w *World) setRelation(entity Entity, comp ID, target Entity) { func (w *World) setRelationBatch(filter Filter, comp ID, target Entity) { batches := batchArchetypes{} w.setRelationBatchNoNotify(filter, comp, target, &batches) - if w.listener != nil { + if w.listener != nil && w.listener.Subscriptions().Contains(event.TargetChanged) { w.notifyQuery(&batches) } } @@ -977,6 +1010,10 @@ func (w *World) setRelationBatchNoNotify(filter Filter, comp ID, target Entity, continue } + if arch.RelationTarget == target { + continue + } + newArch, start, end := w.setRelationArch(arch, archLen, comp, target) batches.Add(newArch, arch, start, end) } @@ -985,9 +1022,13 @@ func (w *World) setRelationBatchNoNotify(filter Filter, comp ID, target Entity, func (w *World) setRelationArch(oldArch *archetype, oldArchLen uint32, comp ID, target Entity) (*archetype, uint32, uint32) { w.checkRelation(oldArch, comp) - if oldArch.RelationTarget == target { - return oldArch, 0, oldArchLen - } + // Before, entities with unchanged target were included in the query, + // end events were emitted for them. Seems better to skip them completely, + // which is done in World.setRelationBatchNoNotify. + //if oldArch.RelationTarget == target { + // return oldArch, 0, oldArchLen + //} + oldIDs := oldArch.Components() arch := oldArch.node.GetArchetype(target) @@ -1145,7 +1186,7 @@ func (w *World) ComponentType(id ID) (reflect.Type, bool) { // Replaces the current listener. Call with nil to remove a listener. // // For details, see [EntityEvent]. -func (w *World) SetListener(listener func(e EntityEvent)) { +func (w *World) SetListener(listener Listener) { w.listener = listener } @@ -1640,33 +1681,39 @@ func (w *World) notifyQuery(batchArch *batchArchetypes) { event := EntityEvent{ Entity{}, Mask{}, batchArch.Added, batchArch.Removed, nil, newRel, - Entity{}, 1, newRel != nil, !arch.RelationTarget.IsZero(), + Entity{}, 0, } oldArch := batchArch.OldArchetype[i] + relChanged := newRel != nil + targChanged := !arch.RelationTarget.IsZero() + if oldArch != nil { var oldRel *ID if oldArch.HasRelationComponent { oldRel = &oldArch.RelationComponent } - relChanged := false + relChanged = false if oldRel != nil || newRel != nil { relChanged = (oldRel == nil) != (newRel == nil) || *oldRel != *newRel } + targChanged = oldArch.RelationTarget != arch.RelationTarget event.OldMask = oldArch.node.Mask event.OldTarget = oldArch.RelationTarget event.OldRelation = oldRel - event.AddedRemoved = 0 - event.RelationChanged = relChanged - event.TargetChanged = oldArch.RelationTarget != arch.RelationTarget } - start, end := batchArch.StartIndex[i], batchArch.EndIndex[i] - var e uint32 - for e = start; e < end; e++ { - entity := arch.GetEntity(e) - event.Entity = entity - w.listener(event) + bits := subscription(oldArch == nil, false, len(batchArch.Added) > 0, len(batchArch.Removed) > 0, relChanged, targChanged) + event.EventTypes = bits + + if w.listener.Subscriptions().ContainsAny(bits) { + start, end := batchArch.StartIndex[i], batchArch.EndIndex[i] + var e uint32 + for e = start; e < end; e++ { + entity := arch.GetEntity(e) + event.Entity = entity + w.listener.Notify(event) + } } } } diff --git a/ecs/world_benchmark_test.go b/ecs/world_benchmark_test.go index e249b6dc..308f9488 100644 --- a/ecs/world_benchmark_test.go +++ b/ecs/world_benchmark_test.go @@ -3,6 +3,7 @@ package ecs import ( "testing" + "github.com/mlange-42/arche/ecs/event" "github.com/mlange-42/arche/ecs/stats" ) @@ -285,6 +286,28 @@ func BenchmarkWorldStats_10Arch(b *testing.B) { _ = st } +func BenchmarkWorldNewEntityNoListener_1000(b *testing.B) { + b.StopTimer() + + world := NewWorld() + + posID := ComponentID[Position](&world) + filterPos := All(posID) + + builder := NewBuilder(&world, posID) + builder.NewBatch(1000) + world.Batch().RemoveEntities(filterPos) + + for i := 0; i < b.N; i++ { + b.StartTimer() + for j := 0; j < 1000; j++ { + world.NewEntity(posID) + } + b.StopTimer() + world.Batch().RemoveEntities(filterPos) + } +} + func BenchmarkWorldNewEntityNoEvent_1000(b *testing.B) { b.StopTimer() @@ -297,6 +320,11 @@ func BenchmarkWorldNewEntityNoEvent_1000(b *testing.B) { builder.NewBatch(1000) world.Batch().RemoveEntities(filterPos) + var temp event.Subscription + listener := newTestListener(func(e EntityEvent) { temp = e.EventTypes }) + listener.Subscribe = 0 + + world.SetListener(&listener) for i := 0; i < b.N; i++ { b.StartTimer() for j := 0; j < 1000; j++ { @@ -305,6 +333,7 @@ func BenchmarkWorldNewEntityNoEvent_1000(b *testing.B) { b.StopTimer() world.Batch().RemoveEntities(filterPos) } + _ = temp } func BenchmarkWorldNewEntityEvent_1000(b *testing.B) { @@ -319,8 +348,9 @@ func BenchmarkWorldNewEntityEvent_1000(b *testing.B) { builder.NewBatch(1000) world.Batch().RemoveEntities(filterPos) - var temp int8 - world.SetListener(func(e EntityEvent) { temp = e.AddedRemoved }) + var temp event.Subscription + listener := newTestListener(func(e EntityEvent) { temp = e.EventTypes }) + world.SetListener(&listener) for i := 0; i < b.N; i++ { b.StartTimer() @@ -333,6 +363,46 @@ func BenchmarkWorldNewEntityEvent_1000(b *testing.B) { _ = temp } +func BenchmarkWorldExchangeNoListener_1000(b *testing.B) { + b.StopTimer() + + world := NewWorld() + + posID := ComponentID[Position](&world) + velID := ComponentID[Velocity](&world) + + builder := NewBuilder(&world, posID) + entities := make([]Entity, 0, 1000) + query := builder.NewBatchQ(1000) + for query.Next() { + entities = append(entities, query.Entity()) + } + + filterPos := All(posID) + filterVel := All(velID) + + pos := []ID{posID} + vel := []ID{velID} + + world.Batch().Exchange(filterPos, vel, pos) + world.Batch().Exchange(filterVel, pos, vel) + + b.StartTimer() + hasPos := true + for i := 0; i < b.N; i++ { + if hasPos { + for _, e := range entities { + world.Exchange(e, vel, pos) + } + } else { + for _, e := range entities { + world.Exchange(e, pos, vel) + } + } + hasPos = !hasPos + } +} + func BenchmarkWorldExchangeNoEvent_1000(b *testing.B) { b.StopTimer() @@ -357,6 +427,11 @@ func BenchmarkWorldExchangeNoEvent_1000(b *testing.B) { world.Batch().Exchange(filterPos, vel, pos) world.Batch().Exchange(filterVel, pos, vel) + var temp event.Subscription + listener := newTestListener(func(e EntityEvent) { temp = e.EventTypes }) + listener.Subscribe = 0 + world.SetListener(&listener) + b.StartTimer() hasPos := true for i := 0; i < b.N; i++ { @@ -371,6 +446,7 @@ func BenchmarkWorldExchangeNoEvent_1000(b *testing.B) { } hasPos = !hasPos } + _ = temp } func BenchmarkWorldExchangeEvent_1000(b *testing.B) { @@ -397,8 +473,9 @@ func BenchmarkWorldExchangeEvent_1000(b *testing.B) { world.Batch().Exchange(filterPos, vel, pos) world.Batch().Exchange(filterVel, pos, vel) - var temp int8 - world.SetListener(func(e EntityEvent) { temp = e.AddedRemoved }) + var temp event.Subscription + listener := newTestListener(func(e EntityEvent) { temp = e.EventTypes }) + world.SetListener(&listener) b.StartTimer() hasPos := true @@ -417,6 +494,38 @@ func BenchmarkWorldExchangeEvent_1000(b *testing.B) { _ = temp } +func BenchmarkWorldExchangeBatchNoListener_1000(b *testing.B) { + b.StopTimer() + + world := NewWorld() + + posID := ComponentID[Position](&world) + velID := ComponentID[Velocity](&world) + + builder := NewBuilder(&world, posID) + builder.NewBatch(1000) + + filterPos := All(posID) + filterVel := All(velID) + + pos := []ID{posID} + vel := []ID{velID} + + world.Batch().Exchange(filterPos, vel, pos) + world.Batch().Exchange(filterVel, pos, vel) + + b.StartTimer() + hasPos := true + for i := 0; i < b.N; i++ { + if hasPos { + world.Batch().Exchange(filterPos, vel, pos) + } else { + world.Batch().Exchange(filterVel, pos, vel) + } + hasPos = !hasPos + } +} + func BenchmarkWorldExchangeBatchNoEvent_1000(b *testing.B) { b.StopTimer() @@ -437,6 +546,11 @@ func BenchmarkWorldExchangeBatchNoEvent_1000(b *testing.B) { world.Batch().Exchange(filterPos, vel, pos) world.Batch().Exchange(filterVel, pos, vel) + var temp event.Subscription + listener := newTestListener(func(e EntityEvent) { temp = e.EventTypes }) + listener.Subscribe = 0 + world.SetListener(&listener) + b.StartTimer() hasPos := true for i := 0; i < b.N; i++ { @@ -447,6 +561,7 @@ func BenchmarkWorldExchangeBatchNoEvent_1000(b *testing.B) { } hasPos = !hasPos } + _ = temp } func BenchmarkWorldExchangeBatchEvent_1000(b *testing.B) { @@ -469,8 +584,9 @@ func BenchmarkWorldExchangeBatchEvent_1000(b *testing.B) { world.Batch().Exchange(filterPos, vel, pos) world.Batch().Exchange(filterVel, pos, vel) - var temp int8 - world.SetListener(func(e EntityEvent) { temp = e.AddedRemoved }) + var temp event.Subscription + listener := newTestListener(func(e EntityEvent) { temp = e.EventTypes }) + world.SetListener(&listener) b.StartTimer() hasPos := true diff --git a/ecs/world_examples_test.go b/ecs/world_examples_test.go index f83a4f6e..2a7e436e 100644 --- a/ecs/world_examples_test.go +++ b/ecs/world_examples_test.go @@ -5,6 +5,7 @@ import ( "reflect" "github.com/mlange-42/arche/ecs" + "github.com/mlange-42/arche/ecs/event" ) func ExampleComponentID() { @@ -275,13 +276,15 @@ func ExampleWorld_Batch() { func ExampleWorld_SetListener() { world := ecs.NewWorld() - listener := func(evt ecs.EntityEvent) { - fmt.Println(evt) - } - world.SetListener(listener) + listener := NewTestListener( + func(evt ecs.EntityEvent) { + fmt.Println(evt) + }, + ) + world.SetListener(&listener) world.NewEntity() - // Output: {{1 0} {[0 0 0 0]} [] [] {0 0} 1 false false} + // Output: {{1 0} {[0 0 0 0]} [] [] {0 0} 1} } func ExampleWorld_Stats() { @@ -290,3 +293,25 @@ func ExampleWorld_Stats() { fmt.Println(stats.Entities.String()) // Output: Entities -- Used: 0, Recycled: 0, Total: 0, Capacity: 128 } + +// TestListener for all [EntityEvent]s. +type TestListener struct { + Callback func(e ecs.EntityEvent) +} + +// NewTestListener creates a new [TestListener] that subscribes to all event types. +func NewTestListener(callback func(e ecs.EntityEvent)) TestListener { + return TestListener{ + Callback: callback, + } +} + +// Notify the listener +func (l *TestListener) Notify(e ecs.EntityEvent) { + l.Callback(e) +} + +// Subscriptions of the listener +func (l *TestListener) Subscriptions() event.Subscription { + return event.All +} diff --git a/ecs/world_test.go b/ecs/world_test.go index 270dedaf..46f20420 100644 --- a/ecs/world_test.go +++ b/ecs/world_test.go @@ -7,6 +7,7 @@ import ( "runtime" "testing" + "github.com/mlange-42/arche/ecs/event" "github.com/stretchr/testify/assert" ) @@ -330,9 +331,10 @@ func TestWorldExchangeBatch(t *testing.T) { w := NewWorld() events := []EntityEvent{} - w.SetListener(func(e EntityEvent) { + listener := newTestListener(func(e EntityEvent) { events = append(events, e) }) + w.SetListener(&listener) posID := ComponentID[Position](&w) velID := ComponentID[Velocity](&w) @@ -347,12 +349,10 @@ func TestWorldExchangeBatch(t *testing.T) { assert.Equal(t, 202, len(events)) assert.Equal(t, EntityEvent{ - Entity: Entity{202, 0}, - Added: []ID{posID, relID}, - NewRelation: &relID, - AddedRemoved: 1, - RelationChanged: true, - TargetChanged: true, + Entity: Entity{202, 0}, + Added: []ID{posID, relID}, + NewRelation: &relID, + EventTypes: event.EntityCreated | event.ComponentAdded | event.RelationChanged | event.TargetChanged, }, events[201]) filter := All(posID, relID) @@ -380,16 +380,14 @@ func TestWorldExchangeBatch(t *testing.T) { assert.Equal(t, 502, len(events)) assert.Equal(t, EntityEvent{ - Entity: Entity{102, 0}, - OldMask: All(velID, relID), - Added: []ID{posID}, - Removed: []ID{velID}, - OldRelation: &relID, - NewRelation: &relID, - OldTarget: target1, - AddedRemoved: 0, - RelationChanged: false, - TargetChanged: false, + Entity: Entity{102, 0}, + OldMask: All(velID, relID), + Added: []ID{posID}, + Removed: []ID{velID}, + OldRelation: &relID, + NewRelation: &relID, + OldTarget: target1, + EventTypes: event.ComponentAdded | event.ComponentRemoved, }, events[501]) query = w.Query(All(posID)) @@ -404,15 +402,13 @@ func TestWorldExchangeBatch(t *testing.T) { assert.Equal(t, 702, len(events)) assert.Equal(t, EntityEvent{ - Entity: Entity{102, 0}, - OldMask: All(posID, relID), - Removed: []ID{relID}, - OldRelation: &relID, - NewRelation: nil, - OldTarget: target1, - AddedRemoved: 0, - RelationChanged: true, - TargetChanged: true, + Entity: Entity{102, 0}, + OldMask: All(posID, relID), + Removed: []ID{relID}, + OldRelation: &relID, + NewRelation: nil, + OldTarget: target1, + EventTypes: event.ComponentRemoved | event.RelationChanged | event.TargetChanged, }, events[701]) w.Batch().RemoveEntities(All(posID)) @@ -420,20 +416,12 @@ func TestWorldExchangeBatch(t *testing.T) { assert.Equal(t, 802, len(events)) assert.Equal(t, EntityEvent{ - Entity: Entity{102, 0}, - OldMask: All(posID), - Removed: []ID{posID}, - AddedRemoved: -1, - RelationChanged: false, - TargetChanged: false, + Entity: Entity{102, 0}, + OldMask: All(posID), + Removed: []ID{posID}, + EventTypes: event.EntityRemoved | event.ComponentRemoved, }, events[801]) - assert.Equal(t, int8(1), events[0].AddedRemoved) - assert.Equal(t, int8(1), events[1].AddedRemoved) - assert.Equal(t, int8(1), events[2].AddedRemoved) - assert.Equal(t, int8(1), events[201].AddedRemoved) - - assert.Equal(t, int8(0), events[202].AddedRemoved) assert.Equal(t, []ID{velID}, events[202].Added) assert.Equal(t, []ID{posID}, events[202].Removed) @@ -613,10 +601,11 @@ func TestWorldNewEntities(t *testing.T) { world := NewWorld(NewConfig().WithCapacityIncrement(16)) events := []EntityEvent{} - world.SetListener(func(e EntityEvent) { - assert.Equal(t, world.IsLocked(), e.EntityRemoved()) + listener := newTestListener(func(e EntityEvent) { + assert.Equal(t, world.IsLocked(), e.Contains(event.EntityRemoved)) events = append(events, e) }) + world.SetListener(&listener) posID := ComponentID[Position](&world) rotID := ComponentID[rotation](&world) @@ -688,10 +677,11 @@ func TestWorldNewEntitiesWith(t *testing.T) { world := NewWorld(NewConfig().WithCapacityIncrement(16)) events := []EntityEvent{} - world.SetListener(func(e EntityEvent) { - assert.Equal(t, world.IsLocked(), e.EntityRemoved()) + listener := newTestListener(func(e EntityEvent) { + assert.Equal(t, world.IsLocked(), e.Contains(event.EntityRemoved)) events = append(events, e) }) + world.SetListener(&listener) posID := ComponentID[Position](&world) rotID := ComponentID[rotation](&world) @@ -762,10 +752,11 @@ func TestWorldRemoveEntities(t *testing.T) { world := NewWorld(NewConfig().WithCapacityIncrement(16)) events := []EntityEvent{} - world.SetListener(func(e EntityEvent) { - assert.Equal(t, world.IsLocked(), e.EntityRemoved()) + listener := newTestListener(func(e EntityEvent) { + assert.Equal(t, world.IsLocked(), e.Contains(event.EntityRemoved)) events = append(events, e) }) + world.SetListener(&listener) posID := ComponentID[Position](&world) rotID := ComponentID[rotation](&world) @@ -802,9 +793,10 @@ func TestWorldRelationSet(t *testing.T) { world := NewWorld() events := []EntityEvent{} - world.SetListener(func(e EntityEvent) { + listener := newTestListener(func(e EntityEvent) { events = append(events, e) }) + world.SetListener(&listener) rotID := ComponentID[rotation](&world) relID := ComponentID[testRelationA](&world) @@ -824,12 +816,12 @@ func TestWorldRelationSet(t *testing.T) { assert.Equal(t, 4, len(events)) assert.Equal(t, EntityEvent{ - Entity: e1, - OldMask: All(relID, rotID), - OldRelation: &relID, - NewRelation: &relID, - OldTarget: Entity{}, - TargetChanged: true, + Entity: e1, + OldMask: All(relID, rotID), + OldRelation: &relID, + NewRelation: &relID, + OldTarget: Entity{}, + EventTypes: event.TargetChanged, }, events[len(events)-1]) assert.Equal(t, targ, world.Relations().Get(e1, relID)) @@ -842,12 +834,12 @@ func TestWorldRelationSet(t *testing.T) { assert.Equal(t, 5, len(events)) assert.Equal(t, EntityEvent{ - Entity: e1, - OldMask: All(relID, rotID), - OldRelation: &relID, - NewRelation: &relID, - OldTarget: targ, - TargetChanged: true, + Entity: e1, + OldMask: All(relID, rotID), + OldRelation: &relID, + NewRelation: &relID, + OldTarget: targ, + EventTypes: event.TargetChanged, }, events[len(events)-1]) assert.Panics(t, func() { world.Relations().Get(e1, rotID) }) @@ -867,13 +859,12 @@ func TestWorldRelationSet(t *testing.T) { assert.Equal(t, 6, len(events)) assert.Equal(t, EntityEvent{ - Entity: e2, - OldMask: All(relID, rotID), - Removed: []ID{relID}, - OldRelation: &relID, - NewRelation: nil, - RelationChanged: true, - TargetChanged: false, + Entity: e2, + OldMask: All(relID, rotID), + Removed: []ID{relID}, + OldRelation: &relID, + NewRelation: nil, + EventTypes: event.ComponentRemoved | event.RelationChanged, }, events[len(events)-1]) assert.Panics(t, func() { world.Relations().Get(e2, relID) }) @@ -901,9 +892,10 @@ func TestWorldRelationSetBatch(t *testing.T) { world := NewWorld() events := []EntityEvent{} - world.SetListener(func(e EntityEvent) { + listener := newTestListener(func(e EntityEvent) { events = append(events, e) }) + world.SetListener(&listener) posID := ComponentID[Position](&world) rotID := ComponentID[rotation](&world) @@ -913,11 +905,15 @@ func TestWorldRelationSetBatch(t *testing.T) { targ2 := world.NewEntity(posID) targ3 := world.NewEntity(posID) + assert.Equal(t, 3, len(events)) + builder := NewBuilder(&world, rotID, relID).WithRelation(relID) builder.NewBatch(100, targ1) builder.NewBatch(100, targ2) builder.NewBatch(100, targ3) + assert.Equal(t, 303, len(events)) + relFilter := NewRelationFilter(All(relID), targ2) q := world.Batch().SetRelationQ(&relFilter, relID, targ1) assert.Equal(t, 100, q.Count()) @@ -928,14 +924,18 @@ func TestWorldRelationSetBatch(t *testing.T) { } assert.Equal(t, 100, cnt) + assert.Equal(t, 403, len(events)) + q = world.Batch().SetRelationQ(All(relID), relID, targ3) - assert.Equal(t, 300, q.Count()) + assert.Equal(t, 200, q.Count()) cnt = 0 for q.Next() { assert.Equal(t, targ3, q.Relation(relID)) cnt++ } - assert.Equal(t, 300, cnt) + assert.Equal(t, 200, cnt) + + assert.Equal(t, 603, len(events)) relFilter = NewRelationFilter(All(relID), targ3) q = world.Batch().SetRelationQ(&relFilter, relID, Entity{}) @@ -947,22 +947,31 @@ func TestWorldRelationSetBatch(t *testing.T) { } assert.Equal(t, 300, cnt) + assert.Equal(t, 903, len(events)) + relFilter = NewRelationFilter(All(relID), Entity{}) world.Batch().SetRelation(&relFilter, relID, targ1) + assert.Equal(t, 1203, len(events)) + world.RemoveEntity(targ3) + assert.Equal(t, 1204, len(events)) + assert.Panics(t, func() { world.Batch().SetRelation(All(relID), relID, targ3) }) - assert.Equal(t, 1304, len(events)) + assert.Equal(t, 1204, len(events)) world.Relations().SetBatch(All(relID), relID, targ1) + assert.Equal(t, 1204, len(events)) q = world.Relations().SetBatchQ(All(relID), relID, targ2) assert.Equal(t, 300, q.Count()) q.Close() + assert.Equal(t, 1504, len(events)) + fmt.Println(debugPrintWorld(&world)) world.Reset() @@ -972,7 +981,10 @@ func TestWorldRelationRemove(t *testing.T) { world := NewWorld() events := []EntityEvent{} - world.SetListener(func(e EntityEvent) { events = append(events, e) }) + listener := newTestListener(func(e EntityEvent) { + events = append(events, e) + }) + world.SetListener(&listener) rotID := ComponentID[rotation](&world) relID := ComponentID[testRelationA](&world) @@ -1188,7 +1200,9 @@ func TestWorldRelation(t *testing.T) { func TestWorldRelationCreate(t *testing.T) { world := NewWorld() - world.SetListener(func(e EntityEvent) {}) + + listener := newTestListener(func(e EntityEvent) {}) + world.SetListener(&listener) posID := ComponentID[Position](&world) relID := ComponentID[testRelationA](&world) @@ -1228,7 +1242,8 @@ func TestWorldRelationCreate(t *testing.T) { func TestWorldRelationMove(t *testing.T) { world := NewWorld() - world.SetListener(func(e EntityEvent) {}) + listener := newTestListener(func(e EntityEvent) {}) + world.SetListener(&listener) posID := ComponentID[Position](&world) relID := ComponentID[testRelationA](&world) @@ -1422,13 +1437,13 @@ func TestRegisterComponents(t *testing.T) { } func TestWorldBatchRemove(t *testing.T) { + world := NewWorld() + events := []EntityEvent{} - listen := func(e EntityEvent) { + listener := newTestListener(func(e EntityEvent) { events = append(events, e) - } - - world := NewWorld() - world.SetListener(listen) + }) + world.SetListener(&listener) rotID := ComponentID[rotation](&world) relID := ComponentID[testRelationA](&world) @@ -1439,8 +1454,8 @@ func TestWorldBatchRemove(t *testing.T) { assert.Equal(t, 3, len(events)) assert.Equal(t, EntityEvent{ - Entity: target3, - AddedRemoved: 1, + Entity: target3, + EventTypes: event.EntityCreated, }, events[len(events)-1]) builder := NewBuilder(&world, rotID, relID).WithRelation(relID) @@ -1451,12 +1466,10 @@ func TestWorldBatchRemove(t *testing.T) { assert.Equal(t, 33, len(events)) assert.Equal(t, EntityEvent{ - Entity: Entity{33, 0}, - Added: []ID{rotID, relID}, - NewRelation: &relID, - AddedRemoved: 1, - RelationChanged: true, - TargetChanged: true, + Entity: Entity{33, 0}, + Added: []ID{rotID, relID}, + NewRelation: &relID, + EventTypes: event.EntityCreated | event.ComponentAdded | event.RelationChanged | event.TargetChanged, }, events[len(events)-1]) filter := All(rotID).Exclusive() @@ -1471,13 +1484,11 @@ func TestWorldBatchRemove(t *testing.T) { assert.Equal(t, 43, len(events)) assert.Equal(t, EntityEvent{ - Entity: Entity{13, 0}, - OldMask: All(rotID, relID), - Removed: []ID{rotID, relID}, - OldRelation: &relID, - AddedRemoved: -1, - RelationChanged: true, - TargetChanged: true, + Entity: Entity{13, 0}, + OldMask: All(rotID, relID), + Removed: []ID{rotID, relID}, + OldRelation: &relID, + EventTypes: event.EntityRemoved | event.ComponentRemoved | event.RelationChanged | event.TargetChanged, }, events[len(events)-1]) relFilter = NewRelationFilter(All(rotID, relID), target2) @@ -1490,8 +1501,8 @@ func TestWorldBatchRemove(t *testing.T) { assert.Equal(t, 56, len(events)) assert.Equal(t, EntityEvent{ - Entity: Entity{3, 0}, - AddedRemoved: -1, + Entity: Entity{3, 0}, + EventTypes: event.EntityRemoved, }, events[len(events)-1]) relFilter = NewRelationFilter(All(rotID, relID), target3) @@ -1506,8 +1517,9 @@ func TestWorldBatchRemove(t *testing.T) { func TestWorldReset(t *testing.T) { world := NewWorld() + listener := newTestListener(func(e EntityEvent) {}) + world.SetListener(&listener) - world.SetListener(func(e EntityEvent) {}) AddResource(&world, &rotation{100}) posID := ComponentID[Position](&world) @@ -1582,14 +1594,13 @@ func TestArchetypeGraph(t *testing.T) { } func TestWorldListener(t *testing.T) { - events := []EntityEvent{} - listen := func(e EntityEvent) { - events = append(events, e) - } - w := NewWorld() - w.SetListener(listen) + events := []EntityEvent{} + listener := newTestListener(func(e EntityEvent) { + events = append(events, e) + }) + w.SetListener(&listener) posID := ComponentID[Position](&w) velID := ComponentID[Velocity](&w) @@ -1599,101 +1610,98 @@ func TestWorldListener(t *testing.T) { e0 := w.NewEntity() assert.Equal(t, 1, len(events)) assert.Equal(t, EntityEvent{ - Entity: e0, AddedRemoved: 1, + Entity: e0, + EventTypes: event.EntityCreated, }, events[len(events)-1]) w.RemoveEntity(e0) assert.Equal(t, 2, len(events)) assert.Equal(t, EntityEvent{ - Entity: e0, AddedRemoved: -1, + Entity: e0, + EventTypes: event.EntityRemoved, }, events[len(events)-1]) e0 = w.NewEntity(posID, velID) assert.Equal(t, 3, len(events)) assert.Equal(t, EntityEvent{ - Entity: e0, - Added: []ID{posID, velID}, - AddedRemoved: 1, + Entity: e0, + Added: []ID{posID, velID}, + EventTypes: event.EntityCreated | event.ComponentAdded, }, events[len(events)-1]) w.RemoveEntity(e0) assert.Equal(t, 4, len(events)) assert.Equal(t, EntityEvent{ - Entity: e0, - OldMask: All(posID, velID), - Removed: []ID{posID, velID}, - AddedRemoved: -1, + Entity: e0, + OldMask: All(posID, velID), + Removed: []ID{posID, velID}, + EventTypes: event.EntityRemoved | event.ComponentRemoved, }, events[len(events)-1]) e0 = w.NewEntityWith(Component{posID, &Position{}}, Component{velID, &Velocity{}}, Component{relID, &relationComp{}}) assert.Equal(t, 5, len(events)) assert.Equal(t, EntityEvent{ - Entity: e0, - Added: []ID{posID, velID, relID}, - NewRelation: &relID, - AddedRemoved: 1, - RelationChanged: true, + Entity: e0, + Added: []ID{posID, velID, relID}, + NewRelation: &relID, + EventTypes: event.EntityCreated | event.ComponentAdded | event.RelationChanged, }, events[len(events)-1]) w.Add(e0, rotID) assert.Equal(t, 6, len(events)) assert.Equal(t, EntityEvent{ - Entity: e0, - OldMask: All(posID, velID, relID), - Added: []ID{rotID}, - OldRelation: &relID, - NewRelation: &relID, - AddedRemoved: 0, + Entity: e0, + OldMask: All(posID, velID, relID), + Added: []ID{rotID}, + OldRelation: &relID, + NewRelation: &relID, + EventTypes: event.ComponentAdded, }, events[len(events)-1]) w.Remove(e0, posID) assert.Equal(t, 7, len(events)) assert.Equal(t, EntityEvent{ - Entity: e0, - OldMask: All(posID, velID, rotID, relID), - Removed: []ID{posID}, - OldRelation: &relID, - NewRelation: &relID, - AddedRemoved: 0, + Entity: e0, + OldMask: All(posID, velID, rotID, relID), + Removed: []ID{posID}, + OldRelation: &relID, + NewRelation: &relID, + EventTypes: event.ComponentRemoved, }, events[len(events)-1]) e1 := w.NewEntity(posID) w.Relations().Set(e0, relID, e1) assert.Equal(t, 9, len(events)) assert.Equal(t, EntityEvent{ - Entity: e0, - OldMask: All(velID, rotID, relID), - OldRelation: &relID, - NewRelation: &relID, - TargetChanged: true, - AddedRemoved: 0, + Entity: e0, + OldMask: All(velID, rotID, relID), + OldRelation: &relID, + NewRelation: &relID, + EventTypes: event.TargetChanged, }, events[len(events)-1]) w.Remove(e0, relID) assert.Equal(t, 10, len(events)) assert.Equal(t, EntityEvent{ - Entity: e0, - OldMask: All(velID, rotID, relID), - Removed: []ID{relID}, - OldRelation: &relID, - NewRelation: nil, - OldTarget: e1, - RelationChanged: true, - TargetChanged: true, - AddedRemoved: 0, + Entity: e0, + OldMask: All(velID, rotID, relID), + Removed: []ID{relID}, + OldRelation: &relID, + NewRelation: nil, + OldTarget: e1, + EventTypes: event.ComponentRemoved | event.RelationChanged | event.TargetChanged, }, events[len(events)-1]) } func TestWorldListenerBuilder(t *testing.T) { - events := []EntityEvent{} - listen := func(e EntityEvent) { - events = append(events, e) - } - w := NewWorld() - w.SetListener(listen) + events := []EntityEvent{} + listener := newTestListener(func(e EntityEvent) { + events = append(events, e) + }) + w.SetListener(&listener) posID := ComponentID[Position](&w) relID := ComponentID[relationComp](&w) @@ -1705,13 +1713,11 @@ func TestWorldListenerBuilder(t *testing.T) { assert.Equal(t, 11, len(events)) assert.Equal(t, EntityEvent{ - Entity: Entity{11, 0}, - OldMask: All(), - Added: []ID{posID, relID}, - NewRelation: &relID, - RelationChanged: true, - TargetChanged: false, - AddedRemoved: 1, + Entity: Entity{11, 0}, + OldMask: All(), + Added: []ID{posID, relID}, + NewRelation: &relID, + EventTypes: event.EntityCreated | event.ComponentAdded | event.RelationChanged, }, events[len(events)-1]) query := builder.NewBatchQ(10) @@ -1719,26 +1725,22 @@ func TestWorldListenerBuilder(t *testing.T) { assert.Equal(t, 21, len(events)) assert.Equal(t, EntityEvent{ - Entity: Entity{21, 0}, - OldMask: All(), - Added: []ID{posID, relID}, - NewRelation: &relID, - RelationChanged: true, - TargetChanged: false, - AddedRemoved: 1, + Entity: Entity{21, 0}, + OldMask: All(), + Added: []ID{posID, relID}, + NewRelation: &relID, + EventTypes: event.EntityCreated | event.ComponentAdded | event.RelationChanged, }, events[len(events)-1]) builder.NewBatch(10, parent) assert.Equal(t, 31, len(events)) assert.Equal(t, EntityEvent{ - Entity: Entity{31, 0}, - OldMask: All(), - Added: []ID{posID, relID}, - NewRelation: &relID, - RelationChanged: true, - TargetChanged: true, - AddedRemoved: 1, + Entity: Entity{31, 0}, + OldMask: All(), + Added: []ID{posID, relID}, + NewRelation: &relID, + EventTypes: event.EntityCreated | event.ComponentAdded | event.RelationChanged | event.TargetChanged, }, events[len(events)-1]) query = builder.NewBatchQ(10, parent) @@ -1746,13 +1748,11 @@ func TestWorldListenerBuilder(t *testing.T) { assert.Equal(t, 41, len(events)) assert.Equal(t, EntityEvent{ - Entity: Entity{41, 0}, - OldMask: All(), - Added: []ID{posID, relID}, - NewRelation: &relID, - RelationChanged: true, - TargetChanged: true, - AddedRemoved: 1, + Entity: Entity{41, 0}, + OldMask: All(), + Added: []ID{posID, relID}, + NewRelation: &relID, + EventTypes: event.EntityCreated | event.ComponentAdded | event.RelationChanged | event.TargetChanged, }, events[len(events)-1]) builder = NewBuilderWith(&w, @@ -1764,13 +1764,11 @@ func TestWorldListenerBuilder(t *testing.T) { assert.Equal(t, 51, len(events)) assert.Equal(t, EntityEvent{ - Entity: Entity{51, 0}, - OldMask: All(), - Added: []ID{posID, relID}, - NewRelation: &relID, - RelationChanged: true, - TargetChanged: false, - AddedRemoved: 1, + Entity: Entity{51, 0}, + OldMask: All(), + Added: []ID{posID, relID}, + NewRelation: &relID, + EventTypes: event.EntityCreated | event.ComponentAdded | event.RelationChanged, }, events[len(events)-1]) query = builder.NewBatchQ(10) @@ -1778,26 +1776,22 @@ func TestWorldListenerBuilder(t *testing.T) { assert.Equal(t, 61, len(events)) assert.Equal(t, EntityEvent{ - Entity: Entity{61, 0}, - OldMask: All(), - Added: []ID{posID, relID}, - NewRelation: &relID, - RelationChanged: true, - TargetChanged: false, - AddedRemoved: 1, + Entity: Entity{61, 0}, + OldMask: All(), + Added: []ID{posID, relID}, + NewRelation: &relID, + EventTypes: event.EntityCreated | event.ComponentAdded | event.RelationChanged, }, events[len(events)-1]) builder.NewBatch(10, parent) assert.Equal(t, 71, len(events)) assert.Equal(t, EntityEvent{ - Entity: Entity{71, 0}, - OldMask: All(), - Added: []ID{posID, relID}, - NewRelation: &relID, - RelationChanged: true, - TargetChanged: true, - AddedRemoved: 1, + Entity: Entity{71, 0}, + OldMask: All(), + Added: []ID{posID, relID}, + NewRelation: &relID, + EventTypes: event.EntityCreated | event.ComponentAdded | event.RelationChanged | event.TargetChanged, }, events[len(events)-1]) query = builder.NewBatchQ(10, parent) @@ -1805,13 +1799,11 @@ func TestWorldListenerBuilder(t *testing.T) { assert.Equal(t, 81, len(events)) assert.Equal(t, EntityEvent{ - Entity: Entity{81, 0}, - OldMask: All(), - Added: []ID{posID, relID}, - NewRelation: &relID, - RelationChanged: true, - TargetChanged: true, - AddedRemoved: 1, + Entity: Entity{81, 0}, + OldMask: All(), + Added: []ID{posID, relID}, + NewRelation: &relID, + EventTypes: event.EntityCreated | event.ComponentAdded | event.RelationChanged | event.TargetChanged, }, events[len(events)-1]) } diff --git a/examples/change_listener/main.go b/examples/change_listener/main.go index 6f643d1b..e5db5f1a 100644 --- a/examples/change_listener/main.go +++ b/examples/change_listener/main.go @@ -6,6 +6,8 @@ import ( "fmt" "github.com/mlange-42/arche/ecs" + "github.com/mlange-42/arche/ecs/event" + "github.com/mlange-42/arche/listener" ) // Position component @@ -25,19 +27,19 @@ type Rotation struct { A float64 } -// Listener type -type Listener struct { +// TestListener type +type TestListener struct { World *ecs.World } -// Listen is called on entity changes. -func (l *Listener) Listen(evt ecs.EntityEvent) { +// Notify is called on entity changes. +func (l *TestListener) Notify(evt ecs.EntityEvent) { // Just prints out what the event is about. // This could also be a method of a type that manages events. // Could use e.g. filters to distribute events to interested/registered systems. - if evt.EntityAdded() { + if evt.Contains(event.EntityCreated) { fmt.Printf("Entity added, has components %v\n", l.World.Ids(evt.Entity)) - } else if evt.EntityRemoved() { + } else if evt.Contains(event.EntityRemoved) { fmt.Printf("Entity removed, had components %v\n", l.World.Ids(evt.Entity)) } else { fmt.Printf("Entity changed, has components %v\n", l.World.Ids(evt.Entity)) @@ -47,7 +49,8 @@ func (l *Listener) Listen(evt ecs.EntityEvent) { func main() { // Create a World. world := ecs.NewWorld() - listener := Listener{World: &world} + ls := TestListener{World: &world} + wrapper := listener.NewCallback(event.Entities|event.Components, ls.Notify) // Get component IDs posID := ecs.ComponentID[Position](&world) @@ -55,7 +58,7 @@ func main() { rotID := ecs.ComponentID[Rotation](&world) // Register a listener function. - world.SetListener(listener.Listen) + world.SetListener(&wrapper) // Create/manipulate/delete entities and observe the listener's output e0 := world.NewEntity(posID) diff --git a/listener/callback.go b/listener/callback.go new file mode 100644 index 00000000..ee78becc --- /dev/null +++ b/listener/callback.go @@ -0,0 +1,32 @@ +package listener + +import ( + "github.com/mlange-42/arche/ecs" + "github.com/mlange-42/arche/ecs/event" +) + +// Callback listener for ecs.EntityEvent. +// +// Calls a function on events that are contained in the subscription mask. +type Callback struct { + callback func(e ecs.EntityEvent) + events event.Subscription +} + +// NewCallback creates a new Callback listener for the given events. +func NewCallback(events event.Subscription, callback func(ecs.EntityEvent)) Callback { + return Callback{ + callback: callback, + events: events, + } +} + +// Notify the listener. +func (l *Callback) Notify(e ecs.EntityEvent) { + l.callback(e) +} + +// Subscriptions of the listener. +func (l *Callback) Subscriptions() event.Subscription { + return l.events +} diff --git a/listener/callback_test.go b/listener/callback_test.go new file mode 100644 index 00000000..af10a16b --- /dev/null +++ b/listener/callback_test.go @@ -0,0 +1,38 @@ +package listener_test + +import ( + "fmt" + "testing" + + "github.com/mlange-42/arche/ecs" + "github.com/mlange-42/arche/ecs/event" + "github.com/mlange-42/arche/listener" + "github.com/stretchr/testify/assert" +) + +func TestCallback(t *testing.T) { + evt := []ecs.EntityEvent{} + ls := listener.NewCallback( + event.All, + func(e ecs.EntityEvent) { + evt = append(evt, e) + }, + ) + assert.Equal(t, event.All, ls.Subscriptions()) + ls.Notify(ecs.EntityEvent{}) + assert.Equal(t, 1, len(evt)) +} + +func ExampleCallback() { + world := ecs.NewWorld() + + ls := listener.NewCallback( + event.Entities|event.Components, + func(e ecs.EntityEvent) { + fmt.Println(e) + }, + ) + world.SetListener(&ls) + + world.NewEntity() +} diff --git a/listener/dispatched.go b/listener/dispatched.go new file mode 100644 index 00000000..97dc3a9e --- /dev/null +++ b/listener/dispatched.go @@ -0,0 +1,44 @@ +package listener + +import ( + "github.com/mlange-42/arche/ecs" + "github.com/mlange-42/arche/ecs/event" +) + +// Dispatched event listener. +type Dispatched struct { + listeners []ecs.Listener + events event.Subscription +} + +// NewDispatched returns a new [Dispatched] listener with sub-listeners. +func NewDispatched(listeners ...ecs.Listener) Dispatched { + var events event.Subscription = 0 + for _, l := range listeners { + events |= l.Subscriptions() + } + return Dispatched{ + listeners: listeners, + events: events, + } +} + +// AddListener adds a listener to this listener. +func (l *Dispatched) AddListener(ls ecs.Listener) { + l.listeners = append(l.listeners, ls) + l.events |= ls.Subscriptions() +} + +// Notify the listener. +func (l *Dispatched) Notify(e ecs.EntityEvent) { + for _, ls := range l.listeners { + if ls.Subscriptions().ContainsAny(e.EventTypes) { + ls.Notify(e) + } + } +} + +// Subscriptions of the listener. +func (l *Dispatched) Subscriptions() event.Subscription { + return l.events +} diff --git a/listener/dispatched_test.go b/listener/dispatched_test.go new file mode 100644 index 00000000..e82391e5 --- /dev/null +++ b/listener/dispatched_test.go @@ -0,0 +1,70 @@ +package listener_test + +import ( + "fmt" + "testing" + + "github.com/mlange-42/arche/ecs" + "github.com/mlange-42/arche/ecs/event" + "github.com/mlange-42/arche/listener" + "github.com/stretchr/testify/assert" +) + +func TestDispatched(t *testing.T) { + entityEvents := []ecs.EntityEvent{} + componentEvents := []ecs.EntityEvent{} + + l1 := listener.NewCallback(event.Entities, func(evt ecs.EntityEvent) { entityEvents = append(entityEvents, evt) }) + l2 := listener.NewCallback(event.Components, func(evt ecs.EntityEvent) { componentEvents = append(componentEvents, evt) }) + + ls := listener.NewDispatched(&l1) + ls.AddListener(&l2) + + assert.Equal(t, event.Entities|event.Components, ls.Subscriptions()) + + ls.Notify(ecs.EntityEvent{EventTypes: event.EntityCreated | event.ComponentAdded}) + ls.Notify(ecs.EntityEvent{EventTypes: event.EntityCreated | event.RelationChanged}) + ls.Notify(ecs.EntityEvent{EventTypes: event.Relations}) + + assert.Equal(t, 2, len(entityEvents)) + assert.Equal(t, 1, len(componentEvents)) +} + +type Position struct { + X float64 + Y float64 +} + +func ExampleDispatched() { + world := ecs.NewWorld() + + posID := ecs.ComponentID[Position](&world) + + entityListener := listener.NewCallback( + event.EntityCreated|event.EntityRemoved, + func(evt ecs.EntityEvent) { fmt.Println("Entity event") }, + ) + componentListener := listener.NewCallback( + event.ComponentAdded|event.ComponentRemoved, + func(evt ecs.EntityEvent) { fmt.Println("Component event") }, + ) + + mainListener := listener.NewDispatched( + &entityListener, + &componentListener, + ) + + world.SetListener(&mainListener) + + // Triggers event.EntityCreated + e := world.NewEntity() + // Triggers event.ComponentAdded + world.Add(e, posID) + + // Triggers event.EntityCreated and event.ComponentAdded + _ = world.NewEntity(posID) + // Output: Entity event + // Component event + // Entity event + // Component event +} diff --git a/listener/doc.go b/listener/doc.go new file mode 100644 index 00000000..19655618 --- /dev/null +++ b/listener/doc.go @@ -0,0 +1,4 @@ +// Package listener provides event listener implementations for Arche. +// +// See the top level module [github.com/mlange-42/arche] for an overview. +package listener