diff --git a/.agents/skills/developing-oecs/SKILL.md b/.agents/skills/developing-oecs/SKILL.md index 4f5b662..27ab080 100644 --- a/.agents/skills/developing-oecs/SKILL.md +++ b/.agents/skills/developing-oecs/SKILL.md @@ -42,7 +42,7 @@ Key ADRs to keep in mind when changing the library: | 006 | Deferred change posting | Prevents mid-system reentrancy | | 007 | R3 for reactivity | Zero-allocation, UI lifecycle-friendly | | 008 | Commands as serializable structs in a queue | Not ECS state | -| 009 | Singleton as reserved entity (ID 1) | Reuses component storage | +| 009 | Singleton per component type | Each singleton gets its own entity | | 010 | Single-threaded by default | Simpler, no locks needed | | 011 | .NET 8 target | LTS through Nov 2026 | | 012 | Public types required for MessagePack | Build-time validation via analyzer | @@ -69,12 +69,22 @@ Dense/sparse array pair. `dense[]` is packed values, `denseEntities[]` is parallel entity IDs, `sparse[]` maps entity ID → dense index (-1 = absent). Uses swap-remove for O(1) deletion. -### `EntityIterator.Select1/2/3` — ref struct iterators +### `WorldQueryExtensions.Select1..Select6` — ref struct iterators -Zero-allocation iterators. Drive from the smallest sparse set to minimize -probes. Support `foreach` via `GetEnumerator()` returning `this`. Must call -`world.BeginIteration()` / `EndIteration()` for pending mutation flushing. -Always skip singleton entity (ID 1). +Zero-allocation `ref struct` enumerators returned by `world.Select()`. +Drive from the smallest sparse set to minimize probes. Support `foreach` via +`GetEnumerator()` returning `this`. Expose `Entity` and `ref Item1`..`ref ItemN` +properties. Constructor/dispose manage `BeginIteration()`/`EndIteration()` for +pending mutation flushing. Singleton entities are excluded via +`IsSingletonEntity()` check. + +Also provides `FindEntity()` (first match) and `FindEntities()` (all matches). + +### `Query` — generic query descriptors + +Structs that encode `With` types as generic parameters. `Without()` fluent +method adds exclusion filters without changing the arity. Replaces the old +`QueryBuilder` / `QueryDescriptor` / `ForEachAction` pattern. ### `SystemGroup` — system orchestration @@ -186,5 +196,5 @@ when there are no fields to compare. components mid-iteration is safe — they're deferred. - **Type not public or missing `[MessagePackObject]`.** Serialization will fail. The `MessagePackAnalyzer` catches most issues at compile time. -- **Entity ID 1 is the singleton.** Don't destroy it. Iterators skip it - automatically. +- **Singletons each have their own entity allocated automatically by + `SetSingleton`.** Iterators skip singleton entities automatically. diff --git a/.agents/skills/testing-games/SKILL.md b/.agents/skills/testing-games/SKILL.md index 8be2096..fa02781 100644 --- a/.agents/skills/testing-games/SKILL.md +++ b/.agents/skills/testing-games/SKILL.md @@ -58,7 +58,7 @@ public void PlaceBet_AdvancesToDealing() Common test helpers: - `SetupGame(seed?)` — create world, register systems, set initial singletons. -- `FindEntity(world)` — find first non-singleton entity with component T. +- `FindEntity(world)` — find first entity with component T (singletons are automatically excluded by query results). - `CountCardsInHand(world, handEntity)` or `GetHandCards(world, handEntity)`. Always keep helpers in the test class (or a shared base) rather than in the diff --git a/.agents/skills/writing-games/SKILL.md b/.agents/skills/writing-games/SKILL.md index eeee58f..258730f 100644 --- a/.agents/skills/writing-games/SKILL.md +++ b/.agents/skills/writing-games/SKILL.md @@ -117,30 +117,40 @@ The `ISystem` interface has no `Query` property. Systems read singletons, build queries, and iterate on their own — this keeps the interface minimal and gives systems full flexibility. -### Iteration Styles +### Iteration -Two options: +Use `world.Select()` with `foreach` for zero-allocation iteration with +`ref` access to components: -**ForEach callbacks** (1–6 components): ```csharp -var query = world.Query().With().With().Build(); -world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel) => +foreach (var it in world.Select()) { - pos.X += vel.X * dt; - world.MarkModified(e); -}); -``` - -**Ref struct iterators** via `EntityIterator.Select()` (1–3 components): -```csharp -using var iter = world.Select(); -while (iter.MoveNext()) -{ - // iter.CurrentEntity, iter.Current1 (ref) + it.Item1.X += it.Item2.X * dt; // ref Position, ref Velocity } ``` -The singleton entity (ID 1) is automatically skipped by all iterators. +For queries with exclusion filters, create a `Query` with `.Without()`: + +```csharp +var query = new Query().Without(); +foreach (var it in world.Select(query)) +{ + if (it.Item1.Row == row && it.Item1.Col == col) { ... } +} +``` + +To find entities, use `FindEntity()` or `FindEntities()`: + +```csharp +var handEntity = world.FindEntity(); +var allCards = world.FindEntities(); +``` + +The `Select` ref struct enumerators expose: +- `it.Entity` — the current entity handle. +- `it.Item1`..`it.ItemN` — `ref` references to the matched components. + +Singleton entities are automatically excluded from query results. ### System Registration @@ -176,7 +186,6 @@ public record struct PlaceBetCommand : ICommand state.CurrentBet = Amount; state.Chips -= Amount; state.Phase = GamePhase.Dealing; - world.MarkModified(World.SingletonEntity); } } ``` @@ -186,10 +195,12 @@ when the queue is drained (automatically by `SystemGroup`). ## Singletons -Global state lives on the singleton entity (ID 1). Use `SetSingleton`, -`GetSingleton` (ref), and `ReadSingleton` (copy): +Each singleton component type gets its own dedicated entity, allocated +automatically by `SetSingleton`. Use `SetSingleton`, `GetSingleton` +(ref), and `ReadSingleton` (copy): ```csharp +// Setup: world.SetSingleton(new GameState { Phase = GamePhase.Betting, Chips = 100 }); // Read-only inspection: @@ -198,12 +209,12 @@ var state = world.ReadSingleton(); // Mutation: ref var mutable = ref world.GetSingleton(); mutable.Phase = GamePhase.RoundOver; -world.MarkModified(World.SingletonEntity); ``` -`GetSingleton` returns a `ref` — always call `MarkModified` after mutating -so reactivity subscribers see the change. `ReadSingleton` returns a copy and -never auto-marks. +`GetSingleton` returns a `ref`. During system execution (inside `SystemGroup`), +mutations via `GetSingleton` are auto-marked. Outside of iteration, call +`MarkModified` after mutating so reactivity subscribers see the change. +`ReadSingleton` returns a copy and never auto-marks. ## Change Tracking diff --git a/docs/api-surface.md b/docs/api-surface.md index b97b936..8da3991 100644 --- a/docs/api-surface.md +++ b/docs/api-surface.md @@ -37,9 +37,6 @@ namespace OECS; public class World : IDisposable { - // --- Singleton entity --- - public static Entity SingletonEntity { get; } // ID=1, Version=1 - // --- Lifecycle --- public World(); @@ -64,24 +61,7 @@ public class World : IDisposable public void RemoveSingleton() where T : struct; // --- Queries --- - public QueryBuilder Query(); - - // --- Query Execution --- - public void ForEach( - QueryDescriptor query, - ForEachAction action) where T1 : struct; - - // Overloads for 2–6 component types: - public void ForEach(QueryDescriptor, ForEachAction) - where T1 : struct where T2 : struct; - public void ForEach(QueryDescriptor, ForEachAction) - where T1 : struct where T2 : struct where T3 : struct; - public void ForEach(QueryDescriptor, ForEachAction) - where T1 : struct where T2 : struct where T3 : struct where T4 : struct; - public void ForEach(QueryDescriptor, ForEachAction) - where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct; - public void ForEach(QueryDescriptor, ForEachAction) - where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct where T6 : struct; + // See QueryExtensions for Select / FindEntity / FindEntities. // --- Commands --- public CommandQueue Commands { get; } @@ -94,7 +74,10 @@ public class World : IDisposable public Observable ObserveEntityChanges(); public Observable ObserveComponentChanges() where T : struct; - public Observable ObserveQuery(QueryDescriptor query); + public Observable ObserveQuery(Query query = default) + where T1 : struct; + public Observable ObserveQuery(Query query = default) + where T1 : struct where T2 : struct; // --- Relationships --- public IReadOnlyCollection GetSources(Entity target) @@ -107,121 +90,97 @@ public class World : IDisposable --- -## ForEachAction Delegates +## WorldQueryExtensions -Custom delegate types for query iteration with `ref` parameters. The built-in -`Action<...>` delegates do not support `ref` parameters. +Extension methods on `World` providing `foreach`-compatible iteration and +entity lookup. Select returns `ref struct` enumerators for zero-allocation +iteration with `ref` access to components. ```csharp namespace OECS; -public delegate void ForEachAction(Entity entity, ref T1 c1) where T1 : struct; - -public delegate void ForEachAction(Entity entity, ref T1 c1, ref T2 c2) - where T1 : struct where T2 : struct; - -public delegate void ForEachAction(Entity entity, ref T1 c1, ref T2 c2, ref T3 c3) - where T1 : struct where T2 : struct where T3 : struct; - -public delegate void ForEachAction(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; - -public delegate void ForEachAction(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; - -public delegate void ForEachAction(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; -``` - ---- - -## EntityIterator - -Extension methods on `World` for `foreach`-style iteration over queries. -Returns `ref struct` iterators that support zero-allocation iteration with -`ref` access to components. - -Two sets of overloads: one accepting an explicit `QueryDescriptor` for -filtering with `Without`, and one without for simple "has component" scans. - -```csharp -namespace OECS; - -public static class EntityIterator +public static class WorldQueryExtensions { - // With explicit QueryDescriptor (supports Without filters). + // Select — returns a ref struct enumerator for foreach. public static Select1 Select( - this World world, QueryDescriptor query) where T1 : struct; + this World world, Query query = default) where T1 : struct; + // Overloads for 2–6 component types: + public static Select2 Select(...) where T1 : struct where T2 : struct; + // ... up to Select6 - public static Select2 Select( - this World world, QueryDescriptor query) - where T1 : struct where T2 : struct; + // FindEntity — returns the first matching entity or Entity.Null. + public static Entity FindEntity( + this World world, Query query = default) where T1 : struct; + public static Entity FindEntity(...) where T1 : struct where T2 : struct; + public static Entity FindEntity(...) where T1 : struct where T2 : struct where T3 : struct; - public static Select3 Select( - this World world, QueryDescriptor query) - where T1 : struct where T2 : struct where T3 : struct; - - // Without QueryDescriptor — iterates all entities with the given component(s). - public static Select1 Select( - this World world) where T1 : struct; - - public static Select2 Select( - this World world) - where T1 : struct where T2 : struct; - - public static Select3 Select( - this World world) - where T1 : struct where T2 : struct where T3 : struct; + // FindEntities — returns all matching entities as List. + public static List FindEntities(...) where T1 : struct; + public static List FindEntities(...) where T1 : struct where T2 : struct; + public static List FindEntities(...) where T1 : struct where T2 : struct where T3 : struct; } ``` -The singletons `CurrentEntity`, `Current1`, `Current2`, `Current3` are -exposed on the ref struct iterators directly. Usage: +Each ref struct (Select1 through Select6) exposes: +- `Entity Entity` — the current entity handle. +- `ref T1 Item1`, `ref T2 Item2`, ... — read-write references to components. +- `bool MoveNext()` — advances and returns true while items remain. +- `GetEnumerator()` — returns `this` for `foreach` compatibility. +- `Dispose()` — ends the iteration scope (flushes deferred mutations). +Usage: ```csharp -// Simple scan: all entities with PlayerHand. -using var iter = world.Select(); -while (iter.MoveNext()) +// Simple scan: all entities with Position. +foreach (var it in world.Select()) { - Console.WriteLine(iter.CurrentEntity); + it.Item1.X += 1; // ref mutates component in-place } -// With query filter: -var query = world.Query().With().Without().Build(); -using var iter2 = world.Select(query); -while (iter2.MoveNext()) +// With Without filter: +var query = new Query().Without(); +foreach (var it in world.Select(query)) { - iter2.Current1.X += 1; + // it.Entity, it.Item1 +} + +// Find first entity: +var hand = world.FindEntity(); + +// Multi-component: +foreach (var it in world.Select()) +{ + it.Item1.X += it.Item2.X * dt; } ``` --- -## QueryBuilder +## Query + +Describes a query over the ECS world. The `With` types are encoded as generic +parameters. Optional `Without` filters are added via the fluent API. ```csharp namespace OECS; -public class QueryBuilder +public struct Query where T1 : struct { - public QueryBuilder With() where T : struct; - public QueryBuilder Without() where T : struct; - public QueryDescriptor Build(); + public Query Without() where W : struct; } + +// Overloads for 2–6 With types: +public struct Query where T1 : struct where T2 : struct { ... } +// ... up to Query ``` ---- - -## QueryDescriptor - +Usage: ```csharp -namespace OECS; +// Two required types, one excluded: +var query = new Query().Without(); +foreach (var it in world.Select(query)) { ... } -public class QueryDescriptor -{ - public IReadOnlySet With { get; } - public IReadOnlySet Without { get; } -} +// Simple scan — no Without filter needed: +foreach (var it in world.Select()) { ... } ``` --- @@ -242,10 +201,10 @@ public interface ISystem ``` Systems do **not** declare a query on the interface. Instead, they either -read singletons directly (`world.ReadSingleton()`) or use the iterator / -`ForEach` API with a `QueryDescriptor` they build internally. This keeps -the interface minimal and gives systems full flexibility over what they -inspect at runtime. +read singletons directly (`world.ReadSingleton()`) or use the +`WorldQueryExtensions` (`Select`, `FindEntity`, `FindEntities`) with a +`Query` they build inline. This keeps the interface minimal and +gives systems full flexibility over what they inspect at runtime. --- @@ -486,35 +445,24 @@ public record struct Velocity // Define a system public class MovementSystem : ITickedSystem { - private readonly QueryDescriptor _query; - - public MovementSystem(World world) - { - _query = world.Query() - .With() - .With() - .Build(); - } - public void Run(World world) => Run(world, Tick.Logical()); public void Run(World world, Tick tick) { float dt = tick.DeltaTime; - world.ForEach(_query, (Entity entity, ref Position pos, ref Velocity vel) => + foreach (var it in world.Select()) { - pos.X += vel.X * dt; - pos.Y += vel.Y * dt; - world.MarkModified(entity); - }); + it.Item1.X += it.Item2.X * dt; + it.Item1.Y += it.Item2.Y * dt; + } } } // Wire it up var world = new World(); var group = new SystemGroup(world); -group.Add(new MovementSystem(world)); +group.Add(new MovementSystem()); // Create entities var player = world.CreateEntity(); @@ -543,6 +491,6 @@ These types are implementation details and may change without notice: | `ChangeSet` | Deduplicated change accumulator within a single batch. | | `RelationshipIndex` | Reverse lookup from target entity to source entities. | | `EntityAllocator` | Free-list + bump allocator for entity IDs. | -| `QueryExecutor` | Query iteration logic with smallest-set driver optimization. | +| `WorldQueryExtensions` | Extension methods with ref struct iterators and entity lookup. | | `ComponentRegistry` | Source-generated registry of all component types for serialization. | | `ComponentDescriptor` | Source-generated per-type descriptor with serialize/deserialize callbacks. |