如何编写和精灵宝可梦一样的 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: String let 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 { //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() }}

我们创建了一个 MapAnnotation 类并实现了 MKAnnotation 协议。

  1. 这个协议需要实现一个 coordinate 属性和 title 属性。
  2. item 属性保存了和大头钉相关的 ARItem。
  3. 实现一个便利初始化方法,在方法中对所有属性进行赋值。

回到 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.followWithHeading setupLocations()}

在定位之前,我们必须获得权限。

在 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) { //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) } } } }}

当用户点击到一个距离你不超过 50 米的敌人时,显示一个摄像画面:

  1. 获取所选中的大头钉的坐标。
  2. 去报 uerLocation 不为空。
  3. 确认所点的大头钉在用户位置 50 米范围内。
  4. 从故事版中实例化一个 ARViewController 实例。
  5. 检查被点击到的大头钉类型是 MapAnnotation。
  6. 显示 viewController。

运行 app,点击你位置附近的任意大头钉,会显示一个空白的 view controller:

技术分享

添加摄像画面

打开 ViewController.swift,在 import SceneKit 后面添加 import AVFoundation:

import UIKitimport SceneKitimport AVFoundationclass ViewController: UIViewController {...

添加两个属性用于保存一个 AVCaptureSession 对象和一个 AVCaptureVideoPreviewLayer 对象:

var cameraSession: AVCaptureSession?var cameraLayer: AVCaptureVideoPreviewLayer?

我们会用 capture session 来访问视频输入(比如镜头)和输出(比如取景框)。

添加一个方法:

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. 创建一些变量,用于返回一些值。
  2. 获得后置摄像头。
  3. 如果摄像头有效,获取它的输入。
  4. 创建 AVCaptureSession 对象。
  5. 将后置摄像头输入添加到 capture session。
  6. 返回一个元组,包含 captureSession 和 error。

现在我们已经从摄像头拿到输入了,就可以把它添加到视图中:

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

代码解释如下:

  • 首先调用前面的方法获得一个 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 { //1 viewController.target = mapAnnotation.item self.present(viewController, animated: true, completion: nil)}

在显示 viewController 之前,将一个 ARItem(它是被点击的大头钉的 item 属性)赋给它。这样,viewController 就能够知道当前敌人的种类。

现在 ViewController 已经获得了 target 的信息了。

打开 ARItem.swift 导入 SceneKit。

import Foundationimport 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 UIKitimport SceneKitimport AVFoundationimport CoreLocation

然后,增加属性声明:

//1var locationManager = CLLocationManager()var heading: Double = 0var userLocation = CLLocation()//2let scene = SCNScene()let cameraNode = SCNNode()let targetNode = SCNNode(geometry: SCNBox(width: 1, height: 1, length: 1, chamferRadius: 0))

代码解释如下:

  1. 我们用一个 CLLocationManager 去监听设备的朝向。heading 的单位为度,表示正北方或者磁北极偏转角度。
  2. 创建一个 SCNode() 和一个 SCNode 对象。targetNode 将用来放入一个立方体。

在 viewDidLoad() 最后一句添加:

//1self.locationManager.delegate = self//2self.locationManager.startUpdatingHeading()//3sceneView.scene = scene cameraNode.camera = SCNCamera()cameraNode.position = SCNVector3(x: 0, y: 0, z: 10)scene.rootNode.addChildNode(cameraNode)

代码解释如下:

  1. 将 ViewController 设置为 CLLocationManager 委托。
  2. 通过调用 startUpdatingHeading 方法,我们可以接收方向通知。默认,当方向改变超过 1 度时,委托方法会被调用。
    This sets ViewController as the delegate for the CLLocationManager.
  3. 设置 SCNView。首先创建了一个空的 scene,然后将相机添加到其中。

添加一个扩展,实现 CLLocationManagerDelegate 协议:

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

当收到新的方向通知,CLLocationManager 会调用这个委托方法。fmod 对 double 进行取模运算,确保方向的取值位于 0-359 之间。

在 ViewController.swift 中添加一个 repostionTarget()方法,注意是放在类实现而不是 CLLocationManagerDelegate 扩展中:

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

代码解释如下:

  1. getHeadingForDirectionFromCoordinate 这个方法用于计算从当前位置到目标的方向,具体实现后面介绍。
  2. 计算设备当前方向和目标方向之间的偏转角度(即 delta)。如果 delta 小于 -15,显示左箭头。如果大于 15,显示右箭头。如果在 -15 到 15 之间,两个箭头都隐藏,表示敌人就在屏幕中。
  3. 计算从设备位置到敌人之间的距离。
  4. 如果 itemNode 不为空……
  5. 同时 node 没有父节点,将 itemNode 的位置设置为 distance 并将 node 放到屏幕上。
  6. 否则,删除所有 action 并创建一个新的 action。

如果你懂 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 {  //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(_:) 方法用于将弧度和角度互转。

getHeadingForDirectionFromCoordinate(from:to:) 方法代码解释如下:

  1. 首先,将角度转换为弧度。
  2. 然后用转换后的弧度计算出方向在转成角度。
  3. 如果 degree 是负数,将之加上 360 度让数据更一致。这是可以的,因为 -90 度就等于 270 度。

还需要几个步骤才能运行你的 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() { //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 = "enemy" self.target.itemNode = node}

代码解释如下:

  1. 首先将模型加载到场景中。目标的 itemDescription 属性名和 .dae 文件名对应。
  2. 然后遍历场景,查找其中和 itemDescription 名字相同的节点。这只会有一个节点,即模型的根节点。
  3. 调整模型放置的位置,以便两个模型都会在同一地方出现。如果两个模型都出自同一个设计师之手,可能这一步是不必要的。但是我的这两个模型分别来自不同的设计师:狼来自于 3dwarehouse.sketchup.com ,龙来自于 https://clara.io。
  4. 将模型添加到空节点,然后将节点赋给当前目标的 itemNode 属性。还剩下一个小问题,即触摸的处理,放在后面介绍。

运行 app,你会看到一只立体的狼,这可比一个便宜的方块要吓人多了!

事实上,这只狼足以让你吓得远远抛开了,但作为勇敢主角的你,逃跑从来不是你的选择!接下来你应该加上几个火球,这样你就能在成为狼的点心之前战胜它了。

抛出火球的最好时机是用户的触摸结束事件,因此在 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. 将触摸转换成场景坐标。
  2. hitTest(_, options:) 方法向指定的位置发射射线,返回一个 SCNHitTestResult 数组,表示该射线所穿过的所有节点。
  3. 从 SceneKit 粒子文件中加载粒子系统,用于发射火球。
  4. 将粒子系统加到一个空节点身上,然后将它放到屏幕下方以外。这使得火球看起来是从玩家位置发射的。
  5. 判断是否有碰撞发生……
  6. 等待 0.5 秒,然后移除敌人所对应的 itemNode。同时将粒子发射器节点移动到敌人的位置。
  7. 如果没有碰撞发生,火球移动到一个固定的位置。

运行 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()])) //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. 将粒子发射器节点的 action 改成一个 action 序列,其中 move 动作仍然保留。
  2. move 动作之后,暂停 3.5 秒。
  3. 通知委托对象,target 被击中。

打开 MapViewController.swift 声明一个属性,用于保存 选中的大头钉:

var selectedAnnotation: MKAnnotation?

这个属性用于待会将它从地图上移出。修改它的 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) }}

非常简单:

  1. 将 viewController 的委托设置为 MapViewController。
  2. 保存用户点中的大头钉对象。

在 MKMapViewDelegate 扩展下面添加:

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

代码解释如下:

  1. 解散 AR 视图。
  2. 从 targets 数组中删除 target。
  3. 从地图上删除大头钉。

运行 app,你将看到最终效果:

技术分享

结束

最终完成的项目在这里下载。

如果你想尽可能地学习如何编写这个 app,请参考下列教程:

  • 关于 MapKit 和位置服务,请参考我们的 MapKit Swift 入门。
  • 关于视频捕捉,请参考我们的 AVFoundation 系列。
  • 关于 SceneKit,请参考我们的 SceneKit 系列教程。
  • 要避免对敌人位置进行硬编码,则需要后台数据的支持,请参考如何编写一个简单的 PHP/MySQL 服务 以及 如何用 Vapor 进行服务端编程。

希望你喜欢本教程。如果有任何问题和建议,请在下面留言。

$(function () {
$(‘pre.prettyprint code‘).each(function () {
var lines = $(this).text().split(‘\n‘).length;
var $numbering = $(‘

    ‘).addClass(‘pre-numbering‘).hide();
    $(this).addClass(‘has-numbering‘).parent().append($numbering);
    for (i = 1; i <= lines; i++) {
    $numbering.append($(‘

  • ‘).text(i));
    };
    $numbering.fadeIn(1700);
    });
    });

相关文章