From 8557fc864d47e9d9215b10b62bab3b5087316e42 Mon Sep 17 00:00:00 2001 From: hypercross Date: Sat, 18 Jul 2026 19:53:58 +0800 Subject: [PATCH] feat: add singleton support to World Introduces a mechanism to store and access singleton components on a reserved entity. The singleton entity is automatically excluded from all standard queries to prevent it from being processed like regular entities. - Added `SetSingleton`, `GetSingleton`, `HasSingleton`, and `RemoveSingleton` to `World`. - Added `RegisterSingleton` to `EntityAllocator` to support the reserved entity. - Updated `QueryExecutor` to skip the singleton entity during iteration. - Added unit tests for singleton functionality. --- src/OECS/EntityAllocator.cs | 10 ++ src/OECS/QueryExecutor.cs | 10 ++ src/OECS/World.cs | 59 ++++++++++ tests/OECS.Tests/SingletonTests.cs | 167 +++++++++++++++++++++++++++++ 4 files changed, 246 insertions(+) create mode 100644 tests/OECS.Tests/SingletonTests.cs diff --git a/src/OECS/EntityAllocator.cs b/src/OECS/EntityAllocator.cs index df4cb0d..87be247 100644 --- a/src/OECS/EntityAllocator.cs +++ b/src/OECS/EntityAllocator.cs @@ -63,6 +63,16 @@ internal class EntityAllocator _freeIds.Push(id); } + /// + /// Registers the singleton entity so that returns true for it. + /// Called once by when the first singleton accessor is used. + /// + public void RegisterSingleton(Entity singleton) + { + EnsureCapacity(singleton.Id); + _versions[singleton.Id] = (byte)singleton.Version; + } + /// /// Returns true if the entity's version matches the current version for its ID. /// diff --git a/src/OECS/QueryExecutor.cs b/src/OECS/QueryExecutor.cs index b6188e3..40f235e 100644 --- a/src/OECS/QueryExecutor.cs +++ b/src/OECS/QueryExecutor.cs @@ -6,9 +6,12 @@ namespace OECS; /// Provides query execution logic for . /// 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. /// internal static class QueryExecutor { + private const uint SingletonId = 1; [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool PassesWithoutFilter(ComponentStore store, Entity entity, IReadOnlySet withoutTypes) { @@ -39,6 +42,7 @@ internal static class QueryExecutor 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]); @@ -81,6 +85,7 @@ internal static class QueryExecutor 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; @@ -103,6 +108,7 @@ internal static class QueryExecutor 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; @@ -130,6 +136,7 @@ internal static class QueryExecutor 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)) @@ -160,6 +167,7 @@ internal static class QueryExecutor 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; @@ -193,6 +201,7 @@ internal static class QueryExecutor 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; @@ -229,6 +238,7 @@ internal static class QueryExecutor 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; diff --git a/src/OECS/World.cs b/src/OECS/World.cs index f7cb418..ba0d2de 100644 --- a/src/OECS/World.cs +++ b/src/OECS/World.cs @@ -11,11 +11,17 @@ namespace OECS; /// public class World : IDisposable { + /// + /// The reserved entity ID for the singleton entity. + /// + 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; #if DEBUG // Tracks entities whose components were accessed via GetComponent @@ -235,6 +241,59 @@ public class World : IDisposable return _changes.ObserveQuery(query); } + // ── Singletons ─────────────────────────────────────────────────── + + /// + /// Sets (adds or replaces) a singleton component of type . + /// Singletons are stored on a reserved entity that is never destroyed + /// and excluded from normal queries. + /// + public void SetSingleton(T component) where T : struct + { + EnsureSingleton(); + AddComponent(SingletonEntity, component); + } + + /// + /// Returns a reference to the singleton component of type . + /// Throws if the singleton has not been set. + /// + public ref T GetSingleton() where T : struct + { + EnsureSingleton(); + return ref GetComponent(SingletonEntity); + } + + /// + /// Returns true if a singleton component of type exists. + /// + public bool HasSingleton() where T : struct + { + return HasComponent(SingletonEntity); + } + + /// + /// Removes the singleton component of type . + /// No-op if the singleton does not have the component. + /// + public void RemoveSingleton() where T : struct + { + RemoveComponent(SingletonEntity); + } + + private 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 ──────────────────────────────────────────────── /// diff --git a/tests/OECS.Tests/SingletonTests.cs b/tests/OECS.Tests/SingletonTests.cs new file mode 100644 index 0000000..11fc1c9 --- /dev/null +++ b/tests/OECS.Tests/SingletonTests.cs @@ -0,0 +1,167 @@ +using FluentAssertions; +using Xunit; + +namespace OECS.Tests; + +public class SingletonTests +{ + private struct GameConfig { public float Gravity; public int MaxPlayers; } + private struct TimeState { public float Elapsed; } + + [Fact] + public void SetSingleton_GetSingleton_RoundTrips() + { + var world = new World(); + + world.SetSingleton(new GameConfig { Gravity = 9.8f, MaxPlayers = 4 }); + + ref var config = ref world.GetSingleton(); + config.Gravity.Should().Be(9.8f); + config.MaxPlayers.Should().Be(4); + } + + [Fact] + public void SetSingleton_ReplacesExisting() + { + var world = new World(); + + world.SetSingleton(new GameConfig { Gravity = 9.8f, MaxPlayers = 4 }); + world.SetSingleton(new GameConfig { Gravity = 1.6f, MaxPlayers = 2 }); + + ref var config = ref world.GetSingleton(); + config.Gravity.Should().Be(1.6f); + config.MaxPlayers.Should().Be(2); + } + + [Fact] + public void HasSingleton_ReturnsCorrectly() + { + var world = new World(); + + world.HasSingleton().Should().BeFalse(); + world.SetSingleton(new GameConfig()); + world.HasSingleton().Should().BeTrue(); + } + + [Fact] + public void RemoveSingleton_RemovesIt() + { + var world = new World(); + + world.SetSingleton(new GameConfig { Gravity = 9.8f, MaxPlayers = 4 }); + world.HasSingleton().Should().BeTrue(); + + world.RemoveSingleton(); + world.HasSingleton().Should().BeFalse(); + } + + [Fact] + public void RemoveSingleton_NoOp_WhenNotPresent() + { + var world = new World(); + + // Should not throw. + world.RemoveSingleton(); + } + + [Fact] + public void SingletonEntity_DoesNotAppearInNormalQueries() + { + var world = new World(); + + // Set a singleton with the same component type that a regular entity has. + world.SetSingleton(new GameConfig { Gravity = 9.8f, MaxPlayers = 4 }); + + var regular = world.CreateEntity(); + world.AddComponent(regular, new GameConfig { Gravity = 1.6f, MaxPlayers = 2 }); + + var query = world.Query().With().Build(); + var results = new List(); + + world.ForEach(query, (Entity e, ref GameConfig cfg) => + { + results.Add(e); + }); + + // Only the regular entity should appear, not the singleton. + results.Should().BeEquivalentTo([regular]); + } + + [Fact] + public void SingletonEntity_IsAlive() + { + var world = new World(); + + world.SetSingleton(new GameConfig()); + + world.IsAlive(World.SingletonEntity).Should().BeTrue(); + } + + [Fact] + public void Singleton_SurvivesTickExecution() + { + var world = new World(); + var group = new SystemGroup(world); + + world.SetSingleton(new TimeState { Elapsed = 0f }); + + // System that reads and updates the singleton. + group.Add(new TimeSystem(world)); + + group.RunTimed(0.5f); + group.RunTimed(0.5f); + + ref var time = ref world.GetSingleton(); + time.Elapsed.Should().Be(1.0f); + } + + [Fact] + public void MultipleSingletonTypes_AreIndependent() + { + var world = new World(); + + world.SetSingleton(new GameConfig { Gravity = 9.8f, MaxPlayers = 4 }); + world.SetSingleton(new TimeState { Elapsed = 42f }); + + ref var config = ref world.GetSingleton(); + ref var time = ref world.GetSingleton(); + + config.Gravity.Should().Be(9.8f); + time.Elapsed.Should().Be(42f); + } + + [Fact] + public void Singleton_Mutation_ViaRef_IsVisible() + { + var world = new World(); + + world.SetSingleton(new GameConfig { Gravity = 0, MaxPlayers = 0 }); + + ref var config = ref world.GetSingleton(); + config.Gravity = 9.8f; + + ref var config2 = ref world.GetSingleton(); + config2.Gravity.Should().Be(9.8f); + } + + private class TimeSystem : ITickedSystem + { + private readonly World _world; + + public QueryDescriptor Query { get; } + + public TimeSystem(World world) + { + _world = world; + Query = world.Query().With().Build(); + } + + public void Run(World world) => Run(world, Tick.Logical()); + + public void Run(World world, Tick tick) + { + ref var time = ref _world.GetSingleton(); + time.Elapsed += tick.DeltaTime; + } + } +}