上一章我们着眼于数据结构设计,同时也让这些新数据类型和函数进行了联动。这一章会让程序更加融入现实,借由big-bang功能做出由键鼠控制的交互式程序。与此同时,我们将会带着之前所学的内容去系统化处理大型程序 —— 更复杂的数据类型与函数设计。
学习目标
- 能够解释交互式图形程序的内在结构
- 能够运用《世界设计方法》(HtDW)来设计具有原子世界状态的交互程序
- 能够评估各元素在清晰度、简洁性及彼此一致性方面的表现
- 能够阅读和编写
big-bang表达式
Interactive Programs
在之前,我们学习了:
- 原子类型:如
String、Image等 - 复杂类型:如
Enumeration、Interval等 - 函数
- 条件表达式:
cond、if等 - …
借此,结合一些实际场景,我们也将这些知识点融会贯通:通过座位号判断是否在走廊这一题就需要用到Interval,cond之类。
从这一章开始,我们将会从更交互的程序 —— 动画与游戏 —— 这种我们每日都在用的桌面应用程序开始。比如用鼠标在自己的窗口内点击,所在位置会出现一个升起的烟花,最后爆炸。
这些看似华丽的图形程序背后也是由许多基础的数据类型与函数组成的,有时也要结合一些数学知识。
The big-bang Mechanism
在了解 big-bang 作为 Racket 的一种机制之前,我们先可以了解下它是如何运作的:设想存在一个窗口,里面只有一个数字,从10开始倒计时到0,每当人按下空格键就将倒计时清零。
这种交互式程序可以改变自身状态和显示内容,以及能够响应键鼠对其行为的影响。
在这个窗口背后,我们可以用一张表格来描述倒计时:

在这张表中,程序遵从tick来运行,而每个tick对应现实中的一秒。也就是说每过一秒,程序从n=10的状态变更为n=9,以此类推,同时这个数字对应的图像显示也会发生改变。
到了这里我们就能对程序的构建产生一些基础的想法了,比如说:
- 定义
Countdown is Natural来指定倒计时背后的数字类型 Countdown的初始状态为10- 写一个函数,传入
Countdown后得到它-1后的值 - 也是写一个函数来更新窗口的内容
所以在这种程序内既需要函数用来运算逻辑,也需要用来渲染到窗口上的。
结合执行顺序来看,我们可以这么做:
- 先在窗口上渲染初始值
10,再获取它的下一个倒计时数字9,等待一秒 - 在窗口上渲染
9,再获取它的下一个倒计时数字8,等待一秒 - 以此类推
至此,big-bang表达式的基本构成也知道了:
- 初始状态:程序开始时的状态,类型是
Countdown - 状态更新函数:当每一个
tick流逝,下一个更新后的状态,签名是Countdown -> Countdown - 渲染函数:当每一个
tick流逝,将更新后的状态绘制到窗口上,签名是Countdown -> Image
整个世界因为它们得以运行,这也是许多游戏引擎的根本运行逻辑,不然为什么叫big-bang这个名呢?
既然big-bang是强大的,那么它自然可以接受任何类型的状态用来处理和渲染。
Domain Analysis
在上一节,我们了解了big-bang表达式的意义与用途。但其实在设计世界时,我们需要将事情分为两步,一步是需要动纸笔的分析,另一步才是真正的代码编写。
ps: 之后干脆都把设计这种交互式程序称作设计世界得了
在此之前,下载来自edX的 cat-starter.rkt 文件。

不要被冗长的题目吓到,它的大概意思是:设计一个世界,其中有一只猫从窗口的左边移动到右边并会一直移出窗口部分(也就是说如果太右边了猫就不见了)
参考 HtDW 的 Recipe,我们在纸笔分析的时候需要考虑:
- 绘制程序的场景
- 识别不变和变化的信息
- 构思
big-bang需要哪些函数
有关位置
在形容一个元素在窗口中的位置时,是从窗口的左上角开始算的,如图:

让我们一个一个来,首先是绘制场景(当然用手画一画就行了,一只猫在一个矩形内从左移动到右),然后就是识别不变的信息。
在整个世界运行过程中,哪些信息不会变呢?这时候思维就要散开了:
- 窗口本身的宽高不会变
- 猫本身这个图像的也不会变
- 猫是从左到右水平位移的,所以猫的
y坐标也不会变,猫始终在竖直位置中心 - 还有一点比较难以发现,就是背景色始终是白色
那么有什么会变呢?其实就只有猫的x坐标。
最后就是big-bang的内容了,参考 Recipe:
| 如果你的程序需要做到: | 那么你就需要这个选项: |
|---|---|
| 随着时间流逝而更新状态 | on-tick |
| 显示 | to-draw |
| 响应键盘的键被按下 | on-key |
| 响应鼠标的活动 | on-mouse |
| 自动停止运行 | stop-when |
on-tick和on-draw是本次分析会用到的big-bang函数,所以也就这两个了。
最后程序的分析如图:

ps: ctr-y 是指 center-y,即小猫在y轴上是在中心的
ps2: MTS 是指 empty-scene,即空场景
Program through main Function
本节会延续上一节的代码部分开始。我们总是要为一个数据类型写一个完整的模板,从数据定义开始再到函数模板什么的。而big-bang设计的世界也需要一个模板,请将下方模板复制进 DrRacket:
1 | (require 2htdp/image) |
我们先按照模板填入我们的信息,程序部分的 Recipe 如下:
- 定义一些常量
- 数据定义
- 函数(保证
big-bang相关的函数在最后)
对于首行的My world program,它应该作为我们程序的一句总结,可以细致地改为A cat that walks from left to right across the screen.
接下是常量部分,我们需要将上一节分析到的常量在;; Constants:后填进去:
1 | (define WIDTH 600) |

CTR-Y的值当然就是HEIGHT的一半,而MTS就是对应宽高的空白场景。
接下来就是数据定义部分,由于程序的核心在于让猫在x轴上动起来,所有我们可以定义Cat is Number替换掉;; WS is ...。
它的解释是:interp. x position of the cat is screen coordinates,之后为此写一些例子,对应小猫的位置:
1 | (define C1 0) ; left edge |
因为Number只是个原子类型,Cat类型的函数模板就是:
1 | (define (fn-for-cat c) |
之后对于下面的主函数,我们需要将WS全部替换为Cat:
- 在 Windows 上使用Ctrl + F
- 在 MacOS 上使用Command + F
之后点击 DrRacket 下方的输入框的Show Replace,左侧填入WS,右侧填入Cat,按Replace即可替换。之后软件会匹配到下一个可替换项,如果这个项是WS的话就替换,如果是ws的话就按Skip跳过这个项。
对于带有big-bang表达式的main函数,我们需要将ws改为c,代表我们是围绕Cat类型操作的。
我们可以把主函数里面的on-key、on-mouse和stop-when删了,因为用不到这些情况。同时将tock改为advance-cat,代表每一刻实际做的事就是让猫移动。
从这里能看出来我们的主函数需要调用另外两个函数,分别是advance-cat和render。
对于下面的第一个函数,即之前还没改名的tock(现在记得改名),它的目的是produce the next cat, by advancing it 1 pixel to right。而下面render的目的是render the cat image at appropriate place on MTS
这两个函数统称为wish-list entry,它包含一个签名、目的、!!!和桩函数。在设计世界的时候可以以此作为待办事项之类的东西,在之后回来实现它们:
1 | ;; Cat -> Cat |
有关!!!
这是一种标记,在之后可以通过全局搜索!!!来找哪些函数没写。
点击运行后,我们在下方的交互区输入(main 0)回车,能得到一个完全空白的窗口。
本节完整代码如下:
1 | (require 2htdp/image) |
Working through the Wish List
找到advance-cat的桩函数,我们现在就要完善它。
首先,Cat类型就是Number,代表x轴坐标,而这个函数需要将传入的Cat向右移动一个像素,所以我们的测试可以是(check-expect (advance-cat 3) 4)。
之后声明从Cat的数据定义拿到函数模板,就可以从Cat那里将函数模板复制到这里,将函数名改为advance-cat,函数体就是简单的(+ c 1):
1 | ;; Cat -> Cat |
运行后得到测试通过的结果就没什么问题了。
之后就是下面的render函数,一开始可能会很疑惑如何将图片放到场景里。在 Racket 里,可以使用place-image表达式,这个表达式需要四个东西:
- 你要放进去的图片,这里我们就用之前定义的
CAT-IMG x轴坐标,是个数字y轴坐标,猫的y轴坐标不变,就是常量CTR-Y- 场景,即常量
MTS
所以render函数接受可变量x轴坐标,然后将小猫放在对于位置上,测试可以这么写:(check-expect (render 4) (place-image CAT-IMG 4 CTR-Y MTS))
ps: 这时候运行你会得到一个测试错误,并附带一个大白窗口
由于传入值是Cat(即Number,或是说x轴坐标),render函数同样可以从Cat复制来函数模板。
之后再将place-image表达式放到函数体里面,由于c就是x轴坐标,place-image里的x轴坐标部分就是c了:
1 | ;; Cat -> Image |
运行后会先提示测试成功,之后在交互区输入(main 0),弹出来的窗口就是猫在移动的动画。
本节advance-cat和render函数部分代码如下:
1 | ;; Cat -> Cat |
Improving a World Program
Add SPEED
程序在很多时候不会一设计好就不再更改了,哪怕是一个很完备的程序,它的用户也希望它变得更好。
本节会基于我们在之前编写的程序从功能性上做出一些更新,下载来自edX的 cat-v1.rkt 文件。
ps: 这个程序已经完成了之前几节的内容
运行通过测试后,在交互区输入(main 0)就能弹出窗口了。小猫同时开始从窗口左边向右平移。
但它的速度实在是慢了点,我们或许可以加加速。如果考虑到速度,它也是一个常量,因为我们没考虑变速。
目前的速度是1 pixel/tick,试下它的三倍速,在常量定义那加一行(define SPEED 3)。
在之前,我们默认将速度设为1,让advance-cat函数的测试写成(check-expect (advance-cat 3) 4),因为这个函数会得到猫的下一个所在位置,也就是3+1=4,但这时候这个+1变成了+SPEED。位置变化和速度相关,所以这里应该改成(check-expect (advance-cat 3) (+ 3 SPEED))。
后面的advance-cat函数体同理。
这个过程看着比较简单,但实际上在增减功能的时候我们总是要从全局考虑,比如这样设计会不会破坏测试,会不会破坏其他函数的兼容性。
Add key handler
题目中描述到需要响应键盘案件,如果按下空格键,会让猫回到最左边重新移动一遍。
本节会处理这个需求,下载来自edX的 cat-v2.rkt 文件。
ps: 这个文件把在v1的基础上完成了上节的内容
从需求出发,我们需要响应空格键被按下这个事件。这里就需要用到在一开始了解big-bang表达式时候被删掉的一个选项了 —— on-key。
我们将这个选项加入到程序的big-bang内:
1 | (define (main c) |
之后就要具体设计这个函数了,到程序的末尾,写上函数的签名:;; Cat Event -> Cat和目的:;; reset cat to left edge when space key is pressed。
这个函数负责处理键盘事件,即Key Handler,所以它会handle key,桩函数就是:(define (handle-key c ke) 0) ; stub。
这时候就可以补齐上面的big-bang选项了:
1 | (define (main c) |
设计函数也是要测试的,那么ke该填什么呢?
KeyEvent
KeyEvent 是一个巨大的枚举,包含字母表中的所有字母以及您可以在键盘上按下的其他键,比如响应a键,值就为"a"。详情请使用Help Desk查询。
我们需要响应空格键,那么就应该是" "了,对于其他按键就不管了:
1 | (check-expect (handle-key 10 " ") 0) |
既然KeyEvent是个大枚举,我们岂不是得把所有的情况都考虑到?对于这种
Recipe 中的on-key模板如下:
1 | (define (handle-key ws ke) |
我们将其按照桩函数改造,并开始实现它。刚好模板的cond表达式确实将空格键和其他按键算作两个情况处理,那我们只需要在(key=? ke " ")对应的A设为0就行,其他维持传入的c不变,得到:
1 | (define (handle-key c ke) |
测试通过后运行,在弹出的窗口按空格键就能看到效果了。
Practice Problems
这一章的 Recommended Problems:
- HtDW P1 - Countdown
- HtDW P2 - Traffic Light
ps: 做完题才发现这模板太严了,考虑到篇幅问题,题解会速通很多地方
模板
以下两道题都可以基于以下模板来做:
1 | (require 2htdp/image) |
HtDW P1 - Countdown 题解
预计耗时:30 min / 简单
这道题让我们实现一个从10开始每秒倒数的倒计时程序,我们可以开始思考常量有哪些:
- 窗口的长宽和空白背景
- 倒计时文本所处的位置、文字大小和颜色(这些是因为
text表达式强制需要这些)
至此我们就能定义以下常量:
1 | (define WIDTH 50) |
到了数据定义部分:其实倒计时本身就是个从0到10的自然数区间,用于显示倒计时中剩余的秒数。由于是区间,我们需要覆盖三个测试,并给出阶段性的解释:
1 | ;; Countdown is Natural[0, 10] |
之后我们要写它的模板函数:
1 | #; |
接下来就是最复杂的函数部分,对于第一个big-bang表达式的主函数,我们只需要明确里面的名字和目的就行,同时题目要求on-tick函数应叫advance-countdown:
1 | ;; Countdown -> Countdown |
on-tick
这里的on-tick表达式后面能跟个1的意思是每经过1秒执行一次。
advance-countdown其实就是一个一直减一的函数,传进去的数都会减1,直到0。在写好测试和桩函数,并从模板函数复制过来后,考虑在函数体内使用if表达式将0与非0的情况分别处理就行:
1 | ;; Countdown -> Countdown |
渲染函数的目的就很简单了,就是把一个数字放在窗口上。准确来说是将数字变成字符串类型后,通过text表达式使其变成Image,再通过place-image表达式将其放在窗口空白背景的某个位置上,写好测试、桩函数和模板函数复制后,我们就能将函数体实现了:
1 | ;; Countdown -> Image |
里面用到的表达式
复习一下:
number->string接受一个数字,返回一个它的字符串类型值text将传入的字符串以某种字号和颜色变成图片place-image将图片放在某个场景的一个位置上
最后就是附加题,当用户按下空格键,倒计时就会回到10。对于handle-key这个函数来说,它只需要传入当前的倒计时和KeyEvent,返回一个新的倒计时数就行,我们可以在函数体内使用cond表达式之类的,如果遇到空格就返回10,否则不变:
1 | ;; Countdown KeyEvent -> Countdown |
HtDW P2 - Traffic Light 题解
预计耗时:50 min / 中等
这道题让我们实现一个交通灯改变的程序,从红变成绿再变成黄,一直交替下去。如图所示:

之后就可以定义一些常量了,我们发现:
- 每个灯的半径和间隔都是不变的
- 纯黑背景(与半径和间隔有关)
- 三个状态对应的内容(比较多)
接下来就是数据定义,交通灯其实就是带有三个状态的枚举,分别是"red"、"yellow"和"green",对应当前交通灯的颜色。由于是枚举类型所以不需要例子:
1 | ;; Light is one of: |
它的函数模板和规则如下:
1 | #; |
对于函数设计,这次的big-bang很简单,只需要on-tick和to-draw。其中on-tick的函数名未next-color,改名后:
1 | ;; Light -> Light |
对于next-color函数,它的目的是返回下一个交通灯,也是接受枚举得到枚举,写出测试、桩函数和模板复制后,完善来自模板的函数体:
1 | ;; Light -> Light |
由于我们把工作量放到了常量定义,所以渲染函数很好写,根据传入的当前灯返回对应的图像就行:
1 | ;; Light -> Image |