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