// Copyright (c) 2015 - 2023 Doozy Entertainment. All Rights Reserved. // This code can only be used under the standard Unity Asset Store End User License Agreement // A Copy of the EULA APPENDIX 1 is available at http://unity3d.com/company/legal/as_terms using System; using System.Collections.Generic; using System.Linq; using Doozy.Runtime.Common.Attributes; using Doozy.Runtime.Common.Extensions; using Doozy.Runtime.Reactor.Easings; using Doozy.Runtime.Reactor.Ticker; using UnityEngine; using static UnityEngine.Mathf; namespace Doozy.Runtime.Reactor.Internal { [Serializable] public abstract class Reaction { #region Reaction State /// Reaction current state public ReactionState state { get; internal set; } /// Reaction state before it was paused (internal use only) public ReactionState stateBeforePause { get; internal set; } /// Reaction is in the Pool public bool isPooled => state == ReactionState.Pooled; /// Reaction is ready to run public bool isIdle => state == ReactionState.Idle; /// Reaction is running (is not in the pool and is not idle) public bool isActive => !isPooled & !isIdle; /// Reaction is running, but it is paused public bool isPaused => state == ReactionState.Paused; /// Reaction is running and is playing the animation public bool isPlaying => state == ReactionState.Playing; /// Reaction is running and is waiting to start playing the animation public bool inStartDelay => state == ReactionState.StartDelay; /// Reaction is running and is waiting the start playing the next loop public bool inLoopDelay => state == ReactionState.LoopDelay; #endregion /// Reaction settings [SerializeField] private ReactionSettings Settings; /// Reaction settings public ReactionSettings settings { get => Settings; internal set => Settings = value; } private float m_LastProgress; /// Reaction calculated progress = elapsedDuration / duration public float progress => m_LastProgress = Clamp01((float)(elapsedDuration / duration)); /// Reaction calculated eased progress. Progress value with the ease modifier applied public float easedProgress => Settings.CalculateEasedProgress(progress); /// /// Reaction current play direction /// Forward - progress goes from 0 to 1 /// Reverse - progress goes from 1 to 0 /// public PlayDirection direction { get; internal set; } /// Reaction heartbeat (ticker) public Heartbeat heartbeat { get; private set; } /// Current start delay public float startDelay { get; internal set; } /// Current elapsed start delay public double elapsedStartDelay { get; private set; } /// /// Special constant used to simulate zero duration. It is needed because we calculate progress = elapsedDuration / duration. /// If duration becomes zero, we get NaN (Not a Number) error. Miau! /// private const float MIN_DURATION = 0.001f; /// Current duration public float duration { get; internal set; } /// Current elapsed duration public double elapsedDuration { get; protected set; } /// Current start duration (needed to reverse the reaction flow) protected float startDuration { get; set; } /// Current target duration (needed to reverse the reaction flow) protected float targetDuration { get; set; } /// Flag used to mark that the reaction is not playing from start to finish, but from a FROM progress value to a TO progress value protected bool customStartDuration { get; set; } /// Current loops count public int loops { get; internal set; } /// Current elapsed loops count public int elapsedLoops { get; private set; } /// Current loop delay (delay between loops) public float loopDelay { get; internal set; } /// Current elapsed loop delay (elapsed delay between loops) public double elapsedLoopDelay { get; private set; } /// Callback invoked when the Reaction starts playing public ReactionCallback OnPlayCallback; /// Callback invoked when the Reaction stops playing public ReactionCallback OnStopCallback; /// Callback invoked when the Reaction finished playing public ReactionCallback OnFinishCallback; /// Callback invoked when the Reaction starts playing a loop (invoked every loop) public ReactionCallback OnLoopCallback; /// Callback invoked when the Reaction was paused (the Reaction is still running) public ReactionCallback OnPauseCallback; /// Callback invoked when the Reaction resumed playing public ReactionCallback OnResumeCallback; /// Callback invoked when the Reaction updates public ReactionCallback OnUpdateCallback; #region Cycle Variables protected float currentCycleEasedProgress => Settings.CalculateEasedProgress(currentCycleProgress); protected List cycleDurations { get; set; } protected int numberOfCycles { get; set; } protected int previousCycleIndex { get; set; } protected int currentCycleIndex { get; set; } protected float currentCycleDuration { get { if (cycleDurations == null || currentCycleIndex != cycleDurations.Count) ComputePlayMode(); return cycleDurations[currentCycleIndex]; } } protected float currentCycleElapsedDuration { get { if (currentCycleIndex == 0) return (float)elapsedDuration; float cycleStartDuration = cycleDurations.TakeWhile((t, i) => currentCycleIndex != i).Sum(); return Clamp((float)(elapsedDuration - cycleStartDuration), 0, targetDuration); } } protected float currentCycleProgress { get { float cycleProgress = Clamp01(currentCycleElapsedDuration / currentCycleDuration); return Approximately(0, cycleProgress) ? 0f : Approximately(cycleProgress, 1f) ? 1f : cycleProgress; } } #endregion /// Reset all callbacks public void ResetCallbacks() { OnUpdateCallback = null; OnPlayCallback = null; OnStopCallback = null; OnFinishCallback = null; OnLoopCallback = null; OnPauseCallback = null; OnResumeCallback = null; } protected Reaction() { // ReSharper disable once IdentifierTypo const int preallocatedCapacity = 100; cycleDurations = new List(preallocatedCapacity); Settings = new ReactionSettings(); this.SetRuntimeHeartbeat(); } /// Reset the reaction public virtual void Reset() { if (isActive) Stop(true); ClearIds(); this.ClearCallbacks(); Settings ??= new ReactionSettings(); Settings.Reset(); } /// /// Clear the reaction's ids /// private void ClearIds() { objectId = null; stringId = null; intId = k_DefaultIntId; targetObject = null; } /// Reverse the reaction's play direction (works if the reaction is running) public void Reverse() { if (!isActive) return; if (inStartDelay) { Stop(); return; } direction = (PlayDirection)((int)direction * -1f); } /// Rewind the reaction to the start (works in both play directions) public void Rewind() { elapsedDuration = direction == PlayDirection.Forward ? 0f : targetDuration; } /// Pause the reaction (if it's active) /// If TRUE, callbacks will not be invoked public void Pause(bool silent = false) { if (!isActive) return; stateBeforePause = state; state = ReactionState.Paused; if (!silent) OnPauseCallback?.Invoke(); } /// Resume the reaction (if it's paused) /// If TRUE, callbacks will not be invoked public void Resume(bool silent = false) { if (!isPaused) return; state = stateBeforePause; if (isActive & !heartbeat.isActive) heartbeat.RegisterToTickService(); if (!silent) OnResumeCallback?.Invoke(); } /// Play the reaction, in the given direction /// Play direction public void Play(PlayDirection playDirection) => Play(playDirection == PlayDirection.Reverse); /// Play the reaction /// If TRUE, the reaction will play in reverse public virtual void Play(bool inReverse = false) { if (isActive) { switch (direction) { case PlayDirection.Forward: if (inReverse) { if (inStartDelay) { Stop(); return; } Reverse(); return; } break; case PlayDirection.Reverse: if (!inReverse) { if (inStartDelay) { Stop(); return; } Reverse(); return; } break; default: throw new ArgumentOutOfRangeException(); } } if (isActive) Stop(true); ResetElapsedValues(); RefreshSettings(); direction = inReverse ? PlayDirection.Reverse : PlayDirection.Forward; customStartDuration = false; startDuration = 0f; targetDuration = duration; elapsedDuration = direction == PlayDirection.Forward ? startDuration : targetDuration; m_LastProgress = progress; ComputePlayMode(); OnPlayCallback?.Invoke(); if (startDelay <= 0 & duration <= MIN_DURATION) { // No delay, no duration, just invoke the finish callback switch (direction) { case PlayDirection.Forward: SetProgressAtOne(); break; case PlayDirection.Reverse: SetProgressAtZero(); break; default: throw new ArgumentOutOfRangeException(); } OnStopCallback?.Invoke(); // invoke the stop callback OnFinishCallback?.Invoke(); // invoke the finish callback // the implementation below is not working for zero duration // ------------------------------------------ // elapsedDuration = direction == PlayDirection.Forward ? targetDuration : startDuration; // m_LastProgress = progress; // UpdateReaction(); // return; // ------------------------------------------ } state = startDelay > 0 & direction == PlayDirection.Forward ? ReactionState.StartDelay : ReactionState.Playing; heartbeat.RegisterToTickService(); } /// Play the reaction from the given start progress (from) to the given end progress (to) /// From (start) progress /// To (end) progress public virtual void PlayFromToProgress(float fromProgress, float toProgress) { fromProgress = GetAdjustedProgress(fromProgress, settings.playMode); toProgress = GetAdjustedProgress(toProgress, settings.playMode); if (isActive) Stop(true); ResetElapsedValues(); RefreshSettings(); direction = fromProgress <= toProgress ? PlayDirection.Forward : PlayDirection.Reverse; customStartDuration = true; float fromDuration = GetDurationAtProgress(fromProgress, duration); float toDuration = GetDurationAtProgress(toProgress, duration); startDuration = direction == PlayDirection.Forward ? fromDuration : toDuration; targetDuration = direction == PlayDirection.Forward ? toDuration : fromDuration; elapsedDuration = direction == PlayDirection.Forward ? startDuration : targetDuration; m_LastProgress = progress; ComputePlayMode(); OnPlayCallback?.Invoke(); if (duration <= MIN_DURATION) { // No delay, no duration, just set the proper progress value and invoke the finish callback switch (direction) { case PlayDirection.Forward: SetProgressAtOne(); break; case PlayDirection.Reverse: SetProgressAtZero(); break; default: throw new ArgumentOutOfRangeException(); } OnStopCallback?.Invoke(); // invoke the stop callback OnFinishCallback?.Invoke(); // invoke the finish callback // the implementation below is not working properly for zero duration // ------------------------------------------------------------------ // elapsedDuration = direction == PlayDirection.Forward ? targetDuration : startDuration; // m_LastProgress = progress; // UpdateReaction(); // return; // ------------------------------------------------------------------ } state = ReactionState.Playing; heartbeat.RegisterToTickService(); } /// Play the reaction from the current progress to the given end progress (to) /// To (end) progress public virtual void PlayToProgress(float toProgress) => PlayFromToProgress(m_LastProgress, toProgress); /// Play the reaction from the given start progress (from) to the current progress /// From (start) progress public virtual void PlayFromProgress(float fromProgress) => PlayFromToProgress(fromProgress, m_LastProgress); /// Get the elapsedDuration value at the given target progress /// Target progress /// Duration protected float GetDurationAtProgress(float targetProgress, float totalDuration) { targetProgress = Clamp01(targetProgress); totalDuration = Max(0, totalDuration); totalDuration = totalDuration == 0 ? 1 : totalDuration; return Clamp(totalDuration * targetProgress, 0, totalDuration).Round(4); } /// Returns the progress value adjusted to the given play mode /// Progress /// Play mode private static float GetAdjustedProgress(float progress, PlayMode playMode) { progress = progress.Clamp01(); switch (playMode) { case PlayMode.Normal: { return progress; } case PlayMode.PingPong: { if (progress == 0f) return 0f; if (progress.Approximately(0.5f)) return 1f; if (progress.Approximately(1f)) return 0f; if (progress < 0.5f) return progress * 2f; if (progress > 0.5f) return (1f - progress) * 2f; return progress; } case PlayMode.Spring: { return progress; } case PlayMode.Shake: { return progress; } default: throw new ArgumentOutOfRangeException(nameof(playMode), playMode, null); } } /// Set the reaction's progress at the given target progress /// Target progress public virtual void SetProgressAt(float targetProgress) { targetProgress = GetAdjustedProgress(targetProgress, settings.playMode); if (isActive) Stop(true); ResetElapsedValues(); RefreshSettings(); direction = PlayDirection.Forward; // startDuration = 0f; // targetDuration = 1f; //save current settings EaseMode easeMode = settings.easeMode; Ease ease = settings.ease; AnimationCurve curve = settings.curve; //apply linear lease settings.easeMode = EaseMode.Ease; settings.ease = Ease.Linear; //update the progress // elapsedDuration = GetDurationAtProgress(targetProgress, targetDuration); elapsedDuration = Clamp01(targetProgress) * duration; // elapsedDuration = Clamp01((float)elapsedDuration); m_LastProgress = progress; if (heartbeat.isActive) heartbeat.UnregisterFromTickService(); if (settings.playMode != PlayMode.Normal) ComputePlayMode(); //This operation needed here because if we set the progress before the reaction ran, we have no values and can get NaN (Not a Number) values UpdateCurrentCycleIndex(); UpdateCurrentValue(); OnUpdateCallback?.Invoke(); //restore previous settings settings.ease = ease; settings.curve = curve; settings.easeMode = easeMode; } /// Set the reaction's progress at 1 (end) public void SetProgressAtOne() => SetProgressAt(1f); /// Set the reaction's progress at 0 (start) public void SetProgressAtZero() => SetProgressAt(0f); /// /// Update the reaction (called by the reaction's Heartbeat to update all the values) /// internal void UpdateReaction() { if (isPooled) { if (heartbeat.isActive) { heartbeat.UnregisterFromTickService(); } return; } if (isIdle & heartbeat.isActive) { heartbeat.UnregisterFromTickService(); } if (IsPaused()) return; if (InStartDelay()) return; if (InLoopDelay()) return; elapsedDuration = elapsedDuration < 0f ? 0f : elapsedDuration; elapsedDuration = Clamp((float)elapsedDuration, startDuration, targetDuration); elapsedDuration = elapsedDuration > duration ? duration : elapsedDuration; m_LastProgress = progress; UpdateCurrentCycleIndex(); UpdateCurrentValue(); OnUpdateCallback?.Invoke(); switch (direction) { case PlayDirection.Forward: if (elapsedDuration < targetDuration) { elapsedDuration += heartbeat.deltaTime * (int)direction; return; } break; case PlayDirection.Reverse: if (elapsedDuration > startDuration) { elapsedDuration += heartbeat.deltaTime * (int)direction; return; } break; default: throw new ArgumentOutOfRangeException(); } elapsedLoops++; if (loops < 0 || loops != 0 && elapsedLoops <= loops) { if (!customStartDuration) { duration = Max(MIN_DURATION, Settings.GetDuration()); startDuration = 0f; targetDuration = duration; ComputePlayMode(); } elapsedDuration = direction == PlayDirection.Forward ? startDuration : targetDuration; m_LastProgress = progress; loopDelay = Settings.GetLoopDelay(); if (loopDelay > 0) { state = ReactionState.LoopDelay; return; } OnLoopCallback?.Invoke(); state = ReactionState.Playing; return; } elapsedDuration = direction == PlayDirection.Forward ? targetDuration : startDuration; elapsedDuration = elapsedDuration.Round(4); m_LastProgress = progress; UpdateCurrentCycleIndex(); UpdateCurrentValue(); OnUpdateCallback?.Invoke(); Finish(); } /// Returns TRUE if the reaction is paused and updates the lastUpdateTime for the heartbeat private bool IsPaused() { if (!isPaused) return false; heartbeat.lastUpdateTime = heartbeat.timeSinceStartup; return true; } /// Returns TRUE if the reaction is in start delay and updates the start delay related variables private bool InStartDelay() { if (!inStartDelay) return false; elapsedStartDelay += heartbeat.deltaTime; elapsedStartDelay = Clamp((float)elapsedStartDelay, 0, startDelay); if (startDelay - elapsedStartDelay > 0) return true; state = ReactionState.Playing; elapsedStartDelay = 0f; return false; } /// Returns TRUE if the reaction is in loop delay and updates the loop delay related variables private bool InLoopDelay() { if (!inLoopDelay) return false; elapsedLoopDelay += heartbeat.deltaTime; elapsedLoopDelay = Clamp((float)elapsedLoopDelay, 0, loopDelay); if (loopDelay - elapsedLoopDelay > 0f) return true; OnLoopCallback?.Invoke(); state = ReactionState.Playing; elapsedLoopDelay = 0f; return false; } /// Update the reaction's current value public abstract void UpdateCurrentValue(); /// Stop the reaction from playing (does not call finish) /// If TRUE, callbacks will not be invoked /// If TRUE, it will try recycle this reaction, by returning it to the pool public virtual void Stop(bool silent = false, bool recycle = false) { if (heartbeat.isActive) heartbeat.UnregisterFromTickService(); if (isPooled) return; if (!silent) OnStopCallback?.Invoke(); state = ReactionState.Idle; if (recycle) Recycle(); } /// Finish the reaction by stopping it, calling callbacks (stop and then finish) and then (if reusable) returns it to the pool /// If TRUE, callbacks will not be invoked /// If TRUE, the animation ends in the To value (set the progress to 1 (one)) /// If TRUE, it will try recycle this reaction, by returning it to the pool public virtual void Finish(bool silent = false, bool endAnimation = false, bool recycle = false) { if (!isActive) return; // ReSharper disable once RedundantArgumentDefaultValue Stop(silent, false); if (!silent) OnFinishCallback?.Invoke(); if (endAnimation) SetProgressAtOne(); if (recycle) Recycle(); } /// Set the heartbeat for this reaction and connect the UpdateReaction to it /// New heartbeat public void SetHeartbeat(Heartbeat h) { heartbeat = h ?? new RuntimeHeartbeat(); heartbeat.AddOnTickCallback(UpdateReaction); } /// /// Reset all relevant elapsed values /// Used mostly for reset purposes /// private void ResetElapsedValues() { elapsedStartDelay = 0; elapsedDuration = 0; elapsedLoops = 0; elapsedLoopDelay = 0; } /// /// Refresh the reaction's current play settings. /// Refreshes the play settings (gets new random values if they are used) /// Calls the appropriate Compute method for the current play mode /// public void RefreshSettings() { settings.Validate(); startDelay = Settings.GetStartDelay(); duration = Settings.GetDuration(); duration = float.IsNaN(duration) || float.IsInfinity(duration) ? 0 : duration; duration = Max(MIN_DURATION, duration); //avoid zero duration as it creates NaN values loops = Settings.GetLoops(); loopDelay = Settings.GetLoopDelay(); ComputePlayMode(); } /// Call the appropriate Compute method depending on the current play mode public void ComputePlayMode() { switch (Settings.playMode) { case PlayMode.Normal: ComputeNormal(); break; case PlayMode.PingPong: ComputePingPong(); break; case PlayMode.Spring: ComputeSpring(); break; case PlayMode.Shake: ComputeShake(); break; default: throw new ArgumentOutOfRangeException(); } } /// Update the current cycle index private void UpdateCurrentCycleIndex() { previousCycleIndex = currentCycleIndex; switch (direction) { case PlayDirection.Forward: { float compoundDuration = 0f; for (int i = 0; i < cycleDurations.Count; i++) { currentCycleIndex = i; compoundDuration += cycleDurations[i]; if (elapsedDuration <= compoundDuration) return; } } break; case PlayDirection.Reverse: { // float compoundDuration = targetDuration; float compoundDuration = duration; for (int i = cycleDurations.Count - 1; i >= 0; i--) { currentCycleIndex = i; compoundDuration -= cycleDurations[i]; if (elapsedDuration > compoundDuration) return; } } break; } } private void EnsureCycleDurationsListCapacity(int requiredCapacity) { if (cycleDurations == null) { cycleDurations = new List(requiredCapacity); return; } if(requiredCapacity <= cycleDurations.Capacity) return; cycleDurations.Capacity = requiredCapacity; } /// Compute normal play mode cycle protected virtual void ComputeNormal() { currentCycleIndex = 0; numberOfCycles = 1; EnsureCycleDurationsListCapacity(numberOfCycles); if (cycleDurations.Count != numberOfCycles) { cycleDurations.Clear(); cycleDurations.Add(duration); } else { cycleDurations[0] = duration; } } /// Compute ping-pong play mode cycles protected virtual void ComputePingPong() { currentCycleIndex = 0; numberOfCycles = 2; float halfDuration = duration / 2f; EnsureCycleDurationsListCapacity(numberOfCycles); if (cycleDurations.Count != numberOfCycles) { cycleDurations.Clear(); cycleDurations.Add(halfDuration); cycleDurations.Add(halfDuration); } else { cycleDurations[0] = halfDuration; cycleDurations[1] = halfDuration; } } /// Compute spring play mode cycles protected virtual void ComputeSpring() { currentCycleIndex = 0; numberOfCycles = Max(1, settings.vibration + (int)(settings.vibration * duration)); if (numberOfCycles % 2 != 0) numberOfCycles++; EnsureCycleDurationsListCapacity(numberOfCycles); if (cycleDurations.Count != numberOfCycles) { cycleDurations.Clear(); for (int i = 0; i < numberOfCycles; i++) { cycleDurations.Add(0f); } } float compoundDuration = 0f; for (int i = 0; i < numberOfCycles; i++) { cycleDurations[i] = duration * ((float)(i + 1) / numberOfCycles); cycleDurations[i] = cycleDurations[i].Round(4); compoundDuration += cycleDurations[i]; } float durationRatio = duration / compoundDuration; for (int i = 0; i < numberOfCycles; i++) cycleDurations[i] *= durationRatio; } /// Compute shake play mode cycles protected virtual void ComputeShake() { currentCycleIndex = 0; numberOfCycles = Max(1, settings.vibration + (int)(settings.vibration * duration)); if (numberOfCycles % 2 == 0) numberOfCycles++; EnsureCycleDurationsListCapacity(numberOfCycles); if (cycleDurations.Count != numberOfCycles) { cycleDurations.Clear(); for (int i = 0; i < numberOfCycles; i++) { cycleDurations.Add(0f); } } float compoundDuration = 0f; for (int i = 0; i < numberOfCycles; i++) { if (settings.fadeOutShake) { float ofCycles = ((float)(i + 1) / numberOfCycles); cycleDurations[i] = EaseFactory.GetEase(Ease.OutExpo).Evaluate(ofCycles) * duration; } else { cycleDurations[i] = duration / numberOfCycles; } compoundDuration += cycleDurations[i]; } float durationRatio = duration / compoundDuration; for (int i = 0; i < numberOfCycles; i++) cycleDurations[i] *= durationRatio; float tempDuration = 0f; for (int i = 0; i < numberOfCycles - 1; i++) tempDuration += cycleDurations[i]; cycleDurations[numberOfCycles - 1] = duration - tempDuration; } public void Recycle() { this.AddToPool(); } #region Static Methods /// Get a reaction from the given reaction type, either from the pool or a new one /// Reaction Type public static T Get() where T : Reaction => ReactionPool.Get(); #endregion #region IDs public const int k_DefaultIntId = -1234; [ClearOnReload(true)] internal static readonly ReactionDictionary ReactionByObjectId = new ReactionDictionary(); [ClearOnReload(true)] internal static readonly ReactionDictionary ReactionByStringId = new ReactionDictionary(); [ClearOnReload(true)] internal static readonly ReactionDictionary ReactionByIntId = new ReactionDictionary(); [ClearOnReload(true)] internal static readonly ReactionDictionary ReactionByTargetObject = new ReactionDictionary(); public bool hasObjectId { get; internal set; } public object objectId { get; internal set; } public string stringId { get; internal set; } public bool hasStringId { get; internal set; } public int intId { get; internal set; } public bool hasIntId { get; internal set; } /// The object this reaction is attached to public object targetObject { get; internal set; } public bool hasTargetObject { get; internal set; } public static void StopAllReactionsByObjectId(object id, bool silent = false) { foreach (Reaction reaction in ReactionByObjectId.GetReactions(id)) reaction.Stop(silent); } public static void StopAllReactionsByStringId(string id, bool silent = false) { foreach (Reaction reaction in ReactionByStringId.GetReactions(id)) reaction.Stop(silent); } public static void StopAllReactionsByIntId(int id, bool silent = false) { foreach (Reaction reaction in ReactionByIntId.GetReactions(id)) reaction.Stop(silent); } public static void StopAllReactionsByTargetObject(object target, bool silent = false) { foreach (Reaction reaction in ReactionByTargetObject.GetReactions(target)) reaction.Stop(silent); } #endregion public override string ToString() => $"[{(heartbeat != null ? heartbeat.GetType().Name : "No Heartbeat")}] " + $"[{GetType().Name}] " + $"[{state}] > " + $"[{direction}] > " + $"[{elapsedDuration.Round(3):0.000} / {duration} seconds] " + $"[{nameof(progress)}: {progress.Round(2):0.00} {(progress.Round(2) * 100f).Round(0):000}%]"; } }