女朋友要玩 Pokemon Go逼裆,所以我就山寨了一個(gè)…附帶全部源代碼

女朋友說(shuō)她要玩 Pokemon Go,所以...

...作為一個(gè)程序員有一個(gè)女朋友已經(jīng)是相當(dāng)不容易赦政,所以...為了滿足她的需求胜宇,我準(zhǔn)備自己做一個(gè)耀怜。去谷歌了一下發(fā)現(xiàn) Ray Wenderlich 有一篇類似的教程,就參考這篇教程真的自己做了一個(gè)桐愉,女朋友玩的很開(kāi)心财破。

現(xiàn)在把制作這個(gè)增強(qiáng)現(xiàn)實(shí)小游戲的方法分享給大家,只要會(huì) iOS 開(kāi)發(fā)就可以看懂从诲,希望大家都可以做出自己的 Pokemon Go左痢,找到女朋友...

在這篇山寨 Pokemon Go 的教程中,會(huì)教你創(chuàng)建一個(gè)自己的增強(qiáng)現(xiàn)實(shí)怪物狩獵游戲系洛。游戲有一個(gè)地圖俊性,顯示了你和敵人的位置,一個(gè) 3D SceneKit view 以顯示后置攝像頭的實(shí)時(shí)預(yù)覽和敵人的 3D 模型描扯。

如果你不了解增強(qiáng)現(xiàn)實(shí)定页,在開(kāi)始前花時(shí)間閱讀一下 Ray Wenderlich 基于位置的增強(qiáng)現(xiàn)實(shí)教程 。這不是學(xué)習(xí)本篇山寨 Pokemon Go 教程的必要條件荆烈,但里面包含了很多關(guān)于數(shù)學(xué)和增強(qiáng)現(xiàn)實(shí)有價(jià)值的信息拯勉,本教程中并不會(huì)涉及。

上手

我準(zhǔn)備了一個(gè)山寨 Pokemon Go 的起始項(xiàng)目憔购,放在了我的 GitHub宫峦,下載或克隆一下。項(xiàng)目包含了兩個(gè) view controller 以及文件夾 art.scnassets玫鸟,里面包含了需要的 3D 模型以及紋理导绷。

ViewController.swiftUIViewController 的子類,用于顯示 app 的 AR 部分屎飘。MapViewController 會(huì)被用于顯示地圖妥曲,上面有你的當(dāng)前位置以及身邊其他敵人的當(dāng)前位置∏展海基本的約束和 outlet 我已經(jīng)弄好了檐盟,這樣大家就可以專注于本教程中最重要的部分,即山寨 Pokemon Go押桃。

把敵人加到地圖上

在女朋友出門(mén)打怪前葵萎,她需要知道怪獸都在哪里。創(chuàng)建一個(gè)新的 Swift File唱凯,命名為 ARItem.swift羡忘。

將如下代碼添加到 ARItem.swiftimport Foundation 行之后:

import CoreLocation

struct ARItem {
   let itemDescription: String
   let location: CLLocation
}

ARItem 有一個(gè)描述和一個(gè)位置,以便了解敵人的類型——以及他正躺在哪里等著你磕昼。

打開(kāi) MapViewController.swift 添加一個(gè) CoreLocation 的 import卷雕,再添加一個(gè)用于存儲(chǔ)目標(biāo)的屬性:

var targets = [ARItem]()

現(xiàn)在添加如下方法:

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)  
}

在這里用硬編碼的方式創(chuàng)建了三個(gè)敵人,位置和描述都是硬編碼的票从。然后要把 (0, 0) 坐標(biāo)替換為靠近你的物理位置的坐標(biāo)漫雕。

有很多方法可以找到這些位置滨嘱。例如,可以創(chuàng)建幾個(gè)圍繞你當(dāng)前位置的隨機(jī)位置蝎亚、使用 Ray Wenderlich 最早的增強(qiáng)現(xiàn)實(shí)教程中的 PlacesLoader九孩、甚至使用 Xcode 偽造你的當(dāng)前位置先馆。但是发框,你不會(huì)希望某個(gè)隨機(jī)的位置是在隔壁老王的臥室里。那樣就尷尬了煤墙。

為了簡(jiǎn)化操作梅惯,可以使用 GPSSPG 這個(gè)在線查詢經(jīng)緯度的網(wǎng)站。打開(kāi)網(wǎng)站然后搜索你所在的位置仿野,會(huì)出現(xiàn)一個(gè)彈出窗口铣减,點(diǎn)擊其他位置也會(huì)出現(xiàn)彈出窗口。

在這個(gè)彈出窗口里可以看到 5 組經(jīng)緯度的值脚作,前面是緯度(latitude)葫哗,后面是經(jīng)度(longitude)。用高德那組球涛,否則會(huì)出現(xiàn)地圖偏移量劣针。我建議你在附近或街上找一些位置來(lái)創(chuàng)建硬編碼,這樣你的女朋友就不用告訴老王她需要到他的房間里捉一條龍了亿扁。

選擇三個(gè)位置捺典,用它們的值替換掉上面的零。

把敵人釘在地圖上

現(xiàn)在已經(jīng)有敵人的位置了从祝,現(xiàn)在需要顯示 MapView襟己。添加一個(gè)新的 Swift File,保存為 MapAnnotation.swift牍陌。在文件中添加如下代碼:

import MapKit
 
class MapAnnotation: NSObject, MKAnnotation {
  //1
  let coordinate: CLLocationCoordinate2D
  let title: String?
  //2
  let item: ARItem
  //3
  init(location: CLLocationCoordinate2D, item: ARItem) {
    self.coordinate = location
    self.item = item
    self.title = item.itemDescription
     
    super.init()
  }

我們創(chuàng)建了一個(gè) MapAnnotation 類擎浴,實(shí)現(xiàn)了 MKAnnoation 協(xié)議。說(shuō)明白一點(diǎn):

  1. 該協(xié)議需要一個(gè)變量 coordinate 和一個(gè)可選值 title毒涧。
  2. 在這里存儲(chǔ)屬于該 annotation 的 ARItem贮预。
  3. 用該初始化方法可以分配所有變量。

現(xiàn)在回到 MapViewController.swift链嘀。添加如下代碼到 setupLocations() 的最后:

 for item in targets {      
   let annotation = MapAnnotation(location: item.location.coordinate, item: item)
   self.mapView.addAnnotation(annotation)    
 }

我們?cè)谏厦姹闅v了 targets 數(shù)組并且為每一個(gè) target 都添加了 annotation萌狂。

現(xiàn)在,在 viewDidLoad() 的最后怀泊,調(diào)用 setupLocations()

 override func viewDidLoad() {
   super.viewDidLoad()
  
   mapView.userTrackingMode = MKUserTrackingMode.followWithHeading
   setupLocations()
 }

要使用位置茫藏,必須先索要權(quán)限。為 MapViewController 添加如下屬性:

 let locationManager = CLLocationManager()

viewDidLoad() 的末尾霹琼,添加如下代碼索取所需的權(quán)限:

 if CLLocationManager.authorizationStatus() == .notDetermined {
   locationManager.requestWhenInUseAuthorization()
 }

注意:如果忘記添加這個(gè)權(quán)限請(qǐng)求务傲,map view 將無(wú)法定位用戶凉当。不幸的是沒(méi)有錯(cuò)誤消息會(huì)指出這一點(diǎn)。這會(huì)導(dǎo)致每次使用位置服務(wù)的時(shí)候都無(wú)法獲取位置售葡,這樣會(huì)比后面搜索尋找錯(cuò)誤的源頭好的多看杭。

構(gòu)建運(yùn)行項(xiàng)目;短時(shí)間后挟伙,地圖會(huì)縮放到你的當(dāng)前位置楼雹,并且在你的敵人的位置上顯示幾個(gè)紅色標(biāo)記。

添加增強(qiáng)現(xiàn)實(shí)

現(xiàn)在已經(jīng)有了一個(gè)很棒的 app尖阔,但還需要添加增強(qiáng)現(xiàn)實(shí)的代碼贮缅。在下面幾節(jié)中,會(huì)添加相機(jī)的實(shí)時(shí)預(yù)覽以及一個(gè)簡(jiǎn)單的小方塊介却,用作敵人的占位符谴供。
首先需要追蹤用戶的位置。為 MapViewController 添加如下屬性:

var userLocation: CLLocation?

然后在底部添加如下擴(kuò)展:

 extension MapViewController: MKMapViewDelegate {
   func mapView(_ mapView: MKMapView, didUpdate userLocation: MKUserLocation) {
     self.userLocation = userLocation.location
   }
 }

每次設(shè)備位置更新 MapView 都會(huì)調(diào)用這個(gè)方法齿坷;簡(jiǎn)單存一下桂肌,以用于另一個(gè)方法。

在擴(kuò)展中添加如下代理方法:

 func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView) {
   //1
   let coordinate = view.annotation!.coordinate
   //2
   if let userCoordinate = userLocation {
     //3
     if userCoordinate.distance(from: CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)) < 50 {
       //4
       let storyboard = UIStoryboard(name: "Main", bundle: nil)
  
       if let viewController = storyboard.instantiateViewController(withIdentifier: "ARViewController") as? ViewController {
         // more code later
         //5
         if let mapAnnotation = view.annotation as? MapAnnotation {
           //6
           self.present(viewController, animated: true, completion: nil)
         }
       }
     }
   }
 }

如果用戶點(diǎn)擊距離 50 米以內(nèi)的敵人永淌,則會(huì)顯示相機(jī)預(yù)覽垒酬,過(guò)程如下:

  1. 獲取被選擇的 annotation 的坐標(biāo)秧秉。
  2. 確狈蛲梗可選值 userLocation 已分配秦叛。
  3. 確保被點(diǎn)擊的對(duì)象在用戶的位置范圍以內(nèi)。
  4. 從 storyboard 實(shí)例化 ARViewController答恶。
  5. 這一行檢查被點(diǎn)擊的 annotation 是否是 MapAnnotation饺蚊。
  6. 最后,顯示 viewController悬嗓。

構(gòu)建運(yùn)行項(xiàng)目污呼,點(diǎn)擊你當(dāng)前位置附近的某個(gè) annotation。你會(huì)看到顯示了一個(gè)白屏:

IMG_0109.PNG

添加相機(jī)預(yù)覽

打開(kāi) ViewController.swift包竹,然后在 SceneKit 的 import 后面 import AVFoundation

 import UIKit
 import SceneKit
 import AVFoundation
  
 class ViewController: UIViewController {
 ...

然后添加如下屬性以存儲(chǔ) AVCaptureSessionAVCaptureVideoPreviewLayer

 var cameraSession: AVCaptureSession?
 var cameraLayer: AVCaptureVideoPreviewLayer?

使用 capture session 來(lái)連接到視頻輸入燕酷,比如攝像頭,然后連接到輸出周瞎,比如預(yù)覽層苗缩。

現(xiàn)在添加如下方法:

func createCaptureSession() -> (session: AVCaptureSession?, error: NSError?) {
  //1
  var error: NSError?
  var captureSession: AVCaptureSession?
 
  //2
  let backVideoDevice = AVCaptureDevice.defaultDevice(withDeviceType: .builtInWideAngleCamera, mediaType: AVMediaTypeVideo, position: .back)
 
  //3
  if backVideoDevice != nil {
    var videoInput: AVCaptureDeviceInput!
    do {
      videoInput = try AVCaptureDeviceInput(device: backVideoDevice)
    } catch let error1 as NSError {
      error = error1
      videoInput = nil
    }
 
    //4
    if error == nil {
      captureSession = AVCaptureSession()
 
      //5
      if 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."])
  }
 
  //6
  return (session: captureSession, error: error)
}

上面的代碼做了如下事情:

  1. 創(chuàng)建了幾個(gè)變量,用于方法返回声诸。
  2. 獲取設(shè)備的后置攝像頭酱讶。
  3. 如果攝像頭存在,獲取它的輸入彼乌。
  4. 創(chuàng)建 AVCaptureSession 的實(shí)例泻肯。
  5. 將視頻設(shè)備加為輸入渊迁。
  6. 返回一個(gè)元組,包含 captureSession 或是 error灶挟。

現(xiàn)在你有了攝像頭的輸入琉朽,可以把它加載到視圖中了:

func loadCamera() {
  //1
  let captureSessionResult = createCaptureSession()
 
  //2  
  guard captureSessionResult.error == nil, let session = captureSessionResult.session else {
    print("Error creating capture session.")
    return
  }
 
  //3
  self.cameraSession = session
 
  //4
  if let cameraLayer = AVCaptureVideoPreviewLayer(session: self.cameraSession) {
    cameraLayer.videoGravity = AVLayerVideoGravityResizeAspectFill
    cameraLayer.frame = self.view.bounds
    //5
    self.view.layer.insertSublayer(cameraLayer, at: 0)
    self.cameraLayer = cameraLayer
  }
}

一步步講解上面的方法:

  1. 首先,調(diào)用上面創(chuàng)建的方法來(lái)獲得 capture session稚铣。
  2. 如果有錯(cuò)誤箱叁,或者 captureSessionnil,就 return榛泛。再見(jiàn)了我的增強(qiáng)現(xiàn)實(shí)蝌蹂。
  3. 如果一切正常噩斟,就在 cameraSession 里存儲(chǔ) capture session曹锨。
  4. 這行嘗試創(chuàng)建一個(gè)視頻預(yù)覽層;如果成功了剃允,它會(huì)設(shè)置 videoGravity 以及把該層的 frame 設(shè)置為 view 的 bounds沛简。這樣會(huì)給用戶一個(gè)全屏預(yù)覽。
  5. 最后斥废,將該層添加為子圖層椒楣,然后將其存儲(chǔ)在 cameraLayer 中。

添加添加如下代碼到 viewDidLoad() 中:

   loadCamera()
   self.cameraSession?.startRunning()

其實(shí)這里就干了兩件事:首先調(diào)用剛剛寫(xiě)的那段卓爾不群的代碼牡肉,然后開(kāi)始從相機(jī)捕獲幀捧灰。幀將會(huì)自動(dòng)顯示到預(yù)覽層上。

構(gòu)建運(yùn)行項(xiàng)目统锤,點(diǎn)擊附近的一個(gè)位置毛俏,然后享受一下全新的相機(jī)預(yù)覽:

添加小方塊

預(yù)覽效果很好,但還不是增強(qiáng)現(xiàn)實(shí)——目前還不是饲窿。在這一節(jié)煌寇,我們會(huì)為每個(gè)敵人添加一個(gè)簡(jiǎn)單的小方塊,根據(jù)用戶的位置和朝向來(lái)移動(dòng)它逾雄。
這個(gè)小游戲有兩種敵人:狼和龍阀溶。因此,我們需要知道面對(duì)的是哪種敵人鸦泳,以及要在哪兒放置它银锻。
把下面的屬性添加到 ViewController(它會(huì)幫你存儲(chǔ)關(guān)于敵人的信息):

var target: ARItem!

現(xiàn)在打開(kāi) MapViewController.swift,找到 mapView(_:, didSelect:) 然后改變最后一條 if 語(yǔ)句做鹰,讓它看起來(lái)像這樣:

if let mapAnnotation = view.annotation as? MapAnnotation {
  //1
  viewController.target = mapAnnotation.item
 
  self.present(viewController, animated: true, completion: nil)
}
  • 在顯示 viewController 之前击纬,存儲(chǔ)了被點(diǎn)擊 annotation 的 ARItem 的引用。所以 viewController 知道你面對(duì)的是什么樣的敵人誊垢。

現(xiàn)在 ViewController 知道了所有需要了解的有關(guān) target 的事情掉弛。

打開(kāi) ARItem.swift 然后 import SceneKit症见。

import Foundation
import SceneKit
 
struct ARItem {
...
}

然后,添加下面這個(gè)屬性以存儲(chǔ) item 的 SCNNode

var itemNode: SCNNode?

確保在 ARItem 結(jié)構(gòu)體現(xiàn)有的屬性之后定義這個(gè)屬性殃饿,因?yàn)槲覀儠?huì)依賴定義了相同參數(shù)順序的隱式初始化方法谋作。
現(xiàn)在 Xcode 在 MapViewController.swift 里顯示了一個(gè) error。要修復(fù)它乎芳,打開(kāi)該文件然后滑動(dòng)到 setupLocations()遵蚜。
修改 Xcode 在編輯器面板左側(cè)用紅點(diǎn)標(biāo)注的行。

在每一行奈惑,為缺少的 itemNode 參數(shù)添加 nil 值吭净。
舉個(gè)例子,改變下面的這行:

 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)

現(xiàn)在知道了要顯示的敵人類型肴甸,也知道了它的位置寂殉,但你還不知道設(shè)備的朝向(heading)。
打開(kāi) ViewController.swift 然后 import CoreLocation原在,你的所有 import 看起來(lái)應(yīng)該如下:

 import UIKit
 import SceneKit
 import AVFoundation
 import CoreLocation

接下來(lái)友扰,添加如下屬性:

//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))

分步講解:

  1. 使用 CLLocationManager 來(lái)接收設(shè)備目前的朝向。朝向從真北或磁北極以度數(shù)測(cè)量庶柿。
  2. 創(chuàng)建了空的 SCNSceneSCNNode村怪。targetNode 是一個(gè)包含小方塊的 SCNNode

將如下代碼添加到 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)

這段代碼相當(dāng)直接:

  1. ViewController 設(shè)置為 CLLocationManager 的代理浮庐。
  2. 調(diào)用本行后甚负,就會(huì)獲得朝向信息。默認(rèn)情況下审残,朝向改變超過(guò) 1 度時(shí)就會(huì)通知代理梭域。
  3. 這是 SCNView 的一些設(shè)置代碼。它創(chuàng)建了一個(gè)空的場(chǎng)景维苔,并且添加了一個(gè)鏡頭碰辅。

為了采用 CLLocationManagerDelegate 協(xié)議,為 ViewController 添加如下擴(kuò)展:

extension ViewController: CLLocationManagerDelegate {
  func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
    //1
    self.heading = fmod(newHeading.trueHeading, 360.0)
    repositionTarget()
  }
}

每次新的朝向信息可用時(shí)介时,CLLocationManager 會(huì)調(diào)用此委派方法没宾。 fmod 是 double 值的模數(shù)函數(shù),確保朝向在 0 到 359 內(nèi)沸柔。

現(xiàn)在為 ViewController.swift 添加 repostionTarget()循衰,但要加在常規(guī)的 implementation 內(nèi),而不是在 CLLocationManagerDelegate 擴(kuò)展里:

func repositionTarget() {
  //1
  let heading = getHeadingForDirectionFromCoordinate(from: userLocation, to: target.location)
 
  //2
  let delta = heading - self.heading
 
  if delta < -15.0 {
    leftIndicator.isHidden = false
    rightIndicator.isHidden = true
  } else if delta > 15 {
    leftIndicator.isHidden = true
    rightIndicator.isHidden = false
  } else {
    leftIndicator.isHidden = true
    rightIndicator.isHidden = true
  }
 
  //3
  let distance = userLocation.distance(from: target.location)
 
  //4
  if let node = target.itemNode {
 
    //5
    if node.parent == nil {
      node.position = SCNVector3(x: Float(delta), y: 0, z: Float(-distance))
      scene.rootNode.addChildNode(node)
    } else {
      //6
      node.removeAllActions()
      node.runAction(SCNAction.move(to: SCNVector3(x: Float(delta), y: 0, z: Float(-distance)), duration: 0.2))
    }
  }
}

上面每個(gè)注釋的部分都做了如下事情:

  1. 你會(huì)在下個(gè)步驟里實(shí)現(xiàn)這個(gè)方法褐澎,這就是用來(lái)計(jì)算當(dāng)前位置到目標(biāo)的朝向的会钝。
  2. 然后計(jì)算設(shè)備當(dāng)前朝向和位置朝向的增量值。如果增量小于 -15,顯示左指示器 label迁酸。如果大于 15先鱼,顯示右指示器 label。如果介于 -15 和 15 之間奸鬓,把二者都隱藏焙畔,因?yàn)閿橙藨?yīng)該在屏幕上了。
  3. 這里獲取了設(shè)備位置到敵人的距離串远。
  4. 如果 item 已分配 node...
  5. 如果 node 沒(méi)有 parent宏多,使用 distance 設(shè)置位置,并且把 node 加到場(chǎng)景里澡罚。
  6. 否則伸但,移除所有 action,然后創(chuàng)建一個(gè)新 action留搔。

如果你很熟悉 SceneKit 或 SpriteKit更胖,最后一行理解起來(lái)應(yīng)該沒(méi)什么問(wèn)題。如果不是催式,我會(huì)在這里詳細(xì)解析一下函喉。

SCNAction.move(to:, duration:) 創(chuàng)建了一個(gè) action,把 node 移動(dòng)到給定的位置荣月,耗時(shí)也是給定的。runAction(_:)SCNOde 的方法梳毙,執(zhí)行了一個(gè) action哺窄。你還可以創(chuàng)建 action 的組和/或序列。如果想學(xué)習(xí)更多账锹,Ray Wenderlich 的書(shū) 3D 蘋(píng)果游戲教程 是一個(gè)很好的資源萌业。
現(xiàn)在來(lái)實(shí)現(xiàn)缺失的方法。將如下方法添加到 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 {
  //1
  let fLat = degreesToRadians(from.coordinate.latitude)
  let fLng = degreesToRadians(from.coordinate.longitude)
  let tLat = degreesToRadians(to.coordinate.latitude)
  let tLng = degreesToRadians(to.coordinate.longitude)
 
  //2
  let degree = radiansToDegrees(atan2(sin(tLng-fLng)*cos(tLat), cos(fLat)*sin(tLat)-sin(fLat)*cos(tLat)*cos(tLng-fLng)))
 
  //3
  if degree >= 0 {
    return degree
  } else {
    return degree + 360
  }
}

radiansToDegrees(_:)degreesToRadians(_:) 只是兩個(gè)簡(jiǎn)單的幫助方法奸柬,用于在弧度和角度之間轉(zhuǎn)換值生年。
getHeadingForDirectionFromCoordinate(from:to:): 內(nèi)部發(fā)生了這些事情:

  1. 首先,將經(jīng)度和緯度的值轉(zhuǎn)換為弧度廓奕。
  2. 使用這些值抱婉,計(jì)算朝向,然后將其轉(zhuǎn)換回角度桌粉。
  3. 如果值為負(fù)蒸绩,則添加 360 度使它為正。這沒(méi)有錯(cuò)铃肯,因?yàn)?-90 度其實(shí)就是 270 度患亿。

還有兩小步,就可以看見(jiàn)我們的工作成果了押逼。
首先步藕,需要將用戶的位置傳遞給 viewController惦界。打開(kāi) MapViewController.swift 然后找到 mapView(_:, didSelect:) 中的最后一個(gè) if 語(yǔ)句,并在顯示 view controller 之前添加下面這行咙冗;

 viewController.userLocation = mapView.userLocation.location!

現(xiàn)在把如下方法添加到 ViewController:

 func setupTarget() {
   targetNode.name = "敵人"
   self.target.itemNode = targetNode    
 }

這里只需要給 targetNode 一個(gè)名字表锻,并將其分配給 target。現(xiàn)在可以在 viewDidLoad() 方法的末尾調(diào)用此方法乞娄,就在添加鏡頭 node 之后:

 scene.rootNode.addChildNode(cameraNode)
 setupTarget()

構(gòu)建運(yùn)行項(xiàng)目瞬逊;看著你那個(gè)并不是很有威脅性的小方塊四處移動(dòng):

拋光

使用立方體或球這種簡(jiǎn)陋的物體構(gòu)建 app 是一種簡(jiǎn)單的方式,不需要花費(fèi)太多時(shí)間搗鼓 3D 模型——但 3D 模型看起來(lái) 太 帥 了仪或。在這節(jié)中确镊,我們會(huì)為游戲添加一些高光,為敵人添加 3D 模型以及拋火球功能范删。

打開(kāi) art.scnassets 文件夾查看兩個(gè) .dae 文件蕾域。這些文件包含了敵人的模型:一個(gè)狼,另一個(gè)是龍到旦。

下一步是更改 ViewController.swift 中的 setupTarget() 以加載其中一個(gè)模型并將其分配給目標(biāo)的 itemNode 屬性旨巷。

用如下代碼替換 setupTarget() 的內(nèi)容:

func setupTarget() {
  //1
  let scene = SCNScene(named: "art.scnassets/\(target.itemDescription).dae")
  //2
  let 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 = "敵人"
  self.target.itemNode = node
}

上面發(fā)生了這些事情:

  1. 首先把模型加載到場(chǎng)景里。target 的 itemDescription.dae 文件的名字添忘。
  2. 接下來(lái)采呐,遍歷場(chǎng)景,找到一個(gè)名為 itemDescription 的 node搁骑。只有一個(gè)具有此名稱的 node斧吐,正好是模型的根 node。
  3. 然后調(diào)整位置仲器,讓兩個(gè)模型出現(xiàn)在相同的位置上煤率。如果這兩個(gè)模型來(lái)自同一個(gè)設(shè)計(jì)器,可能不會(huì)需要這個(gè)步驟乏冀。然而蝶糯,我使用了來(lái)自不同設(shè)計(jì)器的兩個(gè)模型:狼來(lái)自 https://3dwarehouse.sketchup.com/,龍來(lái)自 https://clara.io 辆沦。
  4. 最后昼捍,將模型添加到空 node,然后把它分配給當(dāng)前 target 的 itemNode 屬性众辨。這是一個(gè)小竅門(mén)端三,讓下一節(jié)的觸摸處理更簡(jiǎn)單一些。

構(gòu)建運(yùn)行項(xiàng)目鹃彻;你會(huì)看到一個(gè)狼的 3D 模型郊闯,看起來(lái)比 low 逼的小方塊危險(xiǎn)多了!
事實(shí)上,狼看起來(lái)已經(jīng)可怕到足夠把你的女朋友嚇跑团赁,但作為一個(gè)勇敢的英雄育拨,撤退不是我們的選擇!下面會(huì)為她準(zhǔn)備幾個(gè)小火球欢摄,這樣在她成為狼的午餐之前可以把它殺掉熬丧。
觸摸結(jié)束事件是拋出火球的好時(shí)機(jī),因此將以下方法添加到 ViewController.swift

override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
  //1
  let touch = touches.first!
  let location = touch.location(in: sceneView)
 
  //2
  let hitResult = sceneView.hitTest(location, options: nil)
  //3
  let fireBall = SCNParticleSystem(named: "Fireball.scnp", inDirectory: nil)
  //4
  let emitterNode = SCNNode()
  emitterNode.position = SCNVector3(x: 0, y: -5, z: 10)
  emitterNode.addParticleSystem(fireBall!)
  scene.rootNode.addChildNode(emitterNode)
 
  //5  
  if hitResult.first != nil {
    //6
    target.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 {
    //7
    emitterNode.runAction(SCNAction.move(to: SCNVector3(x: 0, y: 0, z: -30), duration: 0.5))
  }
}

火球工作邏輯:

  1. 把觸摸轉(zhuǎn)換為場(chǎng)景里的坐標(biāo)怀挠。
  2. hitTest(_, options:) 發(fā)送光線跟蹤到給定位置析蝴,并為光線跟蹤線上的每個(gè) node 返回一個(gè) SCNHitTestResult 數(shù)組。
  3. 從SceneKit 的顆粒文件加載火球的顆粒系統(tǒng)绿淋。
  4. 然后將顆粒系統(tǒng)加載到空節(jié)點(diǎn)闷畸,并將其放在屏幕外面的底下。這使得后球看起來(lái)像來(lái)自玩家的位置吞滞。
  5. 如果檢測(cè)到點(diǎn)擊...
  6. ...等待一小段時(shí)間佑菩,然后刪除包含敵人的 itemNode。同時(shí)把發(fā)射器 node 移動(dòng)到敵人的位置裁赠。
  7. 如果沒(méi)有打中殿漠,火球只是移動(dòng)到了固定的位置。

構(gòu)建運(yùn)行項(xiàng)目佩捞,讓狼在烈焰中燃燒吧绞幌!

結(jié)束觸摸

要結(jié)束游戲,需要從列表中移除敵人失尖,關(guān)閉增強(qiáng)現(xiàn)實(shí)視圖啊奄,回到地圖尋找下一個(gè)敵人。
從列表中移除敵人必須在 MapViewController 中完成掀潮,因?yàn)閿橙肆斜碓谀抢铩榇饲砀唬枰砑右粋€(gè)只帶有一個(gè)方法的委托協(xié)議仪吧,在 target 被擊中時(shí)調(diào)用。
ViewController.swift 中添加如下協(xié)議鞠眉,就在類聲明之上:

 protocol ARControllerDelegate {
   func viewController(controller: ViewController, tappedTarget: ARItem)
 }

還要給 ViewController 添加如下屬性:

 var delegate: ARControllerDelegate?

代理協(xié)議中的方法告訴代理有一次命中薯鼠;然后代理可以決定接下來(lái)要做什么。
仍然在 ViewController.swift 中械蹋,找到 touchesEnded(_:with:) 并將 if 語(yǔ)句的條件代碼塊更改如下:

if hitResult.first != nil {
  target.itemNode?.runAction(SCNAction.sequence([SCNAction.wait(duration: 0.5), SCNAction.removeFromParentNode(), SCNAction.hide()]))
  //1
  let sequence = SCNAction.sequence(
    [SCNAction.move(to: target.itemNode!.position, duration: 0.5),
     //2
     SCNAction.wait(duration: 3.5),  
     //3
     SCNAction.run({_ in
        self.delegate?.viewController(controller: self, tappedTarget: self.target)
      })])
   emitterNode.runAction(sequence)
} else {
  ...
}

改變解釋如下:

  1. 將發(fā)射器 node 的操作更改為序列出皇,移動(dòng)操作保持不變。
  2. 發(fā)射器移動(dòng)后哗戈,暫停 3.5 秒郊艘。
  3. 然后通知代理目標(biāo)被擊中。

打來(lái) MapViewController.swift 添加如下屬性以存儲(chǔ)被選中的 annotation:

 var selectedAnnotation: MKAnnotation?

稍后會(huì)用到它以從 MapView 移除。
現(xiàn)在找到 mapView(_:, didSelect:) 纱注,并對(duì)那個(gè)實(shí)例化了 ViewController 的條件綁定和塊(即 if let)作出如下改變:

if let viewController = storyboard.instantiateViewController(withIdentifier: "ARViewController") as? ViewController {
  //1
  viewController.delegate = self
 
  if let mapAnnotation = view.annotation as? MapAnnotation {
    viewController.target = mapAnnotation.item
    viewController.userLocation = mapView.userLocation.location!
 
    //2
    selectedAnnotation = view.annotation
    self.present(viewController, animated: true, completion: nil)
  }
}

相當(dāng)簡(jiǎn)單:

  1. 這行把 ViewController 的代理設(shè)置為 MapViewController畏浆。
  2. 保存被選中的 annotation。

MKMapViewDelegate 擴(kuò)展下面添加如下代碼:

extension MapViewController: ARControllerDelegate {
  func viewController(controller: ViewController, tappedTarget: ARItem) {
    //1
    self.dismiss(animated: true, completion: nil)
    //2
    let index = self.targets.index(where: {$0.itemDescription == tappedTarget.itemDescription})
    self.targets.remove(at: index!)
 
    if selectedAnnotation != nil {
      //3
      mapView.removeAnnotation(selectedAnnotation!)
    }
  }
}

依次思考每個(gè)已注釋的部分:

  1. 首先關(guān)閉了增強(qiáng)現(xiàn)實(shí)視圖狞贱。
  2. 然后從 target 列表中刪除 target刻获。
  3. 最后從地圖上移除 annotation。

構(gòu)建運(yùn)行瞎嬉,看看最后的成品:

下一步蝎毡?

我的 GitHub 上有最終項(xiàng)目,帶有上面的全部代碼氧枣。
如果你想學(xué)習(xí)更多沐兵,以給這個(gè) app 增加更多可能性,可以看看下面的 Ray Wenderlich 的教程:

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市钳吟,隨后出現(xiàn)的幾起案子廷粒,更是在濱河造成了極大的恐慌,老刑警劉巖红且,帶你破解...
    沈念sama閱讀 216,372評(píng)論 6 498
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件坝茎,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡暇番,警方通過(guò)查閱死者的電腦和手機(jī)嗤放,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,368評(píng)論 3 392
  • 文/潘曉璐 我一進(jìn)店門(mén),熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)壁酬,“玉大人次酌,你說(shuō)我怎么就攤上這事恨课。” “怎么了和措?”我有些...
    開(kāi)封第一講書(shū)人閱讀 162,415評(píng)論 0 353
  • 文/不壞的土叔 我叫張陵庄呈,是天一觀的道長(zhǎng)。 經(jīng)常有香客問(wèn)我派阱,道長(zhǎng)诬留,這世上最難降的妖魔是什么? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 58,157評(píng)論 1 292
  • 正文 為了忘掉前任贫母,我火速辦了婚禮文兑,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘腺劣。我一直安慰自己绿贞,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,171評(píng)論 6 388
  • 文/花漫 我一把揭開(kāi)白布橘原。 她就那樣靜靜地躺著籍铁,像睡著了一般。 火紅的嫁衣襯著肌膚如雪趾断。 梳的紋絲不亂的頭發(fā)上拒名,一...
    開(kāi)封第一講書(shū)人閱讀 51,125評(píng)論 1 297
  • 那天,我揣著相機(jī)與錄音芋酌,去河邊找鬼增显。 笑死,一個(gè)胖子當(dāng)著我的面吹牛脐帝,可吹牛的內(nèi)容都是我干的同云。 我是一名探鬼主播,決...
    沈念sama閱讀 40,028評(píng)論 3 417
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼堵腹,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼炸站!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起疚顷,我...
    開(kāi)封第一講書(shū)人閱讀 38,887評(píng)論 0 274
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤武契,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后荡含,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,310評(píng)論 1 310
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡届垫,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,533評(píng)論 2 332
  • 正文 我和宋清朗相戀三年释液,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片装处。...
    茶點(diǎn)故事閱讀 39,690評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡误债,死狀恐怖浸船,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情寝蹈,我是刑警寧澤李命,帶...
    沈念sama閱讀 35,411評(píng)論 5 343
  • 正文 年R本政府宣布,位于F島的核電站箫老,受9級(jí)特大地震影響封字,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜耍鬓,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,004評(píng)論 3 325
  • 文/蒙蒙 一阔籽、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧牲蜀,春花似錦笆制、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 31,659評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至度苔,卻和暖如春匆篓,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背林螃。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 32,812評(píng)論 1 268
  • 我被黑心中介騙來(lái)泰國(guó)打工奕删, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人疗认。 一個(gè)月前我還...
    沈念sama閱讀 47,693評(píng)論 2 368
  • 正文 我出身青樓完残,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親横漏。 傳聞我的和親對(duì)象是個(gè)殘疾皇子谨设,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,577評(píng)論 2 353

推薦閱讀更多精彩內(nèi)容