ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C语言初学者对于编写初阶版扫雷游戏代码的心得感悟

C语言初学者对于编写初阶版扫雷游戏代码的心得感悟

尊敬的各位读者和同道之人大家好,这是本人的第二篇博客。这篇博客将介绍我学习并且自行编写基础版扫雷游戏代码的学习心得,望诸公不啬赐教。

扫雷游戏是一款存在时间较长、玩法操作简单并且受众群体广泛的益智小游戏,特别是最基础的扫雷游戏代码非常适合初学者学习掌握并以此巩固编程本领。在比特教育科技有限公司张鹏伟老师的亲自传授下我们观摩了完整的扫雷游戏代码编写过程并在课后动手实践,接下来我将尽我所能详细拆解这段代码背后的底层逻辑,既要让读者能轻松理解编写代码的过程,于我而言也是极佳的复盘机会。

首先从游戏的整体框架也就是业务逻辑开始由表及里的介绍。游戏的第一步是要在操作端界面打印菜单函数,由玩家自主选择是否进入游戏。当玩家选择玩游戏时,则打印全部由*号覆盖的棋盘界面,而存放雷的分布游戏棋盘则被隐藏。玩家输入坐标,若玩家不幸输入的坐标处存放了雷,则游戏失败并完整打印存放雷的分布的棋盘界面。若不是雷,则系统将排查周围一圈8个方格雷的数目并反馈到输入坐标的位置。当玩家排查完所有位置,则宣告游戏成功。以上就是整个游戏过程的总体架构。下面进入细化拆解部分的讲解。

因为棋盘是一个9*9的二维平面,毋庸置疑我们要创建二维数组。那么问题来了,玩家的操作端需要隐藏雷的分布,可是系统需要存储雷的信息否则无法推进游戏。所以我们必须创建两个二维数组,一个是显示在屏幕上的棋盘,另一个则存储雷的布局并且隐藏。随之而来的是第二个问题,我们应该创建什么类型的数组?我们从结果往前倒推,玩家界面需要用*号覆盖,用户完全不知道哪里有雷哪里没有,所以毫无疑问用户界面显示的棋盘背后对应的是字符型二维数组。但是存放雷的信息的数组是什么类型呢?如果我们用0来对其初始化,有雷的位置用1代替,看似这个数组应当设置为整型类型数组。但是我们为了实现两个游戏棋盘的整齐统一,避免代码的编写产生不必要的麻烦和额外的工作量,我们最终决定同样采用字符型数组。数组的类型和数量已经明确,最后一个问题就是数组的大小该是多少?既然要打印9*9的棋盘,似乎直接将行列设置成9看上去没有毛病。但是我们忽略了一个问题,那就是在排查雷时,系统需要统计玩家输入的坐标周围一圈雷的数目。一旦玩家输入的坐标处在棋盘的边界,那么系统在统计雷的数目时不可避免的出现越界访问的问题,会干扰程序的正常运行并影响用户的游戏体验。为了避免这类情况的发生,我们在创建数组时应当将棋盘大小调整为11*11的模式,这样一来就没有问题了。

如图所示,两个数组就被我们创建好了。这里还有一个不可忽视的细节。如果我们在[ ]中填写11,并不妥当。倒不是因为有什么错误,而是直接填入数字日后在调整棋盘大小需要挨个手动修改参数,耗时费力。所以我们采用宏定义的形式,直接声明ROWS,COLS的大小,一旦涉及到修改数据的问题直接从宏定义处入手,方便快捷。如下图所示:

以上就是对于行列的宏定义。需要注意的是除了ROWS和COLS以外我们还定义了ROW和COL两个常量。这是因为设置ROWS和COLS的目的是为了防止越界访问,真正在打印棋盘时仍然采用9*9的格式。还有ROW+2,COL+2必须用圆括号括起来,因为这里牵扯到预处理的原理。预处理时会将宏定义的常量用定义的内容展开,不加括号会因为操作符的优先级的冲突导致程序运行的结果与预期不符。以上细节问题尤为重要。

提到宏定义,顺带着讲一下多文件模式。在复杂的项目中往往会需要多文件并发工程。期中涉及到函数的声明和宏定义我们会单独创建一个头文件来存放以上内容。如下图所示:

其他源文件需要调用时直接声明自定义头文件即可。

棋盘的问题告一段落,接下来将设计游戏初始阶段程序,包括菜单函数和玩家自主选择进入游戏与否的部分。打印菜单比较容易,我们直接将其独立封装为一个函数,名之曰菜单函数,其代码实现如下:

菜单函数的代码实现菜单函数的声明

在main函数中我们开始设计游戏程序。前面提到过,打印菜单后需要玩家自主选择是否进入游戏。如何实现呢?这就需要玩家输入数字,如菜单所示,输入0则退出游戏,输入1则开始游戏,然而输入其他数据则要重新输入重新做选择。于是我们不妨将此过程设计成一个do-while循环,该循环最大的特点就是二话不说直接进入循环体,循环过程至少执行一遍。玩家一上来就要面临选择问题。当玩家做出选择后还要分不同情况作出不同回应,所以可以将循环体设置成switch分支语句,实现“一个萝卜一个坑”的分类效果。循环条件就是玩家的输入数据(input)不为0,玩家输入0和1以外的数字则要重新进入循环继续做选择,玩家输入1玩完游戏后可以让他再玩一遍。于是代码的编译如下:

玩家任意选择是否玩扫雷游戏

在图中我们可以看到case 1:语句之后出现了调用“game()”函数的指令。game()函数是整个游戏程序的灵魂和生命,因为它承载着游戏的运行逻辑。接下来我就围绕着game()函数继续进行本次的讲解。

介绍game函数之前,我们还是需要回到多文件工作模式的话题上来。前面已经创建了一个全新的名为game.h的头文件,它包含各个函数的声明和库函数的头文件,可以理解为所有函数的“说明书”。而源文件则存放着各类函数的具体代码和功能划分。迎面而来的有个问题就是:游戏的主体逻辑和不同函数的定义糅合在同一个源文件中,这样编写分工不过明确,可读性较差。为了区分游戏整理逻辑和函数的封装,我们可以创建两个源文件。其中一个源文件容纳游戏的代码框架,类似于树干;而另一个源文件则存放不同功能、不同定位的函数代码,类似于旁逸斜出的树枝。两个源文件各司其职、职能分工明确,便于阅读和维护。

test.c文件中用game函数容纳游戏的主体逻辑,一目了然

test.c文件承载游戏的测试逻辑game.c源文件存放不同函数的定义

我们正式进入game.c文件的代码编写和释读。在设计程序之前,不妨先按照游戏的底层逻辑梳理一下框架:从棋盘界面的初始化和玩家界面棋盘的打印,到雷的布置,再到玩家输入坐标后系统的判断和反馈,我们依据流程来封装不同函数,达成不同效果,这样介绍就更加有条不紊。

首先是棋盘的初始化。在创建数组之处我们其实就已经考虑好了如何将其初始化:其中一个棋盘(mine_board)全部初始化成字符'0',另一个棋盘(show_board)全部初始化成' * '。从中我们就可以深切体会到一开始将数组统一设置为字符数组的前瞻性和优越性。因为当两个棋盘都是字符数组时我们设计初始化函数对两个数组各自进行初始化操作,不需要设计成两个不同的函数。包括后续打印棋盘时统一的字符数组的优势也会尽显无疑,我们放到后面说。

Initboard函数的代码实现 Initboard函数的声明

初始化的过程相对容易,无非就是将整个棋盘用对应的字符填充。针对二维数组,这本质上就是一个双层for循环遍历二维数组赋值的过程。需要注意的是传参时到底传ROWS和COLS还是ROW和COL呢?这里我们不能把棋盘的初始化和棋盘的打印混淆!我们之所以要区分ROWS和ROW、COLS和COL两组常量,归根结底是为了规避越界访问的风险。初始化时要全面初始化,ROWS*COLS范围内每一个位置都要被赋予初始值,只有在打印时为了实现9*9的效果用ROW和COL。笔者自己在尝试设计程序时就没有做好区分,导致传参时数据混乱,打印出来的棋盘有乱码,输入坐标后反馈的结果有问题。我们在编写代码时要时刻保持头脑清醒。

初始化结束后,随机进入棋盘打印的环节。我们将对应的函数命名为“Displayboard”。注意:打印棋盘时只需要打印show_board的内容而mine_board千万不能打印,毕竟不能让玩家得知雷的分布嘛,那样的话还玩个什么扫雷游戏呢!不过程序员在调试期间为了观察布雷效果则需要打印mine_board布置雷区后的结果。于是这里再一次体现出程序设计之初我们将mine_board和show_board数组统一设置为字符数组的好处。除此之外,还有一点需要各位关注:打印时传参必须是ROW和COL,原因刚才也提到过,这里就不再过多赘述了,大家也很好理解。打印棋盘的过程实质上也是双层for循环对于二维数组的遍历,和初始化如出一辙。

Displayboard函数的代码实现 Displayboard函数的声明

通过函数的代码和实际运行效果我们可以看出为了方便玩家观察我们棋盘打印的实际效果特地打印了行号和列号。注意列号需要前置打印,相当于先打印从0~9 的一串数字。然后每打印一行的‘ * ’前先打印行号,即在第一层循环中每进入一次循环体就要先打印行号,而且从1开始不能从0开始。最左上角的0既充当初始行号又充当初始列号,它的存在是为了让行号的1~9数字和列号的1~9数字与棋盘对齐。关于行号列号的循环打印在代码中处于什么位置稍有不慎就容易出错,希望大家一定要小心。

棋盘的初始化和打印的准备工作业已告终,下面我们就要布置雷了。同样,布置雷的程序也要封装为独立的函数,我们名之曰“Set_mine”。这个函数只针对“mine_board”数组。如何设置雷前面大致提到过,底层逻辑就是随机选取10个位置并用字符1替代。看似不难,实则在代码的实际编译时我们需要思虑周密,否则运行结果会与我们的预期南辕北辙。首先我们此前提到过,因为未来我们会对游戏的难度进行调整,棋盘大小会变那么雷的数目也会随之而变。为了后期修改调整数据的方便我们可以在game.h头文件中用宏定义声明EASY_COUNT常量并赋予雷的数目。雷的数目问题解决完后,既然提到了“随机”二字,那么随机数生成代码则少不了,产生随机数依赖随机数种子,同时<time.h>头文件少不了除此之外,我们需要明白雷的随机分布是二维坐标的随机分布,也就是(x,y)的随机生成。x和y作为随机变量需要分别代表对应位置的行号列号,那么我们就要用rand()%a+b的代码来为x和y赋值。这里头文件<stdlib.h>不容忽视。具体代码如下:

布置雷过程实质上是重复EASY_COUNT次随机生成(x,y)的过程,所以我们可以设计一个while循环来布雷。那么问题来了:循环条件到底应该怎么写呢?怎么判断循环什么时候终止呢?这个时候我们可以设定一个int 类型的名为count的变量并初始化为EASY_COUNT的值。每放置一个雷,,将原来的字符' 0 '改为字符' 1 ',与此同时count的数值自动减1,直至减到0为止。在循环过程中还有一点要格外注意,那就是两次生成的随机坐标可能恰巧会处在同一位置,为了避免这种情况的出现,我们需要在循环体中写一个if循环的判断,在设置雷之前先判断该位置是否已经被布置过雷。这样,布置雷的函数主体逻辑我们就梳理好了,接下来是其具体的代码实现:

Set_mine函数的代码实现 Set_mine函数的声明

当雷已经在棋盘上全部布置好了之后,距离玩家真正上手实操已经不远了。在此之前还有几个步骤,第一个便是排查雷的数目并反馈在棋盘界面。先前我们提到过,扫雷游戏的规则就是玩家随机输入一个坐标值,系统需要统计周围一圈的雷的数目并返回结果。所以我们可以独立封装一个函数用于排查雷。统计的方法是什么呢?请看下面的示例:

(x-1,y-1)(x-1,y)(x-1,y+1)
(x,y-1)(x,y)(x,y+1)
(x+1,y-1)(x+1,y)(x+1,y+1)
' 1 '' 0 '' 0 '
' 0 '' 0 '' 0 '
' 0 '' 1 '

' 0 '

排查的原理,就是把周围一圈8个雷的ASCII值全部加起来然后减去8个字符‘ 0 ’的ASCII值之和。周围的格子若没有雷那么' 0 ' - ' 0 '的结果还是0,而有雷的情况下就是' 1 ' - ' 0 '的结果。具体的代码实现如下:

Get_mine函数的实现 Get_mine函数的声明

接下来进入程序设计的最后一步,就是玩家的动手操作与系统交互的过程用函数来实现。我们这里可以将函数命名为“Play_game”。按照游戏玩法,玩家需要先输入坐标值,系统来判断并给出反馈。判断的底层逻辑是:如果玩家输入的坐标对应的棋盘位置恰好有雷,那么则在屏幕打印游戏失败并打印mine_board让玩家知晓所有雷的真实分布。若没有雷,那么将排查的结果返回到屏幕上玩家输入的坐标所在位置。这里因为只是扫雷游戏初阶版的程序设计,所以暂不考虑其他更加复杂的游戏要素比如输入坐标后方格的拓展之类的。具体的代码实现如下:

Play_game函数的代码实现

至此,游戏所有模块的函数设计都已经结束,正如制造一辆汽车前所有零部件准备齐全就差将它们组装在一起了。在test.c文件里面的game()函数中我们需要按照游戏的运行逻辑调动支配不同的函数以实现预期的效果:

game()函数

以上就是初阶版扫雷游戏程序设计的全部内容。在本文中,笔者尽最大努力力求将整个代码拆解为一个又一个具体的步骤便于理解其背后的原理。当然,由于笔者能力有限,文中不乏会出现一些错误,望各位同仁能批评指正,我将结合大家的意见进行勘误。不得不说,扫雷游戏的确是我学习C语言以来到目前为止所经历过的最复杂的一个项目,在学习时遇到过许许多多的问题。但是经过总结复盘以及与AI的交互问答,我逐渐掌握了扫雷程序设计的要领。希望大家看到我的这篇博客后有所启发,我们一道互帮互助共同进步!

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进