docs: update documentation and implementation plan
Update the API surface and implementation plan to reflect architectural changes, including new iterator overloads, system design refinements, and the introduction of a source generator.
This commit is contained in:
parent
10c4caed90
commit
b066ac2eba
|
|
@ -139,13 +139,17 @@ public delegate void ForEachAction<T1, T2, T3, T4, T5, T6>(Entity entity, ref T1
|
||||||
|
|
||||||
Extension methods on `World` for `foreach`-style iteration over queries.
|
Extension methods on `World` for `foreach`-style iteration over queries.
|
||||||
Returns `ref struct` iterators that support zero-allocation iteration with
|
Returns `ref struct` iterators that support zero-allocation iteration with
|
||||||
`ref` access to components. Supports 1–3 component types.
|
`ref` access to components.
|
||||||
|
|
||||||
|
Two sets of overloads: one accepting an explicit `QueryDescriptor` for
|
||||||
|
filtering with `Without<T>`, and one without for simple "has component" scans.
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
namespace OECS;
|
namespace OECS;
|
||||||
|
|
||||||
public static class EntityIterator
|
public static class EntityIterator
|
||||||
{
|
{
|
||||||
|
// With explicit QueryDescriptor (supports Without<T> filters).
|
||||||
public static Select1<T1> Select<T1>(
|
public static Select1<T1> Select<T1>(
|
||||||
this World world, QueryDescriptor query) where T1 : struct;
|
this World world, QueryDescriptor query) where T1 : struct;
|
||||||
|
|
||||||
|
|
@ -156,16 +160,38 @@ public static class EntityIterator
|
||||||
public static Select3<T1, T2, T3> Select<T1, T2, T3>(
|
public static Select3<T1, T2, T3> Select<T1, T2, T3>(
|
||||||
this World world, QueryDescriptor query)
|
this World world, QueryDescriptor query)
|
||||||
where T1 : struct where T2 : struct where T3 : struct;
|
where T1 : struct where T2 : struct where T3 : struct;
|
||||||
|
|
||||||
|
// Without QueryDescriptor — iterates all entities with the given component(s).
|
||||||
|
public static Select1<T1> Select<T1>(
|
||||||
|
this World world) where T1 : struct;
|
||||||
|
|
||||||
|
public static Select2<T1, T2> Select<T1, T2>(
|
||||||
|
this World world)
|
||||||
|
where T1 : struct where T2 : struct;
|
||||||
|
|
||||||
|
public static Select3<T1, T2, T3> Select<T1, T2, T3>(
|
||||||
|
this World world)
|
||||||
|
where T1 : struct where T2 : struct where T3 : struct;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Usage:
|
The singletons `CurrentEntity`, `Current1`, `Current2`, `Current3` are
|
||||||
|
exposed on the ref struct iterators directly. Usage:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
using var iter = world.Select<Position, Velocity>(query);
|
// Simple scan: all entities with PlayerHand.
|
||||||
foreach (ref var item in iter)
|
using var iter = world.Select<PlayerHand>();
|
||||||
|
while (iter.MoveNext())
|
||||||
{
|
{
|
||||||
item.Current1.X += item.Current2.X * dt;
|
Console.WriteLine(iter.CurrentEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// With query filter:
|
||||||
|
var query = world.Query().With<Position>().Without<Frozen>().Build();
|
||||||
|
using var iter2 = world.Select<Position>(query);
|
||||||
|
while (iter2.MoveNext())
|
||||||
|
{
|
||||||
|
iter2.Current1.X += 1;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -202,20 +228,32 @@ public class QueryDescriptor
|
||||||
|
|
||||||
## ISystem
|
## ISystem
|
||||||
|
|
||||||
|
A system is a unit of logic that runs during a tick. It receives the `World`
|
||||||
|
and decides what to do — typically reading singletons, iterating queries,
|
||||||
|
or enqueuing commands.
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
namespace OECS;
|
namespace OECS;
|
||||||
|
|
||||||
public interface ISystem
|
public interface ISystem
|
||||||
{
|
{
|
||||||
QueryDescriptor Query { get; }
|
|
||||||
void Run(World world);
|
void Run(World world);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Systems do **not** declare a query on the interface. Instead, they either
|
||||||
|
read singletons directly (`world.ReadSingleton<T>()`) 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ITickedSystem
|
## ITickedSystem
|
||||||
|
|
||||||
|
An optional extension for systems that need tick metadata (delta time or
|
||||||
|
logical tick marker).
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
namespace OECS;
|
namespace OECS;
|
||||||
|
|
||||||
|
|
@ -225,6 +263,10 @@ public interface ITickedSystem : ISystem
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`SystemGroup` checks each system at runtime: if it implements
|
||||||
|
`ITickedSystem`, the `Run(World, Tick)` overload is called; otherwise
|
||||||
|
`Run(World)` is called. Both overloads must be implemented.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tick
|
## Tick
|
||||||
|
|
@ -266,6 +308,9 @@ public class SystemGroup
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Systems execute in registration order. `SystemGroup` automatically drains
|
||||||
|
commands and posts changes after each system and after the full tick.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ICommand
|
## ICommand
|
||||||
|
|
@ -279,6 +324,9 @@ public interface ICommand
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Implementations should be `[MessagePackObject]` structs so they can be
|
||||||
|
serialized and replayed.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CommandQueue
|
## CommandQueue
|
||||||
|
|
@ -297,6 +345,9 @@ public class CommandQueue
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`Enqueue<T>` uses a constrained generic to avoid boxing at the call site.
|
||||||
|
Commands enqueued during `ExecuteAll` are processed in the same drain cycle.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## IRelationship
|
## IRelationship
|
||||||
|
|
@ -324,6 +375,7 @@ namespace OECS;
|
||||||
|
|
||||||
[MessagePackObject]
|
[MessagePackObject]
|
||||||
public struct Relationship<TSelf, TTarget> : IRelationship
|
public struct Relationship<TSelf, TTarget> : IRelationship
|
||||||
|
where TSelf : struct where TTarget : struct
|
||||||
{
|
{
|
||||||
[Key(0)] public Entity Source { get; set; }
|
[Key(0)] public Entity Source { get; set; }
|
||||||
[Key(1)] public Entity Target { get; set; }
|
[Key(1)] public Entity Target { get; set; }
|
||||||
|
|
@ -359,7 +411,7 @@ public enum ChangeKind
|
||||||
## WorldSerializer
|
## WorldSerializer
|
||||||
|
|
||||||
Saves and loads a `World` to/from a MessagePack stream. Components are
|
Saves and loads a `World` to/from a MessagePack stream. Components are
|
||||||
serialized by their runtime type.
|
serialized by their runtime type using the source-generated `ComponentRegistry`.
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
namespace OECS;
|
namespace OECS;
|
||||||
|
|
@ -393,6 +445,7 @@ public class EntitySnapshot
|
||||||
[Key(0)] public uint Id { get; set; }
|
[Key(0)] public uint Id { get; set; }
|
||||||
[Key(1)] public uint Version { get; set; }
|
[Key(1)] public uint Version { get; set; }
|
||||||
[Key(2)] public ComponentEntry[] Components { get; set; }
|
[Key(2)] public ComponentEntry[] Components { get; set; }
|
||||||
|
[IgnoreMember]
|
||||||
public Entity Entity { get; }
|
public Entity Entity { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -410,37 +463,30 @@ public class ComponentEntry
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
using OECS;
|
using OECS;
|
||||||
using R3;
|
|
||||||
|
|
||||||
// Define components
|
// Define components
|
||||||
[MessagePackObject]
|
[MessagePackObject]
|
||||||
public struct Position : IMessagePackSerializationCallbackReceiver
|
public struct Position
|
||||||
{
|
{
|
||||||
[Key(0)] public float X;
|
[Key(0)] public float X;
|
||||||
[Key(1)] public float Y;
|
[Key(1)] public float Y;
|
||||||
|
|
||||||
public void OnBeforeSerialize() { }
|
|
||||||
public void OnAfterDeserialize() { }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[MessagePackObject]
|
[MessagePackObject]
|
||||||
public struct Velocity : IMessagePackSerializationCallbackReceiver
|
public struct Velocity
|
||||||
{
|
{
|
||||||
[Key(0)] public float X;
|
[Key(0)] public float X;
|
||||||
[Key(1)] public float Y;
|
[Key(1)] public float Y;
|
||||||
|
|
||||||
public void OnBeforeSerialize() { }
|
|
||||||
public void OnAfterDeserialize() { }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define a system
|
// Define a system
|
||||||
public class MovementSystem : ITickedSystem
|
public class MovementSystem : ITickedSystem
|
||||||
{
|
{
|
||||||
public QueryDescriptor Query { get; }
|
private readonly QueryDescriptor _query;
|
||||||
|
|
||||||
public MovementSystem(World world)
|
public MovementSystem(World world)
|
||||||
{
|
{
|
||||||
Query = world.Query()
|
_query = world.Query()
|
||||||
.With<Position>()
|
.With<Position>()
|
||||||
.With<Velocity>()
|
.With<Velocity>()
|
||||||
.Build();
|
.Build();
|
||||||
|
|
@ -452,7 +498,7 @@ public class MovementSystem : ITickedSystem
|
||||||
{
|
{
|
||||||
float dt = tick.DeltaTime;
|
float dt = tick.DeltaTime;
|
||||||
|
|
||||||
world.ForEach(Query, (Entity entity, ref Position pos, ref Velocity vel) =>
|
world.ForEach(_query, (Entity entity, ref Position pos, ref Velocity vel) =>
|
||||||
{
|
{
|
||||||
pos.X += vel.X * dt;
|
pos.X += vel.X * dt;
|
||||||
pos.Y += vel.Y * dt;
|
pos.Y += vel.Y * dt;
|
||||||
|
|
@ -466,16 +512,15 @@ var world = new World();
|
||||||
var group = new SystemGroup(world);
|
var group = new SystemGroup(world);
|
||||||
group.Add(new MovementSystem(world));
|
group.Add(new MovementSystem(world));
|
||||||
|
|
||||||
// Observe changes
|
|
||||||
world.ObserveComponentChanges<Position>()
|
|
||||||
.Subscribe(change => Console.WriteLine($"{change.Entity} moved"))
|
|
||||||
.AddTo(disposables);
|
|
||||||
|
|
||||||
// Create entities
|
// Create entities
|
||||||
var player = world.CreateEntity();
|
var player = world.CreateEntity();
|
||||||
world.AddComponent(player, new Position { X = 0, Y = 0 });
|
world.AddComponent(player, new Position { X = 0, Y = 0 });
|
||||||
world.AddComponent(player, new Velocity { X = 1, Y = 0 });
|
world.AddComponent(player, new Velocity { X = 1, Y = 0 });
|
||||||
|
|
||||||
|
// Observe changes via R3
|
||||||
|
world.ObserveComponentChanges<Position>()
|
||||||
|
.Subscribe(change => Console.WriteLine($"{change.Entity} moved"));
|
||||||
|
|
||||||
// Run a tick
|
// Run a tick
|
||||||
group.RunTimed(0.016f); // ~60 FPS
|
group.RunTimed(0.016f); // ~60 FPS
|
||||||
```
|
```
|
||||||
|
|
@ -495,3 +540,5 @@ These types are implementation details and may change without notice:
|
||||||
| `RelationshipIndex` | Reverse lookup from target entity to source entities. |
|
| `RelationshipIndex` | Reverse lookup from target entity to source entities. |
|
||||||
| `EntityAllocator` | Free-list + bump allocator for entity IDs. |
|
| `EntityAllocator` | Free-list + bump allocator for entity IDs. |
|
||||||
| `QueryExecutor` | Query iteration logic with smallest-set driver optimization. |
|
| `QueryExecutor` | Query iteration logic with smallest-set driver optimization. |
|
||||||
|
| `ComponentRegistry` | Source-generated registry of all component types for serialization. |
|
||||||
|
| `ComponentDescriptor` | Source-generated per-type descriptor with serialize/deserialize callbacks. |
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ ones. Architecture decisions are captured in `docs/architecture.md`.
|
||||||
|
|
||||||
## Target
|
## Target
|
||||||
|
|
||||||
- .NET 8 (LTS), `net8.0`
|
- .NET 8 (LTS), `net8.0` (C# 12)
|
||||||
- Dependencies: `MessagePack` (serialization), `R3` (reactivity)
|
- Dependencies: `MessagePack` (serialization), `R3` (reactivity)
|
||||||
- Output: `OECS.dll`
|
- Output: `OECS.dll`
|
||||||
|
- Source generator: `OECS.SourceGen.dll` (component registry for serialization)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -22,8 +23,12 @@ ones. Architecture decisions are captured in `docs/architecture.md`.
|
||||||
|
|
||||||
```
|
```
|
||||||
OECS.sln
|
OECS.sln
|
||||||
├── src/OECS/OECS.csproj
|
├── OECS/OECS.csproj
|
||||||
└── tests/OECS.Tests/OECS.Tests.csproj
|
├── OECS.SourceGen/OECS.SourceGen.csproj
|
||||||
|
├── OECS.Tests/OECS.Tests.csproj
|
||||||
|
├── Game.Blackjack/Blackjack.csproj
|
||||||
|
├── Game.Blackjack.Tests/Game.Blackjack.Tests.csproj
|
||||||
|
└── Game.TicTacToe/...
|
||||||
```
|
```
|
||||||
|
|
||||||
- `OECS.csproj` targets `net8.0`, references `MessagePack` and `R3`.
|
- `OECS.csproj` targets `net8.0`, references `MessagePack` and `R3`.
|
||||||
|
|
@ -76,6 +81,8 @@ Holds a `Dictionary<Type, object>` mapping component types to their
|
||||||
- `void Add<T>(Entity, T)`
|
- `void Add<T>(Entity, T)`
|
||||||
- `void Remove<T>(Entity)`
|
- `void Remove<T>(Entity)`
|
||||||
- `ref T Get<T>(Entity)`
|
- `ref T Get<T>(Entity)`
|
||||||
|
- `T Read<T>(Entity)` — copy without auto-mark
|
||||||
|
- `bool TryGet<T>(Entity, out T)`
|
||||||
- `bool Has<T>(Entity)`
|
- `bool Has<T>(Entity)`
|
||||||
- `void RemoveAll(Entity)` — called on entity destruction
|
- `void RemoveAll(Entity)` — called on entity destruction
|
||||||
|
|
||||||
|
|
@ -99,6 +106,8 @@ void DestroyEntity(Entity entity);
|
||||||
void AddComponent<T>(Entity entity, T component) where T : struct;
|
void AddComponent<T>(Entity entity, T component) where T : struct;
|
||||||
void RemoveComponent<T>(Entity entity) where T : struct;
|
void RemoveComponent<T>(Entity entity) where T : struct;
|
||||||
ref T GetComponent<T>(Entity entity) where T : struct;
|
ref T GetComponent<T>(Entity entity) where T : struct;
|
||||||
|
T ReadComponent<T>(Entity entity) where T : struct;
|
||||||
|
bool TryGetComponent<T>(Entity entity, out T value) where T : struct;
|
||||||
bool HasComponent<T>(Entity entity) where T : struct;
|
bool HasComponent<T>(Entity entity) where T : struct;
|
||||||
bool IsAlive(Entity entity);
|
bool IsAlive(Entity entity);
|
||||||
```
|
```
|
||||||
|
|
@ -128,8 +137,8 @@ A query is defined by:
|
||||||
```
|
```
|
||||||
class QueryDescriptor
|
class QueryDescriptor
|
||||||
{
|
{
|
||||||
HashSet<Type> With { get; }
|
IReadOnlySet<Type> With { get; }
|
||||||
HashSet<Type> Without { get; }
|
IReadOnlySet<Type> Without { get; }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -147,49 +156,74 @@ world.Query()
|
||||||
|
|
||||||
### 2.3 Query Execution
|
### 2.3 Query Execution
|
||||||
|
|
||||||
`World` provides iteration over matching entities. The smallest "with" sparse
|
`World` provides two iteration styles:
|
||||||
set is used as the driver; other sets are probed for membership.
|
|
||||||
|
|
||||||
|
**ForEach callbacks** (1–6 component types):
|
||||||
```csharp
|
```csharp
|
||||||
void ForEach<T1, T2>(QueryDescriptor query, Action<Entity, ref T1, ref T2> action);
|
void ForEach<T1, T2>(QueryDescriptor query, ForEachAction<T1, T2> action);
|
||||||
```
|
```
|
||||||
|
|
||||||
Overloads for 1–6 component types. The `Without` filter is checked by probing
|
**Ref struct iterators** via `EntityIterator.Select<T>()` extensions (1–3 component types):
|
||||||
the corresponding sparse sets.
|
```csharp
|
||||||
|
using var iter = world.Select<Position, Velocity>(query);
|
||||||
|
while (iter.MoveNext()) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Both accept an optional `QueryDescriptor`. When omitted, all entities with
|
||||||
|
the given component types are iterated (no `Without` filter).
|
||||||
|
|
||||||
|
The smallest "with" sparse set is used as the driver; other sets are probed
|
||||||
|
for membership. The singleton entity (ID 1) is always skipped.
|
||||||
|
|
||||||
### 2.4 ISystem
|
### 2.4 ISystem
|
||||||
|
|
||||||
```
|
```
|
||||||
interface ISystem
|
interface ISystem
|
||||||
{
|
{
|
||||||
QueryDescriptor Query { get; }
|
|
||||||
void Run(World world);
|
void Run(World world);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Systems declare their query and receive the world in `Run`. They call
|
Systems do **not** declare a query on the interface. Instead, they either
|
||||||
`world.ForEach(query, ...)` to iterate.
|
read singletons directly or build queries internally and iterate with
|
||||||
|
`ForEach` / `Select`. This keeps the interface minimal and gives systems
|
||||||
|
full flexibility.
|
||||||
|
|
||||||
Alternative considered: auto-injection of component refs. Rejected because it
|
**Why no auto-injection?** The explicit API makes the iteration cost
|
||||||
hides the iteration cost and makes the API less explicit. The explicit
|
visible and avoids the need for source generators or reflection for
|
||||||
`ForEach` call keeps the system author aware of what they're iterating.
|
system dispatch.
|
||||||
|
|
||||||
### 2.5 System Registration & Ordering
|
### 2.5 ITickedSystem
|
||||||
|
|
||||||
|
```
|
||||||
|
interface ITickedSystem : ISystem
|
||||||
|
{
|
||||||
|
void Run(World world, Tick tick);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Systems that need tick metadata (delta time or logical tick marker)
|
||||||
|
implement this. Both `Run` overloads must be implemented; `SystemGroup`
|
||||||
|
dispatches to the appropriate one at runtime.
|
||||||
|
|
||||||
|
### 2.6 System Registration & Ordering
|
||||||
|
|
||||||
```
|
```
|
||||||
class SystemGroup
|
class SystemGroup
|
||||||
{
|
{
|
||||||
void Add(ISystem system); // registration order = execution order
|
void Add(ISystem system); // registration order = execution order
|
||||||
void RunTimed(float deltaTime); // calls each system's Run
|
void Remove(ISystem system);
|
||||||
void RunLogical(); // calls each system's Run
|
void RunTimed(float deltaTime);
|
||||||
|
void RunLogical();
|
||||||
|
int Count { get; }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Systems run in registration order. For now, no explicit dependency graph —
|
Systems run in registration order. `SystemGroup` automatically:
|
||||||
this is the simplest model that works. If needed later, `Before()`/`After()`
|
- Drains commands before the tick, after each system, and after the full tick.
|
||||||
constraints can be added without breaking the API.
|
- Posts changes after each system and after the full tick.
|
||||||
|
|
||||||
### 2.6 Tick
|
### 2.7 Tick
|
||||||
|
|
||||||
```
|
```
|
||||||
readonly struct Tick
|
readonly struct Tick
|
||||||
|
|
@ -199,16 +233,7 @@ readonly struct Tick
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Passed to systems that opt into it via a separate interface:
|
### 2.8 Tests
|
||||||
|
|
||||||
```
|
|
||||||
interface ITickedSystem : ISystem
|
|
||||||
{
|
|
||||||
void Run(World world, Tick tick);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.7 Tests
|
|
||||||
|
|
||||||
- Query with single component returns matching entities.
|
- Query with single component returns matching entities.
|
||||||
- Query with multiple components returns intersection.
|
- Query with multiple components returns intersection.
|
||||||
|
|
@ -216,6 +241,7 @@ interface ITickedSystem : ISystem
|
||||||
- Adding/removing components updates query results.
|
- Adding/removing components updates query results.
|
||||||
- Systems run in registration order.
|
- Systems run in registration order.
|
||||||
- Destroyed entities don't appear in queries.
|
- Destroyed entities don't appear in queries.
|
||||||
|
- `ITickedSystem` receives correct tick data.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -226,31 +252,38 @@ interface ITickedSystem : ISystem
|
||||||
### 3.1 ICommand
|
### 3.1 ICommand
|
||||||
|
|
||||||
```
|
```
|
||||||
[MessagePackObject]
|
|
||||||
interface ICommand
|
interface ICommand
|
||||||
{
|
{
|
||||||
void Execute(World world);
|
void Execute(World world);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Commands are `[MessagePackObject]` structs implementing `ICommand`. They are
|
Commands are structs implementing `ICommand`. They are typically
|
||||||
not stored in ECS sparse sets — they live in a queue.
|
`[MessagePackObject]` for serialization. Not stored in ECS sparse
|
||||||
|
sets — they live in a queue.
|
||||||
|
|
||||||
### 3.2 CommandQueue
|
### 3.2 CommandQueue
|
||||||
|
|
||||||
```
|
```
|
||||||
class CommandQueue
|
class CommandQueue
|
||||||
{
|
{
|
||||||
void Enqueue(ICommand command);
|
void Enqueue<T>(T command) where T : struct, ICommand;
|
||||||
void ExecuteAll(World world); // FIFO, clears queue
|
void ExecuteAll(World world); // FIFO, fully drains queue
|
||||||
|
void Clear();
|
||||||
|
void ClearErrors();
|
||||||
int Count { get; }
|
int Count { get; }
|
||||||
|
IReadOnlyList<Exception> Errors { get; }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`Enqueue<T>` uses a constrained generic to avoid boxing at the call site.
|
||||||
|
Commands enqueued during `ExecuteAll` are processed in the same drain cycle.
|
||||||
|
|
||||||
### 3.3 Integration with World
|
### 3.3 Integration with World
|
||||||
|
|
||||||
`World` owns a `CommandQueue`. After each system runs (or after the full tick),
|
`World` owns a `CommandQueue`. After each system runs (or after the full tick),
|
||||||
the queue is drained. This is configurable:
|
the queue is drained automatically by `SystemGroup`. Manual drain is also
|
||||||
|
available:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
world.ExecuteCommands(); // manual drain
|
world.ExecuteCommands(); // manual drain
|
||||||
|
|
@ -279,36 +312,48 @@ available via `CommandQueue.Errors`.
|
||||||
**Goal:** Relationship components with auto-managed source/target and reverse
|
**Goal:** Relationship components with auto-managed source/target and reverse
|
||||||
lookup.
|
lookup.
|
||||||
|
|
||||||
### 4.1 Relationship\<TSelf, TTarget\>
|
### 4.1 IRelationship
|
||||||
|
|
||||||
|
```
|
||||||
|
interface IRelationship
|
||||||
|
{
|
||||||
|
Entity Source { get; }
|
||||||
|
Entity Target { get; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Relationship\<TSelf, TTarget\>
|
||||||
|
|
||||||
```
|
```
|
||||||
[MessagePackObject]
|
[MessagePackObject]
|
||||||
struct Relationship<TSelf, TTarget> : IRelationship
|
struct Relationship<TSelf, TTarget> : IRelationship
|
||||||
|
where TSelf : struct where TTarget : struct
|
||||||
{
|
{
|
||||||
[Key(0)] Entity Source { get; set; }
|
[Key(0)] Entity Source { get; set; }
|
||||||
[Key(1)] Entity Target { get; set; }
|
[Key(1)] Entity Target { get; set; }
|
||||||
// ... payload fields
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The `IRelationship` marker interface lets `ComponentStore` detect relationships
|
The `IRelationship` marker interface lets `ComponentStore` detect relationships
|
||||||
and maintain the reverse index.
|
and maintain the reverse index. The `TSelf`/`TTarget` phantom types
|
||||||
|
differentiate relationship kinds at the type level.
|
||||||
|
|
||||||
### 4.2 Reverse Index
|
Alternatively, implement `IRelationship` directly on your own struct (see the
|
||||||
|
Blackjack `Holds` and `InDeck` types).
|
||||||
|
|
||||||
`World` maintains a `Dictionary<Entity, HashSet<Entity>>` per relationship
|
### 4.3 Reverse Index
|
||||||
type, mapping target → set of source entities.
|
|
||||||
|
|
||||||
When a relationship component is added/removed, the index is updated
|
`World` maintains a reverse index per relationship type, mapping target →
|
||||||
automatically.
|
set of source entities. Updated automatically when relationship components
|
||||||
|
are added/removed.
|
||||||
|
|
||||||
### 4.3 Reverse Lookup API
|
### 4.4 Reverse Lookup API
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
IReadOnlyCollection<Entity> GetSources<T>(Entity target) where T : struct, IRelationship;
|
IReadOnlyCollection<Entity> GetSources<T>(Entity target) where T : struct, IRelationship;
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.4 Cascading Behavior
|
### 4.5 Cascading Behavior
|
||||||
|
|
||||||
When an entity is destroyed:
|
When an entity is destroyed:
|
||||||
- All relationships where it is the **source** are removed (components dropped).
|
- All relationships where it is the **source** are removed (components dropped).
|
||||||
|
|
@ -316,7 +361,7 @@ When an entity is destroyed:
|
||||||
from source entities).
|
from source entities).
|
||||||
- The reverse index is cleaned up.
|
- The reverse index is cleaned up.
|
||||||
|
|
||||||
### 4.5 Tests
|
### 4.6 Tests
|
||||||
|
|
||||||
- Adding a relationship updates the reverse index.
|
- Adding a relationship updates the reverse index.
|
||||||
- Removing a relationship updates the reverse index.
|
- Removing a relationship updates the reverse index.
|
||||||
|
|
@ -346,23 +391,17 @@ struct EntityChange
|
||||||
{
|
{
|
||||||
Entity Entity { get; }
|
Entity Entity { get; }
|
||||||
ChangeKind Kind { get; }
|
ChangeKind Kind { get; }
|
||||||
Type ComponentType { get; } // null for entity-level changes
|
Type? ComponentType { get; } // null for entity-level changes
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5.2 ChangeSet
|
### 5.2 ChangeBuffer
|
||||||
|
|
||||||
```
|
```
|
||||||
class ChangeSet
|
class ChangeBuffer
|
||||||
{
|
{
|
||||||
void MarkEntityAdded(Entity entity);
|
void Mark<...>(...);
|
||||||
void MarkEntityRemoved(Entity entity);
|
void Post(); // pushes to R3 subjects, then clears
|
||||||
void MarkComponentAdded(Entity entity, Type componentType);
|
|
||||||
void MarkComponentRemoved(Entity entity, Type componentType);
|
|
||||||
void MarkComponentModified(Entity entity, Type componentType);
|
|
||||||
|
|
||||||
IReadOnlyList<EntityChange> Changes { get; }
|
|
||||||
void Clear();
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -381,25 +420,15 @@ Rationale: structural changes are always detectable. Value mutations inside a
|
||||||
value. Requiring an explicit `MarkModified` call is the simplest correct
|
value. Requiring an explicit `MarkModified` call is the simplest correct
|
||||||
approach.
|
approach.
|
||||||
|
|
||||||
**Debug aid:** In `DEBUG` builds, `ref T Get<T>(Entity)` returns a wrapper that
|
**`ReadComponent<T>`** (returns a copy) and **`ReadSingleton<T>`** never
|
||||||
tracks whether the value was written. If a system iterates `ref T` and never
|
auto-mark. Use these when you only need to inspect state without signaling
|
||||||
calls `MarkModified`, a warning is logged. This catches the most common
|
changes.
|
||||||
mistake.
|
|
||||||
|
|
||||||
### 5.4 Posting Model
|
### 5.4 Posting Model
|
||||||
|
|
||||||
```
|
- During a system's `Run`, changes accumulate in a pending buffer.
|
||||||
class ChangeBuffer
|
- After each system's `Run`, `Post()` is called automatically by `SystemGroup`.
|
||||||
{
|
- After the full tick, `Post()` is called once more.
|
||||||
ChangeSet Pending { get; } // accumulates during system run
|
|
||||||
void Post(); // pushes to R3 subjects, then clears
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- During a system's `Run`, changes accumulate in `Pending`.
|
|
||||||
- After each system's `Run`, `Post()` is called automatically.
|
|
||||||
- After the full tick, `Post()` is called once more (for any changes made
|
|
||||||
outside systems, e.g., during command execution).
|
|
||||||
- When not in a system run, changes are **not** posted automatically — the
|
- When not in a system run, changes are **not** posted automatically — the
|
||||||
caller must call `world.PostChanges()`.
|
caller must call `world.PostChanges()`.
|
||||||
|
|
||||||
|
|
@ -408,9 +437,9 @@ class ChangeBuffer
|
||||||
`World` exposes observables:
|
`World` exposes observables:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
IObservable<EntityChange> ObserveEntityChanges();
|
Observable<EntityChange> ObserveEntityChanges();
|
||||||
IObservable<EntityChange> ObserveComponentChanges<T>() where T : struct;
|
Observable<EntityChange> ObserveComponentChanges<T>() where T : struct;
|
||||||
IObservable<EntityChange> ObserveQuery(QueryDescriptor query);
|
Observable<EntityChange> ObserveQuery(QueryDescriptor query);
|
||||||
```
|
```
|
||||||
|
|
||||||
These are backed by `Subject<EntityChange>` instances. Subscribers receive
|
These are backed by `Subject<EntityChange>` instances. Subscribers receive
|
||||||
|
|
@ -445,19 +474,21 @@ world.ObserveComponentChanges<Health>()
|
||||||
### 6.1 Singleton Entity
|
### 6.1 Singleton Entity
|
||||||
|
|
||||||
`World` reserves entity ID `1` as the singleton entity. It is never destroyed
|
`World` reserves entity ID `1` as the singleton entity. It is never destroyed
|
||||||
and is excluded from normal queries by default.
|
and is excluded from normal queries (all iterators skip ID 1).
|
||||||
|
|
||||||
### 6.2 Singleton Accessors
|
### 6.2 Singleton Accessors
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
void SetSingleton<T>(T component) where T : struct;
|
void SetSingleton<T>(T component) where T : struct;
|
||||||
ref T GetSingleton<T>() where T : struct;
|
ref T GetSingleton<T>() where T : struct;
|
||||||
|
T ReadSingleton<T>() where T : struct;
|
||||||
bool HasSingleton<T>() where T : struct;
|
bool HasSingleton<T>() where T : struct;
|
||||||
void RemoveSingleton<T>() where T : struct;
|
void RemoveSingleton<T>() where T : struct;
|
||||||
```
|
```
|
||||||
|
|
||||||
These are convenience wrappers around `AddComponent`/`GetComponent` on the
|
These are convenience wrappers around `AddComponent`/`GetComponent` on the
|
||||||
singleton entity.
|
singleton entity. `GetSingleton` returns a `ref` (auto-marks during iteration);
|
||||||
|
`ReadSingleton` returns a copy (never auto-marks).
|
||||||
|
|
||||||
### 6.3 Query Exclusion
|
### 6.3 Query Exclusion
|
||||||
|
|
||||||
|
|
@ -473,28 +504,62 @@ to include it, they can query it by its entity ID directly.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 7 — Polish & Documentation (Week 7)
|
## Phase 7 — Source Generator & Serialization (Week 7)
|
||||||
|
|
||||||
### 7.1 XML Docs
|
**Goal:** Compile-time component registry for serialization without reflection.
|
||||||
|
|
||||||
|
### 7.1 OECS.SourceGen
|
||||||
|
|
||||||
|
A Roslyn incremental source generator that scans for all types used as
|
||||||
|
generic arguments to `World` methods (`AddComponent<T>`, `GetComponent<T>`,
|
||||||
|
`SetSingleton<T>`, etc.) and generates a `ComponentRegistry` class with
|
||||||
|
per-type serialize/deserialize callbacks via MessagePack.
|
||||||
|
|
||||||
|
### 7.2 WorldSerializer
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
static class WorldSerializer
|
||||||
|
{
|
||||||
|
static void Save(World world, Stream stream);
|
||||||
|
static void Load(World world, Stream stream);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses `ComponentRegistry` to serialize/deserialize all entities and their
|
||||||
|
components to/from a MessagePack stream. `IRelationship.Source` is fixed
|
||||||
|
up to the owning entity on load.
|
||||||
|
|
||||||
|
### 7.3 Snapshot Types
|
||||||
|
|
||||||
|
`WorldSnapshot`, `EntitySnapshot`, and `ComponentEntry` are the public
|
||||||
|
serialization DTOs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 8 — Polish & Documentation (Week 8)
|
||||||
|
|
||||||
|
### 8.1 XML Docs
|
||||||
|
|
||||||
All public API surface gets `<summary>` XML documentation comments.
|
All public API surface gets `<summary>` XML documentation comments.
|
||||||
|
|
||||||
### 7.2 README
|
### 8.2 README
|
||||||
|
|
||||||
Quick-start guide with a minimal example: create world, register system, run
|
Quick-start guide with a minimal example: create world, register system, run
|
||||||
tick, observe changes.
|
tick, observe changes.
|
||||||
|
|
||||||
### 7.3 NuGet Packaging
|
### 8.3 NuGet Packaging
|
||||||
|
|
||||||
`OECS.csproj` includes package metadata:
|
`OECS.csproj` includes package metadata:
|
||||||
- `PackageId`: `OECS`
|
- `PackageId`: `OECS`
|
||||||
- `Description`: "Observable ECS for C# — an entity component system focused on
|
- `Description`: "Observable ECS for C# — an entity component system focused on
|
||||||
a clean reactive API surface."
|
a clean reactive API surface."
|
||||||
- `PackageTags`: `ecs;reactive;observable;gamedev`
|
- `PackageTags`: `ecs;reactive;observable;gamedev`
|
||||||
|
- Bundles `OECS.SourceGen.dll` as an analyzer for consumers.
|
||||||
|
|
||||||
### 7.4 CI (optional)
|
### 8.4 Testing Games
|
||||||
|
|
||||||
GitHub Actions workflow: build, test, pack.
|
Test games (`Game.Blackjack`, `Game.TicTacToe`) serve as integration tests
|
||||||
|
and design validation. See `docs/testing-games.md` for the testing strategy.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -507,7 +572,8 @@ Phase 1 (Core)
|
||||||
└─→ Phase 4 (Relationships)
|
└─→ Phase 4 (Relationships)
|
||||||
└─→ Phase 5 (Reactivity)
|
└─→ Phase 5 (Reactivity)
|
||||||
└─→ Phase 6 (Singletons)
|
└─→ Phase 6 (Singletons)
|
||||||
└─→ Phase 7 (Polish)
|
└─→ Phase 7 (Source Gen & Serialization)
|
||||||
|
└─→ Phase 8 (Polish)
|
||||||
```
|
```
|
||||||
|
|
||||||
Phases 3 and 4 can be done in parallel; Phase 5 depends on both.
|
Phases 3 and 4 can be done in parallel; Phase 5 depends on both.
|
||||||
|
|
@ -520,8 +586,8 @@ Phases 3 and 4 can be done in parallel; Phase 5 depends on both.
|
||||||
default. If needed, systems could declare read/write component access for
|
default. If needed, systems could declare read/write component access for
|
||||||
automatic parallel scheduling — but this adds significant complexity.
|
automatic parallel scheduling — but this adds significant complexity.
|
||||||
|
|
||||||
2. **World serialization?** Since components are MessagePack-serializable,
|
2. **Multiple worlds?** The design supports it naturally — `World` is a class,
|
||||||
snapshotting the entire world is feasible. This is a Phase 7+ stretch goal.
|
|
||||||
|
|
||||||
3. **Multiple worlds?** The design supports it naturally — `World` is a class,
|
|
||||||
you can instantiate multiple. No cross-world references are supported.
|
you can instantiate multiple. No cross-world references are supported.
|
||||||
|
|
||||||
|
3. **Component import from CSV/MasterMemory?** The `design.md` mentions this as
|
||||||
|
a potential feature. Not yet implemented.
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,53 @@
|
||||||
# Testing games
|
# Testing games
|
||||||
|
|
||||||
1. Games are dlls. Use a standalone test project to test a game.
|
1. Games are DLLs. Use a standalone test project to test a game.
|
||||||
2. Create baseline game snapshots in textual format. Save to files.
|
2. Create baseline game snapshots in textual format. Save to files.
|
||||||
3. Create baseline logs via the r3 observable api. Save to files.
|
3. Create baseline logs via the R3 observable API. Save to files.
|
||||||
4. Read the snapshot/logs manually to identify issues.
|
4. Read the snapshot/logs manually to identify issues.
|
||||||
5. Make sure serialization roundtrip works.
|
5. Make sure serialization roundtrip works.
|
||||||
|
|
||||||
## Play tests
|
## Play tests
|
||||||
|
|
||||||
The goal of playtests is to execute a game with AI players and potentially random seeds. Then the log/snapshots are read by the LLM agent to spot issues.
|
The goal of playtests is to execute a game with AI players and potentially
|
||||||
|
random seeds. Then the log/snapshots are read by the LLM agent to spot issues.
|
||||||
|
|
||||||
There is no failure in play tests; all tests pass but the agent should read the log for reference.
|
There is no failure in play tests; all tests pass but the agent should read the
|
||||||
|
log for reference.
|
||||||
|
|
||||||
AI players are not LLMs here; they are typically common game AI implementations.
|
AI players are not LLMs here; they are typically common game AI implementations.
|
||||||
|
|
||||||
Create static functions that analyze the game state and return a command. Each such function is an agent. Each AI player routes its decisions to a random agent in a pool of weighted agents.
|
Create static functions that analyze the game state and return a command. Each
|
||||||
|
such function is an agent. Each AI player routes its decisions to a random
|
||||||
|
agent in a pool of weighted agents.
|
||||||
|
|
||||||
Agents to consider:
|
Agents to consider:
|
||||||
- Random: evenly chooses every move randomly.
|
- **Random:** evenly chooses every move randomly.
|
||||||
- Greedy: uses a specific way to score actions. Always chooses the onewith highest score. There can be multiple Greedy agents wtih different scoring.
|
- **Greedy:** uses a specific way to score actions. Always chooses the one with
|
||||||
|
highest score. There can be multiple Greedy agents with different scoring.
|
||||||
|
|
||||||
For each playtest attempt, generate a full game log, analyze and report what you find from the log.
|
For each playtest attempt, generate a full game log, analyze and report what
|
||||||
|
you find from the log.
|
||||||
|
|
||||||
|
### Round Runner
|
||||||
|
|
||||||
|
Playtests should run multiple rounds, not just one. The recommended pattern:
|
||||||
|
|
||||||
|
```
|
||||||
|
while chips >= minimum_bet AND chips < 2× starting_chips:
|
||||||
|
place bet → deal → agent decides hit/stand → resolve → new round
|
||||||
|
```
|
||||||
|
|
||||||
|
This exercises the full game lifecycle including deck reshuffling, round
|
||||||
|
transitions, and chip management. Safety cap at ~200 rounds to prevent
|
||||||
|
infinite loops.
|
||||||
|
|
||||||
|
### Play Log Format
|
||||||
|
|
||||||
|
Each play log should contain:
|
||||||
|
- **Header:** agent name, seed, result summary, win/loss/push counts.
|
||||||
|
- **Round Summaries:** per-round result, chip delta, hit count.
|
||||||
|
- **Decisions:** per-decision hand total and choice (Hit/Stand).
|
||||||
|
- **Reactivity:** raw R3 observable log of component/entity changes.
|
||||||
|
- **Final State:** snapshot of the world after all rounds.
|
||||||
|
|
||||||
|
Logs are saved as `.playlog` files alongside test output for manual review.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue