Damage Events

Introduction

Previous tutorials have discussed the automatic damage dealing logic provided by collision relationships. Full games often need to modify how much damage is dealt depending on a variety of factors such as element weaknesses. Similarly, games may need to respond to damage dealt such as by providing a visual indication of an entity taking damage.

This tutorial shows how to assign events on IDamageable entities to modify damage dealt and to visually indicate that damage has been dealt.

ReactToDamageDealt Delegate

Entities which implement the IDamageable interface are required to have an event named ReactToDamageDealt. This is automatically generated by the FlatRedBall Editor, so the event is available in custom code. This can be used to add custom code to handle an entity receiving damage.

Keep in mind this event occurs after damage checks have been made. It will only be raised if the IDamageable entity has a different team index than the argument IDamageArea, and before the IDamageable is killed (if enough damage is dealt to kill the IDamageable). For example, we can make the enemy flash when it takes damage. First, we'll change the enemy's AxisAlignedRectangleInstance color to red in the FlatRedBall Editor.

Next, we can handle the event in code. In this case we'll use an async call to set the color to white temporarily, then set it back to red. Keep in mind that for a full project you may want additional logic to allow the enemy to receive damage multiple times without flashing the rectangle back. For the sake of simplicity we'll ignore this and write simpler code which automatically sets the rectangle back to its original color after a short delay.

private void CustomInitialize()
{
    this.ReactToDamageReceived = +HandleDamageReceived;
}

private async void HandleDamageReceived(decimal arg1, IDamageArea arg2)
{
    var colorBefore = AxisAlignedRectangleInstance.Color;
    AxisAlignedRectangleInstance.Color = Color.White;
    await TimeManager.DelaySeconds(.1);
    AxisAlignedRectangleInstance.Color = colorBefore;
}

Now the Enemy flashes white when taking damage. Note that to test this you may want to disable the enemy shooting logic added in the previous tutorial. Also, you may want to adjust the health of the enemy to take more hits.

The purpose of ReactToDamageDealt is to react to damage visually, or to perform other game logic outside of typical IDamageable logic. Therefore, ReactToDamageDealt should not:

  • Kill the damage receiver unless there are special circumstances

  • Deal more damage to the damage receiver

  • Destroy the IDamageArea (damage dealer) unless there are special circumstances

ModifyDamageDealt Event

The ModifyDamageDealt event can be used to perform logic that can change how much damage an entity receives. For example, an enemy may be weak to a particular type of bullet such as an elemental bullet. For this example we will add an IsFireBullet property to the Bullet, and an IsIceEnemy property to the Enemy. Note that a real game may handle these variants through entity inheritance, but we will add properties through code to keep the tutorial shorter. Add the following code to Bullet.cs:

bool isFireBullet;
public bool IsFireElement
{
    get => isFireBullet;
    set
    {
        isFireBullet = value;
        if(value)
        {
            this.CircleInstance.Color = Color.Red;
        }
    }
}

Next, add the following code to Enemy.cs:

bool isIceEnemy;
public bool IsIceEnemy
{
    get => isIceEnemy;
    set
    {
        isIceEnemy = value;
        if(isIceEnemy) 
        {
            AxisAlignedRectangleInstance.Color = Color.Blue;
        }
    }
}

Now we'll modify Player.cs to create fire bullets when pressing left Alt key:

private void CustomActivity()
{
    if(InputManager.Keyboard.KeyPushed(Microsoft.Xna.Framework.Input.Keys.Space))
    {
        var bullet = Factories.BulletFactory.CreateNew(this.Position);
        bullet.YVelocity = 200;
    }
    if (InputManager.Keyboard.KeyPushed(Microsoft.Xna.Framework.Input.Keys.LeftAlt))
    {
        var bullet = Factories.BulletFactory.CreateNew(this.Position);
        bullet.IsFireElement = true;
        bullet.YVelocity = 200;
    }
}

Next, we'll modify the code that creates Enemies in GameScreen.cs to create ice enemies on a right-click:

void CustomActivity(bool firstTimeCalled)
{
    var cursor = GuiManager.Cursor;

    if(cursor.PrimaryPush)
    {
        var cursorPosition = cursor.WorldPosition;
        Factories.EnemyFactory.CreateNew(cursorPosition.X, cursorPosition.Y);
    }
    if (cursor.SecondaryPush)
    {
        var cursorPosition = cursor.WorldPosition;
        var enemy = Factories.EnemyFactory.CreateNew(cursorPosition.X, cursorPosition.Y);
        enemy.IsIceEnemy = true;
    }
}

Now we can create fire enemies and ice bullets by right clicking the mouse and pressing the left Alt key, respectively.

Now that we have two different types of enemies and two different types of bullets, we can write logic in the Enemy's ModifyDamageDealt event in response to the elements:

private void CustomInitialize()
{
    this.ReactToDamageReceived += HandleDamageReceived;
    this.ModifyDamageReceived = HandleModifyDamageReceived;
}

private decimal HandleModifyDamageReceived(decimal damage, IDamageArea arg2)
{
    var asBullet = arg2 as Bullet;

    if(asBullet != null && asBullet.IsFireElement && this.IsIceEnemy)
    {
        // for ice vs fire, deal double damage:
        damage *= 2;
    }

    return damage;
}

Now ice enemies die after 5 hits instead of 10 since each bullet will do an effective 20 points of damage.

Additional Events

This tutorial discusses two events:

  • IDamageable.ReactToDamageReceived

  • IDamageable.ModifyDamageReceived

The IDamageable and IDamageArea entities provide additional events which can be used to handle damage modification, reaction to taking damage, and death. This is the full list of events provided by each interface:

  • IDamageable.ModifyDamageReceived - Allows an IDamageable to modify how much damage it takes

  • IDamageable.ReactToDamageReceived - Allows an IDamageable to respond to taking damage

  • IDamageable.Died - Allows an IDamageable to respond to dying, such as by playing a sound effect or creating particles

  • IDamageArea.ModifyDamageDealt - Allows an IDamageArea to modify how much damage it deals

  • IDamageArea.ReactToDamageDealt - Allows an IDamageArea to respond to dealing damage

  • IDamageArea.KilledDamageable - Allows an IDamageArea to respond to killing an IDamageable

  • IDamageArea.RemovedByCollision - Allows an IDamageArea to respond to being removed by collision

Using += vs =

This tuturoial has used both of the terms *delegate* and *event*, primarily using event to indicate that these are methods which are invoked in response to something happening in the game such as the enemy taking damage. Before moving on to a new topic we should cover some of the technical details of how these delegates work.

Technically, these are delegates and not events. In other words, the IDamageable and IDamageArea interfaces do not define these using the event keyword. Therefore, these can be invoked externally, allowing the DamageableExtensionMethods.TakeDamage methods to call these methods as necessary.

If you are familiar with event syntax in C#, then you are probably used to the += operator when assigning a handler. This approach works for the delegates defined on the IDamageable and IDamageArea interfaces, but we should take a moment to mention a subtlety related to the ModifyDamageDealt and ModifyDamageReceived delegates. These delegates use the Func type, which means they return the modified damage value. If you use the += operator for these two delegates, you may end up dealing or receiving an unexpected amount of damage. The reason for this is because only the last handler added to these two delegates decides how much damage to deal. To understand why this might happen, let's look at an example.

Consider a game with an Enemy entity which has multiple variants (using inheritance). You may have an IsInvulnerable property on the Enemy, resulting in the following code for handling damage:

void CustomInitialize()
{
    ModifyDamageReceived += HandleDamageReceived;
}

decimal HandleDamageReceived(decimal initialDamage, IDamageArea damageArea)
{
    if(IsInvulnerable)
    {
        return 0;
    }
    return initialDamage;
}

You may also decide to have additional logic for an enemy variant which can hide in its shell. If the enemy is in its shell, it would receive half damage. The implementation may look like this:

// In the derived variant, such as TurtleEnemy.cs
void CustomInitialize()
{
    ModifyDamageReceived += HandleDamageReceived;
}

decimal HandleDamageReceived(decimal initialDamage, IDamageArea damageArea)
{
    if(IsInShell)
    {
        return initialDamage/2;
    }
    return initialDamage;
}

In this situation, the base CustomInitialize is called first, then the derived CustomInitialize. This means that whenever the enemy receives damage, the base HandleDamageReceived is called first, then the derived HandleDamageReceived. Unfortunately, when HandleDamageReceived is called on the derived, the initialDamage is not modified by the first call. In other words, the initialDamage will never be affected by the check for IsInvulnerable because each method returns its value indepent of the other. In effect this results in the TurtleEnemy never respecting its IsInvulnerable variable.

Therefore, it's best to never += on any of the Func delegates - ModifyDamageDealt and ModifyDamageReceived. Instead only the = operator should be used for clarity. In this case, we can resolve the problem by creating a virtual method for handling damage which derived classes can override, as shown in the following code:

// In the base Enemy.cs:
void CustomInitialize()
{
    ModifyDamageReceived = HandleDamageReceived;
}

// Mark this as virtual so we can override it in the derived class:
protected virtual decimal HandleDamageReceived(decimal initialDamage, IDamageArea damageArea)
{
    if(IsInvulnerable)
    {
        return 0;
    }
    return initialDamage;
}

Now the derived variant can override the method as shown in the following code:

// do not assign or += the ModifyDamageReceived, we only need to
// override the HandleDamageReceived method:
protected override decimal HandleDamageReceived(decimal initialDamage, IDamageArea damageArea)
{
    // call the base to allow invulnerability to apply
    var damage = base.HandleDamageReceived(initialDamage, damageArea);
    // now do additional logic using the return value:
    
    if(IsInShell)
    {
        return damage /2;
    }
    return damage;
}

Conclusion

This tutorial discusses how to handle events to modify damage and display visual feedback to the player when damage is dealt. The next tutorial covers the removal of IDamageAreas and how to implement damage over time.

Last updated