印度是个文明古国,其古代数学的发展水平令人叹为观止,现在我们所用的阿拉伯数字事实上起源于印度。除了伟大的数学成就,印度也为学习程序设计的芸芸众生留下了一个经典的递归和计算复杂性的美妙案例,那就是大名鼎鼎的汉诺塔问题。在本实践中,作者将以pygame游戏的方式解读该问题背后的算法细节。通过本实践,读者可以了解汉诺塔问题及其递归算法、掌握面向对象程序设计方法、了解生成器结构及其用途、了解用于Python游戏开发的pygame包。
本实践的成果如图1所示。
1. 汉诺塔问题
法国数学家爱德华·卢卡斯曾转述过一个印度的古老传说:在世界中心贝拿勒斯(位于印度北部)的圣庙里,一块黄铜板上插着3根金刚石柱。印度教的主神梵天在创造世界的时候,在其中一根石柱上按照从下到上的顺序,穿好了由大到小的64块金盘,这就是所谓的汉诺塔(Hanoi Tower)。
按照梵天的命令,不论白天黑夜,总有一个婆罗门僧侣在按照下述规则移动这些金盘:一次只移动一个盘,不管在哪根柱上,小盘必须位于大盘之上。僧侣们预言,当所有的金盘都从梵天穿好的那根柱上移到另外一根柱上时,世界就将在一声霹雳中被消灭,而梵塔、庙宇和众生也都将同归于尽。
1.1 求解
如图2所示的5个盘的汉诺塔问题,其总任务是将A柱上的n = 5个盘移至C柱。要实现这个总任务并且保证盘在移动过程中小盘始终位于大盘之上,整个过程可分3步实现。第1步:我们必须先将n - 1 = 4个蓝盘从A柱移至B柱。在第1步的执行过程当中,为了保证规则的贯彻,显然必须借助C柱作为中转柱才能完成。因此,第1步所做的工作可描述为:借助中转柱C, 将n-1=4个盘从A移至B。
现在,最大的白盘在A柱上,C柱是空的。如图3所示。这时可实施第2步:将A柱上的大盘取下,移至C柱,其结果如图4所示。
接下来,我们要做的是第3步:借助中转柱A,将B柱上的n - 1 = 4个盘移至C柱。此时,C柱上虽然已经有了一个盘,但由于此盘是最大的,所以只要移动过程中不搬动C柱上的这个盘即可,最终执行结果如图5所示。
从上面所述的工作步骤中,我们可以总结出这个汉诺塔问题的总任务及其3个子任务, 具体如下:
● 总任务:将 n=5个盘从A柱移至C柱,以B柱为中转柱。
► 子任务 1:将 n - 1 = 4个盘从A柱移至B柱,以C柱为中转柱。
► 子任务 2:将A柱上的大盘移至C柱。
► 子任务 3:将n - 1 = 4个盘从B柱移至C柱,以A柱为中转柱。
不难看出,除了柱子不同外,子任务3与子任务1的工作是一样的,都是把 n - 1 个盘从一根柱子移至另一根柱子。同时,除了需要移动的盘子数量不同外,子任务1和子任务3与总任务也极其相似。
事实上,将n - 1个盘从A移至B的汉诺塔问题与原问题 ——将n个盘从A移至C的汉诺塔问题的性质完全相同,区别仅在于问题的规模 ,即需要移动的盘的数量稍少。我们称为前者是原问题的子问题。
如果我们能将n - 1 = 4个盘从A移至B,从B移至C,那么n = 5个盘的汉诺塔问题可解。接下来解决 4个盘的汉诺塔问题。 具体方法如下:
● 子问题: 将 n′ = 4个盘从A柱移至B柱,以C柱为中转柱。
► 子子问题1:将 n′ - 1 = 3个盘从A柱移至C柱,以B柱为中转柱。
► 简单任务:将A柱上的大盘移至B柱。
► 子子问题2:将n′ - 1 = 3个盘从C柱移至B柱,以A柱为中转柱。
图6展示了这一过程。
通过上述分析可以看出,5个盘的汉诺塔问题可以通过求解4个盘的汉诺塔问题来解决,4个盘的汉诺塔问题可以通过求解3个盘的汉诺塔问题来解决……依此类推,直至求解到一个盘的汉诺塔问题。而1个盘的汉诺塔问题,由于问题的规模足够小,可直接将盘从原柱搬至目标柱即可。所以在前表中,我们称其为“简单任务”。
1.2 递归算法
根据上一节描述的算法思想,我们可以写出求解汉诺塔问题的递归程序:
1 | def hanoi(n, a, b, c): |
上述代码的执行结果如下:
1 | Steps count: 31 |
函数hanoi(n,a,b,c)用于生成以b为中转柱,将n个盘从a移至c的移盘序列。可以看到,这个递归函数的执行过程与上一节的总任务和子任务分解完全一致。当n == 1时,只有一个盘,这是个简单任务,直接移盘即可。如果n > 1,则需要将其分解为两个 n - 1 的汉诺塔子问题以及一个简单任务。子问题的求解用函数递归调用来解决。
上述程序的运行结果表明,5个盘的汉诺塔问题共需要移盘31次。movements列表中按顺序存储了全部的移盘动作。
1.3 计算复杂性
在上一节程序的基础上,作者还尝试计算了n = 5,6,7 … ,12的汉诺塔问题的移盘过程,并得到表1所示的移盘次数。
盘子数 | 所需移盘次数 |
---|---|
5 | 31(25-1) |
6 | 63(26-1) |
7 | 127(27-1) |
8 | 255(28-1) |
… | … |
12 | 4 095(212-1) |
从表1所示的结果中,似乎可以总结出这个规律——n个盘的汉诺塔问题的移盘次数为2n-1。事实上,对移盘次数的数学分析可以证明这个结论。n盘的汉诺塔求解可以拆分成两个n-1的汉诺塔子问题求解和1个简单移盘。如果用T(n)来表示n盘汉诺塔的移盘次数,则函数T(n)可使用下述递归定义。
我们试着把递归函数消解成非递归函数,具体过程如下所示:
故n个盘的汉诺塔共需移盘2n-1次。那么,如果按照梵天的规定移动64个金盘,则总移动次数为264-1 = 18 446 744 073 709 551 615。如果婆罗门僧侣是个熟练工,可以1秒挪一个盘,那么他1小时可以移3 600个盘,1年可移3 600 ×24 × 365 = 31 536 000个盘(忽略闰年误差)。那么,解决64个盘的汉诺塔问题共需要(264 -1)/31 536 000年,即大约5 849亿年。这也许远远超过了地球的预期寿命。
2. pygame游戏框架
pygame是一个跨平台的Python游戏开发包。用户在Windows命令行或者Linux操作系统的终端中执行命令“pip install pygame”,可完成pygame模块的安装。
1 | pip install pygame -i https://pypi.tuna.tsinghua.edu.cn/simple |
下述代码向我们展示了一个简单的PyGame游戏框架。
1 | #BasicPyGame.py |
当BasicPyGame.py被解释器作为主程序文件执行时,__name__ 的值为”__main__“。
上述程序创建了一个App类型对象a,然后执行了其mainLoop()函数。
2.1 pygame初始化
在App的构造函数里,首先使用pygame.init()初始化了pygame模块,然后使用display.setmode()函数创建了一个名为screen的960(长)×540(高)像素的游戏窗口 。接下来用set_caption()设置窗口的标题为“Tower of Hanoi”。然后,用函数image.load()从resource子目录装载了一张png图片作为窗口的背景图。最后,还用music.load()装载了一个mp3文件,并用music.play()实现将其作为背景音乐播放,在music.play()函数中,loops表示循环次数,start = 0.0表示从音乐的起始处开始播放。
上述代码中的os.sep表明与操作系统相关的目录分隔符。在Windows中,os.sep为\;而在Linux中,则是/。这种兼容设计允许读者将本书的随书代码复制到Linux或者Macintosh系统上运行。
2.2 消息循环
上述代码的设计者似乎有意让mainLoop()函数陷入了一个死循环。 它周而复始地试图获取“事件”——pygame.event.get()。而这些所谓的事件,大部分是游戏操作者的指令,即 操作者移动鼠标、按下鼠标键、按下的鼠标键弹起、操作者按下某个键盘键、按下的键盘键弹 起等,从深层次的层面上讲,键盘和鼠标都是由操作系统管理的,这里所述的事件都是由pygame从操作系统为应用程序准备的消息队列里获取的。
pygame获取事件后,可以通过event.type了解事件的类型,并根据事件类型及相关参数做出不同反应。表2列出了我们在本实践中要捕获并处理的主要事件类型。
事件类型 | 说明 | 备注 |
---|---|---|
pygame.locals.QUIT | 要求退出游戏 | 通常是因为操作者点击了游戏窗口上的×试图关闭游戏而引发的 |
pygame.MOUSEBUTTONDOWN | 鼠标键被按下 | 包括鼠标在游戏窗口内的坐标以及鼠标的按键编号等相关信息 |
pygame.MOUSEBUTTONUP | 鼠标键弹起 | 包括鼠标在游戏窗口内的坐标以及鼠标的按键编号等相关信息 |
pygame.MOUSEMOTION | 鼠标移动 | 包括鼠标在游戏窗口内的坐标等相关信息 |
在上述代码中,当遇到QUIT事件时,程序执行sys.exit()退出游戏。 如果遇到的是鼠标事件,则将event信息打印至Visual Studio Code控制台, 打印的信息用字符串”Mouse event:”开头。如果遇到的不是鼠标事件,也可以将event信息打印出来,打印的信息用字符串”Non-mouse event:”开头。
2.3 界面渲染
在上述主消息循环中,每次循环都会执行一次render()函数,即:
1 | #BasicPyGame.py |
在这个自定义函数中,作者现在只写了两行。第3行负责在屏幕上画一次背景图片,第 5行负责刷新显示。
读者可能会问,每次循环都重绘一次屏幕有必要吗? 通常情况下是必要的。因为一个运行着的游戏,显示器显示的每一帧,都可能伴随着界面中游戏元素显示的改变,例如,快速运动的子弹,被忍者切中正在爆炸的的西瓜等。
2.4 执行效果
运行BasicPyGame.py,运行结果如图7所示。
如果读者在图7的窗口范围内移动鼠标,或点击鼠标按钮,或敲击键盘,可以在Visual Studio Code的控制台输出中看到代码打印输出事件信息。我们还建议读者将鼠标从窗口的左上角逐步移动至右下角,观察坐标值的变化。
如果读者单击窗口右上方的X按钮,会触发pygame.locals.QUIT事件,退出游戏。下面是作者从Visual Studio Code控制台输出中摘抄的部分输出信息:
1 | Non-mouse event: <Event(2-KeyDown {'unicode': '', 'key': 107, ...})> |
可以看出,当作者按下一个键盘键时,会触发KeyDown事件,这个键弹起时,又会触发KeyUp事件。当点击鼠标时,会触发MouseButtonDown事件,鼠标键弹起时,又会触发MouseButtonUp事件。鼠标在窗口中移动时,MouseMotion事件则被触发。请读者注意上述事件信息中的坐标和附属信息,如被单击的鼠标键编号、被按下的键盘键的key值等。
需要注意的是,pygame与其他GUI应用程序一样,也是Top-Left坐标系。在本例中,窗口左上角坐标为(0,0),右下角坐标为(960,540)。
3. 按钮
游戏主窗口的下方有4个按钮,分别为重置(RESET)按钮、 自动运行(RUN )按钮、单步运行(STEP)按钮和暂停( PAUSE )按钮。这4个按钮均属于Button类型。
3.1 基本结构
Button.py内包含了一个名为Button的类,它负责处理游戏中与下方操作按钮有关的全部细节。
1 | from enum import Enum |
首先,ButtonState的这个枚举型用于表明按钮的4种状态:normal(正常)、focused(高亮,即鼠标到指定位置但未被按下的状态)、pushed(被按下)、disabled(禁用)。当然,有的读者也会直接使用0、1、2、3来表示这4种状态而不使用枚举型。虽然作者说过“懒惰”是人类进步的阶梯,但并不赞同读者的这种做法。
Button类的构造函数参数中列明了按钮所在的屏幕 (screen),按钮的位置坐标(xCenter, yBottom),以及表示4个不同状态的图片文件的名称。构造函数逐一装载4个图片文件,获取按钮的尺寸信息(self.rect), 并设置其位置( rect.centerx, rect.bottom)。作者根据语意推测,centerx应该是指这个矩形的横向中心坐标,bottom则是指这个矩形的下边沿纵向坐标。同时,构造函数还将按钮的状态初始化为正常(self.state = ButtonState.normal)。
Button类的setEnabled()方法则允许外部程序设置按钮的可用状态,当bEnabled为假时,self.state被赋值ButtonState.disabled。
上述代码中还有一个函数叫blit(),它根据按钮的当前状态,选择相应的图片,将按钮图片画在screen的self.rect位置上。外部程序,在本实例中就是主消息循环中的render()函数,这个函数将逐一执行所有按钮的blit()方法,让按钮对象负责其自身的绘图工作。作者要解释一下这么做的原因,因为Button对象自己最了解自己,它知道自己的状态以及状态对应的图片、位置和大小等信息。如果我们要在主循环的render()函数中手工绘制Button,则需要使用Button的state、rect等属性信息,这将使得Button这个类的接口过于复杂!而简洁的接口及隐藏的实现才是我们追求的目标,过于复杂的接口不符合我们在第9章里讨论的设计哲学。
3.2 按钮事件处理
基于简化接口的设计哲学,在主循环中,获取到一次鼠标单击事件后,我们不会直接去获取每个按钮的几何信息并将其与鼠标事件的发生坐标相比较,以便确定按钮的状态是否应该发生改变(如显示为“按下”效果)或者按钮是否被触发,后者意味着按钮的功能应该被执行。
基于经验,作者选择通过执行按钮对象的mouseEvent()方法将鼠标事件直接发送给按钮对象,由该对象决定对鼠标事件的响应 ,如改变其状态为高亮(focused)、按下(pushed)或正常(normal)等,并经由mouseEvent()的返回值通知调用者该按钮被触发了。
1 | #Button.py |
可以看到,mouseEvent()函数接受一个pygame的event作为参数,这个event预期应为MOUSEMOTION、MOUSEBUTTONDOWN和MOUSEBUTTONUP中的一个。函数首先获取了鼠标的当前位置 — pygame.mouse.getpos(),然后根据事件类型调用相关的成员函数做进一步处理。读者请注意,上述诸如_ _mouseMotionEvent()函数内部仅涉及了按钮状态(self.state)的改变,并没有直接绘制改变后的按钮,因为那是blit()方法的任务。在程序设计的过程中,尽量将数据部分和展现部分分离的做法十分重要。
当鼠标键按下或者弹起事件发生时,如果鼠标坐标不在按钮上,则不必进行相应事件的进一步处理。上述self.rect.collidepoint(x,y)函数即负责检查x,y坐标是否在rect内部。
读者仔细研读上述代码,应该可以总结出下述结论:只要按钮不是禁用状态,当在按钮所处的几何位置有MOUSEBUTTONUP事件发生时,mouseEvent()方法返回self (即按钮对象本身)作为返回值,用于提示外部程序本按钮被触发了。其余情况下,mouseEvent()均返回None值。
4. 金盘
在本实践中共有5个金盘,由5种不同颜色的梯形来表示。Disc.py中的Disc类用于处理相关细节,其代码如下:
1 | #Disc.py |
Disc的构造函数很简单,只包括所在屏幕对象(screen)、盘编号(number)和盘图片文件(imgFile)3个参数。其中,盘编号遵循下述规则:最小盘编号为0,最大盘编号为n-1,n为汉诺塔问题的金盘数。构造函数最后也生成了盘对象的尺寸信息(self.rect),但与Button不同,这里没有设置盘对象的位置信息。盘对象在游戏界面中所处的位置无法根据盘对象内部的属性信息来确定,需要考虑盘所在的柱号、盘在柱上的层数等因素。因此,作者认为确认盘对象的绘制位置的职责还是交给“柱”对象比较合适。
blit()函数将盘在screen上画出来。其self.rect的位置信息需要由“柱”对象来设置。
Disc.iDiscHeight表示盘的像素高度,由于所有盘的像素高度都一样,且该值在程序运行过程中不会被修改,所以将其定义为类属性,而不是对象属性。
5. 柱
一个Pole对象代表汉诺塔问题里的一根石柱。其属性discs是一个列表,用于放置盘对象Disc,其中,下标为0的盘位于最底层。Pole类型的代码如下所示:
1 | from Disc import Disc |
构造函数初始化了discs列表,并且根据参数设置柱在游戏界面中的x坐标。
blit()函数负责将柱上的所有盘全部画出来。如前小节所述,Pole对象需要负责计算每个盘绘制的位置信息,盘的x坐标显然与柱的x坐标相同,而盘的y坐标则需要根据盘在discs列表中的位置进行计算,这里使用到了盘的像素高度的信息Disc.iDiscHeight。我们注意到,这里使用了一个减法(481 - Disc.iDiscHeight*idx)计算disc.rect.bottom,那是因为,pygame是Top-Left坐标系,idx越大,盘所在位置越高,其y坐标越小。
getDiscsTopPosition()函数用于计算柱上最顶层盘的上边沿高度。可以想象,当我们试图把一个盘放上一个柱子前,这个柱对象的getDiscsTopPosition()函数的返回值就应该是这个盘被放上去以后的下边沿y坐标(bottom)。
putDisc()方法用于在柱上添加一个盘,而popDisc()则用于将柱上的顶层盘弹出取下。它们都操作了discs列表。在这里我们需要留意一下函数中的断言(assert), 这些断言用于确保装盘及取盘的正确逻辑。装盘时,被装的盘应比当前柱顶层的盘小,取盘时,柱上应该有盘可取。理论上,如果外部程序的逻辑完全正确,这些断言可以没有。但书写putDisc()函数时的你,应该对数分钟或者数天后使用这个函数时的你或者他人保持适当的怀疑,使用断言十分必要。
6. 主体结构
HanoiTower.py中的HanoiTower类负责组织整个游戏进程,其代码如下:
1 | #HanoiTower.py |
instance代表类的已实例化的对象,其为类属性。结合构造函数中的断言,作者保证了HanoiTower类仅可以被实例化一次。也就是说,HanoiTower类的用户只能够在程序中构造最多一个HanoiTower对象,否则断言会失败。在今后的学习过程中,读者可能会听说诸如“设计模式”这样的名词。此处,作者通过instance类属性确保HanoiTower类仅可被实例化一次,可以认为是设计模式中的单件(Singleton)模式。
initGame()方法负责初始化游戏,它执行createButtons()创建了4个按钮,btnRun、btnReset、btnPause以及btnStep,并将它们放在self.buttons列表中。通过执行createPolesDiscs()方法,它创建了3个柱对象,并存放于self.poles列表中。同时,还创建了5个Disc对象,并存于0号柱 ( self.poles[0])内部。
render()方法负责渲染重绘整个游戏界面,包括背景图、按钮、柱以及柱上的金盘。由于Button类、Pole类都有设计好的blit()方法,直接调用执行即可。注意,我们没有直接描绘Disc对象,因为Disc对象由Pole对象的blit()方法负责描绘。
在构造了一个HanoiTower对象t后,执行t.mainLoop()程序进入主消息循环,循环内部不断地取获事件。如果是QUIT事件,执行sys.exit()退出。如果是MOUSEBUTTONDOWN、MOUSEBUTTONUP、MOUSEMOTION三者之一的鼠标事件,则调用mouseEvent()进行处理。
mainLoop中的每次循环都会调用执行render()方法以刷新界面,这是必要的,因为搬动中的金盘和因为鼠标移动而改变状态的按钮,都需要及时重绘。
7. 按钮触发
在主消息循环中,我们把鼠标事件传递给mouseEvent()方法进行处理,这个方法的代码如下所示:
1 | #HanoiTower.py |
如上述代码所示,在收到event事件后,mouseEvent()直接把event转给Button的mouseEvent()方法进行处理,Button对象根据自身状态以及event来决定自身的响应及展现。如果event是MOUSEBUTTONUP且对应的按钮不处于禁用状态,则Button对象的mouseEvent()函数将返回按钮自身,表明该按钮被触发。显然,当一个按钮报告被触发后,别的按钮不必再执行mouseEvent()函数了,因为一次鼠标事件不可能同时发生在两个按钮身上。
接下来,mouseEvent()检查r的值,如果r是btnRun,执行run()函数开始汉诺塔的自动求解;如果r是btnStep,则执行step()函数执行汉诺塔求解过程的下一步;如果r是btnReset,则执行reset()函数将游戏恢复至初始状态,即全部金盘都在0号柱上;如果r是btnPause,则执行pause()函数暂停汉诺塔的求解。如果既不是None,也不是上述4个按钮,说明r值不符合期待,断言失败。
8. 递归生成器
在进一步讨论之前,我们先得解决游戏中金盘搬运次序的问题。读者如果运行HanoiTower.py,可以看到游戏中金盘是逐个被搬运的。而且在搬运的途中,操作者仍然可以点击按钮干预执行过程。那么,程序是如何知道按什么样的顺序搬运金盘呢? 为了让程序的结构更简洁,我们使用了生成器来构造执行序列。HanoiGenerator.py中有一个名为hanoi()的递归函数,它是一个生成器。其代码如下所示。
1 | def hanoi(n, a, b, c): |
我们看到,这里的hanoi()与本实践稍早介绍的hanoi()递归函数基本相同,区别在于:本节中的hanoi()函数没有打印或者将移盘指令放入列表,而是将移盘指令打包成一个元组,再通过yield语句将元组推送给外部迭代者。
由于包含yield语句,genHanoi = hanoi(5,0,1,2)并不会导致hanoi(5,0,1,2)被立即执行完毕,而是生成了一个函数对象,并赋值给了genHanoi变量。由于函数内部使用了yield语句向外列举结果,所以这个对象是一个可迭代对象(iterable object)。
对于hanoi(5,0,1,2)而言,子问题函数hanoi(4,0,2,1)也是一个可迭代对象,直接用for … in循环列举全部元组指令并yield。同理,子问题函数hanoi(4,1,0,2)也是可迭代对象,可作类似处理。
对于可迭代对象genHanoi,我们既可以通过for … in循环来列举其中的全部元素,也可以通过不断执行next(genHanoi)函数来获取其下一个元素。如果genHanoi内部的全部元素已经列举完毕,然后我们再次通过next()函数去获取其并不存在的下一个元素时,将抛出StopIteration异常,通过捕获该异常,我们知道生成器对象已经没有新元素了,可以结束循环。
如果读者还没有了解与生成器相关的知识,可简单地把genHanoi = hanoi(5,0,1,2)视为一个特殊的序列。每次通过next()函数去查询这个序列,都将得到一个元组,形式如(0,2),这表示汉诺塔求解的下一步是从0号塔取下顶盘,移至2号塔。
执行上述程序,将得到5盘汉诺塔问题的指令序列,下面是该序列的前3项及最后1项。
1 | (0, 2) |
一个汉诺塔问题的解题过程大致如下:生成一个hanoi(5,0,1,2)的可迭代对象,然后使用next()函数从该生成器获取一个移盘指令,执行完后,再取下一个指令 …… 直到StopIteration异常发生,即所有移盘指令都执行完为止。
9. 定时器
有了执行序列后,我们还不能直接工作。因为如果程序简单地通过一个循环来顺序执行移盘指令,将得到意料之外的结果。请阅读下述伪代码:
1 | genHanoi = hanoi(5,0,1,2) |
上述伪代码列举执行序列,然后逐条执行,每移动一次盘,就通过time.sleep(1.0)让程序休息一秒钟 ,就是应用程序向操作系统归还时间片,一秒钟之后再继续执行。这样做看起来似乎是合理的,因为如果移动完一次盘后,立刻直接移动下一个盘,那么计算机的高速运行将导致操作者根本无法看清盘移动的顺序和过程。
在没有单独新开线程的情况下,上述程序只能位于主线程的消息循环内。这种做法将导致严重的后果:主消息循环一直在移动盘,在移动盘结束前它不再处理任何鼠标事件,也就是说程序看起来“卡”死了,它不再响应操作者的任何操作。
通常不要在主线程(负责消息循环)中使用time.sleep()或者执行耗时较长的操作,只有主线程能迅速地获取和处理来自操作者的指令,程序才可能有良好的操作体验。解决这个问题的办法之一是使用单独的线程来执行汉诺塔的解题过程,另一个办法则是定时器。本实践中,我们使用定时器。
定时器(timer)是现代CPU的必需组成部分。现代CPU都支持多个硬件定时器,在设定好溢出计数后,每经过一个时钟周期,相应的计数器会自动加1。当计数器的值达到或超过溢出计数后,将产生一个硬件中断,它就像一个设好的闹钟一样,到点就响。
1 | #HanoiTower.py |
HanoiTower有个类属性userEventDiscMoveInterval = pygame.USEREVENT+1 。这个类属性表明了当移盘间隔定时器到点触发时,主消息循环所捕获的事件类型。此处之所以取值为pygame.USEREVENT + 1,是为了避免与其他系统预定义事件类型相混淆。
intervalTimerStart()函数负责开始移盘间隔定时器。当定时器到点触发时,mainLoop()将捕获一个userEventDiscMoveInterval事件,此时,程序将移动一个盘,继续进行消息循环并等待下一次定时器事件。在移盘过程中,如果发现全部移盘序列已执行完,则会调用intervalTimerStop()结束定时器。
pygame.time.set_timer()函数的第1个参数表明定时器事件的编号,第2个参数表示定时器重复触发的时间间隔, 如本实例中是100ms。如果第2个参数为0,则表示停用该定时器。
10. 飞行的盘
当我们从hanoiGenerator获取一个移盘指令,例如(2,1), 它表示从2号塔上取下顶盘,并将其放至1号塔上。如下述代码所示:
1 | class HanoiTower: |
上述代码很快地完成了取盘和放盘的全过程。但我们不想这么做,出于对游戏效果的考虑,我们期望看到盘从一个柱上取下→飞行至另外一个柱→然后再放下的过程。
10.1 FlyingDisc类
为了展示盘飞行的过程,我们设计了FlyingDisc类型。
1 | class FlyingDisc: |
如代码所示,FlyingDisc的构造函数接受一个Disc、两个Pole对象作为参数,从前往后分别是盘对象、源柱对象和目标柱对象。在构造函数里,飞盘对象初始化了一系列坐标信息:xCurrent (当前x坐标)、yCeiling(飞盘平移时的y坐标)等,请参见代码注释。
在飞行过程中,主消息循环中的render()函数将调用执行飞盘的blit()函数画出飞盘,其坐标为(self.xCurrent, self.yCurrent)。基于同样的理由,我们也会使用定时器来执行飞盘的移动过程。每次定时器事件被捕获,飞盘对象的flyMove()函数会被执行。这个函数将飞盘的飞行过程定义为3段:抬升、 平移和下降。每个阶段对应着不同的坐标修改机制,flyMove()处理这些细节并自动在不同阶段间轮转。外部程序只需要执行flyMove()并观察其返回值即可,如果返回真,说明飞盘飞行表演结束且已放至目标柱,可以尝试下伦移盘了。至于FlyingDisc是如何飞的,坐标怎么变化,路线怎么设计,外部程序完全不用操心。
我们还需要注意到,flyMove()函数如果意识到飞盘已到达目的地,那么飞行表演结束。在返回True之前,它还做了一件重要的事情——把盘放至目标柱上, 实现语句为self.poleTo.putDisc(self.disc)。至此,那个盘属于目标柱了。
10.2 飞行控制
HanoiTower类型中用于控制飞盘飞行的代码如下:
1 | #HanoiTower.py |
当系统试图移盘时,会执行moveDiscStart()函数,这个函数会从源柱上取下一个盘,取下后这个盘将不属于源柱。接下来将这个盘变成一个飞盘 (self.flyingDisc),并开启飞行定时器。在render()函数里,如果有飞盘存在,该飞盘将会不断“实时”刷新。
主消息循环中如果捕获到飞行定时器(userEventFlyDiscMove)事件,则执行flyMove()函数来更新飞盘的坐标信息。在这个函数里,作者做了一个容错设计,如果定时器触发时,发现飞盘已经没了(not self.flyingDisc),则关闭定时器。这是因为,在分时操作系统中,定时器的到达时间可能不是那么可靠,毕竟线程只有在获得时间片以后,才有机会处理定时器事件。
flyMove()函数执行flyingDisc的flyMove()方法更新其坐标,如果返回值为真,说明飞行表演结束了,执行moveDiscEnd()函数处理后续事宜。
moveDiscEnd()函数先是处死飞盘(self.flyingDisc = None), 然后关闭飞行定时器。如果游戏处于自动执行状态,则开启移盘间隔定时器,该定时器到时,将执行下一轮移盘。
我们注意到,有飞盘时,self.flyingDisc有值,无飞盘时,self.flyingDisc为None。程序中,可以通过判断self.flyingDisc的值是否为None来确认当前是否仍有飞盘在飞行中。
10.3 两个定时器的配合
飞行定时器和移盘间隔定时器需要相互协作才能完成任务。这节将解释这两个定时器之间的相互配合。
1 | #HanoiTower.py |
如上述代码所示,当操作者按下并弹起btnRun (RUN按钮)时,该按钮被触发,run()函数会被执行。该函数首先判断是否存在未执行完的执行序列生成者对象(self.hanoiGenerator),如果没有,则重置金盘并新建一个执行序列生成者对象。如果有,则不新建,这意味着,run()函数将会从上次暂停的执行位置继续执行汉诺塔的解题过程。run()函数接下来设置程序状态为running状态,即自动执行,这个程序状态稍后解释。接下来,程序判断有没有飞盘在飞,如果没有,通过runNextMove()函数来启动一个移盘过程。如果有飞盘在飞呢?当飞盘飞行表演结束后,moveDiscEnd()函数会检查程序的状态,如果为running,则会开启移盘间隔定时器,启动下一轮移盘;如果程序不是在自动运行,那么程序状态很可能是idle状态,这表明,最近一次的飞盘飞行由是STEP按钮(单步执行)引发的。
runNextMove()函数先关闭移盘间隔定时器。然后从self.hanoiGenerator获取下一步移盘指令,如果执行序列生成器通过抛出StopIteration异常的形式告知解题已完成,则将程序设置为finished状态,即解题已完成。如果函数成功地获取到了下一个移盘指令,那么调用moveDiscStart()函数启动移盘进程。
所以,我们总共有两个定时器——飞行定时器和移盘间隔定时器。moveDiscStart()用于启动飞行定时器,飞行定时器启动时移盘间隔定时器应是关闭的。飞行定时器启动后会不断修正飞盘的位置并显示,直到飞行结束后,moveDiscEnd()函数负责关闭飞行定时器。如果程序为自动执行态( running),那么moveDiscEnd()会开启一个移盘间隔定时器,当100ms的间隔到后,runNextMove()函数被执行。而runNextMove()函数在开始下一轮移盘前,会关闭移盘间隔定时器。
11. 程序及按钮状态
在不同的程序状态下,按钮的可用性是不同的。按钮可用性的控制通过下述代码来完成。
1 | #HanoiTower.py |
在前述内容中,我们已经知道可以通过syncState()函数来同步/设置程序的状态。在本章实例中,我们在程序中用枚举型定义了3种状态:分别是idle (空闲)、running (自动运行)和finished (执行完成)。
在不同的程序状态下,按钮的可用性是不同的。例如,当程序已经处在运行状态中时,如果再次触发btnRun (运行按钮),可能导致混乱。为避免这种情况的发生,我们在syncState()函数中设置了各按钮的可用/禁用状态。有了这个函数,当程序的当前性质发生变化时,例如由空闲状态变为运行状态时,则执行syncState()函数并将其状态设置为running即可。syncState()会自动把PAUSE按钮解禁,而把RUN按钮禁用。
12. 按钮功能执行
下述代码中的run()、reset()、pause()、step()4个函数分别对应RUN、RESET、PAUSE、STEP按钮。其中,run()函数已经在前面的内容中介绍过了。
1 | #HanoiTower.py |
reset()函数负责将汉诺塔复位为初始状态。如果当前有飞盘在飞,为了避免程序混乱,作者选择什么也不做,等飞盘飞完再说。如果没有飞盘在飞,那么重新创建柱和盘。有读者会发现,这里作者偷了个懒,作者不是把其他柱上的盘移回0号柱,而且干脆重建全部的柱和盘,这种用法Python带来的福利之一,那些被作者抛弃的柱对象和盘对象,会在恰当的时候被“垃圾回收”。最后,reset()将程序状态设为idle。
pause()函数预期仅在自动运行时被执行,它用于暂停执行过程和停止移盘间隔定时器,但不会停止飞行定时器。因为如果当前有飞盘在飞,这个函数会允许该飞盘飞行并放置在目标柱上,以避免引起混乱(如飞盘丢失)。
step()函数是单步执行函数,它不会改变程序的状态。如果没有飞盘在飞,那么它可以启动一轮新的移盘过程。在这轮移盘过程结束时,由于程序的状态不是running,下一轮的移盘不会开启。在本实践中,作者也做了一些容错设计,如当没有执行序列生成器时,step()函数会创建一个执行序列生成器。