feat: add reactivity via change buffering and R3 observables
This commit is contained in:
parent
48f3319615
commit
01eb5aa5f6
|
|
@ -0,0 +1,131 @@
|
||||||
|
using R3;
|
||||||
|
|
||||||
|
namespace OECS;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Buffers changes during system execution and posts them to R3 subjects
|
||||||
|
/// when <see cref="Post"/> is called.
|
||||||
|
///
|
||||||
|
/// Subscribers receive batched changes: all changes accumulated since the
|
||||||
|
/// last <see cref="Post"/> call are pushed in a single burst.
|
||||||
|
/// </summary>
|
||||||
|
internal class ChangeBuffer
|
||||||
|
{
|
||||||
|
private readonly ChangeSet _pending = new();
|
||||||
|
|
||||||
|
private readonly Subject<EntityChange> _entitySubject = new();
|
||||||
|
private readonly Dictionary<Type, Subject<EntityChange>> _componentSubjects = new();
|
||||||
|
private readonly Dictionary<QueryDescriptor, Subject<EntityChange>> _querySubjects = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The change set currently accumulating. Cleared after each <see cref="Post"/>.
|
||||||
|
/// </summary>
|
||||||
|
public ChangeSet Pending => _pending;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pushes all pending changes to R3 subjects, then clears the pending set.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns an observable that emits all entity changes.
|
||||||
|
/// </summary>
|
||||||
|
public Observable<EntityChange> ObserveEntityChanges()
|
||||||
|
{
|
||||||
|
return _entitySubject;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns an observable that emits changes for a specific component type.
|
||||||
|
/// </summary>
|
||||||
|
public Observable<EntityChange> ObserveComponentChanges(Type componentType)
|
||||||
|
{
|
||||||
|
if (!_componentSubjects.TryGetValue(componentType, out var subject))
|
||||||
|
{
|
||||||
|
subject = new Subject<EntityChange>();
|
||||||
|
_componentSubjects[componentType] = subject;
|
||||||
|
}
|
||||||
|
return subject;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns an observable that emits changes matching the given query.
|
||||||
|
/// </summary>
|
||||||
|
public Observable<EntityChange> ObserveQuery(QueryDescriptor query)
|
||||||
|
{
|
||||||
|
if (!_querySubjects.TryGetValue(query, out var subject))
|
||||||
|
{
|
||||||
|
subject = new Subject<EntityChange>();
|
||||||
|
_querySubjects[query] = subject;
|
||||||
|
}
|
||||||
|
return subject;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Disposes all R3 subjects.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
namespace OECS;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The kind of change that occurred in the ECS world.
|
||||||
|
/// </summary>
|
||||||
|
public enum ChangeKind
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A new entity was created.
|
||||||
|
/// </summary>
|
||||||
|
EntityAdded,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An entity was destroyed.
|
||||||
|
/// </summary>
|
||||||
|
EntityRemoved,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A component was added to an entity.
|
||||||
|
/// </summary>
|
||||||
|
ComponentAdded,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A component was removed from an entity.
|
||||||
|
/// </summary>
|
||||||
|
ComponentRemoved,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A component's value was modified. Must be explicitly marked
|
||||||
|
/// via <see cref="World.MarkModified{T}"/>.
|
||||||
|
/// </summary>
|
||||||
|
ComponentModified
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
namespace OECS;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Accumulates <see cref="EntityChange"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
internal class ChangeSet
|
||||||
|
{
|
||||||
|
private readonly List<EntityChange> _changes = new();
|
||||||
|
private readonly HashSet<(Entity Entity, ChangeKind Kind, Type? ComponentType)> _dedup = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// All accumulated changes in insertion order.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<EntityChange> Changes => _changes;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of changes in this set.
|
||||||
|
/// </summary>
|
||||||
|
public int Count => _changes.Count;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records that an entity was created.
|
||||||
|
/// </summary>
|
||||||
|
public void MarkEntityAdded(Entity entity)
|
||||||
|
{
|
||||||
|
TryAdd(new EntityChange(entity, ChangeKind.EntityAdded));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records that an entity was destroyed.
|
||||||
|
/// </summary>
|
||||||
|
public void MarkEntityRemoved(Entity entity)
|
||||||
|
{
|
||||||
|
TryAdd(new EntityChange(entity, ChangeKind.EntityRemoved));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records that a component was added to an entity.
|
||||||
|
/// </summary>
|
||||||
|
public void MarkComponentAdded(Entity entity, Type componentType)
|
||||||
|
{
|
||||||
|
TryAdd(new EntityChange(entity, ChangeKind.ComponentAdded, componentType));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records that a component was removed from an entity.
|
||||||
|
/// </summary>
|
||||||
|
public void MarkComponentRemoved(Entity entity, Type componentType)
|
||||||
|
{
|
||||||
|
TryAdd(new EntityChange(entity, ChangeKind.ComponentRemoved, componentType));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records that a component's value was modified.
|
||||||
|
/// Must be called explicitly by user code via <see cref="World.MarkModified{T}"/>.
|
||||||
|
/// </summary>
|
||||||
|
public void MarkComponentModified(Entity entity, Type componentType)
|
||||||
|
{
|
||||||
|
TryAdd(new EntityChange(entity, ChangeKind.ComponentModified, componentType));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clears all accumulated changes.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
namespace OECS;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Describes a single change that occurred in the ECS world.
|
||||||
|
/// </summary>
|
||||||
|
public readonly struct EntityChange : IEquatable<EntityChange>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The entity that was affected.
|
||||||
|
/// </summary>
|
||||||
|
public Entity Entity { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The kind of change.
|
||||||
|
/// </summary>
|
||||||
|
public ChangeKind Kind { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The component type involved, or null for entity-level changes
|
||||||
|
/// (<see cref="ChangeKind.EntityAdded"/> / <see cref="ChangeKind.EntityRemoved"/>).
|
||||||
|
/// </summary>
|
||||||
|
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}";
|
||||||
|
}
|
||||||
|
|
@ -72,9 +72,16 @@ public class SystemGroup
|
||||||
// Drain commands after each system so subsequent systems
|
// Drain commands after each system so subsequent systems
|
||||||
// see the effects of commands enqueued by prior systems.
|
// see the effects of commands enqueued by prior systems.
|
||||||
_world.ExecuteCommands();
|
_world.ExecuteCommands();
|
||||||
|
|
||||||
|
// Post changes after each system so subscribers see
|
||||||
|
// incremental updates.
|
||||||
|
_world.PostChanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drain any remaining commands (e.g., those enqueued outside systems).
|
// Drain any remaining commands (e.g., those enqueued outside systems).
|
||||||
_world.ExecuteCommands();
|
_world.ExecuteCommands();
|
||||||
|
|
||||||
|
// Post any remaining changes (e.g., from command execution).
|
||||||
|
_world.PostChanges();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
using R3;
|
||||||
|
|
||||||
namespace OECS;
|
namespace OECS;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -12,6 +15,15 @@ public class World : IDisposable
|
||||||
private readonly ComponentStore _components;
|
private readonly ComponentStore _components;
|
||||||
private readonly CommandQueue _commands;
|
private readonly CommandQueue _commands;
|
||||||
private readonly RelationshipIndex _relationships;
|
private readonly RelationshipIndex _relationships;
|
||||||
|
private readonly ChangeBuffer _changes;
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
// Tracks entities whose components were accessed via GetComponent<T>
|
||||||
|
// but never had MarkModified<T> 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()
|
public World()
|
||||||
{
|
{
|
||||||
|
|
@ -19,16 +31,20 @@ public class World : IDisposable
|
||||||
_components = new ComponentStore();
|
_components = new ComponentStore();
|
||||||
_commands = new CommandQueue();
|
_commands = new CommandQueue();
|
||||||
_relationships = new RelationshipIndex();
|
_relationships = new RelationshipIndex();
|
||||||
|
_changes = new ChangeBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Entity Management ────────────────────────────────────────────
|
// ── Entity Management ────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a new entity and returns its handle.
|
/// Creates a new entity and returns its handle.
|
||||||
|
/// Automatically marks an <see cref="ChangeKind.EntityAdded"/> change.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Entity CreateEntity()
|
public Entity CreateEntity()
|
||||||
{
|
{
|
||||||
return _allocator.Allocate();
|
var entity = _allocator.Allocate();
|
||||||
|
_changes.Pending.MarkEntityAdded(entity);
|
||||||
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -63,6 +79,8 @@ public class World : IDisposable
|
||||||
|
|
||||||
_components.RemoveAll(entity);
|
_components.RemoveAll(entity);
|
||||||
_allocator.Free(entity);
|
_allocator.Free(entity);
|
||||||
|
|
||||||
|
_changes.Pending.MarkEntityRemoved(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -96,6 +114,7 @@ public class World : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool isNew = !_components.Has<T>(entity);
|
||||||
_components.Add(entity, component);
|
_components.Add(entity, component);
|
||||||
|
|
||||||
// Update relationship index.
|
// Update relationship index.
|
||||||
|
|
@ -103,6 +122,10 @@ public class World : IDisposable
|
||||||
{
|
{
|
||||||
_relationships.OnAdded(typeof(T), entity, rel.Target);
|
_relationships.OnAdded(typeof(T), entity, rel.Target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark change.
|
||||||
|
if (isNew)
|
||||||
|
_changes.Pending.MarkComponentAdded(entity, typeof(T));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -124,15 +147,26 @@ public class World : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool existed = _components.Has<T>(entity);
|
||||||
_components.Remove<T>(entity);
|
_components.Remove<T>(entity);
|
||||||
|
|
||||||
|
if (existed)
|
||||||
|
_changes.Pending.MarkComponentRemoved(entity, typeof(T));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns a reference to the component of type <typeparamref name="T"/>
|
/// Returns a reference to the component of type <typeparamref name="T"/>
|
||||||
/// for the given entity. Throws if the entity does not have the component.
|
/// for the given entity. Throws if the entity does not have the component.
|
||||||
|
///
|
||||||
|
/// After mutating the component through this reference, you must call
|
||||||
|
/// <see cref="MarkModified{T}"/> so that observers are notified.
|
||||||
|
/// In DEBUG builds, a warning is logged if you forget.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ref T GetComponent<T>(Entity entity) where T : struct
|
public ref T GetComponent<T>(Entity entity) where T : struct
|
||||||
{
|
{
|
||||||
|
#if DEBUG
|
||||||
|
_accessedComponents.Add((entity, typeof(T)));
|
||||||
|
#endif
|
||||||
return ref _components.Get<T>(entity);
|
return ref _components.Get<T>(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,6 +178,63 @@ public class World : IDisposable
|
||||||
return _components.Has<T>(entity);
|
return _components.Has<T>(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Change Tracking ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marks a component of type <typeparamref name="T"/> on the given entity
|
||||||
|
/// as modified. Must be called after mutating a component via
|
||||||
|
/// <see cref="GetComponent{T}"/> so that observers are notified.
|
||||||
|
/// </summary>
|
||||||
|
public void MarkModified<T>(Entity entity) where T : struct
|
||||||
|
{
|
||||||
|
_changes.Pending.MarkComponentModified(entity, typeof(T));
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
_markedModified.Add((entity, typeof(T)));
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Posts all pending changes to R3 subscribers, then clears the pending set.
|
||||||
|
/// Called automatically by <see cref="SystemGroup"/> after each system
|
||||||
|
/// and after the full tick.
|
||||||
|
/// </summary>
|
||||||
|
public void PostChanges()
|
||||||
|
{
|
||||||
|
#if DEBUG
|
||||||
|
WarnUnmarkedModifications();
|
||||||
|
#endif
|
||||||
|
_changes.Post();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns an observable that emits all entity changes (adds, removes,
|
||||||
|
/// component adds, component removes, component modifications).
|
||||||
|
/// </summary>
|
||||||
|
public Observable<EntityChange> ObserveEntityChanges()
|
||||||
|
{
|
||||||
|
return _changes.ObserveEntityChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns an observable that emits changes for a specific component type
|
||||||
|
/// <typeparamref name="T"/> (added, removed, or modified).
|
||||||
|
/// </summary>
|
||||||
|
public Observable<EntityChange> ObserveComponentChanges<T>() where T : struct
|
||||||
|
{
|
||||||
|
return _changes.ObserveComponentChanges(typeof(T));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public Observable<EntityChange> ObserveQuery(QueryDescriptor query)
|
||||||
|
{
|
||||||
|
return _changes.ObserveQuery(query);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Relationships ────────────────────────────────────────────────
|
// ── Relationships ────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -273,6 +364,30 @@ public class World : IDisposable
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
// Future: dispose R3 subscriptions, etc.
|
_changes.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
/// <summary>
|
||||||
|
/// Logs warnings for any components that were accessed via
|
||||||
|
/// <see cref="GetComponent{T}"/> but never had
|
||||||
|
/// <see cref="MarkModified{T}"/> called.
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper that collects changes into a list and exposes an R3 Observer.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class ChangeCollector : IObserver<EntityChange>
|
||||||
|
{
|
||||||
|
public List<EntityChange> 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<EntityChange>(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<EntityChange>(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<Position>().Subscribe(collector.ToObserver());
|
||||||
|
|
||||||
|
world.AddComponent(entity, new Position { X = 1, Y = 2 });
|
||||||
|
world.PostChanges();
|
||||||
|
|
||||||
|
collector.Changes.Should().ContainSingle()
|
||||||
|
.Which.Should().Match<EntityChange>(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<Position>().Subscribe(collector.ToObserver());
|
||||||
|
|
||||||
|
world.RemoveComponent<Position>(entity);
|
||||||
|
world.PostChanges();
|
||||||
|
|
||||||
|
collector.Changes.Should().ContainSingle()
|
||||||
|
.Which.Should().Match<EntityChange>(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<Position>().Subscribe(collector.ToObserver());
|
||||||
|
|
||||||
|
world.MarkModified<Position>(entity);
|
||||||
|
world.PostChanges();
|
||||||
|
|
||||||
|
collector.Changes.Should().ContainSingle()
|
||||||
|
.Which.Should().Match<EntityChange>(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<Position>(entity);
|
||||||
|
world.RemoveComponent<Position>(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<Position>().Subscribe(posCollector.ToObserver());
|
||||||
|
using var sub2 = world.ObserveComponentChanges<Health>().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<Position>().Without<Frozen>().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<Position>().Subscribe(collector.ToObserver());
|
||||||
|
|
||||||
|
var entity = world.CreateEntity();
|
||||||
|
world.AddComponent(entity, new Position { X = 0, Y = 0 });
|
||||||
|
world.MarkModified<Position>(entity);
|
||||||
|
world.MarkModified<Position>(entity); // Duplicate
|
||||||
|
world.MarkModified<Position>(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<Position>().Build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Run(World world)
|
||||||
|
{
|
||||||
|
_world.CreateEntity();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue