8 minute read

This is a quick response to a question I got about Unto Deepest Depths, my dark fantasy strategy game (go wishlist!). The question was how I handle selecting and moving units on the map. While I am going to talk about Units in a future post, the answer didn’t fit nicely into what I’m planning for that one, so I’m doing it as a one-off. If you’ve got questions about how the game works, let me know and maybe I’ll do another quick post on it!

Hopefully you’ve seen or read my post about the core game loop as that ties into today’s post, but as a quick refresher, here’s my game’s architecture:

Level
    |--- Level generator
    |--- Tilemap
    |--- Cell highlights
    |--- Objects
    |--- Map Effects
    |--- Units
        |--- Player group
            |--- Unit A
            |--- Unit B
        |--- Opponent group
            |--- Unit X
            |--- Unit Y
    |--- UI

As I mentioned in that post, the bulk of the code in the Unit Group nodes is for dealing with UI. AI-controlled units just get placed into an array and iterated over on their turn, but player-controlled units need the player to make decisions about who to select, where to move, and where to attack, so let’s look at that process a little more in-depth.

When it’s time for the player to select a unit, I set a flag that I’m awaiting unit selection, lookup the cells of each unit that hasn’t yet taken their turn, and highlight those cells on a map. This could also be done with a mini state machine, but it didn’t feel overly necessary here.

is_awaiting_unit_selection = true
EventBus.show_selectable_player_units.emit(
    unused_units.map(func (unit): return unit.cell)
)

As this is all event driven, there’s nothing happening at this point and the group is just waiting for a mouse click, which is connected through the standard _unhandled_input event. As both left clicking and right clicking are supported, for selecting and de-selecting units, respectively, I first just listen for a press from any mouse button:

func _unhandled_input(event: InputEvent) -> void:
    # AI-controlled groups should not consider anything related to user input
	if !is_player_controlled:
		return

	if event is InputEventMouseButton and event.pressed:
        # This is where the fun begins

Once a mouse button has been pressed, there’s a few different paths the code can go down:

  • Left click and awaiting unit selection
  • Left click and a unit is already selected, meaning an action should be taken
  • Right click when a unit is selected and the unit hasn’t moved, which simply deselects that unit and takes you back to left click scenario
  • Right click when a unit is selected, and the unit has moved but is able to undo their movement, which undoes their movement and takes you to the above right click scenario, which can then of course take you back to the left click scenario

For the first left click action, I need to convert the pixel coordinate where the mouse was clicked to a world cell and see if I’ve clicked on any units. Getting the pixel coordinate of the click is built into Godot (get_global_mouse_position()), so I grab that value and then call Navigation.world_to_cell, which is a custom function that converts a pixel coordinate to a world cell:

func world_to_cell(pos: Vector2) -> Vector2:
	return snap_to_tile(pos) / TILE_SIZE

func snap_to_tile(pos: Vector2, include_half_offset: bool = false) -> Vector2:
	var snapped_pos = floor(pos / float(TILE_SIZE)) * TILE_SIZE
	
	if include_half_offset:
		snapped_pos += Vector2.ONE * TILE_SIZE * 0.5
	
	return snapped_pos

snap_to_tile takes a pixel coordinate and snaps it to the nearest tile, optionally centering the pixel in the cell, so world_to_cell simply takes that value and divides it by the tile size to get the cell identifier. As an example, tiles are 16x16 in Unto Deepest Depths, so if a click occurred at pixel coordinate (34, 3), snap_to_tile would snap it to (32, 0), world_to_cell would divide those values by 16, and output that the click occurred at cell (2, 0). From there, I can check if any unit is occupying that cell. If one is, I mark it as a currently selected unit, ask it to give me its movement options, and highlight those cells on the map.

At this point, the unit group is again waiting for a click to occur and the process largely repeats: wait for a click, grab its cell, and check if the click is valid. This time, though, I don’t need to select a unit but rather check for a valid action selection. As moving and attacking both use the same interface for carrying data, I just check if the player has clicked a valid cell from whatever the list of actions under consideration are.

for ac in current_actions:
    # End point is the only valid cell to click for selecting an action
    if cell == ac.end_point:
        if current_unit.has_moved:
            current_unit.attack(ac)
        else:
            # ac.full_path is simply the entire path that should be traversed
            # We'll get into actions another time
            current_unit.move_along_path(ac.full_path)

If the click is valid, that action is resolved as appropriate, which is a subject for another day, and the turn continues on. If the unit moved, this process loops over but checks against valid attacks this time. After the unit attacks, its turn is over and the group lets the player select from the remaining units, if there are any, or ends its turn.

And that’s a brief look at how unit selection is managed.