Project Delve Part 4.1: Unit Selection

In this post I’m going to walk through a simple solution I made for being able to select units, as well as more application of the UnitActions script we started last time.

Node2D does not have a signal emitted when it is clicked. This is the primary problem that we want to solve for our units, since our UnitActions singleton will want to know when a unit has been selected. This would be easy enough if we only ever wanted units to emit a “clicked” signal, since we could just check the _input() method inside our Unit class. But since we’re going to have a lot of interactable things in our game, I thought it best to find a solution that could be reused for multiple different objects.

The thing I think will work best for this particular problem is making a parent class that any script we choose can extend. In other words, we’re going to use inheritance.


Making The Listener

Go ahead and create a new script and have it inherit from Node2D. You can name it something that describes its intention, which in our case is going to be detecting mouse input within a certain area (I’m naming mine mouse_listener.gd). In order to set the size of the relevant area, we’re going to need an export statement with the width and height. We could achieve this by using two separate variables, width and height, but a Vector2 will work just as well for us, so let’s use that.

export(Vector2) var bounds := Vector2.ZERO

This next part is optional, but I think it could come in handy at some point in the future so I’m going to include it. Say we want to only detect a mouse click when the right or middle mouse button is clicked. How can we control that? The solution most (if not all) game engines use is what’s known as a bit mask, and Godot provides a simple way to create one from the editor via an exported variable.

We can declare a bitmask variable by adding the FLAGS hint to the export keyword. (“Hints” are what Godot calls declaring a type on the export, as a broad definition.)

export(int, FLAGS, "left", "middle", "right") var button_mask := 1

When you save the script and look in the editor, you’ll see the following.

Detecting the input

Now that we have the bounds and button_mask set, let’s use them to determine if a click event happened on our listener object. For this we can override the _unhandled_input() function (we’re using _unhandled_input() instead of _input() because we don’t want this to override any input that may have already been handled).

func _unhandled_input(event):
    # Using a match statement here because we'll be listening for
    # more than just the mouse button event eventually
    match event.get_class():
        "InputEventMouseButton":
            pass

Now the things we want to know are whether the click event just happened (we don’t want to trigger this event for every frame that the button is held down) which buttons were pressed, and where it occurred. For this we can use the event.is_action_pressed() function, the event.button_mask variable, and the event.position variable.

To check if the button pressed is one of the buttons allowed by our button_mask variable, we just need to check whether the AND bitwise operation between the two is greater than 0.

var is_valid_button = event.button_mask & button_mask > 0

To check if the button was just barely pressed, we’ll need to create a new action in the Input Map of our project. To do this go to Project > Project settings… and click on the Input Map tab. From there, add a new action and assign three buttons to it: Left mouse button, Middle mouse button, and Right mouse button. When finished it should look similar to this.

And in the _unhandled_input() function we can add this line.

var just_pressed = event.is_action_pressed("mouse_click")

Finally we need to check whether the event happened within the bounds that we defined. To keep things simple I decided that we would center the bounds on the Node itself and not deal with offsets (other than the offset to center the bounds). So to make those calculations we can do the following.

func _get_offset_bounds():
    # I renamed bounds to size here since it makes
    # more sense in this context
    var size = bounds
    return Rect2(position - size / 2, size)

func _within_bounds(position: Vector2):
    return _get_offset_bounds().has_point(position)

After doing all this we’ll know whether we should emit an event on a mouse click. Our script should look something like this when we put it all together.

extends Node2D

class_name MouseListener

signal clicked

export(Vector2) var bounds := Vector2.ZERO
export(int, FLAGS, "left", "middle", "right") var button_mask := 1

func _unhandled_input(event):
    match event.get_class():
        "InputEventMouseButton":
           var just_pressed = event.is_action_pressed("mouse_click")
           var is_valid_button = event.button_mask & button_mask > 0
           if just_pressed and is_valid_button and _within_bounds(event.position):
               emit_signal("clicked", event)

func _get_offset_bounds():
    var size = bounds
    return Rect2(position - size / 2, size)

func _within_bounds(position: Vector2):
    return _get_offset_bounds().has_point(position)

Visualizing the bounds

This is all well and good, but how can we tell where the bounds extend to? Fortunately Godot makes it easy to make our scripts runnable in the editor via the tool keyword (which we did in an earlier post).

Once we add tool to the top of the script, let’s also create a setter function for our bounds variable so that whenever it changes, our setter function will run. In our setter function we’ll call update() which tells Godot that this node needs to be redrawn.

export(Vector2) var bounds := Vector2.ZERO setget _set_bounds

func _set_bounds(newVal):
    bounds = newVal
    update()

Then we can override the _draw() function to easily draw to the screen.

func _draw():
    # This tells Godot to only run this part in the editor view
    if Engine.editor_hint:
        var draw_bounds = _get_offset_bounds()
        var color = Color("#e74322")
        draw_rect(Rect2(-draw_bounds.size / 2, draw_bounds.size), color, false, 1.5)

Now when you adjust the bounds of the script, you’ll see the area update in real time!


Using The Listener

Now that we’ve got the basic functionality down for our MouseListener, let’s change our unit.gd script to extend MouseListener instead of Node2D like it was before. Also, if we want to visualize the bounds of the unit we’ll need to add the tool keyword to this script too.

Now back in the UnitActions singleton we worked on last time, we’re going to create that function I said not to worry about at the time: _wait_until_unit_selected().

The first thing we want to do in the function is get a list of all the units in the scene and connect their clicked signal to a function. We can get all the units in the current scene the same way we got the list of our debug buttons by adding the units to their own group. Once the function has been connected to all of the units we’ll yield for a custom signal we define called unit_selected. When the unit has been selected we’ll disconnect all the units from listening for the clicked signal, and emit the unit_selected signal with the selected unit. That signal will resume our code at the yield statement and we’ll finally return the unit. (Whew!)

Here’s what that will all look like.

signal unit_selected

func _wait_until_unit_selected():
    print("Waiting for unit selection...")

    var units = _get_all_units()
    for unit in units:
        unit.connect("clicked", self, "_select_unit", [unit])

    var unit = yield(self, "unit_selected")
    return unit

func _select_unit(event, unit):
    var units = _get_all_units()
    for unit in units:
        unit.disconnect("clicked", self, "_select_unit")

    print("Unit selected: " + unit.name)
    emit_signal("unit_selected", unit)

func _get_all_units():
    # All units in the scene should belong to the "units" group
    return get_tree().get_nodes_in_group("units")

In the next post I’ll be guiding us through adding basic functionality to the “Attack” action. Like last time I don’t have a link to the code for this partial section, but I will provide it in the next post.

Leave a Reply

Up ↑