Lecture10. 优化游戏(终结篇)
最后更新于:2022-04-01 23:36:56
> 原文:http://www.jianshu.com/p/595e11f4528b
作者:[PMST](http://www.jianshu.com/users/596f2ba91ce9/latest_articles)
Flappy Bird整个项目临近尾声,要做的只是对游戏体验的优化,本文先解决两个,分别是:
1. 实现Player 静态时的动画,修改早前掉落时直上直下的问题。
2. Player撞击障碍物时,给出一个shake摇晃动画。
游戏最后实现的效果是这样的:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-11-19_564d42ee6d0a3.gif)
## Player动画实现
当游戏状态为.Tutorial的时候,Player是静态呈现在教程界面上的,为此我们想要实现一个动画,让其挥动翅膀。而实现方法也很简单,动画由多张图片组成,指定一定时间播放完毕,具体用**SKTexture**实例化每一个图片,然后放到数组中;紧接着调用**animateWithTextures(_:timePerFrame:)**播放动画。
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-11-19_564d42eeedd6a.png)
找到setupTutorial()方法,再其下方新增一个方法:
~~~
func setupPlayerAnimation() {
var textures: Array = []
// 我们有4张图片
for i in 0..<4 {
textures.append(SKTexture(imageNamed: "Bird\(i)"))
}
// 4=3-1
for i in 3.stride(through: 0, by: -1) {
textures.append(SKTexture(imageNamed: "Bird\(i)"))
}
let playerAnimation = SKAction.animateWithTextures(textures, timePerFrame: 0.07)
player.runAction(SKAction.repeatActionForever(playerAnimation))
}
~~~
正如前面所说,我们采用`for-in`循环实例化了4个`SKTexture`实例存储于数组中,接着调用方法播放动画。现在请将该方法添加到`switchToMainMenu()`以及`switchToTutorial()`方法中的最后,点击运行,看看Player是否挥动翅膀了。
在玩游戏的时候我们会注意到Player掉落时是直上直下,有些呆板,这里需要替换掉,动画效果如图:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-11-19_564d42ef519b9.png)
在开始实现Player旋转机制前,先定义几个常量以及变量,请在`GameScene()`类中添加如下属性
~~~
// 新增常量
let kMinDegrees: CGFloat = -90 // 定义Player最小角度为-90
let kMaxDegrees: CGFloat = 25 // 定义Player最大角度为25
let kAngularVelocity: CGFloat = 1000.0 // 定义角速度
// 新增变量
var playerAngularVelocity: CGFloat = 0.0 // 实时更新player的角度
var lastTouchTime: NSTimeInterval = 0 // 用户最后一次点击时间
var lastTouchY: CGFloat = 0.0 // 用户最后一次点击坐标
~~~
请找到`flapPlayer`方法,这个方法是在游戏状态下,用户点击一次屏幕需要调用的方法(具体请跳到`touchesBegan`方法),为此我们将在这里进行`lastTouchTime`与`lastTouchY`变量的更新,替换后的内容如下:
~~~
func flapPlayer(){
// 发出一次煽动翅膀的声音
runAction(flapAction)
// 重新设定player的速度!!
playerVelocity = CGPointMake(0, kImpulse)
//===========新增内容============
playerAngularVelocity = kAngularVelocity.degreesToRadians()
lastTouchTime = lastUpdateTime
lastTouchY = player.position.y
//==============================
// 使得帽子下上跳动
let moveUp = SKAction.moveByX(0, y: 12, duration: 0.15)
moveUp.timingMode = .EaseInEaseOut
let moveDown = moveUp.reversedAction()
sombrero.runAction(SKAction.sequence([moveUp,moveDown]))
}
~~~
如此每次用户点击一次屏幕,就会重新计算Player应该旋转多少。那么什么时候去真正更新Player的状态呢?答案是`update()`方法。这里我们要更新的是Player的信息,请找到`updatePlayer()`方法,新增如下内容到最后:
~~~
if player.position.y < lastTouchY {
playerAngularVelocity = -kAngularVelocity.degreesToRadians()
}
// Rotate player
let angularStep = playerAngularVelocity * CGFloat(dt)
player.zRotation += angularStep
player.zRotation = min(max(player.zRotation, kMinDegrees.degreesToRadians()), kMaxDegrees.degreesToRadians())
~~~
点击运行!不出意味应该和预期效果一样。
## Shake动画
先前说到Player撞击障碍物后要有一个摇晃的动画以及闪烁的小锅,那样显得更有真实感不是吗,这里需要调用screenShakeWithNode来实现,摇晃对象是谁?自然是**worldNode**喽。
由于内容简单,请直接定位到`switchToFalling()`方法,替换早前内容:
~~~
enum Layer: CGFloat {
case Background
case Obstacle
case Foreground
case Player
case UI
case Flash //新增一个层
}
func switchToFalling() {
gameState = .Falling
// Screen shake
let shake = SKAction.screenShakeWithNode(worldNode, amount: CGPoint(x: 0, y: 7.0), oscillations: 10, duration: 1.0)
worldNode.runAction(shake)
// Flash
let whiteNode = SKSpriteNode(color: SKColor.whiteColor(), size: size)
whiteNode.position = CGPoint(x: size.width/2, y: size.height/2)
whiteNode.zPosition = Layer.Flash.rawValue
worldNode.addChild(whiteNode)
whiteNode.runAction(SKAction.removeFromParentAfterDelay(0.01))
runAction(SKAction.sequence([
whackAction,
SKAction.waitForDuration(0.1),
fallingAction
]))
player.removeAllActions()
stopSpawning()
}
~~~
哦对了,请注释掉GameViewController.swift中的几行代码,去掉所有调试信息,这样才是一个完整的游戏;
~~~
// 4.设置一些调试参数
//skView.showsFPS = true // 显示帧数
//skView.showsNodeCount = true // 显示当前场景下节点个数
//skView.showsPhysics = true // 显示物理体
//skView.ignoresSiblingOrder = true // 忽略节点添加顺序
~~~
点击运行,享受你的劳动果实吧!
##结尾
这个游戏系列文章终于连载完成,当时可能是一时兴起,最后还是坚持下来了。文章更多是在叙述整个游戏是如何开发出来,并未在一些基础知识以及实现原理上细说,这是之后我要补充的,最后谢谢大家的支持。如果觉得不错,请点击喜欢并关注我,同时将我的文章推荐给你的朋友。8~
';
Lecture09. 服务员,说好的菜单呢?
最后更新于:2022-04-01 23:36:54
> 原文:http://www.jianshu.com/p/1fdb34225bef
作者:[PMST](http://www.jianshu.com/users/596f2ba91ce9/latest_articles)
Lecture08课程结束,我们已经走过了90%,剩下的10%是对游戏体验的改进罢了。就比如,刚启动游戏,“Player”就出现在屏幕中Flap一下翅膀,然后还没等用户清楚这个游戏是什么情况的时候,“Player”已经坠地阵亡了。这种游戏体验可谓是差到极致,试想一个用户下载游戏并启动,此时还对游戏没有一丝认知,渴求先看看帮助说明或者玩法介绍之类吧!
因为本课程中,将剔除早前的直接进入游戏的弊端,通过添加主菜单供用户选择开始一次游戏亦或是查看游戏帮助说明等选项。如下:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-11-19_564d42ee2c920.png)
前文已经给出了游戏状态有如下几种:
~~~
enum GameState{
case MainMenu
case Tutorial
case Play
case Falling
case ShowingScore
case GameOver
}
~~~
当我们开始一个游戏的时候,必须制定当前新游戏的状态,比如是*MainMenu*显示主菜单呢还是直接进入正题*Play*开始进行游戏。为此我们自定义一个构造函数`init(size: CGSize, gameState: GameState)`传入gameState设置新游戏初识状态。请添加如下两个方法到*GameScene.swift*中的*GameScene*类中。
~~~
init(size: CGSize, gameState: GameState) {
self.gameState = gameState
super.init(size: size)
}
~~~
添加完毕之后,你会发现编译器报错,这也是情理之中,毕竟修改了构造方法导致早前的初始化方法都不能使用了。不急,慢慢修改。请定位到`switchToNewGame()`方法,要知道早前我们开始一个新游戏就是调用该函数,但是未指定新游戏的状态,为此我们要大刀阔斧地小改一番...如下:
~~~
func switchToNewGame(gameState: GameState) { //改动1 添加了一个传入参数
runAction(popAction)
let newScene = GameScene(size: size,gameState:gameState)//修改传入参数
let transition = SKTransition.fadeWithColor(SKColor.blackColor(), duration: 0.5)
view?.presentScene(newScene, transition: transition)
}
~~~
wo ca!!这下早前所有调用`switchToNewGame()`方法的地方都报错了。请不要着急,凡是循序渐进,首先找到`touchesBegan(touches: Set, withEvent event: UIEvent?)`方法,这次真要大改一番了:
~~~
override func touchesBegan(touches: Set, withEvent event: UIEvent?) {
//1
let touch = touches.first
let touchLocation = touch?.locationInNode(self)
switch gameState {
case .MainMenu:
//2
if touchLocation?.y < size.height * 0.15 {
//TODO: 之后添加
} else if touchLocation?.x < size.width * 0.6 {
//3
switchToNewGame(.Tutorial)
}
break
case .Tutorial:
//TODO: 之后添加
break
case .Play:
flapPlayer()
break
case .Falling:
break
case .ShowingScore:
break
case .GameOver:
//4
if touchLocation?.x < size.width * 0.6 {
switchToNewGame(.MainMenu)
}
break
}
}
~~~
改动还是蛮大的,起码现在需要根据你点击的位置来执行相应的点击事件:
1. 获得第一个点击,然后得到在场景中的位置Position,自然就是点Point:包括x坐标值和y坐标值了。
2. 这里我们只是简单判断点击位置的范围,比如点击位置下偏下方时,就装作点击了"Learn to make this game的按钮"。
3. 倘若通过位置判断,你点击了*Play*按钮,则新建一个初始游戏状态为`.Tutorial`的新游戏,此时并不会立刻开始游戏,而是显示一个教程画面,只有当再次点击时才会开始游戏。
4. 此时处于游戏结束状态,通过点击OK按钮开启一个新游戏,但是游戏状态为.Menu。
此时还有个报错来自于"GameViewController.switf文件",请找到`let scene = GameScene(size:CGSizeMake(320, 320 * aspectRatio))`这一行,改为我们定义的构造方法`let scene = GameScene(size:CGSizeMake(320, 320 * aspectRatio),gameState:.MainMenu)`即可。
点击运行,我去!! 咋不灵了.....
貌似`didMoveToView()`方法中 我们并没有根据游戏初始状态来初始化游戏场景...请转到`GameScene`类中,定位到`didMoveToView()`,将其中内容替换成如下内容:
~~~
override func didMoveToView(view: SKView) {
physicsWorld.gravity = CGVector(dx: 0, dy: 0)
physicsWorld.contactDelegate = self
addChild(worldNode)
// 以下为替换内容
if gameState == .MainMenu {
switchToMainMenu()
} else {
switchToTutorial()
}
}
//MARK: Game States
//添加剩余两个场景切换方法
func switchToMainMenu() {
gameState = .MainMenu
setupBackground()
setupForeground()
setupPlayer()
setupSomebrero()
//TODO: 实现setupMainMenu()主界面布局 之后把注释去掉
}
func switchToTutorial() {
gameState = .Tutorial
setupBackground()
setupForeground()
setupPlayer()
setupSomebrero()
setupLabel()
//TODO: 实现setupTutorial()教程界面布局 之后把注释去掉
}
~~~
其中我们还未实现对主界面的布局,以及教程界面的布局,这也是接下来所要干的事了。
**实现主界面的布局:**
代码貌似很长,但内容很熟悉不是吗,当年你在配置ScoreCard界面的时候不也这么做过?先布局几个button,然后执行几个动画罢了,请边码边回忆是怎么对精灵位置放置,添加动作的。
~~~
func setupMainMenu() {
let logo = SKSpriteNode(imageNamed: "Logo")
logo.position = CGPoint(x: size.width/2, y: size.height * 0.8)
logo.zPosition = Layer.UI.rawValue
worldNode.addChild(logo)
// Play button
let playButton = SKSpriteNode(imageNamed: "Button")
playButton.position = CGPoint(x: size.width * 0.25, y: size.height * 0.25)
playButton.zPosition = Layer.UI.rawValue
worldNode.addChild(playButton)
let play = SKSpriteNode(imageNamed: "Play")
play.position = CGPoint.zero
playButton.addChild(play)
// Rate button
let rateButton = SKSpriteNode(imageNamed: "Button")
rateButton.position = CGPoint(x: size.width * 0.75, y: size.height * 0.25)
rateButton.zPosition = Layer.UI.rawValue
worldNode.addChild(rateButton)
let rate = SKSpriteNode(imageNamed: "Rate")
rate.position = CGPoint.zero
rateButton.addChild(rate)
// Learn button
let learn = SKSpriteNode(imageNamed: "button_learn")
learn.position = CGPoint(x: size.width * 0.5, y: learn.size.height/2 + kMargin)
learn.zPosition = Layer.UI.rawValue
worldNode.addChild(learn)
// Bounce button
let scaleUp = SKAction.scaleTo(1.02, duration: 0.75)
scaleUp.timingMode = .EaseInEaseOut
let scaleDown = SKAction.scaleTo(0.98, duration: 0.75)
scaleDown.timingMode = .EaseInEaseOut
learn.runAction(SKAction.repeatActionForever(SKAction.sequence([
scaleUp, scaleDown
])))
}
~~~
**实现教程界面设置:**
反观这个教程界面就显得简单多了,只需要添加一章玩法帮助的图就ok了,如下:
~~~
func setupTutorial() {
let tutorial = SKSpriteNode(imageNamed: "Tutorial")
tutorial.position = CGPoint(x: size.width * 0.5, y: playableHeight * 0.4 + playableStart)
tutorial.name = "Tutorial"
tutorial.zPosition = Layer.UI.rawValue
worldNode.addChild(tutorial)
let ready = SKSpriteNode(imageNamed: "Ready")
ready.position = CGPoint(x: size.width * 0.5, y: playableHeight * 0.7 + playableStart)
ready.name = "Tutorial"
ready.zPosition = Layer.UI.rawValue
worldNode.addChild(ready)
}
~~~
好了,定位到`switchToMainMenu()`和`switchToTutorial()`方法,把*TODO字样的之后方法进行调用*。
点击运行项目,恩...出来了,而且再次点击Play会转到教程界面。不过再点击的话,貌似没反应了,聪明的你肯定会转到`touchesBegan()`方法,定位到.Tutorial状态,你会发现此时名下啥都没有,怎么可能开始愉快的玩耍呢???
为此在下方添加一个`switchToPlay()`方法并在.Tutorial下调用。
~~~
func switchToPlay() {
// 从.Tutorial 状态转到.Play状态
gameState = .Play
// 移除Tutorial精灵
worldNode.enumerateChildNodesWithName("Tutorial", usingBlock: { node, stop in
node.runAction(SKAction.sequence([
SKAction.fadeOutWithDuration(0.5),
SKAction.removeFromParent()
]))
})
// 开始产生障碍物 从右向左移动
startSpawning()
// 让Player 向上蹦跶一次...
flapPlayer()
}
~~~
点击运行项目,请尽情享受成功的果实吧!
倘若你对游戏某一部分不太熟悉,请到[github](http://www.jianshu.com/p/1fdb34225bef)下载所有课程的代码和课件。
';
Lecture08. Show Me 得分面板!
最后更新于:2022-04-01 23:36:52
> 原文:http://www.jianshu.com/p/da7f53fab298
作者:[PMST](http://www.jianshu.com/users/596f2ba91ce9/latest_articles)
课时7中实现了得分机制,当你越战越勇,得分也蹭蹭地往上加,不过马有失蹄,人有失足,总会不小心失败,这时候就要结算你的劳动成果了:通常都是告知游戏结束,得分几何,最好成绩等等信息。咱们游戏是这么设计的:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-11-19_564d42ee09e7d.png)
**本文任务:**
* 当游戏结束时呈现上图的内容。
**思路:**
* 简单来说就是实例化几个特定纹理(你可以理解为照片*image*)的精灵,然后按照特定布局放置到屏幕中,是不是灰常简单呢?
## 01.使用NSUserDefaults来保存数据
使用*NSUserDefaults*用于持久性的保存得分记录是一个明智的决定,我们不可能为了存一个数据而使用诸如`Core Data`或者`Sqlite`等数据库。
### 1-1 从NSUserDefault中读取分数
请在*GameScene*类中添加一个新方法,用于读取游戏记录得分:
~~~
func bestScore()->Int{
return NSUserDefaults.standardUserDefaults().integerForKey("BestScore")
}
~~~
可以看到方法中为最高得分设置了名为"BestScore"的键。
### 1-2 在NSUserDefault中设置分数
有读取自然有写,请在*bestScore()*方法下方添加一个新方法:
~~~
func setBestScore(bestScore:Int){
NSUserDefaults.standardUserDefaults().setInteger(bestScore, forKey: "BestScore")
NSUserDefaults.standardUserDefaults().synchronize()
}
~~~
## 02.构建得分面板
得分面板如文中给出图片布局,工程量还是蛮大的,不过我会一步一步讲解,无需担心一口吃撑的情况。
首先请找到*setupLabel*方法,在它下方声明咱们的设置得分面板的函数,取名为`setupScorecard()`:
~~~
func setupScorecard() {
//1
if score > bestScore() {
setBestScore(score)
}
//...等下添加其他内容
}
~~~
* 首先调用`bestScore()`取到历史最高得分,与本回合得分比较,倘若这次“走狗屎运”得了高分,咱们就要更新历史最高纪录,也就是调用`setBestScore(score)`方法。
接着,着手添加得分面板的精灵,内容有点多,请注意别码错:
~~~
func setupScorecard() {
if score > bestScore() {
setBestScore(score)
}
// 1 得分面板背景
let scorecard = SKSpriteNode(imageNamed: "ScoreCard")
scorecard.position = CGPoint(x: size.width * 0.5, y: size.height * 0.5)
scorecard.name = "Tutorial"
scorecard.zPosition = Layer.UI.rawValue
worldNode.addChild(scorecard)
// 2 本次得分
let lastScore = SKLabelNode(fontNamed: kFontName)
lastScore.fontColor = SKColor(red: 101.0/255.0, green: 71.0/255.0, blue: 73.0/255.0, alpha: 1.0)
lastScore.position = CGPoint(x: -scorecard.size.width * 0.25, y: -scorecard.size.height * 0.2)
lastScore.text = "\(score)"
scorecard.addChild(lastScore)
// 3 最好成绩
let bestScoreLabel = SKLabelNode(fontNamed: kFontName)
bestScoreLabel.fontColor = SKColor(red: 101.0/255.0, green: 71.0/255.0, blue: 73.0/255.0, alpha: 1.0)
bestScoreLabel.position = CGPoint(x: scorecard.size.width * 0.25, y: -scorecard.size.height * 0.2)
bestScoreLabel.text = "\(self.bestScore())"
scorecard.addChild(bestScoreLabel)
// 4 游戏结束
let gameOver = SKSpriteNode(imageNamed: "GameOver")
gameOver.position = CGPoint(x: size.width/2, y: size.height/2 + scorecard.size.height/2 + kMargin + gameOver.size.height/2)
gameOver.zPosition = Layer.UI.rawValue
worldNode.addChild(gameOver)
// 5 ok按钮背景以及ok标签
let okButton = SKSpriteNode(imageNamed: "Button")
okButton.position = CGPoint(x: size.width * 0.25, y: size.height/2 - scorecard.size.height/2 - kMargin - okButton.size.height/2)
okButton.zPosition = Layer.UI.rawValue
worldNode.addChild(okButton)
let ok = SKSpriteNode(imageNamed: "OK")
ok.position = CGPoint.zeroPoint
ok.zPosition = Layer.UI.rawValue
okButton.addChild(ok)
// 6 share按钮背景以及share标签
let shareButton = SKSpriteNode(imageNamed: "Button")
shareButton.position = CGPoint(x: size.width * 0.75, y: size.height/2 - scorecard.size.height/2 - kMargin - shareButton.size.height/2)
shareButton.zPosition = Layer.UI.rawValue
worldNode.addChild(shareButton)
let share = SKSpriteNode(imageNamed: "Share")
share.position = CGPoint.zeroPoint
share.zPosition = Layer.UI.rawValue
shareButton.addChild(share)
}
~~~
当你码完了这一段超长代码之后,你会松一口气,现在还有一步就能享受胜利的果实了!!
想想我们时候什么需要显示这个得分面板。*Player*掉落失败的时候,对吗?请找到`switchToShowScore()`方法,在最下方调用`setupScorecard()`,点击运行,过几个障碍物,然后自由落体,看看是否良好地显示了得分面板。
Good Job!显示本次得分,历史最高纪录以及选项按钮——不过此时并没有什么卵用。
你有木有发现得分面板“毫无征兆”地就出现在了场景中央,来加个动画吧!!!
##03.为得分面板添加动画
请回到原先的`setupScorecard()`方法,继续再下方添加动画代码:
~~~
//=== 添加一个常量 用于定义动画时间 ====
let kAnimDelay = 0.3
func setupScorecard() {
//。。。。。。
//==== 以下是新添加的内容 =====
gameOver.setScale(0)
gameOver.alpha = 0
let group = SKAction.group([
SKAction.fadeInWithDuration(kAnimDelay),
SKAction.scaleTo(1.0, duration: kAnimDelay)
])
group.timingMode = .EaseInEaseOut
gameOver.runAction(SKAction.sequence([
SKAction.waitForDuration(kAnimDelay),
group
]))
scorecard.position = CGPoint(x: size.width * 0.5, y: -scorecard.size.height/2)
let moveTo = SKAction.moveTo(CGPoint(x: size.width/2, y: size.height/2), duration: kAnimDelay)
moveTo.timingMode = .EaseInEaseOut
scorecard.runAction(SKAction.sequence([
SKAction.waitForDuration(kAnimDelay * 2),
moveTo
]))
okButton.alpha = 0
shareButton.alpha = 0
let fadeIn = SKAction.sequence([
SKAction.waitForDuration(kAnimDelay * 3),
SKAction.fadeInWithDuration(kAnimDelay)
])
okButton.runAction(fadeIn)
shareButton.runAction(fadeIn)
let pops = SKAction.sequence([
SKAction.waitForDuration(kAnimDelay),
popAction,
SKAction.waitForDuration(kAnimDelay),
popAction,
SKAction.waitForDuration(kAnimDelay),
popAction,
SKAction.runBlock(switchToGameOver)
])
runAction(pops)
}
~~~
点击运行,玩耍吧!
> 注意: 我发现了一个BUG,倘若游戏一开始就使得它下落触碰地面,弹出的得分面板*share*标签放置位置是错误的,因为它的背景以`let shareButton = SKSpriteNode(imageNamed: "Button")` 返回的是一个精灵`size=0`,让我百思不得其解。希望找到问题的朋友可以告知我。
';
Lecture07. 老板,来块记分牌!
最后更新于:2022-04-01 23:36:49
> 原文:http://www.jianshu.com/p/bd109ba65dd6
> 作者:[PMST](http://www.jianshu.com/users/596f2ba91ce9/latest_articles)
* “Hey!我昨天*Flappy Bird*得了100分!!!”
* “我叶良辰表示不服!”
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-11-19_564d258d947f2.jpg)
Lecture06课时完毕,我们已经初步完成游戏的主体,可惜却没有一个衡量得分的标准。类似*FlappyBird*游戏,当然是谁通过的障碍物越多,就越牛逼。不如我们设定如下规则:
* 通过一对障碍物得1分。
* 触碰地面或者障碍物判定失败,结算分数。
当前任务主要分为:
1. 显示分数牌
2. 如何判断通过障碍物。
## 01.显示分数牌
像*Flappy Bird*的小游戏,我们不妨仅用*SKLabelNode*来显示分数,就类似平常我们所用的*UILabel*。请在`var gameState: GameState = .Play`语句下方添加对记分牌的声明`var scoreLabel: SKLabelNode!`,同时我们还需要用一个变量存储分数,继续在下方添加`var score = 0`;此外对于这些显示额外帮主内容的,我们还需要添加一个`UI`层,请修改早前的`Layer`枚举:
~~~
enum Layer: CGFloat {
case Background
case Obstacle
case Foreground
case Player
case UI //新内容
}
~~~
类似早前*setupBackground()*,*setupForeground()*那样,我们依葫芦画瓢设置记分牌,请添加一个方法,如下:
~~~
func setupLabel() {
scoreLabel = SKLabelNode(fontNamed: "AmericanTypewriter-Bold")
scoreLabel.fontColor = SKColor(red: 101.0/255.0, green: 71.0/255.0, blue: 73.0/255.0, alpha: 1.0)
scoreLabel.position = CGPoint(x: size.width/2, y: size.height - 20)
scoreLabel.text = "0"
scoreLabel.verticalAlignmentMode = .Top
scoreLabel.zPosition = Layer.UI.rawValue
worldNode.addChild(scoreLabel)
}
~~~
注意到在设置字体名字为`AmericanTypewriter-Bold`过长且之后可能还需要用到,不妨新增一个常量`let kFontName = "AmericanTypewriter-Bold"`(在`kEverySpawnDelay`下方即可),另外`size.height - 20`中的20是一个页边距,也是一个常量,不妨也一并替换掉,声明一个常量`let kMargin: CGFloat = 20.0`。**注意:新增的两个常量都是在GameScene类中作为全局变量。**
现在`setupLabel()`函数改为:
~~~
func setupLabel() {
scoreLabel = SKLabelNode(fontNamed: kFontName)//改动1
scoreLabel.fontColor = SKColor(red: 101.0/255.0, green: 71.0/255.0, blue: 73.0/255.0, alpha: 1.0)
scoreLabel.position = CGPoint(x: size.width/2, y: size.height - kMargin)//改动2
scoreLabel.text = "0"
scoreLabel.verticalAlignmentMode = .Top
scoreLabel.zPosition = Layer.UI.rawValue
worldNode.addChild(scoreLabel)
}
~~~
点击运行,不出意外屏幕正中间靠上已经显示一个大大的"0",可惜无论你经过多少个障碍物,还是鸭蛋,那是因为还未实现计分功能。
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-11-19_564d258da6615.png)
## 02.实现计分
**思路:**
在`update()`方法中,每隔大约33毫秒时间检测一次*Player*是否过了障碍物,倘若过了就得一分,不过这里又有一个问题,倘若已经得知过了第一个障碍物,但紧随33毫秒后之后,仍然只过了第一个障碍物,难道还得分??显然不是!为此我们需要为已经过了一次的障碍物添加一个[Passed]标志,而没有过的障碍物是没有标志位为[]。如下图:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-11-19_564d258ddcc4a.png)
图中的设置了障碍物的标志位:["Passed"]或者[]两种。那么问题来了,哪里存储这些标志位呢?答案是*Sprite*中的`userData`属性,其类型是`NSMutableDictionary`可变字典,请在`func createObstacle()->SKSpriteNode{}`方法中找到`sprite.zPosition = Layer.Obstacle.rawValue`语句下添加一条新语句:
~~~
//...
sprite.userData = NSMutableDictionary()
//...
~~~
注意到一开始`userData`是一个空字典[],倘若执行`userData["Passed"] = NSNumber(bool: true)`,就新增了一个键为`Passed`,值为`true`的元素。
理解完这些,开始构思咱们的`updateScore()`方法:
~~~
func updateScore() {
worldNode.enumerateChildNodesWithName("BottomObstacle", usingBlock: { node, stop in
if let obstacle = node as? SKSpriteNode {
if let passed = obstacle.userData?["Passed"] as? NSNumber {
if passed.boolValue {
return
}
}
if self.player.position.x > obstacle.position.x + obstacle.size.width/2 {
self.score++
self.scoreLabel.text = "\(self.score)"
self.runAction(self.coinAction)
obstacle.userData?["Passed"] = NSNumber(bool: true)
}
}
})
}
~~~
**讲解:**
1. 起初场景中产生的障碍物都是携带的[]空字典内容。
2. *Player*从一对障碍物的左侧穿越到右侧,才算"Passed",计分一次。
3. 检测方法很简单,只需要循环遍历*worldNode*节点中的所有障碍物,检查它的*userData*是否包含了*Passed*键值。两种情况:1.包含意味着当前障碍物已经经过且计算过分数了,所以无须再次累加,直接返回即可;2.当前障碍物为[],说明还未被穿越过,因此需要通过位置检测(*Player*当前位置位于障碍物右侧?),来判断是否穿越得分,是就分数累加且设置当前障碍物为已经"Passed",否则什么都不处理,返回。
请将*updateScore()*添加到*update()*方法中**.Play**情况最下方。
点击运行,通过障碍物得分!!!
';
Lecture06. 碰撞的检测
最后更新于:2022-04-01 23:36:47
> 原文:http://www.jianshu.com/p/bcd17547b395
> 作者:[PMST](http://www.jianshu.com/users/596f2ba91ce9/latest_articles)
前文已经为各个精灵新增了*Physics Body*,设置了三个掩码:
* categoryBitMask表明了分属类别。
* collisionBitMask告知能与哪些物体碰撞。
* contactTestBitMask则告知能与哪些物体接触。
现在遗留的问题是如何检测碰撞?难道是在*update()*方法进行检测:遍历所有的节点,通过判断节点的位置是否有交集吗?天呐!这也太麻烦了。确实,如果通过自己实时检测实在过于劳累,何不让*Sprite Kit*来帮你代劳,每当物体之间发生碰撞了,立马通知你来处理事件。*Bingo!!* 显然这里要用**协议+代理**了,设置场景为代理,每当*Sprite Kit*检测到碰撞事件发生,就通知*GameScene*来处理,当前哪里事情都是在协议(*Protocol*)中声明了。
## 01.游戏状态
在正式开始今天的碰撞检测课程之前,谈谈如何划分游戏各时的状态,仅以*Flappy bird*游戏为例,简单划分如下:
* *MaiMenu*。开始一次游戏、查看排名以及游戏帮助。
* *Tutorial*。考虑到新手对于新游戏的上手,在选择进行一次新游戏时,展示玩法教程显然是一个明确且友好的措施。
* *Play*。正处于游戏的状态。
* *Falling*。*Player*因为不小心碰到障碍物失败下落时刻。**注意:接触障碍物,失败掉落才算!**
* *ShowingScore*。显示得分。
* *GameeOver*。告知游戏结束。
为此请打开*Lecture05*的完成工程,打开*GameScene.swift*文件,新增游戏状态的枚举声明到`enum Layer{}`下方:
~~~
enum GameState{
case MainMenu
case Tutorial
case Play
case Falling
case ShowingScore
case GameOver
}
~~~
当然,我们还需要声明一个变量用于存储游戏场景的状态,请找到*GameScene*类中`let sombrero = SKSpriteNode(imageNamed: "Sombrero")`这条代码,在下方新增三个新变量:
~~~
//1
var hitGround = false
//2
var hitObstacle = false
//3
var gameState: GameState = .Play
~~~
1. 标识符,记录*Player*是否掉落至地面。
2. 标识符,记录*Player*是否碰撞了仙人掌。
3. 游戏状态,默认是正在玩。
## 02.碰撞检测
正如前面提及的**协议+代理**方式检测物体之间的碰撞情况。首先请使得类*GameScene*遵循`SKPhysicsContactDelegate`协议:
~~~
class GameScene: SKScene,SKPhysicsContactDelegate{...}
~~~
接着在*didMoveToView()*方法中设置代理为`self`,找到`physicsWorld.gravity = CGVector(dx: 0, dy: 0)`这行代码,添加该行代码`physicsWorld.contactDelegate = self`。
`SKPhysicsContactDelegate`协议中定义了两个可选方法,分别是:
* `optional public func didBeginContact(contact: SKPhysicsContact)`
* `optional public func didEndContact(contact: SKPhysicsContact)`
分别用于反馈两个物体开始接触、结束接触两个时刻。本文采用第一个方法用户处理物体接触事件。
~~~
func didBeginContact(contact: SKPhysicsContact) {
let other = contact.bodyA.categoryBitMask == PhysicsCategory.Player ? contact.bodyB : contact.bodyA
if other.categoryBitMask == PhysicsCategory.Ground {
hitGround = true
}
if other.categoryBitMask == PhysicsCategory.Obstacle {
hitObstacle = true
}
}
~~~
`contact`包含了接触的所有信息,其中*bodyA*和*bodyB*代表两个碰撞的物体,显然发生碰撞的结果只有两种可能:1.*Player*和地面;2.*Player*和障碍物。可惜我们无法确实*bodyA*就是*Player*,亦或是*bodyB*就是它。这是有不确定性的,我们需要通过`categoryBitMask`来区分“阵营”。一旦确定哪个是*Player*之后,我们就能取到与之发生接触的*other*,通过判断其类别来分别置为标志位。
一旦标志位设置之后,我们需要在*update()*方法中进行处理了!
## 03.根据游戏状态来处理事件
请定位到`update()`方法,修改其中的内容:
~~~
override func update(currentTime: CFTimeInterval) {
if lastUpdateTime > 0 {
dt = currentTime - lastUpdateTime
} else {
dt = 0
}
lastUpdateTime = currentTime
switch gameState {
case .MainMenu:
break
case .Tutorial:
break
case .Play:
updateForeground()
updatePlayer()
//1
checkHitObstacle() //Play状态下检测是否碰撞了障碍物
//2
checkHitGround() //Play状态下检测是否碰撞了地面
break
case .Falling:
updatePlayer()
//3
checkHitGround() //Falling状态下检测是否掉落至地面 此时已经失败了
break
case .ShowingScore:
break
case .GameOver:
break
}
}
~~~
其中1,2,3中三个方法均是通过状态标志位来处理碰撞事件,请添加`checkHitObstacle()`以及`checkHitGround()`方法到`updateForeground()`方法下方:
~~~
// 与障碍物发生碰撞
func checkHitObstacle() {
if hitObstacle {
hitObstacle = false
switchToFalling()
}
}
// 掉落至地面
func checkHitGround() {
if hitGround {
hitGround = false
playerVelocity = CGPoint.zero
player.zRotation = CGFloat(-90).degreesToRadians()
player.position = CGPoint(x: player.position.x, y: playableStart + player.size.width/2)
runAction(hitGroundAction)
switchToShowScore()
}
}
// MARK: - Game States
// 由Play状态变为Falling状态
func switchToFalling() {
gameState = .Falling
runAction(SKAction.sequence([
whackAction,
SKAction.waitForDuration(0.1),
fallingAction
]))
player.removeAllActions()
stopSpawning()
}
// 显示分数状态
func switchToShowScore() {
gameState = .ShowingScore
player.removeAllActions()
stopSpawning()
}
// 重新开始一次游戏
func switchToNewGame() {
runAction(popAction)
let newScene = GameScene(size: size)
let transition = SKTransition.fadeWithColor(SKColor.blackColor(), duration: 0.5)
view?.presentScene(newScene, transition: transition)
}
~~~
完成后自然你发现`stopSpawning()`方法并未实现,因为我打算好好讲讲这个。早前在`didMoveToView()`方法中调用`startSpawning()`源源不断地产生障碍物,但是一旦游戏结束,我们所要做的事情有两个:1.停止继续产生障碍物;2.已经在场景中的障碍物停止移动。那么如何制定某个动作*Action*停止呢?答案是先为这个动作命名(简单来说设置一个**Key**而已),然后用`removeActionForKey()`来移除。
OK,找到`startSpawning()`方法,将`runAction(overallSequence)`替换成`runAction(overallSequence, withKey: "spawn")`;定位到`spawnObstacle()`方法,分别设置*bottomObstacle*和*topObstacle*精灵的名字,方便之后找到它们并进行操作:
~~~
...
bottomObstacle.name = "BottomObstacle"
worldNode.addChild(bottomObstacle)
...
topObstacle.name = "TopObstacle"
worldNode.addChild(topObstacle)
...
~~~
现在来实现`stopSpawning()`方法,在`startSpawning()`下方添加就好:
~~~
func stopSpawning() {
removeActionForKey("spawn")
worldNode.enumerateChildNodesWithName("TopObstacle", usingBlock: { node, stop in
node.removeAllActions()
})
worldNode.enumerateChildNodesWithName("BottomObstacle", usingBlock: { node, stop in
node.removeAllActions()
})
}
~~~
点击运行,我擦!还没来得及点就掉地上了......好吧,只能在游戏进入一瞬间先让*Player*向上蹦跶下。添加`flapPlayer()`到`didMoveToView()`方法的最下方。
点击运行,Nice!!*Player*顺利穿过了障碍,不小心碰到了障碍物,再点击,等等!怎么还能动...好吧,看来`touchesBegan(touches: Set, withEvent event: UIEvent?)`点击事件中我们并未根据游戏状态来处理,是时候修改了。
~~~
override func touchesBegan(touches: Set, withEvent event: UIEvent?) {
switch gameState {
case .MainMenu:
break
case .Tutorial:
break
case .Play:
flapPlayer()
break
case .Falling:
break
case .ShowingScore:
switchToNewGame()
break
case .GameOver:
break
}
}
~~~
点击运行,失败重新开始游戏...等等貌似还有问题,怎么点击想重新开始游戏会突然掉落到地面上...好吧,请看[lecture02](http://www.jianshu.com/p/82697ebf5cad)中的时间间隔图,匆忙的你找找原因,试试解决吧。
';
Lecture05. 真实的物理世界
最后更新于:2022-04-01 23:36:45
> 原文:http://www.jianshu.com/p/f1174b54dad3
> 作者:[PMST](http://www.jianshu.com/users/596f2ba91ce9/latest_articles)
游戏的雏形已经基本实现,呈现了背景,地面持续滚动,*Player*上下跳窜以及源源不断的仙人掌。不过细心的你也应当发现有以下几个不足:
1. Player可以通过不断点击升高到屏幕外。
2. 仙人掌表示不服:你丫想穿越我就穿越,当我是透明吗?
因此本节的任务是设置场景精灵的物理体,当课时完毕,*Player*一旦触碰到仙人掌就会下落,不能继续游戏。
## 01.设置场景内精灵的物体形状
暂且对游戏内容按下不表,先谈谈咱们真实的世界,重力加速度9.8g,非透明的物体之间碰撞会发生形变。而在*Sprite Kit*中的物理世界,首先我需要引出*Physics Shapes* —— 物体形状,就拿人来说,倘若我粗略地来形容一个人的物理体,我就会给出一个`x*y*z`(长宽高计算体积)的长方体,一旦外物触碰轮廓表面,我就说两者发生了接触;不过若已精确角度来说,形容人的物理体以其皮肤表面为轮廓勾勒出一个体积,显然这比先前的立方体来的精确太多了;当然有时候嫌麻烦,指定头部(姑且就当成一个球体吧)作为人的物理体,因此除头部外的身体都相当于是透明的,外物接触了手、腿等都不算发生接触,只有与头部接触才算。
讲了那么多,现在回到游戏,开始塑造真实的物理世界,首先找到`didMoveToView()`方法,在最上方添加一行代码设置场景物理世界的重力为(0,0),原因是我们打算使用自定义设置的参数:
~~~
override func didMoveToView(view: SKView) {
physicsWorld.gravity = CGVector(dx: 0, dy: 0)
//... 以下为早前内容
}
~~~
接着咱们要说**physicsBody**,译为物理体。我们可以设置每个节点的物理体,那样它就可以和其他同样设置了物理体的节点发生碰撞、检测接触等,它有三个属性,值均为UInt32类型:
* *categoryBitMask*: 表明当前body属于哪个类别。
* *collisionBitMask*: 当前物体可以与哪些类别发生碰撞。
* *contactTestBitMask*:用于告知当前物体与哪些类别物理发生接触时。
游戏中类似这种,我们往往用二进制数来表示物体,譬如*0b1*表明是*Player*,*0b10*表明障碍物,*0b100*表明地面。想必编程男都不陌生吧。OK,请在`enum Layer:CGFLoat{}`下方新增一个结构体用于表明分类,注意里面均为类型属性:
~~~
struct PhysicsCategory {
static let None: UInt32 = 0
static let Player: UInt32 = 0b1 // 1
static let Obstacle: UInt32 = 0b10 // 2
static let Ground: UInt32 = 0b100 // 4
}
~~~
对于类型属性,调用方法形如:`PhysicsCategory.None`,更多关于类型属性,请参看官方文档*Type properties*一节。
接下来我们主要添加以下物理体到场景中:
1. Player,这里我们将借助一个勾勒工具来绘制其物理体。
2. 障碍物,同上。
3. 地面,其实就是一条水平线。
为啥要设置以上三个物理体呢?因为设置完物理体后,我们才能知道谁和谁发生了接触*contact*,如此进行下一步计算。至于`collision`咱们是不关心的,不需要设置。
## 设置地面的物理体
找到`setupBackground()`方法 在方法最下方添加如下内容:
~~~
func setupBackground(){
//...
//===以上为早前内容===
//===以下为新增内容===
let lowerLeft = CGPoint(x: 0, y: playableStart)//地板表面的最左侧一点
let lowerRight = CGPoint(x: size.width, y: playableStart) //地板表面的最右侧一点
// 1
self.physicsBody = SKPhysicsBody(edgeFromPoint: lowerLeft, toPoint: lowerRight)
self.physicsBody?.categoryBitMask = PhysicsCategory.Ground
self.physicsBody?.collisionBitMask = 0
self.physicsBody?.contactTestBitMask = PhysicsCategory.Player
}
~~~
对于1中,我们用一条平行线来实例化物理体,然后是三部曲,分别设置了其分类为*Ground*;不予其他任何物理发生碰撞(因为设置了0);设置了能与其发生接触的物体有*Player*。
## 设置Player的物理体
找到`setupPlayer()`方法 同样新增以下内容到方法最后:
~~~
func setupPlayer(){
player.position = CGPointMake(size.width * 0.2, playableHeight * 0.4 + playableStart)
player.zPosition = Layer.Player.rawValue
// 注意我们将worldNode.addChild(player)移到了最下方。
//=========以下为新增内容===========
let offsetX = player.size.width * player.anchorPoint.x
let offsetY = player.size.height * player.anchorPoint.y
let path = CGPathCreateMutable()
CGPathMoveToPoint(path, nil, 17 - offsetX, 23 - offsetY)
CGPathAddLineToPoint(path, nil, 39 - offsetX, 22 - offsetY)
CGPathAddLineToPoint(path, nil, 38 - offsetX, 10 - offsetY)
CGPathAddLineToPoint(path, nil, 21 - offsetX, 0 - offsetY)
CGPathAddLineToPoint(path, nil, 4 - offsetX, 1 - offsetY)
CGPathAddLineToPoint(path, nil, 3 - offsetX, 15 - offsetY)
CGPathCloseSubpath(path)
player.physicsBody = SKPhysicsBody(polygonFromPath: path)
player.physicsBody?.categoryBitMask = PhysicsCategory.Player
player.physicsBody?.collisionBitMask = 0
player.physicsBody?.contactTestBitMask = PhysicsCategory.Obstacle | PhysicsCategory.Ground
worldNode.addChild(player)// hey 我现在在这里!!!!
}
~~~
我们通过绘制路径来勾勒出*Player*的自定义物理体,别吃惊,我只不过借助了某些工具,地址在[这里](http://stackoverflow.com/questions/19040144/spritekits-skphysicsbody-with-polygon-helper-tool),ps:可能需要翻墙。
## 设置仙人掌的物理体
同理我们只需要在产生仙人掌的实例方法中添加其物理体即可,请定位到`createObstacle()->SKSpriteNode`方法:
~~~
func createObstacle() -> SKSpriteNode {
let sprite = SKSpriteNode(imageNamed: "Cactus")
sprite.zPosition = Layer.Obstacle.rawValue
//========以下为新增内容=========
let offsetX = sprite.size.width * sprite.anchorPoint.x
let offsetY = sprite.size.height * sprite.anchorPoint.y
let path = CGPathCreateMutable()
CGPathMoveToPoint(path, nil, 3 - offsetX, 0 - offsetY)
CGPathAddLineToPoint(path, nil, 5 - offsetX, 309 - offsetY)
CGPathAddLineToPoint(path, nil, 16 - offsetX, 315 - offsetY)
CGPathAddLineToPoint(path, nil, 39 - offsetX, 315 - offsetY)
CGPathAddLineToPoint(path, nil, 51 - offsetX, 306 - offsetY)
CGPathAddLineToPoint(path, nil, 49 - offsetX, 1 - offsetY)
CGPathCloseSubpath(path)
sprite.physicsBody = SKPhysicsBody(polygonFromPath: path)
sprite.physicsBody?.categoryBitMask = PhysicsCategory.Obstacle
sprite.physicsBody?.collisionBitMask = 0
sprite.physicsBody?.contactTestBitMask = PhysicsCategory.Player
return sprite
}
~~~
注意到不管是哪种方式设置物理体,我们都需要设置其分类,碰撞掩码以及测试接触掩码,不过这里我们并不需要碰撞,所以全部设为0,即None。
最后请点击运行,你会发现场景中的*Player*、*仙人掌*以及地面表层都有一层轮廓。没错!这就是其各自的物理体。我们在*GameViewCOntroller*中通过设置了`skView.showsPhysics = true`来显示的。
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-11-19_564d258d7380a.png)
';
Lecture04. 仙人掌的狙击
最后更新于:2022-04-01 23:36:43
> 原文:http://www.jianshu.com/p/91de69a4fb2d
> 作者:[PMST](http://www.jianshu.com/users/596f2ba91ce9/latest_articles)
**本节任务:**
* 随机生成障碍物,且一对障碍物上下相距距离固定,但位置随机。
**几种情况:**
`y position = 0`的情况:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-11-19_564d258cc683f.png)
`y position = playableStart`的情况:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-11-19_564d258cdee09.png)
`y position = playableStart - 障碍物.size.height/2`的情况:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-11-19_564d258d077fb.png)
推导一般情况下的公式:`y position = playableStart - 障碍物.size.height/2 + (10%~60%)playgroundHeight`:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-11-19_564d258d25707.png)
上下两个障碍物之间距离固定为3.5倍的*Player*尺寸的高度:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-11-19_564d258d51460.png)
注意推导公式:`y position = playableStart - 障碍物.size.height/2`此时障碍物的顶部刚好与地面齐平,而`(10%~60%)playgroundHeight`是一个浮动范围,表明障碍物超出地面的高度。显然我们的障碍物的层级关系是在背景上面但是在*Foreground*的下面,因此修改早前的*Layer*:
~~~
enum Layer: CGFloat {
case Background
case Obstacle //添加障碍物层级关系
case Foreground
case Player
}
~~~
## 01.产生障碍物的构造方法
我们需要增添一个方法用于实例化一个纹理(图片)为仙人掌的精灵(*SpriteNode*),设置其*zPosition*为*Obstacle*,请在`flapPlayer()`方法上方新增如下方法:
~~~
func createObstacle()->SKSpriteNode{
let sprite = SKSpriteNode(imageNamed: "Cactus")
sprite.zPosition = Layer.Obstacle.rawValue
return sprite
}
~~~
注意到实例方法生成一个纹理为*Cactus*的精灵并返回,这是之后源源不断生成障碍物的基础。
紧接着我们要有一个实例方法,作用是随机产生成对的障碍物到场景中,步骤如下:
1. 使用`createObstacle()`得到下方障碍物的实例,并将其放置紧贴右侧屏幕边线。
2. 障碍物y轴上的放置位置范围为10%~60%,分别计算最小与最大的y轴点位,通过随机函数得到两者之间的一个数作为y值,设置障碍物的*position*,最后添加到*worldNode*节点中。
3. 同理实例化上方障碍物,将其旋转180°后放置距离下方障碍物3.5倍*Player*尺寸的地方,添加到*worldNode*节点中。
4. 给上下障碍物增添一个移动Action,已一定速度自右向左移动,倘若超出屏幕,则从父节点中移除。
~~~
//新增三个常量
let kBottomObstacleMinFraction: CGFloat = 0.1
let kBottomObstacleMaxFraction: CGFloat = 0.6
let kGapMultiplier: CGFloat = 3.5
// 在createObstacle()实例方法下方增添新方法
func spawnObstacle(){
//1
let bottomObstacle = createObstacle() //实例化一个精灵
let startX = size.width + bottomObstacle.size.width/2//x轴位置为屏幕最右侧
//2
let bottomObstacleMin = (playableStart - bottomObstacle.size.height/2) + playableHeight * kBottomObstacleMinFraction //计算障碍物超出地表的最小距离
let bottomObstacleMax = (playableStart - bottomObstacle.size.height/2) + playableHeight * kBottomObstacleMaxFraction //计算障碍物超出地表的最大距离
bottomObstacle.position = CGPointMake(startX, CGFloat.random(min: bottomObstacleMin, max: bottomObstacleMax)) // 随机生成10%~60%的一个距离赋值给position
worldNode.addChild(bottomObstacle) //添加到世界节点中
//3
let topObstacle = createObstacle() //实例化一个精灵
topObstacle.zRotation = CGFloat(180).degreesToRadians()//翻转180°
topObstacle.position = CGPoint(x: startX, y: bottomObstacle.position.y + bottomObstacle.size.height/2 + topObstacle.size.height/2 + player.size.height * kGapMultiplier)//设置y位置 相距3.5倍的Player尺寸距离
worldNode.addChild(topObstacle)//添加至世界节点中
//4 给障碍物添加动作
let moveX = size.width + topObstacle.size.width
let moveDuration = moveX / kGroundSpeed
let sequence = SKAction.sequence([
SKAction.moveByX(-moveX, y: 0, duration: NSTimeInterval(moveDuration)),
SKAction.removeFromParent()
])
topObstacle.runAction(sequence)
bottomObstacle.runAction(sequence)
}
~~~
倘若你迫不及待想看看成果,将`spawnObstacle`方法添加至`didMoveToView()`最下方,点击运行。一对障碍物“呼啸而过”,然后就没有然后了...确实目前这个方法仅仅只是产生一对罢了,为此我们还需要新增一个方法用于源源不断的产生障碍物。请添加如下内容到`spawnObstacle()`方法下方
~~~
func startSpawning(){
//1
let firstDelay = SKAction.waitForDuration(1.75)
//2
let spawn = SKAction.runBlock(spawnObstacle)
//3
let everyDelay = SKAction.waitForDuration(1.5)
//4
let spawnSequence = SKAction.sequence([
spawn,everyDelay
])
//5
let foreverSpawn = SKAction.repeatActionForever(spawnSequence)
//6
let overallSequence = SKAction.sequence([firstDelay,foreverSpawn])
runAction(overallSequence)
}
~~~
1. 第一个障碍物生成延迟1.75秒
2. 生成障碍物的动作,用到了先前的实例方法`spawnObstacle`.
3. 之后生成障碍物的间隔时间为1.5秒
4. 之后障碍物的生成顺序是:产生障碍物,延迟1.5秒;产生障碍物,延迟1.5秒;产生障碍物,延迟1.5秒...可以看出**[产生障碍物,延迟1.5秒]**为一组重复动作。
5. 使用`SKAction.repeatActionForever`重复4中的动作。
6. 将延迟1.75秒和重复动作整合成一个SKAction的数组,然后让场景来执行该动作组。
请将`didMoveToView()`方法中的`spawnObstacle`替换成`startSpawning()`,点击运行。
';
Lecture03. 实现foreground的持续移动
最后更新于:2022-04-01 23:36:40
> 原文:http://www.jianshu.com/p/f88dce673de9
> 作者:[PMST](http://www.jianshu.com/users/596f2ba91ce9/latest_articles)
## 本文任务
* 游戏运行中,Foreground地面持续滚动。
## 持续移动地面
**任务一需要解决的问题**:
1. 如何移动地面。
2. 如何无缝连接。
**问题一**的解决思路是每次渲染完毕进入`update()`方法中更新*Foreground*的坐标位置,即改变*position*的*x*值。
**问题二**的解决思路是实例化两个*Foreground*,相邻紧挨,以约定好的速度向左移动,当第一个节点位置超出屏幕范围(对玩家来说是不可见)时,改变其坐标位置,添加到第二个节点尾部,如此循环实现无缝连接,参考图为:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-11-19_564d258c65229.png)
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-11-19_564d258c83b11.png)
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-11-19_564d258c9e9dc.png)
**代码实现:**
找到*GameScene*类中的`setupForeground()`方法,由于现在需要实例化两个*Foreground*,显然早前的已不再使用,替换方法中的所有内容:
~~~
func setupForeground() {
for i in 0..<2{
let foreground = SKSpriteNode(imageNamed: "Ground")
foreground.anchorPoint = CGPoint(x: 0, y: 1)
// 改动1
foreground.position = CGPoint(x: CGFloat(i) * size.width, y: playableStart)
foreground.zPosition = Layer.Foreground.rawValue
// 改动2
foreground.name = "foreground"
worldNode.addChild(foreground)
}
}
~~~
注意我们采用`for-in`进行2次实例化,代码有两处改动:1.放置位置与*i*值相关;2.给节点取名为“foreground”,方便之后查找操作。
*Foreground*匀速移动,自然速度值需要固定,姑且这里设为150.0,请在`let kImpulse: CGFloat = 400.0`下方添加一行速度常量定义`let kGroundSpeed: CGFloat = 150.0`。
对于*Foreground*的位置更新自然也是在方法`update()`中进行了,每隔大约33毫秒就跳入该函数更新*position*。就像早前`updatePlayer()`一样,在其下方声明一个名为*updateForeground*方法。
~~~
func updateForeground(){
//1
worldNode.enumerateChildNodesWithName("foreground") { (node, stop) -> Void in
//2
if let foreground = node as? SKSpriteNode{
//3
let moveAmt = CGPointMake(-self.kGroundSpeed * CGFloat(self.dt), 0)
foreground.position += moveAmt
//4
if foreground.position.x < -foreground.size.width{
foreground.position += CGPoint(x: foreground.size.width * CGFloat(2), y: 0)
}
}
}
}
~~~
讲解:
1. 还记得先前设置了*Foreground*节点的名字为*foreground*吗?通过`enumerateChildNodesWithName`方法即可遍历所有名为*foreground*的节点。
2. 注意*node*是`SKNode`类型,而*foreground*精灵是`SKSpriteNode`类型,需要向下变形。
3. 计算dt时间中*foreground*移动的距离,更新*position*坐标位置。
4. 倘若该*foreground*超出了屏幕,则正如前面所说的将其添加到第二个精灵尾部。
> 4中的位置条件判断,希望读者理解透彻。首先*SpriteKit*中坐标系与之前不同,原点位于左下角,x轴正方向自左向右,y轴正方向自下向上;其次*wordNode*节点位于原点处,因此它内部的坐标系也是以左下角为原点。请集合上文图片进行理解。
ok,将`updateForeground`方法添加到`update()`中的最下面即可,点击运行。
';
Lecture02. Player的诞生
最后更新于:2022-04-01 23:36:38
> 原文:http://www.jianshu.com/p/82697ebf5cad
> 作者:[PMST](http://www.jianshu.com/users/596f2ba91ce9/latest_articles)
## 01.添加游戏音乐
音乐主要有:Player挥动翅膀上升的声音、撞击障碍物的声音、坠落至地面的声音、过关得分的声音等等。请打开项目看到Resource中的Sounds文件夹,包含了上述所有声音,格式为`.wav`。
SpritKit提供`playSoundFileNamed(soundFile: , waitForCompletion wait: )->SKAction`方法用于实现音乐的播放,注意播放音乐也是一个*Action*动作。请定位到*GameScene.swift*文件,找到`GameScene`类中的`var playableHeight:CGFloat = 0`,在其下方添加如下代码:
~~~
// MARK: 音乐Action
let dingAction = SKAction.playSoundFileNamed("ding.wav", waitForCompletion: false)
let flapAction = SKAction.playSoundFileNamed("flapping.wav", waitForCompletion: false)
let whackAction = SKAction.playSoundFileNamed("whack.wav", waitForCompletion: false)
let fallingAction = SKAction.playSoundFileNamed("falling.wav", waitForCompletion: false)
let hitGroundAction = SKAction.playSoundFileNamed("hitGround.wav", waitForCompletion: false)
let popAction = SKAction.playSoundFileNamed("pop.wav", waitForCompletion: false)
let coinAction = SKAction.playSoundFileNamed("coin.wav", waitForCompletion: false)
~~~
之后在需要音乐播放的时候调用这些已经定义的动作即可。
## 02.添加Player
通过课程一的代码练习,添加一个*Player*只需实例化一个`SKSpriteNode`实例,纹理为*Bird0*这张照片。由于这个精灵之后将在各个函数调用,因此设定了全局变量,请在`var playableHeight: CGFloat = 0`下添加如下代码实例化一个名为"Player"的精灵。如下:
~~~
let player = SKSpriteNode(imageNamed: "Bird0")
~~~
注意到此时我们并未添加该精灵到场景中的`worldNode`节点中,因此我们需要实现一个名为`setupPlayer()`的方法,代码如下
~~~
func setupPlayer(){
player.position = CGPointMake(size.width * 0.2, playableHeight * 0.4 + playableStart)
player.zPosition = Layer.Player.rawValue
worldNode.addChild(player)
}
~~~
函数中仅设置了*position*以及*zPosition*属性,而锚点*anchorPoint*并未设置,采用默认值(0.5,0.5)。找到`didMoveToView(view:)`中的`setupForeground()`这行代码,将上述方法添加至其下方。
点击运行程序,*Player*出现在场景之中。
## 03.update方法
不知道你有没有玩过翻书动画,先准备一个厚厚的小本子,然后在每一页上描画,最后通过快速翻阅组成最简短的动画。如下:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-11-19_564d258c164d0.png)
L02-Animation
前文谈及右下角的*30fps*客官可曾记得?*fps*是*Frame Per Second*的缩写,即每秒的帧数,而一帧为一个画面。因此*30fps*意味着在一秒钟时间内,**App**要渲染30次左右,平均每隔0.033333秒就要重新绘制一次画面。而渲染(绘制)完毕立刻跳入`update(currentTime:)`方法中,大约间隔33.33毫秒左右,执行方法内的代码。不妨你在该函数中设个断点感受一下。
注意到左下角的帧数并不是始终保持在*30fps*,而是不断在上下浮动变化。相邻两帧画面之间的时间并不固定,可能是0.033秒,也可能是0.030秒。不妨测试打印下两帧之间的时间差值,请在`player`下添加两个全局变量:`lastUpdateTime`以及`dt`:
~~~
var lastUpdateTime :NSTimeInterval = 0 //记录上次更新时间
var dt:NSTimeInterval = 0 //两次时间差值
~~~
接着在`Update(currenTime:)`方法中添加如下方法:
~~~
override func update(currentTime: CFTimeInterval) {
if lastUpdateTime > 0{
dt = currentTime - lastUpdateTime
}else{
dt = 0
}
lastUpdateTime = currentTime
print("时间差值为:\(dt*1000) 毫秒")
}
~~~
可以看到打印结果(注意红色框框处):
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-11-19_564d258c3050a.png)
当应用刚启动时,帧数并不稳定,导致时间间隔略大,不过之后基本稳定在33毫秒左右。
## 04.Player的下落公式
这里可能要涉及一些高中的物理知识。地球上的重力加速度为9.8g。物体在半空中静止到下落,每隔dt时间。
* 速度`V = V1 + a * dt`,即**当前速度=初速度 + 加速度 * 时间间隔**。
* dt时间内,下落距离`d =V * dt`,这里采用**平均速度 * 时间差**得到下落距离。
游戏中设定且只有Y轴方向上的重力加速度`kGravity = -1500`,这个值是可调节的,我觉得恰到好处;此外每次玩家点击屏幕,对*Player*要有一个向上的拉力,不妨设为`kImpulse = 400`;最后声明一个变量`playerVelocity`追踪当前*Player*的速度。请添加上述三个全局变量的声明,现在*GameScene*类中的全局变量有以下这些:
~~~
// MARK: - 常量
let kGravity:CGFloat = -1500.0 //重力
let kImpulse:CGFloat = 400 //上升力
let worldNode = SKNode()
var playableStart:CGFloat = 0
var playableHeight:CGFloat = 0
let player = SKSpriteNode(imageNamed: "Bird0")
var lastUpdateTime :NSTimeInterval = 0
var dt:NSTimeInterval = 0
var playerVelocity = CGPoint.zero //速度 注意变量类型为一个点
//...其他内容
~~~
请在*GameScene*类中添加一个方法,将先前公式用swift实现更新*player*的*position*。
~~~
func updatePlayer(){
// 只有Y轴上的重力加速度为-1500
let gravity = CGPoint(x: 0, y: kGravity)
let gravityStep = gravity * CGFloat(dt) //计算dt时间下速度的增量
playerVelocity += gravityStep //计算当前速度
// 位置计算
let velocityStep = playerVelocity * CGFloat(dt) //计算dt时间中下落或上升距离
player.position += velocityStep //计算player的位置
// 倘若Player的Y坐标位置在地面上了就不能再下落了 直接设置其位置的y值为地面的表层坐标
if player.position.y - player.size.height/2 < playableStart {
player.position = CGPoint(x: player.position.x, y: playableStart + player.size.height/2)
}
}
~~~
将该方法添加至`update(currentTime)`方法中的最下面。意味着每隔33.3毫秒左右就要更新一次*Player*的位置。
点击运行,*Player*自由落地至地面,不错吧!
## 05.让Player动起来
游戏中我们点击一次屏幕,*Player*会获得一个向上的牵引力,挥动翅膀向上飞一段距离,倘若之后没有持续的力,则开始自由落体。怎么实现呢?实现机制不难,只需每次玩家点击屏幕,使得*Player*获得向上的速度,具体为先前设定的400即可。
因此,添加一个方法到*GameScene*类中,用于每次用户点击屏幕时调用,作用是让*Player*获得向上的速度!
~~~
func flapPlayer(){
// 发出一次煽动翅膀的声音
runAction(flapAction)
// 重新设定player的速度!!
playerVelocity = CGPointMake(0, kImpulse)
}
~~~
正如前面谈到的,方法中主要做两件事:1.发出一次挥动翅膀的声音。2.重新设定player的速度。
而用户每次点击都会调用`touchesBegan(touches: Set, withEvent event: UIEvent?)`方法。不用我多说了吧,把`flapPlayer()`方法添加进去吧。
运行工程,*player*坠落,点击几下,哇靠,飞起来了!
';
Lecture01. 初窥游戏场景
最后更新于:2022-04-01 23:36:36
> 原文:http://www.jianshu.com/p/85a2e9f29a24
> 作者:[PMST](http://www.jianshu.com/users/596f2ba91ce9/latest_articles)
## 01.项目文件介绍
首先请打开项目,先介绍项目已有文件,你将看到如下目录:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-11-19_564d258b6deaa.png)
L01-Dir
主要讲解以下一些重要的文件:
* Resource文件夹:资源文件放置处
* Art:以atlas图册方式管理素材文件。
* SKTUtiles:采用Extension对一些类进行拓展,添加一些有用的方法或属性。
* Sounds:游戏声音素材
* GameScene.swift:Flappy游戏比较简单,因此一个游戏场景足以,有关于场景内容设置、交互等均在该场景中设置。
* GameViewController.swift:视图控制器,包含一个视图*view*,当然这个视图比较特殊:为*SKView*,用于呈现场景*Scene*。
## 02.呈现视图
选中*GameViewController.swift*文件,先前提及视图控制器中的*SKView*,其职责在于呈现游戏场景*Scene*。不过现在空文件中神马都没有,我们将重写`viewWillLayoutSubviews()`方法呈现场景。定位到*GameViewController*类,添加以下代码:
~~~
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
// 1.对view进行父类向子类的变形,结果是一个可选类型 因此需要解包
if let skView = self.view as? SKView{
// 倘若skView中没有场景Scene,需要重新创建创建一个
if skView.scene == nil{
/*== 创建场景代码 ==*/
// 2.获得高宽比例
let aspectRatio = skView.bounds.size.height / skView.bounds.size.width
// 3.new一个场景实例 这里注意场景的width始终为320 至于高是通过width * aspectRatio获得
let scene = GameScene(size:CGSizeMake(320, 320 * aspectRatio))
// 4.设置一些调试参数
skView.showsFPS = true // 显示帧数
skView.showsNodeCount = true // 显示当前场景下节点个数
skView.showsPhysics = true // 显示物理体
skView.ignoresSiblingOrder = true // 忽略节点添加顺序
// 5.设置场景呈现模式
scene.scaleMode = .AspectFill
// 6.呈现场景
skView.presentScene(scene)
}
}
}
~~~
这里需要注意2、3处,固定了游戏场景的宽度*Width = 320*,高度则通过*Width*乘以高宽比相乘得到,对于*iPhone4s iPhone5/5s*这些宽为320的设备来说自然没什么影响,但是对于*iPhone6/6Pluse*设备,相当于将设备宽高同时缩小相同倍数,直至宽为320时停止;再通过设置*scaleMode*为`AspectFill`(更多*ScaleMode*,请点击[这里](http://blog.csdn.net/colouful987/article/details/44855213)了解)呈现视图。
对于4来说,我们需要了解游戏运行时每秒的帧数、当前场景中节点个数、显示节点的物理体等,因此通过设置这些参数能帮助我们更好的调试。
OK,点击运行项目,模拟器运行结果一片漆黑,不过右下角显示*node=1 60.0fps*,表明当前场景中显示了一个节点,帧数为60左右。
> Question:什么都还没添加,视图中怎么会有一个节点Node了呢?
>
> Answer:场景Scene类为SKScene,继承自SKNode,因此当skView呈现场景时,自然就将一个节点置于其中了。
## 03场景内容的填充
定位到*GameScene.swift*文件,可以看到文件中已经声明了一个*GameScene*类,当然类中我们还未实现任何东西,因此这是运行项目呈现出来的场景是漆黑一片。是时候一步步配置游戏场景了!
首先,定位到*GameScene*类中,在类中顶部添加如下三个变量,如下:
~~~
class GameScene:SKScene:{
let worldNode = SKNode()
var playableStart:CGFloat = 0
var playableHeight:CGFloat = 0
//...文件其他内容
}
~~~
如上实例化了一个节点命名为`worldNode`,原因在于之后游戏中所有的节点都将添加至这个节点中,方便管理。此外游戏中场景分为*Background*和*Ground*两部分,前者是背景,鸟可以在该区域中上下飞行;后者地面,小鸟仅限于跌落至上面。具体划分请看下图:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-11-19_564d258b981b0.png)
L01-Scene
其中,背景和地面均作为节点添加至`worldNode`节点中。请在`didMoveToView(view:)`方法中添加如下代码:
~~~
override func didMoveToView(view: SKView) {
addChild(worldNode)
setupBackground()
setupForeground()
}
~~~
首先添加`worldNode`节点到场景中,接着`setupBackground()`和`setupForeground()`两个方法分别设置背景和地面两个节点,当然此时方法还未实现。
通常游戏包含多个节点,为了细化节点的图层关系,节点`Node`中设定了一个`zPosition`属性用于标识节点相距你的程度,越小越里面,越大越外面。显然游戏中,背景至于最底部,其次是地面,最后才是`Player`那只鸟。为此我们将使用枚举来说明层级关系,在`GameScene`类上方添加`Layer`的声明:
~~~
enum Layer: CGFloat {
case Background
case Foreground
case Player
}
class GameScene:SKScene{}
~~~
干完这些,是时候补充剩下的两个方法的实现了。首先添加`setupBackground()`方法至*GameScene*类中:
~~~
func setupBackground(){
// 1
let background = SKSpriteNode(imageNamed: "Background")
background.anchorPoint = CGPointMake(0.5, 1)
background.position = CGPointMake(size.width/2.0, size.height)
background.zPosition = Layer.Background.rawValue
worldNode.addChild(background)
// 2
playableStart = size.height - background.size.height
playableHeight = background.size.height
}
~~~
依葫芦画瓢实现`setupForeground()`方法:
~~~
func setupForeground() {
let foreground = SKSpriteNode(imageNamed: "Ground")
foreground.anchorPoint = CGPoint(x: 0, y: 1)
foreground.position = CGPoint(x: 0, y: playableStart)
foreground.zPosition = Layer.Foreground.rawValue
worldNode.addChild(foreground)
}
~~~
点击运行,你将看到如下画面,*Good Job!* 你已经完成了第一步,之后我们将添加*Player*以及障碍物到场景中。
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-11-19_564d258be798f.png)
';
前言
最后更新于:2022-04-01 23:36:34
本教程参考自RayWenderlich的视频教程[How To Make a Game Like Flappy Bird Series (Swift)](http://www.raywenderlich.com/video-tutorials#swiftflappy)。本教程中,你将从无到有亲自开发一个基于*SpriteKit*框架的*Flappy bird*小游戏。总体难度不大,但要求你掌握*Swift*基础语法与*SpriteKit*框架知识。此外,教程中所有素材均来自*Raywenderlich*,鼓励学习交流,但请勿用于商业用途。
**友情帮助**: 为了方便大家快速上手项目,我在*github*中上传了起始项目文件供大家下载,请点击[这里](https://github.com/colourful987/JustMakeGame-FlappyBird)下载。
';