From 338e253fee74af02fc633c34ae50b57afd44dc99 Mon Sep 17 00:00:00 2001 From: Yuri Sokolov <7556847+migus88@users.noreply.github.com> Date: Sat, 30 May 2026 17:44:45 +0300 Subject: [PATCH 1/3] Include events on the generated Singleton interface Events were silently dropped by the formatter and by the auto-include candidate check, so a public event on a [Singleton] class never appeared on its generated interface. Format IEventSymbol as `event Type Name;`, accept events in auto-mode, widen [SingletonInclude] / [SingletonIgnore] to AttributeTargets.Event, and add a demo CountChanged event with a test that subscribes through the interface. --- README.md | 6 ++--- .../Helpers/SymbolFormatter.cs | 12 +++++++++ .../Singleton/SingletonGenerator.cs | 1 + .../Demo/Singletons/Code/GameManager.cs | 4 +++ .../Demo/Singletons/Tests/GameManagerTests.cs | 25 ++++++++++++++++++ .../Runtime/Plugins/EngineRoom.Generators.dll | Bin 40448 -> 40448 bytes .../Attributes/SingletonIgnoreAttribute.cs | 8 +++--- .../Attributes/SingletonIncludeAttribute.cs | 4 +-- 8 files changed, 51 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 44f91a0..b9fd184 100644 --- a/README.md +++ b/README.md @@ -217,13 +217,13 @@ public partial class DataStoreManager : MonoBehaviour, IDataStoreManager   -When the generator synthesises the interface, it has to decide which members of your class show up on it. By default that's every public method and property. `[SingletonInclude]` and `[SingletonIgnore]` give you fine-grained control. They are mutually exclusive *per class* — using `[SingletonInclude]` anywhere on the class flips the generator into **explicit mode**. +When the generator synthesises the interface, it has to decide which members of your class show up on it. By default that's every public method, property, and event. `[SingletonInclude]` and `[SingletonIgnore]` give you fine-grained control. They are mutually exclusive *per class* — using `[SingletonInclude]` anywhere on the class flips the generator into **explicit mode**. > **Note** — Neither attribute applies when you supply your own interface via `[Singleton(typeof(IFoo))]`; in that case the interface contract is whatever you typed. **Auto mode** *(default — no `[SingletonInclude]` on the class)* -Every public instance method and property is on the interface. Use `[SingletonIgnore]` to hide individual members: +Every public instance method, property, and event is on the interface. Use `[SingletonIgnore]` to hide individual members: ```csharp [Singleton] @@ -255,7 +255,7 @@ public partial class SoundManager : MonoBehaviour **Constraints** -- Both attributes target methods and properties only. +- Both attributes target methods, properties, and events only. - `[SingletonInclude]` members must be **public** and **non-static** — the analyzer raises `ER0xxx` diagnostics otherwise. diff --git a/src/generators-src/EngineRoom.Generators/Helpers/SymbolFormatter.cs b/src/generators-src/EngineRoom.Generators/Helpers/SymbolFormatter.cs index 1b7aa67..cc0ddf8 100644 --- a/src/generators-src/EngineRoom.Generators/Helpers/SymbolFormatter.cs +++ b/src/generators-src/EngineRoom.Generators/Helpers/SymbolFormatter.cs @@ -34,6 +34,7 @@ public static string FormatAsInterfaceMember(ISymbol symbol) { IMethodSymbol method => FormatMethod(method), IPropertySymbol property => FormatProperty(property), + IEventSymbol @event => FormatEvent(@event), _ => string.Empty, }; } @@ -81,6 +82,17 @@ private static string FormatProperty(IPropertySymbol property) return builder.ToString(); } + private static string FormatEvent(IEventSymbol @event) + { + var builder = new StringBuilder(); + builder.Append("event "); + builder.Append(@event.Type.ToDisplayString(FullyQualifiedType)); + builder.Append(' '); + builder.Append(@event.Name); + builder.Append(';'); + return builder.ToString(); + } + private static string FormatParameter(IParameterSymbol parameter) { var builder = new StringBuilder(); diff --git a/src/generators-src/EngineRoom.Generators/Singleton/SingletonGenerator.cs b/src/generators-src/EngineRoom.Generators/Singleton/SingletonGenerator.cs index 336c13f..66ed6b9 100644 --- a/src/generators-src/EngineRoom.Generators/Singleton/SingletonGenerator.cs +++ b/src/generators-src/EngineRoom.Generators/Singleton/SingletonGenerator.cs @@ -186,6 +186,7 @@ private static bool IsAutoIncludeCandidate(INamedTypeSymbol classSymbol, ISymbol { IMethodSymbol method => method.MethodKind == MethodKind.Ordinary, IPropertySymbol property => !property.IsIndexer, + IEventSymbol => true, _ => false, }; } diff --git a/src/generators-unity/Assets/Demo/Singletons/Code/GameManager.cs b/src/generators-unity/Assets/Demo/Singletons/Code/GameManager.cs index cc82feb..049b236 100644 --- a/src/generators-unity/Assets/Demo/Singletons/Code/GameManager.cs +++ b/src/generators-unity/Assets/Demo/Singletons/Code/GameManager.cs @@ -1,3 +1,4 @@ +using System; using EngineRoom.Runtime.Singleton; using UnityEngine; @@ -6,6 +7,8 @@ namespace EngineRoom.Demo.Singletons [Singleton] public partial class GameManager : MonoBehaviour { + public event Action CountChanged; + public int Count => _count; [Dependency] private ISoundManager _soundManager; @@ -26,6 +29,7 @@ public void RegisterTap() _dataStoreManager.SetScore(_count); _soundManager.PlayTap(); _uiManager.SetCount(_count); + CountChanged?.Invoke(_count); } } } diff --git a/src/generators-unity/Assets/Demo/Singletons/Tests/GameManagerTests.cs b/src/generators-unity/Assets/Demo/Singletons/Tests/GameManagerTests.cs index 8ceeb21..5ffaf11 100644 --- a/src/generators-unity/Assets/Demo/Singletons/Tests/GameManagerTests.cs +++ b/src/generators-unity/Assets/Demo/Singletons/Tests/GameManagerTests.cs @@ -28,5 +28,30 @@ public void RegisterTap_IncrementsCount_AndNotifiesSoundAndUi() Object.DestroyImmediate(((GameManager)game).gameObject); } } + + [Test] + public void RegisterTap_RaisesCountChangedEventOnInterface() + { + MockDataStoreManager.Install(); + MockSoundManager.Install(); + MockUIManager.Install(); + + IGameManager game = GameManager.Create(); + + try + { + var observed = -1; + game.CountChanged += value => observed = value; + + game.RegisterTap(); + game.RegisterTap(); + + Assert.AreEqual(2, observed); + } + finally + { + Object.DestroyImmediate(((GameManager)game).gameObject); + } + } } } diff --git a/src/generators-unity/Packages/games.engine-room.generators/Runtime/Plugins/EngineRoom.Generators.dll b/src/generators-unity/Packages/games.engine-room.generators/Runtime/Plugins/EngineRoom.Generators.dll index e66297c27f8dcc59dcbeb5a3e607229b4e5de747..6460a01ae6c74f62f1e41c2b5047aeb0407a3197 100644 GIT binary patch delta 11965 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 F{{euuHPiqA delta 11691 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 C /// On a -decorated class that has no member - /// tagged with , marks a public method or - /// property to be excluded from the generated singleton interface. Has no - /// effect when the class is in explicit mode (i.e. uses + /// tagged with , marks a public method, + /// property, or event to be excluded from the generated singleton interface. + /// Has no effect when the class is in explicit mode (i.e. uses /// elsewhere). /// - [AttributeUsage(AttributeTargets.Method | AttributeTargets.Property)] + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Event)] public sealed class SingletonIgnoreAttribute : Attribute { } diff --git a/src/generators-unity/Packages/games.engine-room.generators/Runtime/Singleton/Attributes/SingletonIncludeAttribute.cs b/src/generators-unity/Packages/games.engine-room.generators/Runtime/Singleton/Attributes/SingletonIncludeAttribute.cs index 2b1c1d9..54f6f9b 100644 --- a/src/generators-unity/Packages/games.engine-room.generators/Runtime/Singleton/Attributes/SingletonIncludeAttribute.cs +++ b/src/generators-unity/Packages/games.engine-room.generators/Runtime/Singleton/Attributes/SingletonIncludeAttribute.cs @@ -3,10 +3,10 @@ namespace EngineRoom.Runtime.Singleton { /// - /// Marks a method or property on a -decorated class + /// Marks a method, property, or event on a -decorated class /// to be projected onto the generated singleton interface. /// - [AttributeUsage(AttributeTargets.Method | AttributeTargets.Property)] + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Event)] public sealed class SingletonIncludeAttribute : Attribute { } From 19fba2063b978cc61065d59d454db8a5ae07fb7d Mon Sep 17 00:00:00 2001 From: Yuri Sokolov <7556847+migus88@users.noreply.github.com> Date: Sat, 30 May 2026 18:02:36 +0300 Subject: [PATCH 2/3] Convert demo Singleton tests to PlayMode The tests exercise [Dependency] resolution and the singleton lifecycle, which only run in PlayMode. Drop the EditMode-only test runner reference and Editor platform restriction so the [UnityTest] coroutines that yield a frame for Start() can run. --- .../EngineRoom.Demo.Singletons.Tests.asmdef | 7 ++----- .../Demo/Singletons/Tests/GameManagerTests.cs | 16 +++++++++++----- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/generators-unity/Assets/Demo/Singletons/Tests/EngineRoom.Demo.Singletons.Tests.asmdef b/src/generators-unity/Assets/Demo/Singletons/Tests/EngineRoom.Demo.Singletons.Tests.asmdef index cfc1f70..5c1ecd8 100644 --- a/src/generators-unity/Assets/Demo/Singletons/Tests/EngineRoom.Demo.Singletons.Tests.asmdef +++ b/src/generators-unity/Assets/Demo/Singletons/Tests/EngineRoom.Demo.Singletons.Tests.asmdef @@ -4,12 +4,9 @@ "references": [ "EngineRoom.Demo.Singletons", "EngineRoom.Generators.Runtime", - "UnityEngine.TestRunner", - "UnityEditor.TestRunner" - ], - "includePlatforms": [ - "Editor" + "UnityEngine.TestRunner" ], + "includePlatforms": [], "excludePlatforms": [], "allowUnsafeCode": false, "overrideReferences": true, diff --git a/src/generators-unity/Assets/Demo/Singletons/Tests/GameManagerTests.cs b/src/generators-unity/Assets/Demo/Singletons/Tests/GameManagerTests.cs index 5ffaf11..cfc943c 100644 --- a/src/generators-unity/Assets/Demo/Singletons/Tests/GameManagerTests.cs +++ b/src/generators-unity/Assets/Demo/Singletons/Tests/GameManagerTests.cs @@ -1,12 +1,14 @@ +using System.Collections; using NUnit.Framework; using UnityEngine; +using UnityEngine.TestTools; namespace EngineRoom.Demo.Singletons.Tests { public class GameManagerTests { - [Test] - public void RegisterTap_IncrementsCount_AndNotifiesSoundAndUi() + [UnityTest] + public IEnumerator RegisterTap_IncrementsCount_AndNotifiesSoundAndUi() { var store = MockDataStoreManager.Install(); var sound = MockSoundManager.Install(); @@ -14,6 +16,8 @@ public void RegisterTap_IncrementsCount_AndNotifiesSoundAndUi() var game = GameManager.Create(); + yield return null; + try { game.RegisterTap(); @@ -29,14 +33,16 @@ public void RegisterTap_IncrementsCount_AndNotifiesSoundAndUi() } } - [Test] - public void RegisterTap_RaisesCountChangedEventOnInterface() + [UnityTest] + public IEnumerator RegisterTap_RaisesCountChangedEventOnInterface() { MockDataStoreManager.Install(); MockSoundManager.Install(); MockUIManager.Install(); - IGameManager game = GameManager.Create(); + var game = GameManager.Create(); + + yield return null; try { From edb99462c127711119c6fabc22afd244573561bc Mon Sep 17 00:00:00 2001 From: Yuri Sokolov <7556847+migus88@users.noreply.github.com> Date: Sat, 30 May 2026 19:45:28 +0300 Subject: [PATCH 3/3] Add Lifecycle generator; Singleton/Dependency emit helpers instead of owning Awake/Start New [Awake]/[Start]/[OnEnable]/[OnDisable]/[Update]/[FixedUpdate]/[LateUpdate]/[OnDestroy] attributes mark MonoBehaviour methods to be invoked from a single generated dispatcher per lifecycle phase, ordered by an optional int Order arg (source order on tie). [Singleton] and [Dependency] now contribute private SingletonAwake()/DependencyStart() helpers instead of owning Awake/Start themselves, so multiple features can cooperate on the same MonoBehaviour without colliding. The Singleton helper returns false on a duplicate (after Destroy) so the dispatcher short-circuits before any user [Awake] or OnAwake runs on the doomed instance. Backwards-compatible: the Awake/Start dispatchers still call (and declare) the OnAwake()/OnStart() partial hooks. ERG0003/ERG0103 retired in favor of the unified ERG0203, which suggests adding a [Awake]/[Start]/etc. method. --- README.md | 138 ++++++- .../Dependency/DependencyAnalyzer.cs | 14 +- .../Dependency/DependencyDiagnostics.cs | 10 +- .../Dependency/DependencyGenerator.cs | 10 - .../EngineRoom.Generators.csproj | 8 + .../Lifecycle/LifecycleAnalyzer.cs | 316 ++++++++++++++++ .../Lifecycle/LifecycleConstants.cs | 22 ++ .../Lifecycle/LifecycleDiagnostics.cs | 49 +++ .../Lifecycle/LifecycleEntry.cs | 53 +++ .../Lifecycle/LifecycleGenerator.cs | 347 ++++++++++++++++++ .../Lifecycle/LifecycleInfo.cs | 122 ++++++ .../Lifecycle/LifecycleKind.cs | 64 ++++ .../Singleton/SingletonAnalyzer.cs | 14 +- .../Singleton/SingletonDiagnostics.cs | 10 +- .../Singleton/SingletonGenerator.cs | 10 - .../Dependency/DependencyPartial.cs.txt | 5 +- .../Lifecycle/LifecyclePartial.cs.txt | 4 + .../Singleton/SingletonPartial.cs.txt | 10 +- .../Assets/Demo/Lifecycle.meta | 8 + .../Assets/Demo/Lifecycle/Code.meta | 8 + .../Code/EngineRoom.Demo.Lifecycle.asmdef | 16 + .../EngineRoom.Demo.Lifecycle.asmdef.meta | 7 + .../Demo/Lifecycle/Code/LifecycleProbe.cs | 59 +++ .../Lifecycle/Code/LifecycleProbe.cs.meta | 2 + .../Lifecycle/Code/LifecycleSingletonProbe.cs | 25 ++ .../Code/LifecycleSingletonProbe.cs.meta | 2 + .../Demo/Lifecycle/Code/OrderedAwakeProbe.cs | 35 ++ .../Lifecycle/Code/OrderedAwakeProbe.cs.meta | 2 + .../Assets/Demo/Lifecycle/Tests.meta | 8 + .../EngineRoom.Demo.Lifecycle.Tests.asmdef | 22 ++ ...ngineRoom.Demo.Lifecycle.Tests.asmdef.meta | 7 + .../Demo/Lifecycle/Tests/LifecycleTests.cs | 142 +++++++ .../Lifecycle/Tests/LifecycleTests.cs.meta | 2 + .../Runtime/Lifecycle.meta | 8 + .../Runtime/Lifecycle/Attributes.meta | 8 + .../Lifecycle/Attributes/AwakeAttribute.cs | 26 ++ .../Attributes/AwakeAttribute.cs.meta | 2 + .../Attributes/FixedUpdateAttribute.cs | 25 ++ .../Attributes/FixedUpdateAttribute.cs.meta | 2 + .../Attributes/LateUpdateAttribute.cs | 25 ++ .../Attributes/LateUpdateAttribute.cs.meta | 2 + .../Attributes/OnDestroyAttribute.cs | 25 ++ .../Attributes/OnDestroyAttribute.cs.meta | 2 + .../Attributes/OnDisableAttribute.cs | 25 ++ .../Attributes/OnDisableAttribute.cs.meta | 2 + .../Lifecycle/Attributes/OnEnableAttribute.cs | 25 ++ .../Attributes/OnEnableAttribute.cs.meta | 2 + .../Lifecycle/Attributes/StartAttribute.cs | 26 ++ .../Attributes/StartAttribute.cs.meta | 2 + .../Lifecycle/Attributes/UpdateAttribute.cs | 25 ++ .../Attributes/UpdateAttribute.cs.meta | 2 + .../Runtime/Plugins/EngineRoom.Generators.dll | Bin 40448 -> 56320 bytes 52 files changed, 1710 insertions(+), 75 deletions(-) create mode 100644 src/generators-src/EngineRoom.Generators/Lifecycle/LifecycleAnalyzer.cs create mode 100644 src/generators-src/EngineRoom.Generators/Lifecycle/LifecycleConstants.cs create mode 100644 src/generators-src/EngineRoom.Generators/Lifecycle/LifecycleDiagnostics.cs create mode 100644 src/generators-src/EngineRoom.Generators/Lifecycle/LifecycleEntry.cs create mode 100644 src/generators-src/EngineRoom.Generators/Lifecycle/LifecycleGenerator.cs create mode 100644 src/generators-src/EngineRoom.Generators/Lifecycle/LifecycleInfo.cs create mode 100644 src/generators-src/EngineRoom.Generators/Lifecycle/LifecycleKind.cs create mode 100644 src/generators-src/EngineRoom.Generators/Templates/Lifecycle/LifecyclePartial.cs.txt create mode 100644 src/generators-unity/Assets/Demo/Lifecycle.meta create mode 100644 src/generators-unity/Assets/Demo/Lifecycle/Code.meta create mode 100644 src/generators-unity/Assets/Demo/Lifecycle/Code/EngineRoom.Demo.Lifecycle.asmdef create mode 100644 src/generators-unity/Assets/Demo/Lifecycle/Code/EngineRoom.Demo.Lifecycle.asmdef.meta create mode 100644 src/generators-unity/Assets/Demo/Lifecycle/Code/LifecycleProbe.cs create mode 100644 src/generators-unity/Assets/Demo/Lifecycle/Code/LifecycleProbe.cs.meta create mode 100644 src/generators-unity/Assets/Demo/Lifecycle/Code/LifecycleSingletonProbe.cs create mode 100644 src/generators-unity/Assets/Demo/Lifecycle/Code/LifecycleSingletonProbe.cs.meta create mode 100644 src/generators-unity/Assets/Demo/Lifecycle/Code/OrderedAwakeProbe.cs create mode 100644 src/generators-unity/Assets/Demo/Lifecycle/Code/OrderedAwakeProbe.cs.meta create mode 100644 src/generators-unity/Assets/Demo/Lifecycle/Tests.meta create mode 100644 src/generators-unity/Assets/Demo/Lifecycle/Tests/EngineRoom.Demo.Lifecycle.Tests.asmdef create mode 100644 src/generators-unity/Assets/Demo/Lifecycle/Tests/EngineRoom.Demo.Lifecycle.Tests.asmdef.meta create mode 100644 src/generators-unity/Assets/Demo/Lifecycle/Tests/LifecycleTests.cs create mode 100644 src/generators-unity/Assets/Demo/Lifecycle/Tests/LifecycleTests.cs.meta create mode 100644 src/generators-unity/Packages/games.engine-room.generators/Runtime/Lifecycle.meta create mode 100644 src/generators-unity/Packages/games.engine-room.generators/Runtime/Lifecycle/Attributes.meta create mode 100644 src/generators-unity/Packages/games.engine-room.generators/Runtime/Lifecycle/Attributes/AwakeAttribute.cs create mode 100644 src/generators-unity/Packages/games.engine-room.generators/Runtime/Lifecycle/Attributes/AwakeAttribute.cs.meta create mode 100644 src/generators-unity/Packages/games.engine-room.generators/Runtime/Lifecycle/Attributes/FixedUpdateAttribute.cs create mode 100644 src/generators-unity/Packages/games.engine-room.generators/Runtime/Lifecycle/Attributes/FixedUpdateAttribute.cs.meta create mode 100644 src/generators-unity/Packages/games.engine-room.generators/Runtime/Lifecycle/Attributes/LateUpdateAttribute.cs create mode 100644 src/generators-unity/Packages/games.engine-room.generators/Runtime/Lifecycle/Attributes/LateUpdateAttribute.cs.meta create mode 100644 src/generators-unity/Packages/games.engine-room.generators/Runtime/Lifecycle/Attributes/OnDestroyAttribute.cs create mode 100644 src/generators-unity/Packages/games.engine-room.generators/Runtime/Lifecycle/Attributes/OnDestroyAttribute.cs.meta create mode 100644 src/generators-unity/Packages/games.engine-room.generators/Runtime/Lifecycle/Attributes/OnDisableAttribute.cs create mode 100644 src/generators-unity/Packages/games.engine-room.generators/Runtime/Lifecycle/Attributes/OnDisableAttribute.cs.meta create mode 100644 src/generators-unity/Packages/games.engine-room.generators/Runtime/Lifecycle/Attributes/OnEnableAttribute.cs create mode 100644 src/generators-unity/Packages/games.engine-room.generators/Runtime/Lifecycle/Attributes/OnEnableAttribute.cs.meta create mode 100644 src/generators-unity/Packages/games.engine-room.generators/Runtime/Lifecycle/Attributes/StartAttribute.cs create mode 100644 src/generators-unity/Packages/games.engine-room.generators/Runtime/Lifecycle/Attributes/StartAttribute.cs.meta create mode 100644 src/generators-unity/Packages/games.engine-room.generators/Runtime/Lifecycle/Attributes/UpdateAttribute.cs create mode 100644 src/generators-unity/Packages/games.engine-room.generators/Runtime/Lifecycle/Attributes/UpdateAttribute.cs.meta diff --git a/README.md b/README.md index b9fd184..a892a65 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ A Unity package of Roslyn source generators that take care of the boilerplate ar - [Singletons](#singletons) - [Attributes](#attributes) - [Guidance](#guidance) +- [Lifecycle](#lifecycle) - [Requirements](#requirements) --- @@ -142,18 +143,34 @@ public partial class SoundManager : ISoundManager return obj.AddComponent(); } - private void Awake() + // Returns false when this instance is a duplicate (gameObject has already + // been Destroy'd) so the lifecycle dispatcher can short-circuit. + private bool SingletonAwake() { var existing = ISoundManager.Instance as Object; if (existing != null && existing != this) { Object.Destroy(gameObject); - return; + return false; } transform.SetParent(null); DontDestroyOnLoad(gameObject); ISoundManager.Instance = this; + return true; + } +} + +// Emitted by the Lifecycle generator (see below). Calls SingletonAwake first, +// aborts on duplicate, then runs any user [Awake] methods, then OnAwake(). +public partial class SoundManager +{ + private void Awake() + { + if (!SingletonAwake()) + { + return; + } OnAwake(); } @@ -180,7 +197,7 @@ The attribute accepts two optional arguments — a custom interface type and a ` **`destroyOnLoad`** *(default: `false`)* -By default the generated `Awake()` calls `DontDestroyOnLoad(gameObject)` and re-parents the host to the scene root, so the singleton outlives scene transitions. Set `destroyOnLoad: true` when you want the singleton scoped to its scene — useful for per-level managers that should reset on reload. With the flag on, the `DontDestroyOnLoad` and reparenting calls are omitted from the generated `Awake()`. +By default the generated `SingletonAwake` helper calls `DontDestroyOnLoad(gameObject)` and re-parents the host to the scene root, so the singleton outlives scene transitions. Set `destroyOnLoad: true` when you want the singleton scoped to its scene — useful for per-level managers that should reset on reload. With the flag on, the `DontDestroyOnLoad` and reparenting calls are omitted from the helper. **Bring-your-own interface** @@ -205,10 +222,11 @@ public partial class DataStoreManager : MonoBehaviour, IDataStoreManager | Member | What it does | | ------------------------- | --------------------------------------------------------------------------------------- | -| `I` interface | Auto-generated when no interface is supplied. Lists the public-and-non-`[SingletonIgnore]` members of the class (or only `[SingletonInclude]`-tagged members in explicit mode). | -| `static Create()` | Factory that spawns a fresh GameObject named after the class and adds the component. | -| `private void Awake()` | Publishes the instance, enforces the singleton invariant, optionally calls `DontDestroyOnLoad`. | -| `partial void OnAwake()` | Your hook — define it for post-awake setup; called after the instance is published. | +| `I` interface | Auto-generated when no interface is supplied. Lists the public-and-non-`[SingletonIgnore]` members of the class (or only `[SingletonInclude]`-tagged members in explicit mode). | +| `static Create()` | Factory that spawns a fresh GameObject named after the class and adds the component. | +| `private bool SingletonAwake()`| Helper: publishes the instance, enforces the singleton invariant, optionally calls `DontDestroyOnLoad`. Returns `false` on a duplicate (after calling `Destroy(gameObject)`). | +| `private void Awake()` | Lifecycle dispatcher — emitted by the [Lifecycle](#lifecycle) generator. Calls `SingletonAwake()`, short-circuits on duplicate, then user `[Awake]` methods, then `OnAwake()`. | +| `partial void OnAwake()` | Back-compat hook — define it for post-awake setup; called after the instance is published. (For new code, prefer marking a method with `[Awake]`.) | @@ -287,9 +305,19 @@ This will generate the following code: ```csharp public partial class Egg { - private void Start() + private void DependencyStart() { _soundManager = ISoundManager.Instance; + } +} + +// Emitted by the Lifecycle generator (see below). Calls DependencyStart first, +// then any user [Start] methods, then OnStart(). +public partial class Egg +{ + private void Start() + { + DependencyStart(); OnStart(); } @@ -297,7 +325,7 @@ public partial class Egg } ``` -Multiple `[Dependency]` fields on the same class are all assigned in the same generated `Start()` before `OnStart()` runs. +Multiple `[Dependency]` fields on the same class are all assigned in the same generated `DependencyStart()` before any user `[Start]` methods or the back-compat `OnStart()` partial run. @@ -370,6 +398,98 @@ The `[DefaultExecutionOrder]` attribute makes `Bootstrap.Awake` run before any o --- +## Lifecycle + +The Lifecycle generator turns Unity's lifecycle hooks (`Awake`, `Start`, `OnEnable`, `OnDisable`, `Update`, `FixedUpdate`, `LateUpdate`, `OnDestroy`) into method-level attributes. Mark as many methods as you like with `[Awake]`/`[Start]`/etc.; the generator emits a single dispatcher per hook that calls them all in order. This lets multiple features on the same MonoBehaviour cooperate on a lifecycle method without anyone owning `Awake()` outright — which is also how `[Singleton]` and `[Dependency]` integrate cleanly. + +
+Lifecycle attributes  — hook into Unity's lifecycle without owning the method + +  + +One attribute per Unity message: + +| Attribute | Generated dispatcher | +| ---------------- | -------------------- | +| `[Awake]` | `private void Awake()` | +| `[Start]` | `private void Start()` | +| `[OnEnable]` | `private void OnEnable()` | +| `[OnDisable]` | `private void OnDisable()` | +| `[Update]` | `private void Update()` | +| `[FixedUpdate]` | `private void FixedUpdate()` | +| `[LateUpdate]` | `private void LateUpdate()` | +| `[OnDestroy]` | `private void OnDestroy()` | + +```csharp +using EngineRoom.Runtime.Lifecycle; +using UnityEngine; + +public partial class Player : MonoBehaviour +{ + [Awake] + private void WireInput() { /* … */ } + + [Awake] + private void CacheComponents() { /* … */ } + + [Update(order: -10)] + private void PollInput() { /* runs before any unordered [Update] */ } + + [Update] + private void Step() { /* … */ } + + [OnDestroy] + private void Unsubscribe() { /* … */ } +} +``` + +The generator emits a single partial: + +```csharp +public partial class Player +{ + private void Awake() + { + WireInput(); + CacheComponents(); + OnAwake(); // back-compat hook (always declared for Awake/Start) + } + + partial void OnAwake(); + + private void Update() + { + PollInput(); // order = -10, runs first + Step(); // unordered, source position + } + + private void OnDestroy() + { + Unsubscribe(); + } +} +``` + +#### Ordering + +Each attribute takes an optional `int order` (defaults to `0`). Methods are sorted by ascending `Order`, then by their declaration position in source for stable ties. Methods without an explicit order intermix with `order: 0` and follow source order among themselves. When two methods on the same lifecycle share the same **explicit** order, the analyzer warns (`ERG0205`) and the tiebreak falls back to source position. + +#### Interaction with `[Singleton]` and `[Dependency]` + +`[Singleton]` always runs its `SingletonAwake()` helper **before** any user `[Awake]` methods on the same class, and short-circuits the rest of the dispatcher on a duplicate (the `gameObject` has been `Destroy`'d). `[Dependency]` always resolves fields via `DependencyStart()` **before** any user `[Start]` methods. These are special-cased — they don't participate in `Order` sorting. + +For backwards compatibility, the `Awake` and `Start` dispatchers still call (and declare) the `OnAwake()` and `OnStart()` partial hooks at the end, so existing code that implemented those keeps working. New code is encouraged to use `[Awake]`/`[Start]`-marked methods instead. + +#### Constraints + +- The host class must be `partial` and inherit from `MonoBehaviour` (`ERG0201`, `ERG0202`). +- A lifecycle-attributed method must be an instance method, take no parameters, and return `void` (`ERG0204`). +- The class must not define the lifecycle entry-point method directly (e.g. `void Awake()` when the class also has `[Awake]` methods or `[Singleton]`) — the generator emits it (`ERG0203`). + +
+ +--- + ## Requirements Unity **2022.3** or newer. Tested on **Unity 2022.3.62** and **Unity 6000.4.0f1**. diff --git a/src/generators-src/EngineRoom.Generators/Dependency/DependencyAnalyzer.cs b/src/generators-src/EngineRoom.Generators/Dependency/DependencyAnalyzer.cs index 47f8317..46c70c6 100644 --- a/src/generators-src/EngineRoom.Generators/Dependency/DependencyAnalyzer.cs +++ b/src/generators-src/EngineRoom.Generators/Dependency/DependencyAnalyzer.cs @@ -18,7 +18,6 @@ public sealed class DependencyAnalyzer : DiagnosticAnalyzer public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create( DependencyDiagnostics.MustBeMonoBehaviour, DependencyDiagnostics.MustBePartial, - DependencyDiagnostics.MustNotDefineStart, DependencyDiagnostics.FieldMustBeNonPublicInstance, DependencyDiagnostics.FieldTypeMustBeSingletonInterface); @@ -66,16 +65,9 @@ private static void AnalyzeNamedType(SymbolAnalysisContext ctx) ctx.ReportDiagnostic(Diagnostic.Create(DependencyDiagnostics.MustBePartial, classLocation, className)); } - var existingStart = classSymbol.GetMembers("Start") - .OfType() - .FirstOrDefault(static method => method.MethodKind == MethodKind.Ordinary - && method.Parameters.Length == 0 - && !method.IsStatic); - if (existingStart is not null) - { - var startLocation = existingStart.Locations.FirstOrDefault() ?? classLocation; - ctx.ReportDiagnostic(Diagnostic.Create(DependencyDiagnostics.MustNotDefineStart, startLocation, className)); - } + // User-defined Start() conflict is the Lifecycle analyzer's job now: + // [Dependency] no longer emits Start itself — it emits a + // DependencyStart helper that the lifecycle dispatcher calls. } private static void AnalyzeField(SymbolAnalysisContext ctx) diff --git a/src/generators-src/EngineRoom.Generators/Dependency/DependencyDiagnostics.cs b/src/generators-src/EngineRoom.Generators/Dependency/DependencyDiagnostics.cs index 64de39d..4c98aa2 100644 --- a/src/generators-src/EngineRoom.Generators/Dependency/DependencyDiagnostics.cs +++ b/src/generators-src/EngineRoom.Generators/Dependency/DependencyDiagnostics.cs @@ -22,13 +22,9 @@ internal static class DependencyDiagnostics defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); - public static readonly DiagnosticDescriptor MustNotDefineStart = new DiagnosticDescriptor( - id: "ERG0103", - title: "[Dependency] host class must not define its own Start", - messageFormat: "Class '{0}' has a [Dependency] field and must not define a Start() method. Move that code into OnStart() instead.", - category: Category, - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true); + // ERG0103 ("must not define Start") retired with the lifecycle refactor — + // the equivalent check now lives in LifecycleAnalyzer (ERG0203). ID kept + // reserved so it can't be silently reused for an unrelated diagnostic. public static readonly DiagnosticDescriptor FieldMustBeNonPublicInstance = new DiagnosticDescriptor( id: "ERG0104", diff --git a/src/generators-src/EngineRoom.Generators/Dependency/DependencyGenerator.cs b/src/generators-src/EngineRoom.Generators/Dependency/DependencyGenerator.cs index 707642d..5983e18 100644 --- a/src/generators-src/EngineRoom.Generators/Dependency/DependencyGenerator.cs +++ b/src/generators-src/EngineRoom.Generators/Dependency/DependencyGenerator.cs @@ -61,16 +61,6 @@ public void Initialize(IncrementalGeneratorInitializationContext context) return null; } - var hasOwnStart = classSymbol.GetMembers("Start") - .OfType() - .Any(static method => method.MethodKind == MethodKind.Ordinary - && method.Parameters.Length == 0 - && !method.IsStatic); - if (hasOwnStart) - { - return null; - } - if (fieldSymbol.IsStatic) { return null; diff --git a/src/generators-src/EngineRoom.Generators/EngineRoom.Generators.csproj b/src/generators-src/EngineRoom.Generators/EngineRoom.Generators.csproj index fc3f146..a8e01ab 100644 --- a/src/generators-src/EngineRoom.Generators/EngineRoom.Generators.csproj +++ b/src/generators-src/EngineRoom.Generators/EngineRoom.Generators.csproj @@ -41,6 +41,14 @@ + + + + + + + + 4?$fDdyM!;z8j-f(`*WwAb$nh8 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