各位小伙伴们大家好,继三子棋小游戏之后,我们的C语言游戏专栏(基础篇)迎来了第二位成员“扫雷”。在这篇专栏中,小风将带着大家我们所学的一些基础知识来实现出一些非常有趣的小游戏,让大家带着兴趣的学习编程的同时,也能锻炼自己的实践运用能力,培养大家初学时做项目的基础
一、游戏框架设计
下图是一张网页上的扫雷界面,从中我们可以观察到整个游戏的界面还是比较简洁的,抓住其中的要素是我们使用C语言进行编程模拟游戏功能的关键。因此让我们一起发掘出其中的关键要素有哪些,并联想需要哪些知识内容加以解决吧:
- 映入眼帘的便是整个游戏界面的展示,其中最需要关注便是我们的整个扫雷的棋盘了,这是一个有9×9的小方块组成的,虽然无法模拟的这么相像的可视化界面,但我们知道这其中肯定涉及到数组(而且还是一个二维数组),因此我们找到了整个游戏所用到的核心知识点:数组。
其余的想难度等级设置,以及菜单的打印这些我们只需要通过循环分支结构实现即可。当然了,整个游戏的实现并非像我说的这么简单,如何让所有功能有机地结合在一起,其中的逻辑还是需要我们进一步的理清楚的。
框架设计
首先当我们在进行开始着手一个项目的的时候,并不是直接上手直接编程,这样的话效率不是很高,而且如果是想到那些到哪的话,这样将会是我们的程序逻辑感很差,也难以理解,甚至到后可能不得不终止。因此在开始之前,我们的首要任务是对整个项目的框架建设计,就像盖房子一样,后面在对其不断的加工,使得整个项目条理清晰、完整而美观。
下图是小风在编写游戏之前所构建的流程图,整个游戏的思路也是以此为依据进行编写的:
其实所有游戏基本上都简单的化分成三大模块:游戏登录界面、进入游戏控制界面以及退出游戏。然后我们再根据所划分的三大模块进行更细节的构建。
二、游戏所需知识点
我们基础篇的游戏专栏的游戏所涉及的知识点一般都是非常基础的,例如函数、循环分支结构、数组等等之类的,而指针和结构体这些模块基本都不会涉及。首先是因为这一模块的本身的知识点理解起来就很难,需要考虑空间地址占用问题,其次的我们基础篇的游戏专栏更注重大家的基础综合能力,只有这些能力都提升上来了,那么学习后面的知识也会感觉轻松很多。
在该项目中,我们将会使用哪些知识来进行搭建呢?
首先最基础的便是选择分支结构。在我们项目中存在各种条件的判断及选择,因此选择分支结构使我们程序当中必不可少的一环,其中涉及的有:if...else语句、if...else if...else语句、switch...case基本都涉及,在什么样的场合下,该使用那种分支结构更加合适,相信大家学完之后一定会深有体会的哈哈。
其次便是我们的循环结构了。在游戏当中,登录界面的重置,数组的初始化,以及一些双层循环的控制等等,使用循环结构对整个程序的实现的帮助真的不容忽视。在程序当中:像while循环、for循环以及do...while三大循环均有使用,说到这大家是不是非常期待呢?
然后就是我们的函数了。整个程序当中封装了很多的函数,函数的特点在这就不多说了,但我们设计函数的时候最好是本着高内聚低耦合的特点来设计(即我们设计函数要求独立性强,模块化程度高,每一个函数的分工明确)。向我们项目所涉及的函数有:
- //打印游戏菜单:void Print();
- //游戏控制中枢:void game_admin();
- //初始化棋盘:void InitBoard(char arr[ROWS][COLS], int rows, int cols, char ch);
- //显示棋盘:void DisplayBoard(char arr[ROWS][COLS], int row, int col);
- //布置雷:int SetMine(char arr[ROWS][COLS], int row, int col);
- //排查雷:void FindMine(char mine[ROWS][COLS], char show[ROWS][COLS], int row, int col, int order);
这样看下去是否思路已经比较清晰了呢,解下来我们只需要完善每个函数的内容就可以了。
相信细心地的小伙伴发现了我们的函数名的命名都会尽可能的表示其功能,这样也更能方便我么理解和维护。其次便是我们之前所说的数组是我们整个游戏的核心,通过观察我们不难发现绝大部分函数的参数都包含二维数组作为参数。
三、扫雷游戏实现
1.分文件创建项目
分文件开发是目前主流开发方式,基本上所有的工程都是这样开发的。分文件可以将将我们的整个项目大致分为三大部分:
- 自定义头文件:用于封装整个项目所需调用的头文件、常量的定义、自定义函数的声明。
- 函数封装文件:通常需要引用我们自定义的头文件,在这个文件中主要包含的是实现各种功能的函数的定义(也可以将单独一个函数进行封装成一个文件,根据具体的需求)
- 主函数文件:整个项目的入口,即只包含了一个main函数
分文件操作的好处:
- 便于复用代码。通用性强的重复的功能只要写一遍就可以了,下次要用在其它程序上时只要更改很小的部分或者可以不用更改。
- 便于多人协作。在设计软件之初就可以很清楚地分配各个开发部门的任务。模块的编写者本身只要关注他所写的东西,清楚这一部分的功能,留出接口就可以了。另外,对于整个工程的负责人而言,这样会方便浏览全局的工作进度,统筹人员安排。
- 便于修改和维护。如果能确定只是某个模块有问题,在模块内解决即可,不需要牵一发而动全身。要升级某一部分的功能,可以只针对具体的模块重新开发,节约成本。
2.游戏完整项目代码
头文件部分:minesweep.h
#pragma once
#include<stdio.h>
#include<stdlib.h>
#include<time.h>
#include<string.h>
//有效行、列
#define ROW 9
#define COL 9
//扩展行、列
#define ROWS ROW+2
#define COLS COL+2
//难度设定
#define EASY 10
#define MIDDLE 20
#define HARD 30
//打印游戏菜单
void Print();
//游戏控制中枢
void game_admin();
//初始化棋盘
void InitBoard(char arr[ROWS][COLS], int rows, int cols, char ch);
//显示棋盘
void DisplayBoard(char arr[ROWS][COLS], int row, int col);
//布置雷
int SetMine(char arr[ROWS][COLS], int row, int col);
//排查雷
void FindMine(char mine[ROWS][COLS], char show[ROWS][COLS], int row, int col, int order);
函数封装部分:MineSweep_Fun.c
#define _CRT_SECURE_NO_WARNINGS
#include "minesweeper.h"
//打印棋盘
void Print()
{
printf("************************************************\n");
printf("************** 1-->启动游戏 *************\n");
printf("************** 0-->退出游戏 *************\n");
printf("************************************************\n");
}
//进入游戏
void game_admin()
{
char mine[ROWS][COLS] = { 0 }; //隐式棋盘,假设存储的内容全是字符'0'
char show[ROWS][COLS] = { 0 }; //显式棋盘,假设存储的内容全是字符'*'
int order = 0;
//初始化棋盘
InitBoard(mine, ROWS, COLS, '0');
InitBoard(show, ROWS, COLS, '*');
//打印棋盘
DisplayBoard(mine, ROW, COL);
DisplayBoard(show, ROW, COL);
//布置雷
order = SetMine(mine, ROW, COL);
DisplayBoard(mine, ROW, COL);
//排查雷
FindMine(mine, show, ROW, COL, order);
}
//初始化棋盘
void InitBoard(char arr[ROWS][COLS], int rows, int cols, char ch)
{
int i = 0;
int j = 0;
//给棋盘填充符号
for (i = 0; i < rows; i++)
{
for (j = 0; j < cols; j++)
{
arr[i][j] = ch;
}
}
}
//显示棋盘
void DisplayBoard(char arr[ROWS][COLS], int row, int col)
{
int i = 0;
int j = 0;
//界面优化
printf("\n 《扫雷》 \n");
for (i = 0; i <= row; i++)
printf("%2d", i);
printf("\n-----------------------\n");
for (i = 1; i <= row; i++)
{
printf("%2d|", i);
for (j = 1; j <= col; j++)
{
printf("%c ", arr[i][j]);
}
puts("");
}
}
//布置雷
int SetMine(char arr[ROWS][COLS], int row, int col)
{
int count = -1; //雷的个数
int order = 0; //记录难度等级
//选择游戏模式
do {
char str[10] = { 0 };
//设置模式选择
printf("请选择游戏模式(EASY / MIDDLE / HARD):");
scanf("%s", str);
if (strcmp(str, "EASY") == 0)
{
count = EASY;
break;
}
else if (strcmp(str, "MIDDLE") == 0)
{
count = MIDDLE;
break;
}
else if (strcmp(str, "HARD") == 0)
{
count = HARD;
break;
}
else
printf("输入错误,请重新选择!\n");
} while (count);
order = count;
//插入雷
while (count > 0)
{
//随机生成雷的坐标
int x = rand() % ROW + 1;
int y = rand() % COL + 1;
//判断插入的位置是否被占用
if (arr[x][y] == '0')
{
arr[x][y] = '1';
count--;
}
}
return order;
}
//计算雷的数目
int Caculate(char mine[ROWS][COLS], int x, int y)
{
return (
mine[x-1][y-1] +
mine[x][y-1] +
mine[x+1][y-1] +
mine[x-1][y] +
mine[x+1][y] +
mine[x-1][y+1] +
mine[x][y+1] +
mine[x+1][y+1] - '0'*8
);
}
//排查雷
void FindMine(char mine[ROWS][COLS], char show[ROWS][COLS], int row, int col, int order)
{
int x = 0;
int y = 0;
int count = 0; //排查次数
while (1)
{
int num = 0;
printf("请输入排查雷的位置(0 < x < 10, 0 < y < 10):");
scanf("%d %d", &x, &y);
if ((x > 0) && (x < 10) && (y > 0) && (y < 10))
{
if (mine[x][y] == '0')
{
num = Caculate(mine, x, y);
show[x][y] = '0' + num;
}
else
{
printf("恭喜你被炸飞了,游戏结束!\n\n");
break;
}
count++;
//展示每一次的排查情况
DisplayBoard(show, row, col);
//当所有的空位置都被排查出来后,则通过关卡
if (count == row * col - order)
{
printf("恭喜你,成功通关!\n");
break;
}
}
else
printf("输入错误,请重新输入位置信息!\n");
}
}
主函数部分:main.c
#define _CRT_SECURE_NO_WARNINGS
#include "minesweeper.h"
int main()
{
int input = 0;
srand((unsigned int)time(NULL));
do {
Print();
printf("请选择你进行的操作(1/0):");
scanf("%d", &input);
switch (input)
{
case 1:
game_admin();
break;
case 0:
break;
default :
printf("\n输入错误,请重新选择!\n\n");
break;
}
} while (input);
return 0;
}
四、游戏各个部分设计细节思路
主函数模块分析
这里我们采用的是do...while和switch...case嵌套来设计实现我们登录界面的进入,通过input的值巧妙地将分支和循环实现了一个联动,通过下图的一个展示我们发现主函数还是很容易理解的。
封装函数模块
1.Print()函数:登录界面的打印
通过微调printf函数的输出细节,最终实现上图的一个界面效果
2.game_admin()函数:游戏的接口函数,整个函数调用的核心
这一部分在整个游戏项目中可以说是相当重要的,影响着整个游戏能否正常的运行。
首先让我们来观察这一部分的代码,不难发现基本上整个项目中的大部分函数都被集成在这个函数中,相当于是游戏核心,调度游戏中各种功能。
为了方便大家理解,基本上每一步的细节都有注释。在这其中,我们会经常看见ROW、COL、ROWS、COLS四个参数总是重复出现。其实这几个参数使我们在头文件“minesweep.h”中用#define宏定义的常量。
相信很多小伙伴会有这么一个疑问,既然定义了行和列为什么还要对其进行扩展呢,ROWS、COLS两个参数会不会有点多余?
答案显然是否定的,这是为了防止数组越界的作用。例如当我们在进行扫雷时,由于计算的是该区域周围雷个数的总和,如果计算的位置是处于整个棋盘的边界时(例如图中的浅蓝色区域),并且并未对棋盘进行扩展,则在扫到下方区域的方格时,数组必然会产生越界。
所以实际的棋盘大小是11×11的区域,但有效区域是9×9,这片区域也是我们排查雷地和显示雷区的区域。(图中的字符‘1’表示有雷,字符‘0’表示无雷)
3.void InitBoard(char arr[ROWS][COLS], int rows, int cols, char ch)初始化棋盘函数:
//初始化棋盘
void InitBoard(char arr[ROWS][COLS], int rows, int cols, char ch)
{
int i = 0;
int j = 0;
//给棋盘填充符号
for (i = 0; i < rows; i++)
{
for (j = 0; j < cols; j++)
{
arr[i][j] = ch;
}
}
}
在该游戏的设计过程中,实际上我们打印了两个棋盘:一个是mine,在这个区域中,我们能清晰看见埋雷的情况(如果显示出来的话就相当于是开挂了哈哈);另一个则是show,顾名思义,这是展示给玩家看的棋盘,在这片区域,玩家们可以看见自己扫雷位置周围雷数的总和。
这两个棋盘大小一模一样,show相当于mine的投影,实际算出雷的数目是赋值给了show数组,而mine数组的元素并未发生改变,这也使得计算雷的数目更加方便,计算方法不会因为计数而改变,通过mine和show数组的协同变化,大大简化了其中的逻辑。如下图所示:
4.void DisplayBoard(char arr[ROWS][COLS], int row, int col)显示扫雷过程函数
这部分的主要注意的细节还是对整个棋盘外观的微调,只要将这部分设置好,通过循环打印值即可
5.int SetMine(char arr[ROWS][COLS], int row, int col)布置雷函数
该层函数所实现的功能还是比较多的,而且有很多小细节通常是我们容易忽视的,例如:
- 布置雷的个数是通过电脑随机实现的,但雷的个数却是依赖于我们所选则的难度等级(分为EASY / MIDDLE / HARD)三种方式(具体细节看代码,比较两个字符串是否相同需要使用<string.h>)。
- 注意细节,我们在布置雷的时候,由于位置是随机生成的,很有可能会发生重复插入的情况,因此在布置之前需要检查该位置是否有雷,并且通过count来控制插入雷的个数达到要求
//布置雷
int SetMine(char arr[ROWS][COLS], int row, int col)
{
int count = -1; //雷的个数
int order = 0; //记录难度等级
//选择游戏模式
do {
char str[10] = { 0 };
//设置模式选择
printf("请选择游戏模式(EASY / MIDDLE / HARD):");
scanf("%s", str);
if (strcmp(str, "EASY") == 0)
{
count = EASY;
break;
}
else if (strcmp(str, "MIDDLE") == 0)
{
count = MIDDLE;
break;
}
else if (strcmp(str, "HARD") == 0)
{
count = HARD;
break;
}
else
printf("输入错误,请重新选择!\n");
} while (count);
order = count;
//插入雷
while (count > 0)
{
//随机生成雷的坐标
int x = rand() % ROW + 1;
int y = rand() % COL + 1;
//判断插入的位置是否被占用
if (arr[x][y] == '0')
{
arr[x][y] = '1';
count--;
}
}
return order;
}
6.void FindMine(char mine[ROWS][COLS], char show[ROWS][COLS], int row, int col, int order)排查雷函数
排查周边的八个位置的总和,由于这里的数组元素都是字符类型,所以计算实应注意ASCII的转换
//计算雷的数目
int Caculate(char mine[ROWS][COLS], int x, int y)
{
return (
mine[x-1][y-1] +
mine[x][y-1] +
mine[x+1][y-1] +
mine[x-1][y] +
mine[x+1][y] +
mine[x-1][y+1] +
mine[x][y+1] +
mine[x+1][y+1] - '0'*8
);
}
//排查雷
void FindMine(char mine[ROWS][COLS], char show[ROWS][COLS], int row, int col, int order)
{
int x = 0;
int y = 0;
int count = 0; //排查次数
while (1)
{
int num = 0;
printf("请输入排查雷的位置(0 < x < 10, 0 < y < 10):");
scanf("%d %d", &x, &y);
if ((x > 0) && (x < 10) && (y > 0) && (y < 10))
{
if (mine[x][y] == '0')
{
num = Caculate(mine, x, y);
show[x][y] = '0' + num;
}
else
{
printf("恭喜你被炸飞了,游戏结束!\n\n");
break;
}
count++;
//展示每一次的排查情况
DisplayBoard(show, row, col);
//当所有的空位置都被排查出来后,则通过关卡
if (count == row * col - order)
{
printf("恭喜你,成功通关!\n");
break;
}
}
else
printf("输入错误,请重新输入位置信息!\n");
}
}
五、最终游戏运行过程展示
真的太好玩了吧,哈哈哈!大家一起动手实践起来吧!
六、总结
以上便是整个代码实现的整个流程分析讲解,当然啦,还需要各位小伙伴们自己的不断尝试和实践,这中间可能会杯出现各种bug卡主,需要我们进行调试分析,但是只要我们能够坚持不懈得去克服,最终我们的能力一定会有长足的进步!最后希望小风的这篇文章能对大家有所帮助!