viernes, 2 de septiembre de 2011

Unity 3D - Cómo hacer un cuadro de selección como el de un RTS

Como ya he comentado un par de veces, el principal proyecto en el que me encuentro trabajando ahora mismo es Bellum, un juego de estrategia a tiempo real.

Uno de los retos a los que hemos tenido que enfrentarnos es la realización de un cuadro de selección de unidades. No es que sea demasiado difícil, pero no hemos encontrado absolutamente ningún tutorial de Unity 3D que indique cómo hacerlo en toda la red. Así que lo que voy a ofreceros probablemente sea una exclusiva de Internet (o quizás sea que soy un patán con Google, que también es posible).

Muchos de vosotros os preguntaréis para qué os sirve un cuadro de selección si los juegos de estrategia os importan un pimiento. Pues antes de que dejéis de leer os aviso de que, aunque vuestro juego no incluya este elemento, a lo largo del tutorial veremos cosas tan básicas e imprescindibles como tirar "rayos" desde la cámara para ver qué tenemos delante de nuestro puntero (la base de cualquier shooter) o cómo pasar coordenadas de mundo a coordenadas de pantalla.

Así, en este tutorial vamos a aprender a:
- Enfrentarnos a un proyecto real, utilizando las distintas herramientas que nos ofrece Unity 3D.
- Construir un código ordenado que responda a nuestros requerimientos.
- Crear GameObjects desde el código a partir de Prefabs.
- Utilizar los valiosos "Raycast" para detectar elementos de nuestra escena presentes en un determinado vector.
- Pasar coordenadas de mundo a coordenadas de pantalla.
- ¡Ah, sí! Y a crear un cuadro de selección :P.

Aunque voy a reutilizar algo del código de Bellum, para no liaros he decidido crear un nuevo proyecto.

En un alarde de originalidad, lo he llamado "Tutorial_SelectionBox".



Preparando el escenario y las unidades

Nos enfrentamos a la temida pantalla vacía así que para rellenar vamos a empezar poniendo un terreno. Simplemente nos vamos a la opción de menú "Terrain -> Create Terrain" y ya tendremos nuestro suelo. Un terreno a priori y para lo que lo vamos a utilizar es similar al plano que utilizó Cheo en este tutorial, pero a la larga nos ofrecerá algunas opciones que nos permitirá levanta montañas o pintarlo con distintas texturas, así que si queréis dedicarle un rato a jugar con él, podéis usar los botones del componente "Terrain (Script)".

Como no quiero entretenerme con esto, yo lo voy a dejar plano y voy a mover el "Main Camera" para que lo enfoque bien:


Como nos gustaría que al lanzar el juego se vea algo, vamos a añadir una luz omni (SpotLight), que es la que afecta a todo el escenario con la misma intensidad, tal y como hizo Cheo. Guardamos la escena como GameScene (seguro que ya sabéis que es muy importante guardar a menudo).

Para lo que queremos, vamos a establecer en la cámara una rotación de 75 en el eje X. De este modo apuntará hacia el suelo, al puro estilo de los RTS.


Una vez tenemos nuestro complejísimo escenario, dispuesto a albergar las batallas más épicas, voy a añadir las unidades. Como yo igual plancho un huevo que frío una camisa, voy a utilizar un modelo 3D de una navecilla que empecé a hacer una tarde que me dio por ahí, y de paso no sólo aprendemos a importar modelos en formato OBJ, sino que le subo la autoestima a los modeladores.

Primero vamos a importar el modelo (al final del tutorial podéis descargaros el proyecto entero, y podéis sacar de él mi "supermodelo"). Para ello, desde la opción "Assets -> Import New Asset", buscamos el archivo de modelo y le dais a "Import".

Debería aparecernos algo como esto:


Como tenemos que acostumbrarnos a tener una meteodología ordenada, vamos a crearnos una carpeta llamada "Resources", y dentro de ésta otra llamada "Fighter" donde guardaremos los Assets importados: el modelo "fighter.obj" y una carpeta llamada "Materials", donde podremos configurar el material del modelo, esto es texturizado y otros shaders.

Observamos que el objeto conserva los distintos componentes en que lo hayamos dividido en nuestro programa de modelado. Esto podría sernos muy útil para scripts que requieran separar las piezas, como podría ser, por ejemplo, una explosión. Pero esto no lo veremos hoy.

Arrastramos el fighter a nuestro editor, y lo reescalamos de los tres ejes utilizando el pivote central. Después, nos ayudamos de los valores del componente transform tanto del "fighter" como del "Main Camera" para colocar el caza dentro de la cámara.

Para verlo mejor, he ido seleccionando cada uno de los componentes de "fighter" y le he puesto el Main Color en rojo. Lo suyo sería tener alguna textura currada para arrastrarla a donde pone "None (Texture 2D)", pero bastante que estamos utilizando un modelo 3D, porque en un principio iba a recurrir al típico cubo.


Como se veía algo mal, he decidido meterle otra luz, esta vez direccional, para que nos genere sombras y marque los relieves. Le he puesto una Intensity de 0,2 y una Rotation en X de 60. Esta luz, al igual que el SpotLight, podéis ponerla donde más os guste. Si alguno de vosotros es artista gráfico, por favor, perdonen mis pecados, porque no sabía lo que hacía.


Una vez he logrado que se vea algo, vamos a la parte que se me da un poco mejor (tampoco mucho), que es la de la lógica del juego.

Ya tenemos nuestro caza del tamaño y color que nos gusta (porque nos gusta, ¿verdad?), así que vamos a crear un "Prefab" donde lo guardaremos para que podamos meter un montón de cazas iguales.

Para mantener el orden voy a crear una carpeta llamada "GameObjects", y después voy a hacer click donde pone en pequeñito "Create -> Prefab", al que voy a llamar "FighterObject".

Ahora lo único que tenemos que hacer es arrastrar nuestro objecto "fighter" del Hierachy al "FighterObject" que acabamos de crear, y veremos que el cubo cambia de color a azul y nos sale algo como esto:


Vamos a borrar nuestro fighter original, no sin antes apuntarnos sus valores de posición (para no volvernos locos después, porque vamos a crear nuestros cazas desde código a partir del Prefab).

Ahora vamos a empezar a crear la lógica de nuestra aplicación. Lo primero es tener claro qué vamos a hacer:

- Al arrancar la escena se crearán 3 cazas seleccionables (o los que queráis).
- El jugador podrá mover la cámara arrastrando el ratón y con las teclas WASD así como con las flechas de dirección.
- El jugador puede crear un cuadro de selección de color verde transparente, que se deberá mantener cuando movamos la cámara, y con las siguientes características:
- Seleccionará los cazas dentro del cuadro, o si hacemos click sobre uno.
- Marcará de color amarillo los cazas seleccionados.
- Se perderá la selección cuando hagamos click.
- Pulsando una tecla de KeepSelection (Shift) se mantendrá la selección anterior.
- Pulsando una tecla de InvertSelection (Control) se mantendrá la selección anterior, pero el cuadro en lugar de seleccionar, deseleccionará.

Y después ver qué necesitamos:
- Un GameObject "GameLogic" que contendrá los scripts principales del juego.
- Un Prefab "SelectionBox" que podamos instanciar cada vez que inciemos la selección y que dibujaá el cuadro.
- Un script "CameraScript" que controlará el movimiento de la cámara.
- Un script "InputHandlerScript" que manejará los accesos de teclado, así como el ratón (irá en el GameLogic).
- Un script "GameLogicScript" que manejará la lógica del juego. Creará los cazas al principio, controlará la creación del cuadro de selección a través del InputHandlerScript, y toda la lógica de selección (irá en el GameLogic).

¿Por qué no metemos la lógica de selección en el SelectionBox, que parece lo más lógico? Pues porque este objeto únicamente dibuja el cuadro, de modo que se destruirá cuando terminemos la selección, pero los cazas seleccionados, en cambio, se mantendrán.

Este pequeño ejercicio de análisis mínimo es muy importante que lo hagamos siempre antes de empezar a picar código. Como veis es rápido, sencillo y nos ahorrará más de un problema.

Vamos a crear un objeto vacío desde  "GameObject -> Create Empty", al que voy a llamar "GameLogic".

Ahora creamos una nueva carpeta en nuestro proyecto que se llamará "Scripts", y donde crearemos un archivo de código desde "Create -> C Sharp Script" al que voy a llamar "GameLogicScript".

El editor de scripts por defecto es uno muy feo pero que nos puede servir. No obstante, si tenéis otro mejor que prefiráis usar (como es mi caso), podéis cambiarlo en "Edit -> Preferences..." con la opción "External Script Editor".

Al abrir el archivo debemos asegurarnos de que la clase se llame "GameLogicScript" o nos dará un error, ya que debe tener el mismo nombre que el archivo.


Lo primero que vamos a hacer es crear los tres cazas al arrancar. Para eso, vamos a crear un objeto List<GameObject> (se requiere importar la librería "System.Collection.Generic") que guardará los cazas que tenemos, para que podamos trabajar con ellos, y modificaremos la función Start() de modo que quede algo así:
using UnityEngine;
using System.Collections;
using System.Collections.Generic;

public class GameLogicScript : MonoBehaviour
{
 //Lista de cazas
 List<GameObject> _fighters;

 // Use this for initialization
 void Start ()
 {
        //Inicializamos la lista
   _fighters = new List<GameObject>();

  //Vamos a crear 3 cazas
   GameObject fighter = Resources.Load("FighterObject")
   as GameObject;

  GameObject fighter1 = GameObject.Instantiate(fighter,
   new Vector3(831, 1, 961),
   Quaternion.identity) as GameObject;
  GameObject fighter2 = GameObject.Instantiate(fighter,
   new Vector3(841, 1, 961),
   Quaternion.identity) as GameObject;
  GameObject fighter3 = GameObject.Instantiate(fighter,
   new Vector3(851, 1, 961),
   Quaternion.identity) as GameObject;

  //Añadimos los cazas a la lista
  _fighters.Add(fighter1);
  _fighters.Add(fighter2);
  _fighters.Add(fighter3);
 }
 
 // Update is called once per frame
 void Update ()
 {
 
 }
}

Como veis, a la hora de instanciar los cazas, he recogido los valores que ya conocía, y los he separado 10 unidades en X. Sólo he modificado la altura (Y) para que las naves queden justo encima del terreno. Con Quaternion podemos establecer la rotación del objeto, por si quisiéramos ponerlo ladeado. Yo he utilizado la propiedad "Quaternion.identity", que sería lo mismo que instanciar un nuevo Quaternion con todos los valores a 0.

Una vez tenemos nuestro script, guardamos el código y volvemos a Unity. Seleccionamos el objeto GameLogic en el Hierarchy y arrastramos el GameLogicScript al Inspector.

Vamos a comprobar que nuestro proyecto funciona. Esto es algo que tenemos que acostumbrarnos a hacer a menudo. Le damos al Play y... ¡tachán! Nos peta. Si habéis seguido mis pasos deberíais ver el error "ArgumentException: The prefab you want to instantiate is null." en vuestra Console, y por supuesto, no han aparecido los cazas.


No es nada grave, pero es algo con lo que deberemos estar SIEMPRE atentos. Todos los prefab que queramos instanciar deben ir en la carpeta "Resources". Aunque no nos la cree automáticamente, ni existan indicios de que deba ser así, Unity 3D reserva este nombre de carpeta para los Prefab. Así que la solución está en mover nuestro "FighterObject" a "Resources" y crear una carpeta "Models" donde guardaremos el modelo de la carpeta "Fighter". Y ahora podemos cargarnos la carpeta GameObjects, porque éstos van a estar en "Resources".

¿Por qué os he hecho cometer este error? Porque esto es importante que os lo grabéis a fuego, y la mejor manera de aprender es equivocándoos. O eso, o que soy un paquete. Eso... nunca lo sabréis ¡muajajajaja!

Hacemos un "Clear" en la "Console" para borrar los mensajes de error anteriores y volvemos a darle al Play. ¡Ahora sí!


Sin embargo se nos ve el molesto icono de la luz. No os preocupéis, esto en el juego no se verá. Sin embargo, es importante que empecemos a manejar las capas de visibilidad, que nos ayudarán bastante en proyectos más complejos para ver qué elementos tenemos en nuestra escena.

Le damos de nuevo al icono del Play para detener la ejecución (recordemos que los cambios que hagamos mientras está lanzado el juego no se quedan guardados una vez lo paremos) y en el Inspector del "Directional light", vamos a hacer click sobre el menú desplegable de "Layer -> Add Layer...". Comprobamos que las 7 primeras están reservadas por la aplicación. En el 8, vamos a haer click a la derecha para crear una capa que llamaremos "Lights". Volvemos a hacer click en el "Directional light" y vemos que ahora podemos elegir "Layer -> Lights". Vamos a seleccionar el "Spotlight" y vamos a meterlo también en "Layer -> Lights".

Ahora que hemos creado esta nueva capa de visibilidad, nos vamos al desplegable que hay arriba a la derecha y deseleccionamos "Lights". Las capas seleccionadas son las que veremos, y las demás permanecerán invisibles. Si en algún momento no veis algo que se suponía debería estar ahí, seguramente sea que la capa en la que se encuentra no está marcada en Layers.



Lógica del Input y de la cámara

Ahora vamos a manejar los input del usuario, pero sólo vamos a guardar los valores introducidos, de modo que cada componente que los necesite los utilice con su propia lógica.

El código del "InputHandlerScript" es el siguiente:
using UnityEngine;
using System.Collections;

public class InputHandlerScript : MonoBehaviour
{
    //ACCESOS DE TECLADO
    //Camera
    KeyCode _cameraUpKey1 = KeyCode.UpArrow;
    KeyCode _cameraUpKey2 = KeyCode.W;
    KeyCode _cameraDownKey1 = KeyCode.DownArrow;
    KeyCode _cameraDownKey2 = KeyCode.S;
    KeyCode _cameraLeftKey1 = KeyCode.LeftArrow;
    KeyCode _cameraLeftKey2 = KeyCode.A;
    KeyCode _cameraRightKey1 = KeyCode.RightArrow;
    KeyCode _cameraRightKey2 = KeyCode.D;

    //Control
    KeyCode _selectionKey1 = KeyCode.Mouse0;
    KeyCode _selectionKey2 = KeyCode.Return;
    KeyCode _keepSelectionKey1 = KeyCode.LeftShift;
    KeyCode _keepSelectionKey2 = KeyCode.RightShift;
    KeyCode _invertSelectionKey1 = KeyCode.LeftControl;
    KeyCode _invertSelectionKey2 = KeyCode.RightControl;


    //Input State
    public Vector3 _mousePosition;

    public bool _cameraUp;
    public bool _cameraDown;
    public bool _cameraLeft;
    public bool _cameraRight;

    public bool _selectingBegins;
    public bool _selectingEnds;
    public bool _keepSelection;
    public bool _invertSelection;


 // Use this for initialization
 void Start ()
 {

 }
 
 // Update is called once per frame
 void Update ()
 {
        //Reseteamos el input
        this.ResetKeys();
        //Checkeamos los nuevos valores
  this.CheckInput();
 }

    private void ResetKeys()
    {
        //Guardamos la posición del ratón, por si alguien hace uso de ella
        this._mousePosition = Input.mousePosition;

        this._cameraUp = false;
        this._cameraRight = false;
        this._cameraDown = false;
        this._cameraLeft = false;

        this._selectingBegins = false;
        this._selectingEnds = false;

        //El keepSelection y el invertSelection se resetean cuando se deja de pulsar el botón
    }
 
 //Handles keyboard and mouse input
 void CheckInput()
    {
        #region Camera
        if (Input.GetKey(_cameraUpKey1)
            || Input.GetKey(_cameraUpKey2))
  {
            this._cameraUp = true;
  }

        if (Input.GetKey(_cameraDownKey1)
            || Input.GetKey(_cameraDownKey2))
  {
            this._cameraDown = true;
  }

        if (Input.GetKey(_cameraLeftKey1)
            || Input.GetKey(_cameraLeftKey2))
  {
            this._cameraLeft = true;
  }

  if (Input.GetKey(_cameraRightKey1)
            || Input.GetKey(_cameraRightKey2))
  {
            this._cameraRight = true;
  }
        #endregion

        #region Control
        if (Input.GetKeyDown(_selectionKey1)
            || Input.GetKeyDown(_selectionKey2))
        {
            this._selectingBegins = true;
        }

        else if (Input.GetKeyUp(_selectionKey1)
            || Input.GetKeyUp(_selectionKey2))
        {
            this._selectingEnds = true;
        }

        if (Input.GetKeyDown(_keepSelectionKey1)
            || Input.GetKeyDown(_keepSelectionKey2))
        {
            this._keepSelection = true;
        }

        if (Input.GetKeyUp(_keepSelectionKey1)
            || Input.GetKeyDown(_keepSelectionKey2))
        {
            this._keepSelection = false;
        }

        else if (Input.GetKeyDown(_invertSelectionKey1)
            || Input.GetKeyUp(_invertSelectionKey2))
        {
            this._invertSelection = true;
        }

        else if (Input.GetKeyUp(_invertSelectionKey1)
            || Input.GetKeyUp(_invertSelectionKey2))
        {
            this._invertSelection = false;
        }
        #endregion
 }
}

Vamos a arrastrarlo al "GameLogic" igual que hicimos con el "GameLogicScript".

Y ahora vamos a crear el "Camera Script", donde recogemos los valores del "InputHandlerScript" para mover la cámara. El código sería el siguiente:
using UnityEngine;
using System.Collections;

    public class CameraScript : MonoBehaviour
    {
        //CONSTANTES DE CÁMARA
        //Velocidad de movimiento de la cámara
        const float CAMERA_SPEED = 30.0f;
        //Margen de pantalla donde se podrá mover la cámara situando allí el ratón
        const int CAMERA_MOVE_MARGIN = 60;

        //Manejador de input
        InputHandlerScript _input;

        // Use this for initialization
        void Start()
        {
            //Guardamos la referencia al input en nuestra clase
            _input = GameObject.Find("GameLogic").GetComponent<InputHandlerScript>();
        }

        // Update is called once per frame
        void Update()
        {
            //Declaramos un vector velocidad de la Cámara
            Vector3 cameraVector;

            //Comprobamos si el ratón se encuentra en los margenes de movimiento
            CheckMousePosition(out cameraVector);

            //Y ahora comprobamos las entradas del teclado
            if (_input._cameraUp)
                cameraVector.z = CAMERA_SPEED;
            else if (_input._cameraDown)
                cameraVector.z = -CAMERA_SPEED;
            if (_input._cameraRight)
                cameraVector.x = CAMERA_SPEED;
            else if (_input._cameraLeft)
                cameraVector.x = -CAMERA_SPEED;

            //Movemos la cámara en el vector que hemos especificado
            transform.Translate(cameraVector * Time.deltaTime, Space.World);
        }

        void CheckMousePosition(out Vector3 cameraVector)
        {
            cameraVector = new Vector3();

            if (_input._mousePosition.x < CAMERA_MOVE_MARGIN)
            {
                cameraVector.x = -CAMERA_SPEED;
            }
            else if (_input._mousePosition.x > (Screen.width - CAMERA_MOVE_MARGIN))
            {
                cameraVector.x = CAMERA_SPEED;
            }

            if (_input._mousePosition.y < CAMERA_MOVE_MARGIN)
            {
                cameraVector.z = -CAMERA_SPEED;
            }
            else if (_input._mousePosition.y > (Screen.height - CAMERA_MOVE_MARGIN))
            {
                cameraVector.z = CAMERA_SPEED;
            }
        }
    }

Este script lo arrastraremos al "Main Camera". El proyecto debería quedarnos así:


Comprobamos que ahora podemos mover la cámara con el ratón y con el teclado. Si no es así, es que nos hemos olvidado de arrastrar alguno de los scripts.

En este caso, estamos moviendo la cámara, pero si controlásemos el input desde, por ejemplo, una de las naves, ésta se movería igual. Con algo tan sencillo como esto podemos mover tanto una cámara como al protagonista de nuestra historia o lo que más rabia nos dé, así como añadirle acciones, o llamar a un menú de pausa.


El cuadro de selección

Ahora vamos a hacer el cuadro de selección. Para ello vamos a utilizar una GUITexture, a la que le he metido una textura que he hecho fácilmente con cualquier programa que maneje transparencias. Consiste en una imagen PNG de un único pixel verde a la que he añadido una transparencia del 15% llamada "selection.png" que he puesto en una carpeta nueva llamada "Textures".

Los elementos GUI se dibujan directamente en pantalla, es decir, no tienen ninguna profundidad y, por tanto,  no ocupan un espacio tridimensional. Esto es imprescindible para hacer los menús, el HUD, o este cuadro de selección.

Igual que hicimos con los cazas, vamos a crear un Prefab llamado "SelectionBox" dentro de la carpeta "Resources" (¡¡recordad que si no no nos funcionará!!), y vamos a arrastrar el GUITexture que hemos creado en él. Y ya podemos borrarlo del Hierarchy.


Ahora vamos a hacer que nuestro cuadro se dibuje. Para ello volvemos al "GameLogicScript" y esta vez vamos a trabajar sobre el Update. ¿Qué vamos a hacer?

Cuando el usuario inicie un cuadro de selección, vamos a tirar un rayo (clase Ray) desde la posición de nuestro puntero, y vamos a guardar el primer punto 3D en el que colisione (propiedad RaycastHit.point). ¿Por qué? Porque si guardásemos simplemente el punto de la pantalla, al mover la cámara éste cambiaría con respecto a la malla tridimensional, que es donde realmente está apuntando el jugador. Una vez sepamos dónde comienza nuestro cuadro de selección, a la hora de dibujarlo tan sólo necesitaremos pasar sus coordenadas de mundo a las coordenadas de pantalla respecto a la posición actual de la cámara. De este modo, el cuadro no hará cosas raras cuando se mueva la cámara.

El nuevo código del "GameLogicScript" va a quedar así:

using UnityEngine;
using System.Collections;
using System.Collections.Generic;

public class GameLogicScript : MonoBehaviour
{
    InputHandlerScript _input;

    //Lista de cazas
    List _fighters;

    //Cuadro de selección
    GameObject _selectionBox;

    //Origen de la selección actual
    Vector3 _selectionOrigin;

    //Con esta variable sabemos si hemos comenzado una selección
    bool _selecting;

 // Use this for initialization
 void Start ()
    {
        //Guardamos la referencia al input en nuestra clase
        _input = this.GetComponent<InputHandlerScript>();

        //Inicializamos la lista
        _fighters = new List<GameObject>();

     //Vamos a crear 3 cazas
        GameObject fighter = Resources.Load("FighterObject") as GameObject;

        GameObject fighter1 = GameObject.Instantiate(fighter, new Vector3(831, 1, 961), Quaternion.identity) as GameObject;
        GameObject fighter2 = GameObject.Instantiate(fighter, new Vector3(841, 1, 961), Quaternion.identity) as GameObject;
        GameObject fighter3 = GameObject.Instantiate(fighter, new Vector3(851, 1, 961), Quaternion.identity) as GameObject;

        //Añadimos los cazas a la lista
        _fighters.Add(fighter1);
        _fighters.Add(fighter2);
        _fighters.Add(fighter3);
 }
 
 // Update is called once per frame
 void Update ()
    {
        DrawSelectionBox();
 }

    void DrawSelectionBox()
    {
        if (!_selecting)
        {
            //Si no estamos seleccionando, comprobamos que si se ha pulsado la tecla de selección
            if (_input._selectingBegins)
            {
                RaycastHit hit;
                Ray ray;

                //Lanzamos un rayo desde la pantalla de nuestra cámara, tomando como punto la posición de nuestro puntero
                ray = Camera.main.ScreenPointToRay(_input._mousePosition);

                if (Physics.Raycast(ray, out hit))
                {
                    //Guardamos el punto tridimensional en el que colisiona nuestro rayo.
                    _selectionOrigin = hit.point;

                    //Creamos el cuadro de selección
                    _selectionBox = GameObject.Instantiate(Resources.Load("SelectionBox")) as GameObject;
                    _selectionBox.guiTexture.pixelInset = new Rect(_input._mousePosition.x, _input._mousePosition.y, 1, 1);

                    //Indicamos que hemos empezado una selección
                    _selecting = true;
                }
            }
        }
        else
        {
            //Si ya hemos comenzado una selección, comprobamos que ésta no ha acabado
            if (_input._selectingEnds)
            {
                //Destruimos el cuadro de selección
                Destroy(_selectionBox);

                //Indicamos que hemos finalizado nuestra selección
                _selecting = false;
            }
            else
            {
                //Estos son los límites de nuestro cuadro de selección
                Rect bound = _selectionBox.guiTexture.pixelInset;

                //Con esta sencilla función pasamos el origen de la selección a coordenadas de pantalla
                Vector3 selectionOriginBox = Camera.main.WorldToScreenPoint(_selectionOrigin);

                //Recogemos los límites de nuestro cuadro en función del punto de origen y la posición actual del puntero
                bound.xMin = Mathf.Min(selectionOriginBox.x, _input._mousePosition.x);
                bound.yMin = Mathf.Min(selectionOriginBox.y, _input._mousePosition.y);
                bound.xMax = Mathf.Max(selectionOriginBox.x, _input._mousePosition.x);
                bound.yMax = Mathf.Max(selectionOriginBox.y, _input._mousePosition.y);

                //Cambiamos el pixelInset de nuestro cuadro de selección
                _selectionBox.guiTexture.pixelInset = bound;
            }
        }
    }
}

Nos fijamos que hemos introducido unas cuantas propiedades más en nuestro proyecto. Si lo lanzamos ya deberíamos ver cómo se dibuja perfectamente nuestro cuadro de selección, manteniendo la coherencia respecto al espacio tridimensional aún cuando movemos la cámara.


Si las naves estuvieran demasiado alejadas del terreno, el cuadro de selección, que tiene su origen anclado al suelo, podría hacer alguna cosa rara con las naves. En ese caso, podríamos establecer el origen cogiendo la posición del eje Y de las coordenadas de los cazas. Pero todas esas cosas ya os las dejo a vosotros si queréis complementar el código.

Nuestro cuadro aún no selecciona nada, que era para eso para lo que lo queríamos, así que vamos a tener que completar el código del "GameLogic" un poco más con una nueva función que he llamado "UpdateSelection()".

Vamos a utilizar una variable "_selectedFighters" para guardar los cazas seleccionados, de modo que en un futuro podamos darles órdenes. Y crearemos otra variable "_keptSelectedFighters" donde guardaremos la última selección de cazas, y que se mantendrá si el usuario utiliza las teclas de "keepSelection" o "invertSelection". Ambas variables deberemos inicializarlas en la función Start();

Otra cosa que he hecho es meter la lógica del "_selecting" en el "UpdateSelection()", para que el "DrawSelectionBox()" no impida que se ejecute la inicialización y la finalización de la lógica de selección.

El código definitivo nos quedará así:
using UnityEngine;
using System.Collections;
using System.Collections.Generic;

public class GameLogicScript : MonoBehaviour
{
    InputHandlerScript _input;

    //Listas de cazas
    public List<GameObject> _fighters;
    public List<GameObject> _selectedFighters;
    public List<GameObject> _keptSelectedFighters;

    //Cuadro de selección
    public GameObject _selectionBox;

    //Origen de la selección actual
    public Vector3 _selectionOrigin;

    //Con esta variable sabemos si hemos comenzado una selección
    public bool _selecting;

 // Use this for initialization
 void Start ()
    {
        //Guardamos la referencia al input en nuestra clase
        _input = this.GetComponent<InputHandlerScript>();

        //Inicializamos las listas
        _fighters = new List<GameObject>();
        _selectedFighters = new List<GameObject>();
        _keptSelectedFighters = new List<GameObject>();

     //Vamos a crear 3 cazas
        GameObject fighter = Resources.Load("FighterObject") as GameObject;

        GameObject fighter1 = GameObject.Instantiate(fighter, new Vector3(831, 1, 961), Quaternion.identity) as GameObject;
        GameObject fighter2 = GameObject.Instantiate(fighter, new Vector3(841, 1, 961), Quaternion.identity) as GameObject;
        GameObject fighter3 = GameObject.Instantiate(fighter, new Vector3(851, 1, 961), Quaternion.identity) as GameObject;

        //Añadimos los cazas a la lista
        _fighters.Add(fighter1);
        _fighters.Add(fighter2);
        _fighters.Add(fighter3);
 }
 
 // Update is called once per frame
 void Update ()
    {
        DrawSelectionBox();
        UpdateSelection();
 }

    void DrawSelectionBox()
    {
        if (!_selecting)
        {
            //Si no estamos seleccionando, comprobamos que si se ha pulsado la tecla de selección
            if (_input._selectingBegins)
            {
                RaycastHit hit;
                Ray ray;

                //Lanzamos un rayo desde la pantalla de nuestra cámara, tomando como punto la posición de nuestro puntero
                ray = Camera.main.ScreenPointToRay(_input._mousePosition);

                if (Physics.Raycast(ray, out hit))
                {
                    //Guardamos el punto tridimensional en el que colisiona nuestro rayo.
                    _selectionOrigin = hit.point;

                    //Creamos el cuadro de selección
                    _selectionBox = GameObject.Instantiate(Resources.Load("SelectionBox")) as GameObject;
                    _selectionBox.guiTexture.pixelInset = new Rect(_input._mousePosition.x, _input._mousePosition.y, 1, 1);
                }
            }
        }
        else
        {
            //Si ya hemos comenzado una selección, comprobamos que ésta no ha acabado
            if (_input._selectingEnds)
            {
                //Destruimos el cuadro de selección
                Destroy(_selectionBox);
            }
            else
            {
                //Estos son los límites de nuestro cuadro de selección
                Rect bound = _selectionBox.guiTexture.pixelInset;

                //Con esta sencilla función pasamos el origen de la selección a coordenadas de pantalla
                Vector3 selectionOriginBox = Camera.main.WorldToScreenPoint(_selectionOrigin);

                //Recogemos los límites de nuestro cuadro en función del punto de origen y la posición actual del puntero
                bound.xMin = Mathf.Min(selectionOriginBox.x, _input._mousePosition.x);
                bound.yMin = Mathf.Min(selectionOriginBox.y, _input._mousePosition.y);
                bound.xMax = Mathf.Max(selectionOriginBox.x, _input._mousePosition.x);
                bound.yMax = Mathf.Max(selectionOriginBox.y, _input._mousePosition.y);

                //Cambiamos el pixelInset de nuestro cuadro de selección
                _selectionBox.guiTexture.pixelInset = bound;
            }
        }
    }

    void UpdateSelection()
    {
        if (!_selecting)
        {
            if (_input._selectingBegins)
            {
                //Si no mantenemos la selección
                if (!_input._keepSelection && !_input._invertSelection)
                {
                    //Desmarcamos los cazas
                    foreach (GameObject fighter in _selectedFighters)
                    {
                        Component[] renders = fighter.GetComponentsInChildren(typeof(Renderer));
                        foreach (Renderer render in renders)
                            render.material.color -= Color.yellow;
                    }
                    
                    //Limpiamos las listas de cazas seleccionados
                    _selectedFighters.Clear(); //Esta no es necesario limpiarla ya
                    _keptSelectedFighters.Clear();
                }

                //Indicamos que hemos empezado una selección
                _selecting = true;
            }
        }
        else
        {
            if (_input._selectingEnds)
            {
                //Guardamos la lista actual de cazas seleccionados
                foreach (GameObject fighter in _selectedFighters)
                    _keptSelectedFighters.Add(fighter);

                //Indicamos que hemos finalizado nuestra selección
                _selecting = false;
            }
            else
            {
                RaycastHit hit;
                Ray ray;

                //Buscamos las unidades y edificios que se encuentren dentro de la caja de selección
                List<GameObject> fightersInSelectionBox = new List<GameObject>();

                //Dado que no se puede modificar una lista mientras la estás recorriendo,
                //es mejor utilizar listas alternaticas para agregar y remover

                //Lista de cazas que añadiremos a la selección
                List<GameObject> fightersToAdd = new List<GameObject>();

                //Lista de cazas que removeremos de la selección
                List<GameObject> fightersToRemove = new List<GameObject>();


                //Primero lanzamos un rayo para guardar el punto de finalización de la selección
                ray = Camera.main.ScreenPointToRay(_input._mousePosition);
                if (Physics.Raycast(ray, out hit))
                {
                    //Este es el plano tridimensional de selección
                    Rect selectionPlane = new Rect();
                    selectionPlane.xMin = Mathf.Min(_selectionOrigin.x, hit.point.x);
                    selectionPlane.yMin = Mathf.Min(_selectionOrigin.z, hit.point.z);
                    selectionPlane.xMax = Mathf.Max(_selectionOrigin.x, hit.point.x);
                    selectionPlane.yMax = Mathf.Max(_selectionOrigin.z, hit.point.z);

                    //Comprobamos que el rayo no golpea directamente en una unidad

                    //if (this._fighters.Contains(hit.collider.gameObject)) //Si el collider estuviera en el propio objeto

                    //En nuestro caso los colider están en los componentes hijos del FighterObject, por lo que debemos acceder al padre
                    if (hit.collider.gameObject.transform.parent != null && this._fighters.Contains(hit.collider.gameObject.transform.parent.gameObject))
                    {
                        //Esta comprobación es necesaria, ya que al coger un único punto de referencia de los cazas, si éste punto no está dentro del cuadro, no lo seleccionaría
                        fightersInSelectionBox.Add(hit.collider.gameObject.transform.parent.gameObject);
                    }

                    //Agregamos a la lista los cazas que se encuentran dentro del cuadro de selección
                    foreach (GameObject fighter in this._fighters)
                    {
                        if (!fightersInSelectionBox.Contains(fighter) && (fighter.transform.position.x >= selectionPlane.xMin && fighter.transform.position.x <= selectionPlane.xMax && fighter.transform.position.z >= selectionPlane.yMin && fighter.transform.position.z <= selectionPlane.yMax))
                        {
                            fightersInSelectionBox.Add(fighter);
                        }
                    }
                }

                foreach (GameObject fighter in fightersInSelectionBox)
                {
                    if (!_input._invertSelection)
                    {
                        //Si no está pulsada la tecla de invertSelection seleccionamos los cazas del cuadro
                        if (!_selectedFighters.Contains(fighter))
                        {
                            fightersToAdd.Add(fighter);
                        }
                    }
                    else
                    {
                        //Si está pulsada la tecla de invertSelection removemos los cazas del cuadro
                        if (_selectedFighters.Contains(fighter))
                        {
                            fightersToRemove.Add(fighter);
                        }
                    }
                }

                if (!_input._keepSelection)
                {
                    foreach (GameObject fighter in _keptSelectedFighters)
                    {
                        if (!_input._invertSelection)
                        {
                            if (!fightersInSelectionBox.Contains(fighter) && _selectedFighters.Contains(fighter))
                            {
                                fightersToRemove.Add(fighter);
                            }
                        }
                        else
                        {
                            if (!fightersInSelectionBox.Contains(fighter) && !_selectedFighters.Contains(fighter))
                            {
                                fightersToAdd.Add(fighter);
                            }
                        }
                    }
                }

                foreach (GameObject fighter in fightersToAdd)
                {
                    SelectFighter(fighter);
                }

                foreach (GameObject fighter in fightersToRemove)
                {
                    DeselectFighter(fighter);
                }
            }
        }
    }

    void SelectFighter(GameObject fighter)
    {
        //Comprobamos que el caza no esté ya seleccionado
        if (!_selectedFighters.Contains(fighter))
        {
            //Agregamos el caza a la lista
            _selectedFighters.Add(fighter);
            //Marcamos el caza de color amarillo
            Component[] renders = fighter.GetComponentsInChildren(typeof(Renderer));
            foreach (Renderer render in renders)
                render.material.color += Color.yellow;
        }
    }

    void DeselectFighter(GameObject fighter)
    {
        if (_selectedFighters.Contains(fighter))
        {
            //Removemos el caza de la lista
            _selectedFighters.Remove(fighter);
            //Desmarcamos el caza
            Component[] renders = fighter.GetComponentsInChildren(typeof(Renderer));
            foreach (Renderer render in renders)
                render.material.color -= Color.yellow;
        }
    }
}

Bien, ahora ya sólo nos falta pulir un pequeño detalle. Y es que para que el rayo colisione con nuestra nave, tenemos que definirle un collider. Dado que Unity genera los distintos collider a partir de la malla y ésta está en los hijos del ObjectFighter (Cockpit, wings, engines y hull), tendremos que repetir la siguiente operación con cada uno de ellos: Component -> Physics -> BoxCollider.



Y ya está. Tenemos nuestro completísimo cuadro de selección y podemos dejar guardadas las naves en una lista para darles las órdenes pertinentes.



Podéis descargar el código fuente de ejemplo aquí:



2 comentarios:

  1. muy buen tutorial, muy bien explicado... estube intentando bajar elejemplo que pusiste(Podéis descargar el código fuente de ejemplo aquí:

    --- DESCARGAR ---)
    pero no funciona una pena me hubiese gustado estudiarlo mejor..!!! No tenes otra posibilidad de poder bajarlo seria muy bueno!! Te mando un abrazo
    Matias

    ResponderEliminar
    Respuestas
    1. ¡Muchas gracias! Me alegro de que te haya gustado.

      Lo cierto es que a mí el enlace me funciona. Es este: https://dl.dropbox.com/u/17911471/Tutorial_SelectionBox.rar.

      Puede que se cayeran los servidores de Dropbox o algo así. De todas formas, si sigue sin funcionarte, envíame un email a contact@dreaming-arts.com y te paso el archivo por ahí ;).

      Aprovecho para comentarte que la dirección actual del blog es http://www.dreaming-arts.com/.

      ¡Un saludo!

      Eliminar