Solution: moving to units

Here’s a solution to our challenge.

There are two cases where the code needs to change: when moving the cursor and confirming movement.

We have two signal callbacks from the Cursor in GameBoard.gd: _on_Cursor_accept_pressed() and _on_Cursor_moved(). That’s where I started looking.

In those callbacks, we pass a cell to another function that will either draw a path or move the unit to the highlighted cell. Instead of directly passing the cursor’s position, we can modify it and find another, more appropriate cell in the context.

To do so, I wrote a new function, find_closest_walkable_cell(). It relies on our GameBoard._active_unit variable at the moment.

It looks at the current cell and, if there’s a unit there, it loops over the surrounding cells and finds the one closest to the _active_unit. Note that as the game evolves, we may want to remove the use of _active_unit and use another parameter instead to make the function more flexible. But for now, it keeps the code simple.

## If the cursor's over a unit, returns the closest cell in front of the enemy.
## Otherwise, returns the current cell.
func find_closest_walkable_cell(cell: Vector2) -> Vector2:
    # We only need to process and modify the output cell if the cursor is over a unit.
    # If that's not the case, we keep the current cell.
    if not _units.has(cell):
        return cell

    var closest_cell := cell
    # To find the closest cell, we need to store the smallest distance we've had so far to the
    # cells we looked at. We start with "infinite".
    var min_distance := INF

    for direction in DIRECTIONS:
        var target: Vector2 = cell + direction
        # For each neighboring cell we look at, we ensure it's not also occupied by a unit.
        if _units.has(target) and not _units[target] == _active_unit:
            continue

        # We then compare the squared distance to every cell and always take the smallest one.
        var distance := target.distance_squared_to(_active_unit.cell)

        if distance < min_distance:
            min_distance = distance
            closest_cell = target

    return closest_cell

With the function above, if other units surround the target unit, we can’t move to it and keep our previous behavior.

Now, we update both _on_Cursor_moved() and _on_Cursor_accept_pressed() to process the cursor’s cell with our new function.

func _on_Cursor_accept_pressed(cell: Vector2) -> void:
    #...
    elif _active_unit.is_selected:
        # We process the cell by calling `find_closest_walkable_cell()`
        _move_active_unit(find_closest_walkable_cell(cell))


## Updates the interactive path's drawing if there's an active and selected unit.
func _on_Cursor_moved(new_cell: Vector2) -> void:
    if _active_unit and _active_unit.is_selected:
        # We process the new cell by calling `find_closest_walkable_cell()`
        var target_cell = find_closest_walkable_cell(new_cell)
        _unit_path.draw(_active_unit.cell, target_cell)

And with that, when hovering a unit, the cursor offers to move to a cell in front of it.

If the cell in question is occupied, we fall back to another empty cell.

Code reference

Here is the complete GameBoard.gd script with the changes from this solution.

class_name GameBoard
extends Node2D

const DIRECTIONS = [Vector2.LEFT, Vector2.RIGHT, Vector2.UP, Vector2.DOWN]

export var grid: Resource

var _units := {}
var _active_unit: Unit
var _walkable_cells := []

onready var _unit_overlay: UnitOverlay = $UnitOverlay
onready var _unit_path: UnitPath = $UnitPath


func _ready() -> void:
    _reinitialize()


func _unhandled_input(event: InputEvent) -> void:
    if _active_unit and event.is_action_pressed("ui_cancel"):
        _deselect_active_unit()
        _clear_active_unit()


func _get_configuration_warning() -> String:
    var warning := ""
    if not grid:
        warning = "You need a Grid resource for this node to work."
    return warning


func is_occupied(cell: Vector2) -> bool:
    return _units.has(cell)


func get_walkable_cells(unit: Unit) -> Array:
    return _flood_fill(unit.cell, unit.move_range)


func _reinitialize() -> void:
    _units.clear()

    for child in get_children():
        var unit := child as Unit
        if not unit:
            continue
        _units[unit.cell] = unit


func _flood_fill(cell: Vector2, max_distance: int) -> Array:
    var array := []
    var stack := [cell]
    while not stack.empty():
        var current = stack.pop_back()
        if not grid.is_within_bounds(current):
            continue
        if current in array:
            continue

        var difference: Vector2 = (current - cell).abs()
        var distance := int(difference.x + difference.y)
        if distance > max_distance:
            continue

        array.append(current)
        for direction in DIRECTIONS:
            var coordinates: Vector2 = current + direction
            if is_occupied(coordinates):
                continue
            if coordinates in array:
                continue

            stack.append(coordinates)
    return array


func _move_active_unit(new_cell: Vector2) -> void:
    if is_occupied(new_cell) or not new_cell in _walkable_cells:
        return
    _units.erase(_active_unit.cell)
    _units[new_cell] = _active_unit
    _deselect_active_unit()
    _active_unit.walk_along(_unit_path.current_path)
    yield(_active_unit, "walk_finished")
    _clear_active_unit()


func _select_unit(cell: Vector2) -> void:
    if not _units.has(cell):
        return

    _active_unit = _units[cell]
    _active_unit.is_selected = true
    _walkable_cells = get_walkable_cells(_active_unit)
    _unit_overlay.draw(_walkable_cells)
    _unit_path.initialize(_walkable_cells)


func _deselect_active_unit() -> void:
    _active_unit.is_selected = false
    _unit_overlay.clear()
    _unit_path.stop()


func _clear_active_unit() -> void:
    _active_unit = null
    _walkable_cells.clear()


func _on_Cursor_accept_pressed(cell: Vector2) -> void:
    if not _active_unit:
        _select_unit(cell)
    elif _active_unit.is_selected:
        _move_active_unit(find_closest_walkable_cell(cell))


func _on_Cursor_moved(new_cell: Vector2) -> void:
    if _active_unit and _active_unit.is_selected:
        var target_cell = find_closest_walkable_cell(new_cell)
        _unit_path.draw(_active_unit.cell, target_cell)


func find_closest_walkable_cell(cell: Vector2) -> Vector2:
    if not _units.has(cell):
        return cell

    var closest_cell := cell
    var min_distance := INF

    for direction in DIRECTIONS:
        var target: Vector2 = cell + direction
        if _units.has(target) and not _units[target] == _active_unit:
            continue

        var distance := target.distance_squared_to(_active_unit.cell)
        if distance < min_distance:
            min_distance = distance
            closest_cell = target

    return closest_cell