From 6948b01a9db2604789f640373d204d1662878573 Mon Sep 17 00:00:00 2001 From: Emmanuel The Creator <214685372+EmmanuelTheCreator@users.noreply.github.com> Date: Wed, 17 Sep 2025 13:20:10 +0200 Subject: [PATCH] Keep puppet sprites active across exitFrame --- .../MovieEventOrderTests.cs | 41 ++++ .../PuppetSpriteLifecycleTests.cs | 207 ++++++++++++++++++ .../TestDoubles/FakeFrameworkMovie.cs | 29 +++ .../TestDoubles/FakeLingoMovieBuilder.cs | 90 ++++++++ .../TestDoubles/FakeSprite2DManager.cs | 98 +++++++++ .../TestDoubles/FakeTransitionManager.cs | 140 ++++++++++++ .../TestDoubles/FakeTransitionPlayer.cs | 42 ++++ .../TestDoubles/PrivateFieldSetter.cs | 25 +++ .../TestDoubles/RecordingFrameHandler.cs | 22 ++ .../TransitionEventOrderTests.cs | 50 +++++ .../LingoToCSharpConverter.cs | 1 + src/LingoEngine/Core/LingoClock.cs | 13 +- src/LingoEngine/Events/LingoEventMediator.cs | 5 + .../Movies/Events/IHasIdleFrameEvent.cs | 7 + src/LingoEngine/Movies/LingoDebugOverlay.cs | 4 + src/LingoEngine/Movies/LingoMovie.cs | 153 +++++++++++-- src/LingoEngine/Sprites/LingoSprite.cs | 31 ++- src/LingoEngine/Sprites/LingoSprite2D.cs | 30 ++- .../Sprites/LingoSprite2DManager.cs | 7 +- src/LingoEngine/Sprites/LingoSpriteManager.cs | 36 ++- 20 files changed, 999 insertions(+), 32 deletions(-) create mode 100644 Test/LingoEngine.Lingo.Tests/MovieEventOrderTests.cs create mode 100644 Test/LingoEngine.Lingo.Tests/PuppetSpriteLifecycleTests.cs create mode 100644 Test/LingoEngine.Lingo.Tests/TestDoubles/FakeFrameworkMovie.cs create mode 100644 Test/LingoEngine.Lingo.Tests/TestDoubles/FakeLingoMovieBuilder.cs create mode 100644 Test/LingoEngine.Lingo.Tests/TestDoubles/FakeSprite2DManager.cs create mode 100644 Test/LingoEngine.Lingo.Tests/TestDoubles/FakeTransitionManager.cs create mode 100644 Test/LingoEngine.Lingo.Tests/TestDoubles/FakeTransitionPlayer.cs create mode 100644 Test/LingoEngine.Lingo.Tests/TestDoubles/PrivateFieldSetter.cs create mode 100644 Test/LingoEngine.Lingo.Tests/TestDoubles/RecordingFrameHandler.cs create mode 100644 Test/LingoEngine.Lingo.Tests/TransitionEventOrderTests.cs create mode 100644 src/LingoEngine/Movies/Events/IHasIdleFrameEvent.cs diff --git a/Test/LingoEngine.Lingo.Tests/MovieEventOrderTests.cs b/Test/LingoEngine.Lingo.Tests/MovieEventOrderTests.cs new file mode 100644 index 000000000..5f88e1e9f --- /dev/null +++ b/Test/LingoEngine.Lingo.Tests/MovieEventOrderTests.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Linq; +using LingoEngine.Events; +using LingoEngine.Lingo.Tests.TestDoubles; +using Xunit; + +namespace LingoEngine.Lingo.Tests; + +public class MovieEventOrderTests +{ + [Fact] + public void AdvanceFrame_RaisesFrameLifecycleEventsInManualOrder() + { + var timeline = new List(); + var mediator = new LingoEventMediator(); + var frameHandler = new RecordingFrameHandler(timeline); + mediator.Subscribe(frameHandler); + mediator.SubscribeStepFrame(frameHandler); + + var harness = FakeLingoMovieBuilder.Create(mediator, timeline); + PrivateFieldSetter.SetField(harness.Movie, "_isPlaying", true); + + harness.Movie.AdvanceFrame(); + harness.Movie.OnIdle(1f / 60f); + harness.Movie.AdvanceFrame(); + + var expected = new[] + { + "beginSprite", + "stepFrame", + "prepareFrame", + "enterFrame", + "idleFrame", + "exitFrame", + "endSprite" + }; + + Assert.True(timeline.Count >= expected.Length, "timeline missing expected callbacks"); + Assert.Equal(expected, timeline.Take(expected.Length)); + } +} diff --git a/Test/LingoEngine.Lingo.Tests/PuppetSpriteLifecycleTests.cs b/Test/LingoEngine.Lingo.Tests/PuppetSpriteLifecycleTests.cs new file mode 100644 index 000000000..4a47f48d5 --- /dev/null +++ b/Test/LingoEngine.Lingo.Tests/PuppetSpriteLifecycleTests.cs @@ -0,0 +1,207 @@ +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; +using LingoEngine.Events; +using LingoEngine.Lingo.Tests.TestDoubles; +using LingoEngine.Members; +using LingoEngine.Movies; +using LingoEngine.Sprites; +using AbstUI.Components; +using AbstUI.Primitives; +using Xunit; + +namespace LingoEngine.Lingo.Tests; + +public sealed class PuppetSpriteLifecycleTests +{ + [Fact] + public void UpdateActiveSprites_RemovesTimelineSpriteWhenPuppetNotSet() + { + var harness = TestHarness.Create(); + var sprite = harness.CreateSprite(spriteNum: 1, beginFrame: 1, endFrame: 1); + sprite.IsActive = true; + harness.Manager.TrackActiveSprite(sprite); + + harness.Manager.UpdateActiveSprites(currentFrame: 2, lastFrame: 1); + + Assert.DoesNotContain(sprite, harness.Manager.ActiveSprites); + Assert.Contains(sprite, harness.Manager.ExitedSprites); + Assert.False(sprite.IsActive); + } + + [Fact] + public void UpdateActiveSprites_PreservesSpriteWhenPuppetTrue() + { + var harness = TestHarness.Create(); + var sprite = harness.CreateSprite(spriteNum: 1, beginFrame: 1, endFrame: 1); + sprite.IsActive = true; + sprite.Puppet = true; + harness.Manager.TrackActiveSprite(sprite); + + harness.Manager.UpdateActiveSprites(currentFrame: 2, lastFrame: 1); + + Assert.Contains(sprite, harness.Manager.ActiveSprites); + Assert.DoesNotContain(sprite, harness.Manager.ExitedSprites); + Assert.True(sprite.IsActive); + } + + private sealed class TestHarness + { + private TestHarness(TestSpriteManager manager, LingoEventMediator mediator) + { + Manager = manager; + Mediator = mediator; + } + + internal TestSpriteManager Manager { get; } + private LingoEventMediator Mediator { get; } + + internal static TestHarness Create() + { + var mediator = new LingoEventMediator(); + var manager = TestSpriteManager.Create(); + return new TestHarness(manager, mediator); + } + + internal TestSprite CreateSprite(int spriteNum, int beginFrame, int endFrame) + { + var sprite = new TestSprite(Mediator); + sprite.Initialize(spriteNum, beginFrame, endFrame); + return sprite; + } + } + + private sealed class TestSpriteManager : LingoSpriteManager + { + private TestSpriteManager() : base(0, null!, null!) + { + } + + internal static TestSpriteManager Create() + { + var manager = (TestSpriteManager)FormatterServices.GetUninitializedObject(typeof(TestSpriteManager)); + manager.Initialize(); + return manager; + } + + private void Initialize() + { + PrivateFieldSetter.SetField(this, "_mutedSprites", new List()); + PrivateFieldSetter.SetField(this, "_movie", (LingoMovie)FormatterServices.GetUninitializedObject(typeof(LingoMovie))); + PrivateFieldSetter.SetField(this, "_environment", null); + PrivateFieldSetter.SetField(this, "k__BackingField", 0); + PrivateFieldSetter.SetField(this, "_spriteChannels", new Dictionary()); + PrivateFieldSetter.SetField(this, "_spritesByName", new Dictionary()); + PrivateFieldSetter.SetField(this, "_allTimeSprites", new List()); + PrivateFieldSetter.SetField(this, "_newPuppetSprites", new List()); + PrivateFieldSetter.SetField(this, "_activeSprites", new Dictionary()); + PrivateFieldSetter.SetField(this, "_activeSpritesOrdered", new List()); + PrivateFieldSetter.SetField(this, "_enteredSprites", new List()); + PrivateFieldSetter.SetField(this, "_exitedSprites", new List()); + PrivateFieldSetter.SetField(this, "_deletedPuppetSpritesCache", new Dictionary()); + } + + protected override TestSprite OnCreateSprite(LingoMovie movie, Action onRemove) => throw new NotSupportedException(); + + protected override LingoSprite? OnAdd(int spriteNum, int begin, int end, ILingoMember? member) => null; + + protected override void SpriteEntered(TestSprite sprite) + { + } + + protected override void SpriteExited(TestSprite sprite) + { + } + + internal void TrackActiveSprite(TestSprite sprite) + { + _allTimeSprites.Add(sprite); + _activeSprites[sprite.SpriteNum] = sprite; + _activeSpritesOrdered.Add(sprite); + } + + internal IReadOnlyCollection ActiveSprites => _activeSprites.Values; + internal IReadOnlyCollection ExitedSprites => _exitedSprites; + } + + private sealed class TestSprite : LingoSprite + { + private readonly StubFrameworkSprite _stubFrameworkSprite; + + internal TestSprite(LingoEventMediator mediator) : base(mediator) + { + _stubFrameworkSprite = new StubFrameworkSprite(); + PrivateFieldSetter.SetField(this, "_frameworkSprite", _stubFrameworkSprite); + } + + public override int SpriteNumWithChannel => SpriteNum; + + internal void Initialize(int spriteNum, int beginFrame, int endFrame) + { + Init(spriteNum, $"Sprite_{spriteNum}"); + BeginFrame = beginFrame; + EndFrame = endFrame; + } + + public override void OnRemoveMe() + { + } + } + + private sealed class StubFrameworkSprite : ILingoFrameworkSprite + { + public string Name { get; set; } = string.Empty; + public bool Visibility { get; set; } + public float Width { get; set; } + public float Height { get; set; } + public AMargin Margin { get; set; } + public int ZIndex { get; set; } + public object FrameworkNode => this; + public float X { get; set; } + public float Y { get; set; } + public float Blend { get; set; } + public APoint RegPoint { get; set; } + public float DesiredHeight { get; set; } + public float DesiredWidth { get; set; } + public float Rotation { get; set; } + public float Skew { get; set; } + public bool FlipH { get; set; } + public bool FlipV { get; set; } + public bool DirectToStage { get; set; } + public int Ink { get; set; } + + public void Dispose() + { + } + + public void Hide() + { + } + + public void MemberChanged() + { + } + + public void RemoveMe() + { + } + + public void SetPosition(APoint point) + { + X = point.X; + Y = point.Y; + } + + public void SetTexture(IAbstTexture2D texture) + { + } + + public void Show() + { + } + + public void ApplyMemberChangesOnStepFrame() + { + } + } +} diff --git a/Test/LingoEngine.Lingo.Tests/TestDoubles/FakeFrameworkMovie.cs b/Test/LingoEngine.Lingo.Tests/TestDoubles/FakeFrameworkMovie.cs new file mode 100644 index 000000000..257feae30 --- /dev/null +++ b/Test/LingoEngine.Lingo.Tests/TestDoubles/FakeFrameworkMovie.cs @@ -0,0 +1,29 @@ +using AbstUI.Primitives; +using LingoEngine.Movies; + +namespace LingoEngine.Lingo.Tests.TestDoubles; + +internal sealed class FakeFrameworkMovie : ILingoFrameworkMovie +{ + public string Name { get; set; } = "TestFrameworkMovie"; + public bool Visibility { get; set; } = true; + public float Width { get; set; } = 640f; + public float Height { get; set; } = 480f; + public AMargin Margin { get; set; } = AMargin.Zero; + public int ZIndex { get; set; } + public object FrameworkNode => this; + + public void Dispose() + { + } + + public APoint GetGlobalMousePosition() => (0, 0); + + public void RemoveMe() + { + } + + public void UpdateStage() + { + } +} diff --git a/Test/LingoEngine.Lingo.Tests/TestDoubles/FakeLingoMovieBuilder.cs b/Test/LingoEngine.Lingo.Tests/TestDoubles/FakeLingoMovieBuilder.cs new file mode 100644 index 000000000..c649a4aa8 --- /dev/null +++ b/Test/LingoEngine.Lingo.Tests/TestDoubles/FakeLingoMovieBuilder.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; +using LingoEngine.Core; +using LingoEngine.Events; +using LingoEngine.Inputs; +using LingoEngine.Movies; +using LingoEngine.Sprites; + +namespace LingoEngine.Lingo.Tests.TestDoubles; + +internal static class FakeLingoMovieBuilder +{ + internal static FakeLingoMovieHarness Create(LingoEventMediator mediator, List timeline, Action? configure = null) + { + var options = new FakeLingoMovieOptions(); + configure?.Invoke(options); + + var movie = (LingoMovie)FormatterServices.GetUninitializedObject(typeof(LingoMovie)); + + var spriteManagers = new List(); + PrivateFieldSetter.SetField(movie, "_spriteManagers", spriteManagers); + PrivateFieldSetter.SetField(movie, "_eventMediator", mediator); + PrivateFieldSetter.SetField(movie, "_actorList", new ActorList()); + PrivateFieldSetter.SetField(movie, "_idleHandlerPeriod", 1); + PrivateFieldSetter.SetField(movie, "_idleIntervalSeconds", 1f / 60f); + PrivateFieldSetter.SetField(movie, "_currentFrame", 0); + PrivateFieldSetter.SetField(movie, "_lastFrame", 0); + PrivateFieldSetter.SetField(movie, "_nextFrame", -1); + PrivateFieldSetter.SetField(movie, "_needToRaiseStartMovie", false); + PrivateFieldSetter.SetField(movie, "_hasPendingEndSprites", false); + PrivateFieldSetter.SetField(movie, "_frameIsActive", false); + PrivateFieldSetter.SetField(movie, "_idleAccumulator", 0f); + + var mouse = (LingoStageMouse)FormatterServices.GetUninitializedObject(typeof(LingoStageMouse)); + PrivateFieldSetter.SetField(movie, "_lingoMouse", mouse); + + var frameworkMovie = new FakeFrameworkMovie(); + PrivateFieldSetter.SetField(movie, "_frameworkMovie", frameworkMovie); + + var sprite2DManager = FakeSprite2DManager.Create(movie, mouse, timeline); + PrivateFieldSetter.SetField(movie, "_sprite2DManager", sprite2DManager); + + var transitionManager = FakeTransitionManager.Create(movie, mediator, timeline); + PrivateFieldSetter.SetField(movie, "_transitionManager", transitionManager); + + var transitionPlayer = options.TransitionPlayer ?? new FakeTransitionPlayer( + options.TransitionStartLabel != null ? timeline : null, + options.TransitionStartLabel); + PrivateFieldSetter.SetField(movie, "_transitionPlayer", transitionPlayer); + + if (options.RecordTransitionLifecycle) + { + transitionManager.IsLifecycleRecordingEnabled = true; + transitionManager.SetActivationFrame(options.TransitionActivationFrame); + spriteManagers.Add(transitionManager); + } + else + { + transitionManager.IsLifecycleRecordingEnabled = false; + transitionManager.SetActivationFrame(int.MaxValue); + } + + return new FakeLingoMovieHarness(movie, sprite2DManager, transitionManager, transitionPlayer); + } +} + +internal sealed class FakeLingoMovieOptions +{ + internal bool RecordTransitionLifecycle { get; set; } + internal int TransitionActivationFrame { get; set; } = 1; + internal FakeTransitionPlayer? TransitionPlayer { get; set; } + internal string? TransitionStartLabel { get; set; } +} + +internal sealed class FakeLingoMovieHarness +{ + internal FakeLingoMovieHarness(LingoMovie movie, FakeSprite2DManager sprite2DManager, FakeTransitionManager transitionManager, FakeTransitionPlayer transitionPlayer) + { + Movie = movie; + Sprite2DManager = sprite2DManager; + TransitionManager = transitionManager; + TransitionPlayer = transitionPlayer; + } + + internal LingoMovie Movie { get; } + internal FakeSprite2DManager Sprite2DManager { get; } + internal FakeTransitionManager TransitionManager { get; } + internal FakeTransitionPlayer TransitionPlayer { get; } +} diff --git a/Test/LingoEngine.Lingo.Tests/TestDoubles/FakeSprite2DManager.cs b/Test/LingoEngine.Lingo.Tests/TestDoubles/FakeSprite2DManager.cs new file mode 100644 index 000000000..329c64683 --- /dev/null +++ b/Test/LingoEngine.Lingo.Tests/TestDoubles/FakeSprite2DManager.cs @@ -0,0 +1,98 @@ +using System.Collections.Generic; +using System.Runtime.Serialization; +using LingoEngine.Inputs; +using LingoEngine.Members; +using LingoEngine.Movies; +using LingoEngine.Sprites; + +namespace LingoEngine.Lingo.Tests.TestDoubles; + +internal sealed class FakeSprite2DManager : LingoSprite2DManager +{ + private List _timeline = null!; + private bool _shouldBegin; + private bool _shouldEnd; + private bool _hasActivated; + private int _activationFrame; + + private FakeSprite2DManager() : base(null!, null!) + { + } + + internal static FakeSprite2DManager Create(LingoMovie movie, LingoStageMouse mouse, List timeline) + { + var manager = (FakeSprite2DManager)FormatterServices.GetUninitializedObject(typeof(FakeSprite2DManager)); + manager.Initialize(movie, mouse, timeline); + return manager; + } + + private void Initialize(LingoMovie movie, LingoStageMouse mouse, List timeline) + { + _timeline = timeline; + _shouldBegin = false; + _shouldEnd = false; + _hasActivated = false; + _activationFrame = 1; + + PrivateFieldSetter.SetField(this, "_movie", movie); + PrivateFieldSetter.SetField(this, "_environment", null); + PrivateFieldSetter.SetField(this, "_lingoMouse", mouse); + PrivateFieldSetter.SetField(this, "_mutedSprites", new List()); + SpriteNumChannelOffset = LingoSprite2D.SpriteNumOffset; + + PrivateFieldSetter.SetField(this, "_spriteChannels", new Dictionary()); + PrivateFieldSetter.SetField(this, "_spritesByName", new Dictionary()); + PrivateFieldSetter.SetField(this, "_allTimeSprites", new List()); + PrivateFieldSetter.SetField(this, "_newPuppetSprites", new List()); + PrivateFieldSetter.SetField(this, "_activeSprites", new Dictionary()); + PrivateFieldSetter.SetField(this, "_activeSpritesOrdered", new List()); + PrivateFieldSetter.SetField(this, "_enteredSprites", new List()); + PrivateFieldSetter.SetField(this, "_exitedSprites", new List()); + PrivateFieldSetter.SetField(this, "_deletedPuppetSpritesCache", new Dictionary()); + PrivateFieldSetter.SetField(this, "_changedMembers", new List()); + } + + internal int ActivationFrame + { + get => _activationFrame; + set + { + _activationFrame = value; + _hasActivated = false; + _shouldBegin = false; + _shouldEnd = false; + } + } + + internal override void UpdateActiveSprites(int currentFrame, int lastFrame) + { + if (!_hasActivated && currentFrame >= _activationFrame) + { + _shouldBegin = true; + _shouldEnd = true; + _hasActivated = true; + } + } + + internal override void BeginSprites() + { + if (_shouldBegin) + { + _timeline.Add("beginSprite"); + _shouldBegin = false; + } + } + + internal override void PrepareEndSprites() + { + } + + internal override void DispatchEndSprites() + { + if (_shouldEnd) + { + _timeline.Add("endSprite"); + _shouldEnd = false; + } + } +} diff --git a/Test/LingoEngine.Lingo.Tests/TestDoubles/FakeTransitionManager.cs b/Test/LingoEngine.Lingo.Tests/TestDoubles/FakeTransitionManager.cs new file mode 100644 index 000000000..f6210cb07 --- /dev/null +++ b/Test/LingoEngine.Lingo.Tests/TestDoubles/FakeTransitionManager.cs @@ -0,0 +1,140 @@ +using System.Collections.Generic; +using System.Runtime.Serialization; +using LingoEngine.Events; +using LingoEngine.Movies; +using LingoEngine.Sprites; +using LingoEngine.Transitions; + +namespace LingoEngine.Lingo.Tests.TestDoubles; + +internal sealed class FakeTransitionManager : LingoSpriteTransitionManager +{ + private List _timeline = null!; + private bool _shouldBegin; + private bool _shouldEnd; + private bool _hasActivated; + private int _activationFrame; + private bool _recordLifecycle; + private LingoTransitionSprite? _fakeSprite; + + private FakeTransitionManager() : base(null!, null!) + { + } + + internal static FakeTransitionManager Create(LingoMovie movie, LingoEventMediator mediator, List timeline) + { + var manager = (FakeTransitionManager)FormatterServices.GetUninitializedObject(typeof(FakeTransitionManager)); + manager.Initialize(movie, mediator, timeline); + return manager; + } + + private void Initialize(LingoMovie movie, LingoEventMediator mediator, List timeline) + { + _timeline = timeline; + _shouldBegin = false; + _shouldEnd = false; + _hasActivated = false; + _activationFrame = int.MaxValue; + _recordLifecycle = false; + + PrivateFieldSetter.SetField(this, "_movie", movie); + PrivateFieldSetter.SetField(this, "_environment", null); + PrivateFieldSetter.SetField(this, "_mutedSprites", new List()); + SpriteNumChannelOffset = LingoTransitionSprite.SpriteNumOffset; + + PrivateFieldSetter.SetField(this, "_spriteChannels", new Dictionary()); + PrivateFieldSetter.SetField(this, "_spritesByName", new Dictionary()); + PrivateFieldSetter.SetField(this, "_allTimeSprites", new List()); + PrivateFieldSetter.SetField(this, "_newPuppetSprites", new List()); + PrivateFieldSetter.SetField(this, "_activeSprites", new Dictionary()); + PrivateFieldSetter.SetField(this, "_activeSpritesOrdered", new List()); + PrivateFieldSetter.SetField(this, "_enteredSprites", new List()); + PrivateFieldSetter.SetField(this, "_exitedSprites", new List()); + PrivateFieldSetter.SetField(this, "_deletedPuppetSpritesCache", new Dictionary()); + + _fakeSprite = (LingoTransitionSprite)FormatterServices.GetUninitializedObject(typeof(LingoTransitionSprite)); + PrivateFieldSetter.SetField(_fakeSprite, "_eventMediator", mediator); + _fakeSprite.BeginFrame = 1; + _fakeSprite.EndFrame = 1; + } + + internal bool IsLifecycleRecordingEnabled + { + get => _recordLifecycle; + set + { + _recordLifecycle = value; + if (!value) + { + _activationFrame = int.MaxValue; + _hasActivated = false; + _shouldBegin = false; + _shouldEnd = false; + } + } + } + + internal void SetActivationFrame(int frame) + { + _activationFrame = frame; + _hasActivated = false; + _shouldBegin = false; + _shouldEnd = false; + if (_fakeSprite != null) + { + _fakeSprite.BeginFrame = frame; + _fakeSprite.EndFrame = frame; + } + + if (frame == int.MaxValue) + { + _allTimeSprites.Clear(); + } + else if (_fakeSprite != null) + { + _allTimeSprites.Clear(); + _allTimeSprites.Add(_fakeSprite); + } + } + + internal override void UpdateActiveSprites(int currentFrame, int lastFrame) + { + if (!_recordLifecycle || _hasActivated) + return; + + if (currentFrame >= _activationFrame) + { + _shouldBegin = true; + _shouldEnd = true; + _hasActivated = true; + } + } + + internal override void BeginSprites() + { + if (!_recordLifecycle) + return; + + if (_shouldBegin) + { + _timeline.Add("transition.beginSprite"); + _shouldBegin = false; + } + } + + internal override void PrepareEndSprites() + { + } + + internal override void DispatchEndSprites() + { + if (!_recordLifecycle) + return; + + if (_shouldEnd) + { + _timeline.Add("transition.endSprite"); + _shouldEnd = false; + } + } +} diff --git a/Test/LingoEngine.Lingo.Tests/TestDoubles/FakeTransitionPlayer.cs b/Test/LingoEngine.Lingo.Tests/TestDoubles/FakeTransitionPlayer.cs new file mode 100644 index 000000000..4869e64c0 --- /dev/null +++ b/Test/LingoEngine.Lingo.Tests/TestDoubles/FakeTransitionPlayer.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using LingoEngine.Transitions; + +namespace LingoEngine.Lingo.Tests.TestDoubles; + +internal sealed class FakeTransitionPlayer : ILingoTransitionPlayer +{ + private readonly List? _timeline; + private readonly string? _startLabel; + + internal FakeTransitionPlayer(List? timeline = null, string? startLabel = null) + { + _timeline = timeline; + _startLabel = startLabel; + } + + internal bool StartResult { get; set; } + + internal int StartCallCount { get; private set; } + + public bool IsActive { get; private set; } + + public bool Start(LingoTransitionSprite sprite) + { + StartCallCount++; + if (_startLabel != null) + _timeline?.Add(_startLabel); + + IsActive = StartResult; + return StartResult; + } + + public void Tick() + { + if (IsActive) + _timeline?.Add("transition.tick"); + } + + public void Dispose() + { + } +} diff --git a/Test/LingoEngine.Lingo.Tests/TestDoubles/PrivateFieldSetter.cs b/Test/LingoEngine.Lingo.Tests/TestDoubles/PrivateFieldSetter.cs new file mode 100644 index 000000000..156e37cab --- /dev/null +++ b/Test/LingoEngine.Lingo.Tests/TestDoubles/PrivateFieldSetter.cs @@ -0,0 +1,25 @@ +using System; +using System.Reflection; + +namespace LingoEngine.Lingo.Tests.TestDoubles; + +internal static class PrivateFieldSetter +{ + internal static void SetField(object target, string fieldName, object? value) + { + var type = target.GetType(); + while (type != null) + { + var field = type.GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + if (field != null) + { + field.SetValue(target, value); + return; + } + + type = type.BaseType; + } + + throw new InvalidOperationException($"Field '{fieldName}' not found on type '{target.GetType()}'."); + } +} diff --git a/Test/LingoEngine.Lingo.Tests/TestDoubles/RecordingFrameHandler.cs b/Test/LingoEngine.Lingo.Tests/TestDoubles/RecordingFrameHandler.cs new file mode 100644 index 000000000..c07d6ef60 --- /dev/null +++ b/Test/LingoEngine.Lingo.Tests/TestDoubles/RecordingFrameHandler.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using LingoEngine.Movies.Events; + +namespace LingoEngine.Lingo.Tests.TestDoubles; + +internal sealed class RecordingFrameHandler : IHasStepFrameEvent, IHasPrepareFrameEvent, + IHasEnterFrameEvent, IHasIdleFrameEvent, IHasExitFrameEvent +{ + private readonly List _timeline; + + internal RecordingFrameHandler(List timeline) => _timeline = timeline; + + public void StepFrame() => _timeline.Add("stepFrame"); + + public void PrepareFrame() => _timeline.Add("prepareFrame"); + + public void EnterFrame() => _timeline.Add("enterFrame"); + + public void IdleFrame() => _timeline.Add("idleFrame"); + + public void ExitFrame() => _timeline.Add("exitFrame"); +} diff --git a/Test/LingoEngine.Lingo.Tests/TransitionEventOrderTests.cs b/Test/LingoEngine.Lingo.Tests/TransitionEventOrderTests.cs new file mode 100644 index 000000000..3c1820613 --- /dev/null +++ b/Test/LingoEngine.Lingo.Tests/TransitionEventOrderTests.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using System.Linq; +using LingoEngine.Events; +using LingoEngine.Lingo.Tests.TestDoubles; +using Xunit; + +namespace LingoEngine.Lingo.Tests; + +public class TransitionEventOrderTests +{ + [Fact] + public void AdvanceFrame_RaisesTransitionLifecycleAroundFrameHandlers() + { + var timeline = new List(); + var mediator = new LingoEventMediator(); + var frameHandler = new RecordingFrameHandler(timeline); + mediator.Subscribe(frameHandler); + mediator.SubscribeStepFrame(frameHandler); + + var harness = FakeLingoMovieBuilder.Create(mediator, timeline, options => + { + options.RecordTransitionLifecycle = true; + options.TransitionActivationFrame = 1; + }); + + harness.TransitionPlayer.StartResult = false; + PrivateFieldSetter.SetField(harness.Movie, "_isPlaying", true); + + harness.Movie.AdvanceFrame(); + harness.Movie.OnIdle(1f / 60f); + harness.Movie.AdvanceFrame(); + + var expected = new[] + { + "beginSprite", + "transition.beginSprite", + "stepFrame", + "prepareFrame", + "enterFrame", + "idleFrame", + "exitFrame", + "endSprite", + "transition.endSprite" + }; + + Assert.True(timeline.Count >= expected.Length, "timeline missing expected callbacks"); + Assert.Equal(expected, timeline.Take(expected.Length)); + Assert.Equal(1, harness.TransitionPlayer.StartCallCount); + } +} diff --git a/src/LingoEngine.Lingo.Core/LingoToCSharpConverter.cs b/src/LingoEngine.Lingo.Core/LingoToCSharpConverter.cs index 0749325cf..1c89e4424 100644 --- a/src/LingoEngine.Lingo.Core/LingoToCSharpConverter.cs +++ b/src/LingoEngine.Lingo.Core/LingoToCSharpConverter.cs @@ -411,6 +411,7 @@ public string ConvertClass(LingoScriptFile script, LingoHandlerNode? newHandler, ["stepframe"] = "IHasStepFrameEvent", ["prepareframe"] = "IHasPrepareFrameEvent", ["enterframe"] = "IHasEnterFrameEvent", + ["idle"] = "IHasIdleFrameEvent", ["exitframe"] = "IHasExitFrameEvent" }; diff --git a/src/LingoEngine/Core/LingoClock.cs b/src/LingoEngine/Core/LingoClock.cs index 1de92373c..57c428bfb 100644 --- a/src/LingoEngine/Core/LingoClock.cs +++ b/src/LingoEngine/Core/LingoClock.cs @@ -6,6 +6,8 @@ public interface ILingoClockListener { void OnTick(); + + void OnIdle(float deltaTime); } /// /// Lingo Clock interface. @@ -35,16 +37,25 @@ public class LingoClock : ILingoClock public void Tick(float deltaTime) { TickCount++; + var previousAccumulated = _accumulatedTime; _accumulatedTime += deltaTime; float frameTime = 1f / FrameRate; while (_accumulatedTime >= frameTime) { - foreach (var l in _listeners) l.OnTick(); + foreach (var l in _listeners) + l.OnTick(); EngineTickCount++; _accumulatedTime -= frameTime; } + + var idleDelta = _accumulatedTime - previousAccumulated; + if (idleDelta > 0f) + { + foreach (var listener in _listeners) + listener.OnIdle(idleDelta); + } } public void Subscribe(ILingoClockListener listener) diff --git a/src/LingoEngine/Events/LingoEventMediator.cs b/src/LingoEngine/Events/LingoEventMediator.cs index f0252b193..9300c4f8b 100644 --- a/src/LingoEngine/Events/LingoEventMediator.cs +++ b/src/LingoEngine/Events/LingoEventMediator.cs @@ -28,6 +28,7 @@ public class LingoEventMediator : ILingoEventMediator, ILingoMouseEventHandler, private readonly List _stepFrames = new(); // must be handled by actorlist private readonly List _prepareFrames = new(); private readonly List _enterFrames = new(); + private readonly List _idleFrames = new(); private readonly List _exitFrames = new(); private readonly List _focuss = new(); private readonly List _blurs = new(); @@ -83,6 +84,7 @@ public void Subscribe(object ms, int priority = DefaultPriority, bool ignoreMous if (ms is IHasPrepareFrameEvent prepareFrameEvent) Insert(_prepareFrames, prepareFrameEvent); if (ms is IHasEnterFrameEvent enterFrameEvent) Insert(_enterFrames, enterFrameEvent); + if (ms is IHasIdleFrameEvent idleFrameEvent) Insert(_idleFrames, idleFrameEvent); if (ms is IHasExitFrameEvent exitFrameEvent) Insert(_exitFrames, exitFrameEvent); if (ms is IHasFocusEvent focusEvent) Insert(_focuss, focusEvent); if (ms is IHasBlurEvent blurEvent) Insert(_blurs, blurEvent); @@ -113,6 +115,7 @@ public void Unsubscribe(object ms, bool ignoreMouse = false) // Not stepframe it seems stepframe is only used through the actor list. if (ms is IHasPrepareFrameEvent prepareFrameEvent) _prepareFrames.Remove(prepareFrameEvent); if (ms is IHasEnterFrameEvent enterFrameEvent) _enterFrames.Remove(enterFrameEvent); + if (ms is IHasIdleFrameEvent idleFrameEvent) _idleFrames.Remove(idleFrameEvent); if (ms is IHasExitFrameEvent exitFrameEvent) _exitFrames.Remove(exitFrameEvent); if (ms is IHasFocusEvent focusEvent) _focuss.Remove(focusEvent); if (ms is IHasBlurEvent blurEvent) _blurs.Remove(blurEvent); @@ -148,6 +151,7 @@ bool ShouldRemove(object obj) => string.IsNullOrEmpty(preserveNamespaceFragment) FilterList(_mouseExits); FilterList(_prepareFrames); FilterList(_enterFrames); + FilterList(_idleFrames); FilterList(_exitFrames); FilterList(_focuss); FilterList(_blurs); @@ -172,6 +176,7 @@ bool ShouldRemove(object obj) => string.IsNullOrEmpty(preserveNamespaceFragment) internal void RaiseStepFrame() => _stepFrames.ForEach(x => x.StepFrame()); internal void RaisePrepareFrame() => _prepareFrames.ForEach(x => x.PrepareFrame()); internal void RaiseEnterFrame() => _enterFrames.ForEach(x => x.EnterFrame()); + internal void RaiseIdleFrame() => _idleFrames.ForEach(x => x.IdleFrame()); internal void RaiseExitFrame() => _exitFrames.ForEach(x => x.ExitFrame()); public void RaiseFocus() => _focuss.ForEach(x => x.Focus()); public void RaiseBlur() => _blurs.ForEach(x => x.Blur()); diff --git a/src/LingoEngine/Movies/Events/IHasIdleFrameEvent.cs b/src/LingoEngine/Movies/Events/IHasIdleFrameEvent.cs new file mode 100644 index 000000000..0086c7d45 --- /dev/null +++ b/src/LingoEngine/Movies/Events/IHasIdleFrameEvent.cs @@ -0,0 +1,7 @@ +namespace LingoEngine.Movies.Events +{ + public interface IHasIdleFrameEvent + { + void IdleFrame(); + } +} diff --git a/src/LingoEngine/Movies/LingoDebugOverlay.cs b/src/LingoEngine/Movies/LingoDebugOverlay.cs index e3eea20a6..254138112 100644 --- a/src/LingoEngine/Movies/LingoDebugOverlay.cs +++ b/src/LingoEngine/Movies/LingoDebugOverlay.cs @@ -75,6 +75,10 @@ public void OnTick() _engineFrames++; } + public void OnIdle(float deltaTime) + { + } + public void Prepare() { throw new NotImplementedException(); diff --git a/src/LingoEngine/Movies/LingoMovie.cs b/src/LingoEngine/Movies/LingoMovie.cs index 19e03d863..00ebb4b34 100644 --- a/src/LingoEngine/Movies/LingoMovie.cs +++ b/src/LingoEngine/Movies/LingoMovie.cs @@ -58,6 +58,20 @@ public class LingoMovie : ILingoMovie, ILingoClockListener, IDisposable private readonly LingoMovieScriptContainer _movieScripts; private readonly List _spriteManagers = new(); + // Director bases idle timing on a 60 Hz clock where idleHandlerPeriod skips ticks before + // issuing an on idle. Period 0 removes that throttle so handlers can run as fast as + // possible during the delay between enterFrame and exitFrame (see the Director MX 2004 + // Scripting manual entry for idleHandlerPeriod). + private const float IdleBaseIntervalSeconds = 1f / 60f; + private const float UnthrottledIdleQuantumSeconds = 1f / 1000f; + private const int MaxIdleDispatchPerTick = 10_000; + + private bool _frameIsActive; + private bool _hasPendingEndSprites; + private float _idleAccumulator; + private int _idleHandlerPeriod = 1; + private float _idleIntervalSeconds = IdleBaseIntervalSeconds; + #region Properties @@ -153,6 +167,25 @@ public int Tempo get => _tempoManager.Tempo; set => _tempoManager.ChangeTempo(value); } + + /// + /// Mirrors Director's _movie.idleHandlerPeriod: 0 runs on idle as often as possible, + /// positive values cap the frequency to 60/n events per second using the 60 Hz idle + /// timer described in the Director scripting manual. + /// + public int IdleHandlerPeriod + { + get => _idleHandlerPeriod; + set + { + var normalized = value < 0 ? 0 : value; + if (SetProperty(ref _idleHandlerPeriod, normalized, nameof(IdleHandlerPeriod))) + { + _idleIntervalSeconds = normalized == 0 ? UnthrottledIdleQuantumSeconds : normalized * IdleBaseIntervalSeconds; + } + } + } + public int MaxSpriteChannelCount { get => _sprite2DManager.MaxSpriteChannelCount; @@ -335,6 +368,10 @@ public void AdvanceFrame() try { + // The previous frame is completed before advancing so that any + // deferred exitFrame/endSprite work happens in the documented + // order (enterFrame -> idle -> exitFrame -> endSprite). + FinishCurrentFrame(); var frameChanged = false; if (_nextFrame < 0) @@ -347,34 +384,39 @@ public void AdvanceFrame() frameChanged = SetProperty(ref _currentFrame, _nextFrame, nameof(CurrentFrame)); _nextFrame = -1; } + if (frameChanged) { OnPropertyChanged(nameof(Frame)); var transitionSprite = _transitionManager.GetFrameSprite(_currentFrame); - if (transitionSprite != null) - { - if (_transitionPlayer.Start(transitionSprite)) - _skipStepFrame = true; - } + if (transitionSprite != null && _transitionPlayer.Start(transitionSprite)) + _skipStepFrame = true; - // update the list with all ended, and all the new started sprites. + // Refresh the active sprite lists so we know which channels + // will fire beginSprite/endSprite on this frame. _sprite2DManager.UpdateActiveSprites(_currentFrame, _lastFrame); _spriteManagers.ForEach(x => x.UpdateActiveSprites(_currentFrame, _lastFrame)); - // End the sprites first, the frame has change, start by ending all sprites, that are not on this frame anymore. - _sprite2DManager.EndSprites(); - _spriteManagers.ForEach(x => x.EndSprites()); + // Prepare end-sprite state up-front. Director unsubscribes + // sprite behaviors before enterFrame so they cannot react + // during the idle window. + _sprite2DManager.PrepareEndSprites(); + _spriteManagers.ForEach(x => x.PrepareEndSprites()); - // Begin the new sprites + // Manual step 1: beginSprite fires for newly entered + // sprites before any frame scripts run. _sprite2DManager.BeginSprites(); _spriteManagers.ForEach(x => x.BeginSprites()); + + _hasPendingEndSprites = true; } else { - // Are there new puppet sprites set. _sprite2DManager.DoPuppetSprites(); + _hasPendingEndSprites = false; } + _lastFrame = _currentFrame; if (_needToRaiseStartMovie) @@ -383,29 +425,109 @@ public void AdvanceFrame() _needToRaiseStartMovie = false; } - _lingoMouse.UpdateMouseState(); + // Manual step 2: stepFrame executes sprite behaviors that + // opted into per-frame updates. _sprite2DManager.PreStepFrame(); if (!_skipStepFrame) _eventMediator.RaiseStepFrame(); + // Manual step 3: prepareFrame runs before frame data is ready. _eventMediator.RaisePrepareFrame(); + // Manual step 4: enterFrame signals that the playhead is now + // inside the new frame. Idle/keyboard/mouse processing occurs + // after this call while the frame is "open". _eventMediator.RaiseEnterFrame(); OnUpdateStage(); + _skipStepFrame = false; if (frameChanged) CurrentFrameChanged?.Invoke(_currentFrame); - _eventMediator.RaiseExitFrame(); + // Mark the frame as active so idle handlers can run before we + // dispatch exitFrame/endSprite on the next FinishCurrentFrame(). + _frameIsActive = true; } finally { - //_sprite2DManager.EndSprites(); - //_spriteManagers.ForEach(x => x.EndSprites()); _isAdvancing = false; } } + // exitFrame and endSprite must wait until idle handlers have consumed the tempo slack, + // matching the Director lifecycle (enterFrame -> idle -> exitFrame -> endSprite). + private void FinishCurrentFrame() + { + if (!_frameIsActive) + return; + + _frameIsActive = false; + + _lingoMouse.UpdateMouseState(); + _eventMediator.RaiseExitFrame(); + + if (_hasPendingEndSprites) + { + _sprite2DManager.DispatchEndSprites(); + _spriteManagers.ForEach(x => x.DispatchEndSprites()); + _hasPendingEndSprites = false; + } + } + + public void OnIdle(float deltaTime) + { + if (!_isPlaying || !_frameIsActive) + return; + + if (deltaTime <= 0f) + return; + + _idleAccumulator += deltaTime; + + if (_idleHandlerPeriod == 0) + { + DispatchIdleEvents(UnthrottledIdleQuantumSeconds, flushRemainder: true); + } + else + { + DispatchIdleEvents(_idleIntervalSeconds, flushRemainder: false); + } + } + + private void DispatchIdleEvents(float intervalSeconds, bool flushRemainder) + { + if (intervalSeconds <= 0f) + return; + + var iterations = 0; + while (_idleAccumulator >= intervalSeconds && iterations < MaxIdleDispatchPerTick) + { + if (!TryRaiseIdleFrame()) + return; + + _idleAccumulator -= intervalSeconds; + iterations++; + } + + if (flushRemainder && _idleAccumulator > 0f && iterations < MaxIdleDispatchPerTick) + { + if (TryRaiseIdleFrame()) + _idleAccumulator = 0f; + } + + if (iterations >= MaxIdleDispatchPerTick) + _idleAccumulator = 0f; + } + + private bool TryRaiseIdleFrame() + { + if (!_isPlaying || !_frameIsActive) + return false; + + _eventMediator.RaiseIdleFrame(); + return _isPlaying && _frameIsActive; + } + // Play the movie @@ -428,6 +550,7 @@ private void OnStop() { // on stop always restore the mouse to arrow _lingoMouse.SetCursor(AMouseCursor.Arrow); + FinishCurrentFrame(); SetProperty(ref _isPlaying, false, nameof(IsPlaying)); PlayStateChanged?.Invoke(false); _environment.Sound.StopAll(); diff --git a/src/LingoEngine/Sprites/LingoSprite.cs b/src/LingoEngine/Sprites/LingoSprite.cs index bbefecc7e..45cf16b4a 100644 --- a/src/LingoEngine/Sprites/LingoSprite.cs +++ b/src/LingoEngine/Sprites/LingoSprite.cs @@ -161,6 +161,10 @@ internal virtual void DoBeginSprite() if (InitialState != null) LoadState(InitialState); + // beginSprite is the first callback a channel receives on a frame + // where it becomes active. Director delivers it before stepFrame/ + // prepareFrame so behaviors can initialize their state for the new + // frame. // Subscribe all actors foreach (var actor in _spriteActors) { @@ -175,17 +179,36 @@ internal virtual void DoBeginSprite() } protected virtual void BeginSprite() { } - internal virtual void DoEndSprite() + internal virtual void PrepareForEndSprite() { - + // Called as soon as the engine detects a sprite will leave the + // frame. This happens before exitFrame so stepFrame and other + // listeners are unsubscribed during the idle window. foreach (var actor in _spriteActors) { - if (actor is IHasEndSpriteEvent end) end.EndSprite(); - if (actor is IHasStepFrameEvent stepframe) _eventMediator.UnsubscribeStepFrame(stepframe, SpriteNum + 6); + if (actor is IHasStepFrameEvent stepframe) + _eventMediator.UnsubscribeStepFrame(stepframe, SpriteNum + 6); _eventMediator.Unsubscribe(actor); } + } + + internal virtual void DispatchEndSpriteEvent() + { + // endSprite fires after exitFrame once the playhead has left the + // sprite. Behaviors receive their callbacks in attachment order + // followed by the sprite's own EndSprite hook. + foreach (var actor in _spriteActors) + if (actor is IHasEndSpriteEvent end) + end.EndSprite(); + EndSprite(); } + + internal virtual void DoEndSprite() + { + PrepareForEndSprite(); + DispatchEndSpriteEvent(); + } protected virtual void EndSprite() { } public virtual string GetFullName() => $"{SpriteNum}.{Name}"; diff --git a/src/LingoEngine/Sprites/LingoSprite2D.cs b/src/LingoEngine/Sprites/LingoSprite2D.cs index ec8516426..f5c02d4fa 100644 --- a/src/LingoEngine/Sprites/LingoSprite2D.cs +++ b/src/LingoEngine/Sprites/LingoSprite2D.cs @@ -363,6 +363,10 @@ 2 stopMovie internal override void DoBeginSprite() { + // When a score frame activates this sprite we bind visual state + // before Director sends stepFrame/prepareFrame. This mirrors the + // manual which specifies beginSprite occurs before other per-frame + // handlers. if (_textureSubscription == null && _member != null) { _member.UsedBy(this); @@ -393,21 +397,39 @@ internal override void DoBeginSprite() } return null; } - internal override void DoEndSprite() + internal override void PrepareForEndSprite() { + // Called prior to exitFrame so the sprite detaches from the stage + // before idle/exit handlers run. Behaviors no longer receive mouse + // or frame callbacks after this point. _behaviors.ForEach(b => { // Unsubscribe all behaviors _eventMediator.Unsubscribe(b, true); // we ignore mouse because it has to be within the boundingbox - if (_movie.IsPlaying) - if (b is IHasEndSpriteEvent endSpriteEvent) endSpriteEvent.EndSprite(); }); + FrameworkObj.Hide(); _textureSubscription?.Release(); _textureSubscription = null; // Release the old member link with this sprite _member?.ReleaseFromRefUser(this); - base.DoEndSprite(); + + base.PrepareForEndSprite(); + } + + internal override void DispatchEndSpriteEvent() + { + // Runs immediately after exitFrame. Behaviors observe the + // endSprite notification first, followed by the sprite itself so + // cleanup mirrors Director's documented order when leaving a frame. + if (_movie.IsPlaying) + { + foreach (var behavior in _behaviors) + if (behavior is IHasEndSpriteEvent endSpriteEvent) + endSpriteEvent.EndSprite(); + } + + base.DispatchEndSpriteEvent(); } diff --git a/src/LingoEngine/Sprites/LingoSprite2DManager.cs b/src/LingoEngine/Sprites/LingoSprite2DManager.cs index 3382f5b7d..92dede73d 100644 --- a/src/LingoEngine/Sprites/LingoSprite2DManager.cs +++ b/src/LingoEngine/Sprites/LingoSprite2DManager.cs @@ -93,12 +93,13 @@ protected override void OnBeginSprite(LingoSprite2D sprite) _lingoMouse.Subscribe(sprite); base.OnBeginSprite(sprite); } - protected override void OnEndSprite(LingoSprite2D sprite) + + protected override void OnPrepareEndSprite(LingoSprite2D sprite) { - base.OnEndSprite(sprite); + base.OnPrepareEndSprite(sprite); + if (_lingoMouse.IsSubscribed(sprite)) _lingoMouse.Unsubscribe(sprite); - } diff --git a/src/LingoEngine/Sprites/LingoSpriteManager.cs b/src/LingoEngine/Sprites/LingoSpriteManager.cs index c5af1fd36..da2c06508 100644 --- a/src/LingoEngine/Sprites/LingoSpriteManager.cs +++ b/src/LingoEngine/Sprites/LingoSpriteManager.cs @@ -62,13 +62,19 @@ protected LingoSpriteManager(LingoMovie movie, LingoMovieEnvironment environment public abstract void MuteChannel(int channel, bool state); internal abstract void UpdateActiveSprites(int currentFrame, int lastFrame); + internal abstract void PrepareEndSprites(); internal abstract void BeginSprites(); + internal abstract void DispatchEndSprites(); internal virtual LingoSprite? Add(int spriteNumWithChannel, int begin, int end, ILingoMember? member) { return OnAdd(spriteNumWithChannel - SpriteNumChannelOffset, begin, end, member); } protected abstract LingoSprite? OnAdd(int spriteNum, int begin, int end, ILingoMember? member); - internal abstract void EndSprites(); + internal virtual void EndSprites() + { + PrepareEndSprites(); + DispatchEndSprites(); + } } @@ -266,11 +272,15 @@ internal override void UpdateActiveSprites(int currentFrame, int lastFrame) _enteredSprites.Clear(); _exitedSprites.Clear(); + // Determine which sprites will receive beginSprite/endSprite on + // this frame. Entries that remain active stay in the ordered list + // so stepFrame/enterFrame handlers execute correctly. foreach (var sprite in _activeSpritesOrdered.ToArray()) // make a copy of the array { + var timelineActive = sprite.BeginFrame <= currentFrame && sprite.EndFrame >= currentFrame; + var puppetKeepsAlive = sprite.Puppet && !sprite.IsPuppetCached; - bool stillActive = sprite.BeginFrame <= currentFrame && sprite.EndFrame >= currentFrame; - if (!stillActive || sprite.IsPuppetCached) + if ((!timelineActive && !puppetKeepsAlive) || sprite.IsPuppetCached) { _exitedSprites.Add(sprite); _activeSprites.Remove(sprite.SpriteNum); @@ -312,17 +322,33 @@ protected virtual void SpriteExited(TSprite sprite) internal override void BeginSprites() { + // Manual step 1: beginSprite for newly entered channels before any + // frame script (stepFrame/prepareFrame/enterFrame) executes. foreach (var sprite in _enteredSprites) OnBeginSprite(sprite); } protected virtual void OnBeginSprite(TSprite sprite) => sprite.DoBeginSprite(); - internal override void EndSprites() + internal override void PrepareEndSprites() + { + // Director unsubscribes behaviors and listeners as soon as a sprite + // is known to be leaving. This happens prior to enterFrame so idle + // processing cannot touch sprites that are about to end. + foreach (var sprite in _exitedSprites) + OnPrepareEndSprite(sprite); + } + protected virtual void OnPrepareEndSprite(TSprite sprite) => sprite.PrepareForEndSprite(); + + internal override void DispatchEndSprites() { + // Manual step 6: endSprite fires only after exitFrame confirmed the + // playhead has left the channel. foreach (var sprite in _exitedSprites) OnEndSprite(sprite); + + _exitedSprites.Clear(); } - protected virtual void OnEndSprite(TSprite sprite) => sprite.DoEndSprite(); + protected virtual void OnEndSprite(TSprite sprite) => sprite.DispatchEndSpriteEvent();