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:
parent
01eb5aa5f6
commit
8557fc864d
|
|
@ -63,6 +63,16 @@ internal class EntityAllocator
|
|||
_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>
|
||||
/// Returns true if the entity's version matches the current version for its ID.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -6,9 +6,12 @@ namespace OECS;
|
|||
/// 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)
|
||||
{
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -11,11 +11,17 @@ namespace OECS;
|
|||
/// </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;
|
||||
|
||||
#if DEBUG
|
||||
// Tracks entities whose components were accessed via GetComponent<T>
|
||||
|
|
@ -235,6 +241,59 @@ public class World : IDisposable
|
|||
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 ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue