Física en 2D, el tiro parabólico

Escrito por el 8 febrero, 2012

Después de más de un mes de vacaciones, ha llegado la hora de crear un nuevo tutorial. Aunque tengo en el tintero unas cuantas lecciones sobre sonido, inteligencia artificial y matemáticas aplicadas,  he preferido hacer antes un jueguecito muy simple como excusa para empezar a ver algo de física básica, y ya ques estamos, un nuevo tipo de detección de colisiones entre círculos y rectángulos, con el que podrás enriquecer tus propios juegos.

En esta ocasión sigo utilizando la programación orientada a objetos que vimos en el tutorial sobre juegos en javascript, y prácticamente el esqueleto de la aplicación es el mismo: Un constructor, algunos métodos getter y setter para recuperar y establecer atributos, una clase principal con la función loop que funcionará como bucle del juego, y que será llamada contínuamente con un intervalo, y otras funciones auxiliares que sirven para notificar al juego algún cambio de estado, como son las detecciones de colisiones que afecten al estado del juego, y para obtener las entradas de datos con el que hacer funcionar el juego.

Aquí teneis un enlace para ver el juego en movimiento, y aquí teneis el ZIP para descargarlo y probarlo en local.

Introducción

El juego es una versión muy básica del popular Worms, que seguramente conozcan la mayoría de los afortunados lectores de este blog. Aunque por supuesto, no me basé en aquél, si no en una de las primeras versiones del ese tipo de juego, los llamados artillery games. En concreto, mi modelo era el GORILLAS.BAS, programado en MS-DOS QBASIC para los ordenadores 486 del año de la polca.

Aquí podemos ver un video de aquél mítico juego de código abierto, con el que muchos empezaron a hacer sus primeros pinitos en el mundo de la programación:

He simplificado bastante el juego, eliminando la posibilidad de destruir el escenario, para que sea más accesible a programadores noveles.

El objetivo de este tutorial no es aprender a programar el juego desde cero, pues para eso ya escribí una serie de tutoriales que podeis leer en este enlace. En lugar de ello, me centraré en cosas más concretas, como son la implementación de la física involucrada en un tiro parabólico, la detección de colisiones entre círculos y rectángulos, y de algunos otros detalles de menor importancia pero que no estará de más conocer. Es probable que el juego arrastre algún bug, no lo he revisado a fondo, pero, si algun lector se topa con alguno y lo soluciona, que deje un comentario.

Las clases necesarias

Como en cualquier desarrollo utilizando programación orientada a objetos, lo primero que hay que hacer es un análisis de requisitos. Con él, nos será más fácil identificar los objetos que necesitamos para crear el mundo, y cómo repartir entre ellos las tareas que hay que realizar. Para un juego pequeño, como es nuestro caso, dicho análisis se puede hacer mentalmente. Todo es cuestión de experiencia.

Básicamente el suelo está formado por rectángulos de diferentes tamaños y colores; los jugadores también son rectángulos pequeñitos, y las balas, para mostrar un nuevo algoritmo de detección de colisiones, las haremos circulares. Lo correcto hubiera sido crear unas clases RECTANGULO y CIRCULO con los atributos y métodos básicos para manejar esas figuras geométricas, como son las funciones para crearlas, situarlas el alguna posición, darles color, dibujarlas en la pantalla, detectarles colisiones, etc. Y después crear dos clases, NaveJugador y Obstáculo, que heredarían de la clase Rectángulo añadiéndole nuevas funciones y atributos más específicos. Y también una tercera clase, Bola, que heredaría de círculo, y que tambíen extienda sus funciones como algún método que la mueva, algunos atributos con la velocidad que tiene, etc. Pero como soy un vago, he decidido meter todo directamente en las clases Recángulo y Bola, sin herencia ni nada de nada.

Para hacer un poco más cómoda la gestión de coordenada, para esto sí, he creado una clase Punto2D con sus getter y setter básicos para sus atributos X e Y. Aquí la tenemos:

function Punto2D(x,y){
    this.x=x;
    this.y=y;

    /*Getter y Setter*/
    this.get=function(variable){
        switch(variable)
        {
            case "x":
                return this.x;
            case "y":
                return this.y;
        }
    };
    this.set=function(variable, valor){
        switch(variable)
        {
            case "x":
                this.x=valor;
                break;
            case "y":
                this.y=valor;
        }
    };
}

Antes de seguir, quiero destacar una novedad extremadamente interesante. Como podeis ver, ya no hago un getter y un setter para cada atributo (por ejemplo, getX y setX), si no que he optado por una función más genérica get, pasándole como argumento el atributo que quiero recuperar. Y para el set, le paso dos argumentos: el primero es el atributo que quiero cambiar el valor, y el segundo es, como podrías esperar,  el valor. Ésto es posible en JavaScript porque como ya he dicho mil veces, con este lenguage no se comprueban los tipos, puedes meter cualquier cosa en cualquier variable, lo que quiere decir que puedo enviarle en el argumento valor cualquier tipo de dato. Además, el switch de javascript permite hacer casos con Strings (cadenas de texto). Así que utilizando ambas peculiaridades, vualá, un getter y setter concentrado. Ya no más ristras kilométricas de getter y setters desperdigados por el código. Pero ojo, quizás esta solución sea más ineficiente en tiempo de ejecución que tener los métodos getter y setter por separado, a la antigua usanza. Pero para la mayoría de las aplicaciones que vamos a hacer, ese retardo que se produce por hacer las comparaciones con strings es insignificante.

Otra cosa a destacar es que no tiene método constructor. Como esta clase no será heredada por ninguna otra, he decidido hacerla más parecida a Java, así que si le paso directamente los parámetros en la definición de la clase (primera línea), cuando declare sus atributos, puedo asignarles directamente su valor. Luego, cuando la utilice desde cualquier otra clase, podré hacer un:

    var punto=new Punto2d(5,5);

Lo que sin lugar a dudas recuerda muchísimo a Java.

Y por supuesto, nos queda la clase principal del juego, que en este tutorial he llamado Mundo. Ella se encargará de crear (oh sorpresa) el mundo. Y también situar a los jugadores, gestionar las notificaciones que se produzcan (entrada de datos, colisiones, etc) y llevar la información y puntuación del juego. La estudiaremos a su debido tiempo. Quiero volver a recordar que no la detallaré línea a línea, mucha parte del código utilizado ya lo expliqué en los tutoriales anteriores, por lo que aquí no tiene sentido repetir lo mismo.

Detección de colisiones entre rectángulos y círculos

Como podemos observar si corremos el juego, los rectángulos no se mueven nunca. Básicamente, una vez dibujados, se quedarán ahí hasta que uno de los jugadores destruya al otro. Así que su clase es muy sencilla, no requiere de apenas ninguna explicación:

function Rectangulo(p,ancho,alto,color){
    this.pos=p; //Vertice superior izquierdo
    this.ancho=ancho;
    this.alto=alto;
    this.color=color;

    /*Getter y Setter*/
    this.get=function(variable){
        switch(variable)
        {
            case "pos":
                return this.pos;
            case "ancho":
                return this.ancho;
            case "alto":
                return this.alto;
            case "x":
                return this.pos.get("x");
            case "y":
                return this.pos.get("y");
            case "color":
                return this.color;
        }
    };

    this.dibujar=function(contexto){
        contexto.fillStyle = this.color;  
        contexto.fillRect(this.pos.get("x"), this.pos.get("y"), this.ancho, this.alto);
    };
}

Como se puede ver, estamos utilizando la misma solución que usamos con Punto2D: no hay constructor explícito, y el get está concentrado. Lo poco que hace esta clase es pintar el rectángulo en el canvas en la posición especificada y con un color y un tamaño adecuados.

Y como habrás supuesto después de ver el código de Rectángulo, será la clase Bola la que detecte las colisiones con los rectángulos (algo totalmente lógico, pues el círculo es la única entidad que puede moverse por la pantalla, y por lo tanto, chocar). El método que realiza las colisiones, es el siguiente. No voy a mostrar el resto de la clase todavía, porque contiene también el cálculo del tiro parabólico, que explicaré en la siguiente sección. De momento basta con saber que la clase Bola tiene unos atributos radio  y centro autoexplicativos, y que el argumento rec es el rectángulo con el que comprobar si nuestro círculo está colisionando. Math.abs otiene el valor absoluto del parámetro que le pasamos, y Math.pow eleva la base, que es su primer parámetro, a la potencia indicada en el segundo parámetro:

    this.colisiona=function(rec){
        if (this.destruida) return false;
        var cdx=Math.abs(this.centro.get("x")-rec.get("x")-rec.get("ancho")/2);
        var cdy=Math.abs(this.centro.get("y")-rec.get("y")-rec.get("alto")/2);

        if(cdx>(rec.get("ancho")/2+this.radio)) return false;
        if(cdy>(rec.get("alto")/2+this.radio)) return false;

        if(cdx<=(rec.get("ancho")/2)) return true;
        if(cdy<=(rec.get("alto")/2)) return true;

        var distancia=Math.pow(cdx-rec.get("ancho")/2,2)+Math.pow(cdy-rec.get("alto")/2,2);
        return (distancia<=Math.pow(this.radio,2));
    };

Y ahora viene lo difícil, explicar cuándo un rectángulo y un círculo colisionan. Para empezar, hallamos cdx y cdy, que son las distancias en el eje X y en el eje Y que separan los centros de ambas figuras. Ahora, comprobaremos las distintas combinaciones de posiciones que pueden haber entre círculos y cuadrados.

El primero, y más lógico, es comprobas si los centros de ambas figuras están demasiado lejos como para poder colisionar. Es decir, si cdx es mayor que la mitad del ancho del rectángulo mas el radio del círculo, es imposible que las figuras estén colisionando. Podemos verlo en la siguiente imagen:

De forma análoga, hacemos la misma comprobación para cdy, pero utilizando ahora su alto:

A continuación, sabiendo que las dos condiciones de antes son falsas (si no lo fueran, habríamos salido con alguno de los return), sabemos que las figuras están bastante cerca, y hay bastantes papeletas de que estén chocando. Hay 3 posibilidades: Que el centro del círculo esté justo debajo o encima del rectángulo, que esté juston a la derecha o a la izquierda, y por último, que esté mas allá de una de sus diagonales (es decir, abajo a la izquierda, abajo a la derecha, arriba a la izquierda o arriba a la derecha). Si se cumplen alguna de las dos primeras posibilidades, se está produciendo una colisión. Para la tercera, hay que hacer un cálculo adicional.

Bien. Para comprobar las dos primeras posibilidades hay una forma inmediata. Basta con mirar si dos teóricas rectas perpendiculares, una al eje X y otra al eje Y, que pasan por el centro del círculo, cortan al rectángulo. Es decir, si cdx es menor o igual que la mitad del ancho, o si cdy es menor o igual que la mitad del alto. Si alguna condición es cierta (o las dos, como en la imagen, lo que significa que el centro del círculo está dentro del rectángulo), se está produciendo una colisión.

Por último, y sabiendo que las rectas perpendiculares a los ejes que pasan por centro del círculo no cortan el rectángulo, estamos en el caso de que el centro del círculo está mas allá de alguna de las diagonales del rectángulo. Para comprobar si colisionan, trazamos una recta imaginaria entre el centro del rectángulo y el centro del círculo. Para que haya colisión, el trozo de dicha recta que queda fuera del rectángulo tiene que ser menor o igual que el radio del círculo. Como vemos en la imagen, el cálculo es simplemente obtener una hipotenusa al cuadrado aplicando el milenario teorema de pitágoras.

Y con esto, ya sabemos detectar las colisiones entre rectángulos y círculos.

Un poco de física: El tiro parabólico

Y ahora viene la parte más interesante del asunto: Vamos a simular un mundo donde existe gravedad y viento, aunque ignorando el rozamiento del objeto con el aire. Desempolva tus libros de física de la ESO, y busca aquél maravilloso apartado del movimiento parabólico. Dejad de gritar y correr en círculos con las manos en la cabeza, no es tan difícil como a primera vista parece.

Para el tiro parabólico no importa la masa, la forma ni el tamaño del objeto lanzado, sólo su velocidad inicial y el ángulo con el que empieza el movimiento. La velocidad V y el ángulo θ es en realidad un vector (matemático), así que se puede descomponer en Vx y Vy, que es la velocidad que tiene ese vector en los ejes X e Y, simplemente mutiplicando la velocidad (el módulo, o longitud, del vector) con el seno y coseno del ángulo:

  • Vx = V cos θ
  • Vy = V sen θ

Con estos datos, ya podemos calcular la posición de las coordenadas X e Y en cada instante, sabiendo cuál era la posición inicial ( Xi y Yi ) con la siguiente fórmula:

  • X = Xi+Vxt
  • Y = Yi+Vy t + ½ gt2

Siendo t el número de segundos transcurridos desde el inicio del disparo, y g la constante de gravitacion a utilizar, (por ejemplo en la Tierra es de 9.8  m/s2). Si buscaste tu libro de física, verás que también hay otras formulillas para hallar la altura máxima del disparo y la distancia final, para obtenerlas de forma inmediata, pero para nuestro juego, no son necesarias.

Y ahora ya estamos en condiciones de ver la clase Bola por partes (omitiré el método colision, que ya vimos en la sección anterior):

function Bola(mundo,radio,punto,color){

    this.mundo=mundo;
    this.radio=radio;
    this.centro=punto;
    this.ini=new Punto2D(punto.get("x"),punto.get("y"));
    this.color=color;
    this.vx=0;
    this.vy=0;
    this.tiempo=0;

    this.destruida=false;

Con mundo tenemos un puntero al juego para notificarle las colisiones, y el resto de argumentos sirven para situarlo en una posición inicial, con un radio y un color determinado.

En el atributo ini guardamos una copia de la posición inicial, útil para calcular el tiro parabólico, como vimos en las fórmulas anteriores. Y repito, es una copia, si por ejemplo hubiéramos hecho un “this.ini=punto;” funcionaría mal, pues estaríamos pasándole un puntero, asi que this.centro y this.ini estarían apuntando a la misma instancia de la clase Punto2D.

Y en la variable tiempo, vamos almacenando el tiempo transcurrido desde que se inició el lanzamiento. En ella radica la principal diferencia entre la forma de mover a la entidad que estamos utilizando ahora con respecto lo que hacíamos en el juego Space Invaders. La entidad bola tiene un movimiento complejo, por lo que no es posible calcular de forma fácil el movimiento relativo que tiene que hacer la bola desde la posición que ocupa en ese momento y según el tiempo transcurrido desde el último movimiento. En vez de eso, lo que hacemos es calcular la posición con las fórmulas anteriores segun la posición inicial y el tiempo total transcurrido, por ello debemos ir acumulándolo. El culpable de ello es que estamos usando una aceleración ( la gravedad g), que requiere saber cuánto tiempo estamos acelerando para obtener un resultado correcto.

Pero antes de ver el método mover, veámos como se llenan vx y vy:

    this.aplicarFuerza=function (velocidad,angulo){
        var radianes=(angulo / 180) * Math.PI;
        this.vx=velocidad*Math.cos(radianes);
        this.vy=-velocidad*Math.sin(radianes);
    };

Comovemos, sólo estamos aplicando el primer conjunto de fórmulas que puse en la teoría. Nada difícil de entender. Lo único, es que las funciones de seno y coseno en JavaScript se realizan con radianes, mientras que el ángulo que le estamos pasando está en grados. Por lo que tenemos que hacer la conversión previa.

    this.mover=function (delta){
        if (this.destruida) return;
        this.tiempo+=delta*mundo.escalaTiempo;
        this.centro.set("x",this.ini.get("x")+this.vx*this.tiempo);
        this.centro.set("x",this.ini.get("x")+this.vx*this.tiempo+this.mundo.get("viento")/2*this.tiempo*this.tiempo);
        this.centro.set("y",this.ini.get("y")+this.vy*this.tiempo+this.mundo.get("gravedad")/2*this.tiempo*this.tiempo);

        if(this.centro.get("x")>this.mundo.get("anchura")+this.radio*4 || this.centro.get("y")>this.mundo.get("altura") || this.centro.get("x")<-this.radio*4){
            this.mundo.eliminarBola(this);
        }
    };

Y aquí tenemos el método mover, donde implementamos las formulillas que puse en la segunda parte de la parte teórica. La única diferencia es que para incrementar la dificultad del juego, también le he añadido la aceleración que produce el viento, pero como podemos ver, no es más que aplicar la misma fórmula que usamos para el eje Y del tiro parabólico, pero esta vez aplicada al eje X con los valores Vx y viento.

Por último, y no menos importante, el método mover también se encarga de notificar al juego que elimine la bola (y con ello, cambie el turno de los jugadores) cuando salga holgadamente por los lados derecho o izquierdo de la pantalla, o por la parte inferior.

    this.dibujar=function(contexto){
        if (this.destruida) return;
        contexto.beginPath();
        contexto.arc(this.centro.get("x"), this.centro.get("y"), this.radio, 0, 2 * Math.PI, false);
        contexto.fillStyle = this.color;
        contexto.fill();
    };

    this.colisiona=function(rec){
        ...
    };
}

Por último, el método dibujar, sólo pinta un círculo con el color adecuado mediante las funciones de dibujo que tiene el contexto gráfico 2D del CANVAS. Podemos conocer más sobre su uso por ejemplo en éste enlace.

La clase principal del juego

Y por fin, llegamos a la clase principal del juego. Como ya dije al principio, su esqueleto básicamente es el mismo que el que utilicé en el tutorial del Space Invaders, así que lo comentaré muy rápidamente, deteniémdome sólo en los detalles verdaderamente interesantes.A brocha gorda, lo que haremos sera meter los obstáculos en una lista de obstáculos llamada rectangulos, los jugadores en otra lista (de dos posiciones), y las bolas en una tercera lista. ¿Las bolas en una lista? ¡Si sólo hay una! Sí, pero cuando programaba el juego quería ver cientos de bolas disparándose y cayendo, por lo que tuve que crear la lista en donde ir almacenándolas. Luego me dió palo cargarme la lista para sustituirla por una variable normal y corriente, y así se quedó. Tampoco pasa nada.

function Mundo(){
    this.escalaTiempo=5;    //El tiempo transcurrirá X veces más rápido

    this.canvas;
    this.contexto;

    this.radioBola=5;        //El radio de la bola
    this.bolas=[];            //Las bolas que hay volando
    this.elimBolas=[];        //Las bolas que hay que eliminar en la siguiente vuelta del bucle

    this.minRec=5;            //El número mínimo de obstáculos que pintar en el escenario
    this.maxRec=10;            //El número máximo de obstáculos
    this.rangoAltura=0.5;     //Altura max obstaculos en relacion al heigth del canvas
    this.rectangulos=[];    //Lista con los obstaculos

    this.ladoJugador=20;    //Lado del cuadrado que forma al jugador
    this.jugadores=[];        //Lista de jugadores (habrá 2)

    this.gravedad=9.8;        //Gravedad del mundo
    this.maxViento=2;        //Máxima fuerza absoluta del viento
    this.viento=0;            //Viento actual
    this.turnoViento=8;        //Número de turnos a transcurrir para cambiar el viento
    this.tiempoTranscurrido;    
    this.funcionando=false;

    this.idBoton;        
    this.idAngulo;
    this.idFuerza;
    this.idLimpiar;

    this.puntuacion0=0;
    this.puntuacion1=0;

    this.numTurno=0;        //Número de turnos jugados en la partida actual
    this.turno=0;            //Jugador actual que tiene que disparar

    this.constructor=function(idCanvas,boton,angulo,fuerza,limpiar){
        //Propiedades del canvas
        this.canvas=document.getElementById(idCanvas);
        this.canvas.width=1000;
        this.canvas.height=600;
        this.canvas.style.backgroundColor="black";
        this.contexto=this.canvas.getContext('2d');
        //Formulario de recogida de datos
        this.idAngulo=document.getElementById(angulo);
        this.idFuerza=document.getElementById(fuerza);
        this.idLimpiar=document.getElementById(limpiar);
        this.idBoton=document.getElementById(boton);
        var self=this;
        this.idBoton.onclick=function(){
            self.disparar(self.idAngulo.value,self.idFuerza.value);
            self.idBoton.disabled=true;
            return false;
        };

        this.reiniciar();
    };

Como atributos declaro varias variables donde almacenar la configuración del juego (número de obstáculos, gravedad, escala de tiempo para hacer de que la bola se mueva más rápido, etc). Así pueden ser más facilmente modificables para jugar con distintos valores y ver los resultados que producen. Para constructor, dado que tiene muchos argumentos y es un poco complejo, he optado por retormar el viejo estilo de la declaración explícita. Como siempre, se encarga de iniciar el contexto gráfico, iniciar algunos valores básicos, y como novedad, ahora también se encargará de detectar la introducción de datos por parte del usuario. Si recuerdas de los tutoriales anteriores, en el Space Invaders la detección de eventos del teclado se llevaba a cabo con un código en INDEX.HTML, y donde a su vez se llamaban a los métodos apropiados de la clase Juego. Ahora es la clase juego (Mundo), la que directamente detectará los eventos. En particular, para pasarle información al juego, utilizaremos dos <input> del tipo text (casillas de texto) y un botón.

Lo interesante es la variable self, que hace referencia al objeto Mundo que está ejecutando el constructor. ¿Por qué guardamos el this en una variable local? Pues porque dentro de la función que le declaramos al onClick del botón, el this hará referencia al botón, y no al objeto de la clase Mundo. Sin embargo, la variable recién creada self si la tiene accesible, asi que la usaremos para notificar al juego de que realice un disparo con los datos que están en los <input>. Y para evitar que el usuario pueda volver a presionar el botón mientras se realiza el disparo, lo desactivamos. También hubiera sido válido un “this.disabled=true;” en vez de “self.idBoton.disabled=true;“.

    this.reiniciar=function(){
        this.bolas=[];
        this.elimBolas=[];
        this.rectangulos=[];
        this.jugadores=[];

        this.contexto.clearRect(0,0,this.canvas.width,this.canvas.height);
        this.viento=Math.round((Math.random()*this.maxViento*2-this.maxViento)*10)/10;
        this.numTurno=0;
        this.generarMundo();
    };

    this.generarMundo=function(){
        //Obtenemos un número de obstaculos al azar
        var numRec=Math.round(Math.random()*(this.maxRec-this.minRec))+this.minRec;
        //Obtemos la anchura exacta de cada obstaculo (a partir del width del canvas)
        var anchura=this.canvas.width/numRec;

        //Creamos cada rectángulo de obstáculo con los datos adecuados
        var rec,altura;
        for (var i=0;i<numRec;i++)
        {
            altura=Math.round(Math.random()*this.canvas.height*this.rangoAltura)+10;
            rec=new Rectangulo(new Punto2D(i*anchura,this.canvas.height-altura),anchura,altura,this.randomColor());
            this.rectangulos.push(rec);
        }
        this.posicionarJugadores();
    };
    this.posicionarJugadores=function(){
        var nRec=this.rectangulos.length-1;
        var pos1=0;
        var pos2=0;
        while(pos1>=pos2)
        {
            pos1=Math.round(Math.random()*nRec);
            pos2=Math.round(Math.random()*nRec);
        }
        this.jugadores.push(new Rectangulo(new Punto2D(this.rectangulos[pos1].get("x")+this.rectangulos[pos1].get("ancho")/2-this.ladoJugador/2,this.rectangulos[pos1].get("y")-this.ladoJugador),this.ladoJugador,this.ladoJugador,"blue"));
        this.jugadores.push(new Rectangulo(new Punto2D(this.rectangulos[pos2].get("x")+this.rectangulos[pos2].get("ancho")/2-this.ladoJugador/2,this.rectangulos[pos2].get("y")-this.ladoJugador),this.ladoJugador,this.ladoJugador,"red"));
    };

El método de reinicio, resetea algunas variables básicas a los valores adecuados para empezar una escenario (sin perder la puntuación de los jugadores), generando nuevos obstáculos y posicionando a los jugadores en algun lugar del mapa. GenerarMundo lo que hace es calcular al azar el número de obstáculos y sus alturas con los que formar el suelo. Con algunos de los atributos (que vendrían a ser constantes) que declaré al principio podemos decirle el número mínimo y máximo de rectángulos que debe generar, todos con la misma anchura, repartiéndose el ancho del canvas al completo. Para la altura, utilizamos para cada obstáculo otro valor al azar de un rango entre 0 y un porcentaje, determinado en otra constante, de la altura final que tiene el canvas. El método posicionarJugadores situa a los jugadores sobre los obstáculos, en posiciones aleatorias, aunque respetando dos normas: Los jugadores siempre ocuparán el centro de la anchura del rectángulo donde se dibuje, y el jugador azul, que es el primero, estará siempre a la izquierda del jugador rojo.

Para ello, obtiene la altura del obstáculo donde debe dibujarse y se coloca teniendo en cuenta su propio tamaño. Math.random proporciona números decimales aleatorios (o al menos lo que intenta) entre 0 y 1, y Math.round hace el redondeo para convertir un número decimal en otro el entero más próximo. Usados adecuadamente, podemos generar cualquier tipo de rango numérico, tanto de decimales como de enteros.

    this.get=function(clave){
        switch(clave)
        {
            case "gravedad":
                return this.gravedad;
            case "viento":
                return this.viento;
            case "anchura":
                return this.canvas.width;
            case "altura":
                return this.canvas.height;
        }
    };
    //Creamos un color RGB al azar (util al crear los obstaculos)
    this.randomColor=function(){
        return "rgb("+Math.floor(Math.random()*255)+","+Math.floor(Math.random()*255)+","+Math.floor(Math.random()*255)+")";
    }

Con el getter seguimos la filosofía que seguí desde el principio de este tutorial. Cuando creo los rectángulos de los obstáculos, necesito indicarles un color, y para hacerlo más colorido y dinámico, lo genero al azar con el método randomColor, aprovechando que las funcioens de coloreado del CANVAS también funcionan con la paleta RGB.

Ahora, con el único motivo de mostrar una función matemática nueva, usamos un Math.floor, que es el redondeo por defecto. Es decir, simplemente elimina los decimales y se queda con su parte entera. Y ya que estamos, quedaría el Math.ceil, que como algunos habrán supuesto, hace una aproximación por exceso.

this.disparar=function(angulo,fuerza){
        var poX,posY;
        if(this.turno==0)
        {
            posX=this.jugadores[0].get("x")+this.jugadores[0].get("ancho");
        }
        else
        {
            posX=this.jugadores[1].get("x")-this.radioBola;
            angulo=180-angulo;
        }
        posY=this.jugadores[this.turno].get("y")-this.radioBola;

        var bola=new Bola(this,this.radioBola,new Punto2D(posX,posY),this.jugadores[this.turno].get("color"));
        bola.aplicarFuerza(fuerza,angulo);
        this.bolas.push(bola);
    };
    this.eliminarBola=function(bola){
        this.elimBolas.push(bola);
        this.cambiarTurno();
    };
    this.cambiarTurno=function(){
        this.numTurno++;
        if(this.numTurno%this.turnoViento==0)
        {
            this.viento=Math.round((Math.random()*this.maxViento*2-this.maxViento)*10)/10;
        }
        this.turno=(this.turno+1)%2;
        this.idBoton.disabled=false;
    };

El método disparar será el que cree la bola, la posicione en el lugar correcto de disparo (según al jugador que le toque, cuenta que llevamos en la variable turno) y ejecute sobre ella aquél método que iniciaba sus valores Vx e Vy. Por último la meto en la lista de bolas, para que en la siguiente vuelta del bucle se ejecute su mover, su dibujar y su colisión, como ya sabemos de los otros tutoriales.

A continuación tenemos el método que se encarga de eliminar la bola, que además debe cambiar el turno de juego, para que dispare el otro jugador.

En cambiarTurno simplemente actualizamos la variable del turno, y activamos de nuevo el botón para que el siguiente jugador pueda disparar. Además, para darle más emoción al juego, si ya han transcurrido alguna cantidad de turnos, calcularemos un nuevo viento.

this.loop=function(){
        if (!this.funcionando)
        {
            this.tiempoTranscurrido=new Date().getTime();
            this.funcionando=true;
        }
        else
        {
            var delta=(new Date().getTime()) - this.tiempoTranscurrido;
            delta/=1000;
            this.tiempoTranscurrido=new Date().getTime();
            if (this.idLimpiar.checked)
            {
                this.contexto.clearRect(0,0,this.canvas.width,this.canvas.height);
            }
            //Dibujamos los rectangulos pisos
            var nRec=this.rectangulos.length;
            for (var i=0;i<nRec;i++)
            {
                this.rectangulos[i].dibujar(this.contexto);
            }
            //Dibujamos los jugadores
            for (var i=0;i<2;i++)
            {
                this.jugadores[i].dibujar(this.contexto);
            }
            //Dibujamos la bola
            var nbolas=this.bolas.length;
            for (var i=0;i<nbolas;i++)
            {
                this.bolas[i].mover(delta);
                this.bolas[i].dibujar(this.contexto);
            }
            //Eliminamos las bolas que estén en la lista de bolas a eliminar.
            var m=this.elimBolas.length;
            for(var i=0;i<m;i++)
            {
                var n=this.bolas.length;
                for(var j=0;j<n;j++)
                {
                    if (this.elimBolas[i]==this.bolas[j])
                    {
                        this.bolas.splice(j,1);
                        break;
                    }
                }
            }
            this.elimBolas=[];

            //Colisiones jugadores
            var m=this.bolas.length;
            var finJuego=false;
            for(var i=0;i<m;i++)
            {
                var n=this.jugadores.length;
                for(var j=0;j<n;j++)
                {
                    if (this.bolas[i].colisiona(this.jugadores[j]))
                    {
                        this.jugadorDestruido(j);
                        finJuego=true;
                        break;
                    }
                }
                if (finJuego) break;
            }
            //Colisiones pisos
            var m=this.bolas.length;
            for(var i=0;i<m;i++)
            {
                var n=this.rectangulos.length;
                for(var j=0;j<n;j++)
                {
                    if (this.bolas[i].colisiona(this.rectangulos[j]))
                    {
                        this.eliminarBola(this.bolas[i]);
                        break;
                    }
                }
            }

            //GUI puntos y viento
            var v=Math.abs(this.viento);
            if(this.viento>0)
            {
                v="→ "+v;
            }
            else
            {
                v="← "+v;
            }
            this.contexto.font = "bold 20px monospace";
            this.contexto.fillStyle="yellow";
            this.contexto.fillText("Puntos jugador 1: "+this.puntuacion0,20,30);
            this.contexto.fillStyle="green";
            this.contexto.fillText("Viento: "+v+" // Jugador: "+(this.turno+1),this.canvas.width/2-140,30);
            this.contexto.fillStyle="yellow";
            this.contexto.fillText("Puntos jugador 2: "+this.puntuacion1,this.canvas.width-260,30);
        }
    };

Y aquí tenemos el método loop, que funciona prácticamente igual que el que ya conoceis, aunque adaptado a las peculiaridades de este nuevo juego. De lo poco destacable que podemos decir es que me he cargado aquél controlLoop, metiendo su código en las primeras líneas de loop, consiguiendo el mismo resultado pero ahorrándome una función extra. Ahora el intervalo llamará directamente a loop.

También será el loop el que detecte si la bola disparada ha colisionado con alguno de los jugadores, en cuyo caso llamará a jugadorDestruido.

Al principio del código de la GUI, esos simbolitos raros que almaceno en la variable local v son el código Unicode de las flechas, para indicar más claramente la dirección del viento (valor positivo, a la derecha; negativo, izquierda).

    this.jugadorDestruido=function(jugador){
        if(jugador==0)
        {
            this.puntuacion1++;
        }
        else
        {
            this.puntuacion0++;
        }
        this.cambiarTurno();
        this.reiniciar();
    };
}

El método jugadorDestruido actualiza las puntuaciones según el jugador que ha sido alcanzado, reinicia la partida (generando nuevos obstáculos), y cambia el turno de los jugadores.

Y para terminar, sólo nos queda mostrar el INDEX.HTML, que no contiene nada que te pueda sorprender, si has seguido mis otros tutoriales.

<html>
    <head>
        <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
        <title>Tiro parabolico</title>
        <script type="text/javascript" src="js/Punto2D.js"></script>
        <script type="text/javascript" src="js/Rectangulo.js"></script>
        <script type="text/javascript" src="js/Bola.js"></script>
        <script type="text/javascript" src="js/Mundo.js"></script>
        <script>  
            var temporizador;
            var juego;
            function inicio(){
                juego=new Mundo();
                juego.constructor("contexto","lanzar","angulo","fuerza","limpiar");

                temporizador=setInterval("juego.loop();",20);
            }
            function log(texto,nueva){
                var capa=document.getElementById("log");
                if (nueva)
                    capa.innerHTML+="<br/>"+texto;
                else
                    capa.innerHTML=texto;
            }
            function parar(){
                clearInterval(temporizador);
            }
        </script>
    </head>

    <body onload="inicio();">
        <canvas style="background-color:black;" id="contexto"></canvas>
        <br/>
        <button onclick="parar();">Parar</button>
        <div>
            Angulo: <input size="2" maxlength="2" id="angulo" type="text" value="45"/> Fuerza: <input size="2" maxlength="3" id="fuerza" type="text" value="50"/><br/>
            <button id="lanzar">Dispara!</button><br/>
            Borrar estela disparos: <input id="limpiar" type="checkbox" />
       </div>
        <div id="log"></div>

    </body>
</html>

Y con ésto, y un bizcocho… hasta mañana a las ocho. Seguiré meditando qué hacer en el siguiente tutorial. Prometí uno de Tiles, pero sin embargo ya tengo hechos unos cuantos métodos de inteligencia artificial… ya veremos.

Etiquetas: , , , , , ,

Comentarios (7)

  • esta genial, gracias

  • Demasiado bueno, quisiese contribuir con la información!

    Gracais por todo!

    Saludos!

  • Disculpa si es spam pero creo que te va venir como anillo al dedo:
    http://imbuzu.wordpress.com/2009/06/14/javascript-orientado-a-objetos-segun-buzu-los-metodos/

  • hola muy bueno el tutorial me esta a ayudado mucho, me podrías decir como se aria para destruir el escenario..

  • tengo un examen sobre el movimiento parabólico y realmente estoy asustada
    me podría ayudar se lo agradezco leí tu bloc y seguro k tu eres la persona k
    necesito para solucionar mi problema

  • Por fabor, que le pasa a la gente
    Hacen repost con solo el código de un artículo en inglés (cuyo código no funciona) y todos los aplauden.
    Hacen un post donde muestran cómo utilizar un framework que trata este tema, y solo ponen los mismos ejemplos que aparecen en la documentación oficial, y todos lo aplauden!!!!!!!!!
    pero hacen un artículo tan bueno como este y nadie dice nada
    Creo que todos lo aplaudirían si hubieras usado algún framework, ya que al parecer nadie tiene ganas de sentarse a razonar un rato ¬¬
    Pasando a otro tema en VB (o en gamba. No recuerdo bien) tenía un ejemplo similar, pero donde además la bola rebotaba. Por supuesto eso fue antes de que existiera html5, y hace tiempo que deseche el código y ya no recuerdo como lo hice XD
    Saludos, y gracias por tan buenos post

  • Hola.

    Lo primero, gracias por los tutoriales porque me están siendo de gran ayuda.

    Lo que quería comentar es que los cálculos que haces para obtener la hipotenusa sólo son correctos si ésta saliera del rectángulo por uno de sus vértices. En caso de salir por uno de los lados, como en la imagen, sólo uno de los catetos es igual al cd-lado/2. El otro, en este caso el del eje X, es menor a este valor.

    A ver si lo puedes actualizar con el cálculo correcto. Yo voy a intentar sacarlo

    Un saludo.

¿Tienes algo que decir?

Gestionado con Wordpress y Stripes Theme Entradas (RSS) | Comentarios (RSS)