Wednesday, July 17, 2013

Unity: Detecting a Cursor over an Object

Many indie games feature a single avatar moving through a world. (If I've already lost you, Mario is an avatar, because you control his murderous waltz through an enemy-occupied Mushroom Kingdom.)

Having a single avatar makes things easy for both implementation and comprehension: it's easy to make because you just test to see what's close to the player, and it's easy to understand because the player knows who he/she is on screen. "I am that plumber," they think, and they push a button, and since it's easy to check if a button is pressed, the game responds instantly, and all is well.

But! Some games don't have a single avatar, and that's when you need to use the mouse (or your finger) to select an object, or point to a position on the terrain. These games are almost always strategic or tactical in nature, like StarCraft or SimCity, and here we're going to see how to do that in Unity.

In this example, it waits for a mouse click, then sends a ray from the cursor (via the camera) into the game world. If it strikes this object (and it has to have a collider for this to work), you can then run special commands.

 void Update ()
 {
  // This will only fire the first frame after the button was pressed
  if (Input.GetButtonDown("Fire1"))
  {
   // Fire a ray from the camera into the world
   Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
   RaycastHit hit;
   // Test it out to 1000 units. If it hits something, continue.
   if (Physics.Raycast (ray, out hit, 1000f))
   {
    // If it hit THIS object, do something.
    if (hit.collider.gameObject.Equals(this.gameObject))
    {
     Debug.Log("You tapped a " + gameObject.name);
    }
   }
  }
 }

If you're curious, here's how you'd do it on an Android or iOS device. Since there's no debug log that's visible, we'll just destroy the game object when it's tapped.

void Update () 
 {
  RaycastHit hit;
  foreach (Touch touch in Input.touches)
  {
   Ray ray = Camera.main.ScreenPointToRay(touch.position);
   if (Physics.Raycast (ray, out hit, 1000f))
   {
    if (hit.collider.gameObject.Equals(this.gameObject))
    {
     if (touch.phase == TouchPhase.Began)
     {
      Destroy (gameObject);
     }
    }
   }
  }
 }

This technique fine for most projects, and I used this code on every object I wanted the cursor to be "aware" of. However, once I needed a system where I always knew where the cursor was, I realized it didn't scale: a raycast would be calculated every frame, by every object. Furthermore, there's no clean way to handle multiple camera layers, such as a Menu Camera and Game Camera, and that can lead to some "fun" bugs and user frustration.

Now, I have a single cursor control object. Each frame, it sends out a ray, and brings back any GameObject it hit. There, your options split: you can Broadcast a generic command to each script on the object, or if you've standardized everything, you can get a script component and run the command directly. I'm using the Broadcast method, because *shrug*. Here is the (simplified) code I'm using for Chess Heroes.
 Camera menuCamera;
 Camera sceneCamera;
 GameObject hoverObject;
 GameObject menuObject;
 GameObject sceneObject;
 
 void Start () 
 {
  menuCamera = GameObject.Find("MenuCamera").GetComponent();
  sceneCamera = GameObject.Find("SceneCamera").GetComponent();
 }
 
 void Update ()
 {
  // First check if the cursor is over a menu object
  menuObject = GetObjectUnderCursorUsingCamera(menuCamera);
  
  // If it's not null, continue
  if (menuObject)
  {
   // If it's a different object, run MouseOver on it
   if (hoverObject != menuObject)
   {
    MouseOver(menuObject);
   }
  }
  // Otherwise, check the scene
  else
  {
   sceneObject = GetObjectUnderCursorUsingCamera(sceneCamera);
   
   // If it's not null, continue
   if (sceneObject)
   {
    // If it's a different object, run MouseOver on it
    if (hoverObject != sceneObject)
    {
     MouseOver(sceneObject);
    }
   }
   else
   {
    // Nothing of interest in the scene or menu, so run MouseAway
    MouseAway();
   }
  }

  // If the button has been tapped and there's an active object under the cursor, send it a message
  if (Input.GetButtonDown("Fire1") && hoverObject != null)
  {
   hoverObject.BroadcastMessage("OnTap", SendMessageOptions.DontRequireReceiver);
  }
 }
 
 GameObject GetObjectUnderCursorUsingCamera (Camera camera)
 {
  Ray ray = camera.ScreenPointToRay(Input.mousePosition);
  RaycastHit hit;
  if (Physics.Raycast (ray, out hit, 1000f))
  {
   return hit.collider.gameObject;
  }
  return null;
 }
 
 void MouseOver (GameObject targetObject)
 {
  if (hoverObject != null) 
  {
   hoverObject.BroadcastMessage("OnMouseAway", SendMessageOptions.DontRequireReceiver);
  }
  targetObject.BroadcastMessage("OnMouseOver", SendMessageOptions.DontRequireReceiver);
  hoverObject = targetObject;
 }
 
 void MouseAway ()
 {
  if (hoverObject != null)
  {
   hoverObject.BroadcastMessage("OnMouseAway", SendMessageOptions.DontRequireReceiver);
   hoverObject = null;
  }
 }

No comments:

Post a Comment