Compare commits

...

10 Commits

Author SHA1 Message Date
hypercross 5594515a53 feat: add local NuGet development workflow
Add a local NuGet feed configuration, a Directory.Build.props
template for Godot users, and automate source generator bundling
into the NuGet package.
2026-07-19 00:30:36 +08:00
hypercross 1da0ca30d3 refactor: replace boxed component addition with typed delegate 2026-07-18 23:44:10 +08:00
hypercross 96ba037432 feat: add component discovery source generator
Introduce an incremental source generator that automatically discovers
component types used with the World API. This allows the
WorldSerializer to perform serialization and deserialization without
relying on reflection, improving performance and stability.
2026-07-18 23:41:28 +08:00
hypercross 713d578179 feat: add Blackjack example 2026-07-18 23:26:56 +08:00
hypercross b551a67915 feat: add simplified Select extension methods to EntityIterator
Add overloads for Select that allow iterating over components without
manually building a QueryDescriptor, simplifying usage in systems.
2026-07-18 22:27:14 +08:00
hypercross df388ee9b3 refactor: remove QueryDescriptor from ISystem interface
Remove the mandatory Query property from the ISystem interface,
allowing systems to manage their own queries locally or via other
means. Update all implementing systems and tests to use local
query descriptors instead.
2026-07-18 22:22:52 +08:00
hypercross ac7cd80a6f fix(oecs): clear dense entities array on reset 2026-07-18 22:06:33 +08:00
hypercross b6e64d2871 refactor: add struct constraint to Relationship
Add `where TSelf : struct where TTarget : struct` constraints to
the `Relationship` struct to ensure type safety and consistency with
the ECS architecture. Also remove unused `IterateThree` method in
`QueryExecutor`.
2026-07-18 22:05:29 +08:00
hypercross 58477aec50 docs: update API surface documentation 2026-07-18 21:59:09 +08:00
hypercross 32f4aa3e35 refactor: optimize query execution and improve change tracking
- Optimize `QueryExecutor` to use the smallest sparse set as a driver
  for multi-component queries, reducing iteration overhead.
- Implement automatic cleanup of unused `Subject` instances in
  `ChangeBuffer` using a subscriber count.
- Improve `ChangeSet` deduplication to correctly handle rapid
  add/remove transitions for the same component.
- Add validation to ensure `IRelationship` components are added to
  their declared `Source` entity.
- Update documentation to reflect correct `Entity` bit layout.
2026-07-18 21:42:08 +08:00
54 changed files with 2004 additions and 161 deletions

1
.gitignore vendored
View File

@ -15,6 +15,7 @@ x86/
!**/[Pp]ackages/build/
*.nuget.props
*.nuget.targets
nupkgs/
# IDE
.vs/

View File

@ -0,0 +1,21 @@
<!--
Directory.Build.props for Godot C# projects consuming OECS.
Godot regenerates .csproj files on certain editor actions, which can
wipe custom <ItemGroup> entries. This file is never touched by Godot.
Placement: put this file in the root of your Godot project (next to
the .sln file), or in any parent directory.
You also need a nuget.config next to your .sln with the local feed:
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="local-oecs" value="path/to/oecs-sharp/nupkgs" />
</packageSources>
</configuration>
-->
<Project>
<ItemGroup>
<PackageReference Include="OECS" Version="0.1.*" />
</ItemGroup>
</Project>

View File

@ -1,25 +1,84 @@

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.SourceGen", "src\OECS.SourceGen\OECS.SourceGen.csproj", "{C3D4E5F6-A7B8-9012-CDEF-123456789012}"
EndProject
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
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "examples", "examples", "{B36A84DF-456D-A817-6EDD-3EC3E7F6E11F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Blackjack", "examples\Blackjack\Blackjack.csproj", "{85631EDE-F984-4DA6-8EE9-0715AF1204E6}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x64.ActiveCfg = Debug|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x64.Build.0 = Debug|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x86.ActiveCfg = Debug|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x86.Build.0 = Debug|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.Build.0 = Release|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x64.ActiveCfg = Release|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x64.Build.0 = Release|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x86.ActiveCfg = Release|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x86.Build.0 = Release|Any CPU
{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}.Debug|x64.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.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
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.Build.0 = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.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}.Debug|x64.ActiveCfg = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x64.Build.0 = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x86.ActiveCfg = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x86.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
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x64.ActiveCfg = Release|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x64.Build.0 = Release|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x86.ActiveCfg = Release|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x86.Build.0 = Release|Any CPU
{85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Debug|x64.ActiveCfg = Debug|Any CPU
{85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Debug|x64.Build.0 = Debug|Any CPU
{85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Debug|x86.ActiveCfg = Debug|Any CPU
{85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Debug|x86.Build.0 = Debug|Any CPU
{85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Release|Any CPU.Build.0 = Release|Any CPU
{85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Release|x64.ActiveCfg = Release|Any CPU
{85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Release|x64.Build.0 = Release|Any CPU
{85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Release|x86.ActiveCfg = Release|Any CPU
{85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{C3D4E5F6-A7B8-9012-CDEF-123456789012} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{85631EDE-F984-4DA6-8EE9-0715AF1204E6} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F}
EndGlobalSection
EndGlobal

View File

@ -15,8 +15,8 @@ namespace OECS;
public readonly struct Entity : IEquatable<Entity>
{
public static Entity Null { get; } // ID=0, Version=0
public uint Id { get; } // 24-bit identifier
public uint Version { get; } // 8-bit generation
public uint Id { get; } // lower 24-bit identifier
public uint Version { get; } // upper 8-bit generation
public bool IsNull { get; } // true for Entity.Null
public bool Equals(Entity other);
@ -37,6 +37,9 @@ namespace OECS;
public class World : IDisposable
{
// --- Singleton entity ---
public static Entity SingletonEntity { get; } // ID=1, Version=1
// --- Lifecycle ---
public World();
@ -49,11 +52,14 @@ public class World : IDisposable
public void AddComponent<T>(Entity entity, T component) where T : struct;
public void RemoveComponent<T>(Entity entity) where T : struct;
public ref T GetComponent<T>(Entity entity) where T : struct;
public T ReadComponent<T>(Entity entity) where T : struct;
public bool TryGetComponent<T>(Entity entity, out T value) where T : struct;
public bool HasComponent<T>(Entity entity) where T : struct;
// --- Singleton ---
public void SetSingleton<T>(T component) where T : struct;
public ref T GetSingleton<T>() where T : struct;
public T ReadSingleton<T>() where T : struct;
public bool HasSingleton<T>() where T : struct;
public void RemoveSingleton<T>() where T : struct;
@ -63,12 +69,19 @@ public class World : IDisposable
// --- Query Execution ---
public void ForEach<T1>(
QueryDescriptor query,
Action<Entity, ref T1> action) where T1 : struct;
ForEachAction<T1> action) where T1 : struct;
// Overloads for 26 component types:
public void ForEach<T1, T2>(QueryDescriptor, Action<Entity, ref T1, ref T2>)
public void ForEach<T1, T2>(QueryDescriptor, ForEachAction<T1, T2>)
where T1 : struct where T2 : struct;
// ... up to T6
public void ForEach<T1, T2, T3>(QueryDescriptor, ForEachAction<T1, T2, T3>)
where T1 : struct where T2 : struct where T3 : struct;
public void ForEach<T1, T2, T3, T4>(QueryDescriptor, ForEachAction<T1, T2, T3, T4>)
where T1 : struct where T2 : struct where T3 : struct where T4 : struct;
public void ForEach<T1, T2, T3, T4, T5>(QueryDescriptor, ForEachAction<T1, T2, T3, T4, T5>)
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct;
public void ForEach<T1, T2, T3, T4, T5, T6>(QueryDescriptor, ForEachAction<T1, T2, T3, T4, T5, T6>)
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct where T6 : struct;
// --- Commands ---
public CommandQueue Commands { get; }
@ -78,10 +91,10 @@ public class World : IDisposable
public void MarkModified<T>(Entity entity) where T : struct;
public void PostChanges();
public IObservable<EntityChange> ObserveEntityChanges();
public IObservable<EntityChange> ObserveComponentChanges<T>()
public Observable<EntityChange> ObserveEntityChanges();
public Observable<EntityChange> ObserveComponentChanges<T>()
where T : struct;
public IObservable<EntityChange> ObserveQuery(QueryDescriptor query);
public Observable<EntityChange> ObserveQuery(QueryDescriptor query);
// --- Relationships ---
public IReadOnlyCollection<Entity> GetSources<T>(Entity target)
@ -94,6 +107,70 @@ public class World : IDisposable
---
## ForEachAction Delegates
Custom delegate types for query iteration with `ref` parameters. The built-in
`Action<...>` delegates do not support `ref` parameters.
```csharp
namespace OECS;
public delegate void ForEachAction<T1>(Entity entity, ref T1 c1) where T1 : struct;
public delegate void ForEachAction<T1, T2>(Entity entity, ref T1 c1, ref T2 c2)
where T1 : struct where T2 : struct;
public delegate void ForEachAction<T1, T2, T3>(Entity entity, ref T1 c1, ref T2 c2, ref T3 c3)
where T1 : struct where T2 : struct where T3 : struct;
public delegate void ForEachAction<T1, T2, T3, T4>(Entity entity, ref T1 c1, ref T2 c2, ref T3 c3, ref T4 c4)
where T1 : struct where T2 : struct where T3 : struct where T4 : struct;
public delegate void ForEachAction<T1, T2, T3, T4, T5>(Entity entity, ref T1 c1, ref T2 c2, ref T3 c3, ref T4 c4, ref T5 c5)
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct;
public delegate void ForEachAction<T1, T2, T3, T4, T5, T6>(Entity entity, ref T1 c1, ref T2 c2, ref T3 c3, ref T4 c4, ref T5 c5, ref T6 c6)
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct where T6 : struct;
```
---
## EntityIterator
Extension methods on `World` for `foreach`-style iteration over queries.
Returns `ref struct` iterators that support zero-allocation iteration with
`ref` access to components. Supports 13 component types.
```csharp
namespace OECS;
public static class EntityIterator
{
public static Select1<T1> Select<T1>(
this World world, QueryDescriptor query) where T1 : struct;
public static Select2<T1, T2> Select<T1, T2>(
this World world, QueryDescriptor query)
where T1 : struct where T2 : struct;
public static Select3<T1, T2, T3> Select<T1, T2, T3>(
this World world, QueryDescriptor query)
where T1 : struct where T2 : struct where T3 : struct;
}
```
Usage:
```csharp
using var iter = world.Select<Position, Velocity>(query);
foreach (ref var item in iter)
{
item.Current1.X += item.Current2.X * dt;
}
```
---
## QueryBuilder
```csharp
@ -180,6 +257,7 @@ namespace OECS;
public class SystemGroup
{
public SystemGroup(World world);
public void Add(ISystem system);
public void Remove(ISystem system);
public void RunTimed(float deltaTime);
@ -212,6 +290,8 @@ public class CommandQueue
{
public void Enqueue<T>(T command) where T : struct, ICommand;
public void ExecuteAll(World world);
public void Clear();
public void ClearErrors();
public int Count { get; }
public IReadOnlyList<Exception> Errors { get; }
}
@ -233,12 +313,31 @@ public interface IRelationship
---
## Relationship\<TSelf, TTarget\>
A convenience base struct for relationship components. The type parameters are
phantom types that differentiate relationship kinds at the type level, enabling
type-safe queries and reverse lookups.
```csharp
namespace OECS;
[MessagePackObject]
public struct Relationship<TSelf, TTarget> : IRelationship
{
[Key(0)] public Entity Source { get; set; }
[Key(1)] public Entity Target { get; set; }
}
```
---
## EntityChange
```csharp
namespace OECS;
public readonly struct EntityChange
public readonly struct EntityChange : IEquatable<EntityChange>
{
public Entity Entity { get; }
public ChangeKind Kind { get; }
@ -257,6 +356,56 @@ public enum ChangeKind
---
## WorldSerializer
Saves and loads a `World` to/from a MessagePack stream. Components are
serialized by their runtime type.
```csharp
namespace OECS;
public static class WorldSerializer
{
public static void Save(World world, Stream stream);
public static void Load(World world, Stream stream);
}
```
---
## WorldSnapshot / EntitySnapshot / ComponentEntry
Serialization types used by `WorldSerializer`. These are public but intended
primarily for serialization infrastructure.
```csharp
namespace OECS;
[MessagePackObject]
public class WorldSnapshot
{
[Key(0)] public EntitySnapshot[] Entities { get; set; }
}
[MessagePackObject]
public class EntitySnapshot
{
[Key(0)] public uint Id { get; set; }
[Key(1)] public uint Version { get; set; }
[Key(2)] public ComponentEntry[] Components { get; set; }
public Entity Entity { get; }
}
[MessagePackObject]
public class ComponentEntry
{
[Key(0)] public string TypeName { get; set; }
[Key(1)] public byte[] Data { get; set; }
}
```
---
## Usage Example
```csharp
@ -269,13 +418,19 @@ public struct Position : IMessagePackSerializationCallbackReceiver
{
[Key(0)] public float X;
[Key(1)] public float Y;
public void OnBeforeSerialize() { }
public void OnAfterDeserialize() { }
}
[MessagePackObject]
public struct Velocity
public struct Velocity : IMessagePackSerializationCallbackReceiver
{
[Key(0)] public float X;
[Key(1)] public float Y;
public void OnBeforeSerialize() { }
public void OnAfterDeserialize() { }
}
// Define a system
@ -291,6 +446,8 @@ public class MovementSystem : ITickedSystem
.Build();
}
public void Run(World world) => Run(world, Tick.Logical());
public void Run(World world, Tick tick)
{
float dt = tick.DeltaTime;
@ -306,7 +463,7 @@ public class MovementSystem : ITickedSystem
// Wire it up
var world = new World();
var group = new SystemGroup();
var group = new SystemGroup(world);
group.Add(new MovementSystem(world));
// Observe changes
@ -334,5 +491,7 @@ These types are implementation details and may change without notice:
| `SparseSet<T>` | Dense/sparse array pair for component storage. |
| `ComponentStore` | Registry of `SparseSet<T>` instances by type. |
| `ChangeBuffer` | Accumulates `EntityChange` during system run, posts to R3 subjects. |
| `ChangeSet` | Deduplicated change accumulator within a single batch. |
| `RelationshipIndex` | Reverse lookup from target entity to source entities. |
| `EntityAllocator` | Free-list + bump allocator for entity IDs. |
| `QueryExecutor` | Query iteration logic with smallest-set driver optimization. |

View File

@ -45,8 +45,8 @@ signature) and sparse sets (one dense array per component type).
**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.
**Decision:** `readonly struct Entity` wrapping a `uint`. Lower 24 bits are the
ID, upper 8 bits are the version.
**Rationale:**

View File

@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Blackjack</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\OECS\OECS.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\OECS.SourceGen\OECS.SourceGen.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,58 @@
using MessagePack;
using OECS;
namespace Blackjack;
/// <summary>
/// Player hits: draws one card from the deck to the player's hand.
/// </summary>
[MessagePackObject]
public struct HitCommand : ICommand
{
public void Execute(World world)
{
ref var state = ref world.GetSingleton<GameState>();
if (state.Phase != GamePhase.PlayerTurn)
return;
DrawCard<PlayerHand>(world);
}
/// <summary>
/// Draws the top card from the deck and adds it to the given hand.
/// </summary>
internal static void DrawCard<THand>(World world)
where THand : struct
{
var singletonEntity = World.SingletonEntity;
var deckEntity = FindEntity<Deck>(world, singletonEntity);
var handEntity = FindEntity<THand>(world, singletonEntity);
if (deckEntity == Entity.Null || handEntity == Entity.Null)
return;
// Find a card still in the deck.
var cardsInDeck = world.GetSources<InDeck>(deckEntity);
if (cardsInDeck.Count == 0)
return;
var cardEntity = cardsInDeck.First();
// Remove from deck, add to hand.
world.RemoveComponent<InDeck>(cardEntity);
world.AddComponent(cardEntity, new Holds { Source = cardEntity, Target = handEntity });
}
internal static Entity FindEntity<T>(World world, Entity singletonEntity)
where T : struct
{
using var iter = world.Select<T>();
while (iter.MoveNext())
{
if (iter.CurrentEntity != singletonEntity)
return iter.CurrentEntity;
}
return Entity.Null;
}
}

View File

@ -0,0 +1,53 @@
using MessagePack;
using OECS;
namespace Blackjack;
/// <summary>
/// Starts a new round after the previous one ended.
/// </summary>
[MessagePackObject]
public struct NewRoundCommand : ICommand
{
public void Execute(World world)
{
ref var state = ref world.GetSingleton<GameState>();
if (state.Phase != GamePhase.RoundOver)
return;
// Clear hands from previous round.
var singletonEntity = World.SingletonEntity;
var handEntities = new List<Entity>();
using (var iter = world.Select<PlayerHand>())
{
while (iter.MoveNext())
{
if (iter.CurrentEntity != singletonEntity)
handEntities.Add(iter.CurrentEntity);
}
}
using (var iter = world.Select<DealerHand>())
{
while (iter.MoveNext())
{
if (iter.CurrentEntity != singletonEntity)
handEntities.Add(iter.CurrentEntity);
}
}
foreach (var hand in handEntities)
{
var cards = world.GetSources<Holds>(hand);
var deckEntity = HitCommand.FindEntity<Deck>(world, singletonEntity);
foreach (var card in cards)
{
world.RemoveComponent<Holds>(card);
world.AddComponent(card, new InDeck { Source = card, Target = deckEntity });
}
}
state.Phase = GamePhase.Betting;
state.Result = RoundResult.None;
world.MarkModified<GameState>(World.SingletonEntity);
}
}

View File

@ -0,0 +1,29 @@
using MessagePack;
using OECS;
namespace Blackjack;
/// <summary>
/// Places a bet and advances the game to the dealing phase.
/// </summary>
[MessagePackObject]
public struct PlaceBetCommand : ICommand
{
[Key(0)] public int Amount;
public void Execute(World world)
{
ref var state = ref world.GetSingleton<GameState>();
if (state.Phase != GamePhase.Betting)
return;
if (Amount < 1 || Amount > state.Chips)
return;
state.CurrentBet = Amount;
state.Chips -= Amount;
state.Phase = GamePhase.Dealing;
world.MarkModified<GameState>(World.SingletonEntity);
}
}

View File

@ -0,0 +1,22 @@
using MessagePack;
using OECS;
namespace Blackjack;
/// <summary>
/// Player stands: advance to the dealer's turn.
/// </summary>
[MessagePackObject]
public struct StandCommand : ICommand
{
public void Execute(World world)
{
ref var state = ref world.GetSingleton<GameState>();
if (state.Phase != GamePhase.PlayerTurn)
return;
state.Phase = GamePhase.DealerTurn;
world.MarkModified<GameState>(World.SingletonEntity);
}
}

View File

@ -0,0 +1,14 @@
using MessagePack;
namespace Blackjack;
/// <summary>
/// A playing card with a suit and rank.
/// One entity per card.
/// </summary>
[MessagePackObject]
public struct Card
{
[Key(0)] public Suit Suit;
[Key(1)] public Rank Rank;
}

View File

@ -0,0 +1,9 @@
using MessagePack;
namespace Blackjack;
/// <summary>
/// Tag component marking the dealer's hand entity.
/// </summary>
[MessagePackObject]
public struct DealerHand { }

View File

@ -0,0 +1,9 @@
using MessagePack;
namespace Blackjack;
/// <summary>
/// Tag component marking the deck entity.
/// </summary>
[MessagePackObject]
public struct Deck { }

View File

@ -0,0 +1,15 @@
using MessagePack;
using OECS;
namespace Blackjack;
/// <summary>
/// Relationship from a hand entity to a card entity,
/// representing that the hand holds this card.
/// </summary>
[MessagePackObject]
public struct Holds : IRelationship
{
[Key(0)] public Entity Source { get; set; }
[Key(1)] public Entity Target { get; set; }
}

View File

@ -0,0 +1,15 @@
using MessagePack;
using OECS;
namespace Blackjack;
/// <summary>
/// Relationship from a card entity to the deck entity,
/// representing that the card is in the deck.
/// </summary>
[MessagePackObject]
public struct InDeck : IRelationship
{
[Key(0)] public Entity Source { get; set; }
[Key(1)] public Entity Target { get; set; }
}

View File

@ -0,0 +1,9 @@
using MessagePack;
namespace Blackjack;
/// <summary>
/// Tag component marking the player's hand entity.
/// </summary>
[MessagePackObject]
public struct PlayerHand { }

View File

@ -0,0 +1,18 @@
namespace Blackjack;
public enum Rank : byte
{
Ace = 1,
Two = 2,
Three = 3,
Four = 4,
Five = 5,
Six = 6,
Seven = 7,
Eight = 8,
Nine = 9,
Ten = 10,
Jack = 11,
Queen = 12,
King = 13
}

View File

@ -0,0 +1,9 @@
namespace Blackjack;
public enum Suit : byte
{
Clubs = 0,
Diamonds = 1,
Hearts = 2,
Spades = 3
}

View File

@ -0,0 +1,28 @@
namespace Blackjack;
/// <summary>
/// Mulberry32 PRNG — a fast, high-quality 32-bit random number generator.
/// Seed is stored in the ECS GameState singleton.
/// </summary>
public static class Mulberry32
{
/// <summary>
/// Advances the state and returns a random float in [0, 1).
/// </summary>
public static float NextFloat(ref uint state)
{
state += 0x6D2B79F5u;
uint z = state;
z = (z ^ (z >> 15)) * (z | 1u);
z ^= z + (z ^ (z >> 7)) * (z | 61u);
return (z ^ (z >> 14)) / (float)uint.MaxValue;
}
/// <summary>
/// Advances the state and returns a random integer in [min, max].
/// </summary>
public static int NextInt(ref uint state, int min, int max)
{
return (int)(NextFloat(ref state) * (max - min + 1)) + min;
}
}

View File

@ -0,0 +1,114 @@
using OECS;
namespace Blackjack;
public static class Program
{
public static void Main()
{
var world = new World();
// ── Initialize game state ─────────────────────────────────────
// Use a seed based on current time, or a fixed seed for reproducibility.
uint seed = (uint)Environment.TickCount;
// Uncomment the next line for a deterministic game:
// seed = 12345u;
world.SetSingleton(new GameState
{
Phase = GamePhase.Betting,
Result = RoundResult.None,
Chips = 100,
CurrentBet = 0,
RoundNumber = 1,
Seed = seed
});
world.PostChanges();
// ── Wire up systems ───────────────────────────────────────────
var group = new SystemGroup(world);
group.Add(new DeckSetupSystem());
group.Add(new DealSystem());
group.Add(new PlayerBustCheckSystem());
group.Add(new DealerSystem());
group.Add(new RenderSystem());
// ── Game loop ─────────────────────────────────────────────────
group.RunLogical();
while (true)
{
var state = world.ReadSingleton<GameState>();
// Check for game over (out of chips).
if (state.Chips <= 0 && state.Phase == GamePhase.Betting)
{
Console.WriteLine();
Console.WriteLine(" You're out of chips! Game over.");
break;
}
switch (state.Phase)
{
case GamePhase.Betting:
var input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input))
continue;
if (input.Trim().ToLower() == "quit")
return;
if (int.TryParse(input.Trim(), out var bet))
{
world.Commands.Enqueue(new PlaceBetCommand { Amount = bet });
}
else
{
Console.WriteLine(" Invalid bet. Enter a number.");
continue;
}
break;
case GamePhase.PlayerTurn:
var choice = Console.ReadLine();
if (string.IsNullOrWhiteSpace(choice))
continue;
if (choice.Trim().ToLower() == "quit")
return;
switch (choice.Trim().ToLower())
{
case "h":
case "hit":
world.Commands.Enqueue(new HitCommand());
break;
case "s":
case "stand":
world.Commands.Enqueue(new StandCommand());
break;
default:
Console.WriteLine(" Invalid choice. Enter H or S.");
continue;
}
break;
case GamePhase.RoundOver:
Console.ReadLine();
ref var roundState = ref world.GetSingleton<GameState>();
roundState.RoundNumber++;
world.MarkModified<GameState>(World.SingletonEntity);
world.PostChanges();
world.Commands.Enqueue(new NewRoundCommand());
break;
default:
// Dealing, DealerTurn — systems handle these automatically.
break;
}
group.RunLogical();
}
}
}

View File

@ -0,0 +1,10 @@
namespace Blackjack;
public enum GamePhase : byte
{
Betting = 0,
Dealing = 1,
PlayerTurn = 2,
DealerTurn = 3,
RoundOver = 4
}

View File

@ -0,0 +1,17 @@
using MessagePack;
namespace Blackjack;
/// <summary>
/// Global game state stored on the singleton entity.
/// </summary>
[MessagePackObject]
public struct GameState
{
[Key(0)] public GamePhase Phase;
[Key(1)] public RoundResult Result;
[Key(2)] public int Chips;
[Key(3)] public int CurrentBet;
[Key(4)] public int RoundNumber;
[Key(5)] public uint Seed;
}

View File

@ -0,0 +1,12 @@
namespace Blackjack;
public enum RoundResult : byte
{
None = 0,
PlayerBust = 1,
DealerBust = 2,
PlayerWin = 3,
DealerWin = 4,
Push = 5,
Blackjack = 6
}

View File

@ -0,0 +1,31 @@
using OECS;
namespace Blackjack;
/// <summary>
/// Deals initial two cards to player and dealer when entering the dealing phase.
/// Advances to player turn after dealing.
/// </summary>
public class DealSystem : ISystem
{
public void Run(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.Dealing)
return;
ref var mutableState = ref world.GetSingleton<GameState>();
// Deal two cards to player, then two to dealer.
HitCommand.DrawCard<PlayerHand>(world);
HitCommand.DrawCard<PlayerHand>(world);
HitCommand.DrawCard<DealerHand>(world);
HitCommand.DrawCard<DealerHand>(world);
mutableState.Phase = GamePhase.PlayerTurn;
world.MarkModified<GameState>(World.SingletonEntity);
}
}
internal static class PlayerHandTag { public static readonly PlayerHand Instance = new(); }
internal static class DealerHandTag { public static readonly DealerHand Instance = new(); }

View File

@ -0,0 +1,54 @@
using OECS;
namespace Blackjack;
/// <summary>
/// Dealer draws cards until reaching 17 or higher,
/// then resolves the round result.
/// </summary>
public class DealerSystem : ISystem
{
public void Run(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.DealerTurn)
return;
ref var mutableState = ref world.GetSingleton<GameState>();
// Dealer must hit on 16 and below, stand on 17+.
int dealerTotal = HandUtil.CalculateHand(world, DealerHandTag.Instance);
while (dealerTotal < 17)
{
HitCommand.DrawCard<DealerHand>(world);
dealerTotal = HandUtil.CalculateHand(world, DealerHandTag.Instance);
}
int playerTotal = HandUtil.CalculateHand(world, PlayerHandTag.Instance);
mutableState.Phase = GamePhase.RoundOver;
if (dealerTotal > 21)
{
mutableState.Result = RoundResult.DealerBust;
mutableState.Chips += mutableState.CurrentBet * 2;
}
else if (playerTotal > dealerTotal)
{
mutableState.Result = RoundResult.PlayerWin;
mutableState.Chips += mutableState.CurrentBet * 2;
}
else if (dealerTotal > playerTotal)
{
mutableState.Result = RoundResult.DealerWin;
}
else
{
// Push: return the bet.
mutableState.Result = RoundResult.Push;
mutableState.Chips += mutableState.CurrentBet;
}
world.MarkModified<GameState>(World.SingletonEntity);
}
}

View File

@ -0,0 +1,87 @@
using OECS;
namespace Blackjack;
/// <summary>
/// Creates the deck (52 cards + deck/hand entities) and shuffles
/// using mulberry32 with the seed from the GameState singleton.
/// Runs once at the start of each round (during Dealing phase).
/// </summary>
public class DeckSetupSystem : ISystem
{
public void Run(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.Dealing)
return;
// Only create deck entities if they don't exist yet.
var singletonEntity = World.SingletonEntity;
bool hasDeck = false;
using (var iter = world.Select<Deck>())
{
hasDeck = iter.MoveNext() && iter.CurrentEntity != singletonEntity;
}
if (!hasDeck)
{
// Create deck entity.
var deckEntity = world.CreateEntity();
world.AddComponent(deckEntity, new Deck());
// Create all 52 cards.
foreach (Suit suit in Enum.GetValues<Suit>())
{
foreach (Rank rank in Enum.GetValues<Rank>())
{
var cardEntity = world.CreateEntity();
world.AddComponent(cardEntity, new Card { Suit = suit, Rank = rank });
world.AddComponent(cardEntity, new InDeck { Source = cardEntity, Target = deckEntity });
}
}
// Create hand entities.
var playerHand = world.CreateEntity();
world.AddComponent(playerHand, new PlayerHand());
var dealerHand = world.CreateEntity();
world.AddComponent(dealerHand, new DealerHand());
}
// Shuffle the deck using mulberry32 with the current seed.
ref var mutableState = ref world.GetSingleton<GameState>();
var deckEntity2 = HitCommand.FindEntity<Deck>(world, singletonEntity);
var cards = world.GetSources<InDeck>(deckEntity2).ToArray();
Shuffle(world, deckEntity2, cards, ref mutableState.Seed);
}
private static void Shuffle(World world, Entity deckEntity, Entity[] cardEntities, ref uint seed)
{
// Fisher-Yates shuffle using mulberry32.
for (int i = cardEntities.Length - 1; i > 0; i--)
{
int j = Mulberry32.NextInt(ref seed, 0, i);
// Swap the InDeck relationship targets (cards are always in the deck,
// so there's nothing to swap except the cards themselves — but we
// shuffle the card order conceptually by removing and re-adding
// InDeck components in shuffled order). Actually, since InDeck
// is just a tag, we just re-shuffle the order in the source collection.
// The simplest approach: no need to swap component data; we just
// need to ensure the cards are iterated in shuffled order.
// We'll swap the card entities in the array.
(cardEntities[i], cardEntities[j]) = (cardEntities[j], cardEntities[i]);
}
// Now remove all InDeck and re-add in shuffled order so GetSources
// returns them in shuffled order.
for (int i = cardEntities.Length - 1; i >= 0; i--)
{
world.RemoveComponent<InDeck>(cardEntities[i]);
}
for (int i = 0; i < cardEntities.Length; i++)
{
world.AddComponent(cardEntities[i], new InDeck { Source = cardEntities[i], Target = deckEntity });
}
}
}

View File

@ -0,0 +1,72 @@
using OECS;
namespace Blackjack;
/// <summary>
/// Helper to calculate the blackjack value of a hand.
/// Aces count as 11 unless that would bust, then they count as 1.
/// </summary>
public static class HandUtil
{
public static int CalculateHand(World world, DealerHand hand)
{
return CalculateHandImpl(world, hand);
}
public static int CalculateHand(World world, PlayerHand hand)
{
return CalculateHandImpl(world, hand);
}
private static int CalculateHandImpl<T>(World world, T handTag)
where T : struct
{
// Find the hand entity.
Entity handEntity = Entity.Null;
using (var iter = world.Select<T>())
{
while (iter.MoveNext())
{
if (iter.CurrentEntity != World.SingletonEntity)
{
handEntity = iter.CurrentEntity;
break;
}
}
}
if (handEntity == Entity.Null)
return 0;
var cards = world.GetSources<Holds>(handEntity);
int total = 0;
int aceCount = 0;
foreach (var cardEntity in cards)
{
var card = world.ReadComponent<Card>(cardEntity);
int value = card.Rank switch
{
Rank.Ace => 11,
Rank.Jack => 10,
Rank.Queen => 10,
Rank.King => 10,
_ => (int)card.Rank
};
if (card.Rank == Rank.Ace)
aceCount++;
total += value;
}
// Downgrade aces from 11 to 1 as needed.
while (total > 21 && aceCount > 0)
{
total -= 10;
aceCount--;
}
return total;
}
}

View File

@ -0,0 +1,26 @@
using OECS;
namespace Blackjack;
/// <summary>
/// Evaluates the player's hand total after each hit.
/// Busts the player if they exceed 21.
/// </summary>
public class PlayerBustCheckSystem : ISystem
{
public void Run(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.PlayerTurn)
return;
var total = HandUtil.CalculateHand(world, PlayerHandTag.Instance);
if (total <= 21)
return;
ref var mutableState = ref world.GetSingleton<GameState>();
mutableState.Phase = GamePhase.RoundOver;
mutableState.Result = RoundResult.PlayerBust;
world.MarkModified<GameState>(World.SingletonEntity);
}
}

View File

@ -0,0 +1,148 @@
using OECS;
namespace Blackjack;
/// <summary>
/// Renders the current game state to the console.
/// </summary>
public class RenderSystem : ISystem
{
public void Run(World world)
{
var state = world.ReadSingleton<GameState>();
Console.WriteLine();
Console.WriteLine(" Blackjack");
Console.WriteLine(" ═════════");
Console.WriteLine();
Console.WriteLine($" Chips: {state.Chips} | Round: {state.RoundNumber}");
Console.WriteLine();
// Show dealer's hand.
var dealerTotal = HandUtil.CalculateHand(world, DealerHandTag.Instance);
Console.Write(" Dealer: ");
if (state.Phase == GamePhase.PlayerTurn)
{
// Hide first card (hole card) during player's turn.
Console.Write("?? ");
PrintHand(world, DealerHandTag.Instance, skipFirst: true);
}
else
{
PrintHand(world, DealerHandTag.Instance);
Console.Write($" ({dealerTotal})");
}
Console.WriteLine();
// Show player's hand.
var playerTotal = HandUtil.CalculateHand(world, PlayerHandTag.Instance);
Console.Write(" Player: ");
PrintHand(world, PlayerHandTag.Instance);
Console.Write($" ({playerTotal})");
Console.WriteLine();
Console.WriteLine();
// Status line.
switch (state.Phase)
{
case GamePhase.Betting:
Console.Write($" Enter bet (1{state.Chips}): ");
break;
case GamePhase.PlayerTurn:
Console.Write(" [H]it or [S]tand: ");
break;
case GamePhase.RoundOver:
Console.WriteLine($" {ResultText(state.Result)}");
Console.WriteLine();
Console.Write(" Press Enter for next round...");
break;
default:
break;
}
}
private static void PrintHand(World world, DealerHand hand, bool skipFirst = false)
{
PrintHandImpl(world, hand, skipFirst);
}
private static void PrintHand(World world, PlayerHand hand)
{
PrintHandImpl(world, hand, skipFirst: false);
}
private static void PrintHandImpl<T>(World world, T handTag, bool skipFirst)
where T : struct
{
Entity handEntity = Entity.Null;
using (var iter = world.Select<T>())
{
while (iter.MoveNext())
{
if (iter.CurrentEntity != World.SingletonEntity)
{
handEntity = iter.CurrentEntity;
break;
}
}
}
if (handEntity == Entity.Null)
return;
var cards = world.GetSources<Holds>(handEntity);
var ordered = cards.OrderByDescending(c =>
{
var card = world.ReadComponent<Card>(c);
return (int)card.Rank;
}).ToList();
bool first = true;
foreach (var cardEntity in ordered)
{
if (skipFirst && first)
{
first = false;
continue;
}
first = false;
var card = world.ReadComponent<Card>(cardEntity);
Console.Write(CardToString(card));
Console.Write(" ");
}
}
private static string CardToString(Card card)
{
var suit = card.Suit switch
{
Suit.Hearts => "♥",
Suit.Diamonds => "♦",
Suit.Clubs => "♣",
Suit.Spades => "♠",
_ => "?"
};
var rank = card.Rank switch
{
Rank.Ace => "A",
Rank.Jack => "J",
Rank.Queen => "Q",
Rank.King => "K",
_ => ((int)card.Rank).ToString()
};
return $"{rank}{suit}";
}
private static string ResultText(RoundResult result) => result switch
{
RoundResult.PlayerBust => "You bust! Dealer wins.",
RoundResult.DealerBust => "Dealer busts! You win!",
RoundResult.PlayerWin => "You win!",
RoundResult.DealerWin => "Dealer wins.",
RoundResult.Push => "Push — it's a tie!",
RoundResult.Blackjack => "Blackjack!",
_ => ""
};
}

View File

@ -50,8 +50,8 @@ public static class Program
// ── Wire up systems ───────────────────────────────────────────
var group = new SystemGroup(world);
group.Add(new WinCheckSystem(world));
group.Add(new RenderSystem(world));
group.Add(new WinCheckSystem());
group.Add(new RenderSystem());
// ── Game loop ─────────────────────────────────────────────────

View File

@ -7,13 +7,6 @@ namespace TicTacToe;
/// </summary>
public class RenderSystem : ISystem
{
public QueryDescriptor Query { get; }
public RenderSystem(World world)
{
Query = world.Query().With<Cell>().Build();
}
public void Run(World world)
{
var state = world.ReadSingleton<GameState>();
@ -25,8 +18,7 @@ public class RenderSystem : ISystem
grid[r, c] = '.';
// Fill in placed marks using the iterator API.
var markQuery = world.Query().With<Cell>().With<Mark>().Build();
using var markIter = world.Select<Cell, Mark>(markQuery);
using var markIter = world.Select<Cell, Mark>();
while (markIter.MoveNext())
{
grid[markIter.Current1.Row, markIter.Current1.Col] =

View File

@ -8,24 +8,19 @@ namespace TicTacToe;
/// </summary>
public class WinCheckSystem : ISystem
{
public QueryDescriptor Query { get; }
public WinCheckSystem(World world)
{
Query = world.Query().With<Cell>().With<Mark>().Build();
}
public void Run(World world)
{
ref var state = ref world.GetSingleton<GameState>();
if (state.Status != GameStatus.Playing)
// Check game status before doing any work. Use ReadSingleton
// to avoid auto-marking GameState as modified when we bail early.
if (world.ReadSingleton<GameState>().Status != GameStatus.Playing)
return;
ref var state = ref world.GetSingleton<GameState>();
// Build a 3×3 grid of marks using the iterator API.
var grid = new Player[3, 3];
using var iter = world.Select<Cell, Mark>(Query);
using var iter = world.Select<Cell, Mark>();
while (iter.MoveNext())
{
grid[iter.Current1.Row, iter.Current1.Col] = iter.Current2.Player;

View File

@ -12,6 +12,11 @@
<ProjectReference Include="..\..\src\OECS\OECS.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\OECS.SourceGen\OECS.SourceGen.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
<ItemGroup>
<None Update="Data\board.csv">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>

9
nuget.config Normal file
View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<!-- Local feed for OECS development builds.
Run `dotnet pack src/OECS/OECS.csproj -c Release -o ./nupkgs`
to produce the package. -->
<add key="local-oecs" value="./nupkgs" />
</packageSources>
</configuration>

View File

@ -0,0 +1,227 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using System.Collections.Immutable;
using System.Text;
namespace OECS.SourceGen;
/// <summary>
/// Incremental source generator that discovers all component types used with
/// <see cref="OECS.World"/> and generates a strongly-typed registry so that
/// <see cref="OECS.WorldSerializer"/> can save/load without reflection.
/// </summary>
[Generator]
public class ComponentDiscoveryGenerator : IIncrementalGenerator
{
private static readonly HashSet<string> _componentApiMethods = new()
{
// Component management
"AddComponent",
"RemoveComponent",
"HasComponent",
"GetComponent",
"TryGetComponent",
"ReadComponent",
"MarkModified",
// Singletons
"SetSingleton",
"GetSingleton",
"ReadSingleton",
"HasSingleton",
"RemoveSingleton",
// Observability
"ObserveComponentChanges",
// Query building
"With",
"Without",
};
private static readonly HashSet<string> _forEachNames = new()
{
"ForEach",
};
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Collect all generic method invocations that reference component types.
var invocations = context.SyntaxProvider
.CreateSyntaxProvider(
predicate: IsCandidateInvocation,
transform: ExtractComponentType)
.Where(t => t.FullyQualifiedName != null)
.Select((t, _) => (t.FullyQualifiedName!, t.AssemblyQualifiedName!));
// Also collect ForEach type arguments from the World class.
var forEachCalls = context.SyntaxProvider
.CreateSyntaxProvider(
predicate: IsForEachCandidate,
transform: ExtractForEachTypes)
.Where(list => list.Length > 0)
.SelectMany((list, _) => list);
var allTypes = invocations.Collect().Combine(forEachCalls.Collect());
context.RegisterSourceOutput(allTypes, GenerateRegistry);
}
private static bool IsCandidateInvocation(SyntaxNode node, CancellationToken _)
{
if (node is not InvocationExpressionSyntax invocation)
return false;
if (invocation.Expression is MemberAccessExpressionSyntax memberAccess)
{
if (memberAccess.Name is GenericNameSyntax genericName)
{
return _componentApiMethods.Contains(genericName.Identifier.Text);
}
}
return false;
}
private static bool IsForEachCandidate(SyntaxNode node, CancellationToken _)
{
if (node is not InvocationExpressionSyntax invocation)
return false;
if (invocation.Expression is MemberAccessExpressionSyntax memberAccess)
{
var name = memberAccess.Name is GenericNameSyntax gn
? gn.Identifier.Text
: memberAccess.Name.Identifier.Text;
return _forEachNames.Contains(name);
}
return false;
}
private static (string? FullyQualifiedName, string? AssemblyQualifiedName) ExtractComponentType(
GeneratorSyntaxContext ctx, CancellationToken _)
{
if (ctx.Node is not InvocationExpressionSyntax invocation)
return default;
if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess)
return default;
if (memberAccess.Name is not GenericNameSyntax genericName)
return default;
var typeArg = genericName.TypeArgumentList.Arguments.FirstOrDefault();
if (typeArg == null)
return default;
var symbolInfo = ctx.SemanticModel.GetSymbolInfo(typeArg);
if (symbolInfo.Symbol is not INamedTypeSymbol typeSymbol)
return default;
// Only public struct types are valid components.
if (!typeSymbol.IsValueType || typeSymbol.IsAbstract)
return default;
if (typeSymbol.DeclaredAccessibility != Accessibility.Public)
return default;
var fqn = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
// Assembly-qualified name for stable serialization (without assembly version).
var aqn = typeSymbol.ContainingAssembly != null
? $"{typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted))}, {typeSymbol.ContainingAssembly.Name}"
: fqn;
return (fqn, aqn);
}
private static ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName)> ExtractForEachTypes(
GeneratorSyntaxContext ctx, CancellationToken _)
{
if (ctx.Node is not InvocationExpressionSyntax invocation)
return ImmutableArray<(string, string)>.Empty;
if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess)
return ImmutableArray<(string, string)>.Empty;
if (memberAccess.Name is not GenericNameSyntax genericName)
return ImmutableArray<(string, string)>.Empty;
var types = ImmutableArray.CreateBuilder<(string, string)>();
foreach (var typeArg in genericName.TypeArgumentList.Arguments)
{
var symbolInfo = ctx.SemanticModel.GetSymbolInfo(typeArg);
if (symbolInfo.Symbol is INamedTypeSymbol typeSymbol &&
typeSymbol.IsValueType && !typeSymbol.IsAbstract &&
typeSymbol.DeclaredAccessibility == Accessibility.Public)
{
var fqn = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
var aqn = typeSymbol.ContainingAssembly != null
? $"{typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted))}, {typeSymbol.ContainingAssembly.Name}"
: fqn;
types.Add((fqn, aqn));
}
}
return types.ToImmutable();
}
private static void GenerateRegistry(SourceProductionContext context,
(ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName)> Left,
ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName)> Right) data)
{
var (invocations, forEachTypes) = data;
// Collect unique types by fully-qualified name, deduplicating.
var typeMap = new Dictionary<string, (string Fqn, string Aqn)>();
foreach (var t in invocations)
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName);
foreach (var t in forEachTypes)
{
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName);
}
if (typeMap.Count == 0)
return;
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated/>");
sb.AppendLine("using System.Runtime.CompilerServices;");
sb.AppendLine("using MessagePack;");
sb.AppendLine();
sb.AppendLine("namespace OECS");
sb.AppendLine("{");
sb.AppendLine(" file static class ComponentRegistryInitializer");
sb.AppendLine(" {");
sb.AppendLine(" [ModuleInitializer]");
sb.AppendLine(" public static void Initialize()");
sb.AppendLine(" {");
sb.AppendLine(" ComponentRegistry.Initialize(new ComponentDescriptor[]");
sb.AppendLine(" {");
bool first = true;
foreach (var (fqn, aqn) in typeMap.Values.OrderBy(t => t.Fqn))
{
if (!first)
sb.AppendLine(",");
first = false;
sb.AppendLine($" new ComponentDescriptor(");
sb.AppendLine($" typeName: \"{aqn}\",");
sb.AppendLine($" type: typeof({fqn}),");
sb.AppendLine($" serialize: obj => MessagePackSerializer.Serialize(({fqn})obj),");
sb.AppendLine($" deserializeAndAdd: (world, entity, data) =>");
sb.AppendLine($" world.AddComponent(entity, MessagePackSerializer.Deserialize<{fqn}>(data))");
sb.Append(" )");
}
sb.AppendLine();
sb.AppendLine(" });");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine("}");
context.AddSource("ComponentRegistry.g.cs", sb.ToString());
}
}

View File

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>12</LangVersion>
<RootNamespace>OECS.SourceGen</RootNamespace>
<AssemblyName>OECS.SourceGen</AssemblyName>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<IsRoslynComponent>true</IsRoslynComponent>
<IncludeBuildOutput>false</IncludeBuildOutput>
<DevelopmentDependency>true</DevelopmentDependency>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>
</Project>

View File

@ -14,8 +14,18 @@ internal class ChangeBuffer
private readonly ChangeSet _pending = new();
private readonly Subject<EntityChange> _entitySubject = new();
private readonly Dictionary<Type, Subject<EntityChange>> _componentSubjects = new();
private readonly Dictionary<QueryDescriptor, Subject<EntityChange>> _querySubjects = new();
private readonly Dictionary<Type, TrackedSubject> _componentSubjects = new();
private readonly Dictionary<QueryDescriptor, TrackedSubject> _querySubjects = new();
/// <summary>
/// Wraps a <see cref="Subject{T}"/> with a subscriber count so that
/// subjects with no remaining subscribers can be cleaned up.
/// </summary>
private sealed class TrackedSubject
{
public readonly Subject<EntityChange> Subject = new();
public int SubscriberCount;
}
/// <summary>
/// The change set currently accumulating. Cleared after each <see cref="Post"/>.
@ -40,18 +50,18 @@ internal class ChangeBuffer
// Push to component-specific subjects.
if (change.ComponentType != null)
{
if (_componentSubjects.TryGetValue(change.ComponentType, out var compSubject))
if (_componentSubjects.TryGetValue(change.ComponentType, out var compTracked))
{
compSubject.OnNext(change);
compTracked.Subject.OnNext(change);
}
}
// Push to matching query subjects.
foreach (var (query, subject) in _querySubjects)
foreach (var (query, tracked) in _querySubjects)
{
if (ChangeMatchesQuery(change, query))
{
subject.OnNext(change);
tracked.Subject.OnNext(change);
}
}
}
@ -72,12 +82,22 @@ internal class ChangeBuffer
/// </summary>
public Observable<EntityChange> ObserveComponentChanges(Type componentType)
{
if (!_componentSubjects.TryGetValue(componentType, out var subject))
if (!_componentSubjects.TryGetValue(componentType, out var tracked))
{
subject = new Subject<EntityChange>();
_componentSubjects[componentType] = subject;
tracked = new TrackedSubject();
_componentSubjects[componentType] = tracked;
}
return subject;
tracked.SubscriberCount++;
return WrapWithCleanup(tracked.Subject, () =>
{
tracked.SubscriberCount--;
if (tracked.SubscriberCount <= 0)
{
tracked.Subject.Dispose();
_componentSubjects.Remove(componentType);
}
});
}
/// <summary>
@ -85,12 +105,40 @@ internal class ChangeBuffer
/// </summary>
public Observable<EntityChange> ObserveQuery(QueryDescriptor query)
{
if (!_querySubjects.TryGetValue(query, out var subject))
if (!_querySubjects.TryGetValue(query, out var tracked))
{
subject = new Subject<EntityChange>();
_querySubjects[query] = subject;
tracked = new TrackedSubject();
_querySubjects[query] = tracked;
}
return subject;
tracked.SubscriberCount++;
return WrapWithCleanup(tracked.Subject, () =>
{
tracked.SubscriberCount--;
if (tracked.SubscriberCount <= 0)
{
tracked.Subject.Dispose();
_querySubjects.Remove(query);
}
});
}
/// <summary>
/// Wraps an observable so that <paramref name="onLastDispose"/> is called
/// when the last subscriber disposes.
/// </summary>
private static Observable<EntityChange> WrapWithCleanup(
Observable<EntityChange> source, Action onLastDispose)
{
return Observable.Create<EntityChange>(observer =>
{
var subscription = source.Subscribe(observer);
return Disposable.Create(() =>
{
subscription.Dispose();
onLastDispose();
});
});
}
/// <summary>
@ -99,14 +147,14 @@ internal class ChangeBuffer
public void Dispose()
{
_entitySubject.Dispose();
foreach (var subject in _componentSubjects.Values)
foreach (var tracked in _componentSubjects.Values)
{
subject.Dispose();
tracked.Subject.Dispose();
}
_componentSubjects.Clear();
foreach (var subject in _querySubjects.Values)
foreach (var tracked in _querySubjects.Values)
{
subject.Dispose();
tracked.Subject.Dispose();
}
_querySubjects.Clear();
}

View File

@ -4,13 +4,14 @@ namespace OECS;
/// Accumulates <see cref="EntityChange"/> entries during a system run.
///
/// Deduplication: if the same (entity, kind, componentType) change is marked
/// multiple times, only one entry is kept. Entity-level changes (Added/Removed)
/// take precedence over component-level changes for the same entity.
/// multiple times, only one entry is kept. However, if a structurally opposed
/// change arrives (Added vs Removed) for the same (entity, componentType),
/// the old entry is removed so observers see the full state transition.
/// </summary>
internal class ChangeSet
{
private readonly List<EntityChange> _changes = new();
private readonly HashSet<(Entity Entity, ChangeKind Kind, Type? ComponentType)> _dedup = new();
private readonly Dictionary<(Entity Entity, ChangeKind Kind, Type? ComponentType), int> _dedup = new();
/// <summary>
/// All accumulated changes in insertion order.
@ -75,9 +76,31 @@ internal class ChangeSet
private void TryAdd(EntityChange change)
{
var key = (change.Entity, change.Kind, change.ComponentType);
if (_dedup.Add(key))
if (_dedup.ContainsKey(key))
{
// Same (entity, kind, componentType) already recorded — deduplicate.
return;
}
// When a component is re-added after being removed within the same
// batch, remove the old ComponentRemoved entry so observers see the
// full Add → Remove → Add sequence.
if (change.Kind == ChangeKind.ComponentAdded && change.ComponentType != null)
{
var removedKey = (change.Entity, ChangeKind.ComponentRemoved, change.ComponentType);
if (_dedup.Remove(removedKey, out int oldIndex))
{
_changes.RemoveAt(oldIndex);
// Adjust indices for all entries that shifted down.
foreach (var k in _dedup.Keys.ToList())
{
if (_dedup[k] > oldIndex)
_dedup[k]--;
}
}
}
_dedup[key] = _changes.Count;
_changes.Add(change);
}
}
}

View File

@ -0,0 +1,42 @@
namespace OECS;
/// <summary>
/// Describes a component type discovered at compile time by the source generator.
/// Used by <see cref="WorldSerializer"/> to save/load components without reflection.
/// </summary>
public sealed class ComponentDescriptor
{
/// <summary>
/// The assembly-qualified name of the component type, for stable serialization
/// across assemblies.
/// </summary>
public string TypeName { get; }
/// <summary>
/// The <see cref="System.Type"/> of the component.
/// </summary>
public Type Type { get; }
/// <summary>
/// Serializes a boxed component instance to a MessagePack byte array.
/// </summary>
public Func<object, byte[]> Serialize { get; }
/// <summary>
/// Deserializes a MessagePack byte array and adds the component to the entity
/// via the typed <see cref="World.AddComponent{T}"/> method. No reflection.
/// </summary>
public Action<World, Entity, byte[]> DeserializeAndAdd { get; }
public ComponentDescriptor(
string typeName,
Type type,
Func<object, byte[]> serialize,
Action<World, Entity, byte[]> deserializeAndAdd)
{
TypeName = typeName;
Type = type;
Serialize = serialize;
DeserializeAndAdd = deserializeAndAdd;
}
}

View File

@ -0,0 +1,51 @@
using System.Runtime.CompilerServices;
namespace OECS;
/// <summary>
/// Registry of component types discovered at compile time by the
/// OECS.SourceGen incremental generator. The consuming project's
/// generated code populates this via a module initializer, so no
/// manual registration is needed.
/// </summary>
public static class ComponentRegistry
{
private static ComponentDescriptor[]? _descriptors;
private static Dictionary<string, ComponentDescriptor>? _byTypeName;
/// <summary>
/// All discovered component descriptors. Populated automatically
/// by the source generator at module initialization.
/// </summary>
public static ComponentDescriptor[] Descriptors =>
_descriptors ?? Array.Empty<ComponentDescriptor>();
/// <summary>
/// Lookup by assembly-qualified type name. Populated automatically
/// by the source generator at module initialization.
/// </summary>
public static IReadOnlyDictionary<string, ComponentDescriptor> ByTypeName
{
get
{
if (_byTypeName == null)
{
var dict = new Dictionary<string, ComponentDescriptor>();
foreach (var desc in Descriptors)
dict[desc.TypeName] = desc;
_byTypeName = dict;
}
return _byTypeName;
}
}
/// <summary>
/// Called by generated code to register discovered component types.
/// Must be called before any serialization occurs.
/// </summary>
public static void Initialize(ComponentDescriptor[] descriptors)
{
_descriptors = descriptors;
_byTypeName = null; // Rebuild on next access.
}
}

View File

@ -20,6 +20,17 @@ public static class EntityIterator
return new Select1<T1>(world, query);
}
/// <summary>
/// Returns an iterator over entities that have a component of type
/// <typeparamref name="T1"/>. No <c>Without</c> filter is applied.
/// </summary>
public static Select1<T1> Select<T1>(
this World world)
where T1 : struct
{
return new Select1<T1>(world, new QueryDescriptor(new HashSet<Type>(), new HashSet<Type>()));
}
/// <summary>
/// Returns an iterator over entities matching the query, with ref access
/// to two component types.
@ -31,6 +42,18 @@ public static class EntityIterator
return new Select2<T1, T2>(world, query);
}
/// <summary>
/// Returns an iterator over entities that have both components of type
/// <typeparamref name="T1"/> and <typeparamref name="T2"/>.
/// No <c>Without</c> filter is applied.
/// </summary>
public static Select2<T1, T2> Select<T1, T2>(
this World world)
where T1 : struct where T2 : struct
{
return new Select2<T1, T2>(world, new QueryDescriptor(new HashSet<Type>(), new HashSet<Type>()));
}
/// <summary>
/// Returns an iterator over entities matching the query, with ref access
/// to three component types.
@ -41,6 +64,18 @@ public static class EntityIterator
{
return new Select3<T1, T2, T3>(world, query);
}
/// <summary>
/// Returns an iterator over entities that have all three components of type
/// <typeparamref name="T1"/>, <typeparamref name="T2"/>, and
/// <typeparamref name="T3"/>. No <c>Without</c> filter is applied.
/// </summary>
public static Select3<T1, T2, T3> Select<T1, T2, T3>(
this World world)
where T1 : struct where T2 : struct where T3 : struct
{
return new Select3<T1, T2, T3>(world, new QueryDescriptor(new HashSet<Type>(), new HashSet<Type>()));
}
}
/// <summary>

View File

@ -2,15 +2,10 @@ namespace OECS;
/// <summary>
/// A system that runs on every tick.
/// Systems declare a query and receive the world in their <see cref="Run"/> method.
/// Systems receive the world in their <see cref="Run"/> method.
/// </summary>
public interface ISystem
{
/// <summary>
/// The query that defines which entities this system operates on.
/// </summary>
QueryDescriptor Query { get; }
/// <summary>
/// Executes the system logic for this tick.
/// </summary>

View File

@ -23,4 +23,20 @@
<PackageReference Include="R3" Version="1.2.9" />
</ItemGroup>
<!-- Bundle the source generator into the NuGet package as an analyzer.
Consumers get compile-time component discovery automatically. -->
<ItemGroup>
<None Include="..\OECS.SourceGen\bin\$(Configuration)\netstandard2.0\OECS.SourceGen.dll"
Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>
<!-- Ensure the source generator is built before packing.
Skipped when NoBuild property is set to true. -->
<Target Name="BuildSourceGenerator" BeforeTargets="GenerateNuspec"
Condition="'$(NoBuild)' != 'true'">
<MSBuild Projects="..\OECS.SourceGen\OECS.SourceGen.csproj"
Targets="Build"
Properties="Configuration=$(Configuration)" />
</Target>
</Project>

View File

@ -129,21 +129,55 @@ internal static class QueryExecutor
var set3 = store.GetSet(typeof(T3)) as SparseSet<T3>;
if (set3 == null) return;
// Pick the smallest set as the driver.
int c1 = set1.Count, c2 = set2.Count, c3 = set3.Count;
if (c2 <= c1 && c2 <= c3)
{
var dense = set2.Dense;
var denseEntities = set2.DenseEntities;
var count = set2.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref dense[i], ref set3.Get(entity));
}
}
else if (c3 <= c1 && c3 <= c2)
{
var dense = set3.Dense;
var denseEntities = set3.DenseEntities;
var count = set3.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref dense[i]);
}
}
else
{
var dense = set1.Dense;
var denseEntities = set1.DenseEntities;
var count = set1.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without))
continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity));
}
}
}
public static void ForEach<T1, T2, T3, T4>(
ComponentStore store,
@ -160,10 +194,63 @@ internal static class QueryExecutor
var set4 = store.GetSet(typeof(T4)) as SparseSet<T4>;
if (set4 == null) return;
// Pick the smallest set as the driver.
int c1 = set1.Count, c2 = set2.Count, c3 = set3.Count, c4 = set4.Count;
int min = Math.Min(Math.Min(c1, c2), Math.Min(c3, c4));
if (min == c2)
{
var dense = set2.Dense;
var denseEntities = set2.DenseEntities;
var count = set2.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref dense[i], ref set3.Get(entity), ref set4.Get(entity));
}
}
else if (min == c3)
{
var dense = set3.Dense;
var denseEntities = set3.DenseEntities;
var count = set3.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref dense[i], ref set4.Get(entity));
}
}
else if (min == c4)
{
var dense = set4.Dense;
var denseEntities = set4.DenseEntities;
var count = set4.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref set3.Get(entity), ref dense[i]);
}
}
else
{
var dense = set1.Dense;
var denseEntities = set1.DenseEntities;
var count = set1.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
@ -171,11 +258,11 @@ internal static class QueryExecutor
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without))
continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity));
}
}
}
public static void ForEach<T1, T2, T3, T4, T5>(
ComponentStore store,
@ -194,10 +281,83 @@ internal static class QueryExecutor
var set5 = store.GetSet(typeof(T5)) as SparseSet<T5>;
if (set5 == null) return;
// Pick the smallest set as the driver.
int c1 = set1.Count, c2 = set2.Count, c3 = set3.Count, c4 = set4.Count, c5 = set5.Count;
int min = Math.Min(Math.Min(Math.Min(c1, c2), Math.Min(c3, c4)), c5);
if (min == c2)
{
var dense = set2.Dense;
var denseEntities = set2.DenseEntities;
var count = set2.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref dense[i], ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity));
}
}
else if (min == c3)
{
var dense = set3.Dense;
var denseEntities = set3.DenseEntities;
var count = set3.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref dense[i], ref set4.Get(entity), ref set5.Get(entity));
}
}
else if (min == c4)
{
var dense = set4.Dense;
var denseEntities = set4.DenseEntities;
var count = set4.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref set3.Get(entity), ref dense[i], ref set5.Get(entity));
}
}
else if (min == c5)
{
var dense = set5.Dense;
var denseEntities = set5.DenseEntities;
var count = set5.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref dense[i]);
}
}
else
{
var dense = set1.Dense;
var denseEntities = set1.DenseEntities;
var count = set1.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
@ -206,11 +366,11 @@ internal static class QueryExecutor
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without))
continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity));
}
}
}
public static void ForEach<T1, T2, T3, T4, T5, T6>(
ComponentStore store,
@ -231,10 +391,105 @@ internal static class QueryExecutor
var set6 = store.GetSet(typeof(T6)) as SparseSet<T6>;
if (set6 == null) return;
// Pick the smallest set as the driver.
int c1 = set1.Count, c2 = set2.Count, c3 = set3.Count, c4 = set4.Count, c5 = set5.Count, c6 = set6.Count;
int min = Math.Min(Math.Min(Math.Min(c1, c2), Math.Min(c3, c4)), Math.Min(c5, c6));
if (min == c2)
{
var dense = set2.Dense;
var denseEntities = set2.DenseEntities;
var count = set2.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!set6.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref dense[i], ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity), ref set6.Get(entity));
}
}
else if (min == c3)
{
var dense = set3.Dense;
var denseEntities = set3.DenseEntities;
var count = set3.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!set6.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref dense[i], ref set4.Get(entity), ref set5.Get(entity), ref set6.Get(entity));
}
}
else if (min == c4)
{
var dense = set4.Dense;
var denseEntities = set4.DenseEntities;
var count = set4.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!set6.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref set3.Get(entity), ref dense[i], ref set5.Get(entity), ref set6.Get(entity));
}
}
else if (min == c5)
{
var dense = set5.Dense;
var denseEntities = set5.DenseEntities;
var count = set5.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!set6.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref dense[i], ref set6.Get(entity));
}
}
else if (min == c6)
{
var dense = set6.Dense;
var denseEntities = set6.DenseEntities;
var count = set6.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity), ref dense[i]);
}
}
else
{
var dense = set1.Dense;
var denseEntities = set1.DenseEntities;
var count = set1.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
@ -244,9 +499,9 @@ internal static class QueryExecutor
if (!set4.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!set6.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without))
continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity), ref set6.Get(entity));
}
}
}
}

View File

@ -25,6 +25,7 @@ namespace OECS;
/// </summary>
[MessagePackObject]
public struct Relationship<TSelf, TTarget> : IRelationship
where TSelf : struct where TTarget : struct
{
/// <summary>
/// The entity that owns this relationship component.

View File

@ -180,6 +180,7 @@ internal class SparseSet<T> : ISparseSet where T : struct
{
_sparse[_denseEntities[i].Id] = -1;
}
Array.Clear(_denseEntities, 0, _count);
_count = 0;
}

View File

@ -188,6 +188,17 @@ public class World : IDisposable
// If replacing an existing relationship, remove the old index entry first.
if (component is IRelationship newRel)
{
// Guard against mismatched Source: the relationship must be stored
// on the entity it claims as its Source, otherwise the reverse
// index becomes corrupted.
if (newRel.Source != entity)
{
throw new InvalidOperationException(
$"Relationship of type {typeof(T).Name} has Source={newRel.Source} " +
$"but is being added to entity {entity}. " +
$"The Source must match the entity the component is added to.");
}
if (_components.TryGet<T>(entity, out var old))
{
var oldRel = (IRelationship)(object)old;

View File

@ -5,9 +5,10 @@ namespace OECS;
/// <summary>
/// Saves and loads a <see cref="World"/> to/from a MessagePack stream.
///
/// Components are serialized by their runtime type. The caller must
/// ensure all component types used in the world are resolvable via
/// <see cref="Type.GetType(string)"/> at load time.
/// Uses the compile-time generated <see cref="ComponentRegistry"/> to
/// serialize and deserialize components without reflection. All component
/// types used with the World API are discovered by the OECS.SourceGen
/// incremental generator at build time.
/// </summary>
public static class WorldSerializer
{
@ -18,9 +19,9 @@ public static class WorldSerializer
{
var entityComponents = new Dictionary<Entity, List<ComponentEntry>>();
foreach (var ct in world.Components.ComponentTypes)
foreach (var desc in ComponentRegistry.Descriptors)
{
var set = world.Components.GetSet(ct);
var set = world.Components.GetSet(desc.Type);
if (set == null || set.Count == 0) continue;
var entities = set.GetDenseEntities();
@ -37,8 +38,8 @@ public static class WorldSerializer
list.Add(new ComponentEntry
{
TypeName = ct.AssemblyQualifiedName!,
Data = MessagePackSerializer.Serialize(ct, component)
TypeName = desc.TypeName,
Data = desc.Serialize(component)
});
}
}
@ -67,6 +68,11 @@ public static class WorldSerializer
{
var snapshot = MessagePackSerializer.Deserialize<WorldSnapshot>(stream);
// Build a lookup by type name for O(1) descriptor resolution.
var lookup = new Dictionary<string, ComponentDescriptor>();
foreach (var desc in ComponentRegistry.Descriptors)
lookup[desc.TypeName] = desc;
foreach (var es in snapshot.Entities)
{
var entity = world.CreateEntity(es.Entity);
@ -81,18 +87,14 @@ public static class WorldSerializer
foreach (var ce in es.Components)
{
var type = Type.GetType(ce.TypeName)
?? throw new InvalidOperationException(
if (!lookup.TryGetValue(ce.TypeName, out var desc))
throw new InvalidOperationException(
$"Unknown component type '{ce.TypeName}'. " +
$"Ensure the assembly is loaded.");
$"Ensure the component type is used with the World " +
$"API so the source generator can discover it.");
var component = MessagePackSerializer.Deserialize(type, ce.Data)
?? throw new InvalidOperationException(
$"Failed to deserialize component of type '{ce.TypeName}'.");
var method = typeof(World).GetMethod(nameof(World.AddComponent))!
.MakeGenericMethod(type);
method.Invoke(world, [entity, component]);
// Deserialize and add in one typed operation — no reflection.
desc.DeserializeAndAdd(world, entity, ce.Data);
}
}
}

View File

@ -249,13 +249,10 @@ public class CommandTests
private readonly World _world;
private readonly SpawnCommand _command;
public QueryDescriptor Query { get; }
public CommandEnqueueSystem(World world, SpawnCommand command)
{
_world = world;
_command = command;
Query = world.Query().With<TestPosition>().Build();
}
public void Run(World world)
@ -266,18 +263,17 @@ public class CommandTests
private class EntityCountObserver : ISystem
{
private readonly QueryDescriptor _query;
public int SeenCount { get; private set; }
public QueryDescriptor Query { get; }
public EntityCountObserver(World world)
{
Query = world.Query().With<TestPosition>().Build();
_query = world.Query().With<TestPosition>().Build();
}
public void Run(World world)
{
world.ForEach(Query, (Entity e, ref TestPosition pos) =>
world.ForEach(_query, (Entity e, ref TestPosition pos) =>
{
SeenCount++;
});

View File

@ -23,4 +23,9 @@
<ProjectReference Include="..\..\src\OECS\OECS.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\OECS.SourceGen\OECS.SourceGen.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
</Project>

View File

@ -175,11 +175,11 @@ public class SystemTests
private class MovementSystem : ITickedSystem
{
public QueryDescriptor Query { get; }
private readonly QueryDescriptor _query;
public MovementSystem(World world)
{
Query = world.Query().With<Position>().With<Velocity>().Build();
_query = world.Query().With<Position>().With<Velocity>().Build();
}
public void Run(World world) => Run(world, Tick.Logical());
@ -187,7 +187,7 @@ public class SystemTests
public void Run(World world, Tick tick)
{
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.Y += vel.Y * dt;
@ -232,13 +232,11 @@ public class SystemTests
private class TestLogicalSystem : ITickedSystem
{
public QueryDescriptor Query { get; }
public bool WasCalled { get; private set; }
public Tick ReceivedTick { get; private set; }
public TestLogicalSystem(World world)
{
Query = world.Query().With<Position>().Build();
}
public void Run(World world) => Run(world, Tick.Logical());
@ -272,13 +270,10 @@ public class SystemTests
private readonly int _id;
private readonly List<int> _order;
public QueryDescriptor Query { get; }
public OrderTrackingSystem(World world, int id, List<int> order)
{
_id = id;
_order = order;
Query = world.Query().With<Position>().Build();
}
public void Run(World world)

View File

@ -268,12 +268,9 @@ public class ReactivityTests
{
private readonly World _world;
public QueryDescriptor Query { get; }
public EntityCreatorSystem(World world)
{
_world = world;
Query = world.Query().With<Position>().Build();
}
public void Run(World world)

View File

@ -9,6 +9,7 @@ public struct ChildOf { }
public struct ParentOf { }
public struct OwnedBy { }
public struct MemberOf { }
public struct Thing { }
// ── Tests ────────────────────────────────────────────────────────────
@ -153,20 +154,20 @@ public class RelationshipTests
var owner = world.CreateEntity();
var group = world.CreateEntity();
world.AddComponent(entity, new Relationship<OwnedBy, object>
world.AddComponent(entity, new Relationship<OwnedBy, Thing>
{
Source = entity, Target = owner
});
world.AddComponent(entity, new Relationship<MemberOf, object>
world.AddComponent(entity, new Relationship<MemberOf, Thing>
{
Source = entity, Target = group
});
world.GetSources<Relationship<OwnedBy, object>>(owner).Should().BeEquivalentTo([entity]);
world.GetSources<Relationship<MemberOf, object>>(group).Should().BeEquivalentTo([entity]);
world.GetSources<Relationship<OwnedBy, Thing>>(owner).Should().BeEquivalentTo([entity]);
world.GetSources<Relationship<MemberOf, Thing>>(group).Should().BeEquivalentTo([entity]);
// Cross-check: OwnedBy sources should not appear in MemberOf lookups.
world.GetSources<Relationship<MemberOf, object>>(owner).Should().BeEmpty();
world.GetSources<Relationship<MemberOf, Thing>>(owner).Should().BeEmpty();
}
[Fact]

View File

@ -148,12 +148,9 @@ public class SingletonTests
{
private readonly World _world;
public QueryDescriptor Query { get; }
public TimeSystem(World world)
{
_world = world;
Query = world.Query().With<GameConfig>().Build();
}
public void Run(World world) => Run(world, Tick.Logical());