using System; using System.Collections.Generic; using UnityEngine; using UnityEngine.UIElements; namespace Uigenx.Animation { public readonly struct UgxPlayOptions { public readonly UgxPlayMode Mode; public readonly long DelayMs; public readonly float Speed; public readonly Action OnComplete; public UgxPlayOptions(UgxPlayMode mode = UgxPlayMode.Restart, long delayMs = 0, float speed = 1f, Action onComplete = null) { Mode = mode; DelayMs = delayMs; Speed = speed <= 0f ? 1f : speed; OnComplete = onComplete; } public static UgxPlayOptions Default => new UgxPlayOptions(); } /// /// Plays keyframe clips against one panel subtree. A single scheduled item drives every clip, /// element lookups and value parsing happen once per clip, and the tick allocates nothing. /// Main thread only. /// public sealed class UgxAnimator : IDisposable { /// Tick interval. The scheduler is frame bound, so this is a floor, not a guarantee. public const long TickIntervalMs = 16; readonly VisualElement _root; readonly Dictionary _clips = new Dictionary(StringComparer.Ordinal); readonly Dictionary _prepared = new Dictionary(); readonly List _playing = new List(4); readonly List _queued = new List(2); readonly Stack _pool = new Stack(4); IVisualElementScheduledItem _ticker; bool _disposed; public UgxAnimator(VisualElement root) { _root = root ?? throw new ArgumentNullException(nameof(root)); } public VisualElement Root => _root; public int PlayingCount => _playing.Count; public void Register(IReadOnlyList clips) { if (clips == null) return; for (int i = 0; i < clips.Count; i++) Register(clips[i]); } public void Register(UgxClip clip) { if (clip == null || string.IsNullOrEmpty(clip.Name)) return; _clips[clip.Name] = clip; } public bool TryGetClip(string name, out UgxClip clip) => _clips.TryGetValue(name, out clip); public bool IsPlaying(string clipName) { for (int i = 0; i < _playing.Count; i++) if (_playing[i].Clip.Name == clipName) return true; return false; } public bool Play(string clipName, UgxPlayOptions options = default) { if (_disposed) return false; if (!_clips.TryGetValue(clipName, out var clip)) { Debug.LogWarning("[ugx] no clip named '" + clipName + "' is registered on this panel"); return false; } return Play(clip, options); } public bool Play(UgxClip clip, UgxPlayOptions options = default) { if (_disposed || clip == null) return false; var existing = Find(clip); if (existing != null) { switch (options.Mode) { case UgxPlayMode.IgnoreIfPlaying: return false; case UgxPlayMode.Queue: _queued.Add(Rent(clip, options)); return true; default: existing.Reset(options); return true; } } var prepared = Prepare(clip); if (prepared.Tracks.Length == 0) return false; _playing.Add(Rent(clip, options)); EnsureTicker(); return true; } /// Plays clips one after another, each starting when the previous one ends. public void PlaySequence(IReadOnlyList clipNames, long gapMs = 0) { if (clipNames == null || clipNames.Count == 0) return; long delay = 0; for (int i = 0; i < clipNames.Count; i++) { if (!_clips.TryGetValue(clipNames[i], out var clip)) continue; Play(clip, new UgxPlayOptions(UgxPlayMode.Restart, delay)); delay += (long)clip.DurationMs + gapMs; } } public void Stop(string clipName) { for (int i = _playing.Count - 1; i >= 0; i--) { if (_playing[i].Clip.Name != clipName) continue; Recycle(_playing[i]); _playing.RemoveAt(i); } for (int i = _queued.Count - 1; i >= 0; i--) { if (_queued[i].Clip.Name != clipName) continue; Recycle(_queued[i]); _queued.RemoveAt(i); } if (_playing.Count == 0) PauseTicker(); } /// /// Stops the clip and puts every track back on its first keyframe, the clip's rest pose. /// A conditional trigger uses this on the falling edge, because a plain /// would freeze the element wherever the last tick left it. /// public void Rewind(string clipName) { Stop(clipName); if (!_clips.TryGetValue(clipName, out var clip)) return; if (!_prepared.TryGetValue(clip, out var prepared)) return; var tracks = prepared.Tracks; for (int i = 0; i < tracks.Length; i++) tracks[i].Sample(0f); } public void StopAll() { for (int i = 0; i < _playing.Count; i++) Recycle(_playing[i]); _playing.Clear(); for (int i = 0; i < _queued.Count; i++) Recycle(_queued[i]); _queued.Clear(); PauseTicker(); } /// Advances every clip. Exposed so tests and the studio preview can step deterministically. public void Tick(float deltaMs) { for (int i = _playing.Count - 1; i >= 0; i--) { var playback = _playing[i]; if (!_prepared.TryGetValue(playback.Clip, out var prepared)) { _playing.RemoveAt(i); Recycle(playback); continue; } if (!playback.Advance(deltaMs, prepared)) continue; _playing.RemoveAt(i); var onComplete = playback.OnComplete; Recycle(playback); if (onComplete != null) { // Completion callbacks are consumer code. try { onComplete(); } catch (Exception e) { Debug.LogError("[ugx] an animation completion callback threw: " + e); } } } for (int i = _queued.Count - 1; i >= 0; i--) { if (Find(_queued[i].Clip) != null) continue; _playing.Add(_queued[i]); _queued.RemoveAt(i); } if (_playing.Count == 0) PauseTicker(); } public void Dispose() { if (_disposed) return; _disposed = true; StopAll(); _ticker?.Pause(); _ticker = null; _prepared.Clear(); _clips.Clear(); _pool.Clear(); } void EnsureTicker() { if (_ticker == null) { _ticker = _root.schedule.Execute(OnTick).Every(TickIntervalMs); return; } if (!_ticker.isActive) _ticker.Resume(); } void PauseTicker() => _ticker?.Pause(); void OnTick(TimerState state) => Tick(state.deltaTime); Playback Find(UgxClip clip) { for (int i = 0; i < _playing.Count; i++) if (ReferenceEquals(_playing[i].Clip, clip)) return _playing[i]; return null; } Playback Rent(UgxClip clip, UgxPlayOptions options) { var playback = _pool.Count > 0 ? _pool.Pop() : new Playback(); playback.Begin(clip, options); return playback; } void Recycle(Playback playback) { playback.Clear(); if (_pool.Count < 8) _pool.Push(playback); } PreparedClip Prepare(UgxClip clip) { if (_prepared.TryGetValue(clip, out var cached)) return cached; var tracks = new List(clip.Tracks.Length); foreach (var track in clip.Tracks) { if (track == null || track.Keyframes == null || track.Keyframes.Length == 0) continue; if (!UgxStyleSetters.TryGet(track.Property, out var setter)) { Debug.LogWarning("[ugx] clip '" + clip.Name + "' animates unsupported property '" + track.Property + "', track skipped"); continue; } var owner = track.TargetsSelf ? _root : _root.Q(track.TargetName); if (owner == null) { Debug.LogWarning("[ugx] clip '" + clip.Name + "' targets missing element '" + track.TargetName + "', track skipped"); continue; } // A control builds its parts with a USS class and no name, so a part is found by // class under the element that owns it: UQueryExtensions.Q(e, name, className) // with no name matches any descendant carrying the class. var target = track.HasPart ? owner.Q(null, track.PartClass) : owner; if (target == null) { Debug.LogWarning("[ugx] clip '" + clip.Name + "' targets missing part '" + track.PartClass + "' inside '" + (track.TargetsSelf ? "the screen root" : track.TargetName) + "', track skipped"); continue; } int count = track.Keyframes.Length; var times = new float[count]; var values = new UgxStyleValue[count]; var easings = new UgxEasingCurve[count]; bool ok = true; for (int i = 0; i < count; i++) { var key = track.Keyframes[i]; times[i] = key.TimeMs; easings[i] = key.Easing; if (setter.TryParse(key.Value, out values[i])) continue; Debug.LogWarning("[ugx] clip '" + clip.Name + "' has an unparsable value '" + key.Value + "' for '" + track.Property + "', track skipped"); ok = false; break; } if (ok) tracks.Add(new PreparedTrack(target, setter, times, values, easings)); } var prepared = new PreparedClip(tracks.ToArray(), Mathf.Max(1f, clip.DurationMs), clip.Loop); // An empty result means an element was missing this time. Caching it would make the // clip permanently unplayable once the element appears, so only success is cached. if (prepared.Tracks.Length > 0) _prepared[clip] = prepared; return prepared; } sealed class PreparedClip { public readonly PreparedTrack[] Tracks; public readonly float DurationMs; public readonly UgxLoopMode Loop; public PreparedClip(PreparedTrack[] tracks, float durationMs, UgxLoopMode loop) { Tracks = tracks; DurationMs = durationMs; Loop = loop; } } sealed class PreparedTrack { public readonly VisualElement Target; public readonly IUgxStyleSetter Setter; public readonly float[] Times; public readonly UgxStyleValue[] Values; public readonly UgxEasingCurve[] Easings; public PreparedTrack(VisualElement target, IUgxStyleSetter setter, float[] times, UgxStyleValue[] values, UgxEasingCurve[] easings) { Target = target; Setter = setter; Times = times; Values = values; Easings = easings; } public void Sample(float timeMs) { int last = Times.Length - 1; if (timeMs <= Times[0]) { Setter.Apply(Target, Values[0]); return; } if (timeMs >= Times[last]) { Setter.Apply(Target, Values[last]); return; } int i = 0; while (i < last && Times[i + 1] < timeMs) i++; float span = Times[i + 1] - Times[i]; float t = span <= 0f ? 1f : (timeMs - Times[i]) / span; // The easing on a keyframe shapes the segment that leaves it, matching USS. float eased = Easings[i].Evaluate(t); var value = UgxStyleValue.Lerp(Values[i], Values[i + 1], eased); Setter.Apply(Target, value); } } sealed class Playback { public UgxClip Clip; public Action OnComplete; float _elapsedMs; float _delayLeftMs; float _speed = 1f; bool _reversed; public void Begin(UgxClip clip, UgxPlayOptions options) { Clip = clip; OnComplete = options.OnComplete; _elapsedMs = 0f; _delayLeftMs = options.DelayMs; _speed = options.Speed <= 0f ? 1f : options.Speed; _reversed = false; } public void Reset(UgxPlayOptions options) { OnComplete = options.OnComplete; _elapsedMs = 0f; _delayLeftMs = options.DelayMs; _speed = options.Speed <= 0f ? 1f : options.Speed; _reversed = false; } public void Clear() { Clip = null; OnComplete = null; } /// Returns true when the clip finished and should be removed. public bool Advance(float deltaMs, PreparedClip prepared) { if (_delayLeftMs > 0f) { _delayLeftMs -= deltaMs; if (_delayLeftMs > 0f) return false; deltaMs = -_delayLeftMs; _delayLeftMs = 0f; } _elapsedMs += deltaMs * _speed; float duration = prepared.DurationMs; bool finished = false; if (_elapsedMs >= duration) { switch (prepared.Loop) { case UgxLoopMode.Loop: _elapsedMs -= duration * Mathf.Floor(_elapsedMs / duration); break; case UgxLoopMode.PingPong: _elapsedMs -= duration; _reversed = !_reversed; if (_elapsedMs > duration) _elapsedMs = 0f; break; default: _elapsedMs = duration; finished = true; break; } } float sampleTime = _reversed ? duration - _elapsedMs : _elapsedMs; var tracks = prepared.Tracks; for (int i = 0; i < tracks.Length; i++) tracks[i].Sample(sampleTime); return finished; } } } }