using System; using System.Collections.Generic; using System.Globalization; using Uigenx.Actions; using Uigenx.Binding; using UnityEngine; using UnityEngine.UIElements; namespace Uigenx.Animation { public enum UgxTriggerKind { Show = 0, Hide = 1, Hover = 2, HoverExit = 3, Action = 4, DataChange = 5, } /// is the absence of a condition, so `default` fires on every change. public enum UgxCompare { Always = 0, Lt = 1, Lte = 2, Gt = 3, Gte = 4, Eq = 5, Neq = 6, } /// What a satisfied condition does: hold the clip running, or fire once on the rising edge. public enum UgxConditionPlay { Loop = 0, Once = 1, } /// /// A comparison against a bound data value, for example `player.hpNormalised lt 0.25`. The /// truth table must stay identical to `conditionHolds` in the studio schema, or a screen /// behaves one way in Preview and another in the game. /// public readonly struct UgxTriggerCondition { public readonly UgxCompare Op; public readonly UgxConditionPlay Play; /// Numeric threshold. Only read when is null. public readonly double Threshold; /// Text threshold, or null when the threshold is numeric. public readonly string Literal; UgxTriggerCondition(UgxCompare op, UgxConditionPlay play, double threshold, string literal) { Op = op; Play = play; Threshold = threshold; Literal = literal; } public static UgxTriggerCondition Number(UgxCompare op, double threshold, UgxConditionPlay play = UgxConditionPlay.Loop) => new UgxTriggerCondition(op, play, threshold, null); public static UgxTriggerCondition Text(UgxCompare op, string literal, UgxConditionPlay play = UgxConditionPlay.Loop) => new UgxTriggerCondition(op, play, 0d, literal ?? string.Empty); public bool HasCondition => Op != UgxCompare.Always; /// True when the value satisfies the comparison. A missing value satisfies nothing. public bool Evaluate(object value) { if (Op == UgxCompare.Always) return true; if (value == null) return false; if (Literal == null) { if (!TryNumber(value, out double left)) return false; switch (Op) { case UgxCompare.Lt: return left < Threshold; case UgxCompare.Lte: return left <= Threshold; case UgxCompare.Gt: return left > Threshold; case UgxCompare.Gte: return left >= Threshold; case UgxCompare.Eq: return left == Threshold; case UgxCompare.Neq: return left != Threshold; default: return false; } } // A non numeric threshold has no ordering, so only equality is defined. if (Op != UgxCompare.Eq && Op != UgxCompare.Neq) return false; bool same = string.Equals(AsText(value), Literal, StringComparison.Ordinal); return Op == UgxCompare.Eq ? same : !same; } static bool TryNumber(object value, out double number) { switch (value) { case double d: number = d; break; case float f: number = f; break; case int i: number = i; return true; case long l: number = l; return true; case short s: number = s; return true; case byte b: number = b; return true; case decimal m: number = (double)m; return true; case string text: if (!double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out number)) return false; break; default: number = 0d; return false; } return !double.IsNaN(number) && !double.IsInfinity(number); } /// Booleans use the studio spelling, not `Boolean.ToString`'s `True`. static string AsText(object value) { if (value is bool flag) return flag ? "true" : "false"; return Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty; } } /// One compiled `clip plays when X happens` rule. public readonly struct UgxAnimationTrigger { public readonly UgxTriggerKind Kind; public readonly string Clip; /// Element name for hover triggers. Empty means the screen root. public readonly string Target; /// Action name for , data path for DataChange. public readonly string Key; public readonly UgxPlayMode Mode; /// Guard on a DataChange trigger. Default fires on every change to . public readonly UgxTriggerCondition Condition; public UgxAnimationTrigger(UgxTriggerKind kind, string clip, string target = null, string key = null, UgxPlayMode mode = UgxPlayMode.Restart, UgxTriggerCondition condition = default) { Kind = kind; Clip = clip; Target = target; Key = key; Mode = mode; Condition = condition; } } /// /// Wires clips to lifecycle phases, hover, actions and data changes. Owns every subscription it /// makes and releases them all on . /// public sealed class UgxAnimationTriggers : IDisposable { readonly UgxAnimator _animator; readonly VisualElement _root; readonly UgxDisposableSet _subscriptions = new UgxDisposableSet(8); readonly List _onShow = new List(2); readonly List _onHide = new List(2); readonly Dictionary> _onData = new Dictionary>(StringComparer.Ordinal); /// Every entry in , for the "everything changed" notification. readonly List _dataTriggers = new List(2); INotifyBindablePropertyChanged _watchedSource; bool _disposed; public UgxAnimationTriggers(UgxAnimator animator, VisualElement root) { _animator = animator ?? throw new ArgumentNullException(nameof(animator)); _root = root ?? throw new ArgumentNullException(nameof(root)); } public void Install(IReadOnlyList triggers, IUgxDataSource source = null) { if (triggers == null) return; for (int i = 0; i < triggers.Count; i++) Install(triggers[i], source); } public void Install(UgxAnimationTrigger trigger, IUgxDataSource source = null) { if (_disposed || string.IsNullOrEmpty(trigger.Clip)) return; switch (trigger.Kind) { case UgxTriggerKind.Show: _onShow.Add(trigger); return; case UgxTriggerKind.Hide: _onHide.Add(trigger); return; case UgxTriggerKind.Hover: InstallHover(trigger); return; case UgxTriggerKind.HoverExit: InstallHover(trigger); return; case UgxTriggerKind.Action: if (string.IsNullOrEmpty(trigger.Key)) return; var captured = trigger; _subscriptions.Add(UgxActionBus.Subscribe(trigger.Key, _ => Play(captured))); return; case UgxTriggerKind.DataChange: InstallData(trigger, source); return; } } public void FireShow() => PlayAll(_onShow); public void FireHide() => PlayAll(_onHide); public void Dispose() { if (_disposed) return; _disposed = true; if (_watchedSource != null) { _watchedSource.propertyChanged -= OnSourceChanged; _watchedSource = null; } _subscriptions.Dispose(); _onShow.Clear(); _onHide.Clear(); _onData.Clear(); _dataTriggers.Clear(); } void InstallHover(UgxAnimationTrigger trigger) where TEvent : EventBase, new() { var target = string.IsNullOrEmpty(trigger.Target) ? _root : _root.Q(trigger.Target); if (target == null) { Debug.LogWarning("[ugx] animation trigger targets missing element '" + trigger.Target + "'"); return; } var captured = trigger; EventCallback callback = _ => Play(captured); target.RegisterCallback(callback); _subscriptions.Add(new HoverRegistration(target, callback)); } void InstallData(UgxAnimationTrigger trigger, IUgxDataSource source) { if (string.IsNullOrEmpty(trigger.Key)) return; if (!(source is INotifyBindablePropertyChanged notifier)) { Debug.LogWarning("[ugx] data triggered animation '" + trigger.Clip + "' needs a data source that reports changes; it will never fire"); return; } if (!_onData.TryGetValue(trigger.Key, out var list)) { list = new List(1); _onData[trigger.Key] = list; } var entry = new DataTrigger(trigger, source); list.Add(entry); _dataTriggers.Add(entry); if (_watchedSource != notifier) { if (_watchedSource != null) _watchedSource.propertyChanged -= OnSourceChanged; _watchedSource = notifier; _watchedSource.propertyChanged += OnSourceChanged; } // A condition that already holds when the screen binds has to start the clip now; // waiting for the next change would leave the UI a frame behind the data forever. if (trigger.Condition.HasCondition) Evaluate(entry); } void OnSourceChanged(object sender, BindablePropertyChangedEventArgs args) { if (_disposed) return; var path = (string)args.propertyName; // An empty name is the source saying everything changed, so every guard is stale. if (string.IsNullOrEmpty(path)) { for (int i = 0; i < _dataTriggers.Count; i++) Evaluate(_dataTriggers[i]); return; } if (!_onData.TryGetValue(path, out var list)) return; for (int i = 0; i < list.Count; i++) Evaluate(list[i]); } void Evaluate(DataTrigger entry) { var condition = entry.Trigger.Condition; if (!condition.HasCondition) { Play(entry.Trigger); return; } object value; bool holds = entry.Source.TryGet(entry.Trigger.Key, out value) && condition.Evaluate(value); // Only edges matter: a value that keeps satisfying the condition must not restart the clip. if (holds == entry.Held) return; entry.Held = holds; if (holds) Play(entry.Trigger); else if (condition.Play == UgxConditionPlay.Loop) _animator.Rewind(entry.Trigger.Clip); } void PlayAll(List triggers) { for (int i = 0; i < triggers.Count; i++) Play(triggers[i]); } void Play(in UgxAnimationTrigger trigger) => _animator.Play(trigger.Clip, new UgxPlayOptions(trigger.Mode)); sealed class DataTrigger { internal readonly UgxAnimationTrigger Trigger; internal readonly IUgxDataSource Source; /// Whether the condition held at the last evaluation, so only edges act. internal bool Held; internal DataTrigger(UgxAnimationTrigger trigger, IUgxDataSource source) { Trigger = trigger; Source = source; } } sealed class HoverRegistration : IDisposable where TEvent : EventBase, new() { VisualElement _element; EventCallback _callback; internal HoverRegistration(VisualElement element, EventCallback callback) { _element = element; _callback = callback; } public void Dispose() { if (_callback == null) return; _element.UnregisterCallback(_callback); _element = null; _callback = null; } } } }