概览:
又见面噜~
前言
承接上文的数组与函数篇的知识,这里结合随机数,给出了简化版的扫雷游戏,从玩法、设计到代码实现的全过程,希望能帮助大家完全掌握上一篇的所有知识。当然,如果学有余力,大家可以根据本篇最后的‘分析总结’一小节里提供的思路完成游戏的扩充与完善。
一、游戏介绍
1. 经典扫雷
1.1 玩法介绍
在这里给出扫雷游戏的网页版链接:https://www.minesweeper.cn/,大家可以自行搜索先试玩。
二、设计思路
扫雷游戏的玩法与设计其实很简单,只需要一定的简化,我们就可以在控制台上尝试实现了。
1. 核心玩法
划出一定大小的棋盘,棋盘上有未知方块,每个方块内随机分布着地雷,我们要根据提示找出所有安全方块方可胜利,但如果不小心踩到雷,则游戏失败。
2. 游戏设想
在本篇,我们只做简单模式9*9,10个雷的模式,且只保留了核心玩法,做了一定简化,以便讲解。
我们大致思考一下一轮游戏的流程与逻辑:
-
我们希望,玩家点开程序后,先进入菜单,进行选择;
-
这时出现分支,即游戏开始与游戏结束,游戏结束则代码结束,游戏开始则代码继续;
-
代码继续,先完成棋盘的创建,再布置地雷,接着打印棋盘,雷的情况未知;
-
开始排雷,玩家选择一个方块后,我们需要计算所选方块周围有多少雷,并打印出结果,不断循环此过程;
-
直至,炸雷或找出所有安全方块,无论胜利还是失败,结束游戏,回到菜单,选择退出游戏或者开始下一轮游戏 。
3. 代码构想与分析
3.1 代码功能构想
遵循模块化的思想,我们先来大致分析一下有哪些主要的函数/模块:
-
菜单函数:网页版游戏你点击方块即可开始游戏,但我们目前不一定掌握了图形界面的知识,所以,我们简化一下,游戏可以先给出菜单,玩家只需要进行选择并输入规定值即可开始游戏
-
打印棋盘的函数:棋盘这种平面图形,很容易想到用二维数组来存储,因为数据过多,我们不可能靠
printf
函数来打印整个棋盘,效率太低。 -
在棋盘上布置地雷的函数:试想简单模式9*9的棋盘,八十一个方块,都有十个雷,我们肯定需要一个函数来帮我们布置地雷,提高我们的效率
-
在排雷棋盘上排雷的函数:这是游戏的核心部分,扫雷游戏游玩体验的实际上就是这个函数的功能。
3.2 数据结构的分析
扫雷的过程中,布置的雷的信息和排查雷的信息都要存储,所以我们需要一定的数据结构来存储这些信息。
因为我们在9*9的棋盘上布置地雷和排查地雷,所以我们很容易想到创建两个9*9的数组——排雷数组与布雷数组,来存储信息。例如:
布雷数组用来存储雷的布置情况,0
表示没有雷,1
表示有雷,像这样
排雷数组用来存储排雷的情况,*
表示未知方块,像这样
这里注意两个问题
-
因为我们的数组既要存储
*
又要存储数字0
,1
,所以我们不如将其设置成字符类型的数组。 -
我们计算一个坐标周围值为
1
的坐标的个数时,如果在中间还好,八个坐标正正好,但如果在边缘,数组访问就会越界了,所以为了防止这种情况的发生,我们可以扩大数组,即9*9变成11*11,同时为了不影响统计雷的个数的结果,我们将布雷数组扩大出来多余的坐标也初始化为0
3.3 文件结构的设计
结合上一篇的知识,这里我们实践一下
test.c //⽂件中写游戏的测试逻辑
game.c //⽂件中写游戏中函数的实现等
game.h //⽂件中写游戏需要的数据类型和函数声明等
三、代码实现
1. 完整代码
game.h
:
#pragma once
#define ROW 9//棋盘行数
#define COL 9//棋盘列数
#define ROWS ROW + 2//数组行数
#define COLS COL + 2//数组列数
#define EASY_COUNT 10//雷的个数
void InitBoard(char board[ROWS][COLS], int rows, int cols, char set);//初始化棋盘
void DisplayBoard(char board[ROWS][COLS], int row, int col);//打印棋盘
void SetMine(char board[ROWS][COLS], int row, int col);//布置地雷
void FindMine(char mine[ROWS][COLS], char show[ROWS][COLS], int row, int col);//排雷
game.c
:
#include<stdio.h>
#include<stdlib.h>
#include "game.h"
void InitBoard(char board[ROWS][COLS], int rows, int cols, char set)
{
for (int i = 0; i < ROWS; i++)
{
for (int j = 0; j < COLS; j++)
{
board[i][j] = set;
}
}
}
void DisplayBoard(char board[ROWS][COLS], int row, int col)
{
system("cls");
printf("-------扫 雷-------\n");
for (int i = 0; i < row + 1; i++)
{
printf("%d ", i);
}
printf("\n");
for (int i = 1; i < ROW + 1; i++)
{
printf("%d ", i);
for (int j = 1; j < COL + 1; j++)
{
printf("%c ", board[i][j]);
}
printf("\n");
}
}
void SetMine(char board[ROWS][COLS], int row, int col)
{
int count = EASY_COUNT;
while (count)
{
int x = rand() % row + 1;
int y = rand() % col + 1;
if (board[x][y] == '0')
{
board[x][y] = '1';
count--;
}
}
}
int GetMineCount(char mine[ROWS][COLS], int x, int y)
{
return (mine[x - 1][y - 1] + mine[x - 1][y] + mine[x - 1][y + 1]
+ mine[x][y - 1] + mine[x][y + 1] + mine[x + 1][y - 1]
+ mine[x + 1][y] + mine[x + 1][y + 1] - 8 * '0');
}
void FindMine(char mine[ROWS][COLS], char show[ROWS][COLS], int row, int col)
{
int x = 0, y = 0, win = 0;
while (win < row * col - EASY_COUNT)
{
printf("请输入排查的*的坐标:>");
scanf("%d%d", &x, &y);
if (x >= 1 && x <= row && y >= 1 && y <= col)
{
if (mine[x][y] == '1')
{
printf("很遗憾,你被炸死了\n");
break;
}
else
{
int count = GetMineCount(mine, x, y);
show[x][y] = count + '0';
DisplayBoard(show, ROW, COL);
win++;
}
}
else
{
printf("非法坐标,请注意x与y的范围是1~9\n");
}
}
if (win == row * col - EASY_COUNT)
{
DisplayBoard(mine, ROW, COL);
printf("恭喜你,排雷成功\n");
}
}
test.c
:
#include<stdio.h>
#include<stdlib.h>
#include<time.h>
#include "game.h"
void Menu()
{
printf("***************\n");
printf("*** 1.Play ****\n");
printf("*** 0.Exit ****\n");
printf("***************\n");
printf("请选择->\n");
}
void Game()
{
char mine[ROWS][COLS] = { 0 };//存放布置好雷的棋盘
char show[ROWS][COLS] = { 0 };//存放排查后的棋盘
InitBoard(mine, ROWS, COLS, '0');//初始化布雷棋盘
InitBoard(show, ROWS, COLS, '*');//初始化排雷棋盘
SetMine(mine, ROW, COL);//布置地雷
DisplayBoard(show, ROW, COL);//打印排雷棋盘
FindMine(mine, show, ROW, COL);//排雷
}
int main()
{
int input = 0;
srand((unsigned int)time(NULL));
do
{
Menu();
scanf("%d", &input);
switch (input)
{
case 1:
Game();
break;
case 0:
system("cls");
printf("游戏结束\n");
break;
default:
printf("非法输入,请输入 '1' 或 '0'\n");
break;
}
} while (input);
return 0;
}
2. 编程思路
2.1 *随机值的设置
开始前,补充随机值 设置这个知识点。我们需要用到两个头文件和三个函数
头文件:
#include<stdlib.h>//提供rand函数:基于某个数(种子)生成随机数;提供srand函数:改变种子
#include<time.h>//提供time函数:用于获取一个时间戳,将种子的改变与时间相联系
用法:
srand((unsigned int)time(NULL));//获取一个与时间联系的种子
int x = rand() % row + 1;
//rand()可以获取一个基于种子的随机数,这个%的意义在于,任意一个随机数余上一个整数,范围在0到这个整数-1之间
2.2 正题
我们需要注意到,其实菜单和游戏也应该包含在一个循环内,只有在用户选择退出游戏时,我们才结束整个代码,所以我们需要一个输入值input
来决定开始游戏、退出游戏或错误输入这个分支结构的走向,同时在退出游戏这个分支中要求直接跳出循环。
#include<stdio.h>
#include<stdlib.h>//分别提供rand函数、srand函数与system函数
#include<time.h>//提供time函数:用于获取一个时间戳,将种子的改变与时间相联系
#include "game.h"
void Game()
{
}
void Menu()//打印菜单
{
printf("***************\n");
printf("*** 1.Play ****\n");
printf("*** 0.Exit ****\n");
printf("***************\n");
printf("请选择->\n");
}
int main()
{
int input = 0;
srand((unsigned int)time(NULL));//获取一个与时间联系的种子
do
{
Menu();//调用菜单函数
scanf("%d", &input);//玩家在此输入,进行选择
switch (input)//分支判断
{
case 1:
Game();//进入Game函数,开始游戏
break;//结束一轮游戏返回,防止分支重叠,是跳出分支,不是跳出循环
case 0:
system("cls");//系统命令函数,此处命令系统清屏
printf("游戏结束\n");
break;//跳出分支,因为input是0,结束循环
default:
printf("非法输入,请输入 '1' 或 '0'\n");
break;//跳出分支,因为input不为0,循环继续
}
} while (input);
return 0;
}
以上是程序的逻辑设计。接下来就到了重点环节了,Game
函数的设计,也是游戏的精髓所在。
上面,我们在对数据结构分析时已经提到,我们需要使用两个字符数组来记录,布置地雷的棋盘的信息和排查出方块的棋盘的信息,所以一进入Game
函数我们需要先创建这两个数组,同时为了方便改动数组的大小,我们可以在头文件game.h
中定义若干个常量
#define ROW 9//棋盘行数 define一个常量时,常量名规范为大写
#define COL 9//棋盘列数
#define ROWS ROW + 2//数组行数
#define COLS COL + 2//数组列数
接着,回到源文件test.c
的Game
函数用这些常量创建数组
char mine[ROWS][COLS] = { 0 };//存放布置好雷的棋盘
char show[ROWS][COLS] = { 0 };//存放排查雷的棋盘
由于字符数组默认为空,且我们需要字符数组初始为0
和*
,为了方便,我们不妨定义一个初始化函数InitBoard
,这个函数,我们需要传递四个参数:行,列,初始化的值以及要初始化的数组。
我们在头文件game.h
中声明如下:
void InitBoard(char board[ROWS][COLS], int rows, int cols, char set);//初始化棋盘
在源文件game.c
中完成该游戏的若干个核心函数之一的InitBoard
函数的实现
void InitBoard(char board[ROWS][COLS], int rows, int cols, char set)
{
for (int i = 0; i < ROWS; i++)
{
for (int j = 0; j < COLS; j++)
{
board[i][j] = set;
}
}
}
在源文件test.c
中调用如下:
InitBoard(mine, ROWS, COLS, '0');//初始化布雷棋盘
InitBoard(show, ROWS, COLS, '*');//初始化排雷棋盘
完成初始化后,我们需要检验一下是否初始化成功,因为后面也需要用到函数打印棋盘,所以我们先设计一个DisplayBoard()
函数,这个函数需要三个参数:数组的行、列以及整个数组。
在头文件game.h
中声明如下:
void DisplayBoard(char board[ROWS][COLS], int row, int col);//打印棋盘
在源文件test.c
的Game
函数中嵌套调用,等会用来打印并检查一下初始化是否正确
DisplayBoard(mine, ROW, COL);//打印布雷棋盘
DisplayBoard(show, ROW, COL);//打印排雷棋盘
我们回到game.c
函数完成核心代码
void DisplayBoard(char board[ROWS][COLS], int row, int col)
{
system("cls");//清屏
printf("-------扫 雷-------\n");
for (int i = 0; i < row + 1; i++)
{
printf("%d ", i);//打印坐标——列
}
printf("\n");
for (int i = 1; i < ROW + 1; i++)
{
printf("%d ", i);//打印坐标——行
for (int j = 1; j < COL + 1; j++)
{
printf("%c ", board[i][j]);//打印数组
}
printf("\n");
}
}
我们想到,玩家在菜单页面选择完后,我们可以使用system
函数命令系统清屏,保持画面清爽,同时为了更直观便捷,像象棋棋盘一样,什么车2进3,我们应该打印出棋盘每行每列的序号,便于玩家判断各方块的坐标。
用DisplayBoard
函数检验完后,我们将其删去,开始思考:如何在mine
数组上布置地雷呢?我们不妨设计一个布置地雷函数SetMine
,这个函数需要三个参数:棋盘的行数、列数以及数组(因为我们是在棋盘上布置地雷,所以不是数组的行数、列数)
在头文件game.h
中声明:
void SetMine(char board[ROWS][COLS], int row, int col);//布置地雷
在源文件game.c
中定义:
void SetMine(char board[ROWS][COLS], int row, int col)
{
int count = EASY_COUNT;
while (count)
{
int x = rand() % row + 1;
int y = rand() % col + 1;
if (board[x][y] == '0')
{
board[x][y] = '1';
count--;
}
}
}
接着在源文件test.c
的Game
函数中嵌套调用SetMine
函数:
SetMine(mine, ROW, COL);//布置地雷
到此,我们离完成只剩一步之遥,即玩家能够循环排雷的功能。
我们设计函数FindMine
,函数有四个参数:mine
数组,show
数组以及棋盘的行数、列数
在头文件game.h
中声明如下:
void FindMine(char mine[ROWS][COLS], char show[ROWS][COLS], int row, int col);//排雷
考虑到结束一轮游戏的两种情况,即踩雷与找出所有安全方块,所以我们结束排雷的循环过程需要专门用一个数据来存储找出的安全方块的个数,当大于棋盘所有方块个数减去地雷数时,游戏也能结束,且为了便于改变地雷的数量,最终我们需要创建两个数据win
和EASY_COUNT
。
我们在头文件game.h
定义地雷数如下:
#define EASY_COUNT 10
接着,我们来设计FindMine
函数
void FindMine(char mine[ROWS][COLS], char show[ROWS][COLS], int row, int col)
{
int x = 0, y = 0;
int win = 0;//存储找出的安全方块数
while (win < row * col - EASY_COUNT)//当找出的方块等于所有安全方块数,停止循环,结束一轮游戏
{
printf("请输入排查的*的坐标:>");
scanf("%d%d", &x, &y);//玩家在此输入要排查的坐标
if (x >= 1 && x <= row && y >= 1 && y <= col)//考虑非法输入的情况,增加一个判断语句
{
//坐标输入正确,接着分支判断该坐标下是否有雷
if (mine[x][y] == '1')//有雷
{
printf("很遗憾,你被炸死了\n");
break;
}
else//没有雷
{
int count = (mine[x - 1][y - 1] + mine[x - 1][y] + mine[x - 1][y + 1]
+ mine[x][y - 1] + mine[x][y + 1] + mine[x + 1][y - 1]
+ mine[x + 1][y] + mine[x + 1][y + 1] - 8 * '0');//没有雷需统计周围的雷的个数,此处也可封装成函数,便于书写调用,参考上面的完整代码
show[x][y] = count + '0';
DisplayBoard(show, ROW, COL);//合法输入坐标后,程序所有判断完成,打印结果给玩家
win++;//无雷,则++记录安全方块数
}
}
else//非法坐标,提醒玩家,并重新循环一遍
{
printf("非法坐标,请注意x与y的范围是1~9\n");
}
}
if (win == row * col - EASY_COUNT)
{
DisplayBoard(mine, ROW, COL);//找出了所有安全方块,打印雷的分布情况给玩家
printf("恭喜你,排雷成功\n");
}
}
最终Game
函数编写如下:
void Game()
{
char mine[ROWS][COLS] = { 0 };//存放布置好雷的棋盘
char show[ROWS][COLS] = { 0 };//存放排查后的棋盘
InitBoard(mine, ROWS, COLS, '0');//初始化布雷棋盘
InitBoard(show, ROWS, COLS, '*');//初始化排雷棋盘
SetMine(mine, ROW, COL);//布置地雷
DisplayBoard(show, ROW, COL);//打印排雷棋盘
FindMine(mine, show, ROW, COL);//排雷
}
四、分析总结
到此为止,我们就完成了扫雷游戏简化版的实现,其实都是我们上一篇的内容配合一两个零碎的知识点而已。
但相比起经典扫雷游戏,我们还缺少了很多功能,不提图形与界面,我们还缺少了难度的划分,方块的展开,雷的标记,时间显示…
我们是不是可以试着完善呢?对于有兴趣且学有余力的朋友们,我来提供一些思路:
- 难度的划分:我们是不是可以试着在
Game
函数内增加若干个分支呢?分别对应:简单、中级以及高级难度,不同难度,参数不同,则数组的创建不同,传递的参数也不同。 - 方块的展开:我们是否可以在
FindMine
函数中合法输入且无雷情况下,根据count
的统计结果增加两个分支(分别对应count
为0和不为0的情况)呢?接着再在count
为0的分支中来设计函数完成对相邻方块的判断呢? - 雷的标记:我们是否可以设计一个函数替换*为其他符号作为有雷的标记呢?
- 时间显示:我们是否可以结合系统命令函数
system
的清屏命令来设计一个清屏——打印的循环来完成时间的跳动效果呢?这里可以停供一个Sleep()
函数,头文件为windows.h
,可以命令程序休止多少多少毫秒,例如:Sleep(1000)
,命令程序读取到这个Sleep
函数时停止1000毫秒,即1秒。
等等等等…我们可以扩展完善的太多了,有兴趣的朋友们可以挑一个两个完成,如果完成了也可以将代码分享出来,给大家看看你的实现方法捏~。
如果喜欢我,不妨点点赞,关注一下吧!
我会将我对C语言的理解和认知,结合所查得到的所有资料,进行总结并精心写下来。
这不仅是我对知识的梳理与回顾,更是一种分享,供后来者借鉴与学习(无论妙笔或谬误),亦给予自己一个和先行者一起勘误、交流与校正的机会。