In this lesson, we’ll work on the last piece of the puzzle: coordinating interactions between the cursor, the units, and the board.
Here are the interactions we want to allow:
Selecting a unit should also make the UnitOverlay and UnitPath nodes display information about where the unit can and will move.
We’ll coordinate these actions with the GameBoard.
In the Main scene, if you haven’t already, you should first instantiate UnitPath as a child of GameBoard.
Everything else will happen in GameBoard.gd
. Open the script and let’s start adding features.
We’ll start with two properties to track the active unit and cache a reference to our UnitPath.
# The board is going to move one unit at a time. When we select a unit, we will save it as our # `_active_unit` and populate the walkable cells below. This allows us to clear the unit, the # overlay, and the interactive path drawing later on when the player decides to deselect it. var _active_unit: Unit # This is an array of all the cells the `_active_unit` can move to. We will populate the array when # selecting a unit and use it in the `_move_active_unit()` function below. var _walkable_cells := [] onready var _unit_path: UnitPath = $UnitPath
Then, let’s add functions for the main commands we want to handle: select, move, and deselect. We have one function corresponding to each command, with an extra one to clear the properties we just defined, _clear_active_unit()
.
# Selects the unit in the `cell` if there's one there. # Sets it as the `_active_unit` and draws its walkable cells and interactive move path. # The board reacts to the signals emitted by the cursor. And it does so by calling functions that # select and move a unit. func _select_unit(cell: Vector2) -> void: # Here's some optional defensive code: we return early from the function if the unit's not # registered in the `cell`. if not _units.has(cell): return # When selecting a unit, we turn on the overlay and path drawing. We could use signals on the # unit itself to do so, but that would split the logic between several files without a big # maintenance benefit and we'd need to pass extra data to the unit. # I decided to group everything in the GameBoard class because it keeps all the selection logic # in one place. I find it easy to keep track of what the class does this way. _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) # Deselects the active unit, clearing the cells overlay and interactive path drawing. # We need it for the `_move_active_unit()` function below, and we'll use it again in a moment. func _deselect_active_unit() -> void: _active_unit.is_selected = false _unit_overlay.clear() _unit_path.stop() # Clears the reference to the _active_unit and the corresponding walkable cells. # We need it for the `_move_active_unit()` function below. func _clear_active_unit() -> void: _active_unit = null _walkable_cells.clear() # Updates the _units dictionary with the target position for the unit and asks the _active_unit to # walk to it. func _move_active_unit(new_cell: Vector2) -> void: if is_occupied(new_cell) or not new_cell in _walkable_cells: return # When moving a unit, we need to update our `_units` dictionary. We instantly save it in the # target cell even if the unit itself will take time to walk there. # While it's walking, the player won't be able to issue new commands. _units.erase(_active_unit.cell) _units[new_cell] = _active_unit # We also deselect it, clearing up the overlay and path. _deselect_active_unit() # We then ask the unit to walk along the path stored in the UnitPath instance and wait until it # finished. _active_unit.walk_along(_unit_path.current_path) yield(_active_unit, "walk_finished") # Finally, we clear the `_active_unit`, which also clears the `_walkable_cells` array. _clear_active_unit()
And with the functions defined, we can implement the interactions.
For two of them, we need to connect our Cursor’s signals to the GameBoard. To do so, you can head back to the editor, select the Cursor, and in the Node dock, connect both moved
and accept_pressed
to the GameBoard.
Back to GameBoard.gd
, we can use the signal callbacks to select, move units, and update the UnitPath’s drawing.
# Updates the interactive path's drawing if there's an active and selected unit. func _on_Cursor_moved(new_cell: Vector2) -> void: # When the cursor moves, and we already have an active unit selected, we want to update the # interactive path drawing. if _active_unit and _active_unit.is_selected: _unit_path.draw(_active_unit.cell, new_cell) # Selects or moves a unit based on where the cursor is. func _on_Cursor_accept_pressed(cell: Vector2) -> void: # The cursor's "accept_pressed" means that the player wants to interact with a cell. Depending # on the board's current state, this interaction means either that we want to select a unit all # that we want to give it a move order. if not _active_unit: _select_unit(cell) elif _active_unit.is_selected: _move_active_unit(cell)
All we’re missing now is Esc to cancel the selection.
To do so, we can use the _unhandled_input()
callback.
func _unhandled_input(event: InputEvent) -> void: if _active_unit and event.is_action_pressed("ui_cancel"): _deselect_active_unit() _clear_active_unit()
And with that, you should be able to select and move units. Also, you can place multiple units on the board. Just ensure they aren’t on the same cell to avoid errors.
Here’s the complete GameBoard script.
class_name GameBoard extends Node2D const DIRECTIONS = [Vector2.LEFT, Vector2.RIGHT, Vector2.UP, Vector2.DOWN] export var grid: Resource = preload("res://Grid.tres") 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 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 _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 _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 _on_Cursor_moved(new_cell: Vector2) -> void: if _active_unit and _active_unit.is_selected: _unit_path.draw(_active_unit.cell, new_cell) func _on_Cursor_accept_pressed(cell: Vector2) -> void: if not _active_unit: _select_unit(cell) elif _active_unit.is_selected: _move_active_unit(cell) func _unhandled_input(event: InputEvent) -> void: if _active_unit and event.is_action_pressed("ui_cancel"): _deselect_active_unit() _clear_active_unit()