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;
}
}
}
}