Tanoda
SelectionBox.cs
Go to the documentation of this file.
1
5
6/*
7 * What the SelectionBox component does is allow the game player to select objects using an RTS style click and drag interface:
8 *
9 * We want to be able to select Game Objects of any type,
10 * We want to be able to drag select a group of game objects to select them,
11 * We want to be able to hold the shift key and drag select a group of game objects to add them to the current selection,
12 * We want to be able to single click a game object to select it,
13 * We want to be able to hold the shift key and single click a game object to add it to the current selection,
14 * We want to be able to hold the shift key and single click an already selected game object to remove it from the current selection.
15 *
16 * Most importantly, we want this behaviour to work with UI, 2D or 3D gameObjects, so it has to be smart about considering their respective screen spaces.
17 *
18 * Add this component to a Gameobject with a Canvas with RenderMode.ScreenSpaceOverlay
19 * And implement the IBoxSelectable interface on any MonoBehaviour to make it selectable.
20 *
21 * Improvements that could be made:
22 *
23 * Control clicking a game object to select all objects of that type or tag.
24 * Compatibility with Canvas Scaling
25 * Filtering single click selections of objects occupying the same space. (So that, for example, you're only click selecting the game object found closest to the camera)
26 *
27 */
28
29using System.Collections.Generic;
31
33{
34 [RequireComponent(typeof(Canvas))]
35 [AddComponentMenu("UI/Extensions/Selection Box")]
36 public class SelectionBox : MonoBehaviour
37 {
38
39 // The color of the selection box.
40 public Color color;
41
42 // An optional parameter, but you can add a sprite to the selection box to give it a border or a stylized look.
43 // It's suggested you use a monochrome sprite so that the selection
44 // Box color is still relevant.
45 public Sprite art;
46
47 // Will store the location of wherever we first click before dragging.
48 private Vector2 origin;
49
50 // A rectTransform set by the User that can limit which part of the screen is eligible for drag selection
51 public RectTransform selectionMask;
52
53 //Stores the rectTransform connected to the generated gameObject being used for the selection box visuals
54 private RectTransform boxRect;
55
56 // Stores all of the selectable game objects
57 private IBoxSelectable[] selectables;
58
59 // A secondary storage of objects that the user can manually set.
60 private MonoBehaviour[] selectableGroup;
61
62 //Stores the selectable that was touched when the mouse button was pressed down
63 private IBoxSelectable clickedBeforeDrag;
64
65 //Stores the selectable that was touched when the mouse button was released
66 private IBoxSelectable clickedAfterDrag;
67
68 //Custom UnityEvent so we can add Listeners to this instance when Selections are changed.
69 public class SelectionEvent : UnityEvent<IBoxSelectable[]> {}
71
72 //Ensures that the canvas that this component is attached to is set to the correct render mode. If not, it will not render the selection box properly.
73 void ValidateCanvas(){
74 var canvas = gameObject.GetComponent<Canvas>();
75
76 if (canvas.renderMode != RenderMode.ScreenSpaceOverlay) {
77 throw new System.Exception("SelectionBox component must be placed on a canvas in Screen Space Overlay mode.");
78 }
79
80 var canvasScaler = gameObject.GetComponent<CanvasScaler>();
81
82 if (canvasScaler && canvasScaler.enabled && (!Mathf.Approximately(canvasScaler.scaleFactor, 1f) || canvasScaler.uiScaleMode != CanvasScaler.ScaleMode.ConstantPixelSize)) {
83 Destroy(canvasScaler);
84 Debug.LogWarning("SelectionBox component is on a gameObject with a Canvas Scaler component. As of now, Canvas Scalers without the default settings throw off the coordinates of the selection box. Canvas Scaler has been removed.");
85 }
86 }
87
88 /*
89 * The user can manually set a group of objects with monoBehaviours to be the pool of objects considered to be selectable. The benefits of this are two fold:
90 *
91 * 1) The default behaviour is to check every game object in the scene, which is much slower.
92 * 2) The user can filter which objects should be selectable, for example units versus menu selections
93 *
94 */
95 void SetSelectableGroup(IEnumerable<MonoBehaviour> behaviourCollection) {
96
97 // If null, the selectionbox reverts to it's default behaviour
98 if (behaviourCollection == null) {
99 selectableGroup = null;
100
101 return;
102 }
103
104 //Runs a double check to ensure each of the objects in the collection can be selectable, and doesn't include them if not.
105 var behaviourList = new List<MonoBehaviour>();
106
107 foreach(var behaviour in behaviourCollection) {
108 if (behaviour as IBoxSelectable != null) {
109 behaviourList.Add (behaviour);
110 }
111 }
112
113 selectableGroup = behaviourList.ToArray();
114 }
115
116 void CreateBoxRect(){
117 var selectionBoxGO = new GameObject();
118
119 selectionBoxGO.name = "Selection Box";
120 selectionBoxGO.transform.parent = transform;
121 selectionBoxGO.AddComponent<Image>();
122
123 boxRect = selectionBoxGO.transform as RectTransform;
124
125 }
126
127 //Set all of the relevant rectTransform properties to zero,
128 //finally deactivates the boxRect gameobject since it doesn't
129 //need to be enabled when not in a selection action.
130 void ResetBoxRect(){
131
132 //Update the art and color on the off chance they've changed
133 Image image = boxRect.GetComponent<Image>();
134 image.color = color;
135 image.sprite = art;
136
137 origin = Vector2.zero;
138
139 boxRect.anchoredPosition = Vector2.zero;
140 boxRect.sizeDelta = Vector2.zero;
141 boxRect.anchorMax = Vector2.zero;
142 boxRect.anchorMin = Vector2.zero;
143 boxRect.pivot = Vector2.zero;
144 boxRect.gameObject.SetActive(false);
145 }
146
147
148 void BeginSelection(){
149 // Click somewhere in the Game View.
150 if (!Input.GetMouseButtonDown(0))
151 return;
152
153 //The boxRect will be inactive up until the point we start selecting
154 boxRect.gameObject.SetActive(true);
155
156 // Get the initial click position of the mouse.
157 origin = new Vector2(Input.mousePosition.x, Input.mousePosition.y);
158
159 //If the initial click point is not inside the selection mask, we abort the selection
160 if (!PointIsValidAgainstSelectionMask(origin)) {
161 ResetBoxRect();
162 return;
163 }
164
165 // The anchor is set to the same place.
166 boxRect.anchoredPosition = origin;
167
168 MonoBehaviour[] behavioursToGetSelectionsFrom;
169
170 // If we do not have a group of selectables already set, we'll just loop through every object that's a monobehaviour, and look for selectable interfaces in them
171 if (selectableGroup == null) {
172 behavioursToGetSelectionsFrom = GameObject.FindObjectsOfType<MonoBehaviour>();
173 } else {
174 behavioursToGetSelectionsFrom = selectableGroup;
175 }
176
177 //Temporary list to store the found selectables before converting to the main selectables array
178 List<IBoxSelectable> selectableList = new List<IBoxSelectable>();
179
180 foreach (MonoBehaviour behaviour in behavioursToGetSelectionsFrom) {
181
182 //If the behaviour implements the selectable interface, we add it to the selectable list
183 IBoxSelectable selectable = behaviour as IBoxSelectable;
184 if (selectable != null) {
185 selectableList.Add (selectable);
186
187 //We're using left shift to act as the "Add To Selection" command. So if left shift isn't pressed, we want everything to begin deselected
188 if (!Input.GetKey (KeyCode.LeftShift)) {
189 selectable.selected = false;
190 }
191 }
192
193 }
194 selectables = selectableList.ToArray();
195
196 //For single-click actions, we need to get the selectable that was clicked when selection began (if any)
197 clickedBeforeDrag = GetSelectableAtMousePosition();
198
199 }
200
201 bool PointIsValidAgainstSelectionMask(Vector2 screenPoint){
202 //If there is no selection mask, any point is valid
203 if (!selectionMask) {
204 return true;
205 }
206
207 Camera screenPointCamera = GetScreenPointCamera(selectionMask);
208
209 return RectTransformUtility.RectangleContainsScreenPoint(selectionMask, screenPoint, screenPointCamera);
210 }
211
212 IBoxSelectable GetSelectableAtMousePosition() {
213 //Firstly, we cannot click on something that is not inside the selection mask (if we have one)
214 if (!PointIsValidAgainstSelectionMask(Input.mousePosition)) {
215 return null;
216 }
217
218 //This gets a bit tricky, because we have to make considerations depending on the hierarchy of the selectable's gameObject
219 foreach (var selectable in selectables) {
220
221 //First we check to see if the selectable has a rectTransform
222 var rectTransform = (selectable.transform as RectTransform);
223
224 if (rectTransform) {
225 //Because if it does, the camera we use to calculate it's screen point will vary
226 var screenCamera = GetScreenPointCamera(rectTransform);
227
228 //Once we've found the rendering camera, we check if the selectables rectTransform contains the click. That way we
229 //Can click anywhere on a rectTransform to select it.
230 if (RectTransformUtility.RectangleContainsScreenPoint(rectTransform, Input.mousePosition, screenCamera)) {
231
232 //And if it does, we select it and send it back
233 return selectable;
234 }
235 } else {
236 //If it doesn't have a rectTransform, we need to get the radius so we can use it as an area around the center to detect a click.
237 //This works because a 2D or 3D renderer will both return a radius
238 var radius = selectable.transform.GetComponent<UnityEngine.Renderer>().bounds.extents.magnitude;
239
240 var selectableScreenPoint = GetScreenPointOfSelectable(selectable);
241
242 //Check that the click fits within the screen-radius of the selectable
243 if (Vector2.Distance(selectableScreenPoint, Input.mousePosition) <= radius) {
244
245 //And if it does, we select it and send it back
246 return selectable;
247 }
248
249 }
250 }
251
252 return null;
253 }
254
255
256 void DragSelection(){
257 //Return if we're not dragging or if the selection has been aborted (BoxRect disabled)
258 if (!Input.GetMouseButton(0) || !boxRect.gameObject.activeSelf)
259 return;
260
261 // Store the current mouse position in screen space.
262 Vector2 currentMousePosition = new Vector2(Input.mousePosition.x, Input.mousePosition.y);
263
264 // How far have we moved the mouse?
265 Vector2 difference = currentMousePosition - origin;
266
267 // Copy the initial click position to a new variable. Using the original variable will cause
268 // the anchor to move around to wherever the current mouse position is,
269 // which isn't desirable.
270 Vector2 startPoint = origin;
271
272 // The following code accounts for dragging in various directions.
273 if (difference.x < 0)
274 {
275 startPoint.x = currentMousePosition.x;
276 difference.x = -difference.x;
277 }
278 if (difference.y < 0)
279 {
280 startPoint.y = currentMousePosition.y;
281 difference.y = -difference.y;
282 }
283
284 // Set the anchor, width and height every frame.
285 boxRect.anchoredPosition = startPoint;
286 boxRect.sizeDelta = difference;
287
288 //Then we check our list of Selectables to see if they're being preselected or not.
289 foreach(var selectable in selectables) {
290
291 Vector3 screenPoint = GetScreenPointOfSelectable(selectable);
292
293 //If the box Rect contains the selectables screen point and that point is inside a valid selection mask, it's being preselected, otherwise it is not.
294 selectable.preSelected = RectTransformUtility.RectangleContainsScreenPoint(boxRect, screenPoint, null) && PointIsValidAgainstSelectionMask(screenPoint);
295
296 }
297
298 //Finally, since it's possible for our first clicked object to not be within the bounds of the selection box
299 //If it exists, we always ensure that it is preselected.
300 if (clickedBeforeDrag != null) {
301 clickedBeforeDrag.preSelected = true;
302 }
303 }
304
305 void ApplySingleClickDeselection(){
306
307 //If we didn't touch anything with the original mouse press, we don't need to continue checking
308 if (clickedBeforeDrag == null)
309 return;
310
311 //If we clicked a selectable without dragging, and that selectable was previously selected, we must be trying to deselect it.
312 if (clickedAfterDrag != null && clickedBeforeDrag.selected && clickedBeforeDrag.transform == clickedAfterDrag.transform ) {
313 clickedBeforeDrag.selected = false;
314 clickedBeforeDrag.preSelected = false;
315
316 }
317
318 }
319
320 void ApplyPreSelections(){
321
322 foreach(var selectable in selectables) {
323
324 //If the selectable was preSelected, we finalize it as selected.
325 if (selectable.preSelected) {
326 selectable.selected = true;
327 selectable.preSelected = false;
328 }
329 }
330
331 }
332
333 Vector2 GetScreenPointOfSelectable(IBoxSelectable selectable) {
334 //Getting the screen point requires it's own function, because we have to take into consideration the selectables hierarchy.
335
336 //Cast the transform as a rectTransform
337 var rectTransform = selectable.transform as RectTransform;
338
339 //If it has a rectTransform component, it must be in the hierarchy of a canvas, somewhere.
340 if (rectTransform) {
341
342 //And the camera used to calculate it's screen point will vary.
343 Camera renderingCamera = GetScreenPointCamera(rectTransform);
344
345 return RectTransformUtility.WorldToScreenPoint(renderingCamera, selectable.transform.position);
346 }
347
348 //If it's no in the hierarchy of a canvas, the regular Camera.main.WorldToScreenPoint will do.
349 return Camera.main.WorldToScreenPoint(selectable.transform.position);
350
351 }
352
353 /*
354 * Finding the camera used to calculate the screenPoint of an object causes a couple of problems:
355 *
356 * If it has a rectTransform, the root Canvas that the rectTransform is a descendant of will give unusable
357 * screen points depending on the Canvas.RenderMode, if we don't do any further calculation.
358 *
359 * This function solves that problem.
360 */
361 Camera GetScreenPointCamera(RectTransform rectTransform) {
362
363 Canvas rootCanvas = null;
364 RectTransform rectCheck = rectTransform;
365
366 //We're going to check all the canvases in the hierarchy of this rectTransform until we find the root.
367 do {
368 rootCanvas = rectCheck.GetComponent<Canvas>();
369
370 //If we found a canvas on this Object, and it's not the rootCanvas, then we don't want to keep it
371 if (rootCanvas && !rootCanvas.isRootCanvas) {
372 rootCanvas = null;
373 }
374
375 //Then we promote the rect we're checking to it's parent.
376 rectCheck = (RectTransform)rectCheck.parent;
377
378 } while (rootCanvas == null);
379
380 //Once we've found the root Canvas, we return a camera depending on it's render mode.
381 switch (rootCanvas.renderMode) {
382 case RenderMode.ScreenSpaceOverlay:
383 //If we send back a camera when set to screen space overlay, the coordinates will not be accurate. If we return null, they will be.
384 return null;
385
386 case RenderMode.ScreenSpaceCamera:
387 //If it's set to screen space we use the world Camera that the Canvas is using.
388 //If it doesn't have one set, however, we have to send back the current camera. otherwise the coordinates will not be accurate.
389 return (rootCanvas.worldCamera) ? rootCanvas.worldCamera : Camera.main;
390
391 default:
392 case RenderMode.WorldSpace:
393 //World space always uses the current camera.
394 return Camera.main;
395 }
396
397 }
398
400 if (selectables == null) {
401 return new IBoxSelectable[0];
402 }
403
404 var selectedList = new List<IBoxSelectable>();
405
406 foreach(var selectable in selectables) {
407 if (selectable.selected) {
408 selectedList.Add (selectable);
409 }
410 }
411
412 return selectedList.ToArray();
413 }
414
415 void EndSelection(){
416 //Get out if we haven't finished selecting, or if the selection has been aborted (boxRect disabled)
417 if (!Input.GetMouseButtonUp(0) || !boxRect.gameObject.activeSelf)
418 return;
419
420 clickedAfterDrag = GetSelectableAtMousePosition();
421
422 ApplySingleClickDeselection();
423 ApplyPreSelections();
424 ResetBoxRect();
426 }
427
428 void Start(){
429 ValidateCanvas();
430 CreateBoxRect();
431 ResetBoxRect();
432 }
433
434 void Update() {
435 BeginSelection ();
436 DragSelection ();
437 EndSelection ();
438 }
439 }
440}
UnityEngine.Debug Debug
Definition: TanodaServer.cs:19
System.Drawing.Image Image
Definition: TestScript.cs:37
UnityEngine.Color Color
Definition: TestScript.cs:32
Credit Erdener Gonenc - @PixelEnvision.