文章選自掘金蘋果API搬運(yùn)工的文章[SceneKit專題]22-3D平衡球游戲Marble-Maze
主要記錄自己在學(xué)習(xí)ARKit的過程中看到的好的文章,避免到時候鏈接失效無法找到原文的情況,非常感謝原博主的辛勤付出,也在此分享出來跟大家一起學(xué)習(xí)。
創(chuàng)建項目
- 打開Xcode,創(chuàng)建一個新的iOS版SceneKit游戲項目,命名為MarbleMaze.
- 刪除art.scnassets文件夾.
- 從resources文件夾中拖拽一個新的art.scnassets到項目中.
- 我們只使用豎屏模式,所以取消Landscape Left和Landscape Right來禁用旋轉(zhuǎn):
替換GameViewController.swift中的內(nèi)容:
import UIKit
import SceneKit
class GameViewController: UIViewController {
var scnView:SCNView!
override func viewDidLoad() {
super.viewDidLoad()
// 1
setupScene()
setupNodes()
setupSounds()
}
// 2
func setupScene() {
scnView = self.view as! SCNView
scnView.delegate = self
scnView.allowsCameraControl = true
scnView.showsStatistics = true
}
func setupNodes() {
}
func setupSounds() {
}
override var shouldAutorotate : Bool { return false }
override var prefersStatusBarHidden : Bool { return true }
}
// 3
extension GameViewController: SCNSceneRendererDelegate {
func renderer(_ renderer: SCNSceneRenderer,
updateAtTime time: TimeInterval) {
}
}
代碼含義:
- 在
viewDidLoad()中調(diào)用這些空的方法;稍后會向其中添加代碼. - 將self.view轉(zhuǎn)換為SCNView并保存下來.并設(shè)置self為渲染循環(huán)的代理.
- 實現(xiàn)SCNSceneRendererDelegate協(xié)議中的方法.
天空盒子,加載場景
在art.scnassets中找到空的game.scn場景文件.打開并選中默認(rèn)的camera node,然后選中右上方的Scene Inspector.從右下方的媒體庫中找到img_skybox.jpg拖拽到場景的背景屬性上.:

在GameViewController類中添加下面屬性:
var scnScene:SCNScene!
在setupScene()中添加下面代碼:
// 1
scnScene = SCNScene(named: "art.scnassets/game.scn")
// 2
scnView.scene = scnScene
運(yùn)行一下,看看神圣的天空景象:

主角--小球
拖拽一個空的SceneKit場景文件到你的項目,放到art.scnassets中,命名為obj_ball.scn:

選中art.scnassets/obj_ball.scn,展開場景樹,選中默認(rèn)的攝像機(jī)節(jié)點(diǎn).所有的新建場景都包含一個默認(rèn)的攝像機(jī)節(jié)點(diǎn),但作為引用節(jié)點(diǎn)被使用時就很不爽,所以我們刪除它:

下面開始創(chuàng)建木質(zhì)小球.從對象庫中拖拽一個球體到場景中:

打開節(jié)點(diǎn)檢查器.將小球命名為ball,放置位置為(x:0, y:0, z:0):

現(xiàn)在的小球太大了.打開屬性檢查器,更改半徑為0.45,提升分段數(shù)為36來讓它顯得更圓一些:

材質(zhì)設(shè)置
漫反射設(shè)置

法線設(shè)置

高光設(shè)置

反射設(shè)置

發(fā)光設(shè)置

隨著各個貼圖的添加,效果漸變?nèi)缦?

然后需要做的是將小球作為引用節(jié)點(diǎn)添加到場景中去. 選中art.scnassets/game.scn,然后拖拽art.scnassets/obj_ball.scn到場景中.設(shè)置位置為(x:0, y:0, z:0) 并命名為ball:

這樣,小球就作為一個引用節(jié)點(diǎn)被添加到場景中了.
運(yùn)行一下:

挑戰(zhàn)--創(chuàng)建木箱,小石塊,大石塊,柱子的引用節(jié)點(diǎn)
這是一個小小的挑戰(zhàn):
- 為每個對象創(chuàng)建一個空的場景.
-
刪除默認(rèn)的攝像機(jī). 試著創(chuàng)建下面的對象:
- obj_crate1x1:命名為crate并設(shè)置尺寸為 (x:1, y:1, z:1).使用img_crate_diffuse紋理作為漫反射貼圖,img_crate_normal作為法線貼圖.高光顏色設(shè)為中灰色;如果設(shè)為純白色,木箱會看起來像塑料的.
- obj_stone1x1:命名為stone并設(shè)置尺寸為 (x:1, y:1, z:1).使用img_stone_diffuse和img_stone_normal紋理作為貼圖,將法線intensity改為0.5. 設(shè)置高光色為White.
- obj_stone3x3:命名為stone并設(shè)置尺寸為 (x:3, y:3, z:3).紋理設(shè)置同上,高光仍為White.但是需要使用紋理縮放設(shè)置,及WrapT和WrapS來使其生效.
- obj_pillar1x3:命名為pillar并設(shè)置尺寸為 (x:1, y:3, z:1).使用img_pillar_ 紋理;還有高光紋理也要用上.還有應(yīng)用縮放及wrap設(shè)置.
當(dāng)設(shè)置3x3方塊時,可參照下面步驟:


設(shè)置過程中,會看到如下的依次變化:

最終完成版在12-Reference Nodes中的projects/ challenge/MarbleMaze/ 文件夾.
組織場景
選中art.scnassets/game.scn.組織一下場景樹如下:

創(chuàng)建一個空節(jié)點(diǎn)命名為follow_camera:

將camera節(jié)點(diǎn)放到follow_camera下,成為它的子節(jié)點(diǎn),并設(shè)置位置為 (x:0, y:0, z:5),旋轉(zhuǎn)為 (x:0, y:0, z:0):

創(chuàng)建另一個空節(jié)點(diǎn)命名為follow_light:

添加幾個空節(jié)點(diǎn)作為占位節(jié)點(diǎn),設(shè)置位置為零;
- pearls:待收集的珍珠分組.
- section1, section2, section3, section4:這些分組用來盛放本關(guān)卡的不同章節(jié).
創(chuàng)建最后一個空節(jié)點(diǎn),命名為static_light:

燈光
首先是固定燈光 拖拽一個泛光燈和一個環(huán)境光到場景中,并按順序放置在static_lights組節(jié)點(diǎn)中:

選中omni light,打開節(jié)點(diǎn)檢查器,命名為omni,位置,角度設(shè)為零:

打開屬性檢查器,設(shè)置顏色為深灰色:

選中ambient light,打開節(jié)點(diǎn)檢查器:

打開屬性檢查器,設(shè)置顏色為深灰:

查看一下場景中的小球:

接著添加跟隨燈光 拖拽一個聚光燈到場景中,放置在follow_light組節(jié)點(diǎn)下面:

選中聚光燈,打開它的節(jié)點(diǎn)檢查器,設(shè)置位置如下:

這個燈光是follow_light的子節(jié)點(diǎn), follow_light的位置是 (x:0, y:0, z:0),旋轉(zhuǎn)角度 (x:-25, y:-45, z:0);
然后選中聚光燈,打開屬性檢查器,設(shè)置金黃色模擬環(huán)境中的陽光:

完成后的效果:

重用集合體
將游戲中重復(fù)出現(xiàn)的結(jié)構(gòu)做成重用集合體,方便在需要的時候直接調(diào)用. 此處我們制作的是休息點(diǎn),它由一塊3x3的石塊和上面的4根柱子組成.
拖拽一個空的SceneKit場景文件到項目的根目錄中,然后在彈出框中選擇art.scnassets,點(diǎn)擊Create按鈕.

拖拽一個obj_stone3x3.scn的引用節(jié)點(diǎn)到空場景的,放置在(x: 0, y: 0, z:0).

拖拽一個obj_pillar1x3.scn引用節(jié)點(diǎn)到時大石塊的頂部.設(shè)置位置在(x: -1, y: 3, z: 1),即右上角位置.

使用?? (Option +Command) +點(diǎn)擊拖拽,復(fù)制三個柱子,位置如下:
- Top-Left. Positioned at (x: -1, y: 3, z: -1).
- Top-Right. Positioned at (x: 1, y: 3, z: -1).
-
Bottom-Right. Positioned at (x: 1, y: 3, z: 1).
記得刪除場景中默認(rèn)的攝像機(jī).
選中game.scn,然后拖放新創(chuàng)建的set_restpoint.scn到場景下方.位置設(shè)為 (x: 0, y: -2, z: 0)

運(yùn)行一下,會看到漂亮的陰影:

創(chuàng)建其它部件
現(xiàn)在還需要創(chuàng)建幾個其他的集合體,以便在主場景中直接引用. 比如straight_bridge,用了7個stone1x1組成:

zigzag_bridge,用了stone1x1和crate1x1方塊.共9格寬7格長.

然后就可以用這些來組成大場景:

從左下角開始,放置一個restpoint休息點(diǎn)在地平面下,(x:0, y:0, z:0) 處.然后將其他引用集合體拖拽到場景中. 注意將這些都放在section1下面,這是個游戲切換場景的小技巧:通過更改visible標(biāo)記就能控制整個場景的顯示與隱藏.
運(yùn)行一下,移動攝像機(jī)看看,還可以旋轉(zhuǎn)視角,查看更漂亮的美景:


拖拽一個空的SceneKit文件到項目中,命名為obj_pearl.scn,保存到art.scnassets文件夾:

接著從對象庫中拖放一個球體節(jié)點(diǎn)到新場景中:

節(jié)點(diǎn)檢查器中命名改為pearl,位置,角度為零. 屬性檢查器中,設(shè)置半徑為0.2,分段數(shù)為16:


接下來打開材料檢查器,設(shè)置漫反射顏色為黑色,高光為白色.反射貼圖使用img_skybox.jpg,但將強(qiáng)度降為0.75:

完成后的效果圖:

還需要添加游戲工具類
從resources/ GameUtils/ 中拖拽GameUtils文件夾到項目中,如下圖,點(diǎn)擊Finish:

位掩碼(包括分類掩碼,碰撞掩碼,接觸掩碼)
我們將采用如下的分類位掩碼設(shè)置:

打開GameViewController.swift,在開頭添加分類碼:
let CollisionCategoryBall = 1
let CollisionCategoryStone = 2
let CollisionCategoryPillar = 4
let CollisionCategoryCrate = 8
let CollisionCategoryPearl = 16
游戲中,我們想讓小球與除了能量珍珠外的所有物體碰撞,所以需要定義碰撞掩碼,來決定和哪些物體碰撞:

Stone石頭, Pillar柱子, Crate木箱和Pearl能量珍珠和碰撞掩碼都是1,就是說它們能和分類掩碼為1的物體碰撞,也就是都能和小球碰撞.而小球的碰撞掩碼是14: CollisionMask = Stone + Pillar + Crate = 2 + 4 + 8 = 14
接觸掩碼決定了哪些物體碰撞時,代理方法會被調(diào)用.

我們只關(guān)心小球和能量珍珠,柱子及木箱的碰撞,所以:
ContactMask = Pearl + Pillar + Crate = 16 + 8 + 4 = 28
在GameViewController.swift中,添加一個屬性:
var ballNode:SCNNode!
添加下列代碼到setupNodes()中:
ballNode = scnScene.rootNode.childNode(withName: "ball", recursively:
true)!
ballNode.physicsBody?.contactTestBitMask = CollisionCategoryPillar |
CollisionCategoryCrate | CollisionCategoryPearl
啟用物理效果
選中obj_ball.scn,然后選中ball節(jié)點(diǎn),打開物理效果檢查器來將Physics Body類型設(shè)置為Dynamic:

確保重力影響是打開的,不然小球可能會漂在空中:

設(shè)置Category mask為1,Collision mask為14:

Shape為Default shape,Type為Convex:

除了小球,其它物體都是不動的,是靜態(tài)物理形體.設(shè)置如下:

-
obj_stone1x1.scn的Category mask為2, Collision mask為1;
- obj_stone3x3.scn: Category mask為2, Collision mask為1**.
- obj_pillar1x3.scn: Category mask為4,Collision mask為1.
- obj_crate1x1.scn: Category mask為8, Collision mask為1.
- obj_pearl.scn: Category mask為16, Collision mask為-1.
對能量珍珠Physics shape設(shè)為Default shape, Type為Convex:

其余的Physics shape設(shè)為Default shape, Type為Bounding Box:

添加碰撞檢測處理
現(xiàn)在終于設(shè)置好了各個物體,要處理相互的碰撞了.在GameViewController.swift底部:
extension GameViewController : SCNPhysicsContactDelegate {
func physicsWorld(_ world: SCNPhysicsWorld,
didBegin contact: SCNPhysicsContact) {
// 1
var contactNode:SCNNode!
if contact.nodeA.name == "ball" {
contactNode = contact.nodeB
} else {
contactNode = contact.nodeA
}
// 2
if contactNode.physicsBody?.categoryBitMask ==
CollisionCategoryPearl {
contactNode.isHidden = true
contactNode.runAction(
SCNAction.waitForDurationThenRunBlock(
duration: 30) { (node:SCNNode!) -> Void in
node.isHidden = false
})
}
// 3
if contactNode.physicsBody?.categoryBitMask ==
CollisionCategoryPillar ||
contactNode.physicsBody?.categoryBitMask ==
CollisionCategoryCrate {
} }
}
代碼含義:
- 和前面一樣,用來判斷碰撞雙方哪一個是小球.
- 如果碰撞到的是參量珍珠,則消失30秒,然后重新出現(xiàn).
- 判斷小球是碰撞到了柱子還是木箱,可以添加音效.
并在setupScene()底部添加成為代理:
scnScene.physicsWorld.contactDelegate = self
還需要再添加一些小效果讓游戲更生動. 打開obj_ball.scn,選中ball,設(shè)置y軸位置為10讓小球出現(xiàn)時有個掉落效果:

運(yùn)行一下,可以看到掉落下來:

選中游戲場景,然后拖拽obj_pearl.scn到場景中.放置在(x: 0, y: 0, z: 0)處.放到pearls組下面:

運(yùn)行一下,小球掉落并吸收了能量珍珠:

還可以給場景中添加更多的能量珍珠,如下:

輔助類和音效
在前面我們已經(jīng)添加了GameUtils類,現(xiàn)在還需要再添加一些東西以便使用它.
GameViewController中添加下面的屬性:
var game = GameHelper.sharedInstance
var motion = CoreMotionHelper()
var motionForce = SCNVector3(x:0 , y:0, z:0)
再從resources拖放Sounds文件夾到項目中:

在setupSounds()中添加下面代碼:
game.loadSound(name: "GameOver", fileNamed: "GameOver.wav")
game.loadSound(name: "Powerup", fileNamed: "Powerup.wav")
game.loadSound(name: "Reset", fileNamed: "Reset.wav")
game.loadSound(name: "Bump", fileNamed: "Bump.wav")
節(jié)點(diǎn)綁定和狀態(tài)管理
在GameViewController類中添加下面的屬性:
var cameraNode:SCNNode!
在setupNodes()的末尾,添加下列代碼:
// 1
cameraNode = scnScene.rootNode.childNode(withName: "camera",
recursively: true)!
// 2
let constraint = SCNLookAtConstraint(target: ballNode)
cameraNode.constraints = [constraint]
代碼含義:
- 將游戲場景中的camera綁定到cameraNode.
- 給攝像機(jī)添加一個
SCNLookAtConstraint約束,使其朝向ballNode.
當(dāng)攝像機(jī)有SCNLookAtConstraint約束時,小球到處滾動,可能會導(dǎo)致攝像機(jī)向左或向右傾斜,所以我們需要在setupNodes()末尾打開萬向節(jié)鎖:
constraint.isGimbalLockEnabled = true
其它節(jié)點(diǎn)也需要同樣處理.在GameViewController類中添加下列屬性:
var cameraFollowNode:SCNNode!
var lightFollowNode:SCNNode!
在setupNodes()末尾添加下列代碼:
// 1
cameraFollowNode = scnScene.rootNode.childNode(
withName: "follow_camera", recursively: true)!
// 2
cameraNode.addChildNode(game.hudNode)
// 3
lightFollowNode = scnScene.rootNode.childNode(
withName: "follow_light", recursively: true)!
游戲節(jié)點(diǎn)綁定完成,還需要處理游戲的狀態(tài).游戲需要三種基本狀態(tài):
- waitForTap:游戲開始前的狀態(tài)
- playing:點(diǎn)擊屏幕開始游戲的狀態(tài)
- gameOver:能量用光或者掉落下平臺的狀態(tài).
在GameViewController類中添加下列代碼:
// 1
func playGame() {
game.state = GameStateType.playing
cameraFollowNode.eulerAngles.y = 0
cameraFollowNode.position = SCNVector3Zero
}
// 2
func resetGame() {
game.state = GameStateType.tapToPlay
game.playSound(node: ballNode, name: "Reset")
ballNode.physicsBody!.velocity = SCNVector3Zero
ballNode.position = SCNVector3(x:0, y:10, z:0)
cameraFollowNode.position = ballNode.position
lightFollowNode.position = ballNode.position
scnView.isPlaying = true
game.reset()
}
// 3
func testForGameOver() {
if ballNode.presentation.position.y < -5 {
game.state = GameStateType.gameOver
game.playSound(node: ballNode, name: "GameOver")
ballNode.run(SCNAction.waitForDurationThenRunBlock(
duration: 5) { (node:SCNNode!) -> Void in
self.resetGame()
})
} }
代碼含義:
- 切換到
.playing狀態(tài),開始游戲.以及基本的清理和重置. - 切換到
.waitForTap狀態(tài),播放音效,以及各種清理和重置工作. - 檢查小球的位置,y值小于-5,則切換到
.gameOver狀態(tài),播放音效.5秒后自動調(diào)用resetGame(),并切換到.waitForTap狀態(tài).
還要在viewDidLoad()末尾添加調(diào)用:
resetGame()
游戲開始時,玩家需要點(diǎn)擊屏幕.因此在GameViewController類中,添加下面的觸摸代碼:
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?)
{
if game.state == GameStateType.tapToPlay {
playGame() }
}
在GameViewController類中,添加下面的代碼:
func updateMotionControl() {
// 1
if game.state == GameStateType.playing {
motion.getAccelerometerData(interval: 0.1) { (x,y,z) in
self.motionForce = SCNVector3(x: Float(x) * 0.05, y:0,
z: Float(y+0.8) * -0.05)
}
// 2
ballNode.physicsBody!.velocity += motionForce
}
}
代碼含義:
- 根據(jù)當(dāng)前的運(yùn)動數(shù)據(jù)更新
motionForce向量. - 將
motionForce向量賦值給小球的velocity.
還需要在renderer(_, updateAtTime)方法中調(diào)用updateMotionControl()方法:
updateMotionControl()
運(yùn)行游戲,看到小球從空中落下,點(diǎn)擊屏幕開始游戲:


小球身上的發(fā)光效果實際就是生命值,小球的發(fā)光強(qiáng)度將隨著時間不斷減弱直到降為0.0.如果收集到一個能量珍珠,則生命值恢復(fù)到1.0.我們需要一個方法來補(bǔ)充生命值.在GameViewController類中,添加下面的代碼:
func replenishLife() {
// 1
let material = ballNode.geometry!.firstMaterial!
// 2
SCNTransaction.begin()
SCNTransaction.animationDuration = 1.0
// 3
material.emission.intensity = 1.0
// 4
SCNTransaction.commit()
// 5
game.score += 1
game.playSound(node: ballNode, name: "Powerup")
}
- 要獲取發(fā)光貼圖,就需要先獲取ballNode的firstMaterial.
- 通過SCNTransaction.begin() 來開始動畫.此處我們設(shè)置時長為1秒
animationDuration = 1.0. - 設(shè)置發(fā)光強(qiáng)度為1.0.
- 提交動畫事務(wù).提交后SceneKit將開始執(zhí)行動畫,將發(fā)光強(qiáng)度從當(dāng)前值改為1.0
- 增加分?jǐn)?shù),播放音效.
該方法需要在剛變成.playing狀態(tài)時調(diào)用.在playGame()方法的末尾調(diào)用:
replenishLife()
有了恢復(fù)生命值的方法,還需要逐漸減少的方法.在GameViewController類中,添加下面的代碼:
func diminishLife() {
// 1
let material = ballNode.geometry!.firstMaterial!
// 2
if material.emission.intensity > 0 {
material.emission.intensity -= 0.001
} else {
resetGame()
}
}
我們需要在每次檢查.gameOver狀態(tài)時調(diào)用這個方法.
攝像機(jī)和燈光
在GameViewController類中,添加下面的代碼:
func updateCameraAndLights() {
// 1
let lerpX = (ballNode.presentation.position.x -
cameraFollowNode.position.x) * 0.01
let lerpY = (ballNode.presentation.position.y -
cameraFollowNode.position.y) * 0.01
let lerpZ = (ballNode.presentation.position.z -
cameraFollowNode.position.z) * 0.01
cameraFollowNode.position.x += lerpX
cameraFollowNode.position.y += lerpY
cameraFollowNode.position.z += lerpZ
// 2
lightFollowNode.position = cameraFollowNode.position
// 3
if game.state == GameStateType.tapToPlay {
cameraFollowNode.eulerAngles.y += 0.005
}
}
代碼含義:
- 用線性插值法計算要移動的位置.創(chuàng)造出一種特殊的減速移動效果.
- 將lightFollowNode節(jié)點(diǎn)跟隨攝像機(jī)節(jié)點(diǎn).
- 當(dāng)進(jìn)入
.tapToPlay狀態(tài)時,將攝像機(jī)抬起一些.
這個函數(shù)需要在renderer(_, updateAtTime)的末尾調(diào)用,這樣才能在每幀都能實時更新攝像機(jī)和燈光:
updateCameraAndLights()
運(yùn)行一下,如下:

點(diǎn)擊屏幕,開始游戲:

游戲已經(jīng)基本完成,還需要處理一下HUD的顯示問題,以及生命值耗盡的問題.
在GameViewController類中,添加下面的代碼:
func updateHUD() {
switch game.state {
case .playing:
game.updateHUD()
case .gameOver:
game.updateHUD(s: "-GAME OVER-")
case .tapToPlay:
game.updateHUD(s: "-TAP TO PLAY-")
}
}
在renderer(_, updateAtTime)方法的末尾,添加調(diào)用:
updateHUD()

現(xiàn)在生命值耗盡,游戲也不會結(jié)束,只有掉落下去才會死.我們需要處理耗盡問題.在renderer(_, updateAtTime)方法的末尾,添加代碼:
if game.state == GameStateType.playing {
testForGameOver()
diminishLife()
}
還需要處理小球與能量珍珠碰撞時,珍珠消失但小球的生命值沒有增加的問題.只需要在physicsWorld(_, didBeginContact)里處理與珍珠的碰撞代碼塊中,調(diào)用replenishLife()就行了:
replenishLife()
添加碰撞音效,在physicsWorld(_, didBeginContact)里處理與柱子/木箱的碰撞代碼塊中,調(diào)用播放音效就行了:
game.playSound(node: ballNode, name: "Bump")
最后一步,移除setupScene()中的調(diào)試代碼:
//scnView.allowsCameraControl = true
//scnView.showsStatistics = true
最終的完成版代碼,在15-Motion Control中的projects/final/ MarbleMaze/ 文件夾下.



