Site icon The Walrus Game Project

Project Delve Part 6.1: Hero Action Phase

Alrighty, there’s a lot to cover in this one so let’s jump right into this!

Breaking Up the UnitActions Singleton

Up until this point I’ve been throwing a lot of functionality into the unit_actions.gd script, but the time has come to delegate responsibility. From now on, the UnitActions singleton will only be in charge of

  1. Checking whether an action can be performed
  2. Performing that action

Almost all of the code that was in that class will be split into two additional classes: DrawManager and SelectionManager. I’ll get to those in a minute, but first let’s stub the functions the UnitActions class will implement. The way I’ve done this is by defining a can_do_<ActionName>_action() and do_<ActionName>_action() method for each possible action a hero can perform. The “can_do” functions will make checks (outlined in the blue boxes in the flowchart a few sections down) and return a bool value indicating whether the action is valid. The “do” functions will actually perform the actions and return when the full action has been completed, thus they will need to be coroutine functions that utilize the yield keyword.

As an example, I’ll demonstrate a simple implementation of the Rest action.

func can_do_rest_action(unit) -> bool:
    return true

func do_rest_action(unit):
    unit.rest()
    yield(get_tree(), "idle_frame")

For now, feel free to just stub out the rest of the action methods and we’ll return to them later.


DrawManager

The DrawManager class will be responsible for making common custom draw calls on the screen. For the functionality we’ve made so far, this includes drawing the path a unit will take when it moves, and drawing a target and line of sight when making an attack. Like the UnitActions class, we’ll add this to the list of auto-loaded scripts so that it can be accessed from anywhere.

The purpose of these manager classes is to expose the abstraction and hide the implementation. All that outside classes need to know about the DrawManager is that it can make draw calls in the way they need it to. So let’s start by defining the public-facing functions in the DrawManager.

# draw_manager.gd

func enable_path_drawing():
    pass

func disable_path_drawing():
    pass

func enable_target_drawing():
    pass

func disable_target_drawing():
    pass

Let’s cut and paste the drawing functionality from the unit_actions.gd script into the draw_manager.gd script. These will primarily be the functions _highlight_path() and _highlight_target_point(), which are callbacks for the grid_tile_hovered signal emitted by the Dungeon. That means we’re going to need a reference to the current dungeon in the DrawManager class, so let’s add one more public method to the script.

func set_active_dungeon(dungeon):
    _active_dungeon = dungeon

I’ll walk you through what I did for the enable_target_drawing() method and let you figure out the enable_path_drawing() on your own using the same principles I do here.

The things the drawing function needs in order to properly draw are:

  1. The position of the origin point
  2. The target group (null if targeting an empty space is allowed)
  3. Whether line of sight is required

If the target group is null then we just need to connect to the grid_tile_hovered signal on the _active_dungeon. Otherwise we’ll get all the nodes in the target group and connect to their entered signal, assuming the group is composed of MouseListener derived objects.

func enable_target_drawing(origin_position: Vector2, target_group = null, needs_LoS = true):
    if not target_group:
        _active_dungeon.connect("grid_tile_hovered", self, "_draw_target_to_point", [origin_position, needs_LoS])
    else:
        var group = get_tree().get_nodes_in_group(target_group)
        for node in group:
            node.connect("entered", self, "_draw_target_to_group_member", [origin_position, needs_LoS])

For the disable function we basically just need to do the same thing, but use disconnect() instead of connect().

func disable_target_drawing(target_group = null):
    if _active_dungeon.is_connected("grid_tile_hovered", self, "_draw_target_to_point"):
        _active_dungeon.disconnect("grid_tile_hovered", self, "_draw_target_to_point")

    if target_group:
        var group = get_tree().get_nodes_in_group(target_group)
        for node in group:
            if node.is_connected("entered", self, "_draw_target_to_group_member"):

                node.disconnect("entered", self, "_draw_target_to_group_member")

    _active_dungeon.clear_drawings()

(the functions I’m connecting to are derived from the functions we brought over from the UnitActions class)

Now see if you can implement the other two public functions on your own! (If you get stuck or want to “check your work” you can compare it to my code, which I link to at the end of this post. Not saying that my code is the best per-se, but it works!)


SelectionManager

The SelectionManager class will only expose one function for now, which will be the _wait_until_unit_selected() function from UnitActions. Since there are many things we’ll want to select within the game, though, I’m going to make it more generic. I also want to be able to control which things should be selectable, so I’m going to add an additional parameter target_group like I did in the DrawManager.

# selection_manager.gd

signal group_member_selected

# target_group can be either a string, or an array
# of nodes. If it's a string, the group is the result
# of calling get_nodes_in_group() with the string. If
# the target_group is null, it defaults to "interactable".
# If the target_group is empty, null will be returned.
func wait_until_group_member_selected(target_group, highlight_color = null, fade = false, fade_frequency = 0):
    var group = _get_group(target_group)
    if group.empty():
        return null

    for node in group:
        node.connect("clicked", self, "_select_group_member", [node, target_group])
        node.connect("entered", self, "_toggle_highlightable", [node, true, highlight_color, fade, fade_frequency])
        node.connect("exited", self, "_toggle_highlightable", [node, false])

    var member = yield(self, "group_member_selected")
    return member

func _toggle_highlightable(event, highlightable, highlighted, color = null, fade = false, fade_frequency = 0):
    highlightable.toggle_highlight(highlighted, color, fade, fade_frequency)

func _select_group_member(event, member, target_group):
    # This function just disconnects the signals like we did in the draw manager
    _disconnect_group_signals(target_group)
    emit_signal("group_member_selected", member)

I also moved the toggle_highlight() function from the Unit class to a superclass I called Highlightable, which will allow any node that uses or extends that class to show the same outline effect as the units.


Hero Action Phase

Phew! Now that we’ve got all of that behind us, we can finally start work on the HeroActionPhase! (Sorry if my explanations were a little patchy, there was a lot to cover and if I pasted all of the code I wrote along with explanations, it would make this post way too long.)

Just like how in my previous post I created a flowchart diagram for the flow of the game, I thought it’d be a good idea to do the same thing for the HeroActionPhase. I find that there’s something very helpful about being able to visualize a workflow. Building it helps me organize my thoughts and think about how to handle certain states. Here’s what I came up with for the HeroActionPhase.

Dissecting this a bit, this diagram is a high-level outline of what a hero’s turn will look like. We will start out by checking whether the hero is knocked down. If so, there’s only one action they can perform, which is to stand up. Otherwise we will have a list of actions for the hero to choose from (displayed in the orange boxes).

Almost all of the actions have a prerequisite that must be met to determine whether they can be performed, which I’ve outlined on the left in the A-E boxes. The actual checks will likely turn out to be much more nuanced than I’ve written here in the diagram, but they’ll serve as a good starting point for us to build on as we find what those nuanced things are.

The small text in the top left grouped by the curly braces is just a copy-paste of the rules describing the actions a hero can take on their turn, found on page 7 of the Descent Rulebook.


Setting up the phase flow

Let’s go to the hero_action_phase.gd script now. There will be three main variables we need to keep track of during this phase:

  1. The current hero
  2. The number of remaining action points
  3. Any leftover movement

We can initialize these in the constructor, with the hero being passed in as an argument.

# hero_action_phase.gd

var hero: Unit
var action_points: int
var leftover_movement: int

func _init(sm: StateMachine, unit: Unit).(sm, "HeroActionPhase"):
    hero = unit
    action_points = 2
    leftover_movement = 0

When we enter this state we’ll want to present the user with the list of actions they can take, so that means we’ll need to use those checks we created in the UnitActions singleton. We can worry about how the user will select the actions in a little bit, so let’s start with what we know.

# This enum is for convenience so we can keep track of which
# actions we can/do use
enum Actions {
    move,
    rest,
    skill,
    attack,
    interact,
    revive,
    stand,
    move_extra,
    end_turn
}

func enter_state():
    .enter_state()
    _select_action()

func _select_action():
    # This function will call _get_available_actions() to
    # tell the player which actions they can pick, and
    # wait for them to pick one.
    pass

func _action_selected(action):
    # This function will be called when one of the actions are
    # selected and will call the UnitActions.do... function
    # matching the selected action.
    pass

func _get_available_actions():
    # This function will call all of the UnitActions.can_do...
    # functions and store a list of the actions that are available.
    pass

Get available actions

Let’s work on the _get_available_actions() method first. As I said in the comment, this method will be called by the _select_action() function to get a list of which Actions can be taken. The functionality for these checks has been put in the UnitActions singleton so all we need to worry about is calling them from this script, which keeps it simple and clean!

func _get_available_actions():
    # The end turn action will always be available
    # if for some reason the player wants to end their
    # turn early.
    var available = [Actions.end_turn]

    # First we'll check whether the hero needs to
    # stand up, like outlined in the flowchart.
    if UnitActions.can_do_stand_up_action(hero):
        available = [Actions.stand]

    # If they can't perform the stand up action, then we
    # need to check the remaining action points.
    elif action_points <= 0:
        # Check for any leftover movement
        if leftover_movement > 0 and UnitActions.can_do_move_action(hero):
            # I decided to make the leftover movement
            # its own action so that we can easily
            # differentiate it from the regular move
            # action when we're performing the action.
            available.append(Actions.move_extra)

    # If the hero still has remaining action points then
    # we will check all of the other action availability.
    else:
        if UnitActions.can_do_attack_action(hero):
            available.append(Actions.attack)
        if UnitActions.can_do_move_action(hero):
            available.append(Actions.move)
        if UnitActions.can_do_rest_action(hero):
            available.append(Actions.rest)
        if UnitActions.can_do_interact_action(hero):
            available.append(Actions.interact)
        if UnitActions.can_do_skill_action(hero):
            available.append(Actions.skill)
        if UnitActions.can_do_revive_action(hero):
            available.append(Actions.revive)

    return available

Action selected

Now let’s take a look at the _action_selected() function. This function will call on the UnitActions singleton to perform the action, wait for it to finish, and then call _select_action() again to start the cycle over until the player chooses the Actions.end_turn action.

func _action_selected(action):
    # We'll want to keep track of how much ability points
    # are spent during the hero's action, since some actions
    # (like standing up) will take all actions and if the player
    # cancels an action we don't want to count it as an action taken.
    var ap_used := 1

    match action:
        Actions.end_turn:
            # If it's the end turn action we can just update
            # the state and return early
            _change_state(HeroEndPhase.new(_parent))
            return
        Actions.stand:
            yield(UnitActions.do_stand_up_action(hero), "completed")
            ap_used = 2
        Actions.move:
            # The do_move_action() function will return -1 if the
            # action was cancelled
            var cost = yield(UnitActions.do_move_action(hero, true), "completed")
            if cost == -1:
                ap_used = 0
            else:
                leftover_movement = hero.unit_data.speed - cost
        Actions.move_extra:
            var cost = yield(UnitActions.do_move_action(hero, false, leftover_movement), "completed")
            if cost == -1:
                ap_used = 0
            else:
                leftover_movement -= cost
        Actions.rest:
            # Performing a rest action will always take the
            # remaining action points
            yield(UnitActions.do_rest_action(hero), "completed")
            ap_used = 2
        Actions.skill:
            # Eventually we'll come back to this, where null
            # will be replaced by the chosen skill
            yield(UnitActions.do_skill_action(hero, null), "completed")
        Actions.attack:
            # "monsters" is the target group we want to affect
            yield(UnitActions.do_attack_action(hero, "monsters"), "completed")
        Actions.interact:
            yield(UnitActions.do_interact_action(hero), "completed")
        Actions.revive:
            yield(UnitActions.do_revive_action(hero), "completed")

    action_points -= ap_used
    _select_action()

Select action

Finally let’s figure out how we want the player to select their action. For now I think a simple button-based UI will suffice, but because I always prefer working with things that look good over things that look mangy I wanted to do a mock-up first.

I like the idea of having the hero avatars in the top corner, with the current one being highlighted in some way.

Fortunately, making a simple layout is pretty easy in Godot. After messing around with a few options, here’s what I ended up settling on.

Hero turn GUI

I attached a script to the base node and named it hero_turn_gui.gd. In the script I get a reference to all of the buttons in the scene and create a map from the HeroActionPhase.Actions enum to their corresponding button. I also define a button_pressed signal in the script that we’ll be able to connect to in the HeroActionPhase, and a function to enable/disable buttons based on the result of _get_available_actions(). Here’s what all that looks like in the script.

# hero_turn_gui.gd

extends CanvasLayer

class_name HeroTurnGUI

signal button_pressed

onready var _btn_move = $Backdrop/ButtonGrid/Move
onready var _btn_move_extra = $Backdrop/ButtonGrid/MoveExtra
onready var _btn_attack = $Backdrop/ButtonGrid/Attack
onready var _btn_skill = $Backdrop/ButtonGrid/Skill
onready var _btn_interact = $Backdrop/ButtonGrid/Interact
onready var _btn_rest = $Backdrop/ButtonGrid/Rest
onready var _btn_revive = $Backdrop/ButtonGrid/Revive
onready var _btn_stand = $Backdrop/ButtonGrid/StandUp
onready var _btn_end = $Backdrop/ButtonGrid/EndTurn
onready var _hero_name = $HeroName
var _btn_map: Dictionary

func _ready():
    # The purpose of this line will be
    # explained in the next section
    set_meta("gui_type", "hero")

    _btn_map = {
        HeroActionPhase.Actions.move: _btn_move,
        HeroActionPhase.Actions.move_extra: _btn_move_extra,
        HeroActionPhase.Actions.attack: _btn_attack,
        HeroActionPhase.Actions.skill: _btn_skill,
        HeroActionPhase.Actions.interact: _btn_interact,
        HeroActionPhase.Actions.rest: _btn_rest,
        HeroActionPhase.Actions.revive: _btn_revive,
        HeroActionPhase.Actions.stand: _btn_stand,
        HeroActionPhase.Actions.end_turn: _btn_end,
    }


    # This is just a simple way to forward on the button
    # pressed signal to listeners of this script
    for key in _btn_map.keys():
        _btn_map[key].connect("pressed", self, "emit_signal", ["button_pressed", key])

func enable_buttons(action_list: Array):
    for action in _btn_map.keys():
        var btn = _btn_map[action]
        if action_list.has(action):
            btn.disabled = false
        else:
            btn.disabled = true

GUI Manager

Now in order to access this script we’ll need a reference to it somehow. Let’s create another auto-loaded script and call it gui_manager.gd. This script will only have one function (for now) that will return the HeroTurnGUI if it’s found in the scene.

# gui_manager.gd

extends Node

# Gets the Hero GUI in the current scene. If not
# present, this returns null.
func get_hero_gui():
    var guis = _get_gui_type("hero")
    if guis.empty():
        return null
    return guis[0]


func _get_gui_type(type: String) -> Array:
    var guis = []

    # I put the HeroTurnGUI scene in a group called "gui"
    var nodes = get_tree().get_nodes_in_group("gui")
    for node in nodes:
        # Because I specified the gui_type metadata in
        # the HeroTurnGUI, this will be able to get the
        # exact script we're looking for.
        if node.get_meta("gui_type") == type:
            guis.append(node)
    return guis

Select action (…again)

Home stretch guys! Now that everything else is in place we can fill out the _select_action() function back in hero_action_phase.gd. We just need to get a reference to the HeroTurnGUI in the scene, enable the buttons that should be enabled, and listen for the button_pressed signal.

func _select_action():
    var hero_gui = GUIManager.get_hero_gui()
    hero_gui.enable_buttons(_get_available_actions())
    hero_gui.connect("button_pressed", self, "action_selected", [], CONNECT_ONESHOT)

Bringing It All Together

And finally here we are at the end of this long post! I’ll note that even though I didn’t mention it during this post, I did a lot of trial and error testing while writing this code to make sure it all worked as expected, and I highly recommend you do the same as you (if you) follow along with me. That way you don’t have to go back and track down why a certain aspect isn’t working as expected after you’ve finished everything and instead know exactly what changed since your last test, which helps immensely in debugging.

So here’s a little demo of how the code looks so far! (I added a little bit of animation of the GUI so it felt a little more alive)

The code for this post can be found here.

Exit mobile version