Este artigo pertence à série de artigos equinociOS, e aqui iremos tratar do framework SceneKit, que é uma biblioteca para desenvolvimento de gráficos 3d de alta performance. O código será escrito em
Swift
, e um exemplo completo do projeto pode ser encontrado neste repositório. O presente artigo está licenciado como CC - Creative Commons. Você irá, acompanhando o texto, escrever umas150
linhas de código, o tempo médio da leitura é de30
minutos.
Prólogo: Declarações iniciais e criando o projeto.
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 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, voce poderá encontrar o arquivo GameViewController.swift
. Abra ele e vamos comecar!
Capítulo 1: Luzes, Câmera e Ação!
No qual 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.
No qual aprendemos 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 parametros, 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 seguinte variavel var onLeftLane:Bool = true
, e adicione este código no 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!
No qual 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 esse 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 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, voce 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 farois ao carrinho; - Criar um modo POV onde a camera 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 dessa 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
, brinque com o código, enfim: Divirta-se!
Lucas Farris desenvolve jogos desde 2006, entrou no mercado mobile em 2011. Atualmente mora na Polonia.