feat: add component discovery source generator
Introduce an incremental source generator that automatically discovers component types used with the World API. This allows the WorldSerializer to perform serialization and deserialization without relying on reflection, improving performance and stability.
This commit is contained in:
parent
713d578179
commit
96ba037432
61
OECS.sln
61
OECS.sln
|
|
@ -1,25 +1,84 @@
|
|||
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS.SourceGen", "src\OECS.SourceGen\OECS.SourceGen.csproj", "{C3D4E5F6-A7B8-9012-CDEF-123456789012}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS", "src\OECS\OECS.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS.Tests", "tests\OECS.Tests\OECS.Tests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "examples", "examples", "{B36A84DF-456D-A817-6EDD-3EC3E7F6E11F}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Blackjack", "examples\Blackjack\Blackjack.csproj", "{85631EDE-F984-4DA6-8EE9-0715AF1204E6}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x86.Build.0 = Release|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.Build.0 = Release|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x64.Build.0 = Release|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x86.Build.0 = Release|Any CPU
|
||||
{85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Release|x64.Build.0 = Release|Any CPU
|
||||
{85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{85631EDE-F984-4DA6-8EE9-0715AF1204E6} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
|
|
|||
|
|
@ -12,4 +12,9 @@
|
|||
<ProjectReference Include="..\..\src\OECS\OECS.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\OECS.SourceGen\OECS.SourceGen.csproj"
|
||||
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -50,8 +50,8 @@ public static class Program
|
|||
// ── Wire up systems ───────────────────────────────────────────
|
||||
|
||||
var group = new SystemGroup(world);
|
||||
group.Add(new WinCheckSystem(world));
|
||||
group.Add(new RenderSystem(world));
|
||||
group.Add(new WinCheckSystem());
|
||||
group.Add(new RenderSystem());
|
||||
|
||||
// ── Game loop ─────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -10,11 +10,13 @@ public class WinCheckSystem : ISystem
|
|||
{
|
||||
public void Run(World world)
|
||||
{
|
||||
ref var state = ref world.GetSingleton<GameState>();
|
||||
|
||||
if (state.Status != GameStatus.Playing)
|
||||
// Check game status before doing any work. Use ReadSingleton
|
||||
// to avoid auto-marking GameState as modified when we bail early.
|
||||
if (world.ReadSingleton<GameState>().Status != GameStatus.Playing)
|
||||
return;
|
||||
|
||||
ref var state = ref world.GetSingleton<GameState>();
|
||||
|
||||
// Build a 3×3 grid of marks using the iterator API.
|
||||
var grid = new Player[3, 3];
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@
|
|||
<ProjectReference Include="..\..\src\OECS\OECS.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\OECS.SourceGen\OECS.SourceGen.csproj"
|
||||
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="Data\board.csv">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,226 @@
|
|||
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($" deserialize: data => 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>
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
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 into a boxed component instance.
|
||||
/// </summary>
|
||||
public Func<byte[], object> Deserialize { get; }
|
||||
|
||||
public ComponentDescriptor(
|
||||
string typeName,
|
||||
Type type,
|
||||
Func<object, byte[]> serialize,
|
||||
Func<byte[], object> deserialize)
|
||||
{
|
||||
TypeName = typeName;
|
||||
Type = type;
|
||||
Serialize = serialize;
|
||||
Deserialize = deserialize;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using R3;
|
||||
|
||||
namespace OECS;
|
||||
|
|
@ -664,6 +665,27 @@ public class World : IDisposable
|
|||
/// </summary>
|
||||
internal ComponentStore Components => _components;
|
||||
|
||||
/// <summary>
|
||||
/// Adds a boxed component to an entity by its runtime type.
|
||||
/// Used by <see cref="WorldSerializer"/> during deserialization to
|
||||
/// invoke the typed <see cref="AddComponent{T}"/> without reflection.
|
||||
/// </summary>
|
||||
internal void AddComponentBoxed(Type componentType, Entity entity, object component)
|
||||
{
|
||||
// Use a cached delegate per type to avoid reflection on every call.
|
||||
if (!_addComponentDelegates.TryGetValue(componentType, out var del))
|
||||
{
|
||||
var method = typeof(World).GetMethod(nameof(AddComponent))!
|
||||
.MakeGenericMethod(componentType);
|
||||
del = (Action<World, Entity, object>)Delegate.CreateDelegate(
|
||||
typeof(Action<World, Entity, object>), method);
|
||||
_addComponentDelegates[componentType] = del;
|
||||
}
|
||||
del(this, entity, component);
|
||||
}
|
||||
|
||||
private readonly Dictionary<Type, Action<World, Entity, object>> _addComponentDelegates = new();
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
private void ThrowIfNotAlive(Entity entity)
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@ namespace OECS;
|
|||
/// <summary>
|
||||
/// Saves and loads a <see cref="World"/> to/from a MessagePack stream.
|
||||
///
|
||||
/// Components are serialized by their runtime type. The caller must
|
||||
/// ensure all component types used in the world are resolvable via
|
||||
/// <see cref="Type.GetType(string)"/> at load time.
|
||||
/// Uses the compile-time generated <see cref="ComponentRegistry"/> to
|
||||
/// serialize and deserialize components without reflection. All component
|
||||
/// types used with the World API are discovered by the OECS.SourceGen
|
||||
/// incremental generator at build time.
|
||||
/// </summary>
|
||||
public static class WorldSerializer
|
||||
{
|
||||
|
|
@ -18,9 +19,9 @@ public static class WorldSerializer
|
|||
{
|
||||
var entityComponents = new Dictionary<Entity, List<ComponentEntry>>();
|
||||
|
||||
foreach (var ct in world.Components.ComponentTypes)
|
||||
foreach (var desc in ComponentRegistry.Descriptors)
|
||||
{
|
||||
var set = world.Components.GetSet(ct);
|
||||
var set = world.Components.GetSet(desc.Type);
|
||||
if (set == null || set.Count == 0) continue;
|
||||
|
||||
var entities = set.GetDenseEntities();
|
||||
|
|
@ -37,8 +38,8 @@ public static class WorldSerializer
|
|||
|
||||
list.Add(new ComponentEntry
|
||||
{
|
||||
TypeName = ct.AssemblyQualifiedName!,
|
||||
Data = MessagePackSerializer.Serialize(ct, component)
|
||||
TypeName = desc.TypeName,
|
||||
Data = desc.Serialize(component)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -67,6 +68,11 @@ public static class WorldSerializer
|
|||
{
|
||||
var snapshot = MessagePackSerializer.Deserialize<WorldSnapshot>(stream);
|
||||
|
||||
// Build a lookup by type name for O(1) descriptor resolution.
|
||||
var lookup = new Dictionary<string, ComponentDescriptor>();
|
||||
foreach (var desc in ComponentRegistry.Descriptors)
|
||||
lookup[desc.TypeName] = desc;
|
||||
|
||||
foreach (var es in snapshot.Entities)
|
||||
{
|
||||
var entity = world.CreateEntity(es.Entity);
|
||||
|
|
@ -81,18 +87,18 @@ public static class WorldSerializer
|
|||
|
||||
foreach (var ce in es.Components)
|
||||
{
|
||||
var type = Type.GetType(ce.TypeName)
|
||||
?? throw new InvalidOperationException(
|
||||
if (!lookup.TryGetValue(ce.TypeName, out var desc))
|
||||
throw new InvalidOperationException(
|
||||
$"Unknown component type '{ce.TypeName}'. " +
|
||||
$"Ensure the assembly is loaded.");
|
||||
$"Ensure the component type is used with the World " +
|
||||
$"API so the source generator can discover it.");
|
||||
|
||||
var component = MessagePackSerializer.Deserialize(type, ce.Data)
|
||||
var component = desc.Deserialize(ce.Data)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Failed to deserialize component of type '{ce.TypeName}'.");
|
||||
|
||||
var method = typeof(World).GetMethod(nameof(World.AddComponent))!
|
||||
.MakeGenericMethod(type);
|
||||
method.Invoke(world, [entity, component]);
|
||||
// Use the generated typed delegate to add the component.
|
||||
world.AddComponentBoxed(desc.Type, entity, component);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,4 +23,9 @@
|
|||
<ProjectReference Include="..\..\src\OECS\OECS.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\OECS.SourceGen\OECS.SourceGen.csproj"
|
||||
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
Loading…
Reference in New Issue