Section 6: Combat System

In this section, we added a combat system for players and enemies to attack each other and take damage.

Section Intro - Combat System

There are no notes for this lecture.

Player Combo Attacks

In this lecture, we added two animations for player attacks and created the player attack state. There are no notes for this lecture.

Combo Counters

In this lecture, we tracked the player attacks using a counter. When we play the animation, we're combining the ANIM_ATTACK constant and comboCounter field to generate the name for the animation to play.

characterNode.AnimPlayerNode.Play(
    GameConstants.ANIM_ATTACK + comboCounter
);

Next, we subscribed to a signal called AnimationFinished from the animation player node. This signal gets emitted when the animation is finished playing.

characterNode.AnimPlayerNode.AnimationFinished += HandleAnimationFinished;

When our method gets called, we're wrapping the comboCounter field. Something to keep in mind is that the Mathf.Wrap method's maximum threshold argument wraps the value when the threshold is met or exceeded. So, the max threshold is incremented by 1 so that the value doesn't get wrapped too early.

comboCounter = Mathf.Wrap(comboCounter, 1, maxComboCount + 1);

Resetting the Combo Counter With a Timer

In this lecture, we decided to reset the combo counter if the player stops attacking after a few seconds. We used the timer node's Timeout signal for this event. During subscription, we used a lambda function.

comboTimerNode.Timeout += () => comboCounter = 1;

Lambda functions are just shorthand solutions for defining methods. If the signal gets emitted, the function gets executed. In this method, we're setting the comboCounter field to 1.

The timer gets started every time we exit the attack state. By doing so, the timer resets itself even if it's still running.

comboTimerNode.Start();

Hitboxes and Hurtboxes

In this lecture, we took the time to set up hitboxes and hurtboxes with Area3D nodes.

  • Hitboxes - Cause damage. (Ex: Swords, Arrows, Floor Spikes)

  • Hurtbox - Take damage. (Ex: Players, Enemies)

During this process, we subscribed to the AreaEntered signal on the Area3D node. Unlike the BodyEntered signal, this signal gets emitted when another area has been detected.

HurtboxNode.AreaEntered += HandleHurtboxEntered;

Next, we printed a message. For this example, we used string interpolation, which allows us to inject variable values into a string. First, the string must start with the $ character. Next, we must wrap the variable with a pair of {} characters. Other than that, everything else is the same. For demonstration purposes, here's an example of string interpolation and string concatenation.

private void HandleHurtboxEntered(Area3D area)
{
    GD.Print($"{area.Name} hit!"); // area.Name + " hit!"
}

Resources

Custom Stat Resources

In this lecture, we created a custom resource. Resources are a feature in Godot for storing any kind of data that we'd like. We can create a resource by inheriting from the Resource class. During this process, you'll have to make sure the class is partial.

using System;
using Godot;

public partial class StatResource : Resource
{
    [Export] public Stat StatType { get; private set; }
    [Export] public float StatValue { get; private set; }
}

In this example, we're creating a resource for storing stats. The first property will be the type of stat, and the second property will be a value. For the type of stat, we're using an Enum.

Enums are a feature for storing a list of values. They're helpful when you don't want to make typos in your program by relying on strings. We can define an enum with the enum keyword followed by an identifier. Inside the enum, we can provide a list of comma-separated values.

public enum Stat
{
    Strength,
    Health,
}

Understanding LINQ

In this lecture, we used LINQ to help us find a result from an array. To grab a single stat, we defined a method in the Character class called GetStatResource.

public StatResource GetStatResource(Stat stat)
{
    
}

This method will accept a specific stat and then return a resource. All resources are stored in a field called stats on the Character class. This field is an array. To search through an array, we can use a regular foreach and if but there's a feature in C# called Linq to help us filter and sort through collections. First, we must import the namespace to use it.

using System.Linq;

Next, we can use the Where method to loop through the array. On each iteration, the function passed into the Where method will be called where we must return a boolean. If true, the current item in the loop is stored in the results. Otherwise, the element gets discarded from the new array.

public StatResource GetStatResource(Stat stat)
{
    return stats.Where(element => element.StatType == stat)
        .FirstOrDefault();
}

Lastly, we're chaining the FirstOrDefault method since we're only interested in the first result from the array. If nothing is found, null is returned by our method.

Property Getters and Setters

In this lecture, we learned how to create flexible getter and setter accessors. We can update the get and set keywords to functions to refine their behavior. We did so with the StatValue property since we want to be able to set it from outside the class.

using System;
using Godot;

[GlobalClass]
public partial class StatResource : Resource
{
    [Export] public Stat StatType { get; private set; }

    private float _statValue;

    [Export]
    public float StatValue
    {
        get => _statValue;
        set
        {
            _statValue = Mathf.Clamp(value, 0, Mathf.Inf);
        }
    }
}

To use this syntax, both the get and set keywords must be converted into functions. For the get keyword, we're returning a field called _statValue. A few things worth noting about this:

  • Behind the scenes, C# creates a field for you using auto-properties. However, if you plan on using this syntax, you are responsible for creating a field that will actually store the value.

  • It's common practice for a private field that a property will use to start with an _ character and be camelcased.

In the set accessor, we're clamping the value with the Mathf.Clamp method. This method makes sure that a numeric value stays within a range. It accepts the value to clamp, a minimum and maximum threshold.

In this example, we're using the value keyword, which represents the value that the property is being set to from external sources.

Animation Method Tracks

In this lecture, we learned about method tracks, which are a feature to call methods from a specific frame in your animation. There are no notes for this lecture.

Moving Nodes By Local Position

In this lecture, we moved a node by its local position. We have a hitbox that needs to be moved left or right depending on where the enemy is facing. In addition, it needs to be relative to the player. To move a node by its local positon, we can modify a node's Position property.

Vector3 newPosition = characterNode.SpriteNode.FlipH ?
    Vector3.Left :
    Vector3.Right;
float distanceMultiplier = 0.75f;
newPosition *= distanceMultiplier;

characterNode.HitboxNode.Position = newPosition;

In this example, we're setting the Position property to a variable called newPosition. To calculate the position, we used the constants Vector3.Left and Vector3.Right. These constants store the direction of left or right, respectively. They're available on the Vector3 class by default.

Toggling Collision Shapes

In this lecture, we learned how to disable collision shapes to prevent them from detecting characters. We can do so by toggling the Disabled property. We defined a method that will take care of setting this property when called. It accepts a boolean as an argument.

public void ToggleHitbox(bool flag)
{
    HitboxShapeNode.Disabled = flag;
}

Working on the Enemy Attack State

In this lecture, we worked on the enemy attack state. There are no notes for this lecture.

Moving the Enemy Hitbox

In this lecture, we moved the enemy's hitbox to the player's position so that it'll always hit them when the enemy enters the attack state. In the EnterState method, we grabbed the player by using the Area3D method called GetOverlappingBodies, which returns a list of bodies inside the area node.

Node3D target = characterNode.AttackAreaNode
    .GetOverlappingBodies()
    .First();

targetPosition = target.GlobalPosition;

Afterward, we toggled the hitbox when the PerformHit method was called from the animation player node.

private void PerformHit()
{
    characterNode.ToggleHitbox(false);
}

Finishing the Enemy Attack State

In this lecture, we finished the attack state by determining whether the enemy should attack again or transition away. To perform this check, we waited for the animation to finish from the AnimationFinished signal.

private void HandleAnimationFinished(StringName animName)
{
    characterNode.ToggleHitbox(true);

    Node3D target = characterNode.AttackAreaNode
        .GetOverlappingBodies()
        .FirstOrDefault();

    if (target == null)
    {
        Node3D chaseTarget = characterNode.ChaseAreaNode
            .GetOverlappingBodies()
            .FirstOrDefault();

        if (chaseTarget == null)
        {
            characterNode.StateMachineNode.SwitchState<EnemyReturnState>();
            return;
        }

        characterNode.StateMachineNode.SwitchState<EnemyChaseState>();
        return;
    }

    characterNode.AnimPlayerNode.Play(GameConstants.ANIM_ATTACK);

    targetPosition = target.GlobalPosition;

    Vector3 direction = characterNode.GlobalPosition
        .DirectionTo(targetPosition);
    characterNode.SpriteNode.FlipH = direction.X < 0;
}

There are a few things worth mentioning about this bit of code.

  1. We're checking if the area node has any bodies by using the GetOverlappingBodies method.

  2. If not, we're checking if the chase area has any bodies. If the player is there, we'll transition into the chase state. Otherwise, we'll transition into the return state.

  3. If the player is still in the attack area, we'll perform the attack animation again.

  4. The target position gets updated so that the hitbox will get moved when the attack lands.

  5. Lastly, we're flipping the enemy in the direction of the player when the attack starts.

Character Death States

In this lecture, we prepared the death states for both characters. To notify each character that their death state should be played, we created a field with the Action type. The Action type allows us to store functions/methods in a variable.

public Action OnZero;

This action will be used for when a stat reaches zero. We can run any method that gets stored in the field by calling the Invoke method.

OnZero?.Invoke();

We're using the ? operator to check if there's any methods stored to begin with. Otherwise, C# would throw errors if attempted to call a method that didn't exist. It can be a simple way to avoid errors.

During this process, we had to update our state machine if it attempted to transition into a state it's already in.

if (currentState is T) { return; }

In this example, we're checking if the currentState field is a specific data type. If it is, that means we're already in the state and we don't have to transition into it again.

Last updated