Saltar a contenido

Ácción

Una vez añadidos los componentes de la escena, vamos a poner el juego en movimiento. En este bloque veremos lo siguiente:

  • Lectura de la entrada del usuario
    • Tap en pantalla táctil (para disparar)
    • Sensores de movimiento (para mover la nave de izquierda a derecha)
  • Acciones (SCNAction) para programar el comportamiento de los disparos
  • Ciclo del juego para mover la nave en función de la inclinación del móvil

Entrada del usuario

En primer lugar vamos a realizar la lectura de la entrada del usuario. Posteriormente, utilizaremos esta entrada para mover los elementos de la escena.

Tap en la pantalla táctil

Para la lectura de los taps en la pantalla táctil, utilizaremos los componentes de UIKit orientados al reconocimiento de gestos.

TODO B01

  • En el método startTapRecognition(inView:), crearemos un objeto de tipo UITapGestureRecognition, que cuando se produzca el evento de tap en pantalla, llame al método handleTap(:). Para ello, los gesture recognizers cuentan con el constructor init(target:action:). Recuerda también que puedes utilizar la directiva #selector() para especificar un selector que identifique el método al que queremos llamar.
  • Añadiremos el UITapGestureRecognition a la vista (que nos llega como parámetro). Puedes ver la forma de hacer esto en la documentación de UIView.

Podemos ver que el método handleTap(:)ya está implementado, y que lo único que hace es llamar al método shot() que de momento está vacío. En el siguiente apartado lo completaremos para implementar los disparos.

Control por movimiento

Vamos a utilizar los sensores de orientación del dispositivo para implementar control por movimiento.

TODO B02

  • En startMotionUpdates() programaremos lecturas de la orientación del dispositivo (device motion updates) utilizando Core Motion. Utilizaremos los ángulos pitch y roll del dispositivo para mover ligeramente la orientación de la cámara, y para cambiar la velocidad de la nave (almacenada en el campo velocity) según giramos el dispositivo. Puedes utilizar el siguiente código para conseguir estas funcionalidades.
if(self.motion.isDeviceMotionAvailable) {
    self.motion.deviceMotionUpdateInterval = 1.0/60.0
    self.motion.startDeviceMotionUpdates(
            to: OperationQueue.main, 
            withHandler: { (deviceMotion, error) 
                                          -> Void in

        if let roll = deviceMotion?.attitude.roll, 
            let pitch = deviceMotion?.attitude.pitch {
            self.velocity = Float(roll)
            
            if let cameraNode = self.cameraNode, 
                let euler = self.cameraEulerAngle {
                cameraNode.eulerAngles.z = 
                   euler.z - Float(roll) * 0.1
                cameraNode.eulerAngles.x = 
                   euler.x - Float(pitch - 0.75) * 0.1
            }
        }
    })
}

Podemos ver que en el código necesitamos acceder al nodo cámara (self.cameraNode), y también necesitamos tener guardada como referencia la orientación original de la cámara (self.cameraEulerAngle), para hacer el giro de forma relativa a ella.

TODO B03

  • Inicializar los campos self.cameraNode y self.cameraEulerAngle) en el método viewDidLoad(). Recuerda que en SceneKit el nodo raíz de la escena se encuentra en la propiedad rootNode de la clase SCNScene. A partir de dicho nodo raíz, podremos buscar los hijos a partir de su nombre.

Una vez hecho esto, si probamos el juego veremos que la cámara gira levemente cuando giramos el dispositivo.

TODO B04

  • De la misma forma, también deberemos obtener el nodo con la nave ("ship") y almacenarlo en el campo self.ship, para posteriormente poder mover la nave, o lanzar disparos desde ella.

Disparos

Vamos a implementar los disparos, y programaremos su comportamiento utilizando acciones (SCNAction).

TODO B05. Dentro del método shot(), haremos lo siguiente:

  • Definimos la apariencia de la bala
  • Creamos una forma de tipo esfera (SCNSphere) con radio 1.0
  • Vamos a darle a la esfera un material que emita luz amarilla. Para ello, le asignamos en su material princpal firstMaterial, tanto en diffuse como en emission (en la propiedad contents) el color (UIColor) con RGB (0.8, 0.7, 0.2).
  • Creamos la bala como un nodo (SCNNode) con la geometría de esfera anterior, dándole nombre "bullet", y ubicándola en la misma posicion que la nave.
  • Añadimos el nodo a la escena.
  • Programamos una acción que haga que la bala se mueva
  • Definimos una accion que mueva la bala 150 unidades negativas en el eje Z (durante 1 segundo), y tras ello elimine la bala de la escena. Mira la documentación de SCNAction, para ver cómo definir cada una de estas dos acciones, y cómo encadenarlas en una secuencia.
  • Ejecutamos la accion sobre la bala.

Con esto, ya podemos probar a disparar haciendo tap en pantalla.

Movimiento de la nave

Para el movimiento de la nave, haremos que en cada iteración del juego la nave se mueva según el valor de self.velocity (recordemos que actualizaremos esta variable en cada momento a partir del giro del dispositivo). Para ello, haremos que en el ciclo del juego la posición de la nave se modifique en función del delta time (tiempo transcurrido desde la iteración anterior) y de la velocidad actual (leída de la orientación del dispositivo). Otra cuestión a tener en cuenta, es que para mover la nave deberemos conocer cuáles son los límites de la pantalla, para evitar que se salga de la zona visible. Vamos a continuación a abordar cada uno de estos problemas.

Cálculo de los límites visibles de la escena

En primer lugar determinaremos cuáles son los límites de la escena en los que podremos ubicar los objetos sin que salgan de la zona visible. Como tanto la nave, como nuestros disparos y asteroides siempre se moverán dentro de un mismo plazo (plano XZ, es decir,en todos los casos y=0), nos bastará con definir un rectángulo dentro de dicho plano XZ que defina los límites del éste donde se desarrollará la acción.

TODO B06

  • En el método setupLimits, definiremos un CGRect con la zona de la escena 3D (dentro del plano XZ) donde se desarrollará la acción, y lo asignaremos a self.limits.
  • La anchura del rectángulo corresponderá al eje X, y la altura al eje Z.
  • Podemos utilizar el siguiente código para calcular los límites:

let projectedOrigin = view.projectPoint(SCNVector3Zero)
let unprojectedLeft = view.unprojectPoint(
        SCNVector3Make(0, 
                       projectedOrigin.y, 
                       projectedOrigin.z))
let halfWidth = CGFloat(abs(unprojectedLeft.x))
self.limits = CGRect(x: -halfWidth, y: -150, 
                     width: halfWidth*2, 
                     height: 200)

Aclaraciones sobre el código anterior: - Dado que la nave se situará en el eje X (Y=0, Z=0), proyectamos el punto (0, 0, 0) para averiguar sus coordenadas 2D proyectadas en la pantalla. - Una vez obtenidas las coordenadas, desproyectamos las mismas coordenadas pero con X=0 (lado izquierdo de la pantalla), para obtener el punto en la escena que corresponde a ese límite. - Al estar mirando la cámara a (0, 0, 0) habrá simetría, por lo que ya sabemos la coordenada X mínima y máxima. - El campo visual de la cámara en la vertical por defecto es fijo. Es decir, siempre veremos el mismo contenido de la escena en la vertical independientemente del dispositivo (a no ser que lo cambiemos para hacer que la referencia sea el horizontal). De esta forma, sabemos de antemano los límites de la escena que veremos en la coordenada Z, por lo que podemos establecerlos manualmente (de -150 a 50).

Forma alternativa de calcular los límites:

  • Podríamos también utilizar los parámetros de la cámara y el tamaño de pantalla para calcular los límites.
  • A partir de las dimensiones de la vista, podemos calcular su relación de aspecto (ancho / alto).
  • Conocimiendo el campo visual de la cámara en vertical, y la relación de aspecto, podríamos conocer el campo visual horizontal.
  • Con esta información, podríamos también determinar los límites del escenario en la horizontal y vertical.

Cálculo del delta time en el ciclo del juego.

En SceneKit, el método equivalente al update que es llamado en cada iteración del ciclo del juego es renderer(_:,updateAtTime:), que se implementa en el delegado del renderer de la escena SCNSceneRendererDelegate (recordemos que en nuestro caso el renderer es la propia clase SCNView). Para implementarlo haremos lo siguiente:

TODO B07

  • Implementar el protocolo SCNSceneRendererDelegate en nuestra clase.

TODO B08

  • En la inicialización de la vista (setupView) asignaremos nuestra clase (self) como delegado del renderer de la escena (view)
  • En nuestro renderer de la escena (view), activamos la propiedad isPlaying para que así comience a reproducirse el ciclo del juego. Al activar esta propiedad se empezará a llamar en cada ciclo de reloj al método renderer(_:,updateAtTime:).

En renderer(_:,updateAtTime:) podemos ver que nos llega el tiempo de reloj en que se realiza la actualización, pero no nos dice el tiempo transcurrido desde la iteración anterior (delta time), esto deberemos calcularlo nosotros. En la clase hemos introducido un campo previousUpdateTime con este fin.

TODO B09

  • En renderer(_:,updateAtTime:) calculamos el delta time, tomando como referencia el tiempo de actualización actual y el de la iteración anterior. Con el siguiente código, podemos conseguir que la primera vez que se llame, en la que previousUpdateTime todavía no tendrá valor asignado, se tome el tiempo actual como referencia:

let deltaTime = time - (previousUpdateTime ?? time)
previousUpdateTime = time

Movimiento de la nave

Por último, a partir de la velocidad de la nave en el movimiento lateral en el eje X (obtenida a partir del giro que nos da Core Motion) y del delta time, actualizaremos la posición de la nave.

TODO B10

  • Actualizamos la posición X de la nave aplicando la formula posNueva = posAnterior + velocidad * tiempo. Conociendo la velocidad a la que debe ir la nave en X, y el tiempo transcurrido (delta time) es sencillo actualizar la posición.
  • Dado que la velocidad la hemos obtenido de un valor de giro dado en radianes, estará en relación con la inclinación del móvil, pero tendrá un valor bastante bajo. Por ello, en la actualización de la posición es conveniente multiplicarla por un factor que nos permita aumentarla (por ejemplo, un factor de 200.0 puede resultar adecuado.)
  • Una vez calculada la nueva posición, debemos asegurarnos de que no se salga de los límites de la pantalla (limits.minX y limits.maxX calculados anteriormente). En caso de salirse, corregiremos la posición.
  • Para mejorar la experiencia, podemos hacer que al girar el móvil la nave también gire hacia uno u otro lado, lo que hará que sea más realista el movimiento de desplazamiento lateral. Para ello, podemos modificar el ángulo Z (eulerAngles) de la nave justo en el sentido opuesto de lo que nos indica velocity (que recordemos que viene del giro del dispositivo en radianes).