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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ChildOf, Parent>
|
||||
/// {
|
||||
/// Source = child,
|
||||
/// Target = parent
|
||||
/// });
|
||||
///
|
||||
/// // Later, find all children of a parent.
|
||||
/// var children = world.GetSources<Relationship<ChildOf, Parent>>(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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
Reference in New Issue
Block a user