翻出了今年二月份写的一个小游戏,这也是我的第一个独立完成的编程小项目。不做一些分析和总结的话,就太可惜了。
贪吃蛇
在我的童年时期,拼图、推箱子和贪吃蛇几乎翻盖手机中必有的也是唯一的小游戏了。现在,这种“老年机”早已被时代淘汰,而贪吃蛇得益于其知名度和网上繁多的资源、教程,成为大学C语言课程期末作业的常客。(老师们也看烦了吧哈哈)实际上,如果你能独立完成一个能玩的版本,说明你对C语言的运用已经入门了。下面我们就来分析一下贪吃蛇的基本组成要素。
- (2020/07/25更新)
近来在读《深入理解计算机系统》,前面学习汇编相关章节时,Bomb Lab和Attack Lab做得很爽。不过到存储器这一章,实践的内容便大幅减少。再加上链接看得云里雾里(于是打算看看《程序员的自我修养——链接、装载与库》),便想回过头来搞搞贪吃蛇的计分板模块。
这才发现,原来的代码全都集中在两三个源文件和头文件中。为了理清结构和逻辑,改变了项目的结构,全部完成后如下图:
这样感觉清晰多了。也许是受到了cs61b课程的影响,有点模块化的感觉了。
接下来将计分板的部分完成,详情请看下面的介绍,最后修复了一些bug。
添加了一些图片。
游戏的结构
在这里,我们并不关心一些细节问题,只是对各个元素的特点进行分析。
有边界的地图
地图的实现可以基于字符界面和图形界面,基于以下几个因素我选择了字符界面
- 蛇的移动只有四个方向,使用字符界面就可以实现。
- 学习图形界面的时间成本较高。
能在屏幕上朝特定方向移动的蛇
我们看到游戏中角色的“移动”通常是由在短时间内改变屏幕像素的颜色来实现(利用人的视觉暂留效应制造移动的假象)。而对于字符界面来说,像素颜色的改变对应着终端格子中字符的变化。因此,蛇的移动本质上是屏幕上字符的打印。然而C标准库的标准输入输出函数并不能满足我们的全部需求,因为蛇的移动方向包括上和下,单纯用printf十分不方便。
于是我在Linux上找到了一个操纵字符界面的库——ncurses。详细的介绍,请看这篇文章。也可以在man page中阅读官方文档:1
$ man ncurses
随机生成的食物
利用C中最基础的随机数生成函数,我们很容易模拟食物随机生成
计分板(2020/07/25更新)
我想要的效果如下:计分板会展示前八名玩家的最高分数,以及当前玩家的实时分数与排名。可以说是一个动态的计分板。
有三点需要注意:排名、保存数据和读取数据
排名
一开始还以为要用到排序,实际上只要将当前玩家插入到有序序列的合适位置即可
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25/* 遍历后全部重新打印 */
unsigned int ShowScoreboardWithCurrentRecord(Scoreboard sc, Record cr, int y, int x)
{
bool showed = false;
unsigned int i, number = 0;
for (i = 0; i < sc->size; i++)
{
if (!showed && cr->score > sc->records[i]->score)
{
number = i + 1;
if (i < SHOWED_MAX_NUM)
ShowRecord(cr, number, y + i, x);
showed = true;
}
if (i + showed < SHOWED_MAX_NUM)
ShowRecord(sc->records[i], i + 1 + showed, y + i + showed, x);
}
if (!showed)
{
number = i + 1;
if (i < SHOWED_MAX_NUM)
ShowRecord(cr, number, y + i, x);
}
return number;
}Scoreboard
结构体中有Record
数组(这里没有考虑用其他数据结构),Record
结构体包含玩家的名称和分数。时间复杂度为Ω(N),因为遍历了整个数组,而不是仅交换两个
Record
的位置。此外,对于数组这种数据结构来说,插入的开销很大,所以我选择了在合适的位置直接打印。保存数据
涉及到了文件操作的知识,这里只需要用
fsprintf
库函数就行。然而文件中插入一条记录我感觉做不到(无论是用fwrite
库函数还是write
系统调用)因此选择了与排序相同的做法。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26/* 1. 逻辑有些复杂(为了保持有序性、以及指定的一些规则)
* 2. 完全覆盖之前的文件内容
* 3. 没有加密
* 4. 效率还好吧(Ω(n))
*/
void WriteScoreboard(Scoreboard sc, Record cr, int found)
{
if (sc->size > RECORD_MAX_NUM)
FatalError("Records are full");
FILE *fp_sc_w = fopen("score.txt", "w");
bool written = false;
for (int i = 0; i < sc->size; i++)
{
/* 只写一次 && 合适的条件(原来没有记录 || 该记录比原记录大) && 合适的位置 */
if (!written && (found == -1 || cr->score > sc->records[found]->score ) && cr->score > sc->records[i]->score)
{
fprintf(fp_sc_w, "%s %u\n", cr->name, cr->score);
written = true;
}
if (i != found || cr->score <= sc->records[found]->score)
fprintf(fp_sc_w, "%s %u\n", sc->records[i]->name, sc->records[i]->score);
}
if (!written && found == -1)
fprintf(fp_sc_w, "%s %u\n", cr->name, cr->score);
fclose(fp_sc_w);
}这里的逻辑稍微有些复杂,需要仔细推敲(最好先写个伪代码)。文件内容如下:
读取数据
小心异常情况:数据不完整、文件不存在等。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23void ReadScoreboard(Scoreboard sc)
{
FILE *fp_sc_r = OpenScoreboardFile();
int size = 0;
while (size < RECORD_MAX_NUM)
{
int status = ReadRecord(sc->records[size], fp_sc_r);
if (status != 2)
if (status != EOF)
FatalError("Incomplete data");
else
break;
size++;
}
sc->size = size;
if (fp_sc_r != NULL)
fclose(fp_sc_r);
}
int ReadRecord(Record r, FILE *fp)
{
return (fp == NULL) ? EOF : fscanf(fp, "%s %u", r->name, &r->score);
}
实现
有了想法,码代码就不是一件难事了。关键在于实现的方法和细节。下面就说说两个关键部分和可能踩的坑
蛇
关于蛇的实现有两个问题:
- 信息储存位置
- 储存在二维数组中
- 储存在链表中
- 移动方式
- 刷新整个地图
- 刷新蛇
很明显,第二种实现方式效率更高,但实现起来要复杂一些。
我选择将蛇的信息储存在链表中,并通过刷新蛇让蛇动起来。
1 | struct _snake |
使用单向带tail的链表实现时,比较有趣的就是蛇的移动了。我们只操纵链表的头和尾就可以实现蛇的移动,而不需要删除蛇再打印。
还有一种选择是将各个节点的坐标改为前一个(靠近蛇头的)节点的坐标。不清楚哪一种效率更高。
1 | /* 只操作链表的头和尾实现蛇的移动 */ |
程序流程
程序不仅要处理游戏中事件(蛇移动、判定),还要读入玩家的输入。而我们熟知的输入函数都是阻塞的,程序会”停”在该函数处。
常用的解决方法:
- 使用非阻塞的输入函数(如conio.h中的_kbhit函数),并利用轮询(Poll)。但conio.h既不在标准库中,也没有定义在ISO或POSIX中,不能再linux平台上使用。
- 借助信号。信号本质上是一种向一个进程通知发生异步事件的机制。在贪吃蛇中,我们可以设置一个定时信号,定时通知进程去处理游戏中事件,而在主函数中,我们只需要循环获取用户输入就可以了。
关于信号的更详细的文章,请看这篇文章
易出的Bug(2020/07/25更新)
这里记录了我觉得易出bug,有些当时发现就修复了,有些发现了很长一段时间后才修复。
食物刷新在蛇身上
蛇的移动区域与食物的刷新区域完全重合,冲突不可避免,导致的结果是食物短暂出现在蛇身上后就“隐身”了(被蛇身覆盖了)。有两种做法可以避免:
再刷新一次
问题在于蛇身过长,占地图面积比例过大时,效率可能不高(没测试过)。
将蛇身排除在食物刷新区域外
问题在于太麻烦,写起来麻烦,执行起来也麻烦。
我选择了第一种方法
(反正我玩一会就死了)。转向操作太快导致蛇走回头路然后死掉
我们禁止蛇走回头路,通常的方法是忽略反方向的按键(比如蛇朝右走时,只有’w‘和’s‘键能改变方向)。前面提到过,蛇每隔一段时间就移动一次(我设置的是100ms),问题就出在这100ms的间隔中。
假设蛇正朝右走,我们按下’s‘,然后迅速按下’a’。在按下’s’后的100ms内,蛇的方向已经改变为朝下,但还未移动。这时按下的’a’不会被忽略,蛇的方向又变为朝左,100ms过后,蛇头便向左移动,一口咬到了自己的身体。
解决方法为在这100ms内禁止方向改变,在这里我们使用
sleep
函数直接让程序休眠即可:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31int main(void)
{
setup();
while ((key = getchar()) != 'q')
{
if (key == 'w' && abs(snake->y_dir) != 1)
{
TurnUp(snake);
sleep(100);
}
if (key == 's' && abs(snake->y_dir) != 1)
{
TurnDown(snake);
sleep(100);
}
if (key == 'a' && abs(snake->x_dir) != 1)
{
TurnLeft(snake);
sleep(100);
}
if (key == 'd' && abs(snake->x_dir) != 1)
{
TurnRight(snake);
sleep(100);
}
}
wrapup();
return 0;
}
总结
进行分析后,我们发现贪吃蛇的实现思路是很清晰的。不过在码代码的过程中,还需要注意程序的流程以及一些细节的处理(free、指针操作、全局变量、边界状态的判定等)
下面是部分源代码(2020/07/25项目结构变动,具体请浏览项目的GitHub页面)仅作参考,不能直接使用。
1 | /* mysnake.c version 0.1 |