From 01eb5aa5f6c0aa1ea4b705c61e80918b1b7c55bc Mon Sep 17 00:00:00 2001 From: hypercross Date: Sat, 18 Jul 2026 19:50:31 +0800 Subject: [PATCH] feat: add reactivity via change buffering and R3 observables --- src/OECS/ChangeBuffer.cs | 131 +++++++++++++ src/OECS/ChangeKind.cs | 33 ++++ src/OECS/ChangeSet.cs | 83 ++++++++ src/OECS/EntityChange.cs | 45 +++++ src/OECS/SystemGroup.cs | 7 + src/OECS/World.cs | 119 +++++++++++- tests/OECS.Tests/ReactivityTests.cs | 284 ++++++++++++++++++++++++++++ 7 files changed, 700 insertions(+), 2 deletions(-) create mode 100644 src/OECS/ChangeBuffer.cs create mode 100644 src/OECS/ChangeKind.cs create mode 100644 src/OECS/ChangeSet.cs create mode 100644 src/OECS/EntityChange.cs create mode 100644 tests/OECS.Tests/ReactivityTests.cs diff --git a/src/OECS/ChangeBuffer.cs b/src/OECS/ChangeBuffer.cs new file mode 100644 index 0000000..c25842e --- /dev/null +++ b/src/OECS/ChangeBuffer.cs @@ -0,0 +1,131 @@ +using R3; + +namespace OECS; + +/// +/// Buffers changes during system execution and posts them to R3 subjects +/// when is called. +/// +/// Subscribers receive batched changes: all changes accumulated since the +/// last call are pushed in a single burst. +/// +internal class ChangeBuffer +{ + private readonly ChangeSet _pending = new(); + + private readonly Subject _entitySubject = new(); + private readonly Dictionary> _componentSubjects = new(); + private readonly Dictionary> _querySubjects = new(); + + /// + /// The change set currently accumulating. Cleared after each . + /// + public ChangeSet Pending => _pending; + + /// + /// Pushes all pending changes to R3 subjects, then clears the pending set. + /// + public void Post() + { + if (_pending.Count == 0) + return; + + var changes = _pending.Changes; + + foreach (var change in changes) + { + // Push to the global entity subject. + _entitySubject.OnNext(change); + + // Push to component-specific subjects. + if (change.ComponentType != null) + { + if (_componentSubjects.TryGetValue(change.ComponentType, out var compSubject)) + { + compSubject.OnNext(change); + } + } + + // Push to matching query subjects. + foreach (var (query, subject) in _querySubjects) + { + if (ChangeMatchesQuery(change, query)) + { + subject.OnNext(change); + } + } + } + + _pending.Clear(); + } + + /// + /// Returns an observable that emits all entity changes. + /// + public Observable ObserveEntityChanges() + { + return _entitySubject; + } + + /// + /// Returns an observable that emits changes for a specific component type. + /// + public Observable ObserveComponentChanges(Type componentType) + { + if (!_componentSubjects.TryGetValue(componentType, out var subject)) + { + subject = new Subject(); + _componentSubjects[componentType] = subject; + } + return subject; + } + + /// + /// Returns an observable that emits changes matching the given query. + /// + public Observable ObserveQuery(QueryDescriptor query) + { + if (!_querySubjects.TryGetValue(query, out var subject)) + { + subject = new Subject(); + _querySubjects[query] = subject; + } + return subject; + } + + /// + /// Disposes all R3 subjects. + /// + public void Dispose() + { + _entitySubject.Dispose(); + foreach (var subject in _componentSubjects.Values) + { + subject.Dispose(); + } + _componentSubjects.Clear(); + foreach (var subject in _querySubjects.Values) + { + subject.Dispose(); + } + _querySubjects.Clear(); + } + + private static bool ChangeMatchesQuery(EntityChange change, QueryDescriptor query) + { + // Entity-level changes: match if the entity was added/removed and + // the query has any "with" types (we can't know component state for + // removed entities, so we only match added entities that would satisfy + // the query — but we don't have component data here). + // For simplicity, we only match component-level changes against queries. + if (change.ComponentType == null) + return false; + + // A component change matches a query if the component type is in the + // query's With set and not in the Without set. + if (query.Without.Contains(change.ComponentType)) + return false; + + return query.With.Contains(change.ComponentType); + } +} diff --git a/src/OECS/ChangeKind.cs b/src/OECS/ChangeKind.cs new file mode 100644 index 0000000..1b7f9ea --- /dev/null +++ b/src/OECS/ChangeKind.cs @@ -0,0 +1,33 @@ +namespace OECS; + +/// +/// The kind of change that occurred in the ECS world. +/// +public enum ChangeKind +{ + /// + /// A new entity was created. + /// + EntityAdded, + + /// + /// An entity was destroyed. + /// + EntityRemoved, + + /// + /// A component was added to an entity. + /// + ComponentAdded, + + /// + /// A component was removed from an entity. + /// + ComponentRemoved, + + /// + /// A component's value was modified. Must be explicitly marked + /// via . + /// + ComponentModified +} diff --git a/src/OECS/ChangeSet.cs b/src/OECS/ChangeSet.cs new file mode 100644 index 0000000..0c3b0f7 --- /dev/null +++ b/src/OECS/ChangeSet.cs @@ -0,0 +1,83 @@ +namespace OECS; + +/// +/// Accumulates entries during a system run. +/// +/// Deduplication: if the same (entity, kind, componentType) change is marked +/// multiple times, only one entry is kept. Entity-level changes (Added/Removed) +/// take precedence over component-level changes for the same entity. +/// +internal class ChangeSet +{ + private readonly List _changes = new(); + private readonly HashSet<(Entity Entity, ChangeKind Kind, Type? ComponentType)> _dedup = new(); + + /// + /// All accumulated changes in insertion order. + /// + public IReadOnlyList Changes => _changes; + + /// + /// Number of changes in this set. + /// + public int Count => _changes.Count; + + /// + /// Records that an entity was created. + /// + public void MarkEntityAdded(Entity entity) + { + TryAdd(new EntityChange(entity, ChangeKind.EntityAdded)); + } + + /// + /// Records that an entity was destroyed. + /// + public void MarkEntityRemoved(Entity entity) + { + TryAdd(new EntityChange(entity, ChangeKind.EntityRemoved)); + } + + /// + /// Records that a component was added to an entity. + /// + public void MarkComponentAdded(Entity entity, Type componentType) + { + TryAdd(new EntityChange(entity, ChangeKind.ComponentAdded, componentType)); + } + + /// + /// Records that a component was removed from an entity. + /// + public void MarkComponentRemoved(Entity entity, Type componentType) + { + TryAdd(new EntityChange(entity, ChangeKind.ComponentRemoved, componentType)); + } + + /// + /// Records that a component's value was modified. + /// Must be called explicitly by user code via . + /// + public void MarkComponentModified(Entity entity, Type componentType) + { + TryAdd(new EntityChange(entity, ChangeKind.ComponentModified, componentType)); + } + + /// + /// Clears all accumulated changes. + /// + public void Clear() + { + _changes.Clear(); + _dedup.Clear(); + } + + private void TryAdd(EntityChange change) + { + var key = (change.Entity, change.Kind, change.ComponentType); + if (_dedup.Add(key)) + { + _changes.Add(change); + } + } +} diff --git a/src/OECS/EntityChange.cs b/src/OECS/EntityChange.cs new file mode 100644 index 0000000..581edeb --- /dev/null +++ b/src/OECS/EntityChange.cs @@ -0,0 +1,45 @@ +namespace OECS; + +/// +/// Describes a single change that occurred in the ECS world. +/// +public readonly struct EntityChange : IEquatable +{ + /// + /// The entity that was affected. + /// + public Entity Entity { get; } + + /// + /// The kind of change. + /// + public ChangeKind Kind { get; } + + /// + /// The component type involved, or null for entity-level changes + /// ( / ). + /// + public Type? ComponentType { get; } + + internal EntityChange(Entity entity, ChangeKind kind, Type? componentType = null) + { + Entity = entity; + Kind = kind; + ComponentType = componentType; + } + + public bool Equals(EntityChange other) + { + return Entity.Equals(other.Entity) + && Kind == other.Kind + && ComponentType == other.ComponentType; + } + + public override bool Equals(object? obj) => obj is EntityChange other && Equals(other); + + public override int GetHashCode() => HashCode.Combine(Entity, Kind, ComponentType); + + public override string ToString() => ComponentType == null + ? $"{Kind} {Entity}" + : $"{Kind} {ComponentType.Name} on {Entity}"; +} diff --git a/src/OECS/SystemGroup.cs b/src/OECS/SystemGroup.cs index bf424ef..4cd638d 100644 --- a/src/OECS/SystemGroup.cs +++ b/src/OECS/SystemGroup.cs @@ -72,9 +72,16 @@ public class SystemGroup // Drain commands after each system so subsequent systems // see the effects of commands enqueued by prior systems. _world.ExecuteCommands(); + + // Post changes after each system so subscribers see + // incremental updates. + _world.PostChanges(); } // Drain any remaining commands (e.g., those enqueued outside systems). _world.ExecuteCommands(); + + // Post any remaining changes (e.g., from command execution). + _world.PostChanges(); } } \ No newline at end of file diff --git a/src/OECS/World.cs b/src/OECS/World.cs index c4e1364..f7cb418 100644 --- a/src/OECS/World.cs +++ b/src/OECS/World.cs @@ -1,3 +1,6 @@ +using System.Diagnostics; +using R3; + namespace OECS; /// @@ -12,6 +15,15 @@ public class World : IDisposable private readonly ComponentStore _components; private readonly CommandQueue _commands; private readonly RelationshipIndex _relationships; + private readonly ChangeBuffer _changes; + +#if DEBUG + // Tracks entities whose components were accessed via GetComponent + // but never had MarkModified called. Used to warn about missing + // MarkModified calls after a system runs. + private readonly HashSet<(Entity Entity, Type ComponentType)> _accessedComponents = new(); + private readonly HashSet<(Entity Entity, Type ComponentType)> _markedModified = new(); +#endif public World() { @@ -19,16 +31,20 @@ public class World : IDisposable _components = new ComponentStore(); _commands = new CommandQueue(); _relationships = new RelationshipIndex(); + _changes = new ChangeBuffer(); } // ── Entity Management ──────────────────────────────────────────── /// /// Creates a new entity and returns its handle. + /// Automatically marks an change. /// public Entity CreateEntity() { - return _allocator.Allocate(); + var entity = _allocator.Allocate(); + _changes.Pending.MarkEntityAdded(entity); + return entity; } /// @@ -63,6 +79,8 @@ public class World : IDisposable _components.RemoveAll(entity); _allocator.Free(entity); + + _changes.Pending.MarkEntityRemoved(entity); } /// @@ -96,6 +114,7 @@ public class World : IDisposable } } + bool isNew = !_components.Has(entity); _components.Add(entity, component); // Update relationship index. @@ -103,6 +122,10 @@ public class World : IDisposable { _relationships.OnAdded(typeof(T), entity, rel.Target); } + + // Mark change. + if (isNew) + _changes.Pending.MarkComponentAdded(entity, typeof(T)); } /// @@ -124,15 +147,26 @@ public class World : IDisposable } } + bool existed = _components.Has(entity); _components.Remove(entity); + + if (existed) + _changes.Pending.MarkComponentRemoved(entity, typeof(T)); } /// /// Returns a reference to the component of type /// for the given entity. Throws if the entity does not have the component. + /// + /// After mutating the component through this reference, you must call + /// so that observers are notified. + /// In DEBUG builds, a warning is logged if you forget. /// public ref T GetComponent(Entity entity) where T : struct { +#if DEBUG + _accessedComponents.Add((entity, typeof(T))); +#endif return ref _components.Get(entity); } @@ -144,6 +178,63 @@ public class World : IDisposable return _components.Has(entity); } + // ── Change Tracking ────────────────────────────────────────────── + + /// + /// Marks a component of type on the given entity + /// as modified. Must be called after mutating a component via + /// so that observers are notified. + /// + public void MarkModified(Entity entity) where T : struct + { + _changes.Pending.MarkComponentModified(entity, typeof(T)); + +#if DEBUG + _markedModified.Add((entity, typeof(T))); +#endif + } + + /// + /// Posts all pending changes to R3 subscribers, then clears the pending set. + /// Called automatically by after each system + /// and after the full tick. + /// + public void PostChanges() + { +#if DEBUG + WarnUnmarkedModifications(); +#endif + _changes.Post(); + } + + /// + /// Returns an observable that emits all entity changes (adds, removes, + /// component adds, component removes, component modifications). + /// + public Observable ObserveEntityChanges() + { + return _changes.ObserveEntityChanges(); + } + + /// + /// Returns an observable that emits changes for a specific component type + /// (added, removed, or modified). + /// + public Observable ObserveComponentChanges() where T : struct + { + return _changes.ObserveComponentChanges(typeof(T)); + } + + /// + /// Returns an observable that emits changes matching the given query. + /// Only component-level changes whose component type is in the query's + /// "with" set and not in the "without" set are emitted. + /// + public Observable ObserveQuery(QueryDescriptor query) + { + return _changes.ObserveQuery(query); + } + // ── Relationships ──────────────────────────────────────────────── /// @@ -273,6 +364,30 @@ public class World : IDisposable public void Dispose() { - // Future: dispose R3 subscriptions, etc. + _changes.Dispose(); } + +#if DEBUG + /// + /// Logs warnings for any components that were accessed via + /// but never had + /// called. + /// + private void WarnUnmarkedModifications() + { + foreach (var (entity, componentType) in _accessedComponents) + { + if (!_markedModified.Contains((entity, componentType))) + { + Debug.WriteLine( + $"[OECS] Warning: Component '{componentType.Name}' on {entity} " + + $"was accessed via GetComponent but MarkModified was never called. " + + $"Observers will not be notified of the change."); + } + } + + _accessedComponents.Clear(); + _markedModified.Clear(); + } +#endif } diff --git a/tests/OECS.Tests/ReactivityTests.cs b/tests/OECS.Tests/ReactivityTests.cs new file mode 100644 index 0000000..66eebe7 --- /dev/null +++ b/tests/OECS.Tests/ReactivityTests.cs @@ -0,0 +1,284 @@ +using FluentAssertions; +using R3; +using Xunit; + +namespace OECS.Tests; + +public class ReactivityTests +{ + private struct Position { public float X; public float Y; } + private struct Health { public int Value; } + private struct Frozen { } + + /// + /// Helper that collects changes into a list and exposes an R3 Observer. + /// + private sealed class ChangeCollector : IObserver + { + public List Changes = new(); + + public void OnNext(EntityChange value) => Changes.Add(value); + public void OnError(Exception error) { } + public void OnCompleted() { } + } + + [Fact] + public void EntityCreation_PostsEntityAdded() + { + var world = new World(); + var collector = new ChangeCollector(); + using var sub = world.ObserveEntityChanges().Subscribe(collector.ToObserver()); + + var entity = world.CreateEntity(); + world.PostChanges(); + + collector.Changes.Should().ContainSingle() + .Which.Should().Match(c => + c.Entity == entity && c.Kind == ChangeKind.EntityAdded); + } + + [Fact] + public void EntityDestruction_PostsEntityRemoved() + { + var world = new World(); + var entity = world.CreateEntity(); + world.PostChanges(); // Clear pending + + var collector = new ChangeCollector(); + using var sub = world.ObserveEntityChanges().Subscribe(collector.ToObserver()); + + world.DestroyEntity(entity); + world.PostChanges(); + + collector.Changes.Should().ContainSingle() + .Which.Should().Match(c => + c.Entity == entity && c.Kind == ChangeKind.EntityRemoved); + } + + [Fact] + public void ComponentAdd_PostsComponentAdded() + { + var world = new World(); + var entity = world.CreateEntity(); + world.PostChanges(); // Clear pending + + var collector = new ChangeCollector(); + using var sub = world.ObserveComponentChanges().Subscribe(collector.ToObserver()); + + world.AddComponent(entity, new Position { X = 1, Y = 2 }); + world.PostChanges(); + + collector.Changes.Should().ContainSingle() + .Which.Should().Match(c => + c.Entity == entity && + c.Kind == ChangeKind.ComponentAdded && + c.ComponentType == typeof(Position)); + } + + [Fact] + public void ComponentRemove_PostsComponentRemoved() + { + var world = new World(); + var entity = world.CreateEntity(); + world.AddComponent(entity, new Position { X = 1, Y = 2 }); + world.PostChanges(); // Clear pending + + var collector = new ChangeCollector(); + using var sub = world.ObserveComponentChanges().Subscribe(collector.ToObserver()); + + world.RemoveComponent(entity); + world.PostChanges(); + + collector.Changes.Should().ContainSingle() + .Which.Should().Match(c => + c.Entity == entity && + c.Kind == ChangeKind.ComponentRemoved && + c.ComponentType == typeof(Position)); + } + + [Fact] + public void MarkModified_PostsComponentModified() + { + var world = new World(); + var entity = world.CreateEntity(); + world.AddComponent(entity, new Position { X = 0, Y = 0 }); + world.PostChanges(); // Clear pending + + var collector = new ChangeCollector(); + using var sub = world.ObserveComponentChanges().Subscribe(collector.ToObserver()); + + world.MarkModified(entity); + world.PostChanges(); + + collector.Changes.Should().ContainSingle() + .Which.Should().Match(c => + c.Entity == entity && + c.Kind == ChangeKind.ComponentModified && + c.ComponentType == typeof(Position)); + } + + [Fact] + public void Changes_AreBatchedPerPost() + { + var world = new World(); + var collector = new ChangeCollector(); + using var sub = world.ObserveEntityChanges().Subscribe(collector.ToObserver()); + + var a = world.CreateEntity(); + var b = world.CreateEntity(); + world.AddComponent(a, new Position { X = 1, Y = 2 }); + world.AddComponent(b, new Health { Value = 100 }); + + // No Post yet — subscribers should not have received anything. + collector.Changes.Should().BeEmpty(); + + world.PostChanges(); + + // All changes arrive in one batch. + collector.Changes.Should().HaveCount(4); + } + + [Fact] + public void Subscribers_ReceiveChangesInOrder() + { + var world = new World(); + var collector = new ChangeCollector(); + using var sub = world.ObserveEntityChanges().Subscribe(collector.ToObserver()); + + var entity = world.CreateEntity(); + world.AddComponent(entity, new Position { X = 0, Y = 0 }); + world.MarkModified(entity); + world.RemoveComponent(entity); + world.DestroyEntity(entity); + + world.PostChanges(); + + collector.Changes.Select(c => c.Kind).Should().Equal( + ChangeKind.EntityAdded, + ChangeKind.ComponentAdded, + ChangeKind.ComponentModified, + ChangeKind.ComponentRemoved, + ChangeKind.EntityRemoved + ); + } + + [Fact] + public void DisposingSubscription_StopsNotifications() + { + var world = new World(); + var collector = new ChangeCollector(); + var sub = world.ObserveEntityChanges().Subscribe(collector.ToObserver()); + + var entity = world.CreateEntity(); + world.PostChanges(); + collector.Changes.Should().HaveCount(1); + + sub.Dispose(); + + world.DestroyEntity(entity); + world.PostChanges(); + + // No new changes after dispose. + collector.Changes.Should().HaveCount(1); + } + + [Fact] + public void ObserveComponentChanges_OnlyReceivesMatchingType() + { + var world = new World(); + var posCollector = new ChangeCollector(); + var hpCollector = new ChangeCollector(); + + using var sub1 = world.ObserveComponentChanges().Subscribe(posCollector.ToObserver()); + using var sub2 = world.ObserveComponentChanges().Subscribe(hpCollector.ToObserver()); + + var entity = world.CreateEntity(); + world.AddComponent(entity, new Position { X = 1, Y = 2 }); + world.AddComponent(entity, new Health { Value = 100 }); + world.PostChanges(); + + posCollector.Changes.Should().HaveCount(1); + posCollector.Changes[0].ComponentType.Should().Be(typeof(Position)); + + hpCollector.Changes.Should().HaveCount(1); + hpCollector.Changes[0].ComponentType.Should().Be(typeof(Health)); + } + + [Fact] + public void ObserveQuery_OnlyReceivesMatchingChanges() + { + var world = new World(); + var query = world.Query().With().Without().Build(); + var collector = new ChangeCollector(); + + using var sub = world.ObserveQuery(query).Subscribe(collector.ToObserver()); + + var entity = world.CreateEntity(); + world.AddComponent(entity, new Position { X = 1, Y = 2 }); // Should match + world.AddComponent(entity, new Frozen()); // Should NOT match (not in With) + world.AddComponent(entity, new Health { Value = 100 }); // Should NOT match (not in With) + world.PostChanges(); + + collector.Changes.Should().HaveCount(1); + collector.Changes[0].ComponentType.Should().Be(typeof(Position)); + } + + [Fact] + public void Deduplication_PreventsDuplicateChanges() + { + var world = new World(); + var collector = new ChangeCollector(); + using var sub = world.ObserveComponentChanges().Subscribe(collector.ToObserver()); + + var entity = world.CreateEntity(); + world.AddComponent(entity, new Position { X = 0, Y = 0 }); + world.MarkModified(entity); + world.MarkModified(entity); // Duplicate + world.MarkModified(entity); // Duplicate + world.PostChanges(); + + // Should have exactly 2 changes: ComponentAdded + ComponentModified (deduplicated). + collector.Changes.Should().HaveCount(2); + collector.Changes[0].Kind.Should().Be(ChangeKind.ComponentAdded); + collector.Changes[1].Kind.Should().Be(ChangeKind.ComponentModified); + } + + [Fact] + public void Changes_ArePostedAfterEachSystem_InSystemGroup() + { + var world = new World(); + var group = new SystemGroup(world); + + var collector = new ChangeCollector(); + using var sub = world.ObserveEntityChanges().Subscribe(collector.ToObserver()); + + // System that creates an entity. + group.Add(new EntityCreatorSystem(world)); + + // Before running, no changes should be posted. + collector.Changes.Should().BeEmpty(); + + group.RunLogical(); + + // After the system group runs, changes should be posted. + collector.Changes.Should().Contain(c => c.Kind == ChangeKind.EntityAdded); + } + + private class EntityCreatorSystem : ISystem + { + private readonly World _world; + + public QueryDescriptor Query { get; } + + public EntityCreatorSystem(World world) + { + _world = world; + Query = world.Query().With().Build(); + } + + public void Run(World world) + { + _world.CreateEntity(); + } + } +}