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