如何编写和精灵宝可梦一样的 app?
原文:How To Make An App Like Pokemon Go
作者:Jean-Pierre Distler
譯者:kmyhy
如今最流行的一個手機游戲就是精靈寶可夢。它使用增強現實技術將游戲帶入到“真實世界”,讓玩家做一些對健康有益的事情。
在本教程中,我們將編寫自己的增強現實精靈捕獲游戲。這個游戲會顯示一張包含有你的位置和敵人的位置的地圖,用一個 3D SceneKit 視圖呈現后置攝像頭中拍攝的圖像和敵人的 3D 模型。
如果你第一次接觸增強現實,你可以先看一下我們的基于地理位置的 RA 教程。對于要介紹如何編寫精靈寶可夢 app 的本教程來說,它不是必須的,但它里面包含了大量本教程未涉及的關于數學和 RA 的有用知識。
開始
本教程的開始項目在此處下載。項目包含了兩個 view controller 和一個 art.scnassets 文件夾,這個文件夾中包括了必須的 3D 模型和貼圖。
ViewController.swift 是一個 UIViewController 子類,用于顯示 app 的 AR 內容。MapViewController 用于顯示一張地圖,地圖上會包含你的當前位置以及附近敵人的位置。一些基本的東西,比如約束和出口,都是已經建好的了,你只需要關注本教程的核心內容,即怎樣讓 app 長得像精靈寶可夢。
在地圖上添加敵人
在你能夠和敵人戰斗之前,需要知道敵人在哪。新建一個 Swift 文件,叫做 ARItem.swift。
在文件的 ARItem.swift 的 import Foundation 一行后添加:
import CoreLocationstruct ARItem {let itemDescription: Stringlet location: CLLocation }ARItem 有一個描述字段和一個坐標。這樣我們就能夠知道是什么樣的敵人,以及它在哪里。
打開 MapViewController.swift 添加一個 impor CoreLocation 語句以及一個屬性:
var targets = [ARItem]()添加如下方法:
func setupLocations() {let firstTarget = ARItem(itemDescription: "wolf", location: CLLocation(latitude: 0, longitude: 0))targets.append(firstTarget)let secondTarget = ARItem(itemDescription: "wolf", location: CLLocation(latitude: 0, longitude: 0))targets.append(secondTarget)let thirdTarget = ARItem(itemDescription: "dragon", location: CLLocation(latitude: 0, longitude: 0))targets.append(thirdTarget) }我們通過硬編碼的方式創建了 3 個敵人。我們會將坐標(0,0) 替換成靠近你物理坐標附近的坐標。
有許多查找坐標的方法。比如,可以在你當前位置附近創建一些隨機的坐標,使用我們在上一篇教程的 PlacesLoader 或者 Xcode 模擬當前位置。當然,我們不想讓隨機坐標出現在你鄰居的臥室里。那就尷尬了。
簡單點的方法,就是使用 Google 地圖。打開 https://www.google.com/maps/ 查找你當前的位置。當你點擊地圖,會顯示一個大頭釘,底部彈出一個氣泡。
在氣泡中會顯示你的經緯度。我建議你從你的位置或你所在的街道附近創建出一些硬編碼的位置,這樣你就沒有必要去敲鄰居家門,告訴他你需要去他的臥室抓一條龍。
選擇 3 個位置,將上面代碼中的 0 替換成你選擇的坐標。
在地圖上標出敵人
我們已經設定了敵人的坐標,應該在地圖上將它們顯示出來。新增一個 Swift 文件,取名為 MapAnnotation.swift。在這個文件中編寫如下代碼:
import MapKitclass MapAnnotation: NSObject, MKAnnotation {//1let coordinate: CLLocationCoordinate2Dlet title: String?//2let item: ARItem//3init(location: CLLocationCoordinate2D, item: ARItem) {self.coordinate = locationself.item = itemself.title = item.itemDescriptionsuper.init()} }我們創建了一個 MapAnnotation 類并實現了 MKAnnotation 協議。
回到 MapViewController.swift 在 setupLocations() 方法最后一句添加:
for item in targets { let annotation = MapAnnotation(location: item.location.coordinate, item: item)self.mapView.addAnnotation(annotation) }循環遍歷 targets 數組,每個 target 都會添加一個大頭釘到地圖上。
在 viewDidLoad() 方法最后調用 setupLocations():
override func viewDidLoad() {super.viewDidLoad()mapView.userTrackingMode = MKUserTrackingMode.followWithHeadingsetupLocations() }在定位之前,我們必須獲得權限。
在 MapViewController 中添加一個新屬性:
let locationManager = CLLocationManager()在 viewDidLoad() 最后一句,添加請求權限的代碼:
if CLLocationManager.authorizationStatus() == .notDetermined {locationManager.requestWhenInUseAuthorization() }注意:如果不進行權限請求,map view 無法加載用戶位置。而且不會提示任何錯誤信息。每當你調用位置服務時,你都無法獲得位置信息,要排除錯誤請首先從這個地方開始。
運行 app,等一會地圖將縮放到你的當前位置并顯示出一些紅色的大頭釘,它們表示了敵人的位置。
添加增強現實效果
我們有一個看起來不錯的 app,但我們還需要添加一些 AR 元素。在下一節,我們將添加一個攝像窗口并添加一個簡單的方塊來代表敵人。
首先我們需要跟蹤用戶位置。在 MapViewController 聲明屬性:
var userLocation: CLLocation?然后添加一個擴展:
extension MapViewController: MKMapViewDelegate {func mapView(_ mapView: MKMapView, didUpdate userLocation: MKUserLocation) {self.userLocation = userLocation.location} }每次設備的位置發生改變,這個方法會被調用。這個方法中,我們簡單地保存了用戶位置,以便在另一個方法中使用。
在擴展中添加委托方法:
func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView) {//1let coordinate = view.annotation!.coordinate//2if let userCoordinate = userLocation {//3if userCoordinate.distance(from: CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)) < 50 {//4let storyboard = UIStoryboard(name: "Main", bundle: nil)if let viewController = storyboard.instantiateViewController(withIdentifier: "ARViewController") as? ViewController {// more code later//5if let mapAnnotation = view.annotation as? MapAnnotation {//6self.present(viewController, animated: true, completion: nil)}}}} }當用戶點擊到一個距離你不超過 50 米的敵人時,顯示一個攝像畫面:
運行 app,點擊你位置附近的任意大頭釘,會顯示一個空白的 view controller:
添加攝像畫面
打開 ViewController.swift,在 import SceneKit 后面添加 import AVFoundation:
import UIKit import SceneKit import AVFoundationclass ViewController: UIViewController { ...添加兩個屬性用于保存一個 AVCaptureSession 對象和一個 AVCaptureVideoPreviewLayer 對象:
var cameraSession: AVCaptureSession? var cameraLayer: AVCaptureVideoPreviewLayer?我們會用 capture session 來訪問視頻輸入(比如鏡頭)和輸出(比如取景框)。
添加一個方法:
func createCaptureSession() -> (session: AVCaptureSession?, error: NSError?) {//1var error: NSError?var captureSession: AVCaptureSession?//2let backVideoDevice = AVCaptureDevice.defaultDevice(withDeviceType: .builtInWideAngleCamera, mediaType: AVMediaTypeVideo, position: .back)//3if backVideoDevice != nil {var videoInput: AVCaptureDeviceInput!do {videoInput = try AVCaptureDeviceInput(device: backVideoDevice)} catch let error1 as NSError {error = error1videoInput = nil}//4if error == nil {captureSession = AVCaptureSession()//5if captureSession!.canAddInput(videoInput) {captureSession!.addInput(videoInput)} else {error = NSError(domain: "", code: 0, userInfo: ["description": "Error adding video input."])}} else {error = NSError(domain: "", code: 1, userInfo: ["description": "Error creating capture device input."])}} else {error = NSError(domain: "", code: 2, userInfo: ["description": "Back video device not found."])}//6return (session: captureSession, error: error) }這個方法負責這些事情:
現在我們已經從攝像頭拿到輸入了,就可以把它添加到視圖中:
func loadCamera() {//1let captureSessionResult = createCaptureSession()//2 guard captureSessionResult.error == nil, let session = captureSessionResult.session else {print("Error creating capture session.")return}//3self.cameraSession = session//4if let cameraLayer = AVCaptureVideoPreviewLayer(session: self.cameraSession) {cameraLayer.videoGravity = AVLayerVideoGravityResizeAspectFillcameraLayer.frame = self.view.bounds//5self.view.layer.insertSublayer(cameraLayer, at: 0)self.cameraLayer = cameraLayer} }代碼解釋如下:
- 首先調用前面的方法獲得一個 capture session。
- 判斷是否有錯誤發生,或者 capture session 為空,如果是立即 return,和 AR 說 bye-bye 吧!
- 否則,將 capture session 保存到 cameraSession 變量。
- 創建攝像預覽圖層,如果創建成功,設置它的 videoGravity 屬性和 frame 屬性,讓它占據整個屏幕。
- 將攝像預覽圖層(取景框)添加到 sublayers 中并保存到 cameraLayer 變量。
然后,在 viewDidLoad() 加入:
loadCamera()self.cameraSession?.startRunning()這里只做了兩件事情:首先調用前面編寫的方法,然后打開鏡頭取景框。這個取景框立馬會顯示到預覽圖層上。
運行 app,點擊你身邊的任何一個位置,你會看到一個全新的鏡頭預覽界面:
添加方塊
干得不錯,但這還不算真正的 RA。在這一節,我們將添加一個簡單的方塊來表示敵人,并根據用戶的位置和朝向來移動它。
這個游戲會有兩種敵人:狼和龍。
因此,我們需要知道敵人的種類以及應該在哪里顯示它們。
在 ViewController 中添加如下屬性(用于保存敵人的信息):
var target: ARItem!打開 MapViewController.swift, 找到 mapView(_:, didSelect:) 將最后一個 if 語句修改為:
if let mapAnnotation = view.annotation as? MapAnnotation {//1viewController.target = mapAnnotation.itemself.present(viewController, animated: true, completion: nil) }在顯示 viewController 之前,將一個 ARItem(它是被點擊的大頭釘的 item 屬性)賦給它。這樣,viewController 就能夠知道當前敵人的種類。
現在 ViewController 已經獲得了 target 的信息了。
打開 ARItem.swift 導入 SceneKit。
import Foundation import SceneKitstruct ARItem { ... }添加一個屬性,用于保存一個 SCNNode 對象:
var itemNode: SCNNode?確保這個屬性聲明在 ARItem 結構的其它屬性之后,因為在隱式的初始化方法將使用相同的順序來定義參數。
Xcode 會提示 MapViewController.swift 中有一個錯誤。要解決這個錯誤,請打開這個文件,找到 setupLocations() 方法。
我們需要修改在編輯器左邊標有一個紅點的代碼。
對于這些代碼,我們都需要將缺少的 itemNode 參數用 nil 來補上。
例如,這一行:
let firstTarget = ARItem(itemDescription: "wolf", location: CLLocation(latitude: 50.5184, longitude: 8.3902))應當改為:
let firstTarget = ARItem(itemDescription: "wolf", location: CLLocation(latitude: 50.5184, longitude: 8.3902), itemNode: nil)我們知道了敵人的種類,以及它們的位置,但我們還需要知道設備當前朝向。
打開 ViewController.swift ,導入 CoreLocation:
import UIKit import SceneKit import AVFoundation import CoreLocation然后,增加屬性聲明:
//1 var locationManager = CLLocationManager() var heading: Double = 0 var userLocation = CLLocation() //2 let scene = SCNScene() let cameraNode = SCNNode() let targetNode = SCNNode(geometry: SCNBox(width: 1, height: 1, length: 1, chamferRadius: 0))代碼解釋如下:
在 viewDidLoad() 最后一句添加:
//1 self.locationManager.delegate = self //2 self.locationManager.startUpdatingHeading()//3 sceneView.scene = scene cameraNode.camera = SCNCamera() cameraNode.position = SCNVector3(x: 0, y: 0, z: 10) scene.rootNode.addChildNode(cameraNode)代碼解釋如下:
This sets ViewController as the delegate for the CLLocationManager.
添加一個擴展,實現 CLLocationManagerDelegate 協議:
extension ViewController: CLLocationManagerDelegate {func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {//1self.heading = fmod(newHeading.trueHeading, 360.0)repositionTarget()} }當收到新的方向通知,CLLocationManager 會調用這個委托方法。fmod 對 double 進行取模運算,確保方向的取值位于 0-359 之間。
在 ViewController.swift 中添加一個 repostionTarget()方法,注意是放在類實現而不是 CLLocationManagerDelegate 擴展中:
func repositionTarget() {//1let heading = getHeadingForDirectionFromCoordinate(from: userLocation, to: target.location)//2let delta = heading - self.headingif delta < -15.0 {leftIndicator.isHidden = falserightIndicator.isHidden = true} else if delta > 15 {leftIndicator.isHidden = truerightIndicator.isHidden = false} else {leftIndicator.isHidden = truerightIndicator.isHidden = true}//3let distance = userLocation.distance(from: target.location)//4if let node = target.itemNode {//5if node.parent == nil {node.position = SCNVector3(x: Float(delta), y: 0, z: Float(-distance))scene.rootNode.addChildNode(node)} else {//6node.removeAllActions()node.runAction(SCNAction.move(to: SCNVector3(x: Float(delta), y: 0, z: Float(-distance)), duration: 0.2))}} }代碼解釋如下:
如果你懂 SceneKit 或者 SpriteKit,則最后一句代碼你懂的。否則,這里會進行更詳細的介紹。
SCNAction.move(to:, duration:) 方法創建一個 action,將節點以指定時間移動到指定的位置。runAction(_:) 也是 SCNNode 方法,用于執行一個 action。我們還可以創建 action 組/序列。要了解更多內容,請閱讀我們的這本書3D Apple Games by Tutorials。
繼續實現前面未實現的方法。在 ViewController.swift 中添加這幾個方法:
func radiansToDegrees(_ radians: Double) -> Double {return (radians) * (180.0 / M_PI) }func degreesToRadians(_ degrees: Double) -> Double {return (degrees) * (M_PI / 180.0) }func getHeadingForDirectionFromCoordinate(from: CLLocation, to: CLLocation) -> Double { //1let fLat = degreesToRadians(from.coordinate.latitude)let fLng = degreesToRadians(from.coordinate.longitude)let tLat = degreesToRadians(to.coordinate.latitude)let tLng = degreesToRadians(to.coordinate.longitude) //2let degree = radiansToDegrees(atan2(sin(tLng-fLng)*cos(tLat), cos(fLat)*sin(tLat)-sin(fLat)*cos(tLat)*cos(tLng-fLng))) //3if degree >= 0 {return degree} else {return degree + 360} }radiansToDegrees(_:) 和 degreesToRadians(_:) 方法用于將弧度和角度互轉。
getHeadingForDirectionFromCoordinate(from:to:) 方法代碼解釋如下:
還需要幾個步驟才能運行你的 app。
首先,必須將用戶的坐標傳遞給 viewController。打開 MapViewController.swift 找到 mapView(_:, didSelect:) 的最后一個 if 語句,在顯示 view controller 之前加上這句:
viewController.userLocation = mapView.userLocation.location!然后在 ViewController.swift 中添加這個方法:
func setupTarget() {targetNode.name = "enemy"self.target.itemNode = targetNode }這個方法為 targetNode 設置一個名字,然后將它賦給 target。
現在可以在 viewDidLoad() 方法最后來調用這個方法了。在添加完攝像頭之后添加:
scene.rootNode.addChildNode(cameraNode) setupTarget()運行 app,可以看到方塊在移動:
美化我們的 app
在開發 app 初期用方塊或者圓球是一種簡單的處理方法,因為這樣省去了大量 3D 建模的時間——但 3D 模型看起來畢竟要漂亮得多。在這一節,我們將繼續美化我們的 app ,為敵人加入 3D 模型,以及賦予玩家扔出火球的能力。
打開 art.scnassets 文件夾,里面有兩個 .dae 文件。它們包含了敵人的模型:狼和龍。
接下來修改 ViewController.swift 中的 setupTarget() 方法,在其中加載這些 3D 模型并賦給目標的 itemNode 屬性。
將 setupTarget() 方法修改為:
func setupTarget() {//1let scene = SCNScene(named: "art.scnassets/\(target.itemDescription).dae")//2let enemy = scene?.rootNode.childNode(withName: target.itemDescription, recursively: true)//3 if target.itemDescription == "dragon" {enemy?.position = SCNVector3(x: 0, y: -15, z: 0)} else {enemy?.position = SCNVector3(x: 0, y: 0, z: 0)}//4 let node = SCNNode()node.addChildNode(enemy!)node.name = "enemy"self.target.itemNode = node }代碼解釋如下:
運行 app,你會看到一只立體的狼,這可比一個便宜的方塊要嚇人多了!
事實上,這只狼足以讓你嚇得遠遠拋開了,但作為勇敢主角的你,逃跑從來不是你的選擇!接下來你應該加上幾個火球,這樣你就能在成為狼的點心之前戰勝它了。
拋出火球的最好時機是用戶的觸摸結束事件,因此在 ViewController.swift 中實現這個方法:
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {//1let touch = touches.first!let location = touch.location(in: sceneView)//2let hitResult = sceneView.hitTest(location, options: nil)//3let fireBall = SCNParticleSystem(named: "Fireball.scnp", inDirectory: nil)//4let emitterNode = SCNNode()emitterNode.position = SCNVector3(x: 0, y: -5, z: 10)emitterNode.addParticleSystem(fireBall!)scene.rootNode.addChildNode(emitterNode)//5 if hitResult.first != nil {//6target.itemNode?.runAction(SCNAction.sequence([SCNAction.wait(duration: 0.5), SCNAction.removeFromParentNode(), SCNAction.hide()]))let moveAction = SCNAction.move(to: target.itemNode!.position, duration: 0.5)emitterNode.runAction(moveAction)} else {//7emitterNode.runAction(SCNAction.move(to: SCNVector3(x: 0, y: 0, z: -30), duration: 0.5))} }代碼解釋如下:
運行 app,讓惡餓狼在火焰中焚燒吧!
收尾工作
要完成 app,我們還需要將敵人從列表中刪除,關閉 AR 視圖并回到地圖,以便找到下一個敵人。
移除敵人應當在 MapViewController 中進行,因為敵人列表就在那里。我們可以說明只有一個方法的委托協議,當目標被擊中后調用這個方法。
在 ViewController.swift 的類聲明之前,添加如下協議:
protocol ARControllerDelegate {func viewController(controller: ViewController, tappedTarget: ARItem) }同時為 ViewController 聲明一個屬性:
var delegate: ARControllerDelegate?委托方法會告訴委托對象說明時候發生了碰撞事件,然后委托對象就可以進行下一步的處理。
在 ViewController.swift 中找到 touchesEnded(_:with:) 方法,將if 語句中的代碼塊修改為:
if hitResult.first != nil {target.itemNode?.runAction(SCNAction.sequence([SCNAction.wait(duration: 0.5), SCNAction.removeFromParentNode(), SCNAction.hide()]))//1let sequence = SCNAction.sequence([SCNAction.move(to: target.itemNode!.position, duration: 0.5),//2SCNAction.wait(duration: 3.5), //3SCNAction.run({_ inself.delegate?.viewController(controller: self, tappedTarget: self.target)})])emitterNode.runAction(sequence) } else {... }解釋如下:
打開 MapViewController.swift 聲明一個屬性,用于保存 選中的大頭釘:
var selectedAnnotation: MKAnnotation?這個屬性用于待會將它從地圖上移出。修改它的 viewController 的初始化和條件綁定(if let)部分的代碼:
if let viewController = storyboard.instantiateViewController(withIdentifier: "ARViewController") as? ViewController {//1viewController.delegate = selfif let mapAnnotation = view.annotation as? MapAnnotation {viewController.target = mapAnnotation.itemviewController.userLocation = mapView.userLocation.location!//2selectedAnnotation = view.annotationself.present(viewController, animated: true, completion: nil)} }非常簡單:
在 MKMapViewDelegate 擴展下面添加:
extension MapViewController: ARControllerDelegate {func viewController(controller: ViewController, tappedTarget: ARItem) {//1self.dismiss(animated: true, completion: nil)//2let index = self.targets.index(where: {$0.itemDescription == tappedTarget.itemDescription})self.targets.remove(at: index!)if selectedAnnotation != nil {//3mapView.removeAnnotation(selectedAnnotation!)}} }代碼解釋如下:
運行 app,你將看到最終效果:
結束
最終完成的項目在這里下載。
如果你想盡可能地學習如何編寫這個 app,請參考下列教程:
- 關于 MapKit 和位置服務,請參考我們的 MapKit Swift 入門。
- 關于視頻捕捉,請參考我們的 AVFoundation 系列。
- 關于 SceneKit,請參考我們的 SceneKit 系列教程。
- 要避免對敵人位置進行硬編碼,則需要后臺數據的支持,請參考如何編寫一個簡單的 PHP/MySQL 服務 以及 如何用 Vapor 進行服務端編程。
希望你喜歡本教程。如果有任何問題和建議,請在下面留言。
總結
以上是生活随笔為你收集整理的如何编写和精灵宝可梦一样的 app?的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 2019 Multi-Universit
- 下一篇: HDU.5128 The E-pang