如何用 Sprite Kit 和 Swift 制作一个逃逸游戏-第二部分
欢迎回到本教程! 在第一部分,你创建了一个会动的木板和小球到游戏中。 在第二部分,你将添加一些砖块和其它游戏逻辑到游戏中。 这部分内容从第一部分教程继续。如果你没有完成第一部分,可以从这里下载示例项目并继续。 竹砖你已经让小球四处乱蹦并能够制造碰撞,接下来添加一些竹砖用来击碎。毕竟这是一个逃逸游戏嘛! 回到 GameScene.swift,在 didMove(to:) 方法中添加砖块: // 1
let numberOfBlocks = 8
let blockWidth = SKSpriteNode(imageNamed: "block").size.width
let totalBlocksWidth = blockWidth * CGFloat(numberOfBlocks)
// 2
let xOffset = (frame.width - totalBlocksWidth) / 2
// 3
for i in 0..<numberOfBlocks {
let block = SKSpriteNode(imageNamed: "block.png")
block.position = CGPoint(x: xOffset + CGFloat(CGFloat(i) + 0.5) * blockWidth,y: frame.height * 0.8)
block.physicsBody = SKPhysicsBody(rectangleOf: block.frame.size)
block.physicsBody!.allowsRotation = false
block.physicsBody!.friction = 0.0
block.physicsBody!.affectedByGravity = false
block.physicsBody!.isDynamic = false
block.name = BlockCategoryName
block.physicsBody!.categoryBitMask = BlockCategory
block.zPosition = 2
addChild(block)
}
这段代码创建了 8 块砖,并放在屏幕中央。
Build & run,看看效果。 砖块准备好了。但为了监听球和砖之间的碰撞,你必须修改小球的 contactTestBitMask。仍然在 GameScene.swift 中,在 didMove(to:) 方法中添加一个新的 category: ball.physicsBody!.contactTestBitMask = BottomCategory | BlockCategory 这句在 BottomCategory 和 BlockCategory 中间使用了一个 OR 位运算符。这会导致这两个 category 的对应位被设为 1 而其它位设为 0。现在,球和地板、砖发生碰撞都会通知委托对象。 断开竹砖你已经能够检测到球和砖块之间的碰撞了,接下来为 GameScene.swift 添加一个助手方法,从场景中删除砖块: func breakBlock(node: SKNode) {
let particles = SKEmitterNode(fileNamed: "BrokenPlatform")!
particles.position = node.position
particles.zPosition = 3
addChild(particles)
particles.run(SKAction.sequence([SKAction.wait(forDuration: 1.0),SKAction.removeFromParent()]))
node.removeFromParent()
}
这个方法有一个 SKNode 参数。首先,它会用 BrokenPlatform.sks 创建一个 SKEmitterNode 实例,将它的位置设置为该节点所在的位置。emitter 节点的 zPosition 是 3,这样粒子会显示在其它砖块的上层。当粒子发射器被添加到场景之后,node(竹砖)被移除。
接下来的事情就是处理委托通知。在 didBegin(_:) 方法最后添加: if firstBody.categoryBitMask == BallCategory && secondBody.categoryBitMask == BlockCategory {
breakBlock(node: secondBody.node!)
//TODO: check if the game has been won
}
这段代码检查球和砖是否发生碰撞。如果发生,将 node 传递给 breakBlock(node:)方法,这样竹砖就会从屏幕上移除并显示粒子效果! Build & run。当小球击中竹砖,竹砖会四分五裂。 添加玩法现在所有游戏元素都就绪了,是时候让玩家体验输赢的感觉了。 理解状态机大部分游戏逻辑都是受游戏状态控制的。例如,如果游戏处于“主菜单”状态,玩家将无法移动,如果游戏处于 play 状态,玩家才可以动。 大量简单的游戏通过在 update 循环中用布尔值来管理游戏状态。通过状态机,当游戏变得复杂时你可以更好地组织代码。 一个状态机通过单个的当前状态和一系列状态间转换规则来管理一组状态。当游戏状态发生改变,状态机会执行上一状态的退出方法和下一状态的进入方法。这些方法用于控制每个状态下的玩法。当状态成功改变,状态机会执行当前状态的 update 循环。 苹果从 iOS 9 开始引入 GameplayKit 框架,它内置了状态机支持,让我们的工作变得更加容易。GameplayKit 不是本教程讨论的范围,但现在,你将用到其中的两个类:GKStateMachine 和 GKState 类。 添加状态这个游戏有 3 个状态:
为了节省时间,这 3 个状态已经被添加到项目中(你可以查看 Game States 文件组)。要创建状态机,首先添加必要的 import 语句到 GameScene.swift 中: import GameplayKit 然后,在 isFingerOnPaddle = false 声明变量: lazy var gameState: GKStateMachine = GKStateMachine(states: [
WaitingForTap(scene: self),Playing(scene: self),GameOver(scene: self)])
通过定义这个变量,你为游戏创建了一个状态机。注意,创建 GKStateMachine 时使用了一个 GKState 的数组。 等待点击状态:WaitingForTapWaitingForTap 状态是游戏刚加载等待开始的状态。玩家会看到一个 Tap to Play 的提示,游戏等待触摸事件一发生就会进入 play 状态。 在 didMove(to:) 方法中添加代码: let gameMessage = SKSpriteNode(imageNamed: "TapToPlay")
gameMessage.name = GameMessageName
gameMessage.position = CGPoint(x: frame.midX,y: frame.midY)
gameMessage.zPosition = 4
gameMessage.setScale(0.0)
addChild(gameMessage)
gameState.enter(WaitingForTap.self)
这里创建了一个 sprite 用来显示 Tap to Play 文字,后面则会用来显示 Game Over。然后告诉状态机进入 WaitingForTap 状态。 同时在 didMove(to:) 方法中删除这句: ball.physicsBody!.applyImpulse(CGVector(dx: 2.0,dy: -2.0)) // REMOVE 你会将这句完后挪一些地方以便进入 play 状态。 打开 Game States 文件夹下的 WaitingForTap.swift 文件。将 didEnter(from:) 和 willExit(to:) 方法修改为: override func didEnter(from previousState: GKState?) {
let scale = SKAction.scale(to: 1.0,duration: 0.25)
scene.childNode(withName: GameMessageName)!.run(scale)
}
override func willExit(to nextState: GKState) {
if nextState is Playing {
let scale = SKAction.scale(to: 0,duration: 0.4)
scene.childNode(withName: GameMessageName)!.run(scale)
}
}
当游戏进入 WaitingForTap state 状态, didEnter(from:) 方法被调用。这个方法简单将 Tap to Play 放大显示,告诉玩家可以开始了。 当游戏退出 WaitingForTap 状态,进入 play 状态时,willExit(to:) 方法被调用,Tap to Play 会被缩小到 0。 Build & run,点击屏幕开始玩游戏! 好了,但当你点击屏幕,什么也不发生。那是下一个游戏状态的事情! “游戏中”状态Playging 状态会开始游戏,并管理小球的速度。 首先,回到 GameScene.swift,实现助手方法: func randomFloat(from: CGFloat,to: CGFloat) -> CGFloat {
let rand: CGFloat = CGFloat(Float(arc4random()) / 0xFFFFFFFF)
return (rand) * (to - from) + from
}
这个助手方法返回一个位于两个参数之间的随机数。你会用它来让小球一开始的方向产生一些随机性。 现在,打开 Game States 文件夹下的 Playing.swift 文件,新增一个助手方法: func randomDirection() -> CGFloat {
let speedFactor: CGFloat = 3.0
if scene.randomFloat(from: 0.0,to: 100.0) >= 50 {
return -speedFactor
} else {
return speedFactor
}
}
这些代码就像“猜硬币”一样,返回一个正数或者负数。这个方法为小球的初始方向变得随机。 然后在 didEnter(from:) 方法中添加代码: if previousState is WaitingForTap {
let ball = scene.childNode(withName: BallCategoryName) as! SKSpriteNode
ball.physicsBody!.applyImpulse(CGVector(dx: randomDirection(),dy: randomDirection()))
}
当游戏进入 Playing 状态,获取小球 sprite,调用它的 applyImpulse(_:) 方法,让它开始移动。 然后在 update(deltaTime:) 方法中添加代码: let ball = scene.childNode(withName: BallCategoryName) as! SKSpriteNode
let maxSpeed: CGFloat = 400.0
let xSpeed = sqrt(ball.physicsBody!.velocity.dx * ball.physicsBody!.velocity.dx)
let ySpeed = sqrt(ball.physicsBody!.velocity.dy * ball.physicsBody!.velocity.dy)
let speed = sqrt(ball.physicsBody!.velocity.dx * ball.physicsBody!.velocity.dx + ball.physicsBody!.velocity.dy * ball.physicsBody!.velocity.dy)
if xSpeed <= 10.0 {
ball.physicsBody!.applyImpulse(CGVector(dx: randomDirection(),dy: 0.0))
}
if ySpeed <= 10.0 {
ball.physicsBody!.applyImpulse(CGVector(dx: 0.0,dy: randomDirection()))
}
if speed > maxSpeed {
ball.physicsBody!.linearDamping = 0.4
} else {
ball.physicsBody!.linearDamping = 0.0
}
update(deltaTime:) 方法会在每一帧的 Playing 状态时调用。获得小球对象,判断它的速度,即移动速度。如果 x 或 y 速度低于某个阈值,小球会卡在直上直下或直左直右移动的状态,如果这样,我们需要施加另外一个力,让它重新回到成一定角度的运动。 同时,在小球移动的过程中速度回增加。如果速度太快,你需要增加线型阻尼,以便球慢下来。 现在 Playing 状态准备就绪,是时候开始游戏了! 回到 GameScene.swift 将 touchesBegan(_:with:) 方法替换为: override func touchesBegan(_ touches: Set<UITouch>,with event: UIEvent?) {
switch gameState.currentState {
case is WaitingForTap:
gameState.enter(Playing.self)
isFingerOnPaddle = true
case is Playing:
let touch = touches.first
let touchLocation = touch!.location(in: self)
if let body = physicsWorld.body(at: touchLocation) {
if body.node!.name == PaddleCategoryName {
isFingerOnPaddle = true
}
}
default:
break
}
}
检查游戏的当前状态,并根据当前状态做相应的改变。然后,需要修改 update(_:) 方法为: override func update(_ currentTime: TimeInterval) { gameState.update(deltaTime: currentTime) }
update(_:) 方法在每帧刷新时调用。在这里你调用了 Playing 状态的 update(deltaTime:) 方法来控制球的速度。 Build & run,点击屏幕,状态机开始生效了! 游戏结束状态GameOver 状态在竹砖被摧毁,或者小球掉到屏幕底部后发生。 打开 Game States 文件夹下的 GameOver.swift 文件, 在 didEnter(from:) 方法添加: if previousState is Playing {
let ball = scene.childNode(withName: BallCategoryName) as! SKSpriteNode
ball.physicsBody!.linearDamping = 1.0
scene.physicsWorld.gravity = CGVector(dx: 0.0,dy: -9.8)
}
当游戏进入 GameOver 状态,设置了小球的线性阻尼和重力加速度,调至小球掉到地板上并逐渐变慢。 这就是游戏结束状态。接下来实现判定输赢的代码! 有赢就有输状态机也准备好了,游戏接近完成。现在你需要判断游戏的输赢。 打开 GameScene.swift,添加一个助手方法: func isGameWon() -> Bool {
var numberOfBricks = 0
self.enumerateChildNodes(withName: BlockCategoryName) {
node,stop in
numberOfBricks = numberOfBricks + 1
}
return numberOfBricks == 0
}
这个方法通过遍历场景中的所有子节点检查场景中还剩下几块砖。对于每个子节点,判断名字是否叫做 BlockCategoryName。如果一块砖都没有了,判定玩家胜,返回返回 true。 在 gametState 属性声明下增加一个属性: var gameWon : Bool = false {
didSet {
let gameOver = childNode(withName: GameMessageName) as! SKSpriteNode
let textureName = gameWon ? "YouWon" : "GameOver"
let texture = SKTexture(imageNamed: textureName)
let actionSequence = SKAction.sequence([SKAction.setTexture(texture),SKAction.scale(to: 1.0,duration: 0.25)]) gameOver.run(actionSequence) } }
这里,你定义了一个 gameWon 变量,并定义了它的 didSet 属性观察器。这允许你观察属性值的改变,并作出处理。这里,你将 GameMessage 节点的贴图修改为 YouWon 或 GameOver,并显示到屏幕上。
然后,修改 didBegin(_:) 方法。 首先,在 didBegin(_:) 方法一开始添加: if gameState.currentState is Playing {
// 这里是原来的代码...
} // if 语句结束
这会防止游戏在未处于 Playing 状态时进行碰撞检测。 将这一句: print("Hit bottom. First contact has been made.") 替换为: gameState.enter(GameOver.self)
gameWon = false
当球碰到屏幕底部,游戏结束。 将 // TODO: 一句替换为: if isGameWon() {
gameState.enter(GameOver.self)
gameWon = true
}
当所有的砖块被击碎后游戏胜利! 最后,在 touchesBegan(_:with:) 的 default 分支之前添加: case is GameOver:
let newScene = GameScene(fileNamed:"GameScene")
newScene!.scaleMode = .aspectFit
let reveal = SKTransition.flipHorizontal(withDuration: 0.5)
self.view?.presentScene(newScene!,transition: reveal)
你的游戏终于完成了!Build & run! 终止触摸现在游戏已经完成了,让我们给更上一层楼,为它添加一些新功能!你将在球发生碰撞以及砖块被击碎时增加一些音效。游戏结束时也会添加一小段音乐。最后,为小球添加一个专门的粒子发射器,当它反弹时,给它一段尾迹。 添加音效为了节省时间,项目中已经添加了几个声音文件。首先,打开 GameScene.swift,添加如下常量,就在 gameWon 变量下边: let blipSound = SKAction.playSoundFileNamed(“pongblip”,waitForCompletion: false) 上面定义了一堆的 SKAction 常量,每个加载不同的声音文件。因为在使用这些动作之前定义,它们会预加载进内存,防止在第一次播放时游戏出现卡顿。
然后,在 didMove(to:) 方法中,将设置小球的 contactTestBitMask 一句改成:
ball.physicsBody!.contactTestBitMask = BottomCategory | BlockCategory | BorderCategory | PaddleCategory
没有任何新东西,你在小球的 contactTestBitMask 中添加了 BorderCategory 和 PaddleCategory 以便检测小球和屏幕边框、木板的碰撞。
修改 didBegin(_:) 方法,让它根据 firstBody 和 secondBody 播放对应的音效:
```swift // 1
if firstBody.categoryBitMask == BallCategory && secondBody.categoryBitMask == BorderCategory {
run(blipSound)
}
// 2
if firstBody.categoryBitMask == BallCategory && secondBody.categoryBitMask == PaddleCategory {
run(blipPaddleSound)
}
<div class="se-preview-section-delimiter"></div>
当然,当球击碎砖块时,你需要播放破碎音,在 breakBlock(node:) 方法顶部添加: run(bambooBreakSound) 最后,在 gameWon 变量的 didSet 观察器中插入这句: run(gameWon ? gameWonSound : gameOverSound) 还有一个地方要改。 我们需要为小球添加一个粒子系统,当它反弹时会留下一段火焰一样的尾迹! 在 didMove(to:) 方法中添加代码: // 1
let trailNode = SKNode()
trailNode.zPosition = 1
addChild(trailNode)
// 2
let trail = SKEmitterNode(fileNamed: "BallTrail")!
// 3
trail.targetNode = trailNode
// 4
ball.addChild(trail)
这这样了——你已经完成了!Build & run,你的游戏再增强后是这个样子: 结束你可以从这里下载最终完成后的项目。 这是一个简单的逃逸游戏的例子。一旦完成了它,你还可以增加更多的内容。你可以添加积分,或者给砖块一个生命值,添加各种类别的砖块,小球必须击中砖块几次才能摧毁它们。你可以添加一些会赠与奖励的砖块或者能够提升等级的砖块! 如果你想学习更多 Sprite Kit 的课程,你可以阅读我们的 2D iOS& tvOS 游戏教程。 这本书会教你所有关于制作 iOS & tvOS 游戏的知识——包括物理引擎、瓦片地图、粒子系统、以及如何通过一些美化和特效让你的游戏获得“加分”。 希望你喜欢本教程,如果有任何问题和评论,请在下面留言! (编辑:李大同) 【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容! |