From d4a4bdc6fb377d0141d934a21606a3564be10749 Mon Sep 17 00:00:00 2001 From: Luca Todesca <99339137+huggyex64@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:26:56 +0200 Subject: [PATCH 1/2] Add event system + source-gen and integrate scene events Introduce a full event subsystem (Prowl.Runtime.Events) with EventManager, Event, delegate containers, diagnostics, attributes, and helpers; add an EventDomain source generator (Prowl.Runtime.Generators) and EquatableArray. Update core types to use scene event flow: SceneEvents, WindowEvents, GameEvents and generated accessors; change OnRenderCollect signatures to use SceneEvents.OnRenderCollectArgs and propagate renderable/light lists. Wire Game/Window/Scene/GameObject/MonoBehaviour to subscribe and dispatch events, execution order calculation, batching, and subscription management. Update project/solution files to include the generator project. --- Prowl.Runtime.Generators/EquatableArray.cs | 56 ++ .../EventDomainGenerator.cs | 458 +++++++++++++ .../Prowl.Runtime.Generators.csproj | 16 + Prowl.Runtime/Components/GameCanvas.cs | 5 +- .../Components/Lights/DirectionalLight.cs | 5 +- Prowl.Runtime/Components/Lights/Light.cs | 5 +- Prowl.Runtime/Components/Lights/PointLight.cs | 5 +- Prowl.Runtime/Components/Lights/SpotLight.cs | 5 +- Prowl.Runtime/Components/LineRenderer.cs | 5 +- Prowl.Runtime/Components/MeshRenderer.cs | 5 +- .../ParticleSystem/ParticleSystemComponent.cs | 7 +- .../Components/SkinnedMeshRenderer.cs | 5 +- .../Components/Terrain/TerrainComponent.cs | 11 +- Prowl.Runtime/Components/WorldCanvas.cs | 5 +- .../Events/AsyncEventDelegateContainer.cs | 131 ++++ Prowl.Runtime/Events/Event.cs | 647 ++++++++++++++++++ Prowl.Runtime/Events/EventAccessor.cs | 180 +++++ Prowl.Runtime/Events/EventArgsAttribute.cs | 28 + Prowl.Runtime/Events/EventArgsContract.cs | 61 ++ .../Events/EventDelegateContainer.cs | 292 ++++++++ Prowl.Runtime/Events/EventDomainAttribute.cs | 66 ++ Prowl.Runtime/Events/EventKey.cs | 30 + Prowl.Runtime/Events/EventManager.WaitFor.cs | 163 +++++ Prowl.Runtime/Events/EventManager.cs | 573 ++++++++++++++++ Prowl.Runtime/Events/EventParam.cs | 11 + .../Events/EventSystemDiagnostics.cs | 28 + Prowl.Runtime/Events/ExecutionOrder.cs | 132 ++++ Prowl.Runtime/Events/GameEvents.cs | 39 ++ Prowl.Runtime/Events/IAsyncInvocable.cs | 19 + Prowl.Runtime/Events/ICancellable.cs | 16 + .../Events/IEventDelegateContainer.cs | 22 + Prowl.Runtime/Events/IEventManagerHolder.cs | 51 ++ Prowl.Runtime/Events/IInvocable.cs | 16 + Prowl.Runtime/Events/SceneEvents.cs | 35 + Prowl.Runtime/Events/WindowEvents.cs | 48 ++ Prowl.Runtime/Game.cs | 54 +- Prowl.Runtime/GameObject/GameObject.cs | 169 ++++- Prowl.Runtime/GameObject/MonoBehaviour.cs | 168 ++++- Prowl.Runtime/Prowl.Runtime.csproj | 9 +- Prowl.Runtime/Resources/Scene.cs | 164 ++++- Prowl.Runtime/Window.cs | 18 +- Prowl.sln | 14 + Samples/FlyCamera/Program.cs | 6 +- 43 files changed, 3708 insertions(+), 75 deletions(-) create mode 100644 Prowl.Runtime.Generators/EquatableArray.cs create mode 100644 Prowl.Runtime.Generators/EventDomainGenerator.cs create mode 100644 Prowl.Runtime.Generators/Prowl.Runtime.Generators.csproj create mode 100644 Prowl.Runtime/Events/AsyncEventDelegateContainer.cs create mode 100644 Prowl.Runtime/Events/Event.cs create mode 100644 Prowl.Runtime/Events/EventAccessor.cs create mode 100644 Prowl.Runtime/Events/EventArgsAttribute.cs create mode 100644 Prowl.Runtime/Events/EventArgsContract.cs create mode 100644 Prowl.Runtime/Events/EventDelegateContainer.cs create mode 100644 Prowl.Runtime/Events/EventDomainAttribute.cs create mode 100644 Prowl.Runtime/Events/EventKey.cs create mode 100644 Prowl.Runtime/Events/EventManager.WaitFor.cs create mode 100644 Prowl.Runtime/Events/EventManager.cs create mode 100644 Prowl.Runtime/Events/EventParam.cs create mode 100644 Prowl.Runtime/Events/EventSystemDiagnostics.cs create mode 100644 Prowl.Runtime/Events/ExecutionOrder.cs create mode 100644 Prowl.Runtime/Events/GameEvents.cs create mode 100644 Prowl.Runtime/Events/IAsyncInvocable.cs create mode 100644 Prowl.Runtime/Events/ICancellable.cs create mode 100644 Prowl.Runtime/Events/IEventDelegateContainer.cs create mode 100644 Prowl.Runtime/Events/IEventManagerHolder.cs create mode 100644 Prowl.Runtime/Events/IInvocable.cs create mode 100644 Prowl.Runtime/Events/SceneEvents.cs create mode 100644 Prowl.Runtime/Events/WindowEvents.cs 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..420a8b2e4 --- /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. + /// In DEBUG builds a warning is logged — prefer instead. + /// + public override void Invoke(TArgs args) + { + if (!Enabled) return; +#if 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 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..e0c6065e2 --- /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 DEBUG + /// + /// Configurable threshold in milliseconds. Handlers exceeding this duration + /// will be logged as warnings in DEBUG builds. 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 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 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 DEBUG + fullSnapshot = _cachedSnapshot; + fullLength = _cachedSnapshotLength; +#endif + } + +#if 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 DEBUG + sw?.Restart(); +#endif + try + { + span[j].Invoke(args); + } + catch (Exception ex) + { + Console.WriteLine(); + } + +#if 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 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 DEBUG + fullSnapshot = _cachedSnapshot; + fullLength = _cachedSnapshotLength; +#endif + } + +#if 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 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 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 DEBUG + /// + /// Logs a warning when a registered handler is skipped because its TArgs + /// does not match the invoked type. Only compiled into DEBUG builds. + /// + 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..a66bd408d --- /dev/null +++ b/Prowl.Runtime/Events/EventAccessor.cs @@ -0,0 +1,180 @@ +// 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) + => _manager.AddNewDelegate(_eventType, handler, order, sourceFile, sourceLine, sourceMember, false, 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) + => _manager.AddNewAsyncDelegate(_eventType, handler, order, sourceFile, sourceLine, sourceMember, false, 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) + => _manager.AddNewDelegate(_eventType, handler, order, sourceFile, sourceLine, sourceMember, false, 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) + => _manager.AddNewAsyncDelegate(_eventType, handler, order, sourceFile, sourceLine, sourceMember, false, 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..c789b61e5 --- /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 in DEBUG builds. +/// +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 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..b135d645b --- /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 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 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 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..2c582d452 --- /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 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 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 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 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 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 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..040e89992 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); + UpdateExecutionOrder(); + 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); + + _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. @@ -686,6 +798,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 +820,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 +1133,14 @@ private void SortComponents() /// public override void OnDispose() { + DisposeSceneEvents(); + + _disposerDelegate = Scene?.Events.OnFlush.Subscribe(() => + { + Scene?.Flush(this); + _disposerDelegate?.Dispose(); + }, ExecutionOrder); + for (int i = Children.Count - 1; i >= 0; i--) Children[i].Dispose(); @@ -1010,6 +1175,7 @@ private void SetEnabled(bool state) { _enabled = state; HierarchyStateChanged(); + UpdateEventDelegateState(_enabled && _enabledInHierarchy); } /// @@ -1021,6 +1187,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..c2fbdc152 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); } } } @@ -237,6 +331,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); + if (_overrides.HasFlag(OverrideFlags.LateUpdate)) + LateUpdateDelegate = scene.Events.LateUpdate.Subscribe(InternalLateUpdate, ExecutionOrder); + if (_overrides.HasFlag(OverrideFlags.FixedUpdate)) + FixedUpdateDelegate = scene.Events.FixedUpdate.Subscribe(InternalFixedUpdate, ExecutionOrder); + if (_overrides.HasFlag(OverrideFlags.OnRenderCollect)) + OnRenderCollectDelegate = scene.Events.OnRenderCollect.Subscribe(OnRenderCollect, ExecutionOrder); + if (_overrides.HasFlag(OverrideFlags.OnGui)) + OnGuiDelegate = scene.Events.OnGui.Subscribe(OnGui, ExecutionOrder); + if (_overrides.HasFlag(OverrideFlags.DrawGizmos)) + DrawGizmosDelegate = scene.Events.DrawGizmos.Subscribe(DrawGizmos, ExecutionOrder); + + _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 +476,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 +559,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..535d5a2b0 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,14 @@ 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); + public struct FogParams { public enum FogMode @@ -287,6 +294,8 @@ public void Enable() { if (_isActive) throw new Exception("Scene is already enabled!"); + Events.OnBeforeUpdates.Subscribe(SubscribeObjectsToEvents); + _isActive = true; // Create a copy to avoid collection modification during enumeration @@ -311,6 +320,28 @@ public void Enable() } } + public void SubscribeObjectsToEvents() + { + 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. @@ -516,6 +547,72 @@ 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() + { + // 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)); + } + } + /// Unregisters all GameObjects. public void Clear() { @@ -530,6 +627,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 +643,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(); @@ -623,13 +732,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(); + + Events.PreUpdate.Invoke(); + Events.Update.Invoke(); + Events.LateUpdate.Invoke(); + + //foreach (GameObject go in activeGOs) + // go.PreUpdate(); - ForeachComponent(activeGOs, (x) => x.InternalUpdate()); + //ForeachComponent(activeGOs, (x) => x.InternalUpdate()); - ForeachComponent(activeGOs, (x) => x.InternalLateUpdate()); + //ForeachComponent(activeGOs, (x) => x.InternalLateUpdate()); Flush(); } @@ -642,8 +767,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 +781,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 +789,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 +799,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() From 5f7ac19ec5f7f07bbb8cecad64581091297431db Mon Sep 17 00:00:00 2001 From: Luca Todesca <99339137+huggyex64@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:08:44 +0200 Subject: [PATCH 2/2] Use EVENT_DEBUG flag; defer execution order recalcs Replace DEBUG-guarded event diagnostics with EVENT_DEBUG to allow selective compilation of event diagnostics. Add allowMultiple parameter to EventAccessor.Subscribe overloads and propagate it through EventManager so multiple identical subscriptions can be allowed. Introduce Scene.MarkExecutionOrdersDirty and defer recalculation of execution/priorities to once-per-frame (SubscribeObjectsToEvents/RecalculateExecutionOrders) with batching around recalculation. Adjust GameObject/MonoBehaviour to mark scenes dirty when hierarchy/component changes and subscribe scene-level handlers with allowMultiple where needed. Misc: minor reorders to ensure consistent update/subscribe behavior. --- .../Events/AsyncEventDelegateContainer.cs | 6 ++-- Prowl.Runtime/Events/Event.cs | 34 +++++++++---------- Prowl.Runtime/Events/EventAccessor.cs | 20 ++++++----- .../Events/EventDelegateContainer.cs | 4 +-- Prowl.Runtime/Events/EventManager.WaitFor.cs | 6 ++-- Prowl.Runtime/Events/EventManager.cs | 12 +++---- Prowl.Runtime/GameObject/GameObject.cs | 18 +++++++--- Prowl.Runtime/GameObject/MonoBehaviour.cs | 16 +++++---- Prowl.Runtime/Resources/Scene.cs | 27 +++++++++++++-- 9 files changed, 91 insertions(+), 52 deletions(-) diff --git a/Prowl.Runtime/Events/AsyncEventDelegateContainer.cs b/Prowl.Runtime/Events/AsyncEventDelegateContainer.cs index 420a8b2e4..5b70f43e9 100644 --- a/Prowl.Runtime/Events/AsyncEventDelegateContainer.cs +++ b/Prowl.Runtime/Events/AsyncEventDelegateContainer.cs @@ -57,12 +57,12 @@ protected AsyncEventDelegateContainer(T eventType, ExecutionOrder priority, /// /// Synchronous invocation fallback. Fires the async handler without awaiting. - /// In DEBUG builds a warning is logged — prefer instead. + /// When EVENT_DEBUG is defined, a warning is logged — prefer instead. /// public override void Invoke(TArgs args) { if (!Enabled) return; -#if DEBUG +#if EVENT_DEBUG EventSystemDiagnostics.LogWarning?.Invoke( $"[EventSystem] Async handler on {typeof(T).Name} invoked synchronously. " + $"Use InvokeAsync/InvokeEventAsync for proper async execution. " + @@ -112,7 +112,7 @@ public ParameterlessAsyncEventDelegateContainer(T eventType, Func asyncAct public override void Invoke(Unit args) { if (!Enabled) return; -#if DEBUG +#if EVENT_DEBUG EventSystemDiagnostics.LogWarning?.Invoke( $"[EventSystem] Async handler on {typeof(T).Name} invoked synchronously. " + $"Use InvokeAsync/InvokeEventAsync for proper async execution. " + diff --git a/Prowl.Runtime/Events/Event.cs b/Prowl.Runtime/Events/Event.cs index e0c6065e2..029e0f0b2 100644 --- a/Prowl.Runtime/Events/Event.cs +++ b/Prowl.Runtime/Events/Event.cs @@ -25,10 +25,10 @@ private static class CancellableCheck public static readonly bool IsCancellable = typeof(ICancellable).IsAssignableFrom(typeof(TArgs)); } -#if DEBUG +#if EVENT_DEBUG /// /// Configurable threshold in milliseconds. Handlers exceeding this duration - /// will be logged as warnings in DEBUG builds. Set to 0 to disable. + /// will be logged as warnings when EVENT_DEBUG is defined. Set to 0 to disable. /// public static double SlowHandlerThresholdMs { get; set; } = 200.0; #endif @@ -46,7 +46,7 @@ private static class CancellableCheck /// /// 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 DEBUG diagnostics to detect type-mismatch handlers. + /// Used by EVENT_DEBUG diagnostics to detect type-mismatch handlers. /// private EventDelegateContainer[] _cachedSnapshot = []; @@ -135,10 +135,10 @@ public void EndBatch() public void Invoke(TArgs args) { if (!Enabled) return; - + EventDelegateContainer[] typedSnapshot; int typedLength; -#if DEBUG +#if EVENT_DEBUG EventDelegateContainer[] fullSnapshot; int fullLength; #endif @@ -154,13 +154,13 @@ public void Invoke(TArgs args) typedSnapshot = []; typedLength = 0; } -#if DEBUG +#if EVENT_DEBUG fullSnapshot = _cachedSnapshot; fullLength = _cachedSnapshotLength; #endif } -#if DEBUG +#if EVENT_DEBUG // Warn about handlers on this event registered with a different TArgs. if (fullLength > typedLength) { @@ -177,7 +177,7 @@ public void Invoke(TArgs args) var span = typedSnapshot.AsSpan(0, typedLength); for (int j = 0; j < span.Length; j++) { -#if DEBUG +#if EVENT_DEBUG sw?.Restart(); #endif try @@ -189,7 +189,7 @@ public void Invoke(TArgs args) Console.WriteLine(); } -#if DEBUG +#if EVENT_DEBUG if (sw is not null) { sw.Stop(); @@ -207,7 +207,7 @@ public void Invoke(TArgs args) if (CancellableCheck.IsCancellable && args is ICancellable { Cancelled: true }) break; } - + } @@ -227,7 +227,7 @@ public async Task InvokeAsync(TArgs args) EventDelegateContainer[] typedSnapshot; int typedLength; -#if DEBUG +#if EVENT_DEBUG EventDelegateContainer[] fullSnapshot; int fullLength; #endif @@ -243,13 +243,13 @@ public async Task InvokeAsync(TArgs args) typedSnapshot = []; typedLength = 0; } -#if DEBUG +#if EVENT_DEBUG fullSnapshot = _cachedSnapshot; fullLength = _cachedSnapshotLength; #endif } -#if DEBUG +#if EVENT_DEBUG // Warn about handlers on this event registered with a different TArgs. if (fullLength > typedLength) { @@ -266,7 +266,7 @@ public async Task InvokeAsync(TArgs args) for (int j = 0; j < typedLength; j++) { -#if DEBUG +#if EVENT_DEBUG sw?.Restart(); #endif try @@ -281,7 +281,7 @@ public async Task InvokeAsync(TArgs args) Console.WriteLine(ex.Message); } -#if DEBUG +#if EVENT_DEBUG if (sw is not null) { sw.Stop(); @@ -322,10 +322,10 @@ public ReadOnlySpan> GetHandlers() } } -#if DEBUG +#if EVENT_DEBUG /// /// Logs a warning when a registered handler is skipped because its TArgs - /// does not match the invoked type. Only compiled into DEBUG builds. + /// does not match the invoked type. Only compiled when EVENT_DEBUG is defined. /// private void WarnTypeMismatch(EventDelegateContainer container) { diff --git a/Prowl.Runtime/Events/EventAccessor.cs b/Prowl.Runtime/Events/EventAccessor.cs index a66bd408d..6f363b07b 100644 --- a/Prowl.Runtime/Events/EventAccessor.cs +++ b/Prowl.Runtime/Events/EventAccessor.cs @@ -68,8 +68,9 @@ public EventDelegateContainer Subscribe( [CallerFilePath] string? sourceFile = null, [CallerLineNumber] int sourceLine = 0, [CallerMemberName] string? sourceMember = null, - string[]? tags = null) - => _manager.AddNewDelegate(_eventType, handler, order, sourceFile, sourceLine, sourceMember, false, tags); + 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. @@ -80,8 +81,9 @@ public AsyncEventDelegateContainer SubscribeAsync( [CallerFilePath] string? sourceFile = null, [CallerLineNumber] int sourceLine = 0, [CallerMemberName] string? sourceMember = null, - string[]? tags = null) - => _manager.AddNewAsyncDelegate(_eventType, handler, order, sourceFile, sourceLine, sourceMember, false, tags); + 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. @@ -150,8 +152,9 @@ public EventDelegateContainer Subscribe( [CallerFilePath] string? sourceFile = null, [CallerLineNumber] int sourceLine = 0, [CallerMemberName] string? sourceMember = null, - string[]? tags = null) - => _manager.AddNewDelegate(_eventType, handler, order, sourceFile, sourceLine, sourceMember, false, tags); + 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. @@ -162,8 +165,9 @@ public AsyncEventDelegateContainer SubscribeAsync( [CallerFilePath] string? sourceFile = null, [CallerLineNumber] int sourceLine = 0, [CallerMemberName] string? sourceMember = null, - string[]? tags = null) - => _manager.AddNewAsyncDelegate(_eventType, handler, order, sourceFile, sourceLine, sourceMember, false, tags); + 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. diff --git a/Prowl.Runtime/Events/EventDelegateContainer.cs b/Prowl.Runtime/Events/EventDelegateContainer.cs index c789b61e5..04b465f4b 100644 --- a/Prowl.Runtime/Events/EventDelegateContainer.cs +++ b/Prowl.Runtime/Events/EventDelegateContainer.cs @@ -67,7 +67,7 @@ public bool Enabled /// /// Source file where this handler was registered. Captured automatically -/// via in DEBUG builds. +/// via when EVENT_DEBUG is defined. /// public string? SourceFile { get; private set; } @@ -124,7 +124,7 @@ protected EventDelegateContainer(T eventType, ExecutionOrder priority, string[]? protected EventDelegateContainer(T eventType, ExecutionOrder priority, string? sourceFile, int sourceLine, string? sourceMember, string[]? tags = null) : this(eventType, priority, tags) { -#if DEBUG +#if EVENT_DEBUG SourceFile = sourceFile is not null ? System.IO.Path.GetFileName(sourceFile) : null; SourceLine = sourceLine; SourceMember = sourceMember; diff --git a/Prowl.Runtime/Events/EventManager.WaitFor.cs b/Prowl.Runtime/Events/EventManager.WaitFor.cs index b135d645b..d28c52436 100644 --- a/Prowl.Runtime/Events/EventManager.WaitFor.cs +++ b/Prowl.Runtime/Events/EventManager.WaitFor.cs @@ -29,7 +29,7 @@ public partial class EventManager where T : struct, Enum public Task WaitForEventAsync( T eventType, CancellationToken cancellationToken = default, -#if DEBUG +#if EVENT_DEBUG [CallerFilePath] string? sourceFile = null, [CallerLineNumber] int sourceLine = 0, [CallerMemberName] string? sourceMember = null @@ -54,7 +54,7 @@ public Task WaitForEventAsync( T eventType, Func? predicate, CancellationToken cancellationToken = default, -#if DEBUG +#if EVENT_DEBUG [CallerFilePath] string? sourceFile = null, [CallerLineNumber] int sourceLine = 0, [CallerMemberName] string? sourceMember = null @@ -105,7 +105,7 @@ public Task WaitForEventAsync( public async Task WaitForEventAsync( T eventType, CancellationToken cancellationToken = default, -#if DEBUG +#if EVENT_DEBUG [CallerFilePath] string? sourceFile = null, [CallerLineNumber] int sourceLine = 0, [CallerMemberName] string? sourceMember = null diff --git a/Prowl.Runtime/Events/EventManager.cs b/Prowl.Runtime/Events/EventManager.cs index 2c582d452..cc6608ed5 100644 --- a/Prowl.Runtime/Events/EventManager.cs +++ b/Prowl.Runtime/Events/EventManager.cs @@ -279,7 +279,7 @@ public Task InvokeEventAsync(T eventType) /// public EventDelegateContainer AddNewDelegate( T eventType, Action eventDelegate, ExecutionOrder priority = default, -#if DEBUG +#if EVENT_DEBUG [CallerFilePath] string? sourceFile = null, [CallerLineNumber] int sourceLine = 0, [CallerMemberName] string? sourceMember = null, @@ -312,7 +312,7 @@ public EventDelegateContainer AddNewDelegate( /// public EventDelegateContainer AddNewDelegate( T eventType, Action eventDelegate, ExecutionOrder priority = default, -#if DEBUG +#if EVENT_DEBUG [CallerFilePath] string? sourceFile = null, [CallerLineNumber] int sourceLine = 0, [CallerMemberName] string? sourceMember = null @@ -346,7 +346,7 @@ public EventDelegateContainer AddNewDelegate( /// public OneTimeEventDelegateContainer SubscribeOnce( T eventType, Action eventDelegate, ExecutionOrder priority = default, -#if DEBUG +#if EVENT_DEBUG [CallerFilePath] string? sourceFile = null, [CallerLineNumber] int sourceLine = 0, [CallerMemberName] string? sourceMember = null, @@ -378,7 +378,7 @@ public OneTimeEventDelegateContainer SubscribeOnce( /// public OneTimeParameterlessEventDelegateContainer SubscribeOnce( T eventType, Action eventDelegate, ExecutionOrder priority = default, -#if DEBUG +#if EVENT_DEBUG [CallerFilePath] string? sourceFile = null, [CallerLineNumber] int sourceLine = 0, [CallerMemberName] string? sourceMember = null, @@ -412,7 +412,7 @@ public OneTimeParameterlessEventDelegateContainer SubscribeOnce( /// public AsyncEventDelegateContainer AddNewAsyncDelegate( T eventType, Func eventDelegate, ExecutionOrder priority = default, -#if DEBUG +#if EVENT_DEBUG [CallerFilePath] string? sourceFile = null, [CallerLineNumber] int sourceLine = 0, [CallerMemberName] string? sourceMember = null @@ -445,7 +445,7 @@ public AsyncEventDelegateContainer AddNewAsyncDelegate( /// public AsyncEventDelegateContainer AddNewAsyncDelegate( T eventType, Func eventDelegate, ExecutionOrder priority = default, -#if DEBUG +#if EVENT_DEBUG [CallerFilePath] string? sourceFile = null, [CallerLineNumber] int sourceLine = 0, [CallerMemberName] string? sourceMember = null diff --git a/Prowl.Runtime/GameObject/GameObject.cs b/Prowl.Runtime/GameObject/GameObject.cs index 040e89992..ab0506792 100644 --- a/Prowl.Runtime/GameObject/GameObject.cs +++ b/Prowl.Runtime/GameObject/GameObject.cs @@ -120,7 +120,7 @@ public Scene? Scene internal set { _scene = new(value); - UpdateExecutionOrder(); + value?.MarkExecutionOrdersDirty(); UpdateEventDelegateState(_enabled && _enabledInHierarchy); ReadOnlySpan components = CollectionsMarshal.AsSpan(_components); for (int i = 0; i < components.Length; i++) @@ -254,7 +254,7 @@ public void UpdateExecutionOrder() internal void SubscribeSceneEvents(Scene scene) { - PreUpdateDelegate = scene.Events.PreUpdate.Subscribe(PreUpdate, ExecutionOrder); + PreUpdateDelegate = scene.Events.PreUpdate.Subscribe(PreUpdate, ExecutionOrder, allowMultiple: true); _eventsInitialized = true; @@ -401,6 +401,8 @@ public bool SetParent(GameObject NewParent, bool worldPositionStays = true) NewParent.Children.Add(this); _parent = NewParent; + + Scene?.MarkExecutionOrdersDirty(); } if (worldPositionStays) @@ -663,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(); @@ -705,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(); } @@ -1139,7 +1147,7 @@ public override void OnDispose() { Scene?.Flush(this); _disposerDelegate?.Dispose(); - }, ExecutionOrder); + }, ExecutionOrder, allowMultiple: true); for (int i = Children.Count - 1; i >= 0; i--) Children[i].Dispose(); diff --git a/Prowl.Runtime/GameObject/MonoBehaviour.cs b/Prowl.Runtime/GameObject/MonoBehaviour.cs index c2fbdc152..efe5a4b13 100644 --- a/Prowl.Runtime/GameObject/MonoBehaviour.cs +++ b/Prowl.Runtime/GameObject/MonoBehaviour.cs @@ -296,6 +296,8 @@ public void SetSiblingIndex(int index) // Insert at new position GameObject._components.Insert(index, this); + + GameObject.Scene?.MarkExecutionOrdersDirty(); } #endregion @@ -309,6 +311,8 @@ internal void AttachToGameObject(GameObject go) bool isEnabled = _enabled && _go.EnabledInHierarchy; _enabledInHierarchy = isEnabled; + + _go.Scene?.MarkExecutionOrdersDirty(); } /// @@ -358,17 +362,17 @@ internal void SubscribeSceneEvents(Scene scene) _overrides = DetectOverrides(GetType()); if (_overrides.HasFlag(OverrideFlags.Update)) - UpdateDelegate = scene.Events.Update.Subscribe(InternalUpdate, ExecutionOrder); + UpdateDelegate = scene.Events.Update.Subscribe(InternalUpdate, ExecutionOrder, allowMultiple: true); if (_overrides.HasFlag(OverrideFlags.LateUpdate)) - LateUpdateDelegate = scene.Events.LateUpdate.Subscribe(InternalLateUpdate, ExecutionOrder); + LateUpdateDelegate = scene.Events.LateUpdate.Subscribe(InternalLateUpdate, ExecutionOrder, allowMultiple: true); if (_overrides.HasFlag(OverrideFlags.FixedUpdate)) - FixedUpdateDelegate = scene.Events.FixedUpdate.Subscribe(InternalFixedUpdate, ExecutionOrder); + FixedUpdateDelegate = scene.Events.FixedUpdate.Subscribe(InternalFixedUpdate, ExecutionOrder, allowMultiple: true); if (_overrides.HasFlag(OverrideFlags.OnRenderCollect)) - OnRenderCollectDelegate = scene.Events.OnRenderCollect.Subscribe(OnRenderCollect, ExecutionOrder); + OnRenderCollectDelegate = scene.Events.OnRenderCollect.Subscribe(OnRenderCollect, ExecutionOrder, allowMultiple: true); if (_overrides.HasFlag(OverrideFlags.OnGui)) - OnGuiDelegate = scene.Events.OnGui.Subscribe(OnGui, ExecutionOrder); + OnGuiDelegate = scene.Events.OnGui.Subscribe(OnGui, ExecutionOrder, allowMultiple: true); if (_overrides.HasFlag(OverrideFlags.DrawGizmos)) - DrawGizmosDelegate = scene.Events.DrawGizmos.Subscribe(DrawGizmos, ExecutionOrder); + DrawGizmosDelegate = scene.Events.DrawGizmos.Subscribe(DrawGizmos, ExecutionOrder, allowMultiple: true); _eventsInitialized = true; diff --git a/Prowl.Runtime/Resources/Scene.cs b/Prowl.Runtime/Resources/Scene.cs index 535d5a2b0..12b04ea97 100644 --- a/Prowl.Runtime/Resources/Scene.cs +++ b/Prowl.Runtime/Resources/Scene.cs @@ -112,6 +112,16 @@ public static void Unload() 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 @@ -294,7 +304,7 @@ public void Enable() { if (_isActive) throw new Exception("Scene is already enabled!"); - Events.OnBeforeUpdates.Subscribe(SubscribeObjectsToEvents); + Events.OnBeforeUpdates.Subscribe(SubscribeObjectsToEvents, allowMultiple: true); _isActive = true; @@ -322,6 +332,13 @@ 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(); @@ -374,7 +391,6 @@ public void Disable() _isActive = false; } - /// /// Registers a GameObject and all of its children. /// @@ -402,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(); } /// @@ -561,6 +579,8 @@ private void RemoveObject(GameObject obj) /// 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)>(); @@ -611,6 +631,7 @@ public void RecalculateExecutionOrders() for (int i = children.Count - 1; i >= 0; i--) stack.Push((children[i], goLevels, i)); } + Events.Manager.EndBatch(); } /// Unregisters all GameObjects. @@ -723,6 +744,8 @@ public void OnAfterDeserialize() foreach (GameObject obj in serializeObj) Add(obj); + + MarkExecutionOrdersDirty(); } ///