Implementing the damage formulas

With the design of our formulas in mind, it is now time to implement them. To do so, we are going to create a library of static functions1 in a class named Formulas. But first, we should define the available elements of our battle system because other calculations depend on it.

Coding the elements

To code the elements, we’re going to create a class that only holds global constants. I’ve named it Types.gd:

# Using `class_name` allows us to access the constants from any other file.
class_name Types
extends Reference

# This is the same enum we wrote in the ActionData classes.
enum Elements { NONE, CODE, DESIGN, ART, BUG }

# Mapping between an element and the element against which it's strong.
const WEAKNESS_MAPPING = {
    # A value of -1 makes the element strong or weak against nothing.
    Elements.NONE: -1,
    # For example, the line below means that code is strong against art.
    Elements.CODE: Elements.ART,
    Elements.ART: Elements.DESIGN,
    Elements.DESIGN: Elements.CODE,
    Elements.BUG: -1,
}

We can access the properties from anywhere. Let’s see how by completing BattlerStats.gd. Open the file and add the following properties:

# An array of elements against which the battler is weak.
# These weaknesses should be values from our `Types.Elements` enum.
export var weaknesses := []
# The battler's elemental affinity. Gives bonuses with related actions.
export (Types.Elements) var affinity: int = Types.Elements.NONE

A limitation of GDScript is that enums are lists of integers, and so you cannot use them as a type hint: the affinity variable will be an integer and you can assign any value to it, event one that’s not in Types.Elements.

However, we can use Types.Elements as an export hint, in parentheses above. This results in a drop-down menu when setting the variable in the Inspector.

Formulas

Let’s implement the formulas now. Create a new file named Formulas.gd.

We are going to use static functions so we can safely access them from any GDScript file. The most important functions below are calculate_base_damage() and calculate_hit_chance(). They correspond to the two formulas we discussed in the previous lesson.

class_name Formulas
extends Reference

# Returns the product of the attacker's attack and the action's multiplier.
static func calculate_potential_damage(action_data, attacker) -> float:
    return attacker.stats.attack * action_data.damage_multiplier

# The base damage is "attacker.attack * action.multiplier - defender.defense".
# The function multiplies it by a weakness multiplier, calculated by
# `_calculate_weakness_multiplier` below. Finally, we ensure the value is an
# integer in the [1, 999] range.
static func calculate_base_damage(action_data, attacker, defender) -> int:
    var damage: float = calculate_potential_damage(action_data, attacker)
    damage -= defender.stats.defense
    damage *= _calculate_weakness_multiplier(action_data, defender)
    return int(clamp(damage, 1.0, 999.0))

# Calculates a multiplier based on the action and the defender's elements.
static func _calculate_weakness_multiplier(action_data, defender) -> float:
    var multiplier := 1.0
    var element: int = action_data.element
    if element != Types.Elements.NONE:
        # If the defender has an affinity with the action's element, the
        # multiplier should be 0.75
        if Types.WEAKNESS_MAPPING[defender.stats.affinity] == element:
            multiplier = 0.75
        # If the action's element is part of the defender's weaknesses, we
        # set the multiplier to 1.5
        elif Types.WEAKNESS_MAPPING[element] in defender.stats.weaknesses:
            multiplier = 1.5
    return multiplier

Here’s the function that calculates a hit’s chance. It returns a value between 0 and 100. If the hit chance is 100, the attack is guaranteed to hit. A value of 60 means the attack has a 60% chance to hit.

# The formula in pseudo-code:
# (attacker.hit_chance - defender.evasion) * action.hit_chance + affinity_bonus + element_triad_bonus - defender_affinity_bonus
static func calculate_hit_chance(action_data, attacker, defender) -> float:
    var chance: float = attacker.stats.hit_chance - defender.stats.evasion
    # The action's hit chance is a value between 0 and 100 for consistency with
    # the battlers' stats. As we use it as a multiplier here, we need to divide
    # it by 100 first.
    chance *= action_data.hit_chance / 100.0

    # Below, we use the new BattlerStats properties, `affinity` and `weaknesses`,
    # to apply a hit chance bonus or penalty.
    var element: int = action_data.element
    # If the action's element matches the attacker's affinity, we increase the
    # hit rating by 5.
    if element == attacker.stats.affinity:
        chance += 5.0
    if element != Types.Elements.NONE:
        # If the action's element is part of the defender's weaknesses, we
        # increase the hit rating by 10.
        if Types.WEAKNESS_MAPPING[element] in defender.stats.weaknesses:
            chance += 10.0
        # However, if the defender has an affinity with the action's element, we
        # decrease the hit rating by 10.
        if Types.WEAKNESS_MAPPING[defender.stats.affinity] == element:
            chance -= 10.0
    # Clamping the result ensures it's always in the [0, 100] range.
    return clamp(chance, 0.0, 100.0)

You may have noticed that the functions’ arguments lack type hints. This is because of cyclic reference errors in Godot 3.2, a problem that Godot 4.0 should hopefully address.

static func calculate_base_damage(action_data, attacker, defender) -> int:

In the function above, action_data is of type ActionData, and both attacker and defender should be of type Battler.

The formulas’ code

Here is the complete code for reference.

Types.gd:

class_name Types
extends Reference

enum Elements { NONE, CODE, DESIGN, ART, BUG }

const WEAKNESS_MAPPING = {
    Elements.NONE: -1,
    Elements.CODE: Elements.ART,
    Elements.ART: Elements.DESIGN,
    Elements.DESIGN: Elements.CODE,
    Elements.BUG: -1,
}

Formulas.gd:

class_name Formulas
extends Reference

static func calculate_potential_damage(action_data, attacker) -> float:
    return attacker.stats.attack * action_data.damage_multiplier

static func calculate_base_damage(action_data, attacker, defender) -> int:
    var damage: float = calculate_potential_damage(action_data, attacker)
    damage -= defender.stats.defense
    damage *= _calculate_weakness_multiplier(action_data, defender)
    return int(clamp(damage, 1.0, 999.0))

static func calculate_hit_chance(action_data, attacker, defender) -> float:
    var chance: float = attacker.stats.hit_chance - defender.stats.evasion
    chance *= action_data.hit_chance / 100.0

    var element: int = action_data.element
    if element == attacker.stats.affinity:
        chance += 5.0
    if element != Types.Elements.NONE:
        if Types.WEAKNESS_MAPPING[element] in defender.stats.weaknesses:
            chance += 10.0
        if Types.WEAKNESS_MAPPING[defender.stats.affinity] == element:
            chance -= 10.0
    return clamp(chance, 0.0, 100.0)

static func _calculate_weakness_multiplier(action_data, defender) -> float:
    var multiplier := 1.0
    var element: int = action_data.element
    if element != Types.Elements.NONE:
        if Types.WEAKNESS_MAPPING[defender.stats.affinity] == element:
            multiplier = 0.75
        elif Types.WEAKNESS_MAPPING[element] in defender.stats.weaknesses:
            multiplier = 1.5
    return multiplier

  1. Static functions are functions that cannot access the class’s properties. They can only take arguments and return a value. This constraint ensures that you cannot mutate properties of the class. We also say that these functions do not have side-effects: calling them doesn’t cause changes in any system. In general, static functions are useful to create libraries of code you can share and reuse safely, unlike singletons.↩︎