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
@@ -67,6 +67,9 @@ namespace WallstopStudios.UnityHelpers.Tags
67
67
  List<CosmeticEffectData>
68
68
  > _instancedCosmeticEffects = new();
69
69
 
70
+ private readonly Dictionary<EffectStackKey, List<EffectHandle>> _handlesByStackKey = new();
71
+ private readonly Dictionary<long, EffectStackKey> _stackKeyByHandleId = new();
72
+
70
73
  // Stores expiration time of duration effects (We store by Id because it's much cheaper to iterate Guids than it is EffectHandles
71
74
  private readonly Dictionary<long, float> _effectExpirations = new();
72
75
  private readonly Dictionary<long, EffectHandle> _effectHandlesById = new();
@@ -74,6 +77,9 @@ namespace WallstopStudios.UnityHelpers.Tags
74
77
  // Used only to save allocations in Update()
75
78
  private readonly List<long> _expiredEffectIds = new();
76
79
  private readonly List<EffectHandle> _appliedEffects = new();
80
+ private readonly Dictionary<long, List<PeriodicEffectRuntimeState>> _periodicEffectStates =
81
+ new();
82
+ private readonly Dictionary<long, List<EffectBehavior>> _behaviorsByHandleId = new();
77
83
 
78
84
  private bool _initialized;
79
85
 
@@ -118,51 +124,109 @@ namespace WallstopStudios.UnityHelpers.Tags
118
124
  /// </remarks>
119
125
  public EffectHandle? ApplyEffect(AttributeEffect effect)
120
126
  {
121
- EffectHandle? maybeHandle = null;
127
+ if (effect == null)
128
+ {
129
+ return null;
130
+ }
122
131
 
123
- if (effect.durationType != ModifierDurationType.Instant)
132
+ if (effect.durationType == ModifierDurationType.Instant)
124
133
  {
125
- if (effect.durationType == ModifierDurationType.Duration)
134
+ if (RequiresHandle(effect))
135
+ {
136
+ this.LogWarn(
137
+ $"Effect {effect:json} defines periodic or behaviour data but is Instant. These features require a Duration or Infinite effect."
138
+ );
139
+ }
140
+
141
+ InternalApplyEffect(effect);
142
+ return null;
143
+ }
144
+
145
+ EffectStackKey stackKey = effect.GetStackKey();
146
+ List<EffectHandle> existingHandles = TryGetStackHandles(stackKey);
147
+
148
+ switch (effect.stackingMode)
149
+ {
150
+ case EffectStackingMode.Ignore:
151
+ {
152
+ if (existingHandles is { Count: > 0 })
153
+ {
154
+ return existingHandles[0];
155
+ }
156
+
157
+ break;
158
+ }
159
+ case EffectStackingMode.Refresh:
160
+ {
161
+ if (existingHandles is { Count: > 0 })
162
+ {
163
+ EffectHandle handle = existingHandles[0];
164
+ InternalApplyEffect(handle);
165
+ return handle;
166
+ }
167
+
168
+ break;
169
+ }
170
+ case EffectStackingMode.Replace:
126
171
  {
127
- foreach (EffectHandle appliedEffect in _appliedEffects)
172
+ if (existingHandles is { Count: > 0 })
128
173
  {
129
- if (appliedEffect.effect == null)
174
+ using PooledResource<List<EffectHandle>> handleBufferResource =
175
+ Buffers<EffectHandle>.List.Get(out List<EffectHandle> handleBuffer);
176
+ handleBuffer.AddRange(existingHandles);
177
+ for (int i = 0; i < handleBuffer.Count; ++i)
130
178
  {
131
- continue;
179
+ RemoveEffect(handleBuffer[i]);
132
180
  }
181
+ }
133
182
 
134
- string serializableName = appliedEffect.effect.name;
135
- if (string.Equals(effect.name, serializableName, StringComparison.Ordinal))
183
+ break;
184
+ }
185
+ case EffectStackingMode.Stack:
186
+ {
187
+ if (existingHandles is { Count: > 0 } && effect.maximumStacks > 0)
188
+ {
189
+ while (existingHandles.Count >= effect.maximumStacks)
136
190
  {
137
- maybeHandle = appliedEffect;
138
- break;
191
+ EffectHandle oldestHandle = existingHandles[0];
192
+ RemoveEffect(oldestHandle);
139
193
  }
140
194
  }
141
- }
142
-
143
- maybeHandle ??= EffectHandle.CreateInstance(effect);
144
- }
145
195
 
146
- if (maybeHandle.HasValue)
147
- {
148
- EffectHandle handle = maybeHandle.Value;
149
- InternalApplyEffect(handle);
150
- if (
151
- effect.durationType == ModifierDurationType.Duration
152
- && (effect.resetDurationOnReapplication || !_appliedEffects.Contains(handle))
153
- )
154
- {
155
- long handleId = handle.id;
156
- _effectExpirations[handleId] = Time.time + effect.duration;
157
- _effectHandlesById[handleId] = handle;
196
+ break;
158
197
  }
159
198
  }
160
- else
199
+
200
+ EffectHandle newHandle = EffectHandle.CreateInstance(effect);
201
+ RegisterStackHandle(stackKey, newHandle);
202
+ InternalApplyEffect(newHandle);
203
+ return newHandle;
204
+ }
205
+
206
+ private static bool RequiresHandle(AttributeEffect effect)
207
+ {
208
+ return (effect.periodicEffects is { Count: > 0 })
209
+ || (effect.behaviors is { Count: > 0 });
210
+ }
211
+
212
+ private List<EffectHandle> TryGetStackHandles(EffectStackKey stackKey)
213
+ {
214
+ _ = _handlesByStackKey.TryGetValue(stackKey, out List<EffectHandle> handles);
215
+ return handles;
216
+ }
217
+
218
+ private void RegisterStackHandle(EffectStackKey stackKey, EffectHandle handle)
219
+ {
220
+ long handleId = handle.id;
221
+ _stackKeyByHandleId[handleId] = stackKey;
222
+
223
+ if (!_handlesByStackKey.TryGetValue(stackKey, out List<EffectHandle> handles))
161
224
  {
162
- InternalApplyEffect(effect);
225
+ handles = new List<EffectHandle>();
226
+ _handlesByStackKey.Add(stackKey, handles);
163
227
  }
164
228
 
165
- return maybeHandle;
229
+ handles.Add(handle);
166
230
  }
167
231
 
168
232
  /// <summary>
@@ -173,17 +237,236 @@ namespace WallstopStudios.UnityHelpers.Tags
173
237
  {
174
238
  InternalRemoveEffect(handle);
175
239
  _ = _appliedEffects.Remove(handle);
240
+ DeregisterHandle(handle);
176
241
  }
177
242
 
178
243
  public void RemoveAllEffects()
179
244
  {
180
- foreach (EffectHandle handle in _appliedEffects.ToArray())
245
+ using PooledResource<List<EffectHandle>> handleBufferResource =
246
+ Buffers<EffectHandle>.List.Get(out List<EffectHandle> handleBuffer);
247
+ handleBuffer.AddRange(_appliedEffects);
248
+ foreach (EffectHandle handle in handleBuffer)
181
249
  {
182
- InternalRemoveEffect(handle);
250
+ RemoveEffect(handle);
183
251
  }
184
252
  _appliedEffects.Clear();
185
253
  }
186
254
 
255
+ private void DeregisterHandle(EffectHandle handle)
256
+ {
257
+ long handleId = handle.id;
258
+ if (_stackKeyByHandleId.TryGetValue(handleId, out EffectStackKey stackKey))
259
+ {
260
+ if (_handlesByStackKey.TryGetValue(stackKey, out List<EffectHandle> handles))
261
+ {
262
+ handles.Remove(handle);
263
+ if (handles.Count == 0)
264
+ {
265
+ _handlesByStackKey.Remove(stackKey);
266
+ }
267
+ }
268
+
269
+ _stackKeyByHandleId.Remove(handleId);
270
+ }
271
+
272
+ _periodicEffectStates.Remove(handleId);
273
+
274
+ if (_behaviorsByHandleId.Remove(handleId, out List<EffectBehavior> behaviorInstances))
275
+ {
276
+ EffectBehaviorContext context = new(this, handle, 0f);
277
+ for (int i = 0; i < behaviorInstances.Count; ++i)
278
+ {
279
+ EffectBehavior behavior = behaviorInstances[i];
280
+ if (behavior == null)
281
+ {
282
+ continue;
283
+ }
284
+
285
+ behavior.OnRemove(context);
286
+ Destroy(behavior);
287
+ }
288
+ }
289
+ }
290
+
291
+ /// <summary>
292
+ /// Determines whether the specified effect is currently active on this handler.
293
+ /// </summary>
294
+ /// <param name="effect">The effect to check.</param>
295
+ /// <returns><c>true</c> if at least one handle for the effect is active; otherwise, <c>false</c>.</returns>
296
+ public bool IsEffectActive(AttributeEffect effect)
297
+ {
298
+ if (effect == null)
299
+ {
300
+ return false;
301
+ }
302
+
303
+ for (int i = 0; i < _appliedEffects.Count; ++i)
304
+ {
305
+ EffectHandle handle = _appliedEffects[i];
306
+ if (handle.effect == effect)
307
+ {
308
+ return true;
309
+ }
310
+ }
311
+
312
+ return false;
313
+ }
314
+
315
+ /// <summary>
316
+ /// Gets the number of active handles for the specified effect.
317
+ /// </summary>
318
+ /// <param name="effect">The effect to count.</param>
319
+ /// <returns>The number of active handles associated with <paramref name="effect"/>.</returns>
320
+ public int GetEffectStackCount(AttributeEffect effect)
321
+ {
322
+ if (effect == null)
323
+ {
324
+ return 0;
325
+ }
326
+
327
+ int count = 0;
328
+ for (int i = 0; i < _appliedEffects.Count; ++i)
329
+ {
330
+ EffectHandle handle = _appliedEffects[i];
331
+ if (handle.effect == effect)
332
+ {
333
+ ++count;
334
+ }
335
+ }
336
+
337
+ return count;
338
+ }
339
+
340
+ /// <summary>
341
+ /// Copies all active effect handles into the provided buffer.
342
+ /// </summary>
343
+ /// <param name="buffer">
344
+ /// Optional list to populate. When <c>null</c>, a new list is created. The buffer is cleared before population.
345
+ /// </param>
346
+ /// <returns>The populated buffer containing all currently active effect handles.</returns>
347
+ public List<EffectHandle> GetActiveEffects(List<EffectHandle> buffer = null)
348
+ {
349
+ buffer ??= new List<EffectHandle>();
350
+ buffer.Clear();
351
+ buffer.AddRange(_appliedEffects);
352
+ return buffer;
353
+ }
354
+
355
+ /// <summary>
356
+ /// Attempts to retrieve the remaining duration for the specified effect handle.
357
+ /// </summary>
358
+ /// <param name="handle">The handle to inspect.</param>
359
+ /// <param name="remainingDuration">When this method returns, contains the remaining time in seconds, or zero if unavailable.</param>
360
+ /// <returns><c>true</c> if the handle has a tracked duration; otherwise, <c>false</c>.</returns>
361
+ public bool TryGetRemainingDuration(EffectHandle handle, out float remainingDuration)
362
+ {
363
+ long handleId = handle.id;
364
+ if (!_effectExpirations.TryGetValue(handleId, out float expiration))
365
+ {
366
+ remainingDuration = 0f;
367
+ return false;
368
+ }
369
+
370
+ float timeRemaining = expiration - Time.time;
371
+ if (timeRemaining < 0f)
372
+ {
373
+ timeRemaining = 0f;
374
+ }
375
+
376
+ remainingDuration = timeRemaining;
377
+ return true;
378
+ }
379
+
380
+ /// <summary>
381
+ /// Ensures an effect handle exists for the specified effect, optionally refreshing its duration if already active.
382
+ /// </summary>
383
+ /// <param name="effect">The effect to apply or refresh.</param>
384
+ /// <returns>An active handle for the effect, or <c>null</c> for instant effects.</returns>
385
+ public EffectHandle? EnsureHandle(AttributeEffect effect)
386
+ {
387
+ return EnsureHandle(effect, refreshDuration: true);
388
+ }
389
+
390
+ /// <summary>
391
+ /// Ensures an effect handle exists for the specified effect, optionally refreshing its duration if already active.
392
+ /// </summary>
393
+ /// <param name="effect">The effect to apply or refresh.</param>
394
+ /// <param name="refreshDuration">
395
+ /// When <c>true</c>, attempts to refresh the effect's duration when it is already active and supports reapplication.
396
+ /// </param>
397
+ /// <returns>An active handle for the effect, or <c>null</c> for instant effects.</returns>
398
+ public EffectHandle? EnsureHandle(AttributeEffect effect, bool refreshDuration)
399
+ {
400
+ if (effect == null)
401
+ {
402
+ return null;
403
+ }
404
+
405
+ for (int i = 0; i < _appliedEffects.Count; ++i)
406
+ {
407
+ EffectHandle handle = _appliedEffects[i];
408
+ if (handle.effect == effect)
409
+ {
410
+ if (refreshDuration)
411
+ {
412
+ _ = RefreshEffect(handle);
413
+ }
414
+
415
+ return handle;
416
+ }
417
+ }
418
+
419
+ return ApplyEffect(effect);
420
+ }
421
+
422
+ /// <summary>
423
+ /// Attempts to refresh the duration of the specified effect handle.
424
+ /// </summary>
425
+ /// <param name="handle">The handle to refresh.</param>
426
+ /// <returns><c>true</c> if the duration was refreshed; otherwise, <c>false</c>.</returns>
427
+ public bool RefreshEffect(EffectHandle handle)
428
+ {
429
+ return RefreshEffect(handle, ignoreReapplicationPolicy: false);
430
+ }
431
+
432
+ /// <summary>
433
+ /// Attempts to refresh the duration of the specified effect handle.
434
+ /// </summary>
435
+ /// <param name="handle">The handle to refresh.</param>
436
+ /// <param name="ignoreReapplicationPolicy">
437
+ /// When <c>true</c>, refreshes the duration even if <see cref="AttributeEffect.resetDurationOnReapplication"/> is <c>false</c>.
438
+ /// </param>
439
+ /// <returns><c>true</c> if the duration was refreshed; otherwise, <c>false</c>.</returns>
440
+ public bool RefreshEffect(EffectHandle handle, bool ignoreReapplicationPolicy)
441
+ {
442
+ AttributeEffect effect = handle.effect;
443
+ if (effect == null)
444
+ {
445
+ return false;
446
+ }
447
+
448
+ if (effect.durationType != ModifierDurationType.Duration)
449
+ {
450
+ return false;
451
+ }
452
+
453
+ if (!ignoreReapplicationPolicy && !effect.resetDurationOnReapplication)
454
+ {
455
+ return false;
456
+ }
457
+
458
+ long handleId = handle.id;
459
+ if (!_effectExpirations.ContainsKey(handleId))
460
+ {
461
+ return false;
462
+ }
463
+
464
+ float newExpiration = Time.time + effect.duration;
465
+ _effectExpirations[handleId] = newExpiration;
466
+ _effectHandlesById[handleId] = handle;
467
+ return true;
468
+ }
469
+
187
470
  private void InternalRemoveEffect(EffectHandle handle)
188
471
  {
189
472
  foreach (AttributesComponent attributesComponent in _attributes)
@@ -217,17 +500,24 @@ namespace WallstopStudios.UnityHelpers.Tags
217
500
  _appliedEffects.Add(handle);
218
501
  }
219
502
 
503
+ long handleId = handle.id;
504
+ _effectHandlesById[handleId] = handle;
505
+
220
506
  AttributeEffect effect = handle.effect;
221
507
  if (effect.durationType == ModifierDurationType.Duration)
222
508
  {
223
- if (effect.resetDurationOnReapplication || !exists)
509
+ if (!exists || effect.resetDurationOnReapplication)
224
510
  {
225
- long handleId = handle.id;
226
511
  _effectExpirations[handleId] = Time.time + effect.duration;
227
- _effectHandlesById[handleId] = handle;
228
512
  }
229
513
  }
230
514
 
515
+ if (!exists)
516
+ {
517
+ RegisterPeriodicRuntime(handle);
518
+ RegisterBehaviors(handle);
519
+ }
520
+
231
521
  if (!_initialized && _tagHandler == null)
232
522
  {
233
523
  this.AssignRelationalComponents();
@@ -256,6 +546,13 @@ namespace WallstopStudios.UnityHelpers.Tags
256
546
 
257
547
  private void InternalApplyEffect(AttributeEffect effect)
258
548
  {
549
+ if (effect.durationType == ModifierDurationType.Instant && RequiresHandle(effect))
550
+ {
551
+ this.LogWarn(
552
+ $"Effect {effect:json} defines periodic or behaviour data but is Instant. These features require a Duration or Infinite effect."
553
+ );
554
+ }
555
+
259
556
  if (!_initialized && _tagHandler == null)
260
557
  {
261
558
  this.AssignRelationalComponents();
@@ -280,6 +577,116 @@ namespace WallstopStudios.UnityHelpers.Tags
280
577
  }
281
578
  }
282
579
 
580
+ private void RegisterPeriodicRuntime(EffectHandle handle)
581
+ {
582
+ AttributeEffect effect = handle.effect;
583
+ if (effect.periodicEffects is not { Count: > 0 })
584
+ {
585
+ return;
586
+ }
587
+
588
+ List<PeriodicEffectRuntimeState> runtimeStates = null;
589
+ float startTime = Time.time;
590
+
591
+ foreach (PeriodicEffectDefinition definition in effect.periodicEffects)
592
+ {
593
+ if (definition == null)
594
+ {
595
+ continue;
596
+ }
597
+
598
+ (runtimeStates ??= new List<PeriodicEffectRuntimeState>()).Add(
599
+ new PeriodicEffectRuntimeState(definition, startTime)
600
+ );
601
+ }
602
+
603
+ if (runtimeStates is { Count: > 0 })
604
+ {
605
+ _periodicEffectStates[handle.id] = runtimeStates;
606
+ }
607
+ }
608
+
609
+ private void RegisterBehaviors(EffectHandle handle)
610
+ {
611
+ AttributeEffect effect = handle.effect;
612
+ if (effect.behaviors is not { Count: > 0 })
613
+ {
614
+ return;
615
+ }
616
+
617
+ List<EffectBehavior> instances = null;
618
+ foreach (EffectBehavior behavior in effect.behaviors)
619
+ {
620
+ if (behavior == null)
621
+ {
622
+ continue;
623
+ }
624
+
625
+ EffectBehavior clone = Instantiate(behavior);
626
+ (instances ??= new List<EffectBehavior>()).Add(clone);
627
+ }
628
+
629
+ if (instances is not { Count: > 0 })
630
+ {
631
+ return;
632
+ }
633
+
634
+ EffectBehaviorContext context = new(this, handle, 0f);
635
+ for (int i = 0; i < instances.Count; ++i)
636
+ {
637
+ EffectBehavior instance = instances[i];
638
+ if (instance == null)
639
+ {
640
+ continue;
641
+ }
642
+
643
+ instance.OnApply(context);
644
+ }
645
+
646
+ _behaviorsByHandleId[handle.id] = instances;
647
+ }
648
+
649
+ private void ApplyPeriodicTick(
650
+ EffectHandle handle,
651
+ PeriodicEffectRuntimeState runtimeState,
652
+ float currentTime,
653
+ float deltaTime
654
+ )
655
+ {
656
+ PeriodicEffectDefinition definition = runtimeState.definition;
657
+ if (_attributes is { Count: > 0 } && definition.modifications is { Count: > 0 })
658
+ {
659
+ foreach (AttributesComponent attributesComponent in _attributes)
660
+ {
661
+ attributesComponent.ApplyAttributeModifications(definition.modifications, null);
662
+ }
663
+ }
664
+
665
+ if (
666
+ _behaviorsByHandleId.TryGetValue(handle.id, out List<EffectBehavior> behaviors)
667
+ && behaviors.Count > 0
668
+ )
669
+ {
670
+ EffectBehaviorContext context = new(this, handle, deltaTime);
671
+ PeriodicEffectTickContext tickContext = new(
672
+ definition,
673
+ runtimeState.ExecutedTicks,
674
+ currentTime
675
+ );
676
+
677
+ for (int i = 0; i < behaviors.Count; ++i)
678
+ {
679
+ EffectBehavior behavior = behaviors[i];
680
+ if (behavior == null)
681
+ {
682
+ continue;
683
+ }
684
+
685
+ behavior.OnPeriodicTick(context, tickContext);
686
+ }
687
+ }
688
+ }
689
+
283
690
  private void InternalApplyCosmeticEffects(EffectHandle handle)
284
691
  {
285
692
  if (_instancedCosmeticEffects.ContainsKey(handle))
@@ -332,8 +739,7 @@ namespace WallstopStudios.UnityHelpers.Tags
332
739
  {
333
740
  foreach (CosmeticEffectData cosmeticEffectData in attributeEffect.cosmeticEffects)
334
741
  {
335
- CosmeticEffectData cosmeticEffect = cosmeticEffectData;
336
- if (cosmeticEffect == null)
742
+ if (cosmeticEffectData == null)
337
743
  {
338
744
  this.LogError(
339
745
  $"CosmeticEffectData is null for effect {attributeEffect:json}, cannot determine instancing scheme."
@@ -353,7 +759,7 @@ namespace WallstopStudios.UnityHelpers.Tags
353
759
  Buffers<CosmeticEffectComponent>.List.Get();
354
760
  List<CosmeticEffectComponent> cosmeticEffectsBuffer =
355
761
  cosmeticEffectsResource.resource;
356
- cosmeticEffect.GetComponents(cosmeticEffectsBuffer);
762
+ cosmeticEffectData.GetComponents(cosmeticEffectsBuffer);
357
763
  foreach (CosmeticEffectComponent cosmeticComponent in cosmeticEffectsBuffer)
358
764
  {
359
765
  cosmeticComponent.OnApplyEffect(gameObject);
@@ -442,6 +848,13 @@ namespace WallstopStudios.UnityHelpers.Tags
442
848
  }
443
849
 
444
850
  private void Update()
851
+ {
852
+ ProcessEffectExpirations();
853
+ ProcessBehaviorTicks();
854
+ ProcessPeriodicEffects();
855
+ }
856
+
857
+ private void ProcessEffectExpirations()
445
858
  {
446
859
  if (_effectExpirations.Count <= 0)
447
860
  {
@@ -452,19 +865,131 @@ namespace WallstopStudios.UnityHelpers.Tags
452
865
  float currentTime = Time.time;
453
866
  foreach (KeyValuePair<long, float> entry in _effectExpirations)
454
867
  {
455
- if (entry.Value < currentTime)
868
+ if (entry.Value <= currentTime)
456
869
  {
457
870
  _expiredEffectIds.Add(entry.Key);
458
871
  }
459
872
  }
460
873
 
461
- foreach (long expiredHandleId in _expiredEffectIds)
874
+ for (int i = 0; i < _expiredEffectIds.Count; ++i)
462
875
  {
876
+ long expiredHandleId = _expiredEffectIds[i];
463
877
  if (_effectHandlesById.TryGetValue(expiredHandleId, out EffectHandle expiredHandle))
464
878
  {
465
879
  RemoveEffect(expiredHandle);
466
880
  }
467
881
  }
882
+
883
+ _expiredEffectIds.Clear();
884
+ }
885
+
886
+ private void ProcessBehaviorTicks()
887
+ {
888
+ if (_behaviorsByHandleId.Count <= 0)
889
+ {
890
+ return;
891
+ }
892
+
893
+ using PooledResource<List<long>> behaviorHandleIdsResource = Buffers<long>.List.Get(
894
+ out List<long> behaviorHandleIdsBuffer
895
+ );
896
+ behaviorHandleIdsBuffer.AddRange(_behaviorsByHandleId.Keys);
897
+ float deltaTime = Time.deltaTime;
898
+
899
+ for (int i = 0; i < behaviorHandleIdsBuffer.Count; ++i)
900
+ {
901
+ long handleId = behaviorHandleIdsBuffer[i];
902
+ if (!_effectHandlesById.TryGetValue(handleId, out EffectHandle handle))
903
+ {
904
+ continue;
905
+ }
906
+
907
+ if (!_behaviorsByHandleId.TryGetValue(handleId, out List<EffectBehavior> behaviors))
908
+ {
909
+ continue;
910
+ }
911
+
912
+ EffectBehaviorContext context = new(this, handle, deltaTime);
913
+ for (int j = 0; j < behaviors.Count; ++j)
914
+ {
915
+ EffectBehavior behavior = behaviors[j];
916
+ if (behavior == null)
917
+ {
918
+ continue;
919
+ }
920
+
921
+ behavior.OnTick(context);
922
+ }
923
+ }
924
+ }
925
+
926
+ private void ProcessPeriodicEffects()
927
+ {
928
+ if (_periodicEffectStates.Count <= 0)
929
+ {
930
+ return;
931
+ }
932
+
933
+ float currentTime = Time.time;
934
+ float deltaTime = Time.deltaTime;
935
+ using PooledResource<List<long>> periodicRemovalResource = Buffers<long>.List.Get(
936
+ out List<long> periodicRemovalBuffer
937
+ );
938
+ using PooledResource<List<long>> periodHandleIdsResource = Buffers<long>.List.Get(
939
+ out List<long> periodicHandleIdsBuffer
940
+ );
941
+ periodicHandleIdsBuffer.AddRange(_periodicEffectStates.Keys);
942
+
943
+ for (int handleIndex = 0; handleIndex < periodicHandleIdsBuffer.Count; ++handleIndex)
944
+ {
945
+ long handleId = periodicHandleIdsBuffer[handleIndex];
946
+ if (!_effectHandlesById.TryGetValue(handleId, out EffectHandle handle))
947
+ {
948
+ periodicRemovalBuffer.Add(handleId);
949
+ continue;
950
+ }
951
+
952
+ if (
953
+ !_periodicEffectStates.TryGetValue(
954
+ handleId,
955
+ out List<PeriodicEffectRuntimeState> runtimes
956
+ )
957
+ )
958
+ {
959
+ continue;
960
+ }
961
+
962
+ bool hasActive = false;
963
+
964
+ for (int i = 0; i < runtimes.Count; ++i)
965
+ {
966
+ PeriodicEffectRuntimeState runtimeState = runtimes[i];
967
+ if (runtimeState == null)
968
+ {
969
+ continue;
970
+ }
971
+
972
+ while (runtimeState.TryConsumeTick(currentTime))
973
+ {
974
+ ApplyPeriodicTick(handle, runtimeState, currentTime, deltaTime);
975
+ }
976
+
977
+ if (!runtimeState.IsComplete)
978
+ {
979
+ hasActive = true;
980
+ }
981
+ }
982
+
983
+ if (!hasActive)
984
+ {
985
+ periodicRemovalBuffer.Add(handleId);
986
+ }
987
+ }
988
+
989
+ for (int i = 0; i < periodicRemovalBuffer.Count; ++i)
990
+ {
991
+ _periodicEffectStates.Remove(periodicRemovalBuffer[i]);
992
+ }
468
993
  }
469
994
  }
470
995
  }