refactor: reorganize project structure and move examples

- Move examples (Blackjack, TicTacToe) to the root directory
- Convert example projects from Console applications to Libraries
- Add Directory.Build.props for shared build settings
- Update solution file and project references to reflect new paths
This commit is contained in:
2026-07-20 16:30:02 +08:00
parent 5594515a53
commit 7946f4bafd
80 changed files with 61 additions and 549 deletions
+179
View File
@@ -0,0 +1,179 @@
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, TrackedSubject> _componentSubjects = new();
private readonly Dictionary<QueryDescriptor, TrackedSubject> _querySubjects = new();
/// <summary>
/// Wraps a <see cref="Subject{T}"/> with a subscriber count so that
/// subjects with no remaining subscribers can be cleaned up.
/// </summary>
private sealed class TrackedSubject
{
public readonly Subject<EntityChange> Subject = new();
public int SubscriberCount;
}
/// <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 compTracked))
{
compTracked.Subject.OnNext(change);
}
}
// Push to matching query subjects.
foreach (var (query, tracked) in _querySubjects)
{
if (ChangeMatchesQuery(change, query))
{
tracked.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 tracked))
{
tracked = new TrackedSubject();
_componentSubjects[componentType] = tracked;
}
tracked.SubscriberCount++;
return WrapWithCleanup(tracked.Subject, () =>
{
tracked.SubscriberCount--;
if (tracked.SubscriberCount <= 0)
{
tracked.Subject.Dispose();
_componentSubjects.Remove(componentType);
}
});
}
/// <summary>
/// Returns an observable that emits changes matching the given query.
/// </summary>
public Observable<EntityChange> ObserveQuery(QueryDescriptor query)
{
if (!_querySubjects.TryGetValue(query, out var tracked))
{
tracked = new TrackedSubject();
_querySubjects[query] = tracked;
}
tracked.SubscriberCount++;
return WrapWithCleanup(tracked.Subject, () =>
{
tracked.SubscriberCount--;
if (tracked.SubscriberCount <= 0)
{
tracked.Subject.Dispose();
_querySubjects.Remove(query);
}
});
}
/// <summary>
/// Wraps an observable so that <paramref name="onLastDispose"/> is called
/// when the last subscriber disposes.
/// </summary>
private static Observable<EntityChange> WrapWithCleanup(
Observable<EntityChange> source, Action onLastDispose)
{
return Observable.Create<EntityChange>(observer =>
{
var subscription = source.Subscribe(observer);
return Disposable.Create(() =>
{
subscription.Dispose();
onLastDispose();
});
});
}
/// <summary>
/// Disposes all R3 subjects.
/// </summary>
public void Dispose()
{
_entitySubject.Dispose();
foreach (var tracked in _componentSubjects.Values)
{
tracked.Subject.Dispose();
}
_componentSubjects.Clear();
foreach (var tracked in _querySubjects.Values)
{
tracked.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);
}
}
+33
View File
@@ -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
}
+106
View File
@@ -0,0 +1,106 @@
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. However, if a structurally opposed
/// change arrives (Added vs Removed) for the same (entity, componentType),
/// the old entry is removed so observers see the full state transition.
/// </summary>
internal class ChangeSet
{
private readonly List<EntityChange> _changes = new();
private readonly Dictionary<(Entity Entity, ChangeKind Kind, Type? ComponentType), int> _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.ContainsKey(key))
{
// Same (entity, kind, componentType) already recorded — deduplicate.
return;
}
// When a component is re-added after being removed within the same
// batch, remove the old ComponentRemoved entry so observers see the
// full Add → Remove → Add sequence.
if (change.Kind == ChangeKind.ComponentAdded && change.ComponentType != null)
{
var removedKey = (change.Entity, ChangeKind.ComponentRemoved, change.ComponentType);
if (_dedup.Remove(removedKey, out int oldIndex))
{
_changes.RemoveAt(oldIndex);
// Adjust indices for all entries that shifted down.
foreach (var k in _dedup.Keys.ToList())
{
if (_dedup[k] > oldIndex)
_dedup[k]--;
}
}
}
_dedup[key] = _changes.Count;
_changes.Add(change);
}
}
+83
View File
@@ -0,0 +1,83 @@
namespace OECS;
/// <summary>
/// A FIFO queue of <see cref="ICommand"/> instances with deferred execution.
///
/// Commands are executed in the order they were enqueued. If a command's
/// <see cref="ICommand.Execute"/> throws, the exception is caught and stored;
/// remaining commands continue to execute.
///
/// Commands enqueued during <see cref="ExecuteAll"/> are processed in the
/// same drain cycle — the queue keeps draining until empty.
/// </summary>
public class CommandQueue
{
private readonly List<ICommand> _commands = new();
private readonly List<Exception> _errors = new();
/// <summary>
/// Number of commands currently waiting in the queue.
/// </summary>
public int Count => _commands.Count;
/// <summary>
/// Exceptions thrown by commands during <see cref="ExecuteAll"/> calls.
/// Accumulates across drain cycles. Call <see cref="ClearErrors"/> to reset.
/// </summary>
public IReadOnlyList<Exception> Errors => _errors;
/// <summary>
/// Adds a command to the end of the queue.
/// Uses a constrained generic to avoid boxing struct commands.
/// </summary>
public void Enqueue<T>(T command) where T : struct, ICommand
{
// Boxing still occurs here because the heterogeneous queue stores
// ICommand references, but the constrained generic on the method
// avoids boxing at the call site.
_commands.Add(command);
}
/// <summary>
/// Executes all queued commands in FIFO order against the given world.
///
/// The queue is fully drained — commands enqueued by other commands during
/// this call are also executed before the method returns.
/// </summary>
public void ExecuteAll(World world)
{
int index = 0;
while (index < _commands.Count)
{
var command = _commands[index];
index++;
try
{
command.Execute(world);
}
catch (Exception ex)
{
_errors.Add(ex);
}
}
_commands.Clear();
}
/// <summary>
/// Clears all queued commands without executing them.
/// </summary>
public void Clear()
{
_commands.Clear();
}
/// <summary>
/// Clears accumulated error history.
/// </summary>
public void ClearErrors()
{
_errors.Clear();
}
}
+42
View File
@@ -0,0 +1,42 @@
namespace OECS;
/// <summary>
/// Describes a component type discovered at compile time by the source generator.
/// Used by <see cref="WorldSerializer"/> to save/load components without reflection.
/// </summary>
public sealed class ComponentDescriptor
{
/// <summary>
/// The assembly-qualified name of the component type, for stable serialization
/// across assemblies.
/// </summary>
public string TypeName { get; }
/// <summary>
/// The <see cref="System.Type"/> of the component.
/// </summary>
public Type Type { get; }
/// <summary>
/// Serializes a boxed component instance to a MessagePack byte array.
/// </summary>
public Func<object, byte[]> Serialize { get; }
/// <summary>
/// Deserializes a MessagePack byte array and adds the component to the entity
/// via the typed <see cref="World.AddComponent{T}"/> method. No reflection.
/// </summary>
public Action<World, Entity, byte[]> DeserializeAndAdd { get; }
public ComponentDescriptor(
string typeName,
Type type,
Func<object, byte[]> serialize,
Action<World, Entity, byte[]> deserializeAndAdd)
{
TypeName = typeName;
Type = type;
Serialize = serialize;
DeserializeAndAdd = deserializeAndAdd;
}
}
+51
View File
@@ -0,0 +1,51 @@
using System.Runtime.CompilerServices;
namespace OECS;
/// <summary>
/// Registry of component types discovered at compile time by the
/// OECS.SourceGen incremental generator. The consuming project's
/// generated code populates this via a module initializer, so no
/// manual registration is needed.
/// </summary>
public static class ComponentRegistry
{
private static ComponentDescriptor[]? _descriptors;
private static Dictionary<string, ComponentDescriptor>? _byTypeName;
/// <summary>
/// All discovered component descriptors. Populated automatically
/// by the source generator at module initialization.
/// </summary>
public static ComponentDescriptor[] Descriptors =>
_descriptors ?? Array.Empty<ComponentDescriptor>();
/// <summary>
/// Lookup by assembly-qualified type name. Populated automatically
/// by the source generator at module initialization.
/// </summary>
public static IReadOnlyDictionary<string, ComponentDescriptor> ByTypeName
{
get
{
if (_byTypeName == null)
{
var dict = new Dictionary<string, ComponentDescriptor>();
foreach (var desc in Descriptors)
dict[desc.TypeName] = desc;
_byTypeName = dict;
}
return _byTypeName;
}
}
/// <summary>
/// Called by generated code to register discovered component types.
/// Must be called before any serialization occurs.
/// </summary>
public static void Initialize(ComponentDescriptor[] descriptors)
{
_descriptors = descriptors;
_byTypeName = null; // Rebuild on next access.
}
}
+129
View File
@@ -0,0 +1,129 @@
using System.Runtime.CompilerServices;
namespace OECS;
/// <summary>
/// Registry of all component sparse sets, keyed by component type.
/// Provides typed generic accessors that delegate to the underlying
/// <see cref="SparseSet{T}"/> instances.
/// </summary>
internal class ComponentStore
{
private readonly Dictionary<Type, ISparseSet> _sets = new();
/// <summary>
/// Gets or creates the sparse set for component type <typeparamref name="T"/>.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private SparseSet<T> GetSet<T>() where T : struct
{
var type = typeof(T);
if (!_sets.TryGetValue(type, out var set))
{
var newSet = new SparseSet<T>();
_sets[type] = newSet;
return newSet;
}
return (SparseSet<T>)set;
}
/// <summary>
/// Gets the sparse set for component type <typeparamref name="T"/> if it exists.
/// </summary>
private SparseSet<T>? GetSetIfExists<T>() where T : struct
{
if (_sets.TryGetValue(typeof(T), out var set))
return (SparseSet<T>)set;
return null;
}
/// <summary>
/// Returns all registered component types.
/// </summary>
public IEnumerable<Type> ComponentTypes => _sets.Keys;
public void Add<T>(Entity entity, T component) where T : struct
{
GetSet<T>().Add(entity, component);
}
public void Remove<T>(Entity entity) where T : struct
{
GetSetIfExists<T>()?.Remove(entity);
}
/// <summary>
/// Removes the component of the given type from the entity.
/// No-op if the entity does not have the component or the type is not registered.
/// Used for cascading relationship removal during entity destruction.
/// </summary>
public void Remove(Entity entity, Type componentType)
{
GetSet(componentType)?.Remove(entity);
}
public ref T Get<T>(Entity entity) where T : struct
{
return ref GetSet<T>().Get(entity);
}
public bool Has<T>(Entity entity) where T : struct
{
return GetSetIfExists<T>()?.Contains(entity) ?? false;
}
/// <summary>
/// Tries to get the component value for the given entity.
/// Returns true if the component exists, with the value copied to <paramref name="value"/>.
/// </summary>
public bool TryGet<T>(Entity entity, out T value) where T : struct
{
var set = GetSetIfExists<T>();
if (set != null && set.Contains(entity))
{
value = set.Get(entity);
return true;
}
value = default;
return false;
}
/// <summary>
/// Removes all components for the given entity across all component types.
/// </summary>
public void RemoveAll(Entity entity)
{
foreach (var set in _sets.Values)
{
set.Remove(entity);
}
}
/// <summary>
/// Returns the sparse set for a given type, or null if not registered.
/// Used by query execution to probe sets by Type.
/// </summary>
public ISparseSet? GetSet(Type componentType)
{
_sets.TryGetValue(componentType, out var set);
return set;
}
/// <summary>
/// Returns the count of entities in the sparse set for type <typeparamref name="T"/>.
/// Returns 0 if the set does not exist.
/// </summary>
public int Count<T>() where T : struct
{
return GetSetIfExists<T>()?.Count ?? 0;
}
/// <summary>
/// Returns the count of entities in the sparse set for the given type.
/// Returns 0 if the set does not exist.
/// </summary>
public int Count(Type componentType)
{
return GetSet(componentType)?.Count ?? 0;
}
}
+71
View File
@@ -0,0 +1,71 @@
using MessagePack;
namespace OECS;
/// <summary>
/// An opaque handle to an entity in the ECS world.
/// Combines a 24-bit identifier with an 8-bit version to prevent
/// use-after-free bugs when entity IDs are recycled.
/// </summary>
[MessagePackObject(AllowPrivate = true)]
public readonly partial struct Entity : IEquatable<Entity>
{
private const uint IdMask = 0x00FF_FFFF; // lower 24 bits
private const uint VersionMask = 0xFF00_0000; // upper 8 bits
private const int VersionShift = 24;
private const uint MaxId = IdMask;
private const uint MaxVersion = 0xFF;
/// <summary>
/// A sentinel value representing a null or invalid entity.
/// </summary>
public static readonly Entity Null = new(0);
[Key(0)]
private readonly uint _value;
internal Entity(uint id, uint version)
{
_value = (id & IdMask) | ((version & MaxVersion) << VersionShift);
}
private Entity(uint value)
{
_value = value;
}
/// <summary>
/// The 24-bit entity identifier.
/// </summary>
[IgnoreMember]
public uint Id => _value & IdMask;
/// <summary>
/// The 8-bit generation version. Incremented each time the ID is recycled.
/// </summary>
[IgnoreMember]
public uint Version => (_value & VersionMask) >> VersionShift;
/// <summary>
/// Returns true if this entity is the null sentinel.
/// </summary>
[IgnoreMember]
public bool IsNull => _value == 0;
/// <summary>
/// Creates a new entity with the given version, keeping the same ID.
/// </summary>
internal Entity WithVersion(uint version) => new(Id, version);
public bool Equals(Entity other) => _value == other._value;
public override bool Equals(object? obj) => obj is Entity other && Equals(other);
public override int GetHashCode() => _value.GetHashCode();
public override string ToString() => $"Entity({Id}:v{Version})";
public static bool operator ==(Entity left, Entity right) => left.Equals(right);
public static bool operator !=(Entity left, Entity right) => !left.Equals(right);
}
+110
View File
@@ -0,0 +1,110 @@
namespace OECS;
/// <summary>
/// Manages entity ID allocation and recycling.
///
/// Uses a free-list for recycled IDs and a bump allocator for new IDs.
/// Each ID slot tracks a version byte that increments on recycle to
/// prevent use-after-free bugs.
/// </summary>
internal class EntityAllocator
{
private const int DefaultCapacity = 1024;
private const uint FirstValidId = 2; // 0 = Null, 1 = Singleton
private byte[] _versions;
private readonly Stack<uint> _freeIds;
private uint _nextId;
public EntityAllocator(int initialCapacity = DefaultCapacity)
{
_versions = new byte[initialCapacity];
_freeIds = new Stack<uint>();
_nextId = FirstValidId;
}
/// <summary>
/// Allocates a new entity. Reuses a free ID if available, otherwise
/// bumps the allocator.
/// </summary>
public Entity Allocate()
{
uint id;
if (_freeIds.Count > 0)
{
id = _freeIds.Pop();
}
else
{
id = _nextId++;
EnsureCapacity(id);
// First time this ID is used — start at version 1.
// 0 means "never alive" and is used by the sentinel.
_versions[id] = 1;
}
return new Entity(id, _versions[id]);
}
/// <summary>
/// Frees an entity ID, making it available for reuse.
/// Increments the version to invalidate any outstanding references.
/// </summary>
public void Free(Entity entity)
{
uint id = entity.Id;
EnsureCapacity(id);
// Increment version (wrap at 255 back to 1; 0 means "never alive").
_versions[id]++;
if (_versions[id] == 0)
_versions[id] = 1;
_freeIds.Push(id);
}
/// <summary>
/// Registers the singleton entity so that <see cref="IsAlive"/> returns true for it.
/// Called once by <see cref="World"/> when the first singleton accessor is used.
/// </summary>
public void RegisterSingleton(Entity singleton)
{
EnsureCapacity(singleton.Id);
_versions[singleton.Id] = (byte)singleton.Version;
}
/// <summary>
/// Reserves a specific entity ID and version for deserialization.
/// Advances <see cref="_nextId"/> past the reserved ID so future
/// allocations don't collide.
/// </summary>
public void Reserve(Entity entity)
{
uint id = entity.Id;
EnsureCapacity(id);
_versions[id] = (byte)entity.Version;
if (id >= _nextId)
_nextId = id + 1;
}
/// <summary>
/// Returns true if the entity's version matches the current version for its ID.
/// </summary>
public bool IsAlive(Entity entity)
{
uint id = entity.Id;
if (entity.IsNull) return false;
if (id == 1) return true; // Singleton is always alive.
if (id >= _versions.Length) return false;
return _versions[id] == entity.Version && _versions[id] != 0;
}
private void EnsureCapacity(uint id)
{
if (id >= _versions.Length)
{
int newSize = Math.Max((int)id + 1, _versions.Length * 2);
Array.Resize(ref _versions, newSize);
}
}
}
+45
View File
@@ -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}";
}
+327
View File
@@ -0,0 +1,327 @@
using System.Runtime.CompilerServices;
namespace OECS;
/// <summary>
/// Provides zero-allocation ref struct iterators for querying entities.
/// Use with <c>foreach</c> or manual <c>while (iter.MoveNext())</c>.
/// Supports <c>break</c>, <c>continue</c>, and early returns.
/// </summary>
public static class EntityIterator
{
/// <summary>
/// Returns an iterator over entities matching the query, with ref access
/// to one component type.
/// </summary>
public static Select1<T1> Select<T1>(
this World world, QueryDescriptor query)
where T1 : struct
{
return new Select1<T1>(world, query);
}
/// <summary>
/// Returns an iterator over entities that have a component of type
/// <typeparamref name="T1"/>. No <c>Without</c> filter is applied.
/// </summary>
public static Select1<T1> Select<T1>(
this World world)
where T1 : struct
{
return new Select1<T1>(world, new QueryDescriptor(new HashSet<Type>(), new HashSet<Type>()));
}
/// <summary>
/// Returns an iterator over entities matching the query, with ref access
/// to two component types.
/// </summary>
public static Select2<T1, T2> Select<T1, T2>(
this World world, QueryDescriptor query)
where T1 : struct where T2 : struct
{
return new Select2<T1, T2>(world, query);
}
/// <summary>
/// Returns an iterator over entities that have both components of type
/// <typeparamref name="T1"/> and <typeparamref name="T2"/>.
/// No <c>Without</c> filter is applied.
/// </summary>
public static Select2<T1, T2> Select<T1, T2>(
this World world)
where T1 : struct where T2 : struct
{
return new Select2<T1, T2>(world, new QueryDescriptor(new HashSet<Type>(), new HashSet<Type>()));
}
/// <summary>
/// Returns an iterator over entities matching the query, with ref access
/// to three component types.
/// </summary>
public static Select3<T1, T2, T3> Select<T1, T2, T3>(
this World world, QueryDescriptor query)
where T1 : struct where T2 : struct where T3 : struct
{
return new Select3<T1, T2, T3>(world, query);
}
/// <summary>
/// Returns an iterator over entities that have all three components of type
/// <typeparamref name="T1"/>, <typeparamref name="T2"/>, and
/// <typeparamref name="T3"/>. No <c>Without</c> filter is applied.
/// </summary>
public static Select3<T1, T2, T3> Select<T1, T2, T3>(
this World world)
where T1 : struct where T2 : struct where T3 : struct
{
return new Select3<T1, T2, T3>(world, new QueryDescriptor(new HashSet<Type>(), new HashSet<Type>()));
}
}
/// <summary>
/// Ref struct iterator for queries with one component type.
/// </summary>
public ref struct Select1<T1> where T1 : struct
{
private readonly World _world;
private readonly SparseSet<T1>? _set;
private readonly ComponentStore _store;
private readonly IReadOnlySet<Type> _without;
private int _index;
private readonly int _count;
internal Select1(World world, QueryDescriptor query)
{
_world = world;
_store = world.Components;
_without = query.Without;
_set = _store.GetSet(typeof(T1)) as SparseSet<T1>;
_count = _set?.Count ?? 0;
_index = -1;
_world.BeginIteration();
}
public Entity CurrentEntity { get; private set; }
public ref T1 Current1 => ref _set!.Get(CurrentEntity);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool MoveNext()
{
if (_set == null) return false;
while (++_index < _count)
{
CurrentEntity = _set.DenseEntities[_index];
if (CurrentEntity.Id == 1) continue; // Skip singleton.
if (!PassesWithout()) continue;
return true;
}
return false;
}
public Select1<T1> GetEnumerator() => this;
public void Dispose()
{
_world.EndIteration();
}
private bool PassesWithout()
{
foreach (var type in _without)
{
var set = _store.GetSet(type);
if (set != null && set.Contains(CurrentEntity))
return false;
}
return true;
}
}
/// <summary>
/// Ref struct iterator for queries with two component types.
/// Drives iteration from the smaller sparse set to minimize probes.
/// </summary>
public ref struct Select2<T1, T2>
where T1 : struct where T2 : struct
{
private readonly World _world;
private readonly SparseSet<T1>? _set1;
private readonly SparseSet<T2>? _set2;
private readonly ComponentStore _store;
private readonly IReadOnlySet<Type> _without;
private int _index;
private readonly int _count;
private readonly bool _swapped; // true when driving from _set2
internal Select2(World world, QueryDescriptor query)
{
_world = world;
_store = world.Components;
_without = query.Without;
_set1 = _store.GetSet(typeof(T1)) as SparseSet<T1>;
_set2 = _store.GetSet(typeof(T2)) as SparseSet<T2>;
int count1 = _set1?.Count ?? 0;
int count2 = _set2?.Count ?? 0;
_swapped = count2 < count1;
_count = _swapped ? count2 : count1;
_index = -1;
_world.BeginIteration();
}
public Entity CurrentEntity { get; private set; }
public ref T1 Current1 => ref _set1!.Get(CurrentEntity);
public ref T2 Current2 => ref _set2!.Get(CurrentEntity);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool MoveNext()
{
if (_set1 == null || _set2 == null) return false;
while (++_index < _count)
{
if (_swapped)
{
CurrentEntity = _set2!.DenseEntities[_index];
if (CurrentEntity.Id == 1) continue;
if (!_set1.Contains(CurrentEntity)) continue;
}
else
{
CurrentEntity = _set1.DenseEntities[_index];
if (CurrentEntity.Id == 1) continue;
if (!_set2.Contains(CurrentEntity)) continue;
}
if (!PassesWithout()) continue;
return true;
}
return false;
}
public Select2<T1, T2> GetEnumerator() => this;
public void Dispose()
{
_world.EndIteration();
}
private bool PassesWithout()
{
foreach (var type in _without)
{
var set = _store.GetSet(type);
if (set != null && set.Contains(CurrentEntity))
return false;
}
return true;
}
}
/// <summary>
/// Ref struct iterator for queries with three component types.
/// Drives iteration from the smallest sparse set to minimize probes.
/// </summary>
public ref struct Select3<T1, T2, T3>
where T1 : struct where T2 : struct where T3 : struct
{
private readonly World _world;
private readonly SparseSet<T1>? _set1;
private readonly SparseSet<T2>? _set2;
private readonly SparseSet<T3>? _set3;
private readonly ComponentStore _store;
private readonly IReadOnlySet<Type> _without;
private int _index;
private readonly int _count;
private readonly int _driver; // 0 = _set1, 1 = _set2, 2 = _set3
internal Select3(World world, QueryDescriptor query)
{
_world = world;
_store = world.Components;
_without = query.Without;
_set1 = _store.GetSet(typeof(T1)) as SparseSet<T1>;
_set2 = _store.GetSet(typeof(T2)) as SparseSet<T2>;
_set3 = _store.GetSet(typeof(T3)) as SparseSet<T3>;
int count1 = _set1?.Count ?? 0;
int count2 = _set2?.Count ?? 0;
int count3 = _set3?.Count ?? 0;
if (count2 <= count1 && count2 <= count3)
{
_driver = 1;
_count = count2;
}
else if (count3 <= count1 && count3 <= count2)
{
_driver = 2;
_count = count3;
}
else
{
_driver = 0;
_count = count1;
}
_index = -1;
_world.BeginIteration();
}
public Entity CurrentEntity { get; private set; }
public ref T1 Current1 => ref _set1!.Get(CurrentEntity);
public ref T2 Current2 => ref _set2!.Get(CurrentEntity);
public ref T3 Current3 => ref _set3!.Get(CurrentEntity);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool MoveNext()
{
if (_set1 == null || _set2 == null || _set3 == null) return false;
while (++_index < _count)
{
switch (_driver)
{
case 0:
CurrentEntity = _set1.DenseEntities[_index];
if (CurrentEntity.Id == 1) continue;
if (!_set2.Contains(CurrentEntity)) continue;
if (!_set3.Contains(CurrentEntity)) continue;
break;
case 1:
CurrentEntity = _set2!.DenseEntities[_index];
if (CurrentEntity.Id == 1) continue;
if (!_set1.Contains(CurrentEntity)) continue;
if (!_set3.Contains(CurrentEntity)) continue;
break;
case 2:
CurrentEntity = _set3!.DenseEntities[_index];
if (CurrentEntity.Id == 1) continue;
if (!_set1.Contains(CurrentEntity)) continue;
if (!_set2.Contains(CurrentEntity)) continue;
break;
}
if (!PassesWithout()) continue;
return true;
}
return false;
}
public Select3<T1, T2, T3> GetEnumerator() => this;
public void Dispose()
{
_world.EndIteration();
}
private bool PassesWithout()
{
foreach (var type in _without)
{
var set = _store.GetSet(type);
if (set != null && set.Contains(CurrentEntity))
return false;
}
return true;
}
}
+39
View File
@@ -0,0 +1,39 @@
namespace OECS;
// Custom delegate types for query iteration with ref parameters.
// The built-in Action<...> delegates do not support ref parameters.
/// <summary>
/// Callback for iterating entities with one component type.
/// </summary>
public delegate void ForEachAction<T1>(Entity entity, ref T1 c1) where T1 : struct;
/// <summary>
/// Callback for iterating entities with two component types.
/// </summary>
public delegate void ForEachAction<T1, T2>(Entity entity, ref T1 c1, ref T2 c2)
where T1 : struct where T2 : struct;
/// <summary>
/// Callback for iterating entities with three component types.
/// </summary>
public delegate void ForEachAction<T1, T2, T3>(Entity entity, ref T1 c1, ref T2 c2, ref T3 c3)
where T1 : struct where T2 : struct where T3 : struct;
/// <summary>
/// Callback for iterating entities with four component types.
/// </summary>
public delegate void ForEachAction<T1, T2, T3, T4>(Entity entity, ref T1 c1, ref T2 c2, ref T3 c3, ref T4 c4)
where T1 : struct where T2 : struct where T3 : struct where T4 : struct;
/// <summary>
/// Callback for iterating entities with five component types.
/// </summary>
public delegate void ForEachAction<T1, T2, T3, T4, T5>(Entity entity, ref T1 c1, ref T2 c2, ref T3 c3, ref T4 c4, ref T5 c5)
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct;
/// <summary>
/// Callback for iterating entities with six component types.
/// </summary>
public delegate void ForEachAction<T1, T2, T3, T4, T5, T6>(Entity entity, ref T1 c1, ref T2 c2, ref T3 c3, ref T4 c4, ref T5 c5, ref T6 c6)
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct where T6 : struct;
+14
View File
@@ -0,0 +1,14 @@
namespace OECS;
/// <summary>
/// A serializable command that can be enqueued and executed against a <see cref="World"/>.
/// Implementations should be <c>[MessagePackObject]</c> structs so they can be
/// serialized and replayed.
/// </summary>
public interface ICommand
{
/// <summary>
/// Executes the command against the given world.
/// </summary>
void Execute(World world);
}
+22
View File
@@ -0,0 +1,22 @@
namespace OECS;
/// <summary>
/// Marker interface for components that represent a directed relationship
/// between two entities. The component is stored on the <see cref="Source"/>
/// entity and points to the <see cref="Target"/> entity.
///
/// The <see cref="World"/> automatically maintains a reverse index so that
/// all sources pointing to a given target can be looked up efficiently.
/// </summary>
public interface IRelationship
{
/// <summary>
/// The entity that owns this relationship component.
/// </summary>
Entity Source { get; }
/// <summary>
/// The entity that this relationship points to.
/// </summary>
Entity Target { get; }
}
+13
View File
@@ -0,0 +1,13 @@
namespace OECS;
/// <summary>
/// A system that runs on every tick.
/// Systems receive the world in their <see cref="Run"/> method.
/// </summary>
public interface ISystem
{
/// <summary>
/// Executes the system logic for this tick.
/// </summary>
void Run(World world);
}
+13
View File
@@ -0,0 +1,13 @@
namespace OECS;
/// <summary>
/// A system that receives tick data (delta time or logical tick) in addition
/// to the world reference.
/// </summary>
public interface ITickedSystem : ISystem
{
/// <summary>
/// Executes the system logic with tick information.
/// </summary>
void Run(World world, Tick tick);
}
+39
View File
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>OECS</RootNamespace>
<AssemblyName>OECS</AssemblyName>
<!-- NuGet Package Metadata -->
<PackageId>OECS</PackageId>
<Version>0.1.0</Version>
<Description>Observable ECS for C# — an entity component system focused on a clean reactive API surface.</Description>
<PackageTags>ecs;reactive;observable;gamedev</PackageTags>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MessagePack" Version="3.1.7" />
<PackageReference Include="MessagePackAnalyzer" Version="3.1.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="R3" Version="1.2.9" />
</ItemGroup>
<!-- Bundle the source generator into the NuGet package as an analyzer.
Consumers get compile-time component discovery automatically. -->
<ItemGroup>
<None Include="..\OECS.SourceGen\bin\$(Configuration)\netstandard2.0\OECS.SourceGen.dll"
Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>
<!-- Ensure the source generator is built before packing.
Skipped when NoBuild property is set to true. -->
<Target Name="BuildSourceGenerator" BeforeTargets="GenerateNuspec"
Condition="'$(NoBuild)' != 'true'">
<MSBuild Projects="..\OECS.SourceGen\OECS.SourceGen.csproj"
Targets="Build"
Properties="Configuration=$(Configuration)" />
</Target>
</Project>
+39
View File
@@ -0,0 +1,39 @@
namespace OECS;
/// <summary>
/// Fluent builder for constructing <see cref="QueryDescriptor"/> instances.
/// Returned by <see cref="World.Query"/>.
/// </summary>
public class QueryBuilder
{
private readonly HashSet<Type> _with = new();
private readonly HashSet<Type> _without = new();
/// <summary>
/// Requires entities to have a component of type <typeparamref name="T"/>.
/// </summary>
public QueryBuilder With<T>() where T : struct
{
_with.Add(typeof(T));
return this;
}
/// <summary>
/// Excludes entities that have a component of type <typeparamref name="T"/>.
/// </summary>
public QueryBuilder Without<T>() where T : struct
{
_without.Add(typeof(T));
return this;
}
/// <summary>
/// Builds the query descriptor.
/// </summary>
public QueryDescriptor Build()
{
return new QueryDescriptor(
new HashSet<Type>(_with),
new HashSet<Type>(_without));
}
}
+53
View File
@@ -0,0 +1,53 @@
namespace OECS;
/// <summary>
/// Describes a query over the ECS world.
/// Composed of a set of required component types ("with") and
/// a set of excluded component types ("without").
/// </summary>
public class QueryDescriptor
{
/// <summary>
/// Component types that must be present on matching entities.
/// </summary>
public IReadOnlySet<Type> With { get; }
/// <summary>
/// Component types that must NOT be present on matching entities.
/// </summary>
public IReadOnlySet<Type> Without { get; }
private readonly int _hashCode;
internal QueryDescriptor(HashSet<Type> with, HashSet<Type> without)
{
With = with;
Without = without;
_hashCode = ComputeHashCode();
}
/// <summary>
/// Returns true if this query has no "with" components (matches nothing).
/// </summary>
internal bool IsEmpty => With.Count == 0;
public override bool Equals(object? obj)
{
if (obj is not QueryDescriptor other) return false;
if (With.Count != other.With.Count || Without.Count != other.Without.Count)
return false;
return With.SetEquals(other.With) && Without.SetEquals(other.Without);
}
public override int GetHashCode() => _hashCode;
private int ComputeHashCode()
{
var hash = new HashCode();
foreach (var t in With.OrderBy(t => t.GUID.ToString()))
hash.Add(t);
foreach (var t in Without.OrderBy(t => t.GUID.ToString()))
hash.Add(t);
return hash.ToHashCode();
}
}
+507
View File
@@ -0,0 +1,507 @@
using System.Runtime.CompilerServices;
namespace OECS;
/// <summary>
/// Provides query execution logic for <see cref="World"/>.
/// Iterates one sparse set as the driver and probes the remaining sets
/// for membership. The "without" filter is checked after all "with" probes.
///
/// The singleton entity (ID 1) is automatically excluded from all queries.
/// </summary>
internal static class QueryExecutor
{
private const uint SingletonId = 1;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool PassesWithoutFilter(ComponentStore store, Entity entity, IReadOnlySet<Type> withoutTypes)
{
foreach (var type in withoutTypes)
{
var set = store.GetSet(type);
if (set != null && set.Contains(entity))
return false;
}
return true;
}
// ── ForEach overloads ──────────────────────────────────────────────
public static void ForEach<T1>(
ComponentStore store,
QueryDescriptor query,
ForEachAction<T1> action)
where T1 : struct
{
var set = store.GetSet(typeof(T1)) as SparseSet<T1>;
if (set == null) return;
var dense = set.Dense;
var denseEntities = set.DenseEntities;
var count = set.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!PassesWithoutFilter(store, entity, query.Without))
continue;
action(entity, ref dense[i]);
}
}
public static void ForEach<T1, T2>(
ComponentStore store,
QueryDescriptor query,
ForEachAction<T1, T2> action)
where T1 : struct where T2 : struct
{
var set1 = store.GetSet(typeof(T1)) as SparseSet<T1>;
if (set1 == null) return;
var set2 = store.GetSet(typeof(T2)) as SparseSet<T2>;
if (set2 == null) return;
if (set1.Count <= set2.Count)
{
IterateTwo(set1, set2, store, query.Without, action);
}
else
{
IterateTwoSwapped(set2, set1, store, query.Without, action);
}
}
private static void IterateTwo<TDriver, TOther>(
SparseSet<TDriver> driveSet,
SparseSet<TOther> otherSet,
ComponentStore store,
IReadOnlySet<Type> withoutTypes,
ForEachAction<TDriver, TOther> action)
where TDriver : struct where TOther : struct
{
var dense = driveSet.Dense;
var denseEntities = driveSet.DenseEntities;
var count = driveSet.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!otherSet.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, withoutTypes))
continue;
action(entity, ref dense[i], ref otherSet.Get(entity));
}
}
private static void IterateTwoSwapped<TOther, TDriver>(
SparseSet<TDriver> driveSet,
SparseSet<TOther> otherSet,
ComponentStore store,
IReadOnlySet<Type> withoutTypes,
ForEachAction<TOther, TDriver> action)
where TDriver : struct where TOther : struct
{
var dense = driveSet.Dense;
var denseEntities = driveSet.DenseEntities;
var count = driveSet.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!otherSet.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, withoutTypes))
continue;
action(entity, ref otherSet.Get(entity), ref dense[i]);
}
}
public static void ForEach<T1, T2, T3>(
ComponentStore store,
QueryDescriptor query,
ForEachAction<T1, T2, T3> action)
where T1 : struct where T2 : struct where T3 : struct
{
var set1 = store.GetSet(typeof(T1)) as SparseSet<T1>;
if (set1 == null) return;
var set2 = store.GetSet(typeof(T2)) as SparseSet<T2>;
if (set2 == null) return;
var set3 = store.GetSet(typeof(T3)) as SparseSet<T3>;
if (set3 == null) return;
// Pick the smallest set as the driver.
int c1 = set1.Count, c2 = set2.Count, c3 = set3.Count;
if (c2 <= c1 && c2 <= c3)
{
var dense = set2.Dense;
var denseEntities = set2.DenseEntities;
var count = set2.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref dense[i], ref set3.Get(entity));
}
}
else if (c3 <= c1 && c3 <= c2)
{
var dense = set3.Dense;
var denseEntities = set3.DenseEntities;
var count = set3.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref dense[i]);
}
}
else
{
var dense = set1.Dense;
var denseEntities = set1.DenseEntities;
var count = set1.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity));
}
}
}
public static void ForEach<T1, T2, T3, T4>(
ComponentStore store,
QueryDescriptor query,
ForEachAction<T1, T2, T3, T4> action)
where T1 : struct where T2 : struct where T3 : struct where T4 : struct
{
var set1 = store.GetSet(typeof(T1)) as SparseSet<T1>;
if (set1 == null) return;
var set2 = store.GetSet(typeof(T2)) as SparseSet<T2>;
if (set2 == null) return;
var set3 = store.GetSet(typeof(T3)) as SparseSet<T3>;
if (set3 == null) return;
var set4 = store.GetSet(typeof(T4)) as SparseSet<T4>;
if (set4 == null) return;
// Pick the smallest set as the driver.
int c1 = set1.Count, c2 = set2.Count, c3 = set3.Count, c4 = set4.Count;
int min = Math.Min(Math.Min(c1, c2), Math.Min(c3, c4));
if (min == c2)
{
var dense = set2.Dense;
var denseEntities = set2.DenseEntities;
var count = set2.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref dense[i], ref set3.Get(entity), ref set4.Get(entity));
}
}
else if (min == c3)
{
var dense = set3.Dense;
var denseEntities = set3.DenseEntities;
var count = set3.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref dense[i], ref set4.Get(entity));
}
}
else if (min == c4)
{
var dense = set4.Dense;
var denseEntities = set4.DenseEntities;
var count = set4.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref set3.Get(entity), ref dense[i]);
}
}
else
{
var dense = set1.Dense;
var denseEntities = set1.DenseEntities;
var count = set1.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity));
}
}
}
public static void ForEach<T1, T2, T3, T4, T5>(
ComponentStore store,
QueryDescriptor query,
ForEachAction<T1, T2, T3, T4, T5> action)
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct
{
var set1 = store.GetSet(typeof(T1)) as SparseSet<T1>;
if (set1 == null) return;
var set2 = store.GetSet(typeof(T2)) as SparseSet<T2>;
if (set2 == null) return;
var set3 = store.GetSet(typeof(T3)) as SparseSet<T3>;
if (set3 == null) return;
var set4 = store.GetSet(typeof(T4)) as SparseSet<T4>;
if (set4 == null) return;
var set5 = store.GetSet(typeof(T5)) as SparseSet<T5>;
if (set5 == null) return;
// Pick the smallest set as the driver.
int c1 = set1.Count, c2 = set2.Count, c3 = set3.Count, c4 = set4.Count, c5 = set5.Count;
int min = Math.Min(Math.Min(Math.Min(c1, c2), Math.Min(c3, c4)), c5);
if (min == c2)
{
var dense = set2.Dense;
var denseEntities = set2.DenseEntities;
var count = set2.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref dense[i], ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity));
}
}
else if (min == c3)
{
var dense = set3.Dense;
var denseEntities = set3.DenseEntities;
var count = set3.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref dense[i], ref set4.Get(entity), ref set5.Get(entity));
}
}
else if (min == c4)
{
var dense = set4.Dense;
var denseEntities = set4.DenseEntities;
var count = set4.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref set3.Get(entity), ref dense[i], ref set5.Get(entity));
}
}
else if (min == c5)
{
var dense = set5.Dense;
var denseEntities = set5.DenseEntities;
var count = set5.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref dense[i]);
}
}
else
{
var dense = set1.Dense;
var denseEntities = set1.DenseEntities;
var count = set1.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity));
}
}
}
public static void ForEach<T1, T2, T3, T4, T5, T6>(
ComponentStore store,
QueryDescriptor query,
ForEachAction<T1, T2, T3, T4, T5, T6> action)
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct where T6 : struct
{
var set1 = store.GetSet(typeof(T1)) as SparseSet<T1>;
if (set1 == null) return;
var set2 = store.GetSet(typeof(T2)) as SparseSet<T2>;
if (set2 == null) return;
var set3 = store.GetSet(typeof(T3)) as SparseSet<T3>;
if (set3 == null) return;
var set4 = store.GetSet(typeof(T4)) as SparseSet<T4>;
if (set4 == null) return;
var set5 = store.GetSet(typeof(T5)) as SparseSet<T5>;
if (set5 == null) return;
var set6 = store.GetSet(typeof(T6)) as SparseSet<T6>;
if (set6 == null) return;
// Pick the smallest set as the driver.
int c1 = set1.Count, c2 = set2.Count, c3 = set3.Count, c4 = set4.Count, c5 = set5.Count, c6 = set6.Count;
int min = Math.Min(Math.Min(Math.Min(c1, c2), Math.Min(c3, c4)), Math.Min(c5, c6));
if (min == c2)
{
var dense = set2.Dense;
var denseEntities = set2.DenseEntities;
var count = set2.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!set6.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref dense[i], ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity), ref set6.Get(entity));
}
}
else if (min == c3)
{
var dense = set3.Dense;
var denseEntities = set3.DenseEntities;
var count = set3.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!set6.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref dense[i], ref set4.Get(entity), ref set5.Get(entity), ref set6.Get(entity));
}
}
else if (min == c4)
{
var dense = set4.Dense;
var denseEntities = set4.DenseEntities;
var count = set4.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!set6.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref set3.Get(entity), ref dense[i], ref set5.Get(entity), ref set6.Get(entity));
}
}
else if (min == c5)
{
var dense = set5.Dense;
var denseEntities = set5.DenseEntities;
var count = set5.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!set6.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref dense[i], ref set6.Get(entity));
}
}
else if (min == c6)
{
var dense = set6.Dense;
var denseEntities = set6.DenseEntities;
var count = set6.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity), ref dense[i]);
}
}
else
{
var dense = set1.Dense;
var denseEntities = set1.DenseEntities;
var count = set1.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!set6.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity), ref set6.Get(entity));
}
}
}
}
+41
View File
@@ -0,0 +1,41 @@
using MessagePack;
namespace OECS;
/// <summary>
/// A convenience base struct for relationship components.
///
/// The type parameters <typeparamref name="TSelf"/> and <typeparamref name="TTarget"/>
/// are phantom types that differentiate relationship kinds at the type level,
/// enabling type-safe queries and reverse lookups.
///
/// <example>
/// <code>
/// // Define a "ChildOf" relationship between a child and a parent entity.
/// world.AddComponent(child, new Relationship&lt;ChildOf, Parent&gt;
/// {
/// Source = child,
/// Target = parent
/// });
///
/// // Later, find all children of a parent.
/// var children = world.GetSources&lt;Relationship&lt;ChildOf, Parent&gt;&gt;(parent);
/// </code>
/// </example>
/// </summary>
[MessagePackObject]
public struct Relationship<TSelf, TTarget> : IRelationship
where TSelf : struct where TTarget : struct
{
/// <summary>
/// The entity that owns this relationship component.
/// </summary>
[Key(0)]
public Entity Source { get; set; }
/// <summary>
/// The entity that this relationship points to.
/// </summary>
[Key(1)]
public Entity Target { get; set; }
}
+144
View File
@@ -0,0 +1,144 @@
using System.Runtime.CompilerServices;
namespace OECS;
/// <summary>
/// Maintains a reverse index for relationship components, mapping
/// target entities to the set of source entities that point to them.
///
/// Updated automatically by <see cref="World"/> when relationship
/// components are added, removed, or when entities are destroyed.
/// </summary>
internal class RelationshipIndex
{
/// <summary>
/// Per relationship type: target entity → set of source entities.
/// </summary>
private readonly Dictionary<Type, Dictionary<Entity, HashSet<Entity>>> _index = new();
/// <summary>
/// Registers that a source entity now has a relationship pointing to a target.
/// </summary>
public void OnAdded(Type relationshipType, Entity source, Entity target)
{
if (!_index.TryGetValue(relationshipType, out var targetMap))
{
targetMap = new Dictionary<Entity, HashSet<Entity>>();
_index[relationshipType] = targetMap;
}
if (!targetMap.TryGetValue(target, out var sources))
{
sources = new HashSet<Entity>();
targetMap[target] = sources;
}
sources.Add(source);
}
/// <summary>
/// Unregisters a relationship from a source entity pointing to a target.
/// </summary>
public void OnRemoved(Type relationshipType, Entity source, Entity target)
{
if (!_index.TryGetValue(relationshipType, out var targetMap))
return;
if (!targetMap.TryGetValue(target, out var sources))
return;
sources.Remove(source);
if (sources.Count == 0)
targetMap.Remove(target);
}
/// <summary>
/// Returns all source entities that have a relationship of the given type
/// pointing to the specified target entity.
/// </summary>
public IReadOnlyCollection<Entity> GetSources<T>(Entity target)
where T : struct, IRelationship
{
var type = typeof(T);
if (!_index.TryGetValue(type, out var targetMap))
return Array.Empty<Entity>();
if (!targetMap.TryGetValue(target, out var sources))
return Array.Empty<Entity>();
return sources;
}
/// <summary>
/// Returns all target entities that the given source entity points to
/// via the specified relationship type. Returns an empty collection if none.
/// </summary>
public IReadOnlyCollection<Entity> GetTargets(Type relationshipType, Entity source)
{
if (!_index.TryGetValue(relationshipType, out var targetMap))
return Array.Empty<Entity>();
var result = new List<Entity>();
foreach (var (target, sources) in targetMap)
{
if (sources.Contains(source))
result.Add(target);
}
return result;
}
/// <summary>
/// Removes all index entries where the given entity appears as a source.
/// Called before the entity's components are removed during destruction.
/// </summary>
public void RemoveAllSourcesForEntity(Entity entity)
{
foreach (var (_, targetMap) in _index)
{
foreach (var (target, sources) in targetMap)
{
sources.Remove(entity);
}
}
// Clean up empty target entries.
foreach (var (_, targetMap) in _index)
{
var emptyTargets = new List<Entity>();
foreach (var (target, sources) in targetMap)
{
if (sources.Count == 0)
emptyTargets.Add(target);
}
foreach (var target in emptyTargets)
{
targetMap.Remove(target);
}
}
}
/// <summary>
/// Returns all relationship types for which the given entity is a target,
/// along with the set of source entities pointing to it.
/// Used during entity destruction to cascade-remove incoming relationships.
/// </summary>
public IEnumerable<(Type RelationshipType, IReadOnlyCollection<Entity> Sources)> GetIncomingRelationships(Entity target)
{
foreach (var (relType, targetMap) in _index)
{
if (targetMap.TryGetValue(target, out var sources) && sources.Count > 0)
{
yield return (relType, sources);
}
}
}
/// <summary>
/// Returns true if the index has any entries for the given relationship type.
/// </summary>
public bool HasType(Type relationshipType)
{
return _index.ContainsKey(relationshipType);
}
}
+211
View File
@@ -0,0 +1,211 @@
using System.Runtime.CompilerServices;
namespace OECS;
/// <summary>
/// Non-generic interface for sparse set operations that don't require
/// knowing the component type at compile time (e.g., entity destruction).
/// </summary>
internal interface ISparseSet
{
void Remove(Entity entity);
bool Contains(Entity entity);
int Count { get; }
/// <summary>
/// Returns the dense entity array (first <see cref="Count"/> elements are valid).
/// Used by serialization to enumerate entities without knowing the component type.
/// </summary>
Entity[] GetDenseEntities();
/// <summary>
/// Returns the component at the given dense index as an object.
/// Used by serialization to extract component values.
/// </summary>
object GetComponentAt(int denseIndex);
}
/// <summary>
/// A sparse set storing components of type <typeparamref name="T"/>.
///
/// Uses a dense array (packed, no holes) and a sparse array (maps entity ID
/// to dense index) for O(1) add, remove, and lookup. Swap-remove keeps
/// removals cheap at the cost of iteration order instability.
/// </summary>
internal class SparseSet<T> : ISparseSet where T : struct
{
private const int DefaultCapacity = 64;
private T[] _dense;
private Entity[] _denseEntities;
private int[] _sparse;
private int _count;
public SparseSet(int initialCapacity = DefaultCapacity)
{
_dense = new T[initialCapacity];
_denseEntities = new Entity[initialCapacity];
_sparse = new int[initialCapacity];
Array.Fill(_sparse, -1);
}
/// <summary>
/// Number of components currently stored.
/// </summary>
public int Count => _count;
/// <summary>
/// The dense array of component values (first <see cref="Count"/> elements are valid).
/// </summary>
public T[] Dense => _dense;
/// <summary>
/// The dense array of entity IDs, parallel to <see cref="Dense"/>.
/// </summary>
public Entity[] DenseEntities => _denseEntities;
/// <summary>
/// Adds a component for the given entity. Replaces if already present.
/// </summary>
public void Add(Entity entity, T component)
{
EnsureSparseCapacity(entity.Id);
int index = _sparse[entity.Id];
if (index != -1)
{
// Already exists — replace in place.
_dense[index] = component;
return;
}
EnsureDenseCapacity(_count + 1);
index = _count;
_dense[index] = component;
_denseEntities[index] = entity;
_sparse[entity.Id] = index;
_count++;
}
/// <summary>
/// Removes the component for the given entity. No-op if not present.
/// </summary>
public void Remove(Entity entity)
{
if (entity.Id >= (uint)_sparse.Length)
return;
int index = _sparse[entity.Id];
if (index == -1)
return;
// Swap-remove: move the last element into the removed slot.
int lastIndex = _count - 1;
if (index != lastIndex)
{
_dense[index] = _dense[lastIndex];
_denseEntities[index] = _denseEntities[lastIndex];
_sparse[_denseEntities[index].Id] = index;
}
_dense[lastIndex] = default!;
_denseEntities[lastIndex] = default;
_sparse[entity.Id] = -1;
_count--;
}
/// <summary>
/// Returns a reference to the component for the given entity.
/// Throws if the entity does not have this component.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public ref T Get(Entity entity)
{
if (entity.Id >= (uint)_sparse.Length)
ThrowEntityNotPresent(entity);
int index = _sparse[entity.Id];
if (index == -1)
ThrowEntityNotPresent(entity);
return ref _dense[index];
}
/// <summary>
/// Tries to get the component value for the given entity.
/// Returns true and copies the value to <paramref name="value"/> if present.
/// </summary>
public bool TryGet(Entity entity, out T value)
{
if (entity.Id >= (uint)_sparse.Length)
{
value = default;
return false;
}
int index = _sparse[entity.Id];
if (index == -1)
{
value = default;
return false;
}
value = _dense[index];
return true;
}
private static void ThrowEntityNotPresent(Entity entity)
{
throw new InvalidOperationException(
$"Entity {entity} does not have component of type {typeof(T).Name}.");
}
/// <summary>
/// Returns true if the entity has this component.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool Contains(Entity entity)
{
return entity.Id < (uint)_sparse.Length && _sparse[entity.Id] != -1;
}
/// <summary>
/// Removes all components.
/// </summary>
public void Clear()
{
Array.Clear(_dense, 0, _count);
for (int i = 0; i < _count; i++)
{
_sparse[_denseEntities[i].Id] = -1;
}
Array.Clear(_denseEntities, 0, _count);
_count = 0;
}
Entity[] ISparseSet.GetDenseEntities() => _denseEntities;
object ISparseSet.GetComponentAt(int denseIndex) => _dense[denseIndex]!;
private void EnsureSparseCapacity(uint entityId)
{
if (entityId >= (uint)_sparse.Length)
{
int oldSize = _sparse.Length;
int newSize = Math.Max((int)entityId + 1, _sparse.Length * 2);
Array.Resize(ref _sparse, newSize);
Array.Fill(_sparse, -1, oldSize, newSize - oldSize);
}
}
private void EnsureDenseCapacity(int required)
{
if (required > _dense.Length)
{
int newSize = Math.Max(required, _dense.Length * 2);
Array.Resize(ref _dense, newSize);
Array.Resize(ref _denseEntities, newSize);
}
}
}
+91
View File
@@ -0,0 +1,91 @@
namespace OECS;
/// <summary>
/// Manages a group of systems, running them in registration order
/// against a specific <see cref="World"/>.
/// </summary>
public class SystemGroup
{
private readonly World _world;
private readonly List<ISystem> _systems = new();
/// <summary>
/// Creates a system group bound to the given world.
/// </summary>
public SystemGroup(World world)
{
_world = world ?? throw new ArgumentNullException(nameof(world));
}
/// <summary>
/// The number of systems in this group.
/// </summary>
public int Count => _systems.Count;
/// <summary>
/// Adds a system to the group. Systems execute in the order they are added.
/// </summary>
public void Add(ISystem system)
{
_systems.Add(system);
}
/// <summary>
/// Removes a system from the group.
/// </summary>
public void Remove(ISystem system)
{
_systems.Remove(system);
}
/// <summary>
/// Runs all systems with a timed tick.
/// Systems that implement <see cref="ITickedSystem"/> receive the tick data;
/// plain <see cref="ISystem"/> implementations receive only the world.
/// </summary>
public void RunTimed(float deltaTime)
{
RunAll(Tick.Timed(deltaTime));
}
/// <summary>
/// Runs all systems with a logical tick.
/// </summary>
public void RunLogical()
{
RunAll(Tick.Logical());
}
private void RunAll(Tick tick)
{
// Drain commands enqueued before the tick so the first system
// sees their effects.
_world.ExecuteCommands();
foreach (var system in _systems)
{
if (system is ITickedSystem ticked)
{
ticked.Run(_world, tick);
}
else
{
system.Run(_world);
}
// 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();
}
}
+50
View File
@@ -0,0 +1,50 @@
namespace OECS;
/// <summary>
/// Describes the kind of tick being processed.
/// </summary>
public enum TickType
{
/// <summary>
/// A timed tick, carrying a delta time (e.g., frame-based update).
/// </summary>
Timed,
/// <summary>
/// A logical tick, carrying no delta time (e.g., fixed-step simulation).
/// </summary>
Logical
}
/// <summary>
/// Data passed to systems during a tick.
/// </summary>
public readonly struct Tick
{
/// <summary>
/// The kind of tick.
/// </summary>
public TickType Type { get; }
/// <summary>
/// The elapsed time since the last tick, in seconds.
/// Always 0 for logical ticks.
/// </summary>
public float DeltaTime { get; }
private Tick(TickType type, float deltaTime)
{
Type = type;
DeltaTime = deltaTime;
}
/// <summary>
/// Creates a timed tick with the given delta time.
/// </summary>
public static Tick Timed(float deltaTime) => new(TickType.Timed, deltaTime);
/// <summary>
/// Creates a logical tick (no delta time).
/// </summary>
public static Tick Logical() => new(TickType.Logical, 0f);
}
+717
View File
@@ -0,0 +1,717 @@
using System.Diagnostics;
using R3;
namespace OECS;
/// <summary>
/// The central container for all ECS state.
///
/// Manages entity lifecycle, component storage, and provides the primary
/// API for adding, removing, and querying components.
/// </summary>
public class World : IDisposable
{
/// <summary>
/// The reserved entity ID for the singleton entity.
/// </summary>
public static readonly Entity SingletonEntity = new(1, 1);
private readonly EntityAllocator _allocator;
private readonly ComponentStore _components;
private readonly CommandQueue _commands;
private readonly RelationshipIndex _relationships;
private readonly ChangeBuffer _changes;
private bool _singletonCreated;
private bool _disposed;
// Deferred structural mutation support: when iterating entities,
// AddComponent, RemoveComponent, and DestroyEntity are buffered and
// applied after the iteration completes.
private int _iterationDepth;
private readonly List<PendingMutation> _pendingMutations = new();
private enum PendingMutationKind { AddComponent, RemoveComponent, DestroyEntity }
private struct PendingMutation
{
public PendingMutationKind Kind;
public Entity Entity;
public object? Component; // boxed struct for AddComponent
public Type ComponentType;
}
// Auto-dirty-marking: during iteration, component accesses via
// GetComponent<T> are tracked. When the iteration scope ends,
// all accessed components are automatically marked as modified.
// Outside of iteration, MarkModified<T> must be called explicitly.
private readonly HashSet<(Entity Entity, Type ComponentType)> _accessedComponents = new();
private readonly HashSet<(Entity Entity, Type ComponentType)> _markedModified = new();
public World()
{
_allocator = new EntityAllocator();
_components = new ComponentStore();
_commands = new CommandQueue();
_relationships = new RelationshipIndex();
_changes = new ChangeBuffer();
}
// ── Entity Management ────────────────────────────────────────────
/// <summary>
/// Creates a new entity and returns its handle.
/// Automatically marks an <see cref="ChangeKind.EntityAdded"/> change.
/// </summary>
public Entity CreateEntity()
{
var entity = _allocator.Allocate();
_changes.Pending.MarkEntityAdded(entity);
return entity;
}
/// <summary>
/// Creates an entity with a specific handle. Used during deserialization
/// to restore entities with their original IDs and versions.
/// Does NOT mark an EntityAdded change (caller is responsible for
/// posting changes after the full load).
/// </summary>
internal Entity CreateEntity(Entity handle)
{
_allocator.Reserve(handle);
return handle;
}
/// <summary>
/// Destroys an entity, removing all its components and recycling its ID.
///
/// Cascade behavior:
/// - All relationships where this entity is the <b>source</b> are removed.
/// - All relationships where this entity is the <b>target</b> are removed
/// from the source entities.
/// </summary>
public void DestroyEntity(Entity entity)
{
if (_iterationDepth > 0)
{
_pendingMutations.Add(new PendingMutation
{
Kind = PendingMutationKind.DestroyEntity,
Entity = entity
});
return;
}
DestroyEntityImpl(entity);
}
private void DestroyEntityImpl(Entity entity)
{
if (!_allocator.IsAlive(entity))
return;
// Singleton entity is never destroyed.
if (entity.Id == 1)
return;
// Remove all relationships where this entity is the target.
// Materialize to avoid collection-modified-during-enumeration when
// OnRemoved mutates the same HashSet we're iterating.
var incoming = _relationships.GetIncomingRelationships(entity)
.Select(r => (r.RelationshipType, r.Sources.ToList()))
.ToList();
foreach (var (relType, sources) in incoming)
{
foreach (var source in sources)
{
_relationships.OnRemoved(relType, source, entity);
_components.Remove(source, relType);
_changes.Pending.MarkComponentRemoved(source, relType);
}
}
// Remove all relationships where this entity is the source.
_relationships.RemoveAllSourcesForEntity(entity);
// Mark component removals for each component type before removing them.
foreach (var componentType in _components.ComponentTypes.ToList())
{
if (_components.GetSet(componentType)?.Contains(entity) == true)
{
_changes.Pending.MarkComponentRemoved(entity, componentType);
}
}
_components.RemoveAll(entity);
_allocator.Free(entity);
_changes.Pending.MarkEntityRemoved(entity);
}
/// <summary>
/// Returns true if the entity is currently alive (not destroyed).
/// </summary>
public bool IsAlive(Entity entity)
{
return _allocator.IsAlive(entity);
}
// ── Component Management ─────────────────────────────────────────
/// <summary>
/// Adds a component to the entity. Replaces the existing component if
/// the entity already has one of type <typeparamref name="T"/>.
///
/// If <typeparamref name="T"/> implements <see cref="IRelationship"/>,
/// the reverse index is updated automatically.
/// </summary>
public void AddComponent<T>(Entity entity, T component) where T : struct
{
if (_iterationDepth > 0)
{
_pendingMutations.Add(new PendingMutation
{
Kind = PendingMutationKind.AddComponent,
Entity = entity,
Component = component,
ComponentType = typeof(T)
});
return;
}
AddComponentImpl(entity, component);
}
private void AddComponentImpl<T>(Entity entity, T component) where T : struct
{
ThrowIfNotAlive(entity);
// If replacing an existing relationship, remove the old index entry first.
if (component is IRelationship newRel)
{
// Guard against mismatched Source: the relationship must be stored
// on the entity it claims as its Source, otherwise the reverse
// index becomes corrupted.
if (newRel.Source != entity)
{
throw new InvalidOperationException(
$"Relationship of type {typeof(T).Name} has Source={newRel.Source} " +
$"but is being added to entity {entity}. " +
$"The Source must match the entity the component is added to.");
}
if (_components.TryGet<T>(entity, out var old))
{
var oldRel = (IRelationship)(object)old;
_relationships.OnRemoved(typeof(T), entity, oldRel.Target);
}
}
bool isNew = !_components.Has<T>(entity);
_components.Add(entity, component);
// Update relationship index.
if (component is IRelationship rel)
{
_relationships.OnAdded(typeof(T), entity, rel.Target);
}
// Mark change.
if (isNew)
_changes.Pending.MarkComponentAdded(entity, typeof(T));
}
/// <summary>
/// Removes the component of type <typeparamref name="T"/> from the entity.
/// No-op if the entity does not have the component.
///
/// If <typeparamref name="T"/> implements <see cref="IRelationship"/>,
/// the reverse index is updated automatically.
/// </summary>
public void RemoveComponent<T>(Entity entity) where T : struct
{
if (_iterationDepth > 0)
{
_pendingMutations.Add(new PendingMutation
{
Kind = PendingMutationKind.RemoveComponent,
Entity = entity,
ComponentType = typeof(T)
});
return;
}
RemoveComponentImpl<T>(entity);
}
private void RemoveComponentImpl<T>(Entity entity) where T : struct
{
// Update relationship index before removal.
if (typeof(IRelationship).IsAssignableFrom(typeof(T)))
{
if (_components.TryGet<T>(entity, out var existing))
{
var rel = (IRelationship)(object)existing;
_relationships.OnRemoved(typeof(T), entity, rel.Target);
}
}
bool existed = _components.Has<T>(entity);
_components.Remove<T>(entity);
if (existed)
_changes.Pending.MarkComponentRemoved(entity, typeof(T));
}
/// <summary>
/// Returns a reference to the component of type <typeparamref name="T"/>
/// for the given entity. Throws if the entity does not have the component.
///
/// During iteration, this access is auto-marked as modified since it
/// returns a mutable ref. Use <see cref="ReadComponent{T}"/> if you
/// only need to read the value.
/// </summary>
public ref T GetComponent<T>(Entity entity) where T : struct
{
if (_iterationDepth > 0)
_accessedComponents.Add((entity, typeof(T)));
return ref _components.Get<T>(entity);
}
/// <summary>
/// Returns a copy of the component of type <typeparamref name="T"/>
/// for the given entity. Never auto-marks as modified — use this when
/// you only need to read the value.
/// </summary>
public T ReadComponent<T>(Entity entity) where T : struct
{
return _components.Get<T>(entity);
}
/// <summary>
/// Tries to get the component of type <typeparamref name="T"/> for the
/// given entity. Returns true and copies the value to
/// <paramref name="value"/> if the component exists; otherwise returns false.
/// Never auto-marks as modified.
/// </summary>
public bool TryGetComponent<T>(Entity entity, out T value) where T : struct
{
return _components.TryGet<T>(entity, out value);
}
/// <summary>
/// Returns true if the entity has a component of type <typeparamref name="T"/>.
/// </summary>
public bool HasComponent<T>(Entity entity) where T : struct
{
return _components.Has<T>(entity);
}
// ── Change Tracking ──────────────────────────────────────────────
/// <summary>
/// Marks a component of type <typeparamref name="T"/> on the given entity
/// as modified. Only needed outside of iteration scopes — during
/// <see cref="ForEach"/> or a <c>Select</c> loop, components accessed
/// via <see cref="GetComponent{T}"/> are auto-marked.
/// </summary>
public void MarkModified<T>(Entity entity) where T : struct
{
_changes.Pending.MarkComponentModified(entity, typeof(T));
_markedModified.Add((entity, typeof(T)));
}
/// <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()
{
_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);
}
// ── Singletons ───────────────────────────────────────────────────
/// <summary>
/// Sets (adds or replaces) a singleton component of type <typeparamref name="T"/>.
/// Singletons are stored on a reserved entity that is never destroyed
/// and excluded from normal queries.
/// </summary>
public void SetSingleton<T>(T component) where T : struct
{
EnsureSingleton();
AddComponent(SingletonEntity, component);
}
/// <summary>
/// Returns a reference to the singleton component of type <typeparamref name="T"/>.
/// Throws if the singleton has not been set. Auto-marks as modified during iteration.
/// </summary>
public ref T GetSingleton<T>() where T : struct
{
EnsureSingleton();
return ref GetComponent<T>(SingletonEntity);
}
/// <summary>
/// Returns a copy of the singleton component of type <typeparamref name="T"/>.
/// Never auto-marks as modified — use this when you only need to read the singleton.
/// </summary>
public T ReadSingleton<T>() where T : struct
{
EnsureSingleton();
return ReadComponent<T>(SingletonEntity);
}
/// <summary>
/// Returns true if a singleton component of type <typeparamref name="T"/> exists.
/// </summary>
public bool HasSingleton<T>() where T : struct
{
return HasComponent<T>(SingletonEntity);
}
/// <summary>
/// Removes the singleton component of type <typeparamref name="T"/>.
/// No-op if the singleton does not have the component.
/// </summary>
public void RemoveSingleton<T>() where T : struct
{
RemoveComponent<T>(SingletonEntity);
}
internal void EnsureSingleton()
{
if (_singletonCreated)
return;
_singletonCreated = true;
// Manually register the singleton entity in the allocator so
// IsAlive returns true for it. We bypass Allocate() because
// the singleton has a fixed ID and version.
_allocator.RegisterSingleton(SingletonEntity);
}
// ── Relationships ────────────────────────────────────────────────
/// <summary>
/// Returns all source entities that have a relationship of type
/// <typeparamref name="T"/> pointing to the given target entity.
/// </summary>
public IReadOnlyCollection<Entity> GetSources<T>(Entity target)
where T : struct, IRelationship
{
return _relationships.GetSources<T>(target);
}
// ── Commands ──────────────────────────────────────────────────────
/// <summary>
/// The command queue for deferred, serializable commands.
/// Systems enqueue commands via <c>world.Commands.Enqueue(...)</c>.
/// </summary>
public CommandQueue Commands => _commands;
/// <summary>
/// Manually drains the command queue, executing all pending commands.
/// Called automatically by <see cref="SystemGroup"/> after each system
/// and after the full tick.
/// </summary>
public void ExecuteCommands()
{
_commands.ExecuteAll(this);
}
// ── Queries ──────────────────────────────────────────────────────
/// <summary>
/// Returns a new <see cref="QueryBuilder"/> for constructing queries.
/// </summary>
public QueryBuilder Query() => new();
/// <summary>
/// Iterates all entities matching the query, providing ref access to
/// one component type. Structural mutations (AddComponent, RemoveComponent,
/// DestroyEntity) made during iteration are deferred and applied after
/// the iteration completes.
/// </summary>
public void ForEach<T1>(
QueryDescriptor query,
ForEachAction<T1> action)
where T1 : struct
{
ValidateQueryTypes(query, typeof(T1));
BeginIteration();
try
{
QueryExecutor.ForEach(_components, query, action);
}
finally
{
EndIteration();
}
}
/// <summary>
/// Iterates all entities matching the query, providing ref access to
/// two component types. Structural mutations are deferred.
/// </summary>
public void ForEach<T1, T2>(
QueryDescriptor query,
ForEachAction<T1, T2> action)
where T1 : struct where T2 : struct
{
ValidateQueryTypes(query, typeof(T1), typeof(T2));
BeginIteration();
try
{
QueryExecutor.ForEach(_components, query, action);
}
finally
{
EndIteration();
}
}
/// <summary>
/// Iterates all entities matching the query, providing ref access to
/// three component types. Structural mutations are deferred.
/// </summary>
public void ForEach<T1, T2, T3>(
QueryDescriptor query,
ForEachAction<T1, T2, T3> action)
where T1 : struct where T2 : struct where T3 : struct
{
ValidateQueryTypes(query, typeof(T1), typeof(T2), typeof(T3));
BeginIteration();
try
{
QueryExecutor.ForEach(_components, query, action);
}
finally
{
EndIteration();
}
}
/// <summary>
/// Iterates all entities matching the query, providing ref access to
/// four component types. Structural mutations are deferred.
/// </summary>
public void ForEach<T1, T2, T3, T4>(
QueryDescriptor query,
ForEachAction<T1, T2, T3, T4> action)
where T1 : struct where T2 : struct where T3 : struct where T4 : struct
{
ValidateQueryTypes(query, typeof(T1), typeof(T2), typeof(T3), typeof(T4));
BeginIteration();
try
{
QueryExecutor.ForEach(_components, query, action);
}
finally
{
EndIteration();
}
}
/// <summary>
/// Iterates all entities matching the query, providing ref access to
/// five component types. Structural mutations are deferred.
/// </summary>
public void ForEach<T1, T2, T3, T4, T5>(
QueryDescriptor query,
ForEachAction<T1, T2, T3, T4, T5> action)
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct
{
ValidateQueryTypes(query, typeof(T1), typeof(T2), typeof(T3), typeof(T4), typeof(T5));
BeginIteration();
try
{
QueryExecutor.ForEach(_components, query, action);
}
finally
{
EndIteration();
}
}
/// <summary>
/// Iterates all entities matching the query, providing ref access to
/// six component types. Structural mutations are deferred.
/// </summary>
public void ForEach<T1, T2, T3, T4, T5, T6>(
QueryDescriptor query,
ForEachAction<T1, T2, T3, T4, T5, T6> action)
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct where T6 : struct
{
ValidateQueryTypes(query, typeof(T1), typeof(T2), typeof(T3), typeof(T4), typeof(T5), typeof(T6));
BeginIteration();
try
{
QueryExecutor.ForEach(_components, query, action);
}
finally
{
EndIteration();
}
}
/// <summary>
/// Begins an iteration scope. Structural mutations are buffered
/// until <see cref="EndIteration"/> is called.
/// </summary>
internal void BeginIteration()
{
_iterationDepth++;
}
/// <summary>
/// Ends an iteration scope. When the outermost scope ends, all
/// buffered structural mutations are applied and auto-tracked
/// component modifications are posted.
/// </summary>
internal void EndIteration()
{
_iterationDepth--;
if (_iterationDepth == 0)
{
// Auto-mark all components accessed via GetComponent<T>
// during the iteration as modified.
foreach (var (entity, componentType) in _accessedComponents)
{
if (!_markedModified.Contains((entity, componentType)))
{
_changes.Pending.MarkComponentModified(entity, componentType);
}
}
_accessedComponents.Clear();
_markedModified.Clear();
FlushPendingMutations();
}
}
private void FlushPendingMutations()
{
if (_pendingMutations.Count == 0)
return;
foreach (var m in _pendingMutations)
{
switch (m.Kind)
{
case PendingMutationKind.AddComponent:
// Use reflection to call AddComponentImpl<T>.
var addMethod = typeof(World).GetMethod(
nameof(AddComponentImpl),
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
var genericAdd = addMethod.MakeGenericMethod(m.ComponentType);
genericAdd.Invoke(this, [m.Entity, m.Component]);
break;
case PendingMutationKind.RemoveComponent:
var removeMethod = typeof(World).GetMethod(
nameof(RemoveComponentImpl),
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
var genericRemove = removeMethod.MakeGenericMethod(m.ComponentType);
genericRemove.Invoke(this, [m.Entity]);
break;
case PendingMutationKind.DestroyEntity:
DestroyEntityImpl(m.Entity);
break;
}
}
_pendingMutations.Clear();
}
// ── Internal Access ───────────────────────────────────────────────
/// <summary>
/// The component store. Exposed internally for query execution and
/// system infrastructure.
/// </summary>
internal ComponentStore Components => _components;
// ── Helpers ───────────────────────────────────────────────────────
private void ThrowIfNotAlive(Entity entity)
{
if (!_allocator.IsAlive(entity))
throw new InvalidOperationException($"Entity {entity} is not alive.");
}
/// <summary>
/// Validates that the ForEach type parameters match the query's
/// With set. When the With set is empty (only Without filters are
/// specified), any ForEach type parameters are accepted.
/// </summary>
private static void ValidateQueryTypes(QueryDescriptor query, params Type[] forEachTypes)
{
// When With is empty, the query is using only Without filters.
// The ForEach type parameters are the sole source of truth.
if (query.With.Count == 0)
return;
if (query.With.Count != forEachTypes.Length)
ThrowMismatch(query, forEachTypes);
foreach (var t in forEachTypes)
{
if (!query.With.Contains(t))
ThrowMismatch(query, forEachTypes);
}
}
private static void ThrowMismatch(QueryDescriptor query, Type[] forEachTypes)
{
var queryTypes = string.Join(", ", query.With.Select(t => t.Name));
var forEachTypeNames = string.Join(", ", forEachTypes.Select(t => t.Name));
throw new InvalidOperationException(
$"ForEach type parameters [{forEachTypeNames}] do not match " +
$"the query's With types [{queryTypes}]. " +
$"Ensure the ForEach type parameters exactly match the types " +
$"passed to QueryBuilder.With<T>().");
}
// ── Cleanup ───────────────────────────────────────────────────────
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_changes.Dispose();
}
}
+101
View File
@@ -0,0 +1,101 @@
using MessagePack;
namespace OECS;
/// <summary>
/// Saves and loads a <see cref="World"/> to/from a MessagePack stream.
///
/// Uses the compile-time generated <see cref="ComponentRegistry"/> to
/// serialize and deserialize components without reflection. All component
/// types used with the World API are discovered by the OECS.SourceGen
/// incremental generator at build time.
/// </summary>
public static class WorldSerializer
{
/// <summary>
/// Saves the world state to a stream.
/// </summary>
public static void Save(World world, Stream stream)
{
var entityComponents = new Dictionary<Entity, List<ComponentEntry>>();
foreach (var desc in ComponentRegistry.Descriptors)
{
var set = world.Components.GetSet(desc.Type);
if (set == null || set.Count == 0) continue;
var entities = set.GetDenseEntities();
for (int i = 0; i < set.Count; i++)
{
var entity = entities[i];
var component = set.GetComponentAt(i);
if (!entityComponents.TryGetValue(entity, out var list))
{
list = new List<ComponentEntry>();
entityComponents[entity] = list;
}
list.Add(new ComponentEntry
{
TypeName = desc.TypeName,
Data = desc.Serialize(component)
});
}
}
var snapshot = new WorldSnapshot
{
Entities = entityComponents
.Select(kv => new EntitySnapshot
{
Id = kv.Key.Id,
Version = kv.Key.Version,
Components = kv.Value.ToArray()
})
.ToArray()
};
MessagePackSerializer.Serialize(stream, snapshot);
}
/// <summary>
/// Loads the world state from a stream, adding entities and components
/// to the given world. The world should be empty or the caller is
/// responsible for managing duplicate entities.
/// </summary>
public static void Load(World world, Stream stream)
{
var snapshot = MessagePackSerializer.Deserialize<WorldSnapshot>(stream);
// Build a lookup by type name for O(1) descriptor resolution.
var lookup = new Dictionary<string, ComponentDescriptor>();
foreach (var desc in ComponentRegistry.Descriptors)
lookup[desc.TypeName] = desc;
foreach (var es in snapshot.Entities)
{
var entity = world.CreateEntity(es.Entity);
// If the loaded entity is the singleton entity, ensure the
// singleton infrastructure is initialized so that subsequent
// SetSingleton/GetSingleton calls work correctly.
if (entity.Id == World.SingletonEntity.Id)
{
world.EnsureSingleton();
}
foreach (var ce in es.Components)
{
if (!lookup.TryGetValue(ce.TypeName, out var desc))
throw new InvalidOperationException(
$"Unknown component type '{ce.TypeName}'. " +
$"Ensure the component type is used with the World " +
$"API so the source generator can discover it.");
// Deserialize and add in one typed operation — no reflection.
desc.DeserializeAndAdd(world, entity, ce.Data);
}
}
}
}
+39
View File
@@ -0,0 +1,39 @@
using MessagePack;
namespace OECS;
/// <summary>
/// Serializable snapshot of the entire world state.
/// </summary>
[MessagePackObject]
public class WorldSnapshot
{
[Key(0)] public EntitySnapshot[] Entities { get; set; } = [];
}
/// <summary>
/// A single entity and all its components, serialized as name/data pairs.
/// Entity ID and Version are stored separately to avoid issues with
/// MessagePack deserialization of the opaque Entity struct.
/// </summary>
[MessagePackObject]
public class EntitySnapshot
{
[Key(0)] public uint Id { get; set; }
[Key(1)] public uint Version { get; set; }
[Key(2)] public ComponentEntry[] Components { get; set; } = [];
[IgnoreMember]
public Entity Entity => new(Id, Version);
}
/// <summary>
/// A single component, identified by its fully-qualified type name and
/// serialized as a MessagePack byte blob.
/// </summary>
[MessagePackObject]
public class ComponentEntry
{
[Key(0)] public string TypeName { get; set; } = "";
[Key(1)] public byte[] Data { get; set; } = [];
}