Project Delve Part 7: Skills

Sorry it’s been a minute, I’ve been pretty busy the last few weeks and haven’t spent much time on the project. Also to be fully transparent, I’ve started to lose that initial drive I had for the project. That’s not to say I don’t want to complete it — I do! But I want to be completely honest about my process here so I thought that it’d be a good idea to point that out. This is also the primary reason I started this blog, so that when I start to get into a productivity slump or lose motivation I have a sort of “external incentive” to persevere. So with that out of the way, let’s dive into what we’re going to cover today: Skills!


The Process

So, I’m a pretty big fan of Object Oriented Programming and inheritance. Thus my initial plan to create skills was to create some sort of super class that would act as an interface for individual skills to inherit from.

However, after pondering how to best structure the super class for nearly a week (without actually coding or diagraming anything) I realized the best strategy for architecting the skills classes would be from the bottom-up instead of from the top-down. That is, code a skill or two and determine from the concrete code the parts that could be abstracted, thus helping me to template future skill implementations.

The skill

Oath of Honor skill card

I picked a basic skill from the Knight class called Oath of Honor. It’s one of the skills a knight character will start with so I thought it made as much sense as anything to begin there. After reading the skill description again to refresh my memory I determined that there are two parts to this particular skill:

  1. Determining if the skill can be used
  2. Using the skill

I think that this can probably be extrapolated to most other active skills (as opposed to passive skills, which I’ll cover later), so already we’ve found something that can be abstracted! Let’s create the super class now with those two methods and just plan on making more modifications as we go along.

# skill.gd
extends Node

class_name Skill

func can_use() -> bool:
    pass

func use():
    pass

Now that we have a rough template to work from, let’s create the Oath of Honor skill script.

# sk_oath_of_honor.gd
extends Skill

func can_use() -> bool:
    pass

func use():
    pass

Defining can_use()

Nearly all skills will have a stamina cost associated with them, and if the unit does not have the requisite amount of stamina they cannot use the skill. So let’s define a basic function to check for that.

# skill.gd
func _has_enough_stamina():
    return hero.stamina >= stamina_cost

…Oh, but wait. We don’t have a reference to the hero that’s using the skill. Let’s plop that on the Skill class real quick. We can worry about assigning it later.

var hero: Unit

We also need a reference to how much the skill will cost, so let’s add that too.

var stamina_cost: int

Now in the sk_oath_of_honor.gd script let’s call that function inside the can_use() function.

# sk_oath_of_honor.gd
func can_use() -> bool:
    if not _has_enough_stamina():
        return false
    # Do additional checks here...
    return true

The additional checks

Reading the skill description for Oath of Honor, the prerequisites for using this skill (besides its mana cost) are:

  1. An ally hero is within 3 spaces of the current hero
  2. That ally has a monster adjacent to them
  3. There is an empty space adjacent to that monster

In order to find these things we’re going to need an easy way to query the dungeon grid, which brings up the next problem to figure out. I can foresee there being many uses for querying unit positions and dungeon proximities, so I think we should make a new singleton script that has the sole purpose of taking these kind of queries.


DungeonManager

Because I’ve been fond of making so many managers lately, what’s one more? Let’s create a new script called dungeon_manager.gd and add it to the list of AutoLoaded scripts so that it’s globally available. This script will need a reference to the current dungeon object, so let’s add a variable to hold that and a function to set it.

# dungeon_manager.gd
extends Node

# Not setting the type of this because it would
# create a circular dependency... strike 4,001 against GDScript
var _active_dungeon

func set_active_dungeon(dungeon):
    _active_dungeon = dungeon

We’ll be adding functions to this class as we find need for them. For the Oath of Honor skill we need to know those three prerequisites I outlined earlier, so let’s make functions that can help us find what we need. I want to try and keep the script generalized so that it can be used for more things than just unit position querying, so I’m going to name the functions to reflect that.

# Returns the nodes within groups specified by node_groups array
# that are within tile_radius number of tiles of the world_point
func get_nodes_within_grid_radius(world_point: Vector2, tile_radius: int, node_groups: PoolStringArray):
    pass

# Will get a list of all potentially empty spaces adjacent to the
# tile and then remove any space occupied by a node in obstacle_groups
func get_empty_grid_spaces_adjacent_to(world_point: Vector2, obstacle_groups: PoolStringArray = []):
    pass

# Just a proxy function for the dungeon's method
func get_grid_coordinates_of_node(node: Node):
    return _active_dungeon.get_grid_position(node.position)

# Yes, these functions need to be renamed. It's confusing
# to have to remember which function to use on what object
# especially when GDScript has such poor intellisense support.
func grid_to_world_position(grid_coordinate: Vector2, center_of_tile := false):
    return _active_dungeon.map_to_world_point(grid_coordinate, center_of_tile)

Before filling out the implementation of these functions, let’s return to the Oath of Honor script and use these methods as though they work flawlessly (obvi).


Implementing the Rest of the Skill

We’re going to need a list of valid tiles that the hero can move to when they use this skill. We’re also going to need to calculate those same spaces for determining whether the skill can be used, so it makes sense to only run the calculations for finding those spaces once and store the result. For that let’s make a function to do those calculations.

# sk_oath_of_honor.gd
var _valid_grid_spaces := []

func _get_valid_grid_spaces():
    # Get a list of all heroes in range of the skill
    var allies_in_range = DungeonManager.get_nodes_within_grid_radius(hero.position, 3, ["heroes"])
    for ally in allies_in_range:
        # Get a list of the monsters next to the hero in range
        var monsters_next_to_ally = DungeonManager.get_nodes_within_grid_radius(ally.position, 1, ["monsters"])
        if monsters_next_to_ally.size() > 0:
            # Get a list of empty spaces next to ally
            var ally_spaces = DungeonManager.get_empty_grid_spaces_adjacent_to(ally.position, ["units"])

            # Get a list of empty spaces next to monster
            var monster_spaces := []
            for monster in monsters_next_to_ally:
                monster_spaces.append_array(
                    DungeonManager.get_empty_grid_spaces_adjacent_to(monster.position, ["units"])
                )

            # Take intersection of the two lists
            var intersection := []
            for grid_space in ally_spaces:
                if monster_spaces.has(grid_space):
                    intersection.append(grid_space)

            # Add result of the intersection to _valid_grid_spaces
            _valid_grid_spaces.append_array(intersection)

Now in the can_use() function we just have to run this function and return whether the size of the _valid_grid_spaces array is greater than 0, indicating that there’s a valid space to move to.

func can_use() -> bool:
    if not _has_enough_stamina():
        return false
    return _ensure_valid_grid_spaces()

func _ensure_valid_grid_spaces():
     if _valid_grid_spaces.size() > 0:
          return true

     _get_valid_grid_spaces()
     return _valid_grid_spaces.size() > 0

Defining use()

Now the second part comes for creating the skill: using it! We still don’t have all systems properly in place to fully finish the skill, but we have enough at this point where we can throw together something that works.

func use():
    # Calling this function mainly ensures that the
    # _valid_grid_spaces array has been populated
    if not _ensure_valid_grid_spaces()
        return
	
    # TODO: Select one of the valid spaces
    # Just use first in list for now
    var selected_grid_space = _valid_grid_spaces[0]

    # Subtract stamina
    hero.stamina -= stamina_cost

    # Move unit to selected space
    hero.position = DungeonManager.grid_to_world_position(selected_grid_space, true)

    # Attack monster adjacent to ally
    # TODO: perform attack...
    print("Attack...")

    return

Finishing DungeonManager functionality

Alright, like I promised let’s go back to the DungeonManager and fill out the function stubs we added. The logic for getting nodes within a tile radius is fairly straightforward. First we’ll get the list of nodes in the scene we care about and then check whether the position of the node is within the given radius.

func get_nodes_within_grid_radius(world_point: Vector2, tile_radius: int, node_groups: PoolStringArray):
    var within_radius := []
    for node_group in node_groups:
        var nodes = get_tree().get_nodes_in_group(node_group)
        for node in nodes:
            if (
                not within_radius.has(node)
                and _active_dungeon.tile_distance_to(world_point, node.position) <= tile_radius
            ):
                within_radius.append(node)
    return within_radius

The steps for getting the empty grid spaces adjacent to a grid tile are a little more complex. First we’ll add a new function to the Dungeon class called walkable_tile_exists_at(), which will simply check for whether a tile exists at the given point and no grid obstacle exists at that point.

# dungeon.gd
func walkable_tile_exists_at(world_point: Vector2):
    var grid_pos = get_grid_position(world_point)
    return floors.get_cellv(grid_pos) != TileMap.INVALID_CELL and obstacles.get_cellv(grid_pos) == TileMap.INVALID_CELL

Now we just need to run this check for each adjacent tile space and add the results that return true to an array.

func get_empty_grid_spaces_adjacent_to(world_point: Vector2, obstacle_groups: PoolStringArray = []):
    var empty_spaces := []
    # The _get_neighbors() function simply returns the 8 points that surround
    # the world_point, adding the cell_size so we can get the grid coordinate
    # at the neighbor point
    for neighbor_point in _get_neighbors(world_point, _active_dungeon.floors.cell_size):
        if _active_dungeon.walkable_tile_exists_at(neighbor_point):
            empty_spaces.append(_active_dungeon.get_grid_position(neighbor_point))

The next thing we need to do in the function is remove spaces where an obstacle exists (like units or interactables).

    for group in obstacle_groups:
        var obstacles = get_grid_coordinates_of_group(group)
        for obstacle in obstacles.keys():
            empty_tiles.erase(obstacle)

    return empty_tiles

func get_grid_coordinates_of_group(group: String) -> Dictionary:
    var coordinates := {}
    var nodes = get_tree().get_nodes_in_group(group)

    for node in nodes:
        coordinates[_active_dungeon.get_grid_position(node.position)] = node

    return coordinates

Testing it out

Now the fun part! Will it work? Let’s find out!

Initial Oath of Honor test gif
Note that I had to jerry-rig a few things together before testing it out here, such as adding the skill to the list of available skills for the knight. Also, you can’t tell very well due to my bad unit HUD implementation, but the stamina bar for the knight goes down by 1.

SelectionManager Update

The skill card says that the Knight is supposed to move to the “closest available empty space” next to the monster, but I would prefer if the player got to choose which space to jump to. So that’s what I’ll do! I love making the rules!

Let’s add a new function in the SelectionManager to listen for a grid tile to be clicked.

# selection_manager.gd

# Create a new signal to emit when grid tile is clicked
signal grid_tile_selected

func select_grid_tile(valid_grid_coordinates = null):
    Utils.connect_signal(_active_dungeon, "grid_tile_clicked", self, "_clicked_tile", [valid_grid_coordinates])

# This function needs the first three arguments because that's what
# the grid_tile_clicked signal emits with in the dungeon script, even
# though we don't need all of them here.
func _clicked_tile(event, loc, pathfinder, valid_grid_coordinates):
    var grid_coordinate = _active_dungeon.get_grid_position(loc)
    if valid_grid_coordinates and valid_grid_coordinates.size() > 0 and not valid_grid_coordinates.has(grid_coordinate):
        # If the selected tile was not valid, we simply return and
        # wait for a valid tile to be selected
        return

    Utils.disconnect_signal(_active_dungeon, "grid_tile_clicked", self, "_clicked_tile")
    emit_signal("grid_tile_selected", grid_coordinate)

Then in the Oath of Honor skill we can swap the auto-selecting of the first valid grid space with the user-selected space.

# Select one of the valid spaces
# var selected_grid_space = _valid_grid_spaces[0]
SelectionManager.select_grid_tile(_valid_grid_spaces)
var selected_grid_space = yield(SelectionManager, "grid_tile_selected")

Testing the selection logic

Now let’s run the code again and see if it works as expected!

Oath of Honor with space selection gif
I created a tile highlighting effect for better demonstrating, but in hindsight I think it’d be better to highlight all available tiles when the skill is selected instead of only when you hover over a tile.

Last Step (For Now)

Okay, so I’ve been a little coy with showing you demonstrations of the code running without telling you exactly how I finished the setup. I just wanted to cover this topic last since I didn’t originally think of this architecture pattern when first creating the skill, so in a way this post sort of follows my own journey of discovery as I found better ways to do certain things as I worked on it.

Because skills are tied to classes and not to characters (in the way Descent is designed, anyway) it made the most sense to me to have the skills be their own Resources, just like how units are their own Resources. (Remember, a Resource class in Godot is basically just a data class, akin to Unity’s ScriptableObject)

The problem, though, is that skills are a combination of both data and functionality. I want to be able to separate the two so that we can store a reference to the skill’s data and access its functionality later during run-time. Separating things this way also helps with decoupling the classes and interdependencies on one another, and we won’t have to define a new resource type for each different skill we create. I also like this idea because it allows for swapping functionality during run-time if we want to do that somewhere down the line. I also have this idea in my head of defining all of the data for the game in an XML/JSON file format that can be loaded in dynamically, which would only be capable of storing data, not functionality. But it could store a reference to a script class!

…It’s hard to articulate thoughts sometimes, ya know?

Anyway, I got this idea from the game Rimworld, which I made a few mods for. They split their classes this way as well, allowing you to define your data in one place and implement it in another.

The data that units will store will not be Skills, they’ll be SkillDefs. Each SkillDef will have all the skill information like name, description, stamina cost, experience cost, etc. But it will also contain a path reference to the script file that will implement it, which will be used dynamically during the game.

Here’s how I defined the SkillDef resource class.

extends Resource

class_name SkillDef

export(String) var skill_script_path := "res://scripts/skills/skill.gd"
export(String) var skill_name
export(String) var skill_description
export(bool) var is_action
export(bool) var is_interrupt
export(int) var stamina_cost
export(int) var experience_cost

# Because the node is created on the fly, we also
# need to make sure to free it when we're done with it.
# Otherwise they will start to take up game memory.
func get_skill(hero_ref) -> Node2D:
    var skill_node := Node2D.new()
    skill_node.name = skill_name

    # This is where we dynamically load the skill's functionality
    skill_node.script = load(skill_script_path)
    skill_node.hero = hero_ref
    skill_node.skill_def = self
    return skill_node
Screenshot of Oath of Honor resource

Then I’ll add the skill resource onto the Nite character’s skills array (which you’ll need to define) and suddenly it’s available to be used!


And I think that just about covers it! Again, there’s some new stuff I added that I didn’t write about (things like code refactoring and clean-up) but the main purpose of this post was to start to understand how to build Skills for units to use. Hopefully with a little more work we’ll be able to crank these out like hotcakes!

The code for this lesson and project up to this point can be found here.

Leave a Reply

Up ↑

Discover more from The Walrus Game Project

Subscribe now to keep reading and get access to the full archive.

Continue reading