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
@@ -18,6 +18,7 @@ namespace WallstopStudios.UnityHelpers.Editor.Utils
18
18
 
19
19
  // Prevents re-entrant execution during domain reloads/asset refreshes
20
20
  private static bool _isEnsuring;
21
+ private static int _assetEditingScopeDepth;
21
22
 
22
23
  // Controls whether informational logs are emitted. Warnings still always log.
23
24
  internal static bool VerboseLogging { get; set; }
@@ -41,6 +42,7 @@ namespace WallstopStudios.UnityHelpers.Editor.Utils
41
42
 
42
43
  _isEnsuring = true;
43
44
  AssetDatabase.StartAssetEditing();
45
+ _assetEditingScopeDepth++;
44
46
  bool anyChanges = false;
45
47
  try
46
48
  {
@@ -94,10 +96,24 @@ namespace WallstopStudios.UnityHelpers.Editor.Utils
94
96
  }
95
97
 
96
98
  string resolvedResourcesRoot = EnsureAndResolveFolderPath(ResourcesRoot);
99
+ if (string.IsNullOrWhiteSpace(resolvedResourcesRoot))
100
+ {
101
+ Debug.LogError(
102
+ "ScriptableObjectSingletonCreator: Unable to resolve required Resources root folder. Aborting singleton auto-creation."
103
+ );
104
+ break;
105
+ }
97
106
 
98
107
  string resourcesSubFolder = GetResourcesSubFolder(derivedType);
99
108
  string targetFolderRequested = CombinePaths(ResourcesRoot, resourcesSubFolder);
100
109
  string targetFolder = EnsureAndResolveFolderPath(targetFolderRequested);
110
+ if (string.IsNullOrWhiteSpace(targetFolder))
111
+ {
112
+ Debug.LogError(
113
+ $"ScriptableObjectSingletonCreator: Unable to ensure folder '{targetFolderRequested}' for singleton {derivedType.FullName}. Skipping asset creation."
114
+ );
115
+ continue;
116
+ }
101
117
 
102
118
  string targetAssetPath = CombinePaths(
103
119
  targetFolder,
@@ -156,6 +172,10 @@ namespace WallstopStudios.UnityHelpers.Editor.Utils
156
172
  finally
157
173
  {
158
174
  AssetDatabase.StopAssetEditing();
175
+ if (_assetEditingScopeDepth > 0)
176
+ {
177
+ _assetEditingScopeDepth--;
178
+ }
159
179
  _isEnsuring = false;
160
180
 
161
181
  if (anyChanges)
@@ -448,31 +468,119 @@ namespace WallstopStudios.UnityHelpers.Editor.Utils
448
468
  {
449
469
  string desiredName = parts[i];
450
470
 
451
- // Find an existing subfolder that matches the desired segment, ignoring case
452
- string[] subFolders = AssetDatabase.GetSubFolders(current);
453
- string matchedExisting = null;
454
- if (subFolders != null)
471
+ string matchedExisting = FindMatchingSubfolder(current, desiredName);
472
+
473
+ if (string.IsNullOrEmpty(matchedExisting))
455
474
  {
456
- foreach (string sub in subFolders)
475
+ string intendedPath = current + "/" + desiredName;
476
+ string createdGuid = AssetDatabase.CreateFolder(current, desiredName);
477
+ string createdPath = string.Empty;
478
+ if (!string.IsNullOrEmpty(createdGuid))
479
+ {
480
+ createdPath = NormalizePath(AssetDatabase.GUIDToAssetPath(createdGuid));
481
+ }
482
+
483
+ string actualPath = FindMatchingSubfolder(current, desiredName);
484
+ if (string.IsNullOrEmpty(actualPath))
485
+ {
486
+ actualPath = createdPath;
487
+ }
488
+
489
+ bool intendedValid = AssetDatabase.IsValidFolder(intendedPath);
490
+ bool actualValid =
491
+ !string.IsNullOrEmpty(actualPath)
492
+ && AssetDatabase.IsValidFolder(actualPath);
493
+
494
+ if (!intendedValid && !actualValid)
495
+ {
496
+ bool directoryExists =
497
+ Directory.Exists(intendedPath)
498
+ || (!string.IsNullOrEmpty(actualPath) && Directory.Exists(actualPath));
499
+ if (directoryExists || !string.IsNullOrEmpty(createdGuid))
500
+ {
501
+ ForceAssetDatabaseSync();
502
+ }
503
+
504
+ intendedValid = AssetDatabase.IsValidFolder(intendedPath);
505
+ if (!intendedValid)
506
+ {
507
+ actualPath = FindMatchingSubfolder(current, desiredName);
508
+ if (
509
+ string.IsNullOrEmpty(actualPath)
510
+ && !string.IsNullOrEmpty(createdGuid)
511
+ )
512
+ {
513
+ actualPath = NormalizePath(
514
+ AssetDatabase.GUIDToAssetPath(createdGuid)
515
+ );
516
+ }
517
+
518
+ actualValid =
519
+ !string.IsNullOrEmpty(actualPath)
520
+ && AssetDatabase.IsValidFolder(actualPath);
521
+ }
522
+ else
523
+ {
524
+ actualPath = intendedPath;
525
+ actualValid = true;
526
+ }
527
+ }
528
+
529
+ if (intendedValid)
457
530
  {
458
- // sub is like "Assets/Resources" — compare only the terminal name
459
- int lastSlash = sub.LastIndexOf('/', sub.Length - 1);
460
- string terminal = lastSlash >= 0 ? sub.Substring(lastSlash + 1) : sub;
461
- if (
462
- string.Equals(terminal, desiredName, StringComparison.OrdinalIgnoreCase)
531
+ current = ResolveExistingFolderPath(intendedPath);
532
+ LogVerbose(
533
+ $"ScriptableObjectSingletonCreator: Created folder '{current}'."
534
+ );
535
+ continue;
536
+ }
537
+
538
+ if (
539
+ actualValid
540
+ && string.Equals(
541
+ actualPath,
542
+ intendedPath,
543
+ StringComparison.OrdinalIgnoreCase
463
544
  )
545
+ )
546
+ {
547
+ string renameError = AssetDatabase.MoveAsset(actualPath, intendedPath);
548
+ if (string.IsNullOrEmpty(renameError))
549
+ {
550
+ LogVerbose(
551
+ $"ScriptableObjectSingletonCreator: Renamed folder '{actualPath}' to '{intendedPath}' to correct casing."
552
+ );
553
+ current = ResolveExistingFolderPath(intendedPath);
554
+ continue;
555
+ }
556
+
557
+ LogVerbose(
558
+ $"ScriptableObjectSingletonCreator: Reusing newly created folder '{actualPath}' when casing correction to '{intendedPath}' failed: {renameError}."
559
+ );
560
+ current = ResolveExistingFolderPath(actualPath);
561
+ continue;
562
+ }
563
+
564
+ if (actualValid && AssetDatabase.IsValidFolder(actualPath))
565
+ {
566
+ bool deleted = AssetDatabase.DeleteAsset(actualPath);
567
+ if (!deleted)
464
568
  {
465
- matchedExisting = sub;
466
- break;
569
+ Debug.LogWarning(
570
+ $"ScriptableObjectSingletonCreator: Unexpected folder '{actualPath}' was created while attempting to create '{intendedPath}', but it could not be removed."
571
+ );
467
572
  }
573
+
574
+ Debug.LogError(
575
+ $"ScriptableObjectSingletonCreator: Expected to create folder '{intendedPath}', but Unity created '{actualPath}'. Aborting to avoid duplicate folders."
576
+ );
577
+ return string.Empty;
468
578
  }
469
- }
470
579
 
471
- if (string.IsNullOrEmpty(matchedExisting))
472
- {
473
- AssetDatabase.CreateFolder(current, desiredName);
474
- current = current + "/" + desiredName;
475
- LogVerbose($"ScriptableObjectSingletonCreator: Created folder '{current}'.");
580
+ Debug.LogError(
581
+ $"ScriptableObjectSingletonCreator: Failed to create folder '{intendedPath}'."
582
+ );
583
+ return string.Empty;
476
584
  }
477
585
  else
478
586
  {
@@ -561,6 +669,33 @@ namespace WallstopStudios.UnityHelpers.Editor.Utils
561
669
  return current;
562
670
  }
563
671
 
672
+ private static string FindMatchingSubfolder(string parent, string desiredName)
673
+ {
674
+ if (string.IsNullOrWhiteSpace(parent) || string.IsNullOrWhiteSpace(desiredName))
675
+ {
676
+ return null;
677
+ }
678
+
679
+ string[] subFolders = AssetDatabase.GetSubFolders(parent);
680
+ if (subFolders == null || subFolders.Length == 0)
681
+ {
682
+ return null;
683
+ }
684
+
685
+ for (int i = 0; i < subFolders.Length; i++)
686
+ {
687
+ string sub = subFolders[i];
688
+ int lastSlash = sub.LastIndexOf('/', sub.Length - 1);
689
+ string terminal = lastSlash >= 0 ? sub.Substring(lastSlash + 1) : sub;
690
+ if (string.Equals(terminal, desiredName, StringComparison.OrdinalIgnoreCase))
691
+ {
692
+ return sub;
693
+ }
694
+ }
695
+
696
+ return null;
697
+ }
698
+
564
699
  private static string ResolveExistingFolderPath(string intended)
565
700
  {
566
701
  if (string.IsNullOrWhiteSpace(intended))
@@ -621,6 +756,28 @@ namespace WallstopStudios.UnityHelpers.Editor.Utils
621
756
  return current;
622
757
  }
623
758
 
759
+ private static void ForceAssetDatabaseSync()
760
+ {
761
+ if (_assetEditingScopeDepth > 0)
762
+ {
763
+ AssetDatabase.StopAssetEditing();
764
+ try
765
+ {
766
+ AssetDatabase.SaveAssets();
767
+ AssetDatabase.Refresh();
768
+ }
769
+ finally
770
+ {
771
+ AssetDatabase.StartAssetEditing();
772
+ }
773
+ }
774
+ else
775
+ {
776
+ AssetDatabase.SaveAssets();
777
+ AssetDatabase.Refresh();
778
+ }
779
+ }
780
+
624
781
  private static void LogVerbose(string message)
625
782
  {
626
783
  if (VerboseLogging)
package/README.md CHANGED
@@ -77,7 +77,7 @@ void Awake() {
77
77
  void Awake() => this.AssignRelationalComponents();
78
78
  ```
79
79
 
80
- **Bonus:** Works with VContainer/Zenject for automatic DI + relational wiring!
80
+ **Bonus:** Works with VContainer/Zenject/Reflex for automatic DI + relational wiring!
81
81
 
82
82
  [📖 Learn More](Docs/RELATIONAL_COMPONENTS.md) | [🎯 DI – VContainer](Samples~/DI%20-%20VContainer/README.md) | [🎯 DI – Zenject](Samples~/DI%20-%20Zenject/README.md) | [🎯 DI – Reflex](Samples~/DI%20-%20Reflex/README.md)
83
83
 
@@ -1018,7 +1018,7 @@ void ProcessLargeDataset(int size)
1018
1018
 
1019
1019
  - When the define is present, optional assemblies under `Runtime/Integrations/*` compile automatically and expose helpers like `RelationalComponentsInstaller` (Zenject/Reflex) and `RegisterRelationalComponents()` (VContainer).
1020
1020
  - If you use UPM, no manual defines are required — the package IDs above trigger symbols via `versionDefines` in the asmdefs.
1021
- - For test scenarios without LifetimeScope (VContainer) or SceneContext (Zenject), see [DI Integrations: Testing and Edge Cases](Docs/RELATIONAL_COMPONENTS.md#di-integrations-testing-and-edge-cases) for step‑by‑step patterns.
1021
+ - For test scenarios without LifetimeScope (VContainer), SceneContext (Zenject), or SceneScope (Reflex), see [DI Integrations: Testing and Edge Cases](Docs/RELATIONAL_COMPONENTS.md#di-integrations-testing-and-edge-cases) for step‑by‑step patterns.
1022
1022
 
1023
1023
  **Quick start:**
1024
1024
 
@@ -1046,6 +1046,12 @@ using Zenject;
1046
1046
  using WallstopStudios.UnityHelpers.Integrations.Zenject;
1047
1047
 
1048
1048
  var enemy = Container.InstantiateComponentWithRelations(enemyPrefab, parent);
1049
+
1050
+ // Reflex — prefab instantiation with DI + relations
1051
+ using Reflex.Core;
1052
+ using WallstopStudios.UnityHelpers.Integrations.Reflex;
1053
+
1054
+ var enemy = container.InstantiateComponentWithRelations(enemyPrefab, parent);
1049
1055
  ```
1050
1056
 
1051
1057
  See the full guide with scenarios, troubleshooting, and testing patterns: [Relational Components Guide](Docs/RELATIONAL_COMPONENTS.md)
@@ -1064,10 +1070,17 @@ See the full guide with scenarios, troubleshooting, and testing patterns: [Relat
1064
1070
  - `container.InjectGameObjectWithRelations(root, includeInactiveChildren)` — inject hierarchy + assign
1065
1071
  - `container.InstantiateGameObjectWithRelations(prefab, parent)` — instantiate GO + inject + assign
1066
1072
 
1073
+ - Reflex:
1074
+ - `container.InjectWithRelations(component)` — inject + assign a single instance
1075
+ - `container.InstantiateComponentWithRelations(prefab, parent)` — instantiate + inject + assign
1076
+ - `container.InjectGameObjectWithRelations(root, includeInactiveChildren)` — inject hierarchy + assign
1077
+ - `container.InstantiateGameObjectWithRelations(prefab, parent)` — instantiate GO + inject + assign
1078
+
1067
1079
  ### Additive Scene Loads
1068
1080
 
1069
1081
  - VContainer: `RegisterRelationalComponents(..., enableAdditiveSceneListener: true)` registers a listener that hydrates components in newly loaded scenes.
1070
- - Zenject: `RelationalComponentsInstaller` exposes a toggle Listen For Additive Scenes to register the same behavior.
1082
+ - Zenject: `RelationalComponentsInstaller` exposes a toggle "Listen For Additive Scenes" to register the same behavior.
1083
+ - Reflex: `RelationalComponentsInstaller` exposes a toggle "Listen For Additive Scenes" to register the same behavior.
1071
1084
  - Only the newly loaded scene is processed; other loaded scenes are not re‑scanned.
1072
1085
 
1073
1086
  ### Performance Options
@@ -1076,6 +1089,7 @@ See the full guide with scenarios, troubleshooting, and testing patterns: [Relat
1076
1089
  - Single-pass scan (default) reduces `FindObjectsOfType` calls by scanning once and checking type ancestry.
1077
1090
  - VContainer: `new RelationalSceneAssignmentOptions(includeInactive: true, useSinglePassScan: true)`
1078
1091
  - Zenject: `new RelationalSceneAssignmentOptions(includeInactive: true, useSinglePassScan: true)`
1092
+ - Reflex: `new RelationalSceneAssignmentOptions(includeInactive: true, useSinglePassScan: true)`
1079
1093
  - Per-object paths (instantiate/inject helpers, pools) avoid global scans entirely for objects created via DI.
1080
1094
 
1081
1095
  ---
@@ -2,6 +2,7 @@ namespace WallstopStudios.UnityHelpers.Core.Helper
2
2
  {
3
3
  using System;
4
4
  using System.Collections.Concurrent;
5
+ using System.Threading.Tasks;
5
6
  using UnityEngine;
6
7
  using Utils;
7
8
  #if UNITY_EDITOR
@@ -104,7 +105,8 @@ namespace WallstopStudios.UnityHelpers.Core.Helper
104
105
  /// </summary>
105
106
  public System.Threading.Tasks.Task RunAsync(Action action)
106
107
  {
107
- var tcs = new System.Threading.Tasks.TaskCompletionSource<bool>();
108
+ TaskCompletionSource<bool> tcs =
109
+ new System.Threading.Tasks.TaskCompletionSource<bool>();
108
110
  RunOnMainThread(() =>
109
111
  {
110
112
  try
@@ -125,7 +127,7 @@ namespace WallstopStudios.UnityHelpers.Core.Helper
125
127
  /// </summary>
126
128
  public System.Threading.Tasks.Task<T> Post<T>(Func<T> func)
127
129
  {
128
- var tcs = new System.Threading.Tasks.TaskCompletionSource<T>();
130
+ TaskCompletionSource<T> tcs = new System.Threading.Tasks.TaskCompletionSource<T>();
129
131
  RunOnMainThread(() =>
130
132
  {
131
133
  try
@@ -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.