This is my first post in the unity community. I started using unity a couple of months ago and I think it is incredible. I saw a post containing code that implemented rudimentary RTS Selection Box style functionality, which I used as a base to create a much more fleshed out version for a project I’m working on.
Then I thought, hey, why not share it with everyone.
I’ve extensively commented the code, but being an independently trained programmer, just because the explanation makes sense to me doesn’t mean it will make sense to everyone else. If you find caveats, bugs, redundancies, poor coding practices or areas it could otherwise be improved, let me know!
There are two stages to making this work:
Apply the SelectionBox script to a game object with a canvas set to Screen Space Overlay
Implement the IBoxSelectable interface on your own MonoBehaviours to make them selectable.
Firstly, the SelectionBox class:
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;
using System.Collections.Generic;
/*
* What the SelectionBox component does is allow the game player to select objects using an RTS style click and drag interface:
*
* We want to be able to select Game Objects of any type,
* We want to be able to drag select a group of game objects to select them,
* 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,
* We want to be able to single click a game object to select it,
* We want to be able to hold the shift key and single click a game object to add it to the current selection,
* 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.
*
* 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.
*
* Add this component to a Gameobject with a Canvas with RenderMode.ScreenSpaceOverlay
* And implement the IBoxSelectable interface on any MonoBehaviour to make it selectable.
*
* Improvements that could be made:
*
* Control clicking a game object to select all objects of that type or tag.
* Compatability with Canvas Scaling
* 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)
*
*/
namespace UnityEngine.UI.Extensions {
[RequireComponent(typeof(Canvas))]
public class SelectionBox : MonoBehaviour
{
// The color of the selection box.
public Color color;
// An optional parameter, but you can add a sprite to the selection box to give it a border or a stylized look.
// It's suggested you use a monochrome sprite so that the selection
// Box color is still relevent.
public Sprite art;
// Will store the location of wherever we first click before dragging.
private Vector2 origin;
// A rectTransform set by the User that can limit which part of the screen is eligable for drag selection
public RectTransform selectionMask;
//Stores the rectTransform connected to the generated gameObject being used for the selection box visuals
private RectTransform boxRect;
// Stores all of the selectable game objects
private IBoxSelectable[] selectables;
// A secondary storage of objects that the user can manually set.
private MonoBehaviour[] selectableGroup;
//Stores the selectable that was touched when the mouse button was pressed down
private IBoxSelectable clickedBeforeDrag;
//Stores the selectable that was touched when the mouse button was released
private IBoxSelectable clickedAfterDrag;
//Custom UnityEvent so we can add Listeners to this instance when Selections are changed.
public class SelectionEvent : UnityEvent<IBoxSelectable[]> {}
public SelectionEvent onSelectionChange = new SelectionEvent();
//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.
void ValidateCanvas(){
var canvas = gameObject.GetComponent<Canvas>();
if (canvas.renderMode != RenderMode.ScreenSpaceOverlay) {
throw new System.Exception("SelectionBox component must be placed on a canvas in Screen Space Overlay mode.");
}
var canvasScaler = gameObject.GetComponent<CanvasScaler>();
if (canvasScaler && canvasScaler.enabled && (!Mathf.Approximately(canvasScaler.scaleFactor, 1f) || canvasScaler.uiScaleMode != CanvasScaler.ScaleMode.ConstantPixelSize)) {
Destroy(canvasScaler);
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.");
}
}
/*
* 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:
*
* 1) The default behaviour is to check every game object in the scene, which is much slower.
* 2) The user can filter which objects should be selectable, for example units versus menu selections
*
*/
void SetSelectableGroup(IEnumerable<MonoBehaviour> behaviourCollection) {
// If null, the selectionbox reverts to it's default behaviour
if (behaviourCollection == null) {
selectableGroup = null;
return;
}
//Runs a double check to ensure each of the objects in the collection can be selectable, and doesn't include them if not.
var behaviourList = new List<MonoBehaviour>();
foreach(var behaviour in behaviourCollection) {
if (behaviour as IBoxSelectable != null) {
behaviourList.Add (behaviour);
}
}
selectableGroup = behaviourList.ToArray();
}
void CreateBoxRect(){
var selectionBoxGO = new GameObject();
selectionBoxGO.name = "Selection Box";
selectionBoxGO.transform.parent = transform;
selectionBoxGO.AddComponent<Image>();
boxRect = selectionBoxGO.transform as RectTransform;
}
//Set all of the relevant rectTransform properties to zero,
//finally deactivates the boxRect gameobject since it doesn't
//need to be enabled when not in a selection action.
void ResetBoxRect(){
//Update the art and color on the off chance they've changed
Image image = boxRect.GetComponent<Image>();
image.color = color;
image.sprite = art;
origin = Vector2.zero;
boxRect.anchoredPosition = Vector2.zero;
boxRect.sizeDelta = Vector2.zero;
boxRect.anchorMax = Vector2.zero;
boxRect.anchorMin = Vector2.zero;
boxRect.pivot = Vector2.zero;
boxRect.gameObject.SetActive(false);
}
void BeginSelection(){
// Click somewhere in the Game View.
if (!Input.GetMouseButtonDown(0))
return;
//The boxRect will be inactive up until the point we start selecting
boxRect.gameObject.SetActive(true);
// Get the initial click position of the mouse.
origin = new Vector2(Input.mousePosition.x, Input.mousePosition.y);
//If the initial click point is not inside the selection mask, we abort the selection
if (!PointIsValidAgainstSelectionMask(origin)) {
ResetBoxRect();
return;
}
// The anchor is set to the same place.
boxRect.anchoredPosition = origin;
MonoBehaviour[] behavioursToGetSelectionsFrom;
// 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
if (selectableGroup == null) {
behavioursToGetSelectionsFrom = GameObject.FindObjectsOfType<MonoBehaviour>();
} else {
behavioursToGetSelectionsFrom = selectableGroup;
}
//Temporary list to store the found selectables before converting to the main selectables array
List<IBoxSelectable> selectableList = new List<IBoxSelectable>();
foreach (MonoBehaviour behaviour in behavioursToGetSelectionsFrom) {
//If the behaviour implements the selectable interface, we add it to the selectable list
IBoxSelectable selectable = behaviour as IBoxSelectable;
if (selectable != null) {
selectableList.Add (selectable);
//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
if (!Input.GetKey (KeyCode.LeftShift)) {
selectable.selected = false;
}
}
}
selectables = selectableList.ToArray();
//For single-click actions, we need to get the selectable that was clicked when selection began (if any)
clickedBeforeDrag = GetSelectableAtMousePosition();
}
bool PointIsValidAgainstSelectionMask(Vector2 screenPoint){
//If there is no seleciton mask, any point is valid
if (!selectionMask) {
return true;
}
Camera screenPointCamera = GetScreenPointCamera(selectionMask);
return RectTransformUtility.RectangleContainsScreenPoint(selectionMask, screenPoint, screenPointCamera);
}
IBoxSelectable GetSelectableAtMousePosition() {
//Firstly, we cannot click on something that is not inside the selection mask (if we have one)
if (!PointIsValidAgainstSelectionMask(Input.mousePosition)) {
return null;
}
//This gets a bit tricky, because we have to make considerations depending on the heirarchy of the selectable's gameObject
foreach (var selectable in selectables) {
//First we check to see if the selectable has a rectTransform
var rectTransform = (selectable.transform as RectTransform);
if (rectTransform) {
//Because if it does, the camera we use to calulate it's screen point will vary
var screenCamera = GetScreenPointCamera(rectTransform);
//Once we've found the rendering camera, we check if the selectables rectTransform contains the click. That way we
//Can click anywhere on a rectTransform to select it.
if (RectTransformUtility.RectangleContainsScreenPoint(rectTransform, Input.mousePosition, screenCamera)) {
//And if it does, we select it and send it back
return selectable;
}
} else {
//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.
//This works because a 2D or 3D renderer will both return a radius
var radius = selectable.transform.renderer.bounds.extents.magnitude;
var selectableScreenPoint = GetScreenPointOfSelectable(selectable);
//Check that the click fits within the screen-radius of the selectable
if (Vector2.Distance(selectableScreenPoint, Input.mousePosition) <= radius) {
//And if it does, we select it and send it back
return selectable;
}
}
}
return null;
}
void DragSelection(){
//Return if we're not dragging or if the selection has been aborted (BoxRect disabled)
if (!Input.GetMouseButton(0) || !boxRect.gameObject.activeSelf)
return;
// Store the current mouse position in screen space.
Vector2 currentMousePosition = new Vector2(Input.mousePosition.x, Input.mousePosition.y);
// How far have we moved the mouse?
Vector2 difference = currentMousePosition - origin;
// Copy the initial click position to a new variable. Using the original variable will cause
// the anchor to move around to wherever the current mouse position is,
// which isn't desirable.
Vector2 startPoint = origin;
// The following code accounts for dragging in various directions.
if (difference.x < 0)
{
startPoint.x = currentMousePosition.x;
difference.x = -difference.x;
}
if (difference.y < 0)
{
startPoint.y = currentMousePosition.y;
difference.y = -difference.y;
}
// Set the anchor, width and height every frame.
boxRect.anchoredPosition = startPoint;
boxRect.sizeDelta = difference;
//Then we check our list of Selectables to see if they're being preselected or not.
foreach(var selectable in selectables) {
Vector3 screenPoint = GetScreenPointOfSelectable(selectable);
//If the box Rect contains the selectabels screen point and that point is inside a valid selection mask, it's being preselected, otherwise it is not.
selectable.preSelected = RectTransformUtility.RectangleContainsScreenPoint(boxRect, screenPoint, null) && PointIsValidAgainstSelectionMask(screenPoint);
}
//Finally, since it's possible for our first clicked object to not be within the bounds of the selection box
//If it exists, we always ensure that it is preselected.
if (clickedBeforeDrag != null) {
clickedBeforeDrag.preSelected = true;
}
}
void ApplySingleClickDeselection(){
//If we didn't touch anything with the original mouse press, we don't need to continue checking
if (clickedBeforeDrag == null)
return;
//If we clicked a selectable without dragging, and that selectable was previously selected, we must be trying to deselect it.
if (clickedAfterDrag != null && clickedBeforeDrag.selected && clickedBeforeDrag.transform == clickedAfterDrag.transform ) {
clickedBeforeDrag.selected = false;
clickedBeforeDrag.preSelected = false;
}
}
void ApplyPreSelections(){
foreach(var selectable in selectables) {
//If the selectable was preSelected, we finalize it as selected.
if (selectable.preSelected) {
selectable.selected = true;
selectable.preSelected = false;
}
}
}
Vector2 GetScreenPointOfSelectable(IBoxSelectable selectable) {
//Getting the screen point requires it's own function, because we have to take into consideration the selectables heirarchy.
//Cast the transform as a rectTransform
var rectTransform = selectable.transform as RectTransform;
//If it has a rectTransform component, it must be in the heirarchy of a canvas, somewhere.
if (rectTransform) {
//And the camera used to calculate it's screen point will vary.
Camera renderingCamera = GetScreenPointCamera(rectTransform);
return RectTransformUtility.WorldToScreenPoint(renderingCamera, selectable.transform.position);
}
//If it's no in the heirarchy of a canvas, the regular Camera.main.WorldToScreenPoint will do.
return Camera.main.WorldToScreenPoint(selectable.transform.position);
}
/*
* Finding the camera used to calculate the screenPoint of an object causes a couple of problems:
*
* If it has a rectTransform, the root Canvas that the rectTransform is a descendant of will give unusable
* screen points depending on the Canvas.RenderMode, if we don't do any further calculation.
*
* This function solves that problem.
*/
Camera GetScreenPointCamera(RectTransform rectTransform) {
Canvas rootCanvas = null;
RectTransform rectCheck = rectTransform;
//We're going to check all the canvases in the heirarchy of this rectTransform until we find the root.
do {
rootCanvas = rectCheck.GetComponent<Canvas>();
//If we found a canvas on this Object, and it's not the rootCanvas, then we don't want to keep it
if (rootCanvas && !rootCanvas.isRootCanvas) {
rootCanvas = null;
}
//Then we promote the rect we're checking to it's parent.
rectCheck = (RectTransform)rectCheck.parent;
} while (rootCanvas == null);
//Once we've found the root Canvas, we return a camera depending on it's render mode.
switch (rootCanvas.renderMode) {
case RenderMode.ScreenSpaceOverlay:
//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.
return null;
case RenderMode.ScreenSpaceCamera:
//If it's set to screen space we use the world Camera that the Canvas is using.
//If it doesn't have one set, however, we have to send back the current camera. otherwise the coordinates will not be accurate.
return (rootCanvas.worldCamera) ? rootCanvas.worldCamera : Camera.main;
default:
case RenderMode.WorldSpace:
//World space always uses the current camera.
return Camera.main;
}
}
public IBoxSelectable[] GetAllSelected(){
if (selectables == null) {
return new IBoxSelectable[0];
}
var selectedList = new List<IBoxSelectable>();
foreach(var selectable in selectables) {
if (selectable.selected) {
selectedList.Add (selectable);
}
}
return selectedList.ToArray();
}
void EndSelection(){
//Get out if we haven't finished selecting, or if the selection has been aborted (boxRect disabled)
if (!Input.GetMouseButtonUp(0) || !boxRect.gameObject.activeSelf)
return;
clickedAfterDrag = GetSelectableAtMousePosition();
ApplySingleClickDeselection();
ApplyPreSelections();
ResetBoxRect();
onSelectionChange.Invoke(GetAllSelected());
}
void Start(){
ValidateCanvas();
CreateBoxRect();
ResetBoxRect();
}
void Update() {
BeginSelection ();
DragSelection ();
EndSelection ();
}
}
}
Secondly, the IBoxSelectable interface:
using UnityEngine;
using UnityEngine.UI;
using System.Collections.Generic;
namespace UnityEngine.UI.Extensions {
/*
* Implement this interface on any MonoBehaviour that you'd like to be considered selectable.
*/
public interface IBoxSelectable {
bool selected {
get;
set;
}
bool preSelected {
get;
set;
}
//This property doesn't actually need to be implemented, as this interface should already be placed on a MonoBehaviour, which will
//already have it. Defining it here only allows us access to the transform property by casting through the selectable interface.
Transform transform {
get;
}
}
}
Thirdly, an example of how to implement this interface in your own Scripts (Remember to use the namespace!)
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.UI.Extensions;
using System.Collections;
public class ExampleSelectable : MonoBehaviour, IBoxSelectable {
#region Implemented members of IBoxSelectable
bool _selected = false;
public bool selected {
get {
return _selected;
}
set {
_selected = value;
}
}
bool _preSelected = false;
public bool preSelected {
get {
return _preSelected;
}
set {
_preSelected = value;
}
}
#endregion
//We want the test object to be either a UI element, a 2D element or a 3D element, so we'll get the appropriate components
SpriteRenderer spriteRenderer;
Image image;
Text text;
void Start () {
spriteRenderer = transform.GetComponent<SpriteRenderer>();
image = transform.GetComponent<Image>();
text = transform.GetComponent<Text>();
}
void Update () {
//What the game object does with the knowledge that it is selected is entirely up to it.
//In this case we're just going to change the color.
//White if deselected.
Color color = Color.white;
if (preSelected) {
//Yellow if preselected
color = Color.yellow;
}
if (selected) {
//And green if selected.
color = Color.green;
}
//Set the color depending on what the game object has.
if (spriteRenderer) {
spriteRenderer.color = color;
} else if (text) {
text.color = color;
} else if (image) {
image.color = color;
} else if (renderer) {
renderer.material.color = color;
}
}
}
And finally, everything has been attached to this file in a unityPackage with an example scene.
Enjoy!
1888812–121540–SelectionBox.unitypackage (15.2 KB)
