Este artigo foi publicado no equinociOS, promovido pelo Cocoaheads-Br.
Durante este texto, iremos recriar juntos uma versão minimalista do fantástico jogo 2 Cars, mas em um ambiente tridimensional. Com isso, aprenderemos sobre:
- Física e colisões
- Texturas e modelos 3D
- Sistemas de partícula
- Animações e interação com o usuário
Para acompanhar, não é necessário conhecimento prévio de Swift, apenas de programação básica, algumas noções de geometria, e um pouco de conhecimento do XCode.
Comece tendo certeza de que seu XCode está atualizado, pelo menos na versão Version 7.2. Crie um novo projeto, do tipo Game, escolha Swift para a linguagem, SceneKit como tecnologia, e Universal nos dispositivos. Salve onde preferir.
No projeto criado, você poderá encontrar o arquivo GameViewController.swift. Abra-o e vamos começar!
Capítulo 1: luzes, câmera e ação!
Neste capítulo, aprendemos a criar câmeras, posicionar elementos, criar materiais e adicionar objetos à cena.
Apague tudo na classe GameViewController e deixe apenas:
import UIKit import QuartzCore import SceneKit class GameViewController: UIViewController { }
Em seguida, adicione variáveis pra câmera, pro chão e pra nossa cena:
var camera:SCNNode! var ground:SCNNode! var scene:SCNScene! var sceneView:SCNView!
Adicione uma função para criar a cena:
func createScene () { scene = SCNScene() sceneView = self.view as! SCNView sceneView.scene = scene sceneView.allowsCameraControl = true sceneView.showsStatistics = true sceneView.playing = true sceneView.autoenablesDefaultLighting = true }
Adicione uma função responsável por criar a câmera. Note que .position é a propriedade que define a posição tridimensional dela, e eulerAngles (medidos em radianos) definem a orientação (pra onde ela aponta). Os fotógrafos amadores poderão se divertir com os demais parâmetros disponíveis para as lentes.
func createCamera () { camera = SCNNode() camera.camera = SCNCamera() camera.position = SCNVector3(x: 0, y: 25, z: -18) camera.eulerAngles = SCNVector3(x: -1, y: 0, z: 0) scene.rootNode.addChildNode(camera) }
Adicione uma função responsável por criar o chão. SCNFloor cria um plano infinito fixado inicialmente na origem. Note que vamos dar uma tonalidade amarela pra ele usando um SCNMaterial.
func createGround () { let groundGeometry = SCNFloor() groundGeometry.reflectivity = 0.5 let groundMaterial = SCNMaterial() groundMaterial.diffuse.contents = UIColor.yellowColor() groundGeometry.materials = [groundMaterial] ground = SCNNode(geometry: groundGeometry) scene.rootNode.addChildNode(ground) }
E junte tudo no viewDidLoad():
override func viewDidLoad() { super.viewDidLoad() createScene() createCamera() createGround() }
Compile e rode e veja nosso cenário inicial. Use gestos para circular pelo terreno tridimensional.
Capítulo 2: A jornada do herói
Neste capítulo, aprendemos a criar ou importar objetos tridimensionais, animá-los e a interagir com o usuário.
Vamos criar um tímido cenário? Faremos uma faixa na nossa rodovia! Adicione este método e chame-o no viewDidLoad:
func createScenario() { for i in 20...70 { let laneMaterial = SCNMaterial() if i%5<2 { // se a divisao de i por 5 for igual a 0 ou 1 laneMaterial.diffuse.contents = UIColor.clearColor() } else { // se a divisao de i por 5 for 2,3 ou 4 laneMaterial.diffuse.contents = UIColor.blackColor() } let laneGeometry = SCNBox(width: 0.2, height: 0.1, length: 1, chamferRadius:0) laneGeometry.materials = [laneMaterial] let lane = SCNNode(geometry: laneGeometry) lane.position = SCNVector3(x: 0, y: 0, z: -Float(i)) scene.rootNode.addChildNode(lane) let moveDown = SCNAction.moveByX(0, y:0 , z: 5, duration: 0.3) let moveUp = SCNAction.moveByX(0, y: 0, z: -5, duration: 0) let moveLoop = SCNAction.repeatActionForever(SCNAction.sequence([moveDown, moveUp])) lane.runAction(moveLoop) } }
Ok, tem muita coisa acontecendo aqui, vamos por partes. Estamos dentro de um loop, no qual i vai assumir todos os valores inteiros entre 20 e 70. Em cada iteração, colocamos um pequeno tijolinho, preto ou transparente, dependendo de i. Note que isso vai colocar 3 tijolinhos pretos, e 2 transparentes. Em seguida, adicionamos uma animação ao conjunto. Todos os tijolinhos estão sujeitos a duas animações: moveUp e moveDown. A animação moveLoop combina as duas (usando o método sequence), e as repete para sempre (usando repeatActionForever). Por fim, runAction, que pode ser chamado a qualquer SCNNode, aplica a animação em cada um de nossos tijolinhos. Como cada faixa tem 3 tijolinhos pretos + 2 transparentes, nós andamos 5 pra baixo em 0.3 segundos, e instantaneamente subimos 5 pra dar a impressão de que é um movimento contínuo. Tente remover moveUp como experimento. Eis o resultado até agora:
Vamos adicionar nosso personagem principal? Adicione esta variável junto com as outras:
var car:SCNNode!
Em seguida, adicione a função createPlayer, e chame-a no viewDidLoad:
func createPlayer(){ car = SCNNode(geometry: SCNBox(width: 3, height: 2, length: 3, chamferRadius: 0.2)) let material = SCNMaterial() material.reflective.contents = UIColor.blueColor() material.diffuse.contents = UIColor.lightGrayColor() car.geometry!.materials = [material] scene.rootNode.addChildNode(car) car.position = SCNVector3(-4,1,-25) // colocamos ele na frente da camera }
Note que precisamos fazer um ajuste de translação para que nosso modelo se encaixasse no cenário. Rode o código, veja o carrinho aparecendo. Vamos adicionar um escapamento? Clique com o botão direito na pasta de seu projeto, vá em Novo Arquivo… -> Recurso -> SceneKit Particle System e use o template Smoke ou fumaça. Brinque como quiser com os parâmetros. Segue um print de como deixar o sistema bacaninha:
Agora adicione este código no final da função createPlayer:
let particleSystem = SCNParticleSystem(named: "SmokeParticles", inDirectory: nil) let exausterNode = SCNNode(geometry: SCNBox(width: 0, height: 0, length: 0, chamferRadius: 1)) exausterNode.position = SCNVector3(0,0,1.5) exausterNode.addParticleSystem(particleSystem!) car.addChildNode(exausterNode)
Vamos interagir com ele? Adicione a variável var onLeftLane:Bool = true, e adicione este código ao seu viewDidLoad:
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action:"move:") let swipeGestureRecognizer = UISwipeGestureRecognizer(target: self, action: "move:") scnView.addGestureRecognizer(tapGestureRecognizer) scnView.addGestureRecognizer(swipeGestureRecognizer)
Em seguida, vamos implementar o move::
func move(sender: UITapGestureRecognizer){ let position = sender.locationInView(self.view) //localizacao do gesto let right = position.x > self.view.frame.size.width/2 // foi na esquerda ou direita? if right == onLeftLane { // Pra onde vamos let moveSideways:SCNAction = SCNAction.moveByX((right ? 8:-8), y: 0, z: 0, duration: 0.2) moveSideways.timingMode = SCNActionTimingMode.EaseInEaseOut // suaviza a animacao car.runAction(moveSideways) onLeftLane = !right // atualiza a posicao do carro } }
Rode. O resultado deve ser algo como:
Capítulo 3: Obstáculos e recompensas
Neste capítulo, aprendemos a criar inimigos, física e colisões.
Vamos começar definindo quem serão nossas entidades capazes de interagir fisicamente entre si. Insira este enum em seu ViewController:
enum PhysicsCategory: Int { case Player=1, Mob=2, Ground=4, Wall=8 }
Veja que os valores são binários, pois estamos simulando máscaras de bits. Em seguida, na função createGround, vamos dar um formato e um corpo pro nosso chão:
func createGround () { let groundGeometry = SCNFloor() groundGeometry.reflectivity = 0.5 let groundMaterial = SCNMaterial() groundMaterial.diffuse.contents = UIColor.yellowColor() groundGeometry.materials = [groundMaterial] ground = SCNNode(geometry: groundGeometry) ground.physicsBody = SCNPhysicsBody(type: .Static, shape: SCNPhysicsShape(geometry: groundGeometry, options: nil)) ground.physicsBody!.categoryBitMask = PhysicsCategory.Ground.rawValue ground.physicsBody!.contactTestBitMask = PhysicsCategory.Mob.rawValue ground.physicsBody!.collisionBitMask = PhysicsCategory.Mob.rawValue scene.rootNode.addChildNode(ground) }
Vamos rever nossos conceitos. SCNFloor, que é uma subclasse de SCNGeometry, contém uma descrição geométrica (uma equação paramétrica, no caso) que serve para desenhar o objeto na tela. SCNNode é a classe que nos ajuda a compor nossa cena, estabelecendo uma hierarquia entre os objetos tridimensionais. SCNPhysicsShape é a casca do objeto, é o que será usado para que as colisões sejam testadas, simulando um volume sólido. SCNPhysicsBody é o corpo físico, onde podemos atribuir campos gravitacionais, eletromagnéticos, atrito, velocidade, aceleração e outras propriedades físicas.
No nosso groundBody, criamos 3 máscaras:
- categoryBitMask: nos ajuda a definir a qual categoria o objeto pertence.
- contactTestBitMask: define com quais objetos os testes de contato são feitos (veremos isso mais adiante).
- collisionBitMask: contra quais outras categorias esse objeto colide.
Vamos criar alguns inimigos então? Adicione o método spawnEnemyMob():
func spawnEnemyMob() { let enemyMaterial = SCNMaterial() enemyMaterial.reflective.contents = UIColor.redColor() let enemy = SCNNode(geometry: SCNBox(width: 3, height: 3, length: 3, chamferRadius: 0.2)) enemy.geometry!.materials = [enemyMaterial] enemy.physicsBody = SCNPhysicsBody(type: .Dynamic, shape: SCNPhysicsShape(geometry: enemy.geometry!, options: nil)) enemy.physicsBody!.velocity = SCNVector3Make(0, 0, 30) enemy.position = SCNVector3(Int(arc4random_uniform(2)*8)-4,2,-100) enemy.physicsBody!.categoryBitMask = PhysicsCategory.Mob.rawValue enemy.physicsBody!.contactTestBitMask = PhysicsCategory.Player.rawValue enemy.physicsBody!.collisionBitMask = PhysicsCategory.Player.rawValue | PhysicsCategory.Ground.rawValue scene.rootNode.addChildNode(enemy) }
Quase nada de novo aqui. Velocity é a velocidade inicial em que nosso objeto se encontrará quando aparecer na cena. Vamos invocar esses inimigos? Chame no seu viewDidLoad():
spawnEnemyMob() NSTimer.scheduledTimerWithTimeInterval(7, target: self, selector: "spawnEnemyMob", userInfo: nil, repeats: true)
Note que criamos um inimigo, e programamos pra adicionar outro a cada 7 segundos. Rode o código, você deverá ver algo como:
Notou que o bloco passou atravessando o carro? Precisamos adicionar um corpo ao nosso jogador. Adicione este código na sua função createPlayer (antes de car.addChildNode(exausterNode)):
car.physicsBody = SCNPhysicsBody(type: .Kinematic, shape: SCNPhysicsShape(node: car, options: nil))
Rode de novo, veja que existe a colisão. Vamos adicionar um objeto agora para capturar os inimigos e engatilhar a lógica de criação dos próximos. Adicione a variável var wall:SCNNode!, a chamada createWall() no seu viewDidLoad, e crie a função:
func createWall () { wall = SCNNode(geometry:SCNBox(width: 200, height: 200, length: 3, chamferRadius: 0)) wall.physicsBody = SCNPhysicsBody(type: .Static, shape: SCNPhysicsShape(geometry: wall.geometry!, options: nil)) wall.physicsBody!.categoryBitMask = PhysicsCategory.Wall.rawValue wall.physicsBody!.contactTestBitMask = PhysicsCategory.Mob.rawValue wall.physicsBody!.collisionBitMask = PhysicsCategory.Mob.rawValue scene.rootNode.addChildNode(wall) }
Vamos agora detectar as colisões. Adicione a interface SCNPhysicsContactDelegate ao seu view controller, assim:
class GameViewController: UIViewController, SCNPhysicsContactDelegate
Em seguida, vamos criar a função que recebe os avisos de colisões:
func physicsWorld(world: SCNPhysicsWorld, didBeginContact contact: SCNPhysicsContact) { if (contact.nodeA != ground && contact.nodeB != ground) { if (contact.nodeA == car || contact.nodeB == car) { let enemyNode = contact.nodeA == car ? contact.nodeB : contact.nodeA if (enemyNode.parentNode != nil) { enemyNode.removeFromParentNode() spawnEnemyMob() } } else if (contact.nodeA == wall || contact.nodeB == wall) { let enemyNode = contact.nodeA == wall ? contact.nodeB : contact.nodeA if (enemyNode.parentNode != nil) { enemyNode.removeFromParentNode() spawnEnemyMob() } } } }
Estamos verificando se a colisão é com nosso carro, ou com o muro que está escondido atrás da câmera. Se for com um deles, removemos o inimigo e criamos outro. Rode novamente, desvie dos inimigos!
Epílogo: Pra onde ir agora
Como desafio, sugiro as seguintes modificações:
- Mostrar o score na tela;
- Adicionar swag no movimento do carrinho;
- Desligar o autoenablesDefaultLighting da cena, e adicionar faróis ao carrinho;
- Criar um modo POV onde a câmera vai parar dentro do carrinho;
- Adicionar mais faixas, mais inimigos, bonus ou até mais um carro (como é o jogo 2 Cars).
Espero que tenha gostado do texto, fique ligado nos demais artigos desta série. Qualquer dúvida, reclamação, sugestão, o repositório https://github.com/luksfarris/carRush é o melhor lugar para me achar. Abra uma Issue, faça um Pull Request e brinque com o código. Enfim, divirta-se!