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