完善这个游戏
你已经有了一个基本能玩的游戏app了。游戏的规则都执行的不错,也没有什么逻辑上的重大缺陷。我能告诉你的就是,眼下没有BUG。但是这里仍旧有许多我们要改进的地方。
显然,目前的游戏界面看起来既不3D也不华丽,我们后面会给它整容一下。但是眼下,我们有其他一些地方需要微调一下。
我们就从如何表现玩家的得分情况开始吧。
如果玩家将滑条正好放到了目标值的位置,让提醒窗口显示“Perfect”,如果非常接近目标值,就显示“You almost had it”,如果偏离的比较远则显示“Not even close”,这可以给玩家得分一个比较良好的反馈。
练习:想想实现方法。这些判断逻辑应该放在什么地方,并且你应该如何编程实现它?线索:我们在刚才好像用到了很多这样的词,“如果”。
放置这些判断逻辑的正确位置是showAlert(),因为你在这里创建了UIAlertController的对象,用于给玩家显示一个提示窗口。你已经对message的文本做了一些处理,现在你要用类似的方法来处理title的文本。
这里是改进后的方法的代码:
@IBAction func showAlert() {
let difference = abs(targetValue - currentValue)
let points = 100 - difference
score += points
//添加下面这一段
let title: String
if difference == 0 {
title = "Perfect"
} else if difference < 5 {
title = "You almost had it!"
} else if difference < 10 {
title = "Pretty good!"
} else {
title = "Not even close..."
}
let message = "Your scored \(points) points"
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) //这里改动一下
let action = UIAlertAction(title: "OK", style: .default, handler: nil)
alert.addAction(action)
present(alert,animated: true,completion: nil)
startNewRound()
updateLabels()
}
你创建了一个名为title的String型局部变量,用来存放在提醒窗口顶部显示的文本。最初,变量title没有任何值。
你使用滑条和目标值的差值difference来判断应该显示哪一条文本:
如果difference等于0,说明玩家非常牛X,于是你将“perfect”放入title。
如果difference小于5,你将“You almost had it”放入title。
如果difference小于10则显示“Pretty good”
如果difference大于等于10,则认为玩家表现不佳,显示“Not even close”
你能理解这段代码的逻辑了吗?它仅仅是一堆if语句用于判断difference变量的值,并且选一条对应的文本显示。
你用title变量的文本替换了你在创建这个UIAlertController对象时,使用的一条固定文本(Hello World)。
运行app,然后玩几局。你将看到title文本根据你的得分情况在不断变化。这就是if语句的作用。
练习:当玩家得到一个perfect时,给玩家额外的加100分作为奖励。并且在非常接近100分的时候,比如98,97分时也给予一定的奖励(说不定在奖励之下,有人会给你充值哦_)。
现在对玩家挑战高难度低分时,我们有相应的激励机制了,一个perfect不仅仅是100分而是200分。并且在非常接近100时,我们也给一个50分的奖励。
这里我如何实现这一目的的代码(注意看注释部分):
@IBAction func showAlert() {
let difference = abs(targetValue - currentValue)
var points = 100 - difference //将let改为var,points从常量改变为变量
let title: String
if difference == 0 {
title = "Perfect"
points += 100 //添加这一行
} else if difference < 5 {
title = "You almost had it!"
if difference == 1 {
points += 50
} //添加这个if语句
} else if difference < 10 {
title = "Pretty good!"
} else {
title = "Not even close..."
}
score += points //这一行原来在上面,把它移到下面
...
}
你应该注意以下几个事情:
在第一个if后面的花括号内,你看到了一行新的语句。当difference等于0时,你不仅使title显示为“Perfect”,而且额外的给points加了100分。
第二个if也改了。它的内部出现了一个新的if语句。这样做并没有问题。你想要单独处理difference等于1的情况,当等于1时,额外的加50分上去,这就是这个新的if的作用。
毕竟,当difference大于0小于5时,它当然可以为1,但并不总是1。因此你用了一个额外的if语句来检查defference是否为1,如果是,则加50分。
因为这些新的语句添加了新的分数,所以points不能再是常量了,它现在必须是一个变量。这就是为什么我们把points前面的关键字由let更改为var。
最后,score += points这一行必须移动到所有if语句的后面。这是必须的,因为app也许会在这些if语句的内部修改points的值,并且这些额外的得分也需要加到总分score中去。
如果你自己写的版本和我的略有不同,也没什么关系,只要它能够提供同样的功能并且正常工作。在写程序的过程中,处理一个问题经常会有多种方法,只要它们的执行结果一致就没问题。
运行app,并且看看刚才的改动都生效没有。
回顾一下局部变量(Local variables)
我已经多次指出局部变量与实例变量的区别。作为你此刻应该知道的内容是,一个局部变量仅仅在它所属的方法被调期间才存在,而实力变量则在它所属的对象的视图控制器(view controler)存在期间一直存在。局部常量和实例常量也是如此。
在showAlert()内部,有六个局部的量(常量和变量)和三个实例的量(常量和变量):
let difference = abs(targetValue - currentValue)
var points = 100 - difference
let title = . . .
score += points
let message = . . .
let alert = . . .
let action = . . .
练习:指出哪些是局部的,哪些是实例的,哪些是变量,哪些又是常量?
答案:局部的非常好辨认,因为它们的名字前都有let或者var,说明它们是在方法内部被定义的。(不要误会我的意思,并不是说有let和var就是局部变量,let和var是定义常量和变量的关键字,有let或者var开头,说明它们在方法内部刚刚被定义,所以是局部的,我们一开头也说过,变量或常量的作用范围纯粹看它们被定义在哪里)。
let difference = . . .
var points = . . .
let title = . . .
let message = . . .
let alert = . . .
let action = . . .
这些符号(let和var)用于创建新的变量(var)或者常量(let)。因为它们在方法的内部被创建,所以它们是局部的。
这六个项目——difference, points, title, message, alert, 以及action被限制在showAlert()内部,并且在它之外并不存在。一旦showAlert()方法执行完毕,它们就被释放了。
例如:每次玩家点击Hit Me按钮后,difference都会得到一个不同的值,即使它是常量。我们前面不是说过常量的值是不可以改变的吗?
原因是这样的:每次showAlert()方法被调用的时候,这些局部的常量和变量都会被重新创建。旧的哪些早都被扔掉不要了。
具体就是当showAlert()被调用时,它会创建一个全新的difference变量,之前一次的difference已经不存在了,被释放了。而这次新创建的这个difference在showAlert()运行结束后,也被扔掉了,下一次showAlert()运行时又创建了一个全新的。所以difference是个常量,但是它的值每次都不同,因为每次你看到的都是一个全新的difference(细思极恐系列_)。
但是在showAlert()一次运行期间,difference的值,是不能发生变化的。唯一可以改变的就是points,因为它是变量(var)。
再来看实例变量,它们被定义在任何一个方法的外面。通常都把它们放在一个文件的开头,像下面这样:
class ViewController: UIViewController {
var currentValue = 0
var targetValue = 0
var score = 0
var round = 0
你可以在任何方法内部调用这些变量或者常量,不需要重新定义一次,并且它们会长期存在。
如果你像这样做:
@IBAction func showAlert() {
let difference = abs(targetValue - currentValue)
var points = 100 - difference
var score = score + points // doesn’t work!
... }
这样不会得到我们想要的结果。因为你在score前面放了一个var,这样它就是属于showAlert() 内部的一个变量了,它不会影响外面那个score的值,并且showAlert() 运行结束后,它就消失了,这样玩家的score永远不会被显示。
很明显这不是你想要的结果,幸运的是,刚才那段代码甚至不会被编译,因为Xcode知道这样做是可疑的。
⚠️为了让这两种类型的变量有所区别,以便于一看就知道它们能活多久,有些程序员会在实例变量的前面加一个下划线。
它们会将score命名为_score。这样可以减少一些麻烦,因为在变量名字前加一个下划线就不会和局部变量弄混了。这只是个人的一些习惯,Swift才不在乎你怎么给变量取名。
还有一些程序员喜欢在前面加个m(代表member)或者加个f(代表field),有些甚至在变量名称后面加个下划线。这些方法是愚蠢的,不要去学。如果你不能心知肚明的清楚每个变量的作用范围,这些前缀或者后缀只能把你带向更深的深渊。
等待提醒窗口离开
在这个游戏中,仍然有些事情困扰着我。也许你已经注意到了。。。
当你一点击Hit Me按钮,提醒窗口就弹出来了,并且与此同时滑条立即恢复到了中间位置,回合数值立即显示为加1,并且目标值也立即被一个新的随机数替换掉了。
就是说你还木有机会观察上一回合结果的时候,新一回合的数据立马被更新到屏幕上了,这让人觉得有点怪怪的。
你也许想知道为什么会这样,毕竟在showAlert()中你是在显示提醒窗口之后才调用的startNewRound()。
@IBAction func showAlert() {
. . .
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
let action = UIAlertAction(title: "OK", style: .default, handler: nil)
alert.addAction(action)
present(alert,animated: true,completion: nil)
startNewRound()
updateLabels()
}
和你期望的相反,present(alert. . .)并没有暂停其他方法的执行,并且等到提醒窗口消失后才执行它们。其他一些平台上的alert是被设计为如此工作的,但是在iOS上不是。
取而代之的是present(alert. . .)将提醒窗口放到屏幕上的同时立即返回结果,然后showAlert()中的剩余方法立即被执行,新的一回合甚至在提醒窗口的弹出动画尚未结束时,就被更新到屏幕上了。
用程序术语讲就是,alert(提醒窗口)是异步工作的。更多关于异步和同步的内容我们在下一个课程中讲,现在对你而言这件事,就是意味着,在alert执行结束前你不知道其中的进展情况。你只能赌showAlert()运行结束后一切正常。
所以如果你无法在弹出窗口消失前在showAlert()内部等待,那么你如何等待它的关闭呢?
答案是简单的:事件!和你之前看到的一样,大多数iOS程序都涉及等待特殊的事件发生——按钮被点击,滑条被拖动等等。这里没有什么不同,你只需要以某种事件等待alert的结束。在这段时间内,你什么都不做。
这是它工作的原理:
对于alert来说,每一次点击Hit Me按钮,你都必须提供一个UIAlertController的对象。这个对象告诉alert,按钮(这个按钮是指提弹出的提醒窗口上的那个按钮)上的文本是“OK”,以及这个按钮是什么样式的(这里我们使用的是默认样式):
let action = UIAlertAction(title: "OK", style: .default, handler: nil)
这里的第三个参数,handle,告诉alert当OK按钮被点击后应该发生什么。这就是你寻找的等待alert消失的事件(点击OK按钮后,alert就被解除了,并且会触发一个事件)。
目前handle是nil,就是说什么都不做。为了做出我们需要的更改,你需要给UIAlertAction一些代码执行,当OK按钮被点击后。当玩家最终点击OK按钮后,alert将自己从屏幕上移除并且会跳转到你的代码上。你可以在这个地方打打注意。
这种模式也被称作‘回调’,在iOS上这种模式会在多种用途中出现。之前你经常被要求创建一个新的方法用于处理事件,但是现在,你需要一点新的东西——闭包(closure)。
将showAlert()的底部稍微改动一下:
@IBAction func showAlert() {
. . .
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
let action = UIAlertAction(title: "OK", style: .default, handler: {
action in
self.startNewRound()
self.updateLabels()
})
alert.addAction(action)
present(alert,animated: true,completion: nil)
}
这里改变了两个地方:
1、你移除了方法中底部的startNewRound()和updateLabels(),千万别漏掉这一点。
2、你将这两个方法塞到UIAlertAction的参数handle的代码块中去了。
这样的代码块就叫做闭包(closure)。你可以把它想象做一个没有名称的方法。这些代码不会被立即执行,之后点击OK按钮后才会执行。这个闭包在alert被解除后才告诉app开始新的一回合以及更新标签的内容。
运行app并且观察一下效果。我想你的游戏效果应该比刚才好些了。
⚠️:self
你也许想知道为什么在handle的代码块里你用self.startNewRound()取代了startNewRound()。
self关键字允许view controller指向自己。这个概念对你而言不应该太陌生。当你说:“我需要一个冰淇淋”时,你用‘我’这个词指代了自己。类似的,程序中的对象也可以用一个代称来讨论它们自己。
通常你不需要这个self向view controller传递消息。这里是个例外:在闭包中,你必须使用关键字self来指向view controller。
这是Swift的语法规则。如果你在闭包中忘记了self,Xcode会创建app失败(亲自试试)。之所以存在这个规则是因为闭包可以‘捕获’变量,这会带来一些意外的结果,基本上不是好的结果。你会在另外的课程中学习这些内容,在本课程中,我们就讨论到这里。
重新开始
我不是指删掉你之前的所用东西,如果你已经这么做了,那么恭喜你多了一次复习的机会。我说的是这个游戏app中的“Start Over”按钮。这个按钮用于将你的得分和回合数重制为默认值。
Start Over按钮的作用之一是用来和其他玩家一较高下。比如第一个玩家玩十个回合,然后重置分数让第二个玩家玩十回合,看看谁的得分更高。
练习:试着自己完成Start Over按钮的功能。你已经见识过了按下一个按钮后,view controller是如何进行响应的,并且你应该可以实现如何改变score和round变量的值。
你会如何做呢?如果你卡住了的话,跟着我的讲解往下做。
首先,在ViewController.swift中添加一个新的方法,用于开始一次新的游戏。我建议你将这个方法放在startNewRound()的附近,因为这两个方法的概念差不多。
添加新的方法:
func startNewGame() {
score = 0
round = 0
startNewRound()
}
这个方法重置了score和round的值,并且同时开始新的一个回合。
注意一下,这里你设置round的值为0而不是1。是因为在startNewRound()中已经设置了对round加1。
如果你将round设置为1,那么startNewRound()中再被加1,那么第一回合你看到的回合数就是2了。
所以这里设置为0,让startNewRound()在第一局开始前对它进行加1操作。
(这些代码比我的说明更能解释,为什么我们不用平常的语言去编程,而要用专门的编程语言)
你同时需要一个action方法处理点击Start Over按钮后触发的动作:
将下面的action方法添加到ViewController.swift中:
@IBAction func startOver() {
startNewGame()
updateLabels()
}
这个方法放在代码中的哪个位置并不重要,但是放在其他action方法等下面是一个不错的选择。
当Start Over按钮被点击后,startOver()这个action方法首先调用startNewGame()用于开始新的一次游戏。(看到了吗,如果你选用合适的方法名称,那么你的代码目的也就一目了然了)
因为startNewGame()改变了score与round这两个实例变量的值,所以你需要调用updateLabels()来更新这些标签的文本。
作为最终的完善,你应该将viewDidLoad()中的startNewRound()替换为startNewGame()。因为当app刚开始运行时分数和回合数都应该是0,这一改动不会对app的运行效果有任何影响,只是使你的代码更加合理了。
override func viewDidLoad() {
super.viewDidLoad()
startNewGame() //改动这一行
updateLabels()
}
最后,将Start Over按钮和action方法连接起来。
打开storyboard,按住ctrl并且拖动start over按钮到view controller。放开鼠标后在弹出的窗口上选择startOver。这样按钮的Touch Inside event就和你刚才定义的动作连接起来了。
运行app,并且玩几局。然后点击start over按钮看看有没有生效。
小帖士:如果你丢失了某个按钮或者标签的连接,不知道它们是连接到哪个方法,你可以右击storyboard中黄色图标的那个view controller,就可以看到你操作过的所有连接。
你可以在05-polish中找到本节课的相关代码。