[SceneKit专题]3D平衡球游戏Marble Maze
说明
本系列文章是对<3D Apple Games by Tutorials>一书的学习记录和体会此书对应的代码地址
更多iOS相关知识查看github上WeekWeekUpProject
11-Materials材质
创建项目
- 打开Xcode,创建一个新的iOS版SceneKit游戏项目,命名为MarbleMaze.
- 删除art.scnassets文件夹.
- 从resources文件夹中拖拽一个新的art.scnassets到项目中.
- 我们只使用竖屏模式,所以取消Landscape Left和Landscape Right来禁用旋转:
WX20171113-211135.png
替换GameViewController.swift中的内容:
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()
中调用这些空的方法;稍后会向其中添加代码. - 将self.view转换为SCNView并保存下来.并设置self为渲染循环的代理.
- 实现SCNSceneRendererDelegate协议中的方法.
天空盒子,加载场景
在art.scnassets中找到空的game.scn场景文件.打开并选中默认的camera node,然后选中右上方的Scene Inspector.从右下方的媒体库中找到img_skybox.jpg拖拽到场景的背景属性上.:
在GameViewController类中添加下面属性:
var scnScene:SCNScene!
在setupScene()
中添加下面代码:
// 1
scnScene = SCNScene(named: "art.scnassets/game.scn")
// 2
scnView.scene = scnScene
运行一下,看看神圣的天空景象:
WX20171113-213918@2x.png
12-Reference Nodes引用节点
主角--小球
拖拽一个空的SceneKit场景文件到你的项目,放到art.scnassets中,命名为obj_ball.scn:
选中art.scnassets/obj_ball.scn,展开场景树,选中默认的摄像机节点.所有的新建场景都包含一个默认的摄像机节点,但作为引用节点被使用时就很不爽,所以我们删除它:
下面开始创建木质小球.从对象库中拖拽一个球体到场景中:
WX20171113-221006@2x.png
打开节点检查器.将小球命名为ball,放置位置为(x:0, y:0, z:0):
现在的小球太大了.打开属性检查器,更改半径为0.45,提升分段数为36来让它显得更圆一些:
材质设置
漫反射设置
WX20171113-222847@2x.png
法线设置
WX20171113-222907@2x.png
高光设置
WX20171113-222929@2x.png
反射设置
WX20171113-222944@2x.png
发光设置
WX20171113-222959@2x.png
随着各个贴图的添加,效果渐变如下:
WX20171113-223012@2x.png
然后需要做的是将小球作为引用节点添加到场景中去.
选中art.scnassets/game.scn,然后拖拽art.scnassets/obj_ball.scn到场景中.设置位置为(x:0, y:0, z:0)并命名为ball:
这样,小球就作为一个引用节点被添加到场景中了.
运行一下:
WX20171113-223103@2x.png
挑战--创建木箱,小石块,大石块,柱子的引用节点
这是一个小小的挑战:
- 为每个对象创建一个空的场景.
-
删除默认的摄像机.
试着创建下面的对象:
WX20171114-205653@2x.png
- obj_crate1x1:命名为crate并设置尺寸为(x:1, y:1, z:1).使用img_crate_diffuse纹理作为漫反射贴图,img_crate_normal作为法线贴图.高光颜色设为中灰色;如果设为纯白色,木箱会看起来像塑料的.
- obj_stone1x1:命名为stone并设置尺寸为(x:1, y:1, z:1).使用img_stone_diffuse和img_stone_normal纹理作为贴图,将法线intensity改为0.5. 设置高光色为White.
- obj_stone3x3:命名为stone并设置尺寸为(x:3, y:3, z:3).纹理设置同上,高光仍为White.但是需要使用纹理缩放设置,及WrapT和WrapS来使其生效.
- obj_pillar1x3:命名为pillar并设置尺寸为(x:1, y:3, z:1).使用img_pillar_*纹理;还有高光纹理也要用上.还有应用缩放及wrap设置.
当设置3x3方块时,可参照下面步骤:
WX20171114-205708@2x.png
WX20171114-205724@2x.png
设置过程中,会看到如下的依次变化:
WX20171114-205738@2x.png
最终完成版在12-Reference Nodes中的projects/ challenge/MarbleMaze/文件夹.
13-Shadows阴影
组织场景
选中art.scnassets/game.scn.组织一下场景树如下:
创建一个空节点命名为follow_camera:
将camera节点放到follow_camera下,成为它的子节点,并设置位置为(x:0, y:0, z:5),旋转为(x:0, y:0, z:0):
创建另一个空节点命名为follow_light:
添加几个空节点作为占位节点,设置位置为零;
- pearls:待收集的珍珠分组.
- section1, section2, section3, section4:这些分组用来盛放本关卡的不同章节.
创建最后一个空节点,命名为static_light:
灯光
首先是固定灯光
拖拽一个泛光灯和一个环境光到场景中,并按顺序放置在static_lights组节点中:
选中omni light
,打开节点检查器,命名为omni,位置,角度设为零:
打开属性检查器,设置颜色为深灰色:
WX20171114-222017@2x.png
选中ambient light
,打开节点检查器:
打开属性检查器,设置颜色为深灰:
WX20171114-225145@2x.png
查看一下场景中的小球:
WX20171114-225209@2x.png
接着添加跟随灯光
拖拽一个聚光灯到场景中,放置在follow_light组节点下面:
选中聚光灯,打开它的节点检查器,设置位置如下:
WX20171114-225241@2x.png
这个灯光是follow_light的子节点, follow_light的位置是(x:0, y:0, z:0),旋转角度(x:-25, y:-45, z:0);
然后选中聚光灯,打开属性检查器,设置金黄色模拟环境中的阳光:
WX20171114-225308@2x.png
完成后的效果:
WX20171114-225335@2x.png
重用集合体
将游戏中重复出现的结构做成重用集合体,方便在需要的时候直接调用.
此处我们制作的是休息点,它由一块3x3的石块和上面的4根柱子组成.
拖拽一个空的SceneKit场景文件到项目的根目录中,然后在弹出框中选择art.scnassets,点击Create按钮.
拖拽一个obj_stone3x3.scn的引用节点到空场景的,放置在(x: 0, y: 0, z:0).
拖拽一个obj_pillar1x3.scn引用节点到时大石块的顶部.设置位置在(x: -1, y: 3, z: 1),即右上角位置.
使用⌥⌘ (Option +Command) +点击拖拽,复制三个柱子,位置如下:
- 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).
WX20171115-142328@2x.png
记得删除场景中默认的摄像机.
选中game.scn,然后拖放新创建的set_restpoint.scn到场景下方.位置设为(x: 0, y: -2, z: 0)
运行一下,会看到漂亮的阴影:
WX20171115-142402@2x.png
创建其它部件
现在还需要创建几个其他的集合体,以便在主场景中直接引用.
比如straight_bridge,用了7个stone1x1组成:
zigzag_bridge,用了stone1x1和crate1x1方块.共9格宽7格长.
然后就可以用这些来组成大场景:
WX20171115-142450@2x.png
从左下角开始,放置一个restpoint休息点在地平面下,(x:0, y:0, z:0)处.然后将其他引用集合体拖拽到场景中.
注意将这些都放在section1下面,这是个游戏切换场景的小技巧:通过更改visible标记就能控制整个场景的显示与隐藏.
运行一下,移动摄像机看看,还可以旋转视角,查看更漂亮的美景:
WX20171115-142516@2x.png
WX20171115-142531@2x.png
14-Intermediate Collision Detection中级碰撞检测
拖拽一个空的SceneKit文件到项目中,命名为obj_pearl.scn,保存到art.scnassets文件夹:
接着从对象库中拖放一个球体节点到新场景中:
WX20171115-154104@2x.png
节点检查器中命名改为pearl,位置,角度为零.
属性检查器中,设置半径为0.2,分段数为16:
WX20171115-161805@2x.png
接下来打开材料检查器,设置漫反射颜色为黑色,高光为白色.反射贴图使用img_skybox.jpg,但将强度降为0.75:
完成后的效果图:
WX20171115-161839@2x.png
还需要添加游戏工具类
从resources/ GameUtils/中拖拽GameUtils文件夹到项目中,如下图,点击Finish:
位掩码(分类掩码,碰撞掩码,接触掩码)
我们将采用如下的分类位掩码设置:
WX20171115-161946@2x.png
打开GameViewController.swift,在开头添加分类码:
let CollisionCategoryBall = 1
let CollisionCategoryStone = 2
let CollisionCategoryPillar = 4
let CollisionCategoryCrate = 8
let CollisionCategoryPearl = 16
游戏中,我们想让小球与除了能量珍珠外的所有物体碰撞,所以需要定义碰撞掩码,来决定和哪些物体碰撞:
WX20171115-162001@2x.png
Stone石头, Pillar柱子, Crate木箱和Pearl能量珍珠和碰撞掩码都是1,就是说它们能和分类掩码为1的物体碰撞,也就是都能和小球碰撞.而小球的碰撞掩码是14:
CollisionMask = Stone + Pillar + Crate = 2 + 4 + 8 = 14
接触掩码决定了哪些物体碰撞时,代理方法会被调用.
WX20171115-162017@2x.png
我们只关心小球和能量珍珠,柱子及木箱的碰撞,所以:
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节点,打开物理效果检查器来将Physics Body类型设置为Dynamic:
确保重力影响是打开的,不然小球可能会漂在空中:
WX20171115-162056@2x.png
设置Category mask为1,Collision mask为14:
Shape为Default shape,Type为Convex:
除了小球,其它物体都是不动的,是静态物理形体.设置如下:
WX20171115-162924@2x.png
-
obj_stone1x1.scn的Category mask为2, Collision mask为1;
WX20171115-162938@2x.png - 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设为Default shape, Type为Convex:
其余的Physics shape设为Default shape, Type为Bounding Box:
添加碰撞检测处理
现在终于设置好了各个物体,要处理相互的碰撞了.在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秒,然后重新出现.
- 判断小球是碰撞到了柱子还是木箱,可以添加音效.
并在setupScene()
底部添加成为代理:
scnScene.physicsWorld.contactDelegate = self
还需要再添加一些小效果让游戏更生动.
打开obj_ball.scn,选中ball,设置y轴位置为10让小球出现时有个掉落效果:
运行一下,可以看到掉落下来:
WX20171115-163106@2x.png
选中游戏场景,然后拖拽obj_pearl.scn到场景中.放置在(x: 0, y: 0, z: 0)处.放到pearls组下面:
运行一下,小球掉落并吸收了能量珍珠:
WX20171115-163158@2x.png
还可以给场景中添加更多的能量珍珠,如下:
WX20171115-163217@2x.png
15-Motion Control运动控制
辅助类和音效
在前面我们已经添加了GameUtils类,现在还需要再添加一些东西以便使用它.
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")
节点绑定和状态管理
在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.
- 给摄像机添加一个
SCNLookAtConstraint
约束,使其朝向ballNode
.
当摄像机有SCNLookAtConstraint
约束时,小球到处滚动,可能会导致摄像机向左或向右倾斜,所以我们需要在setupNodes()
末尾打开万向节锁:
constraint.isGimbalLockEnabled = true
其它节点也需要同样处理.在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)!
游戏节点绑定完成,还需要处理游戏的状态.游戏需要三种基本状态:
- waitForTap:游戏开始前的状态
- playing:点击屏幕开始游戏的状态
- gameOver:能量用光或者掉落下平台的状态.
在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
状态,开始游戏.以及基本的清理和重置. - 切换到
.waitForTap
状态,播放音效,以及各种清理和重置工作. - 检查小球的位置,y值小于-5,则切换到
.gameOver
状态,播放音效.5秒后自动调用resetGame()
,并切换到.waitForTap
状态.
还要在viewDidLoad()
末尾添加调用:
resetGame()
游戏开始时,玩家需要点击屏幕.因此在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
}
}
代码含义:
- 根据当前的运动数据更新
motionForce
向量. - 将
motionForce
向量赋值给小球的velocity
.
还需要在renderer(_, updateAtTime)
方法中调用updateMotionControl()
方法:
updateMotionControl()
运行游戏,看到小球从空中落下,点击屏幕开始游戏:
WX20171118-214158@2x.png
WX20171118-214223@2x.png
小球身上的发光效果实际就是生命值,小球的发光强度将随着时间不断减弱直到降为0.0.如果收集到一个能量珍珠,则生命值恢复到1.0.我们需要一个方法来补充生命值.在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")
}
- 要获取发光贴图,就需要先获取ballNode的firstMaterial.
- 通过SCNTransaction.begin()来开始动画.此处我们设置时长为1秒
animationDuration = 1.0
. - 设置发光强度为1.0.
- 提交动画事务.提交后SceneKit将开始执行动画,将发光强度从当前值改为1.0
- 增加分数,播放音效.
该方法需要在刚变成.playing
状态时调用.在playGame()
方法的末尾调用:
replenishLife()
有了恢复生命值的方法,还需要逐渐减少的方法.在GameViewController
类中,添加下面的代码:
func diminishLife() {
// 1
let material = ballNode.geometry!.firstMaterial!
// 2
if material.emission.intensity > 0 {
material.emission.intensity -= 0.001
} else {
resetGame()
}
}
我们需要在每次检查.gameOver
状态时调用这个方法.
摄像机和灯光
在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
}
}
代码含义:
- 用线性插值法计算要移动的位置.创造出一种特殊的减速移动效果.
- 将lightFollowNode节点跟随摄像机节点.
- 当进入
.tapToPlay
状态时,将摄像机抬起一些.
这个函数需要在renderer(_, updateAtTime)
的末尾调用,这样才能在每帧都能实时更新摄像机和灯光:
updateCameraAndLights()
运行一下,如下:
WX20171118-214308@2x.png
点击屏幕,开始游戏:
WX20171118-214324@2x.png
游戏已经基本完成,还需要处理一下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)
方法的末尾,添加调用:
updateHUD()
WX20171118-214346@2x.png
现在生命值耗尽,游戏也不会结束,只有掉落下去才会死.我们需要处理耗尽问题.在renderer(_, updateAtTime)
方法的末尾,添加代码:
if game.state == GameStateType.playing {
testForGameOver()
diminishLife()
}
还需要处理小球与能量珍珠碰撞时,珍珠消失但小球的生命值没有增加的问题.只需要在physicsWorld(_, didBeginContact)
里处理与珍珠的碰撞代码块中,调用replenishLife()
就行了:
replenishLife()
添加碰撞音效,在physicsWorld(_, didBeginContact)
里处理与柱子/木箱的碰撞代码块中,调用播放音效就行了:
game.playSound(node: ballNode, name: "Bump")
最后一步,移除setupScene()
中的调试代码:
//scnView.allowsCameraControl = true
//scnView.showsStatistics = true
最终的完成版代码,在15-Motion Control中的projects/final/ MarbleMaze/文件夹下.