如何编写和精灵宝可梦一样的 app?
原文:How To Make An App Like Pokemon Go
作者:Jean-Pierre Distler
譯者:kmyhy
如今最流行的一個(gè)手機(jī)游戲就是精靈寶可夢。它使用增強(qiáng)現(xiàn)實(shí)技術(shù)將游戲帶入到“真實(shí)世界”,讓玩家做一些對(duì)健康有益的事情。
在本教程中,我們將編寫自己的增強(qiáng)現(xiàn)實(shí)精靈捕獲游戲。這個(gè)游戲會(huì)顯示一張包含有你的位置和敵人的位置的地圖,用一個(gè) 3D SceneKit 視圖呈現(xiàn)后置攝像頭中拍攝的圖像和敵人的 3D 模型。
如果你第一次接觸增強(qiáng)現(xiàn)實(shí),你可以先看一下我們的基于地理位置的 RA 教程。對(duì)于要介紹如何編寫精靈寶可夢 app 的本教程來說,它不是必須的,但它里面包含了大量本教程未涉及的關(guān)于數(shù)學(xué)和 RA 的有用知識(shí)。
開始
本教程的開始項(xiàng)目在此處下載。項(xiàng)目包含了兩個(gè) view controller 和一個(gè) art.scnassets 文件夾,這個(gè)文件夾中包括了必須的 3D 模型和貼圖。
ViewController.swift 是一個(gè) UIViewController 子類,用于顯示 app 的 AR 內(nèi)容。MapViewController 用于顯示一張地圖,地圖上會(huì)包含你的當(dāng)前位置以及附近敵人的位置。一些基本的東西,比如約束和出口,都是已經(jīng)建好的了,你只需要關(guān)注本教程的核心內(nèi)容,即怎樣讓 app 長得像精靈寶可夢。
在地圖上添加敵人
在你能夠和敵人戰(zhàn)斗之前,需要知道敵人在哪。新建一個(gè) Swift 文件,叫做 ARItem.swift。
在文件的 ARItem.swift 的 import Foundation 一行后添加:
import CoreLocationstruct ARItem {let itemDescription: Stringlet location: CLLocation }ARItem 有一個(gè)描述字段和一個(gè)坐標(biāo)。這樣我們就能夠知道是什么樣的敵人,以及它在哪里。
打開 MapViewController.swift 添加一個(gè) impor CoreLocation 語句以及一個(gè)屬性:
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) }我們通過硬編碼的方式創(chuàng)建了 3 個(gè)敵人。我們會(huì)將坐標(biāo)(0,0) 替換成靠近你物理坐標(biāo)附近的坐標(biāo)。
有許多查找坐標(biāo)的方法。比如,可以在你當(dāng)前位置附近創(chuàng)建一些隨機(jī)的坐標(biāo),使用我們在上一篇教程的 PlacesLoader 或者 Xcode 模擬當(dāng)前位置。當(dāng)然,我們不想讓隨機(jī)坐標(biāo)出現(xiàn)在你鄰居的臥室里。那就尷尬了。
簡單點(diǎn)的方法,就是使用 Google 地圖。打開 https://www.google.com/maps/ 查找你當(dāng)前的位置。當(dāng)你點(diǎn)擊地圖,會(huì)顯示一個(gè)大頭釘,底部彈出一個(gè)氣泡。
在氣泡中會(huì)顯示你的經(jīng)緯度。我建議你從你的位置或你所在的街道附近創(chuàng)建出一些硬編碼的位置,這樣你就沒有必要去敲鄰居家門,告訴他你需要去他的臥室抓一條龍。
選擇 3 個(gè)位置,將上面代碼中的 0 替換成你選擇的坐標(biāo)。
在地圖上標(biāo)出敵人
我們已經(jīng)設(shè)定了敵人的坐標(biāo),應(yīng)該在地圖上將它們顯示出來。新增一個(gè) Swift 文件,取名為 MapAnnotation.swift。在這個(gè)文件中編寫如下代碼:
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()} }我們創(chuàng)建了一個(gè) MapAnnotation 類并實(shí)現(xiàn)了 MKAnnotation 協(xié)議。
回到 MapViewController.swift 在 setupLocations() 方法最后一句添加:
for item in targets { let annotation = MapAnnotation(location: item.location.coordinate, item: item)self.mapView.addAnnotation(annotation) }循環(huán)遍歷 targets 數(shù)組,每個(gè) target 都會(huì)添加一個(gè)大頭釘?shù)降貓D上。
在 viewDidLoad() 方法最后調(diào)用 setupLocations():
override func viewDidLoad() {super.viewDidLoad()mapView.userTrackingMode = MKUserTrackingMode.followWithHeadingsetupLocations() }在定位之前,我們必須獲得權(quán)限。
在 MapViewController 中添加一個(gè)新屬性:
let locationManager = CLLocationManager()在 viewDidLoad() 最后一句,添加請(qǐng)求權(quán)限的代碼:
if CLLocationManager.authorizationStatus() == .notDetermined {locationManager.requestWhenInUseAuthorization() }注意:如果不進(jìn)行權(quán)限請(qǐng)求,map view 無法加載用戶位置。而且不會(huì)提示任何錯(cuò)誤信息。每當(dāng)你調(diào)用位置服務(wù)時(shí),你都無法獲得位置信息,要排除錯(cuò)誤請(qǐng)首先從這個(gè)地方開始。
運(yùn)行 app,等一會(huì)地圖將縮放到你的當(dāng)前位置并顯示出一些紅色的大頭釘,它們表示了敵人的位置。
添加增強(qiáng)現(xiàn)實(shí)效果
我們有一個(gè)看起來不錯(cuò)的 app,但我們還需要添加一些 AR 元素。在下一節(jié),我們將添加一個(gè)攝像窗口并添加一個(gè)簡單的方塊來代表敵人。
首先我們需要跟蹤用戶位置。在 MapViewController 聲明屬性:
var userLocation: CLLocation?然后添加一個(gè)擴(kuò)展:
extension MapViewController: MKMapViewDelegate {func mapView(_ mapView: MKMapView, didUpdate userLocation: MKUserLocation) {self.userLocation = userLocation.location} }每次設(shè)備的位置發(fā)生改變,這個(gè)方法會(huì)被調(diào)用。這個(gè)方法中,我們簡單地保存了用戶位置,以便在另一個(gè)方法中使用。
在擴(kuò)展中添加委托方法:
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)}}}} }當(dāng)用戶點(diǎn)擊到一個(gè)距離你不超過 50 米的敵人時(shí),顯示一個(gè)攝像畫面:
運(yùn)行 app,點(diǎn)擊你位置附近的任意大頭釘,會(huì)顯示一個(gè)空白的 view controller:
添加攝像畫面
打開 ViewController.swift,在 import SceneKit 后面添加 import AVFoundation:
import UIKit import SceneKit import AVFoundationclass ViewController: UIViewController { ...添加兩個(gè)屬性用于保存一個(gè) AVCaptureSession 對(duì)象和一個(gè) AVCaptureVideoPreviewLayer 對(duì)象:
var cameraSession: AVCaptureSession? var cameraLayer: AVCaptureVideoPreviewLayer?我們會(huì)用 capture session 來訪問視頻輸入(比如鏡頭)和輸出(比如取景框)。
添加一個(gè)方法:
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) }這個(gè)方法負(fù)責(zé)這些事情:
現(xiàn)在我們已經(jīng)從攝像頭拿到輸入了,就可以把它添加到視圖中:
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} }代碼解釋如下:
- 首先調(diào)用前面的方法獲得一個(gè) capture session。
- 判斷是否有錯(cuò)誤發(fā)生,或者 capture session 為空,如果是立即 return,和 AR 說 bye-bye 吧!
- 否則,將 capture session 保存到 cameraSession 變量。
- 創(chuàng)建攝像預(yù)覽圖層,如果創(chuàng)建成功,設(shè)置它的 videoGravity 屬性和 frame 屬性,讓它占據(jù)整個(gè)屏幕。
- 將攝像預(yù)覽圖層(取景框)添加到 sublayers 中并保存到 cameraLayer 變量。
然后,在 viewDidLoad() 加入:
loadCamera()self.cameraSession?.startRunning()這里只做了兩件事情:首先調(diào)用前面編寫的方法,然后打開鏡頭取景框。這個(gè)取景框立馬會(huì)顯示到預(yù)覽圖層上。
運(yùn)行 app,點(diǎn)擊你身邊的任何一個(gè)位置,你會(huì)看到一個(gè)全新的鏡頭預(yù)覽界面:
添加方塊
干得不錯(cuò),但這還不算真正的 RA。在這一節(jié),我們將添加一個(gè)簡單的方塊來表示敵人,并根據(jù)用戶的位置和朝向來移動(dòng)它。
這個(gè)游戲會(huì)有兩種敵人:狼和龍。
因此,我們需要知道敵人的種類以及應(yīng)該在哪里顯示它們。
在 ViewController 中添加如下屬性(用于保存敵人的信息):
var target: ARItem!打開 MapViewController.swift, 找到 mapView(_:, didSelect:) 將最后一個(gè) if 語句修改為:
if let mapAnnotation = view.annotation as? MapAnnotation {//1viewController.target = mapAnnotation.itemself.present(viewController, animated: true, completion: nil) }在顯示 viewController 之前,將一個(gè) ARItem(它是被點(diǎn)擊的大頭釘?shù)?item 屬性)賦給它。這樣,viewController 就能夠知道當(dāng)前敵人的種類。
現(xiàn)在 ViewController 已經(jīng)獲得了 target 的信息了。
打開 ARItem.swift 導(dǎo)入 SceneKit。
import Foundation import SceneKitstruct ARItem { ... }添加一個(gè)屬性,用于保存一個(gè) SCNNode 對(duì)象:
var itemNode: SCNNode?確保這個(gè)屬性聲明在 ARItem 結(jié)構(gòu)的其它屬性之后,因?yàn)樵陔[式的初始化方法將使用相同的順序來定義參數(shù)。
Xcode 會(huì)提示 MapViewController.swift 中有一個(gè)錯(cuò)誤。要解決這個(gè)錯(cuò)誤,請(qǐng)打開這個(gè)文件,找到 setupLocations() 方法。
我們需要修改在編輯器左邊標(biāo)有一個(gè)紅點(diǎn)的代碼。
對(duì)于這些代碼,我們都需要將缺少的 itemNode 參數(shù)用 nil 來補(bǔ)上。
例如,這一行:
let firstTarget = ARItem(itemDescription: "wolf", location: CLLocation(latitude: 50.5184, longitude: 8.3902))應(yīng)當(dāng)改為:
let firstTarget = ARItem(itemDescription: "wolf", location: CLLocation(latitude: 50.5184, longitude: 8.3902), itemNode: nil)我們知道了敵人的種類,以及它們的位置,但我們還需要知道設(shè)備當(dāng)前朝向。
打開 ViewController.swift ,導(dǎo)入 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.
添加一個(gè)擴(kuò)展,實(shí)現(xiàn) CLLocationManagerDelegate 協(xié)議:
extension ViewController: CLLocationManagerDelegate {func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {//1self.heading = fmod(newHeading.trueHeading, 360.0)repositionTarget()} }當(dāng)收到新的方向通知,CLLocationManager 會(huì)調(diào)用這個(gè)委托方法。fmod 對(duì) double 進(jìn)行取模運(yùn)算,確保方向的取值位于 0-359 之間。
在 ViewController.swift 中添加一個(gè) repostionTarget()方法,注意是放在類實(shí)現(xiàn)而不是 CLLocationManagerDelegate 擴(kuò)展中:
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,則最后一句代碼你懂的。否則,這里會(huì)進(jìn)行更詳細(xì)的介紹。
SCNAction.move(to:, duration:) 方法創(chuàng)建一個(gè) action,將節(jié)點(diǎn)以指定時(shí)間移動(dòng)到指定的位置。runAction(_:) 也是 SCNNode 方法,用于執(zhí)行一個(gè) action。我們還可以創(chuàng)建 action 組/序列。要了解更多內(nèi)容,請(qǐng)閱讀我們的這本書3D Apple Games by Tutorials。
繼續(xù)實(shí)現(xiàn)前面未實(shí)現(xiàn)的方法。在 ViewController.swift 中添加這幾個(gè)方法:
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(_:) 方法用于將弧度和角度互轉(zhuǎn)。
getHeadingForDirectionFromCoordinate(from:to:) 方法代碼解釋如下:
還需要幾個(gè)步驟才能運(yùn)行你的 app。
首先,必須將用戶的坐標(biāo)傳遞給 viewController。打開 MapViewController.swift 找到 mapView(_:, didSelect:) 的最后一個(gè) if 語句,在顯示 view controller 之前加上這句:
viewController.userLocation = mapView.userLocation.location!然后在 ViewController.swift 中添加這個(gè)方法:
func setupTarget() {targetNode.name = "enemy"self.target.itemNode = targetNode }這個(gè)方法為 targetNode 設(shè)置一個(gè)名字,然后將它賦給 target。
現(xiàn)在可以在 viewDidLoad() 方法最后來調(diào)用這個(gè)方法了。在添加完攝像頭之后添加:
scene.rootNode.addChildNode(cameraNode) setupTarget()運(yùn)行 app,可以看到方塊在移動(dòng):
美化我們的 app
在開發(fā) app 初期用方塊或者圓球是一種簡單的處理方法,因?yàn)檫@樣省去了大量 3D 建模的時(shí)間——但 3D 模型看起來畢竟要漂亮得多。在這一節(jié),我們將繼續(xù)美化我們的 app ,為敵人加入 3D 模型,以及賦予玩家扔出火球的能力。
打開 art.scnassets 文件夾,里面有兩個(gè) .dae 文件。它們包含了敵人的模型:狼和龍。
接下來修改 ViewController.swift 中的 setupTarget() 方法,在其中加載這些 3D 模型并賦給目標(biāo)的 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 }代碼解釋如下:
運(yùn)行 app,你會(huì)看到一只立體的狼,這可比一個(gè)便宜的方塊要嚇人多了!
事實(shí)上,這只狼足以讓你嚇得遠(yuǎn)遠(yuǎn)拋開了,但作為勇敢主角的你,逃跑從來不是你的選擇!接下來你應(yīng)該加上幾個(gè)火球,這樣你就能在成為狼的點(diǎn)心之前戰(zhàn)勝它了。
拋出火球的最好時(shí)機(jī)是用戶的觸摸結(jié)束事件,因此在 ViewController.swift 中實(shí)現(xiàn)這個(gè)方法:
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))} }代碼解釋如下:
運(yùn)行 app,讓惡餓狼在火焰中焚燒吧!
收尾工作
要完成 app,我們還需要將敵人從列表中刪除,關(guān)閉 AR 視圖并回到地圖,以便找到下一個(gè)敵人。
移除敵人應(yīng)當(dāng)在 MapViewController 中進(jìn)行,因?yàn)閿橙肆斜砭驮谀抢铩N覀兛梢哉f明只有一個(gè)方法的委托協(xié)議,當(dāng)目標(biāo)被擊中后調(diào)用這個(gè)方法。
在 ViewController.swift 的類聲明之前,添加如下協(xié)議:
protocol ARControllerDelegate {func viewController(controller: ViewController, tappedTarget: ARItem) }同時(shí)為 ViewController 聲明一個(gè)屬性:
var delegate: ARControllerDelegate?委托方法會(huì)告訴委托對(duì)象說明時(shí)候發(fā)生了碰撞事件,然后委托對(duì)象就可以進(jìn)行下一步的處理。
在 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 聲明一個(gè)屬性,用于保存 選中的大頭釘:
var selectedAnnotation: MKAnnotation?這個(gè)屬性用于待會(huì)將它從地圖上移出。修改它的 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 擴(kuò)展下面添加:
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!)}} }代碼解釋如下:
運(yùn)行 app,你將看到最終效果:
結(jié)束
最終完成的項(xiàng)目在這里下載。
如果你想盡可能地學(xué)習(xí)如何編寫這個(gè) app,請(qǐng)參考下列教程:
- 關(guān)于 MapKit 和位置服務(wù),請(qǐng)參考我們的 MapKit Swift 入門。
- 關(guān)于視頻捕捉,請(qǐng)參考我們的 AVFoundation 系列。
- 關(guān)于 SceneKit,請(qǐng)參考我們的 SceneKit 系列教程。
- 要避免對(duì)敵人位置進(jìn)行硬編碼,則需要后臺(tái)數(shù)據(jù)的支持,請(qǐng)參考如何編寫一個(gè)簡單的 PHP/MySQL 服務(wù) 以及 如何用 Vapor 進(jìn)行服務(wù)端編程。
希望你喜歡本教程。如果有任何問題和建議,請(qǐng)?jiān)谙旅媪粞浴?/p>
總結(jié)
以上是生活随笔為你收集整理的如何编写和精灵宝可梦一样的 app?的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 2019 Multi-Universit
- 下一篇: HDU.5128 The E-pang