Uso de par y empujadores para mover y rotar una nave espacial diseñada por el jugador

Mientras trabajaba en un juego en el que las naves espaciales están diseñadas por jugadores y pueden ser parcialmente destruidas, me encontré con un problema interesante: mover una nave usando propulsores no es una tarea fácil. Simplemente puede mover y girar la nave como si fuera un automóvil, pero si desea que el diseño de la nave y los daños estructurales afecten el movimiento de la nave de una manera creíble, simular los propulsores podría ser un mejor enfoque. En este tutorial, te mostraré cómo hacer esto..

Asumiendo que un barco puede tener múltiples propulsores en varias configuraciones, y que la forma y las propiedades físicas del barco pueden cambiar (por ejemplo, se podrían destruir partes del barco), es necesario determinar cual Empujadores para disparar con el fin de mover y girar la nave. Ese es el principal desafío que tenemos que afrontar aquí..

La demostración está escrita en Haxe, pero la solución se puede implementar fácilmente en cualquier idioma. Se supone un motor de física similar a Box2D o Nape, pero cualquier motor que proporcione los medios para aplicar fuerzas e impulsos y consultar las propiedades físicas de los cuerpos funcionará..

Prueba la demo

Haga clic en el SWF para enfocarlo, luego use las teclas de flecha y las teclas Q y W para activar diferentes propulsores. Puede cambiar a diferentes diseños de naves espaciales utilizando las teclas numéricas del 1 al 4, y puede hacer clic en cualquier bloque o hélice para eliminarlo de la nave..


Representando el barco

Este diagrama muestra las clases que representan el barco y cómo se relacionan entre sí:

BodySprite Es una clase que representa un cuerpo físico con una representación gráfica. Permite que los objetos de visualización se adhieran a las formas y se asegura de que se muevan y giren correctamente con el cuerpo.

los Enviar La clase es un contenedor de módulos. Administra la estructura de la nave y se ocupa de adjuntar y separar módulos. Contiene una sola ModuleManager ejemplo.

Adjuntar un módulo adjunta su forma y objeto de visualización al subyacente BodySprite, pero quitar un módulo requiere un poco más de trabajo. Primero se eliminan la forma y el objeto de visualización del módulo de la BodySprite, y luego se comprueba la estructura de la nave para que todos los módulos que no estén conectados al núcleo (el módulo con el círculo rojo) se separen. Esto se hace usando un algoritmo similar al relleno de inundación que toma en cuenta la forma en que cada módulo puede conectarse a otros módulos (por ejemplo, los propulsores solo pueden conectarse desde un lado, dependiendo de su orientación).

Los módulos de separación son algo diferentes: su forma y objeto de visualización aún se eliminan de la BodySprite, pero luego se adjuntan a una instancia de ShipDebris.

Esta forma de representar el barco no es la más simple, pero encontré que funciona muy bien. La alternativa sería representar cada módulo como un cuerpo separado y "pegarlos" juntos con una junta de soldadura. Si bien esto haría que la separación de la nave fuera mucho más fácil, también haría que la nave se sintiera gomosa y elástica si tuviera una gran cantidad de módulos.

los ModuleManager es un contenedor que mantiene los módulos de un barco tanto en una lista (que permite una fácil iteración) como en un mapa hash (que permite un fácil acceso a través de coordenadas locales).

los ShipModule La clase obviamente representa un módulo de barco. Es una clase abstracta que define algunos métodos de conveniencia y atributos que tiene cada módulo. Cada subclase de módulo es responsable de construir su propio objeto y forma de visualización, y de actualizarse si es necesario. Los módulos también se actualizan cuando se adjuntan a ShipDebris, pero en ese caso el attachToShip la bandera está establecida en falso.

Entonces, un barco es en realidad solo una colección de módulos funcionales: bloques de construcción cuya ubicación y tipo definen el comportamiento del barco. Por supuesto, tener una bonita nave flotando como un montón de ladrillos sería un juego aburrido, por lo que debemos descubrir cómo hacerlo moverse de una manera que sea divertido de jugar y convincente a la vez realista..


Simplificando el problema

Girar y mover una nave al disparar selectivamente los propulsores, variando su empuje ya sea ajustando el acelerador o encendiéndolos y apagándolos en una sucesión rápida, es un problema difícil. Afortunadamente, también es innecesario..

Por ejemplo, si quisiera rotar un barco precisamente alrededor de un punto, podría hacerlo simplemente diciéndole a su motor de física que haga girar todo el cuerpo. En este caso, sin embargo, estaba buscando una solución simple que no sea perfecta, pero que sea divertida de jugar. Para simplificar el problema, introduciré una restricción:

Los empujadores solo pueden estar encendidos o apagados y no pueden variar su empuje.

Ahora que hemos abandonado la perfección y la complejidad, el problema es mucho más simple. Necesitamos determinar, para cada propulsor, si debe estar encendido o apagado, dependiendo de su posición en el barco y la entrada del jugador. Podríamos asignar una clave diferente para cada propulsor, pero terminaríamos con un QWOP interestelar, por lo que usaremos las teclas de flecha para girar y mover, y Q y W para trazar.


El caso simple: mover el barco hacia adelante y hacia atrás

La primera tarea es mover el barco hacia adelante y hacia atrás, ya que este es el caso más simple posible. Para mover la nave, simplemente dispararemos los propulsores orientados en la dirección opuesta a la que queremos ir. Por ejemplo, si quisiéramos avanzar, dispararíamos todos los propulsores que miran hacia atrás..

 // Actualiza el propulsor, una vez por fotograma anula la función pública update (): Void if (attachmentToShip) // Mueve hacia adelante y hacia atrás if ((Input.check (Key.UP) && orientación == ShipModule.SOUTH) || (Input.check (Key.DOWN) && orientación == ShipModule.NORTH)) fire (thrustImpulse);  // Strafing else if ((Input.check (Key.Q) && orientación == ShipModule.EAST) || (Input.check (Key.W) && orientación == ShipModule.WEST)) fire (thrustImpulse); 

Obviamente, esto no siempre producirá el efecto deseado. Debido a la restricción anterior, si los propulsores no están colocados uniformemente, mover la nave podría hacer que gire. Además de eso, no siempre es posible elegir la combinación correcta de propulsores para mover un barco según sea necesario. A veces, ninguna combinación de propulsores moverá la nave como queremos. Este es un efecto deseable en mi juego, ya que hace que el daño de la nave y el mal diseño de la nave sean muy obvios..


Una configuración de nave que no puede moverse hacia atrás.

Rotando el barco

En este ejemplo, es obvio que disparar los propulsores A, D y E hará que la nave gire en el sentido de las agujas del reloj (y también se desvíe un poco, pero ese es un problema completamente diferente). La rotación de la nave se reduce a saber de qué manera contribuye un propulsor a la rotación de la nave..

Resulta que lo que estamos buscando aquí es la ecuación de esfuerzo de torsión - específicamente el signo y la magnitud del par.

Así que echemos un vistazo a lo que es el par. El par se define como una medida de cuánto una fuerza que actúa sobre un objeto hace que ese objeto gire:

Como queremos rotar la nave alrededor de su centro de masa, nuestro [latex] r [/ latex] es el vector de distancia desde la posición de nuestro propulsor hasta el centro de masa de toda la nave. El centro de rotación podría ser cualquier punto, pero el centro de masa es probablemente el que un jugador esperaría.

El vector de fuerza [látex] F [/ látex] es un vector de dirección de unidad que describe la orientación de nuestro propulsor. En este caso, no nos importa el par de torsión real, solo su signo, así que está bien usar solo el vector de dirección.

Como el producto cruzado no está definido para vectores bidimensionales, simplemente trabajaremos con vectores tridimensionales y estableceremos el componente [latex] z [/ latex] en 0, haciendo que las matemáticas se simplifiquen a la perfección:

[látex]
\ tau = r \ times F \\
\ tau = (r_x, \ quad r_y, \ quad 0) \ times (F_x, \ quad F_y, \ quad 0) \\
\ tau = (-0 \ cdot F_y + r_y \ cdot 0, \ quad 0 \ cdot F_x - r_x \ cdot 0, \ quad -r_y \ cdot F_x + r_x \ cdot F_y) \\
\ tau = (0, \ quad 0, \ quad -r_y \ cdot F_x + r_x \ cdot F_y) \\
\ tau_z = r_x \ cdot F_y - r_y \ cdot F_x \\
[/látex]


Los círculos de colores describen cómo el propulsor afecta a la nave: el verde indica que el propulsor hace que la nave gire en el sentido de las agujas del reloj, el rojo indica que causa que la nave gire en el sentido contrario a las agujas del reloj. El tamaño de cada círculo indica cuánto afecta el propulsor a la rotación de la nave.

Con esto en su lugar, podemos calcular cómo cada propulsor afecta a la nave individualmente. Un valor de retorno positivo indica que el propulsor hará que la nave gire en el sentido de las agujas del reloj y viceversa. Implementar esto en el código es muy sencillo:

 // Calcula el par no bastante usando la ecuación sobre la función privada calculaTorque (): Float var distToCOM = shape.localCOM.mul (-1.0); devolver distToCOM.x * thrustDir.y - distToCOM.y * thrustDir.x;  // La actualización del Thruster anula la función pública update (): Void if (attachmentToShip) // Si el thruster está conectado a un barco, procesamos la entrada // del jugador y disparamos el thruster cuando es necesario. var torque = calculaTorque (); if ((Input.check (Key.UP) && orientación == ShipModule.SOUTH) || (Input.check (Key.DOWN) && orientación == ShipModule.NORTH)) fire (thrustImpulse);  else if ((Input.check (Key.Q) && orientación == ShipModule.EAST) || (Input.check (Key.W) && orientación == ShipModule.WEST)) fire (thrustImpulse);  else if ((Input.check (Key.LEFT) && torque < -torqueThreshold) || (Input.check(Key.RIGHT) && torque > torqueThreshold)) fire (thrustImpulse);  else thrusterOn = false;  else // Si el propulsor no está conectado a un barco, entonces está adjunto // a un pedazo de escombros. Si el propulsor estaba disparando cuando estaba // separado, continuará disparando por un tiempo. // detachedThrustTimer es una variable utilizada como un simple temporizador, // y se establece cuando el propulsor se separa de un barco. if (detachedThrustTimer> 0) detachedThrustTimer - = NapeWorld.currentWorld.deltaTime; fuego (thrustImpulse);  else thrusterOn = false;  animar ();  // Dispara el propulsor aplicando un impulso al cuerpo padre, // con la dirección opuesta a la dirección del propulsor y // la magnitud pasada como parámetro. // La bandera thrusterOn se usa para la animación. función pública fire (cantidad: Float): Void var thrustVec = thrustDir.mul (- cantidad); var impulseVec = thrustVec.rotate (parent.body.rotation); parent.body.applyWorldImpulse (impulseVec, getWorldPos ()); thrusterOn = true; 

Conclusión

La solución demostrada es fácil de implementar y funciona bien para un juego de este tipo. Por supuesto, hay espacio para mejorar: este tutorial y la demostración no tienen en cuenta que un barco podría ser pilotado por algo más que un jugador humano, y la implementación de un piloto de IA que en realidad puede volar un barco medio destruido sería un desafío muy interesante (uno que tendré que enfrentar en algún momento, de todos modos).