com.wallstop-studios.unity-helpers 2.0.4 → 2.1.1

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 (99) 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/ILIST_SORTING_PERFORMANCE.md +92 -0
  6. package/Docs/ILIST_SORTING_PERFORMANCE.md.meta +7 -0
  7. package/Docs/INDEX.md +10 -1
  8. package/Docs/Images/random_generators.svg +7 -7
  9. package/Docs/RANDOM_PERFORMANCE.md +18 -15
  10. package/Docs/REFLECTION_HELPERS.md +1 -1
  11. package/Docs/RELATIONAL_COMPONENTS.md +51 -6
  12. package/Docs/SERIALIZATION.md +1 -1
  13. package/Docs/SINGLETONS.md +2 -2
  14. package/Docs/SPATIAL_TREES_2D_GUIDE.md +3 -3
  15. package/Docs/SPATIAL_TREES_3D_GUIDE.md +3 -3
  16. package/Docs/SPATIAL_TREE_2D_PERFORMANCE.md +64 -64
  17. package/Docs/SPATIAL_TREE_3D_PERFORMANCE.md +64 -64
  18. package/Docs/SPATIAL_TREE_SEMANTICS.md +7 -7
  19. package/Editor/Core/Helper/AnimationEventHelpers.cs +1 -1
  20. package/Editor/CustomDrawers/WShowIfPropertyDrawer.cs +131 -41
  21. package/Editor/Utils/ScriptableObjectSingletonCreator.cs +175 -18
  22. package/README.md +42 -18
  23. package/Runtime/Core/Extension/IListExtensions.cs +720 -12
  24. package/Runtime/Core/Helper/UnityMainThreadDispatcher.cs +2 -3
  25. package/Runtime/Core/Random/AbstractRandom.cs +52 -5
  26. package/Runtime/Core/Random/DotNetRandom.cs +3 -3
  27. package/Runtime/Core/Random/FlurryBurstRandom.cs +285 -0
  28. package/Runtime/Core/Random/FlurryBurstRandom.cs.meta +3 -0
  29. package/Runtime/Core/Random/IllusionFlow.cs +3 -3
  30. package/Runtime/Core/Random/LinearCongruentialGenerator.cs +3 -3
  31. package/Runtime/Core/Random/PcgRandom.cs +6 -6
  32. package/Runtime/Core/Random/PhotonSpinRandom.cs +387 -0
  33. package/Runtime/Core/Random/PhotonSpinRandom.cs.meta +3 -0
  34. package/Runtime/Core/Random/RomuDuo.cs +3 -3
  35. package/Runtime/Core/Random/SplitMix64.cs +3 -3
  36. package/Runtime/Core/Random/SquirrelRandom.cs +6 -4
  37. package/Runtime/Core/Random/StormDropRandom.cs +271 -0
  38. package/Runtime/Core/Random/StormDropRandom.cs.meta +3 -0
  39. package/Runtime/Core/Random/UnityRandom.cs +3 -3
  40. package/Runtime/Core/Random/WyRandom.cs +6 -4
  41. package/Runtime/Core/Random/XorShiftRandom.cs +3 -3
  42. package/Runtime/Core/Random/XoroShiroRandom.cs +3 -3
  43. package/Runtime/Tags/Attribute.cs +144 -24
  44. package/Runtime/Tags/AttributeEffect.cs +119 -16
  45. package/Runtime/Tags/AttributeMetadataCache.cs +312 -3
  46. package/Runtime/Tags/AttributeModification.cs +59 -29
  47. package/Runtime/Tags/AttributesComponent.cs +20 -0
  48. package/Runtime/Tags/EffectBehavior.cs +171 -0
  49. package/Runtime/Tags/EffectBehavior.cs.meta +4 -0
  50. package/Runtime/Tags/EffectHandle.cs +5 -0
  51. package/Runtime/Tags/EffectHandler.cs +385 -39
  52. package/Runtime/Tags/EffectStackKey.cs +79 -0
  53. package/Runtime/Tags/EffectStackKey.cs.meta +4 -0
  54. package/Runtime/Tags/PeriodicEffectDefinition.cs +102 -0
  55. package/Runtime/Tags/PeriodicEffectDefinition.cs.meta +4 -0
  56. package/Runtime/Tags/PeriodicEffectRuntimeState.cs +40 -0
  57. package/Runtime/Tags/PeriodicEffectRuntimeState.cs.meta +4 -0
  58. package/Samples~/DI - Zenject/README.md +0 -2
  59. package/Tests/Editor/Attributes/WShowIfPropertyDrawerTests.cs +285 -0
  60. package/Tests/Editor/Attributes/WShowIfPropertyDrawerTests.cs.meta +11 -0
  61. package/Tests/Editor/Core/Attributes/RelationalComponentAssignerTests.cs +2 -2
  62. package/Tests/Editor/Tags/AttributeMetadataCacheTests.cs +192 -0
  63. package/Tests/Editor/Tags/AttributeMetadataCacheTests.cs.meta +11 -0
  64. package/{node_modules.meta → Tests/Editor/Tags.meta} +1 -1
  65. package/Tests/Editor/Utils/ScriptableObjectSingletonTests.cs +41 -0
  66. package/Tests/Runtime/Extensions/IListExtensionTests.cs +187 -1
  67. package/Tests/Runtime/Helper/ObjectsTests.cs +3 -3
  68. package/Tests/Runtime/Integrations/Reflex/RelationalComponentsReflexTests.cs +2 -2
  69. package/Tests/Runtime/Performance/IListSortingPerformanceTests.cs +346 -0
  70. package/Tests/Runtime/Performance/IListSortingPerformanceTests.cs.meta +11 -0
  71. package/Tests/Runtime/Performance/RandomPerformanceTests.cs +3 -0
  72. package/Tests/Runtime/Random/FlurryBurstRandomTests.cs +12 -0
  73. package/Tests/Runtime/Random/FlurryBurstRandomTests.cs.meta +3 -0
  74. package/Tests/Runtime/Random/PhotonSpinRandomTests.cs +12 -0
  75. package/Tests/Runtime/Random/PhotonSpinRandomTests.cs.meta +3 -0
  76. package/Tests/Runtime/Random/RandomProtoSerializationTests.cs +14 -0
  77. package/Tests/Runtime/Random/RandomTestBase.cs +39 -4
  78. package/Tests/Runtime/Random/StormDropRandomTests.cs +12 -0
  79. package/Tests/Runtime/Random/StormDropRandomTests.cs.meta +3 -0
  80. package/Tests/Runtime/Serialization/JsonSerializationTest.cs +4 -3
  81. package/Tests/Runtime/Serialization/ProtoInterfaceResolutionEdgeTests.cs +2 -2
  82. package/Tests/Runtime/Serialization/ProtoRootRegistrationTests.cs +1 -1
  83. package/Tests/Runtime/Serialization/ProtoSerializeBehaviorTests.cs +1 -1
  84. package/Tests/Runtime/Tags/AttributeEffectTests.cs +135 -0
  85. package/Tests/Runtime/Tags/AttributeEffectTests.cs.meta +3 -0
  86. package/Tests/Runtime/Tags/AttributeModificationTests.cs +137 -0
  87. package/Tests/Runtime/Tags/AttributeTests.cs +192 -0
  88. package/Tests/Runtime/Tags/AttributeTests.cs.meta +3 -0
  89. package/Tests/Runtime/Tags/EffectBehaviorTests.cs +184 -0
  90. package/Tests/Runtime/Tags/EffectBehaviorTests.cs.meta +3 -0
  91. package/Tests/Runtime/Tags/EffectHandlerTests.cs +618 -0
  92. package/Tests/Runtime/Tags/Helpers/RecordingEffectBehavior.cs +89 -0
  93. package/Tests/Runtime/Tags/Helpers/RecordingEffectBehavior.cs.meta +4 -0
  94. package/Tests/Runtime/Tags/PeriodicEffectDefinitionSerializationTests.cs +92 -0
  95. package/Tests/Runtime/Tags/PeriodicEffectDefinitionSerializationTests.cs.meta +3 -0
  96. package/package.json +1 -1
  97. package/scripts/lint-doc-links.ps1 +156 -11
  98. package/Tests/Runtime/Tags/AttributeDataTests.cs +0 -312
  99. /package/Tests/Runtime/Tags/{AttributeDataTests.cs.meta → AttributeModificationTests.cs.meta} +0 -0
@@ -0,0 +1,271 @@
1
+ namespace WallstopStudios.UnityHelpers.Core.Random
2
+ {
3
+ using System;
4
+ using System.Runtime.Serialization;
5
+ using System.Text.Json.Serialization;
6
+ using ProtoBuf;
7
+ using WallstopStudios.UnityHelpers.Core.Extension;
8
+ using WallstopStudios.UnityHelpers.Core.Helper;
9
+ using WallstopStudios.UnityHelpers.Utils;
10
+
11
+ /// <summary>
12
+ /// StormDrop32: a large-state ARX generator inspired by SHISHUA-style buffer mixing, emphasizing long periods and diffusion.
13
+ /// </summary>
14
+ /// <remarks>
15
+ /// <para>
16
+ /// https://github.com/wileylooper/stormdrop
17
+ /// Ported from <c>wileylooper/stormdrop</c>. The 32-bit variant maintains a 1024-element ring buffer and two 32-bit
18
+ /// accumulators. Each step mixes the current index with the accumulators, rotates, and feeds the buffer to provide
19
+ /// high-quality sequences suitable for heavy simulation workloads.
20
+ /// </para>
21
+ /// <para>Pros:</para>
22
+ /// <list type="bullet">
23
+ /// <item><description>Large period and strong diffusion thanks to the 4 KB buffer.</description></item>
24
+ /// <item><description>Deterministic snapshots via <see cref="RandomState"/>.</description></item>
25
+ /// <item><description>Thread-local access available via <see cref="ThreadLocalRandom{T}.Instance"/>.</description></item>
26
+ /// </list>
27
+ /// <para>Cons:</para>
28
+ /// <list type="bullet">
29
+ /// <item><description>Higher per-instance memory compared to smaller generators.</description></item>
30
+ /// <item><description>Not cryptographically secure.</description></item>
31
+ /// </list>
32
+ /// <para>When to use:</para>
33
+ /// <list type="bullet">
34
+ /// <item><description>Procedural workloads needing long non-overlapping streams or large batches.</description></item>
35
+ /// </list>
36
+ /// <para>When not to use:</para>
37
+ /// <list type="bullet">
38
+ /// <item><description>Memory-constrained contexts; prefer smaller-state generators like FlurryBurst.</description></item>
39
+ /// <item><description>Security/adversarial scenarios.</description></item>
40
+ /// </list>
41
+ /// </remarks>
42
+ /// <example>
43
+ /// <code>
44
+ /// using WallstopStudios.UnityHelpers.Core.Random;
45
+ ///
46
+ /// StormDropRandom rng = new StormDropRandom(seed: 42u);
47
+ /// float noise = rng.NextFloat();
48
+ /// Vector3 point = rng.NextVector3InSphere(10f); // via RandomExtensions
49
+ /// </code>
50
+ /// </example>
51
+ [Serializable]
52
+ [DataContract]
53
+ [ProtoContract(SkipConstructor = true)]
54
+ public sealed class StormDropRandom
55
+ : AbstractRandom,
56
+ IEquatable<StormDropRandom>,
57
+ IComparable,
58
+ IComparable<StormDropRandom>
59
+ {
60
+ private const uint Increment = 1_111_111_111U;
61
+ private const int ElementCount = 1024;
62
+ private const int ElementMask = ElementCount - 1;
63
+ private const int ElementByteSize = ElementCount * sizeof(uint);
64
+ private const int WarmupRounds = 128;
65
+
66
+ public static StormDropRandom Instance => ThreadLocalRandom<StormDropRandom>.Instance;
67
+
68
+ public override RandomState InternalState
69
+ {
70
+ get
71
+ {
72
+ using PooledResource<byte[]> payloadLease = WallstopArrayPool<byte>.Get(
73
+ ElementByteSize,
74
+ out byte[] buffer
75
+ );
76
+ Buffer.BlockCopy(_elements, 0, buffer, 0, ElementByteSize);
77
+
78
+ ulong state1 = ((ulong)_a << 32) | _b;
79
+ return BuildState(
80
+ state1,
81
+ payload: new ArraySegment<byte>(buffer, 0, ElementByteSize)
82
+ );
83
+ }
84
+ }
85
+
86
+ [ProtoMember(6)]
87
+ private uint[] _elements = new uint[ElementCount];
88
+
89
+ [ProtoMember(7)]
90
+ private uint _a;
91
+
92
+ [ProtoMember(8)]
93
+ private uint _b;
94
+
95
+ public StormDropRandom()
96
+ : this(Guid.NewGuid()) { }
97
+
98
+ public StormDropRandom(Guid guid)
99
+ {
100
+ InitializeFromGuid(guid);
101
+ }
102
+
103
+ public StormDropRandom(uint seed)
104
+ {
105
+ uint seedB = seed ^ 0x9E3779B9U;
106
+ if (seedB == 0)
107
+ {
108
+ seedB = 1U;
109
+ }
110
+
111
+ InitializeFromScalars(seed, seedB);
112
+ }
113
+
114
+ public StormDropRandom(uint seedA, uint seedB)
115
+ {
116
+ InitializeFromScalars(seedA, seedB == 0 ? 1U : seedB);
117
+ }
118
+
119
+ [JsonConstructor]
120
+ public StormDropRandom(RandomState internalState)
121
+ {
122
+ _a = (uint)(internalState.State1 >> 32);
123
+ _b = (uint)internalState.State1;
124
+ LoadSerializedElements(internalState._payload);
125
+ RestoreCommonState(internalState);
126
+ }
127
+
128
+ public override uint NextUint()
129
+ {
130
+ unchecked
131
+ {
132
+ uint index = _b & ElementMask;
133
+ uint mix = (_elements[index] ^ _a) + _b;
134
+
135
+ _a = RotateLeft(_a, 17) ^ _b;
136
+ _b += Increment;
137
+
138
+ _elements[_b & ElementMask] += RotateLeft(mix, 13);
139
+
140
+ return mix;
141
+ }
142
+ }
143
+
144
+ public override IRandom Copy()
145
+ {
146
+ return new StormDropRandom(InternalState);
147
+ }
148
+
149
+ public bool Equals(StormDropRandom other)
150
+ {
151
+ if (other == null)
152
+ {
153
+ return false;
154
+ }
155
+
156
+ if (_a != other._a || _b != other._b)
157
+ {
158
+ return false;
159
+ }
160
+
161
+ if (!_elements.AsSpan().SequenceEqual(other._elements))
162
+ {
163
+ return false;
164
+ }
165
+
166
+ return _cachedGaussian == other._cachedGaussian;
167
+ }
168
+
169
+ public override bool Equals(object obj)
170
+ {
171
+ return Equals(obj as StormDropRandom);
172
+ }
173
+
174
+ public int CompareTo(object obj)
175
+ {
176
+ return CompareTo(obj as StormDropRandom);
177
+ }
178
+
179
+ public int CompareTo(StormDropRandom other)
180
+ {
181
+ if (other == null)
182
+ {
183
+ return -1;
184
+ }
185
+
186
+ int comparison = _a.CompareTo(other._a);
187
+ if (comparison != 0)
188
+ {
189
+ return comparison;
190
+ }
191
+
192
+ comparison = _b.CompareTo(other._b);
193
+ if (comparison != 0)
194
+ {
195
+ return comparison;
196
+ }
197
+
198
+ for (int i = 0; i < ElementCount; ++i)
199
+ {
200
+ comparison = _elements[i].CompareTo(other._elements[i]);
201
+ if (comparison != 0)
202
+ {
203
+ return comparison;
204
+ }
205
+ }
206
+
207
+ return 0;
208
+ }
209
+
210
+ public override int GetHashCode()
211
+ {
212
+ return Objects.HashCode(_a, _b);
213
+ }
214
+
215
+ public override string ToString()
216
+ {
217
+ return this.ToJson();
218
+ }
219
+
220
+ private void InitializeFromGuid(Guid guid)
221
+ {
222
+ (ulong seed0, ulong seed1) = RandomUtilities.GuidToUInt64Pair(guid);
223
+ ulong mixer = seed0 ^ (seed1 << 1) ^ 0x9E3779B97F4A7C15UL;
224
+ InitializeFromMixer(ref mixer);
225
+ }
226
+
227
+ private void InitializeFromScalars(uint seedA, uint seedB)
228
+ {
229
+ ulong mixer = ((ulong)seedA << 32) | seedB;
230
+ mixer ^= 0xD2B74407B1CE6E93UL;
231
+ InitializeFromMixer(ref mixer);
232
+ }
233
+
234
+ private void InitializeFromMixer(ref ulong mixer)
235
+ {
236
+ if (_elements == null || _elements.Length != ElementCount)
237
+ {
238
+ _elements = new uint[ElementCount];
239
+ }
240
+
241
+ for (int i = 0; i < ElementCount; ++i)
242
+ {
243
+ _elements[i] = Mix32(ref mixer);
244
+ }
245
+
246
+ _a = Mix32(ref mixer);
247
+ _b = Mix32(ref mixer) | 1U;
248
+
249
+ for (int i = 0; i < WarmupRounds; ++i)
250
+ {
251
+ _ = NextUint();
252
+ }
253
+ }
254
+
255
+ private void LoadSerializedElements(byte[] payload)
256
+ {
257
+ if (_elements == null || _elements.Length != ElementCount)
258
+ {
259
+ _elements = new uint[ElementCount];
260
+ }
261
+
262
+ if (payload != null && payload.Length >= ElementByteSize)
263
+ {
264
+ Buffer.BlockCopy(payload, 0, _elements, 0, ElementByteSize);
265
+ return;
266
+ }
267
+
268
+ Array.Clear(_elements, 0, _elements.Length);
269
+ }
270
+ }
271
+ }
@@ -0,0 +1,3 @@
1
+ fileFormatVersion: 2
2
+ guid: c0ca8e2683c74d1fb77e8f1f5a54b490
3
+ timeCreated: 1761441026
@@ -5,9 +5,6 @@ namespace WallstopStudios.UnityHelpers.Core.Random
5
5
  using System.Text.Json.Serialization;
6
6
  using ProtoBuf;
7
7
 
8
- [Serializable]
9
- [DataContract]
10
- [ProtoContract]
11
8
  /// <summary>
12
9
  /// An adapter over <c>UnityEngine.Random</c> exposing the <see cref="IRandom"/> interface.
13
10
  /// </summary>
@@ -47,6 +44,9 @@ namespace WallstopStudios.UnityHelpers.Core.Random
47
44
  /// // Note: calling UnityEngine.Random elsewhere will affect this sequence.
48
45
  /// </code>
49
46
  /// </example>
47
+ [Serializable]
48
+ [DataContract]
49
+ [ProtoContract]
50
50
  public sealed class UnityRandom : AbstractRandom
51
51
  {
52
52
  public static readonly UnityRandom Instance = new();
@@ -7,15 +7,14 @@ namespace WallstopStudios.UnityHelpers.Core.Random
7
7
  using System.Text.Json.Serialization;
8
8
  using ProtoBuf;
9
9
 
10
- // https://github.com/cocowalla/wyhash-dotnet/blob/master/src/WyHash/WyRng.cs
11
- [Serializable]
12
- [DataContract]
13
- [ProtoContract(SkipConstructor = true)]
14
10
  /// <summary>
15
11
  /// A wyhash-inspired PRNG variant (WyRandom) leveraging multiply-mix operations for speed and good distribution.
16
12
  /// </summary>
17
13
  /// <remarks>
18
14
  /// <para>
15
+ /// Reference implementation: https://github.com/cocowalla/wyhash-dotnet/blob/master/src/WyHash/WyRng.cs
16
+ /// </para>
17
+ /// <para>
19
18
  /// Designed around 64-bit multiply-and-mix steps, this generator is fast and suitable for general-purpose
20
19
  /// randomness and hashing-like use cases. It is not a cryptographic hash nor a CSPRNG.
21
20
  /// </para>
@@ -45,6 +44,9 @@ namespace WallstopStudios.UnityHelpers.Core.Random
45
44
  /// var color = rng.NextColor(); // via RandomExtensions
46
45
  /// </code>
47
46
  /// </example>
47
+ [Serializable]
48
+ [DataContract]
49
+ [ProtoContract(SkipConstructor = true)]
48
50
  public sealed class WyRandom : AbstractRandom
49
51
  {
50
52
  private const ulong Prime0 = 0xa0761d6478bd642f;
@@ -5,9 +5,6 @@ namespace WallstopStudios.UnityHelpers.Core.Random
5
5
  using System.Text.Json.Serialization;
6
6
  using ProtoBuf;
7
7
 
8
- [Serializable]
9
- [DataContract]
10
- [ProtoContract]
11
8
  /// <summary>
12
9
  /// A classic, extremely fast XorShift PRNG with small state and modest quality.
13
10
  /// </summary>
@@ -53,6 +50,9 @@ namespace WallstopStudios.UnityHelpers.Core.Random
53
50
  /// var fast = XorShiftRandom.Instance; // per-thread instance
54
51
  /// </code>
55
52
  /// </example>
53
+ [Serializable]
54
+ [DataContract]
55
+ [ProtoContract]
56
56
  public sealed class XorShiftRandom : AbstractRandom
57
57
  {
58
58
  public static XorShiftRandom Instance => ThreadLocalRandom<XorShiftRandom>.Instance;
@@ -8,9 +8,6 @@ namespace WallstopStudios.UnityHelpers.Core.Random
8
8
  using Helper;
9
9
  using ProtoBuf;
10
10
 
11
- [Serializable]
12
- [DataContract]
13
- [ProtoContract]
14
11
  /// <summary>
15
12
  /// A fast 128-bit state Xoroshiro-based PRNG with good quality and tiny footprint.
16
13
  /// </summary>
@@ -55,6 +52,9 @@ namespace WallstopStudios.UnityHelpers.Core.Random
55
52
  /// var replay = new XoroShiroRandom(state);
56
53
  /// </code>
57
54
  /// </example>
55
+ [Serializable]
56
+ [DataContract]
57
+ [ProtoContract]
58
58
  public sealed class XoroShiroRandom
59
59
  : AbstractRandom,
60
60
  IEquatable<XoroShiroRandom>,
@@ -4,6 +4,7 @@ namespace WallstopStudios.UnityHelpers.Tags
4
4
  using System.Collections.Generic;
5
5
  using System.ComponentModel;
6
6
  using System.Globalization;
7
+ using System.Runtime.CompilerServices;
7
8
  using System.Runtime.Serialization;
8
9
  using System.Text.Json.Serialization;
9
10
  using Core.Extension;
@@ -40,7 +41,6 @@ namespace WallstopStudios.UnityHelpers.Tags
40
41
  /// </para>
41
42
  /// </remarks>
42
43
  [Serializable]
43
- [ProtoContract]
44
44
  public sealed class Attribute
45
45
  : IEquatable<Attribute>,
46
46
  IEquatable<float>,
@@ -91,6 +91,38 @@ namespace WallstopStudios.UnityHelpers.Tags
91
91
 
92
92
  private bool _currentValueCalculated;
93
93
 
94
+ private readonly Dictionary<EffectHandle, List<AttributeModification>> _modifications =
95
+ new();
96
+
97
+ /// <summary>
98
+ /// Initializes a new instance of the <see cref="Attribute"/> class with a base value of 0.
99
+ /// </summary>
100
+ public Attribute()
101
+ : this(0) { }
102
+
103
+ /// <summary>
104
+ /// Initializes a new instance of the <see cref="Attribute"/> class with the specified base value.
105
+ /// </summary>
106
+ /// <param name="value">The base value for this attribute.</param>
107
+ public Attribute(float value)
108
+ {
109
+ _baseValue = value;
110
+ _currentValueCalculated = false;
111
+ }
112
+
113
+ /// <summary>
114
+ /// Initializes a new instance of the <see cref="Attribute"/> class for JSON deserialization.
115
+ /// </summary>
116
+ /// <param name="baseValue">The base value for this attribute.</param>
117
+ /// <param name="currentValue">The cached current value.</param>
118
+ [JsonConstructor]
119
+ public Attribute(float baseValue, float currentValue)
120
+ {
121
+ _baseValue = baseValue;
122
+ _currentValue = currentValue;
123
+ _currentValueCalculated = true;
124
+ }
125
+
94
126
  /// <summary>
95
127
  /// Recalculates the current value by applying all active modifications to the base value.
96
128
  /// Modifications are sorted and applied in order: Addition, Multiplication, then Override.
@@ -99,8 +131,9 @@ namespace WallstopStudios.UnityHelpers.Tags
99
131
  {
100
132
  float calculatedValue = _baseValue;
101
133
  using PooledResource<List<AttributeModification>> modificationBuffer =
102
- Buffers<AttributeModification>.List.Get();
103
- List<AttributeModification> modifications = modificationBuffer.resource;
134
+ Buffers<AttributeModification>.List.Get(
135
+ out List<AttributeModification> modifications
136
+ );
104
137
  foreach (
105
138
  KeyValuePair<EffectHandle, List<AttributeModification>> entry in _modifications
106
139
  )
@@ -108,8 +141,7 @@ namespace WallstopStudios.UnityHelpers.Tags
108
141
  modifications.AddRange(entry.Value);
109
142
  }
110
143
 
111
- modifications.Sort((a, b) => ((int)a.action).CompareTo((int)b.action));
112
-
144
+ modifications.Sort();
113
145
  foreach (AttributeModification attributeModification in modifications)
114
146
  {
115
147
  ApplyAttributeModification(attributeModification, ref calculatedValue);
@@ -119,9 +151,6 @@ namespace WallstopStudios.UnityHelpers.Tags
119
151
  _currentValueCalculated = true;
120
152
  }
121
153
 
122
- private readonly Dictionary<EffectHandle, List<AttributeModification>> _modifications =
123
- new();
124
-
125
154
  /// <summary>
126
155
  /// Implicitly converts an Attribute to its current float value.
127
156
  /// </summary>
@@ -137,32 +166,112 @@ namespace WallstopStudios.UnityHelpers.Tags
137
166
  public static implicit operator Attribute(float value) => new(value);
138
167
 
139
168
  /// <summary>
140
- /// Initializes a new instance of the <see cref="Attribute"/> class with a base value of 0.
169
+ /// Applies a temporary additive modification to the attribute.
141
170
  /// </summary>
142
- public Attribute()
143
- : this(0) { }
171
+ /// <param name="value">The amount to add to the attribute's calculated value.</param>
172
+ /// <returns>
173
+ /// An effect handle that can later be supplied to <see cref="RemoveAttributeModification(EffectHandle)"/>
174
+ /// to revoke this addition.
175
+ /// </returns>
176
+ /// <exception cref="ArgumentException">
177
+ /// Thrown when <paramref name="value"/> is not a finite number.
178
+ /// </exception>
179
+ public EffectHandle Add(float value)
180
+ {
181
+ ValidateInput(value);
182
+
183
+ EffectHandle handle = EffectHandle.CreateInstanceInternal();
184
+ AttributeModification modification = new()
185
+ {
186
+ action = ModificationAction.Addition,
187
+ value = value,
188
+ };
189
+ ApplyAttributeModification(modification, handle);
190
+ return handle;
191
+ }
144
192
 
145
193
  /// <summary>
146
- /// Initializes a new instance of the <see cref="Attribute"/> class with the specified base value.
194
+ /// Applies a temporary subtractive modification to the attribute.
147
195
  /// </summary>
148
- /// <param name="value">The base value for this attribute.</param>
149
- public Attribute(float value)
196
+ /// <param name="value">The amount to subtract from the attribute's calculated value.</param>
197
+ /// <returns>
198
+ /// An effect handle that can later be supplied to <see cref="RemoveAttributeModification(EffectHandle)"/>
199
+ /// to revoke this subtraction.
200
+ /// </returns>
201
+ /// <exception cref="ArgumentException">
202
+ /// Thrown when <paramref name="value"/> is not a finite number.
203
+ /// </exception>
204
+ public EffectHandle Subtract(float value)
150
205
  {
151
- _baseValue = value;
152
- _currentValueCalculated = false;
206
+ ValidateInput(value);
207
+
208
+ EffectHandle handle = EffectHandle.CreateInstanceInternal();
209
+ AttributeModification modification = new()
210
+ {
211
+ action = ModificationAction.Addition,
212
+ // Subtraction is represented as a negative additive modifier to preserve modifier ordering.
213
+ value = -value,
214
+ };
215
+ ApplyAttributeModification(modification, handle);
216
+ return handle;
153
217
  }
154
218
 
155
219
  /// <summary>
156
- /// Initializes a new instance of the <see cref="Attribute"/> class for JSON deserialization.
220
+ /// Applies a temporary division-based modification to the attribute.
157
221
  /// </summary>
158
- /// <param name="baseValue">The base value for this attribute.</param>
159
- /// <param name="currentValue">The cached current value.</param>
160
- [JsonConstructor]
161
- public Attribute(float baseValue, float currentValue)
222
+ /// <param name="value">
223
+ /// The divisor that will be applied to the attribute's calculated value.
224
+ /// </param>
225
+ /// <returns>
226
+ /// An effect handle that can later be supplied to <see cref="RemoveAttributeModification(EffectHandle)"/>
227
+ /// to revoke this division.
228
+ /// </returns>
229
+ /// <exception cref="ArgumentException">
230
+ /// Thrown when <paramref name="value"/> is zero or not a finite number.
231
+ /// </exception>
232
+ public EffectHandle Divide(float value)
162
233
  {
163
- _baseValue = baseValue;
164
- _currentValue = currentValue;
165
- _currentValueCalculated = true;
234
+ ValidateInput(value);
235
+
236
+ if (value == 0f)
237
+ {
238
+ throw new ArgumentException("Cannot divide by zero.", nameof(value));
239
+ }
240
+
241
+ EffectHandle handle = EffectHandle.CreateInstanceInternal();
242
+ AttributeModification modification = new()
243
+ {
244
+ action = ModificationAction.Multiplication,
245
+ // Apply division by multiplying by the reciprocal to maintain multiplication ordering guarantees.
246
+ value = 1f / value,
247
+ };
248
+ ApplyAttributeModification(modification, handle);
249
+ return handle;
250
+ }
251
+
252
+ /// <summary>
253
+ /// Applies a temporary multiplicative modification to the attribute.
254
+ /// </summary>
255
+ /// <param name="value">The multiplier to apply to the attribute's calculated value.</param>
256
+ /// <returns>
257
+ /// An effect handle that can later be supplied to <see cref="RemoveAttributeModification(EffectHandle)"/>
258
+ /// to revoke this multiplication.
259
+ /// </returns>
260
+ /// <exception cref="ArgumentException">
261
+ /// Thrown when <paramref name="value"/> is not a finite number.
262
+ /// </exception>
263
+ public EffectHandle Multiply(float value)
264
+ {
265
+ ValidateInput(value);
266
+
267
+ EffectHandle handle = EffectHandle.CreateInstanceInternal();
268
+ AttributeModification modification = new()
269
+ {
270
+ action = ModificationAction.Multiplication,
271
+ value = value,
272
+ };
273
+ ApplyAttributeModification(modification, handle);
274
+ return handle;
166
275
  }
167
276
 
168
277
  /// <summary>
@@ -173,6 +282,17 @@ namespace WallstopStudios.UnityHelpers.Tags
173
282
  _currentValueCalculated = false;
174
283
  }
175
284
 
285
+ private static void ValidateInput(float value, [CallerMemberName] string caller = null)
286
+ {
287
+ if (!float.IsFinite(value))
288
+ {
289
+ throw new ArgumentException(
290
+ $"Cannot {caller?.ToLowerInvariant()} by infinity or NaN.",
291
+ nameof(value)
292
+ );
293
+ }
294
+ }
295
+
176
296
  /// <summary>
177
297
  /// Applies an attribute modification to this attribute.
178
298
  /// If a handle is provided, the modification is temporary and can be removed.