Compare commits

..

No commits in common. "5594515a53fcc0142fc7b39f7d9c286ab3c0e357" and "339e850e10e609cfa195025680a04876c98b9a0e" have entirely different histories.

54 changed files with 161 additions and 2004 deletions

1
.gitignore vendored
View File

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

View File

@ -1,21 +0,0 @@
<!--
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,84 +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.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; } // lower 24-bit identifier
public uint Version { get; } // upper 8-bit generation
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);
@ -37,9 +37,6 @@ namespace OECS;
public class World : IDisposable
{
// --- Singleton entity ---
public static Entity SingletonEntity { get; } // ID=1, Version=1
// --- Lifecycle ---
public World();
@ -52,14 +49,11 @@ 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;
@ -69,19 +63,12 @@ public class World : IDisposable
// --- Query Execution ---
public void ForEach<T1>(
QueryDescriptor query,
ForEachAction<T1> action) where T1 : struct;
Action<Entity, ref T1> action) where T1 : struct;
// Overloads for 26 component types:
public void ForEach<T1, T2>(QueryDescriptor, ForEachAction<T1, T2>)
public void ForEach<T1, T2>(QueryDescriptor, Action<Entity, ref T1, ref T2>)
where T1 : struct where T2 : struct;
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;
// ... up to T6
// --- Commands ---
public CommandQueue Commands { get; }
@ -91,10 +78,10 @@ public class World : IDisposable
public void MarkModified<T>(Entity entity) where T : struct;
public void PostChanges();
public Observable<EntityChange> ObserveEntityChanges();
public Observable<EntityChange> ObserveComponentChanges<T>()
public IObservable<EntityChange> ObserveEntityChanges();
public IObservable<EntityChange> ObserveComponentChanges<T>()
where T : struct;
public Observable<EntityChange> ObserveQuery(QueryDescriptor query);
public IObservable<EntityChange> ObserveQuery(QueryDescriptor query);
// --- Relationships ---
public IReadOnlyCollection<Entity> GetSources<T>(Entity target)
@ -107,70 +94,6 @@ 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
@ -257,7 +180,6 @@ namespace OECS;
public class SystemGroup
{
public SystemGroup(World world);
public void Add(ISystem system);
public void Remove(ISystem system);
public void RunTimed(float deltaTime);
@ -290,8 +212,6 @@ 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; }
}
@ -313,31 +233,12 @@ 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 : IEquatable<EntityChange>
public readonly struct EntityChange
{
public Entity Entity { get; }
public ChangeKind Kind { get; }
@ -356,56 +257,6 @@ 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
@ -418,19 +269,13 @@ public struct Position : IMessagePackSerializationCallbackReceiver
{
[Key(0)] public float X;
[Key(1)] public float Y;
public void OnBeforeSerialize() { }
public void OnAfterDeserialize() { }
}
[MessagePackObject]
public struct Velocity : IMessagePackSerializationCallbackReceiver
public struct Velocity
{
[Key(0)] public float X;
[Key(1)] public float Y;
public void OnBeforeSerialize() { }
public void OnAfterDeserialize() { }
}
// Define a system
@ -446,8 +291,6 @@ 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;
@ -463,7 +306,7 @@ public class MovementSystem : ITickedSystem
// Wire it up
var world = new World();
var group = new SystemGroup(world);
var group = new SystemGroup();
group.Add(new MovementSystem(world));
// Observe changes
@ -491,7 +334,5 @@ 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`. Lower 24 bits are the
ID, upper 8 bits are the version.
**Decision:** `readonly struct Entity` wrapping a `uint`. Upper 24 bits are the
ID, lower 8 bits are the version.
**Rationale:**

View File

@ -1,20 +0,0 @@
<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

@ -1,58 +0,0 @@
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

@ -1,53 +0,0 @@
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

@ -1,29 +0,0 @@
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

@ -1,22 +0,0 @@
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

@ -1,14 +0,0 @@
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

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

View File

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

View File

@ -1,15 +0,0 @@
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

@ -1,15 +0,0 @@
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

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

View File

@ -1,18 +0,0 @@
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

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

View File

@ -1,28 +0,0 @@
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

@ -1,114 +0,0 @@
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

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

View File

@ -1,17 +0,0 @@
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

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

View File

@ -1,31 +0,0 @@
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

@ -1,54 +0,0 @@
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

@ -1,87 +0,0 @@
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

@ -1,72 +0,0 @@
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

@ -1,26 +0,0 @@
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

@ -1,148 +0,0 @@
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());
group.Add(new RenderSystem());
group.Add(new WinCheckSystem(world));
group.Add(new RenderSystem(world));
// ── Game loop ─────────────────────────────────────────────────

View File

@ -7,6 +7,13 @@ 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>();
@ -18,7 +25,8 @@ public class RenderSystem : ISystem
grid[r, c] = '.';
// Fill in placed marks using the iterator API.
using var markIter = world.Select<Cell, Mark>();
var markQuery = world.Query().With<Cell>().With<Mark>().Build();
using var markIter = world.Select<Cell, Mark>(markQuery);
while (markIter.MoveNext())
{
grid[markIter.Current1.Row, markIter.Current1.Col] =

View File

@ -8,19 +8,24 @@ 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)
{
// 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>();
if (state.Status != GameStatus.Playing)
return;
// Build a 3×3 grid of marks using the iterator API.
var grid = new Player[3, 3];
using var iter = world.Select<Cell, Mark>();
using var iter = world.Select<Cell, Mark>(Query);
while (iter.MoveNext())
{
grid[iter.Current1.Row, iter.Current1.Col] = iter.Current2.Player;

View File

@ -12,11 +12,6 @@
<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>

View File

@ -1,9 +0,0 @@
<?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

@ -1,227 +0,0 @@
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

@ -1,25 +0,0 @@
<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,18 +14,8 @@ internal class ChangeBuffer
private readonly ChangeSet _pending = new();
private readonly Subject<EntityChange> _entitySubject = 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;
}
private readonly Dictionary<Type, Subject<EntityChange>> _componentSubjects = new();
private readonly Dictionary<QueryDescriptor, Subject<EntityChange>> _querySubjects = new();
/// <summary>
/// The change set currently accumulating. Cleared after each <see cref="Post"/>.
@ -50,18 +40,18 @@ internal class ChangeBuffer
// Push to component-specific subjects.
if (change.ComponentType != null)
{
if (_componentSubjects.TryGetValue(change.ComponentType, out var compTracked))
if (_componentSubjects.TryGetValue(change.ComponentType, out var compSubject))
{
compTracked.Subject.OnNext(change);
compSubject.OnNext(change);
}
}
// Push to matching query subjects.
foreach (var (query, tracked) in _querySubjects)
foreach (var (query, subject) in _querySubjects)
{
if (ChangeMatchesQuery(change, query))
{
tracked.Subject.OnNext(change);
subject.OnNext(change);
}
}
}
@ -82,22 +72,12 @@ internal class ChangeBuffer
/// </summary>
public Observable<EntityChange> ObserveComponentChanges(Type componentType)
{
if (!_componentSubjects.TryGetValue(componentType, out var tracked))
if (!_componentSubjects.TryGetValue(componentType, out var subject))
{
tracked = new TrackedSubject();
_componentSubjects[componentType] = tracked;
subject = new Subject<EntityChange>();
_componentSubjects[componentType] = subject;
}
tracked.SubscriberCount++;
return WrapWithCleanup(tracked.Subject, () =>
{
tracked.SubscriberCount--;
if (tracked.SubscriberCount <= 0)
{
tracked.Subject.Dispose();
_componentSubjects.Remove(componentType);
}
});
return subject;
}
/// <summary>
@ -105,40 +85,12 @@ internal class ChangeBuffer
/// </summary>
public Observable<EntityChange> ObserveQuery(QueryDescriptor query)
{
if (!_querySubjects.TryGetValue(query, out var tracked))
if (!_querySubjects.TryGetValue(query, out var subject))
{
tracked = new TrackedSubject();
_querySubjects[query] = tracked;
subject = new Subject<EntityChange>();
_querySubjects[query] = 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();
});
});
return subject;
}
/// <summary>
@ -147,14 +99,14 @@ internal class ChangeBuffer
public void Dispose()
{
_entitySubject.Dispose();
foreach (var tracked in _componentSubjects.Values)
foreach (var subject in _componentSubjects.Values)
{
tracked.Subject.Dispose();
subject.Dispose();
}
_componentSubjects.Clear();
foreach (var tracked in _querySubjects.Values)
foreach (var subject in _querySubjects.Values)
{
tracked.Subject.Dispose();
subject.Dispose();
}
_querySubjects.Clear();
}

View File

@ -4,14 +4,13 @@ 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. 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.
/// multiple times, only one entry is kept. Entity-level changes (Added/Removed)
/// take precedence over component-level changes for the same entity.
/// </summary>
internal class ChangeSet
{
private readonly List<EntityChange> _changes = new();
private readonly Dictionary<(Entity Entity, ChangeKind Kind, Type? ComponentType), int> _dedup = new();
private readonly HashSet<(Entity Entity, ChangeKind Kind, Type? ComponentType)> _dedup = new();
/// <summary>
/// All accumulated changes in insertion order.
@ -76,31 +75,9 @@ internal class ChangeSet
private void TryAdd(EntityChange change)
{
var key = (change.Entity, change.Kind, change.ComponentType);
if (_dedup.ContainsKey(key))
if (_dedup.Add(key))
{
// Same (entity, kind, componentType) already recorded — deduplicate.
return;
_changes.Add(change);
}
// 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

@ -1,42 +0,0 @@
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

@ -1,51 +0,0 @@
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,17 +20,6 @@ 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.
@ -42,18 +31,6 @@ 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.
@ -64,18 +41,6 @@ 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,10 +2,15 @@ namespace OECS;
/// <summary>
/// A system that runs on every tick.
/// Systems receive the world in their <see cref="Run"/> method.
/// Systems declare a query and 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,20 +23,4 @@
<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,53 +129,19 @@ 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;
var dense = set1.Dense;
var denseEntities = set1.DenseEntities;
var count = set1.Count;
if (c2 <= c1 && c2 <= c3)
for (int i = 0; i < count; i++)
{
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;
action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity));
}
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;
action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity));
}
}
@ -194,73 +160,20 @@ 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));
var dense = set1.Dense;
var denseEntities = set1.DenseEntities;
var count = set1.Count;
if (min == c2)
for (int i = 0; i < count; i++)
{
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];
if (entity.Id == SingletonId) 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 dense[i], ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity));
}
var entity = denseEntities[i];
if (entity.Id == SingletonId) 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 dense[i], ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity));
}
}
@ -281,94 +194,21 @@ 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);
var dense = set1.Dense;
var denseEntities = set1.DenseEntities;
var count = set1.Count;
if (min == c2)
for (int i = 0; i < count; i++)
{
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];
if (entity.Id == SingletonId) 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 dense[i], ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity));
}
var entity = denseEntities[i];
if (entity.Id == SingletonId) 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 dense[i], ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity));
}
}
@ -391,117 +231,22 @@ 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));
var dense = set1.Dense;
var denseEntities = set1.DenseEntities;
var count = set1.Count;
if (min == c2)
for (int i = 0; i < count; i++)
{
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];
if (entity.Id == SingletonId) continue;
if (!set2.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 dense[i], ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity), ref set6.Get(entity));
}
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set2.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 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,7 +25,6 @@ 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,7 +180,6 @@ internal class SparseSet<T> : ISparseSet where T : struct
{
_sparse[_denseEntities[i].Id] = -1;
}
Array.Clear(_denseEntities, 0, _count);
_count = 0;
}

View File

@ -188,17 +188,6 @@ 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,10 +5,9 @@ namespace OECS;
/// <summary>
/// Saves and loads a <see cref="World"/> to/from a MessagePack stream.
///
/// 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.
/// 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.
/// </summary>
public static class WorldSerializer
{
@ -19,9 +18,9 @@ public static class WorldSerializer
{
var entityComponents = new Dictionary<Entity, List<ComponentEntry>>();
foreach (var desc in ComponentRegistry.Descriptors)
foreach (var ct in world.Components.ComponentTypes)
{
var set = world.Components.GetSet(desc.Type);
var set = world.Components.GetSet(ct);
if (set == null || set.Count == 0) continue;
var entities = set.GetDenseEntities();
@ -38,8 +37,8 @@ public static class WorldSerializer
list.Add(new ComponentEntry
{
TypeName = desc.TypeName,
Data = desc.Serialize(component)
TypeName = ct.AssemblyQualifiedName!,
Data = MessagePackSerializer.Serialize(ct, component)
});
}
}
@ -68,11 +67,6 @@ 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);
@ -87,14 +81,18 @@ public static class WorldSerializer
foreach (var ce in es.Components)
{
if (!lookup.TryGetValue(ce.TypeName, out var desc))
throw new InvalidOperationException(
var type = Type.GetType(ce.TypeName)
?? throw new InvalidOperationException(
$"Unknown component type '{ce.TypeName}'. " +
$"Ensure the component type is used with the World " +
$"API so the source generator can discover it.");
$"Ensure the assembly is loaded.");
// Deserialize and add in one typed operation — no reflection.
desc.DeserializeAndAdd(world, entity, ce.Data);
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]);
}
}
}

View File

@ -249,10 +249,13 @@ 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)
@ -263,17 +266,18 @@ 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,9 +23,4 @@
<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
{
private readonly QueryDescriptor _query;
public QueryDescriptor Query { get; }
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,11 +232,13 @@ 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());
@ -270,10 +272,13 @@ 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,9 +268,12 @@ 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,7 +9,6 @@ public struct ChildOf { }
public struct ParentOf { }
public struct OwnedBy { }
public struct MemberOf { }
public struct Thing { }
// ── Tests ────────────────────────────────────────────────────────────
@ -154,20 +153,20 @@ public class RelationshipTests
var owner = world.CreateEntity();
var group = world.CreateEntity();
world.AddComponent(entity, new Relationship<OwnedBy, Thing>
world.AddComponent(entity, new Relationship<OwnedBy, object>
{
Source = entity, Target = owner
});
world.AddComponent(entity, new Relationship<MemberOf, Thing>
world.AddComponent(entity, new Relationship<MemberOf, object>
{
Source = entity, Target = group
});
world.GetSources<Relationship<OwnedBy, Thing>>(owner).Should().BeEquivalentTo([entity]);
world.GetSources<Relationship<MemberOf, Thing>>(group).Should().BeEquivalentTo([entity]);
world.GetSources<Relationship<OwnedBy, object>>(owner).Should().BeEquivalentTo([entity]);
world.GetSources<Relationship<MemberOf, object>>(group).Should().BeEquivalentTo([entity]);
// Cross-check: OwnedBy sources should not appear in MemberOf lookups.
world.GetSources<Relationship<MemberOf, Thing>>(owner).Should().BeEmpty();
world.GetSources<Relationship<MemberOf, object>>(owner).Should().BeEmpty();
}
[Fact]

View File

@ -148,9 +148,12 @@ 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());