本章重点
• 控制台下的高级操作
• 顺序表的使用
• 链表的使用
首先祝贺大家完成了C语言基础知识的学习。俗语有云,“学而时习之,不亦乐乎。”要学习编程,复习的方法不是读课本、记忆知识点,而是动手完成自己的程序。只有通过大量的编程训练才能真正掌握C语言,进而造就优秀的程序员。
本章介绍三个C语言应用实例,意在展示C语言的强大功能,激发大家的兴趣和学习热情。这些应用尽管看上去功能强大、实现复杂,但做好程序的需求分析和框架设计之后,会发现它们写起来其实并不困难。
请大家做好准备,迎接C语言带来的挑战吧!
14.1 小应用:迷宫游戏
本节将完成迷宫游戏的设计。该游戏基于控制台操作,运行时在屏幕上输出一个由字符组成的迷宫。玩家通过键盘操纵迷宫中的小人从入口走到出口,游戏即结束。
为了方便游戏设计,规定迷宫的左上角为起点,右下角为终点。
迷宫游戏的开发过程主要涉及到如下知识点:
• 在控制台下开发小游戏的一般方法,包括直接获取用户输入、控制台文本输出高级控制、控制台界面重绘等知识。
• 游戏内部数据结构的设计。
• 游戏状态的存储与使用。
游戏运行时的截图如图14-1所示:
图 141 迷宫游戏截图
14.1.1 需求分析
本节仅分析功能性需求,省略非功能性需求。
迷宫游戏需要实现如下功能:
• 在控制台上用字符显示迷宫,遵循下面的规定:
空格表示通道;
井号“#”表示墙;
“@”符号表示玩家。
• 玩家使用键盘上的“ASWD”键位来控制小人的行进,无需通过回车来确认每一步输入(这一点和之前介绍的“井字游戏”的操作是不一样的)。输入无效时(例如下一步会撞墙)通过蜂鸣声对用户进行提示。
• 记录玩家走的步数,并进行提示;
• 玩家每走一步,要进行整个界面的重绘,即用新的内容覆盖原有内容,而不是直接在控制台底端输出新的画面;
• 为保护玩家的视力,要求使用不同的前景色和背景色输出游戏标题、操作说明和迷宫图。可考虑整个界面使用蓝色背景,游戏标题使用黄色,迷宫和操作说明的使用白色背景、黑色前景。
• 支持生成随机迷宫,而不是使用一个固定的迷宫,从而增强游戏的耐玩性。
14.1.2 程序设计
- 数据结构
迷宫游戏中的主要数据结构有两个,一个是用来存储迷宫本身的数组,另一个是用来存放玩家信息的结构体。
程序中使用一个二维字符数组来存放迷宫的原始状态,数组每一个位置代表一个格子,每个元素就是格子中的内容(“#”或空格)。例如图14-2的迷宫可以由例程14-1来表示。
图 142 4 x 4迷宫示例
例程 141 用代码表示一个4 x 4 的迷宫
char maze[4][4] = {' ', ' ', '#', '#', /* 第一行 */
'#', ' ', '#', '#', /* 第二行 */
'#', ' ', ' ', '#', /* 第三行 */
'#', '#', '#', ' '}; /* 第四行 */
按照上面的表示方法,maze[0][0]是第一行、第一列的元素,maze[0][1]是第一行、第二列的元素,maze[2][0]则是第三行、第一列的元素。对应到常用的XY坐标系中,则是纵坐标y在前、横坐标x在后,如图14-3所示:
图 143 XY坐标系中的表示方法
上图中,第二行、第一列的格子对应于元素maze[1][0],即y = 1、x = 0,而不是x = 1、y = 0。这就需要大家在使用x、y坐标访问迷宫格子时做好转换工作。
完成了迷宫的数据结构设计之后,再来看看如何存储玩家的信息。要存储的玩家信息包括两类数据:
• 小人的当前位置(横纵坐标x、y);
• 小人在迷宫中的标记(本游戏中是“@”符号)。
之所以单独设定小人在迷宫中的标记而不是将其写死在程序中,是因为这样方便大家在未来对游戏进行进一步开发,例如实现增加一名玩家、增加一些敌人、添加宝箱等物品等特性。设计时进行一定的灵活性考虑,可以避免未来维护升级或二次开发时对程序的设计做过大改动,进而减少了成本投入,也降低了修改程序后带来新问题的可能性。
下面的代码定义了用来存储玩家当前状态的结构体Item。
例程 142 存储玩家信息的结构体Item
/* 结构体,用来存储玩家当前的状态 */
typedef struct Item_
{
/* 位置信息 */
int x;
int y;
const char symbol;
} Item;
-
游戏流程
-
生成随机迷宫
经过之前几小节的工作,迷宫游戏已经开发完成了。但是游戏里使用的是一个预先设定好的迷宫,这使得每次运行时的迷宫都是一样的。如果一个游戏总是有相同的问题、相同的解法,那实在是太无聊了。借助深度优先搜索算法,可以实现生成任意大小的随机迷宫。本小节将介绍相关的算法,并给出参考实现。
首先简单介绍什么是搜索算法,以及深度优先算法的特点。
下面是一张由五个节点和四条边组成的图。搜索算法是指在一张由多个节点和连接节点的边的图中逐个访问每个节点,且每个节点只被访问一次的算法,也被称为遍历。大家可以按照任意的顺序来访问节点,例如“3->1->2->5->4”是一种有效的搜索顺序,“4->2->1->5->3”也是一种有效的搜索顺序。 由节点和边组成的图的示例
深度优先搜索是指从起始点出发,沿着边遍历图的每个节点尽可能深地搜索每个分支的一种搜索方法。当一个分支搜索完毕,再回到之前未搜索的另一个分支继续搜索,直到访问完所有的节点为止。下面以上图为例详细介绍深度优先搜索的方法。
-
从第一个节点1出发,发现两个分支2和4。选择2号节点继续搜索,将4号节点暂时存起来;
-
从2号节点继续搜索,只发现一个分支3;
-
从3号节点继续搜索,发现了一个分支4;
-
从4号节点继续搜索,发现一个分支5;
-
从5号节点继续搜索,未发现任何分支。这时取出第一步时暂时跳过的4号节点,继续搜索;
-
发现4号节点已经被搜索过了,因此跳过这个节点。
-
搜索完毕。
这时图14-5的深度优先搜索顺序就是“1->2->3->4->5”。
利用第十章中介绍过的栈结构,可以很轻松地实现一个深度优先搜索算法。仍以图14-5为例介绍基本思路:
-
初始化一个空栈Stack; 初始化一个空栈
-
将起始点1号节点入栈; 1号节点入栈
-
从栈中弹出一个节点,在图中找出它的后继节点,并放到栈中。这个步骤被称作展开节点。这里弹出节点1,并将节点4和2入栈。为了先访问左边的分支,需要将另一个分支先入栈,再将左边的分支入栈;
节点4、2入栈
-
重复展开节点的步骤。从栈中弹出节点2,将它的后继节点——3号节点入栈; 节点3入栈
-
从栈中弹出节点3,将它的后继4号节点入栈; 节点4入栈
-
从栈中弹出节点4,将它的后继5号节点入栈; 节点5入栈
-
从栈中弹出节点5。由于5号节点没有后继,因此这一步没有入栈的节点; 节点5出栈
-
从栈中弹出节点4。发现之前4号节点已经被访问过,跳过这个节点。
-
栈空了,搜索随之结束。搜索顺序为“1->2->3->4->5”。
随机迷宫生成算法的基本思路如下:
-
设定迷宫的左上角为起点,右下角为终点。出于迷宫显示上的考虑,要求整个迷宫除了起点和终点之外的所有位于边上的格子都是墙;
-
将所有格子都标记为未访问;
-
从第二行、第二列的方格出发,对整个迷宫进行深度优先搜索,但与普通的深度优先搜索不同的是,普通的深搜的步进为1,即每次展开节点时,会将节点的直接后继入栈;这里要求搜索的步进为2,即展开节点时,将那些和当前节点有一个节点的距离的节点入栈。最后将当前节点设置为已访问。
具体解释见图14-13:假设当前节点是0号格子,假设步进为1,那么展开0号格子后入栈的就是0号格子周围的四个格子,如左图所示(有网状底纹的格子);当深搜的步进为2时,入栈的是0号格子四周距离它一个格子距离的节点,如右图所示(同样是有网状底纹的格子)。 从0号节点开始深搜的示意图
还应注意的是,四个节点应该按照随机的顺序入栈,否则整个迷宫看上去就不再随机了。
- 将栈顶节点出栈。如果该节点没有被访问过,那么将该节点和它的直接前驱节点(即访问到这个节点之前访问的那个节点)之间的那个格子标记为通道,然后继续展开当前节点;否则忽略该节点。 标记通道节点
- 重复上述过程,直到栈变空为止。这时迷宫中应该包含三类格子:已访问、未访问和通道。 该算法要求迷宫的长和宽都是奇数,否则无法访问到终点方块。算法结束时,可保证至少有一条从起点到终点通道。最后只要将格子复制到新的迷宫内,将未访问的格子设置为墙,已访问的格子和通道格子都设置为通道即可。
14.1.3 代码实现
- 常量定义
在Maze.h头文件中定义游戏开发过程中需要使用到的各种常量,如例程14-3所示。
例程 143 常量定义
1 /* 常量定义 */
1 /* 状态常量 */
2 #define RESULT_SUCCESS 0
3 #define RESULT_CANNOT_MOVE 1
4 #define RESULT_ARRIVED 2
5 /* 迷宫上的符号 */
6 #define PLAYER '@' /* 玩家 */
7 #define BLANK ' ' /* 道路 */
8 #define WALL '#' /* 墙 */
9 #define START '-' /* 起点 */
10 #define DESTINATION '+' /* 终点 */
11 /* 方向按键 */
12 #define UP 'W'
13 #define DOWN 'S'
14 #define RIGHT 'D'
15 #define LEFT 'A'
16 #define QUIT 'Q'
17 #define HEIGHT 5 /* 迷宫的宽度 */
18 #define WIDTH 5 /* 迷宫的高度 */
19 #define INDENT 8 /* 迷宫距离窗口左边界的空格数 */
- 主函数
程序的主函数如下。
例程 144 迷宫游戏主程序
1 int main()
20 {
21 /* 生成的原始迷宫 */
22 char maze[HEIGHT][WIDTH];
23 /* 保存迷宫、起点、终点和玩家位置的数组 */
24 char grid[HEIGHT][WIDTH];
25 /* 用户的按键 */
26 int key;
27 /* 总步数 */
28 int steps = 0;
29 /* 是否已经到达终点的标志 */
30 int reachedDestination = 0;
31 /* 玩家对象 */
32 Item player = { 0, 0, PLAYER };
33 /* 提示信息 */
34 char* message;
35 /* 清屏 */
36 clearScreen();
37 /* 初始化迷宫 */
38 initializeMaze(grid, maze, &player);
39 /* 输出整个迷宫 */
40 printScreen(grid, steps, NULL);
41 while ((key = getKey()) != QUIT)
42 {
43 if (reachedDestination)
44 {
45 continue;
46 }
47 if (key == UP || key == DOWN || key == LEFT || key == RIGHT)
48 {
49 int dx = 0, dy = 0;
50 int ret;
51 ret = step(grid, &player, key);
52 updateGrid(grid, maze, &player);
53 switch (ret)
54 {
55 case RESULT_CANNOT_MOVE:
56 /* 发出蜂鸣声提示用户 */
57 printf("\a");
58 message = "无法移动到指定位置";
59 break;
60 case RESULT_ARRIVED:
61 message = "已经到达终点,按 Q 键退出游戏";
62 reachedDestination = 1;
63 break;
64 default:
65 ++steps;
66 message = NULL;
67 break;
68 }
69 }
70 else
71 {
72 /* 发出蜂鸣声提示用户 */
73 printf("\a");
74 message = "按键无效";
75 }
76 /* 输出整个迷宫 */
77 printScreen(grid, steps, message);
78 }
79 exitGame();
80 return 0;
81 }
主函数的第23行调用getKey()函数来获取用户的按键信息。getKey()的实现如下:
例程 145 getKey()函数
1 int getKey()
82 {
83 int key;
84 /* 使用 _getch() 函数获取一个按键,无需等待换行符 */
85 key = _getch();
86 /* 转换为大写字母之后返回 */
87 key = toupper(key);
88 return key;
89 }