From 45c42621cdf4638d1c0afb92b5e8b1a48ad1faee Mon Sep 17 00:00:00 2001 From: Chris3606 Date: Mon, 10 Jul 2023 23:56:46 -0500 Subject: [PATCH 1/8] Fixed nullablility annotations for Effects system. --- .../articles/howtos/effects-system.md | 44 ++++++ GoRogue.Snippets/HowTos/EffectsSystem.cs | 143 ++++++++++++++++++ GoRogue.UnitTests/EffectTests.cs | 14 +- GoRogue.UnitTests/Mocks/MockEffects.cs | 2 +- GoRogue/Effect.cs | 6 +- GoRogue/EffectTrigger.cs | 6 +- 6 files changed, 201 insertions(+), 14 deletions(-) create mode 100644 GoRogue.Docs/articles/howtos/effects-system.md create mode 100644 GoRogue.Snippets/HowTos/EffectsSystem.cs diff --git a/GoRogue.Docs/articles/howtos/effects-system.md b/GoRogue.Docs/articles/howtos/effects-system.md new file mode 100644 index 00000000..1c7d51e1 --- /dev/null +++ b/GoRogue.Docs/articles/howtos/effects-system.md @@ -0,0 +1,44 @@ +--- +title: Effects System +--- + +# Overview +The Effects system exists to provide a class structure suitable for representing any "effect" in-game. These could include dealing damage, healing a target, area-based effects, over-time effects, or even permanent/conditional modifiers. The system provides the capability for effects to have duration in arbitrary units, from instantaneous (immediate), to infinite (activates whenever a certain event happens, forever). + +# Effects +At its core, the `Effect` class is an abstract class that should be subclassed in order to define what a given effect does. It defines an abstract `OnTrigger` method that, when called, should take all needed actions to cause a particular effect. The (non abstract) public `Trigger()` function should be called to trigger the effect, as it calls the `OnTrigger` function, as well as decrements the remaining duration on an effect (if it is not instantaneous or infinite). + +## Parameters of Effects +Different effects may need vastly different types and numbers of parameters passed to the `Trigger()` function in order to work properly. For this reason, `Effect` takes a generic type parameter which indicates the type of the (single) argument that will be passed to the `Trigger` function. In order to enable some advanced functionality with [EffectTriggers](#duration-of-effects-and-effecttrigger), all types used as the value for this type parameter must inherit from the class `EffectArgs`. It is also possible to pass multiple parameters to the `Trigger` function -- you can simply create a class/struct that wraps all the values you need to pass into one type, and use that as the type parameter when subclassing. This will be demonstrated in a later code example. + +## Constructing Effects +Each effect takes a string parameter representing its name (for display purposes), and an integer variable representing its duration. Duration (including infinite and instant duration effects), are covered in more depth below. + +## Creating a Subclass +For the sake of a concise code example, we will create a small code example which takes a Monster class with an HP field, and creates an effect to apply basic damage. + +[!code-csharp[](../../../GoRogue.Snippets/HowTos/EffectsSystem.cs#EffectsBasicExample)] + +# Duration of Effects and EffectTrigger +The code example above may appear to be excessively large for such a simple task; the advantage of using `Effect` for this type of functionality lies in Effect's capability for durations. `Effect` takes as a constructor parameter an integer duration. This duration can either be an integer constant in range `[1, int.MaxValue]`, or one of two special (static) constants. These constants are either `Effect.Instant`, which represents effects that simply take place whenever their `Trigger()` function is called and do not partake in the duration system, or `Effect.Infinite`, which represents and effect that has an infinite duration. + +The duration value is in no particular unit of measurement, other than "number of times `Trigger()` is called". In fact, the duration value means very little by itself -- rather, any non-instant effect is explicitly meant to be used with an `EffectTrigger`. `EffectTrigger` is, in essence, a highly augmented list of `Effect` instances that all take the same parameter to their `Trigger()` function. It has a method that calls the `Trigger()` functions of all Effects in its list (which modifies the duration value for the `Effect` as appropriate), then removes any effect from the list whose durations have reached 0. It also allows any effect in the list to "cancel" the trigger, preventing the `Trigger()` functions in subsequent effects from being called. In this way, `EffectTrigger` provides a convenient way to manage duration-based effects. + +## Creating an EffectTrigger +When we create an `EffectTrigger`, we must specify a type parameter. This is the same type parameter that we specified when dealing with effects -- it is the type of the argument passed to the `Trigger()` function of effects it holds, and the type used must subclass `EffectArgs`. Only `Effect` instances taking this specified type to their `Trigger()` function may be added to that `EffectTrigger`. For example, if you have an instance of type `EffectTrigger`, only `Effect` instances may be added to it -- eg. only `Effect` instances that take an argument of type `DamageArgs` to their `Trigger()` function. + +## Adding Effects +`Effect` instances can be added to an `EffectTrigger` by calling the `Add()` function, and passing the `Effect` to add. Such an effect will automatically have its `Trigger()` method called next time the effect trigger's [TriggerEffects](#triggering-added-effects) function is called. If an effect with duration 0 (instant or expired duration) is added, an exception is thrown. + +## Triggering Added Effects +Once effects have been added, all the effects may be triggered with a single call to the `TriggerEffects()` function. When this function is called, all effects that have been added to the `EffectTrigger` have their `Trigger()` function called. If any of the effects set the `CancelTrigger` field of their argument to true, the trigger is "cancelled", and no subsequent effects will have their `Trigger()` function called. + +## A Code Example +In this example, we will utilize the `Damage` effect written in the previous code example to create and demonstrate instantaneous, damage-over-time, and infinite damage-over-time effects. + +[!code-csharp[](../../../GoRogue.Snippets/HowTos/EffectsSystem.cs#EffectsAdvancedExample)] + +# Conditional-Duration Effects +We can also represent effects that have arbitrary, or conditional durations, via the infinite-duration capability. + +For example, consider a healing effect that heals the player, but only when there is at least one enemy within a certain radius at the beginning of a turn. We could easily implement such an effect by giving this effect infinite duration and adding it to an `EffectTrigger` that has its `TriggerEffects()` function called at the beginning of the turn. The `OnTrigger()` implementation could do any relevant checking as to whether or not an enemy is in range. Furthermore, if we wanted to permanently cancel this effect as soon as there was no longer an enemy within the radius, we could simply set the effect's duration to 0 in the `OnTrigger()` implementation when it does not detect an enemy, and the effect would be automatically removed from its `EffectTrigger`. \ No newline at end of file diff --git a/GoRogue.Snippets/HowTos/EffectsSystem.cs b/GoRogue.Snippets/HowTos/EffectsSystem.cs new file mode 100644 index 00000000..f0c14b76 --- /dev/null +++ b/GoRogue.Snippets/HowTos/EffectsSystem.cs @@ -0,0 +1,143 @@ +using GoRogue.DiceNotation; + +namespace GoRogue.Snippets.HowTos +{ + #region EffectsBasicExample + public static class EffectsBasicExample + { + class Monster + { + public int HP { get; set; } + + public Monster(int hp) + { + HP = hp; + } + } + + // Our Damage effect will need two parameters to function -- who is taking + // the damage, eg. the target, and a damage bonus to apply to the roll. + // Thus, we wrap these in one class so an instance may be passed to Trigger. + class DamageArgs : EffectArgs + { + public Monster Target { get; } + public int DamageBonus {get; } + + public DamageArgs(Monster target, int damageBonus) + { + Target = target; + DamageBonus = damageBonus; + } + } + + // We inherit from Effect, where T is the type of the + // argument we want to pass to the Trigger function. + class Damage : Effect + { + // Since our damage effect can be instantaneous or + // span a duration (details on durations later), + // we take a duration and pass it along to the base + // class constructor. + public Damage(int duration) + : base("Damage", duration) + { } + + // Our damage is 1d6, plus the damage bonus. + protected override void OnTrigger(DamageArgs args) + { + // Rolls 1d6 -- see Dice Rolling documentation for details + int damageRoll = Dice.Roll("1d6"); + int totalDamage = damageRoll + args.DamageBonus; + args.Target.HP -= totalDamage; + } + } + + public static void ExampleCode() + { + Monster myMonster = new Monster(10); + // Effect that triggers instantaneously -- details later + Damage myDamage = new Damage(Damage.Instant); + // Instant effect, so it happens whenever we call Trigger + myDamage.Trigger(new DamageArgs(myMonster, 2)); + } + } + #endregion + + #region AdvancedEffectsExample + public static class AdvancedEffectsExample + { + class Monster + { + private int _hp; + + public int HP + { + get => _hp; + set + { + _hp = value; + Console.WriteLine($"An effect triggered; monster now has {_hp} HP."); + } + } + + public Monster(int hp) + { + HP = hp; + } + } + + class DamageArgs : EffectArgs + { + public Monster Target { get; } + public int DamageBonus {get; } + + public DamageArgs(Monster target, int damageBonus) + { + Target = target; + DamageBonus = damageBonus; + } + } + + class Damage : Effect + { + public Damage(int duration) + : base("Damage", duration) + { } + + protected override void OnTrigger(DamageArgs args) + { + int damageRoll = Dice.Roll("1d6"); + int totalDamage = damageRoll + args.DamageBonus; + args.Target.HP -= totalDamage; + } + } + + public static void ExampleCode() + { + Monster myMonster = new Monster(40); + // Effect that triggers instantaneously, so it happens only when we call Trigger + // and cannot be added to any EffectTrigger + Damage myDamage = new Damage(Damage.Instant); + Console.WriteLine("Triggering instantaneous effect..."); + myDamage.Trigger(new DamageArgs(myMonster, 2)); + + EffectTrigger trigger = new EffectTrigger(); + // We add one 3-round damage over time effect, one infinite damage effect. + trigger.Add(new Damage(3)); + trigger.Add(new Damage(Damage.Infinite)); + + Console.WriteLine($"Current Effects: {trigger}"); + for (int round = 1; round <= 4; round++) + { + Console.WriteLine($"Enter a character to trigger round {round}"); + Console.ReadLine(); + + Console.WriteLine($"Triggering round {round}...."); + trigger.TriggerEffects(new DamageArgs(myMonster, 2)); + Console.WriteLine($"Current Effects: {trigger}"); + } + + } + } + #endregion +} diff --git a/GoRogue.UnitTests/EffectTests.cs b/GoRogue.UnitTests/EffectTests.cs index c5eef10f..86c0b80e 100644 --- a/GoRogue.UnitTests/EffectTests.cs +++ b/GoRogue.UnitTests/EffectTests.cs @@ -34,16 +34,16 @@ public void EffectDurationDecrement() [Fact] public void EffectToString() { - string NAME = "Int Effect 1"; - var DURATION = 5; - var intEffect = new IntEffect(NAME, DURATION); - Assert.Equal(intEffect.ToString(), $"{NAME}: {DURATION} duration remaining"); + const string name = "Int Effect 1"; + const int duration = 5; + var intEffect = new IntEffect(name, duration); + Assert.Equal(intEffect.ToString(), $"{name}: {duration} duration remaining"); } [Fact] public void EffectTriggerAdd() { - var effectTrigger = new EffectTrigger(); + var effectTrigger = new EffectTrigger(); Assert.Equal(0, effectTrigger.Effects.Count); effectTrigger.Add(new IntEffect("Test Effect 1", 1)); @@ -62,7 +62,7 @@ public void EffectTriggerAdd() public void EffectTriggerEffects() { const int multiDuration = 3; - var effectTrigger = new EffectTrigger(); + var effectTrigger = new EffectTrigger(); var effect1 = new IntEffect("Int Effect 1", 1); var effect2 = new IntEffect("Int Effect 3", multiDuration); @@ -84,7 +84,7 @@ public void EffectTriggerEffects() Assert.Equal(multiDuration - 2, effectTrigger.Effects[0].Duration); Assert.Equal(IntEffect.Infinite, effectTrigger.Effects[1].Duration); - var secEffectTrigger = new EffectTrigger(); + var secEffectTrigger = new EffectTrigger(); var testEffect = new IntEffect("Int effect dummy", 1); var cancelingEffect = new CancelingIntEffect("Int effect 3", 1); secEffectTrigger.Add(cancelingEffect); diff --git a/GoRogue.UnitTests/Mocks/MockEffects.cs b/GoRogue.UnitTests/Mocks/MockEffects.cs index 49f32dc7..e2011f27 100644 --- a/GoRogue.UnitTests/Mocks/MockEffects.cs +++ b/GoRogue.UnitTests/Mocks/MockEffects.cs @@ -2,7 +2,7 @@ namespace GoRogue.UnitTests.Mocks { - public class IntEffect : Effect + public class IntEffect : Effect { public IntEffect(string name, int startingDuration) : base(name, startingDuration) diff --git a/GoRogue/Effect.cs b/GoRogue/Effect.cs index fc63918e..1e848503 100644 --- a/GoRogue/Effect.cs +++ b/GoRogue/Effect.cs @@ -59,7 +59,7 @@ public class EffectArgs /// The type of the parameter that will be specified to the function when called. /// [PublicAPI] - public abstract class Effect where TTriggerArgs : EffectArgs + public abstract class Effect where TTriggerArgs : EffectArgs? { /// /// The value one should specify as the effect duration for an infinite effect, eg. an effect @@ -134,7 +134,7 @@ public int Duration /// Parameters that are passed to . /// Can be null. /// - public void Trigger(TTriggerArgs? args) + public void Trigger(TTriggerArgs args) { OnTrigger(args); @@ -147,7 +147,7 @@ public void Trigger(TTriggerArgs? args) /// This function is called automatically when is called. /// /// Class containing all arguments requires to function. - protected abstract void OnTrigger(TTriggerArgs? e); + protected abstract void OnTrigger(TTriggerArgs e); /// /// Returns a string of the effect's name and duration. diff --git a/GoRogue/EffectTrigger.cs b/GoRogue/EffectTrigger.cs index 29c3c4d0..aa80761a 100644 --- a/GoRogue/EffectTrigger.cs +++ b/GoRogue/EffectTrigger.cs @@ -38,7 +38,7 @@ namespace GoRogue /// function of any Effect added to this EffectTrigger. /// [PublicAPI] - public class EffectTrigger where TTriggerArgs : EffectArgs + public class EffectTrigger where TTriggerArgs : EffectArgs? { private readonly List> _effects; @@ -97,13 +97,13 @@ public virtual void Add(Effect effect) /// Argument to pass to the function /// of each effect. /// - public void TriggerEffects(TTriggerArgs? args) + public void TriggerEffects(TTriggerArgs args) { foreach (var effect in _effects) if (effect.Duration != 0) { effect.Trigger(args); - if (args != null && args.CancelTrigger) + if (args?.CancelTrigger ?? false) break; } From bb0f7131ce0b1d5dc357de134d9a1ea11e418ab9 Mon Sep 17 00:00:00 2001 From: Chris3606 Date: Tue, 11 Jul 2023 10:58:38 -0500 Subject: [PATCH 2/8] Moved effect classes to their own namespace. --- GoRogue.Snippets/HowTos/EffectsSystem.cs | 1 + GoRogue.UnitTests/EffectTests.cs | 1 + GoRogue.UnitTests/Mocks/MockEffects.cs | 1 + GoRogue/{ => Effects}/Effect.cs | 2 +- GoRogue/{ => Effects}/EffectTrigger.cs | 2 +- 5 files changed, 5 insertions(+), 2 deletions(-) rename GoRogue/{ => Effects}/Effect.cs (99%) rename GoRogue/{ => Effects}/EffectTrigger.cs (99%) diff --git a/GoRogue.Snippets/HowTos/EffectsSystem.cs b/GoRogue.Snippets/HowTos/EffectsSystem.cs index f0c14b76..cf3a3c41 100644 --- a/GoRogue.Snippets/HowTos/EffectsSystem.cs +++ b/GoRogue.Snippets/HowTos/EffectsSystem.cs @@ -1,4 +1,5 @@ using GoRogue.DiceNotation; +using GoRogue.Effects; namespace GoRogue.Snippets.HowTos { diff --git a/GoRogue.UnitTests/EffectTests.cs b/GoRogue.UnitTests/EffectTests.cs index 86c0b80e..c019b7c5 100644 --- a/GoRogue.UnitTests/EffectTests.cs +++ b/GoRogue.UnitTests/EffectTests.cs @@ -1,4 +1,5 @@ using System; +using GoRogue.Effects; using GoRogue.UnitTests.Mocks; using Xunit; diff --git a/GoRogue.UnitTests/Mocks/MockEffects.cs b/GoRogue.UnitTests/Mocks/MockEffects.cs index e2011f27..17649e5d 100644 --- a/GoRogue.UnitTests/Mocks/MockEffects.cs +++ b/GoRogue.UnitTests/Mocks/MockEffects.cs @@ -1,4 +1,5 @@ using System; +using GoRogue.Effects; namespace GoRogue.UnitTests.Mocks { diff --git a/GoRogue/Effect.cs b/GoRogue/Effects/Effect.cs similarity index 99% rename from GoRogue/Effect.cs rename to GoRogue/Effects/Effect.cs index 1e848503..8d04b7fc 100644 --- a/GoRogue/Effect.cs +++ b/GoRogue/Effects/Effect.cs @@ -1,7 +1,7 @@ using System; using JetBrains.Annotations; -namespace GoRogue +namespace GoRogue.Effects { /// /// Default argument for any effect. Any class that is used as the template argument for an diff --git a/GoRogue/EffectTrigger.cs b/GoRogue/Effects/EffectTrigger.cs similarity index 99% rename from GoRogue/EffectTrigger.cs rename to GoRogue/Effects/EffectTrigger.cs index aa80761a..3f7d8333 100644 --- a/GoRogue/EffectTrigger.cs +++ b/GoRogue/Effects/EffectTrigger.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using JetBrains.Annotations; -namespace GoRogue +namespace GoRogue.Effects { /// /// Represents an "event" that can automatically trigger and manage one or more From bc37f31cea1648468a47ad85d85556b2ba2537e0 Mon Sep 17 00:00:00 2001 From: Chris3606 Date: Tue, 11 Jul 2023 11:22:27 -0500 Subject: [PATCH 3/8] Added performance tests for effects. --- .../Effects/CountingEffect.cs | 14 +++++++ .../Effects/EffectTests.cs | 30 +++++++++++++++ .../Effects/EffectTriggerTests.cs | 38 +++++++++++++++++++ 3 files changed, 82 insertions(+) create mode 100644 GoRogue.PerformanceTests/Effects/CountingEffect.cs create mode 100644 GoRogue.PerformanceTests/Effects/EffectTests.cs create mode 100644 GoRogue.PerformanceTests/Effects/EffectTriggerTests.cs diff --git a/GoRogue.PerformanceTests/Effects/CountingEffect.cs b/GoRogue.PerformanceTests/Effects/CountingEffect.cs new file mode 100644 index 00000000..bf0764a9 --- /dev/null +++ b/GoRogue.PerformanceTests/Effects/CountingEffect.cs @@ -0,0 +1,14 @@ +using GoRogue.Effects; + +namespace GoRogue.PerformanceTests.Effects +{ + public class CountingEffect : Effect + { + public static int Count; + + public CountingEffect(int duration) : base("CountingEffect", duration) + { } + + protected override void OnTrigger(EffectArgs? e) => Count++; + } +} diff --git a/GoRogue.PerformanceTests/Effects/EffectTests.cs b/GoRogue.PerformanceTests/Effects/EffectTests.cs new file mode 100644 index 00000000..ba65cb38 --- /dev/null +++ b/GoRogue.PerformanceTests/Effects/EffectTests.cs @@ -0,0 +1,30 @@ +using BenchmarkDotNet.Attributes; +using GoRogue.Effects; + +namespace GoRogue.PerformanceTests.Effects +{ + public class EffectTests + { + private Effect _effect = null!; + + [GlobalSetup] + public void GlobalSetup() + { + _effect = new CountingEffect(CountingEffect.Instant); + } + + [Benchmark] + public int TriggerEffect() + { + _effect.Trigger(new EffectArgs()); + return CountingEffect.Count; + } + + [Benchmark] + public int TriggerEffectNullArgs() + { + _effect.Trigger(null); + return CountingEffect.Count; + } + } +} diff --git a/GoRogue.PerformanceTests/Effects/EffectTriggerTests.cs b/GoRogue.PerformanceTests/Effects/EffectTriggerTests.cs new file mode 100644 index 00000000..fa122e1b --- /dev/null +++ b/GoRogue.PerformanceTests/Effects/EffectTriggerTests.cs @@ -0,0 +1,38 @@ +using BenchmarkDotNet.Attributes; +using GoRogue.Effects; +using JetBrains.Annotations; + +namespace GoRogue.PerformanceTests.Effects +{ + public class EffectTriggerTests + { + [UsedImplicitly] + [Params(1, 5, 10, 25)] + public int NumberOfEffectsTriggered; + + private EffectTrigger _trigger = null!; + + [GlobalSetup] + public void GlobalSetup() + { + _trigger = new EffectTrigger(); + + for (int i = 0; i < NumberOfEffectsTriggered; i++) + _trigger.Add(new CountingEffect(CountingEffect.Infinite)); + } + + [Benchmark] + public int TriggerEffects() + { + _trigger.TriggerEffects(new EffectArgs()); + return CountingEffect.Count; + } + + [Benchmark] + public int TriggerEffectsNullArgs() + { + _trigger.TriggerEffects(null); + return CountingEffect.Count; + } + } +} From 710d748e06b8ce672931fb52600b8997f5f75868 Mon Sep 17 00:00:00 2001 From: Chris3606 Date: Tue, 11 Jul 2023 16:21:54 -0500 Subject: [PATCH 4/8] Re-implemented effects to use an `out bool` to handle cancellation, and relaxed type restrictions on the additional parameter. Created versions of effects that don't take parameters. --- .../Effects/AdvancedCountingEffect.cs | 18 ++ .../Effects/CountingEffect.cs | 8 +- .../Effects/EffectTests.cs | 14 +- .../Effects/EffectTriggerTests.cs | 20 +- GoRogue.Snippets/HowTos/EffectsSystem.cs | 272 +++++++++--------- .../Effects/AdvancedEffectTests.cs | 108 +++++++ .../{ => Effects}/EffectTests.cs | 44 +-- GoRogue.UnitTests/Mocks/MockEffects.cs | 48 +++- GoRogue/Effects/AdvancedEffect.cs | 79 +++++ GoRogue/Effects/AdvancedEffectTrigger.cs | 46 +++ GoRogue/Effects/Effect.cs | 161 +++-------- GoRogue/Effects/EffectBase.cs | 70 +++++ GoRogue/Effects/EffectDuration.cs | 25 ++ GoRogue/Effects/EffectTrigger.cs | 99 ++----- GoRogue/Effects/EffectTriggerBase.cs | 73 +++++ 15 files changed, 714 insertions(+), 371 deletions(-) create mode 100644 GoRogue.PerformanceTests/Effects/AdvancedCountingEffect.cs create mode 100644 GoRogue.UnitTests/Effects/AdvancedEffectTests.cs rename GoRogue.UnitTests/{ => Effects}/EffectTests.cs (67%) create mode 100644 GoRogue/Effects/AdvancedEffect.cs create mode 100644 GoRogue/Effects/AdvancedEffectTrigger.cs create mode 100644 GoRogue/Effects/EffectBase.cs create mode 100644 GoRogue/Effects/EffectDuration.cs create mode 100644 GoRogue/Effects/EffectTriggerBase.cs diff --git a/GoRogue.PerformanceTests/Effects/AdvancedCountingEffect.cs b/GoRogue.PerformanceTests/Effects/AdvancedCountingEffect.cs new file mode 100644 index 00000000..50f2211c --- /dev/null +++ b/GoRogue.PerformanceTests/Effects/AdvancedCountingEffect.cs @@ -0,0 +1,18 @@ +using GoRogue.Effects; + +namespace GoRogue.PerformanceTests.Effects +{ + public class AdvancedCountingEffect : AdvancedEffect + { + public static int Count; + + public AdvancedCountingEffect(int duration) : base("CountingEffect", duration) + { } + + protected override void OnTrigger(out bool cancelTrigger, int args) + { + Count += args; + cancelTrigger = false; + } + } +} diff --git a/GoRogue.PerformanceTests/Effects/CountingEffect.cs b/GoRogue.PerformanceTests/Effects/CountingEffect.cs index bf0764a9..bebeab74 100644 --- a/GoRogue.PerformanceTests/Effects/CountingEffect.cs +++ b/GoRogue.PerformanceTests/Effects/CountingEffect.cs @@ -2,13 +2,17 @@ namespace GoRogue.PerformanceTests.Effects { - public class CountingEffect : Effect + public class CountingEffect : Effect { public static int Count; public CountingEffect(int duration) : base("CountingEffect", duration) { } - protected override void OnTrigger(EffectArgs? e) => Count++; + protected override void OnTrigger(out bool cancelTrigger) + { + Count++; + cancelTrigger = false; + } } } diff --git a/GoRogue.PerformanceTests/Effects/EffectTests.cs b/GoRogue.PerformanceTests/Effects/EffectTests.cs index ba65cb38..58656f4e 100644 --- a/GoRogue.PerformanceTests/Effects/EffectTests.cs +++ b/GoRogue.PerformanceTests/Effects/EffectTests.cs @@ -5,26 +5,28 @@ namespace GoRogue.PerformanceTests.Effects { public class EffectTests { - private Effect _effect = null!; + private Effect _effect = null!; + private AdvancedEffect _advancedEffect = null!; [GlobalSetup] public void GlobalSetup() { - _effect = new CountingEffect(CountingEffect.Instant); + _effect = new CountingEffect(EffectDuration.Instant); + _advancedEffect = new AdvancedCountingEffect(EffectDuration.Instant); } [Benchmark] public int TriggerEffect() { - _effect.Trigger(new EffectArgs()); + _effect.Trigger(out bool _); return CountingEffect.Count; } [Benchmark] - public int TriggerEffectNullArgs() + public int TriggerAdvancedEffect() { - _effect.Trigger(null); - return CountingEffect.Count; + _advancedEffect.Trigger(out bool _, 1); + return AdvancedCountingEffect.Count; } } } diff --git a/GoRogue.PerformanceTests/Effects/EffectTriggerTests.cs b/GoRogue.PerformanceTests/Effects/EffectTriggerTests.cs index fa122e1b..9a1ae80f 100644 --- a/GoRogue.PerformanceTests/Effects/EffectTriggerTests.cs +++ b/GoRogue.PerformanceTests/Effects/EffectTriggerTests.cs @@ -10,29 +10,35 @@ public class EffectTriggerTests [Params(1, 5, 10, 25)] public int NumberOfEffectsTriggered; - private EffectTrigger _trigger = null!; + private EffectTrigger _trigger = null!; + private AdvancedEffectTrigger _advancedTrigger = null!; [GlobalSetup] public void GlobalSetup() { - _trigger = new EffectTrigger(); + _trigger = new EffectTrigger(); + _advancedTrigger = new AdvancedEffectTrigger(); for (int i = 0; i < NumberOfEffectsTriggered; i++) - _trigger.Add(new CountingEffect(CountingEffect.Infinite)); + { + _trigger.Add(new CountingEffect(EffectDuration.Infinite)); + _advancedTrigger.Add(new AdvancedCountingEffect(EffectDuration.Infinite)); + } + } [Benchmark] public int TriggerEffects() { - _trigger.TriggerEffects(new EffectArgs()); + _trigger.TriggerEffects(); return CountingEffect.Count; } [Benchmark] - public int TriggerEffectsNullArgs() + public int TriggerAdvancedEffects() { - _trigger.TriggerEffects(null); - return CountingEffect.Count; + _advancedTrigger.TriggerEffects(1); + return AdvancedCountingEffect.Count; } } } diff --git a/GoRogue.Snippets/HowTos/EffectsSystem.cs b/GoRogue.Snippets/HowTos/EffectsSystem.cs index cf3a3c41..2d9c8f7a 100644 --- a/GoRogue.Snippets/HowTos/EffectsSystem.cs +++ b/GoRogue.Snippets/HowTos/EffectsSystem.cs @@ -4,141 +4,141 @@ namespace GoRogue.Snippets.HowTos { #region EffectsBasicExample - public static class EffectsBasicExample - { - class Monster - { - public int HP { get; set; } - - public Monster(int hp) - { - HP = hp; - } - } - - // Our Damage effect will need two parameters to function -- who is taking - // the damage, eg. the target, and a damage bonus to apply to the roll. - // Thus, we wrap these in one class so an instance may be passed to Trigger. - class DamageArgs : EffectArgs - { - public Monster Target { get; } - public int DamageBonus {get; } - - public DamageArgs(Monster target, int damageBonus) - { - Target = target; - DamageBonus = damageBonus; - } - } - - // We inherit from Effect, where T is the type of the - // argument we want to pass to the Trigger function. - class Damage : Effect - { - // Since our damage effect can be instantaneous or - // span a duration (details on durations later), - // we take a duration and pass it along to the base - // class constructor. - public Damage(int duration) - : base("Damage", duration) - { } - - // Our damage is 1d6, plus the damage bonus. - protected override void OnTrigger(DamageArgs args) - { - // Rolls 1d6 -- see Dice Rolling documentation for details - int damageRoll = Dice.Roll("1d6"); - int totalDamage = damageRoll + args.DamageBonus; - args.Target.HP -= totalDamage; - } - } - - public static void ExampleCode() - { - Monster myMonster = new Monster(10); - // Effect that triggers instantaneously -- details later - Damage myDamage = new Damage(Damage.Instant); - // Instant effect, so it happens whenever we call Trigger - myDamage.Trigger(new DamageArgs(myMonster, 2)); - } - } - #endregion - - #region AdvancedEffectsExample - public static class AdvancedEffectsExample - { - class Monster - { - private int _hp; - - public int HP - { - get => _hp; - set - { - _hp = value; - Console.WriteLine($"An effect triggered; monster now has {_hp} HP."); - } - } - - public Monster(int hp) - { - HP = hp; - } - } - - class DamageArgs : EffectArgs - { - public Monster Target { get; } - public int DamageBonus {get; } - - public DamageArgs(Monster target, int damageBonus) - { - Target = target; - DamageBonus = damageBonus; - } - } - - class Damage : Effect - { - public Damage(int duration) - : base("Damage", duration) - { } - - protected override void OnTrigger(DamageArgs args) - { - int damageRoll = Dice.Roll("1d6"); - int totalDamage = damageRoll + args.DamageBonus; - args.Target.HP -= totalDamage; - } - } - - public static void ExampleCode() - { - Monster myMonster = new Monster(40); - // Effect that triggers instantaneously, so it happens only when we call Trigger - // and cannot be added to any EffectTrigger - Damage myDamage = new Damage(Damage.Instant); - Console.WriteLine("Triggering instantaneous effect..."); - myDamage.Trigger(new DamageArgs(myMonster, 2)); - - EffectTrigger trigger = new EffectTrigger(); - // We add one 3-round damage over time effect, one infinite damage effect. - trigger.Add(new Damage(3)); - trigger.Add(new Damage(Damage.Infinite)); - - Console.WriteLine($"Current Effects: {trigger}"); - for (int round = 1; round <= 4; round++) - { - Console.WriteLine($"Enter a character to trigger round {round}"); - Console.ReadLine(); - - Console.WriteLine($"Triggering round {round}...."); - trigger.TriggerEffects(new DamageArgs(myMonster, 2)); - Console.WriteLine($"Current Effects: {trigger}"); - } - - } - } + // public static class EffectsBasicExample + // { + // class Monster + // { + // public int HP { get; set; } + // + // public Monster(int hp) + // { + // HP = hp; + // } + // } + // + // // Our Damage effect will need two parameters to function -- who is taking + // // the damage, eg. the target, and a damage bonus to apply to the roll. + // // Thus, we wrap these in one class so an instance may be passed to Trigger. + // class DamageArgs : EffectArgs + // { + // public Monster Target { get; } + // public int DamageBonus {get; } + // + // public DamageArgs(Monster target, int damageBonus) + // { + // Target = target; + // DamageBonus = damageBonus; + // } + // } + // + // // We inherit from Effect, where T is the type of the + // // argument we want to pass to the Trigger function. + // class Damage : Effect + // { + // // Since our damage effect can be instantaneous or + // // span a duration (details on durations later), + // // we take a duration and pass it along to the base + // // class constructor. + // public Damage(int duration) + // : base("Damage", duration) + // { } + // + // // Our damage is 1d6, plus the damage bonus. + // protected override void OnTrigger(DamageArgs args) + // { + // // Rolls 1d6 -- see Dice Rolling documentation for details + // int damageRoll = Dice.Roll("1d6"); + // int totalDamage = damageRoll + args.DamageBonus; + // args.Target.HP -= totalDamage; + // } + // } + // + // public static void ExampleCode() + // { + // Monster myMonster = new Monster(10); + // // Effect that triggers instantaneously -- details later + // Damage myDamage = new Damage(Damage.Instant); + // // Instant effect, so it happens whenever we call Trigger + // myDamage.Trigger(new DamageArgs(myMonster, 2)); + // } + // } + // #endregion + // + // #region AdvancedEffectsExample + // public static class AdvancedEffectsExample + // { + // class Monster + // { + // private int _hp; + // + // public int HP + // { + // get => _hp; + // set + // { + // _hp = value; + // Console.WriteLine($"An effect triggered; monster now has {_hp} HP."); + // } + // } + // + // public Monster(int hp) + // { + // HP = hp; + // } + // } + // + // class DamageArgs : EffectArgs + // { + // public Monster Target { get; } + // public int DamageBonus {get; } + // + // public DamageArgs(Monster target, int damageBonus) + // { + // Target = target; + // DamageBonus = damageBonus; + // } + // } + // + // class Damage : Effect + // { + // public Damage(int duration) + // : base("Damage", duration) + // { } + // + // protected override void OnTrigger(DamageArgs args) + // { + // int damageRoll = Dice.Roll("1d6"); + // int totalDamage = damageRoll + args.DamageBonus; + // args.Target.HP -= totalDamage; + // } + // } + // + // public static void ExampleCode() + // { + // Monster myMonster = new Monster(40); + // // Effect that triggers instantaneously, so it happens only when we call Trigger + // // and cannot be added to any EffectTrigger + // Damage myDamage = new Damage(Damage.Instant); + // Console.WriteLine("Triggering instantaneous effect..."); + // myDamage.Trigger(new DamageArgs(myMonster, 2)); + // + // EffectTrigger trigger = new EffectTrigger(); + // // We add one 3-round damage over time effect, one infinite damage effect. + // trigger.Add(new Damage(3)); + // trigger.Add(new Damage(Damage.Infinite)); + // + // Console.WriteLine($"Current Effects: {trigger}"); + // for (int round = 1; round <= 4; round++) + // { + // Console.WriteLine($"Enter a character to trigger round {round}"); + // Console.ReadLine(); + // + // Console.WriteLine($"Triggering round {round}...."); + // trigger.TriggerEffects(new DamageArgs(myMonster, 2)); + // Console.WriteLine($"Current Effects: {trigger}"); + // } + // + // } + // } #endregion } diff --git a/GoRogue.UnitTests/Effects/AdvancedEffectTests.cs b/GoRogue.UnitTests/Effects/AdvancedEffectTests.cs new file mode 100644 index 00000000..7c8d857c --- /dev/null +++ b/GoRogue.UnitTests/Effects/AdvancedEffectTests.cs @@ -0,0 +1,108 @@ +using System; +using GoRogue.Effects; +using GoRogue.UnitTests.Mocks; +using Xunit; + +namespace GoRogue.UnitTests.Effects +{ + public class AdvancedEffectTests + { + private const int Args = 5; + + [Fact] + public void EffectDurationDecrement() + { + var effect = new AdvancedIntEffect("Test effect", 2, Args); + Assert.Equal(2, effect.Duration); + + effect.Trigger(Args); + Assert.Equal(1, effect.Duration); + + effect.Trigger(out bool _, Args); + Assert.Equal(0, effect.Duration); + + effect.Trigger(Args); + Assert.Equal(0, effect.Duration); + + effect.Trigger(out bool _, Args); + Assert.Equal(0, effect.Duration); + + var effect2 = new AdvancedIntEffect("Test Effect", EffectDuration.Infinite, Args); + Assert.Equal(EffectDuration.Infinite, effect2.Duration); + + effect2.Trigger(Args); + Assert.Equal(EffectDuration.Infinite, effect2.Duration); + + effect2.Trigger(Args); + Assert.Equal(EffectDuration.Infinite, effect2.Duration); + + effect2.Trigger(out bool _, Args); + Assert.Equal(EffectDuration.Infinite, effect2.Duration); + } + + [Fact] + public void EffectToString() + { + const string name = "Int Effect 1"; + const int duration = 5; + var intEffect = new AdvancedIntEffect(name, duration, Args); + Assert.Equal(intEffect.ToString(), $"{name}: {duration} duration remaining"); + } + + [Fact] + public void EffectTriggerAdd() + { + var effectTrigger = new AdvancedEffectTrigger(); + Assert.Equal(0, effectTrigger.Effects.Count); + + effectTrigger.Add(new AdvancedIntEffect("Test Effect 1", 1, Args)); + Assert.Equal(1, effectTrigger.Effects.Count); + + effectTrigger.Add(new AdvancedIntEffect("Test Effect 2", 2, Args)); + Assert.Equal(2, effectTrigger.Effects.Count); + + effectTrigger.Add(new AdvancedIntEffect("Test Effect Inf", EffectDuration.Infinite, Args)); + Assert.Equal(3, effectTrigger.Effects.Count); + + Assert.Throws(() => effectTrigger.Add(new AdvancedIntEffect("Test Effect 0", 0, Args))); + } + + [Fact] + public void EffectTriggerEffects() + { + const int multiDuration = 3; + var effectTrigger = new AdvancedEffectTrigger(); + + var effect1 = new AdvancedIntEffect("Int Effect 1", 1, Args); + var effect2 = new AdvancedIntEffect("Int Effect 3", multiDuration, Args); + + var effectInf = new AdvancedIntEffect("Int Effect Inf", EffectDuration.Infinite, Args); + + effectTrigger.Add(effect2); + effectTrigger.TriggerEffects(5); + Assert.Equal(1, effectTrigger.Effects.Count); + Assert.Equal(multiDuration - 1, effectTrigger.Effects[0].Duration); + Assert.Equal(1, effect1.Duration); + + effectTrigger.Add(effect1); + effectTrigger.Add(effectInf); + Assert.Equal(3, effectTrigger.Effects.Count); + + effectTrigger.TriggerEffects(5); + Assert.Equal(2, effectTrigger.Effects.Count); + Assert.Equal(multiDuration - 2, effectTrigger.Effects[0].Duration); + Assert.Equal(EffectDuration.Infinite, effectTrigger.Effects[1].Duration); + + var secEffectTrigger = new AdvancedEffectTrigger(); + var testEffect = new AdvancedIntEffect("Int effect dummy", 1, Args); + var cancelingEffect = new CancelingAdvancedIntEffect("Int effect 3", 1, Args); + secEffectTrigger.Add(cancelingEffect); + secEffectTrigger.Add(testEffect); + Assert.Equal(2, secEffectTrigger.Effects.Count); + + secEffectTrigger.TriggerEffects(5); + Assert.Equal(1, secEffectTrigger.Effects.Count); + Assert.Equal(1, secEffectTrigger.Effects[0].Duration); // Must have cancelled + } + } +} diff --git a/GoRogue.UnitTests/EffectTests.cs b/GoRogue.UnitTests/Effects/EffectTests.cs similarity index 67% rename from GoRogue.UnitTests/EffectTests.cs rename to GoRogue.UnitTests/Effects/EffectTests.cs index c019b7c5..5bfffd69 100644 --- a/GoRogue.UnitTests/EffectTests.cs +++ b/GoRogue.UnitTests/Effects/EffectTests.cs @@ -3,7 +3,7 @@ using GoRogue.UnitTests.Mocks; using Xunit; -namespace GoRogue.UnitTests +namespace GoRogue.UnitTests.Effects { public class EffectTests { @@ -13,23 +13,29 @@ public void EffectDurationDecrement() var effect = new IntEffect("Test effect", 2); Assert.Equal(2, effect.Duration); - effect.Trigger(null); + effect.Trigger(); Assert.Equal(1, effect.Duration); - effect.Trigger(null); + effect.Trigger(out bool _); Assert.Equal(0, effect.Duration); - effect.Trigger(null); + effect.Trigger(); Assert.Equal(0, effect.Duration); - var effect2 = new IntEffect("Test Effect", IntEffect.Infinite); - Assert.Equal(IntEffect.Infinite, effect2.Duration); + effect.Trigger(out bool _); + Assert.Equal(0, effect.Duration); + + var effect2 = new IntEffect("Test Effect", EffectDuration.Infinite); + Assert.Equal(EffectDuration.Infinite, effect2.Duration); + + effect2.Trigger(); + Assert.Equal(EffectDuration.Infinite, effect2.Duration); - effect2.Trigger(null); - Assert.Equal(IntEffect.Infinite, effect2.Duration); + effect2.Trigger(); + Assert.Equal(EffectDuration.Infinite, effect2.Duration); - effect2.Trigger(null); - Assert.Equal(IntEffect.Infinite, effect2.Duration); + effect2.Trigger(out bool _); + Assert.Equal(EffectDuration.Infinite, effect2.Duration); } [Fact] @@ -44,7 +50,7 @@ public void EffectToString() [Fact] public void EffectTriggerAdd() { - var effectTrigger = new EffectTrigger(); + var effectTrigger = new EffectTrigger(); Assert.Equal(0, effectTrigger.Effects.Count); effectTrigger.Add(new IntEffect("Test Effect 1", 1)); @@ -53,7 +59,7 @@ public void EffectTriggerAdd() effectTrigger.Add(new IntEffect("Test Effect 2", 2)); Assert.Equal(2, effectTrigger.Effects.Count); - effectTrigger.Add(new IntEffect("Test Effect Inf", IntEffect.Infinite)); + effectTrigger.Add(new IntEffect("Test Effect Inf", EffectDuration.Infinite)); Assert.Equal(3, effectTrigger.Effects.Count); Assert.Throws(() => effectTrigger.Add(new IntEffect("Test Effect 0", 0))); @@ -63,15 +69,15 @@ public void EffectTriggerAdd() public void EffectTriggerEffects() { const int multiDuration = 3; - var effectTrigger = new EffectTrigger(); + var effectTrigger = new EffectTrigger(); var effect1 = new IntEffect("Int Effect 1", 1); var effect2 = new IntEffect("Int Effect 3", multiDuration); - var effectInf = new IntEffect("Int Effect Inf", IntEffect.Infinite); + var effectInf = new IntEffect("Int Effect Inf", EffectDuration.Infinite); effectTrigger.Add(effect2); - effectTrigger.TriggerEffects(null); // Test with null arguments + effectTrigger.TriggerEffects(); Assert.Equal(1, effectTrigger.Effects.Count); Assert.Equal(multiDuration - 1, effectTrigger.Effects[0].Duration); Assert.Equal(1, effect1.Duration); @@ -80,19 +86,19 @@ public void EffectTriggerEffects() effectTrigger.Add(effectInf); Assert.Equal(3, effectTrigger.Effects.Count); - effectTrigger.TriggerEffects(null); + effectTrigger.TriggerEffects(); Assert.Equal(2, effectTrigger.Effects.Count); Assert.Equal(multiDuration - 2, effectTrigger.Effects[0].Duration); - Assert.Equal(IntEffect.Infinite, effectTrigger.Effects[1].Duration); + Assert.Equal(EffectDuration.Infinite, effectTrigger.Effects[1].Duration); - var secEffectTrigger = new EffectTrigger(); + var secEffectTrigger = new EffectTrigger(); var testEffect = new IntEffect("Int effect dummy", 1); var cancelingEffect = new CancelingIntEffect("Int effect 3", 1); secEffectTrigger.Add(cancelingEffect); secEffectTrigger.Add(testEffect); Assert.Equal(2, secEffectTrigger.Effects.Count); - secEffectTrigger.TriggerEffects(new EffectArgs()); + secEffectTrigger.TriggerEffects(); Assert.Equal(1, secEffectTrigger.Effects.Count); Assert.Equal(1, secEffectTrigger.Effects[0].Duration); // Must have cancelled } diff --git a/GoRogue.UnitTests/Mocks/MockEffects.cs b/GoRogue.UnitTests/Mocks/MockEffects.cs index 17649e5d..264b80b2 100644 --- a/GoRogue.UnitTests/Mocks/MockEffects.cs +++ b/GoRogue.UnitTests/Mocks/MockEffects.cs @@ -1,16 +1,18 @@ -using System; -using GoRogue.Effects; +using GoRogue.Effects; +using Xunit; namespace GoRogue.UnitTests.Mocks { - public class IntEffect : Effect + public class IntEffect : Effect { public IntEffect(string name, int startingDuration) : base(name, startingDuration) { } - protected override void OnTrigger(EffectArgs? e) - { } + protected override void OnTrigger(out bool cancelTrigger) + { + cancelTrigger = false; + } } public class CancelingIntEffect : IntEffect @@ -19,11 +21,39 @@ public CancelingIntEffect(string name, int startingDuration) : base(name, startingDuration) { } - protected override void OnTrigger(EffectArgs? e) + protected override void OnTrigger(out bool cancelTrigger) + { + cancelTrigger = true; + } + } + + public class AdvancedIntEffect : AdvancedEffect + { + public readonly int ExpectedArgs; + + public AdvancedIntEffect(string name, int startingDuration, int expectedArgs) + : base(name, startingDuration) + { + ExpectedArgs = expectedArgs; + } + + protected override void OnTrigger(out bool cancelTrigger, int args) + { + Assert.Equal(ExpectedArgs, args); + cancelTrigger = false; + } + } + + public class CancelingAdvancedIntEffect : AdvancedIntEffect + { + public CancelingAdvancedIntEffect(string name, int startingDuration, int expectedArgs) + : base(name, startingDuration, expectedArgs) + { } + + protected override void OnTrigger(out bool cancelTrigger, int args) { - e = e ?? throw new ArgumentException($"Effect arguments for {nameof(CancelingIntEffect)} may not be null", - nameof(e)); - e.CancelTrigger = true; + Assert.Equal(ExpectedArgs, args); + cancelTrigger = true; } } } diff --git a/GoRogue/Effects/AdvancedEffect.cs b/GoRogue/Effects/AdvancedEffect.cs new file mode 100644 index 00000000..1a6ef988 --- /dev/null +++ b/GoRogue/Effects/AdvancedEffect.cs @@ -0,0 +1,79 @@ +using JetBrains.Annotations; + +namespace GoRogue.Effects +{ + /// + /// More advanced version of which allows for a parameter to be passed to the + /// Trigger method. + /// + /// + /// This effect type is useful when information about a particular trigger needs to be passed to the effect + /// in order for it to work. For example, an effect which reacts to damage might need to know how much damage + /// is being dealt in order to function. + /// + /// + /// The type of the parameter that will be specified to the + /// function (or its overloads) when called. + /// + [PublicAPI] + public abstract class AdvancedEffect : EffectBase + { + /// + /// Constructor. + /// + /// Name for the effect. + /// Starting duration for the effect. + protected AdvancedEffect(string name, int startingDuration) + : base(name, startingDuration) + { } + + /// + /// Triggers the effect. If you're calling this function manually, you should use the + /// function instead, unless you intend to manually support cancellation of + /// a trigger. + /// + /// + /// Any effect that has Instant duration or duration 0 when this function is called + /// will still have its function called. + /// + /// + /// When set to true, if the effect is being called by an EffectTrigger, the trigger will be cancelled; + /// eg. any events which have yet to be triggered will not be triggered during the current call to + /// . If the effect is not being called by an EffectTrigger, + /// this parameter has no effect. + /// + /// The parameter to pass to the function. + public void Trigger(out bool cancelTrigger, TTriggerArgs args) + { + OnTrigger(out cancelTrigger, args); + + if (Duration != 0) + Duration = Duration == EffectDuration.Infinite ? EffectDuration.Infinite : Duration - 1; + } + + /// + /// Triggers the effect, ignoring any result set to the boolean value in . + /// Should be called to trigger instantaneously occuring effects or effects that aren't part of an EffectTrigger + /// and thus don't support trigger cancellation. + /// + /// + /// Any effect that has Instant duration or duration 0 when this function is called + /// will still have its function called. + /// + /// The parameter to pass to the function. + public void Trigger(TTriggerArgs args) => Trigger(out _, args); + + /// + /// Implement to take whatever action(s) the effect is supposed to accomplish. + /// This function is called automatically when is called. + /// + /// + /// When set to true, if the effect is being called by an EffectTrigger, the trigger will be cancelled; + /// eg. any events which have yet to be triggered will not be triggered during the current call to + /// . If the effect is not being called by an EffectTrigger, + /// this parameter has no effect. + /// + /// Arguments passed to the Trigger function. + protected abstract void OnTrigger(out bool cancelTrigger, TTriggerArgs args); + } +} diff --git a/GoRogue/Effects/AdvancedEffectTrigger.cs b/GoRogue/Effects/AdvancedEffectTrigger.cs new file mode 100644 index 00000000..07ab35f8 --- /dev/null +++ b/GoRogue/Effects/AdvancedEffectTrigger.cs @@ -0,0 +1,46 @@ +using JetBrains.Annotations; + +namespace GoRogue.Effects +{ + /// + /// More advanced version of which allows for a parameter to be passed to the + /// TriggerEffects method. + /// + /// + /// This effect trigger type is useful when information about a particular trigger needs to be passed to the effects + /// in order for them to work. For example, effects which react to damage might need to know how much damage + /// is being dealt in order to function. + /// + /// + /// The type of the parameter that will be specified to the + /// function when called. + /// + [PublicAPI] + public class AdvancedEffectTrigger : EffectTriggerBase> + { + /// + /// Calls the function of each effect + /// in the list (as long as its duration is not 0), then + /// removes any effect that has duration 0. + /// + /// + /// If some effect sets the boolean it receives as an "out" parameter to true, the loop will be broken and no + /// subsequent effects in the list will have Trigger called. After either this occurs or all effects have had + /// their Trigger function called, any effect in the list that has a duration of 0 is automatically removed from + /// the list. + /// + /// Arguments to pass to the Trigger function of each effect that is triggered. + public void TriggerEffects(TTriggerArgs args) + { + foreach (var effect in EffectsList) + if (effect.Duration != 0) + { + effect.Trigger(out var cancelTrigger, args); + if (cancelTrigger) + break; + } + + EffectsList.RemoveAll(eff => eff.Duration == 0); + } + } +} diff --git a/GoRogue/Effects/Effect.cs b/GoRogue/Effects/Effect.cs index 8d04b7fc..5139095e 100644 --- a/GoRogue/Effects/Effect.cs +++ b/GoRogue/Effects/Effect.cs @@ -1,31 +1,7 @@ -using System; -using JetBrains.Annotations; +using JetBrains.Annotations; namespace GoRogue.Effects { - /// - /// Default argument for any effect. Any class that is used as the template argument for an - /// effect must inherit from this class. - /// - /// - /// These arguments allow cancellation of the triggering of a chain of effects when triggered by - /// an , as detailed in that class's documentation. - /// - [PublicAPI] - public class EffectArgs - { - /// - /// Whether or not the should stop calling all subsequent effect's - /// functions. See EffectTrigger's documentation for details. - /// - public bool CancelTrigger; - - /// - /// Constructor. - /// - public EffectArgs() => CancelTrigger = false; - } - /// /// Class designed to represent any sort of in-game effect. This could be anything from a simple /// physical damage effect to a heal effect or permanent effects. These might include AOE effects, @@ -36,127 +12,76 @@ public class EffectArgs /// happens, potentially instantaneously or potentially one or more times on a certain event /// (beginning of a turn, end of a turn, on taking damage, etc). The standard way to use the /// Effect class is to create a subclass of Effect, that at the very least implements the - /// function, which should accomplish whatever the effect should - /// do when it is triggered. The subclass can specify what parameter(s) the OnTrigger function - /// needs to take in via the class's type parameter. If multiple arguments are needed, one should create - /// a class that subclasses that contains all the parameters, and the effect subclass - /// should then take an instance of the EffectArgs subclass as the single parameter. If no arguments are needed, - /// then one may pass null as the parameter to Trigger. + /// function, which should accomplish whatever the effect should + /// do when it is triggered. + /// /// The concept of a duration is also built into the interface, and is considered to be in arbitrary units. The duration - /// concept is designed to be used with instances, and has no effect when an effect is not + /// concept is designed to be used with instances, and has no effect when an effect is not /// utilized with an EffectTrigger. The duration is interpreted as simply the number of times the effect's - /// ) function will be called before it will be removed from an EffectTrigger. If the - /// effect - /// is instantaneous, eg. it happens only when Trigger is called, on no particular event (such as a simple instant damage + /// ) function will be called before it will be removed from an EffectTrigger. If the + /// effect is instantaneous, eg. it happens only when Trigger is called, on no particular event (such as a simple instant damage /// effect), then the duration specified in the constructor should be the static class constant - /// . If the effect is meant to have an infinite duration, or the effect wears off on some - /// condition other than time passing, the duration may be set to , and then manipulated - /// appropriately to 0 when the effect has expired. - /// More explanation of Effects and EffectTriggers, and usage examples, can be found at the GoRogue documentation site - /// here. + /// . If the effect is meant to have an infinite duration, or the effect wears off on some + /// condition other than time passing, the duration may be set to , and then manipulated + /// appropriately to 0 when the effect has expired. More explanation of Effects and EffectTriggers, and usage examples, + /// can be found at the GoRogue documentation site here. /// - /// - /// The type of the parameter that will be specified to the function when called. - /// [PublicAPI] - public abstract class Effect where TTriggerArgs : EffectArgs? + public abstract class Effect : EffectBase { - /// - /// The value one should specify as the effect duration for an infinite effect, eg. an effect - /// that will never expire or whose expiration time is arbitrary (for example, based on a condition - /// other than the passing of time). - /// - public const int Infinite = -1; - - /// - /// The value one should specify as the effect duration for an instantaneous effect, eg. an - /// effect that only occurs when Trigger is manually called, and thus cannot be added to an - /// . - /// - public const int Instant = 0; - - private int _duration; - /// /// Constructor. /// /// Name for the effect. /// Starting duration for the effect. protected Effect(string name, int startingDuration) - { - Name = name; - _duration = startingDuration; - } + : base(name, startingDuration) + { } - /// - /// The name of the effect. - /// - public string Name { get; set; } /// - /// The duration of the effect. + /// Triggers the effect. If you're calling this function manually, you should use the + /// function instead, unless you intend to manually support cancellation of a trigger. /// /// - /// When the duration reaches 0, the Effect will be automatically removed from an - /// . - /// The duration can be changed from a subclass, which can be used in to - /// cause an effect to be "cancelled", eg. immediately expire, or to extend/reduce its duration. + /// Any effect that has Instant duration or duration 0 when this function is called + /// will still have its function called. /// - public int Duration - { - get => _duration; - set - { - if (_duration != value) - { - _duration = value; - if (Expired != null && _duration == 0) - Expired.Invoke(this, EventArgs.Empty); - } - } - } - - /// - /// Event that fires as soon as the effect is about to expire. Fires after the - /// function has been called but before it is - /// removed from any instances. - /// - public event EventHandler? Expired; - - /// - /// Should be called on instantaneous effects to trigger the effect. - /// - /// - /// Any effect that has INSTANT duration or duration 0 when this function is called - /// will still have its function called. - /// - /// - /// Parameters that are passed to . - /// Can be null. + /// + /// When set to true, if the effect is being called by an EffectTrigger, the trigger will be cancelled; + /// eg. any events which have yet to be triggered will not be triggered during the current call to + /// . If the effect is not being called by an EffectTrigger, + /// this parameter has no effect. /// - public void Trigger(TTriggerArgs args) + public void Trigger(out bool cancelTrigger) { - OnTrigger(args); + OnTrigger(out cancelTrigger); if (Duration != 0) - Duration = Duration == Infinite ? Infinite : Duration - 1; + Duration = Duration == EffectDuration.Infinite ? EffectDuration.Infinite : Duration - 1; } /// - /// Implement to take whatever action(s) the effect is supposed to accomplish. - /// This function is called automatically when is called. + /// Triggers the effect, ignoring any result set to the boolean value in . + /// Should be called to trigger instantaneously occuring effects or effects that aren't part of an EffectTrigger + /// and thus don't support trigger cancellation. /// - /// Class containing all arguments requires to function. - protected abstract void OnTrigger(TTriggerArgs e); + /// + /// Any effect that has Instant duration or duration 0 when this function is called + /// will still have its function called. + /// + public void Trigger() => Trigger(out _); /// - /// Returns a string of the effect's name and duration. + /// Implement to take whatever action(s) the effect is supposed to accomplish. + /// This function is called automatically when is called. /// - /// String representation of the effect. - public override string ToString() - { - string durationStr = Duration == Infinite ? "Infinite" : Duration.ToString(); - return $"{Name}: {durationStr} duration remaining"; - } + /// + /// When set to true, if the effect is being called by an EffectTrigger, the trigger will be cancelled; + /// eg. any events which have yet to be triggered will not be triggered during the current call to + /// . If the effect is not being called by an EffectTrigger, + /// this parameter has no effect. + /// + protected abstract void OnTrigger(out bool cancelTrigger); } } diff --git a/GoRogue/Effects/EffectBase.cs b/GoRogue/Effects/EffectBase.cs new file mode 100644 index 00000000..c7458e48 --- /dev/null +++ b/GoRogue/Effects/EffectBase.cs @@ -0,0 +1,70 @@ +using System; +using JetBrains.Annotations; + +namespace GoRogue.Effects +{ + /// + /// Base class for and . Typically not useful + /// unless you're creating a a custom implementation of effects and/or triggers. + /// + [PublicAPI] + public abstract class EffectBase + { + private int _duration; + /// + /// The duration of the effect. + /// + /// + /// When the duration reaches 0, the effect will be automatically removed from an + /// . + /// The duration can be changed from a subclass, which can be used in OnTrigger to + /// cause an effect to be "cancelled", eg. immediately expire, or to extend/reduce its duration. + /// + public int Duration + { + get => _duration; + set + { + if (_duration != value) + { + _duration = value; + if (Expired != null && _duration == 0) + Expired.Invoke(this, EventArgs.Empty); + } + } + } + + /// + /// The name of the effect. + /// + public string Name { get; set; } + + /// + /// Event that fires as soon as the effect is about to expire. Fires after the + /// OnTrigger function has been called but before it is + /// removed from any instances. + /// + public event EventHandler? Expired; + + /// + /// Constructor. + /// + /// Name for the effect. + /// Starting duration for the effect. + protected EffectBase(string name, int startingDuration) + { + Name = name; + _duration = startingDuration; + } + + /// + /// Returns a string of the effect's name and duration. + /// + /// String representation of the effect. + public override string ToString() + { + string durationStr = Duration == EffectDuration.Infinite ? "Infinite" : Duration.ToString(); + return $"{Name}: {durationStr} duration remaining"; + } + } +} diff --git a/GoRogue/Effects/EffectDuration.cs b/GoRogue/Effects/EffectDuration.cs new file mode 100644 index 00000000..c96e2edb --- /dev/null +++ b/GoRogue/Effects/EffectDuration.cs @@ -0,0 +1,25 @@ +using JetBrains.Annotations; + +namespace GoRogue.Effects +{ + /// + /// Static class containing special constants used for the duration of effects. + /// + [PublicAPI] + public static class EffectDuration + { + /// + /// The value one should specify as the effect duration for an infinite effect, eg. an effect + /// that will never expire or whose expiration time is arbitrary (for example, based on a condition + /// other than the passing of time). + /// + public const int Infinite = -1; + + /// + /// The value one should specify as the effect duration for an instantaneous effect, eg. an + /// effect that only occurs when Trigger is manually called, and thus cannot be added to an + /// effect trigger. + /// + public const int Instant = 0; + } +} diff --git a/GoRogue/Effects/EffectTrigger.cs b/GoRogue/Effects/EffectTrigger.cs index 3f7d8333..41f1fa35 100644 --- a/GoRogue/Effects/EffectTrigger.cs +++ b/GoRogue/Effects/EffectTrigger.cs @@ -1,26 +1,23 @@ -using System; -using System.Collections.Generic; -using JetBrains.Annotations; +using JetBrains.Annotations; namespace GoRogue.Effects { /// /// Represents an "event" that can automatically trigger and manage one or more - /// instances, and acts as part of the implementation of + /// instances, and acts as part of the implementation of /// duration in Effect. /// /// /// EffectTrigger's primary purpose is to represent an event that can trigger one or more effects, and /// automatically remove those effects from the list when their duration reaches 0. Each EffectTrigger - /// instance can have one or more (non-instantaneous) effects added to it. All Effects must take the same - /// type of argument to their - /// function, as specified by this class's type parameter. - /// Each time the function is called, every Effect has its - /// Trigger function called (provided its duration is not 0). Each Effect may, via the TriggerArgs - /// member, stop the effect from - /// being sent to subsequent effects in the EffectTrigger's list. Once either all effects in the list - /// have had their Trigger function called, or some effect has cancelled the triggering, any effect - /// whose duration has reached 0 is removed from the EffectTrigger automatically. + /// instance can have one or more (non-instantaneous) effects added to it. + /// + /// Each time the function is called, every Effect has its + /// Trigger function called (provided its duration is not 0). Each Effect may, via the cancelTrigger + /// parameter, member, stop the event from being sent to subsequent effects in the EffectTrigger's list. + /// Once either all effects in the list have had their Trigger function called, or some effect has cancelled the + /// triggering, any effect whose duration has reached 0 is removed from the EffectTrigger automatically. + /// /// Typically, one instance of this class is created per "event" that can trigger effects, and then the /// instance's TriggerEffects function is called whenever that event happens. For example, in a /// typical roguelike, all damageable creatures might have an instance of this class called @@ -29,85 +26,39 @@ namespace GoRogue.Effects /// creature would then need to call OnDamageTakenEffects.TriggerEffects(...). In this way, all effects /// added to the OnDamageTakenEffects EffectTrigger would be triggered automatically whenever the /// creature takes damage. + /// /// For some complex game mechanics, it may be desirable to control how effects stack, the order they appear /// in the effects list of EffectTriggers, etc. In these cases, sub-classing EffectTrigger and overriding the /// add and remove functions can allow this functionality. + /// + /// If you need to pass a parameter with extra data to the Trigger function, you should use + /// and instead. /// - /// - /// The type of argument that must be accepted by the - /// function of any Effect added to this EffectTrigger. - /// [PublicAPI] - public class EffectTrigger where TTriggerArgs : EffectArgs? + public class EffectTrigger : EffectTriggerBase { - private readonly List> _effects; - - /// - /// Constructor. - /// - public EffectTrigger() => _effects = new List>(); - - /// - /// List of all effects that are part of this EffectTrigger. - /// - public IReadOnlyList> Effects => _effects.AsReadOnly(); - - /// - /// Adds the given effect to this EffectTrigger, provided the effect's duration is not 0. If - /// the effect's duration is 0, an ArgumentException is thrown. - /// - /// The effect to add to this trigger. - public virtual void Add(Effect effect) - { - if (effect.Duration == 0) - throw new ArgumentException( - $"Tried to add effect {effect.Name} to an EffectTrigger, but it has duration 0!", nameof(effect)); - - _effects.Add(effect); - } - - /// - /// Removes the given effect from this EffectTrigger. - /// - /// The effect to remove - public virtual void Remove(Effect effect) => _effects.Remove(effect); - - /// - /// Yields a string representation of each effect that has been added to the effect trigger. - /// - /// - /// A string representation of each effect that has been added to the effect trigger. - /// - public override string ToString() => _effects.ExtendToString(); - /// - /// Calls the function of each effect + /// Calls the function of each effect /// in the list (as long as its duration is not 0), then /// removes any effect that has duration 0. /// /// - /// The argument given is passed along to the - /// function of each effect that has Trigger called. If some effect sets the - /// flag in the argument to true, the loop will be broken and no subsequent effects in the list will have - /// Trigger called. After either this occurs or all effects have had Trigger called, any effect in the list - /// that has a duration of 0 is automatically removed from the list. It is valid to pass null - /// as the argument to this function, if the effects need no actual parameters. + /// If some effect sets the boolean it receives as an "out" parameter to true, the loop will be broken and no + /// subsequent effects in the list will have Trigger called. After either this occurs or all effects have had + /// their Trigger function called, any effect in the list that has a duration of 0 is automatically removed from + /// the list. /// - /// - /// Argument to pass to the function - /// of each effect. - /// - public void TriggerEffects(TTriggerArgs args) + public void TriggerEffects() { - foreach (var effect in _effects) + foreach (var effect in EffectsList) if (effect.Duration != 0) { - effect.Trigger(args); - if (args?.CancelTrigger ?? false) + effect.Trigger(out var cancelTrigger); + if (cancelTrigger) break; } - _effects.RemoveAll(eff => eff.Duration == 0); + EffectsList.RemoveAll(eff => eff.Duration == 0); } } } diff --git a/GoRogue/Effects/EffectTriggerBase.cs b/GoRogue/Effects/EffectTriggerBase.cs new file mode 100644 index 00000000..c7a680c3 --- /dev/null +++ b/GoRogue/Effects/EffectTriggerBase.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using JetBrains.Annotations; + +namespace GoRogue.Effects +{ + /// + /// Base class for and . Typically not + /// useful unless you're creating a a custom implementation of effects and/or triggers. + /// + [PublicAPI] + public class EffectTriggerBase where TEffect : EffectBase + { + /// + /// All effects that are part of this trigger. + /// + protected readonly List EffectsList; + /// + /// List of all effects that are part of this trigger. + /// + public IReadOnlyList Effects => EffectsList.AsReadOnly(); + + /// + /// Constructor. + /// + protected EffectTriggerBase() => EffectsList = new List(); + + /// + /// Adds the given effect to this trigger, provided the effect's duration is not 0. If + /// the effect's duration is 0, an ArgumentException is thrown. + /// + /// The effect to add to this trigger. + public virtual void Add(TEffect effect) + { + if (effect.Duration == 0) + throw new ArgumentException( + $"Tried to add effect {effect.Name} to an effect trigger, but it has duration 0!", nameof(effect)); + + EffectsList.Add(effect); + } + + /// + /// Adds the given effects to this trigger, provided the effect's durations are not 0. If + /// an effect's duration is 0, an ArgumentException is thrown. + /// + /// The effects to add to this trigger. + public virtual void AddRange(IEnumerable effects) + { + foreach (var effect in effects) + Add(effect); + } + + /// + /// Removes the given effect from this trigger. + /// + /// The effect to remove + public virtual void Remove(TEffect effect) => EffectsList.Remove(effect); + + /// + /// Removes all given effects from this trigger which match the predicate. + /// + /// The predicate to decide which effects to remove. + public void RemoveAll([InstantHandle] Predicate match) => EffectsList.RemoveAll(match); + + /// + /// Yields a string representation of each effect that has been added to the effect trigger. + /// + /// + /// A string representation of each effect that has been added to the effect trigger. + /// + public override string ToString() => EffectsList.ExtendToString(); + } +} From 17453108b89db32aae6963a4d1d004f663c0392e Mon Sep 17 00:00:00 2001 From: Chris3606 Date: Wed, 12 Jul 2023 12:52:27 -0500 Subject: [PATCH 5/8] Updated documentation to more fully document new effects system. --- .../articles/howtos/effects-system.md | 77 +++- GoRogue.Docs/articles/toc.yml | 2 + GoRogue.Snippets/HowTos/EffectsSystem.cs | 384 +++++++++++------- GoRogue.Snippets/Program.cs | 2 +- 4 files changed, 315 insertions(+), 150 deletions(-) diff --git a/GoRogue.Docs/articles/howtos/effects-system.md b/GoRogue.Docs/articles/howtos/effects-system.md index 1c7d51e1..825e9e9e 100644 --- a/GoRogue.Docs/articles/howtos/effects-system.md +++ b/GoRogue.Docs/articles/howtos/effects-system.md @@ -2,43 +2,94 @@ title: Effects System --- -# Overview -The Effects system exists to provide a class structure suitable for representing any "effect" in-game. These could include dealing damage, healing a target, area-based effects, over-time effects, or even permanent/conditional modifiers. The system provides the capability for effects to have duration in arbitrary units, from instantaneous (immediate), to infinite (activates whenever a certain event happens, forever). +# Effects System +The Effects system exists to provide a class structure suitable for representing any "effect" in-game. These could include dealing damage, mitigating damage, healing a target, area-based effects, over-time effects, or even permanent/conditional modifiers. The system provides the capability for effects to have duration in arbitrary units, from instantaneous (immediate), to infinite (activates whenever a certain event happens, forever or until manually removed). # Effects At its core, the `Effect` class is an abstract class that should be subclassed in order to define what a given effect does. It defines an abstract `OnTrigger` method that, when called, should take all needed actions to cause a particular effect. The (non abstract) public `Trigger()` function should be called to trigger the effect, as it calls the `OnTrigger` function, as well as decrements the remaining duration on an effect (if it is not instantaneous or infinite). ## Parameters of Effects -Different effects may need vastly different types and numbers of parameters passed to the `Trigger()` function in order to work properly. For this reason, `Effect` takes a generic type parameter which indicates the type of the (single) argument that will be passed to the `Trigger` function. In order to enable some advanced functionality with [EffectTriggers](#duration-of-effects-and-effecttrigger), all types used as the value for this type parameter must inherit from the class `EffectArgs`. It is also possible to pass multiple parameters to the `Trigger` function -- you can simply create a class/struct that wraps all the values you need to pass into one type, and use that as the type parameter when subclassing. This will be demonstrated in a later code example. +`Effect` doesn't allow you to pass any sort of input data to the `Trigger()` function. In many cases, this isn't an issue, because more often than not parameters that have to do with effects can instead be given to that effect as constructor properties, rather than at the time of trigger. If you do need to pass a parameter to `Trigger()`, however, `AdvancedEffect` allows this. `AdvancedEffect` takes a generic type parameter which indicates the type of the (single) argument that will be passed to the `Trigger` function. It is also possible to pass multiple parameters to the `Trigger` function -- you can simply create a class/struct that wraps all the values you need to pass into one type, and use that as the type parameter when subclassing. ## Constructing Effects Each effect takes a string parameter representing its name (for display purposes), and an integer variable representing its duration. Duration (including infinite and instant duration effects), are covered in more depth below. -## Creating a Subclass +# Basic Example For the sake of a concise code example, we will create a small code example which takes a Monster class with an HP field, and creates an effect to apply basic damage. [!code-csharp[](../../../GoRogue.Snippets/HowTos/EffectsSystem.cs#EffectsBasicExample)] # Duration of Effects and EffectTrigger -The code example above may appear to be excessively large for such a simple task; the advantage of using `Effect` for this type of functionality lies in Effect's capability for durations. `Effect` takes as a constructor parameter an integer duration. This duration can either be an integer constant in range `[1, int.MaxValue]`, or one of two special (static) constants. These constants are either `Effect.Instant`, which represents effects that simply take place whenever their `Trigger()` function is called and do not partake in the duration system, or `Effect.Infinite`, which represents and effect that has an infinite duration. +The code example above may appear to be excessively large for such a simple task. However, one of the advantages of using using `Effect` for this type of functionality is that the effects system has built-in support for durations. `Effect` takes as a constructor parameter an integer duration. This duration can either be an integer value in range `[1, int.MaxValue]`, or one of two special (static) constants. These constants are either `EffectDuration.Instant`, which represents effects that simply take place whenever their `Trigger()` function is called and do not partake in the duration system, or `EffectDuration.Infinite`, which represents and effect that has an infinite duration. -The duration value is in no particular unit of measurement, other than "number of times `Trigger()` is called". In fact, the duration value means very little by itself -- rather, any non-instant effect is explicitly meant to be used with an `EffectTrigger`. `EffectTrigger` is, in essence, a highly augmented list of `Effect` instances that all take the same parameter to their `Trigger()` function. It has a method that calls the `Trigger()` functions of all Effects in its list (which modifies the duration value for the `Effect` as appropriate), then removes any effect from the list whose durations have reached 0. It also allows any effect in the list to "cancel" the trigger, preventing the `Trigger()` functions in subsequent effects from being called. In this way, `EffectTrigger` provides a convenient way to manage duration-based effects. - -## Creating an EffectTrigger -When we create an `EffectTrigger`, we must specify a type parameter. This is the same type parameter that we specified when dealing with effects -- it is the type of the argument passed to the `Trigger()` function of effects it holds, and the type used must subclass `EffectArgs`. Only `Effect` instances taking this specified type to their `Trigger()` function may be added to that `EffectTrigger`. For example, if you have an instance of type `EffectTrigger`, only `Effect` instances may be added to it -- eg. only `Effect` instances that take an argument of type `DamageArgs` to their `Trigger()` function. +The duration value is in no particular unit of measurement, other than "number of times `Trigger()` is called". In fact, the duration value means very little by itself -- rather, any non-instant effect is explicitly meant to be used with an `EffectTrigger`. `EffectTrigger` is, in essence, a highly augmented list of `Effect` instances. It has a method that calls the `Trigger()` functions of all Effects in its list (which modifies the duration value for the `Effect` as appropriate), then removes any effect from the list whose durations have reached 0. It also allows any effect in the list to "cancel" the trigger, preventing the `Trigger()` functions in subsequent effects from being called. In this way, `EffectTrigger` provides a convenient way to manage duration-based effects. ## Adding Effects `Effect` instances can be added to an `EffectTrigger` by calling the `Add()` function, and passing the `Effect` to add. Such an effect will automatically have its `Trigger()` method called next time the effect trigger's [TriggerEffects](#triggering-added-effects) function is called. If an effect with duration 0 (instant or expired duration) is added, an exception is thrown. ## Triggering Added Effects -Once effects have been added, all the effects may be triggered with a single call to the `TriggerEffects()` function. When this function is called, all effects that have been added to the `EffectTrigger` have their `Trigger()` function called. If any of the effects set the `CancelTrigger` field of their argument to true, the trigger is "cancelled", and no subsequent effects will have their `Trigger()` function called. +Once effects have been added, all the effects may be triggered with a single call to the `TriggerEffects()` function. When this function is called, all effects that have been added to the `EffectTrigger` have their `Trigger()` function called. If any of the effects set the `cancelTrigger` boolean value they receive to true, the trigger is "cancelled", and no subsequent effects in that `EffectTrigger` will have their `Trigger()` function called. ## A Code Example -In this example, we will utilize the `Damage` effect written in the previous code example to create and demonstrate instantaneous, damage-over-time, and infinite damage-over-time effects. +In this example, we will utilize the `Damage` effect written in the previous code example to create an `EffectTrigger` and demonstrate its support for instantaneous, damage-over-time, and infinite damage-over-time effects. + +[!code-csharp[](../../../GoRogue.Snippets/HowTos/EffectsSystem.cs#EffectTriggersAndDurationsExample)] + +For reference, the output of the above code is something like this: + +```console +Triggering instantaneous effect... +An effect triggered; monster now has 96 HP. + +Added some duration-based effects; current effects: [Damage: 3 duration remaining, Damage: Infinite duration remaining] +Press enter to trigger round 1: +Triggering round 1.... +An effect triggered; monster now has 92 HP. +An effect triggered; monster now has 86 HP. -[!code-csharp[](../../../GoRogue.Snippets/HowTos/EffectsSystem.cs#EffectsAdvancedExample)] +Current Effects: [Damage: 2 duration remaining, Damage: Infinite duration remaining] +Press enter to trigger round 2: +Triggering round 2.... +An effect triggered; monster now has 79 HP. +An effect triggered; monster now has 72 HP. + +Current Effects: [Damage: 1 duration remaining, Damage: Infinite duration remaining] +Press enter to trigger round 3: +Triggering round 3.... +An effect triggered; monster now has 69 HP. +An effect triggered; monster now has 65 HP. + +Current Effects: [Damage: Infinite duration remaining] +Press enter to trigger round 4: +Triggering round 4.... +An effect triggered; monster now has 59 HP. + +Current Effects: [Damage: Infinite duration remaining] +``` # Conditional-Duration Effects We can also represent effects that have arbitrary, or conditional durations, via the infinite-duration capability. -For example, consider a healing effect that heals the player, but only when there is at least one enemy within a certain radius at the beginning of a turn. We could easily implement such an effect by giving this effect infinite duration and adding it to an `EffectTrigger` that has its `TriggerEffects()` function called at the beginning of the turn. The `OnTrigger()` implementation could do any relevant checking as to whether or not an enemy is in range. Furthermore, if we wanted to permanently cancel this effect as soon as there was no longer an enemy within the radius, we could simply set the effect's duration to 0 in the `OnTrigger()` implementation when it does not detect an enemy, and the effect would be automatically removed from its `EffectTrigger`. \ No newline at end of file +For example, consider a healing effect that heals the player, but only when there is at least one enemy within a certain radius at the beginning of a turn. We could easily implement such an effect by giving this effect infinite duration and adding it to an `EffectTrigger` that has its `TriggerEffects()` function called at the beginning of the turn. The `OnTrigger()` implementation could do any relevant checking as to whether or not an enemy is in range. Furthermore, if we wanted to permanently cancel this effect as soon as there was no longer an enemy within the radius, we could simply set the effect's duration to 0 in the `OnTrigger()` implementation when it does not detect an enemy, and the effect would be automatically removed from its `EffectTrigger`. + + +# Passing Parameters to the Trigger Function +In the case above, we passed the damage bonus and target parameters to the effect in its constructor. This works well for many use cases, when the parameters are part of the effect itself. However, in other use cases, we may want to pass parameters to the `OnTrigger()` function of the effect. This is typically the case when the effect is being used with a trigger, and the parameter is something to do with the trigger event itself, rather than the effect. + +A good example of this might be an "armor" effect, that is called via a trigger which triggers whenever damage is taken. The "armor" effect should reduce incoming damage by a fixed percentage. For this, the effect needs to know how much damage the target is taking; and this isn't known until the effect is triggered, so it can't be specified when the effect is created. + +`Effect` and `EffectTrigger` do not support this use case. Instead, GoRogue contains `AdvancedEffect` and `AdvancedEffectTrigger` classes to support this. These are identical to `Effect` and `EffectTrigger`, respectively, except that they take a type parameter which specifies the type of an arbitrary argument that must be provided to their `Trigger` and `TriggerEffects` functions. + +The following code uses this functionality to implement an "armor" effect like we described above. + +[!code-csharp[](../../../GoRogue.Snippets/HowTos/EffectsSystem.cs#AdvancedEffectsExample)] + +The output of this code will look something like this: + +```console +Damage effect dealt 4 damage (before reduction). +Damage taken reduced from 4 to 2 by armor. +Monster took damage; it now has 98 HP. +``` + +Note that the type parameter given to an `AdvancedEffectTrigger` must also be the same type parameter given to any `AdvancedEffect` added to it. \ No newline at end of file diff --git a/GoRogue.Docs/articles/toc.yml b/GoRogue.Docs/articles/toc.yml index c4b0ef3d..1b50f02f 100644 --- a/GoRogue.Docs/articles/toc.yml +++ b/GoRogue.Docs/articles/toc.yml @@ -9,6 +9,8 @@ - name: How-Tos href: howtos/index.md items: + - name: Effects System + href: howtos/effects-system.md - name: Factories href: howtos/factories.md - name: Map Generation diff --git a/GoRogue.Snippets/HowTos/EffectsSystem.cs b/GoRogue.Snippets/HowTos/EffectsSystem.cs index 2d9c8f7a..4cefa1d2 100644 --- a/GoRogue.Snippets/HowTos/EffectsSystem.cs +++ b/GoRogue.Snippets/HowTos/EffectsSystem.cs @@ -4,141 +4,253 @@ namespace GoRogue.Snippets.HowTos { #region EffectsBasicExample - // public static class EffectsBasicExample - // { - // class Monster - // { - // public int HP { get; set; } - // - // public Monster(int hp) - // { - // HP = hp; - // } - // } - // - // // Our Damage effect will need two parameters to function -- who is taking - // // the damage, eg. the target, and a damage bonus to apply to the roll. - // // Thus, we wrap these in one class so an instance may be passed to Trigger. - // class DamageArgs : EffectArgs - // { - // public Monster Target { get; } - // public int DamageBonus {get; } - // - // public DamageArgs(Monster target, int damageBonus) - // { - // Target = target; - // DamageBonus = damageBonus; - // } - // } - // - // // We inherit from Effect, where T is the type of the - // // argument we want to pass to the Trigger function. - // class Damage : Effect - // { - // // Since our damage effect can be instantaneous or - // // span a duration (details on durations later), - // // we take a duration and pass it along to the base - // // class constructor. - // public Damage(int duration) - // : base("Damage", duration) - // { } - // - // // Our damage is 1d6, plus the damage bonus. - // protected override void OnTrigger(DamageArgs args) - // { - // // Rolls 1d6 -- see Dice Rolling documentation for details - // int damageRoll = Dice.Roll("1d6"); - // int totalDamage = damageRoll + args.DamageBonus; - // args.Target.HP -= totalDamage; - // } - // } - // - // public static void ExampleCode() - // { - // Monster myMonster = new Monster(10); - // // Effect that triggers instantaneously -- details later - // Damage myDamage = new Damage(Damage.Instant); - // // Instant effect, so it happens whenever we call Trigger - // myDamage.Trigger(new DamageArgs(myMonster, 2)); - // } - // } - // #endregion - // - // #region AdvancedEffectsExample - // public static class AdvancedEffectsExample - // { - // class Monster - // { - // private int _hp; - // - // public int HP - // { - // get => _hp; - // set - // { - // _hp = value; - // Console.WriteLine($"An effect triggered; monster now has {_hp} HP."); - // } - // } - // - // public Monster(int hp) - // { - // HP = hp; - // } - // } - // - // class DamageArgs : EffectArgs - // { - // public Monster Target { get; } - // public int DamageBonus {get; } - // - // public DamageArgs(Monster target, int damageBonus) - // { - // Target = target; - // DamageBonus = damageBonus; - // } - // } - // - // class Damage : Effect - // { - // public Damage(int duration) - // : base("Damage", duration) - // { } - // - // protected override void OnTrigger(DamageArgs args) - // { - // int damageRoll = Dice.Roll("1d6"); - // int totalDamage = damageRoll + args.DamageBonus; - // args.Target.HP -= totalDamage; - // } - // } - // - // public static void ExampleCode() - // { - // Monster myMonster = new Monster(40); - // // Effect that triggers instantaneously, so it happens only when we call Trigger - // // and cannot be added to any EffectTrigger - // Damage myDamage = new Damage(Damage.Instant); - // Console.WriteLine("Triggering instantaneous effect..."); - // myDamage.Trigger(new DamageArgs(myMonster, 2)); - // - // EffectTrigger trigger = new EffectTrigger(); - // // We add one 3-round damage over time effect, one infinite damage effect. - // trigger.Add(new Damage(3)); - // trigger.Add(new Damage(Damage.Infinite)); - // - // Console.WriteLine($"Current Effects: {trigger}"); - // for (int round = 1; round <= 4; round++) - // { - // Console.WriteLine($"Enter a character to trigger round {round}"); - // Console.ReadLine(); - // - // Console.WriteLine($"Triggering round {round}...."); - // trigger.TriggerEffects(new DamageArgs(myMonster, 2)); - // Console.WriteLine($"Current Effects: {trigger}"); - // } - // - // } - // } + public static class EffectsBasicExample + { + class Monster + { + public int HP { get; set; } + + public Monster(int hp) + { + HP = hp; + } + } + + // Our Damage effect will need two parameters to function -- who is taking + // the damage, eg. the target, and a damage bonus to apply to the roll. + // + // We don't pass any parameters to the Trigger function, however; we instead + // pass them all as constructor parameters to the class. This also allows us + // to inherit from Effect instead of AdvancedEffect. + class Damage : Effect + { + public readonly Monster Target; + public readonly int DamageBonus; + + // Since our damage effect can be instantaneous or span a duration + // (details on durations later), we take a duration and pass it along to + // the base class constructor, as well as the parameters we need to deal + // damage. + public Damage(Monster target, int damageBonus, int duration) + : base("Damage", duration) + { + Target = target; + DamageBonus = damageBonus; + } + + // Our damage is 1d6, plus the damage bonus. + protected override void OnTrigger(out bool cancelTrigger) + { + // Since the parameter is an "out" parameter, we must set it to something. + // We don't want to cancel a trigger that triggered this effect, so we + // set it to false. In this example, we're not using the effect with + // EffectTriggers anyway, so this parameter doesn't have any effect + // either way. + cancelTrigger = false; + + // Rolls 1d6 -- see Dice Rolling documentation for details + int damageRoll = Dice.Roll("1d6"); + int totalDamage = damageRoll + DamageBonus; + Target.HP -= totalDamage; + } + } + + public static void ExampleCode() + { + Monster myMonster = new Monster(10); + + // We'll make this an instant effect, so it happens + // whenever (and only whenever) we call Trigger(). + Damage myDamage = new Damage(myMonster, 2, EffectDuration.Instant); + myDamage.Trigger(); + } + } + #endregion + + #region EffectTriggersAndDurationsExample + public static class EffectTriggersAndDurationsExample + { + class Monster + { + private int _hp; + + public int HP + { + get => _hp; + set + { + _hp = value; + Console.WriteLine($"An effect triggered; monster now has {_hp} HP."); + } + } + + public Monster(int hp) + { + _hp = hp; + } + } + + class Damage : Effect + { + public readonly Monster Target; + public readonly int DamageBonus; + + public Damage(Monster target, int damageBonus, int duration) + : base("Damage", duration) + { + Target = target; + DamageBonus = damageBonus; + } + + protected override void OnTrigger(out bool cancelTrigger) + { + cancelTrigger = false; + + int damageRoll = Dice.Roll("1d6"); + int totalDamage = damageRoll + DamageBonus; + Target.HP -= totalDamage; + } + } + + public static void ExampleCode() + { + Monster myMonster = new Monster(100); + // Effect that triggers instantaneously, so it happens only when we call Trigger + // and cannot be added to any EffectTrigger + Damage myDamage = new Damage(myMonster, 2, EffectDuration.Instant); + Console.WriteLine("Triggering instantaneous effect..."); + myDamage.Trigger(); + + EffectTrigger trigger = new EffectTrigger(); + // We add one 3-round damage over time effect, and one infinite damage effect. + trigger.Add(new Damage(myMonster, 2, 3)); + trigger.Add(new Damage(myMonster, 2, EffectDuration.Infinite)); + + Console.WriteLine($"\nAdded some duration-based effects; current effects: {trigger}"); + for (int round = 1; round <= 4; round++) + { + Console.Write($"Press enter to trigger round {round}: "); + Console.ReadLine(); + + Console.WriteLine($"Triggering round {round}...."); + trigger.TriggerEffects(); + Console.WriteLine($"\nCurrent Effects: {trigger}"); + } + + } + } + #endregion + + #region AdvancedEffectsExample + public static class AdvancedEffectsExample + { + // This will be the class we use to pass information to our effect's Trigger function. + // It could be a struct, but we'll use a class here so we can easily modify the + // DamageTaken value. + class DamageArgs + { + public int DamageTaken; + + public DamageArgs(int damageTaken) + { + DamageTaken = damageTaken; + } + } + + class Monster + { + private int _hp; + + public int HP + { + get => _hp; + set + { + _hp = value; + Console.WriteLine($"Monster took damage; it now has {_hp} HP."); + } + } + + public readonly AdvancedEffectTrigger DamageTrigger; + + public Monster(int hp) + { + _hp = hp; + DamageTrigger = new AdvancedEffectTrigger(); + } + + public void TakeDamage(int damage) + { + // Create the DamageArgs object to pass to the trigger and pass it + // to the TriggerEffects function. + Console.WriteLine($"Damage effect dealt {damage} damage (before reduction)."); + DamageArgs args = new DamageArgs(damage); + DamageTrigger.TriggerEffects(args); + + // Use whatever damage we get back as the damage to take + HP -= args.DamageTaken; + } + } + + class DamageEffect : Effect + { + public readonly Monster Target; + public readonly int DamageBonus; + + public DamageEffect(Monster target, int damageBonus, int duration) + : base("Damage", duration) + { + Target = target; + DamageBonus = damageBonus; + } + + protected override void OnTrigger(out bool cancelTrigger) + { + cancelTrigger = false; + + int damageRoll = Dice.Roll("1d6"); + int totalDamage = damageRoll + DamageBonus; + + // Unlike the previous example, we'll take damage with the TakeDamage + // function so that our DamageTrigger can trigger. + Target.TakeDamage(totalDamage); + } + } + + // Note that this effect inherits from AdvancedEffect, and takes whatever + // type we specify as an additional argument to its Trigger and OnTrigger + // functions. + class ArmorEffect : AdvancedEffect + { + public readonly float ReductionPct; + + public ArmorEffect(float reductionPct, int startingDuration) + : base("ArmorEffect", startingDuration) + { + ReductionPct = reductionPct; + } + + protected override void OnTrigger(out bool cancelTrigger, DamageArgs args) + { + cancelTrigger = false; + + int originalDamage = args.DamageTaken; + args.DamageTaken = (int)(args.DamageTaken * (1f - ReductionPct)); + Console.WriteLine($"Damage taken reduced from {originalDamage} to {args.DamageTaken} by armor."); + } + } + + public static void ExampleCode() + { + Monster myMonster = new Monster(100); + + // Add a 50% armor effect + myMonster.DamageTrigger.Add(new ArmorEffect(0.5f, EffectDuration.Infinite)); + + // Trigger an effect to deal some damage + var myDamage = new DamageEffect(myMonster, 2, EffectDuration.Instant); + myDamage.Trigger(); + } + } #endregion } diff --git a/GoRogue.Snippets/Program.cs b/GoRogue.Snippets/Program.cs index 552166f6..b08789f8 100644 --- a/GoRogue.Snippets/Program.cs +++ b/GoRogue.Snippets/Program.cs @@ -3,4 +3,4 @@ using GoRogue.Snippets.HowTos; Console.WriteLine("This project exists only to contain snippets for the documentation."); -DiceNotation.ExampleCode(); +AdvancedEffectsExample.ExampleCode(); From 4b9d3f42159fdfc42afc0ad0d487e4d37c64e580 Mon Sep 17 00:00:00 2001 From: Chris3606 Date: Wed, 12 Jul 2023 12:52:43 -0500 Subject: [PATCH 6/8] Updated changelog. --- changelog.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/changelog.md b/changelog.md index e770a310..ffd547ef 100644 --- a/changelog.md +++ b/changelog.md @@ -5,7 +5,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] -- None +### Added +- `AdvancedEffect` and `AdvancedEffectTrigger` have been added, and have a `Trigger` function that takes a parameter of the type you specify, which allows the effect to be triggered with additional context information. + - This is equivalent to the old method of passing parameters which entailed creating a subclass of `EffectArgs`. + - These classes accept a parameter of an arbitrary type, rather than forcing you to subclass `EffectArgs` to pass parameters. + +### Changed +- `Effect` and `EffectTrigger` no longer accept type parameters + - Versions called `AdvancedEffect` and `AdvancedEffectTrigger` have been added which do accept type parameters +- Cancellation of a trigger from an effect is now handled via an `out bool` parameter given to `Trigger` and `OnTrigger` + - Set this boolean value to true to cancel the trigger + +### Removed +- `EffectArgs` has been removed. + - Parameters to `AdvancedEffect` and `AdvancedEffectTrigger` can now be of an arbitrary type. ## [3.0.0-beta07] - 2023-07-11 ### Added From f257021f64e8592371919c326f88a2992a21c351 Mon Sep 17 00:00:00 2001 From: Chris3606 Date: Wed, 12 Jul 2023 12:59:37 -0500 Subject: [PATCH 7/8] New changelog update. --- changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog.md b/changelog.md index ffd547ef..226aa6d2 100644 --- a/changelog.md +++ b/changelog.md @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - These classes accept a parameter of an arbitrary type, rather than forcing you to subclass `EffectArgs` to pass parameters. ### Changed +- All classes related to the effects system have been moved to the `GoRogue.Effects` namespace. - `Effect` and `EffectTrigger` no longer accept type parameters - Versions called `AdvancedEffect` and `AdvancedEffectTrigger` have been added which do accept type parameters - Cancellation of a trigger from an effect is now handled via an `out bool` parameter given to `Trigger` and `OnTrigger` From 4a3e88d2d10f5dd86ac7c5f390ea81350b4a6435 Mon Sep 17 00:00:00 2001 From: Chris3606 Date: Wed, 12 Jul 2023 13:46:34 -0500 Subject: [PATCH 8/8] Updated csproj and changelog for beta08 release. --- GoRogue/GoRogue.csproj | 2 +- changelog.md | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/GoRogue/GoRogue.csproj b/GoRogue/GoRogue.csproj index 78e0d6e8..7fd7aec7 100644 --- a/GoRogue/GoRogue.csproj +++ b/GoRogue/GoRogue.csproj @@ -11,7 +11,7 @@ Configure versioning information, making sure to append "debug" to Debug version to allow publishing to NuGet seperately from Release version. --> - 3.0.0-beta07 + 3.0.0-beta08 $(Version)-debug diff --git a/changelog.md b/changelog.md index 226aa6d2..0c9f8a1b 100644 --- a/changelog.md +++ b/changelog.md @@ -5,6 +5,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +- None + +## [3.0.0-beta08] - 2023-07-12 + ### Added - `AdvancedEffect` and `AdvancedEffectTrigger` have been added, and have a `Trigger` function that takes a parameter of the type you specify, which allows the effect to be triggered with additional context information. - This is equivalent to the old method of passing parameters which entailed creating a subclass of `EffectArgs`.