com.wallstop-studios.unity-helpers 2.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/.github/workflows/format-on-demand.yml +2 -2
  2. package/.github/workflows/markdown-json.yml +1 -1
  3. package/.github/workflows/npm-publish.yml +1 -1
  4. package/.github/workflows/prettier-autofix.yml +4 -4
  5. package/.github/workflows/yaml-format-lint.yml +1 -1
  6. package/Docs/EFFECTS_SYSTEM.md +1316 -0
  7. package/{EFFECTS_SYSTEM_TUTORIAL.md → Docs/EFFECTS_SYSTEM_TUTORIAL.md} +1 -1
  8. package/{GETTING_STARTED.md → Docs/GETTING_STARTED.md} +10 -8
  9. package/{GLOSSARY.md → Docs/GLOSSARY.md} +4 -4
  10. package/Docs/HELPER_UTILITIES.md +885 -0
  11. package/Docs/HELPER_UTILITIES.md.meta +7 -0
  12. package/{INDEX.md → Docs/INDEX.md} +107 -62
  13. package/Docs/MATH_AND_EXTENSIONS.md +1039 -0
  14. package/{RANDOM_PERFORMANCE.md → Docs/RANDOM_PERFORMANCE.md} +15 -15
  15. package/{RELATIONAL_COMPONENTS.md → Docs/RELATIONAL_COMPONENTS.md} +21 -3
  16. package/{SPATIAL_TREES_2D_GUIDE.md → Docs/SPATIAL_TREES_2D_GUIDE.md} +2 -2
  17. package/{SPATIAL_TREES_3D_GUIDE.md → Docs/SPATIAL_TREES_3D_GUIDE.md} +1 -1
  18. package/{SPATIAL_TREE_2D_PERFORMANCE.md → Docs/SPATIAL_TREE_2D_PERFORMANCE.md} +64 -64
  19. package/{SPATIAL_TREE_3D_PERFORMANCE.md → Docs/SPATIAL_TREE_3D_PERFORMANCE.md} +64 -64
  20. package/Docs/UTILITY_COMPONENTS.md +906 -0
  21. package/Docs/UTILITY_COMPONENTS.md.meta +7 -0
  22. package/Docs/VISUAL_COMPONENTS.md +337 -0
  23. package/Docs/VISUAL_COMPONENTS.md.meta +7 -0
  24. package/Editor/Sprites/AnimationCopier.cs +3 -3
  25. package/README.md +69 -62
  26. package/Runtime/AssemblyInfo.cs +2 -0
  27. package/Runtime/Core/DataStructure/KDTree3D.cs +1 -1
  28. package/Runtime/Core/DataStructure/OctTree3D.cs +1 -1
  29. package/Runtime/Core/Extension/AsyncOperationExtensions.cs +122 -0
  30. package/Runtime/Core/Helper/Logging/UnityLogTagFormatter.cs +76 -90
  31. package/Runtime/Core/Serialization/ProtobufUnitySurrogates.cs +24 -29
  32. package/Runtime/Integrations/Reflex/AssemblyInfo.cs +7 -0
  33. package/Runtime/Integrations/Reflex/AssemblyInfo.cs.meta +11 -0
  34. package/Runtime/Integrations/Reflex/ContainerRelationalExtensions.cs +198 -0
  35. package/Runtime/Integrations/Reflex/ContainerRelationalExtensions.cs.meta +11 -0
  36. package/Runtime/Integrations/Reflex/RelationalComponentsInstaller.cs +86 -0
  37. package/Runtime/Integrations/Reflex/RelationalComponentsInstaller.cs.meta +11 -0
  38. package/Runtime/Integrations/Reflex/RelationalReflexSceneBootstrapper.cs +316 -0
  39. package/Runtime/Integrations/Reflex/RelationalReflexSceneBootstrapper.cs.meta +11 -0
  40. package/Runtime/Integrations/Reflex/RelationalSceneAssignmentOptions.cs +86 -0
  41. package/Runtime/Integrations/Reflex/RelationalSceneAssignmentOptions.cs.meta +11 -0
  42. package/Runtime/Integrations/Reflex/WallstopStudios.UnityHelpers.Integration.Reflex.asmdef +20 -0
  43. package/Runtime/Integrations/Reflex/WallstopStudios.UnityHelpers.Integration.Reflex.asmdef.meta +7 -0
  44. package/Runtime/Integrations/Reflex.meta +8 -0
  45. package/Runtime/Utils/ScriptableObjectSingleton.cs +1 -1
  46. package/Samples~/DI - Reflex/README.md +527 -0
  47. package/Samples~/DI - Reflex/README.md.meta +7 -0
  48. package/Samples~/DI - Reflex/Scripts/ReflexPaletteService.cs +36 -0
  49. package/Samples~/DI - Reflex/Scripts/ReflexPaletteService.cs.meta +11 -0
  50. package/Samples~/DI - Reflex/Scripts/ReflexRelationalConsumer.cs +79 -0
  51. package/Samples~/DI - Reflex/Scripts/ReflexRelationalConsumer.cs.meta +11 -0
  52. package/Samples~/DI - Reflex/Scripts/ReflexSampleInstaller.cs +30 -0
  53. package/Samples~/DI - Reflex/Scripts/ReflexSampleInstaller.cs.meta +11 -0
  54. package/Samples~/DI - Reflex/Scripts/ReflexSpawner.cs +79 -0
  55. package/Samples~/DI - Reflex/Scripts/ReflexSpawner.cs.meta +11 -0
  56. package/Samples~/DI - Reflex/Scripts/Samples.UnityHelpers.DI.Reflex.asmdef +26 -0
  57. package/Samples~/DI - Reflex/Scripts/Samples.UnityHelpers.DI.Reflex.asmdef.meta +9 -0
  58. package/Samples~/DI - Reflex/Scripts.meta +8 -0
  59. package/Samples~/DI - Reflex.meta +8 -0
  60. package/Samples~/DI - VContainer/README.md +6 -5
  61. package/Samples~/DI - Zenject/README.md +6 -5
  62. package/Tests/Editor/Core/Attributes/RelationalComponentAssignerTests.cs +29 -31
  63. package/Tests/Editor/Integrations/Reflex/ReflexIntegrationCompilationTests.cs +41 -0
  64. package/Tests/Editor/Integrations/Reflex/ReflexIntegrationCompilationTests.cs.meta +11 -0
  65. package/Tests/Editor/Integrations/Reflex/WallstopStudios.UnityHelpers.Tests.Editor.Reflex.asmdef +27 -0
  66. package/Tests/Editor/Integrations/Reflex/WallstopStudios.UnityHelpers.Tests.Editor.Reflex.asmdef.meta +7 -0
  67. package/Tests/Editor/Integrations/Reflex.meta +8 -0
  68. package/Tests/Editor/Integrations/VContainer/VContainerRelationalEntryPointTests.cs +15 -16
  69. package/Tests/Editor/Integrations/VContainer/VContainerRelationalHelpersTests.cs +7 -13
  70. package/Tests/Editor/Integrations/Zenject/ZenjectRelationalHelpersTests.cs +7 -11
  71. package/Tests/Editor/Integrations/Zenject/ZenjectRelationalInitializerTests.cs +19 -21
  72. package/Tests/Editor/PersistentDirectorySettingsTests.cs +0 -1
  73. package/Tests/Editor/Sprites/AnimationCopierFilterTests.cs +0 -1
  74. package/Tests/Editor/Sprites/AnimationViewerWindowTests.cs +2 -2
  75. package/Tests/Editor/Tools/ImageBlurToolTests.cs +1 -1
  76. package/Tests/Editor/Utils/CommonTestBase.cs +17 -0
  77. package/Tests/Editor/Utils/ScriptableObjectSingletonCreatorTests.cs +1 -1
  78. package/Tests/Runtime/Extensions/AsyncOperationExtensionsTests.cs +179 -0
  79. package/Tests/Runtime/Extensions/RandomExtensionTests.cs +55 -0
  80. package/Tests/Runtime/Extensions/UnityLogTagFormatterEdgeTests.cs +84 -0
  81. package/Tests/Runtime/Integrations/Reflex/RelationalComponentsReflexTests.cs +445 -0
  82. package/Tests/Runtime/Integrations/Reflex/RelationalComponentsReflexTests.cs.meta +11 -0
  83. package/Tests/Runtime/Integrations/Reflex/WallstopStudios.UnityHelpers.Tests.Runtime.Reflex.asmdef +28 -0
  84. package/Tests/Runtime/Integrations/Reflex/WallstopStudios.UnityHelpers.Tests.Runtime.Reflex.asmdef.meta +7 -0
  85. package/Tests/Runtime/Integrations/Reflex.meta +8 -0
  86. package/Tests/Runtime/Integrations/VContainer/RelationalComponentsVContainerTests.cs +24 -29
  87. package/Tests/Runtime/Integrations/VContainer/RelationalObjectPoolsVContainerTests.cs +8 -3
  88. package/Tests/Runtime/Integrations/Zenject/RelationalComponentsZenjectTests.cs +10 -20
  89. package/Tests/Runtime/Performance/RandomPerformanceTests.cs +1 -1
  90. package/Tests/Runtime/Performance/SpatialTree2DPerformanceTests.cs +1 -1
  91. package/Tests/Runtime/Performance/SpatialTree3DPerformanceTests.cs +1 -1
  92. package/Tests/Runtime/Serialization/JsonRoundtripComprehensiveTests.cs +4 -9
  93. package/Tests/Runtime/Serialization/ProtoRoundtripComprehensiveTests.cs +13 -13
  94. package/Tests/Runtime/TestUtils/CommonTestBase.cs +11 -0
  95. package/Tests/Runtime/TestUtils/ReflexTestSupport.cs +111 -0
  96. package/Tests/Runtime/TestUtils/ReflexTestSupport.cs.meta +12 -0
  97. package/Tests/Runtime/Utils/MatchColliderToSpriteTests.cs +4 -4
  98. package/Tests/TestUtils.meta +8 -0
  99. package/package.json +6 -1
  100. package/EFFECTS_SYSTEM.md +0 -242
  101. package/MATH_AND_EXTENSIONS.md +0 -316
  102. /package/{CHANGELOG.md → Docs/CHANGELOG.md} +0 -0
  103. /package/{CHANGELOG.md.meta → Docs/CHANGELOG.md.meta} +0 -0
  104. /package/{CONTRIBUTING.md → Docs/CONTRIBUTING.md} +0 -0
  105. /package/{CONTRIBUTING.md.meta → Docs/CONTRIBUTING.md.meta} +0 -0
  106. /package/{DATA_STRUCTURES.md → Docs/DATA_STRUCTURES.md} +0 -0
  107. /package/{DATA_STRUCTURES.md.meta → Docs/DATA_STRUCTURES.md.meta} +0 -0
  108. /package/{EDITOR_TOOLS_GUIDE.md → Docs/EDITOR_TOOLS_GUIDE.md} +0 -0
  109. /package/{EDITOR_TOOLS_GUIDE.md.meta → Docs/EDITOR_TOOLS_GUIDE.md.meta} +0 -0
  110. /package/{EFFECTS_SYSTEM.md.meta → Docs/EFFECTS_SYSTEM.md.meta} +0 -0
  111. /package/{EFFECTS_SYSTEM_TUTORIAL.md.meta → Docs/EFFECTS_SYSTEM_TUTORIAL.md.meta} +0 -0
  112. /package/{GETTING_STARTED.md.meta → Docs/GETTING_STARTED.md.meta} +0 -0
  113. /package/{GLOSSARY.md.meta → Docs/GLOSSARY.md.meta} +0 -0
  114. /package/{HULLS.md → Docs/HULLS.md} +0 -0
  115. /package/{HULLS.md.meta → Docs/HULLS.md.meta} +0 -0
  116. /package/{INDEX.md.meta → Docs/INDEX.md.meta} +0 -0
  117. /package/{LICENSE.md → Docs/LICENSE.md} +0 -0
  118. /package/{LICENSE.md.meta → Docs/LICENSE.md.meta} +0 -0
  119. /package/{MATH_AND_EXTENSIONS.md.meta → Docs/MATH_AND_EXTENSIONS.md.meta} +0 -0
  120. /package/{RANDOM_PERFORMANCE.md.meta → Docs/RANDOM_PERFORMANCE.md.meta} +0 -0
  121. /package/{REFLECTION_HELPERS.md → Docs/REFLECTION_HELPERS.md} +0 -0
  122. /package/{REFLECTION_HELPERS.md.meta → Docs/REFLECTION_HELPERS.md.meta} +0 -0
  123. /package/{RELATIONAL_COMPONENTS.md.meta → Docs/RELATIONAL_COMPONENTS.md.meta} +0 -0
  124. /package/{SERIALIZATION.md → Docs/SERIALIZATION.md} +0 -0
  125. /package/{SERIALIZATION.md.meta → Docs/SERIALIZATION.md.meta} +0 -0
  126. /package/{SINGLETONS.md → Docs/SINGLETONS.md} +0 -0
  127. /package/{SINGLETONS.md.meta → Docs/SINGLETONS.md.meta} +0 -0
  128. /package/{SPATIAL_TREES_2D_GUIDE.md.meta → Docs/SPATIAL_TREES_2D_GUIDE.md.meta} +0 -0
  129. /package/{SPATIAL_TREES_3D_GUIDE.md.meta → Docs/SPATIAL_TREES_3D_GUIDE.md.meta} +0 -0
  130. /package/{SPATIAL_TREE_2D_PERFORMANCE.md.meta → Docs/SPATIAL_TREE_2D_PERFORMANCE.md.meta} +0 -0
  131. /package/{SPATIAL_TREE_3D_PERFORMANCE.md.meta → Docs/SPATIAL_TREE_3D_PERFORMANCE.md.meta} +0 -0
  132. /package/{SPATIAL_TREE_SEMANTICS.md → Docs/SPATIAL_TREE_SEMANTICS.md} +0 -0
  133. /package/{SPATIAL_TREE_SEMANTICS.md.meta → Docs/SPATIAL_TREE_SEMANTICS.md.meta} +0 -0
  134. /package/{THIRD_PARTY_NOTICES.md → Docs/THIRD_PARTY_NOTICES.md} +0 -0
  135. /package/{THIRD_PARTY_NOTICES.md.meta → Docs/THIRD_PARTY_NOTICES.md.meta} +0 -0
@@ -0,0 +1,1316 @@
1
+ # Effects, Attributes, and Tags — Deep Dive
2
+
3
+ ## TL;DR — What Problem This Solves
4
+
5
+ - **⭐ Build buff/debuff systems without writing custom code for every effect.**
6
+ - Data‑driven ScriptableObjects: designers create 100s of effects, programmers build system once.
7
+ - **Time saved: Weeks of boilerplate eliminated + designers empowered to iterate freely.**
8
+
9
+ ### ⭐ The Designer Empowerment Killer Feature
10
+
11
+ **The Problem - Hardcoded Effects:**
12
+
13
+ ```csharp
14
+ // Every buff needs its own custom MonoBehaviour:
15
+
16
+ public class HasteEffect : MonoBehaviour
17
+ {
18
+ private float duration = 5f;
19
+ private float originalSpeed;
20
+ private PlayerStats player;
21
+
22
+ void Start()
23
+ {
24
+ player = GetComponent<PlayerStats>();
25
+ originalSpeed = player.speed;
26
+ player.speed *= 1.5f; // Apply speed boost
27
+ }
28
+
29
+ void Update()
30
+ {
31
+ duration -= Time.deltaTime;
32
+ if (duration <= 0)
33
+ {
34
+ player.speed = originalSpeed; // Restore
35
+ Destroy(this);
36
+ }
37
+ }
38
+ }
39
+
40
+ // 20 effects × 50 lines each = 1000 lines of repetitive code
41
+ // Designers can't create effects without programmer
42
+ ```
43
+
44
+ **The Solution - Data-Driven:**
45
+
46
+ ```csharp
47
+ // Programmers build system once (Unity Helpers provides this):
48
+ // - AttributesComponent base class
49
+ // - EffectHandler manages application/removal
50
+ // - ScriptableObject authoring
51
+
52
+ // Designers create effects in Editor (NO CODE):
53
+ // 1. Right-click → Create → Attribute Effect
54
+ // 2. Name: "Haste"
55
+ // 3. Add modification: Speed × 1.5
56
+ // 4. Duration: 5 seconds
57
+ // 5. Done!
58
+
59
+ // Apply at runtime (one line):
60
+ target.ApplyEffect(hasteEffect);
61
+ ```
62
+
63
+ **Designer Workflow:**
64
+
65
+ 1. Create effect asset in 30 seconds (no code)
66
+ 2. Test in-game immediately
67
+ 3. Tweak values and iterate freely
68
+ 4. Create variations (Haste II, Haste III) by duplicating assets
69
+
70
+ **Impact:**
71
+
72
+ - **Programmer time saved**: Weeks of boilerplate → system built once
73
+ - **Designer empowerment**: Block creating 100s of effects instantly
74
+ - **Iteration speed**: Change values without code changes/recompiles
75
+ - **Maintainability**: All effects in one system vs scattered scripts
76
+
77
+ Data‑driven gameplay effects that modify stats, apply tags, and drive cosmetic presentation.
78
+
79
+ This guide explains the concepts, how they work together, authoring patterns, recipes, best practices, and FAQs.
80
+
81
+ Visuals
82
+
83
+ ![Effects Pipeline](Docs/Images/effects_pipeline.svg)
84
+
85
+ ![Attribute Resolution](Docs/Images/attribute_resolution.svg)
86
+
87
+ ## Concepts
88
+
89
+ - `Attribute` — A dynamic numeric value with a base and a calculated current value. Current value applies all active modifications.
90
+ - `AttributeModification` — Declarative change to an `Attribute`. Actions: Addition, Multiplication, Override. Applied in that order.
91
+ - `AttributeEffect` — ScriptableObject asset bundling modifications, tags, cosmetic data, and duration policy.
92
+ - `EffectHandle` — Opaque identifier for a specific application instance (for Duration/Infinite effects). Used to remove one stack.
93
+ - `AttributesComponent` — Base MonoBehaviour exposing modifiable `Attribute` fields (e.g., Health, Speed) on your character.
94
+ - `EffectHandler` — Component that applies/removes effects, tracks durations, forwards modifications to `AttributesComponent`, applies tags and cosmetics.
95
+ - `TagHandler` — Counts and queries string tags for gating gameplay (e.g., "Stunned"). Removes tags only when all sources are gone.
96
+ - `CosmeticEffectData` — Prefab‑like container with `CosmeticEffectComponent` behaviours; reused or instanced per effect application.
97
+
98
+ ## How It Works
99
+
100
+ 1. You author an `AttributeEffect` with modifications, tags, cosmetics, and duration.
101
+ 2. You apply it to a GameObject: `EffectHandle? handle = target.ApplyEffect(effect);`
102
+ 3. `EffectHandler` will:
103
+ - Create an `EffectHandle` (for Duration/Infinite) and track expiration
104
+ - Apply tags via `TagHandler` (counted; multiple sources safe)
105
+ - Apply cosmetic behaviours (`CosmeticEffectData`)
106
+ - Forward `AttributeModification`s to all `AttributesComponent`s on the GameObject
107
+ 4. On removal (manual or expiration), all of the above are cleanly reversed.
108
+
109
+ Instant effects modify base values permanently and return `null` instead of a handle.
110
+
111
+ ## Authoring Guide
112
+
113
+ 1. Define stats:
114
+
115
+ ```csharp
116
+ public class CharacterStats : AttributesComponent
117
+ {
118
+ public Attribute Health = 100f;
119
+ public Attribute Speed = 5f;
120
+ public Attribute Defense = 10f;
121
+ }
122
+ ```
123
+
124
+ 1. Create an `AttributeEffect` asset (Project view → Create → Wallstop Studios → Unity Helpers → Attribute Effect):
125
+
126
+ - modifications: e.g., `{ attribute: "Speed", action: Multiplication, value: 1.5f }`
127
+ - durationType: `Duration` with `duration = 5`
128
+ - resetDurationOnReapplication: true to refresh timer on reapply
129
+ - effectTags: e.g., `[ "Haste" ]`
130
+ - cosmeticEffects: prefab with `CosmeticEffectData` + `CosmeticEffectComponent` scripts
131
+
132
+ 1. Apply/remove at runtime:
133
+
134
+ ```csharp
135
+ GameObject player = ...;
136
+ AttributeEffect haste = ...; // ScriptableObject reference
137
+ EffectHandle? handle = player.ApplyEffect(haste);
138
+ // ... later ...
139
+ if (handle.HasValue)
140
+ {
141
+ player.RemoveEffect(handle.Value);
142
+ }
143
+ ```
144
+
145
+ 1. Query tags anywhere:
146
+
147
+ ```csharp
148
+ if (player.HasTag("Stunned"))
149
+ {
150
+ // Disable input, play animation, etc.
151
+ }
152
+ ```
153
+
154
+ ## Recipes
155
+
156
+ ### 1) Buff with % Speed for 5s (refreshable)
157
+
158
+ - Effect: Multiplication `Speed *= 1.5f`, `Duration=5`, `resetDurationOnReapplication=true`, tag `Haste`.
159
+ - Apply to extend: reapply before expiry to reset the timer.
160
+
161
+ ### 2) Poison: −5 Health instantly and "Poisoned" tag for 10s
162
+
163
+ - modifications: Addition `{ attribute: "Health", value: -5f }`
164
+ - durationType: Duration `10s`
165
+ - effectTags: `[ "Poisoned" ]`
166
+ - cosmetics: particles + UI icon
167
+
168
+ ### 3) Equipment Aura: +10 Defense while equipped
169
+
170
+ - durationType: Infinite
171
+ - modifications: Addition `{ attribute: "Defense", value: 10f }`
172
+ - Apply on equip, store handle, remove on unequip.
173
+
174
+ ### 4) One‑off Permanent Bonus
175
+
176
+ - durationType: Instant (returns null)
177
+ - modifications: Addition or Override on base value (no handle; cannot be removed).
178
+
179
+ ### 5) Stacking Multiple Instances
180
+
181
+ - Apply the same effect multiple times → multiple `EffectHandle`s; remove one handle to remove one stack.
182
+ - Use tags to gate behaviour regardless of which instance applied it.
183
+
184
+ ### 6) Shared vs Instanced Cosmetics
185
+
186
+ - In `CosmeticEffectData`, set a component’s `RequiresInstance = true` for per‑application instances (e.g., particles).
187
+ - Keep `RequiresInstance = false` for shared presenters (e.g., status icon overlay).
188
+
189
+ ## Best Practices
190
+
191
+ - Use Addition for flat changes; Multiplication for percentage changes; Override sparingly (wins last).
192
+ - Use the Attribute Metadata Cache generator to power editor dropdowns for `attribute` names and avoid typos.
193
+ - Centralize tag strings as constants to prevent mistakes and improve refactor safety.
194
+ - Prefer shared cosmetics where feasible; instantiate only when state must be isolated per application.
195
+ - If reapplication should refresh timers, set `resetDurationOnReapplication = true` on the effect.
196
+
197
+ ### Type-Safe Effect References with Enums
198
+
199
+ Instead of managing effects through inspector references or Resources.Load calls, consider using an enum-based registry for centralized, type-safe access to all your effects:
200
+
201
+ **The Pattern:**
202
+
203
+ ```csharp
204
+ // 1. Define an enum for all your effects
205
+ public enum EffectType
206
+ {
207
+ HastePotion,
208
+ StrengthBuff,
209
+ PoisonDebuff,
210
+ ShieldBuff,
211
+ FireDamageOverTime,
212
+ }
213
+
214
+ // 2. Create a centralized registry
215
+ public class EffectRegistry : ScriptableObject
216
+ {
217
+ [System.Serializable]
218
+ private class EffectEntry
219
+ {
220
+ public EffectType type;
221
+ public AttributeEffect effect;
222
+ }
223
+
224
+ [SerializeField] private EffectEntry[] effects;
225
+ private Dictionary<EffectType, AttributeEffect> effectLookup;
226
+
227
+ private void OnEnable()
228
+ {
229
+ effectLookup = effects.ToDictionary(e => e.type, e => e.effect);
230
+ }
231
+
232
+ public AttributeEffect GetEffect(EffectType type)
233
+ {
234
+ return effectLookup.TryGetValue(type, out AttributeEffect effect)
235
+ ? effect
236
+ : null;
237
+ }
238
+ }
239
+
240
+ // 3. Usage - type-safe and refactorable
241
+ public class PlayerAbilities : MonoBehaviour
242
+ {
243
+ [SerializeField] private EffectRegistry effectRegistry;
244
+
245
+ public void DrinkHastePotion()
246
+ {
247
+ // Compiler ensures this effect exists
248
+ AttributeEffect haste = effectRegistry.GetEffect(EffectType.HastePotion);
249
+ this.ApplyEffect(haste);
250
+
251
+ // Typos are caught at compile time
252
+ // effectRegistry.GetEffect(EffectType.HastPotoin); // ❌ Won't compile
253
+ }
254
+ }
255
+ ```
256
+
257
+ **Using DisplayName for Editor-Friendly Names:**
258
+
259
+ ```csharp
260
+ using System.ComponentModel;
261
+
262
+ public enum EffectType
263
+ {
264
+ [Description("Haste Potion")]
265
+ HastePotion,
266
+
267
+ [Description("Strength Buff (10s)")]
268
+ StrengthBuff,
269
+
270
+ [Description("Poison DoT")]
271
+ PoisonDebuff,
272
+
273
+ [Description("Shield (+50 Defense)")]
274
+ ShieldBuff,
275
+ }
276
+
277
+ // Custom PropertyDrawer can display Description in inspector
278
+ // Or use Unity's [InspectorName] attribute in Unity 2021.2+:
279
+ // [InspectorName("Haste Potion")] HastePotion,
280
+ ```
281
+
282
+ **Cached Name Pattern for Performance:**
283
+
284
+ If you're doing frequent lookups or displaying effect names in UI, cache the enum-to-string mappings:
285
+
286
+ ```csharp
287
+ public static class EffectTypeExtensions
288
+ {
289
+ private static readonly Dictionary<EffectType, string> DisplayNames = new()
290
+ {
291
+ { EffectType.HastePotion, "Haste Potion" },
292
+ { EffectType.StrengthBuff, "Strength Buff" },
293
+ { EffectType.PoisonDebuff, "Poison" },
294
+ { EffectType.ShieldBuff, "Shield" },
295
+ };
296
+
297
+ public static string GetDisplayName(this EffectType type)
298
+ {
299
+ return DisplayNames.TryGetValue(type, out string name)
300
+ ? name
301
+ : type.ToString();
302
+ }
303
+ }
304
+
305
+ // Usage in UI
306
+ void UpdateEffectTooltip(EffectType effectType)
307
+ {
308
+ tooltipText.text = effectType.GetDisplayName();
309
+ // No allocations, no typos, refactor-safe
310
+ }
311
+ ```
312
+
313
+ **Benefits:**
314
+
315
+ ✅ **Type safety** - Compiler catches typos and missing effects
316
+ ✅ **Refactoring** - Rename effects across entire codebase reliably
317
+ ✅ **Autocomplete** - IDE suggests all available effects
318
+ ✅ **Performance** - Dictionary lookup faster than Resources.Load
319
+ ✅ **No magic strings** - Effect references are code symbols, not brittle strings
320
+
321
+ **Drawbacks:**
322
+
323
+ ⚠️ **Centralization** - All effects must be registered in the enum and registry
324
+ ⚠️ **Designer friction** - Programmers must add enum entries for new effects
325
+ ⚠️ **Scalability** - With 100+ effects, enum becomes unwieldy (consider categories)
326
+ ⚠️ **Asset decoupling** - Effects are tied to code enum, harder to add via mods/DLC
327
+
328
+ **When to Use:**
329
+
330
+ - ✅ Small to medium projects (< 50 effects)
331
+ - ✅ Programmer-driven effect creation
332
+ - ✅ Need strong refactoring safety
333
+ - ✅ Want compile-time validation
334
+
335
+ **When to Avoid:**
336
+
337
+ - ❌ Designer-driven workflows (they can't add enum entries)
338
+ - ❌ Modding/DLC systems (effects defined outside codebase)
339
+ - ❌ Very large effect catalogs (enums become bloated)
340
+ - ❌ Rapid prototyping (slows iteration)
341
+
342
+ ---
343
+
344
+ **Integration with Unity Helpers' Built-in Enum Utilities:**
345
+
346
+ This package already includes high-performance `EnumDisplayNameAttribute` and `ToCachedName()` extensions (see `EnumExtensions.cs:437-478`). You can leverage these for optimal performance:
347
+
348
+ ```csharp
349
+ using WallstopStudios.UnityHelpers.Core.Attributes;
350
+ using WallstopStudios.UnityHelpers.Core.Extension;
351
+
352
+ public enum EffectType
353
+ {
354
+ [EnumDisplayName("Haste Potion")]
355
+ HastePotion,
356
+
357
+ [EnumDisplayName("Strength Buff (10s)")]
358
+ StrengthBuff,
359
+
360
+ [EnumDisplayName("Poison DoT")]
361
+ PoisonDebuff,
362
+ }
363
+
364
+ // High-performance cached display name (zero allocation after first call)
365
+ void UpdateEffectTooltip(EffectType effectType)
366
+ {
367
+ tooltipText.text = effectType.ToDisplayName(); // Uses EnumDisplayNameCache<T>
368
+ }
369
+
370
+ // Or use ToCachedName() for the enum's field name without attributes
371
+ void LogEffect(EffectType effectType)
372
+ {
373
+ Debug.Log($"Applied: {effectType.ToCachedName()}"); // Uses EnumNameCache<T>
374
+ }
375
+ ```
376
+
377
+ **Performance characteristics:**
378
+
379
+ - `ToDisplayName()`: O(1) lookup, zero allocations (array-based for enums ≤256 values)
380
+ - `ToCachedName()`: O(1) lookup, zero allocations, thread-safe with concurrent dictionary
381
+ - Both use aggressive inlining and avoid boxing
382
+
383
+ This eliminates the need to manually maintain a `DisplayNames` dictionary as shown in the earlier example—the package already provides optimized caching infrastructure.
384
+
385
+ ## FAQ
386
+
387
+ Q: Why didn’t I get an `EffectHandle`?
388
+
389
+ - Instant effects modify the base value permanently and do not return a handle (`null`). Duration/Infinite do.
390
+
391
+ Q: Do modifications stack across multiple effects?
392
+
393
+ - Yes. Each `Attribute` applies all active modifications ordered by action: Addition → Multiplication → Override.
394
+
395
+ Q: How do I remove just one instance of an effect?
396
+
397
+ - Keep the `EffectHandle` returned from `ApplyEffect` and pass it to `RemoveEffect(handle)`.
398
+
399
+ Q: Two systems apply the same tag. Who owns removal?
400
+
401
+ - The tag is reference‑counted. Each application increments the count; removal decrements it. The tag is removed when the count reaches 0.
402
+
403
+ Q: When should I use tags vs checking stats?
404
+
405
+ - Use tags to represent categorical states (e.g., Stunned/Poisoned/Invulnerable) independent from numeric values. Check stats for numeric thresholds or calculations.
406
+
407
+ ## Troubleshooting
408
+
409
+ - Attribute name doesn’t apply
410
+ - Ensure the `attribute` field matches a public/private `Attribute` field name on an `AttributesComponent` subclass.
411
+ - Regenerate the Attribute Metadata Cache to update editor dropdowns.
412
+
413
+ - Effect didn’t clean up cosmetics
414
+ - Confirm `RequiresInstance` is set correctly and components either clean up themselves (`CleansUpSelf`) or are destroyed by `EffectHandler`.
415
+
416
+ - Duration didn’t refresh on reapply
417
+ - Set `resetDurationOnReapplication = true` on the `AttributeEffect`.
418
+
419
+ ## Advanced Scenarios: Beyond Buffs and Debuffs
420
+
421
+ While the Effects System excels at traditional buff/debuff mechanics, its true power lies in building **robust capability systems** that drive complex gameplay decisions across your entire codebase. This section explores advanced patterns that transform tags from "nice-to-have" into mission-critical architecture.
422
+
423
+ ### Understanding the Capability Pattern
424
+
425
+ **The Problem with Flags:**
426
+
427
+ Many developers start with hardcoded boolean flags:
428
+
429
+ ```csharp
430
+ // ❌ OLD WAY: Scattered boolean flags
431
+ public class PlayerController : MonoBehaviour
432
+ {
433
+ public bool isInvulnerable;
434
+ public bool canDash;
435
+ public bool hasDoubleJump;
436
+ public bool isInvisible;
437
+ // 50+ booleans later...
438
+
439
+ void TakeDamage(float damage)
440
+ {
441
+ if (isInvulnerable) return;
442
+ // ...
443
+ }
444
+
445
+ void Update()
446
+ {
447
+ if (Input.GetKeyDown(KeyCode.Space) && canDash)
448
+ Dash();
449
+ }
450
+ }
451
+
452
+ // Problems:
453
+ // 1. Every system needs direct references to check flags
454
+ // 2. Adding temporary effects requires custom timers
455
+ // 3. Multiple sources granting same capability = conflicts
456
+ // 4. No centralized place to see what capabilities exist
457
+ // 5. Difficult to debug "why can't I do X?"
458
+ ```
459
+
460
+ **The Solution - Tag-Based Capabilities:**
461
+
462
+ ```csharp
463
+ // ✅ NEW WAY: Tag-based capability system
464
+ public class PlayerController : MonoBehaviour
465
+ {
466
+ void TakeDamage(float damage)
467
+ {
468
+ // Any system can grant "Invulnerable" tag
469
+ if (this.HasTag("Invulnerable")) return;
470
+ // ...
471
+ }
472
+
473
+ void Update()
474
+ {
475
+ // Check capability before allowing action
476
+ if (Input.GetKeyDown(KeyCode.Space) && this.HasTag("CanDash"))
477
+ Dash();
478
+ }
479
+ }
480
+
481
+ // Benefits:
482
+ // 1. Decoupled - systems query tags, don't need direct references
483
+ // 2. Multiple sources work automatically (reference-counted)
484
+ // 3. Temporary effects are free - just apply/remove tag
485
+ // 4. Debuggable - inspect TagHandler to see all active tags
486
+ // 5. Designer-friendly - add capabilities via ScriptableObjects
487
+ ```
488
+
489
+ ### When to Use This Pattern
490
+
491
+ ✅ **Perfect for:**
492
+
493
+ - **State management** - "Stunned", "Invisible", "Invulnerable", "Flying"
494
+ - **Capability gating** - "CanDash", "CanDoubleJump", "CanCastSpells"
495
+ - **System coordination** - "InCombat", "InCutscene", "InDialogue"
496
+ - **Permission systems** - "HasQuestItem", "UnlockedArea", "CompletedTutorial"
497
+ - **AI behavior** - "Aggressive", "Fleeing", "Alerted", "Patrolling"
498
+ - **Complex gameplay** - "Burning", "Wet", "Electrified" (element interactions)
499
+
500
+ ❌ **Not ideal for:**
501
+
502
+ - **Simple one-off checks** - If you only check in one place, a boolean is fine
503
+ - **Continuous numeric values** - Use Attributes for health, speed, damage
504
+ - **Performance-critical inner loops** - Cache tag checks outside hot paths
505
+
506
+ ### Pattern 1: Invulnerability System
507
+
508
+ **The Problem:** Many different sources need to grant invulnerability (power-ups, cutscenes, dash moves, debug mode). Without tags, you need complex logic to track all sources.
509
+
510
+ **The Solution:**
511
+
512
+ ```csharp
513
+ // === Setup (done once by programmer) ===
514
+
515
+ // 1. Create invulnerability effects as ScriptableObjects
516
+ // DashInvulnerability.asset:
517
+ // - durationType: Duration (0.3 seconds)
518
+ // - effectTags: ["Invulnerable", "Dashing"]
519
+ // - cosmeticEffects: flash sprite white
520
+
521
+ // PowerStarInvulnerability.asset:
522
+ // - durationType: Duration (10 seconds)
523
+ // - effectTags: ["Invulnerable", "PowerStar"]
524
+ // - cosmeticEffects: rainbow sparkles + music
525
+
526
+ // DebugInvulnerability.asset:
527
+ // - durationType: Infinite
528
+ // - effectTags: ["Invulnerable", "Debug"]
529
+ // - cosmeticEffects: debug overlay
530
+
531
+ // === Usage (everywhere in codebase) ===
532
+
533
+ // Combat system
534
+ public class CombatSystem : MonoBehaviour
535
+ {
536
+ public void TakeDamage(GameObject target, float damage)
537
+ {
538
+ // One simple check - doesn't care WHY they're invulnerable
539
+ if (target.HasTag("Invulnerable"))
540
+ {
541
+ Debug.Log("Target is invulnerable!");
542
+ return;
543
+ }
544
+
545
+ // Apply damage...
546
+ }
547
+ }
548
+
549
+ // Player dash ability
550
+ public class DashAbility : MonoBehaviour
551
+ {
552
+ [SerializeField] private AttributeEffect dashInvulnerability;
553
+
554
+ public void Dash()
555
+ {
556
+ // Grant 0.3s of invulnerability during dash
557
+ this.ApplyEffect(dashInvulnerability);
558
+ // Automatically removed after 0.3s
559
+ }
560
+ }
561
+
562
+ // Debug menu
563
+ public class DebugMenu : MonoBehaviour
564
+ {
565
+ [SerializeField] private AttributeEffect debugInvulnerability;
566
+ private EffectHandle? debugHandle;
567
+
568
+ public void ToggleInvulnerability()
569
+ {
570
+ if (debugHandle.HasValue)
571
+ {
572
+ player.RemoveEffect(debugHandle.Value);
573
+ debugHandle = null;
574
+ }
575
+ else
576
+ {
577
+ debugHandle = player.ApplyEffect(debugInvulnerability);
578
+ }
579
+ }
580
+ }
581
+
582
+ // Cutscene controller
583
+ public class CutsceneController : MonoBehaviour
584
+ {
585
+ [SerializeField] private AttributeEffect cutsceneInvulnerability;
586
+ private EffectHandle? cutsceneHandle;
587
+
588
+ void StartCutscene()
589
+ {
590
+ // Prevent player from taking damage during cutscenes
591
+ cutsceneHandle = player.ApplyEffect(cutsceneInvulnerability);
592
+ }
593
+
594
+ void EndCutscene()
595
+ {
596
+ if (cutsceneHandle.HasValue)
597
+ player.RemoveEffect(cutsceneHandle.Value);
598
+ }
599
+ }
600
+
601
+ // AI system
602
+ public class EnemyAI : MonoBehaviour
603
+ {
604
+ void ChooseTarget()
605
+ {
606
+ // Don't waste time attacking invulnerable targets
607
+ List<GameObject> validTargets = allTargets
608
+ .Where(t => !t.HasTag("Invulnerable"))
609
+ .ToList();
610
+
611
+ // Attack closest valid target...
612
+ }
613
+ }
614
+ ```
615
+
616
+ **Why This Works:**
617
+
618
+ - ✅ **Multiple sources** - Dash, power-ups, debug mode all grant invulnerability independently
619
+ - ✅ **Reference-counted** - All sources must end before invulnerability is removed
620
+ - ✅ **Decoupled** - Combat system doesn't know about dash, debug, or cutscene systems
621
+ - ✅ **Designer-friendly** - Create new invulnerability sources without code changes
622
+ - ✅ **Debuggable** - Inspect TagHandler to see exactly why someone is invulnerable
623
+
624
+ **Common Pitfall to Avoid:**
625
+
626
+ ```csharp
627
+ // ❌ DON'T: Check multiple specific tags
628
+ if (target.HasTag("DashInvulnerable") ||
629
+ target.HasTag("PowerStarInvulnerable") ||
630
+ target.HasTag("DebugInvulnerable"))
631
+ {
632
+ // Now you need to update this everywhere you add a new invulnerability source!
633
+ }
634
+
635
+ // ✅ DO: Check one general capability tag
636
+ if (target.HasTag("Invulnerable"))
637
+ {
638
+ // Works with all current and future invulnerability sources
639
+ }
640
+ ```
641
+
642
+ ### Pattern 2: Complex AI Decision-Making
643
+
644
+ **The Problem:** AI needs to make decisions based on complex state (player stealth, environmental conditions, buffs, etc.). Without a unified system, you end up with brittle if/else chains.
645
+
646
+ **The Solution:**
647
+
648
+ ```csharp
649
+ // === Setup effects that grant capability tags ===
650
+
651
+ // Stealth.asset:
652
+ // - effectTags: ["Invisible", "Stealthy"]
653
+ // - modifications: (none - just tags)
654
+
655
+ // InWater.asset:
656
+ // - effectTags: ["Wet", "Swimming"]
657
+ // - modifications: Speed × 0.5
658
+
659
+ // OnFire.asset:
660
+ // - effectTags: ["Burning", "OnFire"]
661
+ // - modifications: Health + (-5 per second)
662
+
663
+ // === AI uses tags to make robust decisions ===
664
+
665
+ public class EnemyAI : MonoBehaviour
666
+ {
667
+ public void UpdateAI()
668
+ {
669
+ GameObject player = FindPlayer();
670
+
671
+ // 1. Visibility checks
672
+ if (player.HasTag("Invisible"))
673
+ {
674
+ // Can't see invisible targets - use last known position
675
+ PatrolToLastKnownPosition();
676
+ return;
677
+ }
678
+
679
+ // 2. Threat assessment
680
+ if (player.HasTag("Invulnerable") && player.HasTag("PowerStar"))
681
+ {
682
+ // Player is powered up - flee!
683
+ Flee(player.transform.position);
684
+ return;
685
+ }
686
+
687
+ // 3. Environmental awareness
688
+ if (this.HasTag("Burning"))
689
+ {
690
+ // On fire - prioritize finding water
691
+ GameObject water = FindNearestWater();
692
+ if (water != null)
693
+ {
694
+ MoveTowards(water.transform.position);
695
+ return;
696
+ }
697
+ }
698
+
699
+ // 4. Tactical decisions
700
+ if (player.HasTag("Stunned") || player.HasTag("Slowed"))
701
+ {
702
+ // Player is vulnerable - aggressive pursuit
703
+ AggressiveAttack(player);
704
+ return;
705
+ }
706
+
707
+ // 5. Element interactions
708
+ if (this.HasTag("Wet") && player.HasTag("ElectricWeapon"))
709
+ {
710
+ // We're wet and player has electric weapon - dangerous!
711
+ MaintainDistance(player, minDistance: 10f);
712
+ return;
713
+ }
714
+
715
+ // Default behavior
716
+ ChaseAndAttack(player);
717
+ }
718
+
719
+ // Helper: Check multiple conditions easily
720
+ bool CanEngageInCombat()
721
+ {
722
+ // Can't fight if we're stunned, fleeing, or in a cutscene
723
+ return !this.HasTag("Stunned") &&
724
+ !this.HasTag("Fleeing") &&
725
+ !this.HasTag("InCutscene");
726
+ }
727
+ }
728
+ ```
729
+
730
+ **Why This Works:**
731
+
732
+ - ✅ **Readable** - AI logic is self-documenting ("if player is invisible")
733
+ - ✅ **Extensible** - Add new capabilities without modifying AI code
734
+ - ✅ **Composable** - Combine multiple tags for complex conditions
735
+ - ✅ **Testable** - Apply tags in tests to verify AI behavior
736
+ - ✅ **Designer-friendly** - Designers can create new effects that AI automatically responds to
737
+
738
+ ### Pattern 3: Permission and Unlock Systems
739
+
740
+ **The Problem:** Games have many gated systems (abilities, areas, features). Tracking unlocks with individual booleans becomes unwieldy.
741
+
742
+ **The Solution:**
743
+
744
+ ```csharp
745
+ // === Setup unlock effects ===
746
+
747
+ // UnlockDoubleJump.asset:
748
+ // - durationType: Infinite (permanent unlock)
749
+ // - effectTags: ["CanDoubleJump", "HasUpgrade"]
750
+
751
+ // QuestKeyItem.asset:
752
+ // - durationType: Infinite
753
+ // - effectTags: ["HasKeyItem", "CanEnterDungeon"]
754
+
755
+ // TutorialComplete.asset:
756
+ // - durationType: Infinite
757
+ // - effectTags: ["TutorialComplete", "CanAccessMultiplayer"]
758
+
759
+ // === Usage throughout game systems ===
760
+
761
+ // Ability system
762
+ public class PlayerAbilities : MonoBehaviour
763
+ {
764
+ void Update()
765
+ {
766
+ // Jump
767
+ if (Input.GetKeyDown(KeyCode.Space))
768
+ {
769
+ if (isGrounded)
770
+ {
771
+ Jump();
772
+ }
773
+ // Double jump only works if unlocked
774
+ else if (this.HasTag("CanDoubleJump") && !hasUsedDoubleJump)
775
+ {
776
+ Jump();
777
+ hasUsedDoubleJump = true;
778
+ }
779
+ }
780
+
781
+ // Dash
782
+ if (Input.GetKeyDown(KeyCode.LeftShift))
783
+ {
784
+ if (this.HasTag("CanDash"))
785
+ {
786
+ Dash();
787
+ }
788
+ else
789
+ {
790
+ ShowMessage("Unlock dash ability first!");
791
+ }
792
+ }
793
+ }
794
+ }
795
+
796
+ // Level gate
797
+ public class DungeonGate : MonoBehaviour
798
+ {
799
+ void OnTriggerEnter2D(Collider2D other)
800
+ {
801
+ GameObject player = other.gameObject;
802
+
803
+ if (player.HasTag("HasKeyItem"))
804
+ {
805
+ // Has the key - open gate
806
+ OpenGate();
807
+ }
808
+ else
809
+ {
810
+ // Missing key - show hint
811
+ ShowMessage("You need the Ancient Key to enter.");
812
+ }
813
+ }
814
+ }
815
+
816
+ // UI system
817
+ public class MainMenuUI : MonoBehaviour
818
+ {
819
+ [SerializeField] private Button multiplayerButton;
820
+
821
+ void Update()
822
+ {
823
+ // Disable multiplayer until tutorial is complete
824
+ multiplayerButton.interactable = player.HasTag("TutorialComplete");
825
+ }
826
+ }
827
+
828
+ // Save system
829
+ public class SaveSystem : MonoBehaviour
830
+ {
831
+ public SaveData CreateSaveData(GameObject player)
832
+ {
833
+ // Save all permanent unlocks
834
+ var saveData = new SaveData
835
+ {
836
+ unlockedAbilities = new List<string>()
837
+ };
838
+
839
+ // Check all capability tags
840
+ if (player.HasTag("CanDoubleJump"))
841
+ saveData.unlockedAbilities.Add("DoubleJump");
842
+
843
+ if (player.HasTag("CanDash"))
844
+ saveData.unlockedAbilities.Add("Dash");
845
+
846
+ if (player.HasTag("HasKeyItem"))
847
+ saveData.unlockedAbilities.Add("KeyItem");
848
+
849
+ return saveData;
850
+ }
851
+
852
+ public void LoadSaveData(GameObject player, SaveData saveData)
853
+ {
854
+ // Reapply permanent unlocks
855
+ foreach (string ability in saveData.unlockedAbilities)
856
+ {
857
+ AttributeEffect unlock = LoadUnlockEffect(ability);
858
+ player.ApplyEffect(unlock);
859
+ }
860
+ }
861
+ }
862
+ ```
863
+
864
+ **Why This Works:**
865
+
866
+ - ✅ **Persistent** - Infinite duration effects work like permanent flags
867
+ - ✅ **Serializable** - Easy to save/load by checking tags
868
+ - ✅ **Discoverable** - Inspect TagHandler to see all unlocks
869
+ - ✅ **No hardcoded strings** - Create unlock effects as assets
870
+ - ✅ **Prevents duplication** - Reference-counting handles multiple unlock sources
871
+
872
+ ### Pattern 4: Elemental Interaction Systems
873
+
874
+ **The Problem:** Complex element systems (wet + electric = shock, burning + ice = extinguish) require tracking multiple states and their interactions.
875
+
876
+ **The Solution:**
877
+
878
+ ```csharp
879
+ // === Setup element effects ===
880
+
881
+ // Wet.asset:
882
+ // - durationType: Duration (10 seconds)
883
+ // - effectTags: ["Wet", "ConductsElectricity"]
884
+ // - cosmeticEffects: water drips
885
+
886
+ // Burning.asset:
887
+ // - durationType: Duration (5 seconds)
888
+ // - effectTags: ["Burning", "OnFire"]
889
+ // - modifications: Health + (-5 per second)
890
+ // - cosmeticEffects: fire particles
891
+
892
+ // Frozen.asset:
893
+ // - durationType: Duration (3 seconds)
894
+ // - effectTags: ["Frozen", "Immobilized"]
895
+ // - modifications: Speed × 0
896
+
897
+ // Electrified.asset:
898
+ // - durationType: Duration (4 seconds)
899
+ // - effectTags: ["Electrified", "Stunned"]
900
+ // - modifications: Speed × 0
901
+
902
+ // === Interaction system ===
903
+
904
+ public class ElementalInteractions : MonoBehaviour
905
+ {
906
+ [SerializeField] private AttributeEffect wetEffect;
907
+ [SerializeField] private AttributeEffect burningEffect;
908
+ [SerializeField] private AttributeEffect frozenEffect;
909
+ [SerializeField] private AttributeEffect electrifiedEffect;
910
+
911
+ public void OnEnvironmentalEffect(GameObject target, string effectType)
912
+ {
913
+ switch (effectType)
914
+ {
915
+ case "Water":
916
+ // Apply wet
917
+ target.ApplyEffect(wetEffect);
918
+
919
+ // Water puts out fire
920
+ if (target.HasTag("Burning"))
921
+ {
922
+ target.RemoveAllEffectsWithTag("Burning");
923
+ CreateSteamParticles(target.transform.position);
924
+ }
925
+ break;
926
+
927
+ case "Fire":
928
+ // Fire dries wet targets
929
+ if (target.HasTag("Wet"))
930
+ {
931
+ target.RemoveAllEffectsWithTag("Wet");
932
+ CreateSteamParticles(target.transform.position);
933
+ }
934
+ else
935
+ {
936
+ // Set on fire if dry
937
+ target.ApplyEffect(burningEffect);
938
+ }
939
+ break;
940
+
941
+ case "Ice":
942
+ // Ice freezes wet targets (stronger effect)
943
+ if (target.HasTag("Wet"))
944
+ {
945
+ target.ApplyEffect(frozenEffect);
946
+ target.RemoveAllEffectsWithTag("Wet");
947
+ }
948
+ break;
949
+
950
+ case "Electric":
951
+ // Electric shocks wet targets
952
+ if (target.HasTag("Wet"))
953
+ {
954
+ // Extra damage and stun
955
+ target.ApplyEffect(electrifiedEffect);
956
+ target.TakeDamage(20f); // Bonus damage
957
+ CreateElectricParticles(target.transform.position);
958
+ }
959
+ break;
960
+ }
961
+ }
962
+
963
+ public float CalculateElementalDamageMultiplier(GameObject attacker, GameObject target)
964
+ {
965
+ float multiplier = 1f;
966
+
967
+ // Fire does extra damage to frozen targets (they thaw)
968
+ if (attacker.HasTag("FireWeapon") && target.HasTag("Frozen"))
969
+ multiplier *= 1.5f;
970
+
971
+ // Electric does massive damage to wet targets
972
+ if (attacker.HasTag("ElectricWeapon") && target.HasTag("Wet"))
973
+ multiplier *= 2.0f;
974
+
975
+ // Ice does extra damage to burning targets (extinguish)
976
+ if (attacker.HasTag("IceWeapon") && target.HasTag("Burning"))
977
+ multiplier *= 1.5f;
978
+
979
+ return multiplier;
980
+ }
981
+ }
982
+ ```
983
+
984
+ **Why This Works:**
985
+
986
+ - ✅ **Composable** - Elements interact naturally through tags
987
+ - ✅ **Discoverable** - All active elements visible in TagHandler
988
+ - ✅ **Designer-friendly** - Create new elements without code changes
989
+ - ✅ **Debuggable** - See exact element state at any moment
990
+ - ✅ **Extensible** - Add new elements and interactions easily
991
+
992
+ ### Pattern 5: State Machine Replacement
993
+
994
+ **The Problem:** Traditional state machines become complex with many states and transitions. Tags can represent state more flexibly.
995
+
996
+ **Traditional Approach:**
997
+
998
+ ```csharp
999
+ // ❌ OLD WAY: Rigid state machine
1000
+ public enum PlayerState
1001
+ {
1002
+ Idle,
1003
+ Walking,
1004
+ Running,
1005
+ Jumping,
1006
+ Attacking,
1007
+ Stunned,
1008
+ // What if player is jumping AND attacking?
1009
+ // What if player is attacking AND stunned?
1010
+ // Need combinatorial explosion of states!
1011
+ }
1012
+
1013
+ private PlayerState currentState;
1014
+
1015
+ void Update()
1016
+ {
1017
+ switch (currentState)
1018
+ {
1019
+ case PlayerState.Stunned:
1020
+ // Can't do anything when stunned
1021
+ return;
1022
+
1023
+ case PlayerState.Attacking:
1024
+ // Can't move while attacking
1025
+ // But what if we want to allow movement during some attacks?
1026
+ break;
1027
+
1028
+ // 50 more cases...
1029
+ }
1030
+ }
1031
+ ```
1032
+
1033
+ **Tag-Based Approach:**
1034
+
1035
+ ```csharp
1036
+ // ✅ NEW WAY: Flexible tag-based state
1037
+ void Update()
1038
+ {
1039
+ // States can overlap naturally
1040
+ bool isGrounded = CheckGrounded();
1041
+ bool isMoving = Input.GetAxis("Horizontal") != 0;
1042
+
1043
+ // Check capabilities, not rigid states
1044
+ if (this.HasTag("Stunned") || this.HasTag("Frozen"))
1045
+ {
1046
+ // Can't act while crowd-controlled
1047
+ return;
1048
+ }
1049
+
1050
+ // Movement
1051
+ if (isMoving && !this.HasTag("Immobilized"))
1052
+ {
1053
+ Move();
1054
+
1055
+ // Can attack while moving (if not attacking already)
1056
+ if (Input.GetButtonDown("Fire1") && !this.HasTag("Attacking"))
1057
+ {
1058
+ Attack();
1059
+ }
1060
+ }
1061
+
1062
+ // Jumping
1063
+ if (Input.GetButtonDown("Jump") && isGrounded)
1064
+ {
1065
+ if (this.HasTag("CanJump") && !this.HasTag("Jumping"))
1066
+ {
1067
+ Jump();
1068
+ }
1069
+ }
1070
+
1071
+ // Special abilities
1072
+ if (Input.GetButtonDown("Dash"))
1073
+ {
1074
+ if (this.HasTag("CanDash") && !this.HasTag("Dashing"))
1075
+ {
1076
+ Dash();
1077
+ }
1078
+ }
1079
+ }
1080
+
1081
+ // Actions apply tags to themselves
1082
+ void Attack()
1083
+ {
1084
+ // Apply "Attacking" tag for duration of attack
1085
+ this.ApplyEffect(attackingEffect); // 0.5s duration
1086
+ // Play animation...
1087
+ }
1088
+
1089
+ void Dash()
1090
+ {
1091
+ // Apply multiple tags during dash
1092
+ this.ApplyEffect(dashingEffect);
1093
+ // Effect grants: ["Dashing", "Invulnerable", "FastMovement"]
1094
+ // All removed automatically after duration
1095
+ }
1096
+ ```
1097
+
1098
+ **Why This Works:**
1099
+
1100
+ - ✅ **Composable** - Multiple states can be active simultaneously
1101
+ - ✅ **Flexible** - Easy to add conditional behavior based on tags
1102
+ - ✅ **No spaghetti** - Avoid complex state transition logic
1103
+ - ✅ **Self-documenting** - Tag names describe what's happening
1104
+ - ✅ **Designer-friendly** - Add new states via ScriptableObjects
1105
+
1106
+ ### Pattern 6: Debugging and Cheat Codes
1107
+
1108
+ **The Problem:** Debug tools and cheat codes need to temporarily grant capabilities without affecting production code.
1109
+
1110
+ **The Solution:**
1111
+
1112
+ ```csharp
1113
+ public class DebugConsole : MonoBehaviour
1114
+ {
1115
+ [SerializeField] private AttributeEffect godModeEffect;
1116
+ [SerializeField] private AttributeEffect noclipEffect;
1117
+ [SerializeField] private AttributeEffect unlockAllEffect;
1118
+
1119
+ private Dictionary<string, EffectHandle?> activeDebugEffects = new();
1120
+
1121
+ void Update()
1122
+ {
1123
+ // God mode (invulnerable + infinite resources)
1124
+ if (Input.GetKeyDown(KeyCode.F1))
1125
+ {
1126
+ ToggleDebugEffect("GodMode", godModeEffect);
1127
+ }
1128
+
1129
+ // Noclip (fly through walls)
1130
+ if (Input.GetKeyDown(KeyCode.F2))
1131
+ {
1132
+ ToggleDebugEffect("Noclip", noclipEffect);
1133
+ }
1134
+
1135
+ // Unlock all abilities
1136
+ if (Input.GetKeyDown(KeyCode.F3))
1137
+ {
1138
+ ApplyDebugEffect("UnlockAll", unlockAllEffect);
1139
+ }
1140
+ }
1141
+
1142
+ void ToggleDebugEffect(string name, AttributeEffect effect)
1143
+ {
1144
+ if (activeDebugEffects.TryGetValue(name, out EffectHandle? handle) && handle.HasValue)
1145
+ {
1146
+ player.RemoveEffect(handle.Value);
1147
+ activeDebugEffects.Remove(name);
1148
+ Debug.Log($"Debug: {name} OFF");
1149
+ }
1150
+ else
1151
+ {
1152
+ EffectHandle? newHandle = player.ApplyEffect(effect);
1153
+ activeDebugEffects[name] = newHandle;
1154
+ Debug.Log($"Debug: {name} ON");
1155
+ }
1156
+ }
1157
+
1158
+ void ApplyDebugEffect(string name, AttributeEffect effect)
1159
+ {
1160
+ player.ApplyEffect(effect);
1161
+ Debug.Log($"Debug: Applied {name}");
1162
+ }
1163
+ }
1164
+
1165
+ // GodMode.asset:
1166
+ // - durationType: Infinite
1167
+ // - effectTags: ["Invulnerable", "InfiniteResources", "Debug"]
1168
+ // - modifications: Health × 999, Stamina × 999
1169
+
1170
+ // Noclip.asset:
1171
+ // - durationType: Infinite
1172
+ // - effectTags: ["Noclip", "Flying", "Debug"]
1173
+ // - cosmeticEffects: ghost transparency
1174
+
1175
+ // UnlockAll.asset:
1176
+ // - durationType: Infinite
1177
+ // - effectTags: ["CanDoubleJump", "CanDash", "CanWallJump", "Debug"]
1178
+ ```
1179
+
1180
+ **Why This Works:**
1181
+
1182
+ - ✅ **Non-invasive** - Debug code doesn't pollute production code
1183
+ - ✅ **Discoverable** - Inspect TagHandler to see active debug effects
1184
+ - ✅ **Reusable** - Same effects can be used by actual gameplay
1185
+ - ✅ **Safe** - Easy to ensure debug effects don't ship (check for "Debug" tag)
1186
+
1187
+ ### Comparison to Other Approaches
1188
+
1189
+ | Approach | Pros | Cons |
1190
+ | ---------------------- | --------------------------------------- | ----------------------------------------------------- |
1191
+ | **Boolean Flags** | Simple, fast | Not composable, hard to debug, scattered |
1192
+ | **Enums** | Type-safe, clear options | Rigid, can't combine states |
1193
+ | **Bitflags** | Combinable, fast | Limited to 64 states, not designer-friendly |
1194
+ | **State Machines** | Structured, predictable | Complex with many states, rigid transitions |
1195
+ | **Tag System (this!)** | Flexible, composable, designer-friendly | Slightly slower than booleans, strings less type-safe |
1196
+
1197
+ **When to Use Tags vs Attributes:**
1198
+
1199
+ | Use Case | Solution | Example |
1200
+ | ------------------------ | ----------------------------- | --------------------------------------- |
1201
+ | **Binary state** | Tag | "Invulnerable", "CanDash" |
1202
+ | **Numeric value** | Attribute | Health, Speed, Damage |
1203
+ | **Temporary state** | Tag with Duration | "Stunned" for 3 seconds |
1204
+ | **Stacking bonuses** | Attribute with Multiplication | Speed × 1.5 from multiple haste effects |
1205
+ | **Category membership** | Tag | "Enemy", "Friendly", "Neutral" |
1206
+ | **Resource management** | Attribute | Stamina, Mana |
1207
+ | **Permission/unlock** | Tag with Infinite duration | "CanEnterDungeon", "TutorialComplete" |
1208
+ | **Complex interactions** | Multiple Tags | "Wet" + "Electrified" = shocked |
1209
+
1210
+ ### Best Practices for Capability Systems
1211
+
1212
+ 1. **Namespace your tags** - Use prefixes to avoid conflicts
1213
+
1214
+ ```csharp
1215
+ // ✅ Good: Clear categories
1216
+ "Status_Stunned"
1217
+ "Ability_CanDash"
1218
+ "Quest_HasKeyItem"
1219
+ "Element_Burning"
1220
+
1221
+ // ❌ Bad: Ambiguous
1222
+ "Stunned" // Status or ability?
1223
+ "Fire" // On fire or has fire weapon?
1224
+ ```
1225
+
1226
+ 2. **Create tag constants** - Avoid string typos
1227
+
1228
+ ```csharp
1229
+ public static class GameplayTags
1230
+ {
1231
+ // States
1232
+ public const string Invulnerable = "Invulnerable";
1233
+ public const string Stunned = "Stunned";
1234
+ public const string Invisible = "Invisible";
1235
+
1236
+ // Capabilities
1237
+ public const string CanDash = "CanDash";
1238
+ public const string CanDoubleJump = "CanDoubleJump";
1239
+
1240
+ // Elements
1241
+ public const string Burning = "Burning";
1242
+ public const string Wet = "Wet";
1243
+ public const string Frozen = "Frozen";
1244
+ }
1245
+
1246
+ // Usage
1247
+ if (player.HasTag(GameplayTags.Invulnerable))
1248
+ {
1249
+ // Compiler will catch typos!
1250
+ }
1251
+ ```
1252
+
1253
+ 3. **Document tag meanings** - Keep a central registry
1254
+
1255
+ ```csharp
1256
+ /// Tags Registry
1257
+ /// ===================================
1258
+ /// Invulnerable - Cannot take damage from any source
1259
+ /// Stunned - Cannot perform any actions (move, attack, cast)
1260
+ /// InCombat - Currently engaged in combat (prevents resting, saving)
1261
+ /// Invisible - Cannot be seen by AI or targeted
1262
+ /// CanDash - Has unlocked dash ability
1263
+ /// CanDoubleJump - Has unlocked double jump ability
1264
+ /// Wet - Conducts electricity, prevents fire, can be frozen
1265
+ /// Burning - Takes fire damage over time, can ignite others
1266
+ ```
1267
+
1268
+ 4. **Use effect tags for internal organization**
1269
+
1270
+ ```csharp
1271
+ // EffectTags vs GrantTags:
1272
+ // - EffectTags: Internal organization (removable via RemoveAllEffectsWithTag)
1273
+ // - GrantTags: Gameplay queries (checked via HasTag)
1274
+
1275
+ // Example effect:
1276
+ // HastePotion.asset:
1277
+ // - effectTags: ["Potion", "Buff", "Consumable"] // For removal/organization
1278
+ // - grantTags: ["Haste", "MovementBuff"] // For gameplay queries
1279
+ ```
1280
+
1281
+ 5. **Test tag combinations** - Verify interactions work correctly
1282
+
1283
+ ```csharp
1284
+ [Test]
1285
+ public void TestInvulnerability_MultipleSourcesStack()
1286
+ {
1287
+ GameObject player = CreateTestPlayer();
1288
+
1289
+ // Apply invulnerability from two sources
1290
+ EffectHandle? dash = player.ApplyEffect(dashInvulnerability);
1291
+ EffectHandle? powerup = player.ApplyEffect(powerupInvulnerability);
1292
+
1293
+ Assert.IsTrue(player.HasTag("Invulnerable"));
1294
+
1295
+ // Remove one source - should still be invulnerable
1296
+ player.RemoveEffect(dash.Value);
1297
+ Assert.IsTrue(player.HasTag("Invulnerable"));
1298
+
1299
+ // Remove second source - now vulnerable
1300
+ player.RemoveEffect(powerup.Value);
1301
+ Assert.IsFalse(player.HasTag("Invulnerable"));
1302
+ }
1303
+ ```
1304
+
1305
+ ## Performance Notes
1306
+
1307
+ - Attribute field discovery is cached (and can be precomputed by the Attribute Metadata Cache generator).
1308
+ - Tag queries provide overloads for lists to minimize allocations; prefer `IReadOnlyList<string>` overloads in hot paths.
1309
+ - Cosmetics can be a significant cost; prefer shared presenters when possible.
1310
+
1311
+ ---
1312
+
1313
+ Related:
1314
+
1315
+ - README section: "Effects, Attributes, and Tags"
1316
+ - Attribute Metadata Cache (Editor Tools) for dropdowns and performance