refactor: replace QueryBuilder with generic Query and Select iterators
Replace the old `QueryBuilder` and `ForEach` callback pattern with a more performant, zero-allocation `Select` API using `ref struct` iterators. This change also updates the singleton implementation to use dedicated entities per component type rather than a single reserved entity. - Replace `QueryBuilder`/`QueryDescriptor` with generic `Query<T1..T6>` - Replace `ForEach` callbacks with `world.Select<T1..T6>()` iterators - Replace `EntityIterator` with `WorldQueryExtensions` - Update singleton logic to allocate one entity per component type - Add `FindEntity<T>` and `FindEntities<T>` lookup methods
This commit is contained in:
@@ -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<T...>` — ref struct iterators
|
||||
### `WorldQueryExtensions.Select1..Select6<T...>` — 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<T1..T6>()`.
|
||||
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<T>()` (first match) and `FindEntities<T>()` (all matches).
|
||||
|
||||
### `Query<T1..T6>` — generic query descriptors
|
||||
|
||||
Structs that encode `With` types as generic parameters. `Without<W>()` 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<T>`.** Iterators skip singleton entities automatically.
|
||||
|
||||
@@ -58,7 +58,7 @@ public void PlaceBet_AdvancesToDealing()
|
||||
|
||||
Common test helpers:
|
||||
- `SetupGame(seed?)` — create world, register systems, set initial singletons.
|
||||
- `FindEntity<T>(world)` — find first non-singleton entity with component T.
|
||||
- `FindEntity<T>(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
|
||||
|
||||
@@ -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<T1..T6>()` with `foreach` for zero-allocation iteration with
|
||||
`ref` access to components:
|
||||
|
||||
**ForEach callbacks** (1–6 components):
|
||||
```csharp
|
||||
var query = world.Query().With<Position>().With<Velocity>().Build();
|
||||
world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel) =>
|
||||
foreach (var it in world.Select<Position, Velocity>())
|
||||
{
|
||||
pos.X += vel.X * dt;
|
||||
world.MarkModified<Position>(e);
|
||||
});
|
||||
```
|
||||
|
||||
**Ref struct iterators** via `EntityIterator.Select<T>()` (1–3 components):
|
||||
```csharp
|
||||
using var iter = world.Select<PlayerHand>();
|
||||
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<T>` with `.Without<W>()`:
|
||||
|
||||
```csharp
|
||||
var query = new Query<Cell>().Without<Mark>();
|
||||
foreach (var it in world.Select(query))
|
||||
{
|
||||
if (it.Item1.Row == row && it.Item1.Col == col) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
To find entities, use `FindEntity<T>()` or `FindEntities<T>()`:
|
||||
|
||||
```csharp
|
||||
var handEntity = world.FindEntity<PlayerHand>();
|
||||
var allCards = world.FindEntities<Card>();
|
||||
```
|
||||
|
||||
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<GameState>(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<T>`,
|
||||
`GetSingleton<T>` (ref), and `ReadSingleton<T>` (copy):
|
||||
Each singleton component type gets its own dedicated entity, allocated
|
||||
automatically by `SetSingleton<T>`. Use `SetSingleton<T>`, `GetSingleton<T>`
|
||||
(ref), and `ReadSingleton<T>` (copy):
|
||||
|
||||
```csharp
|
||||
// Setup:
|
||||
world.SetSingleton(new GameState { Phase = GamePhase.Betting, Chips = 100 });
|
||||
|
||||
// Read-only inspection:
|
||||
@@ -198,12 +209,12 @@ var state = world.ReadSingleton<GameState>();
|
||||
// Mutation:
|
||||
ref var mutable = ref world.GetSingleton<GameState>();
|
||||
mutable.Phase = GamePhase.RoundOver;
|
||||
world.MarkModified<GameState>(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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user