com.wallstop-studios.unity-helpers 2.0.0 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/format-on-demand.yml +2 -2
- package/.github/workflows/markdown-json.yml +1 -1
- package/.github/workflows/npm-publish.yml +1 -1
- package/.github/workflows/prettier-autofix.yml +4 -4
- package/.github/workflows/yaml-format-lint.yml +1 -1
- package/Docs/EFFECTS_SYSTEM.md +1316 -0
- package/{EFFECTS_SYSTEM_TUTORIAL.md → Docs/EFFECTS_SYSTEM_TUTORIAL.md} +1 -1
- package/{GETTING_STARTED.md → Docs/GETTING_STARTED.md} +10 -8
- package/{GLOSSARY.md → Docs/GLOSSARY.md} +4 -4
- package/Docs/HELPER_UTILITIES.md +885 -0
- package/Docs/HELPER_UTILITIES.md.meta +7 -0
- package/{INDEX.md → Docs/INDEX.md} +107 -62
- package/Docs/MATH_AND_EXTENSIONS.md +1039 -0
- package/{RANDOM_PERFORMANCE.md → Docs/RANDOM_PERFORMANCE.md} +15 -15
- package/{RELATIONAL_COMPONENTS.md → Docs/RELATIONAL_COMPONENTS.md} +21 -3
- package/{SPATIAL_TREES_2D_GUIDE.md → Docs/SPATIAL_TREES_2D_GUIDE.md} +2 -2
- package/{SPATIAL_TREES_3D_GUIDE.md → Docs/SPATIAL_TREES_3D_GUIDE.md} +1 -1
- package/{SPATIAL_TREE_2D_PERFORMANCE.md → Docs/SPATIAL_TREE_2D_PERFORMANCE.md} +64 -64
- package/{SPATIAL_TREE_3D_PERFORMANCE.md → Docs/SPATIAL_TREE_3D_PERFORMANCE.md} +64 -64
- package/Docs/UTILITY_COMPONENTS.md +906 -0
- package/Docs/UTILITY_COMPONENTS.md.meta +7 -0
- package/Docs/VISUAL_COMPONENTS.md +337 -0
- package/Docs/VISUAL_COMPONENTS.md.meta +7 -0
- package/Editor/Sprites/AnimationCopier.cs +3 -3
- package/README.md +69 -62
- package/Runtime/AssemblyInfo.cs +2 -0
- package/Runtime/Core/DataStructure/KDTree3D.cs +1 -1
- package/Runtime/Core/DataStructure/OctTree3D.cs +1 -1
- package/Runtime/Core/Extension/AsyncOperationExtensions.cs +122 -0
- package/Runtime/Core/Helper/Logging/UnityLogTagFormatter.cs +76 -90
- package/Runtime/Core/Serialization/ProtobufUnitySurrogates.cs +24 -29
- package/Runtime/Integrations/Reflex/AssemblyInfo.cs +7 -0
- package/Runtime/Integrations/Reflex/AssemblyInfo.cs.meta +11 -0
- package/Runtime/Integrations/Reflex/ContainerRelationalExtensions.cs +198 -0
- package/Runtime/Integrations/Reflex/ContainerRelationalExtensions.cs.meta +11 -0
- package/Runtime/Integrations/Reflex/RelationalComponentsInstaller.cs +86 -0
- package/Runtime/Integrations/Reflex/RelationalComponentsInstaller.cs.meta +11 -0
- package/Runtime/Integrations/Reflex/RelationalReflexSceneBootstrapper.cs +316 -0
- package/Runtime/Integrations/Reflex/RelationalReflexSceneBootstrapper.cs.meta +11 -0
- package/Runtime/Integrations/Reflex/RelationalSceneAssignmentOptions.cs +86 -0
- package/Runtime/Integrations/Reflex/RelationalSceneAssignmentOptions.cs.meta +11 -0
- package/Runtime/Integrations/Reflex/WallstopStudios.UnityHelpers.Integration.Reflex.asmdef +20 -0
- package/Runtime/Integrations/Reflex/WallstopStudios.UnityHelpers.Integration.Reflex.asmdef.meta +7 -0
- package/Runtime/Integrations/Reflex.meta +8 -0
- package/Runtime/Utils/ScriptableObjectSingleton.cs +1 -1
- package/Samples~/DI - Reflex/README.md +527 -0
- package/Samples~/DI - Reflex/README.md.meta +7 -0
- package/Samples~/DI - Reflex/Scripts/ReflexPaletteService.cs +36 -0
- package/Samples~/DI - Reflex/Scripts/ReflexPaletteService.cs.meta +11 -0
- package/Samples~/DI - Reflex/Scripts/ReflexRelationalConsumer.cs +79 -0
- package/Samples~/DI - Reflex/Scripts/ReflexRelationalConsumer.cs.meta +11 -0
- package/Samples~/DI - Reflex/Scripts/ReflexSampleInstaller.cs +30 -0
- package/Samples~/DI - Reflex/Scripts/ReflexSampleInstaller.cs.meta +11 -0
- package/Samples~/DI - Reflex/Scripts/ReflexSpawner.cs +79 -0
- package/Samples~/DI - Reflex/Scripts/ReflexSpawner.cs.meta +11 -0
- package/Samples~/DI - Reflex/Scripts/Samples.UnityHelpers.DI.Reflex.asmdef +26 -0
- package/Samples~/DI - Reflex/Scripts/Samples.UnityHelpers.DI.Reflex.asmdef.meta +9 -0
- package/Samples~/DI - Reflex/Scripts.meta +8 -0
- package/Samples~/DI - Reflex.meta +8 -0
- package/Samples~/DI - VContainer/README.md +6 -5
- package/Samples~/DI - Zenject/README.md +6 -5
- package/Tests/Editor/Core/Attributes/RelationalComponentAssignerTests.cs +29 -31
- package/Tests/Editor/Integrations/Reflex/ReflexIntegrationCompilationTests.cs +41 -0
- package/Tests/Editor/Integrations/Reflex/ReflexIntegrationCompilationTests.cs.meta +11 -0
- package/Tests/Editor/Integrations/Reflex/WallstopStudios.UnityHelpers.Tests.Editor.Reflex.asmdef +27 -0
- package/Tests/Editor/Integrations/Reflex/WallstopStudios.UnityHelpers.Tests.Editor.Reflex.asmdef.meta +7 -0
- package/Tests/Editor/Integrations/Reflex.meta +8 -0
- package/Tests/Editor/Integrations/VContainer/VContainerRelationalEntryPointTests.cs +15 -16
- package/Tests/Editor/Integrations/VContainer/VContainerRelationalHelpersTests.cs +7 -13
- package/Tests/Editor/Integrations/Zenject/ZenjectRelationalHelpersTests.cs +7 -11
- package/Tests/Editor/Integrations/Zenject/ZenjectRelationalInitializerTests.cs +19 -21
- package/Tests/Editor/PersistentDirectorySettingsTests.cs +0 -1
- package/Tests/Editor/Sprites/AnimationCopierFilterTests.cs +0 -1
- package/Tests/Editor/Sprites/AnimationViewerWindowTests.cs +2 -2
- package/Tests/Editor/Tools/ImageBlurToolTests.cs +1 -1
- package/Tests/Editor/Utils/CommonTestBase.cs +17 -0
- package/Tests/Editor/Utils/ScriptableObjectSingletonCreatorTests.cs +1 -1
- package/Tests/Runtime/Extensions/AsyncOperationExtensionsTests.cs +179 -0
- package/Tests/Runtime/Extensions/RandomExtensionTests.cs +55 -0
- package/Tests/Runtime/Extensions/UnityLogTagFormatterEdgeTests.cs +84 -0
- package/Tests/Runtime/Integrations/Reflex/RelationalComponentsReflexTests.cs +445 -0
- package/Tests/Runtime/Integrations/Reflex/RelationalComponentsReflexTests.cs.meta +11 -0
- package/Tests/Runtime/Integrations/Reflex/WallstopStudios.UnityHelpers.Tests.Runtime.Reflex.asmdef +28 -0
- package/Tests/Runtime/Integrations/Reflex/WallstopStudios.UnityHelpers.Tests.Runtime.Reflex.asmdef.meta +7 -0
- package/Tests/Runtime/Integrations/Reflex.meta +8 -0
- package/Tests/Runtime/Integrations/VContainer/RelationalComponentsVContainerTests.cs +24 -29
- package/Tests/Runtime/Integrations/VContainer/RelationalObjectPoolsVContainerTests.cs +8 -3
- package/Tests/Runtime/Integrations/Zenject/RelationalComponentsZenjectTests.cs +10 -20
- package/Tests/Runtime/Performance/RandomPerformanceTests.cs +1 -1
- package/Tests/Runtime/Performance/SpatialTree2DPerformanceTests.cs +1 -1
- package/Tests/Runtime/Performance/SpatialTree3DPerformanceTests.cs +1 -1
- package/Tests/Runtime/Serialization/JsonRoundtripComprehensiveTests.cs +4 -9
- package/Tests/Runtime/Serialization/ProtoRoundtripComprehensiveTests.cs +13 -13
- package/Tests/Runtime/TestUtils/CommonTestBase.cs +11 -0
- package/Tests/Runtime/TestUtils/ReflexTestSupport.cs +111 -0
- package/Tests/Runtime/TestUtils/ReflexTestSupport.cs.meta +12 -0
- package/Tests/Runtime/Utils/MatchColliderToSpriteTests.cs +4 -4
- package/Tests/TestUtils.meta +8 -0
- package/package.json +6 -1
- package/EFFECTS_SYSTEM.md +0 -242
- package/MATH_AND_EXTENSIONS.md +0 -316
- /package/{CHANGELOG.md → Docs/CHANGELOG.md} +0 -0
- /package/{CHANGELOG.md.meta → Docs/CHANGELOG.md.meta} +0 -0
- /package/{CONTRIBUTING.md → Docs/CONTRIBUTING.md} +0 -0
- /package/{CONTRIBUTING.md.meta → Docs/CONTRIBUTING.md.meta} +0 -0
- /package/{DATA_STRUCTURES.md → Docs/DATA_STRUCTURES.md} +0 -0
- /package/{DATA_STRUCTURES.md.meta → Docs/DATA_STRUCTURES.md.meta} +0 -0
- /package/{EDITOR_TOOLS_GUIDE.md → Docs/EDITOR_TOOLS_GUIDE.md} +0 -0
- /package/{EDITOR_TOOLS_GUIDE.md.meta → Docs/EDITOR_TOOLS_GUIDE.md.meta} +0 -0
- /package/{EFFECTS_SYSTEM.md.meta → Docs/EFFECTS_SYSTEM.md.meta} +0 -0
- /package/{EFFECTS_SYSTEM_TUTORIAL.md.meta → Docs/EFFECTS_SYSTEM_TUTORIAL.md.meta} +0 -0
- /package/{GETTING_STARTED.md.meta → Docs/GETTING_STARTED.md.meta} +0 -0
- /package/{GLOSSARY.md.meta → Docs/GLOSSARY.md.meta} +0 -0
- /package/{HULLS.md → Docs/HULLS.md} +0 -0
- /package/{HULLS.md.meta → Docs/HULLS.md.meta} +0 -0
- /package/{INDEX.md.meta → Docs/INDEX.md.meta} +0 -0
- /package/{LICENSE.md → Docs/LICENSE.md} +0 -0
- /package/{LICENSE.md.meta → Docs/LICENSE.md.meta} +0 -0
- /package/{MATH_AND_EXTENSIONS.md.meta → Docs/MATH_AND_EXTENSIONS.md.meta} +0 -0
- /package/{RANDOM_PERFORMANCE.md.meta → Docs/RANDOM_PERFORMANCE.md.meta} +0 -0
- /package/{REFLECTION_HELPERS.md → Docs/REFLECTION_HELPERS.md} +0 -0
- /package/{REFLECTION_HELPERS.md.meta → Docs/REFLECTION_HELPERS.md.meta} +0 -0
- /package/{RELATIONAL_COMPONENTS.md.meta → Docs/RELATIONAL_COMPONENTS.md.meta} +0 -0
- /package/{SERIALIZATION.md → Docs/SERIALIZATION.md} +0 -0
- /package/{SERIALIZATION.md.meta → Docs/SERIALIZATION.md.meta} +0 -0
- /package/{SINGLETONS.md → Docs/SINGLETONS.md} +0 -0
- /package/{SINGLETONS.md.meta → Docs/SINGLETONS.md.meta} +0 -0
- /package/{SPATIAL_TREES_2D_GUIDE.md.meta → Docs/SPATIAL_TREES_2D_GUIDE.md.meta} +0 -0
- /package/{SPATIAL_TREES_3D_GUIDE.md.meta → Docs/SPATIAL_TREES_3D_GUIDE.md.meta} +0 -0
- /package/{SPATIAL_TREE_2D_PERFORMANCE.md.meta → Docs/SPATIAL_TREE_2D_PERFORMANCE.md.meta} +0 -0
- /package/{SPATIAL_TREE_3D_PERFORMANCE.md.meta → Docs/SPATIAL_TREE_3D_PERFORMANCE.md.meta} +0 -0
- /package/{SPATIAL_TREE_SEMANTICS.md → Docs/SPATIAL_TREE_SEMANTICS.md} +0 -0
- /package/{SPATIAL_TREE_SEMANTICS.md.meta → Docs/SPATIAL_TREE_SEMANTICS.md.meta} +0 -0
- /package/{THIRD_PARTY_NOTICES.md → Docs/THIRD_PARTY_NOTICES.md} +0 -0
- /package/{THIRD_PARTY_NOTICES.md.meta → Docs/THIRD_PARTY_NOTICES.md.meta} +0 -0
|
@@ -0,0 +1,1316 @@
|
|
|
1
|
+
# Effects, Attributes, and Tags — Deep Dive
|
|
2
|
+
|
|
3
|
+
## TL;DR — What Problem This Solves
|
|
4
|
+
|
|
5
|
+
- **⭐ Build buff/debuff systems without writing custom code for every effect.**
|
|
6
|
+
- Data‑driven ScriptableObjects: designers create 100s of effects, programmers build system once.
|
|
7
|
+
- **Time saved: Weeks of boilerplate eliminated + designers empowered to iterate freely.**
|
|
8
|
+
|
|
9
|
+
### ⭐ The Designer Empowerment Killer Feature
|
|
10
|
+
|
|
11
|
+
**The Problem - Hardcoded Effects:**
|
|
12
|
+
|
|
13
|
+
```csharp
|
|
14
|
+
// Every buff needs its own custom MonoBehaviour:
|
|
15
|
+
|
|
16
|
+
public class HasteEffect : MonoBehaviour
|
|
17
|
+
{
|
|
18
|
+
private float duration = 5f;
|
|
19
|
+
private float originalSpeed;
|
|
20
|
+
private PlayerStats player;
|
|
21
|
+
|
|
22
|
+
void Start()
|
|
23
|
+
{
|
|
24
|
+
player = GetComponent<PlayerStats>();
|
|
25
|
+
originalSpeed = player.speed;
|
|
26
|
+
player.speed *= 1.5f; // Apply speed boost
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
void Update()
|
|
30
|
+
{
|
|
31
|
+
duration -= Time.deltaTime;
|
|
32
|
+
if (duration <= 0)
|
|
33
|
+
{
|
|
34
|
+
player.speed = originalSpeed; // Restore
|
|
35
|
+
Destroy(this);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 20 effects × 50 lines each = 1000 lines of repetitive code
|
|
41
|
+
// Designers can't create effects without programmer
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**The Solution - Data-Driven:**
|
|
45
|
+
|
|
46
|
+
```csharp
|
|
47
|
+
// Programmers build system once (Unity Helpers provides this):
|
|
48
|
+
// - AttributesComponent base class
|
|
49
|
+
// - EffectHandler manages application/removal
|
|
50
|
+
// - ScriptableObject authoring
|
|
51
|
+
|
|
52
|
+
// Designers create effects in Editor (NO CODE):
|
|
53
|
+
// 1. Right-click → Create → Attribute Effect
|
|
54
|
+
// 2. Name: "Haste"
|
|
55
|
+
// 3. Add modification: Speed × 1.5
|
|
56
|
+
// 4. Duration: 5 seconds
|
|
57
|
+
// 5. Done!
|
|
58
|
+
|
|
59
|
+
// Apply at runtime (one line):
|
|
60
|
+
target.ApplyEffect(hasteEffect);
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**Designer Workflow:**
|
|
64
|
+
|
|
65
|
+
1. Create effect asset in 30 seconds (no code)
|
|
66
|
+
2. Test in-game immediately
|
|
67
|
+
3. Tweak values and iterate freely
|
|
68
|
+
4. Create variations (Haste II, Haste III) by duplicating assets
|
|
69
|
+
|
|
70
|
+
**Impact:**
|
|
71
|
+
|
|
72
|
+
- **Programmer time saved**: Weeks of boilerplate → system built once
|
|
73
|
+
- **Designer empowerment**: Block creating 100s of effects instantly
|
|
74
|
+
- **Iteration speed**: Change values without code changes/recompiles
|
|
75
|
+
- **Maintainability**: All effects in one system vs scattered scripts
|
|
76
|
+
|
|
77
|
+
Data‑driven gameplay effects that modify stats, apply tags, and drive cosmetic presentation.
|
|
78
|
+
|
|
79
|
+
This guide explains the concepts, how they work together, authoring patterns, recipes, best practices, and FAQs.
|
|
80
|
+
|
|
81
|
+
Visuals
|
|
82
|
+
|
|
83
|
+

|
|
84
|
+
|
|
85
|
+

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