Skip to content

Commit 2362a46

Browse files
Add selection box experiment
1 parent 8658cf2 commit 2362a46

21 files changed

+923
-0
lines changed

08.selection-box/devlog.md

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
In the [Selectable Object experiment](https://pigdev.itch.io/experiments/devlog/166154/selectable-objects) we've saw how to use Area2D to create an SelectableArea to trigger selection on a single object at a time.
2+
3+
But...in strategy games, especially in the RTS genre, it's more likely that we would want players to select multiple units at once. A common solution for this kind of system is a _SelectionBox_.
4+
5+
![](https://img.itch.zone/aW1nLzQwMDI2NzIuZ2lm/original/9%2Bx2I2.gif)
6+
7+
_Download the SelectionBox experiment!_
8+
9+
<iframe frameborder="0" src="https://itch.io/embed/679971" width="552" height="167"><a href="https://pigdev.itch.io/experiments">Gamedev Experiments by Pigdev</a></iframe>
10+
11+
This is the most common approach in many user interfaces, you've probably used such thing with your OS graphic interface.
12+
13+
The SelectionBox is a rectangle that grows towards the mouse selecting everything inside it once we release the left mouse button.
14+
15+
![](https://img.itch.zone/aW1nLzQwMDI2NzMuZ2lm/original/QKXJpf.gif)
16+
17+
In this experiment's approach I'm going to take advantage of the previous experiment's SelectableArea, but once you get the idea you can implement it with any CanvasItem.
18+
19+
## Rect resizing
20+
21+
For this system I'm going to use a [`ColorRect`](https://docs.godotengine.org/en/stable/classes/class_colorrect.html) just to have a decent looking SelectionBox, but you can use any Control node, preferably one that serves pure graphical purposes like a TextureRect.
22+
23+
Then I'll rename it _SelectionBox_ and change the _Color_ property to a transparent blue.
24+
25+
![](https://img.itch.zone/aW1nLzQwMDI2ODUuZ2lm/original/R6RpzM.gif)
26+
27+
Almost everything from here is done code wise, so let's attach a script to it!
28+
29+
The first that we need to do is to detect when the player presses the left mouse button, here I've created an InputAction on the _Project > Project Settings > Input Map_ and called it "selection", it refers to left mouse button input events.
30+
31+
When the player presses the `selection` InputAction we want to reset the Rect's size. We'll need to know where the player clicked, so let's keep the `click_position` stored in a member variable.
32+
33+
```
34+
extends ColorRect
35+
36+
var click_position = Vector2.ZERO
37+
38+
func _unhandled_input(event):
39+
if event.is_action("select"):
40+
if event.is_pressed():
41+
reset_rect()
42+
click_position = get_global_mouse_position()
43+
44+
45+
func reset_rect():
46+
rect_size = Vector2.ZERO
47+
```
48+
49+
With the box in place we want to be allowed to scale it while holding the left mouse button and dragging the mouse. For that, we are going to expand the SelectionBox by setting its end point to the mouse position with the `set_end` method.
50+
51+
Since we want to do this smootly whenever the mouse changes its position we are going to `expand_to_mouse`in the `_process` callback.
52+
53+
To prevent this from happening if we are not holding the left mouse button, we can toggle the `_process` callback based on if the `select` action is pressed and start without processing by calling `set_process(false)` at the `_ready` callback.
54+
55+
```
56+
func _ready():
57+
set_process(false)
58+
59+
60+
func _unhandled_input(event):
61+
if event.is_action("select"):
62+
if event.is_pressed():
63+
reset_rect()
64+
set_process(event.is_pressed())
65+
66+
67+
func _process(delta):
68+
expand_to_mouse()
69+
70+
71+
func expand_to_mouse():
72+
var mouse_position = get_global_mouse_position()
73+
set_begin(click_position)
74+
set_end(mouse_position)
75+
```
76+
77+
![](https://img.itch.zone/aW1nLzQwMDI2ODcuZ2lm/original/XY3SfT.gif)
78+
79+
## Fixing begin and end points
80+
81+
Now we have a bit of a problem, our SelectionBox only resizes to the right and bottom, i.e. in the third quadrant.
82+
83+
![](https://img.itch.zone/aW1nLzQwMDI2ODguZ2lm/original/yP%2FbEw.gif)
84+
85+
To fix that we need to fix the begin and end points. The idea is that:
86+
87+
- The begin vertical and horizontal axes should be the minimum value between the current mouse position and the click position
88+
- The end vertical and horizontal axes should be the maximum value between the current mouse position and the click position
89+
90+
Well...for that we can use the `min` and `max` builtin functions to build two new points based on that logic. Our `expand_to_mouse` method should become something like this:
91+
92+
```
93+
func expand_to_mouse():
94+
var mouse_position = get_global_mouse_position()
95+
96+
var min_point = Vector2.ZERO
97+
min_point.x = min(mouse_position.x, click_position.x)
98+
min_point.y = min(mouse_position.y, click_position.y)
99+
set_begin(min_point)
100+
101+
var max_point = Vector2.ZERO
102+
max_point.x = max(mouse_position.x, click_position.x)
103+
max_point.y = max(mouse_position.y, click_position.y)
104+
set_end(max_point)
105+
```
106+
107+
And that should fix that problem!
108+
109+
![](https://img.itch.zone/aW1nLzQwMDI2ODkuZ2lm/original/vvB8xH.gif)
110+
111+
## Selecting selectable stuff
112+
113+
Now, here is where things start to get a bit complicated because is when game systems start to interact with each other and the approaches can become quite _ad hoc-y_.
114+
115+
When we release the left mouse button we want to deselect everything that was selected before and select everything that is selectable.
116+
117+
Why did I say that things get a bit complicated? Simply because I don't know how your selection system works, so you have to guess it yourself. But if you are using my SelectableArea approach you can assume a few things:
118+
119+
- We can toggle a `selected` bool on them
120+
- We can iterate through a `selected` node group to know what is currently selected
121+
122+
But how do we know what is _selectable_ or not? Well...we can use more groups. By adding SelectableAreas to a `selectable` group we can scope down the objects we need to process to know what can be selected when we release the left mouse button.
123+
124+
![](https://img.itch.zone/aW1nLzQwMDI2NTkucG5n/original/RxoPT9.png)
125+
126+
Knowing the selectable objects available we just need to know if their `global_position` is inside the SelectionBox by using the `has_point` method on the SelectionBox global rectangle.
127+
128+
```
129+
func select():
130+
for selection_area in get_tree().get_nodes_in_group("selectable"):
131+
if get_global_rect().has_point(selection_area.global_position):
132+
selection_area.exclusive = false
133+
selection_area.selected = true
134+
```
135+
136+
Remember that if a `SelectableArea.exclusive = true` it will deselect other SelectableAreas when its `selected` becomes `true`. So to prevent that we set `exclusive` to `false` before selecting it.
137+
138+
Now, to deselect is about the same process, but we have a scope even narrower. Since SelectableAreas add themselves to a `selected` group we just need to iterate on it and set their `selected` variable to `false`
139+
140+
```
141+
func deselect():
142+
for selection_area in get_tree().get_nodes_in_group("selected"):
143+
selection_area.exclusive = true
144+
selection_area.selected = false
145+
```
146+
147+
Finally, we just need to call the `deselect()` method before calling the `select()` when we release the left mouse button and reset it again after we've selected what we want.
148+
149+
```
150+
func _unhandled_input(event):
151+
if event.is_action("select"):
152+
if event.is_pressed():
153+
reset_rect()
154+
click_position = get_global_mouse_position()
155+
else:
156+
deselect()
157+
select()
158+
reset_rect()
159+
set_process(event.is_pressed())
160+
```
161+
162+
Since we are resetting the rect when we release the `select` InputAction, we don't need to reset it again when we click, so we can remove it from the `if event.is_pressed()` branch. The final code looks like this:
163+
164+
```
165+
extends ColorRect
166+
167+
var click_position = Vector2.ZERO
168+
169+
func _ready():
170+
rect_size = Vector2.ZERO
171+
set_process(false)
172+
173+
174+
func _process(delta):
175+
expand_to_mouse()
176+
177+
178+
func _unhandled_input(event):
179+
if event.is_action("select"):
180+
if event.is_pressed():
181+
click_position = get_global_mouse_position()
182+
else:
183+
deselect()
184+
select()
185+
reset_rect()
186+
set_process(event.is_pressed())
187+
188+
189+
func expand_to_mouse():
190+
var mouse_position = get_global_mouse_position()
191+
192+
var min_point = Vector2.ZERO
193+
min_point.x = min(mouse_position.x, click_position.x)
194+
min_point.y = min(mouse_position.y, click_position.y)
195+
set_begin(min_point)
196+
197+
var max_point = Vector2.ZERO
198+
max_point.x = max(mouse_position.x, click_position.x)
199+
max_point.y = max(mouse_position.y, click_position.y)
200+
set_end(max_point)
201+
202+
203+
func select():
204+
for selection_area in get_tree().get_nodes_in_group("selectable"):
205+
if get_global_rect().has_point(selection_area.global_position):
206+
selection_area.exclusive = false
207+
selection_area.selected = true
208+
209+
210+
func deselect():
211+
for selection_area in get_tree().get_nodes_in_group("selected"):
212+
selection_area.exclusive = true
213+
selection_area.selected = false
214+
215+
216+
func reset_rect():
217+
rect_size = Vector2.ZERO
218+
219+
```
220+
221+
222+
---
223+
224+
If you have any questions or if something wasn't clear, please leave a comment below. Don't forget to [join the community](https://pigdev.itch.io/experiments/community) to discuss this experiment and more topics as well.
225+
226+
You can also make [requests](https://itch.io/board/791663/requests) for more experiments you'd like to see.
227+
228+
That's it for this experiment, thank you a lot for reading. _**Keep developing and until the next time!**_
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
extends KinematicBody2D
2+
3+
const FLOOR_NORMAL = Vector2.UP
4+
const SNAP_DIRECTION = Vector2.DOWN
5+
const SNAP_LENGTH = 32
6+
const SLOPE_LIMIT = deg2rad(45)
7+
8+
export(float) var speed = 500.0
9+
export(float) var gravity = 2000.0
10+
export(float) var jump_strength = 800.0
11+
12+
var direction = Vector2.ZERO
13+
var velocity = Vector2.ZERO
14+
var snap_vector = SNAP_DIRECTION * SNAP_LENGTH
15+
16+
onready var sprite = $Sprite
17+
onready var label = $Label
18+
19+
20+
func _ready() -> void:
21+
set_process_unhandled_input(false)
22+
23+
24+
func _physics_process(delta):
25+
move(delta)
26+
27+
28+
func _unhandled_input(event):
29+
handle_input(event)
30+
31+
32+
func handle_input(event):
33+
if event.is_action("left") or event.is_action("right"):
34+
update_direction()
35+
if event.is_action_pressed("jump"):
36+
if Input.is_action_pressed("down"):
37+
fall_through()
38+
else:
39+
jump()
40+
elif event.is_action_released("jump"):
41+
cancel_jump()
42+
cancel_fall_through()
43+
44+
45+
func move(delta):
46+
velocity.y += gravity * delta
47+
velocity.y = move_and_slide_with_snap(velocity, snap_vector, FLOOR_NORMAL).y
48+
if is_on_floor():
49+
snap_vector = SNAP_DIRECTION * SNAP_LENGTH
50+
51+
52+
func jump():
53+
if is_on_floor():
54+
snap_vector = Vector2.ZERO
55+
velocity.y = -jump_strength
56+
57+
58+
func cancel_jump():
59+
if not is_on_floor() and velocity.y < 0.0:
60+
velocity.y = 0.0
61+
62+
63+
func fall_through():
64+
if is_on_floor():
65+
set_collision_mask_bit(1, false)
66+
67+
68+
func cancel_fall_through():
69+
if get_collision_mask_bit(1) == false:
70+
set_collision_mask_bit(1, true)
71+
72+
73+
func update_direction():
74+
direction.x = Input.get_action_strength("right") - Input.get_action_strength("left")
75+
velocity.x = direction.x * speed
76+
if not velocity.x == 0:
77+
sprite.flip_h = velocity.x < 0
78+
79+
80+
func _on_SelectableArea2D_selection_toggled(selected):
81+
set_process_unhandled_input(selected)
82+
label.visible = selected
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
[gd_scene load_steps=7 format=2]
2+
3+
[ext_resource path="res://Actors/side-scroll-player.svg" type="Texture" id=1]
4+
[ext_resource path="res://Actors/Player.gd" type="Script" id=2]
5+
[ext_resource path="res://Actors/SelectableArea.tscn" type="PackedScene" id=3]
6+
[ext_resource path="res://Interface/label_font.tres" type="DynamicFont" id=4]
7+
8+
[sub_resource type="RectangleShape2D" id=1]
9+
extents = Vector2( 32, 32 )
10+
11+
[sub_resource type="RectangleShape2D" id=2]
12+
extents = Vector2( 32, 32 )
13+
14+
[node name="Player" type="KinematicBody2D"]
15+
collision_mask = 3
16+
script = ExtResource( 2 )
17+
18+
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
19+
position = Vector2( 0, -32 )
20+
shape = SubResource( 1 )
21+
22+
[node name="SelectableArea2D" parent="." instance=ExtResource( 3 )]
23+
24+
[node name="CollisionShape2D" type="CollisionShape2D" parent="SelectableArea2D"]
25+
position = Vector2( 0, -32 )
26+
shape = SubResource( 2 )
27+
28+
[node name="Sprite" type="Sprite" parent="."]
29+
position = Vector2( 0, -32 )
30+
texture = ExtResource( 1 )
31+
32+
[node name="Label" type="Label" parent="."]
33+
visible = false
34+
margin_left = -48.0
35+
margin_top = -96.0
36+
margin_right = 48.0
37+
margin_bottom = -70.0
38+
custom_fonts/font = ExtResource( 4 )
39+
text = "selected"
40+
align = 1
41+
__meta__ = {
42+
"_edit_use_anchors_": false
43+
}
44+
[connection signal="selection_toggled" from="SelectableArea2D" to="." method="_on_SelectableArea2D_selection_toggled"]
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
extends Area2D
2+
3+
signal selection_toggled(selected)
4+
5+
export var exclusive = true
6+
7+
var selected = false setget set_selected
8+
9+
10+
func _input_event(viewport, event, shape_idx):
11+
if event.is_action_released("select"):
12+
set_selected(not selected)
13+
14+
15+
func set_selected(select):
16+
if select:
17+
_make_exclusive()
18+
add_to_group("selected")
19+
else:
20+
if is_in_group("selected"):
21+
remove_from_group("selected")
22+
selected = select
23+
emit_signal("selection_toggled", selected)
24+
25+
26+
func _make_exclusive():
27+
if not exclusive:
28+
return
29+
for selection_area in get_tree().get_nodes_in_group("selected"):
30+
selection_area.selected = false
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[gd_scene load_steps=2 format=2]
2+
3+
[ext_resource path="res://Actors/SelectableArea.gd" type="Script" id=1]
4+
5+
[node name="SelectableArea2D" type="Area2D" groups=[
6+
"selectable",
7+
]]
8+
script = ExtResource( 1 )

0 commit comments

Comments
 (0)