From 34439e0f95c2df5cc3e6b070f0b93be610c4f2b9 Mon Sep 17 00:00:00 2001 From: hypercross Date: Sat, 18 Jul 2026 19:03:37 +0800 Subject: [PATCH] feat: initialize OECS project structure Initializes the OECS repository with the core foundation, including: - Entity management with 24-bit IDs and 8-bit versioning - Sparse set-based component storage - Entity allocation and recycling logic - Core World API for entity and component lifecycle - Project scaffolding for source and unit tests - Design documentation and architecture decision records (ADRs) --- .gitignore | 33 ++ OECS.sln | 25 ++ design.md | 65 ++++ docs/api-surface.md | 338 ++++++++++++++++++ docs/architecture.md | 336 ++++++++++++++++++ docs/implementation-plan.md | 527 +++++++++++++++++++++++++++++ src/OECS/ComponentStore.cs | 103 ++++++ src/OECS/Entity.cs | 64 ++++ src/OECS/EntityAllocator.cs | 86 +++++ src/OECS/OECS.csproj | 22 ++ src/OECS/SparseSet.cs | 157 +++++++++ src/OECS/World.cs | 114 +++++++ tests/OECS.Tests/CoreTests.cs | 226 +++++++++++++ tests/OECS.Tests/OECS.Tests.csproj | 26 ++ 14 files changed, 2122 insertions(+) create mode 100644 .gitignore create mode 100644 OECS.sln create mode 100644 design.md create mode 100644 docs/api-surface.md create mode 100644 docs/architecture.md create mode 100644 docs/implementation-plan.md create mode 100644 src/OECS/ComponentStore.cs create mode 100644 src/OECS/Entity.cs create mode 100644 src/OECS/EntityAllocator.cs create mode 100644 src/OECS/OECS.csproj create mode 100644 src/OECS/SparseSet.cs create mode 100644 src/OECS/World.cs create mode 100644 tests/OECS.Tests/CoreTests.cs create mode 100644 tests/OECS.Tests/OECS.Tests.csproj diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..52dd5a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Build results +[Dd]ebug/ +[Rr]elease/ +x64/ +x86/ +[Bb]in/ +[Oo]bj/ +*.user +*.suo +*.cache + +# NuGet +*.nupkg +**/[Pp]ackages/* +!**/[Pp]ackages/build/ +*.nuget.props +*.nuget.targets + +# IDE +.vs/ +.vscode/ +.idea/ +*.swp +*~ + +# OS +.DS_Store +Thumbs.db + +# Test results +TestResults/ +*.trx +*.coverage \ No newline at end of file diff --git a/OECS.sln b/OECS.sln new file mode 100644 index 0000000..b6b35a8 --- /dev/null +++ b/OECS.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS", "src\OECS\OECS.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS.Tests", "tests\OECS.Tests\OECS.Tests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/design.md b/design.md new file mode 100644 index 0000000..82c70b6 --- /dev/null +++ b/design.md @@ -0,0 +1,65 @@ +# Observable ECS in C#. + +An ECS system that focuses on a clean obserable api surface rather than performance. + +## Entity + +Entity should be an opaque integer type. + +Divided into identifer bits and version bits. + +## Component + +c# structs marked for [MessagePack](https://github.com/MessagePack-CSharp/MessagePack-CSharp) serialization. + +Components are in sparse-sets so they can cheaply be added/removed. + +## Relationship + +special components that automanage the source/target. + +can be iterated by target for easy reverse lookup. + +## Commands + +serializable structs like components, not in ecs but in a queue. + +have a `run` that gets called when you consume the command queue. + +## Singletons + +a special entity is used to carry singletons. + +## System + +Queries are composable with `With` and `Without` . Iterable for `ref TComponent`. + +Systems are callbacks registered to queries when a tick happens. Ordering of systems is determined at registration time. + +ticks can be `timed`(with deltaTime) or `logical`(without args). + +## Reactivity + +Use [R3](https://github.com/Cysharp/R3) for reactivity api. + +Queries can be observed for changes. This is different from iteration for entity/component refs; System always run in ticks and not reactively. + +Query subscriptions are not bound to entities but typically bound to, for example, frontend UI lifecycles. + +Changes include entity add/remove, or entity component add/remove. + +Changes need to be marked and then posted. + +### In Systems + +When a `System` makes changes in its `run` function, changes are posted after the `run`. + +When `run` happens in a tick, those changes are posted after the tick. + +When not in a system `run`, changes are not posted automatically. + +Marking components for updates is manual. structural changes are auto marked. + +## Serialization + +Entities/Components can be serialized from `.csv` files or [MasterMemory](https://github.com/Cysharp/MasterMemory) tables. diff --git a/docs/api-surface.md b/docs/api-surface.md new file mode 100644 index 0000000..728e424 --- /dev/null +++ b/docs/api-surface.md @@ -0,0 +1,338 @@ +# API Surface: OECS + +This document describes the public API surface of the OECS library. It serves +as a contract for implementation and a reference for consumers. + +All types reside in the `OECS` namespace unless otherwise noted. + +--- + +## Entity + +```csharp +namespace OECS; + +public readonly struct Entity : IEquatable +{ + public static Entity Null { get; } // ID=0, Version=0 + public uint Id { get; } // 24-bit identifier + public uint Version { get; } // 8-bit generation + public bool IsNull { get; } // true for Entity.Null + + public bool Equals(Entity other); + public override bool Equals(object? obj); + public override int GetHashCode(); + public override string ToString(); // "Entity(42:v3)" + public static bool operator ==(Entity left, Entity right); + public static bool operator !=(Entity left, Entity right); +} +``` + +--- + +## World + +```csharp +namespace OECS; + +public class World : IDisposable +{ + // --- Lifecycle --- + public World(); + + // --- Entity Management --- + public Entity CreateEntity(); + public void DestroyEntity(Entity entity); + public bool IsAlive(Entity entity); + + // --- Component Management --- + public void AddComponent(Entity entity, T component) where T : struct; + public void RemoveComponent(Entity entity) where T : struct; + public ref T GetComponent(Entity entity) where T : struct; + public bool HasComponent(Entity entity) where T : struct; + + // --- Singleton --- + public void SetSingleton(T component) where T : struct; + public ref T GetSingleton() where T : struct; + public bool HasSingleton() where T : struct; + public void RemoveSingleton() where T : struct; + + // --- Queries --- + public QueryBuilder Query(); + + // --- Query Execution --- + public void ForEach( + QueryDescriptor query, + Action action) where T1 : struct; + + // Overloads for 2–6 component types: + public void ForEach(QueryDescriptor, Action) + where T1 : struct where T2 : struct; + // ... up to T6 + + // --- Commands --- + public CommandQueue Commands { get; } + public void ExecuteCommands(); + + // --- Reactivity --- + public void MarkModified(Entity entity) where T : struct; + public void PostChanges(); + + public IObservable ObserveEntityChanges(); + public IObservable ObserveComponentChanges() + where T : struct; + public IObservable ObserveQuery(QueryDescriptor query); + + // --- Relationships --- + public IReadOnlyCollection GetSources(Entity target) + where T : struct, IRelationship; + + // --- Cleanup --- + public void Dispose(); +} +``` + +--- + +## QueryBuilder + +```csharp +namespace OECS; + +public class QueryBuilder +{ + public QueryBuilder With() where T : struct; + public QueryBuilder Without() where T : struct; + public QueryDescriptor Build(); +} +``` + +--- + +## QueryDescriptor + +```csharp +namespace OECS; + +public class QueryDescriptor +{ + public IReadOnlySet With { get; } + public IReadOnlySet Without { get; } +} +``` + +--- + +## ISystem + +```csharp +namespace OECS; + +public interface ISystem +{ + QueryDescriptor Query { get; } + void Run(World world); +} +``` + +--- + +## ITickedSystem + +```csharp +namespace OECS; + +public interface ITickedSystem : ISystem +{ + void Run(World world, Tick tick); +} +``` + +--- + +## Tick + +```csharp +namespace OECS; + +public readonly struct Tick +{ + public TickType Type { get; } + public float DeltaTime { get; } + + public static Tick Timed(float deltaTime); + public static Tick Logical(); +} + +public enum TickType +{ + Timed, + Logical +} +``` + +--- + +## SystemGroup + +```csharp +namespace OECS; + +public class SystemGroup +{ + public void Add(ISystem system); + public void Remove(ISystem system); + public void RunTimed(float deltaTime); + public void RunLogical(); + public int Count { get; } +} +``` + +--- + +## ICommand + +```csharp +namespace OECS; + +public interface ICommand +{ + void Execute(World world); +} +``` + +--- + +## CommandQueue + +```csharp +namespace OECS; + +public class CommandQueue +{ + public void Enqueue(T command) where T : struct, ICommand; + public void ExecuteAll(World world); + public int Count { get; } + public IReadOnlyList Errors { get; } +} +``` + +--- + +## IRelationship + +```csharp +namespace OECS; + +public interface IRelationship +{ + Entity Source { get; } + Entity Target { get; } +} +``` + +--- + +## EntityChange + +```csharp +namespace OECS; + +public readonly struct EntityChange +{ + public Entity Entity { get; } + public ChangeKind Kind { get; } + public Type? ComponentType { get; } // null for entity-level changes +} + +public enum ChangeKind +{ + EntityAdded, + EntityRemoved, + ComponentAdded, + ComponentRemoved, + ComponentModified +} +``` + +--- + +## Usage Example + +```csharp +using OECS; +using R3; + +// Define components +[MessagePackObject] +public struct Position : IMessagePackSerializationCallbackReceiver +{ + [Key(0)] public float X; + [Key(1)] public float Y; +} + +[MessagePackObject] +public struct Velocity +{ + [Key(0)] public float X; + [Key(1)] public float Y; +} + +// Define a system +public class MovementSystem : ITickedSystem +{ + public QueryDescriptor Query { get; } + + public MovementSystem(World world) + { + Query = world.Query() + .With() + .With() + .Build(); + } + + public void Run(World world, Tick tick) + { + float dt = tick.DeltaTime; + + world.ForEach(Query, (Entity entity, ref Position pos, ref Velocity vel) => + { + pos.X += vel.X * dt; + pos.Y += vel.Y * dt; + world.MarkModified(entity); + }); + } +} + +// Wire it up +var world = new World(); +var group = new SystemGroup(); +group.Add(new MovementSystem(world)); + +// Observe changes +world.ObserveComponentChanges() + .Subscribe(change => Console.WriteLine($"{change.Entity} moved")) + .AddTo(disposables); + +// Create entities +var player = world.CreateEntity(); +world.AddComponent(player, new Position { X = 0, Y = 0 }); +world.AddComponent(player, new Velocity { X = 1, Y = 0 }); + +// Run a tick +group.RunTimed(0.016f); // ~60 FPS +``` + +--- + +## Internal Types (not part of public API) + +These types are implementation details and may change without notice: + +| Type | Purpose | +|---|---| +| `SparseSet` | Dense/sparse array pair for component storage. | +| `ComponentStore` | Registry of `SparseSet` instances by type. | +| `ChangeBuffer` | Accumulates `EntityChange` during system run, posts to R3 subjects. | +| `RelationshipIndex` | Reverse lookup from target entity to source entities. | +| `EntityAllocator` | Free-list + bump allocator for entity IDs. | diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..daabbd0 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,336 @@ +# Architecture Decisions: OECS + +This document records the key architectural decisions made during the design of +OECS, with the rationale behind each one. It serves as a reference for +contributors and a guardrail against accidental complexity. + +--- + +## ADR-001: Sparse Sets over Archetypes + +**Status:** Accepted + +**Context:** The ECS needs to store components and support iteration. The two +dominant patterns are archetypes (entities grouped by exact component +signature) and sparse sets (one dense array per component type). + +**Decision:** Use sparse sets. + +**Rationale:** + +- The design prioritizes cheap add/remove operations because reactivity is a + first-class concern. Sparse sets have O(1) add/remove via swap-remove. +- Archetypes require moving an entity to a new archetype when its component + signature changes. This complicates change tracking — you must detect the + move and emit events. +- Sparse sets have worse cache locality for multi-component queries (you probe + multiple arrays), but for an observable-first ECS targeting UI workflows, + query throughput is not the bottleneck. +- Sparse sets are simpler to implement and reason about. + +**Consequences:** + +- Multi-component queries probe N sparse sets. The smallest set drives + iteration to minimize probes. +- Memory overhead: one `sparse` array per component type, sized to max entity + ID. Acceptable for the expected entity counts (tens of thousands, not + millions). + +--- + +## ADR-002: 32-bit Entity with Version Bits + +**Status:** Accepted + +**Context:** Entities need to be cheap to copy, comparable, and safe against +use-after-free (accessing a recycled entity ID). + +**Decision:** `readonly struct Entity` wrapping a `uint`. Upper 24 bits are the +ID, lower 8 bits are the version. + +**Rationale:** + +- 24-bit ID space supports ~16.7 million simultaneously alive entities. More + than enough for observable-first use cases (UI entities, game objects in a + typical scene). +- 8-bit version allows 256 generations before an ID wraps. With a free list, + hot IDs are recycled quickly, making version collisions unlikely. +- 32-bit struct is 4 bytes — fits in a register, zero heap allocations. +- `Entity.Null` = `0` (ID=0, version=0). ID 0 is never allocated, so this is + a natural sentinel. + +**Alternatives considered:** + +- `ulong` (64-bit): More headroom but 2× memory in every array and struct that + references an entity. Overkill for the target scale. +- `Guid`: Heap-allocated when boxed, 16 bytes, not comparable by ref. Rejected + for performance and ergonomics. + +**Consequences:** + +- `World` must track the current version per ID slot (a `byte[]` parallel to + the free list). +- Entity equality checks both ID and version. + +--- + +## ADR-003: Explicit Query Iteration over Auto-Injection + +**Status:** Accepted + +**Context:** Systems need to iterate entities matching a component signature. +Two API styles exist: auto-injection (the framework calls the system with the +right components) and explicit iteration (the system calls `ForEach`). + +**Decision:** Use explicit iteration via `world.ForEach(query, action)`. + +**Rationale:** + +- Auto-injection hides the iteration cost. A system that looks like a simple + method is actually O(N) — this is surprising. +- Explicit iteration makes the performance model visible. The system author + sees the `ForEach` call and understands they're iterating. +- Auto-injection requires either code generation or reflection to match + parameters to component types. Explicit iteration uses generics, which are + resolved at compile time. +- Explicit iteration is more flexible: a system can run multiple queries, or + conditionally skip iteration. + +**Consequences:** + +- Slightly more verbose system code. Acceptable tradeoff for clarity. +- No source generators or reflection needed for system dispatch. + +--- + +## ADR-004: Registration Order for System Execution + +**Status:** Accepted + +**Context:** Systems need a defined execution order. Options include +registration order, explicit dependency declarations, and stage-based +grouping. + +**Decision:** Registration order determines execution order. No dependency +graph. + +**Rationale:** + +- Registration order is the simplest model that works. It's predictable and + requires no additional API surface. +- For the target use case (observable ECS for UI-heavy applications), the + number of systems is typically small (< 50). Manual ordering is manageable + at this scale. +- Dependency graphs add complexity (cycle detection, topological sort) without + proportional benefit at this scale. +- If needed later, `Before()`/`After()` constraints can be added to + `SystemGroup` without breaking existing code. + +**Consequences:** + +- System authors must be mindful of registration order. +- `SystemGroup` is a simple ordered list, not a graph. + +--- + +## ADR-005: Manual Marking for Component Modifications + +**Status:** Accepted + +**Context:** The reactivity system needs to know when a component value changes +so it can notify observers. Structural changes (add/remove) are detectable, but +in-place mutations via `ref T` are not. + +**Decision:** Require explicit `world.MarkModified(entity)` calls after +mutating a component. Provide a debug-mode warning when a `ref T` is obtained +but never marked. + +**Rationale:** + +- C# structs returned by `ref` have no built-in change detection. Wrapping them + in a property-change-notifying container would break `ref` semantics and add + overhead. +- Auto-detection via `IEquatable` comparison is possible but expensive: + every component would be compared every tick, even if unchanged. +- Manual marking puts the cost on the author, where it belongs. The debug + warning catches the most common mistake (forgetting to mark). + +**Alternatives considered:** + +- **Dirty flag on every component:** Requires a wrapper struct, breaks `ref` + returns, adds per-component memory overhead. +- **Hash-based change detection:** Compute hash on write, compare on post. + Expensive for large components, false positives on hash collisions. +- **Copy-on-write:** Store previous value, compare on post. Doubles memory for + all components. + +**Consequences:** + +- System authors must remember to call `MarkModified`. The debug warning + mitigates this. +- No per-component memory or CPU overhead for change detection. + +--- + +## ADR-006: Deferred Change Posting + +**Status:** Accepted + +**Context:** Changes made during a system's `Run` need to be communicated to +observers. Posting immediately would interleave observer callbacks with system +logic, leading to reentrancy bugs. + +**Decision:** Accumulate changes during `Run`, post them after `Run` completes. +Post once more after the full tick. + +**Rationale:** + +- Prevents observers from seeing partially-updated state mid-system. +- Allows batching: multiple changes to the same entity/component are collapsed + into one notification. +- Matches the mental model of "the tick is the atomic unit of work." + +**Consequences:** + +- Observers always see state after a complete system or tick, never during. +- If an observer needs to react mid-tick, they must split their logic into + multiple systems. + +--- + +## ADR-007: R3 for Reactivity + +**Status:** Accepted + +**Context:** The ECS needs a reactive programming library for observable +queries and change subscriptions. + +**Decision:** Use [R3](https://github.com/Cysharp/R3). + +**Rationale:** + +- R3 is the de facto standard for reactive programming in modern .NET (the + successor to UniRx). +- It's actively maintained by Cysharp (same author as MessagePack-CSharp). +- Zero-allocation observables, `IObservable` compatible, `AddTo` for + lifecycle management. +- First-party support for `IDisposable` subscription handles — natural fit for + UI lifecycle binding. + +**Alternatives considered:** + +- **System.Reactive (Rx.NET):** Heavier, more allocation-heavy, less + game-dev-friendly. +- **Custom event system:** Reinventing the wheel. R3 provides operators + (Where, Select, Throttle) for free. + +**Consequences:** + +- Dependency on R3. Acceptable — it's the same ecosystem as MessagePack. +- Subscribers use standard Rx patterns (`Subscribe`, `AddTo`). + +--- + +## ADR-008: Commands as Serializable Structs + +**Status:** Accepted + +**Context:** The design calls for a command queue where commands are +serializable and executed deferred. + +**Decision:** Commands are `[MessagePackObject]` structs implementing +`ICommand`. They live in a `CommandQueue`, not in ECS sparse sets. + +**Rationale:** + +- Structs avoid heap allocations per command. +- MessagePack serialization enables networking, replay, and save/load of + command streams. +- Separation from ECS state: commands are transient actions, not persistent + data. Mixing them into sparse sets would blur this distinction. + +**Consequences:** + +- Commands cannot be queried like components. This is intentional. +- `ICommand` interface on a struct causes boxing if passed as `ICommand`. + Mitigation: `CommandQueue.Enqueue(T command) where T : struct, ICommand` + uses constrained generics to avoid boxing. Internal storage still boxes + (heterogeneous queue), but the enqueue path is allocation-free. + +--- + +## ADR-009: Singleton as Reserved Entity + +**Status:** Accepted + +**Context:** Singletons (global state like `Time`, `Config`, `InputState`) need +a home in the ECS. + +**Decision:** Reserve entity ID `1` as the singleton entity. Provide +convenience accessors (`SetSingleton`, `GetSingleton`). Exclude from +normal queries. + +**Rationale:** + +- Reuses existing component storage — no separate dictionary or global + variables. +- Query exclusion prevents accidental iteration over singleton data in entity + queries. +- Simpler than a separate "resource" system (as in Bevy). One concept (entity + + components) covers both entities and singletons. + +**Consequences:** + +- Entity ID `1` is permanently reserved. +- `DestroyEntity` on the singleton entity is a no-op or throws. + +--- + +## ADR-010: Single-Threaded by Default + +**Status:** Accepted + +**Context:** Should systems run in parallel? Should component access be +thread-safe? + +**Decision:** Single-threaded execution. No locks, no `ConcurrentDictionary`, +no parallel scheduling. + +**Rationale:** + +- The target use case (observable ECS for UI) is inherently single-threaded. + UI frameworks require main-thread access. +- Parallel system execution adds significant complexity: dependency analysis, + component access arbitration, synchronization. +- If needed later, systems can declare read/write access sets for automatic + parallel scheduling. This is an additive change. + +**Consequences:** + +- All systems run sequentially on the calling thread. +- No thread-safety guarantees. Calling `World` methods from multiple threads is + undefined behavior. +- Simpler implementation, easier debugging. + +--- + +## ADR-011: .NET 8 Target + +**Status:** Accepted + +**Context:** The library needs a target framework. + +**Decision:** Target `net8.0` (LTS). + +**Rationale:** + +- .NET 8 is the current LTS release with support through November 2026. +- `ref struct` improvements, generic math, and performance enhancements over + .NET 6/7. +- R3 and MessagePack both support .NET 8. +- No need for .NET 9 preview features. + +**Consequences:** + +- Consumers must be on .NET 8 or later. +- Can use `ref` returns, `readonly struct`, and other modern C# features. diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md new file mode 100644 index 0000000..9487ddb --- /dev/null +++ b/docs/implementation-plan.md @@ -0,0 +1,527 @@ +# Implementation Plan: OECS (Observable ECS) + +## Overview + +This document lays out the phased implementation of OECS as a .NET class library +(DLL). Each phase produces a shippable increment; later phases build on earlier +ones. Architecture decisions are captured in `docs/architecture.md`. + +## Target + +- .NET 8 (LTS), `net8.0` +- Dependencies: `MessagePack` (serialization), `R3` (reactivity) +- Output: `OECS.dll` + +--- + +## Phase 1 — Core Foundation (Week 1) + +**Goal:** Create/destroy entities, add/remove components, iterate sparse sets. + +### 1.1 Project Scaffold + +``` +OECS.sln +├── src/OECS/OECS.csproj +└── tests/OECS.Tests/OECS.Tests.csproj +``` + +- `OECS.csproj` targets `net8.0`, references `MessagePack` and `R3`. +- `OECS.Tests.csproj` references `OECS` + `xunit` + `FluentAssertions`. + +### 1.2 Entity + +`readonly struct Entity : IEquatable` + +| Decision | Rationale | +|---|---| +| 32-bit `uint` backing | Keeps size small; 24-bit ID (16.7M) + 8-bit version (256 gens) is ample for observable-first use cases. | +| `Entity.Null` sentinel (value `0`) | Entity ID 0 is reserved; version 0 means "never alive." | +| `Id` (24-bit) and `Version` (8-bit) properties | Expose for debugging; opaque otherwise. | +| `ToString()` → `"Entity(42:v3)"` | Debuggability. | + +Internal constructor; `World` is the only factory. + +### 1.3 SparseSet\ + +``` +class SparseSet where T : struct +``` + +| Field | Purpose | +|---|---| +| `T[] dense` | Packed component values (no holes). | +| `Entity[] denseEntities` | Parallel array: which entity owns each dense slot. | +| `int[] sparse` | Maps entity ID → dense index; `-1` = absent. | + +Operations: `Add(Entity, T)`, `Remove(Entity)`, `ref T Get(Entity)`, +`bool Contains(Entity)`, `int Count`, `void Clear()`. + +`dense` and `sparse` arrays grow geometrically (×2) on overflow. + +**Why sparse sets over archetypes?** The design prioritizes cheap add/remove +(for reactivity) over raw iteration throughput. Archetypes would require moving +entities between archetypes on component change, which complicates change +tracking. + +### 1.4 ComponentStore + +``` +class ComponentStore +``` + +Holds a `Dictionary` mapping component types to their +`SparseSet`. Provides typed generic methods: + +- `void Add(Entity, T)` +- `void Remove(Entity)` +- `ref T Get(Entity)` +- `bool Has(Entity)` +- `void RemoveAll(Entity)` — called on entity destruction + +### 1.5 World + +``` +class World +``` + +| Responsibility | Detail | +|---|---| +| Entity allocation | Free-list of recycled IDs; bump allocator for new IDs. | +| Component access | Delegates to `ComponentStore`. | +| Entity destruction | Returns ID to free list, increments version, removes all components. | + +API surface: + +```csharp +Entity CreateEntity(); +void DestroyEntity(Entity entity); +void AddComponent(Entity entity, T component) where T : struct; +void RemoveComponent(Entity entity) where T : struct; +ref T GetComponent(Entity entity) where T : struct; +bool HasComponent(Entity entity) where T : struct; +bool IsAlive(Entity entity); +``` + +### 1.6 Tests + +- Entity creation returns unique IDs. +- Entity destruction recycles IDs with incremented version. +- `IsAlive` returns false for destroyed entities. +- Add/remove/has component round-trips correctly. +- Sparse set iteration visits all added components. +- Removing a component mid-iteration is safe (deferred or swap-remove). + +--- + +## Phase 2 — Queries & Systems (Week 2) + +**Goal:** Define queries, register systems, run ticks. + +### 2.1 Query Description + +A query is defined by: + +- A set of "with" component types. +- A set of "without" component types. + +``` +class QueryDescriptor +{ + HashSet With { get; } + HashSet Without { get; } +} +``` + +### 2.2 QueryBuilder + +Fluent API returned by `World.Query()`: + +```csharp +world.Query() + .With() + .With() + .Without() + .Build() // → QueryDescriptor +``` + +### 2.3 Query Execution + +`World` provides iteration over matching entities. The smallest "with" sparse +set is used as the driver; other sets are probed for membership. + +```csharp +void ForEach(QueryDescriptor query, Action action); +``` + +Overloads for 1–6 component types. The `Without` filter is checked by probing +the corresponding sparse sets. + +### 2.4 ISystem + +``` +interface ISystem +{ + QueryDescriptor Query { get; } + void Run(World world); +} +``` + +Systems declare their query and receive the world in `Run`. They call +`world.ForEach(query, ...)` to iterate. + +Alternative considered: auto-injection of component refs. Rejected because it +hides the iteration cost and makes the API less explicit. The explicit +`ForEach` call keeps the system author aware of what they're iterating. + +### 2.5 System Registration & Ordering + +``` +class SystemGroup +{ + void Add(ISystem system); // registration order = execution order + void RunTimed(float deltaTime); // calls each system's Run + void RunLogical(); // calls each system's Run +} +``` + +Systems run in registration order. For now, no explicit dependency graph — +this is the simplest model that works. If needed later, `Before()`/`After()` +constraints can be added without breaking the API. + +### 2.6 Tick + +``` +readonly struct Tick +{ + TickType Type { get; } // Timed or Logical + float DeltaTime { get; } // 0 for logical ticks +} +``` + +Passed to systems that opt into it via a separate interface: + +``` +interface ITickedSystem : ISystem +{ + void Run(World world, Tick tick); +} +``` + +### 2.7 Tests + +- Query with single component returns matching entities. +- Query with multiple components returns intersection. +- `Without` excludes entities with T. +- Adding/removing components updates query results. +- Systems run in registration order. +- Destroyed entities don't appear in queries. + +--- + +## Phase 3 — Commands (Week 3) + +**Goal:** Serializable command queue with deferred execution. + +### 3.1 ICommand + +``` +[MessagePackObject] +interface ICommand +{ + void Execute(World world); +} +``` + +Commands are `[MessagePackObject]` structs implementing `ICommand`. They are +not stored in ECS sparse sets — they live in a queue. + +### 3.2 CommandQueue + +``` +class CommandQueue +{ + void Enqueue(ICommand command); + void ExecuteAll(World world); // FIFO, clears queue + int Count { get; } +} +``` + +### 3.3 Integration with World + +`World` owns a `CommandQueue`. After each system runs (or after the full tick), +the queue is drained. This is configurable: + +```csharp +world.ExecuteCommands(); // manual drain +``` + +Systems enqueue commands via `world.Commands.Enqueue(...)`. + +### 3.4 Error Handling + +If a command's `Execute` throws, the exception is caught and stored. The queue +continues processing remaining commands. After `ExecuteAll`, any errors are +available via `CommandQueue.Errors`. + +### 3.5 Tests + +- Commands execute in FIFO order. +- A command can read/write ECS state. +- A command enqueued during `ExecuteAll` runs in the same drain cycle. +- Exceptions are collected, not lost. +- Serialization round-trip preserves command data. + +--- + +## Phase 4 — Relationships (Week 4) + +**Goal:** Relationship components with auto-managed source/target and reverse +lookup. + +### 4.1 Relationship\ + +``` +[MessagePackObject] +struct Relationship : IRelationship +{ + [Key(0)] Entity Source { get; set; } + [Key(1)] Entity Target { get; set; } + // ... payload fields +} +``` + +The `IRelationship` marker interface lets `ComponentStore` detect relationships +and maintain the reverse index. + +### 4.2 Reverse Index + +`World` maintains a `Dictionary>` per relationship +type, mapping target → set of source entities. + +When a relationship component is added/removed, the index is updated +automatically. + +### 4.3 Reverse Lookup API + +```csharp +IReadOnlyCollection GetSources(Entity target) where T : struct, IRelationship; +``` + +### 4.4 Cascading Behavior + +When an entity is destroyed: +- All relationships where it is the **source** are removed (components dropped). +- All relationships where it is the **target** are removed (components dropped + from source entities). +- The reverse index is cleaned up. + +### 4.5 Tests + +- Adding a relationship updates the reverse index. +- Removing a relationship updates the reverse index. +- Destroying a source entity cleans up its relationships. +- Destroying a target entity cleans up incoming relationships. +- Reverse lookup returns correct sources. + +--- + +## Phase 5 — Reactivity (Week 5–6) + +**Goal:** Change tracking, marking, posting, and R3 observable queries. + +### 5.1 Change Kinds + +``` +enum ChangeKind +{ + EntityAdded, + EntityRemoved, + ComponentAdded, + ComponentRemoved, + ComponentModified +} + +struct EntityChange +{ + Entity Entity { get; } + ChangeKind Kind { get; } + Type ComponentType { get; } // null for entity-level changes +} +``` + +### 5.2 ChangeSet + +``` +class ChangeSet +{ + void MarkEntityAdded(Entity entity); + void MarkEntityRemoved(Entity entity); + void MarkComponentAdded(Entity entity, Type componentType); + void MarkComponentRemoved(Entity entity, Type componentType); + void MarkComponentModified(Entity entity, Type componentType); + + IReadOnlyList Changes { get; } + void Clear(); +} +``` + +### 5.3 Automatic vs. Manual Marking + +| Change Type | Marking | +|---|---| +| Entity created | Auto | +| Entity destroyed | Auto | +| Component added | Auto | +| Component removed | Auto | +| Component **modified** | **Manual** via `world.MarkModified(entity)` | + +Rationale: structural changes are always detectable. Value mutations inside a +`ref T` are not — the sparse set has no way to know the caller changed the +value. Requiring an explicit `MarkModified` call is the simplest correct +approach. + +**Debug aid:** In `DEBUG` builds, `ref T Get(Entity)` returns a wrapper that +tracks whether the value was written. If a system iterates `ref T` and never +calls `MarkModified`, a warning is logged. This catches the most common +mistake. + +### 5.4 Posting Model + +``` +class ChangeBuffer +{ + 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 + caller must call `world.PostChanges()`. + +### 5.5 R3 Integration + +`World` exposes observables: + +```csharp +IObservable ObserveEntityChanges(); +IObservable ObserveComponentChanges() where T : struct; +IObservable ObserveQuery(QueryDescriptor query); +``` + +These are backed by `Subject` instances. Subscribers receive +batched changes after each `Post()`. + +### 5.6 Subscription Lifecycle + +Subscriptions return `IDisposable`. UI code ties this to component lifecycle: + +```csharp +world.ObserveComponentChanges() + .Subscribe(change => UpdateHealthBar(change)) + .AddTo(componentDisposables); // R3's AddTo +``` + +### 5.7 Tests + +- Entity creation posts `EntityAdded`. +- Entity destruction posts `EntityRemoved`. +- Component add/remove posts corresponding changes. +- `MarkModified` posts `ComponentModified`. +- Changes are batched per `Post()` call. +- Subscribers receive changes in order. +- Disposing a subscription stops notifications. + +--- + +## Phase 6 — Singletons (Week 6) + +**Goal:** Singleton entity and ergonomic accessors. + +### 6.1 Singleton Entity + +`World` reserves entity ID `1` as the singleton entity. It is never destroyed +and is excluded from normal queries by default. + +### 6.2 Singleton Accessors + +```csharp +void SetSingleton(T component) where T : struct; +ref T GetSingleton() where T : struct; +bool HasSingleton() where T : struct; +void RemoveSingleton() where T : struct; +``` + +These are convenience wrappers around `AddComponent`/`GetComponent` on the +singleton entity. + +### 6.3 Query Exclusion + +Queries automatically exclude the singleton entity. If a user genuinely wants +to include it, they can query it by its entity ID directly. + +### 6.4 Tests + +- `SetSingleton`/`GetSingleton` round-trips. +- Singleton entity does not appear in normal queries. +- Removing a singleton works. +- Singleton survives tick execution. + +--- + +## Phase 7 — Polish & Documentation (Week 7) + +### 7.1 XML Docs + +All public API surface gets `` XML documentation comments. + +### 7.2 README + +Quick-start guide with a minimal example: create world, register system, run +tick, observe changes. + +### 7.3 NuGet Packaging + +`OECS.csproj` includes package metadata: +- `PackageId`: `OECS` +- `Description`: "Observable ECS for C# — an entity component system focused on + a clean reactive API surface." +- `PackageTags`: `ecs;reactive;observable;gamedev` + +### 7.4 CI (optional) + +GitHub Actions workflow: build, test, pack. + +--- + +## Dependency Graph + +``` +Phase 1 (Core) + └─→ Phase 2 (Queries & Systems) + └─→ Phase 3 (Commands) + └─→ Phase 4 (Relationships) + └─→ Phase 5 (Reactivity) + └─→ Phase 6 (Singletons) + └─→ Phase 7 (Polish) +``` + +Phases 3 and 4 can be done in parallel; Phase 5 depends on both. + +--- + +## Open Questions + +1. **Parallel system execution?** Deferred. The design is single-threaded by + default. If needed, systems could declare read/write component access for + automatic parallel scheduling — but this adds significant complexity. + +2. **World serialization?** Since components are MessagePack-serializable, + 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. diff --git a/src/OECS/ComponentStore.cs b/src/OECS/ComponentStore.cs new file mode 100644 index 0000000..edb4a4a --- /dev/null +++ b/src/OECS/ComponentStore.cs @@ -0,0 +1,103 @@ +using System.Runtime.CompilerServices; + +namespace OECS; + +/// +/// Registry of all component sparse sets, keyed by component type. +/// Provides typed generic accessors that delegate to the underlying +/// instances. +/// +internal class ComponentStore +{ + private readonly Dictionary _sets = new(); + + /// + /// Gets or creates the sparse set for component type . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private SparseSet GetSet() where T : struct + { + var type = typeof(T); + if (!_sets.TryGetValue(type, out var set)) + { + var newSet = new SparseSet(); + _sets[type] = newSet; + return newSet; + } + return (SparseSet)set; + } + + /// + /// Gets the sparse set for component type if it exists. + /// + private SparseSet? GetSetIfExists() where T : struct + { + if (_sets.TryGetValue(typeof(T), out var set)) + return (SparseSet)set; + return null; + } + + /// + /// Returns all registered component types. + /// + public IEnumerable ComponentTypes => _sets.Keys; + + public void Add(Entity entity, T component) where T : struct + { + GetSet().Add(entity, component); + } + + public void Remove(Entity entity) where T : struct + { + GetSetIfExists()?.Remove(entity); + } + + public ref T Get(Entity entity) where T : struct + { + return ref GetSet().Get(entity); + } + + public bool Has(Entity entity) where T : struct + { + return GetSetIfExists()?.Contains(entity) ?? false; + } + + /// + /// Removes all components for the given entity across all component types. + /// + public void RemoveAll(Entity entity) + { + foreach (var set in _sets.Values) + { + set.Remove(entity); + } + } + + /// + /// Returns the sparse set for a given type, or null if not registered. + /// Used by query execution to probe sets by Type. + /// + public ISparseSet? GetSet(Type componentType) + { + _sets.TryGetValue(componentType, out var set); + return set; + } + + /// + /// Returns the count of entities in the sparse set for type . + /// Returns 0 if the set does not exist. + /// + public int Count() where T : struct + { + return GetSetIfExists()?.Count ?? 0; + } + + /// + /// Returns the count of entities in the sparse set for the given type. + /// Returns 0 if the set does not exist. + /// + public int Count(Type componentType) + { + return GetSet(componentType)?.Count ?? 0; + } +} diff --git a/src/OECS/Entity.cs b/src/OECS/Entity.cs new file mode 100644 index 0000000..ab5ffa5 --- /dev/null +++ b/src/OECS/Entity.cs @@ -0,0 +1,64 @@ +namespace OECS; + +/// +/// An opaque handle to an entity in the ECS world. +/// Combines a 24-bit identifier with an 8-bit version to prevent +/// use-after-free bugs when entity IDs are recycled. +/// +public readonly struct Entity : IEquatable +{ + private const uint IdMask = 0x00FF_FFFF; // lower 24 bits + private const uint VersionMask = 0xFF00_0000; // upper 8 bits + private const int VersionShift = 24; + private const uint MaxId = IdMask; + private const uint MaxVersion = 0xFF; + + /// + /// A sentinel value representing a null or invalid entity. + /// + public static readonly Entity Null = new(0); + + private readonly uint _value; + + internal Entity(uint id, uint version) + { + _value = (id & IdMask) | ((version & MaxVersion) << VersionShift); + } + + private Entity(uint value) + { + _value = value; + } + + /// + /// The 24-bit entity identifier. + /// + public uint Id => _value & IdMask; + + /// + /// The 8-bit generation version. Incremented each time the ID is recycled. + /// + public uint Version => (_value & VersionMask) >> VersionShift; + + /// + /// Returns true if this entity is the null sentinel. + /// + public bool IsNull => _value == 0; + + /// + /// Creates a new entity with the given version, keeping the same ID. + /// + internal Entity WithVersion(uint version) => new(Id, version); + + public bool Equals(Entity other) => _value == other._value; + + public override bool Equals(object? obj) => obj is Entity other && Equals(other); + + public override int GetHashCode() => _value.GetHashCode(); + + public override string ToString() => $"Entity({Id}:v{Version})"; + + public static bool operator ==(Entity left, Entity right) => left.Equals(right); + + public static bool operator !=(Entity left, Entity right) => !left.Equals(right); +} diff --git a/src/OECS/EntityAllocator.cs b/src/OECS/EntityAllocator.cs new file mode 100644 index 0000000..df4cb0d --- /dev/null +++ b/src/OECS/EntityAllocator.cs @@ -0,0 +1,86 @@ +namespace OECS; + +/// +/// Manages entity ID allocation and recycling. +/// +/// Uses a free-list for recycled IDs and a bump allocator for new IDs. +/// Each ID slot tracks a version byte that increments on recycle to +/// prevent use-after-free bugs. +/// +internal class EntityAllocator +{ + private const int DefaultCapacity = 1024; + private const uint FirstValidId = 2; // 0 = Null, 1 = Singleton + + private byte[] _versions; + private readonly Stack _freeIds; + private uint _nextId; + + public EntityAllocator(int initialCapacity = DefaultCapacity) + { + _versions = new byte[initialCapacity]; + _freeIds = new Stack(); + _nextId = FirstValidId; + } + + /// + /// Allocates a new entity. Reuses a free ID if available, otherwise + /// bumps the allocator. + /// + public Entity Allocate() + { + uint id; + if (_freeIds.Count > 0) + { + id = _freeIds.Pop(); + } + else + { + id = _nextId++; + EnsureCapacity(id); + // First time this ID is used — start at version 1. + // 0 means "never alive" and is used by the sentinel. + _versions[id] = 1; + } + + return new Entity(id, _versions[id]); + } + + /// + /// Frees an entity ID, making it available for reuse. + /// Increments the version to invalidate any outstanding references. + /// + public void Free(Entity entity) + { + uint id = entity.Id; + EnsureCapacity(id); + + // Increment version (wrap at 255 back to 1; 0 means "never alive"). + _versions[id]++; + if (_versions[id] == 0) + _versions[id] = 1; + + _freeIds.Push(id); + } + + /// + /// Returns true if the entity's version matches the current version for its ID. + /// + public bool IsAlive(Entity entity) + { + uint id = entity.Id; + if (entity.IsNull) return false; + if (id == 1) return true; // Singleton is always alive. + if (id >= _versions.Length) return false; + return _versions[id] == entity.Version && _versions[id] != 0; + } + + private void EnsureCapacity(uint id) + { + if (id >= _versions.Length) + { + int newSize = Math.Max((int)id + 1, _versions.Length * 2); + Array.Resize(ref _versions, newSize); + } + } +} diff --git a/src/OECS/OECS.csproj b/src/OECS/OECS.csproj new file mode 100644 index 0000000..d1a65b4 --- /dev/null +++ b/src/OECS/OECS.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + OECS + OECS + + + OECS + 0.1.0 + Observable ECS for C# — an entity component system focused on a clean reactive API surface. + ecs;reactive;observable;gamedev + + + + + + + + diff --git a/src/OECS/SparseSet.cs b/src/OECS/SparseSet.cs new file mode 100644 index 0000000..70b429f --- /dev/null +++ b/src/OECS/SparseSet.cs @@ -0,0 +1,157 @@ +using System.Runtime.CompilerServices; + +namespace OECS; + +/// +/// Non-generic interface for sparse set operations that don't require +/// knowing the component type at compile time (e.g., entity destruction). +/// +internal interface ISparseSet +{ + void Remove(Entity entity); + int Count { get; } +} + +/// +/// A sparse set storing components of type . +/// +/// Uses a dense array (packed, no holes) and a sparse array (maps entity ID +/// to dense index) for O(1) add, remove, and lookup. Swap-remove keeps +/// removals cheap at the cost of iteration order instability. +/// +internal class SparseSet : ISparseSet where T : struct +{ + private const int DefaultCapacity = 64; + + private T[] _dense; + private Entity[] _denseEntities; + private int[] _sparse; + private int _count; + + public SparseSet(int initialCapacity = DefaultCapacity) + { + _dense = new T[initialCapacity]; + _denseEntities = new Entity[initialCapacity]; + _sparse = new int[initialCapacity]; + Array.Fill(_sparse, -1); + } + + /// + /// Number of components currently stored. + /// + public int Count => _count; + + /// + /// The dense array of component values (first elements are valid). + /// + public T[] Dense => _dense; + + /// + /// The dense array of entity IDs, parallel to . + /// + public Entity[] DenseEntities => _denseEntities; + + /// + /// Adds a component for the given entity. Replaces if already present. + /// + public void Add(Entity entity, T component) + { + EnsureSparseCapacity(entity.Id); + + int index = _sparse[entity.Id]; + if (index != -1) + { + // Already exists — replace in place. + _dense[index] = component; + return; + } + + EnsureDenseCapacity(_count + 1); + + index = _count; + _dense[index] = component; + _denseEntities[index] = entity; + _sparse[entity.Id] = index; + _count++; + } + + /// + /// Removes the component for the given entity. No-op if not present. + /// + public void Remove(Entity entity) + { + if (entity.Id >= (uint)_sparse.Length) + return; + + int index = _sparse[entity.Id]; + if (index == -1) + return; + + // Swap-remove: move the last element into the removed slot. + int lastIndex = _count - 1; + if (index != lastIndex) + { + _dense[index] = _dense[lastIndex]; + _denseEntities[index] = _denseEntities[lastIndex]; + _sparse[_denseEntities[index].Id] = index; + } + + _dense[lastIndex] = default!; + _denseEntities[lastIndex] = default; + _sparse[entity.Id] = -1; + _count--; + } + + /// + /// Returns a reference to the component for the given entity. + /// Throws if the entity does not have this component. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public ref T Get(Entity entity) + { + int index = _sparse[entity.Id]; + return ref _dense[index]; + } + + /// + /// Returns true if the entity has this component. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool Contains(Entity entity) + { + return entity.Id < (uint)_sparse.Length && _sparse[entity.Id] != -1; + } + + /// + /// Removes all components. + /// + public void Clear() + { + Array.Clear(_dense, 0, _count); + for (int i = 0; i < _count; i++) + { + _sparse[_denseEntities[i].Id] = -1; + } + _count = 0; + } + + private void EnsureSparseCapacity(uint entityId) + { + if (entityId >= (uint)_sparse.Length) + { + int newSize = Math.Max((int)entityId + 1, _sparse.Length * 2); + Array.Resize(ref _sparse, newSize); + Array.Fill(_sparse, -1, _sparse.Length - (newSize - _sparse.Length), newSize - _sparse.Length); + } + } + + private void EnsureDenseCapacity(int required) + { + if (required > _dense.Length) + { + int newSize = Math.Max(required, _dense.Length * 2); + Array.Resize(ref _dense, newSize); + Array.Resize(ref _denseEntities, newSize); + } + } +} diff --git a/src/OECS/World.cs b/src/OECS/World.cs new file mode 100644 index 0000000..c76af7d --- /dev/null +++ b/src/OECS/World.cs @@ -0,0 +1,114 @@ +namespace OECS; + +/// +/// The central container for all ECS state. +/// +/// Manages entity lifecycle, component storage, and provides the primary +/// API for adding, removing, and querying components. +/// +public class World : IDisposable +{ + private readonly EntityAllocator _allocator; + private readonly ComponentStore _components; + + public World() + { + _allocator = new EntityAllocator(); + _components = new ComponentStore(); + } + + // ── Entity Management ──────────────────────────────────────────── + + /// + /// Creates a new entity and returns its handle. + /// + public Entity CreateEntity() + { + return _allocator.Allocate(); + } + + /// + /// Destroys an entity, removing all its components and recycling its ID. + /// + public void DestroyEntity(Entity entity) + { + if (!_allocator.IsAlive(entity)) + return; + + // Singleton entity is never destroyed. + if (entity.Id == 1) + return; + + _components.RemoveAll(entity); + _allocator.Free(entity); + } + + /// + /// Returns true if the entity is currently alive (not destroyed). + /// + public bool IsAlive(Entity entity) + { + return _allocator.IsAlive(entity); + } + + // ── Component Management ───────────────────────────────────────── + + /// + /// Adds a component to the entity. Replaces the existing component if + /// the entity already has one of type . + /// + public void AddComponent(Entity entity, T component) where T : struct + { + ThrowIfNotAlive(entity); + _components.Add(entity, component); + } + + /// + /// Removes the component of type from the entity. + /// No-op if the entity does not have the component. + /// + public void RemoveComponent(Entity entity) where T : struct + { + _components.Remove(entity); + } + + /// + /// Returns a reference to the component of type + /// for the given entity. Throws if the entity does not have the component. + /// + public ref T GetComponent(Entity entity) where T : struct + { + return ref _components.Get(entity); + } + + /// + /// Returns true if the entity has a component of type . + /// + public bool HasComponent(Entity entity) where T : struct + { + return _components.Has(entity); + } + + // ── Internal Access ─────────────────────────────────────────────── + + /// + /// The component store. Exposed internally for query execution and + /// system infrastructure. + /// + internal ComponentStore Components => _components; + + // ── Helpers ─────────────────────────────────────────────────────── + + private void ThrowIfNotAlive(Entity entity) + { + if (!_allocator.IsAlive(entity)) + throw new InvalidOperationException($"Entity {entity} is not alive."); + } + + // ── Cleanup ─────────────────────────────────────────────────────── + + public void Dispose() + { + // Future: dispose R3 subscriptions, etc. + } +} diff --git a/tests/OECS.Tests/CoreTests.cs b/tests/OECS.Tests/CoreTests.cs new file mode 100644 index 0000000..3292c64 --- /dev/null +++ b/tests/OECS.Tests/CoreTests.cs @@ -0,0 +1,226 @@ +using FluentAssertions; +using Xunit; + +namespace OECS.Tests; + +public class EntityTests +{ + [Fact] + public void CreateEntity_ReturnsUniqueIds() + { + var world = new World(); + var a = world.CreateEntity(); + var b = world.CreateEntity(); + + a.Should().NotBe(b); + a.Id.Should().NotBe(b.Id); + } + + [Fact] + public void Null_IsSentinel() + { + Entity.Null.IsNull.Should().BeTrue(); + Entity.Null.Id.Should().Be(0); + Entity.Null.Version.Should().Be(0); + } + + [Fact] + public void DestroyEntity_RecyclesId_WithIncrementedVersion() + { + var world = new World(); + var original = world.CreateEntity(); + uint originalId = original.Id; + uint originalVersion = original.Version; + + world.DestroyEntity(original); + world.IsAlive(original).Should().BeFalse(); + + var recycled = world.CreateEntity(); + recycled.Id.Should().Be(originalId); + recycled.Version.Should().BeGreaterThan(originalVersion); + } + + [Fact] + public void IsAlive_ReturnsFalse_ForDestroyedEntity() + { + var world = new World(); + var entity = world.CreateEntity(); + + world.IsAlive(entity).Should().BeTrue(); + world.DestroyEntity(entity); + world.IsAlive(entity).Should().BeFalse(); + } + + [Fact] + public void IsAlive_ReturnsFalse_ForNull() + { + var world = new World(); + world.IsAlive(Entity.Null).Should().BeFalse(); + } + + [Fact] + public void ToString_IncludesIdAndVersion() + { + var world = new World(); + var entity = world.CreateEntity(); + + var str = entity.ToString(); + str.Should().Contain(entity.Id.ToString()); + str.Should().Contain(entity.Version.ToString()); + } + + [Fact] + public void Equality_ChecksBothIdAndVersion() + { + var world = new World(); + var a = world.CreateEntity(); + world.DestroyEntity(a); + var b = world.CreateEntity(); // Same ID, different version + + a.Should().NotBe(b); + (a == b).Should().BeFalse(); + } +} + +public class ComponentTests +{ + private struct Position { public float X; public float Y; } + private struct Velocity { public float X; public float Y; } + private struct Health { public int Value; } + + [Fact] + public void AddAndGetComponent_RoundTrips() + { + var world = new World(); + var entity = world.CreateEntity(); + + world.AddComponent(entity, new Position { X = 1, Y = 2 }); + ref var pos = ref world.GetComponent(entity); + + pos.X.Should().Be(1); + pos.Y.Should().Be(2); + } + + [Fact] + public void AddComponent_ReplacesExisting() + { + var world = new World(); + var entity = world.CreateEntity(); + + world.AddComponent(entity, new Position { X = 1, Y = 2 }); + world.AddComponent(entity, new Position { X = 3, Y = 4 }); + + ref var pos = ref world.GetComponent(entity); + pos.X.Should().Be(3); + pos.Y.Should().Be(4); + } + + [Fact] + public void HasComponent_ReturnsCorrectly() + { + var world = new World(); + var entity = world.CreateEntity(); + + world.HasComponent(entity).Should().BeFalse(); + world.AddComponent(entity, new Position()); + world.HasComponent(entity).Should().BeTrue(); + } + + [Fact] + public void RemoveComponent_RemovesIt() + { + var world = new World(); + var entity = world.CreateEntity(); + + world.AddComponent(entity, new Position { X = 1, Y = 2 }); + world.RemoveComponent(entity); + world.HasComponent(entity).Should().BeFalse(); + } + + [Fact] + public void RemoveComponent_NoOp_WhenNotPresent() + { + var world = new World(); + var entity = world.CreateEntity(); + + // Should not throw. + world.RemoveComponent(entity); + } + + [Fact] + public void MultipleComponents_OnSameEntity() + { + var world = new World(); + var entity = world.CreateEntity(); + + world.AddComponent(entity, new Position { X = 1, Y = 2 }); + world.AddComponent(entity, new Velocity { X = 3, Y = 4 }); + world.AddComponent(entity, new Health { Value = 100 }); + + world.HasComponent(entity).Should().BeTrue(); + world.HasComponent(entity).Should().BeTrue(); + world.HasComponent(entity).Should().BeTrue(); + + ref var pos = ref world.GetComponent(entity); + ref var vel = ref world.GetComponent(entity); + ref var hp = ref world.GetComponent(entity); + + pos.X.Should().Be(1); + vel.Y.Should().Be(4); + hp.Value.Should().Be(100); + } + + [Fact] + public void DestroyEntity_RemovesAllComponents() + { + var world = new World(); + var entity = world.CreateEntity(); + + world.AddComponent(entity, new Position()); + world.AddComponent(entity, new Velocity()); + + world.DestroyEntity(entity); + + // After destroy, the entity is not alive, so component queries + // on the stale handle are irrelevant. But a new entity with the + // same ID should not have the old components. + var newEntity = world.CreateEntity(); + world.HasComponent(newEntity).Should().BeFalse(); + world.HasComponent(newEntity).Should().BeFalse(); + } + + [Fact] + public void GetComponent_Throws_WhenNotPresent() + { + var world = new World(); + var entity = world.CreateEntity(); + + var act = () => world.GetComponent(entity); + act.Should().Throw(); + } + + [Fact] + public void AddComponent_Throws_WhenEntityNotAlive() + { + var world = new World(); + var entity = world.CreateEntity(); + world.DestroyEntity(entity); + + var act = () => world.AddComponent(entity, new Position()); + act.Should().Throw(); + } + + [Fact] + public void Mutation_ViaRef_IsVisible() + { + var world = new World(); + var entity = world.CreateEntity(); + + world.AddComponent(entity, new Position { X = 0, Y = 0 }); + ref var pos = ref world.GetComponent(entity); + pos.X = 42; + + ref var pos2 = ref world.GetComponent(entity); + pos2.X.Should().Be(42); + } +} diff --git a/tests/OECS.Tests/OECS.Tests.csproj b/tests/OECS.Tests/OECS.Tests.csproj new file mode 100644 index 0000000..1c8ca26 --- /dev/null +++ b/tests/OECS.Tests/OECS.Tests.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + enable + enable + OECS.Tests + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + +