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);
|
_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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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