Advertisement
  1. Code
  2. SpriteKit

Crea invasores del espacio con Swift y Sprite Kit: finalizando el juego

Scroll to top
Read Time: 16 min
This post is part of a series called Create Space Invaders with Swift and Sprite Kit.
Create Space Invaders With Swift and SpriteKit: Implementing Gameplay

() translation by (you can also view the original English article)

Final product imageFinal product imageFinal product image
What You'll Be Creating

En las partes previas de ésta serie, hicimos que los invasores se movieran, que el jugador y los invasores dispararan balas, e implementamos la detección de colisión. En la cuarta y última parte de ésta serie, agregaremos la capacidad para mover al jugador usando el acelerómetro, manejar los niveles, y asegurar que el jugador muera cuando es alcanzado por una bala. Comencemos.

1. Finalizando la clase Player

Paso 1: Agregando Propiedades

Agrega las siguientes propiedades a la clase Player debajo de la propiedad canFire.

1
private var invincible = false
2
private var lives:Int = 3 {
3
    didSet {
4
        if(lives < 0){
5
            kill()
6
        }else{
7
            respawn()
8
         }
9
    }
10
}

La propiedad invincible será usada para hacer al jugador temporalmente invencible cuando pierda una vida. La propiedad lives es el número de vidas que el jugador tiene antes de morir.

Estamos usando un observador de propiedad en la propiedad lives, que será llamado cada vez que se establezca su valor. El observador didSet es llamado inmediatamente después de que se establezca el nuevo valor de la propiedad. Al hacer ésto, cada vez que decrementamos la propiedad lives automáticamente revisa si lives es menor a cero, llamando al método kill si es verdadero. Si al jugador le quedan vidas, el método respawn es invocado. Observadores de propiedad son muy útiles y pueden ahorra mucho código extra.

Paso 2: respawn

El método respawn hace al jugador invencible por una pequeña cantidad de tiempo y desaparece y aparece al jugador  para indicar que es temporalmente invencible. La implementación del método respawn se ve así:

1
func respawn(){
2
    invincible = true
3
    let fadeOutAction = SKAction.fadeOutWithDuration(0.4)
4
    let fadeInAction = SKAction.fadeInWithDuration(0.4)
5
    let fadeOutIn = SKAction.sequence([fadeOutAction,fadeInAction])
6
    let fadeOutInAction = SKAction.repeatAction(fadeOutIn, count: 5)
7
    let setInvicibleFalse = SKAction.runBlock(){
8
        self.invincible = false
9
    }
10
    runAction(SKAction.sequence([fadeOutInAction,setInvicibleFalse]))
11
        
12
}

Establecemos invicible en true y creamos un numero de objetos SKAction.  A estas alturas, deberías estar familiarizado con la forma que funciona la clase SKAction.

Paso 3: die

El método die es simple. Revisa si invincible es false y, si es así, decrementa la variable lives.

1
 func die (){
2
    if(invincible == false){
3
        lives -= 1
4
    }
5
}

Paso 4: kill

El método kill resetea invaderNum a 1 y regresa al usuario a la StartGameScene para que puedan comenzar un nuevo juego.

1
func kill(){
2
    invaderNum = 1
3
    let gameOverScene = StartGameScene(size: self.scene!.size)
4
    gameOverScene.scaleMode = self.scene!.scaleMode
5
    let transitionType = SKTransition.flipHorizontalWithDuration(0.5)
6
    self.scene!.view!.presentScene(gameOverScene,transition: transitionType)
7
}

Éste código debería serte familiar pues es casi idéntico al código que usamos para mover a la GameScene desde la StartGameScene. Nota que obligamos a liberar  scene para accesar a las propiedades size  y scaleMode de la escena.

Ésto completa la clase Player. Ahora necesitamos llamar a los métodos die y kill en el método didBeginContact(_:).

1
func didBeginContact(contact: SKPhysicsContact) {
2
    ...
3
    if ((firstBody.categoryBitMask & CollisionCategories.Player != 0) &&
4
            (secondBody.categoryBitMask & CollisionCategories.InvaderBullet != 0)) {
5
        player.die()
6
    }
7
        
8
    if ((firstBody.categoryBitMask & CollisionCategories.Invader != 0) &&
9
            (secondBody.categoryBitMask & CollisionCategories.Player != 0)) {
10
         player.kill()
11
    }
12
        
13
}

Podemos ahora probar todo.  Una fórma rápida de probar el método die es al poner como comentario moveInvaders en el método update(_:).  Después de que el jugador muere y reaparece tres veces, deberías ser retornado a StartGameScene.

Para probar el método kill, asegúrate de que el llamado de moveInvaders no esté como comentario. Establece la propiedad invaderSpeed a un valor alto, por ejemplo 200. Los invasores deben llegar al jugador muy rápidamente, lo que resulta en una muerte instantánea. Cambia invaderSpeed otra vez a 2 una vez que hayas terminado el testeo.

2. Terminando los Invasores que Disparan

Como está el juego en éste momento, los invasores de la fila de abajo pueden disparar balas. Ya tenemos la detección de colisión para cuando la bala de un jugador impacta a un invasor. En éste paso, removeremos un invasor que es impactado por una bala y agregamos al invasor una fila arriba al arreglo de invasores que pueden disparar. Agrega lo siguiente al método didBeginContact(_:).

1
func didBeginContact(contact: SKPhysicsContact) {
2
    ...
3
    if ((firstBody.categoryBitMask & CollisionCategories.Invader != 0) &&
4
        (secondBody.categoryBitMask & CollisionCategories.PlayerBullet != 0)){
5
        if (contact.bodyA.node?.parent == nil || contact.bodyB.node?.parent == nil) {
6
            return
7
        }
8
                
9
    let invadersPerRow = invaderNum * 2 + 1
10
    let theInvader = firstBody.node? as Invader
11
    let newInvaderRow = theInvader.invaderRow - 1
12
    let newInvaderColumn = theInvader.invaderColumn
13
    if(newInvaderRow >= 1){
14
    self.enumerateChildNodesWithName("invader") { node, stop in
15
        let invader = node as Invader
16
            if invader.invaderRow == newInvaderRow && invader.invaderColumn == newInvaderColumn{
17
                self.invadersWhoCanFire.append(invader)
18
                    stop.memory = true
19
                }
20
            }
21
        }
22
    let invaderIndex = findIndex(invadersWhoCanFire,valueToFind: firstBody.node? as Invader)
23
        if(invaderIndex != nil){
24
            invadersWhoCanFire.removeAtIndex(invaderIndex!)
25
    }
26
    theInvader.removeFromParent()
27
    secondBody.node?.removeFromParent()
28
        
29
    } 
30
}

Hemos removido el comando NSLog y primero checamos si contact.bodyA.node?.parent y contact.bodyB.node?.parent no son nil. Serán nil si ya hemos procesado éste contacto. En ese caso, regresamos de la función.

Calculamos el invadersPerRow como lo hicimos antes y establecemos theInvader en firstBody.node?, pasándolo a un Invader. Posteriormente, obtenemos newInvaderRow al restar 1 y newInvaderColumn, que permanece igual.

Solo queremos permitir a invasores que disparen si newInvaderRow es mayor o igual a 1, de otra manera estaríamos tratando de establecer un invasor en la fila 0 que sea capaz de disparar. No hay fila 0 así que ésto causaría un error.

Posteriormente, enumeramos los invasores, buscando el invasor que tiene la  fila y columna correcta. Una vez que es encontrado, lo agregamos al arreglo invadersWhoCanFire y ponemos stop.memory en true, para que la enumeración se detenga pronto.

Necesitamos encontrar al invasor que fue alcanzado con una bala en el arreglo invadersWhoCanFire así que podemos removerlo. Normalmente, arreglos tienen alguna clase de funcionalidad como un método indexOf o algo similar para lograr ésto. Al momento de escribir éste artículo, no hay tal método para arreglos en el lenguaje Swift. La librería estándar Swift define una función find que pudiéramos usar, pero encontré un método en las secciones de genéricos en la Guía del Lenguaje de Programación Swift que lograría lo que necesitamos. La función es propiamente llamada findIndex. Agrega lo siguiente a la parte de abajo de GameScene.swift.

1
func findIndex<T: Equatable>(array: [T], valueToFind: T) -> Int? {
2
    for (index, value) in enumerate(array) {
3
        if value == valueToFind {
4
            return index
5
        }
6
    }
7
        return nil
8
}

Si eres curioso sobre cómo funciona ésta función, entonces te recomiendo que leas más sobre genéricos en la Guía del Lenguaje de Programación Swift.

Ahora que tenemos un método que podemos usar para encontrar al invasor, lo invocamos, pasando el arreglo invadersWhoCanFire y theInvader. Revisamos si invaderIndex no es igual a nil y removemos al invasor del arreglo invadersWhoCanFire usando el método removeAtIndex(index:Int).

Ahora puedes probar si funciona como debería. Una forma fácil sería poner como comentario el llamado a player.die en el método didBeginContact(_:). Asegúrate de remover el comentario cuando termines el testeo. Nota que el programa se congela si matas a todos los invasores. Arreglaremos ésto en el próximo paso.

La aplicación se congela, porque tenemos un SKAction repeatActionForever(_:) llamando a invasores para disparar balas.  A éstas alturas no quedan invasores que disparan balas así que el juego se congela. Podemos arreglar ésto al revisar la propiedad isEmpty en el arreglo invadersWhoCanFire. Si el arreglo está vacío, el nivel se terminó. Ingresa lo siguiente en el método fireInvaderBullet.

1
func fireInvaderBullet(){
2
    if(invadersWhoCanFire.isEmpty){
3
        invaderNum += 1
4
        levelComplete()
5
    }else{
6
        let randomInvader = invadersWhoCanFire.randomElement()
7
        randomInvader.fireBullet(self)
8
    }
9
}

El nivel está completo, lo que significa que incrementamos invaderNum, que es usado para los niveles. También invocamos levelComplete, que aún necesitamos para crear en los próximos pasos.

3. Completando un Nivel

Necesitamos tener un número definido  de niveles. Si no, despues de varias rondas tendremos tantos invasores que no cabrán en la pantalla. Agregamos una propiedad maxLevels a la clase GameScene.

1
class GameScene: SKScene, SKPhysicsContactDelegate{
2
    ...
3
    let player:Player = Player()
4
    let maxLevels = 3

Ahora agregamos el método levelComplete en la parte inferior de GameScene.swift.

1
func levelComplete(){
2
    if(invaderNum <= maxLevels){
3
        let levelCompleteScene = LevelCompleteScene(size: size)
4
        levelCompleteScene.scaleMode = scaleMode
5
        let transitionType = SKTransition.flipHorizontalWithDuration(0.5)
6
        view?.presentScene(levelCompleteScene,transition: transitionType)
7
    }else{
8
        invaderNum = 1
9
        newGame()
10
    }
11
}

Primero checamos para ver si invaderNum es menor o igual a maxLevels que establecimos. Si es así, transicionamos a la LevelCompleteScene, de otra manera reseteamos invaderNum a 1 y llamamos newGame. LevelCompleteScene no existe aún y tampoco el método newGame así que enfrentemos ésto de una vez en los dos próximos pasos.

4. Implementando la clase LevelCompleteScene

Crea una nueva Clase Cocoa Touch llamada LevelCompleteScene que es una subclase de SKScene. La implementación de la clase se ve así:

1
import Foundation
2
import SpriteKit
3
4
class LevelCompleteScene:SKScene{
5
6
    override func didMoveToView(view: SKView) {
7
        self.backgroundColor = SKColor.blackColor()
8
        let startGameButton = SKSpriteNode(imageNamed: "nextlevelbtn")
9
        startGameButton.position = CGPointMake(size.width/2,size.height/2 - 100)
10
        startGameButton.name = "nextlevel"
11
        addChild(startGameButton)
12
    }
13
    
14
    override func touchesBegan(touches: NSSet, withEvent event: UIEvent) {
15
        /* Called when a touch begins */
16
        for touch: AnyObject in touches {
17
            let touchLocation = touch.locationInNode(self)
18
            let touchedNode = self.nodeAtPoint(touchLocation)
19
            if(touchedNode.name == "nextlevel"){
20
                let gameOverScene = GameScene(size: size)
21
                gameOverScene.scaleMode = scaleMode
22
                let transitionType = SKTransition.flipHorizontalWithDuration(0.5)
23
                view?.presentScene(gameOverScene,transition: transitionType)            }
24
            
25
            
26
        }
27
    }
28
}

La implementación es idéntica a la clase StartGameScreen, excepto que establecemos la propiedad name de startGameButton en "nextlevel". Éste código debería serte familiar. Si no, entonces dirígete a la primera parte de éste tutorial para que lo recuerdes.

5. newGame

El método newGame simplemente transiciona otra vez a la StartGameScene. Agrega lo siguiente a la parte inferior de GameScene.swift.

1
func newGame(){
2
    let gameOverScene = StartGameScene(size: size)
3
    gameOverScene.scaleMode = scaleMode
4
    let transitionType = SKTransition.flipHorizontalWithDuration(0.5)
5
    view?.presentScene(gameOverScene,transition: transitionType)
6
}

Si pruebas la aplicación, puedes jugar unos cuantos niveles o perder unos cuantos juegos, pero el jugador no tiene forma de moverse y ésto lo hace un juego aburrido. Arreglemos esto en el próximo paso.

6. Moviendo al Jugador Usando el Acelerómetro

Vamos a querer usar el acelerómetro para mover al jugador. Primero necesitamos importar el framework CoreMotion. Añade un comando import para el framework en la parte superior de GameScene.swift.

1
import SpriteKit
2
import CoreMotion

También necesitamos un par de nuevas propiedades.

1
let maxLevels = 3
2
let motionManager: CMMotionManager = CMMotionManager()
3
var accelerationX: CGFloat = 0.0

Después, agregamos un método setupAccelermoeter en la parte inferior de GameScene.swift.

1
func setupAccelerometer(){
2
    motionManager.accelerometerUpdateInterval = 0.2
3
    motionManager.startAccelerometerUpdatesToQueue(NSOperationQueue.currentQueue(), withHandler: {
4
    (accelerometerData: CMAccelerometerData!, error: NSError!) in
5
    let acceleration = accelerometerData.acceleration
6
    self.accelerationX = CGFloat(acceleration.x)
7
    })
8
}

Aquí establecemos el accelerometerUpdateInterval, que es el intervalo en segundos para proporcionar actualizaciones al manejador. Encontré que 0.2 funciona bien, puedes intentar diferentes valores si quieres. Dentro del manejador, una clausura o función que maneja variables independientes, obtenemos el accelerometerData.accelereation, que es una estructura de tipo CMAcceleration.

1
struct CMAcceleration {
2
    var x: Double
3
    var y: Double
4
    var z: Double
5
    init()
6
    init(x x: Double, y y: Double, z z: Double)
7
}

Estamos únicamente interesados en la propiedad x y usamos conversión de tipo numérica para pasarla a CGFloat para nuestra propiedad accelerationX.

Ahora que tenemos definida la propiedad accelerationX, podemos mover al jugador. Hacemos ésto en el método didSimulatePhysics. Agrega lo siguiente en la parte inferior de GameScene.swift.

1
override func didSimulatePhysics() {
2
     player.physicsBody?.velocity = CGVector(dx: accelerationX * 600, dy: 0)
3
}

Invoca setupAccelerometer en didMoveToView(_:) y deberías poder mover al jugador con el acelerómetro. Sólo hay un problema. El jugador puede moverse fuera de la pantalla a cualquiera de los dos lados y lleva unos cuantos segundos el regresarlo. Podemos arreglarlo usando el motor de física y colisiones. Haremos ésto en el próximo paso.

1
override func didMoveToView(view: SKView) {
2
    ...
3
    setupInvaders()
4
    setupPlayer()
5
    invokeInvaderFire()
6
    setupAccelerometer()
7
}

7. Restringiendo el Movimiento del Jugador

Como se mencionó en el paso anterior, el jugador puede moverse fuera de la pantalla. Ésto se soluciona usando el motor de física de Sprite Kit. Primero, agrega una nueva CollisionCategory llamada EdgeBody.

1
struct CollisionCategories{
2
    static let Invader : UInt32 = 0x1 << 0
3
    static let Player: UInt32 = 0x1 << 1
4
    static let InvaderBullet: UInt32 = 0x1 << 2
5
    static let PlayerBullet: UInt32 = 0x1 << 3
6
    static let EdgeBody: UInt32 = 0x1 << 4
7
}

Establece ésta como la collisionBitMask del jugador en su método init.

1
override init() {
2
    ...
3
    self.physicsBody?.categoryBitMask = CollisionCategories.Player
4
    self.physicsBody?.contactTestBitMask = CollisionCategories.InvaderBullet | CollisionCategories.Invader
5
    self.physicsBody?.collisionBitMask = CollisionCategories.EdgeBody
6
    animate()
7
}

Finalmente, creamos un physicsBody en la misma escena. Añade lo siguiente en el método didMoveToView(view: SKView) en GameScene.swift.

1
 override func didMoveToView(view: SKView) {
2
        self.physicsWorld.gravity=CGVectorMake(0, 0)
3
        self.physicsWorld.contactDelegate = self
4
        self.physicsBody = SKPhysicsBody(edgeLoopFromRect: frame)
5
        self.physicsBody?.categoryBitMask = CollisionCategories.EdgeBody
6
}

Inicializamos un cuerpo de física al invocar init(edgeLoopFromRect:), pasando frame de la escena. El inicializador crea un bucle de borde del marco de la escena. Es importante notar que un borde no tiene volumen o masa y siempre es tratado como si la propiedad dinámica fuera igual a false. Los bordes pueden también colisionar sólo con cuerpos físicos basados en volumen, que es nuestro jugador.

También establecemos categoryBitMast en CollisionCategories.EdgeBody. Si pruebas la aplicación, podrías notar que tu nave ya no puede moverse hasta afuera de la pantalla, pero a veces rota. Cuando un cuerpo físico choca con otro cuerpo físico, es posible que ésto resulte en una rotación. Éste es el comportamiento por defecto. Para remediar ésto, establecemos allowsRotation en false en Player.swift.

1
override init() {
2
    ...
3
    self.physicsBody?.collisionBitMask = CollisionCategories.EdgeBody
4
    self.physicsBody?.allowsRotation = false
5
    animate()
6
}

8. Campo Estrellas

Paso 1: Crear el Campo de Estrellas

El cuerpo tiene un campo de estrellas en movimiento en el fondo. Podemos crear el campo de inicio usando el generador de partículas de Sprite Kit.

Crea un nuevo archivo y selecciona Resource de la sección iOS. Elige SpriteKit Particle File como la plantilla y da click en Next. Para Particle template elige rain y guárdala como StarField. Da click en Create para abrir el archivo en el editor. Para ver las opciones, abre el SKNode Inspector en la parte derecha.


En lugar de pasar cada ajuste aquí, lo que podría tomar mucho tiempo, sería mejor leer la documentación para aprender sobre cada ajuste individual. No entraré en detalles sobre los ajustes del campo inicial tampoco. Si estás interesado, abre el archivo en Xcode y observa los ajuste que utilicé.

Paso 2: Añadiendo el Campo Estrella a las Escenas

Agrega lo siguiente a didMoveToView(_:) en StartGameScene.swift.

1
override func didMoveToView(view: SKView) {
2
    backgroundColor = SKColor.blackColor()
3
    let starField = SKEmitterNode(fileNamed: "StarField")
4
    starField.position = CGPointMake(size.width/2,size.height/2)
5
    starField.zPosition = -1000
6
    addChild(starField)
7
}

Usamos un SKEmitterNode para cargar el archivo StarField.sks, establecemos su position y le damos una  zPosition baja. La razón para la zPosition baja es asegurarnos de que no impida al usuario presionar el botón inicio. El sistema  de partículas genera cientos de partículas así que al definirlo realmente bajo resolvemos ese problema. Deberías saber también que puedes configurar manualmente todas las propiedades de partículas en un SKEmitterNode, aunque es mucho más fácil usar el editor para crear un archivo .sks y cargarlo en el runtime.

Ahora, agregamos el campo de estrellas a GameScene.swift y LevelCompleteScene.swift. El código es exactamente el mismo que el de arriba.

9. Implementando la Clase PulsatingText

Paso 1: Crea la Clase PulsatingText

La StartGameScene y LevelCompleteScene tienen texto que crece y decrece reiteradamente. Pondremos una subclase SKLabeNode y usamos un par de instancias SKAction para lograr éste efecto.

Crea una nueva Clase Cocoa Touch  que es una subclase de SKLabelNode, nómbrala PulsatingText, y agrégale el siguiente código.

1
import UIKit
2
import SpriteKit
3
4
class PulsatingText : SKLabelNode {
5
    
6
    func setTextFontSizeAndPulsate(theText: String, theFontSize: CGFloat){
7
        self.text = theText;
8
        self.fontSize = theFontSize
9
        let scaleSequence = SKAction.sequence([SKAction.scaleTo(2, duration: 1),SKAction.scaleTo(1.0, duration:1)])
10
        let scaleForever = SKAction.repeatActionForever(scaleSequence)
11
        self.runAction(scaleForever)
12
    }
13
}

Una de las primeras cosas de las que pudieras haber notado es que no hay inicializador. Si tu subclase no define un inicializador designado, automáticamente hereda todos los inicializadores designados de su superclase.

Tenemos un método setTextFontSizeAndPulsate(theText:theFontSize:), que hace exactamente lo que dice. Establece las propiedades text y fontSize de SKLabelNode, y crea un número de instancias SKAction para escalar el texto más grande y luego regresar a su tamaño normal, creando un efecto de desaparecer y aparecer repetidamente.

Paso 2: Agrega PulsatingText a StartGameScene

Agrega el siguiente código a StartGameScene.swift en didMoveToView(_:).

1
 override func didMoveToView(view: SKView) {
2
    backgroundColor = SKColor.blackColor()
3
    let invaderText = PulsatingText(fontNamed: "ChalkDuster")
4
    invaderText.setTextFontSizeAndPulsate("INVADERZ", theFontSize: 50)
5
    invaderText.position = CGPointMake(size.width/2,size.height/2 + 200)
6
    addChild(invaderText)
7
}

Inicializamos una instancia PulsatingText, invaderText, e invocamos setTextFontSizeAndPulsate(theText:theFontSize:) en él. Posteriormente establecemos su position y la agregamos a la escena.

Paso 3: Agregamos PulsatingText a LevelCompleteScene

Agrega el siguiente codigo a LevelCompleteScene.swift en didMoveToView(_:).

1
 override func didMoveToView(view: SKView) {
2
    self.backgroundColor = SKColor.blackColor()
3
    let invaderText = PulsatingText(fontNamed: "ChalkDuster")
4
    invaderText.setTextFontSizeAndPulsate("LEVEL COMPLETE", theFontSize: 50)
5
    invaderText.position = CGPointMake(size.width/2,size.height/2 + 200)
6
    addChild(invaderText)
7
}

Ésto es exactamente los mismo que en el paso anterior. Sólo el texto que estamos pasando es diferente.

10. Llevando el Juego Más Lejos

Ésto completa el juego. Tengo algunas sugerencias de cómo podrías expandir el juego. Dentro del directorio images, hay tres imágenes de invasores diferentes. Cuando estás añadiendo invasores a la escena, eliges al azar una de éstas tres imágenes. Necesitarás actualizar el inicializador del invasor para aceptar una imagen como parámetro. Consulta la clase Bullet para darte una idea.

Hay también una imagen de un OVNI. Trata de hacer aparecer ésto y moverlo en la escena cada quince segundos aproximadamente. Si el jugador lo golpea, dále una vida extra. Puedes querer limitar el número de vidas que pueden tener si haces ésto. Finalmente, intenta hacer un display para las vidas de los jugadores.

Éstas son sólo algunas sugerencias. Intenta y haz el juego tu sólo.

Conclusión

Ésto cierra la serie. Deberías tener un juego que se asemeja mucho al original de los Invasores del Espacio. Espero que te haya parecido útil éste tutorial y hayas aprendido algo nuevo. Gracias por leer.

¡Sé el primero en conocer las nuevas traducciones–sigue @tutsplus_es en Twitter!

Advertisement
Did you find this post useful?
Want a weekly email summary?
Subscribe below and we’ll send you a weekly email summary of all new Code tutorials. Never miss out on learning about the next big thing.
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.