diff --git a/Prowl.Runtime.Generators/EquatableArray.cs b/Prowl.Runtime.Generators/EquatableArray.cs new file mode 100644 index 000000000..10ebd5532 --- /dev/null +++ b/Prowl.Runtime.Generators/EquatableArray.cs @@ -0,0 +1,56 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; +using System.Collections; +using System.Collections.Generic; + +namespace Prowl.Generators; + +/// +/// Value-equality wrapper around T[] for use in incremental generator +/// pipeline models. The default array equality (reference) would defeat caching. +/// +internal readonly struct EquatableArray : IEquatable>, IEnumerable + where T : IEquatable +{ + private readonly T[]? _array; + + public EquatableArray(T[] array) => _array = array; + + public int Length => _array?.Length ?? 0; + + public T this[int index] => _array![index]; + + public bool Equals(EquatableArray other) + { + if (ReferenceEquals(_array, other._array)) return true; + if (_array is null || other._array is null) return false; + if (_array.Length != other._array.Length) return false; + for (int i = 0; i < _array.Length; i++) + { + if (!_array[i].Equals(other._array[i])) + return false; + } + return true; + } + + public override bool Equals(object? obj) => obj is EquatableArray other && Equals(other); + + public override int GetHashCode() + { + if (_array is null) return 0; + unchecked + { + int hash = 17; + for (int i = 0; i < _array.Length; i++) + hash = hash * 31 + _array[i].GetHashCode(); + return hash; + } + } + + public IEnumerator GetEnumerator() + => ((IEnumerable)(_array ?? Array.Empty())).GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/Prowl.Runtime.Generators/EventDomainGenerator.cs b/Prowl.Runtime.Generators/EventDomainGenerator.cs new file mode 100644 index 000000000..eed937849 --- /dev/null +++ b/Prowl.Runtime.Generators/EventDomainGenerator.cs @@ -0,0 +1,458 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Threading; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Prowl.Generators; + +[Generator] +public class EventDomainGenerator : IIncrementalGenerator +{ + private const string EventDomainAttrFqn = "Prowl.Runtime.Events.EventDomainAttribute"; + private const string EventArgsAttrFqn = "Prowl.Runtime.Events.EventArgsAttribute"; + private const string EventKeyFqn = "global::Prowl.Runtime.Events.EventKey"; + private const string UnitFqn = "global::Prowl.Runtime.Events.Unit"; + + private static readonly DiagnosticDescriptor s_notPartialDiag = new( + id: "PEVT0001", + title: "EventDomain class must be partial", + messageFormat: "The class '{0}' is marked with [EventDomain] but is not declared as 'partial'. Add the 'partial' modifier.", + category: "Prowl", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var classDeclarations = context.SyntaxProvider + .ForAttributeWithMetadataName( + EventDomainAttrFqn, + predicate: static (node, _) => node is ClassDeclarationSyntax, + transform: static (ctx, ct) => GetDomainInfo(ctx, ct)) + .Where(static m => m is not null); + + context.RegisterSourceOutput(classDeclarations, + static (spc, domain) => Execute(spc, domain!.Value)); + } + + #region Data model (value-equatable for incremental caching) + + private struct EventKeyInfo : IEquatable + { + public string Name; + public string ArgsTypeFqn; + public bool IsUnit; + + public bool Equals(EventKeyInfo other) + => Name == other.Name + && ArgsTypeFqn == other.ArgsTypeFqn + && IsUnit == other.IsUnit; + + public override bool Equals(object? obj) => obj is EventKeyInfo o && Equals(o); + + public override int GetHashCode() + { + unchecked + { + int h = 17; + h = h * 31 + (Name?.GetHashCode() ?? 0); + h = h * 31 + (ArgsTypeFqn?.GetHashCode() ?? 0); + h = h * 31 + IsUnit.GetHashCode(); + return h; + } + } + } + + private struct ContainingTypeInfo : IEquatable + { + public string Keyword; // "class", "struct", "record class", etc. + public string Name; + public string Accessibility; // "public", "internal", etc. + public bool IsStatic; + + public bool Equals(ContainingTypeInfo other) + => Keyword == other.Keyword + && Name == other.Name + && Accessibility == other.Accessibility + && IsStatic == other.IsStatic; + + public override bool Equals(object? obj) => obj is ContainingTypeInfo o && Equals(o); + + public override int GetHashCode() + { + unchecked + { + int h = 17; + h = h * 31 + (Keyword?.GetHashCode() ?? 0); + h = h * 31 + (Name?.GetHashCode() ?? 0); + h = h * 31 + (Accessibility?.GetHashCode() ?? 0); + h = h * 31 + IsStatic.GetHashCode(); + return h; + } + } + } + + private struct EventDomainInfo : IEquatable + { + public string? Namespace; + public string ClassName; + public string ClassAccessibility; + public bool IsStatic; + public bool IsGlobal; + public bool IsPartial; + public EquatableArray ContainingTypes; + public EquatableArray Events; + public Location? DiagnosticLocation; + + public bool Equals(EventDomainInfo other) + => Namespace == other.Namespace + && ClassName == other.ClassName + && ClassAccessibility == other.ClassAccessibility + && IsStatic == other.IsStatic + && IsGlobal == other.IsGlobal + && IsPartial == other.IsPartial + && ContainingTypes.Equals(other.ContainingTypes) + && Events.Equals(other.Events); + + public override bool Equals(object? obj) => obj is EventDomainInfo o && Equals(o); + + public override int GetHashCode() + { + unchecked + { + int h = 17; + h = h * 31 + (Namespace?.GetHashCode() ?? 0); + h = h * 31 + (ClassName?.GetHashCode() ?? 0); + h = h * 31 + (ClassAccessibility?.GetHashCode() ?? 0); + h = h * 31 + IsStatic.GetHashCode(); + h = h * 31 + IsGlobal.GetHashCode(); + h = h * 31 + IsPartial.GetHashCode(); + h = h * 31 + ContainingTypes.GetHashCode(); + h = h * 31 + Events.GetHashCode(); + return h; + } + } + } + + #endregion + + private static EventDomainInfo? GetDomainInfo(GeneratorAttributeSyntaxContext ctx, CancellationToken ct) + { + var classDecl = (ClassDeclarationSyntax)ctx.TargetNode; + var classSymbol = (INamedTypeSymbol)ctx.TargetSymbol; + + bool isPartial = classDecl.Modifiers.Any(SyntaxKind.PartialKeyword); + bool isStatic = classSymbol.IsStatic; + + // Extract Global property from [EventDomain] attribute + bool isGlobal = false; + foreach (var attrData in classSymbol.GetAttributes()) + { + if (attrData.AttributeClass?.ToDisplayString() == "Prowl.Runtime.Events.EventDomainAttribute") + { + foreach (var namedArg in attrData.NamedArguments) + { + if (namedArg.Key == "Global" && namedArg.Value.Value is bool g) + isGlobal = g; + } + break; + } + } + + // Collect EventKey fields with optional [EventArgs] + var events = new List(); + foreach (var member in classSymbol.GetMembers()) + { + ct.ThrowIfCancellationRequested(); + if (member is IFieldSymbol field + && field.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == EventKeyFqn) + { + string argsType = UnitFqn; // default to Unit if no [EventArgs] + foreach (var attr in field.GetAttributes()) + { + if (attr.AttributeClass?.ToDisplayString() == "Prowl.Runtime.Events.EventArgsAttribute" + && attr.ConstructorArguments.Length == 1 + && attr.ConstructorArguments[0].Value is ITypeSymbol typeArg + && typeArg.TypeKind != TypeKind.Error) + { + argsType = typeArg.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + break; + } + } + + bool isUnit = argsType == UnitFqn; + string eventName = field.Name.StartsWith("_") ? field.Name.Substring(1) : field.Name; + events.Add(new EventKeyInfo + { + Name = eventName, + ArgsTypeFqn = argsType, + IsUnit = isUnit, + }); + } + } + + if (events.Count == 0 && isPartial) + return null; // nothing to generate + + // Collect containing type chain for nested classes + var containingTypes = new List(); + var parent = classSymbol.ContainingType; + while (parent is not null) + { + containingTypes.Insert(0, new ContainingTypeInfo + { + Keyword = parent.IsRecord ? "record class" : "class", + Name = parent.Name, + Accessibility = AccessibilityToString(parent.DeclaredAccessibility), + IsStatic = parent.IsStatic, + }); + parent = parent.ContainingType; + } + + string? ns = classSymbol.ContainingNamespace.IsGlobalNamespace + ? null + : classSymbol.ContainingNamespace.ToDisplayString(); + + return new EventDomainInfo + { + Namespace = ns, + ClassName = classSymbol.Name, + ClassAccessibility = AccessibilityToString(classSymbol.DeclaredAccessibility), + IsStatic = isStatic, + IsGlobal = isGlobal, + IsPartial = isPartial, + ContainingTypes = new EquatableArray(containingTypes.ToArray()), + Events = new EquatableArray(events.ToArray()), + DiagnosticLocation = classDecl.Identifier.GetLocation(), + }; + } + + private static void Execute(SourceProductionContext spc, EventDomainInfo domain) + { + // Emit diagnostic if class is not partial + if (!domain.IsPartial) + { + spc.ReportDiagnostic(Diagnostic.Create( + s_notPartialDiag, + domain.DiagnosticLocation, + domain.ClassName)); + return; + } + + if (domain.Events.Length == 0) + return; + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("// Generated by Prowl.Runtime.Generators — do not edit."); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + + if (domain.Namespace is not null) + { + sb.AppendLine($"namespace {domain.Namespace}"); + sb.AppendLine("{"); + } + + string indent = domain.Namespace is not null ? " " : ""; + + // Open containing types + foreach (var ct in domain.ContainingTypes) + { + string staticMod = ct.IsStatic ? " static" : ""; + sb.AppendLine($"{indent}{ct.Accessibility}{staticMod} partial {ct.Keyword} {ct.Name}"); + sb.AppendLine($"{indent}{{"); + indent += " "; + } + + // Open the domain class + string classMods = domain.IsStatic ? " static" : ""; + sb.AppendLine($"{indent}{domain.ClassAccessibility}{classMods} partial class {domain.ClassName}"); + sb.AppendLine($"{indent}{{"); + string ci = indent + " "; // content indent + + // --- Generate enum --- + sb.AppendLine($"{ci}/// Auto-generated enum backing the event keys in this domain."); + sb.AppendLine($"{ci}public enum EventTypes"); + sb.AppendLine($"{ci}{{"); + foreach (var evt in domain.Events) + { + sb.AppendLine($"{ci} [global::Prowl.Runtime.Events.EventArgs(typeof({evt.ArgsTypeFqn}))]"); + sb.AppendLine($"{ci} {evt.Name},"); + } + sb.AppendLine($"{ci}}}"); + sb.AppendLine(); + + // --- Generate manager --- + string globalArg = domain.IsGlobal ? "global: true" : ""; + string memberStatic = domain.IsStatic ? " static" : ""; + string managerField = domain.IsStatic ? "s_eventManager" : "_eventManager"; + string fieldDecl = domain.IsStatic ? "private static readonly" : "private readonly"; + sb.AppendLine($"{ci}{fieldDecl} global::Prowl.Runtime.Events.EventManager {managerField} = new({globalArg});"); + sb.AppendLine(); + sb.AppendLine($"{ci}/// Gets the for this event domain. For instance domains, dispose this when the owner is no longer needed."); + sb.AppendLine($"{ci}public{memberStatic} global::Prowl.Runtime.Events.EventManager Manager => {managerField};"); + sb.AppendLine(); + + // --- Generate event accessor properties for += / -= subscription and .Invoke() --- + foreach (var evt in domain.Events) + { + string argsType = evt.ArgsTypeFqn; + + if (evt.IsUnit) + { + sb.AppendLine($"{ci}/// Access using += / -= to subscribe, or .Invoke() to fire. For priority/tags/source capture or IDisposable container, use {evt.Name}.Subscribe(...) on this accessor."); + sb.AppendLine($"{ci}public{memberStatic} global::Prowl.Runtime.Events.EventAccessor {evt.Name}"); + sb.AppendLine($"{ci}{{"); + sb.AppendLine($"{ci} get => new({managerField}, EventTypes.{evt.Name});"); + sb.AppendLine($"{ci} set {{ }}"); + sb.AppendLine($"{ci}}}"); + } + else + { + sb.AppendLine($"{ci}/// Access using += / -= to subscribe, or .Invoke({argsType}) to fire. For priority/tags/source capture or IDisposable container, use {evt.Name}.Subscribe(...) on this accessor."); + sb.AppendLine($"{ci}public{memberStatic} global::Prowl.Runtime.Events.EventAccessor {evt.Name}"); + sb.AppendLine($"{ci}{{"); + sb.AppendLine($"{ci} get => new({managerField}, EventTypes.{evt.Name});"); + sb.AppendLine($"{ci} set {{ }}"); + sb.AppendLine($"{ci}}}"); + } + } + sb.AppendLine(); + + // --- Generate per-event convenience methods --- + foreach (var evt in domain.Events) + { + string argsType = evt.ArgsTypeFqn; + + sb.AppendLine($"{ci}// --- {evt.Name} ---"); + sb.AppendLine(); + + if (evt.IsUnit) + { + EmitUnitMethods(sb, ci, evt.Name, memberStatic, managerField); + } + else + { + EmitTypedMethods(sb, ci, evt.Name, argsType, memberStatic, managerField); + } + } + + // --- Generate tag-based management methods --- + sb.AppendLine($"{ci}/// Enables all handlers in this domain that have the specified tag."); + sb.AppendLine($"{ci}public{memberStatic} void EnableByTag(string tag) => {managerField}.EnableByTag(tag);"); + sb.AppendLine(); + sb.AppendLine($"{ci}/// Disables all handlers in this domain that have the specified tag."); + sb.AppendLine($"{ci}public{memberStatic} void DisableByTag(string tag) => {managerField}.DisableByTag(tag);"); + sb.AppendLine(); + sb.AppendLine($"{ci}/// Removes all handlers in this domain that have the specified tag."); + sb.AppendLine($"{ci}public{memberStatic} void RemoveByTag(string tag) => {managerField}.RemoveByTag(tag);"); + sb.AppendLine(); + sb.AppendLine($"{ci}/// Enables all handlers across all global managers of this domain that have the specified tag."); + sb.AppendLine($"{ci}public static void GlobalEnableByTag(string tag) => global::Prowl.Runtime.Events.EventManager.GlobalEnableByTag(tag);"); + sb.AppendLine(); + sb.AppendLine($"{ci}/// Disables all handlers across all global managers of this domain that have the specified tag."); + sb.AppendLine($"{ci}public static void GlobalDisableByTag(string tag) => global::Prowl.Runtime.Events.EventManager.GlobalDisableByTag(tag);"); + sb.AppendLine(); + sb.AppendLine($"{ci}/// Removes all handlers across all global managers of this domain that have the specified tag."); + sb.AppendLine($"{ci}public static void GlobalRemoveByTag(string tag) => global::Prowl.Runtime.Events.EventManager.GlobalRemoveByTag(tag);"); + sb.AppendLine(); + + // Close domain class + sb.AppendLine($"{indent}}}"); + + // Close containing types + for (int i = domain.ContainingTypes.Length - 1; i >= 0; i--) + { + indent = indent.Substring(0, indent.Length - 4); + sb.AppendLine($"{indent}}}"); + } + + // Close namespace + if (domain.Namespace is not null) + sb.AppendLine("}"); + + string hintName = domain.ContainingTypes.Length > 0 + ? string.Join(".", domain.ContainingTypes.Select(c => c.Name)) + "." + domain.ClassName + ".g.cs" + : domain.ClassName + ".g.cs"; + + spc.AddSource(hintName, sb.ToString()); + } + + private static void EmitUnitMethods(StringBuilder sb, string ci, string name, string memberStatic, string managerField) + { + // Invoke (parameterless) + sb.AppendLine($"{ci}/// Invokes on this domain's manager."); + sb.AppendLine($"{ci}[global::System.Runtime.CompilerServices.MethodImpl(global::System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]"); + sb.AppendLine($"{ci}public{memberStatic} void Invoke{name}()"); + sb.AppendLine($"{ci} => {managerField}.InvokeEvent(EventTypes.{name});"); + sb.AppendLine(); + + // InvokeAsync (parameterless) + sb.AppendLine($"{ci}/// Asynchronously invokes on this domain's manager, awaiting async handlers."); + sb.AppendLine($"{ci}public{memberStatic} global::System.Threading.Tasks.Task Invoke{name}Async()"); + sb.AppendLine($"{ci} => {managerField}.InvokeEventAsync(EventTypes.{name});"); + sb.AppendLine(); + + // GlobalInvoke (parameterless) — always static + sb.AppendLine($"{ci}/// Invokes across all global managers of this domain."); + sb.AppendLine($"{ci}[global::System.Runtime.CompilerServices.MethodImpl(global::System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]"); + sb.AppendLine($"{ci}public static void GlobalInvoke{name}()"); + sb.AppendLine($"{ci} => global::Prowl.Runtime.Events.EventManager.GlobalInvokeEvent(EventTypes.{name});"); + sb.AppendLine(); + + // GlobalInvokeAsync (parameterless) — always static + sb.AppendLine($"{ci}/// Asynchronously invokes across all global managers of this domain, awaiting async handlers."); + sb.AppendLine($"{ci}public static global::System.Threading.Tasks.Task GlobalInvoke{name}Async()"); + sb.AppendLine($"{ci} => global::Prowl.Runtime.Events.EventManager.GlobalInvokeEventAsync(EventTypes.{name});"); + sb.AppendLine(); + } + + private static void EmitTypedMethods(StringBuilder sb, string ci, string name, string argsType, string memberStatic, string managerField) + { + // Invoke (typed) + sb.AppendLine($"{ci}/// Invokes on this domain's manager."); + sb.AppendLine($"{ci}[global::System.Runtime.CompilerServices.MethodImpl(global::System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]"); + sb.AppendLine($"{ci}public{memberStatic} void Invoke{name}({argsType} args)"); + sb.AppendLine($"{ci} => {managerField}.InvokeEvent(EventTypes.{name}, args);"); + sb.AppendLine(); + + // InvokeAsync (typed) + sb.AppendLine($"{ci}/// Asynchronously invokes on this domain's manager, awaiting async handlers."); + sb.AppendLine($"{ci}public{memberStatic} global::System.Threading.Tasks.Task Invoke{name}Async({argsType} args)"); + sb.AppendLine($"{ci} => {managerField}.InvokeEventAsync(EventTypes.{name}, args);"); + sb.AppendLine(); + + // GlobalInvoke (typed) — always static + sb.AppendLine($"{ci}/// Invokes across all global managers of this domain."); + sb.AppendLine($"{ci}[global::System.Runtime.CompilerServices.MethodImpl(global::System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]"); + sb.AppendLine($"{ci}public static void GlobalInvoke{name}({argsType} args)"); + sb.AppendLine($"{ci} => global::Prowl.Runtime.Events.EventManager.GlobalInvokeEvent(EventTypes.{name}, args);"); + sb.AppendLine(); + + // GlobalInvokeAsync (typed) — always static + sb.AppendLine($"{ci}/// Asynchronously invokes across all global managers of this domain, awaiting async handlers."); + sb.AppendLine($"{ci}public static global::System.Threading.Tasks.Task GlobalInvoke{name}Async({argsType} args)"); + sb.AppendLine($"{ci} => global::Prowl.Runtime.Events.EventManager.GlobalInvokeEventAsync(EventTypes.{name}, args);"); + sb.AppendLine(); + } + + private static string AccessibilityToString(Accessibility accessibility) => accessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Protected => "protected", + Accessibility.ProtectedOrInternal => "protected internal", + Accessibility.ProtectedAndInternal => "private protected", + Accessibility.Private => "private", + _ => "internal", + }; +} diff --git a/Prowl.Runtime.Generators/Prowl.Runtime.Generators.csproj b/Prowl.Runtime.Generators/Prowl.Runtime.Generators.csproj new file mode 100644 index 000000000..fed008746 --- /dev/null +++ b/Prowl.Runtime.Generators/Prowl.Runtime.Generators.csproj @@ -0,0 +1,16 @@ + + + + netstandard2.0 + latest + enable + true + true + RS2008 + + + + + + + diff --git a/Prowl.Runtime/Components/GameCanvas.cs b/Prowl.Runtime/Components/GameCanvas.cs index 8e0b87a2e..3b2ed64db 100644 --- a/Prowl.Runtime/Components/GameCanvas.cs +++ b/Prowl.Runtime/Components/GameCanvas.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using Prowl.Echo; +using Prowl.Runtime.Events; using Prowl.Runtime.GUI; using Prowl.Runtime.Rendering; using Prowl.Runtime.Resources; @@ -190,13 +191,13 @@ public override void OnDisable() { /* tree retained but no canvas walk picks us /// Overlay/Camera canvases ignore this hook — they are pulled by /// from the pipeline directly. /// - public override void OnRenderCollect(Camera camera, List renderables, List _) + public override void OnRenderCollect(SceneEvents.OnRenderCollectArgs onRenderCollectArgs) { if (RenderMode != RenderMode.WorldSpace) return; RebuildIfDirty(); Tree.RefreshTransforms(); foreach (UIRenderItem it in Tree.Items) - renderables.Add(it); + onRenderCollectArgs.renderables.Add(it); } // ============================================================ diff --git a/Prowl.Runtime/Components/Lights/DirectionalLight.cs b/Prowl.Runtime/Components/Lights/DirectionalLight.cs index 6dc0c3016..f3cab709b 100644 --- a/Prowl.Runtime/Components/Lights/DirectionalLight.cs +++ b/Prowl.Runtime/Components/Lights/DirectionalLight.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; +using Prowl.Runtime.Events; using Prowl.Runtime.Rendering; using Prowl.Vector; @@ -36,9 +37,9 @@ public enum CascadeCount : int private Float4[] _cascadeAtlasParams = new Float4[4]; // xy = atlas pos, z = atlas size, w = split distance private int _activeCascades = 0; - public override void OnRenderCollect(Camera camera, List renderables, List lights) + public override void OnRenderCollect(SceneEvents.OnRenderCollectArgs args) { - lights.Add(this); + args.lights.Add(this); } public override void DrawGizmos() diff --git a/Prowl.Runtime/Components/Lights/Light.cs b/Prowl.Runtime/Components/Lights/Light.cs index d45a50f83..ee101c4a0 100644 --- a/Prowl.Runtime/Components/Lights/Light.cs +++ b/Prowl.Runtime/Components/Lights/Light.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; +using Prowl.Runtime.Events; using Prowl.Runtime.Rendering; using Prowl.Vector; @@ -56,9 +57,9 @@ public abstract class Light : MonoBehaviour, IRenderableLight public int ShadowSlot { get; internal set; } = -1; - public override void OnRenderCollect(Camera camera, List renderables, List lights) + public override void OnRenderCollect(SceneEvents.OnRenderCollectArgs args) { - lights.Add(this); + args.lights.Add(this); } public virtual int GetLayer() => GameObject.LayerIndex; diff --git a/Prowl.Runtime/Components/Lights/PointLight.cs b/Prowl.Runtime/Components/Lights/PointLight.cs index 15e417733..bc8756ea1 100644 --- a/Prowl.Runtime/Components/Lights/PointLight.cs +++ b/Prowl.Runtime/Components/Lights/PointLight.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; +using Prowl.Runtime.Events; using Prowl.Runtime.Rendering; using Prowl.Vector; @@ -28,9 +29,9 @@ public enum Resolution : int private Float4x4[] _shadowMatrices = new Float4x4[6]; // View-projection for each face private bool _shadowsValid = false; - public override void OnRenderCollect(Camera camera, List renderables, List lights) + public override void OnRenderCollect(SceneEvents.OnRenderCollectArgs args) { - lights.Add(this); + args.lights.Add(this); } public override void DrawGizmos() diff --git a/Prowl.Runtime/Components/Lights/SpotLight.cs b/Prowl.Runtime/Components/Lights/SpotLight.cs index 916acf4f7..b80066e13 100644 --- a/Prowl.Runtime/Components/Lights/SpotLight.cs +++ b/Prowl.Runtime/Components/Lights/SpotLight.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; +using Prowl.Runtime.Events; using Prowl.Runtime.Rendering; using Prowl.Vector; @@ -28,9 +29,9 @@ public enum Resolution : int private Float4x4 _shadowMatrix; private Float4 _shadowAtlasParams; // xy = atlas pos, z = atlas size, w = 1.0 - public override void OnRenderCollect(Camera camera, List renderables, List lights) + public override void OnRenderCollect(SceneEvents.OnRenderCollectArgs args) { - lights.Add(this); + args.lights.Add(this); } public override void DrawGizmos() diff --git a/Prowl.Runtime/Components/LineRenderer.cs b/Prowl.Runtime/Components/LineRenderer.cs index b9f0ba1d5..5a8c3432d 100644 --- a/Prowl.Runtime/Components/LineRenderer.cs +++ b/Prowl.Runtime/Components/LineRenderer.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using Prowl.Runtime.Rendering; +using Prowl.Runtime.Events; using Prowl.Runtime.Resources; using Prowl.Vector; @@ -84,10 +85,10 @@ public override void Update() } } - public override void OnRenderCollect(Camera camera, List renderables, List lights) + public override void OnRenderCollect(SceneEvents.OnRenderCollectArgs args) { if (Material.Res != null && Points != null && Points.Count >= 2) - renderables.Add(this); + args.renderables.Add(this); } private bool PointsEqual(List a, List b) diff --git a/Prowl.Runtime/Components/MeshRenderer.cs b/Prowl.Runtime/Components/MeshRenderer.cs index 663e96cc5..7c3a50950 100644 --- a/Prowl.Runtime/Components/MeshRenderer.cs +++ b/Prowl.Runtime/Components/MeshRenderer.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; +using Prowl.Runtime.Events; using Prowl.Runtime.Rendering; using Prowl.Runtime.Resources; using Prowl.Vector; @@ -36,7 +37,7 @@ public AssetRef Material /// UV2 → atlas transform: uv2 * xy + zw. Assigned by the lightmap bake. [HideInInspector] public Float4 LightmapScaleOffset = new(1, 1, 0, 0); - public override void OnRenderCollect(Camera camera, List renderables, List lights) + public override void OnRenderCollect(SceneEvents.OnRenderCollectArgs args) { var mesh = Mesh.Res; if (mesh == null || Materials.Count == 0) return; @@ -62,7 +63,7 @@ public override void OnRenderCollect(Camera camera, List renderable Float3 giAnchor = Float4x4.TransformPoint(mesh.bounds.Center, Transform.LocalToWorldMatrix); LightmapBinding.Fill(props, GameObject.Scene, LightmapIndex, LightmapScaleOffset, giAnchor, mesh.HasUV2); - renderables.Add(new MeshRenderable( + args.renderables.Add(new MeshRenderable( mesh, mat, Transform.LocalToWorldMatrix, GameObject.LayerIndex, props, subMeshIndex: subCount > 1 ? s : -1)); } diff --git a/Prowl.Runtime/Components/ParticleSystem/ParticleSystemComponent.cs b/Prowl.Runtime/Components/ParticleSystem/ParticleSystemComponent.cs index 9806269bd..042e8c42c 100644 --- a/Prowl.Runtime/Components/ParticleSystem/ParticleSystemComponent.cs +++ b/Prowl.Runtime/Components/ParticleSystem/ParticleSystemComponent.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using Prowl.Runtime.Events; using Prowl.Runtime.Rendering; using Prowl.Runtime.Resources; using Prowl.Runtime.ParticleSystem.Modules; @@ -155,7 +156,7 @@ public override void Update() } } - public override void OnRenderCollect(Camera camera, List renderables, List lights) + public override void OnRenderCollect(SceneEvents.OnRenderCollectArgs args) { if (_particles.Count <= 0 || Material.Res == null || _quadMesh == null) return; @@ -168,7 +169,7 @@ public override void OnRenderCollect(Camera camera, List renderable // Create batched instanced renderables InstancedMeshRenderable.CreateBatched( - renderables, + args.renderables, _quadMesh, Material.Res, _transforms, @@ -181,7 +182,7 @@ public override void OnRenderCollect(Camera camera, List renderable ); if (Light.Enabled) - CollectParticleLights(lights); + CollectParticleLights(args.lights); } /// diff --git a/Prowl.Runtime/Components/SkinnedMeshRenderer.cs b/Prowl.Runtime/Components/SkinnedMeshRenderer.cs index 024c4fe49..223a7f29d 100644 --- a/Prowl.Runtime/Components/SkinnedMeshRenderer.cs +++ b/Prowl.Runtime/Components/SkinnedMeshRenderer.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using Prowl.Echo; +using Prowl.Runtime.Events; using Prowl.Runtime.Rendering; using Prowl.Runtime.Resources; using Prowl.Vector; @@ -411,7 +412,7 @@ private void UploadMorphWeightTexture() _morphWeightTexture!.SetData(data.AsMemory(0, _morphWeightCapacity), 0, 0, (uint)_morphWeightCapacity, 1); } - public override void OnRenderCollect(Camera camera, List renderables, List lights) + public override void OnRenderCollect(SceneEvents.OnRenderCollectArgs args) { var mesh = SharedMesh.Res; if (mesh == null || Materials.Count == 0) return; @@ -470,7 +471,7 @@ public override void OnRenderCollect(Camera camera, List renderable if (mesh.HasBlendShapes) ApplyBlendShapeProps(mesh, props); - renderables.Add(new SkinnedMeshRenderable( + args.renderables.Add(new SkinnedMeshRenderable( mesh, mat, Transform.LocalToWorldMatrix, GameObject.LayerIndex, worldBounds, props, subMeshIndex: s)); } diff --git a/Prowl.Runtime/Components/Terrain/TerrainComponent.cs b/Prowl.Runtime/Components/Terrain/TerrainComponent.cs index 4bbb5080e..06db65932 100644 --- a/Prowl.Runtime/Components/Terrain/TerrainComponent.cs +++ b/Prowl.Runtime/Components/Terrain/TerrainComponent.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using Prowl.Runtime.Events; using Prowl.Runtime.Rendering; using Prowl.Runtime.Resources; using Prowl.Vector; @@ -142,7 +143,7 @@ public Float3 TerrainToWorld(Float3 localPoint) => public Float3 WorldToTerrain(Float3 worldPoint) => Float4x4.TransformPoint(worldPoint, Transform.WorldToLocalMatrix); - public override void OnRenderCollect(Camera camera, List renderables, List lights) + public override void OnRenderCollect(SceneEvents.OnRenderCollectArgs args) { var terrainData = Data.Res; if (terrainData == null) return; @@ -152,7 +153,7 @@ public override void OnRenderCollect(Camera camera, List renderable Float4x4 worldToTerrain = Transform.WorldToLocalMatrix; // Camera position in terrain-local space for LOD - Float3 camLocal = WorldToTerrain(camera.Transform.Position); + Float3 camLocal = WorldToTerrain(args.camera.Transform.Position); camLocal.Y = 0; if (_quadtree == null @@ -223,7 +224,7 @@ public override void OnRenderCollect(Camera camera, List renderable var bounds = TransformAABB(localMin, localMax, terrainToWorld); InstancedMeshRenderable.CreateBatched( - renderables, _baseMesh, mat, _transforms, + args.renderables, _baseMesh, mat, _transforms, (bounds.Min + bounds.Max) * 0.5f, layer: GameObject.LayerIndex, properties: _properties, bounds: bounds); @@ -247,11 +248,11 @@ public override void OnRenderCollect(Camera camera, List renderable var htex = terrainData.GetHeightmapTexture(); if (htex != null) grassMat.SetTexture("_Heightmap", htex); - _grassRenderer?.CollectRenderables(terrainData, this, camera, grassMat, GrassDistance, GrassDensityMultiplier, renderables); + _grassRenderer?.CollectRenderables(terrainData, this, args.camera, grassMat, GrassDistance, GrassDensityMultiplier, args.renderables); } // Trees - _treeRenderer?.CollectRenderables(terrainData, this, camera, TreeDistance, renderables); + _treeRenderer?.CollectRenderables(terrainData, this, args.camera, TreeDistance, args.renderables); } public override void DrawGizmos() diff --git a/Prowl.Runtime/Components/WorldCanvas.cs b/Prowl.Runtime/Components/WorldCanvas.cs index 62b47fcc4..0f2c92323 100644 --- a/Prowl.Runtime/Components/WorldCanvas.cs +++ b/Prowl.Runtime/Components/WorldCanvas.cs @@ -6,6 +6,7 @@ using Prowl.PaperUI; using Prowl.Runtime.GUI; +using Prowl.Runtime.Events; using Prowl.Runtime.Rendering; using Prowl.Runtime.Resources; using Prowl.Vector; @@ -103,7 +104,7 @@ public override void Update() } - public override void OnRenderCollect(Camera camera, List renderables, List lights) + public override void OnRenderCollect(SceneEvents.OnRenderCollectArgs args) { // Push this canvas as a renderable if (_renderTexture.IsValid() && (Material.Res?.IsValid() ?? false) && _quadMesh.IsValid()) @@ -111,7 +112,7 @@ public override void OnRenderCollect(Camera camera, List renderable _properties.Clear(); _properties.SetInt("_ObjectID", InstanceID); _properties.SetTexture("_MainTex", _renderTexture.MainTexture); - renderables.Add(this); + args.renderables.Add(this); } } diff --git a/Prowl.Runtime/Events/AsyncEventDelegateContainer.cs b/Prowl.Runtime/Events/AsyncEventDelegateContainer.cs new file mode 100644 index 000000000..5b70f43e9 --- /dev/null +++ b/Prowl.Runtime/Events/AsyncEventDelegateContainer.cs @@ -0,0 +1,131 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; +using System.Threading.Tasks; + +namespace Prowl.Runtime.Events { + +/// +/// Typed delegate container wrapping a for async +/// event handlers. Participates in the same priority-sorted snapshot as synchronous +/// containers, enabling mixed sync/async handler chains. +/// +/// When invoked via the synchronous path, the returned +/// is observed but not awaited. Use +/// or the generated +/// InvokeXxxAsync methods to properly await async handlers. +/// +/// +public class AsyncEventDelegateContainer : EventDelegateContainer, IAsyncInvocable + where T : struct, Enum +{ + /// + public override bool MatchesDelegate(Delegate handler) => handler != null && handler.Equals(_asyncDelegate); + + private readonly Func? _asyncDelegate; + + public override Delegate? GetDelegate() => _asyncDelegate; + + public AsyncEventDelegateContainer(T eventType, Func asyncDelegate, ExecutionOrder priority = default, string[]? tags = null) + : base(eventType, priority, tags) + { + _asyncDelegate = asyncDelegate; + } + +public AsyncEventDelegateContainer(T eventType, Func asyncDelegate, ExecutionOrder priority, + string? sourceFile, int sourceLine, string? sourceMember, string[]? tags = null) + : base(eventType, priority, sourceFile, sourceLine, sourceMember, tags) +{ + _asyncDelegate = asyncDelegate; +} + + /// + /// Protected constructor for subclasses that provide their own invocation + /// logic and do not use the field. + /// + protected AsyncEventDelegateContainer(T eventType, ExecutionOrder priority, string[]? tags = null) + : base(eventType, priority, tags) + { + } + +protected AsyncEventDelegateContainer(T eventType, ExecutionOrder priority, + string? sourceFile, int sourceLine, string? sourceMember, string[]? tags = null) + : base(eventType, priority, sourceFile, sourceLine, sourceMember, tags) +{ +} + + /// + /// Synchronous invocation fallback. Fires the async handler without awaiting. + /// When EVENT_DEBUG is defined, a warning is logged — prefer instead. + /// + public override void Invoke(TArgs args) + { + if (!Enabled) return; +#if EVENT_DEBUG + EventSystemDiagnostics.LogWarning?.Invoke( + $"[EventSystem] Async handler on {typeof(T).Name} invoked synchronously. " + + $"Use InvokeAsync/InvokeEventAsync for proper async execution. " + + $"Handler: {SourceDescription}"); +#endif + _asyncDelegate?.Invoke(args); + } + + /// + /// Asynchronously invokes the handler and returns the resulting . + /// + public virtual Task InvokeAsync(TArgs args) + { + if (!Enabled) return Task.CompletedTask; + return _asyncDelegate?.Invoke(args) ?? Task.CompletedTask; + } +} + +/// +/// Specialized container for parameterless async events that stores a +/// directly, avoiding the closure allocation that +/// wrapping in a would incur. +/// +public sealed class ParameterlessAsyncEventDelegateContainer : AsyncEventDelegateContainer + where T : struct, Enum +{ + /// + public override bool MatchesDelegate(Delegate handler) => handler != null && handler.Equals(_asyncAction); + + private readonly Func _asyncAction; + + public override Delegate? GetDelegate() => _asyncAction; + + public ParameterlessAsyncEventDelegateContainer(T eventType, Func asyncAction, ExecutionOrder priority = default, string[]? tags = null) + : base(eventType, priority, tags) + { + _asyncAction = asyncAction; + } + +public ParameterlessAsyncEventDelegateContainer(T eventType, Func asyncAction, ExecutionOrder priority, + string? sourceFile, int sourceLine, string? sourceMember, string[]? tags = null) + : base(eventType, priority, sourceFile, sourceLine, sourceMember, tags) +{ + _asyncAction = asyncAction; +} + + public override void Invoke(Unit args) + { + if (!Enabled) return; +#if EVENT_DEBUG + EventSystemDiagnostics.LogWarning?.Invoke( + $"[EventSystem] Async handler on {typeof(T).Name} invoked synchronously. " + + $"Use InvokeAsync/InvokeEventAsync for proper async execution. " + + $"Handler: {SourceDescription}"); +#endif + _asyncAction?.Invoke(); + } + + public override Task InvokeAsync(Unit args) + { + if (!Enabled) return Task.CompletedTask; + return _asyncAction?.Invoke() ?? Task.CompletedTask; + } +} + +} diff --git a/Prowl.Runtime/Events/Event.cs b/Prowl.Runtime/Events/Event.cs new file mode 100644 index 000000000..029e0f0b2 --- /dev/null +++ b/Prowl.Runtime/Events/Event.cs @@ -0,0 +1,647 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; +using System.Buffers; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +namespace Prowl.Runtime.Events; + +public class Event where T : struct, Enum +{ + /// + /// JIT-time cache: true when implements + /// . Evaluated once per closed generic and stored in a + /// static field, so the hot-path never boxes value-type + /// args just to check the interface. + /// + private static class CancellableCheck + { + public static readonly bool IsCancellable = typeof(ICancellable).IsAssignableFrom(typeof(TArgs)); + } + +#if EVENT_DEBUG + /// + /// Configurable threshold in milliseconds. Handlers exceeding this duration + /// will be logged as warnings when EVENT_DEBUG is defined. Set to 0 to disable. + /// + public static double SlowHandlerThresholdMs { get; set; } = 200.0; +#endif + + private readonly T _eventType; + public T EventType => _eventType; + + private readonly EventManager _eventManager; + public EventManager EventManager => _eventManager; + + private readonly List> _delegates = []; + + private readonly object _lock = new(); + + /// + /// Copy-on-write snapshot: a flat, priority-sorted array of all delegates. + /// Rebuilt only when the subscriber list changes (Add/Remove), never on Invoke. + /// Used by EVENT_DEBUG diagnostics to detect type-mismatch handlers. + /// + private EventDelegateContainer[] _cachedSnapshot = []; + + /// + /// Actual valid length of when rented from ArrayPool. + /// The rented array may be larger than needed; use this length for iteration. + /// + private int _cachedSnapshotLength = 0; + + /// + /// Per-TArgs typed COW snapshots keyed by . + /// Each value is a EventDelegateContainer<T, TArgs>[] stored as + /// . + /// + /// retrieves the matching typed array with a single + /// dictionary lookup and one array-reference cast — no per-element type check. + /// + /// + private readonly ConcurrentDictionary _typedSnapshots = new(); + + /// + /// Per-TArgs actual lengths of typed snapshots. Since typed arrays + /// are rented from ArrayPool, they may be larger than needed. + /// + private readonly ConcurrentDictionary _typedSnapshotLengths = new(); + + private volatile bool _enabled = true; + public bool Enabled + { + get => _enabled; + set => _enabled = value; + } + + /// + /// When greater than zero, snapshot rebuilds are deferred until the batch + /// count returns to zero. Incremented by and + /// decremented by . Must only be accessed under . + /// + private int _batchDepth; + + /// + /// Tracks whether any Add/Remove occurred while batching was active, + /// so that knows whether a rebuild is needed. + /// + private bool _batchDirty; + + public Event(EventManager eventManager, T eventType) + { + this._eventType = eventType; + this._eventManager = eventManager; + } + + /// + /// Begins a batch operation. While batched, and + /// will not rebuild snapshots. Call when finished to rebuild once. + /// Calls may be nested; only the outermost triggers the rebuild. + /// + public void BeginBatch() + { + lock (_lock) + { + _batchDepth++; + } + } + + /// + /// Ends a batch operation. If this is the outermost batch and any mutations + /// occurred, the COW snapshot is rebuilt exactly once. + /// + public void EndBatch() + { + lock (_lock) + { + if (_batchDepth > 0) + _batchDepth--; + + if (_batchDepth == 0 && _batchDirty) + { + _batchDirty = false; + RebuildSnapshot(); + } + } + } + + + public void Invoke(TArgs args) + { + if (!Enabled) return; + + EventDelegateContainer[] typedSnapshot; + int typedLength; +#if EVENT_DEBUG + EventDelegateContainer[] fullSnapshot; + int fullLength; +#endif + lock (_lock) + { + if (_typedSnapshots.TryGetValue(typeof(TArgs), out object? obj)) + { + typedSnapshot = (EventDelegateContainer[])obj; + typedLength = _typedSnapshotLengths[typeof(TArgs)]; + } + else + { + typedSnapshot = []; + typedLength = 0; + } +#if EVENT_DEBUG + fullSnapshot = _cachedSnapshot; + fullLength = _cachedSnapshotLength; +#endif + } + +#if EVENT_DEBUG + // Warn about handlers on this event registered with a different TArgs. + if (fullLength > typedLength) + { + for (int j = 0; j < fullLength; j++) + { + if (fullSnapshot[j] is not EventDelegateContainer) + WarnTypeMismatch(fullSnapshot[j]); + } + } + + double threshold = SlowHandlerThresholdMs; + Stopwatch? sw = threshold > 0 ? Stopwatch.StartNew() : null; +#endif + var span = typedSnapshot.AsSpan(0, typedLength); + for (int j = 0; j < span.Length; j++) + { +#if EVENT_DEBUG + sw?.Restart(); +#endif + try + { + span[j].Invoke(args); + } + catch (Exception ex) + { + Console.WriteLine(); + } + +#if EVENT_DEBUG + if (sw is not null) + { + sw.Stop(); + double elapsed = sw.Elapsed.TotalMilliseconds; + if (elapsed > threshold) + { + EventSystemDiagnostics.LogWarning?.Invoke( + $"[EventSystem] Slow handler on {typeof(T).Name}.{_eventType}: " + + $"{elapsed:F2}ms (threshold {threshold:F1}ms). " + + $"Handler: {typedSnapshot[j].SourceDescription}"); + } + } +#endif + + if (CancellableCheck.IsCancellable && args is ICancellable { Cancelled: true }) + break; + } + + + } + + /// + /// Asynchronously invokes all handlers for the given , + /// awaiting async handlers () sequentially while + /// calling synchronous handlers inline. Priority ordering and cancellation semantics + /// are identical to . + /// + /// Intended for editor/tool events where handlers legitimately need to perform I/O. + /// Not recommended for the game-loop hot path — use instead. + /// + /// + public async Task InvokeAsync(TArgs args) + { + if (!Enabled) return; + + EventDelegateContainer[] typedSnapshot; + int typedLength; +#if EVENT_DEBUG + EventDelegateContainer[] fullSnapshot; + int fullLength; +#endif + lock (_lock) + { + if (_typedSnapshots.TryGetValue(typeof(TArgs), out object? obj)) + { + typedSnapshot = (EventDelegateContainer[])obj; + typedLength = _typedSnapshotLengths[typeof(TArgs)]; + } + else + { + typedSnapshot = []; + typedLength = 0; + } +#if EVENT_DEBUG + fullSnapshot = _cachedSnapshot; + fullLength = _cachedSnapshotLength; +#endif + } + +#if EVENT_DEBUG + // Warn about handlers on this event registered with a different TArgs. + if (fullLength > typedLength) + { + for (int j = 0; j < fullLength; j++) + { + if (fullSnapshot[j] is not EventDelegateContainer) + WarnTypeMismatch(fullSnapshot[j]); + } + } + + double threshold = SlowHandlerThresholdMs; + Stopwatch? sw = threshold > 0 ? Stopwatch.StartNew() : null; +#endif + + for (int j = 0; j < typedLength; j++) + { +#if EVENT_DEBUG + sw?.Restart(); +#endif + try + { + if (typedSnapshot[j] is IAsyncInvocable asyncHandler) + await asyncHandler.InvokeAsync(args).ConfigureAwait(false); + else + typedSnapshot[j].Invoke(args); + } + catch (Exception ex) + { + Console.WriteLine(ex.Message); + } + +#if EVENT_DEBUG + if (sw is not null) + { + sw.Stop(); + double elapsed = sw.Elapsed.TotalMilliseconds; + if (elapsed > threshold) + { + EventSystemDiagnostics.LogWarning?.Invoke( + $"[EventSystem] Slow handler on {typeof(T).Name}.{_eventType}: " + + $"{elapsed:F2}ms (threshold {threshold:F1}ms). " + + $"Handler: {typedSnapshot[j].SourceDescription}"); + } + } +#endif + + if (CancellableCheck.IsCancellable && args is ICancellable { Cancelled: true }) + break; + } + + } + + /// + /// Returns a read-only view of the currently registered handlers for the + /// given type, sorted by priority. + /// The span references a COW snapshot — it is safe to read after the lock + /// is released but may become stale if handlers are added or removed. + /// + public ReadOnlySpan> GetHandlers() + { + lock (_lock) + { + if (_typedSnapshots.TryGetValue(typeof(TArgs), out object? obj)) + { + var array = (EventDelegateContainer[])obj; + int length = _typedSnapshotLengths[typeof(TArgs)]; + return array.AsSpan(0, length); + } + return ReadOnlySpan>.Empty; + } + } + +#if EVENT_DEBUG + /// + /// Logs a warning when a registered handler is skipped because its TArgs + /// does not match the invoked type. Only compiled when EVENT_DEBUG is defined. + /// + private void WarnTypeMismatch(EventDelegateContainer container) + { + // Extract the registered TArgs from the concrete generic type. + Type containerType = container.GetType(); + Type? registeredArgs = null; + if (containerType.IsGenericType && containerType.GenericTypeArguments.Length == 2) + registeredArgs = containerType.GenericTypeArguments[1]; + + EventSystemDiagnostics.LogWarning?.Invoke( + $"[EventSystem] Type mismatch on {typeof(T).Name}.{_eventType}: " + + $"handler registered for '{registeredArgs?.Name ?? "unknown"}' " + + $"but invoked with '{typeof(TArgs).Name}'. Handler was skipped. " + + $"(registered at {container.SourceDescription})"); + } +#endif + + public bool Add(EventDelegateContainer eventDelegate, bool allowMultiple = false) + { + bool added = false; + + lock (_lock) + { + if (allowMultiple || !ContainsDelegate(eventDelegate.GetDelegate())) + { + // Binary search for insertion point to maintain sorted order by priority. + // Uses upper-bound semantics so equal priorities preserve insertion order (stable). + int lo = 0, hi = _delegates.Count; + while (lo < hi) + { + int mid = (lo + hi) >>> 1; + if (_delegates[mid].Priority.CompareTo(eventDelegate.Priority) <= 0) + lo = mid + 1; + else + hi = mid; + } + _delegates.Insert(lo, eventDelegate); + eventDelegate.Link(this); + added = true; + } + if (added) + { + if (_batchDepth > 0) + _batchDirty = true; + else + RebuildSnapshot(); + } + eventDelegate.Added = added; + return added; + } + } + + public bool Remove(EventDelegateContainer eventDelegate) + { + lock (_lock) + { + bool result = _delegates.Remove(eventDelegate); + if (result) + { + eventDelegate.Unlink(); + if (_batchDepth > 0) + _batchDirty = true; + else + RebuildSnapshot(); + } + return result; + } + } + + /// + /// Removes the first delegate container whose wrapped handler equals the + /// specified . Used by generated -= event accessors. + /// + public bool RemoveByDelegate(Delegate handler) + { + lock (_lock) + { + for (int i = 0; i < _delegates.Count; i++) + { + if (_delegates[i].MatchesDelegate(handler)) + { + EventDelegateContainer container = _delegates[i]; + _delegates.RemoveAt(i); + container.Unlink(); + if (_batchDepth > 0) + _batchDirty = true; + else + RebuildSnapshot(); + return true; + } + } + return false; + } + } + + private bool ContainsDelegate(Delegate? handler) + { + if (handler == null) return false; + for (int i = 0; i < _delegates.Count; i++) + { + if (_delegates[i].MatchesDelegate(handler)) return true; + } + return false; + } + + public void EnableByTag(string tag) + { + lock (_lock) + { + foreach (var del in _delegates) + { + if (del.HasTag(tag)) del.Enable(); + } + } + } + + public void DisableByTag(string tag) + { + lock (_lock) + { + foreach (var del in _delegates) + { + if (del.HasTag(tag)) del.Disable(); + } + } + } + + public void RemoveByTag(string tag) + { + lock (_lock) + { + bool removed = false; + for (int i = _delegates.Count - 1; i >= 0; i--) + { + if (_delegates[i].HasTag(tag)) + { + var container = _delegates[i]; + _delegates.RemoveAt(i); + container.Unlink(); + removed = true; + } + } + + if (removed) + { + if (_batchDepth > 0) + _batchDirty = true; + else + RebuildSnapshot(); + } + } + } + + + /// + /// Rebuilds the flat, priority-sorted snapshot array and per-TArgs typed + /// snapshot arrays from the current delegate list. + /// Must be called under . + /// + private void RebuildSnapshot() + { + int totalCount = _delegates.Count; + + if (totalCount == 0) + { + // Return old snapshot to pool before clearing + if (_cachedSnapshot.Length > 0) + ArrayPool>.Shared.Return(_cachedSnapshot, clearArray: true); + + _cachedSnapshot = []; + _cachedSnapshotLength = 0; + _typedSnapshots.Clear(); + _typedSnapshotLengths.Clear(); + return; + } + + // Return old snapshot to pool before renting new one + if (_cachedSnapshot.Length > 0) + ArrayPool>.Shared.Return(_cachedSnapshot, clearArray: true); + + // Rent from ArrayPool instead of allocating + EventDelegateContainer[] snapshot = ArrayPool>.Shared.Rent(totalCount); + for (int i = 0; i < totalCount; i++) + snapshot[i] = _delegates[i]; + _cachedSnapshot = snapshot; + _cachedSnapshotLength = totalCount; + + RebuildTypedSnapshots(); + } + + /// + /// Rebuilds per-TArgs typed snapshot arrays from the priority-sorted + /// delegate buckets. Each resulting array is a properly typed + /// EventDelegateContainer<T, TArgs>[], enabling + /// to iterate with direct method calls and + /// zero per-element type checks. + /// Must be called under . + /// + private void RebuildTypedSnapshots() + { + // Return all old typed snapshots to their respective pools + foreach (var kvp in _typedSnapshots) + { + Type argsType = kvp.Key; + if (!s_arrayReturnMethods.TryGetValue(argsType, out Action? returnMethod)) + { + returnMethod = CreateArrayReturnMethod(argsType); + s_arrayReturnMethods[argsType] = returnMethod; + } + returnMethod(kvp.Value); + } + + _typedSnapshots.Clear(); + _typedSnapshotLengths.Clear(); + + // First pass: collect containers per ArgsType, maintaining priority order. + Dictionary>>? groups = null; + + for (int i = 0; i < _delegates.Count; i++) + { + EventDelegateContainer container = _delegates[i]; + Type argsType = container.ArgsType; + + groups ??= new Dictionary>>(); + if (!groups.TryGetValue(argsType, out List>? list)) + { + list = new List>(); + groups[argsType] = list; + } + list.Add(container); + } + + if (groups is null) + return; + + // Second pass: create properly typed arrays via cached generic delegates, + // avoiding Array.CreateInstance + per-element SetValue overhead. + foreach (KeyValuePair>> entry in groups) + { + if (!s_arrayBuilders.TryGetValue(entry.Key, out Func>, object>? builder)) + { + builder = CreateArrayBuilder(entry.Key); + s_arrayBuilders[entry.Key] = builder; + } + _typedSnapshots[entry.Key] = builder(entry.Value); + _typedSnapshotLengths[entry.Key] = entry.Value.Count; + } + } + + /// + /// Generic helper invoked through a cached delegate. Creates a strongly typed + /// array and populates it with simple reference casts — no + /// overhead. + /// + private static object BuildTypedArray(List> list) + { + // Rent from ArrayPool instead of allocating + EventDelegateContainer[] result = ArrayPool>.Shared.Rent(list.Count); + for (int i = 0; i < list.Count; i++) + result[i] = (EventDelegateContainer)list[i]; + return result; + } + + /// + /// Generic helper invoked through a cached delegate. Returns a rented typed array + /// to its ArrayPool<EventDelegateContainer<T, TArgs>>.Shared. + /// + private static void ReturnTypedArray(object array) + { + ArrayPool>.Shared.Return( + (EventDelegateContainer[])array, clearArray: true); + } + + /// + /// Creates and returns a delegate that calls + /// closed over the given . The reflection cost is paid + /// once; subsequent rebuilds reuse the cached delegate. + /// + private static Func>, object> CreateArrayBuilder(Type argsType) + { + MethodInfo openMethod = typeof(Event) + .GetMethod(nameof(BuildTypedArray), BindingFlags.NonPublic | BindingFlags.Static)!; +#pragma warning disable IL3050 // MakeGenericMethod: the closed generic is only over reference-compatible types already loaded. + MethodInfo closedMethod = openMethod.MakeGenericMethod(argsType); +#pragma warning restore IL3050 + return (Func>, object>) + Delegate.CreateDelegate(typeof(Func>, object>), closedMethod); + } + + /// + /// Creates and returns a delegate that calls + /// closed over the given . Used to return rented arrays + /// to the appropriate ArrayPool instance. + /// + private static Action CreateArrayReturnMethod(Type argsType) + { + MethodInfo openMethod = typeof(Event) + .GetMethod(nameof(ReturnTypedArray), BindingFlags.NonPublic | BindingFlags.Static)!; +#pragma warning disable IL3050 // MakeGenericMethod: the closed generic is only over reference-compatible types already loaded. + MethodInfo closedMethod = openMethod.MakeGenericMethod(argsType); +#pragma warning restore IL3050 + return (Action) + Delegate.CreateDelegate(typeof(Action), closedMethod); + } + + /// + /// Per-TArgs cached factory delegates that build strongly typed + /// EventDelegateContainer<T, TArgs>[] from a list of base containers. + /// Avoids repeated and per-element + /// overhead. + /// + private static readonly ConcurrentDictionary>, object>> s_arrayBuilders = new(); + + /// + /// Per-TArgs cached methods that return rented arrays to the appropriate + /// ArrayPool<EventDelegateContainer<T, TArgs>>.Shared. + /// + private static readonly ConcurrentDictionary> s_arrayReturnMethods = new(); +} diff --git a/Prowl.Runtime/Events/EventAccessor.cs b/Prowl.Runtime/Events/EventAccessor.cs new file mode 100644 index 000000000..6f363b07b --- /dev/null +++ b/Prowl.Runtime/Events/EventAccessor.cs @@ -0,0 +1,184 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; + +namespace Prowl.Runtime.Events { + +/// +/// Lightweight accessor for a parameterless event slot. +/// Supports += / -= for subscribe/unsubscribe and +/// for firing the event. +/// +/// +/// This is a returned by generated event +/// properties. The no-op setter on the property allows +/// Domain.OnFoo += handler to compile (expands to +/// Domain.OnFoo = Domain.OnFoo + handler). +/// +public readonly struct EventAccessor where TEnum : struct, Enum +{ + private readonly EventManager _manager; + private readonly TEnum _eventType; + + public EventAccessor(EventManager manager, TEnum eventType) + { + _manager = manager; + _eventType = eventType; + } + + /// Fire the parameterless event. + public void Invoke() => _manager.InvokeEvent(_eventType); + + /// Asynchronously fire the parameterless event, awaiting async handlers. + public Task InvokeAsync() => _manager.InvokeEventAsync(_eventType); + + public static EventAccessor operator +(EventAccessor accessor, Action handler) + { + accessor._manager.AddNewDelegate(accessor._eventType, handler, default(ExecutionOrder)); + return accessor; + } + + public static EventAccessor operator -(EventAccessor accessor, Action handler) + { + accessor._manager.RemoveDelegate(accessor._eventType, handler); + return accessor; + } + + public static EventAccessor operator +(EventAccessor accessor, Func handler) + { + accessor._manager.AddNewAsyncDelegate(accessor._eventType, handler, default(ExecutionOrder)); + return accessor; + } + + public static EventAccessor operator -(EventAccessor accessor, Func handler) + { + accessor._manager.RemoveDelegate(accessor._eventType, handler); + return accessor; + } + + /// + /// Subscribes a parameterless handler to the event. Supports order, tags, and source location capture. + /// Dispose the returned container to unsubscribe. + /// + public EventDelegateContainer Subscribe( + Action handler, ExecutionOrder order = default, + [CallerFilePath] string? sourceFile = null, + [CallerLineNumber] int sourceLine = 0, + [CallerMemberName] string? sourceMember = null, + string[]? tags = null, + bool allowMultiple = false) + => _manager.AddNewDelegate(_eventType, handler, order, sourceFile, sourceLine, sourceMember, allowMultiple, tags); + + /// + /// Subscribes a parameterless async handler to the event. Supports order, tags, and source location capture. + /// Dispose the returned container to unsubscribe. + /// + public AsyncEventDelegateContainer SubscribeAsync( + Func handler, ExecutionOrder order = default, + [CallerFilePath] string? sourceFile = null, + [CallerLineNumber] int sourceLine = 0, + [CallerMemberName] string? sourceMember = null, + string[]? tags = null, + bool allowMultiple = false) + => _manager.AddNewAsyncDelegate(_eventType, handler, order, sourceFile, sourceLine, sourceMember, allowMultiple, tags); + + /// + /// Subscribes a parameterless handler that automatically unsubscribes after one invocation. + /// + public OneTimeParameterlessEventDelegateContainer SubscribeOnce( + Action handler, ExecutionOrder order = default, + [CallerFilePath] string? sourceFile = null, + [CallerLineNumber] int sourceLine = 0, + [CallerMemberName] string? sourceMember = null, + string[]? tags = null) + => _manager.SubscribeOnce(_eventType, handler, order, sourceFile, sourceLine, sourceMember, tags); +} + +/// +/// Lightweight accessor for a typed event slot. +/// Supports += / -= for subscribe/unsubscribe and +/// for firing the event with arguments. +/// +public readonly struct EventAccessor where TEnum : struct, Enum +{ + private readonly EventManager _manager; + private readonly TEnum _eventType; + + public EventAccessor(EventManager manager, TEnum eventType) + { + _manager = manager; + _eventType = eventType; + } + + /// Fire the event with the given arguments. + public void Invoke(TArgs args) => _manager.InvokeEvent(_eventType, args); + + /// Asynchronously fire the event with the given arguments, awaiting async handlers. + public Task InvokeAsync(TArgs args) => _manager.InvokeEventAsync(_eventType, args); + + public static EventAccessor operator +(EventAccessor accessor, Action handler) + { + accessor._manager.AddNewDelegate(accessor._eventType, handler, default(ExecutionOrder)); + return accessor; + } + + public static EventAccessor operator -(EventAccessor accessor, Action handler) + { + accessor._manager.RemoveDelegate(accessor._eventType, handler); + return accessor; + } + + public static EventAccessor operator +(EventAccessor accessor, Func handler) + { + accessor._manager.AddNewAsyncDelegate(accessor._eventType, handler, default(ExecutionOrder)); + return accessor; + } + + public static EventAccessor operator -(EventAccessor accessor, Func handler) + { + accessor._manager.RemoveDelegate(accessor._eventType, handler); + return accessor; + } + + /// + /// Subscribes a typed handler to the event. Supports order, tags, and source location capture. + /// Dispose the returned container to unsubscribe. + /// + public EventDelegateContainer Subscribe( + Action handler, ExecutionOrder order = default, + [CallerFilePath] string? sourceFile = null, + [CallerLineNumber] int sourceLine = 0, + [CallerMemberName] string? sourceMember = null, + string[]? tags = null, + bool allowMultiple = false) + => _manager.AddNewDelegate(_eventType, handler, order, sourceFile, sourceLine, sourceMember, allowMultiple, tags); + + /// + /// Subscribes a typed async handler to the event. Supports order, tags, and source location capture. + /// Dispose the returned container to unsubscribe. + /// + public AsyncEventDelegateContainer SubscribeAsync( + Func handler, ExecutionOrder order = default, + [CallerFilePath] string? sourceFile = null, + [CallerLineNumber] int sourceLine = 0, + [CallerMemberName] string? sourceMember = null, + string[]? tags = null, + bool allowMultiple = false) + => _manager.AddNewAsyncDelegate(_eventType, handler, order, sourceFile, sourceLine, sourceMember, allowMultiple, tags); + + /// + /// Subscribes a typed handler that automatically unsubscribes after one invocation. + /// + public OneTimeEventDelegateContainer SubscribeOnce( + Action handler, ExecutionOrder order = default, + [CallerFilePath] string? sourceFile = null, + [CallerLineNumber] int sourceLine = 0, + [CallerMemberName] string? sourceMember = null, + string[]? tags = null) + => _manager.SubscribeOnce(_eventType, handler, order, sourceFile, sourceLine, sourceMember, tags); +} + +} diff --git a/Prowl.Runtime/Events/EventArgsAttribute.cs b/Prowl.Runtime/Events/EventArgsAttribute.cs new file mode 100644 index 000000000..4e6117756 --- /dev/null +++ b/Prowl.Runtime/Events/EventArgsAttribute.cs @@ -0,0 +1,28 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; + +namespace Prowl.Runtime.Events { + +/// +/// Declares the canonical TArgs type for an event enum value. +/// When present, and +/// will assert that the +/// supplied TArgs matches the declared type. +/// +/// Omitting the attribute on a value means "any TArgs is accepted" (opt-in safety). +/// +/// +[AttributeUsage(AttributeTargets.Field, AllowMultiple = false, Inherited = false)] +public sealed class EventArgsAttribute : Attribute +{ + public Type ArgsType { get; } + + public EventArgsAttribute(Type argsType) + { + ArgsType = argsType ?? throw new ArgumentNullException(nameof(argsType)); + } +} + +} diff --git a/Prowl.Runtime/Events/EventArgsContract.cs b/Prowl.Runtime/Events/EventArgsContract.cs new file mode 100644 index 000000000..0acbd1929 --- /dev/null +++ b/Prowl.Runtime/Events/EventArgsContract.cs @@ -0,0 +1,61 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace Prowl.Runtime.Events; + +/// +/// Builds and caches a mapping from each enum value +/// to the declared by its +/// (if any). Evaluated once per closed generic T. +/// +internal static class EventArgsContract where T : struct, Enum +{ + /// + /// Maps each enum value to its declared TArgs type, or null + /// if the value has no . + /// + private static readonly Dictionary s_declaredArgs = Build(); + + private static Dictionary Build() + { + Dictionary map = new Dictionary(); + Type enumType = typeof(T); + + foreach (T value in Enum.GetValues()) + { + string name = Enum.GetName(value)!; + FieldInfo? field = enumType.GetField(name); + EventArgsAttribute? attr = field?.GetCustomAttribute(); + map[value] = attr?.ArgsType; + } + + return map; + } + + /// + /// Returns true when is compatible + /// with the contract declared on . + /// Returns true when no attribute is present (opt-in model). + /// + public static bool IsValid(T eventType) + { + if (!s_declaredArgs.TryGetValue(eventType, out Type? declared) || declared is null) + return true; + + return declared == typeof(TArgs); + } + + /// + /// Returns the declared type name for diagnostics, or "(none)". + /// + public static string GetDeclaredName(T eventType) + { + if (s_declaredArgs.TryGetValue(eventType, out Type? declared) && declared is not null) + return declared.Name; + return "(none)"; + } +} diff --git a/Prowl.Runtime/Events/EventDelegateContainer.cs b/Prowl.Runtime/Events/EventDelegateContainer.cs new file mode 100644 index 000000000..04b465f4b --- /dev/null +++ b/Prowl.Runtime/Events/EventDelegateContainer.cs @@ -0,0 +1,292 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; +using System.Runtime.CompilerServices; + +namespace Prowl.Runtime.Events { + +/// +/// Non-generic base class for delegate containers, enabling heterogeneous storage +/// within a single . Subscribe via the typed +/// derived class. +/// Implements for self-unsubscription. +/// +public abstract class EventDelegateContainer : IEventDelegateContainer where T : struct, Enum +{ + public EventManager? EventManager => Event?.EventManager; + + private Event _event; + public Event Event + { + get { return _event; } + private set + { + _event = value; + } + } + + private bool _added; + public bool Added + { + get { return _added; } + internal set { _added = value; } + } + + private bool _disposed; + + private readonly T eventType; + public T EventType => eventType; + + private bool enabled = true; + public bool Enabled + { + get => enabled; + private set => enabled = value; + } + + private readonly ExecutionOrder priority; + public ExecutionOrder Priority => priority; + + /// + /// The TArgs type this container was registered with. + /// Used by to build per-type snapshots without reflection. + /// + public abstract Type ArgsType { get; } + + /// + /// Returns the underlying delegate wrapped by this container. + /// + public abstract Delegate? GetDelegate(); + + /// + /// Returns true when this container wraps the specified handler delegate. + /// Used by the generated -= event accessor path. + /// + public abstract bool MatchesDelegate(Delegate handler); + +/// +/// Source file where this handler was registered. Captured automatically +/// via when EVENT_DEBUG is defined. +/// +public string? SourceFile { get; private set; } + +/// +/// Source line number where this handler was registered. +/// +public int SourceLine { get; private set; } + +/// +/// Name of the member that registered this handler. +/// +public string? SourceMember { get; private set; } + +/// +/// Returns a compact "File:Line (Member)" string for diagnostics, +/// or "unknown" when source info was not captured. +/// +public string SourceDescription => + SourceFile is not null + ? $"{SourceFile}:{SourceLine} ({SourceMember})" + : "unknown"; + + public void Link(Event @event) + { + Event = @event; + } + + public void Unlink() + { + Event = null; + } + + private string[]? _tags; + public string[]? Tags => _tags; + + public bool HasTag(string tag) + { + if (_tags == null) return false; + for (int i = 0; i < _tags.Length; i++) + { + if (string.Equals(_tags[i], tag, StringComparison.Ordinal)) + return true; + } + return false; + } + + protected EventDelegateContainer(T eventType, ExecutionOrder priority, string[]? tags = null) + { + this.priority = priority; + this.eventType = eventType; + this._tags = tags; + } + + protected EventDelegateContainer(T eventType, ExecutionOrder priority, string? sourceFile, int sourceLine, string? sourceMember, string[]? tags = null) + : this(eventType, priority, tags) + { +#if EVENT_DEBUG + SourceFile = sourceFile is not null ? System.IO.Path.GetFileName(sourceFile) : null; + SourceLine = sourceLine; + SourceMember = sourceMember; +#endif + } + + public void Enable() + { + Enabled = true; + } + public void Disable() + { + Enabled = false; + } + + /// + /// Removes this delegate from its parent event, enabling using patterns + /// and preventing leaks. + /// + public void Dispose() + { + if (_disposed) return; + + _disposed = true; + Event?.Remove(this); + } +} + +/// +/// Typed delegate container wrapping an . +/// +public class EventDelegateContainer : EventDelegateContainer, IInvocable where T : struct, Enum +{ + public override Type ArgsType => typeof(TArgs); + + /// + public override bool MatchesDelegate(Delegate handler) => handler != null && handler.Equals(eventDelegate); + + private readonly Action? eventDelegate; + + public override Delegate? GetDelegate() => eventDelegate; + + public EventDelegateContainer(T eventType, Action eventDelegate, ExecutionOrder priority = default, string[]? tags = null) + : base(eventType, priority, tags) + { + this.eventDelegate = eventDelegate; + } + +public EventDelegateContainer(T eventType, Action eventDelegate, ExecutionOrder priority, + string? sourceFile, int sourceLine, string? sourceMember, string[]? tags = null) + : base(eventType, priority, sourceFile, sourceLine, sourceMember, tags) +{ + this.eventDelegate = eventDelegate; +} + + /// + /// Protected constructor for subclasses that provide their own invocation + /// logic and do not use the field. + /// + protected EventDelegateContainer(T eventType, ExecutionOrder priority, string[]? tags = null) + : base(eventType, priority, tags) + { + } + +protected EventDelegateContainer(T eventType, ExecutionOrder priority, + string? sourceFile, int sourceLine, string? sourceMember, string[]? tags = null) + : base(eventType, priority, sourceFile, sourceLine, sourceMember, tags) +{ +} + +public virtual void Invoke(TArgs args) + { + if (!Enabled) return; + eventDelegate?.Invoke(args); + } +} + +/// +/// Specialized container for parameterless events that stores an +/// directly, avoiding the closure allocation that wrapping in an +/// would incur. +/// +public sealed class ParameterlessEventDelegateContainer : EventDelegateContainer where T : struct, Enum +{ + /// + public override bool MatchesDelegate(Delegate handler) => handler != null && handler.Equals(_action); + + private readonly Action _action; + + public override Delegate? GetDelegate() => _action; + + public ParameterlessEventDelegateContainer(T eventType, Action action, ExecutionOrder priority = default, string[]? tags = null) + : base(eventType, priority, tags) + { + _action = action; + } + +public ParameterlessEventDelegateContainer(T eventType, Action action, ExecutionOrder priority, + string? sourceFile, int sourceLine, string? sourceMember, string[]? tags = null) + : base(eventType, priority, sourceFile, sourceLine, sourceMember, tags) +{ + _action = action; +} + + public override void Invoke(Unit args) + { + if (!Enabled) return; + _action?.Invoke(); + } +} + +/// +/// A typed delegate container that automatically disposes itself after a single invocation. +/// +public sealed class OneTimeEventDelegateContainer : EventDelegateContainer where T : struct, Enum +{ + private readonly Action? _eventDelegate; + + public override Delegate? GetDelegate() => _eventDelegate; + + public override Type ArgsType => typeof(TArgs); + public override bool MatchesDelegate(Delegate handler) => handler != null && handler.Equals(_eventDelegate); + + public OneTimeEventDelegateContainer(T eventType, Action eventDelegate, ExecutionOrder priority, + string? sourceFile, int sourceLine, string? sourceMember, string[]? tags = null) + : base(eventType, priority, sourceFile, sourceLine, sourceMember, tags) + { + _eventDelegate = eventDelegate; + } + + public override void Invoke(TArgs args) + { + if (!Enabled) return; + _eventDelegate?.Invoke(args); + Dispose(); + } +} + +/// +/// A parameterless delegate container that automatically disposes itself after a single invocation. +/// +public sealed class OneTimeParameterlessEventDelegateContainer : EventDelegateContainer where T : struct, Enum +{ + private readonly Action _action; + + public override Delegate? GetDelegate() => _action; + + public override bool MatchesDelegate(Delegate handler) => handler != null && handler.Equals(_action); + + public OneTimeParameterlessEventDelegateContainer(T eventType, Action action, ExecutionOrder priority, + string? sourceFile, int sourceLine, string? sourceMember, string[]? tags = null) + : base(eventType, priority, sourceFile, sourceLine, sourceMember, tags) + { + _action = action; + } + + public override void Invoke(Unit args) + { + if (!Enabled) return; + _action?.Invoke(); + Dispose(); + } +} + +} diff --git a/Prowl.Runtime/Events/EventDomainAttribute.cs b/Prowl.Runtime/Events/EventDomainAttribute.cs new file mode 100644 index 000000000..a5299f6d8 --- /dev/null +++ b/Prowl.Runtime/Events/EventDomainAttribute.cs @@ -0,0 +1,66 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; + +namespace Prowl.Runtime.Events { + +/// +/// Marks a partial class as an event domain. +/// The source generator will produce a backing enum, an , +/// event accessor properties, and typed Invoke* / GlobalInvoke* convenience methods +/// for each field. Advanced subscriptions are available on the accessors +/// via .Subscribe(...) etc. +/// +/// Static domains (the class is static) produce a single shared +/// and static convenience methods — ideal for +/// global engine events. +/// +/// +/// Instance domains (the class is not static) produce a +/// per-instance and instance convenience methods — +/// ideal for component-level or per-object events. Dispose +/// via Manager.Dispose() when the owner is no longer needed. +/// GlobalInvoke methods remain static and broadcast across all global managers. +/// +/// +/// Static example: +/// +/// [EventDomain(Global = true)] +/// public static partial class MyEvents +/// { +/// [EventArgs(typeof(MyArgs))] +/// private static readonly EventKey _OnSomething = new(); +/// +/// public readonly struct MyArgs { public int Value; } +/// } +/// // Usage: MyEvents.InvokeOnSomething(args); +/// +/// +/// +/// Instance example: +/// +/// [EventDomain] +/// public partial class ActorEvents +/// { +/// [EventArgs(typeof(DamageArgs))] +/// private static readonly EventKey _OnDamaged = new(); +/// +/// public readonly record struct DamageArgs(float Amount); +/// } +/// // Usage: actor.InvokeOnDamaged(new DamageArgs(10)); +/// +/// +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class EventDomainAttribute : Attribute +{ + /// + /// When true, the generated is created + /// with global: true, making it participate in + /// calls. + /// + public bool Global { get; set; } +} + +} diff --git a/Prowl.Runtime/Events/EventKey.cs b/Prowl.Runtime/Events/EventKey.cs new file mode 100644 index 000000000..85b1f4a2f --- /dev/null +++ b/Prowl.Runtime/Events/EventKey.cs @@ -0,0 +1,30 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +namespace Prowl.Runtime.Events { + +/// +/// Marker type for source-generated event domains. +/// Declare private static readonly EventKey _OnXxx fields inside a class marked with +/// to define events. The leading underscore is stripped +/// by the generator to produce the public event name. Optionally decorate each +/// field with to specify the event's argument type +/// (defaults to when omitted). +/// +/// Example: +/// +/// [EventDomain] +/// public static partial class MyEvents +/// { +/// [EventArgs(typeof(MyPayload))] +/// private static readonly EventKey _OnSomething = new(); +/// } +/// +/// The generator will emit the OnSomething event accessor property (for +=/Invoke) along with +/// per-event InvokeOnSomething / GlobalInvokeOnSomething convenience methods. +/// Full subscription with priority, tags, etc. is done via OnSomething.Subscribe(...). +/// +/// +public readonly struct EventKey; + +} diff --git a/Prowl.Runtime/Events/EventManager.WaitFor.cs b/Prowl.Runtime/Events/EventManager.WaitFor.cs new file mode 100644 index 000000000..d28c52436 --- /dev/null +++ b/Prowl.Runtime/Events/EventManager.WaitFor.cs @@ -0,0 +1,163 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Prowl.Runtime.Events; + +public partial class EventManager where T : struct, Enum +{ + /// + /// Returns a that completes the next time + /// is invoked with arguments of type + /// . The internal subscription is removed + /// automatically once the task transitions to a final state. + /// + /// The event to await. + /// + /// Cancels the wait. When triggered, the returned task transitions to + /// and the subscription is disposed. + /// + /// This manager has been disposed. + /// + /// does not match the contract declared by + /// . + /// + public Task WaitForEventAsync( + T eventType, + CancellationToken cancellationToken = default, +#if EVENT_DEBUG + [CallerFilePath] string? sourceFile = null, + [CallerLineNumber] int sourceLine = 0, + [CallerMemberName] string? sourceMember = null +#else + string? sourceFile = null, + int sourceLine = 0, + string? sourceMember = null +#endif + ) + { + return WaitForEventAsync(eventType, predicate: null, cancellationToken, sourceFile, sourceLine, sourceMember); + } + + /// + /// Returns a that completes the next time + /// is invoked with arguments matching + /// . Invocations for which the predicate + /// returns false are ignored — the wait continues until the + /// predicate matches, the token is cancelled, or this manager is disposed. + /// + public Task WaitForEventAsync( + T eventType, + Func? predicate, + CancellationToken cancellationToken = default, +#if EVENT_DEBUG + [CallerFilePath] string? sourceFile = null, + [CallerLineNumber] int sourceLine = 0, + [CallerMemberName] string? sourceMember = null +#else + string? sourceFile = null, + int sourceLine = 0, + string? sourceMember = null +#endif + ) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (!EventArgsContract.IsValid(eventType)) + { + throw new InvalidOperationException( + $"[EventSystem] Type mismatch on {typeof(T).Name}.{eventType}: " + + $"WaitForEventAsync invoked with '{typeof(TArgs).Name}' but the event " + + $"declares '{EventArgsContract.GetDeclaredName(eventType)}' via [EventArgs]."); + } + + if (cancellationToken.IsCancellationRequested) + return Task.FromCanceled(cancellationToken); + + TaskCompletionSource tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + WaitState state = new(tcs, predicate); + + EventDelegateContainer container = new( + eventType, state.Handle, default, sourceFile, sourceLine, sourceMember); + state.Subscription = container; + AddDelegate(container); + + if (cancellationToken.CanBeCanceled) + { + state.Registration = cancellationToken.Register(static s => + { + WaitState ws = (WaitState)s!; + ws.Cancel(); + }, state); + } + + return tcs.Task; + } + + /// + /// Awaits the next firing of a parameterless event. Equivalent to the + /// generic WaitForEventAsync<Unit> overload. + /// + public async Task WaitForEventAsync( + T eventType, + CancellationToken cancellationToken = default, +#if EVENT_DEBUG + [CallerFilePath] string? sourceFile = null, + [CallerLineNumber] int sourceLine = 0, + [CallerMemberName] string? sourceMember = null +#else + string? sourceFile = null, + int sourceLine = 0, + string? sourceMember = null +#endif + ) + { + await WaitForEventAsync(eventType, cancellationToken, sourceFile, sourceLine, sourceMember).ConfigureAwait(false); + } + + /// + /// Holds the completion source, optional predicate, subscription, and + /// cancellation registration for a single + /// call. Encapsulates the cleanup logic so completion, cancellation, and + /// predicate paths all share the same teardown. + /// + private sealed class WaitState + { + private readonly TaskCompletionSource _tcs; + private readonly Func? _predicate; + + internal EventDelegateContainer? Subscription; + internal CancellationTokenRegistration Registration; + + internal WaitState(TaskCompletionSource tcs, Func? predicate) + { + _tcs = tcs; + _predicate = predicate; + } + + internal void Handle(TArgs args) + { + if (_predicate is not null && !_predicate(args)) + return; + + if (_tcs.TrySetResult(args)) + Cleanup(); + } + + internal void Cancel() + { + if (_tcs.TrySetCanceled()) + Cleanup(); + } + + private void Cleanup() + { + Subscription?.Dispose(); + Registration.Dispose(); + } + } +} diff --git a/Prowl.Runtime/Events/EventManager.cs b/Prowl.Runtime/Events/EventManager.cs new file mode 100644 index 000000000..cc6608ed5 --- /dev/null +++ b/Prowl.Runtime/Events/EventManager.cs @@ -0,0 +1,573 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; + +namespace Prowl.Runtime.Events; + +public partial class EventManager : IDisposable where T : struct, Enum +{ + private static readonly List> s_instances = new List>(); + private static readonly object s_instancesLock = new(); + private bool _disposed; + + /// + /// Copy-on-write snapshot of the static instances list, rebuilt only on Add/Remove. + /// + private static EventManager[] s_instancesSnapshot = []; + + /// + /// Copy-on-write snapshot containing only global managers. + /// Rebuilt when instances or their flag change. + /// + private static EventManager[] s_globalSnapshot = []; + + /// + /// Rebuilds the global-only snapshot from the current instances list. + /// Must be called under . + /// + private static void RebuildGlobalSnapshot() + { + List> globals = new List>(); + for (int i = 0; i < s_instances.Count; i++) + { + if (s_instances[i].Global) + globals.Add(s_instances[i]); + } + s_globalSnapshot = globals.Count > 0 ? [.. globals] : []; + } + + public static EventManager LastGlobalInstance + { + get + { + EventManager[] snapshot = s_globalSnapshot; + for (int i = snapshot.Length - 1; i >= 0; i--) + { + if (snapshot[i].Enabled) + return snapshot[i]; + } + return null; + } + } + + private readonly ConcurrentDictionary> _events = new ConcurrentDictionary>(); + private int _batchDepth; + + private bool global = false; + public bool Global + { + get => global; + set + { + if (global == value) return; + global = value; + lock (s_instancesLock) + { + RebuildGlobalSnapshot(); + } + } + } + + private bool enabled = true; + public bool Enabled + { + get => enabled; + set + { + enabled = value; + foreach (Event xEvent in _events.Values) + { + xEvent.Enabled = value; + } + } + } + + public EventManager(bool global = false) + { + Global = global; + lock (s_instancesLock) + { + s_instances.Add(this); + s_instancesSnapshot = [.. s_instances]; + RebuildGlobalSnapshot(); + } + } + + /// + /// Returns the for the given enum value, + /// creating it atomically on first access via . + /// + private Event GetOrCreateEvent(T eventType) + { + return _events.GetOrAdd(eventType, key => + { + Event evt = new Event(this, key); + if (!enabled) + evt.Enabled = false; + int depth = _batchDepth; + for (int i = 0; i < depth; i++) + evt.BeginBatch(); + return evt; + }); + } + + public bool AddDelegate(EventDelegateContainer eventDelegate, bool allowMultiple = false) + { + return GetOrCreateEvent(eventDelegate.EventType).Add(eventDelegate, allowMultiple); + } + + public void RemoveDelegate(EventDelegateContainer eventDelegate) + { + if (_events.TryGetValue(eventDelegate.EventType, out Event? evt)) + { + evt.Remove(eventDelegate); + } + } + + /// + /// Removes the first delegate container that wraps the given handler delegate. + /// Used by the generated event -= accessors. + /// + public bool RemoveDelegate(T eventType, Delegate handler) + { + if (_events.TryGetValue(eventType, out Event? evt)) + return evt.RemoveByDelegate(handler); + return false; + } + + /// + /// Begins a batch operation on all existing events. While batched, + /// and will not + /// rebuild COW snapshots. Call when finished. + /// Calls may be nested. + /// + public void BeginBatch() + { + _batchDepth++; + foreach (Event evt in _events.Values) + evt.BeginBatch(); + } + + /// + /// Ends a batch operation. If this is the outermost batch and mutations + /// occurred, COW snapshots are rebuilt once per event. + /// + public void EndBatch() + { + if (_batchDepth > 0) + _batchDepth--; + foreach (Event evt in _events.Values) + evt.EndBatch(); + } + + + + public void RemoveEvent(Event xEvent) + { + _events.Remove(xEvent.EventType, out _); + } + + public void EnableEvent(T eventType) + { + GetOrCreateEvent(eventType).Enabled = true; + } + + public void DisableEvent(T eventType) + { + GetOrCreateEvent(eventType).Enabled = false; + } + + public void EnableByTag(string tag) + { + ObjectDisposedException.ThrowIf(_disposed, this); + foreach (var evt in _events.Values) + evt.EnableByTag(tag); + } + + public void DisableByTag(string tag) + { + ObjectDisposedException.ThrowIf(_disposed, this); + foreach (var evt in _events.Values) + evt.DisableByTag(tag); + } + + public void RemoveByTag(string tag) + { + ObjectDisposedException.ThrowIf(_disposed, this); + foreach (var evt in _events.Values) + evt.RemoveByTag(tag); + } + + + /// + /// Invoke an event with typed arguments. Only delegates registered + /// with a matching will be called. + /// + public void InvokeEvent(T eventType, TArgs args) + { + if (_disposed || !Enabled) return; + + if (!EventArgsContract.IsValid(eventType)) + { + EventSystemDiagnostics.LogError?.Invoke( + $"[EventSystem] Type mismatch on {typeof(T).Name}.{eventType}: " + + $"invoked with '{typeof(TArgs).Name}' but the event declares " + + $"'{EventArgsContract.GetDeclaredName(eventType)}' via [EventArgs]."); + return; + } + + if (_events.TryGetValue(eventType, out Event? evt)) + evt.Invoke(args); + } + + /// + /// Returns the for the given enum value if it + /// has been created, or null if no subscribers have been registered. + /// + public Event? GetEvent(T eventType) + { + _events.TryGetValue(eventType, out Event? evt); + return evt; + } + + /// + /// Invoke a parameterless event. + /// + public void InvokeEvent(T eventType) + { + InvokeEvent(eventType, default(Unit)); + } + + /// + /// Asynchronously invoke an event with typed arguments. Async handlers are + /// awaited sequentially; synchronous handlers are called inline. + /// Intended for editor/tool events that perform I/O. + /// + public async Task InvokeEventAsync(T eventType, TArgs args) + { + if (_disposed || !Enabled) return; + + if (!EventArgsContract.IsValid(eventType)) + { + EventSystemDiagnostics.LogError?.Invoke( + $"[EventSystem] Type mismatch on {typeof(T).Name}.{eventType}: " + + $"invoked with '{typeof(TArgs).Name}' but the event declares " + + $"'{EventArgsContract.GetDeclaredName(eventType)}' " + + $"via [EventArgs]."); + return; + } + + if (_events.TryGetValue(eventType, out Event? evt)) + await evt.InvokeAsync(args).ConfigureAwait(false); + } + + /// + /// Asynchronously invoke a parameterless event. + /// + public Task InvokeEventAsync(T eventType) + { + return InvokeEventAsync(eventType, default(Unit)); + } + + /// + /// Register a typed delegate for an event. + /// + public EventDelegateContainer AddNewDelegate( + T eventType, Action eventDelegate, ExecutionOrder priority = default, +#if EVENT_DEBUG + [CallerFilePath] string? sourceFile = null, + [CallerLineNumber] int sourceLine = 0, + [CallerMemberName] string? sourceMember = null, +#else + string? sourceFile = null, + int sourceLine = 0, + string? sourceMember = null, +#endif + bool allowMultiple = false, + string[]? tags = null + ) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (!EventArgsContract.IsValid(eventType)) + { + throw new InvalidOperationException( + $"[EventSystem] Type mismatch on {typeof(T).Name}.{eventType}: " + + $"handler registered with '{typeof(TArgs).Name}' but the event " + + $"declares '{EventArgsContract.GetDeclaredName(eventType)}' " + + $"via [EventArgs]. Fix the subscriber's type parameter."); + } + EventDelegateContainer container = new(eventType, eventDelegate, priority, sourceFile, sourceLine, sourceMember, tags); + GetOrCreateEvent(eventType).Add(container, allowMultiple); + return container; + } + + /// + /// Register a parameterless delegate for an event. + /// + public EventDelegateContainer AddNewDelegate( + T eventType, Action eventDelegate, ExecutionOrder priority = default, +#if EVENT_DEBUG + [CallerFilePath] string? sourceFile = null, + [CallerLineNumber] int sourceLine = 0, + [CallerMemberName] string? sourceMember = null +#else + string? sourceFile = null, + int sourceLine = 0, + string? sourceMember = null +#endif + ,bool allowMultiple = false, + string[]? tags = null + ) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (!EventArgsContract.IsValid(eventType)) + { + throw new InvalidOperationException( + $"[EventSystem] Type mismatch on {typeof(T).Name}.{eventType}: " + + $"handler registered with 'Unit' (parameterless) but the event " + + $"declares '{EventArgsContract.GetDeclaredName(eventType)}' " + + $"via [EventArgs]. Fix the subscriber's type parameter."); + } + + ParameterlessEventDelegateContainer container = new(eventType, eventDelegate, priority, sourceFile, sourceLine, sourceMember, tags); + GetOrCreateEvent(eventType).Add(container, allowMultiple); + return container; + } + + /// + /// Register a typed delegate that is automatically disposed after a single invocation. + /// + public OneTimeEventDelegateContainer SubscribeOnce( + T eventType, Action eventDelegate, ExecutionOrder priority = default, +#if EVENT_DEBUG + [CallerFilePath] string? sourceFile = null, + [CallerLineNumber] int sourceLine = 0, + [CallerMemberName] string? sourceMember = null, +#else + string? sourceFile = null, + int sourceLine = 0, + string? sourceMember = null, +#endif + string[]? tags = null + ) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (!EventArgsContract.IsValid(eventType)) + { + throw new InvalidOperationException( + $"[EventSystem] Type mismatch on {typeof(T).Name}.{eventType}: " + + $"handler registered with '{typeof(TArgs).Name}' but the event " + + $"declares '{EventArgsContract.GetDeclaredName(eventType)}' " + + $"via [EventArgs]. Fix the subscriber's type parameter."); + } + OneTimeEventDelegateContainer container = new(eventType, eventDelegate, priority, sourceFile, sourceLine, sourceMember, tags); + GetOrCreateEvent(eventType).Add(container); + return container; + } + + /// + /// Register a parameterless delegate that is automatically disposed after a single invocation. + /// + public OneTimeParameterlessEventDelegateContainer SubscribeOnce( + T eventType, Action eventDelegate, ExecutionOrder priority = default, +#if EVENT_DEBUG + [CallerFilePath] string? sourceFile = null, + [CallerLineNumber] int sourceLine = 0, + [CallerMemberName] string? sourceMember = null, +#else + string? sourceFile = null, + int sourceLine = 0, + string? sourceMember = null, +#endif + string[]? tags = null + ) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (!EventArgsContract.IsValid(eventType)) + { + throw new InvalidOperationException( + $"[EventSystem] Type mismatch on {typeof(T).Name}.{eventType}: " + + $"handler registered with 'Unit' (parameterless) but the event " + + $"declares '{EventArgsContract.GetDeclaredName(eventType)}' " + + $"via [EventArgs]. Fix the subscriber's type parameter."); + } + + OneTimeParameterlessEventDelegateContainer container = new(eventType, eventDelegate, priority, sourceFile, sourceLine, sourceMember, tags); + GetOrCreateEvent(eventType).Add(container); + return container; + } + + + /// + /// Register a typed async delegate for an event. + /// + public AsyncEventDelegateContainer AddNewAsyncDelegate( + T eventType, Func eventDelegate, ExecutionOrder priority = default, +#if EVENT_DEBUG + [CallerFilePath] string? sourceFile = null, + [CallerLineNumber] int sourceLine = 0, + [CallerMemberName] string? sourceMember = null +#else + string? sourceFile = null, + int sourceLine = 0, + string? sourceMember = null +#endif + , bool allowMultiple = false, + string[]? tags = null + ) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (!EventArgsContract.IsValid(eventType)) + { + throw new InvalidOperationException( + $"[EventSystem] Type mismatch on {typeof(T).Name}.{eventType}: " + + $"async handler registered with '{typeof(TArgs).Name}' but the event " + + $"declares '{EventArgsContract.GetDeclaredName(eventType)}' " + + $"via [EventArgs]. Fix the subscriber's type parameter."); + } + AsyncEventDelegateContainer container = new(eventType, eventDelegate, priority, sourceFile, sourceLine, sourceMember, tags); + GetOrCreateEvent(eventType).Add(container, allowMultiple); + return container; + } + + /// + /// Register a parameterless async delegate for an event. + /// + public AsyncEventDelegateContainer AddNewAsyncDelegate( + T eventType, Func eventDelegate, ExecutionOrder priority = default, +#if EVENT_DEBUG + [CallerFilePath] string? sourceFile = null, + [CallerLineNumber] int sourceLine = 0, + [CallerMemberName] string? sourceMember = null +#else + string? sourceFile = null, + int sourceLine = 0, + string? sourceMember = null +#endif + , bool allowMultiple = false, + string[]? tags = null + ) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (!EventArgsContract.IsValid(eventType)) + { + throw new InvalidOperationException( + $"[EventSystem] Type mismatch on {typeof(T).Name}.{eventType}: " + + $"async handler registered with 'Unit' (parameterless) but the event " + + $"declares '{EventArgsContract.GetDeclaredName(eventType)}' " + + $"via [EventArgs]. Fix the subscriber's type parameter."); + } + + ParameterlessAsyncEventDelegateContainer container = new(eventType, eventDelegate, priority, sourceFile, sourceLine, sourceMember, tags); + GetOrCreateEvent(eventType).Add(container, allowMultiple); + return container; + } + + + /// + /// Invoke an event with typed arguments across all global managers. + /// + public static void GlobalInvokeEvent(T eventType, TArgs args) + { + EventManager[] snapshot = s_globalSnapshot; + + for (int i = 0; i < snapshot.Length; i++) + { + EventManager instance = snapshot[i]; + if (instance.Enabled) + { + instance.InvokeEvent(eventType, args); + } + } + } + + /// + /// Invoke a parameterless event across all global managers. + /// + public static void GlobalInvokeEvent(T eventType) + { + GlobalInvokeEvent(eventType, default(Unit)); + } + + public static void GlobalEnableByTag(string tag) + { + EventManager[] snapshot = s_instancesSnapshot; + for (int i = 0; i < snapshot.Length; i++) + snapshot[i].EnableByTag(tag); + } + + public static void GlobalDisableByTag(string tag) + { + EventManager[] snapshot = s_instancesSnapshot; + for (int i = 0; i < snapshot.Length; i++) + snapshot[i].DisableByTag(tag); + } + + public static void GlobalRemoveByTag(string tag) + { + EventManager[] snapshot = s_instancesSnapshot; + for (int i = 0; i < snapshot.Length; i++) + snapshot[i].RemoveByTag(tag); + } + + /// + /// Asynchronously invoke an event with typed arguments across all global managers. + /// + public static async Task GlobalInvokeEventAsync(T eventType, TArgs args) + { + EventManager[] snapshot = s_globalSnapshot; + + for (int i = 0; i < snapshot.Length; i++) + { + EventManager instance = snapshot[i]; + if (instance.Enabled) + { + await instance.InvokeEventAsync(eventType, args).ConfigureAwait(false); + } + } + } + + /// + /// Asynchronously invoke a parameterless event across all global managers. + /// + public static Task GlobalInvokeEventAsync(T eventType) + { + return GlobalInvokeEventAsync(eventType, default(Unit)); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + enabled = false; + foreach (Event evt in _events.Values) + evt.Enabled = false; + _events.Clear(); + lock (s_instancesLock) + { + s_instances.Remove(this); + s_instancesSnapshot = [.. s_instances]; + RebuildGlobalSnapshot(); + } + GC.SuppressFinalize(this); + } + + ~EventManager() + { + if (!_disposed) + { + EventSystemDiagnostics.LogWarning?.Invoke($"EventManager<{typeof(T).Name}> was not disposed before finalization."); + } + } +} diff --git a/Prowl.Runtime/Events/EventParam.cs b/Prowl.Runtime/Events/EventParam.cs new file mode 100644 index 000000000..1af96cec2 --- /dev/null +++ b/Prowl.Runtime/Events/EventParam.cs @@ -0,0 +1,11 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +namespace Prowl.Runtime.Events { + +/// +/// A zero-size struct used as TArgs for parameterless events. +/// +public readonly struct Unit; + +} diff --git a/Prowl.Runtime/Events/EventSystemDiagnostics.cs b/Prowl.Runtime/Events/EventSystemDiagnostics.cs new file mode 100644 index 000000000..1cc4700a6 --- /dev/null +++ b/Prowl.Runtime/Events/EventSystemDiagnostics.cs @@ -0,0 +1,28 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; + +namespace Prowl.Runtime.Events { + +/// +/// Configurable logging hooks for the event system. +/// The hosting application should assign +/// and during startup +/// so that diagnostic messages are routed through the engine's logging +/// infrastructure. +/// +public static class EventSystemDiagnostics +{ + /// + /// Called for non-critical diagnostic messages (e.g. slow handlers, sync-over-async). + /// + public static Action? LogWarning { get; set; } + + /// + /// Called for error-level diagnostic messages (e.g. type-mismatch on invoke). + /// + public static Action? LogError { get; set; } +} + +} diff --git a/Prowl.Runtime/Events/ExecutionOrder.cs b/Prowl.Runtime/Events/ExecutionOrder.cs new file mode 100644 index 000000000..c3dabe1a5 --- /dev/null +++ b/Prowl.Runtime/Events/ExecutionOrder.cs @@ -0,0 +1,132 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace Prowl.Runtime.Events; + +/// +/// Represents a hierarchical execution order as an ordered array of integers. +/// Used to stably order event delivery (Update, FixedUpdate, etc.) according to +/// scene hierarchy depth and sibling index. +/// Comparison is lexicographic: [0,34,5] < [1,0,2] < [1,0,4]. +/// A default instance is equivalent to [0]. +/// +public readonly struct ExecutionOrder : IComparable, IEquatable +{ + private static readonly int[] s_default = [0]; + + private readonly int[]? _levels; + + /// The order levels (path in the hierarchy). Never null; defaults to a single-element [0]. + public ReadOnlySpan Levels => _levels ?? s_default; + + public ExecutionOrder(params int[] levels) + { + _levels = levels is { Length: > 0 } ? levels : null; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int CompareTo(ExecutionOrder other) + { + int[] a = _levels ?? s_default; + int[] b = other._levels ?? s_default; + if (ReferenceEquals(a, b)) return 0; + return CompareLevels(a, b); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int CompareLevels(int[] a, int[] b) + { + int len = Math.Min(a.Length, b.Length); + for (int i = 0; i < len; i++) + { + int d = a[i] - b[i]; + if (d != 0) return d; + } + return a.Length - b.Length; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool Equals(ExecutionOrder other) + { + int[] a = _levels ?? s_default; + int[] b = other._levels ?? s_default; + if (ReferenceEquals(a, b)) return true; + return a.AsSpan().SequenceEqual(b); + } + + public override bool Equals(object? obj) => obj is ExecutionOrder other && Equals(other); + + public override int GetHashCode() + { + int[] levels = _levels ?? s_default; + HashCode hc = new(); + for (int i = 0; i < levels.Length; i++) + hc.Add(levels[i]); + return hc.ToHashCode(); + } + + public static bool operator ==(ExecutionOrder left, ExecutionOrder right) => left.Equals(right); + public static bool operator !=(ExecutionOrder left, ExecutionOrder right) => !left.Equals(right); + public static bool operator <(ExecutionOrder left, ExecutionOrder right) => left.CompareTo(right) < 0; + public static bool operator >(ExecutionOrder left, ExecutionOrder right) => left.CompareTo(right) > 0; + public static bool operator <=(ExecutionOrder left, ExecutionOrder right) => left.CompareTo(right) <= 0; + public static bool operator >=(ExecutionOrder left, ExecutionOrder right) => left.CompareTo(right) >= 0; + + /// Allows int to be used where is expected. + public static implicit operator ExecutionOrder(int single) => new(single); + + /// Allows int[] to be used where is expected. + public static implicit operator ExecutionOrder(int[] levels) => new(levels); + + public override string ToString() + { + int[] levels = _levels ?? s_default; + if (levels.Length == 1) return levels[0].ToString(); + return "[" + string.Join(",", levels) + "]"; + } + + /// + /// Sorts a in-place using an insertion sort + /// optimized for the small lists typical in event systems. Avoids repeated + /// construction by working directly with the + /// underlying int[] arrays and uses binary search on the already-sorted + /// prefix to find the insertion point, reducing the number of comparisons from + /// O(n²) to O(n log n) while keeping O(n²) moves (which dominate only at + /// larger sizes where the list would be unusual for an event system). + /// + public static void Sort(List list) + { + Span span = CollectionsMarshal.AsSpan(list); + int count = span.Length; + if (count <= 1) return; + + for (int i = 1; i < count; i++) + { + ExecutionOrder key = span[i]; + int[] keyLevels = key._levels ?? s_default; + + // Binary search in the sorted region [0..i) + int lo = 0, hi = i; + while (lo < hi) + { + int mid = (lo + hi) >>> 1; + if (CompareLevels(span[mid]._levels ?? s_default, keyLevels) <= 0) + lo = mid + 1; + else + hi = mid; + } + + // Shift elements [lo..i) right by one + if (lo < i) + { + span.Slice(lo, i - lo).CopyTo(span.Slice(lo + 1)); + span[lo] = key; + } + } + } +} diff --git a/Prowl.Runtime/Events/GameEvents.cs b/Prowl.Runtime/Events/GameEvents.cs new file mode 100644 index 000000000..adccc3e93 --- /dev/null +++ b/Prowl.Runtime/Events/GameEvents.cs @@ -0,0 +1,39 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +using Prowl.Runtime.Resources; + +namespace Prowl.Runtime.Events; + +[EventDomain] +public partial class GameEvents +{ + [EventArgs(typeof(GameEventsArgs))] + private static readonly EventKey _OnBeforeBeginUpdate = new(); + [EventArgs(typeof(GameEventsArgs))] + private static readonly EventKey _OnAfterBeginUpdate = new(); + + [EventArgs(typeof(GameEventsArgs))] + private static readonly EventKey _OnBeforeFixedUpdate = new(); + [EventArgs(typeof(GameEventsArgs))] + private static readonly EventKey _OnAfterFixedUpdate = new(); + + [EventArgs(typeof(GameEventsArgs))] + private static readonly EventKey _OnBeforeUpdate = new(); + private static readonly EventKey _OnUpdate = new(); + [EventArgs(typeof(GameEventsArgs))] + private static readonly EventKey _OnAfterUpdate = new(); + + [EventArgs(typeof(GameEventsArgs))] + private static readonly EventKey _OnBeforeEndUpdate = new(); + [EventArgs(typeof(GameEventsArgs))] + private static readonly EventKey _OnAfterEndUpdate = new(); + + public readonly record struct GameEventsArgs(Scene? Scene); +} diff --git a/Prowl.Runtime/Events/IAsyncInvocable.cs b/Prowl.Runtime/Events/IAsyncInvocable.cs new file mode 100644 index 000000000..3be9ff333 --- /dev/null +++ b/Prowl.Runtime/Events/IAsyncInvocable.cs @@ -0,0 +1,19 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System.Threading.Tasks; + +namespace Prowl.Runtime.Events { + +/// +/// Interface for typed, async invocation of an event handler. +/// Implemented by so that +/// can await async handlers while still +/// calling synchronous handlers inline. +/// +public interface IAsyncInvocable +{ + Task InvokeAsync(TArgs args); +} + +} diff --git a/Prowl.Runtime/Events/ICancellable.cs b/Prowl.Runtime/Events/ICancellable.cs new file mode 100644 index 000000000..dc2faed08 --- /dev/null +++ b/Prowl.Runtime/Events/ICancellable.cs @@ -0,0 +1,16 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +namespace Prowl.Runtime.Events { + +/// +/// Implement on event argument types to allow handlers to stop further propagation. +/// When a handler sets to true, subsequent handlers +/// in the priority chain are skipped. +/// +public interface ICancellable +{ + bool Cancelled { get; set; } +} + +} diff --git a/Prowl.Runtime/Events/IEventDelegateContainer.cs b/Prowl.Runtime/Events/IEventDelegateContainer.cs new file mode 100644 index 000000000..59b1f24f2 --- /dev/null +++ b/Prowl.Runtime/Events/IEventDelegateContainer.cs @@ -0,0 +1,22 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Prowl.Runtime.Events { + +public interface IEventDelegateContainer : IDisposable +{ + public bool Enabled { get; } + public void Enable(); + public void Disable(); + + public bool Added { get; } + public bool HasTag(string tag); +} + +} diff --git a/Prowl.Runtime/Events/IEventManagerHolder.cs b/Prowl.Runtime/Events/IEventManagerHolder.cs new file mode 100644 index 000000000..04fba982a --- /dev/null +++ b/Prowl.Runtime/Events/IEventManagerHolder.cs @@ -0,0 +1,51 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; +using System.Collections.Generic; + +namespace Prowl.Runtime.Events { + +public interface IEventManagerHolder where T : struct, Enum +{ + EventManager EventManager { get; } +} + +public static class EventManagerExtensions +{ + public static void InvokeEvents(this IEventManagerHolder[] holders, T eventType, TArgs args) where T : struct, Enum + { + for (int i = 0; i < holders.Length; i++) + { + IEventManagerHolder? holder = holders[i]; + holder?.EventManager.InvokeEvent(eventType, args); + } + } + public static void InvokeEvents(this List> holders, T eventType, TArgs args) where T : struct, Enum + { + for (int i = 0; i < holders.Count; i++) + { + IEventManagerHolder holder = holders[i]; + holder?.EventManager.InvokeEvent(eventType, args); + } + } + + public static void InvokeEvents(this IEventManagerHolder[] holders, T eventType) where T : struct, Enum + { + for (int i = 0; i < holders.Length; i++) + { + IEventManagerHolder? holder = holders[i]; + holder?.EventManager.InvokeEvent(eventType); + } + } + public static void InvokeEvents(this List> holders, T eventType) where T : struct, Enum + { + for (int i = 0; i < holders.Count; i++) + { + IEventManagerHolder holder = holders[i]; + holder?.EventManager.InvokeEvent(eventType); + } + } +} + +} diff --git a/Prowl.Runtime/Events/IInvocable.cs b/Prowl.Runtime/Events/IInvocable.cs new file mode 100644 index 000000000..e6d81f9fa --- /dev/null +++ b/Prowl.Runtime/Events/IInvocable.cs @@ -0,0 +1,16 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +namespace Prowl.Runtime.Events { + +/// +/// Interface for typed, zero-cast invocation of an event handler. +/// Implemented by so that +/// callers holding a typed reference can invoke without a runtime type check. +/// +public interface IInvocable +{ + void Invoke(TArgs args); +} + +} diff --git a/Prowl.Runtime/Events/SceneEvents.cs b/Prowl.Runtime/Events/SceneEvents.cs new file mode 100644 index 000000000..c01955913 --- /dev/null +++ b/Prowl.Runtime/Events/SceneEvents.cs @@ -0,0 +1,35 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +using Prowl.PaperUI; +using Prowl.Runtime.Rendering; +using Prowl.Runtime.Resources; + +using Silk.NET.Windowing; + +namespace Prowl.Runtime.Events; + +[EventDomain] +public partial class SceneEvents +{ + private static readonly EventKey _FixedUpdate = new(); + private static readonly EventKey _Update = new(); + private static readonly EventKey _LateUpdate = new(); + private static readonly EventKey _PreUpdate = new(); + [EventArgs(typeof(OnRenderCollectArgs))] + private static readonly EventKey _OnRenderCollect = new(); + private static readonly EventKey _DrawGizmos = new(); + [EventArgs(typeof(Paper))] + private static readonly EventKey _OnGui = new(); + + private static readonly EventKey _OnBeforeUpdates = new(); + private static readonly EventKey _OnFlush = new(); + + public readonly record struct OnRenderCollectArgs(Camera camera, List renderables, List lights); +} diff --git a/Prowl.Runtime/Events/WindowEvents.cs b/Prowl.Runtime/Events/WindowEvents.cs new file mode 100644 index 000000000..f223e1b8c --- /dev/null +++ b/Prowl.Runtime/Events/WindowEvents.cs @@ -0,0 +1,48 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +using Prowl.Runtime.Resources; + +using Silk.NET.Maths; +using Silk.NET.Windowing; + +namespace Prowl.Runtime.Events; + +[EventDomain] +public static partial class WindowEvents +{ + private static readonly EventKey _Load = new(); + [EventArgs(typeof(FloatArgs))] + private static readonly EventKey _Update = new(); + [EventArgs(typeof(float))] + private static readonly EventKey _Render = new(); + [EventArgs(typeof(FloatArgs))] + private static readonly EventKey _PostRender = new(); + [EventArgs(typeof(BoolArgs))] + private static readonly EventKey _FocusChanged = new(); + [EventArgs(typeof(Vector2D))] + private static readonly EventKey _Resize = new(); + [EventArgs(typeof(Vector2IntArgs))] + private static readonly EventKey _FramebufferResize = new(); + private static readonly EventKey _Closing = new(); + + [EventArgs(typeof(Vector2IntArgs))] + private static readonly EventKey _Move = new(); + [EventArgs(typeof(WindowStateArgs))] + private static readonly EventKey _StateChanged = new(); + [EventArgs(typeof(FileDropArgs))] + private static readonly EventKey _FileDrop = new(); + + + public readonly record struct FloatArgs(float Value); + public readonly record struct BoolArgs(bool Value); + public readonly record struct Vector2IntArgs(Vector2D Value); + public readonly record struct WindowStateArgs(WindowState Value); + public readonly record struct FileDropArgs(string[] Paths); +} diff --git a/Prowl.Runtime/Game.cs b/Prowl.Runtime/Game.cs index df36b0001..0e7b93ee2 100644 --- a/Prowl.Runtime/Game.cs +++ b/Prowl.Runtime/Game.cs @@ -5,14 +5,16 @@ using Echo.Logging; -using Prowl.Runtime.Audio; - using Prowl.PaperUI; +using Prowl.Runtime.Audio; +using Prowl.Runtime.Events; using Prowl.Runtime.GUI; using Prowl.Runtime.Resources; using Prowl.Runtime.UI; using Prowl.Vector; +using static Prowl.Runtime.Events.GameEvents; + namespace Prowl.Runtime; public class EchoLogger : IEchoLogger @@ -37,6 +39,16 @@ public abstract class Game public Paper PaperInstance => _paper; + private GameEvents _events; + public GameEvents Events + { + get + { + _events ??= new GameEvents(); + return _events; + } + } + public bool DrawGizmos { get; set; } /// @@ -63,7 +75,7 @@ public void Run(string title, int width, int height) InitializeWindow(title, width, height); - Window.Load += () => + WindowEvents.Load += () => { AudioContext.Initialize(44100, 2, 2048); @@ -82,10 +94,14 @@ public void Run(string title, int width, int height) Initialize(); }; - Window.Update += (delta) => + WindowEvents.Update.Subscribe((args) => { try { + Scene? currentScene = Scene.Current; + + GameEventsArgs gameEventsArgs = new(currentScene); + UpdatePaperInput(); AudioContext.Update(); @@ -94,24 +110,28 @@ public void Run(string title, int width, int height) Time.TimeStack.Clear(); Time.TimeStack.Push(time); - Input.UpdateActions(delta); + Input.UpdateActions(args.Value); // UI input runs after low-level Input is fresh and before script Updates UIEventSystem.Tick(time.Time); + Events.InvokeOnBeforeBeginUpdate(gameEventsArgs); + BeginUpdate(); - Scene? currentScene = Scene.Current; + Events.InvokeOnAfterBeginUpdate(gameEventsArgs); // Fixed update loop only when gameplay should run - fixedTimeAccumulator += delta; + fixedTimeAccumulator += args.Value; if (Application.ShouldRunGameplay) { Application.IsGameplayExecuting = true; int count = 0; while (fixedTimeAccumulator >= Time.FixedDeltaTime && count++ < Time.MaxFixedIterations) { + Events.InvokeOnBeforeFixedUpdate(gameEventsArgs); currentScene?.FixedUpdate(); + Events.InvokeOnAfterFixedUpdate(gameEventsArgs); fixedTimeAccumulator -= Time.FixedDeltaTime; } Application.IsGameplayExecuting = false; @@ -122,8 +142,12 @@ public void Run(string title, int width, int height) fixedTimeAccumulator = MathF.Min(fixedTimeAccumulator, Time.FixedDeltaTime); } + Events.InvokeOnBeforeUpdate(gameEventsArgs); + OnUpdate(currentScene); + Events.InvokeOnAfterUpdate(gameEventsArgs); + // Consume step request re-pause after one frame if (Application.StepRequested) { @@ -131,8 +155,12 @@ public void Run(string title, int width, int height) Application.IsPaused = true; } + Events.InvokeOnBeforeEndUpdate(gameEventsArgs); + EndUpdate(); + Events.InvokeOnAfterEndUpdate(gameEventsArgs); + if (frameCounter++ % 60 == 0) { Console.Title = $"{title} - {Window.InternalWindow.FramebufferSize.X}x{Window.InternalWindow.FramebufferSize.Y} - FPS: {1.0 / Time.DeltaTime}"; @@ -145,9 +173,9 @@ public void Run(string title, int width, int height) Debug.LogError(e.ToString()); throw; } - }; + }); - Window.Render += (delta) => + WindowEvents.Render += (delta) => { try { @@ -216,18 +244,18 @@ public void Run(string title, int width, int height) } }; - Window.Resize += (size) => + WindowEvents.Resize += (size) => { // Paper's resolution is resynced from PreparePaperFrame each render frame. Resize(size.X, size.Y); }; - Window.FramebufferResize += (size) => + WindowEvents.FramebufferResize += (size) => { - _paperRenderer.UpdateProjection(size.X, size.Y); + _paperRenderer.UpdateProjection(size.Value.X, size.Value.Y); }; - Window.Closing += () => + WindowEvents.Closing += () => { Closing(); diff --git a/Prowl.Runtime/GameObject/GameObject.cs b/Prowl.Runtime/GameObject/GameObject.cs index a1492037a..ab0506792 100644 --- a/Prowl.Runtime/GameObject/GameObject.cs +++ b/Prowl.Runtime/GameObject/GameObject.cs @@ -7,9 +7,11 @@ using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using Prowl.Echo; using Prowl.Runtime.Resources; +using Prowl.Runtime.Events; using Prowl.Vector; namespace Prowl.Runtime; @@ -115,7 +117,19 @@ public bool IsStatic public Scene? Scene { get => _scene != null && _scene.TryGetTarget(out Scene? scene) ? scene : null; - internal set => _scene = new(value); + internal set + { + _scene = new(value); + value?.MarkExecutionOrdersDirty(); + UpdateEventDelegateState(_enabled && _enabledInHierarchy); + ReadOnlySpan components = CollectionsMarshal.AsSpan(_components); + for (int i = 0; i < components.Length; i++) + { + MonoBehaviour component = components[i]; + // Let the component use its own effective state (its Enabled + its eih, which depends on this GO chain) + component.UpdateEventDelegateState(component._enabled && component._enabledInHierarchy); + } + } } /// Is this GameObject a prefab instance? @@ -176,6 +190,104 @@ public Transform Transform return _transform; } } + private bool _eventsInitialized = false; + private ExecutionOrder _executionOrder; + public ExecutionOrder ExecutionOrder + { + get + { + return _executionOrder; + } + set + { + _executionOrder = value; + if (_eventsInitialized && Scene.IsValid()) + { + SubscribeSceneEvents(Scene); + DisposeSceneEvents(); + } + } + } + + public void UpdateExecutionOrder() + { + int[] goLevels; + + if (_parent != null) + { + int sibIdx = _parent.Children.IndexOf(this); + if (sibIdx < 0) sibIdx = 0; + ReadOnlySpan pLevels = _parent.ExecutionOrder.Levels; + goLevels = new int[pLevels.Length + 2]; + pLevels.CopyTo(goLevels); + goLevels[pLevels.Length] = 1; + goLevels[pLevels.Length + 1] = sibIdx; + } + else + { + Scene? scene = Scene; + int sibIdx = scene != null ? scene.GetRootIndex(this) : 0; + if (sibIdx < 0) sibIdx = 0; + goLevels = [sibIdx]; + } + + ExecutionOrder = new ExecutionOrder(goLevels); + + ReadOnlySpan components = GetComponentsAsSpan(); + if (components.Length > 0) + { + int[] compLevels = new int[goLevels.Length + 2]; + goLevels.CopyTo(compLevels, 0); + compLevels[goLevels.Length] = 0; + for (int i = 0; i < components.Length; i++) + { + compLevels[compLevels.Length - 1] = i; + components[i].ExecutionOrder = new ExecutionOrder((int[])compLevels.Clone()); + } + } + } + + private EventDelegateContainer PreUpdateDelegate = null; + + private EventDelegateContainer _disposerDelegate; + + internal void SubscribeSceneEvents(Scene scene) + { + + PreUpdateDelegate = scene.Events.PreUpdate.Subscribe(PreUpdate, ExecutionOrder, allowMultiple: true); + + _eventsInitialized = true; + + // Apply current effective state immediately (in case object or ancestors are disabled) + UpdateEventDelegateState(_enabled && _enabledInHierarchy); + } + + internal void DisposeSceneEvents() + { + PreUpdateDelegate?.Dispose(); + _eventsInitialized = false; + } + + + private void UpdateEventDelegateState(bool enable) + { + if (!_eventsInitialized) + { + Scene?.ToSubscribe.Add(this); + return; + } + + if (enable) + { + + PreUpdateDelegate?.Enable(); + + } + else + { + PreUpdateDelegate?.Disable(); + } + } /// /// Returns the Transform as a if it is one, otherwise null. @@ -289,6 +401,8 @@ public bool SetParent(GameObject NewParent, bool worldPositionStays = true) NewParent.Children.Add(this); _parent = NewParent; + + Scene?.MarkExecutionOrdersDirty(); } if (worldPositionStays) @@ -551,8 +665,11 @@ public MonoBehaviour AddComponent([DynamicallyAccessedMembers(DynamicallyAccesse SortComponents(); - // Trigger OnEnable if the GameObject is in an active scene and enabled Scene? scene = Scene; + if (scene.IsValid()) + scene.ToSubscribe.Add(newComponent); + + // Trigger OnEnable if the GameObject is in an active scene and enabled if (scene.IsValid() && scene.IsActive && newComponent.EnabledInHierarchy) newComponent.InternalOnEnable(); @@ -593,8 +710,11 @@ public void AddComponent(MonoBehaviour comp) SortComponents(); - // Trigger OnEnable if the GameObject is in an active scene and enabled Scene? scene = Scene; + if (scene.IsValid()) + scene.ToSubscribe.Add(comp); + + // Trigger OnEnable if the GameObject is in an active scene and enabled if (scene.IsValid() && scene.IsActive && comp.EnabledInHierarchy) comp.InternalOnEnable(); } @@ -686,6 +806,11 @@ public void RemoveComponent(Guid component) /// The component of type T, or null if not found. public T? GetComponent() where T : MonoBehaviour => (T?)GetComponent(typeof(T)); + public ReadOnlySpan GetComponentsAsSpan() where T : MonoBehaviour + { + return CollectionsMarshal.AsSpan(GetComponentsList()); + } + /// /// Gets the first component of the specified type attached to the GameObject. /// @@ -703,6 +828,46 @@ public void RemoveComponent(Guid component) return null; } + public List GetComponentsList() where T : MonoBehaviour + { + var results = new List(); + + var type = typeof(T); + + if (type == typeof(MonoBehaviour)) + { + ReadOnlySpan compsSpan = CollectionsMarshal.AsSpan(_components); + + for (int i = 0; i < compsSpan.Length; i++) + { + var comp = compsSpan[i]; + if (comp is T t) + results.Add(t); + } + + } + else if (_componentCache.TryGetValue(type, out IReadOnlyCollection? cached)) + { + if (cached is IList list) + { + for (int i = 0; i < cached.Count; i++) + { + if (list[i] is T t) + results.Add(t); + } + } + + } + else + { + foreach (MonoBehaviour comp in _components) + if (comp is T t) + results.Add(t); + } + + return results; + } + /// /// Gets the component with the specified identifier attached to the GameObject. /// @@ -976,6 +1141,14 @@ private void SortComponents() /// public override void OnDispose() { + DisposeSceneEvents(); + + _disposerDelegate = Scene?.Events.OnFlush.Subscribe(() => + { + Scene?.Flush(this); + _disposerDelegate?.Dispose(); + }, ExecutionOrder, allowMultiple: true); + for (int i = Children.Count - 1; i >= 0; i--) Children[i].Dispose(); @@ -1010,6 +1183,7 @@ private void SetEnabled(bool state) { _enabled = state; HierarchyStateChanged(); + UpdateEventDelegateState(_enabled && _enabledInHierarchy); } /// @@ -1021,6 +1195,7 @@ private void HierarchyStateChanged() if (_enabledInHierarchy != newState) { _enabledInHierarchy = newState; + UpdateEventDelegateState(newState); foreach (MonoBehaviour component in GetComponents()) component.HierarchyStateChanged(); } diff --git a/Prowl.Runtime/GameObject/MonoBehaviour.cs b/Prowl.Runtime/GameObject/MonoBehaviour.cs index 0ef3ac11a..efe5a4b13 100644 --- a/Prowl.Runtime/GameObject/MonoBehaviour.cs +++ b/Prowl.Runtime/GameObject/MonoBehaviour.cs @@ -2,11 +2,14 @@ // Licensed under the MIT License. See the LICENSE file in the project root for details. using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Reflection; using Prowl.Echo; using Prowl.PaperUI; +using Prowl.Runtime.Events; using Prowl.Runtime.Rendering; using Prowl.Runtime.Resources; using Prowl.Vector; @@ -89,6 +92,96 @@ internal bool ShouldExecuteGameplay /// public string Tag => _go.Tag; + #region Override Detection Cache + + [Flags] + private enum OverrideFlags : byte + { + None = 0, + Update = 1 << 0, + LateUpdate = 1 << 1, + FixedUpdate = 1 << 2, + OnRenderCollect = 1 << 3, + OnGui = 1 << 4, + DrawGizmos = 1 << 5, + } + + private static readonly ConcurrentDictionary s_overrideCache = new(); + + private static OverrideFlags DetectOverrides(Type type) + { + return s_overrideCache.GetOrAdd(type, static t => + { + OverrideFlags flags = OverrideFlags.None; + Type baseType = typeof(MonoBehaviour); + + if (t.GetMethod(nameof(Update), BindingFlags.Instance | BindingFlags.Public, Type.EmptyTypes)?.DeclaringType != baseType) + flags |= OverrideFlags.Update; + if (t.GetMethod(nameof(LateUpdate), BindingFlags.Instance | BindingFlags.Public, Type.EmptyTypes)?.DeclaringType != baseType) + flags |= OverrideFlags.LateUpdate; + if (t.GetMethod(nameof(FixedUpdate), BindingFlags.Instance | BindingFlags.Public, Type.EmptyTypes)?.DeclaringType != baseType) + flags |= OverrideFlags.FixedUpdate; + if (t.GetMethod(nameof(OnRenderCollect), BindingFlags.Instance | BindingFlags.Public, [typeof(SceneEvents.OnRenderCollectArgs)])?.DeclaringType != baseType) + flags |= OverrideFlags.OnRenderCollect; + if (t.GetMethod(nameof(OnGui), BindingFlags.Instance | BindingFlags.Public, [typeof(Paper)])?.DeclaringType != baseType) + flags |= OverrideFlags.OnGui; + if (t.GetMethod(nameof(DrawGizmos), BindingFlags.Instance | BindingFlags.Public, Type.EmptyTypes)?.DeclaringType != baseType) + flags |= OverrideFlags.DrawGizmos; + + return flags; + }); + } + + [SerializeIgnore] + private OverrideFlags _overrides; + + #endregion + + private ExecutionOrder _executionOrder; + + public ExecutionOrder ExecutionOrder + { + get + { + return _executionOrder; + } + set + { + _executionOrder = value; + if (_eventsInitialized && Scene.IsValid()) + { + DisposeSceneEvents(); + SubscribeSceneEvents(Scene); + } + } + } + + /// + /// Updates this component's based on its owning GameObject's + /// order and this component's index. Uses discriminator 0 so components sort + /// after their GO but before its children. + /// + public void UpdateExecutionOrder() + { + if (!GameObject.IsValid()) return; + + ReadOnlySpan goLevels = GameObject.ExecutionOrder.Levels; + int[] levels = new int[goLevels.Length + 2]; + goLevels.CopyTo(levels); + levels[goLevels.Length] = 0; // discriminator: component + levels[goLevels.Length + 1] = GameObject._components.IndexOf(this); + ExecutionOrder = new ExecutionOrder(levels); + } + + private EventDelegateContainer UpdateDelegate = null; + private EventDelegateContainer LateUpdateDelegate = null; + private EventDelegateContainer FixedUpdateDelegate = null; + private EventDelegateContainer OnRenderCollectDelegate = null; + private EventDelegateContainer OnGuiDelegate = null; + private EventDelegateContainer DrawGizmosDelegate = null; + + private bool _eventsInitialized = false; + /// /// Gets or sets whether the MonoBehaviour is enabled. /// @@ -101,6 +194,7 @@ public bool Enabled { _enabled = value; HierarchyStateChanged(); + UpdateEventDelegateState(_enabled && _enabledInHierarchy); } } } @@ -202,6 +296,8 @@ public void SetSiblingIndex(int index) // Insert at new position GameObject._components.Insert(index, this); + + GameObject.Scene?.MarkExecutionOrdersDirty(); } #endregion @@ -215,6 +311,8 @@ internal void AttachToGameObject(GameObject go) bool isEnabled = _enabled && _go.EnabledInHierarchy; _enabledInHierarchy = isEnabled; + + _go.Scene?.MarkExecutionOrdersDirty(); } /// @@ -237,6 +335,76 @@ internal void HierarchyStateChanged() else InternalOnDisable(); } + + // Sync event subscriptions so disabled-in-hierarchy objects stop receiving Update/FixedUpdate etc. + if (_eventsInitialized) + UpdateEventDelegateState(newState); + } + } + + private void DisposeSceneEvents() + { + if (_eventsInitialized) + { + UpdateDelegate?.Dispose(); + LateUpdateDelegate?.Dispose(); + FixedUpdateDelegate?.Dispose(); + OnRenderCollectDelegate?.Dispose(); + OnGuiDelegate?.Dispose(); + DrawGizmosDelegate?.Dispose(); + + _eventsInitialized = false; + } + } + + internal void SubscribeSceneEvents(Scene scene) + { + _overrides = DetectOverrides(GetType()); + + if (_overrides.HasFlag(OverrideFlags.Update)) + UpdateDelegate = scene.Events.Update.Subscribe(InternalUpdate, ExecutionOrder, allowMultiple: true); + if (_overrides.HasFlag(OverrideFlags.LateUpdate)) + LateUpdateDelegate = scene.Events.LateUpdate.Subscribe(InternalLateUpdate, ExecutionOrder, allowMultiple: true); + if (_overrides.HasFlag(OverrideFlags.FixedUpdate)) + FixedUpdateDelegate = scene.Events.FixedUpdate.Subscribe(InternalFixedUpdate, ExecutionOrder, allowMultiple: true); + if (_overrides.HasFlag(OverrideFlags.OnRenderCollect)) + OnRenderCollectDelegate = scene.Events.OnRenderCollect.Subscribe(OnRenderCollect, ExecutionOrder, allowMultiple: true); + if (_overrides.HasFlag(OverrideFlags.OnGui)) + OnGuiDelegate = scene.Events.OnGui.Subscribe(OnGui, ExecutionOrder, allowMultiple: true); + if (_overrides.HasFlag(OverrideFlags.DrawGizmos)) + DrawGizmosDelegate = scene.Events.DrawGizmos.Subscribe(DrawGizmos, ExecutionOrder, allowMultiple: true); + + _eventsInitialized = true; + + // Apply current effective state immediately (in case the MB or its GO ancestors are disabled at subscription time) + UpdateEventDelegateState(_enabled && _enabledInHierarchy); + } + + internal void UpdateEventDelegateState(bool enable) + { + if (!_eventsInitialized) + { + Scene?.ToSubscribe.Add(this); + return; + } + + if (enable) + { + UpdateDelegate?.Enable(); + LateUpdateDelegate?.Enable(); + FixedUpdateDelegate?.Enable(); + OnRenderCollectDelegate?.Enable(); + OnGuiDelegate?.Enable(); + DrawGizmosDelegate?.Enable(); + } + else + { + UpdateDelegate?.Disable(); + LateUpdateDelegate?.Disable(); + FixedUpdateDelegate?.Disable(); + OnRenderCollectDelegate?.Disable(); + OnGuiDelegate?.Disable(); + DrawGizmosDelegate?.Disable(); } } @@ -312,7 +480,7 @@ public virtual void LateUpdate() { } /// Components add their renderables/lights to the provided lists. /// Camera is provided for LOD and distance-based decisions. /// - public virtual void OnRenderCollect(Camera camera, List renderables, List lights) { } + public virtual void OnRenderCollect(SceneEvents.OnRenderCollectArgs onRenderCollectArgs) { } /// /// Called for rendering and handling GUI gizmos. @@ -395,6 +563,8 @@ public void OnAfterDeserialize() /// public override void OnDispose() { + DisposeSceneEvents(); + if (GameObject.IsValid()) GameObject.RemoveComponent(this); } diff --git a/Prowl.Runtime/Prowl.Runtime.csproj b/Prowl.Runtime/Prowl.Runtime.csproj index f7fb63813..bc4c9b84c 100644 --- a/Prowl.Runtime/Prowl.Runtime.csproj +++ b/Prowl.Runtime/Prowl.Runtime.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -38,6 +38,13 @@ + + + + diff --git a/Prowl.Runtime/Resources/Scene.cs b/Prowl.Runtime/Resources/Scene.cs index d99469a34..12b04ea97 100644 --- a/Prowl.Runtime/Resources/Scene.cs +++ b/Prowl.Runtime/Resources/Scene.cs @@ -1,12 +1,14 @@ -// This file is part of the Prowl Game Engine +// This file is part of the Prowl Game Engine // Licensed under the MIT License. See the LICENSE file in the project root for details. using System; using System.Collections.Generic; using System.Linq; +using System.Runtime.InteropServices; using Prowl.Echo; using Prowl.PaperUI; +using Prowl.Runtime.Events; using Prowl.Runtime.Rendering; using Prowl.Vector; @@ -102,9 +104,24 @@ public static void Unload() public PhysicsWorld Physics => _physics; + public SceneEvents Events { get; } = new(); + [SerializeIgnore] private bool _isActive = false; + private object _lock; + public HashSet ToSubscribe = new(ReferenceEqualityComparer.Instance); + + [SerializeIgnore] + private bool _executionOrdersDirty; + + /// + /// Marks that the execution orders (priorities for event subscriptions) are out of date + /// due to hierarchy changes. The orders will be recalculated once at the start of the + /// next frame, before any new object event subscriptions are processed. + /// + public void MarkExecutionOrdersDirty() => _executionOrdersDirty = true; + public struct FogParams { public enum FogMode @@ -287,6 +304,8 @@ public void Enable() { if (_isActive) throw new Exception("Scene is already enabled!"); + Events.OnBeforeUpdates.Subscribe(SubscribeObjectsToEvents, allowMultiple: true); + _isActive = true; // Create a copy to avoid collection modification during enumeration @@ -311,6 +330,35 @@ public void Enable() } } + public void SubscribeObjectsToEvents() + { + // Ensure execution orders (used as subscription priorities) are up-to-date + // exactly once per frame, before we process any pending object subscriptions. + if (_executionOrdersDirty) + { + RecalculateExecutionOrders(); + } + + Events.Manager.BeginBatch(); + ReadOnlySpan list = CollectionsMarshal.AsSpan(ToSubscribe.ToList()); + ToSubscribe.Clear(); + for (int i = 0; i < list.Length; i++) + { + + EngineObject obj = list[i]; + if (obj.IsDisposed) continue; + if (obj is GameObject go) + { + go.SubscribeSceneEvents(this); + } + else if (obj is MonoBehaviour mb) + { + mb.SubscribeSceneEvents(this); + } + } + Events.Manager.EndBatch(); + } + /// /// Disables this scene, triggering OnDisable callbacks for its components. /// For most use cases, prefer using Scene.Unload() or Scene.Load() instead of calling Disable() directly. @@ -343,7 +391,6 @@ public void Disable() _isActive = false; } - /// /// Registers a GameObject and all of its children. /// @@ -371,6 +418,8 @@ public void SetRootIndex(GameObject obj, int index) index = Math.Max(0, Math.Min(index, rootIndices.Count)); int insertAt = index < rootIndices.Count ? rootIndices[index] : _allObj.Count; _allObj.Insert(insertAt, obj); + + MarkExecutionOrdersDirty(); } /// @@ -516,6 +565,75 @@ private void RemoveObject(GameObject obj) return null; } + /// + /// Assigns an to every GameObject and MonoBehaviour + /// based on its position in the scene hierarchy using a depth-first traversal. + /// + /// Order scheme per GO at hierarchy levels [L0, L1, …, Ln]: + /// + /// GameObject itself: (L0, L1, …, Ln) + /// Component i: (L0, L1, …, Ln, 0, i)0 discriminator sorts before children + /// Child j: (L0, L1, …, Ln, 1, j)1 discriminator sorts after components + /// + /// + /// + public void RecalculateExecutionOrders() + { + _executionOrdersDirty = false; + Events.Manager.BeginBatch(); + // Stack-based DFS. Each entry carries the parent's levels array. + // Children are built as (parentLevels + [1, childIndex]). + var stack = new Stack<(GameObject go, int[] parentLevels, int siblingIndex)>(); + + // Push roots in reverse so first root is processed first. + var roots = RootObjects.ToList(); + for (int i = roots.Count - 1; i >= 0; i--) + stack.Push((roots[i], [], i)); + + while (stack.Count > 0) + { + (GameObject go, int[] parentLevels, int sibIdx) = stack.Pop(); + + if (go.IsDisposed) continue; + + // GO priority = parentLevels + (1, siblingIndex), or just (siblingIndex) for roots. + int[] goLevels; + if (parentLevels.Length == 0) + { + goLevels = [sibIdx]; + } + else + { + goLevels = new int[parentLevels.Length + 2]; + parentLevels.CopyTo(goLevels, 0); + goLevels[parentLevels.Length] = 1; // discriminator: child + goLevels[parentLevels.Length + 1] = sibIdx; + } + + go.ExecutionOrder = new ExecutionOrder(goLevels); + + // Components: (goLevels + [0, componentIndex]) + ReadOnlySpan components = go.GetComponentsAsSpan(); + if (components.Length > 0) + { + int[] compLevels = new int[goLevels.Length + 2]; + goLevels.CopyTo(compLevels, 0); + compLevels[goLevels.Length] = 0; // discriminator: component (sorts before children) + for (int c = 0; c < components.Length; c++) + { + compLevels[compLevels.Length - 1] = c; + components[c].ExecutionOrder = new ExecutionOrder((int[])compLevels.Clone()); + } + } + + // Push children in reverse order so first child is processed first. + List children = go.Children; + for (int i = children.Count - 1; i >= 0; i--) + stack.Push((children[i], goLevels, i)); + } + Events.Manager.EndBatch(); + } + /// Unregisters all GameObjects. public void Clear() { @@ -530,6 +648,8 @@ public void Clear() /// Unregisters all dead / disposed GameObjects public void Flush() { + Events?.OnFlush.Invoke(); + return; List removed = []; foreach (GameObject obj in _allObj) { @@ -544,6 +664,16 @@ public void Flush() obj.Scene = null; } + public void Flush(GameObject gameObject) + { + if (gameObject.IsDisposed) + { + _allObj.Remove(gameObject); + _allObjSet.Remove(gameObject); + gameObject.Scene = null; + } + } + public override void OnDispose() { base.OnDispose(); @@ -614,6 +744,8 @@ public void OnAfterDeserialize() foreach (GameObject obj in serializeObj) Add(obj); + + MarkExecutionOrdersDirty(); } /// @@ -623,13 +755,29 @@ public void OnAfterDeserialize() /// public void Update() { - List activeGOs = [.. ActiveObjects]; - foreach (GameObject go in activeGOs) - go.PreUpdate(); + //List activeGOs = [.. ActiveObjects]; + + //ReadOnlySpan activeGOs = CollectionsMarshal.AsSpan(ActiveObjectsList); + + //for (int i = 0;i < activeGOs.Length; i++) + //{ + // GameObject go = activeGOs[i]; + // go.PreUpdate(); + //} + + + Events.OnBeforeUpdates.Invoke(); - ForeachComponent(activeGOs, (x) => x.InternalUpdate()); + Events.PreUpdate.Invoke(); + Events.Update.Invoke(); + Events.LateUpdate.Invoke(); - ForeachComponent(activeGOs, (x) => x.InternalLateUpdate()); + //foreach (GameObject go in activeGOs) + // go.PreUpdate(); + + //ForeachComponent(activeGOs, (x) => x.InternalUpdate()); + + //ForeachComponent(activeGOs, (x) => x.InternalLateUpdate()); Flush(); } @@ -642,8 +790,10 @@ public void FixedUpdate() { Physics.Update(); - List activeGOs = [.. ActiveObjects]; - ForeachComponent(activeGOs, (x) => x.InternalFixedUpdate()); + Events.FixedUpdate.Invoke(); + + //ReadOnlySpan activeGOs = CollectionsMarshal.AsSpan(ActiveObjectsList); + //ForeachComponent(activeGOs, (x) => x.InternalFixedUpdate()); Flush(); } @@ -654,11 +804,7 @@ public void FixedUpdate() /// public void CollectRenderables(Camera camera, List renderables, List lights) { - List activeGOs = [.. ActiveObjects]; - ForeachComponent(activeGOs, (x) => - { - x.OnRenderCollect(camera, renderables, lights); - }); + Events.OnRenderCollect.Invoke(new(camera, renderables, lights)); } /// @@ -666,13 +812,7 @@ public void CollectRenderables(Camera camera, List renderables, Lis /// public void DrawGizmos() { - List activeGOs = [.. ActiveObjects]; - ForeachComponent(activeGOs, (x) => - { - if (!x.HideFlags.HasFlag(HideFlags.NoGizmos)) - x.DrawGizmos(); - }); - + Events.DrawGizmos.Invoke(); Flush(); } @@ -682,12 +822,7 @@ public void DrawGizmos() /// public void OnGui(Paper paper) { - List activeGOs = [.. ActiveObjects]; - ForeachComponent(activeGOs, (x) => - { - x.OnGui(paper); - }); - + Events.OnGui.Invoke(paper); Flush(); } diff --git a/Prowl.Runtime/Window.cs b/Prowl.Runtime/Window.cs index 2d8efa9cc..c371d8193 100644 --- a/Prowl.Runtime/Window.cs +++ b/Prowl.Runtime/Window.cs @@ -4,6 +4,8 @@ using System; using System.Runtime.InteropServices; +using Prowl.Runtime.Events; + using Silk.NET.Input; using Silk.NET.Maths; using Silk.NET.Windowing; @@ -169,16 +171,17 @@ public static void InitWindow(string title, int width, int height, WindowState s InternalWindow.Resize += OnResize; InternalWindow.FramebufferResize += OnFramebufferResize; InternalWindow.Move += OnMove; - InternalWindow.StateChanged += (state) => StateChanged?.Invoke(state); - InternalWindow.FileDrop += (files) => FileDrop?.Invoke(files); + InternalWindow.StateChanged += (state) => { StateChanged?.Invoke(state); WindowEvents.StateChanged.Invoke(new WindowEvents.WindowStateArgs(state)); }; + InternalWindow.FileDrop += (files) => { FileDrop?.Invoke(files); WindowEvents.FileDrop.Invoke(new WindowEvents.FileDropArgs(files)); }; InternalWindow.FocusChanged += (focused) => { isFocused = focused; FocusChanged?.Invoke(focused); + WindowEvents.FocusChanged.Invoke(new WindowEvents.BoolArgs(focused)); }; } - private static void OnMove(Vector2D d) => Move?.Invoke(d); + private static void OnMove(Vector2D d) { Move?.Invoke(d); WindowEvents.Move.Invoke(new WindowEvents.Vector2IntArgs(d)); } public static void Start() { @@ -192,6 +195,7 @@ public static void Start() // has a live render thread to drain its CBs; otherwise it would deadlock. Graphics.BeginFrame(); Load?.Invoke(); + WindowEvents.Load.Invoke(); Graphics.EndFrameAndWait(); try { MainLoop(); } @@ -216,9 +220,12 @@ private static void MainLoop() InternalWindow.DoEvents(); Update?.Invoke(delta); + WindowEvents.Update.Invoke(new WindowEvents.FloatArgs(delta)); WindowInputHandler?.LateUpdate(); Render?.Invoke(delta); + WindowEvents.Render.Invoke(delta); PostRender?.Invoke(delta); + WindowEvents.PostRender.Invoke(new WindowEvents.FloatArgs(delta)); // SwapBuffers runs on the render thread as part of the frame-end // sentinel no context handoff per frame. @@ -236,8 +243,8 @@ public static void OnLoad() Input.PushHandler(WindowInputHandler); } - public static void OnResize(Vector2D size) => Resize?.Invoke(size); - public static void OnFramebufferResize(Vector2D size) => FramebufferResize?.Invoke(size); + public static void OnResize(Vector2D size) { Resize?.Invoke(size); WindowEvents.Resize.Invoke(size); } + public static void OnFramebufferResize(Vector2D size) { FramebufferResize?.Invoke(size); WindowEvents.FramebufferResize.Invoke(new WindowEvents.Vector2IntArgs(size)); } public static void OnClose() { @@ -245,6 +252,7 @@ public static void OnClose() // otherwise race scene unload and try to submit GPU work after the render thread exits). AssetLoader.Stop(); Closing?.Invoke(); + WindowEvents.Closing.Invoke(); WindowInputHandler.Dispose(); Graphics.Dispose(); } diff --git a/Prowl.sln b/Prowl.sln index 13d3486eb..e53447303 100644 --- a/Prowl.sln +++ b/Prowl.sln @@ -31,6 +31,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BananaMan", "Samples\Banana EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Prowl.Editor", "Prowl.Editor\Prowl.Editor.csproj", "{F0DFDD68-2F29-4637-9C99-D42CC53F21A3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Prowl.Runtime.Generators", "Prowl.Runtime.Generators\Prowl.Runtime.Generators.csproj", "{DE93BF43-6456-407E-84FB-37F461838BD8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -197,6 +199,18 @@ Global {F0DFDD68-2F29-4637-9C99-D42CC53F21A3}.Release|x64.Build.0 = Release|Any CPU {F0DFDD68-2F29-4637-9C99-D42CC53F21A3}.Release|x86.ActiveCfg = Release|Any CPU {F0DFDD68-2F29-4637-9C99-D42CC53F21A3}.Release|x86.Build.0 = Release|Any CPU + {DE93BF43-6456-407E-84FB-37F461838BD8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DE93BF43-6456-407E-84FB-37F461838BD8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DE93BF43-6456-407E-84FB-37F461838BD8}.Debug|x64.ActiveCfg = Debug|Any CPU + {DE93BF43-6456-407E-84FB-37F461838BD8}.Debug|x64.Build.0 = Debug|Any CPU + {DE93BF43-6456-407E-84FB-37F461838BD8}.Debug|x86.ActiveCfg = Debug|Any CPU + {DE93BF43-6456-407E-84FB-37F461838BD8}.Debug|x86.Build.0 = Debug|Any CPU + {DE93BF43-6456-407E-84FB-37F461838BD8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DE93BF43-6456-407E-84FB-37F461838BD8}.Release|Any CPU.Build.0 = Release|Any CPU + {DE93BF43-6456-407E-84FB-37F461838BD8}.Release|x64.ActiveCfg = Release|Any CPU + {DE93BF43-6456-407E-84FB-37F461838BD8}.Release|x64.Build.0 = Release|Any CPU + {DE93BF43-6456-407E-84FB-37F461838BD8}.Release|x86.ActiveCfg = Release|Any CPU + {DE93BF43-6456-407E-84FB-37F461838BD8}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Samples/FlyCamera/Program.cs b/Samples/FlyCamera/Program.cs index d45771c9b..03f2e2c6c 100644 --- a/Samples/FlyCamera/Program.cs +++ b/Samples/FlyCamera/Program.cs @@ -18,6 +18,7 @@ // using Prowl.Runtime; +using Prowl.Runtime.Events; using Prowl.Runtime.Rendering; using Prowl.Runtime.Resources; using Prowl.Vector; @@ -243,10 +244,11 @@ public override void Update() } } - public override void OnRenderCollect(Camera renderCamera, List renderables, List lights) + public override void OnRenderCollect(SceneEvents.OnRenderCollectArgs onRenderCollectArgs) + { if (rootNode != null) - rootNode.Collect(renderables); + rootNode.Collect(onRenderCollectArgs.renderables); } public override void DrawGizmos()