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:
Breakdown of Selection System Logic
- 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 toselected_units
group. - 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
- 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:
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 byCamera3D
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.