Skip to content

Effects System

Chris3606 edited this page Oct 20, 2018 · 28 revisions

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).

Table of Contents

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 is a template class. The template parameter represents the type of the (single) argument that will be passed to the Trigger function. In order to enable some advanced functionality with EffectTriggers, all template types for Effect must inherit from the class EffectArgs. Taking this template parameter makes it possible to pass multiple parameters to the Trigger function -- simply create a class/struct that wraps all the values you need to pass into one class, and use that as the template 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.

using GoRogue;
using GoRogue.DiceNotation;

class Monster
{
    private int _hp;
    public int HP
    {
        get => _hp;
        set
        {
            _hp = value;
            System.Console.WriteLine("Monster HP changed, now has " + _hp + " HP.");
        }
    }

    public Monster(int startingHP)
    {
        _hp = startingHP;
    }
}

// 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; private set; }
    public int DamageBonus {get; private set; }

    public DamageArgs(Monster target, int damageBonus)
    {
        Target = target;
        DamageBonus = damageBonus;
    }
}

// We inherit from Effect<T>, where T is the type of the
// argument we want to pass to the Trigger function.
class Damage : Effect<DamageArgs>
{
    // 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;
    }
}

class Program
{
    static void Main(string[] args)
    {
        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));
    }
}

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<T>.INSTANT, which represents effects that simply take place whenever their Trigger() function is called and do not partake in the duration system, or Effect<T>.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 template parameter. This is the same template 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 must subclass EffectArgs. Only Effect instances taking this specified type to their Trigger() function may be added to that EffectTrigger. For example, if I have an instance of type EffectTrigger<DamageArgs>, only Effect<DamageArgs> 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 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.

using GoRogue;
using GoRogue.DiceNotation;

class Monster
{
    private int _hp;
    public int HP
    {
        get => _hp;
        set
        {
            _hp = value;
            System.Console.WriteLine("An effect triggered; monster now has " + _hp + " HP.");
        }
    }

    public Monster(int startingHP)
    {
        _hp = startingHP;
    }
}

// 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; private set; }
    public int DamageBonus {get; private set; }

    public DamageArgs(Monster target, int damageBonus)
    {
        Target = target;
        DamageBonus = damageBonus;
    }
}

// We inherit from Effect<T>, where T is the type of the
// argument we want to pass to the Trigger function.
class Damage : Effect<DamageArgs>
{
    // Since our damage effect can be instantaneous or 
    // span a duration, 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;
    }
}

class Program
{
    static void Main(string[] args)
    {
        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);
        System.Console.WriteLine("Triggering instantaneous effect...");
        myDamage.Trigger(new DamageArgs(myMonster, 2));

        EffectTrigger<DamageArgs> trigger = new EffectTrigger<DamageArgs>();
        // We add one 3-round damage over time effect, one infinite damage effect.
        trigger.Add(new Damage(3));
        trigger.Add(new Damage(Damage.INFINITE));

        System.Console.WriteLine("Current Effects: " + trigger);
        System.Console.WriteLine("Enter a character to advance to the first turn: ");
        System.Console.ReadLine();
        for (int round = 1; round <= 4; round++)
        {
            System.Console.WriteLine("Triggering round " + round + "....");
            trigger.TriggerEffects(new DamageArgs(myMonster, 2));
            System.Console.WriteLine("Current Effects: " + trigger);
            if (round < 4) // There is a next round
            {
                System.Console.WriteLine("Enter a character to trigger round " + (round + 1));
                System.Console.ReadLine();
            }
        }
    }
}

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.