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.
This commit is contained in:
hypercross 2026-07-18 19:53:58 +08:00
parent 01eb5aa5f6
commit 8557fc864d
4 changed files with 246 additions and 0 deletions

View File

@ -63,6 +63,16 @@ internal class EntityAllocator
_freeIds.Push(id); _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> /// <summary>
/// Returns true if the entity's version matches the current version for its ID. /// Returns true if the entity's version matches the current version for its ID.
/// </summary> /// </summary>

View File

@ -6,9 +6,12 @@ namespace OECS;
/// Provides query execution logic for <see cref="World"/>. /// Provides query execution logic for <see cref="World"/>.
/// Iterates one sparse set as the driver and probes the remaining sets /// Iterates one sparse set as the driver and probes the remaining sets
/// for membership. The "without" filter is checked after all "with" probes. /// for membership. The "without" filter is checked after all "with" probes.
///
/// The singleton entity (ID 1) is automatically excluded from all queries.
/// </summary> /// </summary>
internal static class QueryExecutor internal static class QueryExecutor
{ {
private const uint SingletonId = 1;
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool PassesWithoutFilter(ComponentStore store, Entity entity, IReadOnlySet<Type> withoutTypes) private static bool PassesWithoutFilter(ComponentStore store, Entity entity, IReadOnlySet<Type> withoutTypes)
{ {
@ -39,6 +42,7 @@ internal static class QueryExecutor
for (int i = 0; i < count; i++) for (int i = 0; i < count; i++)
{ {
var entity = denseEntities[i]; var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) if (!PassesWithoutFilter(store, entity, query.Without))
continue; continue;
action(entity, ref dense[i]); action(entity, ref dense[i]);
@ -81,6 +85,7 @@ internal static class QueryExecutor
for (int i = 0; i < count; i++) for (int i = 0; i < count; i++)
{ {
var entity = denseEntities[i]; var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!otherSet.Contains(entity)) continue; if (!otherSet.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, withoutTypes)) if (!PassesWithoutFilter(store, entity, withoutTypes))
continue; continue;
@ -103,6 +108,7 @@ internal static class QueryExecutor
for (int i = 0; i < count; i++) for (int i = 0; i < count; i++)
{ {
var entity = denseEntities[i]; var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!otherSet.Contains(entity)) continue; if (!otherSet.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, withoutTypes)) if (!PassesWithoutFilter(store, entity, withoutTypes))
continue; continue;
@ -130,6 +136,7 @@ internal static class QueryExecutor
for (int i = 0; i < count; i++) for (int i = 0; i < count; i++)
{ {
var entity = denseEntities[i]; var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set2.Contains(entity)) continue; if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue; if (!set3.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) if (!PassesWithoutFilter(store, entity, query.Without))
@ -160,6 +167,7 @@ internal static class QueryExecutor
for (int i = 0; i < count; i++) for (int i = 0; i < count; i++)
{ {
var entity = denseEntities[i]; var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set2.Contains(entity)) continue; if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue; if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue; if (!set4.Contains(entity)) continue;
@ -193,6 +201,7 @@ internal static class QueryExecutor
for (int i = 0; i < count; i++) for (int i = 0; i < count; i++)
{ {
var entity = denseEntities[i]; var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set2.Contains(entity)) continue; if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue; if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue; if (!set4.Contains(entity)) continue;
@ -229,6 +238,7 @@ internal static class QueryExecutor
for (int i = 0; i < count; i++) for (int i = 0; i < count; i++)
{ {
var entity = denseEntities[i]; var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set2.Contains(entity)) continue; if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue; if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue; if (!set4.Contains(entity)) continue;

View File

@ -11,11 +11,17 @@ namespace OECS;
/// </summary> /// </summary>
public class World : IDisposable 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 EntityAllocator _allocator;
private readonly ComponentStore _components; private readonly ComponentStore _components;
private readonly CommandQueue _commands; private readonly CommandQueue _commands;
private readonly RelationshipIndex _relationships; private readonly RelationshipIndex _relationships;
private readonly ChangeBuffer _changes; private readonly ChangeBuffer _changes;
private bool _singletonCreated;
#if DEBUG #if DEBUG
// Tracks entities whose components were accessed via GetComponent<T> // Tracks entities whose components were accessed via GetComponent<T>
@ -235,6 +241,59 @@ public class World : IDisposable
return _changes.ObserveQuery(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.
/// </summary>
public ref T GetSingleton<T>() where T : struct
{
EnsureSingleton();
return ref GetComponent<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);
}
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 ──────────────────────────────────────────────── // ── Relationships ────────────────────────────────────────────────
/// <summary> /// <summary>

View File

@ -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<GameConfig>();
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<GameConfig>();
config.Gravity.Should().Be(1.6f);
config.MaxPlayers.Should().Be(2);
}
[Fact]
public void HasSingleton_ReturnsCorrectly()
{
var world = new World();
world.HasSingleton<GameConfig>().Should().BeFalse();
world.SetSingleton(new GameConfig());
world.HasSingleton<GameConfig>().Should().BeTrue();
}
[Fact]
public void RemoveSingleton_RemovesIt()
{
var world = new World();
world.SetSingleton(new GameConfig { Gravity = 9.8f, MaxPlayers = 4 });
world.HasSingleton<GameConfig>().Should().BeTrue();
world.RemoveSingleton<GameConfig>();
world.HasSingleton<GameConfig>().Should().BeFalse();
}
[Fact]
public void RemoveSingleton_NoOp_WhenNotPresent()
{
var world = new World();
// Should not throw.
world.RemoveSingleton<GameConfig>();
}
[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<GameConfig>().Build();
var results = new List<Entity>();
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<TimeState>();
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<GameConfig>();
ref var time = ref world.GetSingleton<TimeState>();
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<GameConfig>();
config.Gravity = 9.8f;
ref var config2 = ref world.GetSingleton<GameConfig>();
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<GameConfig>().Build();
}
public void Run(World world) => Run(world, Tick.Logical());
public void Run(World world, Tick tick)
{
ref var time = ref _world.GetSingleton<TimeState>();
time.Elapsed += tick.DeltaTime;
}
}
}