// MIT License - Copyright (c) 2025 wallstop
// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE
namespace WallstopStudios.UnityHelpers.Tags
{
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
using Core.Extension;
using ProtoBuf;
using UnityEngine;
///
/// Represents a dynamic numeric attribute that supports temporary modifications through effects.
/// Attributes maintain a base value and automatically calculate a current value by applying all active modifications.
///
///
///
/// This class provides a flexible system for game attributes (like health, speed, damage, etc.) that can be
/// temporarily or permanently modified. Modifications are applied in a specific order based on their action type:
/// Addition, then Multiplication, then Override.
///
///
/// Example usage:
///
/// // Create an attribute with base value of 100
/// Attribute health = new Attribute(100f);
///
/// // Apply a modification (e.g., +20 health from a buff)
/// health.ApplyAttributeModification(new AttributeModification
/// {
/// action = ModificationAction.Addition,
/// value = 20f
/// }, effectHandle);
///
/// // Current value is now 120
/// float currentHealth = health.CurrentValue;
///
///
///
[Serializable]
public sealed class Attribute
: IEquatable,
IEquatable,
IComparable,
IComparable
{
///
/// Gets the current calculated value of the attribute, including all active modifications.
/// This value is cached and recalculated only when modifications change.
///
/// The current value after applying all modifications to the base value.
public float CurrentValue
{
get
{
#if UNITY_EDITOR
/*
For some reason, there's a bug with loot tables where
_currentValueCalculated will be true but the current
value is not calculated, so ignore the flag if we're
in editor mode, where this happens
*/
if (Application.isPlaying)
#endif
{
if (_currentValueCalculated)
{
return _currentValue;
}
}
CalculateCurrentValue();
return _currentValue;
}
}
///
/// Gets the base value of the attribute before any modifications are applied.
///
/// The unmodified base value.
public float BaseValue => _baseValue;
[SerializeField]
internal float _baseValue;
[SerializeField]
private float _currentValue;
private bool _currentValueCalculated;
private readonly Dictionary> _modifications =
new();
///
/// Initializes a new instance of the class with a base value of 0.
///
public Attribute()
: this(0) { }
///
/// Initializes a new instance of the class with the specified base value.
///
/// The base value for this attribute.
public Attribute(float value)
{
_baseValue = value;
_currentValueCalculated = false;
}
///
/// Initializes a new instance of the class for JSON deserialization.
///
/// The base value for this attribute.
/// The cached current value.
[JsonConstructor]
public Attribute(float baseValue, float currentValue)
{
_baseValue = baseValue;
_currentValue = currentValue;
_currentValueCalculated = true;
}
///
/// Recalculates the current value by applying all active modifications to the base value.
/// Modifications are sorted and applied in order: Addition, Multiplication, then Override.
///
internal void CalculateCurrentValue()
{
float calculatedValue = _baseValue;
if (_modifications.Count > 0)
{
ApplyModificationsInOrder(ModificationAction.Addition, ref calculatedValue);
ApplyModificationsInOrder(ModificationAction.Multiplication, ref calculatedValue);
ApplyModificationsInOrder(ModificationAction.Override, ref calculatedValue);
}
_currentValue = calculatedValue;
_currentValueCalculated = true;
}
///
/// Implicitly converts an Attribute to its current float value.
///
/// The attribute to convert.
/// The current value of the attribute.
public static implicit operator float(Attribute attribute) => attribute.CurrentValue;
///
/// Implicitly converts a float value to an Attribute with that base value.
///
/// The base value for the attribute.
/// A new Attribute with the specified base value.
public static implicit operator Attribute(float value) => new(value);
///
/// Applies a temporary additive modification to the attribute.
///
/// The amount to add to the attribute's calculated value.
///
/// An effect handle that can later be supplied to
/// to revoke this addition.
///
///
/// Thrown when is not a finite number.
///
public EffectHandle Add(float value)
{
ValidateInput(value);
EffectHandle handle = EffectHandle.CreateInstanceInternal();
AttributeModification modification = new()
{
action = ModificationAction.Addition,
value = value,
};
ApplyAttributeModification(modification, handle);
return handle;
}
///
/// Applies a temporary subtractive modification to the attribute.
///
/// The amount to subtract from the attribute's calculated value.
///
/// An effect handle that can later be supplied to
/// to revoke this subtraction.
///
///
/// Thrown when is not a finite number.
///
public EffectHandle Subtract(float value)
{
ValidateInput(value);
EffectHandle handle = EffectHandle.CreateInstanceInternal();
AttributeModification modification = new()
{
action = ModificationAction.Addition,
// Subtraction is represented as a negative additive modifier to preserve modifier ordering.
value = -value,
};
ApplyAttributeModification(modification, handle);
return handle;
}
///
/// Applies a temporary division-based modification to the attribute.
///
///
/// The divisor that will be applied to the attribute's calculated value.
///
///
/// An effect handle that can later be supplied to
/// to revoke this division.
///
///
/// Thrown when is zero or not a finite number.
///
public EffectHandle Divide(float value)
{
ValidateInput(value);
if (value == 0f)
{
throw new ArgumentException("Cannot divide by zero.", nameof(value));
}
EffectHandle handle = EffectHandle.CreateInstanceInternal();
AttributeModification modification = new()
{
action = ModificationAction.Multiplication,
// Apply division by multiplying by the reciprocal to maintain multiplication ordering guarantees.
value = 1f / value,
};
ApplyAttributeModification(modification, handle);
return handle;
}
///
/// Applies a temporary multiplicative modification to the attribute.
///
/// The multiplier to apply to the attribute's calculated value.
///
/// An effect handle that can later be supplied to
/// to revoke this multiplication.
///
///
/// Thrown when is not a finite number.
///
public EffectHandle Multiply(float value)
{
ValidateInput(value);
EffectHandle handle = EffectHandle.CreateInstanceInternal();
AttributeModification modification = new()
{
action = ModificationAction.Multiplication,
value = value,
};
ApplyAttributeModification(modification, handle);
return handle;
}
///
/// Clears the cached current value, forcing it to be recalculated on next access.
///
public void ClearCache()
{
_currentValueCalculated = false;
}
private void ApplyModificationsInOrder(ModificationAction action, ref float value)
{
foreach (
KeyValuePair> entry in _modifications
)
{
List modifications = entry.Value;
for (int index = 0; index < modifications.Count; index++)
{
AttributeModification modification = modifications[index];
if (modification.action == action)
{
ApplyAttributeModification(modification, ref value);
}
}
}
}
private static void ValidateInput(float value, [CallerMemberName] string caller = null)
{
if (!float.IsFinite(value))
{
throw new ArgumentException(
$"Cannot {caller?.ToLowerInvariant()} by infinity or NaN.",
nameof(value)
);
}
}
///
/// Applies an attribute modification to this attribute.
/// If a handle is provided, the modification is temporary and can be removed.
/// If no handle is provided, the modification is permanent and applied directly to the base value.
///
/// The modification to apply.
/// Optional effect handle for temporary modifications. If null, the modification is permanent.
public void ApplyAttributeModification(
AttributeModification attributeModification,
EffectHandle? handle = null
)
{
// If we don't have a handle, then this is an instant effect, apply it to the base value.
if (!handle.HasValue)
{
ApplyAttributeModification(attributeModification, ref _baseValue);
}
else
{
_modifications.GetOrAdd(handle.Value).Add(attributeModification);
}
CalculateCurrentValue();
}
///
/// Removes all modifications associated with the specified effect handle.
///
/// The effect handle whose modifications should be removed.
/// true if modifications were found and removed; otherwise, false.
public bool RemoveAttributeModification(EffectHandle handle)
{
bool removed = _modifications.Remove(handle);
if (removed)
{
CalculateCurrentValue();
}
return removed;
}
[OnDeserialized]
private void AfterDeserialize(StreamingContext streamingContext)
{
ClearCache();
}
[ProtoAfterDeserialization]
private void AfterProtoDeserialized()
{
ClearCache();
}
private static void ApplyAttributeModification(
AttributeModification attributeModification,
ref float value
)
{
switch (attributeModification.action)
{
case ModificationAction.Addition:
{
value += attributeModification.value;
break;
}
case ModificationAction.Multiplication:
{
value *= attributeModification.value;
break;
}
case ModificationAction.Override:
{
value = attributeModification.value;
break;
}
default:
{
throw new InvalidEnumArgumentException(
nameof(attributeModification.action),
(int)attributeModification.action,
typeof(ModificationAction)
);
}
}
}
///
/// Determines whether this attribute is equal to another attribute by comparing their current values.
///
/// The attribute to compare with.
/// true if the current values are equal; otherwise, false.
public bool Equals(Attribute other)
{
if (ReferenceEquals(this, other))
{
return true;
}
return other != null && CurrentValue.Equals(other.CurrentValue);
}
///
/// Compares this attribute to another attribute based on their current values.
///
/// The attribute to compare with.
///
/// A value less than 0 if this attribute is less than ;
/// 0 if they are equal;
/// a value greater than 0 if this attribute is greater than .
///
public int CompareTo(Attribute other)
{
if (ReferenceEquals(this, other))
{
return 0;
}
return other == null ? 1 : CurrentValue.CompareTo(other.CurrentValue);
}
///
/// Compares this attribute's current value to a float value.
///
/// The float value to compare with.
///
/// A value less than 0 if this attribute is less than ;
/// 0 if they are equal;
/// a value greater than 0 if this attribute is greater than .
///
public int CompareTo(float other)
{
return CurrentValue.CompareTo(other);
}
///
/// Determines whether this attribute is equal to the specified object.
/// Supports comparison with Attribute and numeric types.
///
/// The object to compare with.
/// true if the values are equal; otherwise, false.
public override bool Equals(object other)
{
switch (other)
{
case Attribute attribute:
{
return Equals(attribute);
}
case float attribute:
{
return Equals(attribute);
}
case double attribute:
{
return Equals((float)attribute);
}
case int attribute:
{
return Equals((float)attribute);
}
case long attribute:
{
return Equals((float)attribute);
}
case short attribute:
{
return Equals((float)attribute);
}
case uint attribute:
{
return Equals((float)attribute);
}
case ulong attribute:
{
return Equals((float)attribute);
}
case ushort attribute:
{
return Equals((float)attribute);
}
case byte attribute:
{
return Equals((float)attribute);
}
case sbyte attribute:
{
return Equals((float)attribute);
}
default:
{
return false;
}
}
}
///
/// Determines whether this attribute's current value equals the specified float value.
///
/// The float value to compare with.
/// true if the values are equal; otherwise, false.
public bool Equals(float other)
{
return CurrentValue.Equals(other);
}
///
/// Returns the hash code for this attribute.
///
/// A hash code for the current object.
public override int GetHashCode()
{
// ReSharper disable once BaseObjectGetHashCodeCallInGetHashCode
return base.GetHashCode();
}
///
/// Converts this attribute to its string representation using the current value.
///
/// A string representation of the current value in invariant culture format.
public override string ToString()
{
return ((float)this).ToString(CultureInfo.InvariantCulture);
}
}
}