RTS Unit Selection in Unity

on tutorials rts

Real-time strategy games usually allow a user to click and drag a box to select all the units within that box. A single left click would clear the selection. I have not found a tutorial that uses the UI canvas for this, so I took it upon myself to implement a solution and share it.

Setting Up The Scene

A unit--it could be a box-- is needed to be the thing that a user selects, then a plane is needed for our ground, and finally a canvas element.

Setting Up The Unit

posts/rts-unit-selection-in-unity/soldier-setup.png Your box should look something like this

  • Create a box game object
  • Attach an empty child game object-- this will be our halo
  • Add a projector component to it
  • Add a collider component to ensure raycasts will hit it

posts/rts-unit-selection-in-unity/halo-setup.png Your halo should look like so

The projector component has to ignore all layers except the ground layer. Or else the projector will project the halo on top of other units / objects.

posts/rts-unit-selection-in-unity/projector.png Position the halo like so on the unit

posts/rts-unit-selection-in-unity/SelectionCircleAlpha.png Download this and use it as the texture

Download Selection Shader

Full credit where credit is due, I took the halo and some code from this blog post.

Setting Up The UI Canvas

posts/rts-unit-selection-in-unity/canvas-setup.png

Create a canvas element and then attach an image component, this will be our select box.

posts/rts-unit-selection-in-unity/selectbox-setup.png It should look the this

Code Pieces

  • SelectionController - Manages which entities are within a box.
  • UiController - Renders the selection box on screen.
  • SelectUtil - Contains core logic for finding entities within a box.

I wrote about my game architecture in an other post. I recommend you read that to understand how these controllers connect with each other.

Creating Selection Controller

public class SelectionController {

  private Level level;
  private Team playerTeam;

  public List<Entity> selection { get; private set; }

  private Vector3 anchor;
  private Vector3 outer;
  private bool hasActiveBox;

  public SelectionController(Level level){
    this.level = level;
    selection = new List<Entity>();
    this.playerTeam = level.playerTeam;
  }

  public void ClearSelection() {
    RemoveAllSelections();

    EventManager.Instance.TriggerEvent(new SelectionEvent(selection));
  }

  private void AddToSelection(Entity entity) {
    DebugUtil.Assert(entity != null, "Selected object with no entity");

    selection.Add(entity);

    entity.transform.Find("Halo").gameObject.SetActive(true);
  }

  private void AddAllWithinBounds() {
    Bounds bounds = SelectUtils.GetViewportBounds(Camera.main, anchor, outer);

    this.level.playerTeam.ForEach( (Entity entity) => {
      if (SelectUtils.IsWithinBounds(Camera.main, bounds, entity.transform.position)) {
        AddToSelection(entity);
      }
    });
  }

  private void AddSingleEntity() {
    Entity entity = SelectUtils.FindEntityAt(Camera.main, anchor);

    if (entity != null) {
      if (entity.team == level.playerTeam) {
        AddToSelection(entity);
      }
    }
  }

  private void RemoveAllSelections() {

    foreach (Entity entity in selection) {
      entity.transform.Find("Halo").gameObject.SetActive(false);
    }

    selection.Clear();
  }

  public void SelectEntities() {
    RemoveAllSelections();

    if (outer == anchor) {
      AddSingleEntity();
    } else {
      AddAllWithinBounds();
    }

    hasActiveBox = false;

    EventManager.Instance.TriggerEvent(new SelectionEvent(selection));
  }

  public void CreateBoxSelection() {
    hasActiveBox = true;

    anchor = Input.mousePosition;
    outer = Input.mousePosition;

    EventManager.Instance.TriggerEvent(new StartSelectBoxEvent(anchor));
  }

  public void DragBoxSelection() {
    if (hasActiveBox) {
      outer = Input.mousePosition;

      EventManager.Instance.TriggerEvent(new DragSelectBoxEvent(outer));
    }
  }

}

It is used like so:

void Update() {
  if (Input.GetButtonDown("Fire1")) {
      _selectionController.CreateBoxSelection();
  }

  if (Input.GetButtonUp("Fire1")) {
      _selectionController.SelectEntities();
  }

  _selectionController.DragBoxSelection();
}

When the left-click button is first pressed the initial box position is placed. As the mouse moves the DragBoxSelection will update the boxes size and trigger events for the UiController to render on screen.

private void OnDragSelectBox(DragSelectBoxEvent e) {
  _uiController.DragSelectBox(e.outer);
}

Once a selection has been made a SelectionEvent is triggered, which clears the box and can then be used by an other controller.

Selection Helper

public class SelectUtil {

  public static Entity FindEntityAt(Camera camera, Vector3 position) {
    RaycastHit hit;
    LayerMask mask = 1 << LayerMask.NameToLayer("Entity");

    if (Physics.Raycast(camera.ScreenPointToRay(position), out hit, 100, mask.value)) {
      Entity entity = hit.transform.gameObject.GetComponent<Entity>();
      if (entity != null) {
          return entity;
      }
    }

    return null;
  }

  public static Bounds GetViewportBounds(Camera camera, Vector3 anchor, Vector3 outer) {
    Vector3 anchorView = camera.ScreenToViewportPoint(anchor);
    Vector3 outerView = camera.ScreenToViewportPoint(outer);
    Vector3 min = Vector3.Min(anchorView, outerView);
    Vector3 max = Vector3.Max(anchorView, outerView);

    min.z = camera.nearClipPlane;
    max.z = camera.farClipPlane;

    Bounds bounds = new Bounds();
    bounds.SetMinMax(min, max);
    return bounds;
  }

  public static bool IsWithinBounds(Camera camera, Bounds viewportBounds, Vector3 position) {
    Vector3 viewportPoint = camera.WorldToViewportPoint(position);

    return viewportBounds.Contains(viewportPoint);
  }

}

I like to break out core logic like this into its own utility, for unit testing and reusability. This can be used for other features, like figuring out which units are within an attack range.

Creating UI Controller

public class UiController {

  GameObject selectboxObject;
  GameObject canvas;
  RectTransform rectTransform;
  Vector2 anchor;

  public UiController() {
    selectboxObject = GameObject.Find("/HUDCanvas/Selectbox");
    rectTransform = selectboxObject.GetComponent<RectTransform>();
    canvas = GameObject.Find("/HUDCanvas");

    ClearBox();
  }

  public void StartSelectBox(Vector3 anchor) {
    selectboxObject.SetActive(true);

    this.anchor = ScreenToLocal(anchor);

    rectTransform.localPosition = new Vector3(this.anchor.x, this.anchor.y, rectTransform.localPosition.z);
  }

  public void DragSelectBox(Vector3 outer) {
    Vector2 rectPos = ScreenToLocal(outer);
    Vector2 newSize = new Vector2();
    Vector3 newPosition = new Vector3(anchor.x, anchor.y, rectTransform.localPosition.z);

    if (rectPos.x - anchor.x < 0) {
      // move location
      newSize.x = anchor.x - rectPos.x;
      newPosition.x = rectPos.x;
    } else {
      newSize.x = rectPos.x - anchor.x;
    }

    if (rectPos.y - anchor.y < 0) {
      // move location
      newSize.y = anchor.y - rectPos.y;
      newPosition.y = rectPos.y;
    } else {
      newSize.y = rectPos.y - anchor.y;
    }

    rectTransform.sizeDelta = newSize;
    rectTransform.localPosition = newPosition;
  }

  private Vector2 ScreenToLocal(Vector3 point) {
    Vector2 pos;
    RectTransformUtility.ScreenPointToLocalPointInRectangle( canvas.transform as RectTransform, point, Camera.main, out pos);

    return pos;
  }

  public void ClearAll() {
      ClearBox();
  }

  public void ClearBox() {
    selectboxObject.SetActive(false);
    rectTransform.sizeDelta = new Vector2(0, 0);
  }

}

The final piece is the UiController this actaully renders the image on screen to see which units will be selected. Remember it's best practice to always separate view from logic, for reusability and testing. The RectTransform was used to convert mouse clicks into a canvas position, so the box would appear in the correct place.

I'll be continuing the series next with commanding entities to move.