Compare commits
10 Commits
339e850e10
...
5594515a53
| Author | SHA1 | Date |
|---|---|---|
|
|
5594515a53 | |
|
|
1da0ca30d3 | |
|
|
96ba037432 | |
|
|
713d578179 | |
|
|
b551a67915 | |
|
|
df388ee9b3 | |
|
|
ac7cd80a6f | |
|
|
b6e64d2871 | |
|
|
58477aec50 | |
|
|
32f4aa3e35 |
|
|
@ -15,6 +15,7 @@ x86/
|
||||||
!**/[Pp]ackages/build/
|
!**/[Pp]ackages/build/
|
||||||
*.nuget.props
|
*.nuget.props
|
||||||
*.nuget.targets
|
*.nuget.targets
|
||||||
|
nupkgs/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.vs/
|
.vs/
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
61
OECS.sln
61
OECS.sln
|
|
@ -1,25 +1,84 @@
|
||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
# Visual Studio Version 17
|
# Visual Studio Version 17
|
||||||
VisualStudioVersion = 17.0.31903.59
|
VisualStudioVersion = 17.0.31903.59
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
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}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS", "src\OECS\OECS.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS.Tests", "tests\OECS.Tests\OECS.Tests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS.Tests", "tests\OECS.Tests\OECS.Tests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
|
||||||
EndProject
|
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
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Debug|x64 = Debug|x64
|
||||||
|
Debug|x86 = Debug|x86
|
||||||
Release|Any CPU = Release|Any CPU
|
Release|Any CPU = Release|Any CPU
|
||||||
|
Release|x64 = Release|x64
|
||||||
|
Release|x86 = Release|x86
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
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.ActiveCfg = Debug|Any CPU
|
||||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = 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
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ namespace OECS;
|
||||||
public readonly struct Entity : IEquatable<Entity>
|
public readonly struct Entity : IEquatable<Entity>
|
||||||
{
|
{
|
||||||
public static Entity Null { get; } // ID=0, Version=0
|
public static Entity Null { get; } // ID=0, Version=0
|
||||||
public uint Id { get; } // 24-bit identifier
|
public uint Id { get; } // lower 24-bit identifier
|
||||||
public uint Version { get; } // 8-bit generation
|
public uint Version { get; } // upper 8-bit generation
|
||||||
public bool IsNull { get; } // true for Entity.Null
|
public bool IsNull { get; } // true for Entity.Null
|
||||||
|
|
||||||
public bool Equals(Entity other);
|
public bool Equals(Entity other);
|
||||||
|
|
@ -37,6 +37,9 @@ namespace OECS;
|
||||||
|
|
||||||
public class World : IDisposable
|
public class World : IDisposable
|
||||||
{
|
{
|
||||||
|
// --- Singleton entity ---
|
||||||
|
public static Entity SingletonEntity { get; } // ID=1, Version=1
|
||||||
|
|
||||||
// --- Lifecycle ---
|
// --- Lifecycle ---
|
||||||
public World();
|
public World();
|
||||||
|
|
||||||
|
|
@ -49,11 +52,14 @@ public class World : IDisposable
|
||||||
public void AddComponent<T>(Entity entity, T component) where T : struct;
|
public void AddComponent<T>(Entity entity, T component) where T : struct;
|
||||||
public void RemoveComponent<T>(Entity entity) where T : struct;
|
public void RemoveComponent<T>(Entity entity) where T : struct;
|
||||||
public ref T GetComponent<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;
|
public bool HasComponent<T>(Entity entity) where T : struct;
|
||||||
|
|
||||||
// --- Singleton ---
|
// --- Singleton ---
|
||||||
public void SetSingleton<T>(T component) where T : struct;
|
public void SetSingleton<T>(T component) where T : struct;
|
||||||
public ref T GetSingleton<T>() 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 bool HasSingleton<T>() where T : struct;
|
||||||
public void RemoveSingleton<T>() where T : struct;
|
public void RemoveSingleton<T>() where T : struct;
|
||||||
|
|
||||||
|
|
@ -63,12 +69,19 @@ public class World : IDisposable
|
||||||
// --- Query Execution ---
|
// --- Query Execution ---
|
||||||
public void ForEach<T1>(
|
public void ForEach<T1>(
|
||||||
QueryDescriptor query,
|
QueryDescriptor query,
|
||||||
Action<Entity, ref T1> action) where T1 : struct;
|
ForEachAction<T1> action) where T1 : struct;
|
||||||
|
|
||||||
// Overloads for 2–6 component types:
|
// Overloads for 2–6 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;
|
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 ---
|
// --- Commands ---
|
||||||
public CommandQueue Commands { get; }
|
public CommandQueue Commands { get; }
|
||||||
|
|
@ -78,10 +91,10 @@ public class World : IDisposable
|
||||||
public void MarkModified<T>(Entity entity) where T : struct;
|
public void MarkModified<T>(Entity entity) where T : struct;
|
||||||
public void PostChanges();
|
public void PostChanges();
|
||||||
|
|
||||||
public IObservable<EntityChange> ObserveEntityChanges();
|
public Observable<EntityChange> ObserveEntityChanges();
|
||||||
public IObservable<EntityChange> ObserveComponentChanges<T>()
|
public Observable<EntityChange> ObserveComponentChanges<T>()
|
||||||
where T : struct;
|
where T : struct;
|
||||||
public IObservable<EntityChange> ObserveQuery(QueryDescriptor query);
|
public Observable<EntityChange> ObserveQuery(QueryDescriptor query);
|
||||||
|
|
||||||
// --- Relationships ---
|
// --- Relationships ---
|
||||||
public IReadOnlyCollection<Entity> GetSources<T>(Entity target)
|
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 1–3 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
|
## QueryBuilder
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
|
|
@ -180,6 +257,7 @@ namespace OECS;
|
||||||
|
|
||||||
public class SystemGroup
|
public class SystemGroup
|
||||||
{
|
{
|
||||||
|
public SystemGroup(World world);
|
||||||
public void Add(ISystem system);
|
public void Add(ISystem system);
|
||||||
public void Remove(ISystem system);
|
public void Remove(ISystem system);
|
||||||
public void RunTimed(float deltaTime);
|
public void RunTimed(float deltaTime);
|
||||||
|
|
@ -212,6 +290,8 @@ public class CommandQueue
|
||||||
{
|
{
|
||||||
public void Enqueue<T>(T command) where T : struct, ICommand;
|
public void Enqueue<T>(T command) where T : struct, ICommand;
|
||||||
public void ExecuteAll(World world);
|
public void ExecuteAll(World world);
|
||||||
|
public void Clear();
|
||||||
|
public void ClearErrors();
|
||||||
public int Count { get; }
|
public int Count { get; }
|
||||||
public IReadOnlyList<Exception> Errors { 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
|
## EntityChange
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
namespace OECS;
|
namespace OECS;
|
||||||
|
|
||||||
public readonly struct EntityChange
|
public readonly struct EntityChange : IEquatable<EntityChange>
|
||||||
{
|
{
|
||||||
public Entity Entity { get; }
|
public Entity Entity { get; }
|
||||||
public ChangeKind Kind { 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
|
## Usage Example
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
|
|
@ -269,13 +418,19 @@ public struct Position : IMessagePackSerializationCallbackReceiver
|
||||||
{
|
{
|
||||||
[Key(0)] public float X;
|
[Key(0)] public float X;
|
||||||
[Key(1)] public float Y;
|
[Key(1)] public float Y;
|
||||||
|
|
||||||
|
public void OnBeforeSerialize() { }
|
||||||
|
public void OnAfterDeserialize() { }
|
||||||
}
|
}
|
||||||
|
|
||||||
[MessagePackObject]
|
[MessagePackObject]
|
||||||
public struct Velocity
|
public struct Velocity : IMessagePackSerializationCallbackReceiver
|
||||||
{
|
{
|
||||||
[Key(0)] public float X;
|
[Key(0)] public float X;
|
||||||
[Key(1)] public float Y;
|
[Key(1)] public float Y;
|
||||||
|
|
||||||
|
public void OnBeforeSerialize() { }
|
||||||
|
public void OnAfterDeserialize() { }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define a system
|
// Define a system
|
||||||
|
|
@ -291,6 +446,8 @@ public class MovementSystem : ITickedSystem
|
||||||
.Build();
|
.Build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Run(World world) => Run(world, Tick.Logical());
|
||||||
|
|
||||||
public void Run(World world, Tick tick)
|
public void Run(World world, Tick tick)
|
||||||
{
|
{
|
||||||
float dt = tick.DeltaTime;
|
float dt = tick.DeltaTime;
|
||||||
|
|
@ -306,7 +463,7 @@ public class MovementSystem : ITickedSystem
|
||||||
|
|
||||||
// Wire it up
|
// Wire it up
|
||||||
var world = new World();
|
var world = new World();
|
||||||
var group = new SystemGroup();
|
var group = new SystemGroup(world);
|
||||||
group.Add(new MovementSystem(world));
|
group.Add(new MovementSystem(world));
|
||||||
|
|
||||||
// Observe changes
|
// 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. |
|
| `SparseSet<T>` | Dense/sparse array pair for component storage. |
|
||||||
| `ComponentStore` | Registry of `SparseSet<T>` instances by type. |
|
| `ComponentStore` | Registry of `SparseSet<T>` instances by type. |
|
||||||
| `ChangeBuffer` | Accumulates `EntityChange` during system run, posts to R3 subjects. |
|
| `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. |
|
| `RelationshipIndex` | Reverse lookup from target entity to source entities. |
|
||||||
| `EntityAllocator` | Free-list + bump allocator for entity IDs. |
|
| `EntityAllocator` | Free-list + bump allocator for entity IDs. |
|
||||||
|
| `QueryExecutor` | Query iteration logic with smallest-set driver optimization. |
|
||||||
|
|
@ -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
|
**Context:** Entities need to be cheap to copy, comparable, and safe against
|
||||||
use-after-free (accessing a recycled entity ID).
|
use-after-free (accessing a recycled entity ID).
|
||||||
|
|
||||||
**Decision:** `readonly struct Entity` wrapping a `uint`. Upper 24 bits are the
|
**Decision:** `readonly struct Entity` wrapping a `uint`. Lower 24 bits are the
|
||||||
ID, lower 8 bits are the version.
|
ID, upper 8 bits are the version.
|
||||||
|
|
||||||
**Rationale:**
|
**Rationale:**
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
using MessagePack;
|
||||||
|
|
||||||
|
namespace Blackjack;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tag component marking the dealer's hand entity.
|
||||||
|
/// </summary>
|
||||||
|
[MessagePackObject]
|
||||||
|
public struct DealerHand { }
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
using MessagePack;
|
||||||
|
|
||||||
|
namespace Blackjack;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tag component marking the deck entity.
|
||||||
|
/// </summary>
|
||||||
|
[MessagePackObject]
|
||||||
|
public struct Deck { }
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
using MessagePack;
|
||||||
|
|
||||||
|
namespace Blackjack;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tag component marking the player's hand entity.
|
||||||
|
/// </summary>
|
||||||
|
[MessagePackObject]
|
||||||
|
public struct PlayerHand { }
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
namespace Blackjack;
|
||||||
|
|
||||||
|
public enum Suit : byte
|
||||||
|
{
|
||||||
|
Clubs = 0,
|
||||||
|
Diamonds = 1,
|
||||||
|
Hearts = 2,
|
||||||
|
Spades = 3
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
namespace Blackjack;
|
||||||
|
|
||||||
|
public enum GamePhase : byte
|
||||||
|
{
|
||||||
|
Betting = 0,
|
||||||
|
Dealing = 1,
|
||||||
|
PlayerTurn = 2,
|
||||||
|
DealerTurn = 3,
|
||||||
|
RoundOver = 4
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
namespace Blackjack;
|
||||||
|
|
||||||
|
public enum RoundResult : byte
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
PlayerBust = 1,
|
||||||
|
DealerBust = 2,
|
||||||
|
PlayerWin = 3,
|
||||||
|
DealerWin = 4,
|
||||||
|
Push = 5,
|
||||||
|
Blackjack = 6
|
||||||
|
}
|
||||||
|
|
@ -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(); }
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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!",
|
||||||
|
_ => ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -50,8 +50,8 @@ public static class Program
|
||||||
// ── Wire up systems ───────────────────────────────────────────
|
// ── Wire up systems ───────────────────────────────────────────
|
||||||
|
|
||||||
var group = new SystemGroup(world);
|
var group = new SystemGroup(world);
|
||||||
group.Add(new WinCheckSystem(world));
|
group.Add(new WinCheckSystem());
|
||||||
group.Add(new RenderSystem(world));
|
group.Add(new RenderSystem());
|
||||||
|
|
||||||
// ── Game loop ─────────────────────────────────────────────────
|
// ── Game loop ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,6 @@ namespace TicTacToe;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class RenderSystem : ISystem
|
public class RenderSystem : ISystem
|
||||||
{
|
{
|
||||||
public QueryDescriptor Query { get; }
|
|
||||||
|
|
||||||
public RenderSystem(World world)
|
|
||||||
{
|
|
||||||
Query = world.Query().With<Cell>().Build();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Run(World world)
|
public void Run(World world)
|
||||||
{
|
{
|
||||||
var state = world.ReadSingleton<GameState>();
|
var state = world.ReadSingleton<GameState>();
|
||||||
|
|
@ -25,8 +18,7 @@ public class RenderSystem : ISystem
|
||||||
grid[r, c] = '.';
|
grid[r, c] = '.';
|
||||||
|
|
||||||
// Fill in placed marks using the iterator API.
|
// Fill in placed marks using the iterator API.
|
||||||
var markQuery = world.Query().With<Cell>().With<Mark>().Build();
|
using var markIter = world.Select<Cell, Mark>();
|
||||||
using var markIter = world.Select<Cell, Mark>(markQuery);
|
|
||||||
while (markIter.MoveNext())
|
while (markIter.MoveNext())
|
||||||
{
|
{
|
||||||
grid[markIter.Current1.Row, markIter.Current1.Col] =
|
grid[markIter.Current1.Row, markIter.Current1.Col] =
|
||||||
|
|
@ -66,4 +58,4 @@ public class RenderSystem : ISystem
|
||||||
Console.WriteLine();
|
Console.WriteLine();
|
||||||
Console.Write(" Enter row col (e.g. \"1 2\"): ");
|
Console.Write(" Enter row col (e.g. \"1 2\"): ");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -8,24 +8,19 @@ namespace TicTacToe;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class WinCheckSystem : ISystem
|
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)
|
public void Run(World world)
|
||||||
{
|
{
|
||||||
ref var state = ref world.GetSingleton<GameState>();
|
// Check game status before doing any work. Use ReadSingleton
|
||||||
|
// to avoid auto-marking GameState as modified when we bail early.
|
||||||
if (state.Status != GameStatus.Playing)
|
if (world.ReadSingleton<GameState>().Status != GameStatus.Playing)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
ref var state = ref world.GetSingleton<GameState>();
|
||||||
|
|
||||||
// Build a 3×3 grid of marks using the iterator API.
|
// Build a 3×3 grid of marks using the iterator API.
|
||||||
var grid = new Player[3, 3];
|
var grid = new Player[3, 3];
|
||||||
|
|
||||||
using var iter = world.Select<Cell, Mark>(Query);
|
using var iter = world.Select<Cell, Mark>();
|
||||||
while (iter.MoveNext())
|
while (iter.MoveNext())
|
||||||
{
|
{
|
||||||
grid[iter.Current1.Row, iter.Current1.Col] = iter.Current2.Player;
|
grid[iter.Current1.Row, iter.Current1.Col] = iter.Current2.Player;
|
||||||
|
|
@ -87,4 +82,4 @@ public class WinCheckSystem : ISystem
|
||||||
state.Status = winner == Player.X ? GameStatus.XWon : GameStatus.OWon;
|
state.Status = winner == Player.X ? GameStatus.XWon : GameStatus.OWon;
|
||||||
world.MarkModified<GameState>(World.SingletonEntity);
|
world.MarkModified<GameState>(World.SingletonEntity);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -12,10 +12,15 @@
|
||||||
<ProjectReference Include="..\..\src\OECS\OECS.csproj" />
|
<ProjectReference Include="..\..\src\OECS\OECS.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\OECS.SourceGen\OECS.SourceGen.csproj"
|
||||||
|
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Update="Data\board.csv">
|
<None Update="Data\board.csv">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</None>
|
</None>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -14,8 +14,18 @@ internal class ChangeBuffer
|
||||||
private readonly ChangeSet _pending = new();
|
private readonly ChangeSet _pending = new();
|
||||||
|
|
||||||
private readonly Subject<EntityChange> _entitySubject = new();
|
private readonly Subject<EntityChange> _entitySubject = new();
|
||||||
private readonly Dictionary<Type, Subject<EntityChange>> _componentSubjects = new();
|
private readonly Dictionary<Type, TrackedSubject> _componentSubjects = new();
|
||||||
private readonly Dictionary<QueryDescriptor, Subject<EntityChange>> _querySubjects = 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>
|
/// <summary>
|
||||||
/// The change set currently accumulating. Cleared after each <see cref="Post"/>.
|
/// The change set currently accumulating. Cleared after each <see cref="Post"/>.
|
||||||
|
|
@ -40,18 +50,18 @@ internal class ChangeBuffer
|
||||||
// Push to component-specific subjects.
|
// Push to component-specific subjects.
|
||||||
if (change.ComponentType != null)
|
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.
|
// Push to matching query subjects.
|
||||||
foreach (var (query, subject) in _querySubjects)
|
foreach (var (query, tracked) in _querySubjects)
|
||||||
{
|
{
|
||||||
if (ChangeMatchesQuery(change, query))
|
if (ChangeMatchesQuery(change, query))
|
||||||
{
|
{
|
||||||
subject.OnNext(change);
|
tracked.Subject.OnNext(change);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -72,12 +82,22 @@ internal class ChangeBuffer
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Observable<EntityChange> ObserveComponentChanges(Type componentType)
|
public Observable<EntityChange> ObserveComponentChanges(Type componentType)
|
||||||
{
|
{
|
||||||
if (!_componentSubjects.TryGetValue(componentType, out var subject))
|
if (!_componentSubjects.TryGetValue(componentType, out var tracked))
|
||||||
{
|
{
|
||||||
subject = new Subject<EntityChange>();
|
tracked = new TrackedSubject();
|
||||||
_componentSubjects[componentType] = subject;
|
_componentSubjects[componentType] = tracked;
|
||||||
}
|
}
|
||||||
return subject;
|
|
||||||
|
tracked.SubscriberCount++;
|
||||||
|
return WrapWithCleanup(tracked.Subject, () =>
|
||||||
|
{
|
||||||
|
tracked.SubscriberCount--;
|
||||||
|
if (tracked.SubscriberCount <= 0)
|
||||||
|
{
|
||||||
|
tracked.Subject.Dispose();
|
||||||
|
_componentSubjects.Remove(componentType);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -85,12 +105,40 @@ internal class ChangeBuffer
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Observable<EntityChange> ObserveQuery(QueryDescriptor query)
|
public Observable<EntityChange> ObserveQuery(QueryDescriptor query)
|
||||||
{
|
{
|
||||||
if (!_querySubjects.TryGetValue(query, out var subject))
|
if (!_querySubjects.TryGetValue(query, out var tracked))
|
||||||
{
|
{
|
||||||
subject = new Subject<EntityChange>();
|
tracked = new TrackedSubject();
|
||||||
_querySubjects[query] = subject;
|
_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>
|
/// <summary>
|
||||||
|
|
@ -99,14 +147,14 @@ internal class ChangeBuffer
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_entitySubject.Dispose();
|
_entitySubject.Dispose();
|
||||||
foreach (var subject in _componentSubjects.Values)
|
foreach (var tracked in _componentSubjects.Values)
|
||||||
{
|
{
|
||||||
subject.Dispose();
|
tracked.Subject.Dispose();
|
||||||
}
|
}
|
||||||
_componentSubjects.Clear();
|
_componentSubjects.Clear();
|
||||||
foreach (var subject in _querySubjects.Values)
|
foreach (var tracked in _querySubjects.Values)
|
||||||
{
|
{
|
||||||
subject.Dispose();
|
tracked.Subject.Dispose();
|
||||||
}
|
}
|
||||||
_querySubjects.Clear();
|
_querySubjects.Clear();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,14 @@ namespace OECS;
|
||||||
/// Accumulates <see cref="EntityChange"/> entries during a system run.
|
/// Accumulates <see cref="EntityChange"/> entries during a system run.
|
||||||
///
|
///
|
||||||
/// Deduplication: if the same (entity, kind, componentType) change is marked
|
/// Deduplication: if the same (entity, kind, componentType) change is marked
|
||||||
/// multiple times, only one entry is kept. Entity-level changes (Added/Removed)
|
/// multiple times, only one entry is kept. However, if a structurally opposed
|
||||||
/// take precedence over component-level changes for the same entity.
|
/// change arrives (Added vs Removed) for the same (entity, componentType),
|
||||||
|
/// the old entry is removed so observers see the full state transition.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal class ChangeSet
|
internal class ChangeSet
|
||||||
{
|
{
|
||||||
private readonly List<EntityChange> _changes = new();
|
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>
|
/// <summary>
|
||||||
/// All accumulated changes in insertion order.
|
/// All accumulated changes in insertion order.
|
||||||
|
|
@ -75,9 +76,31 @@ internal class ChangeSet
|
||||||
private void TryAdd(EntityChange change)
|
private void TryAdd(EntityChange change)
|
||||||
{
|
{
|
||||||
var key = (change.Entity, change.Kind, change.ComponentType);
|
var key = (change.Entity, change.Kind, change.ComponentType);
|
||||||
if (_dedup.Add(key))
|
if (_dedup.ContainsKey(key))
|
||||||
{
|
{
|
||||||
_changes.Add(change);
|
// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,17 @@ public static class EntityIterator
|
||||||
return new Select1<T1>(world, query);
|
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>
|
/// <summary>
|
||||||
/// Returns an iterator over entities matching the query, with ref access
|
/// Returns an iterator over entities matching the query, with ref access
|
||||||
/// to two component types.
|
/// to two component types.
|
||||||
|
|
@ -31,6 +42,18 @@ public static class EntityIterator
|
||||||
return new Select2<T1, T2>(world, query);
|
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>
|
/// <summary>
|
||||||
/// Returns an iterator over entities matching the query, with ref access
|
/// Returns an iterator over entities matching the query, with ref access
|
||||||
/// to three component types.
|
/// to three component types.
|
||||||
|
|
@ -41,6 +64,18 @@ public static class EntityIterator
|
||||||
{
|
{
|
||||||
return new Select3<T1, T2, T3>(world, query);
|
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>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,10 @@ namespace OECS;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A system that runs on every tick.
|
/// 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>
|
/// </summary>
|
||||||
public interface ISystem
|
public interface ISystem
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// The query that defines which entities this system operates on.
|
|
||||||
/// </summary>
|
|
||||||
QueryDescriptor Query { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Executes the system logic for this tick.
|
/// Executes the system logic for this tick.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -23,4 +23,20 @@
|
||||||
<PackageReference Include="R3" Version="1.2.9" />
|
<PackageReference Include="R3" Version="1.2.9" />
|
||||||
</ItemGroup>
|
</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>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -129,19 +129,53 @@ internal static class QueryExecutor
|
||||||
var set3 = store.GetSet(typeof(T3)) as SparseSet<T3>;
|
var set3 = store.GetSet(typeof(T3)) as SparseSet<T3>;
|
||||||
if (set3 == null) return;
|
if (set3 == null) return;
|
||||||
|
|
||||||
var dense = set1.Dense;
|
// Pick the smallest set as the driver.
|
||||||
var denseEntities = set1.DenseEntities;
|
int c1 = set1.Count, c2 = set2.Count, c3 = set3.Count;
|
||||||
var count = set1.Count;
|
|
||||||
|
|
||||||
for (int i = 0; i < count; i++)
|
if (c2 <= c1 && c2 <= c3)
|
||||||
{
|
{
|
||||||
var entity = denseEntities[i];
|
var dense = set2.Dense;
|
||||||
if (entity.Id == SingletonId) continue;
|
var denseEntities = set2.DenseEntities;
|
||||||
if (!set2.Contains(entity)) continue;
|
var count = set2.Count;
|
||||||
if (!set3.Contains(entity)) continue;
|
for (int i = 0; i < count; i++)
|
||||||
if (!PassesWithoutFilter(store, entity, query.Without))
|
{
|
||||||
continue;
|
var entity = denseEntities[i];
|
||||||
action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -160,20 +194,73 @@ internal static class QueryExecutor
|
||||||
var set4 = store.GetSet(typeof(T4)) as SparseSet<T4>;
|
var set4 = store.GetSet(typeof(T4)) as SparseSet<T4>;
|
||||||
if (set4 == null) return;
|
if (set4 == null) return;
|
||||||
|
|
||||||
var dense = set1.Dense;
|
// Pick the smallest set as the driver.
|
||||||
var denseEntities = set1.DenseEntities;
|
int c1 = set1.Count, c2 = set2.Count, c3 = set3.Count, c4 = set4.Count;
|
||||||
var count = set1.Count;
|
int min = Math.Min(Math.Min(c1, c2), Math.Min(c3, c4));
|
||||||
|
|
||||||
for (int i = 0; i < count; i++)
|
if (min == c2)
|
||||||
{
|
{
|
||||||
var entity = denseEntities[i];
|
var dense = set2.Dense;
|
||||||
if (entity.Id == SingletonId) continue;
|
var denseEntities = set2.DenseEntities;
|
||||||
if (!set2.Contains(entity)) continue;
|
var count = set2.Count;
|
||||||
if (!set3.Contains(entity)) continue;
|
for (int i = 0; i < count; i++)
|
||||||
if (!set4.Contains(entity)) continue;
|
{
|
||||||
if (!PassesWithoutFilter(store, entity, query.Without))
|
var entity = denseEntities[i];
|
||||||
continue;
|
if (entity.Id == SingletonId) continue;
|
||||||
action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -194,21 +281,94 @@ internal static class QueryExecutor
|
||||||
var set5 = store.GetSet(typeof(T5)) as SparseSet<T5>;
|
var set5 = store.GetSet(typeof(T5)) as SparseSet<T5>;
|
||||||
if (set5 == null) return;
|
if (set5 == null) return;
|
||||||
|
|
||||||
var dense = set1.Dense;
|
// Pick the smallest set as the driver.
|
||||||
var denseEntities = set1.DenseEntities;
|
int c1 = set1.Count, c2 = set2.Count, c3 = set3.Count, c4 = set4.Count, c5 = set5.Count;
|
||||||
var count = set1.Count;
|
int min = Math.Min(Math.Min(Math.Min(c1, c2), Math.Min(c3, c4)), c5);
|
||||||
|
|
||||||
for (int i = 0; i < count; i++)
|
if (min == c2)
|
||||||
{
|
{
|
||||||
var entity = denseEntities[i];
|
var dense = set2.Dense;
|
||||||
if (entity.Id == SingletonId) continue;
|
var denseEntities = set2.DenseEntities;
|
||||||
if (!set2.Contains(entity)) continue;
|
var count = set2.Count;
|
||||||
if (!set3.Contains(entity)) continue;
|
for (int i = 0; i < count; i++)
|
||||||
if (!set4.Contains(entity)) continue;
|
{
|
||||||
if (!set5.Contains(entity)) continue;
|
var entity = denseEntities[i];
|
||||||
if (!PassesWithoutFilter(store, entity, query.Without))
|
if (entity.Id == SingletonId) continue;
|
||||||
continue;
|
if (!set1.Contains(entity)) continue;
|
||||||
action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -231,22 +391,117 @@ internal static class QueryExecutor
|
||||||
var set6 = store.GetSet(typeof(T6)) as SparseSet<T6>;
|
var set6 = store.GetSet(typeof(T6)) as SparseSet<T6>;
|
||||||
if (set6 == null) return;
|
if (set6 == null) return;
|
||||||
|
|
||||||
var dense = set1.Dense;
|
// Pick the smallest set as the driver.
|
||||||
var denseEntities = set1.DenseEntities;
|
int c1 = set1.Count, c2 = set2.Count, c3 = set3.Count, c4 = set4.Count, c5 = set5.Count, c6 = set6.Count;
|
||||||
var count = set1.Count;
|
int min = Math.Min(Math.Min(Math.Min(c1, c2), Math.Min(c3, c4)), Math.Min(c5, c6));
|
||||||
|
|
||||||
for (int i = 0; i < count; i++)
|
if (min == c2)
|
||||||
{
|
{
|
||||||
var entity = denseEntities[i];
|
var dense = set2.Dense;
|
||||||
if (entity.Id == SingletonId) continue;
|
var denseEntities = set2.DenseEntities;
|
||||||
if (!set2.Contains(entity)) continue;
|
var count = set2.Count;
|
||||||
if (!set3.Contains(entity)) continue;
|
for (int i = 0; i < count; i++)
|
||||||
if (!set4.Contains(entity)) continue;
|
{
|
||||||
if (!set5.Contains(entity)) continue;
|
var entity = denseEntities[i];
|
||||||
if (!set6.Contains(entity)) continue;
|
if (entity.Id == SingletonId) continue;
|
||||||
if (!PassesWithoutFilter(store, entity, query.Without))
|
if (!set1.Contains(entity)) continue;
|
||||||
continue;
|
if (!set3.Contains(entity)) 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));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -25,6 +25,7 @@ namespace OECS;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[MessagePackObject]
|
[MessagePackObject]
|
||||||
public struct Relationship<TSelf, TTarget> : IRelationship
|
public struct Relationship<TSelf, TTarget> : IRelationship
|
||||||
|
where TSelf : struct where TTarget : struct
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The entity that owns this relationship component.
|
/// The entity that owns this relationship component.
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,7 @@ internal class SparseSet<T> : ISparseSet where T : struct
|
||||||
{
|
{
|
||||||
_sparse[_denseEntities[i].Id] = -1;
|
_sparse[_denseEntities[i].Id] = -1;
|
||||||
}
|
}
|
||||||
|
Array.Clear(_denseEntities, 0, _count);
|
||||||
_count = 0;
|
_count = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,17 @@ public class World : IDisposable
|
||||||
// If replacing an existing relationship, remove the old index entry first.
|
// If replacing an existing relationship, remove the old index entry first.
|
||||||
if (component is IRelationship newRel)
|
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))
|
if (_components.TryGet<T>(entity, out var old))
|
||||||
{
|
{
|
||||||
var oldRel = (IRelationship)(object)old;
|
var oldRel = (IRelationship)(object)old;
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,10 @@ namespace OECS;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Saves and loads a <see cref="World"/> to/from a MessagePack stream.
|
/// Saves and loads a <see cref="World"/> to/from a MessagePack stream.
|
||||||
///
|
///
|
||||||
/// Components are serialized by their runtime type. The caller must
|
/// Uses the compile-time generated <see cref="ComponentRegistry"/> to
|
||||||
/// ensure all component types used in the world are resolvable via
|
/// serialize and deserialize components without reflection. All component
|
||||||
/// <see cref="Type.GetType(string)"/> at load time.
|
/// types used with the World API are discovered by the OECS.SourceGen
|
||||||
|
/// incremental generator at build time.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class WorldSerializer
|
public static class WorldSerializer
|
||||||
{
|
{
|
||||||
|
|
@ -18,9 +19,9 @@ public static class WorldSerializer
|
||||||
{
|
{
|
||||||
var entityComponents = new Dictionary<Entity, List<ComponentEntry>>();
|
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;
|
if (set == null || set.Count == 0) continue;
|
||||||
|
|
||||||
var entities = set.GetDenseEntities();
|
var entities = set.GetDenseEntities();
|
||||||
|
|
@ -37,8 +38,8 @@ public static class WorldSerializer
|
||||||
|
|
||||||
list.Add(new ComponentEntry
|
list.Add(new ComponentEntry
|
||||||
{
|
{
|
||||||
TypeName = ct.AssemblyQualifiedName!,
|
TypeName = desc.TypeName,
|
||||||
Data = MessagePackSerializer.Serialize(ct, component)
|
Data = desc.Serialize(component)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -67,6 +68,11 @@ public static class WorldSerializer
|
||||||
{
|
{
|
||||||
var snapshot = MessagePackSerializer.Deserialize<WorldSnapshot>(stream);
|
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)
|
foreach (var es in snapshot.Entities)
|
||||||
{
|
{
|
||||||
var entity = world.CreateEntity(es.Entity);
|
var entity = world.CreateEntity(es.Entity);
|
||||||
|
|
@ -81,18 +87,14 @@ public static class WorldSerializer
|
||||||
|
|
||||||
foreach (var ce in es.Components)
|
foreach (var ce in es.Components)
|
||||||
{
|
{
|
||||||
var type = Type.GetType(ce.TypeName)
|
if (!lookup.TryGetValue(ce.TypeName, out var desc))
|
||||||
?? throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Unknown component type '{ce.TypeName}'. " +
|
$"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)
|
// Deserialize and add in one typed operation — no reflection.
|
||||||
?? throw new InvalidOperationException(
|
desc.DeserializeAndAdd(world, entity, ce.Data);
|
||||||
$"Failed to deserialize component of type '{ce.TypeName}'.");
|
|
||||||
|
|
||||||
var method = typeof(World).GetMethod(nameof(World.AddComponent))!
|
|
||||||
.MakeGenericMethod(type);
|
|
||||||
method.Invoke(world, [entity, component]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -249,13 +249,10 @@ public class CommandTests
|
||||||
private readonly World _world;
|
private readonly World _world;
|
||||||
private readonly SpawnCommand _command;
|
private readonly SpawnCommand _command;
|
||||||
|
|
||||||
public QueryDescriptor Query { get; }
|
|
||||||
|
|
||||||
public CommandEnqueueSystem(World world, SpawnCommand command)
|
public CommandEnqueueSystem(World world, SpawnCommand command)
|
||||||
{
|
{
|
||||||
_world = world;
|
_world = world;
|
||||||
_command = command;
|
_command = command;
|
||||||
Query = world.Query().With<TestPosition>().Build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Run(World world)
|
public void Run(World world)
|
||||||
|
|
@ -266,18 +263,17 @@ public class CommandTests
|
||||||
|
|
||||||
private class EntityCountObserver : ISystem
|
private class EntityCountObserver : ISystem
|
||||||
{
|
{
|
||||||
|
private readonly QueryDescriptor _query;
|
||||||
public int SeenCount { get; private set; }
|
public int SeenCount { get; private set; }
|
||||||
|
|
||||||
public QueryDescriptor Query { get; }
|
|
||||||
|
|
||||||
public EntityCountObserver(World world)
|
public EntityCountObserver(World world)
|
||||||
{
|
{
|
||||||
Query = world.Query().With<TestPosition>().Build();
|
_query = world.Query().With<TestPosition>().Build();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Run(World world)
|
public void Run(World world)
|
||||||
{
|
{
|
||||||
world.ForEach(Query, (Entity e, ref TestPosition pos) =>
|
world.ForEach(_query, (Entity e, ref TestPosition pos) =>
|
||||||
{
|
{
|
||||||
SeenCount++;
|
SeenCount++;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -23,4 +23,9 @@
|
||||||
<ProjectReference Include="..\..\src\OECS\OECS.csproj" />
|
<ProjectReference Include="..\..\src\OECS\OECS.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\OECS.SourceGen\OECS.SourceGen.csproj"
|
||||||
|
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -175,11 +175,11 @@ public class SystemTests
|
||||||
|
|
||||||
private class MovementSystem : ITickedSystem
|
private class MovementSystem : ITickedSystem
|
||||||
{
|
{
|
||||||
public QueryDescriptor Query { get; }
|
private readonly QueryDescriptor _query;
|
||||||
|
|
||||||
public MovementSystem(World world)
|
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());
|
public void Run(World world) => Run(world, Tick.Logical());
|
||||||
|
|
@ -187,7 +187,7 @@ public class SystemTests
|
||||||
public void Run(World world, Tick tick)
|
public void Run(World world, Tick tick)
|
||||||
{
|
{
|
||||||
float dt = tick.DeltaTime;
|
float dt = tick.DeltaTime;
|
||||||
world.ForEach(Query, (Entity entity, ref Position pos, ref Velocity vel) =>
|
world.ForEach(_query, (Entity entity, ref Position pos, ref Velocity vel) =>
|
||||||
{
|
{
|
||||||
pos.X += vel.X * dt;
|
pos.X += vel.X * dt;
|
||||||
pos.Y += vel.Y * dt;
|
pos.Y += vel.Y * dt;
|
||||||
|
|
@ -232,13 +232,11 @@ public class SystemTests
|
||||||
|
|
||||||
private class TestLogicalSystem : ITickedSystem
|
private class TestLogicalSystem : ITickedSystem
|
||||||
{
|
{
|
||||||
public QueryDescriptor Query { get; }
|
|
||||||
public bool WasCalled { get; private set; }
|
public bool WasCalled { get; private set; }
|
||||||
public Tick ReceivedTick { get; private set; }
|
public Tick ReceivedTick { get; private set; }
|
||||||
|
|
||||||
public TestLogicalSystem(World world)
|
public TestLogicalSystem(World world)
|
||||||
{
|
{
|
||||||
Query = world.Query().With<Position>().Build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Run(World world) => Run(world, Tick.Logical());
|
public void Run(World world) => Run(world, Tick.Logical());
|
||||||
|
|
@ -272,13 +270,10 @@ public class SystemTests
|
||||||
private readonly int _id;
|
private readonly int _id;
|
||||||
private readonly List<int> _order;
|
private readonly List<int> _order;
|
||||||
|
|
||||||
public QueryDescriptor Query { get; }
|
|
||||||
|
|
||||||
public OrderTrackingSystem(World world, int id, List<int> order)
|
public OrderTrackingSystem(World world, int id, List<int> order)
|
||||||
{
|
{
|
||||||
_id = id;
|
_id = id;
|
||||||
_order = order;
|
_order = order;
|
||||||
Query = world.Query().With<Position>().Build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Run(World world)
|
public void Run(World world)
|
||||||
|
|
|
||||||
|
|
@ -268,12 +268,9 @@ public class ReactivityTests
|
||||||
{
|
{
|
||||||
private readonly World _world;
|
private readonly World _world;
|
||||||
|
|
||||||
public QueryDescriptor Query { get; }
|
|
||||||
|
|
||||||
public EntityCreatorSystem(World world)
|
public EntityCreatorSystem(World world)
|
||||||
{
|
{
|
||||||
_world = world;
|
_world = world;
|
||||||
Query = world.Query().With<Position>().Build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Run(World world)
|
public void Run(World world)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ public struct ChildOf { }
|
||||||
public struct ParentOf { }
|
public struct ParentOf { }
|
||||||
public struct OwnedBy { }
|
public struct OwnedBy { }
|
||||||
public struct MemberOf { }
|
public struct MemberOf { }
|
||||||
|
public struct Thing { }
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────────────
|
// ── Tests ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -153,20 +154,20 @@ public class RelationshipTests
|
||||||
var owner = world.CreateEntity();
|
var owner = world.CreateEntity();
|
||||||
var group = world.CreateEntity();
|
var group = world.CreateEntity();
|
||||||
|
|
||||||
world.AddComponent(entity, new Relationship<OwnedBy, object>
|
world.AddComponent(entity, new Relationship<OwnedBy, Thing>
|
||||||
{
|
{
|
||||||
Source = entity, Target = owner
|
Source = entity, Target = owner
|
||||||
});
|
});
|
||||||
world.AddComponent(entity, new Relationship<MemberOf, object>
|
world.AddComponent(entity, new Relationship<MemberOf, Thing>
|
||||||
{
|
{
|
||||||
Source = entity, Target = group
|
Source = entity, Target = group
|
||||||
});
|
});
|
||||||
|
|
||||||
world.GetSources<Relationship<OwnedBy, object>>(owner).Should().BeEquivalentTo([entity]);
|
world.GetSources<Relationship<OwnedBy, Thing>>(owner).Should().BeEquivalentTo([entity]);
|
||||||
world.GetSources<Relationship<MemberOf, object>>(group).Should().BeEquivalentTo([entity]);
|
world.GetSources<Relationship<MemberOf, Thing>>(group).Should().BeEquivalentTo([entity]);
|
||||||
|
|
||||||
// Cross-check: OwnedBy sources should not appear in MemberOf lookups.
|
// 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]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -148,12 +148,9 @@ public class SingletonTests
|
||||||
{
|
{
|
||||||
private readonly World _world;
|
private readonly World _world;
|
||||||
|
|
||||||
public QueryDescriptor Query { get; }
|
|
||||||
|
|
||||||
public TimeSystem(World world)
|
public TimeSystem(World world)
|
||||||
{
|
{
|
||||||
_world = world;
|
_world = world;
|
||||||
Query = world.Query().With<GameConfig>().Build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Run(World world) => Run(world, Tick.Logical());
|
public void Run(World world) => Run(world, Tick.Logical());
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue