// 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.Text; using System.Text.Json.Serialization; using Core.Extension; using Core.Helper; using UnityEngine; using WallstopStudios.UnityHelpers.Core.Attributes; using WallstopStudios.UnityHelpers.Utils; #if ODIN_INSPECTOR using Sirenix.OdinInspector; #endif /// /// Determines which handles are considered the "same stack" when evaluating stacking policies. /// public enum EffectStackGroup { [Obsolete("Please use a valid EffectStackGroup instead.")] None = 0, /// /// Uses the effect asset reference. Each ScriptableObject instance is its own group. /// Reference = 1, /// /// Uses a custom string key supplied via . /// Effects with matching keys share a stack regardless of asset reference. /// CustomKey = 2, } /// /// Describes how additional applications of an effect interact with existing stacks. /// public enum EffectStackingMode { [Obsolete("Please use a valid EffectStackingMode instead.")] None = 0, /// /// Always create a new stack (subject to optional stack limit). /// Stack = 1, /// /// Reuse the first existing stack and refresh duration if possible. /// Refresh = 2, /// /// Remove existing stacks sharing the same group before creating a new one. /// Replace = 3, /// /// Ignore new applications when a stack is already active. /// Ignore = 4, } /// /// Reusable, data‑driven bundle of stat modifications, tags, and cosmetic feedback. /// Serves as the authoring unit for buffs, debuffs, and status effects. /// /// /// /// Composition: /// - Attribute modifications: a list of applied to fields /// - Tags: string markers for cross‑system state gating and queries /// - Cosmetics: references for visuals/audio on apply/remove /// - Duration: with seconds and reapplication policy /// /// /// Problems solved and benefits: /// - Centralizes effect logic and presentation in one asset /// - Safely stacks via per application /// - Works with and for lifecycle and state tracking /// - Author once, reuse everywhere (designers can tweak without code changes) /// /// /// Usage examples: /// /// // Create a speed boost effect in the editor /// // Then apply it to a GameObject: /// GameObject player = ...; /// AttributeEffect speedBoost = ...; // ScriptableObject reference /// EffectHandle? handle = player.ApplyEffect(speedBoost); /// /// // Instant vs Duration vs Infinite /// // - Instant: modifies base values permanently, returns null handle /// // - Duration: temporary, expires automatically, returns handle /// // - Infinite: persists until RemoveEffect(handle) is called, returns handle /// /// // Removing later /// if (handle.HasValue) player.RemoveEffect(handle.Value); /// /// /// [Serializable] [CreateAssetMenu(menuName = "Wallstop Studios/Unity Helpers/Attribute Effect")] public sealed class AttributeEffect : #if ODIN_INSPECTOR SerializedScriptableObject #else ScriptableObject #endif , IEquatable { /// /// Gets a human-readable description of this effect based on its modifications. /// The description is automatically generated from the modifications list. /// /// A formatted string describing all modifications in this effect. /// "+20 Health, +1.5x Speed, -10% Defense" public string HumanReadableDescription => BuildDescription(); /// /// The list of attribute modifications to apply when this effect is activated. /// Each modification specifies an attribute name, action type, and value. /// public List modifications = new(); /// /// Periodic modifier sets executed on a cadence while the effect remains active. /// public List periodicEffects = new(); /// /// Specifies how long this effect should persist (Instant, Duration, or Infinite). /// public ModifierDurationType durationType = ModifierDurationType.Duration; /// /// The duration in seconds for this effect. Only used when is . /// #if ODIN_INSPECTOR [ShowIf("@durationType == ModifierDurationType.Duration")] #else [WShowIf( nameof(durationType), expectedValues: new object[] { ModifierDurationType.Duration } )] #endif public float duration; /// /// If true, reapplying this effect while it's already active will reset the duration timer. /// Only used when is . /// /// /// A poison effect with resetDurationOnReapplication=true will restart its 5-second timer /// each time the poison is reapplied, preventing stacking but extending the effect. /// #if ODIN_INSPECTOR [ShowIf("@durationType == ModifierDurationType.Duration")] #else [WShowIf( nameof(durationType), expectedValues: new object[] { ModifierDurationType.Duration } )] #endif public bool resetDurationOnReapplication; /// /// A list of string tags that are applied when this effect is active. /// Tags can be used to track effect categories, prevent certain actions, or enable special behaviors. /// /// /// Tags like "Stunned", "Poisoned", "Invulnerable" can be checked by game systems /// to determine if certain actions should be allowed or prevented. /// public List effectTags = new(); /// /// A list of cosmetic effect data that defines visual and audio feedback for this effect. /// These are applied when the effect becomes active and removed when it expires. /// [JsonIgnore] public List cosmeticEffects = new(); /// /// Custom behaviours instantiated per active handle. /// [JsonIgnore] public List behaviors = new(); /// /// Determines how this effect groups stacks for stacking decisions. /// public EffectStackGroup stackGroup = EffectStackGroup.Reference; /// /// Optional stack key used when is set to . /// public string stackGroupKey; /// /// Determines how successive applications interact with existing stacks for the same group. /// public EffectStackingMode stackingMode = EffectStackingMode.Refresh; /// /// Optional cap on simultaneous stacks when is . /// A value of 0 means unlimited stacks. /// [Min(0)] public int maximumStacks; /// /// Determines whether this effect applies the specified tag. /// /// The tag to check. /// true if the tag is present; otherwise, false. public bool HasTag(string effectTag) { if (effectTags == null || string.IsNullOrEmpty(effectTag)) { return false; } for (int i = 0; i < effectTags.Count; ++i) { if (string.Equals(effectTags[i], effectTag, StringComparison.Ordinal)) { return true; } } return false; } /// /// Determines whether this effect applies any of the specified tags. /// /// The tags to inspect. /// true if at least one tag is applied; otherwise, false. public bool HasAnyTag(IEnumerable effectTagsToCheck) { if (effectTags == null || effectTagsToCheck == null) { return false; } switch (effectTagsToCheck) { case IReadOnlyList list: { return HasAnyTag(list); } case HashSet hashSet: { foreach (string candidate in hashSet) { if (string.IsNullOrEmpty(candidate)) { continue; } if (HasTag(candidate)) { return true; } } return false; } } foreach (string candidate in effectTagsToCheck) { if (string.IsNullOrEmpty(candidate)) { continue; } if (HasTag(candidate)) { return true; } } return false; } /// /// Determines whether this effect applies any of the specified tags. /// Optimized for indexed collections. /// /// The tags to inspect. /// true if at least one tag is applied; otherwise, false. public bool HasAnyTag(IReadOnlyList effectTagsToCheck) { if (effectTags == null || effectTagsToCheck == null) { return false; } for (int i = 0; i < effectTagsToCheck.Count; ++i) { string candidate = effectTagsToCheck[i]; if (string.IsNullOrEmpty(candidate)) { continue; } if (HasTag(candidate)) { return true; } } return false; } /// /// Determines whether this effect contains modifications for the specified attribute. /// /// The attribute name to inspect. /// true if the effect modifies ; otherwise, false. public bool ModifiesAttribute(string attributeName) { if (modifications == null || string.IsNullOrEmpty(attributeName)) { return false; } for (int i = 0; i < modifications.Count; ++i) { AttributeModification modification = modifications[i]; if (string.Equals(modification.attribute, attributeName, StringComparison.Ordinal)) { return true; } } return false; } /// /// Copies all modifications that affect the specified attribute into the provided buffer. /// /// The attribute to filter by. /// The destination buffer. Existing entries are preserved. /// The number of modifications added to . public List GetModifications( string attributeName, List buffer = null ) { buffer ??= new List(); buffer.Clear(); if (modifications == null || string.IsNullOrEmpty(attributeName)) { return buffer; } for (int i = 0; i < modifications.Count; ++i) { AttributeModification modification = modifications[i]; if (string.Equals(modification.attribute, attributeName, StringComparison.Ordinal)) { buffer.Add(modification); } } return buffer; } /// /// Converts this effect to a JSON string representation including all modifications, tags, and cosmetic effects. /// /// A JSON string representing this effect. public override string ToString() { string[] cosmeticEffectNames = BuildCosmeticEffectNames(); return new { Description = HumanReadableDescription, CosmeticEffects = cosmeticEffectNames, modifications, durationType, duration, tags = effectTags, }.ToJson(); } private string[] BuildCosmeticEffectNames() { if (cosmeticEffects == null || cosmeticEffects.Count == 0) { return Array.Empty(); } using PooledResource> namesLease = Buffers.List.Get( out List names ); { for (int i = 0; i < cosmeticEffects.Count; i++) { CosmeticEffectData effect = cosmeticEffects[i]; if (effect == null) { continue; } string effectName = effect.name; if (effectName.Length == 0) { continue; } names.Add(effectName); } if (names.Count == 0) { return Array.Empty(); } return names.ToArray(); } } private string BuildDescription() { if (modifications == null) { return nameof(AttributeEffect); } using PooledResource stringBuilderBuffer = Buffers.StringBuilder.Get( out StringBuilder descriptionBuilder ); for (int i = 0; i < modifications.Count; ++i) { AttributeModification modification = modifications[i]; switch (modification.action) { case ModificationAction.Addition: { if (modification.value < 0) { _ = descriptionBuilder.Append(modification.value); _ = descriptionBuilder.Append(' '); } else if (modification.value == 0) { continue; } else { _ = descriptionBuilder.AppendFormat("+{0} ", modification.value); } break; } case ModificationAction.Multiplication: { if (modification.value < 1) { _ = descriptionBuilder.AppendFormat( "-{0}% ", (1 - modification.value) * 100 ); } // ReSharper disable once CompareOfFloatsByEqualityOperator else if (modification.value == 1) { continue; } else { _ = descriptionBuilder.AppendFormat( "+{0}% ", (modification.value - 1) * 100 ); } break; } case ModificationAction.Override: { _ = descriptionBuilder.AppendFormat("{0} ", modification.value); break; } default: { throw new InvalidEnumArgumentException( nameof(modification.value), (int)modification.value, typeof(ModificationAction) ); } } _ = descriptionBuilder.Append(modification.attribute.ToPascalCase(" ")); if (i < modifications.Count - 1) { _ = descriptionBuilder.Append(", "); } } return descriptionBuilder.ToString(); } internal EffectStackKey GetStackKey() { if (stackGroup == EffectStackGroup.CustomKey && !string.IsNullOrEmpty(stackGroupKey)) { return EffectStackKey.CreateCustom(stackGroupKey); } return EffectStackKey.CreateReference(this); } /// /// Determines whether this effect is equal to another effect by comparing all fields. /// This is needed because deserialization creates new instances, so reference equality is insufficient. /// /// The effect to compare with. /// true if all fields match; otherwise, false. public bool Equals(AttributeEffect other) { if (ReferenceEquals(this, other)) { return true; } if (other == null) { return false; } if (!string.Equals(name, other.name)) { return false; } if (durationType != other.durationType) { return false; } // ReSharper disable once CompareOfFloatsByEqualityOperator if (duration != other.duration) { return false; } if (resetDurationOnReapplication != other.resetDurationOnReapplication) { return false; } if (modifications == null) { if (other.modifications != null) { return false; } } else if (other.modifications == null) { return false; } else { if (modifications.Count != other.modifications.Count) { return false; } for (int i = 0; i < modifications.Count; ++i) { if (modifications[i] != other.modifications[i]) { return false; } } } if (effectTags == null) { if (other.effectTags != null) { return false; } } else if (other.effectTags == null) { return false; } else { if (effectTags.Count != other.effectTags.Count) { return false; } for (int i = 0; i < effectTags.Count; ++i) { if ( !string.Equals(effectTags[i], other.effectTags[i], StringComparison.Ordinal) ) { return false; } } } if (cosmeticEffects == null) { if (other.cosmeticEffects != null) { return false; } } else if (other.cosmeticEffects == null) { return false; } else { if (cosmeticEffects.Count != other.cosmeticEffects.Count) { return false; } for (int i = 0; i < cosmeticEffects.Count; ++i) { if (!Equals(cosmeticEffects[i], other.cosmeticEffects[i])) { return false; } } } return true; } /// /// Determines whether this effect equals the specified object. /// /// The object to compare with. /// true if the object is an AttributeEffect with equal values; otherwise, false. public override bool Equals(object obj) { return ReferenceEquals(this, obj) || obj is AttributeEffect other && Equals(other); } /// /// Returns the hash code for this effect based on its configuration. /// /// A hash code combining counts of modifications, tags, and cosmetic effects. public override int GetHashCode() { return Objects.HashCode( modifications?.Count, durationType, duration, resetDurationOnReapplication, effectTags?.Count, cosmeticEffects?.Count ); } } }