Site icon The Walrus Game Project

Project Delve Part 3: Unit Stats

Now that we have some pathfinding down, the next order of business is to limit movement even more. (Aren’t rules the best?!) Except in rare instances, we want our characters to be constrained by the number of tiles they’re allowed to move on their turn. Right now units have an infinite movement range (so long as a path to the target exists), so we’re going to change that.

The simplest way to add a movement restriction to a unit would be to create another exported variable and use that. However, this is another opportunity to explore more features of Godot and introduce another equally viable solution (and in some cases even more desirable) to this problem. Before going on, let me back up a bit…


Super Relevant Background

Every meaningful character in any RPG ever made has one or more “stats.” Stats are things like: the amount of damage a thing can take before being defeated, how fast something moves, how strong something is, etc. Every character will probably have slightly different stats from every other character, which introduces an opportunity for strategy. Some characters will be inherently better at doing some things than others, and choosing a team/party of characters that balance out one another’s weaknesses while complimenting their strengths is vital to any game with strategic elements.

If we abstract the concept of strengths and weaknesses we can easily quantify them. Games have been doing this for years, and we’re jumping on the bandwagon!


Adding Stats

Now back to movement restriction. One of the stats we want our units to have (both friendly and not) is a speed stat. This will determine how far a unit will be able to move for a single action, in terms of tiles (diagonal included). But like I mentioned before, there’s a better way to handle this than using a script variable.

In Unity there’s a concept of Scriptable Objects. These are essentially just chunks of custom data that can be used anywhere you like within your scripts. They’re meant to be easily modifiable and reproduceable and thus are excellent solutions for when you want to inject data into a script without needing to code anything (after the initial setup, anyway).

In Godot, this idea is known as Resources, and we’re going to create a resource script to contain the stat data of every character in the game! …Not right now of course, but eventually that’s the goal.

For now we’re going to start with a single piece of custom data: speed.

In the Godot FileSystem tab, right click and select “New Script…” and name it something like unit_stats.gd. Have it inherit from Resource and open it up. We’re going to add only one line to the file and it’s going to look a lot like what we’ve done before, so no new ground here.

extends Resource

# Okay, I lied. You can also optionally add this line.
class_name UnitStats

export(int) var speed

We’ll also need to add an _init() function. This function is necessary for Resource scripts to initialize their data with default values.

func _init(default_speed = 1):
    speed = default_speed

Edit: I just learned that the _init() function isn’t strictly necessary if you can give your exported values initial constants inline. See the “Bonus Content” section at the bottom.

Now let’s go back to the FileSystem tab in the editor and right click again, but this time select “New Resource…” You can scroll through the long list of possible resources, or you can use the search bar at the top to search for your new unit_stats.gd script. Because we marked it as extending Resource, it will show up! Click “Create” and you’ll see the resource file pop up in the FileSystem tab. When you double-click on it, it will be brought up in the inspector and you should see the following.

Our speed variable shows up just like it would on a regular Node script! Feel free to edit the value to whatever you’d like (a number greater than zero, preferably).

Now how do we let our CharacterController know about this new value? Well we can just make an exported variable at the top of the character_controller.gd script for it to be assigned in the editor.

export(Resource) var unit_stats

and we can access it like so

func a_function():
    go_fast(unit_stats.speed)

Now, I can hear you saying, “why did we go through that extra step if we were just going to end up adding an exported variable to the CharacterController anyway?” Well that’s an excellent question. If all we planned on doing was adding a single variable then I would agree with you — this method does seem like overkill. However, this new UnitStats class will become the key to managing all the individual stats of any and every character we plan on adding to the game. If we decided not to use a Resource file for that kind of stuff, we would need to make a new Node for every character we wanted to add, as well as modify the variables on the specific Node the character_controller.gd script was attached to, which would quickly become cumbersome. This way we can decouple the functionality from the data, and be free to use either without a reliance on the other.

When all’s said and done of course, some might argue that it’s still not be the best solution for this problem. To that person I would say, “you make a valid point,” and then continue to do things my way because this is my tutorial and I say we’re using it!


Making Use Of It

Now that we have reference to a speed stat for our unit, we need to be able to know if a move from tile A to tile B is legal, based on the unit’s speed. For this, let’s add a new function in the Pathfinder class called path_cost(). It will take a start position and end position and return an integer representing the total movement cost of moving between the two points.

Unfortunately the AStar class doesn’t provide that functionality out of the box, so we’re going to need to do it ourselves.

We’ll start the function in the same way we started the find_path() method.

func path_cost(start: Vector2, end: Vector2):
    # We need to transform the variables to be in tile-coordinate space
    start = _tilemap.world_to_map(start)
    end = _tilemap.world_to_map(end)

Because AStar references points by ID (which we called “index” in a previous post), we also need to get the ID of the start and end point.

    var start_point_index = _get_point_index(start)
    var end_point_index = _get_point_index(end)

The other two bits of information we need to know about AStar is that it has two methods we haven’t used yet: get_id_path() and get_point_weight_scale(). The first method behaves exactly like the get_point_path() method in our find_path() function, except that it returns the IDs of the path points instead of the coordinates of the path points. With these IDs, all we need to do is sum up the total value of calling get_point_weight_scale() for each ID and return that.

    var id_path = a_star.get_id_path(start_point_index, end_point_index)

    var cost := 0
    for point_id in id_path:
        cost += a_star.get_point_weight_scale(point_id)

    return cost

Now we can check whether the move is valid when moving the unit based on the unit’s speed! We just need to call pathfinder.path_cost(start, end) and compare it with unit_stats.speed. If the cost of the path is greater than the unit’s speed then we won’t move.

This code begs for optimization since we’re essentially calculating the same path twice if the move is within the unit’s speed. However, this works for demonstration purposes and I may go back at some point to tidy it up a bit.

Feel free to add any extra stats you want to keep track of on a unit-to-unit basis in the unit_stats.gd script. I’m also going to be adding health, attack, and defense. (I’m expecting attack and defense to be something a little more complex in a few lessons but for now an int value will be fine)


Bonus Content: Unit Types

I went back and forth on whether to make this section its own post or not, but ultimately I think it fits best along with this post.

In Descent there are several different categories of units. When I refer to a unit type (at least in this section) I’m not referring to any specific monster or hero class. Unit types are going to be defined by us as a group of units that activate together. What do I mean when I say they activate together? Well, we’ll get more into detail further along but if you haven’t played Descent before or read the rules (or even D&D), here’s the gist:

Rounds are turn-based and broken into two phases. Each hero takes their turn individually, in any order. After all of the heroes have had their turn (known as the hero phase), the monsters are activated (the monster phase). However, monsters can only activate on a group-by-group basis (known as a monster group). When a monster group activates, each monster of the same “species” performs their turns in any order, just like how the heroes did. When all monsters in the same group have finished their turns, it moves on to the next monster group (again, in any order). Once all monster groups have activated, the round ends and the process starts again.

In this lesson we’re going to be defining these unit types and structuring the code in a way that makes it easy for us to add new heroes and monsters quickly in the future!


The Good Stuff

First off, I want to rename the unit_stats.gd script. Stats are not the only unique thing about units; each unit will also need other data to be fully complete like sprites and special abilities. These other traits don’t really fit well under the idea of “stats,” which I think of as exclusively numbers. The resource files we create need to be more encompassing than just a unit’s stats. So let’s change the name of the file to unit_data.gd and update the class_name to UnitData.

Now we’re going to add quite a few more attributes to this resource file, so feel free to copy/paste this part.

export(Texture) var sprite = null
export(Vector2) var size := Vector2.ONE

export(int) var health := 0
export(int) var speed := 0
export(int) var defense := 0  # will change type eventually
export(int) var attack := 0  # will change type eventually

export(int, -1, 5) var strength := -1
export(int, -1, 5) var insight := -1
export(int, -1, 5) var perception := -1
export(int, -1, 5) var knowledge := -1
export(int) var stamina := -1

Another thing I want to do is make this file a tool file. What that means is Godot will recognize that this is a script that should be run within the editor itself. The reason I want to do this is twofold: first, I wanted to try it out. And second, I want whenever I set the sprite in the UnitData resource for it to update the sprite in the scene it’s attached to.

To make this script a tool script, all we need to do is add that keyword as the first line of the script.

tool
extends Resource

...

Now we need to add the setget keyword after we declare the sprite export.

export(Texture) var sprite = null setget _set_sprite

Now we need to define the _set_sprite() function elsewhere in the script. I’ll add it just below the wall of exported variables.

func _set_sprite(new_sprite):
    sprite = new_sprite
    var scene = get_local_scene()
    if scene != null and scene.has_node("Sprite"):
        var sprite_node = scene.get_node("Sprite")
        sprite_node.texture = new_sprite

In order for the get_local_scene() function to work properly, you’ll need to check the “Local to scene” property in the editor on the resource. If you’ve got it all set up right, you should see something like the following when you change the sprite on the UnitData resource.

Lastly we’re going to create one more script and call it monster_group_data.gd, and have it extend Resource as well. Inside this script we’re going to export two variables.

export(Resource) var elite_data
export(Resource) var minion_data

In each monster group there will normally be one elite monster who is stronger, and multiple minion monsters that make up the bulk of the group and are weaker. For each monster group we create, we want to have the data to create both the elites and the minions together in the same place.

The code for this post can be found here.

Exit mobile version