com.wallstop-studios.unity-helpers 2.0.3 → 2.1.0

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 (58) hide show
  1. package/Docs/DATA_STRUCTURES.md +7 -7
  2. package/Docs/EFFECTS_SYSTEM.md +836 -8
  3. package/Docs/EFFECTS_SYSTEM_TUTORIAL.md +77 -18
  4. package/Docs/HULLS.md +2 -2
  5. package/Docs/RANDOM_PERFORMANCE.md +1 -1
  6. package/Docs/REFLECTION_HELPERS.md +1 -1
  7. package/Docs/RELATIONAL_COMPONENTS.md +51 -6
  8. package/Docs/SERIALIZATION.md +1 -1
  9. package/Docs/SINGLETONS.md +2 -2
  10. package/Docs/SPATIAL_TREES_2D_GUIDE.md +3 -3
  11. package/Docs/SPATIAL_TREES_3D_GUIDE.md +3 -3
  12. package/Docs/SPATIAL_TREE_SEMANTICS.md +7 -7
  13. package/Editor/CustomDrawers/WShowIfPropertyDrawer.cs +131 -41
  14. package/Editor/Utils/ScriptableObjectSingletonCreator.cs +175 -18
  15. package/README.md +17 -3
  16. package/Runtime/Core/Helper/UnityMainThreadDispatcher.cs +4 -2
  17. package/Runtime/Tags/Attribute.cs +144 -24
  18. package/Runtime/Tags/AttributeEffect.cs +278 -11
  19. package/Runtime/Tags/AttributeModification.cs +59 -29
  20. package/Runtime/Tags/AttributeUtilities.cs +465 -0
  21. package/Runtime/Tags/AttributesComponent.cs +20 -0
  22. package/Runtime/Tags/EffectBehavior.cs +171 -0
  23. package/Runtime/Tags/EffectBehavior.cs.meta +4 -0
  24. package/Runtime/Tags/EffectHandle.cs +5 -0
  25. package/Runtime/Tags/EffectHandler.cs +564 -39
  26. package/Runtime/Tags/EffectStackKey.cs +79 -0
  27. package/Runtime/Tags/EffectStackKey.cs.meta +4 -0
  28. package/Runtime/Tags/PeriodicEffectDefinition.cs +102 -0
  29. package/Runtime/Tags/PeriodicEffectDefinition.cs.meta +4 -0
  30. package/Runtime/Tags/PeriodicEffectRuntimeState.cs +40 -0
  31. package/Runtime/Tags/PeriodicEffectRuntimeState.cs.meta +4 -0
  32. package/Runtime/Tags/TagHandler.cs +375 -21
  33. package/Samples~/DI - Zenject/README.md +0 -2
  34. package/Tests/Editor/Attributes/WShowIfPropertyDrawerTests.cs +285 -0
  35. package/Tests/Editor/Attributes/WShowIfPropertyDrawerTests.cs.meta +11 -0
  36. package/Tests/Editor/Core/Attributes/RelationalComponentAssignerTests.cs +2 -2
  37. package/Tests/Editor/Utils/ScriptableObjectSingletonTests.cs +41 -0
  38. package/Tests/Runtime/Serialization/JsonSerializationTest.cs +4 -3
  39. package/Tests/Runtime/Tags/AttributeEffectTests.cs +135 -0
  40. package/Tests/Runtime/Tags/AttributeEffectTests.cs.meta +3 -0
  41. package/Tests/Runtime/Tags/AttributeModificationTests.cs +137 -0
  42. package/Tests/Runtime/Tags/AttributeTests.cs +192 -0
  43. package/Tests/Runtime/Tags/AttributeTests.cs.meta +3 -0
  44. package/Tests/Runtime/Tags/AttributeUtilitiesTests.cs +245 -0
  45. package/Tests/Runtime/Tags/CosmeticAndCollisionTests.cs +1 -1
  46. package/Tests/Runtime/Tags/EffectBehaviorTests.cs +184 -0
  47. package/Tests/Runtime/Tags/EffectBehaviorTests.cs.meta +3 -0
  48. package/Tests/Runtime/Tags/EffectHandlerTests.cs +809 -0
  49. package/Tests/Runtime/Tags/Helpers/RecordingEffectBehavior.cs +89 -0
  50. package/Tests/Runtime/Tags/Helpers/RecordingEffectBehavior.cs.meta +4 -0
  51. package/Tests/Runtime/Tags/PeriodicEffectDefinitionSerializationTests.cs +92 -0
  52. package/Tests/Runtime/Tags/PeriodicEffectDefinitionSerializationTests.cs.meta +3 -0
  53. package/Tests/Runtime/Tags/TagHandlerTests.cs +130 -6
  54. package/package.json +1 -1
  55. package/scripts/lint-doc-links.ps1 +156 -11
  56. package/Tests/Runtime/Tags/AttributeDataTests.cs +0 -312
  57. package/node_modules.meta +0 -8
  58. /package/Tests/Runtime/Tags/{AttributeDataTests.cs.meta → AttributeModificationTests.cs.meta} +0 -0
@@ -5,6 +5,7 @@
5
5
  - **⭐ Build buff/debuff systems without writing custom code for every effect.**
6
6
  - Data‑driven ScriptableObjects: designers create 100s of effects, programmers build system once.
7
7
  - **Time saved: Weeks of boilerplate eliminated + designers empowered to iterate freely.**
8
+ - **✨ Attributes are NOT required!** Use the system purely for tag-based state management and timed cosmetic effects.
8
9
 
9
10
  ### ⭐ The Designer Empowerment Killer Feature
10
11
 
@@ -88,7 +89,7 @@ Visuals
88
89
 
89
90
  - `Attribute` — A dynamic numeric value with a base and a calculated current value. Current value applies all active modifications.
90
91
  - `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
+ - `AttributeEffect` — ScriptableObject asset bundling modifications, tags, cosmetic data, duration policy, periodic tick schedules, and optional runtime behaviours.
92
93
  - `EffectHandle` — Opaque identifier for a specific application instance (for Duration/Infinite effects). Used to remove one stack.
93
94
  - `AttributesComponent` — Base MonoBehaviour exposing modifiable `Attribute` fields (e.g., Health, Speed) on your character.
94
95
  - `EffectHandler` — Component that applies/removes effects, tracks durations, forwards modifications to `AttributesComponent`, applies tags and cosmetics.
@@ -115,8 +116,9 @@ Instant effects modify base values permanently and return `null` instead of a ha
115
116
  ```csharp
116
117
  public class CharacterStats : AttributesComponent
117
118
  {
118
- public Attribute Health = 100f;
119
+ public Attribute MaxHealth = 100f;
119
120
  public Attribute Speed = 5f;
121
+ public Attribute AttackDamage = 10f;
120
122
  public Attribute Defense = 10f;
121
123
  }
122
124
  ```
@@ -151,6 +153,635 @@ if (player.HasTag("Stunned"))
151
153
  }
152
154
  ```
153
155
 
156
+ ## Understanding Attributes: What to Model and What to Avoid
157
+
158
+ **Important: Attributes are NOT required!** The Effects System is extremely powerful even when used solely for tag-based state management and cosmetic effects.
159
+
160
+ ### What Makes a Good Attribute?
161
+
162
+ Attributes work best for values that are:
163
+
164
+ - **Primarily modified by the effects system** (buffs, debuffs, equipment)
165
+ - **Derived from a base value** (MaxHealth, Speed, AttackDamage, Defense)
166
+ - **Calculated values** where you need to see the result of all modifications
167
+
168
+ ### What Makes a Poor Attribute?
169
+
170
+ **❌ DON'T use Attributes for "current" values like CurrentHealth, CurrentMana, or CurrentAmmo!**
171
+
172
+ **Why?** These values are frequently modified by multiple systems:
173
+
174
+ - Combat system subtracts health on damage
175
+ - Healing system adds health
176
+ - Regeneration ticks add health over time
177
+ - Death system resets health to zero
178
+ - Save/load system restores health
179
+
180
+ **The Problem:**
181
+
182
+ ```csharp
183
+ // ❌ BAD: CurrentHealth as an Attribute
184
+ public class PlayerStats : AttributesComponent
185
+ {
186
+ public Attribute CurrentHealth = 100f; // DON'T DO THIS!
187
+ public Attribute MaxHealth = 100f; // This is fine
188
+ }
189
+
190
+ // Multiple systems modify CurrentHealth:
191
+ void TakeDamage(float damage)
192
+ {
193
+ // Direct mutation bypasses the effects system
194
+ playerStats.CurrentHealth.BaseValue -= damage;
195
+
196
+ // Problem 1: If an effect was modifying CurrentHealth,
197
+ // it still applies! Now calculations are wrong.
198
+ // Problem 2: If you remove an effect, it may restore
199
+ // the ORIGINAL base value, undoing damage taken.
200
+ // Problem 3: Save/load becomes complicated - do you save
201
+ // base or current? What about active modifiers?
202
+ }
203
+ ```
204
+
205
+ **The Solution - Separate Current and Max:**
206
+
207
+ ```csharp
208
+ // ✅ GOOD: CurrentHealth is a regular field, MaxHealth is an Attribute
209
+ public class PlayerStats : AttributesComponent
210
+ {
211
+ // Regular field - modified by combat/healing systems directly
212
+ private float currentHealth = 100f;
213
+
214
+ // Attribute - modified by buffs/effects
215
+ public Attribute MaxHealth = 100f;
216
+
217
+ public float CurrentHealth
218
+ {
219
+ get => currentHealth;
220
+ set => currentHealth = Mathf.Clamp(value, 0, MaxHealth.Value);
221
+ }
222
+
223
+ void Start()
224
+ {
225
+ // Initialize current health to max
226
+ currentHealth = MaxHealth.Value;
227
+
228
+ // When max health changes, clamp current health
229
+ MaxHealth.OnValueChanged += (oldMax, newMax) =>
230
+ {
231
+ // If max decreased, ensure current doesn't exceed new max
232
+ if (currentHealth > newMax)
233
+ {
234
+ currentHealth = newMax;
235
+ }
236
+ };
237
+ }
238
+ }
239
+
240
+ // Combat system can now safely modify current health
241
+ void TakeDamage(float damage)
242
+ {
243
+ playerStats.CurrentHealth -= damage; // Simple and correct
244
+ }
245
+
246
+ // Effects system modifies max health
247
+ void ApplyHealthBuff()
248
+ {
249
+ // MaxHealth × 1.5 (buffs max, current stays same)
250
+ player.ApplyEffect(healthBuffEffect);
251
+ }
252
+ ```
253
+
254
+ ### Attribute Best Practices
255
+
256
+ **✅ DO use Attributes for:**
257
+
258
+ - **MaxHealth, MaxMana, MaxStamina** - caps that buffs modify
259
+ - **Speed, MovementSpeed** - continuous values modified by effects
260
+ - **AttackDamage, Defense, CritChance** - combat stats
261
+ - **CooldownReduction, CastSpeed** - multiplicative modifiers
262
+ - **CarryCapacity, JumpHeight** - gameplay parameters
263
+
264
+ **❌ DON'T use Attributes for:**
265
+
266
+ - **CurrentHealth, CurrentMana** - depleting resources with complex mutation
267
+ - **Position, Rotation** - physics/transform state
268
+ - **Inventory count, Currency** - discrete counts from multiple sources
269
+ - **Quest progress, Level** - progression state
270
+ - **Input state, UI state** - transient application state
271
+
272
+ ### Why This Matters
273
+
274
+ When you use Attributes for frequently-mutated "current" values:
275
+
276
+ 1. **State conflicts** - Effects system and other systems fight over the value
277
+ 2. **Save/load bugs** - Unclear whether to save base value or current value with modifiers
278
+ 3. **Unexpected restorations** - Removing an effect may restore old base value, losing damage/healing
279
+ 4. **Performance overhead** - Recalculating modifications on every damage tick
280
+ 5. **Complexity** - Need to carefully coordinate between effects and direct mutations
281
+
282
+ **The Golden Rule:** If a value is modified by systems outside the effects system regularly (combat, regeneration, consumption), it should NOT be an Attribute. Use a regular field instead, and let Attributes handle the maximums/limits.
283
+
284
+ ## Using Tags WITHOUT Attributes
285
+
286
+ Even without any Attributes, the Effects System is extremely powerful for tag-based state management and cosmetic effects.
287
+
288
+ ### When to Use Tags Without Attributes
289
+
290
+ You should consider tag-only effects when:
291
+
292
+ - Managing categorical states ("Stunned", "Invisible", "InDialogue")
293
+ - Implementing temporary permissions ("CanDash", "CanDoubleJump")
294
+ - Coordinating system interactions ("InCombat", "InCutscene")
295
+ - Creating purely visual effects (particles, overlays) with timed lifetimes
296
+ - Building capability systems without numeric modifiers
297
+
298
+ ### Example: Pure Tag Effects
299
+
300
+ ```csharp
301
+ // No AttributesComponent needed!
302
+ public class StealthCharacter : MonoBehaviour
303
+ {
304
+ [SerializeField] private AttributeEffect invisibilityEffect;
305
+ [SerializeField] private AttributeEffect stunnedEffect;
306
+
307
+ void Start()
308
+ {
309
+ // Apply invisibility for 5 seconds
310
+ // InvisibilityEffect.asset:
311
+ // - durationType: Duration (5 seconds)
312
+ // - effectTags: ["Invisible", "Stealthy"]
313
+ // - modifications: (EMPTY - no attributes needed!)
314
+ // - cosmeticEffects: shimmer particles
315
+ this.ApplyEffect(invisibilityEffect);
316
+ }
317
+
318
+ void Update()
319
+ {
320
+ // Check tags to gate behavior
321
+ if (this.HasTag("Stunned"))
322
+ {
323
+ // Prevent all actions
324
+ return;
325
+ }
326
+
327
+ // AI can't detect invisible characters
328
+ if (!this.HasTag("Invisible"))
329
+ {
330
+ BroadcastPosition();
331
+ }
332
+ }
333
+ }
334
+ ```
335
+
336
+ ### Example: Tag Lifetimes for Cosmetics
337
+
338
+ Tags with durations provide automatic cleanup for visual effects:
339
+
340
+ ```csharp
341
+ // Create a "ShowDamageIndicator" effect:
342
+ // DamageIndicator.asset:
343
+ // - durationType: Duration (1.5 seconds)
344
+ // - effectTags: ["DamageIndicator"]
345
+ // - modifications: (EMPTY)
346
+ // - cosmeticEffects: DamageNumbersPrefab
347
+
348
+ public class CombatFeedback : MonoBehaviour
349
+ {
350
+ [SerializeField] private AttributeEffect damageIndicator;
351
+
352
+ public void ShowDamage(float amount)
353
+ {
354
+ // Apply effect - cosmetic spawns automatically
355
+ this.ApplyEffect(damageIndicator);
356
+
357
+ // After 1.5 seconds, cosmetic is automatically cleaned up
358
+ // No manual cleanup code needed!
359
+ }
360
+ }
361
+ ```
362
+
363
+ ### Benefits of Tag-Only Usage
364
+
365
+ ✅ **Simpler setup** - No AttributesComponent required
366
+ ✅ **Automatic cleanup** - Duration-based tags clean up themselves
367
+ ✅ **Reference counting** - Multiple sources work naturally
368
+ ✅ **Cosmetic integration** - Visual effects lifecycle managed automatically
369
+ ✅ **System decoupling** - Any system can query tags without dependencies
370
+
371
+ ### Tag-Only Patterns
372
+
373
+ **1. Temporary Permissions:**
374
+
375
+ ```csharp
376
+ // PowerUpEffect.asset:
377
+ // - durationType: Duration (10 seconds)
378
+ // - effectTags: ["CanDash", "CanDoubleJump", "PoweredUp"]
379
+ // - modifications: (EMPTY)
380
+
381
+ public void GrantPowerUp()
382
+ {
383
+ player.ApplyEffect(powerUpEffect);
384
+ // Player now has special abilities for 10 seconds
385
+ }
386
+ ```
387
+
388
+ **2. State Management:**
389
+
390
+ ```csharp
391
+ // DialogueStateEffect.asset:
392
+ // - durationType: Infinite
393
+ // - effectTags: ["InDialogue", "InputDisabled"]
394
+
395
+ EffectHandle? dialogueHandle = player.ApplyEffect(dialogueState);
396
+ // ... dialogue system runs ...
397
+ player.RemoveEffect(dialogueHandle.Value);
398
+ ```
399
+
400
+ **3. Visual-Only Effects:**
401
+
402
+ ```csharp
403
+ // LevelUpEffect.asset:
404
+ // - durationType: Duration (2 seconds)
405
+ // - effectTags: ["LevelingUp"]
406
+ // - cosmeticEffects: GlowParticles, LevelUpSound
407
+
408
+ player.ApplyEffect(levelUpEffect);
409
+ // Particles and sound play, then clean up automatically
410
+ ```
411
+
412
+ ## Cosmetic Effects - Complete Guide
413
+
414
+ Cosmetic effects handle the visual and audio presentation of effects. They provide a clean separation between gameplay logic (tags, attributes) and presentation (particles, sounds, UI).
415
+
416
+ ### Architecture Overview
417
+
418
+ **Component Hierarchy:**
419
+
420
+ ```text
421
+ CosmeticEffectData (Container GameObject/Prefab)
422
+ └─ CosmeticEffectComponent (Base class - abstract)
423
+ └─ Your custom implementations:
424
+ - ParticleCosmeticEffect
425
+ - AudioCosmeticEffect
426
+ - UICosmeticEffect
427
+ - AnimationCosmeticEffect
428
+ ```
429
+
430
+ ### Creating a Cosmetic Effect
431
+
432
+ ### Step 1: Create a prefab with CosmeticEffectData\*\*
433
+
434
+ 1. Create new GameObject in scene
435
+ 2. Add Component → `CosmeticEffectData`
436
+ 3. Add your custom cosmetic components (particle systems, audio sources, etc.)
437
+ 4. Save as prefab
438
+ 5. Reference this prefab in your `AttributeEffect.cosmeticEffects` list
439
+
440
+ ### Step 2: Implement CosmeticEffectComponent subclasses\*\*
441
+
442
+ ```csharp
443
+ using UnityEngine;
444
+ using WallstopStudios.UnityHelpers.Tags;
445
+
446
+ public class ParticleCosmeticEffect : CosmeticEffectComponent
447
+ {
448
+ [SerializeField] private ParticleSystem particles;
449
+
450
+ // RequiresInstance = true creates a new instance per application
451
+ // RequiresInstance = false shares one instance across all applications
452
+ public override bool RequiresInstance => true;
453
+
454
+ // CleansUpSelf = true means you handle destruction yourself
455
+ // CleansUpSelf = false means EffectHandler destroys the GameObject
456
+ public override bool CleansUpSelf => false;
457
+
458
+ public override void OnApplyEffect(GameObject target)
459
+ {
460
+ base.OnApplyEffect(target);
461
+
462
+ // Attach cosmetic to target
463
+ transform.SetParent(target.transform);
464
+ transform.localPosition = Vector3.zero;
465
+
466
+ // Start visual effect
467
+ particles.Play();
468
+ }
469
+
470
+ public override void OnRemoveEffect(GameObject target)
471
+ {
472
+ base.OnRemoveEffect(target);
473
+
474
+ // Stop particles
475
+ particles.Stop();
476
+
477
+ // If CleansUpSelf = false, GameObject is destroyed automatically
478
+ // If CleansUpSelf = true, you must handle destruction
479
+ }
480
+ }
481
+ ```
482
+
483
+ ### RequiresInstance: Shared vs Instanced
484
+
485
+ **RequiresInstance = false (Shared):**
486
+
487
+ - One cosmetic instance is reused for all applications
488
+ - Best for: UI overlays, status icons, shared audio managers
489
+ - Lower memory footprint
490
+ - All targets share the same cosmetic GameObject
491
+
492
+ ```csharp
493
+ public class StatusIconCosmetic : CosmeticEffectComponent
494
+ {
495
+ public override bool RequiresInstance => false; // SHARED
496
+
497
+ [SerializeField] private Image iconImage;
498
+ private int activeCount = 0;
499
+
500
+ public override void OnApplyEffect(GameObject target)
501
+ {
502
+ base.OnApplyEffect(target);
503
+ activeCount++;
504
+
505
+ // Show icon if this is first application
506
+ if (activeCount == 1)
507
+ {
508
+ iconImage.enabled = true;
509
+ }
510
+ }
511
+
512
+ public override void OnRemoveEffect(GameObject target)
513
+ {
514
+ base.OnRemoveEffect(target);
515
+ activeCount--;
516
+
517
+ // Hide icon when no more applications
518
+ if (activeCount == 0)
519
+ {
520
+ iconImage.enabled = false;
521
+ }
522
+ }
523
+ }
524
+ ```
525
+
526
+ **RequiresInstance = true (Instanced):**
527
+
528
+ - New cosmetic instance created for each application
529
+ - Best for: Particles, per-effect animations, independent visuals
530
+ - Each application has isolated state
531
+ - Higher memory cost, but full independence
532
+
533
+ ```csharp
534
+ public class FireParticleCosmetic : CosmeticEffectComponent
535
+ {
536
+ public override bool RequiresInstance => true; // INSTANCED
537
+
538
+ [SerializeField] private ParticleSystem fireParticles;
539
+
540
+ public override void OnApplyEffect(GameObject target)
541
+ {
542
+ base.OnApplyEffect(target);
543
+
544
+ // Each instance is independent
545
+ transform.SetParent(target.transform);
546
+ transform.localPosition = Vector3.zero;
547
+ fireParticles.Play();
548
+ }
549
+ }
550
+ ```
551
+
552
+ ### CleansUpSelf: Automatic vs Manual Cleanup
553
+
554
+ **CleansUpSelf = false (Automatic - Default):**
555
+
556
+ - EffectHandler destroys the GameObject when effect is removed
557
+ - Simplest option for most cases
558
+ - Immediate cleanup
559
+
560
+ ```csharp
561
+ public class SimpleParticleEffect : CosmeticEffectComponent
562
+ {
563
+ public override bool CleansUpSelf => false; // AUTOMATIC
564
+
565
+ public override void OnRemoveEffect(GameObject target)
566
+ {
567
+ base.OnRemoveEffect(target);
568
+ // GameObject destroyed automatically by EffectHandler
569
+ }
570
+ }
571
+ ```
572
+
573
+ **CleansUpSelf = true (Manual Cleanup):**
574
+
575
+ - You are responsible for destroying the GameObject
576
+ - Use when you need delayed cleanup (fade out animations, particle finish)
577
+ - More control over cleanup timing
578
+
579
+ ```csharp
580
+ public class FadeOutEffect : CosmeticEffectComponent
581
+ {
582
+ public override bool CleansUpSelf => true; // MANUAL
583
+
584
+ [SerializeField] private float fadeOutDuration = 1f;
585
+ private bool isRemoving = false;
586
+
587
+ public override void OnRemoveEffect(GameObject target)
588
+ {
589
+ base.OnRemoveEffect(target);
590
+
591
+ if (!isRemoving)
592
+ {
593
+ isRemoving = true;
594
+ StartCoroutine(FadeOutAndDestroy());
595
+ }
596
+ }
597
+
598
+ private IEnumerator FadeOutAndDestroy()
599
+ {
600
+ // Fade out over time
601
+ float elapsed = 0f;
602
+ SpriteRenderer sprite = GetComponent<SpriteRenderer>();
603
+ Color originalColor = sprite.color;
604
+
605
+ while (elapsed < fadeOutDuration)
606
+ {
607
+ elapsed += Time.deltaTime;
608
+ float alpha = 1f - (elapsed / fadeOutDuration);
609
+ sprite.color = new Color(originalColor.r, originalColor.g, originalColor.b, alpha);
610
+ yield return null;
611
+ }
612
+
613
+ // Now safe to destroy
614
+ Destroy(gameObject);
615
+ }
616
+ }
617
+ ```
618
+
619
+ ### Complete Cosmetic Examples
620
+
621
+ #### Example 1: Buff Visual with Particles and Sound\*\*
622
+
623
+ ```csharp
624
+ public class BuffCosmetic : CosmeticEffectComponent
625
+ {
626
+ [SerializeField] private ParticleSystem buffParticles;
627
+ [SerializeField] private AudioSource audioSource;
628
+ [SerializeField] private AudioClip applySound;
629
+ [SerializeField] private AudioClip removeSound;
630
+
631
+ public override bool RequiresInstance => true;
632
+ public override bool CleansUpSelf => false;
633
+
634
+ public override void OnApplyEffect(GameObject target)
635
+ {
636
+ base.OnApplyEffect(target);
637
+
638
+ // Position cosmetic on target
639
+ transform.SetParent(target.transform);
640
+ transform.localPosition = Vector3.zero;
641
+
642
+ // Play effects
643
+ buffParticles.Play();
644
+ audioSource.PlayOneShot(applySound);
645
+ }
646
+
647
+ public override void OnRemoveEffect(GameObject target)
648
+ {
649
+ base.OnRemoveEffect(target);
650
+
651
+ audioSource.PlayOneShot(removeSound);
652
+ buffParticles.Stop();
653
+ // Automatic cleanup after this
654
+ }
655
+ }
656
+ ```
657
+
658
+ #### Example 2: Status UI Overlay (Shared)\*\*
659
+
660
+ ```csharp
661
+ public class StatusOverlayCosmetic : CosmeticEffectComponent
662
+ {
663
+ [SerializeField] private SpriteRenderer overlaySprite;
664
+ [SerializeField] private Color overlayColor = Color.red;
665
+
666
+ public override bool RequiresInstance => false; // SHARED
667
+ public override bool CleansUpSelf => false;
668
+
669
+ private SpriteRenderer targetSprite;
670
+
671
+ public override void OnApplyEffect(GameObject target)
672
+ {
673
+ base.OnApplyEffect(target);
674
+
675
+ targetSprite = target.GetComponent<SpriteRenderer>();
676
+ if (targetSprite != null)
677
+ {
678
+ // Tint the sprite
679
+ targetSprite.color = overlayColor;
680
+ }
681
+ }
682
+
683
+ public override void OnRemoveEffect(GameObject target)
684
+ {
685
+ base.OnRemoveEffect(target);
686
+
687
+ if (targetSprite != null)
688
+ {
689
+ // Restore original color
690
+ targetSprite.color = Color.white;
691
+ }
692
+ }
693
+ }
694
+ ```
695
+
696
+ #### Example 3: Animation Trigger\*\*
697
+
698
+ ```csharp
699
+ public class AnimationCosmetic : CosmeticEffectComponent
700
+ {
701
+ [SerializeField] private string applyTrigger = "BuffApplied";
702
+ [SerializeField] private string removeTrigger = "BuffRemoved";
703
+
704
+ public override bool RequiresInstance => false;
705
+
706
+ public override void OnApplyEffect(GameObject target)
707
+ {
708
+ base.OnApplyEffect(target);
709
+
710
+ Animator animator = target.GetComponent<Animator>();
711
+ if (animator != null)
712
+ {
713
+ animator.SetTrigger(applyTrigger);
714
+ }
715
+ }
716
+
717
+ public override void OnRemoveEffect(GameObject target)
718
+ {
719
+ base.OnRemoveEffect(target);
720
+
721
+ Animator animator = target.GetComponent<Animator>();
722
+ if (animator != null)
723
+ {
724
+ animator.SetTrigger(removeTrigger);
725
+ }
726
+ }
727
+ }
728
+ ```
729
+
730
+ ### Combining Multiple Cosmetics
731
+
732
+ A single effect can have multiple cosmetic components with different behaviors:
733
+
734
+ ```csharp
735
+ // PoisonEffect prefab:
736
+ // - CosmeticEffectData
737
+ // - PoisonParticles (RequiresInstance = true) // One per stack
738
+ // - PoisonStatusIcon (RequiresInstance = false) // Shared UI element
739
+ // - PoisonAudioLoop (RequiresInstance = true) // One audio loop per stack
740
+ ```
741
+
742
+ ### Cosmetic Lifecycle
743
+
744
+ **Application Flow:**
745
+
746
+ 1. `AttributeEffect` applied to GameObject
747
+ 2. `EffectHandler` checks `cosmeticEffects` list
748
+ 3. For each `CosmeticEffectData`:
749
+ - If `RequiresInstancing = true`: Instantiate and parent to target
750
+ - If `RequiresInstancing = false`: Reuse existing instance
751
+ 4. Call `OnApplyEffect(target)` on all components
752
+ 5. Cosmetics remain active while effect is active
753
+
754
+ **Removal Flow:**
755
+
756
+ 1. Effect expires or is manually removed
757
+ 2. `EffectHandler` calls `OnRemoveEffect(target)` on all components
758
+ 3. For each component:
759
+ - If `CleansUpSelf = false`: EffectHandler destroys GameObject immediately
760
+ - If `CleansUpSelf = true`: Component handles its own destruction
761
+
762
+ ### Best Practices
763
+
764
+ **Performance:**
765
+
766
+ - ✅ Prefer `RequiresInstance = false` when possible (lower overhead)
767
+ - ✅ Use object pooling for frequently spawned instanced cosmetics
768
+ - ✅ Keep `OnApplyEffect` and `OnRemoveEffect` lightweight
769
+ - ❌ Avoid expensive operations in these callbacks
770
+
771
+ **Architecture:**
772
+
773
+ - ✅ One responsibility per cosmetic component (particles, audio, UI separate)
774
+ - ✅ Store references in `OnApplyEffect`, use them in `OnRemoveEffect`
775
+ - ✅ Always call `base.OnApplyEffect()` and `base.OnRemoveEffect()`
776
+ - ❌ Don't access gameplay logic from cosmetics (maintain separation)
777
+
778
+ **Cleanup:**
779
+
780
+ - ✅ Use `CleansUpSelf = false` unless you need delayed cleanup
781
+ - ✅ If using `CleansUpSelf = true`, ensure you always destroy the GameObject
782
+ - ✅ Handle null targets gracefully (target may be destroyed early)
783
+ - ❌ Don't leak GameObjects by forgetting to clean up
784
+
154
785
  ## Recipes
155
786
 
156
787
  ### 1) Buff with % Speed for 5s (refreshable)
@@ -158,12 +789,38 @@ if (player.HasTag("Stunned"))
158
789
  - Effect: Multiplication `Speed *= 1.5f`, `Duration=5`, `resetDurationOnReapplication=true`, tag `Haste`.
159
790
  - Apply to extend: reapply before expiry to reset the timer.
160
791
 
161
- ### 2) Poison: −5 Health instantly and "Poisoned" tag for 10s
792
+ ### 2) Poison: "Poisoned" tag for 10s with periodic damage
162
793
 
163
- - modifications: Addition `{ attribute: "Health", value: -5f }`
164
- - durationType: Duration `10s`
794
+ - periodicEffects: add a definition with `interval = 1s`, `maxTicks = 10`, and an empty `modifications` array (ticks drive behaviours)
795
+ - behaviors: attach a `PoisonDamageBehavior` that applies damage during `OnPeriodicTick` (sample below)
796
+ - durationType: Duration `10s` (or Infinite if the periodic schedule should drive expiry)
165
797
  - effectTags: `[ "Poisoned" ]`
166
798
  - cosmetics: particles + UI icon
799
+ - Optional: add an immediate modification for on-apply burst damage
800
+
801
+ ```csharp
802
+ [CreateAssetMenu(menuName = "Combat/Effects/Poison Damage")]
803
+ public sealed class PoisonDamageBehavior : EffectBehavior
804
+ {
805
+ [SerializeField]
806
+ private float damagePerTick = 5f;
807
+
808
+ public override void OnPeriodicTick(
809
+ EffectBehaviorContext context,
810
+ PeriodicEffectTickContext tickContext
811
+ )
812
+ {
813
+ if (!context.Target.TryGetComponent(out PlayerHealth health))
814
+ {
815
+ return;
816
+ }
817
+
818
+ health.ApplyDamage(damagePerTick);
819
+ }
820
+ }
821
+ ```
822
+
823
+ Pair this with a health component that owns mutable current-health state instead of modelling `CurrentHealth` as an Attribute.
167
824
 
168
825
  ### 3) Equipment Aura: +10 Defense while equipped
169
826
 
@@ -178,14 +835,33 @@ if (player.HasTag("Stunned"))
178
835
 
179
836
  ### 5) Stacking Multiple Instances
180
837
 
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.
838
+ - Set `stackingMode` on the effect asset to control reapplication:
839
+ - `Stack` keeps separate handles (respecting `maximumStacks`, trimming the oldest when the cap is reached).
840
+ - `Refresh` reuses the first handle; set `resetDurationOnReapplication = true` if the timer should reset on reapply.
841
+ - `Replace` removes existing handles in the same group before adding a new one.
842
+ - `Ignore` rejects duplicate applications.
843
+ - Use `stackGroup = CustomKey` with a shared `stackGroupKey` when different assets should share a stack identity.
844
+ - Inspect active stacks with `EffectHandler.GetEffectStackCount(effect)` or tag counts for debugging and UI.
183
845
 
184
846
  ### 6) Shared vs Instanced Cosmetics
185
847
 
186
848
  - In `CosmeticEffectData`, set a component’s `RequiresInstance = true` for per‑application instances (e.g., particles).
187
849
  - Keep `RequiresInstance = false` for shared presenters (e.g., status icon overlay).
188
850
 
851
+ ### Periodic Tick Payloads
852
+
853
+ - Populate the `periodicEffects` list on an `AttributeEffect` to schedule damage/heal-over-time, resource regen, or scripted pulses without external coroutines.
854
+ - Each definition supports `initialDelay`, `interval`, and `maxTicks` (0 = infinite) plus its own `AttributeModification` bundle applied on every tick.
855
+ - Periodic payloads run only for Duration/Infinite effects; they automatically stop after `maxTicks` or when the effect handle is removed.
856
+ - Combine multiple definitions for mixed cadences (e.g., fast minor regen + slower burst heals).
857
+
858
+ ### Effect Behaviours
859
+
860
+ - Attach `EffectBehavior` ScriptableObjects to the `behaviors` list for per-handle runtime logic.
861
+ - The system clones behaviours on apply and calls `OnApply`, `OnTick` (each frame), `OnPeriodicTick` (after periodic payloads fire), and `OnRemove`.
862
+ - Behaviours are ideal for integrating bespoke systems (e.g., camera shakes, AI hooks, quest tracking) while keeping designer-authored effects data-driven.
863
+ - Keep behaviours stateless or store per-handle state on the cloned instance; clean up in `OnRemove`.
864
+
189
865
  ## Best Practices
190
866
 
191
867
  - Use Addition for flat changes; Multiplication for percentage changes; Override sparingly (wins last).
@@ -382,9 +1058,153 @@ void LogEffect(EffectType effectType)
382
1058
 
383
1059
  This eliminates the need to manually maintain a `DisplayNames` dictionary as shown in the earlier example—the package already provides optimized caching infrastructure.
384
1060
 
1061
+ ## API Reference
1062
+
1063
+ ### AttributeEffect Query Methods
1064
+
1065
+ **Checking for Tags:**
1066
+
1067
+ ```csharp
1068
+ // Check if effect has a specific tag
1069
+ bool hasTag = effect.HasTag("Haste");
1070
+
1071
+ // Check if effect has any of the specified tags
1072
+ bool hasAny = effect.HasAnyTag(new[] { "Haste", "Speed", "Boost" });
1073
+ bool hasAnyFromList = effect.HasAnyTag(myTagList); // IReadOnlyList<string> overload
1074
+ ```
1075
+
1076
+ **Checking for Attribute Modifications:**
1077
+
1078
+ ```csharp
1079
+ // Check if effect modifies a specific attribute
1080
+ bool modifiesSpeed = effect.ModifiesAttribute("Speed");
1081
+
1082
+ // Get all modifications for a specific attribute
1083
+ using var lease = Buffers<AttributeModification>.List.Get(out List<AttributeModification> mods);
1084
+ effect.GetModifications("Speed", mods);
1085
+ foreach (AttributeModification mod in mods)
1086
+ {
1087
+ Debug.Log($"Action: {mod.action}, Value: {mod.value}");
1088
+ }
1089
+ ```
1090
+
1091
+ ### TagHandler Query Methods
1092
+
1093
+ **Basic Tag Queries:**
1094
+
1095
+ ```csharp
1096
+ // Check if a single tag is active
1097
+ if (player.HasTag("Stunned"))
1098
+ {
1099
+ DisableInput();
1100
+ }
1101
+
1102
+ // Check if any of the tags are active
1103
+ if (player.HasAnyTag(new[] { "Stunned", "Frozen", "Sleeping" }))
1104
+ {
1105
+ PreventMovement();
1106
+ }
1107
+
1108
+ // Check if all tags are active
1109
+ if (player.HasAllTags(new[] { "Wet", "Grounded" }))
1110
+ {
1111
+ ApplyElectricShock();
1112
+ }
1113
+
1114
+ // Check if none of the tags are active
1115
+ if (player.HasNoneOfTags(new[] { "Invulnerable", "Untargetable" }))
1116
+ {
1117
+ AllowDamage();
1118
+ }
1119
+ ```
1120
+
1121
+ **Tag Count Queries:**
1122
+
1123
+ ```csharp
1124
+ // Get the active count for a tag
1125
+ if (player.TryGetTagCount("Poisoned", out int stacks) && stacks >= 3)
1126
+ {
1127
+ TriggerCriticalPoisonWarning();
1128
+ }
1129
+
1130
+ // Get all active tags
1131
+ List<string> activeTags = player.GetActiveTags();
1132
+ foreach (string tag in activeTags)
1133
+ {
1134
+ Debug.Log($"Active tag: {tag}");
1135
+ }
1136
+ ```
1137
+
1138
+ **Collection Type Support:**
1139
+
1140
+ All tag query methods support multiple collection types with optimized implementations:
1141
+
1142
+ - `IReadOnlyList<string>` (optimized with index-based iteration)
1143
+ - `List<string>`
1144
+ - `HashSet<string>`
1145
+ - `SortedSet<string>`
1146
+ - `Queue<string>`
1147
+ - `Stack<string>`
1148
+ - `LinkedList<string>`
1149
+ - Any `IEnumerable<string>`
1150
+
1151
+ ```csharp
1152
+ // Example with different collection types
1153
+ HashSet<string> immunityTags = new() { "Invulnerable", "Immune" };
1154
+ if (player.HasAnyTag(immunityTags))
1155
+ {
1156
+ PreventDamage();
1157
+ }
1158
+
1159
+ List<string> crowdControlTags = new() { "Stunned", "Rooted", "Silenced" };
1160
+ if (player.HasNoneOfTags(crowdControlTags))
1161
+ {
1162
+ EnableAllAbilities();
1163
+ }
1164
+ ```
1165
+
1166
+ ### EffectHandler Query Methods
1167
+
1168
+ **Effect State Queries:**
1169
+
1170
+ ```csharp
1171
+ // Check if a specific effect is currently active
1172
+ if (effectHandler.IsEffectActive(hasteEffect))
1173
+ {
1174
+ ShowHasteIndicator();
1175
+ }
1176
+
1177
+ // Get the stack count for an effect
1178
+ int hasteStacks = effectHandler.GetEffectStackCount(hasteEffect);
1179
+ Debug.Log($"Haste stacks: {hasteStacks}");
1180
+
1181
+ // Get remaining duration for a specific effect instance
1182
+ if (effectHandler.TryGetRemainingDuration(effectHandle, out float remaining))
1183
+ {
1184
+ UpdateDurationUI(remaining);
1185
+ }
1186
+ ```
1187
+
1188
+ **Effect Manipulation:**
1189
+
1190
+ ```csharp
1191
+ // Refresh an effect's duration
1192
+ if (effectHandler.RefreshEffect(effectHandle))
1193
+ {
1194
+ Debug.Log("Effect duration refreshed");
1195
+ }
1196
+
1197
+ // Refresh effect ignoring reapplication policy
1198
+ effectHandler.RefreshEffect(effectHandle, ignoreReapplicationPolicy: true);
1199
+ ```
1200
+
385
1201
  ## FAQ
386
1202
 
387
- Q: Why didn’t I get an `EffectHandle`?
1203
+ Q: Should I use an Attribute for CurrentHealth?
1204
+
1205
+ - **No!** Use Attributes for values primarily modified by the effects system (MaxHealth, Speed, AttackDamage). CurrentHealth is modified by multiple systems (combat, healing, regeneration) and should be a regular field. See "Understanding Attributes: What to Model and What to Avoid" section above for details. Mixing direct mutations with effect modifications causes state conflicts and save/load bugs.
1206
+
1207
+ Q: Why didn't I get an `EffectHandle`?
388
1208
 
389
1209
  - Instant effects modify the base value permanently and do not return a handle (`null`). Duration/Infinite do.
390
1210
 
@@ -404,6 +1224,14 @@ Q: When should I use tags vs checking stats?
404
1224
 
405
1225
  - Use tags to represent categorical states (e.g., Stunned/Poisoned/Invulnerable) independent from numeric values. Check stats for numeric thresholds or calculations.
406
1226
 
1227
+ Q: How do I check if an effect modifies a specific attribute?
1228
+
1229
+ - Use `effect.ModifiesAttribute("AttributeName")` to check if an effect contains modifications for a specific attribute, or `effect.GetModifications("AttributeName", buffer)` to retrieve all modifications for that attribute.
1230
+
1231
+ Q: How do I query tag counts or check multiple tags at once?
1232
+
1233
+ - Use `TryGetTagCount(tag, out int count)` to get the active count for a tag, `HasAllTags(tags)` to check if all tags are active, or `HasNoneOfTags(tags)` to check if none are active.
1234
+
407
1235
  ## Troubleshooting
408
1236
 
409
1237
  - Attribute name doesn’t apply