// 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 Core.Extension;
using UnityEngine;
///
/// Tag system for gameplay state: applies, counts, and queries string-based tags on a GameObject.
/// Used to represent transient states (stunned, poisoned) and effect categories without coupling to specific effects.
///
///
///
/// Why tags? Tags decouple “what is active” from “what applied it.” Systems can ask
/// “is Stunned?” or “has any of X,Y?” without caring which effect created the state.
/// This enables clean gating (e.g., block movement while Stunned) and cross‑system coordination.
///
///
/// Counting semantics: TagHandler maintains a reference count per tag. Multiple effects can apply the
/// same tag concurrently; the tag remains active until its count returns to 0. This solves common issues
/// where removing one source would accidentally clear the state still required by another effect.
///
///
/// Integration: The coordinates tag application/removal via
/// and .
/// Instant effects can call since no handle exists.
///
///
/// Benefits:
/// - Decoupled state queries across systems (AI, input, animation)
/// - Safe stacking via counts (no premature clears)
/// - Lightweight string keys with event notifications for UI/FX
/// - Optimized overloads for common collection types
///
///
/// Usage examples:
///
/// TagHandler tags = gameObject.GetComponent<TagHandler>();
///
/// // Querying
/// if (tags.HasTag("Stunned")) { /* disable input */ }
/// if (tags.HasAnyTag(new [] { "Frozen", "Stunned" })) { /* play break-free anim */ }
///
/// // Manual application (advanced; normally applied via EffectHandler)
/// tags.ApplyTag("Poisoned");
/// tags.RemoveTag("Poisoned", allInstances: false);
///
/// // Events for UI/telemetry
/// tags.OnTagAdded += tag => Debug.Log($"+{tag}");
/// tags.OnTagRemoved += tag => Debug.Log($"-{tag}");
/// tags.OnTagCountChanged += (tag, count) => Debug.Log($"{tag}: {count}");
///
///
///
/// Tips:
/// - Keep tag strings consistent (consider central constants to avoid typos).
/// - Prefer using AttributeEffects to drive tags rather than calling ApplyTag/RemoveTag directly.
/// - Use for perf‑critical code.
///
///
[DisallowMultipleComponent]
public sealed class TagHandler : MonoBehaviour
{
///
/// Invoked when a tag is first applied (count goes from 0 to 1).
///
public event Action OnTagAdded;
///
/// Invoked when a tag is completely removed (count goes from 1 to 0).
///
public event Action OnTagRemoved;
///
/// Invoked when a tag's count changes but remains above 0.
/// Provides the tag name and the new count.
///
public event Action OnTagCountChanged;
///
/// Gets a read-only collection of all currently active tags (tags with count > 0).
///
public IReadOnlyCollection Tags => _tagCount.Keys;
[SerializeField]
private List _initialEffectTags = new();
private readonly Dictionary _tagCount = new(StringComparer.Ordinal);
private readonly Dictionary _effectHandles = new();
private void Awake()
{
if (_initialEffectTags is { Count: > 0 })
{
foreach (string effectTag in _initialEffectTags)
{
InternalApplyTag(effectTag);
}
}
}
///
/// Checks whether the specified tag is currently active (has a count > 0).
///
/// The tag to check for.
/// true if the tag is active; otherwise, false. Returns false for null or empty strings.
public bool HasTag(string effectTag)
{
if (string.IsNullOrEmpty(effectTag))
{
return false;
}
return _tagCount.ContainsKey(effectTag);
}
///
/// Checks whether any of the specified tags are currently active.
/// Optimized for different collection types with specialized implementations.
///
/// The collection of tags to check.
/// true if any of the tags are active; otherwise, false.
public bool HasAnyTag(IEnumerable effectTags)
{
switch (effectTags)
{
case IReadOnlyList list:
{
return HasAnyTag(list);
}
case HashSet hashSet:
{
foreach (string effectTag in hashSet)
{
if (string.IsNullOrEmpty(effectTag))
{
continue;
}
if (_tagCount.ContainsKey(effectTag))
{
return true;
}
}
return false;
}
case SortedSet sortedSet:
{
foreach (string effectTag in sortedSet)
{
if (string.IsNullOrEmpty(effectTag))
{
continue;
}
if (_tagCount.ContainsKey(effectTag))
{
return true;
}
}
return false;
}
case Queue queue:
{
foreach (string effectTag in queue)
{
if (string.IsNullOrEmpty(effectTag))
{
continue;
}
if (_tagCount.ContainsKey(effectTag))
{
return true;
}
}
return false;
}
case Stack stack:
{
foreach (string effectTag in stack)
{
if (string.IsNullOrEmpty(effectTag))
{
continue;
}
if (_tagCount.ContainsKey(effectTag))
{
return true;
}
}
return false;
}
case LinkedList linkedList:
{
foreach (string effectTag in linkedList)
{
if (string.IsNullOrEmpty(effectTag))
{
continue;
}
if (_tagCount.ContainsKey(effectTag))
{
return true;
}
}
return false;
}
}
foreach (string effectTag in effectTags)
{
if (string.IsNullOrEmpty(effectTag))
{
continue;
}
if (_tagCount.ContainsKey(effectTag))
{
return true;
}
}
return false;
}
///
/// Checks whether any of the specified tags are currently active.
/// Optimized for IReadOnlyList with index-based iteration.
///
/// The list of tags to check.
/// true if any of the tags are active; otherwise, false.
public bool HasAnyTag(IReadOnlyList effectTags)
{
for (int i = 0; i < effectTags.Count; ++i)
{
string effectTag = effectTags[i];
if (string.IsNullOrEmpty(effectTag))
{
continue;
}
if (_tagCount.ContainsKey(effectTag))
{
return true;
}
}
return false;
}
///
/// Checks whether all of the specified tags are currently active.
/// Optimized for different collection types with specialized implementations.
///
/// The collection of tags to check.
/// true if all tags are active; otherwise, false. Returns false when is null.
public bool HasAllTags(IEnumerable effectTags)
{
if (effectTags == null)
{
return false;
}
switch (effectTags)
{
case IReadOnlyList list:
{
return HasAllTags(list);
}
case HashSet hashSet:
{
foreach (string effectTag in hashSet)
{
if (string.IsNullOrEmpty(effectTag))
{
continue;
}
if (!_tagCount.ContainsKey(effectTag))
{
return false;
}
}
return true;
}
case SortedSet sortedSet:
{
foreach (string effectTag in sortedSet)
{
if (string.IsNullOrEmpty(effectTag))
{
continue;
}
if (!_tagCount.ContainsKey(effectTag))
{
return false;
}
}
return true;
}
case Queue queue:
{
foreach (string effectTag in queue)
{
if (string.IsNullOrEmpty(effectTag))
{
continue;
}
if (!_tagCount.ContainsKey(effectTag))
{
return false;
}
}
return true;
}
case Stack stack:
{
foreach (string effectTag in stack)
{
if (string.IsNullOrEmpty(effectTag))
{
continue;
}
if (!_tagCount.ContainsKey(effectTag))
{
return false;
}
}
return true;
}
case LinkedList linkedList:
{
foreach (string effectTag in linkedList)
{
if (string.IsNullOrEmpty(effectTag))
{
continue;
}
if (!_tagCount.ContainsKey(effectTag))
{
return false;
}
}
return true;
}
}
foreach (string effectTag in effectTags)
{
if (string.IsNullOrEmpty(effectTag))
{
continue;
}
if (!_tagCount.ContainsKey(effectTag))
{
return false;
}
}
return true;
}
///
/// Checks whether all of the specified tags are active.
/// Optimized for IReadOnlyList with index-based iteration.
///
/// The list of tags to check.
/// true if all of the tags are active, or if the list is empty; otherwise, false.
public bool HasAllTags(IReadOnlyList effectTags)
{
if (effectTags == null)
{
return false;
}
for (int i = 0; i < effectTags.Count; ++i)
{
string effectTag = effectTags[i];
if (string.IsNullOrEmpty(effectTag))
{
continue;
}
if (!_tagCount.ContainsKey(effectTag))
{
return false;
}
}
return true;
}
///
/// Determines whether none of the specified tags are active.
///
/// The collection of tags to inspect.
///
/// true when the collection is null, empty, or every tag is currently inactive; otherwise, false.
///
///
///
/// if (tagHandler.HasNoneOfTags(new[] { "Stunned", "Frozen" }))
/// {
/// EnablePlayerInput();
/// }
///
///
public bool HasNoneOfTags(IEnumerable effectTags)
{
if (effectTags == null)
{
return true;
}
return !HasAnyTag(effectTags);
}
///
/// Determines whether none of the specified tags are active.
///
/// The list of tags to inspect.
///
/// true when the list is null, empty, or every tag is currently inactive; otherwise, false.
///
public bool HasNoneOfTags(IReadOnlyList effectTags)
{
if (effectTags == null)
{
return true;
}
return !HasAnyTag(effectTags);
}
///
/// Attempts to retrieve the active instance count for the specified tag.
///
/// The tag whose count should be retrieved.
///
/// When this method returns, contains the active count for the tag (cast to ) if found; otherwise, zero.
///
/// true if the tag is currently tracked; otherwise, false.
///
///
/// if (tagHandler.TryGetTagCount("Poisoned", out int stacks) && stacks >= 3)
/// {
/// TriggerCriticalWarning();
/// }
///
///
public bool TryGetTagCount(string effectTag, out int count)
{
if (string.IsNullOrEmpty(effectTag))
{
count = default;
return false;
}
if (_tagCount.TryGetValue(effectTag, out uint uintCount))
{
count = unchecked((int)uintCount);
return true;
}
count = default;
return false;
}
///
/// Retrieves the set of currently active tags into an optional buffer.
///
///
/// Optional list to populate. When null, a new list is created. The buffer is cleared before population.
///
/// The populated buffer containing all active tags.
///
///
/// List<string> activeTags = tagHandler.GetActiveTags(_reusableTagBuffer);
/// if (activeTags.Contains("Rooted"))
/// {
/// DisableMovement();
/// }
///
///
public List GetActiveTags(List buffer = null)
{
List target = buffer;
if (target != null)
{
target.Clear();
}
if (_tagCount.Count == 0)
{
return target ?? new List(0);
}
if (target == null)
{
target = new List(_tagCount.Count);
}
else if (target.Capacity < _tagCount.Count)
{
target.Capacity = _tagCount.Count;
}
foreach (KeyValuePair entry in _tagCount)
{
if (entry.Value == 0)
{
continue;
}
target.Add(entry.Key);
}
return target;
}
///
/// Collects all active effect handles that currently contribute the specified tag.
///
/// The tag to query.
///
/// Optional list to populate. When null, a new list is created. The buffer is cleared before population.
///
/// The populated buffer containing matching effect handles.
///
///
/// List<EffectHandle> handles = tagHandler.GetHandlesWithTag("Burning", _handleBuffer);
/// foreach (EffectHandle handle in handles)
/// {
/// effectHandler.RemoveEffect(handle);
/// }
///
///
public List GetHandlesWithTag(
string effectTag,
List buffer = null
)
{
if (string.IsNullOrEmpty(effectTag))
{
return buffer ?? new List(0);
}
List target = buffer;
if (target != null)
{
target.Clear();
}
if (_effectHandles.Count == 0)
{
return target ?? new List(0);
}
int estimatedCapacity = Math.Min(_effectHandles.Count, 8);
foreach (EffectHandle handle in _effectHandles.Values)
{
if (
handle.effect.effectTags != null
&& handle.effect.effectTags.Contains(effectTag)
)
{
target ??= new List(estimatedCapacity);
target.Add(handle);
}
}
return target ?? new List(0);
}
///
/// Applies a tag, incrementing its count. If the tag is new, raises .
/// Otherwise, raises .
///
/// The tag to apply.
public void ApplyTag(string effectTag)
{
InternalApplyTag(effectTag);
}
///
/// Removes all instances of the specified tag and returns the contributing effect handles.
///
/// The tag to remove.
///
/// Optional list that receives the handles whose effects applied .
/// When null, a new list is created. The buffer is cleared before population.
///
///
/// The populated buffer of handles whose tags were removed. The buffer is empty when the tag was not active.
///
///
///
/// List<EffectHandle> dispelled = tagHandler.RemoveTag("Stunned", _handles);
/// foreach (EffectHandle handle in dispelled)
/// {
/// NotifyDispel(handle);
/// }
///
///
public List RemoveTag(string effectTag, List buffer = null)
{
if (string.IsNullOrEmpty(effectTag))
{
if (buffer != null)
{
buffer.Clear();
return buffer;
}
return new List(0);
}
List target = buffer;
if (target != null)
{
target.Clear();
}
if (_effectHandles.Count > 0)
{
int estimatedCapacity = Math.Min(_effectHandles.Count, 8);
foreach (EffectHandle handle in _effectHandles.Values)
{
if (
handle.effect.effectTags != null
&& handle.effect.effectTags.Contains(effectTag)
)
{
target ??= new List(estimatedCapacity);
target.Add(handle);
}
}
if (target != null)
{
foreach (EffectHandle handle in target)
{
ForceRemoveTags(handle);
}
}
}
InternalRemoveTag(effectTag, allInstances: true);
return target ?? new List(0);
}
///
/// Provides an allocation-free view of handles contributing the specified tag.
///
/// The tag to query.
public HandleEnumerable EnumerateHandlesWithTag(string effectTag)
{
if (string.IsNullOrEmpty(effectTag) || _effectHandles.Count == 0)
{
return HandleEnumerable.Empty;
}
return new HandleEnumerable(_effectHandles.GetEnumerator(), effectTag);
}
///
/// Applies all tags from an effect handle's effect.
/// Tracks the handle to support later removal via .
///
/// The effect handle containing tags to apply.
public void ForceApplyTags(EffectHandle handle)
{
long id = handle.id;
if (!_effectHandles.TryAdd(id, handle))
{
return;
}
ApplyEffectTags(handle.effect);
}
///
/// Applies all tags from an effect without tracking a handle.
/// Used for instant effects that don't need removal tracking.
///
/// The effect containing tags to apply.
public void ForceApplyEffect(AttributeEffect effect)
{
ApplyEffectTags(effect);
}
///
/// Removes all tags that were applied by the specified effect handle.
///
/// The effect handle whose tags should be removed.
/// true if the handle was found and tags were removed; otherwise, false.
public bool ForceRemoveTags(EffectHandle handle)
{
long id = handle.id;
if (!_effectHandles.Remove(id, out EffectHandle appliedHandle))
{
return false;
}
RemoveEffectTags(appliedHandle.effect);
return true;
}
private void InternalApplyTag(string effectTag)
{
uint currentCount = _tagCount.AddOrUpdate(
effectTag,
_ => 1U,
(_, existing) => existing + 1
);
if (currentCount == 1)
{
OnTagAdded?.Invoke(effectTag);
}
else
{
OnTagCountChanged?.Invoke(effectTag, currentCount);
}
}
private void InternalRemoveTag(string effectTag, bool allInstances)
{
if (!_tagCount.TryGetValue(effectTag, out uint count))
{
return;
}
if (count != 0)
{
if (!allInstances)
{
--count;
}
else
{
count = 0;
}
}
if (count == 0)
{
_ = _tagCount.Remove(effectTag);
OnTagRemoved?.Invoke(effectTag);
}
else
{
_tagCount[effectTag] = count;
OnTagCountChanged?.Invoke(effectTag, count);
}
}
private void ApplyEffectTags(AttributeEffect effect)
{
if (effect.effectTags == null)
{
return;
}
foreach (string effectTag in effect.effectTags)
{
InternalApplyTag(effectTag);
}
}
private void RemoveEffectTags(AttributeEffect effect)
{
if (effect.effectTags == null)
{
return;
}
foreach (string effectTag in effect.effectTags)
{
InternalRemoveTag(effectTag, allInstances: false);
}
}
///
/// Provides an allocation-free enumerable view of the currently active tags.
///
/// A struct enumerable that yields each active tag exactly once.
public ActiveTagEnumerable EnumerateActiveTags()
{
if (_tagCount.Count == 0)
{
return ActiveTagEnumerable.Empty;
}
return new ActiveTagEnumerable(_tagCount);
}
///
/// Struct-backed enumerable over the active tags without additional allocations.
///
public readonly struct ActiveTagEnumerable
{
private readonly Dictionary _source;
internal ActiveTagEnumerable(Dictionary source)
{
_source = source;
}
public static ActiveTagEnumerable Empty => new ActiveTagEnumerable(null);
public ActiveTagEnumerator GetEnumerator()
{
if (_source == null || _source.Count == 0)
{
return default;
}
return new ActiveTagEnumerator(_source.GetEnumerator());
}
}
///
/// Struct-backed enumerable over effect handles that contribute a specific tag.
///
public readonly struct HandleEnumerable
{
private readonly Dictionary.Enumerator _enumerator;
private readonly string _effectTag;
private readonly bool _hasData;
internal HandleEnumerable(
Dictionary.Enumerator enumerator,
string effectTag
)
{
_enumerator = enumerator;
_effectTag = effectTag;
_hasData = true;
}
public static HandleEnumerable Empty => new HandleEnumerable(default, string.Empty);
public HandleEnumerator GetEnumerator()
{
if (!_hasData || string.IsNullOrEmpty(_effectTag))
{
return default;
}
return new HandleEnumerator(_enumerator, _effectTag);
}
}
///
/// Enumerator that filters effect handles by tag without temporary lists.
///
public struct HandleEnumerator
{
private Dictionary.Enumerator _enumerator;
private readonly string _effectTag;
private bool _hasEnumerator;
private EffectHandle _current;
internal HandleEnumerator(
Dictionary.Enumerator enumerator,
string effectTag
)
{
_enumerator = enumerator;
_effectTag = effectTag;
_hasEnumerator = true;
_current = default;
}
public readonly EffectHandle Current => _current;
public bool MoveNext()
{
if (!_hasEnumerator)
{
return false;
}
while (_enumerator.MoveNext())
{
EffectHandle handle = _enumerator.Current.Value;
if (
handle.effect?.effectTags != null
&& handle.effect.effectTags.Contains(_effectTag)
)
{
_current = handle;
return true;
}
}
_hasEnumerator = false;
_current = default;
return false;
}
}
///
/// Enumerator that skips tags whose counts have dropped to zero.
///
public struct ActiveTagEnumerator
{
private Dictionary.Enumerator _enumerator;
private bool _hasEnumerator;
private string _current;
internal ActiveTagEnumerator(Dictionary.Enumerator enumerator)
{
_enumerator = enumerator;
_hasEnumerator = true;
_current = string.Empty;
}
public readonly string Current => _current ?? string.Empty;
public bool MoveNext()
{
if (!_hasEnumerator)
{
return false;
}
while (_enumerator.MoveNext())
{
KeyValuePair entry = _enumerator.Current;
if (entry.Value == 0)
{
continue;
}
_current = entry.Key;
return true;
}
_hasEnumerator = false;
_current = string.Empty;
return false;
}
}
}
}