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