扫雷开发笔记

扫雷开发笔记

思维导图

扫雷游戏开发(1)

流程图

.png

难度设置

简单:9*9 10雷

中级: 16*16 40雷

困难 : 20*20 70雷

完整代码

Github仓库,如果喜欢可以点个Star。

写在前面

我们使用了二位数组来实现扫雷,因Easyx库使用了C++的库且C++已适配绝大部分C语言的语法,所以我们提交的是.cpp文件,并封装了exe文件,可以直接双击运行。

开发环境

使用IDE:VS 2022

项目属性:使用多字节字符集

C语言标准:C89

头文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <easyx.h>  // EasyX图形库,安装方式请见官网文档
#include <stdio.h>
#include <time.h> //计时库
#include <windows.h> //windows的库,用于获取时间戳

#define MAX_ROW 20
#define MAX_COL 20 //以最大雷区作为常量
#define IMG_SIZE 40 // 这个数字用于实现从二维数组到游戏窗口的坐标转化

IMAGE img[13]; // 以数组的方式引入图片
int mine[MAX_ROW + 2][MAX_COL + 2]; //+2是为了解决边缘问题
int num = 0;
int mx;
int my;
int ROW = 16, COL = 16, MINE_NUM = 40;

//定义时间常量
time_t start_time;
time_t current_time;
int elapsed_time;

时间戳:时间戳是使用数字签名技术产生的数据,因为程序的运行时间不同,时间戳也会不断变化,我们使用它作为生成随机数的种子。

游戏初始化模块(gameInit)

开始界面停留

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
31
32
33
34
35
initgraph(IMG_SIZE * ROW + 240, IMG_SIZE * COL);  //创建游戏窗口
int mid1 = (IMG_SIZE * ROW + 240) / 2;
int mid2 = (IMG_SIZE * COL) / 2;
int y_length = IMG_SIZE * COL;
int x_length = IMG_SIZE * ROW + 240;

settextstyle(30, 0, _T("宋体")); //这里是以字符的左上角作为坐标基准
settextcolor(WHITE);
const char* text = "开始";

// 获取字符串的宽度和高度
int textWidth = textwidth(text);
int textHeight = textheight(text);

// 计算字符串的中央位置
int centerX = x_length / 2 - textWidth / 2;
int centerY = y_length / 2 - textHeight / 2;


cleardevice();

outtextxy(centerX, centerY, text);


while (1) {
ExMessage em;
if (peekmessage(&em, EX_MOUSE)) {
if (em.message == WM_LBUTTONDOWN) {
if (em.x >= centerX && em.x <= centerX + textWidth &&
em.y >= centerY && em.y <= centerY + textHeight) { // 点击开始(处于坐标范围内),否则用户永远卡在该循环内。
break;
}
}
}
}

这里使通过点击处是否处于字符坐标区间内来判定是否开始。

初始化计时器

1
2
3
4
5
6
7
8
9
// 初始化计时器
start_time = time(NULL);
current_time = start_time;
elapsed_time = 0;

// 设置字体和颜色
settextstyle(20, 0, _T("宋体"));
settextcolor(RGB(255, 255, 255));
outtextxy(IMG_SIZE * ROW + 20, 20, _T("Time: "));

这里没什么好说的。

加载图片

1
2
3
4
5
6
7
// 加载图片
char buf[260] = ""; //用于存储图片路径
for (int i = 0; i < 12; ++i) {
memset(buf, 0, sizeof(buf));//每次循环前,使用 memset 函数将 buf 数组清零
sprintf_s(buf, "./img/%d.jpg", i); //将路径格式化
loadimage(&img[i], buf, IMG_SIZE, IMG_SIZE);
}

memset函数能够快速填充空字符来清楚buff数组。

随机数种子

1
2
3
4
5
LARGE_INTEGER frequency;
LARGE_INTEGER start;
QueryPerformanceFrequency(&frequency);
QueryPerformanceCounter(&start);
srand((unsigned int)(start.QuadPart));

以当前时间戳来作为生成随机数的种子,使得在短时间内多次启动程序生成的随机数都是不同的。

生成雷区

1
2
3
4
5
6
7
8
9
10
11
12
int row, col;
memset(mine, 0, sizeof(mine)); // 清空雷区

for (int i = 0; i < MINE_NUM;) { //随机生成雷
row = rand() % ROW + 1;
col = rand() % COL + 1; //rand函数,1-9之间随机生成值
if (mine[row][col] == 0) {
mine[row][col] = 9; //9表示地雷
++i;
}
}

更新格子数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 更新周围数字(九宫格内雷的数量)
for (int i = 1; i < ROW; ++i) {
for (int j = 1; j < COL; ++j) {
if (mine[i][j] == 9) {
for (int a = i - 1; a <= i + 1; ++a) {
for (int b = j - 1; b <= j + 1; ++b) {
if (mine[a][b] != 9) {
mine[a][b]++;
}
}
}
}
}
}

数据偏移加密

1
2
3
4
5
6
7
// 给所有格子加上偏移量,防止与被翻开格子冲突,也是加密数据的一种表现
for (int i = 1; i < ROW + 1; ++i) {
for (int j = 1; j < COL + 1; ++j) {
mine[i][j] += 20; //只有显示为29的才是雷,在20-28之间的都是非雷格子,点击翻开格子和递归翻开格子的操作就是-20
}
}

类似于凯撒密码,但这里的偏移值为20

游戏胜利判断模块(isOver)

检查是否踩到雷

1
2
3
4
5
6
7
8
9
10
bool isHitMine = false;  //布尔型变量,只有0和1两种形式(即true和flase)
for (int i = 1; i < ROW + 1; ++i) {
for (int j = 1; j < COL + 1; ++j) {
if (mine[i][j] == 9 && (mine[i][j] < 20 || mine[i][j] > 29)) { // 雷被翻开
isHitMine = true;
break;
}
}
if (isHitMine) break;
}

布尔型变量:其只有0和1两种形式,即flase和true,常作为条件判断。

重置计数器并弹出消息盒子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 如果踩雷
if (isHitMine) {
int ok = MessageBox(GetHWnd(), "还要排雷吗", "你没了", MB_OKCANCEL); //easyx的函数,弹出消息盒子
if (ok == IDOK) {
// 重置雷区
for (int i = 1; i < ROW + 1; ++i) {
for (int j = 1; j < COL + 1; ++j) {
if (mine[i][j] == 9) {
mine[i][j] += 20; // 将雷标记为已翻开
}
}
}
num = 0;
start_time = time(NULL); // 重置游戏开始时间
elapsed_time = 0; // 重置计时器
gameInit(); // 重新初始化雷区和图像
}
else {
exit(-1); // 退出游戏
}
}

exit(-1);:终止当前进程。

检查非雷格子被翻开

1
2
3
4
5
6
7
8
int uncoveredNonMine = 0;            //翻开的非雷格子数量
for (int i = 1; i < ROW + 1; ++i) {
for (int j = 1; j < COL + 1; ++j) {
if (mine[i][j] < 20 && mine[i][j] != 9) { // 非雷且已翻开
uncoveredNonMine++;
}
}
}

游戏绘制模块 (gameDraw)

对指定格子进行贴图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
for (int i = 1; i < ROW + 1; ++i) {
for (int j = 1; j < COL + 1; ++j) {
int x = (j - 1) * IMG_SIZE;
int y = (i - 1) * IMG_SIZE;
if (mine[i][j] >= 0 && mine[i][j] <= 9) {
putimage(x, y, &img[mine[i][j]]);
}
else if (mine[i][j] >= 20 && mine[i][j] < 30) {
putimage(x, y, &img[10]);
}
else if (mine[i][j] > 29) {
putimage(x, y, &img[11]);
}
}
}

显示计数器

1
2
3
4
5
6
7
8
    // 显示计时器
char time_str[20];
sprintf_s(time_str, "%d s", elapsed_time);
settextcolor(RGB(255, 255, 255));
outtextxy(IMG_SIZE * ROW + 80, 20, _T(" "));
settextcolor(RGB(0, 0, 0));
outtextxy(IMG_SIZE * ROW + 110, 20, time_str);
}

递归展开格子模块(openNUll)

1
2
3
4
5
6
7
8
9
10
11
12
if (mine[r][c] == 0) {
for (int i = r - 1; i <= r + 1; ++i) {
for (int j = c - 1; j <= c + 1; ++j) {
if ((mine[i][j] == 20 || mine[i][j] != 29) && mine[i][j] > 9) {
mine[i][j] -= 20;
num++;
openNUll(i, j);
}
}
}
}

开始菜单模块(showMenu)

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
31
32
33
34
35
36
37
initgraph(400, 300);  // 创建菜单窗口
setbkcolor(WHITE);
cleardevice();

settextstyle(20, 0, _T("宋体"));
settextcolor(BLACK);
outtextxy(150, 50, _T("选择游戏难度:"));
outtextxy(150, 100, _T("1. 简单"));
outtextxy(150, 140, _T("2. 中等"));
outtextxy(150, 180, _T("3. 困难"));

while (1) {
ExMessage em;
if (peekmessage(&em, EX_MOUSE)) {
if (em.message == WM_LBUTTONDOWN) {
if (em.x > 150 && em.x < 250 && em.y > 100 && em.y < 120) { // 点击简单(处于坐标范围内)
ROW = 9;
COL = 9;
MINE_NUM = 10;
break;
}
else if (em.x > 150 && em.x < 250 && em.y > 140 && em.y < 160) { // 点击中等
ROW = 16;
COL = 16;
MINE_NUM = 40;
break;
}
else if (em.x > 150 && em.x < 250 && em.y > 180 && em.y < 200) { // 点击困难
ROW = 20;
COL = 20;
MINE_NUM = 70;
break;
}
}
}
}
closegraph(); // 关闭菜单窗口

侧边栏提示模块(tips)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
current_time = time(NULL);
elapsed_time = (int)(current_time - start_time);

char time_str[20];
sprintf_s(time_str, "%d s", elapsed_time);

settextcolor(RGB(255, 255, 255)); //设置时间字体颜色。
outtextxy(IMG_SIZE * ROW + 85, 20, time_str); //easyx的输出
outtextxy(IMG_SIZE * ROW + 15, 50, _T("鼠标左键:翻开格子"));
outtextxy(IMG_SIZE * ROW + 15, 80, _T("鼠标右键:标记地雷"));
outtextxy(IMG_SIZE * ROW + 15, 110, _T("所有的非雷格子都被打开"));
outtextxy(IMG_SIZE * ROW + 15, 140, _T("才算游戏胜利"));
outtextxy(IMG_SIZE * ROW + 15, 170, _T("祝你游戏愉快"));

鼠标消息处理模块(mouseClick)

左键点击并保护雷区

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
31
32
static bool firstClick = true;  // 用一个变量标记是否为第一次点击
ExMessage em; //Easyx函数存储消息
if (peekmessage(&em, EX_MOUSE)) {
mx = em.y / IMG_SIZE + 1;
my = em.x / IMG_SIZE + 1;

if (em.lbutton) { // 鼠标左键点击
if (firstClick) {
firstClick = false; // 标记第一次点击已完成,准备随机布雷。

// 生成雷区并保护第一次点击及其周围,不能在周围生成雷
int protectedRow = mx;
int protectedCol = my; //获取鼠标点击位置。

// 随机生成雷区
int row, col;
memset(mine, 0, sizeof(mine)); // 清空雷区,清除gameinit函数的第一次结果
for (int i = 0; i < MINE_NUM;) {
row = rand() % ROW + 1;
col = rand() % COL + 1;

// 跳过第一次点击区域及其周围九宫格
if (row >= protectedRow - 1 && row <= protectedRow + 1 &&
col >= protectedCol - 1 && col <= protectedCol + 1) {
continue;
}

if (mine[row][col] == 0) {
mine[row][col] = 9; // 9 表示地雷
++i;
}
}

更新周围数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 更新周围数字
for (int i = 1; i < ROW + 1; ++i) {
for (int j = 1; j < COL + 1; ++j) {
if (mine[i][j] == 9) {
for (int a = i - 1; a <= i + 1; ++a) {
for (int b = j - 1; b <= j + 1; ++b) {
if (mine[a][b] != 9) {
mine[a][b]++;
}
}
}
}
}
}

加密数据

1
2
3
4
5
for (int i = 1; i < ROW + 1; ++i) {
for (int j = 1; j < COL + 1; ++j) {
mine[i][j] += 20;
}
}

翻开格子

1
2
3
4
5
6
    if (mine[mx][my] > 9) {
mine[mx][my] -= 20;
openNUll(mx, my);
num++;
}
}

右键标记功能

1
2
3
4
5
6
7
8
9
10
        else if (em.rbutton) {  // 鼠标右键标记
if (mine[mx][my] > 9 && mine[mx][my] <= 29) {
mine[mx][my] += 20;
}
else {
mine[mx][my] -= 20;
}
}
}
}

主函数部分(main)

1
2
3
4
5
6
7
8
9
10
11
12
13
int main() {
showMenu();
gameInit();
while (true) { //不断循环保证数据随时更新
tips();
mouseClick();
gameDraw();
isOver();
}

return 0;
}

整体原理

  • 菜单的调用程序从 main函数开始执行,首先调用 showMenu函数创建一个菜单窗口,展示不同难度选项供玩家选择,玩家通过鼠标点击相应区域确定游戏难度(简单、中等、困难),对应不同的行数、列数和雷的数量设置。
  • 初始化操作选择完难度后,gameInit函数被调用,进行多方面的初始化操作。先是利用 initgraph初始化图形界面,设置好窗口大小并准备显示游戏内容。接着初始化计时器,记录游戏开始时间,用于后续统计游戏用时。同时,加载游戏中所需的各类图片资源,像代表数字、空白格、地雷等不同状态的图片。然后,生成雷区布局,通过获取合适的随机数种子,按照设定好的雷数,在排除首次点击及其周边九宫格范围的情况下,随机放置地雷,并根据地雷分布更新周围格子所对应的周边地雷数量,最后通过给每个格子数据添加偏移量的方式对雷区数据进行加密处理,便于后续判断格子状态和展开操作。
  • 界面更新与提示:在游戏运行的主循环(main函数中的 while (true)循环)里,每次循环都会调用 tips函数来更新游戏界面右侧的提示信息,包括显示游戏耗时以及告知玩家鼠标左右键的操作功能(左键翻开格子、右键标记地雷等),让玩家清楚游戏进展情况和操作方法。
  • 鼠标交互处理mouseClick函数负责处理鼠标消息。对于鼠标左键点击,若为首次点击,会保护点击位置及其九宫格区域不布雷,随后生成雷区、更新周围雷数并加密雷区;若非首次点击,会根据点击格子的状态进行相应处理,比如翻开空白格及递归展开其周围空白格,同时统计翻开格子数量。对于鼠标右键点击,则是实现对格子进行标记地雷或取消标记的功能,通过改变对应格子存储的数据来体现标记状态的切换。
  • 界面绘制展示gameDraw函数根据雷区二维数组中每个格子的数据值,确定要在对应位置绘制的图片(例如对应数字的图片、空白格图片、地雷图片等),将雷区当前的状态直观展示在游戏窗口上,同时更新显示游戏的计时器信息,实时呈现游戏的状态变化。
  • 游戏结束判断isOver函数负责判断游戏是否结束,分为失败和胜利两种情况。通过遍历雷区数组,判断是否有雷被翻开以此来确定是否踩雷,若踩雷则弹出消息框询问玩家是否继续游戏,继续则重置相关状态重新开始,取消则退出游戏。同时,会统计已翻开的非雷格子数量,当该数量等于总格子数减去雷数时,判定游戏胜利,同样弹出消息框让玩家选择继续游戏或者退出,选择继续就重置状态重新初始化游戏,选择退出则直接结束程序。

开发过程出现的BUG和解决思路

1.开头暴雷

第一个解决思路是开头暴雷后重新排雷,结果出现了内存溢出的错误,第二个思路是先不排雷,在第一次点击的地方生成保护区,再开始排雷。笔者引入了一个bool值,解决了该问题。

2.重新开始后失去开头保护

一开始的解决思路是使用goto,但这会降低代码可读性,也不符合编译标准。所以,我编写了一个新的函数restartgame来重置游戏,这个思路被证实能够完美地解决该问题。

写在最后

这可能是一个很拙劣的游戏源码,但编写他花了我和我的组员很多时间,如果喜欢,别忘了点个Star,谢谢。