最近有些地方要用到 Timeline 這樣的系統, 因為 Unity 自己提供了一套, 就直接拿來用了, 發現這套 Timeline 設計的比較復雜, 而且很多點都缺失, 甚至生命周期都不完善, 有點為了解耦而強行 MVC / MVVM 的設計思路, 擴展起來還是很麻煩的.
簡單來說要做擴展只要生成兩份代碼就行了, 一個是繼承 PlayableAsset, ITimelineClipAsset 的 Clip, 可以把它看成是創建 Timeline 行為的入口, 它既是入口, 又是編輯器工具下的可視化對象, 另一個是繼承 PlayableBehaviour 的, 它就是實際的 Timeline 邏輯對象, 包含了一些運行生命周期.
首先問題就是生命周期的問題, 它是一個積分類型的系統, 並且缺失了 OnComplete 的結束回調.
看看 PlayableBehaviour 的主要生命周期重載:
public override void OnBehaviourPlay(Playable playable, FrameData info) { // 開始播放時觸發 } public override void ProcessFrame(Playable playable, FrameData info, object playerData) { // 播放后每幀觸發 }
如下圖 OnBehaviourPlay 在系統運行到這個 Clip 的起點的時候, 會觸發, 而如果系統運行到這個 Clip 還沒結束的時候, 進行了暫停, 然后再次恢復播放的話, 它還是會觸發 :
舉例來說一個UI按鈕的點擊, 在進入狀態時觸發點擊, 退出狀態時再次點擊, 那么在這里首先是無法實現, 然后是點擊的次數無法控制, 因為每次暫停都可能造成錯誤觸發點擊.
那么就需要擴展一套生命周期的控制, 來完成補充生命周期以及觸發控制邏輯了. 直接通過繼承 PlayableBehaviour 來創建一個基類擴展, 其它只要繼承就行了 :
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.Playables; using UnityEngine.Timeline; namespace UnityTimelineExtention { [System.Serializable] public abstract class PlayableBaseBehaviour : PlayableBehaviour { // 全局控制 private static readonly Dictionary<PlayableDirector, HashSet<PlayableBaseBehaviour>> All = new Dictionary<PlayableDirector, HashSet<PlayableBaseBehaviour>>(); // 通用數據 [SerializeField] [Header("開始時間")] public double startTime = 0.0; [SerializeField] [Header("開始時間")] public double endTime = 0.0; // 緩存數據 protected PlayableDirector playableDirector { get; private set; } // 屬性 public double duration { get { return endTime - startTime; } } protected bool Inited { get; set; } #region Main Funcs public override void OnBehaviourPlay(Playable playable, FrameData info) { base.OnBehaviourPlay(playable, info); if(false == Inited) { playableDirector = playable.GetGraph().GetResolver() as PlayableDirector; if(Application.isPlaying || CanPlayInEditorMode()) { All.GetValue(playableDirector).Add(this); // 結束回調, 寫在功能前 UnityTimelineEventDispatcher.Instance.SetEndCall(playableDirector, this, (_tag) => { OnExit(); }); OnInit(playableDirector, info); } } Inited = true; } public override void ProcessFrame(Playable playable, FrameData info, object playerData) { base.ProcessFrame(playable, info, playerData); OnUpdate(playableDirector, info, playerData); } /// <summary> /// 只觸發一次的接口 /// </summary> /// <param name="playable"></param> /// <param name="info"></param> public abstract void OnInit(PlayableDirector playableDirector, FrameData info); /// <summary> /// 每個動畫幀觸發 /// </summary> /// <param name="playableDirector"></param> /// <param name="info"></param> /// <param name="playerData"></param> public abstract void OnUpdate(PlayableDirector playableDirector, FrameData info, object playerData); /// <summary> /// 自動添加生命周期回調 /// </summary> public abstract void OnExit(); /// <summary> /// 標記, 是否能在編輯器下使用 /// </summary> /// <returns></returns> public virtual bool CanPlayInEditorMode() { return true; } #endregion #region Help Funcs public void ResetInitState() { Inited = false; } /// <summary> /// 重置Init狀態 /// </summary> /// <param name="playableDirector"></param> public static void UnInit(PlayableDirector playableDirector) { var pool = All.TryGetNullableValue(playableDirector); if(pool != null) { foreach(var target in pool) { target.ResetInitState(); } } } /// <summary> /// 強制移除,重置Init狀態 -- 小心使用 /// </summary> /// <param name="playableDirector"></param> /// <param name="target"></param> public static void RemoveFromPool(PlayableDirector playableDirector, PlayableBaseBehaviour target) { var pool = All.TryGetNullableValue(playableDirector); if(pool != null) { if(pool.Remove(target)) { target.ResetInitState(); } } } #endregion } }
這樣就添加了生命周期, 重命名成了 OnInit, OnUpdate, OnExit 這樣的三個, 只有 OnExit 是額外添加的, 需要通過另外的監視器 UnityTimelineEventDispatcher 來實現 :
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.Playables; namespace UnityTimelineExtention { using Common; public class UnityTimelineEventDispatcher : Singleton<UnityTimelineEventDispatcher> { #region Defines public class PlayableEvent { public PlayableDirector playableDirector; public Common.ObsoluteTime timer; public double startTime; public double endTime; public double duration { get { return endTime - startTime; } } public System.Action endCall; public void Invoke() { if(endCall != null) { var temp = endCall; endCall = null; temp.Invoke(); } } } #endregion private bool _reigsted = false; private readonly Dictionary<PlayableDirector, MyDictionary<PlayableBaseBehaviour, PlayableEvent>> m_events = new Dictionary<PlayableDirector, MyDictionary<PlayableBaseBehaviour, PlayableEvent>>(); #region Overrides protected override void Initialize() { RegisterUpdate(); } protected override void UnInitialize() { if(Application.isPlaying) { Core.CoroutineRoot.instance.update -= Tick; } #if UNITY_EDITOR UnityEditor.EditorApplication.update -= Tick; #endif _reigsted = false; } #endregion #region Main Funcs public void SetEndCall<T>(PlayableDirector director, T target, System.Action<T> endCall) where T : PlayableBaseBehaviour { if(director) { if(target != null) { if(m_events.ContainsKey(director) == false) { director.stopped -= OnStop; director.stopped += OnStop; } PopEnd(director, director.time); var playableEvent = m_events.GetValue(director).GetValue(target); playableEvent.playableDirector = director; playableEvent.startTime = target.startTime; playableEvent.endTime = target.endTime; playableEvent.timer = new Common.ObsoluteTime(); playableEvent.endCall = () => { endCall.Invoke(target); }; } } } private void Tick() { foreach(var kv in m_events) { kv.Value.Remove((_tag, _playableEvent) => { if(_playableEvent.playableDirector) { var gap = _playableEvent.playableDirector.time - (_playableEvent.startTime + _playableEvent.duration - 0.001); if(gap >= 0.0) { _playableEvent.Invoke(); return true; } } else { if(_playableEvent.timer.ElapsedSeconds() >= _playableEvent.duration) { _playableEvent.Invoke(); return true; } } return false; }); } } #endregion #region Help Funcs private void PopEnd(PlayableDirector director, double time) { var events = m_events.TryGetNullableValue(director); if(events != null) { events.Remove((_tag, _playableEvent) => { if(time >= (_playableEvent.startTime + _playableEvent.duration)) { PlayableBaseBehaviour.RemoveFromPool(director, _tag); _playableEvent.Invoke(); return true; } return false; }); } } public void OnStop(PlayableDirector playableDirector) { if(playableDirector) { Debug.Log(playableDirector.gameObject.name + " Stopped"); var events = m_events.TryGetNullableValue(playableDirector); if(events != null) { PopEnd(playableDirector, playableDirector.duration + UnityTimelineTools.Epsilon); events.Clear(); } } } private void RegisterUpdate() { if(false == _reigsted) { _reigsted = true; if(Application.isPlaying) { Core.CoroutineRoot.instance.update -= Tick; Core.CoroutineRoot.instance.update += Tick; } else { #if UNITY_EDITOR UnityEditor.EditorApplication.update -= Tick; UnityEditor.EditorApplication.update += Tick; #endif } } } #endregion } }
這里主要就是通過監視 PlayableDirector 的時間來測試一個 PlayableBehaviour 是否已經到達結束, 觸發它的 OnExit 方法.
這里需要一提的是在運行時的 PlayableBehaviour 並沒有自己的開始結束時間信息, 需要從 TimelineClip 里面去獲取, 而 TimelineClip 又是從 PlayableAsset 的 TrackAsset 中找, 需要繞一個大圈, 非常麻煩, 而我們在重載接口里得到的輸入一般是 Playable 或者 PlayableGraph, 要獲取 PlayableBehaviour 也是需要顯式轉換 :
playableDirector = playable.GetGraph().GetResolver() as PlayableDirector;
找資料都要找半天...
通過這樣的方式來找對應 TimelineClip :
// find clip public static TimelineClip FindClip(PlayableDirector director, PlayableAsset asset) { if(director) { var trackAssets = ((TimelineAsset)director.playableAsset).GetOutputTracks(); foreach(var trackers in trackAssets) { foreach(var clip in trackers.GetClips()) { if(clip.asset == asset) { return clip; } } } } return null; } // find clip public static TimelineClip FindClip(PlayableGraph graphy, PlayableAsset asset) { var director = graphy.GetResolver() as PlayableDirector; return FindClip(director, asset); } // find clip public static TimelineClip FindClip(Playable playable, PlayableAsset asset) { return FindClip(playable.GetGraph(), asset); }
有了這些, 我們現在需要的就是創建代碼了, 做個工具生成代碼就行了:
兩個代碼 template :
1. Clip
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.Playables; using UnityEngine.Timeline; namespace UnityTimelineExtention { public class #CLASSNAME#Clip : PlayableAsset, ITimelineClipAsset { // 用來同步數據的 public #CLASSNAME#Behaviour template; public ClipCaps clipCaps { get { return ClipCaps.None; } } // 重寫初始化長度 //public override double duration //{ // get { return 0.5f; } //} public override Playable CreatePlayable(PlayableGraph graph, GameObject owner) { var playable = ScriptPlayable<#CLASSNAME#Behaviour>.Create(graph, template); var playable#CLASSNAME#Behaviour = playable.GetBehaviour(); var clip = UnityTimelineTools.FindClip(graph, this); if(clip != null) { playable#CLASSNAME#Behaviour.startTime = clip.start; playable#CLASSNAME#Behaviour.endTime = clip.end; } return playable; } } }
2. Behaviour
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.Playables; using UnityEngine.Timeline; namespace UnityTimelineExtention { [System.Serializable] public class #CLASSNAME#Behaviour : PlayableBaseBehaviour { // 基類數據 /* playableDirector */ // 當前動作開始回調 public override void OnInit(PlayableDirector playableDirector, FrameData info) { // 功能 Debug.Log("#CLASSNAME#Behaviour Play"); } public override void OnUpdate(PlayableDirector playableDirector, FrameData info, object playerData) { // update } public override void OnExit() { // end Debug.Log("#CLASSNAME#Behaviour End"); } } }
這樣生成的代碼里面, XXXBehaviour 就有了生命周期了, 並且有了開始結束時間, 並且能限制 OnInit 在時間范圍內只執行一次.
這里有個特殊的對象, TimelineAsset 也就是在一個 TimelineAsset 里面嵌套另外一個, 編輯器本身沒有支持, 所以它並不是一個完美嵌套的設計, 我這里就直接按照普通對象來生成代碼然后添加強行播放邏輯:
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.Playables; using UnityEngine.Timeline; namespace UnityTimelineExtention { [System.Serializable] public class TimelineAssetPlayerBehaviour : PlayableBaseBehaviour { // 面板序列化數據 [SerializeField] public TimelineAsset timelineAsset; // 緩存數據 private PlayableDirector assetDirector; private bool isPlaying = false; public override void OnInit(PlayableDirector playableDirector, FrameData info) { // 功能 Debug.Log("TimelineAssetPlayerBehaviour Play"); if(timelineAsset) { if(assetDirector == false) { assetDirector = UnityTimelineTools.PlayTimeline(timelineAsset); } assetDirector.Pause(); assetDirector.time = 0; assetDirector.Evaluate(); isPlaying = true; } } public override void OnUpdate(PlayableDirector playableDirector, FrameData info, object playerData) { Tick((float)(playableDirector.time - this.startTime)); } public override void OnExit() { isPlaying = false; if(assetDirector) { assetDirector.time = assetDirector.duration + UnityTimelineTools.Epsilon; assetDirector.Evaluate(); assetDirector.Stop(); } } private void Tick(float elapsedTime) { if(isPlaying && assetDirector) { assetDirector.time = elapsedTime; assetDirector.Evaluate(); } } } }
在播放過程中使用 Evaluate 的方式來進行, 保證邏輯一致, 並且在結束時調用 Stop 能正確觸發結束回調.
所以可見, 一個 Behaviour 的開始結束時間信息非常重要.
------------------- 一些小技巧 -------------------
Unity系統的序列化支持泛型對象了, 比如下面的我需要拖入一個 Transform 來序列化這個 Transform 的絕對縮放可以這樣寫 :
[System.Serializable] public class SavableSelector<T, V> where T : Component where V : struct { [SerializeField] public V Value; private System.Func<T, V> _selector; public SavableSelector(System.Func<T, V> selector) { _selector = selector; } [Sirenix.OdinInspector.ShowInInspector] [Sirenix.OdinInspector.LabelText("拖入對象 --> ")] public T Target { get { return null; } set { if(value) { this.Value = _selector.Invoke(value); } } } }
我在 XXXBehaviour 中這樣來序列化 :
[SerializeField] [Header("記錄原始縮放")] public SerializableDatas.SavableSelector<Transform, Vector3> Scaler = new SerializableDatas.SavableSelector<Transform, Vector3>((_trans) => { return _trans.lossyScale; });
面板上可以得到這樣的顯示, 序列化數據就直接獲取即可:
var rawScale = Scaler.Value;
依賴 OdinInspector 做一些編輯器的顯示簡直太方便了.