In strategy games it's common to select units by making a rectangle with the mouse. All of those units within the area will then be selected when you release the mouse button. This is how it looks like in the strategy game Red Alert:
I've recently been prototyping a strategy game and I realized that selecting all units within a rectangle wasn't as easy as I first had thought. The difference with the classic strategy games is that we are now operating in 3D space and not 2D space as in Red Alert. So we will se that we are not selecting units within a rectangle in 3D space, but the corners in the rectangle are actually not 90 degrees, even though it looks like the corners are 90 degrees on the screen.
The result will hopefully look something like this YouTube video:
But before we begin with the heavy lifting we need a basic scene. So just create a ground plane (make sure it's on layer 8 or change it in the code) and a few cubes that will act as our units (make sure they are taged as Friendly or change it in the code). A unit can either be:
So we need 3 different materials to show if a unit is either highlighted, selected, or not anything at all. Also add 4 spheres to make it easier to debug the corners of the rectangle in which we will select the units.
You also need a camera script. A basic script that will do the job:
using UnityEngine;
using System.Collections;
namespace Tutorial
{
public class TiltedCamera : MonoBehaviour
{
//The height we begin with
public float startZoom;
//The camera's transform to save space
Transform cameraTrans;
void Start()
{
//Get the camera's transform
cameraTrans = Camera.main.transform;
//Move the camera to the start position
cameraTrans.position = Vector3.zero;
//Rotate the camera to the correct rotation
cameraTrans.eulerAngles = new Vector3(45f, 0f, 0f);
//Zoom the camera to the initial zoom
cameraTrans.Translate(-Vector3.forward * startZoom);
}
}
}
When I first began trying to make a rectangle I though about making a line renderer, but it didn't look good. So we are instead going to use Unity's GUI system. So add an "UI Image" to the scene. This will automatically add a canvas and the Image will be a child to the canvas. Rename the Image to Selection Square.
The square itself will consist of a tiny 3x3 pixels large image, where the center is transparent and the surrounding frame is the color you want the selection rectangle to consist of. Click here to download it.
Change it to Texture type: Sprite (2D and UI), and add the 3x3 image as source image in the GUI Image object. Now in the GUI Image object you change the Image type to Sliced, but now Unity will complain that "This image doesn't have any borders."
To give it borders, select the image and click on Sprite editor. From the different sides of the image, drag to form borders across the center pixel:
Then click on apply and try to resize the GUI Image object (and uncheck Fill Center). You should now see that the border is always 1 pixel large no matter how large the image is. Also make sure the transform is anchored to the middle of the canvas.
The idea here is that we are going to:
To make all this work we need to determine if we are clicking with the left mouse button or holding it to make a rectangle. We are going to do that by using a timer. So if we have held down the mouse button for a certain amount of time, we will assume that we are going to make a rectangle. Otherwise we assume that we are clicking with the mouse button. But that's the easy part.
The complicated part is to determine the corners of the rectangle, and then draw the GUI rectangle, and then check if a unit is within the rectangle. To do that we have to convert the world position where we started the rectangle to a GUI position, which is in 2D. When we have done that we figure out the center position of the rectangle we want to draw. And if we have the start position and end position (like top-left and bottom-right corner) we can easily calculate the width and the height of the rectangle. We then add the center position and the size to the GUI rectangle image we created.
When we know the size of the GUI rectangle, we have to convert the corners back to the 3D world because two of the corners are not the same as if we had made the same calculations in 3D space. If you test to make a rectangle, then pause the game, and look at the scene from the top, you will see that what looks like a rectangle in 2D is actually not a rectangle in 3D.
So to determine if a unit is within the polygon we are going to divide it into two triangles and then just check if the center position of a unit is within one of the triangles. If you want you could check all corners of your unit, but it will be a little slower.
Anyway, this is the final script:
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
namespace Tutorial
{
public class SelectionSquare : MonoBehaviour
{
//Add all units in the scene to this array
public GameObject[] allUnits;
//The selection square we draw when we drag the mouse to select units
public RectTransform selectionSquareTrans;
//To test the square's corners
public Transform sphere1;
public Transform sphere2;
public Transform sphere3;
public Transform sphere4;
//The materials
public Material normalMaterial;
public Material highlightMaterial;
public Material selectedMaterial;
//All currently selected units
[System.NonSerialized]
public List<GameObject> selectedUnits = new List<GameObject>();
//We have hovered above this unit, so we can deselect it next update
//and dont have to loop through all units
GameObject highlightThisUnit;
//To determine if we are clicking with left mouse or holding down left mouse
float delay = 0.3f;
float clickTime = 0f;
//The start and end coordinates of the square we are making
Vector3 squareStartPos;
Vector3 squareEndPos;
//If it was possible to create a square
bool hasCreatedSquare;
//The selection squares 4 corner positions
Vector3 TL, TR, BL, BR;
void Start()
{
//Deactivate the square selection image
selectionSquareTrans.gameObject.SetActive(false);
}
void Update()
{
//Select one or several units by clicking or draging the mouse
SelectUnits();
//Highlight by hovering with mouse above a unit which is not selected
HighlightUnit();
}
//Select units with click or by draging the mouse
void SelectUnits()
{
//Are we clicking with left mouse or holding down left mouse
bool isClicking = false;
bool isHoldingDown = false;
//Click the mouse button
if (Input.GetMouseButtonDown(0))
{
clickTime = Time.time;
//We dont yet know if we are drawing a square, but we need the first coordinate in case we do draw a square
RaycastHit hit;
//Fire ray from camera
if (Physics.Raycast(Camera.main.ScreenPointToRay(Input.mousePosition), out hit, 200f, 1 << 8))
{
//The corner position of the square
squareStartPos = hit.point;
}
}
//Release the mouse button
if (Input.GetMouseButtonUp(0))
{
if (Time.time - clickTime <= delay)
{
isClicking = true;
}
//Select all units within the square if we have created a square
if (hasCreatedSquare)
{
hasCreatedSquare = false;
//Deactivate the square selection image
selectionSquareTrans.gameObject.SetActive(false);
//Clear the list with selected unit
selectedUnits.Clear();
//Select the units
for (int i = 0; i < allUnits.Length; i++)
{
GameObject currentUnit = allUnits[i];
//Is this unit within the square
if (IsWithinPolygon(currentUnit.transform.position))
{
currentUnit.GetComponent<MeshRenderer>().material = selectedMaterial;
selectedUnits.Add(currentUnit);
}
//Otherwise deselect the unit if it's not in the square
else
{
currentUnit.GetComponent<MeshRenderer>().material = normalMaterial;
}
}
}
}
//Holding down the mouse button
if (Input.GetMouseButton(0))
{
if (Time.time - clickTime > delay)
{
isHoldingDown = true;
}
}
//Select one unit with left mouse and deselect all units with left mouse by clicking on what's not a unit
if (isClicking)
{
//Deselect all units
for (int i = 0; i < selectedUnits.Count; i++)
{
selectedUnits[i].GetComponent<MeshRenderer>().material = normalMaterial;
}
//Clear the list with selected units
selectedUnits.Clear();
//Try to select a new unit
RaycastHit hit;
//Fire ray from camera
if (Physics.Raycast(Camera.main.ScreenPointToRay(Input.mousePosition), out hit, 200f))
{
//Did we hit a friendly unit?
if (hit.collider.CompareTag("Friendly"))
{
GameObject activeUnit = hit.collider.gameObject;
//Set this unit to selected
activeUnit.GetComponent<MeshRenderer>().material = selectedMaterial;
//Add it to the list of selected units, which is now just 1 unit
selectedUnits.Add(activeUnit);
}
}
}
//Drag the mouse to select all units within the square
if (isHoldingDown)
{
//Activate the square selection image
if (!selectionSquareTrans.gameObject.activeInHierarchy)
{
selectionSquareTrans.gameObject.SetActive(true);
}
//Get the latest coordinate of the square
squareEndPos = Input.mousePosition;
//Display the selection with a GUI image
DisplaySquare();
//Highlight the units within the selection square, but don't select the units
if (hasCreatedSquare)
{
for (int i = 0; i < allUnits.Length; i++)
{
GameObject currentUnit = allUnits[i];
//Is this unit within the square
if (IsWithinPolygon(currentUnit.transform.position))
{
currentUnit.GetComponent<MeshRenderer>().material = highlightMaterial;
}
//Otherwise deactivate
else
{
currentUnit.GetComponent<MeshRenderer>().material = normalMaterial;
}
}
}
}
}
//Highlight a unit when mouse is above it
void HighlightUnit()
{
//Change material on the latest unit we highlighted
if (highlightThisUnit != null)
{
//But make sure the unit we want to change material on is not selected
bool isSelected = false;
for (int i = 0; i < selectedUnits.Count; i++)
{
if (selectedUnits[i] == highlightThisUnit)
{
isSelected = true;
break;
}
}
if (!isSelected)
{
highlightThisUnit.GetComponent<MeshRenderer>().material = normalMaterial;
}
highlightThisUnit = null;
}
//Fire a ray from the mouse position to get the unit we want to highlight
RaycastHit hit;
//Fire ray from camera
if (Physics.Raycast(Camera.main.ScreenPointToRay(Input.mousePosition), out hit, 200f))
{
//Did we hit a friendly unit?
if (hit.collider.CompareTag("Friendly"))
{
//Get the object we hit
GameObject currentObj = hit.collider.gameObject;
//Highlight this unit if it's not selected
bool isSelected = false;
for (int i = 0; i < selectedUnits.Count; i++)
{
if (selectedUnits[i] == currentObj)
{
isSelected = true;
break;
}
}
if (!isSelected)
{
highlightThisUnit = currentObj;
highlightThisUnit.GetComponent<MeshRenderer>().material = highlightMaterial;
}
}
}
}
//Is a unit within a polygon determined by 4 corners
bool IsWithinPolygon(Vector3 unitPos)
{
bool isWithinPolygon = false;
//The polygon forms 2 triangles, so we need to check if a point is within any of the triangles
//Triangle 1: TL - BL - TR
if (IsWithinTriangle(unitPos, TL, BL, TR))
{
return true;
}
//Triangle 2: TR - BL - BR
if (IsWithinTriangle(unitPos, TR, BL, BR))
{
return true;
}
return isWithinPolygon;
}
//Is a point within a triangle
//From http://totologic.blogspot.se/2014/01/accurate-point-in-triangle-test.html
bool IsWithinTriangle(Vector3 p, Vector3 p1, Vector3 p2, Vector3 p3)
{
bool isWithinTriangle = false;
//Need to set z -> y because of other coordinate system
float denominator = ((p2.z - p3.z) * (p1.x - p3.x) + (p3.x - p2.x) * (p1.z - p3.z));
float a = ((p2.z - p3.z) * (p.x - p3.x) + (p3.x - p2.x) * (p.z - p3.z)) / denominator;
float b = ((p3.z - p1.z) * (p.x - p3.x) + (p1.x - p3.x) * (p.z - p3.z)) / denominator;
float c = 1 - a - b;
//The point is within the triangle if 0 <= a <= 1 and 0 <= b <= 1 and 0 <= c <= 1
if (a >= 0f && a <= 1f && b >= 0f && b <= 1f && c >= 0f && c <= 1f)
{
isWithinTriangle = true;
}
return isWithinTriangle;
}
//Display the selection with a GUI square
void DisplaySquare()
{
//The start position of the square is in 3d space, or the first coordinate will move
//as we move the camera which is not what we want
Vector3 squareStartScreen = Camera.main.WorldToScreenPoint(squareStartPos);
squareStartScreen.z = 0f;
//Get the middle position of the square
Vector3 middle = (squareStartScreen + squareEndPos) / 2f;
//Set the middle position of the GUI square
selectionSquareTrans.position = middle;
//Change the size of the square
float sizeX = Mathf.Abs(squareStartScreen.x - squareEndPos.x);
float sizeY = Mathf.Abs(squareStartScreen.y - squareEndPos.y);
//Set the size of the square
selectionSquareTrans.sizeDelta = new Vector2(sizeX, sizeY);
//The problem is that the corners in the 2d square is not the same as in 3d space
//To get corners, we have to fire a ray from the screen
//We have 2 of the corner positions, but we don't know which,
//so we can figure it out or fire 4 raycasts
TL = new Vector3(middle.x - sizeX / 2f, middle.y + sizeY / 2f, 0f);
TR = new Vector3(middle.x + sizeX / 2f, middle.y + sizeY / 2f, 0f);
BL = new Vector3(middle.x - sizeX / 2f, middle.y - sizeY / 2f, 0f);
BR = new Vector3(middle.x + sizeX / 2f, middle.y - sizeY / 2f, 0f);
//From screen to world
RaycastHit hit;
int i = 0;
//Fire ray from camera
if (Physics.Raycast(Camera.main.ScreenPointToRay(TL), out hit, 200f, 1 << 8))
{
TL = hit.point;
i++;
}
if (Physics.Raycast(Camera.main.ScreenPointToRay(TR), out hit, 200f, 1 << 8))
{
TR = hit.point;
i++;
}
if (Physics.Raycast(Camera.main.ScreenPointToRay(BL), out hit, 200f, 1 << 8))
{
BL = hit.point;
i++;
}
if (Physics.Raycast(Camera.main.ScreenPointToRay(BR), out hit, 200f, 1 << 8))
{
BR = hit.point;
i++;
}
//Could we create a square?
hasCreatedSquare = false;
//We could find 4 points
if (i == 4)
{
//Display the corners for debug
//sphere1.position = TL;
//sphere2.position = TR;
//sphere3.position = BL;
//sphere4.position = BR;
hasCreatedSquare = true;
}
}
}
}
That's it! What you have should now look like this:
If you prefer an audio version of this tutorial, this guy is using this tutorial to make a strategy game: Unity Tutorial - RTS Controls - Selection Box GUI - Part 4