jueves, 10 de mayo de 2012

Level 03: Detección de Colisiones II

"El hombre todo lo perfecciona en torno suyo; lo que no hace es perfeccionarse a sí mismo."
Jean Baptiste Alphonse Karr (1808 - 1890) - Escritor francés.
Antes que nada...

¡Saludos!

Uffff... esta vez sí que costó armar esta entrada. ¡Pero se pudo! Costó entender y aplicar todo lo investigado. Pero bueno, este fue un buen logro.

En la entrada anterior se vió principalmente sobre cómo detectar colisiones simples usando las clases BoundingBox y BoundingSphere. El gran problema que se presenta es que la detección de colisiones es imprecisa cuando se trabaja con Texturas (Sprites) de formas irregulares (o para ser más precisos, de forma no circular o rectangular puras). Peor aún cuando las mismas texturas se rotan o escalan ya que tanto los BoundingBox y BoundingSphere están alineados al Eje X y al Eje Y y por lo tanto no pueden ser rotados.

Es por eso que estuve investigando sobre posibles soluciones en muchos (...y la verdad que sí, MUCHOS) sitios. De todas las soluciones que encontré, escogí aquellas metodologías que, me en mi opinión, eran mejores. Compartiré los resultados en esta entrada.
  
Los temas que veremos esta vez, serán:
  1. Los problemas de la Detección de Colisiones con BoundingBox (y BoundingSphere).
  2. Detección de Colisiones a Nivel Pixel ("Pixel Perfect").
  3. Detección de Colisiones combinadas: Pixel Perfect + Rectangle.  
 
Optimizaremos la detección de colisiones frente a distintas transformaciones.

Recomiento repasar un poco los temas vistos anteriormente, porque aplicaremos varios de ellos en esta ocasión. Sin nada más que decir, ¡empecemos entonces!


Problemas de Detección con BoundingBox

En la entrada anterior, se detectaba colisiones con BoundingBox o BoundingSphere. Vimos que la detección funcionaba correctamente con objetos que tenían la forma exacta a la de un rectángulo o círculo perfecto. Pero también vimos que la detección se volvía inprecisa con objetos que no tenían exactamente esas formas, aunque luego vimos que el uso múltiple de BoundingBox o BoundingSpheres era una posible solución a ese problema.

Pero, ¿qué sucede cuando se modifica la rotación o escala de esos objetos, mientras se detecta colisiones simultáneamente?

Antes de contestar esa pregunta, tenemos que hacer unas pocas modificaciones a nuestra clase Figura: Si lo pensamos bien, sabremos que lo que tienen en común todas las figuras que creamos es una posición, una rotación y una escala (recuérdese que por defecto nuestras figuras tenían un ángulo de 0º (0 rad) y una escala de 0.5f). Y como vimos en la entrada Level 01, la rotación guarda relación con el origen o "punto de ancla" (así lo llamo yo) pues, cuando se rota una figura, se toma ese punto como referencia. En fin, estas 4 características tendrán todas las figuras que crearemos, por lo tanto serán los nuevos atributos de nuestra clase Figura. Agreguemos eso a continuación y corrijamos el programa principal Game1.

En la clase Figura, primero agregamos los nuevos atributos: puntoAncla, angulo y escala. Luego agregamos sus respectivos set & get, modificamos nuestro constructor y al método Draw(), que aplica esos atributos. Las partes modificadas de la clase Figura, deberían quedar más o menos así:

class Figura
    {
        private Texture2D textura;  // Textura que se usará para representar al jugador.
        private Vector2 posicion;   // Contendrá las coordenadas de su posición.
                Vector2 puntoAncla; // Contendrá las coordenadas de su punto de anclaje(u Origen) como referencia para rotación y escala.
        private float velocidadMov, // Representa al valor de la velocidad de su desplazamiento.
                      angulo,       // Guardará el valor del ángulo de inclinación del objeto.
                      escala;       // Guardará el valor de la escala de la Textura (o Sprite).

        public void Inicializar(Texture2D textura, Vector2 posicion, float velocidadMov)
        {
            this.textura = textura;
            this.posicion = posicion;
            this.velocidadMov = velocidadMov;
            this.puntoAncla = Vector2.Zero;
            this.velocidadMov = 5f;
            this.angulo = 0f;
            this.escala = 0.5f;
        }

public void Dibujar(SpriteBatch spriteBatch)
        {
            // Dibujo al objeto.
            spriteBatch.Draw(
                            textura,
                            posicion,
                            null, 
                            Color.White,
                            angulo,         //MathHelper.ToRadians(45)
                            puntoAncla,
                            escala,
                            SpriteEffects.None,
                            0f
                            );          
       }
//============= PUNTO DE ANCLA ========
        public Vector2 GetPuntoAncla()
        {
            return puntoAncla;
        }

        public void SetPuntoAncla(Vector2 puntoAncla)
        {
            this.puntoAncla = puntoAncla;
        }

        // ============ ESCALA ================
        public float GetEscala()
        {
            return escala;
        }

        public void SetEscala(float escala)
        {
            this.escala = escala;
        }

        public void IncreaseEscala(float valor)
        {
            if (this.escala + valor > 0.02f && this.escala + valor < 4.5f)
            this.escala += valor;
        }


        // ============ ANGULO ================
        public float GetAngulo()
        {
            return angulo;
        }

        public void SetAngulo(float angulo)
        {
            this.angulo = angulo;
        }

        public void InreaseAngulo(float valor)
        {
            float val = MathHelper.ToDegrees(valor);
            if (MathHelper.ToDegrees(this.angulo + valor) > 360)
            {
                this.angulo = valor;
            }
            else if (MathHelper.ToDegrees(this.angulo + valor) < 0)
            {
                this.angulo = MathHelper.ToRadians(360) - valor;
            }
            else
                this.angulo += valor;
        }
}

De esta manera, podremos modificar la rotación y escala de nuestras figuras, desde el programa principal Game1. Por ejemplo, podríamos crear un método que pueda rotar las figuras y otro que pueda aumentar o reducir su escala de tamaño, ambas cosas mediante teclas. Apliquemos eso solamente en la figura cuadrada y hagamos que la estrella anaranjada pueda rotar por su cuenta. Entonces, agregamos los siguientes métodos y modificamos el Update():

namespace Prueba01
{
protected override void Update(GameTime gameTime)
        {
            // Allows the game to exit
            if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed)
                this.Exit();

            estadoTeclado = Keyboard.GetState();
            estadoMouse = Mouse.GetState();
            MoverFigura(gameTime);
            RotarFigura();                          // Se gira una Figura dependiendo de los 2 estados anteriores.
            figNeutral.InreaseAngulo(0.005f);   // Rotación de la Figura Neutral.
            EscalarFigura();                        // Se redimensiona el tamaño de una Figura.
            huboColision = DetectarColision(gameTime);

            base.Update(gameTime);
        }

            public void RotarFigura()
        {
            if (estadoTeclado.IsKeyDown(Keys.A))
            {
                jugador.InreaseAngulo(0.02f);
            }

            if (estadoTeclado.IsKeyDown(Keys.D))
            {
                jugador.InreaseAngulo(-0.02f);
            }
        }

        public void EscalarFigura()
        {
            if (estadoTeclado.IsKeyDown(Keys.W))
            {
                jugador.IncreaseEscala(0.01f);
            }

            if (estadoTeclado.IsKeyDown(Keys.S))
            {
                jugador.IncreaseEscala(-0.01f);
            }
        }
}


Como se puede ver, los métodos RotarFigura() y EscalarFigura(), permiten modificar el ángulo de rotación de y el valor de escala del tamaño total de la figura cuadrada, respectivamente. Esto lo logramos mediante las teclas 'A' y 'D' para la rotación y 'W' y 'S' para la escala. Veamos qué sucede si ejecutamos el programa ahora:

Error que ocurre en la Detección de Colisiones Simples con figuras transformadas.

Volviendo a la pregunta que decía "¿qué sucede cuando se modifica la rotación o escala de esos objetos, mientras se detecta colisiones simultáneamente?" ... bueno, acaba de ser contestada. Podemos notar que la detección de colisiones funciona demasiado mal. Lo que está sucediendo en este caso es que, aunque no se vea, los BoundingBox de la figura cuadrada y el de la estrella nunca se tocan, pues no respetan la escala ni mucho menos la rotación de la textura transformada. Este es el enorme problema al aplicar el método Detección Simple de Colisiones por BoundingBox (o BoundingSphere) en figuras transformadas.

Tratar de solucionar esto, cuesta mucho tiempo, muchos cálculos y muchos dolores de cabeza. Pero por suerte, no es imposible. Veremos a continuación un método que solucionará este gran problema.
Pero antes, debo avisar que lamentablemente descartaremos al método que creamos como DetectarColision(). No usaremos BoundingBox ni BoundingSphere, porque no encontré la forma de hacerlos rotar y porque hay otra clase muy similar que nos servirá mucho. Todo esto lo veremos más adelante, en esta Entrada.


Detección de Colisiones a Nivel Pixel o "Pixel Perfect"

Aviso: Todos los métodos que se presentan a continuación, fueron realizados tomando como referencia otros sitios.
Este método de detección es el más preciso de todos, pero así también conlleva a un gran problema de rendimiento cuando se analiza una gran cantidad de objetos y, peor aún, de gran tamaño (en cuanto a pixeles).  Noté también cuando estaba practicando que, si se comete algún error de programación, el consumo de CPU es tanto que el juego se hace demasiado lento... y eso que contaba solamente con 3 objetos. Así que hay que tener sumo cuidado al aplicar este método. Pero por ahora, veamos de qué se trata.

Anteriormente había aconsejado que vieran el sitio web del experto en XNA, Riemer Grootjans, del cual tomé como referencia para aprender este método. El artículo que leí se llama 2D Colission Detection y, sí, está inglés. Pero a continuación voy a explicar de qué se trata más o menos la Detección Pixel Perfect, citando partes de dicho artículo. Veamos entonces:

La Detección de Colisiones a Nivel Pixel consiste básicamente en tomar la textura de un objeto y verificar por cada pixel si algúno de ellos colisiona con algún pixel de la textura de otro objeto. Veamos el ejemplo que propone Riemer:



  
Cada textura contiene pixeles transparentes (representados en la imagen con color verde oscuro) y pixeles no transparentes (representados en color rosado claro, en el cañon, y en rojo oscuro, en el cohete). Se dice que se produce una colisión cuando pixeles no transparentes de 2 texturas distintas se superponen. Riemer ejemplifica esta situación con la siguiente imagen:


Repito, noten que se produce la colisión únicamente con pixeles no transparentes de 2 texturas de objetos distintos. En otras palabras, la colisión no se produce cuando, entre 2 texturas, al menos 1 de los pixeles no es transparente.

También hay que tener en cuenta que los objetos pueden variar su rotación y/o escala. Este método de detección puede adaptarse a esos cambios mediante el uso de matrices. Gracias a las matrices, podemos adecuar la detección según la posición, la rotación y la escala del objeto que deseemos analizar. Por lo tanto, se podrían detectar colisiones en casos como estos:


Riemer dice:
"Cuando una matriz guarda la posición, rotación y escala de una imagen, puedes usar esta matriz para encontrar la coordenada final de la pantalla de cualquier pixel de esa imagen. Si tomamos la inversa de esta matriz, dada cualquier coordenada de pantalla podemos encontrar la coordenada del pixel en la imagen original.
Como primer paso, se multiplica (="transforma") la coordenada del pixel por la matriz de la imagen A. Esto nos dará la coordenada de dónde será renderizado el pixel en la pantalla.
El siguiente paso es lo opuesto: dada la coordenada de la pantalla, esta se multiplica por la inversa de la matriz de la imagen B. Esto nos dará la coordenada en la imagen B original.
 Luego vemos que el pixel en la imagen original A y el pixel en la imagen original B no son transparentes, por lo que ambos colisionan en la pantalla."
¡Es un capo Riemer! =D
A veces me cuesta mucho entender las cosas y aún más cuando están en otro idioma. Así que tuve que leer varias y varias... y muchas varias veces para entenderlo completamente y aplicarlo en el proyecto correctamente. ¡Pero se pudo! Compartiendo lo que pude aprender, a continuación llevemos esto a la práctica:

Para analizar la detección de colisiones de 2 objetos distintos, necesitamos primero 2 matrices de cada uno:
  • Matriz con los datos de cada pixel de la imagen.
  • Matriz de la imagen (con los datos de la posición, rotación y escala).
Primero, necesitamos obtener los datos de cada uno de los pixeles que tiene la textura (Sprite) del objeto que queremos analizar. Para ello agregamos el siguiente método propuesto por Riemer en nuestra clase Figura:

public Color[,] GetMatrizPixeles()
        {
            Color[] colors1D = new Color[(textura.Width) * (textura.Height)];
            textura.GetData(colors1D);
            Color[,] colors2D = new Color[(textura.Width), (textura.Height)];

            for (int x = 0; x < textura.Width; x++)
                for (int y = 0; y < textura.Height; y++)
                    colors2D[x, y] = colors1D[x + y * textura.Width];

            return colors2D; 
        }

De esta manera obtenemos los datos de los pixeles de una imagen guardados en una matriz. Luego debemos obtener la matriz de la imagen, teniendo en cuenta su posición en la pantalla, su rotación y el valor de su escala del tamaño original. Por suerte, al comienzo de esta Entrada, hemos incluído esos datos como nuevos atributos de nuestra clase. Riemer supone cierta situación y dice:
  • 1: "Si queremos renderizar la imagen tal cual es (o: con la transformación de Identidad), se renderizaría con el tamaño original en la esquina superior izquierda de la pantalla."
  • 2: "Primero, la imagen del cohete es movida por lo que su punto correspondiente a su esquina superior izquierda está en la posición especificada como segundo argumento en el método SpriteBatch.Draw()" (en nuestro caso, ubicado dentro del método Dibujar() en la clase Figura).
  • 3: "Luego, todo (por ejemplo) es reducido. Puedes ver que esto incluye tanto la imagen como sus propios ejes X e Y".
  • 4: "Luego, todo (por ejemplo) es rotado. De nuevo, esto incluye a la imagen como a sus propios ejes X e Y".
  • 5: "Finalmente, la imagen resultante es movida (rotada o escalada) según su origen.X (puntoAncla.X en nuestro caso) sobre su eje X, y según su origen.Y (puntoAncla.Y en nuestro caso) sobre su eje Y."

Lo importante de esto es saber que el orden en que se ejecutan las cosas es muy importante, ya que si se intercambian los pasos podría haber errores como el siguiente, donde se intercambia el cuarto paso por el quinto paso:


Lo que sucedió es que las coordenadas del sistema fueron movidos primero, luego esas coordenadas fueron rotadas y terminaron en una posición totalmente diferente. Esto explica por qué el cohete se sale parcialmente de la pantalla.
Teniendo en cuenta esto, agreguemos entonces el siguiente método a nuestra clase Figura, para obtener la matriz de la textura:

public Matrix GetMatrizTextura()
        {
            Matrix matFigura = Matrix.CreateTranslation(-puntoAncla.X, -puntoAncla.Y, 0) *
                                Matrix.CreateRotationZ(angulo) *
                                Matrix.CreateScale(escala) *
                                Matrix.CreateTranslation(posicion.X, posicion.Y, 0);
            return matFigura;
        }


Ya tenemos listos los métodos que retornan los elementos necesarios para la detección Pixel Perfect. Ahora queda el paso final que consiste en analizar y detectar colisión entre 2 objetos. Esto lo haremos desde la clase principal Game1.
Primero, definamos al principio de la clase 3 booleanos (uno para cada objeto) que nos pueda indicar si hubo, o no, colisión (con true o false, respectivamente). Luego añadimos el siguiente método de detección, realizado por Riemer, que pueda modificar el estado de esos booleanos:

 public class Game1 : Microsoft.Xna.Framework.Game
    {
        GraphicsDeviceManager graphics;
        SpriteBatch spriteBatch;

        Figura jugador;                 // Instancia del objeto de clase Figura.
        KeyboardState estadoTeclado;    // Referencia que guardará el último estado del teclado, por cada loop.

        Figura jugador2;
        MouseState estadoMouse;         // Referencia que guardará el último estado del Mouse, por cada loop.

        Figura figNeutral;              // Referencia del tercer objeto para prueba de colisiones. Es inmóvil.

        SpriteFont font;                // Se guardará el tipo de fuente que se usará en los textos.
        
        // Tomarán el valor de 'true' en caso de que exista alguna colisión a nivel pixel.
        bool huboColisionPix;           
        bool huboColisionNJ1Pix;        
        bool huboColisionNJ2Pix;

public bool ColisionPixelPerfectBool(Color[,] pixelesFig1, Matrix matFig1, Color[,] pixelesFig2, Matrix matFig2)
        {
            bool huboColision = false;
            Matrix mat1a2 = matFig1 * Matrix.Invert(matFig2);
            int ancho1 = pixelesFig1.GetLength(0);
            int alto1 = pixelesFig1.GetLength(1);
            int ancho2 = pixelesFig2.GetLength(0);
            int alto2 = pixelesFig2.GetLength(1);

            for (int x1 = 0; x1 < ancho1; x1++)
            {
                for (int y1 = 0; y1 < alto1; y1++)
                {
                    Vector2 pos1 = new Vector2(x1, y1);
                    Vector2 pos2 = Vector2.Transform(pos1, mat1a2);
                    int x2 = (int)pos2.X;
                    int y2 = (int)pos2.Y;

                    if ((x2 >= 0) && (x2 < ancho2))
                    {
                        if ((y2 >= 0) && (y2 < alto2))
                        {
                            if (pixelesFig1[x1, y1].A > 0)
                            {
                                if (pixelesFig2[x2, y2].A > 0)
                                {
                                    huboColision = true;
                                    return huboColision;
                                }
                            }
                        }
                    }
                }
            }
            return huboColision;
        }

   }


Finalmente, agregaremos un método que permita invocar al ColisionPixelPerfect() según la cantidad de figuras que usamos en el juego. En este caso invocaremos a dicho método 3 veces para analizar las colisiones entre los 3 objetos que hemos creado. Luego aplicamos todo esto dentro del método Update() y finalizamos modificando el método Draw(), en donde deberíamos cambiar el texto que indica si hubo colisión, según el resultado del análisis. Tendría que quedar entonces algo así:
        
protected override void Update(GameTime gameTime)
        {
            // Allows the game to exit
            if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed)
                this.Exit();

            estadoTeclado = Keyboard.GetState();    // Se verifica el estado del Teclado (inactivo / tecla pulsada)
            estadoMouse = Mouse.GetState();         // Se verifica el estado del Mouse (inactivo / tecla pulsada)
            MoverFigura();                          // Se mueve una Figura dependiendo de los 2 estados anteriores.
            RotarFigura();                          // Se gira una Figura dependiendo de los 2 estados anteriores.
            figNeutral.InreaseAngulo(0.005f);   // Rotación de la Figura Neutral.
            EscalarFigura();                        // Se redimensiona el tamaño de una Figura.
            ControlarColisiones();                  // Checkea si hay algún tipo de colisión entre los objetos creados.
            
            base.Update(gameTime);
        }

public void ControlarColisiones()
        {        
                huboColisionPix = ColisionPixelPerfectBool(jugador.GetMatrizPixeles(), jugador.GetMatrizTextura(),
                                                            jugador2.GetMatrizPixeles(), jugador2.GetMatrizTextura()
                                                            );
         
                huboColisionNJ1Pix = ColisionPixelPerfectBool(jugador.GetMatrizPixeles(), jugador.GetMatrizTextura(),
                                                            figNeutral.GetMatrizPixeles(), figNeutral.GetMatrizTextura()
                                                            );
           
                huboColisionNJ2Pix = ColisionPixelPerfectBool(jugador2.GetMatrizPixeles(), jugador2.GetMatrizTextura(),
                                                            figNeutral.GetMatrizPixeles(), figNeutral.GetMatrizTextura()
                                                            );          
        }


        protected override void Draw(GameTime gameTime)
        {
            string anchoPantalla = Convert.ToString(GraphicsDevice.Viewport.Width);
            string altoPantalla = Convert.ToString(GraphicsDevice.Viewport.Height);
            string textoColision;
            GraphicsDevice.Clear(Color.CornflowerBlue);
          
            if (huboColisionPix || huboColisionNJ1Pix || huboColisionNJ2Pix)
                {
                    GraphicsDevice.Clear(Color.DarkSlateBlue);
                    textoColision = "Si";
                }       
   
            else
            {
                GraphicsDevice.Clear(Color.CornflowerBlue);
                textoColision = "No";
            }
            
            // ============ Comienza dibujado ===================
            spriteBatch.Begin();
            
           // ... 
           // ... y aquí va el resto del código original.
           // ... 
        }


Y con esto terminamos con el método de Detección de Colisiones a Nivel Pixel. Ejecutemos el código y veamos qué sucede:

Demostración de la Detección de Colisiones a Nivel Pixel en funcionamiento.

Como podrán notar, el método funciona perfectamente y es muy preciso. Este método es la solución para aquellos objetos con texturas de formas complejas que pudieramos llegar a utilizar alguna vez.
Otra cosa importante es que, no sé si ustedes también, pero noté que inmediatamente luego de arrancar el programa, este funcionó lentamente por unos segundos y luego comenzó a correr normalmente (aunque en ciertas ocasiones se notaba una pequeña lentitud). Seguramente tiene que ver con el costo del que mencioné más arriba, sobre el consumo de CPU que requiere este método. Esa es la gran desventaja de la detección Pixel Perfect. El rendimiento empeora dependiendo de la cantidad de objetos a analizar y sus respectivos tamaños: mientras más cantidades de objetos y mientras más grandes sean, peor será el rendimiento.

Y bueno, no todo lo que brilla es oro. Pero, según lo que leí en algunos sitios, existen muchas maneras de "alivianar" el consumo de CPU, sin tener que dejar de emplear este excelente método. Uno de ellos es combinando la Detección Simple (que vimos en la entrada anterior) con Pixel Perfect. Elegí esta combinación porque me parecía bastante sencilla y además es de mucha ayuda. Lo veremos a continuación.



Detección de Colisiones combinada: Rectangle + Pixel Perfect

Aviso: Busqué la forma de aplicar Pixel Perfect, combinados con BoundingBox o BoundingSphere, pero no encontré nada. Si alguna vez llego a encontrar la forma de aplicarlo, estaré compartiendo eso en alguna futura Entrada. En su lugar, usaremos la clase Rectangle.
Esta combinación de métodos permite mejorar el rendimiento, conservando la precisión de la detección de colisión Pixel Perfect. Sólamente consiste en utilizar una clase muy parecida al BoundingBox, llamada Rectangle. Como se puede deducir de su nombre, un objeto de la clase Rectangle no es más que un rectángulo. La diferencia es que un BoundingBox está pensado para utilizarse en 3D (por sus coordenadas x,y,z), mientras que Rectangle nos resulta útil en 2D. Además, podemos transformarlo según la posición, rotación y escala de la textura de un objeto (al igual que las matrices). Es por esta ventaja que usaremos Rectangle para detectar primero una Colisión Simple, de bajo consumo de CPU, para que luego proceda a la detección Pixel Perfect sólo en caso de que ocurra la situación anterior. De esta manera, los cálculos no serán tan 'pesados'.

Pero antes, agreguemos un par de métodos que servirán para observar el comportamiento del origen de la textura de nuestro objeto. Sabemos que por defecto, el origen, al que llamaremos en mi caso como "puntoAncla", es una instancia de la clase Vector2 con las coordenadas X e Y ubicadas en la esquina superior izquierda (0,0) de la textura de una figura. La rotación tomará como referencia ese punto, tal como vimos en la entrada Level 01. Esta situación se dá con la estrella anaranjada, que contínuamente está rotando según su punto de Ancla (origen).
Agreguemos ahora un par de métodos en la clase Figura que permita cambiar las coordenadas del puntoAncla (a parte del set correspondiente) y ubicarla en el centro de la textura o en la ubicación por defecto (0,0) esquina superior izquierda.
Agregamos entonces los siguientes métodos en la clase Figura:
        
public void CentrarOrigen()
        {
            puntoAncla = new Vector2((textura.Width/2) , (textura.Height/2));
        }

 public void ReestablecerOrigen()
        {
            puntoAncla = Vector2.Zero;
        }


Y llamamos a estos métodos en la clase principal Game1, por ejemplo dentro del método EscalarFigura(). No son relevantes estos métodos, son sólamente para analizar situaciones particulares. Hagamos que afecte únicamente a la figura cuadrada:
        
public void EscalarFigura()
        {
            if (estadoTeclado.IsKeyDown(Keys.W))
            {
                jugador.IncreaseEscala(0.01f);
            }

            if (estadoTeclado.IsKeyDown(Keys.S))
            {
                jugador.IncreaseEscala(-0.01f);
            }

            if (estadoTeclado.IsKeyDown(Keys.F))
            {
                jugador.CentrarOrigen();
            }

            if (estadoTeclado.IsKeyDown(Keys.G))
            {
                jugador.ReestablecerOrigen();
            }
        }

Así, si presionamos la tecla 'G' el puntoAncla tendrá el valor (0,0). En cambio, si presionamos la tecla 'F', el puntoAncla tendrá sus coordenadas en el centro de la textura del objeto (en este caso, el centro de la figura cuadrada de 200x200 pixeles, tiene las coordenadas [100,100]). Pueden comprobarlo ejecutando el código, el método Pixel Perfect también estará funcionando.

Ahora sí, regresemos con el tema sobre el uso de Rectangle para la detección de colisiones.

Necesitamos crear un Rectangle que se adapte según la posición, la rotación y la escala de nuestra figura. Para ello, agregamos el siguiente método en la clase Figura, que retornará un Rectangle transformado de acuerdo con los 3 elementos mencionados:
        
        public Rectangle CrearRectanguloLim()
        {
            // Creamos un Rectangle teniendo en cuenta el ancho y alto de la textura.
            Rectangle rectangulo = new Rectangle(0, 0, textura.Width, textura.Height);

            // Necesitamos adaptar ese Rectangulo según la rotación, escala y posición del objeto.
            Matrix transformada = GetMatrizTextura();

            // Se toma el valor de cada esquina del Rectangulo creado.
            Vector2 esquinaSupIzq = new Vector2(rectangulo.Left, rectangulo.Top);
            Vector2 esquinaSupDer = new Vector2(rectangulo.Right, rectangulo.Top);
            Vector2 esquinaInfIzq = new Vector2(rectangulo.Left, rectangulo.Bottom);
            Vector2 esquinaInfDer = new Vector2(rectangulo.Right, rectangulo.Bottom);

            // Se transforman esas 4 esquinas según la figura.
            Vector2.Transform(ref esquinaSupIzq, ref transformada, out esquinaSupIzq);
            Vector2.Transform(ref esquinaSupDer, ref transformada, out esquinaSupDer);
            Vector2.Transform(ref esquinaInfIzq, ref transformada, out esquinaInfIzq);
            Vector2.Transform(ref esquinaInfDer, ref transformada, out esquinaInfDer);

            // Se busca el mínimo y máximo punto del rectangulo.
            Vector2 min = Vector2.Min(Vector2.Min(esquinaSupIzq, esquinaSupDer),
                                      Vector2.Min(esquinaInfIzq, esquinaInfDer));
            Vector2 max = Vector2.Max(Vector2.Max(esquinaSupIzq, esquinaSupDer),
                                                  Vector2.Max(esquinaInfIzq, esquinaInfDer));

            // Retorna un rectangulo transformado
            return new Rectangle((int)min.X, (int)min.Y, (int)(max.X - min.X), (int)(max.Y - min.Y));
            }

Así es como se obtiene un Rectangle que se pueda adaptar a las transformaciones de nuestra figura. Con esto, podremos mejorar el rendimiento de nuestra Detección de Colisiones. ¿Cómo?

La clase Rectangle tiene el mismo método que la clase BoundingBox (o BoundingSphere), que conocimos bajo el nombre de Intersects(). Ya sabemos cómo funciona, recuerden que en la entrada anterior lo expliqué. La idea es alivianar el uso de CPU, verificando primero si hubo alguna intersección entre 2 Rectangles y luego, en caso afirmativo, proseguir con la detección a nivel pixel. Tengan en cuenta que usando el método de detección con Rectangle se realiza un sólo control, mientras que con Pixel Perfect se realizan muchos más controles: piensen que se verifica cada uno de los pixeles de una textura con cada uno de los pixeles de otra figura... son muchísimos controles. Esta es la clave de por qué el uso combinado de Detección de Colisiones por Rectangle y Pixel Perfect mejora el rendimiento y mantiene precisión.
Volquemos en el proyecto lo dicho anteriormente. Para ello, necesitamos definir 3 booleanos más (de nuevo, uno para cada objeto) al principio de la clase Game1, que nos sirvan para indicar si hubo o no hubo alguna intersección entre los Rectangles de nuestras figuras. Recordemos también, de inicializar dichas variables en el método Initialize(). Luego, modificamos nuestro método ControlarColisiones(), de acuerdo a lo que habíamos planteado. Y por último, modificamos el método Draw() para que el texto de la pantalla indique si hubo o no colisión. Todas estas modificaciones deberían quedar de esta manera:
        
 public class Game1 : Microsoft.Xna.Framework.Game
    {
        GraphicsDeviceManager graphics;
        SpriteBatch spriteBatch;

        Figura jugador;                 // Instancia del objeto de clase Figura.
        KeyboardState estadoTeclado;    // Referencia que guardará el último estado del teclado, por cada loop.

        Figura jugador2;
        MouseState estadoMouse;         // Referencia que guardará el último estado del Mouse, por cada loop.

        Figura figNeutral;              // Referencia del tercer objeto para prueba de colisiones. Es inmóvil.

        SpriteFont font;                // Se guardará el tipo de fuente que se usará en los textos.
        
        // Tomarán el valor de 'true' en caso de que exista alguna colisión a nivel pixel.
        bool huboColisionPix;           
        bool huboColisionNJ1Pix;        
        bool huboColisionNJ2Pix;

        // Tomarán el valor de 'true' en caso de que exista alguna intersección de Rectangle.
        bool huboColisionRect;
        bool huboColisionNJ1Rect;
        bool huboColisionNJ2Rect;

        protected override void Initialize()
        {
            jugador = new Figura();
            jugador2 = new Figura();
            figNeutral = new Figura();
            huboColisionPix = false;
            huboColisionNJ1Pix = false;
            huboColisionNJ2Pix = false;
            huboColisionRect = false;
            huboColisionNJ1Rect = false;
            huboColisionNJ2Rect = false;
            base.Initialize();
        }

        public void ControlarColisiones()
        {
            if (jugador.CrearRectanguloLim().Intersects(jugador2.CrearRectanguloLim()))
            {
                huboColisionRect = true;
                huboColisionPix = ColisionPixelPerfectBool(jugador.GetMatrizPixeles(), jugador.GetMatrizTextura(),
                                                            jugador2.GetMatrizPixeles(), jugador2.GetMatrizTextura()
                                                            );
            }
            else
                huboColisionRect = false;


            if (jugador.CrearRectanguloLim().Intersects(figNeutral.CrearRectanguloLim()))
            {
                huboColisionNJ1Rect = true;
                huboColisionNJ1Pix = ColisionPixelPerfectBool(jugador.GetMatrizPixeles(), jugador.GetMatrizTextura(),
                                                            figNeutral.GetMatrizPixeles(), figNeutral.GetMatrizTextura()
                                                            );
            }
            else
                huboColisionNJ1Rect = false;


            if (jugador2.CrearRectanguloLim().Intersects(figNeutral.CrearRectanguloLim()))
            {
                huboColisionNJ2Rect = true;
                huboColisionNJ2Pix = ColisionPixelPerfectBool(jugador2.GetMatrizPixeles(), jugador2.GetMatrizTextura(),
                                                            figNeutral.GetMatrizPixeles(), figNeutral.GetMatrizTextura()
                                                            );
            }
            else
                huboColisionNJ2Rect = false;
        }

        protected override void Draw(GameTime gameTime)
        {
            string anchoPantalla = Convert.ToString(GraphicsDevice.Viewport.Width);
            string altoPantalla = Convert.ToString(GraphicsDevice.Viewport.Height);
            string textoColision;
            GraphicsDevice.Clear(Color.CornflowerBlue);

            
            if (huboColisionRect || huboColisionNJ1Rect || huboColisionNJ2Rect)
            {
                GraphicsDevice.Clear(Color.BlueViolet);
                textoColision = "Casi";
            
                if (huboColisionPix || huboColisionNJ1Pix || huboColisionNJ2Pix)
                {
                    GraphicsDevice.Clear(Color.DarkSlateBlue);
                    textoColision = "Si";
                }
            }
            else
            {
                GraphicsDevice.Clear(Color.CornflowerBlue);
                textoColision = "No";
            }
            
            // ============ Comienza dibujado ===================
            spriteBatch.Begin();
            //...
            //... y aquí va todo lo que teníamos anteriormente en el método.
            //...
            }
   }


Ahora si ejecutamos el programa, podrán notar que funciona todo lo que estuvimos haciendo. 

¡Excelente! Pero...¿cómo sabemos si realmente funciona?

Para terminar esta entrada, añadiremos algunos métodos que me ayudaron mucho mientras practicaba. Necesité que se pudiera visualizar algunos datos de la figura cuadrada, como su posición, su ángulo de rotación (en grados y radianes), la coordenada (x,y) de su punto de Ancla (u origen) y las coordenadas de cada esquina de su respectivo Rectangle. Lo único que hice fue añadir textos que puedan leerse en la pantalla y que puedan modificarse según las transformaciones de la figura. Todo esto lo hice en el método Draw(). Agreguemos eso:
        
       protected override void Draw(GameTime gameTime)
        {
            string anchoPantalla = Convert.ToString(GraphicsDevice.Viewport.Width);
            string altoPantalla = Convert.ToString(GraphicsDevice.Viewport.Height);
            string textoColision;
            GraphicsDevice.Clear(Color.CornflowerBlue);

            
            if (huboColisionRect || huboColisionNJ1Rect || huboColisionNJ2Rect)
            {
                GraphicsDevice.Clear(Color.BlueViolet);
                textoColision = "Casi";
            
                if (huboColisionPix || huboColisionNJ1Pix || huboColisionNJ2Pix)
                {
                    GraphicsDevice.Clear(Color.DarkSlateBlue);
                    textoColision = "Si";
                }
            }
            else
            {
                GraphicsDevice.Clear(Color.CornflowerBlue);
                textoColision = "No";
            }
            
            // ============ Comienza dibujado ===================
            spriteBatch.Begin();

            // Se dibuja a cada Jugador
            jugador.Dibujar(spriteBatch);
            jugador2.Dibujar(spriteBatch);
            figNeutral.Dibujar(spriteBatch);

            // Se dibuja el texto donde informa el valor de Ancho de la pantalla
            spriteBatch.DrawString(font,
                                "Ancho: " + anchoPantalla,
                                new Vector2(GraphicsDevice.Viewport.TitleSafeArea.X,
                                            GraphicsDevice.Viewport.TitleSafeArea.Y),
                                Color.White,
                                0f,
                                new Vector2(0f,0f),
                                0.8f,
                                SpriteEffects.None,0);

            // Se dibuja el texto donde informa el valor de Alto de la pantalla
            spriteBatch.DrawString(font,
                                    "Alto: " + altoPantalla,
                                    new Vector2(GraphicsDevice.Viewport.TitleSafeArea.X+20,
                                                GraphicsDevice.Viewport.TitleSafeArea.Y+50),
                                    Color.OrangeRed,
                                    MathHelper.ToRadians(90),
                                    new Vector2(0,0),
                                    0.8f,
                                    SpriteEffects.None,
                                    0f);

            // Se dibuja un texto que indica si hubo alguna colisión o no.
            spriteBatch.DrawString(font,
                        "Colision: " + textoColision,
                        new Vector2(GraphicsDevice.Viewport.TitleSafeArea.X,
                                    GraphicsDevice.Viewport.TitleSafeArea.Bottom - 25),
                        Color.Orange,
                        0f,
                        new Vector2(0f, 0f),
                        0.8f,
                        SpriteEffects.None,
                        0f);

            // Se dibuja un texto donde indica las coordenadas (x,y) del jugador1
            spriteBatch.DrawString(font,
                        "Jugador 1: (" + jugador.GetPosicionX() + ", " + jugador.GetPosicionY() + ")",
                        new Vector2(GraphicsDevice.Viewport.TitleSafeArea.Center.X + 100,
                                    GraphicsDevice.Viewport.TitleSafeArea.Y),
                        Color.Aquamarine,
                        0f,
                        new Vector2(0f, 0f),
                        0.8f,
                        SpriteEffects.None,
                        0f);

            // Se dibuja un texto donde indica las coordenadas (x,y) del jugador2
            spriteBatch.DrawString(font,
                        "Jugador 2: (" + jugador2.GetPosicionX() + ", " + jugador2.GetPosicionY() + ")",
                        new Vector2(GraphicsDevice.Viewport.TitleSafeArea.Center.X + 100,
                                    GraphicsDevice.Viewport.TitleSafeArea.Y+25),
                        Color.Aquamarine,
                        0f,
                        new Vector2(0f, 0f),
                        0.8f,
                        SpriteEffects.None,
                        0f);

            // Se dibuja un texto que indica el ángulo (en radiandes) del jugador1
            spriteBatch.DrawString(font,
                        "Angulo J1 [rad]: (" + jugador.GetAngulo() + ")",
                        new Vector2(GraphicsDevice.Viewport.TitleSafeArea.Center.X + 100,
                                    GraphicsDevice.Viewport.TitleSafeArea.Y+ 50),
                        Color.Aquamarine,
                        0f,
                        new Vector2(0f, 0f),
                        0.8f,
                        SpriteEffects.None,
                        0f);

            // Se dibuja un texto que indica el ángulo (en grados) del jugador1
            spriteBatch.DrawString(font,
                        "Angulo J1 [gra]: (" + MathHelper.ToDegrees(jugador.GetAngulo()) + ")",
                        new Vector2(GraphicsDevice.Viewport.TitleSafeArea.Center.X + 100,
                                    GraphicsDevice.Viewport.TitleSafeArea.Y + 75),
                        Color.Aquamarine,
                        0f,
                        new Vector2(0f, 0f),
                        0.8f,
                        SpriteEffects.None,
                        0f);

            // Se dibuja un texto donde indica las coordenadas del punto de ancla
            // (u "Origen") (x,y) del jugador1
            spriteBatch.DrawString(font,
                        "Punto de Ancla J1: (" + jugador.GetPuntoAncla().X + ", " + jugador.GetPuntoAncla().Y + ")",
                        new Vector2(GraphicsDevice.Viewport.TitleSafeArea.Center.X + 100,
                                    GraphicsDevice.Viewport.TitleSafeArea.Y + 150),
                        Color.Aquamarine,
                        0f,
                        new Vector2(0f, 0f),
                        0.8f,
                        SpriteEffects.None,
                        0f);

            // Se dibuja un texto donde indica las coordenadas (x,y) del
            // Rectángulo utilizado como BoundinBox (o Cuadro Delimitador) del jugador1
            spriteBatch.DrawString(font,
                        "Coord (X,Y) RectangleJ1: (" + jugador.CrearRectanguloLim().X + ", " + jugador.CrearRectanguloLim().Y + ")",
                        new Vector2(GraphicsDevice.Viewport.TitleSafeArea.Center.X + 100,
                                    GraphicsDevice.Viewport.TitleSafeArea.Y + 175),
                        Color.Aquamarine,
                        0f,
                        new Vector2(0f, 0f),
                        0.8f,
                        SpriteEffects.None,
                        0f);
            // Se dibuja un texto donde indica el valor (ancho,alto) del
            // Rectángulo utilizado como BoundinBox (o Cuadro Delimitador) del jugador1
            spriteBatch.DrawString(font,
                        "Ancho y Alto RectangleJ1: (" + jugador.CrearRectanguloLim().Width + ", " + jugador.CrearRectanguloLim().Height + ")",
                        new Vector2(GraphicsDevice.Viewport.TitleSafeArea.Center.X + 100,
                                    GraphicsDevice.Viewport.TitleSafeArea.Y + 200),
                        Color.Aquamarine,
                        0f,
                        new Vector2(0f, 0f),
                        0.8f,
                        SpriteEffects.None,
                        0f);
           
            spriteBatch.End();
            // ============ Termina dibujado ===================         

            base.Draw(gameTime);
        }

De esta manera, cuando ejecutemos el programa, vamos a poder visualizar algunos datos importantes sobre nuestra figura cuadrada. Se vería algo como esto:

Detección por Rectangle y Pixel Perfect, con algunos datos sobre los objetos.

...mmm... sin embargo, a decir verdad, eso no parece ser suficiente. Estaría muy bueno que los Rectangles que creamos se pudieran visualizar de alguna forma, ¿verdad?. ¡Buenas noticias! Mientras investigaba, me encontré con que eso es posible. Así que para concluir esta entrada, haremos que los Rectangles se puedan ver para observar cómo funcionan. Para ello, necesitaremos añadir a nuestro proyecto una nueva textura de tan solo 1x1 pixel. ¿Extraño? Ya veremos para qué sirve.

Entonces, comencemos añadiendo un nuevo atributo a nuestra clase Figura. Será un Texture2D cuyo modificador será static, pues no pertenecerá únicamente a un objeto, sino a todos los objetos de la misma clase. Llamaremos a este atributo como bound ("límite" en inglés). En este campo guardaremos la textura de 1x1 pixel. Pueden crearla ustedes mismos en Paint y luego agregarla al proyecto. Me hubiera gustado mostrarla aquí abajo, pero es tan pequeña que no puedo observarla jajaja.
Luego crearemos un método sobrecargado a Dibujar(), el cual tendrá 2 parámetros más: uno que indique el color en que se quiera ver el Rectangle, y otro que indique el valor de transparencia del Rectangle.
Añadimos lo mencionado, dentro de la clase Figura:
        
class Figura
    {
        private Texture2D textura;  // Textura que se usará para representar al jugador.
        private Vector2 posicion;   // Contendrá las coordenadas de su posición.
                Vector2 puntoAncla; // Contendrá las coordenadas de su punto de anclaje(u Origen) como referencia para rotación y escala.
        private float velocidadMov, // Representa al valor de la velocidad de su desplazamiento.
                      angulo,       // Guardará el valor del ángulo de inclinación del objeto.
                      escala;       // Guardará el valor de la escala de la Textura (o Sprite).

        public static Texture2D bounds; // Utilizamos esta textura para dibujar los BoundingBox.

        public void Dibujar(SpriteBatch spriteBatch, Color colorDelCuadro, byte transparencia)
        {
            // Se elije aplica Transparencia al color indicado.
            colorDelCuadro.A = transparencia;

            // Utilizando lo anterior, dibujo un Cuadro Delimitador.
            spriteBatch.Draw(bounds, CrearRectanguloLim(), colorDelCuadro);

            // Dibujo al objeto.
            spriteBatch.Draw(
                            textura,
                            posicion,
                            null,
                            Color.White,
                            angulo,         //MathHelper.ToRadians(45)
                            puntoAncla,
                            escala,
                            SpriteEffects.None,
                            0f
                            );
        }
    }

Con esto, logramos que se pueda visualizar nuestro Rectangle en la pantalla. Lo que hace el método spriteBatch.Draw() de la línea 18, es tomar una textura ('bounds' en nuestro caso) y rellenar con esa textura un Rectangle (que obviamente es el Rectangle que usamos para detectar colisiones) con el color y la transparencia que le pasamos como parámetro.
Lo que finalmente queda por hacer es invocar al método sobrecargado que creamos recién dentro del método Draw() de la clase principal Game1. Terminemos con esto reemplazando lo viejo por lo nuevo:
        
        protected override void Draw(GameTime gameTime)
        {
            //...
            //...
            // ============ Comienza dibujado ===================
            spriteBatch.Begin();

            // Se dibuja a cada Jugador
            jugador.Dibujar(spriteBatch, Color.DarkTurquoise, 175);
            jugador2.Dibujar(spriteBatch, Color.DarkTurquoise, 175);
            figNeutral.Dibujar(spriteBatch, Color.DarkTurquoise, 175);
            
            //...
            //...
        }

¡Y eso es todo! Ahora, si ejecutamos nuestro programa podremos ver a nuestro Rectangle de la siguiente forma:

Visualización de las transformaciones de los Rectangle, según las características de sus respectivos objetos.

Podremos notar entonces que el Rectangle se adapta a la posición, rotación y escala de su respectivo objeto. Cuando 2 Rectangles se intersectan, el texto de la esquina inferior izquierda de la pantalla indica que "casi" hubo una colisión. Entonces, al cumplirse la detección por Rectangle, comienza luego la detección Pixel Perfect y, si esta también se cumple, el texto indicará finalmente que "" hubo colisión. Realmente es muy útil que podamos visualizar la manera en que se llevan a cabo las detecciones de colisiones. ¡Espero que les sirva este método!
 
Conclusión
Aviso: Tengo mucho sueño...
Uff... ¡Por fin! Esta es la Entrada más difícil que me tocó armar hasta ahora. Costó muchos dolores de cabeza, pero me alegra demasiado haber terminado esta parte. ¡Y voy por más!

Aún no estoy seguro de lo que vendrá en la siguiente Entrada, pero estoy pensando en desarrollar un juego bastante básico, donde se aplique todo lo que vimos hasta ahora y quizás otras cosas nuevas más. Lo importante es que con esta entrada, en mi opinión, ya avanzamos lo suficiente. De aquí se pueden hacer muchas cosas, lo importante es animarse y, por lo menos, intentar.

Pueden descargar el código fuente de este proyecto en el siguiente botón. Les comento que hay agregadas más cosas que no muestro en esta Entrada, por el simple hecho de que no eran importantes. Por ejemplo, los métodos tienen sus respectivos comentarios, hay otra textura que pueden intercambiar por la de otro objeto, hay más métodos sobrecargados, etc. ¡Espero que les sirva tanto como a mí! 



Ahora me toca descansar un poco. ¡Hasta la próxima!


¡Sigue adelante, siempre!

7 comentarios:

Buaah!! Interesantisimo Dante y unas explicaciones magnificas como siempre! Muchas gracias por todo el trabajo expuesto.
Hoy he empezado este tutorial y veo que me llevará mucho tiempo entender todo esto, pero que gran logro, colisiones a nivel pixel!!
¿Harás más tutoriales?

Saludos y mucha suerte desde España!

Hola Juan, me alegra mucho que todo te haya sido útil! La idea es no dejar morir este blog jaja. Asi que sí, voy a seguir con tutoriales en la medida que pueda. Cada vez tengo menos tiempo para dedicarle a este espacio, pero no quiero dejarlo.

Así que paciencia, que ya voy a estar armando más cosas. En estos momentos estoy tratando de terminar un juego para seguir practicando. Cuando lo termine voy a compartirlo.

Saludos y muchas gracias nuevamente!

Hola Dante. Gran trabajo. Pero ¿Que hay de las colisiones con PNG irregulares? Llevo 2 semanas buscando y aún no he encontrado nada que funcione. ¿No te animas?

public Vector2 GetPuntoAncla()
{
return puntoAncla;
}

public void SetPuntoAncla(Vector2 puntoAncla)
{
this.puntoAncla = puntoAncla;
}

chico en xna podemos usar propiedades completas o propfull donde en lugar de hacer estos dos metodos se podria sacar escribiendo: propfull + tabulacion + tabulacion: resultado:

private Vector2 puntoAncla;

public Vector2 PuntoAncla
{
get { return puntoAncla; }
set { puntoAncla = value; }
}
En cuanto a lo de colisiones perfectas, excelente. No lo he podido aplicar a mi juego porque se generan mas de mil objetos en la pantalla, pero tengo otro método que es bueno y se puede aplicar en cuanto esas tres propiedades que mencionas.

Espero también hacer un blog como tu, ya que el IDE xna es muy bueno y mas cuando se utiliza completamente orientado a objetos, en mi caso si hiciera un foro me enfocaría mas en cálculos matemáticos y a realizar movimientos muy padres de la IA como lo son las curvas (epicicloides, hipocicloides, espirales, cardioides, senoides, cosenoides, etc), estas con sus derivadas para el angulo de rotacion y claro explicar algunas clases interesantes, como lo son: el Escenario, el Menu, HUD, IA, Proyectiles, Usuario, Multimedia y la mayoria de las clases heredando de otra de vital importancia y abstracta Sprite

Saludos Joan Pescador! Con lo que hice en este tutorial no deberías tener problemas con las imágenes irregulares. Avísame si has logrado algo!

Hola Rigo! La verdad que sí, es muy bueno. El único lenguaje que sabía en aquel tiempo era C#, por eso empecé con XNA. Pero no me arrepiento, te da mucha libertad. Actualemente, aprendí varios lenguajes más y probé otros frameworks, como LibGDX, SFML. Probé también varios Engines, como Unity 3D, Game Maker. Me resulta difícil decirte cuál es el mejor, porque cada uno tiene sus pros y contras. Igualmente, te recomiendo que explores un poco y te quedes con el que te resulte más sencillo.

Apoyo mucho tu enfoque. Yo no me llevo muy bien con las matemáticas, pero se ve que tú sí jaja. Espero ver tu propio blog algún día.

Con respecto al mío, como verás lo tengo un poco abandonado y dudo que retome XNA de nuevo por ahora. Espero poder darle un poco de vida este 2014 con otros temas. Aún está pendiente el "juego simple" que nunca terminé jaja. Si sientes ganas de aportar algo a este blog, eres muy bienvenido de hacerlo cuando quieras! La idea es compartir aunque sea un poco de todo.

¡Saludos Rigo!

Publicar un comentario