En esta serie de tutoriales, te mostraré cómo hacer un shooter de doble palo inspirado en Geometry Wars, con gráficos de neón, efectos de partículas locas y música impresionante, para iOS con C ++ y OpenGL ES 2.0.
En lugar de confiar en un marco de juego existente o en una biblioteca de sprites, intentaremos programar lo más cerca posible del hardware (o "metal") que podamos. Dado que los dispositivos que ejecutan iOS se ejecutan en hardware de menor escala en comparación con una PC de escritorio o una consola de juegos, esto nos permitirá obtener el máximo provecho posible.
Artículos RelacionadosEl objetivo de estos tutoriales es repasar los elementos necesarios que te permitirán crear tu propio juego móvil de alta calidad para iOS, ya sea desde cero o en base a un juego de escritorio existente. Te animo a descargar y jugar con el código, o incluso a usarlo como base para tus propios proyectos..
Cubriremos los siguientes temas durante esta serie:
Esto es lo que tendremos al final de la serie:
Y esto es lo que tendremos al final de esta primera parte:
La música y los efectos de sonido que puedes escuchar en estos videos fueron creados por RetroModular, y puedes leer cómo lo hizo en nuestra sección de audio..
Los sprites son de Jacob Zinman-Jeanes, nuestro diseñador residente de Tuts +.
La fuente que usaremos es una fuente de mapa de bits (en otras palabras, no una "fuente" real, sino un archivo de imagen), que es algo que he creado para este tutorial..
Todas las ilustraciones se pueden encontrar en los archivos fuente..
Empecemos.
Antes de adentrarnos en los aspectos específicos del juego, hablemos sobre la biblioteca de aplicaciones y el código Bootstrap de la aplicación que proporcioné para apoyar el desarrollo de nuestro juego..
Aunque principalmente usaremos C ++ y OpenGL para codificar nuestro juego, necesitaremos algunas clases de utilidad adicionales. Estas son todas las clases que he escrito para ayudar al desarrollo en otros proyectos, por lo que son probadas por el tiempo y son utilizables para nuevos proyectos como este..
paquete.h
: Un encabezado de conveniencia utilizado para incluir todos los encabezados relevantes de la biblioteca de utilidades. Lo incluiremos indicando #include "Utility / package.h"
sin tener que incluir nada mas.Aprovecharemos algunos patrones de programación probados y reales que se utilizan en C ++ y otros lenguajes..
tSingleton
: Implementa una clase de singleton usando un patrón "Meyers Singleton". Está basado en plantillas y es extensible, por lo que podemos abstraer todo el código singleton a una sola clase.opcional
: Esta es una característica de C ++ 14 (llamada std :: opcional
) todavía no está disponible en las versiones actuales de C ++ (todavía estamos en C ++ 11). También es una función disponible en XNA y C # (donde se llama Anulable
.) Nos permite tener parámetros "opcionales" para los métodos. Se usa en el tSpriteBatch
clase.Ya que no estamos usando un marco de juego existente, necesitaremos algunas clases para lidiar con las matemáticas detrás de escena.
Matemáticas
: Una clase estática que proporciona algunos métodos más allá de lo que está disponible en C ++, como convertir de grados a radianes o redondear números a potencias de dos.tVector
: Un conjunto básico de clases de vectores, que proporciona variantes de 2 elementos, 3 elementos y 4 elementos. También tipeamos esta estructura para Puntos y Colores..Matriz
: Dos definiciones de matriz, una variante de 2x2 (para operaciones de rotación) y una opción de 4x4 (para la matriz de proyección requerida para obtener información en pantalla),tRect
: Una clase de rectángulo que proporciona ubicación, tamaño y un método para determinar si los puntos se encuentran dentro de los rectángulos o no.Aunque OpenGL es una API poderosa, está basada en C, y administrar objetos puede ser algo difícil de hacer en la práctica. Entonces, tendremos un pequeño puñado de clases para administrar los objetos OpenGL por nosotros.
superficie t
: Ofrece una forma de crear un mapa de bits basado en una imagen cargada desde el paquete de la aplicación.tTextura
: Ajusta la interfaz a los comandos de textura de OpenGL y carga t Superficies
en texturas.tShader
: Ajusta la interfaz al compilador de sombreado de OpenGL, lo que facilita la compilación de sombreadores.tPrograma
: Ajusta la interfaz a la interfaz del programa de sombreado de OpenGL, que es esencialmente la combinación de dos tShader
clases.Estas clases representan lo más cercano que tendremos a un "marco de juego"; proporcionan algunos conceptos de alto nivel que no son típicos de OpenGL, pero que son útiles para propósitos de desarrollo de juegos.
tViewport
: Contiene el estado del viewport. Usamos esto principalmente para manejar los cambios en la orientación del dispositivo.tAutosizeViewport
: Una clase que gestiona los cambios en la ventana gráfica. Maneja los cambios de orientación del dispositivo directamente y ajusta la escala de visualización para que se ajuste a la pantalla del dispositivo, de modo que la relación de aspecto se mantenga igual, lo que significa que las cosas no se estiran ni aplastan.tSpriteFont
: Nos permite cargar una "fuente de mapa de bits" del paquete de la aplicación y usarla para escribir texto en la pantalla.tSpriteBatch
: Inspirado por XNA's SpriteBatch
Clase, escribí esta clase para resumir lo mejor de lo que nuestro juego necesita. Nos permite ordenar los sprites cuando dibujamos de tal manera que obtengamos las mejores ganancias de velocidad posibles en el hardware que tenemos. También lo usaremos directamente para escribir texto en pantalla..Un conjunto mínimo de clases para redondear las cosas..
timetro
: Un temporizador del sistema, usado principalmente para animaciones.tInputEvent
: Definiciones de clase básicas para proporcionar cambios de orientación (inclinación del dispositivo), eventos táctiles y un evento de "teclado virtual" para emular un gamepad de forma más discreta.sonido
: Una clase dedicada a cargar y reproducir efectos de sonido y música..También necesitaremos lo que yo llamo código "Boostrap", es decir, código que se abstrae de cómo se inicia una aplicación, o "arranca".
Esto es lo que hay en Oreja
:
AppDelegate
: Esta clase maneja el inicio de la aplicación, así como suspender y reanudar eventos para cuando el usuario presiona el botón Inicio.ViewController
: Esta clase maneja los eventos de orientación del dispositivo y crea nuestra vista OpenGLOpenGLView
: Esta clase inicializa OpenGL, le dice al dispositivo que se actualice a 60 cuadros por segundo y maneja eventos táctiles.En este tutorial crearemos un shooter de doble palo; El jugador controlará el barco utilizando los controles multitáctiles en pantalla..
Usaremos varias clases para lograr esto:
Entidad
: La clase base para enemigos, balas y la nave del jugador. Las entidades pueden moverse y ser dibujadas..Bala
y JugadorEnvío
.EntityManager
: Realiza un seguimiento de todas las entidades en el juego y realiza la detección de colisiones.Entrada
: Ayuda a gestionar la entrada desde la pantalla táctil.Art º
: Carga y guarda referencias a las texturas necesarias para el juego.Sonar
: Carga y guarda referencias a los sonidos y la música..Matemáticas
y Extensiones
: Contiene algunos métodos estáticos útiles yGameRoot
: Controla el bucle principal del juego. Esta es nuestra clase principal..El código en este tutorial pretende ser simple y fácil de entender. No tendrá todas las características diseñadas para satisfacer todas las necesidades posibles; más bien, hará solo lo que necesita hacer. Manteniéndolo simple te hará más fácil entender los conceptos, y luego modificarlos y expandirlos en tu propio juego único..
Abra el proyecto Xcode existente. GameRoot es la clase principal de nuestra aplicación.
Comenzaremos creando una clase base para nuestras entidades de juego. Echa un vistazo a la
class Entity public: enum Kind kDontCare = 0, kBullet, kEnemy, kBlackHole,; protegido: tTexture * mImage; tColor4f mColor; tPoint2f mPosition; tVector2f mVelocity; orientación flotante; float mRadius; bool mIsExpired; Tipo mKind; entidad pública(); ~ Entidad virtual (); tDimension2f getSize () const; actualización de vacío virtual () = 0; sorteo virtual vacío (tSpriteBatch * spriteBatch); tPoint2f getPosition () const; tVector2f getVelocity () const; void setVelocity (const tVector2f & nv); float getRadius () const; bool isExpired () const; Kind getKind () const; void setExpired (); ;
Todas nuestras entidades (enemigos, balas y la nave del jugador) tienen algunas propiedades básicas, como una imagen y una posición.. mIsExpired
se utilizará para indicar que la entidad ha sido destruida y debe eliminarse de cualquier lista que contenga una referencia a ella.
A continuación creamos un EntityManager
Para rastrear nuestras entidades y actualizarlas y dibujarlas:
clase EntityManager: publicSingletonprotegido: estándar :: lista mEntidades; std :: list mAddedEntities; std :: list mBullets; bool mIsUpdating; protegido: EntityManager (); public: int getCount () const; void add (Entidad * entidad); void addEntity (Entidad * entidad); actualización nula (); empate vacío (tSpriteBatch * spriteBatch); bool isColliding (Entidad * a, Entidad * b); amigo clase tSingleton ; ; void EntityManager :: add (Entity * entidad) if (! mIsUpdating) addEntity (entidad); else mAddedEntities.push_back (entidad); void EntityManager :: update () mIsUpdating = true; para (std :: list :: iterator iter = mEntities.begin (); iter! = mEntities.end (); iter ++) (* iter) -> update (); if ((* iter) -> isExpired ()) * iter = NULL; mIsUpdating = falso; para (std :: list :: iterator iter = mAddedEntities.begin (); iter! = mAddedEntities.end (); iter ++) addEntity (* iter); mAddedEntities.clear (); mEntities.remove (NULL); para (std :: list :: iterator iter = mBullets.begin (); iter! = mBullets.end (); iter ++) if ((* iter) -> isExpired ()) delete * iter; * iter = NULL; mBullets.remove (NULL); void EntityManager :: draw (tSpriteBatch * spriteBatch) for (std :: list :: iterator iter = mEntities.begin (); iter! = mEntities.end (); iter ++) (* iter) -> draw (spriteBatch);
Recuerde, si modifica una lista mientras está iterando sobre ella, obtendrá una excepción de tiempo de ejecución. El código anterior se ocupa de esto al poner en cola las entidades agregadas durante la actualización en una lista separada, y agregarlas después de que finalice la actualización de las entidades existentes.
Tendremos que cargar algunas texturas si queremos dibujar algo, así que haremos una clase estática para contener referencias a todas nuestras texturas:
clase de arte: público tSingletonprotegido: tTexture * mPlayer; tTexture * mSeeker; tTexture * mWanderer; tTexture * mBullet; tTexture * mPointer; protegido: Arte (); public: tTexture * getPlayer () const; tTexture * getSeeker () const; tTexture * getWanderer () const; tTexture * getBullet () const; tTexture * getPointer () const; amigo clase tSingleton ; ; Art :: Art () mPlayer = new tTexture (tSurface ("player.png")); mSeeker = new tTexture (tSurface ("seeker.png")); mWanderer = new tTexture (tSurface ("wanderer.png")); mBullet = new tTexture (tSurface ("bullet.png")); mPointer = new tTexture (tSurface ("pointer.png"));
Nosotros cargamos el arte llamando Art :: getInstance ()
en GameRoot :: onInitView ()
. Esto causa la Art º
Singleton para construirse y llamar al constructor., Arte :: Arte ()
.
Además, varias clases deberán conocer las dimensiones de la pantalla, por lo que tenemos los siguientes miembros en GameRoot
:
tDimension2f mViewportSize; tSpriteBatch * mSpriteBatch; tAutosizeViewport * mViewport;
Y en el GameRoot
constructor, establecemos el tamaño:
GameRoot :: GameRoot (): mViewportSize (800, 600), mSpriteBatch (NULL)
La resolución 800x600px es lo que usó el Shape Blaster basado en XNA original. Podríamos usar cualquier resolución que deseemos (como una más cercana a la resolución específica de un iPhone o iPad), pero mantendremos la resolución original solo para asegurarnos de que nuestro juego coincida con la apariencia del original.
Ahora vamos a repasar el JugadorEnvío
clase:
clase PlayerShip: entidad pública, tSingleton públicaprotected: static const int kCooldownFrames; int mCooldowmRemaining; int mFramesUntilRespawn; protegido: PlayerShip (); public: void update (); empate vacío (tSpriteBatch * spriteBatch); bool getIsDead (); matanza del vacío (); amigo clase tSingleton ; ; PlayerShip :: PlayerShip (): mCooldowmRemaining (0), mFramesUntilRespawn (0) mImage = Art :: getInstance () -> getPlayer (); mPosition = tPoint2f (GameRoot :: getInstance () -> getViewportSize (). x / 2, GameRoot :: getInstance () -> getViewportSize (). y / 2); mRadius = 10;
Nosotros hicimos JugadorEnvío
Un singleton, establece su imagen, y la coloca en el centro de la pantalla..
Finalmente, agreguemos la nave del jugador a la EntityManager
. El codigo en GameRoot :: onInitView
Se ve como esto:
// In GameRoot :: onInitView EntityManager :: getInstance () -> add (PlayerShip :: getInstance ());… glClearColor (0,0,0,1); glEnable (GL_BLEND); glBlendFunc (GL_SRC_ALPHA, GL_ONE); glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); glHint (GL_GENERATE_MIPMAP_HINT, GL_DONT_CARE); glDisable (GL_DEPTH_TEST); glDisable (GL_CULL_FACE);
Estamos dibujando los sprites con mezcla aditiva, que es parte de lo que les dará su look "neon". Tampoco queremos bluring o blending, así que usamos GL_NEAREST
para nuestros filtros. No necesitamos o nos preocupamos por las pruebas de profundidad o el sacrificio de la cara posterior (de todos modos solo agrega gastos generales innecesarios), así que lo desactivamos.
El codigo en GameRoot :: onRedrawView
Se ve como esto:
// En GameRoot :: onRedrawView EntityManager :: getInstance () -> update (); EntityManager :: getInstance () -> draw (mSpriteBatch); mSpriteBatch-> draw (0, Art :: getInstance () -> getPointer (), Input :: getInstance () -> getMousePosition (), tOptional()); mViewport-> run (); glClear (GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); mSpriteBatch-> end (); glFlush ();
Si ejecutas el juego en este punto, deberías ver tu nave en el centro de la pantalla. Sin embargo, no responde a la entrada. Vamos a añadir algo de entrada al juego siguiente.
Para el movimiento, utilizaremos una interfaz multitáctil. Antes de utilizar toda la fuerza con los gamepads en pantalla, solo tendremos una interfaz táctil básica en funcionamiento.
En el Shape Blaster original para Windows, el movimiento del jugador se puede hacer con las teclas WASD del teclado. Para apuntar, podrían usar las teclas de flecha o el mouse. Esto tiene la intención de emular los controles de doble palo de Geometry Wars: un stick analógico para movimiento, uno para apuntar.
Dado que Shape Blaster ya utiliza el concepto de movimiento del teclado y el mouse, la forma más fácil de agregar entrada sería emulando los comandos del teclado y el mouse a través del tacto. Comenzaremos con el movimiento del mouse, ya que tanto el touch como el mouse comparten un componente similar: un punto que contiene coordenadas X e Y.
Haremos una clase estática para realizar un seguimiento de los distintos dispositivos de entrada y para cuidar el cambio entre los diferentes tipos de puntería:
Clase de entrada: público tSingleton protegido: tPoint2f mMouseState; tPoint2f mLastMouseState; tPoint2f mFreshMouseState; std :: vectormKeyboardState; std :: vector mLastKeyboardState; std :: vector mFreshKeyboardState; bool mIsAimingWithMouse; uint8_t mLeftEngaged; uint8_t mRightEngaged; public: enum KeyType kUp = 0, kLeft, kDown, kRight, kW, kA, kS, kD,; protegido: tVector2f GetMouseAimDirection () const; protegido: Entrada (); public: tPoint2f getMousePosition () const; actualización nula (); // Comprueba si una tecla se acaba de presionar bool wasKeyPressed (KeyType) const; tVector2f getMovementDirection () const; tVector2f getAimDirection () const; void onKeyboard (const tKeyboardEvent & msg); void onTouch (const tTouchEvent & msg); amigo clase tSingleton; ; void Input :: update () mLastKeyboardState = mKeyboardState; mLastMouseState = mMouseState; mKeyboardState = mFreshKeyboardState; mMouseState = mFreshMouseState; if (mKeyboardState [kLeft] || mKeyboardState [kRight] || mKeyboardState [kUp] || mKeyboardState [kDown]) mIsAimingWithMouse = false; else if (mMouseState! = mLastMouseState) mIsAimingWithMouse = true;
Nosotros llamamos Entrada :: actualizar ()
al principio de GameRoot :: onRedrawView ()
para que la clase de entrada funcione.
Como se indicó anteriormente, utilizaremos el teclado
Estado más adelante en la serie para dar cuenta del movimiento.
Ahora vamos a hacer que el barco dispare.
Primero, necesitamos una clase de balas..
clase Bullet: public Entity public: Bullet (const tPoint2f & position, const tVector2f & speed); actualización nula (); ; Bullet :: Bullet (const tPoint2f & position, const tVector2f & speedity) mImage = Art :: getInstance () -> getBullet (); mPosición = posición; mVelocity = velocidad; mOrientation = atan2f (mVelocity.y, mVelocity.x); mRadius = 8; mKind = kBullet; void Bullet :: update () if (mVelocity.lengthSquared ()> 0) mOrientation = atan2f (mVelocity.y, mVelocity.x); mPosición + = mVelocidad; if (! tRectf (0, 0, GameRoot :: getInstance () -> getViewportSize ()). contiene (tPoint2f ((int32_t) mPosition.x, (int32_t) mPosition.y))) mIsExpired = true;
Queremos un breve período de enfriamiento entre balas, por lo que tendremos una constante para eso:
const int PlayerShip :: kCooldownFrames = 6;
Además, agregaremos el siguiente código a PlayerShip :: Actualización ()
:
tVector2f aim = Input :: getInstance () -> getAimDirection (); if (aim.lengthSquared ()> 0 && mCooldowmRemaining <= 0) mCooldowmRemaining = kCooldownFrames; float aimAngle = atan2f(aim.y, aim.x); float cosA = cosf(aimAngle); float sinA = sinf(aimAngle); tMatrix2x2f aimMat(tVector2f(cosA, sinA), tVector2f(-sinA, cosA)); float randomSpread = tMath::random() * 0.08f + tMath::random() * 0.08f - 0.08f; tVector2f vel = 11.0f * (tVector2f(cosA, sinA) + tVector2f(randomSpread, randomSpread)); tVector2f offset = aimMat * tVector2f(35, -8); EntityManager::getInstance()->add (new Bullet (mPosition + offset, vel)); offset = aimMat * tVector2f (35, 8); EntityManager :: getInstance () -> add (new Bullet (mPosition + offset, vel)); tSound * curShot = Sound :: getInstance () -> getShot (); if (! curShot-> isPlaying ()) curShot-> play (0, 1); if (mCooldowmRemaining> 0) mCooldowmRemaining--;
Este código crea dos balas que viajan paralelas entre sí. Agrega una pequeña cantidad de aleatoriedad a la dirección, lo que hace que los disparos se extiendan un poco como una ametralladora. Sumamos dos números aleatorios juntos porque esto hace que su suma sea más centrada (alrededor de cero) y menos probable que envíe balas muy lejos. Utilizamos una matriz bidimensional para rotar la posición inicial de las balas en la dirección en que viajan.
También utilizamos dos nuevos métodos de ayuda:
Extensiones :: NextFloat ()
: Devuelve un flotante aleatorio entre un valor mínimo y máximo.MathUtil :: FromPolar ()
: Crea un tVector2f
desde un ángulo y magnitud.Así que vamos a ver cómo se ven:
// In Extensions float Extensions :: nextFloat (float minValue, float maxValue) return (float) tMath :: random () * (maxValue - minValue) + minValue; // En MathUtil tVector2f MathUtil :: fromPolar (ángulo de flotación, magnitud de flotación) magnitud de retorno * tVector2f ((float) cosf (angle), (float) sinf (angle));
Hay una cosa más que debemos hacer ahora que tenemos la información inicial. Entrada
clase: dibujemos un cursor de ratón personalizado para que sea más fácil ver hacia dónde apunta el barco. En JuegoRoot.Draw
, simplemente dibuja el arte mPointer
en la posición del "ratón".
mSpriteBatch-> draw (0, Art :: getInstance () -> getPointer (), Input :: getInstance () -> getMousePosition (), tOptional());
Si prueba el juego ahora, podrá tocar en cualquier parte de la pantalla para dirigir el flujo continuo de balas, lo que es un buen comienzo.
En la siguiente parte, completaremos el juego inicial agregando enemigos y una puntuación..