Broadcasting Value Change
Why?
- Simplify UI code by eliminating the need to poll for changes and set view every frame
- Support gameplay logic such as conditional buffs (e.g. whenever health decreases, get a temporary attack boost)
Suppose we have UI code that takes dynamic data from a referenced object and presents it on a text label. Rather than polling for changes in the object and setting the text label every frame, we can listen to an event that will be invoked when the value we’re interested in changes.
For example, a UI element that cares about player health will listen to a HealthChanged
event exposed by the PlayerData
object:
public class PlayerData
{
[SerializeField] private float _health;
public float Health {
get => _health;
set {
if (_health != value)
{
_health = value;
HealthChanged?.Invoke(_health);
}
}
}
public event Action<float> HealthChanged;
}
In stat-heavy games like RPGs or roguelikes, a player would typically have multiple attributes (aka. player stats) such as health, attack, defence, mana, etc. This type of code will seem a bit verbose if we need to repeat it for all player stats. We can encapsulate this functionality in a wrapper class.
On a side note, the INotifyPropertyChanged interface provided by .NET System.ComponentModel
supports a similar pattern. However, the event it uses to notify property change passes a PropertyChangedEventArgs
which contains a property name. This isn’t exactly what we want.
Solution: Bindable Property
BindableProperty<T>
is a simple class that maintains a value and exposes an event that will be invoked when the value changes.
using System;
namespace NekoLib
{
/// <summary>
/// Interface for property that holds a value and exposes an event for value change.
/// </summary>
/// <typeparam name="T"></typeparam>
public interface IBindableProperty<T>
{
public T Value { get; set; }
public event Action<T> ValueChanged;
}
}
using System;
using UnityEngine;
namespace NekoLib
{
/// <summary>
/// Data class that holds a value and exposes an event for value change.
/// </summary>
/// <typeparam name="T"></typeparam>
[System.Serializable]
public class BindableProperty<T> : IBindableProperty<T> where T : struct
{
public virtual T Value {
get => _value;
set {
if (!_value.Equals(value))
{
_value = value;
OnValueChange();
}
}
}
public event Action<T> ValueChanged;
[SerializeField] protected T _value;
public BindableProperty(T value)
{
Value = value;
}
protected virtual void OnValueChange()
{
ValueChanged?.Invoke(Value);
}
}
}
Usage
This is similar to UniRX‘s reactive property. Just declare your value with the BindableProperty type, which already encapsulates a ValueChanged
event. We can now use a simple one-liner to declare an observable property.
public class Player
{
[field: SerializeField] public BindableProperty<float> Health { get; private set; }
}
Then our UI code can listen to the value change event:
public class UIPlayerHealthController : MonoBehaviour
{
public Player Player {get; set;}
private void OnEnable()
{
Player.Health.ValueChanged += HandlePlayerHealthChanged;
}
private void OnDisable()
{
Player.Health.ValueChanged -= HandlePlayerHealthChanged;
}
public void HandlePlayerHealthChanged(float value)
{
// Update the view here.
}
}
Reactive Stats
Implementation
Building upon our implementation of BindableProperty
, we can achieve some interesting functionalities. The modifiable attributes system “Reactive Stats” is dependant on base interfaces IBindableProperty
, IReadonlyProperty
. The core elements include Stat
, StatModifier
, and corresponding interfaces.
The Stat
class implements the IBindableProperty
interface, meaning UI code can easily listen to its changes. A modifiable attribute (i.e. stat) is represented by an instance of the Stat class. Each stat instance is declared with a base value and exposes a final value. Each stat instance maintains its own collection of value modifiers, which are used to calculate the final value.
The final value is calculated lazily, so adding and removing multiple modifiers within a frame will have limited impact on performance. The final value will only be re-calculated when its getter is called.
using System;
using NekoLib;
namespace NekoLib.Stats
{
/// <summary>
/// Interface for property that represents a modifiable stat.
/// </summary>
public interface IStat : IReadonlyProperty<float>
{
public event Action<float> ValueChanged;
public event Action<IStatModifier> ModifierAdded;
public event Action<IStatModifier> ModifierRemoved;
public void AddModifier(IStatModifier modifier);
public bool RemoveModifier(IStatModifier modifier);
public bool RemoveModifiersBySource(object source);
}
}
IStat
extends IReadonlyProperty
, which is simply an interface for a wrapper class that holds a readonly property. Its use case will be demonstrated later when extending the system.
namespace NekoLib
{
/// <summary>
/// Interface for a readonly property wrapper.
/// </summary>
/// <typeparam name="T"></typeparam>
public interface IReadonlyProperty<T>
{
public T Value { get; }
}
}
Rather than letting Stat
directly extend BindableProperty, here I implement the IBindableProperty interface. This gives more freedom in inspector serialization (e.g. hiding _value
). The Value
getter and setter are going to be completely rewritten anyway. Stat.BaseValue
is the equivalent of BindableProperty.Value
in BindableProperty, but users from the outside are often only interested in Stat.Value
.
using System;
using System.Collections.Generic;
using UnityEngine;
using NekoLib;
namespace NekoLib.Stats
{
/// <summary>
/// Data class that represents a modifiable stat.
/// Manages a collection of value modifiers, and provides relevant events.
/// </summary>
[System.Serializable]
public class Stat : IStat, IBindableProperty<float>
{
/// <summary>
/// Final value of the stat.
/// </summary>
public virtual float Value {
get {
if (_isDirty)
{
_value = CalculateValue();
_isDirty = false;
}
return _value;
}
set { }
}
/// <summary>
/// Base value of the stat.
/// </summary>
public virtual float BaseValue {
get => _baseValue;
set {
if (_baseValue != value)
{
_baseValue = value;
_isDirty = true;
OnValueChange();
}
}
}
public event Action<float> ValueChanged;
public event Action<IStatModifier> ModifierAdded;
public event Action<IStatModifier> ModifierRemoved;
protected float _value;
[SerializeField] protected float _baseValue;
protected bool _isDirty = true;
protected readonly List<IStatModifier> _modifiers = new List<IStatModifier>();
public Stat(float baseValue = 0f)
{
BaseValue = baseValue;
}
// ...
}
}
A StatModifier
instance holds a Stat which defines the value of the modifier. This means a stat modifier’s value can also receive modifiers just like a stat. Source
records the source of the modifier. Type
marks how its value should contribute to stat value calculation. Order
marks the modifier’s calculation order, which provides flexibility in handling percentage-based modifiers.
namespace NekoLib.Stats
{
/// <summary>
/// Data class that represents a value modifier for stats.
/// </summary>
public class StatModifier : IStatModifier
{
public Stat Stat { get; }
public object Source { get; }
public StatModifierType Type { get; }
public int Order { get; }
public StatModifier(float value, object source, StatModifierType type = StatModifierType.Flat, int order = 0)
{
Stat = new Stat(value);
Source = source;
Type = type;
Order = order;
}
}
}
namespace NekoLib.Stats
{
public enum StatModifierType
{
Flat,
PercentAdd,
PercentMult,
}
}
Adding and Removing Modifiers
Stat provides methods to add and remove stat modifiers. It also provides a method to remove all stat modifiers from a specified source. This can be useful for use cases like buffs and weapons. After adding or removing modifiers, set _isDirty = true
to mark the final value for re-calculation next time it’s accessed.
/// <summary>
/// Add a modifier.
/// </summary>
/// <param name="modifier"></param>
public void AddModifier(IStatModifier modifier)
{
_modifiers.Add(modifier);
_modifiers.Sort(CompareModifierOrder);
modifier.Stat.ValueChanged += OnModifierValueChanged;
_isDirty = true;
OnValueChange();
ModifierAdded?.Invoke(modifier);
}
/// <summary>
/// Remove a modifier.
/// </summary>
/// <param name="modifier"></param>
/// <returns></returns>
public bool RemoveModifier(IStatModifier modifier)
{
bool isRemoved = _modifiers.Remove(modifier);
if (isRemoved)
{
modifier.Stat.ValueChanged -= OnModifierValueChanged;
_isDirty = true;
OnValueChange();
ModifierRemoved?.Invoke(modifier);
}
return isRemoved;
}
/// <summary>
/// Remove all modifiers from the specified source.
/// </summary>
/// <param name="source"></param>
/// <returns></returns>
public bool RemoveModifiersBySource(object source)
{
bool isRemoved = false;
for (int i = _modifiers.Count - 1; i >= 0; i--)
{
var modifier = _modifiers[i];
if (modifier.Source == source)
{
_modifiers.RemoveAt(i);
isRemoved = true;
modifier.Stat.ValueChanged -= OnModifierValueChanged;
_isDirty = true;
OnValueChange();
ModifierRemoved?.Invoke(modifier);
}
}
return isRemoved;
}
ValueChanged carries the Value property. The final value will re-calculated when this property is accessed.
private void OnModifierValueChanged(float value)
{
OnValueChange();
}
protected virtual void OnValueChange()
{
ValueChanged?.Invoke(Value);
}
When adding a modifier, it will be ordered based on the defined calculation order.
private int CompareModifierOrder(IStatModifier a, IStatModifier b)
{
if (a.Order < b.Order) return -1;
else if (a.Order > b.Order) return 1;
return 0;
}
Calculating The Final Value
How to calculate the final stat value depends on project requirements. The method shown in this section re-calculates the final value from scratch using all of the modifier values. If you’re certain your project won’t involve lots of orthogonal percentage-based modifiers to be inserted at different steps of the calculation, it’s easier to just ignore this section and calculate the final value progressively every time you add/remove/change a modifier.
Start with the base value, apply stat modifiers in order, and iteratively calculate the final value based on the modifier type.
Flat
modifier values are directly added.
PercentMult
modifier values will be applied as a percentage change based on the currently calculated final value up to this step.
For PercentAdd
modifiers, accumulate modifier values for consecutive modifiers of the same type, then apply the accumulated percentage value when we encounter a different type or reach the end. This is expected to be the most-used type of modifiers for ARPG games like Genshin Impact, where we have distinct modifier zones where percentage-based buffs are aggregated. This type of modifier is best combined with explicitly defined Order
properties, so that they can be correctly grouped as intended.
/// <summary>
/// Calculate the final value of the stat by applying the existing modifiers.
/// </summary>
/// <returns></returns>
protected virtual float CalculateValue()
{
float value = BaseValue;
float additivePercent = 0f;
// Apply modifiers.
for (int i = 0; i < _modifiers.Count; i++)
{
StatModifier modifier = _modifiers[i];
switch (modifier.Type)
{
case StatModifier.StatModifierType.Flat:
// Apply flat value modifier.
value += modifier.Stat.Value;
break;
case StatModifier.StatModifierType.PercentAdd:
// Accumulate consequtive additive percentage modifiers to apply together.
additivePercent += modifier.Stat.Value;
if (i + 1 >= _modifiers.Count || _modifiers[i + 1].Type != StatModifier.StatModifierType.PercentAdd)
{
value *= (1 + additivePercent);
additivePercent = 0f;
}
break;
case StatModifier.StatModifierType.PercentMult:
// Apply percentage modifier.
value *= (1 + modifier.Stat.Value);
break;
}
value = (float)Math.Round(value, 4);
}
return value;
}
Extension: Bounded Stat
As an example, BoundedStat
extends Stat
to add upper and lower bounds. The bounds can be Stat
instances. The final value is also lazily calculated, and always clamped. This is useful for gameplay resource stats like health and mana.
using UnityEngine;
using NekoLib;
namespace NekoLib.Stats
{
/// <summary>
/// Data class that represents a modifiable stat.
/// The value is bounded inclusively by lower and higher bounds.
/// </summary>
public class BoundedStat : Stat
{
public override float BaseValue { set => base.BaseValue = Mathf.Clamp(value, MinValue, MaxValue); }
public IReadonlyProperty<float> MinProperty { get; }
public IReadonlyProperty<float> MaxProperty { get; }
public float MinValue => MinProperty.Value;
public float MaxValue => MaxProperty.Value;
public BoundedStat(float baseValue, float minValue, float maxValue) : base(baseValue)
{
MinProperty = new ReadonlyProperty<float>(minValue);
MaxProperty = new ReadonlyProperty<float>(maxValue);
}
public BoundedStat(float baseValue, IReadonlyProperty<float> minProperty, IReadonlyProperty<float> maxProperty) : base(baseValue)
{
MinProperty = minProperty;
MaxProperty = maxProperty;
}
public BoundedStat(float baseValue, float minValue, IReadonlyProperty<float> maxProperty) : base(baseValue)
{
MinProperty = new ReadonlyProperty<float>(minValue);
MaxProperty = maxProperty;
}
public BoundedStat(float baseValue, IReadonlyProperty<float> minProperty, float maxValue) : base(baseValue)
{
MinProperty = MinProperty;
MaxProperty = new ReadonlyProperty<float>(maxValue);
}
}
}
Here ReadonlyProperty
is a dummy class that implements IReadonlyProperty
.
namespace NekoLib
{
/// <summary>
/// A readonly property wrapper.
/// Intended to be a dummy concrete implementation of <see cref="IReadonlyProperty{T}"/>.
/// </summary>
/// <typeparam name="T"></typeparam>
public struct ReadonlyProperty<T> : IReadonlyProperty<T>
{
public T Value { get; }
public ReadonlyProperty(T value)
{
Value = value;
}
}
}
Why is this used?
Normally we expect the upper bound of BoundedStat to be a Stat, for example:
Stat MaxHealth = new Stat(100f);
BoundedStat CurrentHealth = new BoundedStat(100f, 0f, MaxHealth);
(Recall that Stat
also inherently implements IReadonlyProperty
through IStat
, hence why this works). When an upper bound or lower bound Stat
is not specified in the constructor, we can directly create dummy ReadonlyProperty
instances to initialize the BoundedStat and perform clamping in the same way. Without this, we would either have to create actual Stat instances which would take more memory, or write verbose case checks for obtaining the clamp bounds.