SceneKit系列文章目录node
更多iOS相关知识查看github上WeekWeekUpProjectgit
本教程将包含如下内容:github
开始前,先下载初始项目starter project 打开项目,简单查看一下里面都有些什么.你会发现球和罐头的素材,还有一个GameHelper文件能提供一些有用的函数. 建立并运行,看上去一片黑: 编程
不要难过,这只是一个干净的工做台供你开始.swift
在开始砸罐头以前,须要给游戏添加一个菜单选项.打开GameViewController.swift并添加一个新的属性:数组
// Scene properties
var menuScene = SCNScene(named: "resources.scnassets/Menu.scn")!
复制代码
这段代码将加载菜单场景.你将可使用menuScene来实现菜单和等级场景之间的跳转. 要弹出菜单场景,须要在**viewDidLoad()**里添加下列代码:bash
// 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:) 交叉淡出的转场.app
在viewDidLoad() 底部添加调用presentMenu():dom
override func viewDidLoad() {
super.viewDidLoad()
presentMenu()
}
复制代码
编译运行,会看到这样的菜单场景: 编辑器
打开resources.scnassets/Level.scn场景:
从对象库中拖入一个Floor节点到场景中:
选择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中建立游戏的一大好处就是,可以很是简单就利用内置的物理引擎来实现真实的物理效果. 为一个节点启用物理效果,你只须要给它添加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
}
复制代码
解释一下上面的代码:
在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..<level.canPositions.count {
let canNode = baseCanNode.clone()
canNode.geometry = baseCanNode.geometry?.copy() as? SCNGeometry
canNode.geometry?.firstMaterial = baseCanNode.geometry?.firstMaterial?.copy() as? SCNMaterial
// 3
let shouldCreateBaseVariation = GKRandomSource.sharedRandom().nextInt() % 2 == 0
canNode.eulerAngles = SCNVector3(x: 0, y: shouldCreateBaseVariation ? -110 : 55, z: 0)
canNode.name = "Can #\(idx)"
if let materials = canNode.geometry?.materials {
for material in materials where material.multiply.contents != nil {
if shouldCreateBaseVariation {
material.multiply.contents = "resources.scnassets/Can_Diffuse-2.png"
} else {
material.multiply.contents = "resources.scnassets/Can_Diffuse-1.png"
}
}
}
let canPhysicsBody = SCNPhysicsBody(
type: .dynamic,
shape: SCNPhysicsShape(geometry: SCNCylinder(radius: 0.33, height: 1.125), options: nil)
)
canPhysicsBody.mass = 0.75
canPhysicsBody.contactTestBitMask = 1
canNode.physicsBody = canPhysicsBody
// 4
canNode.position = level.canPositions[idx]
levelScene.rootNode.addChildNode(canNode)
helper.canNodes.append(canNode)
}
}
复制代码
以上代码含义:
完成这些后,立刻能看到关卡中的罐头了.在这以前,还须要建立一些关卡.
在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)
复制代码
这个函数中:
要调用这个新函数,在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)
复制代码
这段代码让第一个球延迟到关卡加载后. 这里物体效果有一点小问题.编译运行看看:
点击菜单;你会看到小球掉落到场景中,而后从屏幕中掉出去了. 因为地板目前尚未设置物理形体,因此球体并不知道本身应该弹跳落在地板上,而是穿过地板,掉落下去.
除了用代码给地板添加物理形体处,还能够在场景编辑器中添加.只需点击几下鼠标,就能让小球正常弹跳落在地板上.
进入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<UITouch>, 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<UITouch>, with event: UIEvent?) {
super.touchesEnded(touches, with: event)
guard startTouch != nil else { return }
endTouch = touches.first
endTouchTime = Date().timeIntervalSince1970
throwBall()
}
复制代码
当玩家手指离开屏幕,你须要保存触摸结束点及时间,由于它们决定了投掷方向是否正确. 编译运行,试着击倒这些罐头:
若是你的准头好的话,你可能把全部罐头都击倒在地面上了.可是你尚未完成,当全部罐头撞击地面后你应该能够进入下一关了.
SceneKit处理这种碰撞检测很是容易.SCNPhysicsContactDelegate协议定义了几个有用的碰撞处理函数:
它们都颇有用,但这个游戏中咱们只须要用到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.