说明
SceneKit系列文章目录
更多iOS相关知识查看github上WeekWeekUpProject
本教程将包含以下内容:
- 在SceneKit编辑器中建立基本的3D场景.
- 编程加载并呈现3D场景.
- 建立仿真物理,如何应用力.
- 通过触摸与3D场景中的物体交互.
- 设计并实现基本的碰撞检测.
开始
开始前,先下载初始项目starter project
打开项目,简单查看一下里面都有些什么.你会发现球和罐头的素材,还有一个GameHelper文件能提供一些有用的函数.
创建并运行,看上去一片黑:
不要难过,这只是一个干净的工作台供你开始.
建立并弹出菜单
在开始砸罐头之前,需要给游戏添加一个菜单选项.打开GameViewController.swift并添加一个新的属性:
// Scene properties
var menuScene = SCNScene(named: "resources.scnassets/Menu.scn")!
这段代码将加载菜单场景.你将可以使用menuScene来实现菜单和等级场景之间的跳转.
要弹出菜单场景,需要在viewDidLoad()里添加下列代码:
// MARK: - Helpers
func presentMenu() {
let hudNode = menuScene.rootNode.childNode(withName: "hud", recursively: true)!
hudNode.geometry?.materials = [helper.menuHUDMaterial]
hudNode.rotation = SCNVector4(x: 1, y: 0, z: 0, w: Float(M_PI))
helper.state = .tapToPlay
let transition = SKTransition.crossFade(withDuration: 1.0)
scnView.present(
menuScene,
with: transition,
incomingPointOfView: nil,
completionHandler: nil
)
}
这个函数配置了菜单场景中的抬头显示节点(HUD),并通过present(scene:with:incomingPointOfView:completionHandler:)交叉淡出的转场.
在viewDidLoad()底部添加调用presentMenu():
override func viewDidLoad() {
super.viewDidLoad()
presentMenu()
}
编译运行,会看到这样的菜单场景:
在场景编辑器中创建等级
打开resources.scnassets/Level.scn场景:
从对象库中拖入一个Floor节点到场景中:
在右侧的 Attributes Inspector中将 Reflectivity改为 0.05,这样地板就有了轻微反射.
选择Material Inspector并设置wood-floor.jpg为Diffuse纹理.设置Offset为(x: 0, y: 0.2),设置Scale为(x: 15, y: 15),最后,设置Rotation为90度:
现在地板已经放置好了,还需要再添加砖墙作为背景.墙的几何体已经在Wall.scn场景里为你配置好了.用Reference Node引用节点将其添加到等级场景中.
在Level.scn场景中,从媒体库中拖拽一个Wall引用节点到场景中.
在Node Inspector中设置节点名字为wall并设置位置为(x: 0, y: 0, z: -5).
下一步,你需要一个点来堆放罐头.从Object Library对象库中拖放一个Box命名为shelf,并放置到(x: 0.0, y: 2.25, z: -2.25)处,正好在墙的前面.
在Attributes Inspector中设置Width为10,Height为0.25.最后,在Material Inspector中,设置Diffuse为wood-table.png,打开附加属性,设置WrapS和WrapT为Repeat,设置Scale为(x: 2, y: 2).使纹理充满整个盒子,让它看起来像是一个真的架子.
为了完成这个关卡,还需要添加一对灯光和一个摄像机.从对象库中拖放一个Spot light点光源,设置Position为(x: 8.3, y: 13.5, z: 15.0),Euler为(x: -40, y: 28, z: 0). 这样就将点光源放置在空中,朝向场景中的焦点--架子.
在Attributes Inspector中, 设置Inner Angle为35,Outer Angle为85.这让灯光更柔和,也扩展了点光源锥体,扩大了场景中照亮的范围.
最后,在Shadow下面, 设置Sample radius为4,Sample count为1,并设置Color为黑色,透明度50%.让会让点光源投射出柔和的阴影:
为了淡化黑色的阴影,添加环境光照,拖放一个Ambient light到场景中.默认设置就可以了.
最后,你必须添加一个摄像机到场景中,来给游戏一个透视视角.拖放一个Carmera到场景中.Position在(x: 0.0, y: 2.5, z: 14.0),Rotation为(x: -5, y:0 , z:0). 在Attributes Inspector中, 将Y fov改为45.
很好!这样关卡设计就完成了.看看起来像这样:
加载关呈现关卡
在Level.scn中已经有一关了,那么怎么在设备上查看它呢?
在GameViewController中menuScene属性下面添加一行:
var levelScene = SCNScene(named: "resources.scnassets/Level.scn")!
这段代码加载了场景,并让你能够访问关卡中的所有节点.
现在,为了呈现这一关的场景,在presentMenu()后面添加下面的函数:
func presentLevel() {
helper.state = .playing
let transition = SKTransition.crossFade(withDuration: 1.0)
scnView.present(
levelScene,
with: transition,
incomingPointOfView: nil,
completionHandler: nil
)
}
该函数设置游戏状态为.playing,然后以交叉淡入的转场效果呈现中关卡场景,类似于在菜单场景中做的那样.
在touchesBegan(_:with:)方法最后面添加下面的代码:
if helper.state == .tapToPlay {
presentLevel()
}
这样,当你点击菜单场景时,游戏就会开始.
编译运行,然后点击菜单场景,会看到你设计的关卡淡入:
SceneKit中的物理效果
用SceneKit中创建游戏的一大好处就是,能够非常简单就利用内置的物理引擎来实现真实的物理效果.
为一个节点启用物理效果,你只需要给它添加physics body物理形体,并配置它的属性就可以了.你可以改变若干参数来模拟一个真实世界的物体;用到的最常见属性是形状,质量,摩擦因子,阻尼系数和回弹系数.
在该游戏中,你会用到物理效果和力来把球扔到罐头处.罐头将会有物理形体,来模拟空的铝罐.你的排球会很重,能猛击较轻的罐头,并都掉落在地板上.
动态地给关卡添加物理效果
在给游戏添加物理效果之前,你需要访问场景编辑器中创建的节点.为此,在GameViewController中场景属性后面添加下面几行:
// Node properties
var cameraNode: SCNNode!
var shelfNode: SCNNode!
var baseCanNode: SCNNode!
你需要这些节点来布局罐头,配置物理形体,定位场景中的其它节点.
下一步,在scnView计算属性后面添加以下代码:
// Node that intercept touches in the scene
lazy var touchCatchingPlaneNode: SCNNode = {
let node = SCNNode(geometry: SCNPlane(width: 40, height: 40))
node.opacity = 0.001
node.castsShadow = false
return node
}()
这是一个懒加载的不可见节点,你将会在处理场景中的触摸时用到它.
现在,准备开始写关卡中的物理效果.在presentLevel()后面,添加以下函数:
// MARK: - Creation
func createScene() {
// 1
cameraNode = levelScene.rootNode.childNode(withName: "camera", recursively: true)!
shelfNode = levelScene.rootNode.childNode(withName: "shelf", recursively: true)!
// 2
guard let canScene = SCNScene(named: "resources.scnassets/Can.scn") else { return }
baseCanNode = canScene.rootNode.childNode(withName: "can", recursively: true)!
// 3
let shelfPhysicsBody = SCNPhysicsBody(
type: .static,
shape: SCNPhysicsShape(geometry: shelfNode.geometry!)
)
shelfPhysicsBody.isAffectedByGravity = false
shelfNode.physicsBody = shelfPhysicsBody
// 4
levelScene.rootNode.addChildNode(touchCatchingPlaneNode)
touchCatchingPlaneNode.position = SCNVector3(x: 0, y: 0, z: shelfNode.position.z)
touchCatchingPlaneNode.eulerAngles = cameraNode.eulerAngles
}
解释一下上面的代码:
- 先找到在场景编辑器中创建的节点,并赋值给camera和shelf属性.
- 接着给baseCanNode赋值一个从预先创建的罐头场景中加载出来的节点.
- 创建静态物理形体给架子,并添加到shelfNode上去.
- 最后,放置好这个不可见的触摸捕捉节点,正对场景中的摄像机.
在viewDidLoad()里面的presentMenu()后面调用它:
createScene()
刚才添加的新的物理属性并没有任何可见效果,所以还需要继续添加罐头到场景中.
创建罐头
在游戏中,罐头将会有很多种排列来让游戏更难,更有趣.要实现这种效果,你需要一个重用的方法来创建罐头,配置他们的物理性质,并将它们添加到关卡中.
先从添加下面代码到presentLevel()后面开始:
func setupNextLevel() {
// 1
if helper.ballNodes.count > 0 {
helper.ballNodes.removeLast()
}
// 2
let level = helper.levels[helper.currentLevel]
for idx in 0..
以上代码含义:
- 如果玩家完成了前一个关卡,意味着他们还有球剩余,那他们可以再得到一个球做为奖励.
- 你循环遍历每个罐在当前关卡中的位置,通过克隆baseCanNode来创建并配置罐.你会在下一步中明白,什么是罐头的定位.
- 这里创建一个随机布尔值,来确定罐头有什么纹理和旋转角度.
- 每个罐头的位置,通过储存在canPositions中的数据来决定.
完成这些后,马上能看到关卡中的罐头了.在这之前,还需要创建一些关卡.
在GameHelper.swift中,你会发现一个GameLevel结构体,包含了一个简单的属性,代表关卡中每个罐头的3D坐标数组.还有另一个关卡数组,储存着你创建的关卡.
为了构成levels数组,要添加下面代码到GameViewController中的setupNextLevel()后面:
func createLevelsFrom(baseNode: SCNNode) {
// Level 1
let levelOneCanOne = SCNVector3(
x: baseNode.position.x - 0.5,
y: baseNode.position.y + 0.62,
z: baseNode.position.z
)
let levelOneCanTwo = SCNVector3(
x: baseNode.position.x + 0.5,
y: baseNode.position.y + 0.62,
z: baseNode.position.z
)
let levelOneCanThree = SCNVector3(
x: baseNode.position.x,
y: baseNode.position.y + 1.75,
z: baseNode.position.z
)
let levelOne = GameLevel(
canPositions: [
levelOneCanOne,
levelOneCanTwo,
levelOneCanThree
]
)
// Level 2
let levelTwoCanOne = SCNVector3(
x: baseNode.position.x - 0.65,
y: baseNode.position.y + 0.62,
z: baseNode.position.z
)
let levelTwoCanTwo = SCNVector3(
x: baseNode.position.x - 0.65,
y: baseNode.position.y + 1.75,
z: baseNode.position.z
)
let levelTwoCanThree = SCNVector3(
x: baseNode.position.x + 0.65,
y: baseNode.position.y + 0.62,
z: baseNode.position.z
)
let levelTwoCanFour = SCNVector3(
x: baseNode.position.x + 0.65,
y: baseNode.position.y + 1.75,
z: baseNode.position.z
)
let levelTwo = GameLevel(
canPositions: [
levelTwoCanOne,
levelTwoCanTwo,
levelTwoCanThree,
levelTwoCanFour
]
)
helper.levels = [levelOne, levelTwo]
}
这个函数只是创建了罐头的位置,并将其保存在帮助类的levels数组中.
要查看你的进度,在createScene()的底部添加下面代码:
createLevelsFrom(baseNode: shelfNode)
最后在presentLevel()的顶部添加这些代码:
setupNextLevel()
编译运行,然后点击菜单,就能看到罐头堆放在一起,像这样:
很好!现在有一个高效的可重用的方法,来加载关卡中的不同布局了.是时候添加一个球,开始投掷出去了.
添加球体
此时你还不能和游戏进行交互;你只能盯着看这些罐头生锈.
在文件头部的baseCanNode下面再添加一个节点属性,如下:
var currentBallNode: SCNNode?
它将用来追踪当前玩家正在交互的球.
下一步,在createLevelsFrom(baseNode:)后面添加一个新的函数:
func dispenseNewBall() {
// 1
let ballScene = SCNScene(named: "resources.scnassets/Ball.scn")!
let ballNode = ballScene.rootNode.childNode(withName: "sphere", recursively: true)!
ballNode.name = "ball"
let ballPhysicsBody = SCNPhysicsBody(
type: .dynamic,
shape: SCNPhysicsShape(geometry: SCNSphere(radius: 0.35))
)
ballPhysicsBody.mass = 3
ballPhysicsBody.friction = 2
ballPhysicsBody.contactTestBitMask = 1
ballNode.physicsBody = ballPhysicsBody
ballNode.position = SCNVector3(x: -1.75, y: 1.75, z: 8.0)
ballNode.physicsBody?.applyForce(SCNVector3(x: 0.825, y: 0, z: 0), asImpulse: true)
// 2
currentBallNode = ballNode
levelScene.rootNode.addChildNode(ballNode)
这个函数中:
- 你从Ball.scn中创建一个球,并配置其物理形体来模拟一个棒球.
- 在球的位置确定后,使用一个初始的力来使球从左侧进入视图.
要调用这个新函数,在setupNextLevel()末尾添加下面内容:
// Delay the ball creation on level change
let waitAction = SCNAction.wait(duration: 1.0)
let blockAction = SCNAction.run { _ in
self.dispenseNewBall()
}
let sequenceAction = SCNAction.sequence([waitAction, blockAction])
levelScene.rootNode.runAction(sequenceAction)
这段代码让第一个球延迟到关卡加载后.
这里物体效果有一点小问题.编译运行看看:
点击菜单;你会看到小球掉落到场景中,然后从屏幕中掉出去了.
由于地板目前还没有设置物理形体,所以球体并不知道自己应该弹跳落在地板上,而是穿过地板,掉落下去.
除了用代码给地板添加物理形体处,还可以在场景编辑器中添加.只需点击几下鼠标,就能让小球正常弹跳落在地板上.
用SceneKit编辑器添加物体形体
进入resources.scnassets/Level.scn并点击地板节点.选中Physics Inspector 将Type类型改为Static, 然后将Category mask设置为5.
这就是用SceneKit编辑器添加物理形体!其它设置项会带来不同行为,但是这个游戏中默认设置就好了.
编译运行,会看到小球弹跳进入并滚动到中间,准备好被扔出去的位置:
重复相同步骤,也给墙壁添加物理形体,毕竟我们不希望球贯穿墙壁一直飞下去.
投掷小球
现在是时候猛击罐头了.添加下面的属性到GameViewController:
// Ball throwing mechanics
var startTouchTime: TimeInterval!
var endTouchTime: TimeInterval!
var startTouch: UITouch?
var endTouch: UITouch?
根据触摸开始和结束的时间可以得出玩家移动手指的速度.从而计算出将小球扔向罐头的速度.触摸的位置也非常重要,因为它决定了飞行的方向是否正确.
然后在dispenseNewBall()后面添加下面的函数:
func throwBall() {
guard let ballNode = currentBallNode else { return }
guard let endingTouch = endTouch else { return }
// 1
let firstTouchResult = scnView.hitTest(
endingTouch.location(in: view),
options: nil
).filter({
$0.node == touchCatchingPlaneNode
}).first
guard let touchResult = firstTouchResult else { return }
// 2
levelScene.rootNode.runAction(
SCNAction.playAudio(
helper.whooshAudioSource,
waitForCompletion: false
)
)
// 3
let timeDifference = endTouchTime - startTouchTime
let velocityComponent = Float(min(max(1 - timeDifference, 0.1), 1.0))
// 4
let impulseVector = SCNVector3(
x: touchResult.localCoordinates.x,
y: touchResult.localCoordinates.y * velocityComponent * 3,
z: shelfNode.position.z * velocityComponent * 15
)
ballNode.physicsBody?.applyForce(impulseVector, asImpulse: true)
helper.ballNodes.append(ballNode)
// 5
currentBallNode = nil
startTouchTime = nil
endTouchTime = nil
startTouch = nil
endTouch = nil
}
在这个函数中:
- 首先,用了点击测试来得到触摸的节点.
- 接着,播放嗖的音效作为音频的反馈.
- 根据触摸开始和结束的时间计算速度.
- 然后创建一个矢量,从被触摸物体的本地坐标到架子的位置,用速度大小做为矢量长度.
- 最后,清理投掷属性,准备下次投掷.
为了让这个函数起作用,你需要游戏中的触摸事件处理.
将整个touchesBegan(_:with:)替换为:
override func touchesBegan(_ touches: Set, with event: UIEvent?) {
super.touchesBegan(touches, with: event)
if helper.state == .tapToPlay {
presentLevel()
} else {
guard let firstTouch = touches.first else { return }
let point = firstTouch.location(in: scnView)
let hitResults = scnView.hitTest(point, options: [:])
if hitResults.first?.node == currentBallNode {
startTouch = touches.first
startTouchTime = Date().timeIntervalSince1970
}
}
}
在触摸开始时,如果游戏是可玩状态,且触摸是在当前球上,那么记录触摸起点.
接着,替换touchesEnded(_: with:)为:
override func touchesEnded(_ touches: Set, with event: UIEvent?) {
super.touchesEnded(touches, with: event)
guard startTouch != nil else { return }
endTouch = touches.first
endTouchTime = Date().timeIntervalSince1970
throwBall()
}
当玩家手指离开屏幕,你需要保存触摸结束点及时间,因为它们决定了投掷方向是否正确.
编译运行,试着击倒这些罐头:
碰撞检测
如果你的准头好的话,你可能把所有罐头都击倒在地面上了.但是你还没有完成,当所有罐头撞击地面后你应该可以进入下一关了.
SceneKit处理这种碰撞检测非常容易.SCNPhysicsContactDelegate协议定义了几个有用的碰撞处理函数:
- physicsWorld(_:didBegin:):该方法在两个物体形体相互接触时调用.
- physicsWorld(_:didUpdate:):该方法在接触开始后调用,并提供关于两物体碰撞进展的附加信息.
- physicsWorld(_:didEnd:):该方法在两物体接触停止后调用.
它们都很有用,但这个游戏中我们只需要用到physicsWorld(_:didBeginContact:).
添加碰撞检测
当小球与其它节点碰撞时,你肯定会想要根据碰撞节点的类型来播放一些碰撞音效.还有罐头碰撞地面时,需要增加分数.
首先,给GameViewController添加下面的属性:
var bashedCanNames: [String] = []
你将用这个来记录已经碰撞过的罐头.
开始处理碰撞,在GameViewController.swift底部添加下面的扩展:
extension GameViewController: SCNPhysicsContactDelegate {
// MARK: SCNPhysicsContactDelegate
func physicsWorld(_ world: SCNPhysicsWorld, didBegin contact: SCNPhysicsContact) {
guard let nodeNameA = contact.nodeA.name else { return }
guard let nodeNameB = contact.nodeB.name else { return }
// 1
var ballFloorContactNode: SCNNode?
if nodeNameA == "ball" && nodeNameB == "floor" {
ballFloorContactNode = contact.nodeA
} else if nodeNameB == "ball" && nodeNameA == "floor" {
ballFloorContactNode = contact.nodeB
}
if let ballNode = ballFloorContactNode {
// 2
guard ballNode.action(forKey: GameHelper.ballFloorCollisionAudioKey) == nil else { return }
ballNode.runAction(
SCNAction.playAudio(
helper.ballFloorAudioSource,
waitForCompletion: true
),
forKey: GameHelper.ballFloorCollisionAudioKey
)
return
}
// 3
var ballCanContactNode: SCNNode?
if nodeNameA.contains("Can") && nodeNameB == "ball" {
ballCanContactNode = contact.nodeA
} else if nodeNameB.contains("Can") && nodeNameA == "ball" {
ballCanContactNode = contact.nodeB
}
if let canNode = ballCanContactNode {
guard canNode.action(forKey: GameHelper.ballCanCollisionAudioKey) == nil else {
return
}
canNode.runAction(
SCNAction.playAudio(
helper.ballCanAudioSource,
waitForCompletion: true
),
forKey: GameHelper.ballCanCollisionAudioKey
)
return
}
// 4
if bashedCanNames.contains(nodeNameA) || bashedCanNames.contains(nodeNameB) { return }
// 5
var canNodeWithContact: SCNNode?
if nodeNameA.contains("Can") && nodeNameB == "floor" {
canNodeWithContact = contact.nodeA
} else if nodeNameB.contains("Can") && nodeNameA == "floor" {
canNodeWithContact = contact.nodeB
}
// 6
if let bashedCan = canNodeWithContact {
bashedCan.runAction(
SCNAction.playAudio(
helper.canFloorAudioSource,
waitForCompletion: false
)
)
bashedCanNames.append(bashedCan.name!)
helper.score += 1
}
}
}
这段代码中:
- 首先,检测碰撞是不是发生在球和地板之间.
- 如果球碰到了地板,播放音效.
- 如果小球没有与地板接触,就判断小球是否与罐头接触.如果接触,播放另一段音效.
- 如果当前的罐头已经与地板碰撞过,不需要处理,因为你已经处理过了.
- 检查罐头是否与地板碰撞.
- 如果罐头接触到地板,记录罐头的名字,来确保这个罐头的碰撞只处理了一次.当新的罐头碰撞到地板时增加分数.
会有很多碰撞发生---很多需要处理!
在physicsWorld(_:didBegin:)底单添加下面的代码:
// 1
if bashedCanNames.count == helper.canNodes.count {
// 2
if levelScene.rootNode.action(forKey: GameHelper.gameEndActionKey) != nil {
levelScene.rootNode.removeAction(forKey: GameHelper.gameEndActionKey)
}
let maxLevelIndex = helper.levels.count - 1
// 3
if helper.currentLevel == maxLevelIndex {
helper.currentLevel = 0
} else {
helper.currentLevel += 1
}
// 4
let waitAction = SCNAction.wait(duration: 1.0)
let blockAction = SCNAction.run { _ in
self.setupNextLevel()
}
let sequenceAction = SCNAction.sequence([waitAction, blockAction])
levelScene.rootNode.runAction(sequenceAction)
}
代码做的是:
- 如果被撞掉下来的罐头数量和本关的罐头数量一致,我们进入下一关.
- 移除旧游戏结束动作.
- 一旦最后一关完成,循环各个关卡,因为本游戏是为了获取最高分.
- 在短暂的延迟后加载下一关卡.
为了让接触代理正常工作,在createScene()顶部添加下面的代码:
levelScene.physicsWorld.contactDelegate = self
最后添加下面代码到presentLevel()之后:
func resetLevel() {
// 1
currentBallNode?.removeFromParentNode()
// 2
bashedCanNames.removeAll()
// 3
for canNode in helper.canNodes {
canNode.removeFromParentNode()
}
helper.canNodes.removeAll()
// 4
for ballNode in helper.ballNodes {
ballNode.removeFromParentNode()
}
}
这段代码在玩家晋级一关后,帮助清理记录状态.做的是:
- 如果有当前的球,移除它.
- 移除所有在接触代理中用过的掉落罐头节点名称.
- 循环罐头节点,从它们的父节点移除,然后清理数组.
- 移除每个小球节点
你需要在好几个地方调用这个函数.在presentLevel()顶部添加下面代码:
resetLevel()
用下面代码替换physicsWorld(_:didBegin:)中移动到下一关的blockAction:
let blockAction = SCNAction.run { _ in
self.resetLevel()
self.setupNextLevel()
}
编译运行游戏;终于可以玩游戏了!试着只用一个球就打落所有罐头!
你不能指望每个玩家都能一击过关.下个任务是实现一个HUD,这样玩家就能看到他们的分数和剩余球数.
改善游戏性
在createScene()末尾添加下面代码:
levelScene.rootNode.addChildNode(helper.hudNode)
现在玩家就能看到他们的得分,以及剩余球数.你仍然需要一个方法来判断是掉落下一个球还是结束游戏.
在throwBall()的末尾添加下面几行:
if helper.ballNodes.count == GameHelper.maxBallNodes {
let waitAction = SCNAction.wait(duration: 3)
let blockAction = SCNAction.run { _ in
self.resetLevel()
self.helper.ballNodes.removeAll()
self.helper.currentLevel = 0
self.helper.score = 0
self.presentMenu()
}
let sequenceAction = SCNAction.sequence([waitAction, blockAction])
levelScene.rootNode.runAction(sequenceAction, forKey: GameHelper.gameEndActionKey)
} else {
let waitAction = SCNAction.wait(duration: 0.5)
let blockAction = SCNAction.run { _ in
self.dispenseNewBall()
}
let sequenceAction = SCNAction.sequence([waitAction, blockAction])
levelScene.rootNode.runAction(sequenceAction)
}
这个if语句处理玩家投掷完最后一球的情况.它给了他们三秒的延时,来让最后一个或两个罐头从架子上掉落下来.另一种情况,一旦玩家投完一球,你就会在一段延时之后重新掉落一个新的球,让他们有机会继续砸其它罐头!
最后一个改善点是,要显示玩家的最高分数,以便他们展示给朋友们看
添加下面代码到presentMenu()中,放在helper.state = .tapToPlay之后:
helper.menuLabelNode.text = "Highscore: \(helper.highScore)"
这段代码刷新菜单的HUD,这样玩家就能看到他们的最高分了!
全部完成!运行试试你能不能打败自己的高分?
本教程中的最终完成版项目可以看这里here.