Skip to content

Commit

Permalink
Listener subscriptions (#334)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
mlange-42 authored Jan 16, 2024
1 parent 9db71a4 commit 8b1ed91
Show file tree
Hide file tree
Showing 19 changed files with 822 additions and 312 deletions.
2 changes: 0 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ on:
branches:
- main
pull_request:
branches:
- main

jobs:

Expand Down
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions ecs/batch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
66 changes: 50 additions & 16 deletions ecs/event.go
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
}
45 changes: 45 additions & 0 deletions ecs/event/event.go
Original file line number Diff line number Diff line change
@@ -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
}
18 changes: 18 additions & 0 deletions ecs/event/event_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
50 changes: 30 additions & 20 deletions ecs/event_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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]} [] [] <nil> <nil> {0 0} 1 false false}
// Output: {{1 0} {[0 0 0 0]} [] [] <nil> <nil> {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
}
26 changes: 26 additions & 0 deletions ecs/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package ecs
import (
"fmt"
"strings"

"github.com/mlange-42/arche/ecs/event"
)

// Page size of pagedSlice type
Expand Down Expand Up @@ -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)
Expand Down
Loading

0 comments on commit 8b1ed91

Please sign in to comment.