Swift开发Sprite Kit游戏实践
本文作者Ajay Venkat是一名年仅13岁的iOS开发者,他非常喜欢用苹果的Sprite Kit 2D游戏框架来开发iOS游戏,在了解到很多同龄孩子也对学习如何使用Sprite Kit来开发iOS游戏非常感兴趣之后,他以自己用Swift语言所开发的一款名为“Space Monkey”的游戏为例,在Ray Wenderlich上写下了这篇指南,以下为译文:
快速入门
首先要安装苹果的iOS和Mac应用免费开发工具——Xcode。如果还没有安装Xcode,可以从App Store上下载;如果已经安装好,须确保自己使用的是最新版本。安装好Xcode后,下载并解压starter project,双击SpaceMonkey.xcodeproj,文件就会在Xcode中打开。单击“Play”,接着你会看到一整片黑屏:
starter project给了一个好的起步,接下来就是着手开发自己的游戏了。我已经在project中添加了art(艺术效果)和sounds(音效),放在文件夹Sounds 和 sprites.atlas里。art能派上大用场,不妨用“Space Monkey”牛刀小试一下吧!
添加monkey
用Sprite Kit为游戏添加图片(比如space monkey)有三步:
- 创建sprite。第一步用SKSpriteNode 类创建一个sprite。sprite是图片的副本,可在游戏里随意移动。
- 定位sprite。第二步在屏幕上定位sprite,让space monkey从屏幕左边出场,逐渐移动到中间。
- 将sprite添加到场景中。创建sprite还不够,要展示sprite,需要把它添加到场景中。
一步一步来,打开GameScene.swift,将代码换成如下所示:
import SpriteKit class GameScene: SKScene { // 1 - Create the sprite let player = SKSpriteNode(imageNamed:"spacemonkey_fly02") override func didMoveToView(view: SKView) { // 2 - Position the sprite player.position = CGPoint(x:frame.size.width * 0.1, y: frame.size.height * 0.5) // 3 - Add the sprite to the scene addChild(player) // 4 - Set scene background color to black backgroundColor = SKColor.blackColor() } }
再回顾一下上述步骤:
- 用Art文件夹里的monkey图片通过SKSpriteNode(imageNamed:)创建SKSpriteNode 。注意:这一步将sprite存储在了类的一个property 里,方便开发者回头查看。
- 场景首次呈现时调用的是didMoveToView(_:),这里适合添加初始设置代码。这一步在屏幕的左边设定sprite的position 。
- 用sprite的名称调用 addChild(),再添加到场景里。
- 游戏场景的背景色设为黑色,贴合monkey在太空飞行的设定。
编写完成后运行,就能看见monkey飞翔在太空之中了:
接下来添加monkey的敌人!
添加敌人
monkey只有一个,而敌人有很多个,而且可能出现在屏幕的不同位置。
首先需要一些method(method)创建随机数字。将这些新method添加至GameScene.swift,放在 didMoveToView(_:)后(大括号之前):
func random() -> CGFloat { return CGFloat(Float(arc4random()) / 0xFFFFFFFF) } func random(#min: CGFloat, max: CGFloat) -> CGFloat { return random() * (max - min) + min }
random()返回一个介于0到1之间的十进制值。random(min:max:)返回一个固定范围的随机值。指南不详述这些步骤的原理,读者只需用好这些method就够了。
接下来,将这个新的method直接添加在 random(min:max:)之后:
// 1 func spawnEnemy() { // 2 let enemy = SKSpriteNode(imageNamed: "boss_ship") // 3 enemy.name = "enemy" // 4 enemy.position = CGPoint(x: frame.size.width, y: frame.size.height * random(min: 0, max: 1)) // 5 addChild(enemy) }
再回顾一下上述步骤:
- 创建一个method,命名为spawnEnemy()。
- 跟添加monkey差不多,通过调用SKSpriteNode(imageNamed:)和添加图片名称创建一个sprite。
- 为sprite添加名称,方便以后辨认。
- 在屏幕右边设定敌人在X轴位置,而Y轴位置用random()设定一个随机值。
- 最后只要用addChild()把sprite添加到parent/场景中。
剩下的就是多次调用这个method了!调用前先创建actions的序列,使敌人以固定时间间隔出现在屏幕上。将这一行添加到didMoveToView(_:)末尾:
runAction(SKAction.repeatActionForever( SKAction.sequence([ SKAction.runBlock(spawnEnemy), SKAction.waitForDuration(1.0)])))这里调用spawnEnemy(),用不断重复的序列创建一个action,等待时长为1秒,即敌人出现的间隔。
这时代码看起来如下所示:
import SpriteKit class GameScene: SKScene { let player = SKSpriteNode(imageNamed:"spacemonkey_fly02") override func didMoveToView(view: SKView) { player.position = CGPoint(x:frame.size.width * 0.1, y: frame.size.height * 0.5) addChild(player) backgroundColor = SKColor.blackColor() runAction(SKAction.repeatActionForever( SKAction.sequence([ SKAction.runBlock(spawnEnemy), SKAction.waitForDuration(1.0)]))) } func random() -> CGFloat { return CGFloat(Float(arc4random()) / 0xFFFFFFFF) } func random(#min: CGFloat, max: CGFloat) -> CGFloat { return random() * (max - min) + min } func spawnEnemy() { let enemy = SKSpriteNode(imageNamed: "boss_ship") enemy.name = "enemy" enemy.position = CGPoint(x: frame.size.width, y: frame.size.height * random(min: 0, max: 1)) addChild(enemy) } }
编写完成后运行,敌人就出现在屏幕右边的随机位置上了:
让敌人们动起来
先对“敌人”的代码进行微调,使它们在出现之前,完全隐藏在屏幕后(而非仅仅隐藏一半)。从天而降的设定为游戏增加了难度,也增添了乐趣。
那么首先要做的就是更新spawnEnemy()那一行代码,敌人sprite的position编写如下:
enemy.position = CGPoint(x: frame.size.width + enemy.size.width/2, y: frame.size.height * random(min: 0, max: 1))
现在,用更多action让敌人从屏幕一端移动到另一端,游戏变得更有趣了。
将这一行代码添加到spawnEnemy()末尾:
enemy.runAction( SKAction.moveByX(-size.width - enemy.size.width, y: 0.0, duration: NSTimeInterval(random(min: 1, max: 2))))
此处稍作解释:
- 敌人sprite运行一个SKAction。
- SKAction包含一个moveByX() method,用于控制敌人在X轴上移动的固定距离。将整个屏幕画面设置为左移(-size.width),还要设置完整尺寸的sprite (-enemy.size.width)。
- SKAction有个规定sprite移动速度的时间参数;此处设定SKAction后,每1-2秒就改变一个随机值,加快了敌人的移动速度。
编写完成后运行,结果应该是敌人移动到了屏幕另一端,然后完全消失。如下所示:
物理移动sprite
游戏的设定是:不点击屏幕时,monkey会落下来;点击时,monkey会跳起。
这里用SKAction移动monkey,就像之前移动敌人一样。推荐用Sprite Kit内置的物理引擎,更加简单。
好,现在来试试看。还是GameScene.swift,在didMoveToView(_:)后添加如下代码:
player.physicsBody = SKPhysicsBody(circleOfRadius:player.frame.size.width * 0.3) player.physicsBody?.allowsRotation = false
第一行为monkey创建了一个physics body,在物理引擎的作用下,monkey因引力和其他外力而落下”。
注意:physics body(物理实体)的形状是圆的,仅跟monkey的形状近似而已。无需做到精确,只要凑效就好。同时将physics body设定为不旋转。
编写完成后运行,就能看到monkey在屏幕上时而落下,时而消失,很酷吧?
物理推力
为了避免monkey“落下”,需要用物理推力让它重新跳起来。
这时要在spawnEnemy()后添加一个新的method:
func jumpPlayer() { // 1 let impulse = CGVector(dx: 0, dy: 75) // 2 player.physicsBody?.applyImpulse(impulse) }
再回顾一下上述步骤:
- 首先创建一个固定数值推动力的CGVector,规定monkey跳起的距离。我也是尝试了多次才总结出具体数值的。
- 用applyImpulse()制造推力,再转化为线速度和角速度推力。理论上,monkey在穿行太空的时候还会旋转,所以刚刚才要将physics body设定为不旋转。
代码在被调用之前,monkey是不能跳起来的;要使monkey跳起来,就要重写点击屏幕时调用的那个method。在jumpPlayer()底下复制这些代码:
override func touchesBegan(touches: Set<NSObject>, withEvent event: UIEvent) { jumpPlayer() }
点击屏幕时,自动调用这些method。
现在就差一步了——在 didMoveToView(_:)后添加如下代码:
// 1 let collisionFrame = CGRectInset(frame, 0, -self.size.height * 0.2) // 2 physicsBody = SKPhysicsBody(edgeLoopFromRect: collisionFrame)
代码会在屏幕边缘产生一个特殊的physics body,避免monkey飞出或者坠落在太空中。现在回顾一下上述代码:
- 首先创造一个可通过CGRectInset()扩大或缩小至多20%的矩形,即monkey的活动范围。monkey的轮廓可以稍微消失在屏幕外,但不能完全消失不见。
- 然后设定场景本身的physics body。刚刚创建的physics body是圆的,此处将它变为一个循环边,即“矩形的边缘”,不过听上去更简洁些。
编写完成后运行,就能看到如下场景:
一只蹦蹦跳跳的小monkey出现啦!
碰撞检测
到目前为止,如果monkey遇到敌人,可以跳过去;但是跟敌人相撞的话,什么效果都没有,所以需要在游戏中添加碰撞检测(collision detection),有如下几步:
- 为所有sprite创建physics body:现在monkey已经有了一个physics body,但是敌人还没有,所以先完成这一步。
- 为每个physics body设定category(类别)和contact(接触点):这一步为sprite分类,比如为monkey和敌人设定不同的类别;同样可以在特定的physics body和其他类别的physics body之间设定“contact(接触点)”。
- 设置Contact Delegate:设定两个physics body何时接触。要搞清不同的physics body属于哪个类别,是敌人还是monkey。大功告成!
还记得怎么给monkey添加physics body吗?现在轮到为敌人的sprite添加physics body了,来制造碰撞效果。
首先将如下所示添加至GameScene.swift最顶端:
enum BodyType: UInt32 { case player = 1 case enemy = 2 case ground = 4 }
这里要做的就是为每个sprite创建类别。ground number不是针对sprite,而是针对应用边框设定的,所以当monkey碰到屏幕边缘时会弹起,而不是落到屏幕之外!
接下来,执行SKPhysicsContactDelegate协定,标记GameScene(游戏场景):
class GameScene: SKScene, SKPhysicsContactDelegate {协议的作用是保证代码执行特定的method。此处执行针对两个physics body相撞的method。然后调整contactDelegate的值,将如下代码添加到didMoveToView(_:)末尾:
physicsWorld.contactDelegate = self
完成后,两个physics body碰撞时,物理世界就会自动调用代码中的method。
在spawnEnemy()末尾添加如下代码:
// 1 enemy.physicsBody = SKPhysicsBody(circleOfRadius: enemy.size.width/4) // 2 enemy.physicsBody?.dynamic = false // 3 enemy.physicsBody?.affectedByGravity = false // 4 enemy.physicsBody?.allowsRotation = false // 5 enemy.physicsBody?.categoryBitMask = BodyType.enemy.rawValue // 6 enemy.physicsBody?.contactTestBitMask = BodyType.player.rawValue // 7 enemy.physicsBody?.collisionBitMask = 0
此处稍稍解释一下:
- 为敌人创建physics body。physics body不一定要跟sprite的形状完全吻合,近似就好。这里用的是圆形,半径设为sprite的1/4,免得碰撞效果太猛。
- 把dynamic关掉,实现物理控制sprite。
- 防止引力对sprite的影响。这一步不言自明,主要让敌人的sprite避免物理引力的干扰。
- 这一步是为了避免sprite在physics body碰撞时旋转。
- 将类别位掩码设为之前设置过的敌人类别。
- 敌人和monkey接触时,Sprite Kit发出提醒。
- 为monkey设置 collisionBitMask后,当接触到敌人时,两者会互相弹开;如果不想要这种效果,将值设为0。
将如下所示添加到didMoveToView(_:)的后面:
physicsBody?.categoryBitMask = BodyType.ground.rawValue player.physicsBody?.categoryBitMask = BodyType.player.rawValue player.physicsBody?.contactTestBitMask = BodyType.enemy.rawValue player.physicsBody?.collisionBitMask = BodyType.ground.rawValue
这里为monkey和ground设置类别和碰撞位掩码,让两者彼此碰撞;在monkey和敌人之间设置“contact(接触点)”。
现在到了最重要的一步,完善碰撞检测,执行之前提到的method来处理“contacts”:
func didBeginContact(contact: SKPhysicsContact) { let contactMask = contact.bodyA.categoryBitMask | contact.bodyB.categoryBitMask switch(contactMask) { case BodyType.player.rawValue | BodyType.enemy.rawValue: let secondNode = contact.bodyB.node secondNode?.removeFromParent() let firstNode = contact.bodyA.node firstNode?.removeFromParent() default: return } }
因为之前已将场景设置为物理世界的contactDelegate,两个physics body碰撞时会自动调用这个method。
它将两个位掩码结合成一个单个的接触点掩码,检验是否是monkey和敌人相撞,如果是,就将两者从屏幕上移除。
编写完成后运行,效果如下:
Game Over!
如果monkey跟敌人相撞,或落出屏幕的话,就会显示”Game Over“,接着出现重新点击开始游戏的画面。
首先将以下所有变量添加到GameScene顶端,在 let player = SKSpriteNode(imageNamed:"spacemonkey_fly02")之后:
// 1 var gameOver = false // 2 let endLabel = SKLabelNode(text: "Game Over") let endLabel2 = SKLabelNode(text: "Tap to restart!") let touchToBeginLabel = SKLabelNode(text: "Touch to begin!") let points = SKLabelNode(text: "0") // 3 var numPoints = 0 // 4 let explosionSound = SKAction.playSoundFileNamed("explosion.mp3", waitForCompletion: true) let coinSound = SKAction.playSoundFileNamed("coin.wav", waitForCompletion: false)再回顾一下上述步骤:
- 首先创建一个gameOver布尔变量,不论游戏是否结束,都进行跟踪记录。
- 创建一些label node,好在Sprite Kit中设置屏幕上显示的字幕。
- 创建integer储存分数。注意,用var来标记 integer,不是let,方便之后进行修改。
- 最后创建一些action,随后制造音效。
下一步创建名为setupLabels()的新方法:
func setupLabels() { // 1 touchToBeginLabel.position = CGPoint(x: frame.size.width/2, y: frame.size.height/2) touchToBeginLabel.fontColor = UIColor.whiteColor() touchToBeginLabel.fontSize = 50 addChild(touchToBeginLabel) // 2 points.position = CGPoint(x: frame.size.width/2, y: frame.size.height * 0.1) points.fontColor = UIColor.whiteColor() points.fontSize = 100 addChild(points) }
再回顾一下上述步骤:
- 将“touch to begin(点击开始)”标签放在屏幕中央,字体白色,大小50pt。
- 将position label设在屏幕底端,白色,大小100。
现在在didMoveToView(_:)里调用setupLabels():
setupLabels()再删掉touchesBegan(_:withEvent:),添加如下代码:
override func touchesBegan(touches: Set<NSObject>, withEvent event: UIEvent) { // 1 if (!gameOver) { if player.physicsBody?.dynamic == false { player.physicsBody?.dynamic = true touchToBeginLabel.hidden = true backgroundColor = SKColor.blackColor() runAction(SKAction.repeatActionForever( SKAction.sequence([ SKAction.runBlock(spawnEnemy), SKAction.waitForDuration(1.0)]))) } // 2 jumpPlayer() } // 3 else if (gameOver) { let newScene = GameScene(size: size) newScene.scaleMode = scaleMode let reveal = SKTransition.flipHorizontalWithDuration(0.5) view?.presentScene(newScene, transition: reveal) } }
回顾一下:
- 如果游戏还没结束,monkey还处于非动态(受物理引擎控制),同样说明新游戏还没开始。这时将dynamic设置为true,隐藏label,大批敌人开始出现在屏幕上。
- 不管怎样都要调用 jumpPlayer ,因为只有dynamic 设置为true的时候,它才能被调用。
- 如果游戏结束了,要重新开始,那么创建一个新的GameScene ,显示在屏幕上。
接下来在代码中添加如下方法:
override func update(currentTime: CFTimeInterval) { //1 if !gameOver { //2 if player.position.y <= 0 { endGame() } //3 enumerateChildNodesWithName("enemy") { enemy, _ in //4 if enemy.position.x <= 0 { //5 self.updateEnemy(enemy) } } } }
回顾一下:
- 如果gameOver 为false的话,看看monkey是否消失了,如果是,调用endGame——这个随后再添加。
- 用enumerateChildNodesWithName 查看场景中的带有“enemy(敌人)“的对象。每当一个敌人消失,就会调用updateEnemy()——这个也是随后再添加。
现在添加名为updateEnemy()的method,在框架渲染前调用——产生的效果是:每当一个敌人消失,玩家就会得1分:
func updateEnemy(enemy: SKNode) { //1 if enemy.position.x < 0 { //2 enemy.removeFromParent() //3 runAction(coinSound) //4 numPoints++ //5 points.text = "\(numPoints)" } }
再回顾一下上述步骤:
- 看一下敌人是否在在屏幕外的x轴上。
- 如果是,把敌人从parent上移除(从游戏中移除)。
- 将玩家分数储存在numPoints里。
- 将分数转化为分数条,再将分数条插入分数label。
现在需要稍稍改变下didBeginContact(_:)了。从parent移除第一个节点之后,添加这行代码:
endGame()现在终于可以用endGamemethod了,久等了:
func endGame() { // 1 gameOver = true // 2 removeAllActions() // 3 runAction(explosionSound) // 4 endLabel.position = CGPoint(x: frame.size.width/2, y: frame.size.height/2) endLabel.fontColor = UIColor.whiteColor() endLabel.fontSize = 50 endLabel2.position = CGPoint(x: frame.size.width/2, y: frame.size.height/2 + endLabel.fontSize) endLabel2.fontColor = UIColor.whiteColor() endLabel2.fontSize = 20 points.fontColor = UIColor.whiteColor() addChild(endLabel) addChild(endLabel2) }
现在来回顾一下:
- 将gameOver设置为true。
- 移除场景中的所有action,终止所有动画效果。
- 运行爆炸声效。
- 定位,再为场景添加endLabel。
现在用didMoveToView(_:)移除这个代码块:
backgroundColor = SKColor.blackColor() runAction(SKAction.repeatActionForever( SKAction.sequence([ SKAction.runBlock(spawnEnemy), SKAction.waitForDuration(1.0)])))最后添加一行:
player.physicsBody?.dynamic = false
这样设置之后,点击屏幕后游戏才会开始,否则monkey是不会移动的。
编写完成后运行,游戏大功告成了!
背景音乐
等会儿,还有一件事!打开ViewController.swift,添加一个新属性:
var backgroundMusicPlayer: AVAudioPlayer!还要添加一个新method:
func playBackgroundMusic(filename: String) { let url = NSBundle.mainBundle().URLForResource( filename, withExtension: nil) if (url == nil) { println("Could not find file: \(filename)") return } var error: NSError? = nil backgroundMusicPlayer = AVAudioPlayer(contentsOfURL: url, error: &error) if backgroundMusicPlayer == nil { println("Could not create audio player: \(error!)") return } backgroundMusicPlayer.numberOfLoops = -1 backgroundMusicPlayer.prepareToPlay() backgroundMusicPlayer.play() }
这个method很方便,用来添加一些背景音乐。工作原理在此不详述。
用起来很简单,只要在skView.presentScene(scene)那一行后添加viewWillLayoutSubviews():
playBackgroundMusic("BackgroundMusic.mp3")编写好之后运行,美妙的背景音乐出现啦!太棒了!