Making a Selection Box in Godot

rts game single unit selection godot

In basic RTS unit tutorial, we created a unit that is un-selectable, and it just moves to wherever mouse is clicked. In reality, we want only those units to move that are selected. Earlier, we made RaycastSystem, which provided very useful functions for selection. In SelectionSystem, we ill use RaycastSystem and some additional logic to create a descent unit & building selection.

  • in my implementation, i made separate functions for units and buildings. to make a scalable system, it is better to modify it in such a way so any kind of groups can be added in array instead of hardcoding groups.
  • use selection system to move the units around.

Creating a Selection System

Start by creating a folder named “SelectionBox”. In this folder create a scene named “SelectionBox.tscn”. The root node must be Node2D (as selection box is drawn in 2D, on top of our 3D game world). Under the root, node, create a NinePatchRect node. Also attach a script to root node. Overall scene setup looks like this:

godot selection box

Breakdown of Selection System Logic

  1. When user presses left mouse button, fire a ray-cast to the mouse position. If it hit an object, then check if that object is a unit or not (by checking if it exists in units group or not). If it is unit, add it to selected_units group.
  2. In case user drags the mouse after left mouse button clicked, then keep track of start position and the current position of the mouse (until left mouse button is released). The rectangle formed between the two positions is the selection rectangle. All the units that lie in that area should be put in selected_units group to select them.
    • To get their screen-space position, we have to unproject their position from world space to screen space; and then we check if that unprojected position lies in that selection rectangle. Godot provides a function for world-space to screen-space: camera.unproject_position.

Now implement what we discussed above.

Define variables & camera

"""
	- All units should be in the "units" group to be selected
	- The selected units will be added to the "selected_units" group
"""



extends Node2D



var camera: Camera3D # our 3D game camera
@onready var nine_patch_rect = $NinePatchRect # to visualize selection rectangle

var is_selecting = false # is currently selecting?
var selection_start = Vector2() # start of selection rectangle
var selection_rect = Rect2() # end of selection rectangle



func _ready():
	camera = get_viewport().get_camera_3d()

Detecting Selection Click or Drag

Selection start and end are calculated based on user inputs. If user clicks, we start the process of selection. This means we store the mouse position when selection started, make is_selecting to true, and if user has dragged the mouse, we make nine_patch_rect visible.

In _process, we update selection rectangle size every frame. Also, we set the size & position of nine_patch_rect to synchronize it with the selection rectangle we are calculating based on mouse motion.


func _input(event):
	if event is InputEventMouseButton:
		if event.button_index == MOUSE_BUTTON_LEFT:
			if event.pressed:
				# Start selection
				is_selecting = true
				selection_start = get_global_mouse_position()
				nine_patch_rect.position = selection_start
				nine_patch_rect.size = Vector2()
			else:
				# End selection
				if is_selecting:
					is_selecting = false
					nine_patch_rect.visible = false
					_select_units()
		# De-select all units if RMB is pressed
		elif event.button_index == MOUSE_BUTTON_RIGHT:
			_clear_previous_selection()

	elif event is InputEventMouseMotion:
		if is_selecting:
			# Show selection box only when mouse is dragged and rect is larger than (32,32)
			if selection_rect.size.length() > 32:
				nine_patch_rect.visible = true
			else:
				nine_patch_rect.visible = false




func _process(delta):
	if is_selecting:
		# Continuously update the selection rectangle to match the mouse position
		var current_mouse_position = get_global_mouse_position()
		selection_rect = Rect2(selection_start, current_mouse_position - selection_start).abs()
		nine_patch_rect.position = selection_rect.position
		nine_patch_rect.size = selection_rect.size

In above code, we didn’t define _select_units and _clear_previous_selection. One is to perform actual selection when user releases the mouse button & other is to de-select if user presses right mouse button (unlike selection which occurs with left mouse button).

These functions are simple, in _select_units, if some units are already selected, then do nothing. But if there is no one selected already, then do two things:

  • Hit a raycast in the current mouse position (using raycast system we created earlier). And if it hit any object, then return that object to see if the object is a unit or not. If it si a unit, select it (place it in selected_units group).
  • Also perform selection of those units that lie within the area (of selection rectangle). This is done by calculating the screen-space positions of all the world-space units. This calculation is done using camera.unproject_position function provided by Camera3D class in Godot engine.

The _clear_previous_selection function just removes all the elements from the selected_units group.

func _select_units():
	# if selected items are already there, do nothing
	# Let the user de-select the units by pressing RMB
	if get_tree().get_nodes_in_group("selected_units").size() > 0:
		return
	
	# Also select unit if they are just clicked, not in selection box
	# passing collision_mask of Units to check for only units (and not return terrain, etc)
	var clicked_object = RaycastSystem.get_raycast_hit_object(0b00000000_00000000_00000000_00000010)
	if clicked_object and clicked_object in get_tree().get_nodes_in_group("units"):
		clicked_object.add_to_group("selected_units")
		print("Clicked object:", clicked_object.name)
	
	# select all the units within the selection rectangle, by using unproject_position
	for unit in get_tree().get_nodes_in_group("units"):
		var unit_screen_position = camera.unproject_position(unit.global_transform.origin)
		if selection_rect.has_point(unit_screen_position):
			unit.add_to_group("selected_units") # Add to seloected units group



# Clear the previous selection
func _clear_previous_selection():
	for selected_unit in get_tree().get_nodes_in_group("selected_units"):
		selected_unit.remove_from_group("selected_units")
	

In above code, I passed the collision_mask of units only, which I assigned to be 0b00000000_00000000_00000000_00000010, so raycast will check only in the collision_layer where our units are present. Otherwise, if we kept using the default collision layer for the units, it will hit terrain and other objects, and will be unable to detect units. So make sure to put units in different collision layer, terrain in default collision layer and buildings in another collision layer, to separate all kinds of objects in our game.

What happens when a unit is placed in selected_units group?

The purpose of this module is to put units in selected_units if selected, or to remove them from from selected_units if de-selected. It is upto the unit script now to check using if-statements that if I am in selected_units, only then do certain actions, else not.

The unit script will check if it lies inside selected_units, if it does, it will react accordingly. For example, a unit will move only if it is selected (i.e., lies inside selected_units). Also, in the below example, a unit shows health-bar only if it is selected. Health-bar is made in future posts in this series, but see how its .visible property is made true only if the unit is selected.

rts game selection box
Unit script now checks if it is in group selected_units, only then move itself, like this: if mouse_pressed and self in selected_units_group: then proceed with movement logic. it also shows health-bar if it is in selected_units.

Leave a Reply

Your email address will not be published. Required fields are marked *