Colisiones y reflexiones

Hola. En la serie de artículos sobre el evento Draw() del DrawingArea vimos la forma de dibujar objetos y animarlos dentro de la superficie de dibujo, pero se nos plantean dos nuevas cuestiones relacionadas con el movimiento. Por una parte, si un objeto se mueve en línea recta lo que puede ocurrir  es que dicho objeto desaparezca para siempre al sobrepasar los límites de nuestro “lienzo” y por otro lado quizás lo que queremos es que ese objeto rebote contra algún otro cuerpo o los bordes del lienzo o reaparezca de nuevo en pantalla. Comencemos por el último caso.

Conseguir que un objeto no se escape es sencillo usando aritmética modular. El efecto que conseguiremos es que el objeto vuelva a aparecer en el lienzo justo por la parte opuesta por donde desapareció con la misma velocidad y trayectoria, una técnica muy usada en los antiguos juegos de arcade, también llamada Screen Wrapping ó Wraparound. Lo único que debemos hacer es actualizar la posición del objeto de manera que cuando sea mayor o menor de los límites X e Y recalcular la posición para que aparezca por el lado opuesto siendo ésta el resto o residuo de la división entre la posición y la anchura o altura del lienzo. De forma que podemos escribir pseudocódigo como este para obtener ese efecto:

posiciónX = posicionX modulo ANCHO_LIENZO
posiciónY = posicionY modulo ALTO_LIENZO

Para probar su efecto, podemos usar el código que vimos en la entrada anterior  donde poníamos en movimiento un círculo rojo. El código quedaría como sigue:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
' Gambas class file
 
Private posicion As New Integer[]
Private velocidad As New Integer[]
 
Public Sub _new()
    ' inicializamos las variables globales con valores
    ' enteros dentro de un array que representa los puntos x, y
    ' que representan un punto en las coordenadas del DA
 
    posicion = [20, 20]
    velocidad = [1, 1]
 
End
 
Public Sub DrawingArea1_Draw()
 
    Draw.FillColor = Color.Red
    Draw.FillStyle = Fill.Solid
    Draw.Circle(posicion[0], posicion[1], 20)
    ' sumamos el vector velocidad a la posición en cada momento
    ' primero el componente X (que es el primer elemento del array)
    posicion[0] += velocidad[0]
    ' y luego el componente Y que es el segundo elemento del array
    posicion[1] += velocidad[1]
    ' actualizamos la posición si el circulo de sale del área de dibujo
    posicion[0] = posicion[0] Mod DrawingArea1.Width
    posicion[1] = posicion[1] Mod DrawingArea1.Height
 
End
 
Public Sub Timer1_Timer()
    ' el timer tiene su propiedad Delay = 16
    ' lo que equivale a 60 fps
 
    DrawingArea1.Refresh
 
End

Bien, si corremos este código veremos cómo el círculo siempre está presente, una técnica muy útil para dibujar una nave o meteoritos que vuelan por todas partes…

A continuación veremos cómo hacer que un objeto rebote cuando alcanza el borde de nuestro lienzo (cuando digo “lienzo” me refiero lógicamente al DrawingArea, que es donde pintamos a modo de lienzo. No lo he dicho antes por su obviedad, pero me pareció oportuno hacerlo ahora antes de continuar complicando la cosa). Para ello no hay más remedio que recordar algunos fundamentos matemáticos bastante sencillos, como el Teorema de Pitágoras que vamos a usar para calcular la distancia entre dos puntos en un plano.

Distancia entre dos puntos

Consideremos dos puntos p y cuya posición representamos descompuesta en coordenadas X e Y como (p.X, p.Y) y (q.X, q.Y). Representamos dichos puntos en un plano y calculamos la distancia D:

point_distEntonces tenemos que la distancia entre p y q es la hipotenusa de un triángulo rectángulo que forman los vectores (p.X, q.X) y (p.Y, q.Y). Aplicando el teorema de Pitágoras:

\dpi{120} \fn_cm D = \sqrt{(p.X - q.X)^{2} + (p.Y - q.Y)^{2}}

Una vez obtenida la distancia entre dos puntos, podemos saber si están lo suficientemente cerca como para considerar que han colisionado. Por ejemplo, en el caso del círculo es sencillo deducir que el punto central, el origen del círculo es uno de los puntos y el otro puede ser cualquier punto del borde del drawing area. Para saber si el círculo (o la “pelota”) ha tocado el borde de la pantalla sólo tendremos que sumar o restar el radio del círculo en el eje de coordenadas que corresponda.

Si la pelota se acerca al borde derecho tendremos que sumar el radio al punto que representa el centro del círculo para obtener el punto más cercano al borde y si consideramos el borde izquierdo, haremos lo contrario, restar el radio. Lo mismo es aplicable para los bordes superior e inferior. Si la colisión fuese entre dos círculos bastaría con calcular la distancia entre ambos centros y restar la suma de los radios.

Pero vamos a verlo con otra imagen para comprenderlo mejor:

colision

Aplicando lo explicado hasta ahora podemos calcular si el círculo colisiona con la pared izquierda si p.X <= r y con la pared derecha si p.X >= (ancho -1) – r. Lo mismo se aplica para los casos con colisiones en las paredes superior e inferior, sustituyendo X por Y. En el caso de querer comprobar si ambos círculos colisionan entre sí, comprobaremos que la distancia entre ellos es menor que la suma de los radios de cada uno. Para comprobar colisiones entre objetos complejos hay varios algoritmos, pero resulta mucho más sencillo usar una circunferencia de un radio suficiente para rodear un objeto irregular.

Si recordamos que el origen de las coordenadas en un ordenador es la esquina superior izquierda, incrementando X hacia la derecha e Y hacia abajo, es muy fácil crear código en Gambas a partir de las fórmulas, que nos devuelvan si se ha producido o no una colisión:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
' usamos una constante para el radio del circulo
Private Const RADIO_1 As Integer
Private Const RADIO_2 As Integer
 
' función que devuelve distancia entre dos puntos
' (usamos la clase nativa Point para los puntos)
Private Function dist(punto1 As Point, punto2 As Point) As Float
    Return Sqr((punto1.X - punto2.X) ^ 2 + (punto1.Y - punto2.Y) ^ 2)
End
 
' comprobamos colisiones con los bordes del lienzo
If p.X <= RADIO_1 Then 'borde izquierdo
    ' rebotar
Else If p.X >= (DrawingArea1.Width - 1) - RADIO_1 Then ' borde derecho
    ' rebotar
Else If p.Y <= RADIO_1 Then ' borde superior
    'rebotar
Else If p.Y >= (DrawingArea1.Height - 1) - RADIO_1 Then ' borde inferior
    'rebotar
Endif
 
' si queremos comprobar la colisión entre dos objetos p y q
' considerando que ambos son circulares
If dist(p, q) - (RADIO_1 + RADIO_2) <= 0 Then
    ' rebotar
Endif

Vectores y movimiento

En anteriores entradas habíamos aprendido que la velocidad es también un vector y es éste el que sumamos a la posición del objeto que movemos. Recordemos las fórmulas, usando la clase Point en este caso:

p.X = p.X  +  a  *  vel.X
p.X = p.Y  +  a  *  vel.Y

Hemos introducido una nueva variable “a” que será un multiplicador de la velocidad, que puede ser constante o no. Podemos aumentar la velocidad multiplicando por valores float superiores a 1 o disminuir la velocidad multiplicando por valores inferiores a 1. Si el multiplicador es 0, entonces la velocidad será 0 y el objeto se detendrá. ¿Pero qué pasa si usamos valores negativos? Pues que el vector de velocidad será inverso y el movimiento también. Esta deducción nos lleva a…

Reflexiones

Si consideramos el siguiente gráfico donde se expresa un punto P en movimiento llega al borde derecho con una velocidad V podemos ver que se produce una reflexión en el eje X que mantiene la misma magnitud tanto en el eje X como en el Y, pero que es contraria en el eje X. Es decir, cambia el sentido porque también cambia el signo del componente X del vector V.

reflexion

 

De este modo tan sencillo podemos calcular las reflexiones en las paredes de nuestro drawing area, teniendo en cuenta este mismo esquema para reflexiones en la pared superior e inferior, donde el componente del vector V que cambiará de signo será el Y.

Así podemos afirmar:

vel.X *= -1 ' para las paredes laterales
vel.Y *= -1 ' para las paredes sup. e inferior

Vamos a ver todo lo expuesto hasta ahora modificando el ejemplo inicial del círculo en movimiento para que éste rebote en todas las paredes y en otro círculo adicional.

Ejemplo práctico en Gambas3

He aquí un ejemplo de todo lo explicado. Tengo que reconocer que el desempeño del DrawingArea es bastante malo, ya que se queda parado en ocasiones sin saber a qué se debe exactamente. Otro de los inconvenientes es que la classe Draw sólo acepta valores enteros, por lo que las animaciones no son suaves y no hay variaciones significativas en las trayectorias de los elementos. Veremos más adelante cómo se comportan las animaciones usando la clase Paint, que admite valores más exactos de tipo Float y además dibuja con suavizado antialiasing.

Una última reflexión personal: el control Drawing Area no creo que esté diseñado para hacer juegos o animaciones muy costosas, pero servirá para explicar lo básico y desde luego para crear otros controles, como por ejemplo algún tipo de display, vúmetros o algún tipo de instrumento de medida que se nos ocurra, barras de progreso personalizables y un largo etcétera sólo limitado por nuestra imaginación. Si queremos dedicarnos a juegos que requieran muchas animaciones, creo que lo mejor será aprender a usar SDL…

Bueno, finalmente aquí os dejo el código:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
' Gambas class file
 
Private posicion1 As Point
Private velocidad1 As Point
Private posicion2 As Point
Private velocidad2 As Point
Private Const RADIO_1 As Integer = 25
Private Const RADIO_2 As Integer = 15
Private Const COEFICIENTE As Float = 2
 
Public Sub _new()
    ' inicializamos las variables globales con valores aleatorios
 
    Randomize
    posicion1 = Point(Rnd(RADIO_1, DrawingArea1.Width - RADIO_1),
        Rnd(RADIO_1, DrawingArea1.Height - RADIO_1))
 
    posicion2 = Point(Rnd(RADIO_1, DrawingArea1.Width - RADIO_2),
        Rnd(RADIO_1, DrawingArea1.Height - RADIO_2))
 
    velocidad1 = Point(Rnd(-3, 3), Rnd(-3, 3))
    velocidad2 = Point(Rnd(-3, 3), Rnd(-3, 3))
 
End
 
Public Sub DrawingArea1_Draw()
 
    Draw.FillColor = Color.Red
    Draw.FillStyle = Fill.Solid
    Draw.Circle(posicion1.X, posicion1.Y, RADIO_1)
    Draw.FillColor = Color.Orange
    Draw.Circle(posicion2.X, posicion2.Y, RADIO_2)
    ' actualizamos posicion del objeto 1
    posicion1.X += velocidad1.X * COEFICIENTE
    posicion1.Y += velocidad1.Y * COEFICIENTE
    ' actualizamos posicion del objeto 2
    posicion2.X += velocidad2.X * COEFICIENTE
    posicion2.Y += velocidad2.Y * COEFICIENTE
 
    ' comprobamos colisiones con los bordes del lienzo
    velocidad1 = colisionParedes(DrawingArea1, posicion1, RADIO_1, velocidad1)
    velocidad2 = colisionParedes(DrawingArea1, posicion2, RADIO_2, velocidad2)
 
    ' comprobamos colision entre los dos objetos
    velocidad1 = colisionEntre2objetos(posicion1, RADIO_1, velocidad1, posicion2, RADIO_2)
    velocidad2 = colisionEntre2objetos(posicion2, RADIO_2, velocidad2, posicion1, RADIO_1)
 
End
 
Public Sub Timer1_Timer()
    ' el timer tiene su propiedad Delay = 16
    ' lo que equivale a 60 fps
 
    DrawingArea1.Refresh
 
End
 
Private Function dist(punto1 As Point, punto2 As Point) As Float
    ' función que devuelve distancia entre dos puntos
    ' (usamos la clase nativa Point para los puntos)
 
    Return Sqr((punto1.X - punto2.X) ^ 2 + (punto1.Y - punto2.Y) ^ 2)
 
End
 
Private Function colisionParedes(canvas As DrawingArea, obj As Point, r As Integer, vel As Point) As Point
    ' comprobar colisión con bordes del lienzo
 
    If obj.X &lt;= r Then 'borde izquierdo         
        Return Point(vel.X * -1, vel.Y)     
    Else If obj.X &gt;= (canvas.Width - 1) - r Then ' borde derecho
        Return Point(vel.X * -1, vel.Y)
    Else If obj.Y &lt;= r Then ' borde superior         
        Return Point(vel.X, vel.Y * -1)     
    Else If obj.Y &gt;= (canvas.Height - 1) - r Then ' borde inferior
        Return Point(vel.X, vel.Y * -1)
    Else
        Return vel
    Endif
 
End
 
Private Function colisionEntre2objetos(obj1 As Point, r1 As Integer, vel As Point, obj2 As Point, r2 As Integer) As Point
 
    If dist(obj1, obj2) - (r1 + r2) &lt;= 0 Then
        Return Point(vel.x * -1, vel.Y * -1)
    Else
        Return vel
    Endif
 
End

Puedes descargar el proyecto de gambas3 completo:

colisiones.tar
Título: colisiones.tar (165 clicks)
Leyenda:
Filename: colisiones-tar.gz
Size: 6 KB

 

Análisis final

Después de analizar un poco el código, he visto que realmente la clase Point no sirve, porque solo maneja valores enteros. De manera que he vuelto a usar arrays de tipo Float[] para almacenar las coordenadas y la velocidad como números Float. También he cambiado a dibujar con la clase Paint, en vez de Draw por las mismas razones, y parece que el ejemplo gana un poco en suavidad, aunque no desaparecen algunos “glitches” y paradas bruscas por motivos que aun desconozco.

Aquí os dejo el nuevo proyecto con las modificaciones, por si es de vuestro interés.

colisiones2
Título: colisiones2.tar (183 clicks)
Leyenda: colisiones2
Filename: colisiones2-tar.gz
Size: 8 KB


Licencia Creative Commons

Publicado en Programación Etiquetado con: , , , , , , , ,
3 Comentarios en “Colisiones y reflexiones
  1. jsbsan dice:

    Para el tema de detectar colisiones, yo he usado la clase rect ( http://gambasdoc.org/help/comp/gb.qt4/rect?es&v3 ).
    Tiene varios métodos muy útiles, por ejemplo el de Intersection.
    Lo use en el programa “guerra de estrellas”, para saber cuando dos naves colisionaban…

    Saludos

  2. Shell dice:

    Muy buen articulo y muy buenos ejemplo. Gracias por crearlos.

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

*