Samuel Bouchet
Game developer at Lonestone game studio

Animations Done Right with Reactive UI (Unity 6 example)

10/12/2024

Reactive UI on observable state in video games is reliable and efficient, yet animations become a challenge. Let's see how to tackle that.

Neoproxima UI illustration Neoproxima UI illustration

Creating smooth, dynamic UI animations in video games can be tricky, especially when the game involves complex states or frequent updates.

In games like Neoproxima and Foretales, user interfaces are reactive and state-driven, ensuring scalability and reliability but creating an additional challenge when it comes to UI animations. Let's explore some patterns to get the animations right!


What is a Reactive UI?

At its core, a reactive UI uses observable state as the single source of truth. When something changes in the state—like the player’s health bar or inventory—the UI automatically updates from its observable subscription. This means developers don't need to manually manage updates to every affected element.

Benefits of a Reactive Approach:

  1. Perfect Consistency: No more fiddling with manual triggers. Your UI stays in sync with the game state.
  2. Decoupled Code: Separation between state and UI logic makes the system easier to maintain and debug. Display complexity can be isolated for each use case in the dedicated component.

Challenges

  1. Always in Sync: It means that when you want to animate or delay a visual element, additional logic is required and you can't use a raw binding.

Using State to Drive Animations

Let's start with a simple example to illustrate the point. Here I'll be using an observable state using TinkState# and a custom Unity UI component.

State.cs
public class GameState {
    public static State<int> HealthPoints = Observable.State<int>(100);
}
HealthBar.cs
public class HealthBar : MonoBehaviour {
    public RectTransform HealthFill;
    private IDisposable autoRun;

    private void Start(){
        autoRun = Observable.AutoRun(() => {
            float healthPercentage = GameState.HealthPoints.Value / 100f;
            HealthFill.anchorMax = new Vector3(healthPercentage, 1);
        });
    }

    private void OnDestroy() {
        autoRun.Dispose();
    }
}

At this point, anytime the HealthPoints state is modified, the AutoRun will trigger and update the health bar. Always in sync.

Usually, this is not enough because no health bar ever just instantly changes to the end state: this is not juicy at all. You want to tween the modification and use a "change" preview before the animation.

Simple Trick: Transition from Current State

Ok, let's go with a simple trick: transitioning from the current state is the simplest way to get an animation. It's ok for simple cases and easy to implement. It might not work for all transition cases, but a low-effort solution that works most of the time is often enough.

HealthBar.cs
public class HealthBar : MonoBehaviour {
    public RectTransform HealthFill;
    private IDisposable autoRun;

    private void Start(){
        autoRun = Observable.AutoRun(() => {
            float healthPercentage = GameState.HealthPoints.Value / 100f;
            // Abort existing animations
            DOTween.Kill(this);
            // Punch scale and tween
            Sequence sequence = DOTween.Sequence()
                .Append(HealthFill.DOPunchScale(new Vector3(0.5f, 0.5f, 0.5f), 0.2f).SetEase(Ease.InOutQuad))
                .Append(HealthFill.DOAnchorMax(new Vector3(healthPercentage, 1), 0.5f).SetEase(Ease.OutQuad))
                .SetTarget(this);
        });
    }

    private void OnDestroy() {
        autoRun.Dispose();
    }
}

Whatever the current state, the health bar will punch scale then transition smoothly toward the new value.

Complex Sequence Scenario

In a case where multiple sources of damage impact the health bar value, the previous approach might not be clear enough for the player to understand what's happening.

Let's pretend we are working on a turn-based game and the character takes damage, then gets a heal bonus, then takes damage again. The game engine would solve all 3 events immediately if they happen in the same turn, but the UI won't reflect the whole sequence and only transition to the final value.

For this situation, we will use an animation queue pattern. This is how it could work.

The base architecture:

  • An observable state that can be modified only through game events (function calls in this example).
  • An AnimationEvent queue to hold all animations to be displayed to the player.
  • An observer pattern (aka. pub/sub)
  • The DemoState will publish AnimationEvent so that subscribers can handle simultaneously the animations.
  • Components will subscribe to AnimationEvent and handle the ones they want.
  • Note that the callback is asynchronous; the publication will wait for all subscribers to end their animations before starting the next one.

The idea is that any game event will enqueue an animation query for the components to react. Then all animations will be queried sequentially and executed in parallel if multiple components implements animations. Finally, we wait for the slowest subscriber before moving to the next animation.

The state is centralized in a file. Here it's a MonoBehaviour which will allow other components to reference it and also trigger the animation queue whenever an animation is available.

The animated component keep a "synchronize with state" binding logic, but waits for the animations to complete before setting the state. This is to handle the case were the state is updated but no animation is triggered.

DemoState.cs
using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using Sirenix.OdinInspector;
using TinkState;
using UnityEngine;

public enum DemoGameEvent {
    InitGame,
    TakeDamage,
    Heal
}

public class DemoAnimationEvent : AnimationEvent {
    public DemoGameEvent GameEvent;
    public float NextHealthValue;
}

public interface IAnimationSub {
    UniTask OnAnimationEvent(AnimationEvent evt);
}

public class DemoState : MonoBehaviour {
    // Private state
    private readonly State<int> _healthPoints = Observable.State(100);

    // Public readonly observable interface for the state
    public Observable<int> HealthPoints => _healthPoints;

    // Game event to init the state
    public void InitGame(int initialHealth) {
        _healthPoints.Value = initialHealth;
        _animationEvents.Enqueue(new DemoAnimationEvent {
            GameEvent = DemoGameEvent.InitGame,
            NextHealthValue = HealthPoints.Value
        });
    }

    // Game event to apply damage
    public void TakeDamage(int damage) {
        _healthPoints.Value -= damage;
        _animationEvents.Enqueue(new DemoAnimationEvent {
            GameEvent = DemoGameEvent.TakeDamage,
            NextHealthValue = _healthPoints.Value
        });
    }

    // Game event to heal
    public void Heal(int heal) {
        _healthPoints.Value += heal;
        _animationEvents.Enqueue(new DemoAnimationEvent {
            GameEvent = DemoGameEvent.Heal,
            NextHealthValue = HealthPoints.Value
        });
    }

    // Animation logic
    private readonly Queue<DemoAnimationEvent> _animationEvents = new();
    private readonly List<IAnimationSub> _animationSubs = new();

    public void Register(IAnimationSub sub) {
        _animationSubs.Add(sub);
    }

    // Bool used as semaphore to prevent multiple parallel calls of TriggerAnimations()
    private bool _isAnimating = false;

    private void LateUpdate() {
        if (!_isAnimating) TriggerAnimations().Forget();
    }

    private async UniTask TriggerAnimations() {
        _isAnimating = true;
        while (_animationEvents.Count > 0) {
            var evt = _animationEvents.Dequeue();
            await UniTask.WhenAll(_animationSubs.Select(sub => sub.OnAnimationEvent(evt)));
        }

        _isAnimating = false;
    }

    // What would happen in the game core logic
    [Button]
    private void SimulateEvents() {
        // Start full
        InitGame(100);

        // Big Hit!
        TakeDamage(50);
        // Heal!
        Heal(20);
        // Hit!
        TakeDamage(5);
    }
}
SlideGaugeDemo.cs A slider component that subscribes to the animation queue and implements health animation logic
using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using DG.Tweening;
using Sirenix.OdinInspector;
using TinkState;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

public class SlideGaugeDemo : MonoBehaviour, IAnimationSub {
    [Title("Configuration")]
    public float MaxValue = 100f;

    [Title("Bindings")]
    [Required]
    public DemoState StateManager = null!;

    [Required, ChildGameObjectsOnly]
    public RectTransform HealthFill = null!;

    [Required, ChildGameObjectsOnly]
    public RectTransform PreviewFill = null!;

    [Required, ChildGameObjectsOnly]
    public TextMeshProUGUI ValueText = null!;

    [Title("Animation")]
    public float BumpDuration = 0.15f;

    public float TransitionDuration = 0.3f;

    private CancellationTokenSource? _cts;

    private void Start() {
        _cts = new CancellationTokenSource();
        // register the component as needing animations callbacks
        StateManager.Register(this);

        // Keep the state binding but delay until the animation is completed
        Observable.AutoRun(async () => {
            // async lambda won't throw exceptions, use try catch to handle them
            try {
                await UniTask.WaitUntil(() => StateManager.IsAnimationCompleted, cancellationToken: _cts.Token);
                float healthPercentage = StateManager.HealthPoints.Value / MaxValue;
                SetFinalState(healthPercentage, StateManager.HealthPoints.Value);
            } catch (Exception e) {
                Debug.LogError(e);
            }
        }).AddTo(_cts.Token);
    }

    private void SetFinalState(float healthPercentage, int healthPointsValue) {
        HealthFill.anchorMax = new Vector2(healthPercentage, 1);
        PreviewFill.anchorMax = new Vector2(healthPercentage, 1);
        UpdateText(healthPointsValue);
    }

    public async UniTask OnAnimationEvent(AnimationEvent e) {
        if (e is not DemoAnimationEvent evt) return;
        var currentValue = HealthFill.anchorMax.x * MaxValue;
        var nextValuePercent = evt.NextHealthValue / MaxValue;
        var diff = nextValuePercent - HealthFill.anchorMax.x;

        if (evt.GameEvent is DemoGameEvent.InitGame) {
            // For the initial state, no animation just set the value
            SetFinalState(nextValuePercent, evt.NextHealthValue);
        } else if (evt.GameEvent is DemoGameEvent.Heal) {
            // More health than before, quick tween preview then normal tween HealthFill
            PreviewFill.GetComponent<Image>().color = Color.green;
            // Abort existing animations
            DOTween.Kill(this);
            await DOTween.Sequence()
                .Append(PreviewFill.DOAnchorMax(new Vector3(nextValuePercent, 1), TransitionDuration * 0.3f).SetEase(Ease.OutQuad))
                .Append(transform.DOPunchRotation(new Vector3(0, 0, Mathf.Abs(diff * 5f)), TransitionDuration).SetEase(Ease.OutQuad))
                .Join(HealthFill.DOAnchorMax(new Vector3(evt.NextHealthValue / MaxValue, 1), TransitionDuration).SetEase(Ease.OutQuad))
                .Join(
                    DOTween.To(() => currentValue, x => currentValue = x, evt.NextHealthValue, TransitionDuration)
                        .OnUpdate(() => UpdateText(currentValue))
                )
                .SetTarget(this)
                .AsyncWaitForCompletion();
        } else if (evt.GameEvent is DemoGameEvent.TakeDamage) {
            // Less health than before, quick tween HealthFill then normal tween preview
            PreviewFill.GetComponent<Image>().color = Color.white;
            // Abort existing animations
            DOTween.Kill(this);
            await DOTween.Sequence()
                .Append(transform.DOPunchScale(Vector3.one * diff * 0.5f, BumpDuration).SetEase(Ease.InOutQuad))
                .Join(HealthFill.DOAnchorMax(new Vector3(nextValuePercent, 1), TransitionDuration * 0.3f).SetEase(Ease.OutQuad))
                .Append(PreviewFill.DOAnchorMax(new Vector3(evt.NextHealthValue / MaxValue, 1), TransitionDuration).SetEase(Ease.OutQuad))
                .Join(
                    DOTween.To(() => currentValue, x => currentValue = x, evt.NextHealthValue, TransitionDuration)
                        .OnUpdate(() => UpdateText(currentValue))
                )
                .SetTarget(this)
                .AsyncWaitForCompletion();
        }

        // Let some time for the player to acknowledge the information
        await UniTask.WaitForSeconds(0.2f);
    }

    private void UpdateText(float currentValue) {
        ValueText.text = $"{currentValue:0}/{MaxValue}";
    }

    private void OnDestroy() {
        _cts?.Cancel();
    }
}

Library used :

Evolutions and Improvements

  • Use game event objects to have a serializable/networkable event stream instead of function calls.
  • Have a merge strategy for some animations. For example, it could be useful to batch damage taken by merging animations, but to still separate damage taken and heal.
  • Have a simultaneous strategy for some animations. For example, you might want to animate mana and health changes at the same time since they would be visually close to each other.
  • Use better contextual game events. Here "TakeDamage" is a badly named event because there is no context information. A better game event would be "Attack(source, target, damage)" or "PoisonTick(target, tickDamage)" for example, but it will depend on the game context.

Find me on social networks

If you found this article helpful, check my other articles and don't forget to follow me on your favorite network for more game development tips and discussions.

Published by Samuel Bouchet.
Do you like reading SF? Try out latest game Neoproxima