扫雷开发笔记 思维导图
流程图
难度设置 简单: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> #include <stdio.h> #include <time.h> #include <windows.h> #define MAX_ROW 20 #define MAX_COL 20 #define IMG_SIZE 40 IMAGE img[13 ]; int mine[MAX_ROW + 2 ][MAX_COL + 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)); 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 ; if (mine[row][col] == 0 ) { mine[row][col] = 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 ; } }
类似于凯撒密码,但这里的偏移值为20
游戏胜利判断模块(isOver) 检查是否踩到雷 1 2 3 4 5 6 7 8 9 10 bool isHitMine = false ; 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); 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); } } } }
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); 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; 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)); 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 ; ++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 ,谢谢。