Site icon The Walrus Game Project

Project Delve Part 6.2: Overlord and Monster Phases

Hiya again, I’m back with the next steps for making the Turn System! (Well, I suppose the actual turn system itself is already completed with the functioning state machine, but I want to fully flesh out the selecting and performing of each of the unit’s turns)

Today the focus is going to be on the Overlord and monster phases. The code for the MonsterActionPhase will look almost identical to the code for the HeroActionPhase, since monsters are people too.

…Okay, maybe not. But the code will still remain very similar. So similar in fact that I wonder whether I should combine the HeroActionPhase and the MonsterActionPhase into one base class. That would probably be the wise thing to do, but since when have I been wise? I’m going to not worry about that particular good-practice right now and just continue on, since I’m eager to keep up my momentum.


MonsterActionPhase

I’ll start by copying all of the contents of the hero_action_phase.gd script and pasting it into the monster_action_phase.gd script. Then I’ll rename a few variables and change the class_name value back to MonsterActionPhase so that the top of the script now looks like this.

extends State

class_name MonsterActionPhase

var monster: Unit
var action_points: int
var leftover_movement: int

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

Now, the monsters don’t get as many action choices as the heroes do. Specifically, they aren’t able to do the Stand Up action or the Revive action, since when a monster is defeated they’re removed from the game. They also aren’t able to do the Rest action since they don’t have stamina points.

The other major difference between a hero’s turn and a monster’s turn is that monsters are only allowed to spend one action point on an attack during their turn, whereas heroes are allowed to use both action points on attacking, if they so choose.

So with all of that in mind, let’s modify the _get_available_actions() function to remove the unavailable action types, as well as make an additional check when determining whether to allow the Attack action.

var has_attacked: bool

func _init(sm: StateMachine, unit: Unit).(sm, "MonsterActionPhase"):
    # ...
    has_attacked = false

# ...

func _get_available_actions():
    var available = [UnitActions.Actions.end_turn]
    if action_points <= 0:
        if leftover_movement > 0 and UnitActions.can_do_move_action(monster):
            available.append(UnitActions.Actions.move_extra)
    else:
        if not has_attacked and UnitActions.can_do_attack_action(monster, null):
            available.append(UnitActions.Actions.attack)
        if UnitActions.can_do_move_action(monster):
            available.append(UnitActions.Actions.move)
        if UnitActions.can_do_interact_action(monster):
            available.append(UnitActions.Actions.interact)
        if UnitActions.can_do_skill_action(monster):
            available.append(UnitActions.Actions.skill)

As a side note, I moved the Actions enum to the UnitActions singleton since both the heroes and monsters will be using it.

Then all we need to do is modify the _action_selected() function to remove the actions that cannot be selected! We can also set the has_attacked variable in the UnitActions.Actions.attack block.

func _action_selected(action):
    # ...
    match action:
        # ...
        UnitActions.Actions.Attack:
            has_attacked = yield(UnitActions.do_attack_action(monster, "heroes"), "completed")

And that pretty much covers the modifications we need to make to the MonsterActionPhase!


GUI

In the last post we made a GUIManager with a HeroTurnGUI scene. Because the monster group phase will behave very similarly to the hero group phase I think we can repurpose that GUI to serve both the heroes and monsters! The only differences will be which actions are available, which we already covered in the last section.

Firstly, since this will now be used by both the heroes and the monsters I think it makes sense to rename it to something less hero-centric. I’ve opted to rename it to UnitTurnGUI, and I’ll accordingly change the function name in GUIManager to be get_unit_turn_gui() instead of get_hero_turn_gui(), as well as remove the “gui_type” metadata and instead just assign the scene to a group “unit_gui”.

The other change I thought to make for the UnitTurnGUI is to change the column count of the button row to match the number of visible buttons. That way we won’t get overflowing buttons like we saw in the demo last week.

The function now looks like this:

func enable_buttons(action_list: Array, hide_disabled_buttons = false):
    var count = 0
    for action in _btn_map.keys():
        var btn = _btn_map[action]
        if action_list.has(action):
            btn.disabled = false
            btn.visible = true
        else:
            btn.disabled = true
            btn.visible = (
                false
                if (
                    hide_disabled_buttons
                    or action == UnitActions.Actions.stand
                    or action == UnitActions.Actions.move_extra
                )
                else true
            )
        if btn.visible:
            count += 1
    _button_grid.columns = count

Unit activation order

Another key thing we’ll need to be able to do is select which units we want to activate when. Currently we’re just activating the units in the order they’re in within the scene tree, but part of the strategy of the game will be determining the order of operations for your units’ activations.

I think that selecting the unit’s avatar image might work, so let’s try that out.

Let’s create a new script and name it something like avatar_selection_gui.gd. This GUI will hold the list of CharacterAvatar scenes previously held by the UnitTurnGUI, so we can remove the reference to that from UnitTurnGUI and add it to the AvatarSelectionGUI. There are two ways to go about doing this, one would be to sub-class the UnitTurnGUI to the AvatarSelectionGUI. The other would be to create a new scene with the AvatarSelectionGUI as the scene root node. I like the second approach more since it keeps the UI responsibilities separate, so that’s what I’ll do.

We’re going to add three new functions to the AvatarSelectionGUI, one to enable selection, one to disable selection, and one to set which avatars should be shown in grayscale (indicating they’ve already been activated).

Let’s start with the enable_avatar_selection() and disable_avatar_selection() functions.

func enable_avatar_selection(disabled_options = []):
    for i in range(_avatar_list.avatars.size()):
        if disabled_options.has(_unit_list[i]):
            # If the avatar is disabled we don't want
            # to connect the following callbacks.
            continue

        var avatar = _avatar_list.avatars[i]

        # The "clicked" signal is a custom signal on character_avatar.gd
        # The mouse_entered and mouse_exited are signals provided by the Control node
        avatar.connect("clicked", self, "_on_clicked_avatar", [avatar])
        avatar.connect("mouse_entered", self, "_on_hover_over_avatar", [avatar, true])
        avatar.connect("mouse_exited", self, "_on_hover_over_avatar", [avatar, false])

func disable_avatar_selection():
    for avatar in _avatar_list.avatars:
        avatar.disconnect("clicked", self, "_on_clicked_avatar")
        avatar.disconnect("mouse_entered", self,  "_on_hover_over_avatar")
        avatar.disconnect("mouse_exited", self, "_on_hover_over_avatar")

The _on_clicked_avatar() function will emit a custom defined signal, avatar_clicked, along with the unit the avatar represents. How do we know which unit the avatar represents? I’m glad you asked! In the set_avatar_list() function that we brought over from the UnitTurnGUI we’re given a list of units. From those units we instantiate all the avatar scenes we need. All we need to do is add an extra line of code in that function to link the avatar with the unit it represents.

avatar.set_meta("linked_unit", unit)

Then in the _on_clicked_avatar() function, we just need to put the following.

emit_signal("avatar_clicked", avatar.get_meta("linked_unit"))

The other function about hover enter and exit will set the border outline to white or black, depending on whether it’s entering or exiting.

func _on_hover_over_avatar(avatar, entering):
    if entering:
        avatar.border_color = Color.white
    else:
        avatar.border_color = Color.black

As for the grayscaling, I just added another option to the CharacterAvatar shader indicating whether it should be grayscaled or not, and the grayscale_avatars() function will set this option on the desired avatars.

And that pretty much covers it!

Testing it out

The last thing to do is replace the current _select_next_hero()/_select_next_monster() functions with using this functionality instead of using the next unit in the list. This is as simple as connecting to the avatar_clicked function on the AvatarSelectionGUI and starting the selected unit’s turn.

# For simplicity I'm using the term "unit" rather than "hero"
#  or "monster" but the function will behave the same for both.
func _select_next_unit():
    var gui = GUIManager.get_avatar_selection_gui()
    gui.set_avatar_list(units)
    gui.set_header_text("Select unit...")
    gui.grayscale_avatars(_have_finished_turn)
    gui.connect("avatar_clicked", self, "_start_unit_turn",  [], CONNECT_ONESHOT)
    gui.enable_avatar_selection(_have_finished_turn)

func _start_unit_turn(unit):
    GUIManager.get_avatar_selection_gui().disable_avatar_selection()
    # Change state to unit turn start phase ...
Want to play “see how many bugs you can spot?”

We’re now able to select which unit we want to activate, and you can see the columns get resized when there’s an extra action available. The attack action is also only available once per turn for monsters. Sweet! We’ve verified our code works!


Monster Groups

Now, if all we had to do were select between any monster on the screen then our work would be done! (Minus one or twelve bug fixes) However, the Overlord’s monster phase works by selecting a monster group, and then activating all monsters within that group before moving on to the next group. That means that we need yet another layer of selection logic.

Fortunately, because we separated the AvatarSelectionGUI from the UnitTurnGUI we only need to worry about the first in the OverlordMonsterPhase. However, we’ll need to tweak the AvatarSelectionGUI slightly in order to accommodate non-unit nodes (since the OverlordMonsterPhase will be concerned with nodes that have units as their children, rather than the units themselves).

Fortunately this won’t be too much of a hassle. We’ll just add a second parameter option to the set_avatar_list() function, is_unit_group. This will affect the function in the following way.

func set_avatar_list(units, is_unit_group = false):
    _unit_list = units

    var avatars = []
    for unit in units:
        if not unit or (is_unit_group and unit.get_children().size() == 0):
            continue

        var avatar = _avatar_scene.instance()
        var meta_key
        if is_unit_group:
            # We're assuming that all children of the unit group node
            # are of type "Unit"
            avatar.character_sprite = unit.get_children()[0].unit_data.sprite
            meta_key = "linked_units"
        else:
            avatar.character_sprite = unit.unit_data.sprite
            meta_key = "linked_unit"

        avatar.set_meta(meta_key, unit)
        avatars.append(avatar)
    _avatar_list.set_avatar_list(avatars, true)

Then when in the callbacks like the _on_clicked_avatar() or _on_hover_over_avatar() we just need to check for which meta key is defined before using the appropriate one.

if avatar.has_meta("linked_units"): ...

All Done!

And that’s it! Obviously I didn’t provide every new line of code I wrote, but I hope that I explained my process well enough for people to follow along and fill in the spaces themselves. Here’s a quick demo of checking that the unit selection process is working seamlessly with the state machine.

The code for all the posts up to this point can be found here.

Exit mobile version