基于C 语言一步步写出简易控制台扫雷游戏

简介

此个扫雷游戏很适合作为 C 语言初学者的第一个完整小游戏:界面只是控制台,不需要图形库;但它会让我们真正把二维数组、函数拆分、循环、分支、随机数、递归和多文件协作串起来。

最终效果

完成后的程序提供三档难度:简单 9 x 9、10 个雷;中等 16 x 16、40 个雷;困难 30 x 25、99 个雷。玩家可以输入坐标排查格子,也可以标记或取消标记格子;踩到雷会结束本局,所有安全格都被打开时获胜。
一局游戏的规则和程序约定如下:

项目当前实现的约定
玩家坐标输入“行、列”,可玩坐标从 (1, 1) 开始
未打开格'*'
标记格'F',仅表示玩家的猜测
真实雷区用字符 '1' 表示雷,用字符 '0' 表示非雷
打开安全格显示周围八个位置中的地雷数量
空白区域周围雷数为 0 时,自动打开相邻安全格
胜利条件打开全部安全格,而不是“把所有雷都标出来”

最后一条很重要。标记只是辅助操作,玩家可以把安全格错标成 F,也可以不标记任何格子。因此,最可靠的胜利条件是:已打开安全格数量等于安全格总数。

整个流程在脑中可以先初步思考成下面这样:

启动程序
  -> 选择开始或退出
  -> 选择难度
  -> 创建并初始化两张棋盘
  -> 在真实棋盘随机布雷
  -> 循环读取玩家操作
       -> 排查:踩雷、显示数字,或展开空白区域
       -> 标记:在 '*' 与 'F' 之间切换
       -> 已打开全部安全格:胜利

部分效果示例:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
(本人比较菜,只通关过简单难度,其它的效果可自行探索或设计,最后有源代码)

开始敲代码前,先想清楚思路再展开

(以防有人不太了解扫雷游戏机制,可以先体验一下网页版 https://www.minesweeper.cn/)

第一次做小项目时,最容易出现的情况是:每一行语法似乎都认识,但不知道“下一步该写哪个函数”。这个简易的扫雷游戏可以训练把一个大问题拆成小问题的能力。你不需要一口气理解全部代码,可以按照下面的顺序学习和验证:

学习顺序先解决的问题这一步完成后应该看到什么
1怎样保存棋盘?两个二维数组已经创建并初始化
2怎么样让玩家看见棋盘?控制台能输出带行列号的星号棋盘
3怎样让程序记住地雷?mine中随机出现固定数量的1
4怎样处理一次玩家操作?能标记、取消标记,或打开一个安全格
5怎样一次打开一片空白?空白格周围自动展开
6怎样结束一局游戏?踩到地雷失败,或成功打开全部安全格胜利

刚开始时可以把一次打开一片空白难度选择的功能暂时搁置作为扩展,先做出初始版本能成功运行后再来进行扩展,这样可以快速获得项目前期成就感,再去开发的时候会更加有信心

这张顺序表也说明了一个写项目的习惯:先完成可以单独观察的小功能,再把它们接在一起。 例如,棋盘都还没有打印正确时,不要急着写递归或者其它排查功能;因为此时即使出错,你也很难区分问题来自显示、数组还是功能本身。

第一步,先把项目简单拆成三个文件

我当前是把整体程序分成了三个文件:

简易扫雷/
├── main.c    // 程序入口、主菜单、难度选择、开始一局游戏
├── game.c    // 棋盘初始化、显示、布雷、计数、标记、递归展开和排查
└── game.h    // 公共常量、标准库头文件、函数声明

初学时把所有代码都放进 main.c 很常见,但一旦函数变多,菜单、打印棋盘、随机布雷和递归逻辑就会混在一起。拆成三个文件后,阅读和修改时更容易找到责任位置:

文件它回答的问题
main.c程序如何开始?用户如何选择难度?
game.c一局扫雷具体如何运行?
game.h其他文件可以使用哪些常量和函数?

game.hmain.cgame.c 之间的共同约定。比如:main.c 只要知道 FindMine 的函数声明,就可以调用它;至于函数体里如何判断踩雷,由 game.c 负责。编译时,编译器会分别处理两个 .c 文件,再把它们链接成同一个程序。

可以把“声明”和“定义”暂时理解为“先写使用说明,再写具体做法”:

// game.h 中的声明:告诉其他文件怎样调用
void SetMine(char board[ROWS][COLS], int row, int col, int mineCount);

// game.c 中的定义:真正执行随机布雷 
void SetMine(char board[ROWS][COLS], int row, int col, int mineCount)
{
    /* 函数体写在这里 */
}

如果在 main.c 中调用函数却没有先包含声明,编译器无法可靠判断参数是否正确;如果只写声明而没有定义,链接阶段会找不到真正的函数实现。头文件正是用来让多个源文件共享这份“调用说明”的。
在这里插入图片描述

第二步:关键设计,为何要设置两张棋盘

扫雷同时存在两种状态:

  1. 真实状态: 哪个位置雷,程序知道,但玩家不应提前看到。
  2. 展示状态: 玩家当前看到的是星号、标记或数字。

如果只用一个二维数组,标记操作会覆盖地雷信息;如果为了隐藏而不断修改同一个数组,判断踩雷、显示数字和结束后揭示雷区都会变得混乱。因此,可以在初始化时创建两张棋盘:

char show[ROWS][COLS];
char mine[ROWS][COLS];

它们分别保存的内容是:

数组可能存储的字符作用
mine'0''1'保存真实雷区;'1' 表示雷
show'*''F''0''8'保存玩家看到的棋盘

这里的 '0''1' 带有单引号,表示字符,不是整数 01。由于数组元素类型是 char,本项目统一用字符存储棋盘内容。

这里的'0''1'带有单引号,表示字符,不是整数01,由于数组元素类型是char ,本项目统一用字符串存储棋盘内容。

有了双棋盘后,很多判断会非常清楚:

一个位置是不是雷?                 查看 mine[x][y]
这个位置能否继续打开或标记?         查看 show[x][y]
正常游戏该给玩家显示什么?           显示 show
踩雷或胜利后要揭示哪里有雷?         显示 mine

先用一个很小的例子看懂两张棋盘

假设我们只观察一个 3 x 3 的局部区域。真实棋盘中,第 2 行第 3 列有雷;但在游戏刚开始时,玩家不能看到它:

mine(程序保存的答案)        show(玩家当前看到的内容)
0 0 0                           * * *
0 0 1                           * * *
0 0 0                           * * *

如果玩家把 (2, 3) 标记,只有右侧展示棋盘发生变化:

mine                            show
0 0 0                           * * *
0 0 1                           * * F
0 0 0                           * * *

这里的 F 只是玩家的判断,程序并没有因为它改变真实答案。之后玩家取消标记,右侧再变回 '*';如果玩家排查到真正的雷,程序才读取左侧的 mine[2][3] == '1' 来判定失败。这个例子能帮你避免一个很常见的错误:不要把标记写入 mine
左侧的雷区只由程序使用,右侧才是玩家在游戏过程中看到的内容

让玩家坐标从 1 开始,并且多开两圈数组空间

二维数组的自然下标从 0 开始,但让玩家输入 (1, 1)(1, 2) 更符合看棋盘时的习惯。于是本项目只把 1..row1..col 作为实际可玩区域,并额外申请一圈缓冲区:

#define MAX_ROW HARD_ROW
#define MAX_COL HARD_COL
#define ROWS (MAX_ROW + 2)
#define COLS (MAX_COL + 2)

以简单难度 9 x 9 为例,玩家真正能操作的是第 1 到第 9 行、列;第 0 行、第 0 列以及更靠外的一圈不显示给玩家。

这样设计最大的便利在于计算八个邻居(玩家排查成功后展示周围雷的数量)。左上角 (1, 1) 的左上邻居是 (0, 0),它仍在数组范围中;程序在读取八方向时不必为每个方向重复写边界判断。

不过缓冲区不等于可玩区域。因此,在接收用户坐标和递归展开时,仍然必须判断:

if (x < 1 || x > row || y < 1 || y > col)
{
    return 0;
}

它的目的不是防止访问缓冲区,而是防止缓冲区被误当成棋盘格打开或显示。

读二维数组时,把第一个下标当作行,第二个下标当作列

对于下面的声明:

char mine[ROWS][COLS];

mine[3][5] 可以直接读成“第 3 行、第 5 列的格子”。本项目始终用 x 表示行、y 表示列,所以:

mine[x][y]   // x 是行号,y 是列号
show[x][y]   // 同一个坐标在展示棋盘中的状态

尽量不要把 xy 理解为数学坐标系中的横轴和纵轴再反过来使用,不然后续写很多功能时容易产生混淆。只要从一开始坚持“第一个下标是行,第二个下标是列”,打印棋盘、校验输入、计算周围八格和递归展开都会使用同一套约定。

第三步:写 game.h,先固定公共规则

头文件中放的是两个 .c 文件都需要知道的内容:标准库、难度参数、棋盘大小和函数声明。

#pragma once

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

//简单难度相关数值
#define EASY_ROW 9
#define EASY_COL 9
#define EASY_COUNT 10

//中等难度相关数值
#define MEDIUM_ROW 16
#define MEDIUM_COL 16
#define MEDIUM_COUNT 40

//困难难度相关数值
#define HARD_ROW 30
#define HARD_COL 25
#define HARD_COUNT 99

//棋盘大小相关数值
#define MAX_ROW HARD_ROW
#define MAX_COL HARD_COL
#define ROWS (MAX_ROW + 2)
#define COLS (MAX_COL + 2)

数组始终按最大难度申请;每局真正使用几行几列,则通过 rowcol 参数决定。这样不需要为简单、中等、困难各复制一份 SetMineFindMine。(刚开始时可以自己直接定义一个标准数量测试,难度选择可以推后实现)

三个标准库的作用也要分清:

头文件此项目中使用到的内容
<stdio.h>printfscanf
<stdlib.h>randsrandsystem
<time.h>time

#pragma once 用于避免同一个头文件在一次编译中被重复包含。它是主流编译器支持的扩展;

接着写出供其他文件调用的函数声明:

//初始化棋盘
void InitBoard(char board[ROWS][COLS], int rows, int cols, char s);
//打印展示棋盘
void DisplayBoard(char board[ROWS][COLS], int row, int col);
//更新棋盘
void RefreshBoard(char board[ROWS][COLS], int row, int col);
//设置地雷位置
void SetMine(char board[ROWS][COLS], int row, int col, int mineCount);
//递归展开周围无雷区域(可当作后续扩展,不立即实现)
int ExpandBlank(char mine[ROWS][COLS], char show[ROWS][COLS],int x, int y, int row, int col);
//标记/取消标记地雷                
void ToggleMark(char show[ROWS][COLS], int x, int y);
//排查地雷位置
void FindMine(char mine[ROWS][COLS], char show[ROWS][COLS],int row, int col,int mineCount);

声明只告诉编译器函数的名字、参数和返回值,具体实现写在 game.c 中。

后续有一个函数GetMineCount 没有写进头文件,是因为它只在 game.c 内部使用;不必让 main.c 知道它的存在。

第四步:先实现最基础的棋盘能力

先让程序能够初始化并打印一张棋盘,后面所有功能才有可靠的基础。

用双重循环初始化数组

InitBoard 的任务很简单:把指定范围内的每一个元素都写成同一个字符。

//使用指定字符初始化棋盘
void InitBoard(char board[ROWS][COLS], int rows, int cols, char s)
{
    for (int i = 0; i < rows; i++)
    {
        for (int j = 0; j < cols; j++)
        {
            board[i][j] = s;
        }
    }
}

外层循环控制行号,内层循环控制当前行的列号。每局开始时调用两次:

InitBoard(show, ROWS, COLS, '*');
InitBoard(mine, ROWS, COLS, '0');

第一句让我们一开始只能看到星号;第二句让真实棋盘先全部是非雷格,之后再随机选择一些位置改为 '1'。把整个 ROWS x COLS 数组都初始化,也让外围缓冲区保持 '0',这正是后面八个方向计数能够成立的前提。

InitBoard(show, ROWS, COLS, '*') 为例,双重循环的执行过程可以理解为:

外层 i内层 j 依次取值被写入的元素
00COLS - 1show[0][0]show[0][COLS - 1]
10COLS - 1show[1][0]show[1][COLS - 1]
ROWS - 10COLS - 1最后一行全部元素

虽然我们不会看见第 0 行和第 0 列,这些位置依然要初始化。否则它们会保留不确定的旧数据,GetMineCount 在统计边缘格的邻居时就可能得到错误结果。

把二维数组按棋盘形式打印出来

//展示棋盘
void DisplayBoard(char board[ROWS][COLS], int row, int col)
{
	printf("* * * * 简易扫雷游戏* * * * \n");
	for (int i = 0; i <= col; i++)
	{
		printf("%-3d", i);  //占用3个字符位置(‘-’表示左对齐)
	}
	printf("\n");
	for (int i = 1; i <= row; i++)
	{
		printf("%-3d", i);
		for (int j = 1; j <= col; j++)
		{
			printf("%-3c", board[i][j]);
		}
		printf("\n");
	}
}

这里有两个容易忽略的细节:

  • 列标题从 0 打到 col,其中 0 只是为了和左侧行号对齐;真正的游戏格仍然从第 1 列开始。
  • %-3d%-3c3 表示至少占 3 个字符宽度,- 表示左对齐。困难模式有 1011 等这样的两位数坐标,不设置宽度时,后面的列会歪掉。

可以先单独验证这一小步:创建 show,初始化为 '*',调用 DisplayBoard(show, 9, 9)。预期得到的是带行列号的 9 x 9 星号棋盘。先确认显示正确,再继续做布雷。

输出大致会有下面的形状。它是排版示意,具体星号间距由控制台字体决定:

* * * * 简易扫雷游戏* * * *
0  1  2  3  4  ...
1  *  *  *  *  ...
2  *  *  *  *  ...
3  *  *  *  *  ...

第一行数字是列号,左侧数字是行号。此时不要把左上角的 0 当成一个可操作格,它只是给行号留出的对齐位置。

每次操作后刷新屏幕

控制台默认会把新的输出接在旧输出后面。若每次排查都直接打印棋盘,屏幕会越来越长,玩家很难看清当前状态。当前项目使用“清屏后重画”:

//排查雷的位置后刷新棋盘数据
void RefreshBoard(char board[ROWS][COLS], int row, int col)
{
    system("cls");
    DisplayBoard(board, row, col);
}

cls 是 Windows 命令提示符中的清屏命令,所以这一实现依赖 Windows。它适合入门项目,但会整屏重绘,可能有闪烁;若做跨平台版本,需要换用适合目标终端的方案。

第五步:随机布雷,再理解字符如何参与计数

随机数种子设置

rand() 生成的是其实伪随机序列,其生成的随机数由程序设置的种子决定,若每次程序启动都用同一个种子,它可能得到同样的雷区。因此,在 main 函数开头设置一次种子:

srand((unsigned int)time(NULL));

time(NULL) 提供当前时间,把它作为种子能让不同时间启动程序时更可能得到不同布局。

让雷随机落在可玩区域内,且不重复

//设置雷的位置并标记
void SetMine(char board[ROWS][COLS], int row, int col, int mineCount)
{
	int count = mineCount;
	while (count != 0)
	{
		int x = rand() % row + 1;
		int y = rand() % col + 1;
		if (board[x][y] != '1')
		{
			board[x][y] = '1';
			count--;
		}
	}
}

rand() % row 的结果范围是 0row - 1,再加 1 后就变成 1row。列号同理,因此雷只会落在玩家能操作的区域。

随机到重复位置是正常的。关键在于:只有当前位置原来不是 '1' 时,才真正放一颗雷并让 count--。如果把 count-- 写在 if 外面,程序可能重复随机到同一格,却错误地以为已经放够了雷。

可以用一个只有 3 颗雷的假想过程理解 count

开始:count = 3
第 1 次随机到 (2, 4),原来是 '0' -> 写成 '1',count 变为 2
第 2 次随机到 (2, 4),原来已经是 '1' -> 不改 count,仍然是 2
第 3 次随机到 (5, 1),原来是 '0' -> 写成 '1',count 变为 1
...
count 变为 0 时 -> 说明恰好成功放入 3 颗不重复的雷

因此 while (count != 0) 的判断对象不是“尝试了几次”,而是“还有几颗雷没有成功放入”。随机程序里经常会出现这种写法:随机结果可能无效,但只有满足条件的结果才推进进度。

八方向字符求和得到周围地雷数量

真实棋盘只保存 '0''1'(字符串形式),因此可以直接把八个邻居相加,再减去八个 '0'

//获取周围雷的数量
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');
}

原因是字符编码中:

'0' - '0' 的结果是 0
'1' - '0' 的结果是 1
(可查看ASCII码表验证)

八个邻居加起来后,减去 8 * '0',留下的就是 '1''0' 多出的数量,也就是地雷数量。若周围有 3 个雷,函数返回整数 3

显示到 show 时,要把整数变回字符:

show[x][y] = count + '0';

整数 3 加上字符 '0' 后就是字符 '3',用 %c 打印时便会显示数字 3。这种写法的前提是结果范围在 0..9;扫雷八方向最多只有 8 个雷,满足要求。

这里最容易混淆的是下面两组值:

写法类型上的含义在本项目中的用途
013整数常量循环次数、坐标、地雷数量、count
'0''1''3'单个字符保存到 mineshow 并用 %c 打印

例如,下面两句看起来很像,但含义不同:

int count = 3;           // count 是整数,表示“周围有 3 个雷”
show[x][y] = '3';        // show 存字符,表示“屏幕上显示字符 3”

源码选择 count + '0',是为了把整数计数转换成要显示的字符。不要把它误写成 show[x][y] = count;,因为整数 3 直接按字符输出时并不是我们想看的数字 '3'

第六步:标记后的界面状态切换

玩家标记的是“我认为这里可能有雷”,并不代表程序确认这里有雷。因此,标记功能只能修改 show,绝不能修改 mine

//标记或取消标记未排查的格子
void ToggleMark(char show[ROWS][COLS], int x, int y)
{
	if (show[x][y] == '*')
	{
		show[x][y] = 'F';
	}
	else if (show[x][y] == 'F')
	{
		show[x][y] = '*';
	}
}

它只有两种状态变化:

'*'  --标记-->      'F'
'F'  --取消标记-->   '*'

已经打开的数字格既不是 '*' 也不是 'F',这个函数自然不会改动它。不过这不代表可以省略主循环中的判断:在调用 ToggleMark 前,仍要告诉用户“已排查的格子不能标记”,让交互规则更清楚。

第七步:用递归自动展开空白区域

这一步可以当作扩展功能,可先跳过实现基本游戏功能后再完成

点击一个周围没有雷的格子后,真正的扫雷会把相连的空白区域及边缘数字一起打开。这里用递归实现。

先把递归理解成一句普通的话:当前格是空白格,就让同一个函数继续检查它周围八个格;当前格不是空白格或不该打开时,就停止。

确定停止条件,防止递归失控

//递归展开周围没有地雷的区域
int ExpandBlank(char mine[ROWS][COLS], char show[ROWS][COLS], int x, int y, int row, int col)
{
	//判断周围的坐标合法性
	if (x < 1 || x > row || y < 1 || y > col || mine[x][y] == '1' || show[x][y] != '*')
	{
		return 0;
	}

	int count = GetMineCount(mine, x, y);
	int opened = 1;
	show[x][y] = count + '0';
	if (count == 0)
	{
		//遍历周围的区域
		for (int i = x - 1; i <= x + 1; i++)
		{
			for (int j = y - 1; j <= y + 1; j++)
			{
				if (i != x || j != y)
				{
					opened += ExpandBlank(mine, show, i, j, row, col);
				}
			}
		}
	}

	return opened;
}

函数开头的条件合并了四类“不该继续处理”的情况:

情况为什么返回 0
坐标不在可玩区不能打开缓冲区,也不能显示越界位置
当前格是雷展开函数只负责打开安全格;踩雷由主循环处理
当前格已经打开不应重复计数,也不应重复递归
当前格被标记当前规则要求先取消标记,才允许排查

通过检查后,函数先计算周围雷数,把当前格写入 show,并记下 opened = 1。这表示“当前格是本次新打开的一个安全格”。

如果 count 大于 0,当前格显示数字后就结束;如果 count == 0,再遍历周围 3 x 3 区域并递归检查八个邻居。if (i != x || j != y) 用来跳过中心格自身。

最关键的一行是递归之前的:

show[x][y] = count + '0';

这就是访问标记。假设相邻的两个空白格都仍是 '*':A 调用 B,B 又调用 A,如果没有先把 A 改成已打开状态,它们就会无限互相调用,最后栈溢出。现在 B 再次碰到 A 时,发现 show[x][y] != '*',立即返回 0

用一次小范围追踪理解递归到底做了什么

假设玩家排查 (2, 2),它周围没有雷;但 (3, 3) 附近有雷,所以 (3, 3) 最终显示数字 1。递归过程可以按下面的顺序理解:

1. ExpandBlank(2, 2) 通过检查,先把 show[2][2] 写成 '0'。
2. 因为 count == 0,函数尝试 (1,1) 到 (3,3) 的八个邻居。
3. 某个邻居若同样是空白且还是 '*',它也先写成 '0',再继续检查自己的邻居。
4. 走到 (3,3) 时,若它周围有 1 个雷,就写成 '1',但不再向外展开。
5. 若递归又走回 (2,2),它已经不是 '*',立即返回 0,不会再次展开。
6. 所有邻居处理完后,各层把自己打开的格子数相加并返回。

箭头只从周围雷数为 0 的格子继续扩散;边缘数字会被打开,但不会继续向外递归

为什么递归函数要返回“新打开格数量”

单击一个数字格时,通常只打开一个格子;单击空白格时,可能一下打开几十个格子。因此不能在主循环里简单写 win++。当前函数返回本次实际打开的安全格数量:

win += ExpandBlank(mine, show, x, y, row, col);

每个有效新格贡献 1,无效、已打开、被标记或雷格贡献 0。递归返回值逐层累加,最终 win 正好反映已经打开了多少安全格。

第八步:在 FindMine 中处理一局游戏

前面的函数解决了单个能力,FindMine 则负责把玩家输入变成一次次状态变化。

用安全格总数定义循环和胜利条件

int win = 0;

while (win < row * col - mineCount)
{
    // 读取一次操作并处理
}

row * col 是总格数,减去雷数就是安全格总数。win 只在成功打开安全格时增加,因此二者相等时,玩家就赢了。

先验证坐标,再用数组下标

printf("请选择操作:1.排查  2.标记/取消标记\n");
scanf("%d", &operation);
printf("请输入坐标:");
scanf("%d%*c%d", &x, &y);

if (x >= 1 && x <= row && y >= 1 && y <= col)
{
    // 现在才可以读取 show[x][y] 和 mine[x][y]
}
else
{
    printf("坐标非法,请重新输入\n");
}

%d%*c%d 的含义是“读一个整数、读入并丢弃一个分隔字符、再读一个整数”,因此可以输入 3,43 4。在数组访问前验证 1..row1..col 是必须的;若先访问再检查,用户输入 0,1 或很大的数字时就可能发生越界访问。

还要分清两层校验:当前源码已经做了坐标范围校验,保证不会因行列号越界访问数组;但它没有做完整的输入格式校验。例如用户输入字母,scanf("%d", &operation) 会读取失败,输入缓冲区里的字符也不会自动消失。初学阶段先理解范围校验的作用即可,后续可自行进行优化改良。

排查操作的判断顺序

排查操作对应 operation == 1。顺序值得细看:

if (show[x][y] == 'F')
{
    printf("该位置已标记,请先取消标记\n");
}
else if (mine[x][y] == '1')
{
    RefreshBoard(mine, row, col);
    printf("踩到雷啦,游戏结束!\n");
    break;
}
else if (show[x][y] != '*')
{
    printf("该位置已经排查过,请重新输入\n");
}
else
{
    win += ExpandBlank(mine, show, x, y, row, col);
    RefreshBoard(show, row, col);
}

先检查标记格,避免直接打开玩家已经标记的位置;再去真实棋盘确认是否踩雷;接着拒绝重复打开;最后才执行递归展开。这里再次体现双棋盘的分工:show 决定交互状态,mine 决定真实答案。

踩雷后显示 mine,是为了让玩家看到雷区;正常操作后显示 show,避免提前泄露答案。

标记操作与最终胜利提示

operation == 2 时,只允许操作 '*''F'

if (show[x][y] == '*' || show[x][y] == 'F')
{
    ToggleMark(show, x, y);
    RefreshBoard(show, row, col);
}
else
{
    printf("已排查的格子不能标记\n");
}

每轮操作结束后,若 win 达到安全格总数,显示真实雷区并提示通关:

if (win == row * col - mineCount)
{
    RefreshBoard(mine, row, col);
    printf("恭喜通关!\n");
}

第九步:在 main.c 中组装菜单、难度与每一局游戏

main.c 不负责八方向计数,也不负责递归。它的职责是把程序入口和一局游戏组织起来。

菜单函数只输出菜单

//游戏开始菜单
void menu()
{
	printf("* * * * * * * * * * \n");
	printf("*     1.play      *\n");
	printf("*     0.exit      *\n");
	printf("* * * * * * * * * * \n");
}

//难度选择菜单
void difficultyMenu()
{
	printf("请选择游戏难度:\n");
	printf("1. 简单:9 * 9 棋盘,10 个雷\n");
	printf("2. 中等:16 * 16 棋盘,40 个雷\n");
	printf("3. 困难:30 * 25 棋盘,99 个雷\n");
}

函数只做一件事会更容易维护。以后想把英文菜单改成中文、增加说明或修改样式,只需要改这里,不会影响布雷逻辑。

game 每次都创建一张新的棋盘

//游戏进行过程
void game(int row, int col, int mineCount)
{
	char show[ROWS][COLS];
	char mine[ROWS][COLS];

	//初始化棋盘(show用于玩家展示,mine用于标记地雷位置)
	InitBoard(show, ROWS, COLS, '*');
	InitBoard(mine, ROWS, COLS, '0');

	//更新棋盘
	RefreshBoard(show, row, col);

	//设置地雷位置
	SetMine(mine, row, col, mineCount);

	//排查地雷位置k
	FindMine(mine, show, row, col, mineCount);
}

数组定义在 game 函数内,这意味着每次开始新的一局都会创建并初始化新的棋盘,不会残留上一局数据。先显示 show,再给 mine 布雷也不会泄露答案,因为 SetMine 从头到尾只修改 mine

main 开始到进入一局游戏,函数调用关系可以顺着读一遍:

main
  -> menu / difficultyMenu       输出用户选择界面
  -> game(row, col, mineCount)  创建两张本局棋盘
       -> InitBoard              写入初始状态
       -> RefreshBoard           显示玩家棋盘
       -> SetMine                在真实棋盘布雷
       -> FindMine               反复处理玩家操作,直到结束

当你以后觉得“函数太多,不知道从哪里读”时,可以先从 main 找程序入口,再沿着调用关系向下读。不要一开始就钻进最复杂的递归函数,这样更容易建立整体方向。

switch 把难度选择转化成三个参数

switch (difficulty)
{
case 1:
    game(EASY_ROW, EASY_COL, EASY_COUNT);
    break;
case 2:
    game(MEDIUM_ROW, MEDIUM_COL, MEDIUM_COUNT);
    break;
case 3:
    game(HARD_ROW, HARD_COL, HARD_COUNT);
    break;
default:
    printf("难度输入错误,请重新选择\n");
    break;
}

三种难度并没有三套游戏代码,区别只是行数、列数和雷数。把“会变化的数据”变成参数,保留同一套游戏流程,是函数复用的一个常见思路。

外层的 do ... while (input); 使菜单持续显示,直到用户输入 0。一局结束后 FindMine 返回,程序重新回到外层菜单,因此用户可以再选择一次难度开始新游戏。

第十步:运行后该怎样验证

不要只看“程序能启动”就认为项目完成。可以按功能逐项测试:

场景操作预期结果
选择难度依次输入 123棋盘大小和雷数对应三档常量
坐标边界输入 0,11,0 或超出范围的坐标输出“坐标非法”
标记切换对同一未打开格连续标记两次'*' -> 'F' -> '*'
重复排查排查已经显示数字的格输出已排查提示
标记后排查先标记,再用操作 1 排查该格要求先取消标记
踩雷排查真实雷格显示真实雷区并结束本局
空白展开排查一个周围雷数为 0 的格多个空白格和边缘数字一起打开
胜利打开全部安全格显示真实棋盘与“恭喜通关!”

提醒:胜利验证只验证简单难度就好,不要太犟,中等或困难是真的折磨。——(疑似作者玩红温后的牢骚)

当前版本的边界,以及下一步适合加什么

可以把当前的程序当作基础,再进行进一步的设计与优化,不需要一次性加完所有功能,先承认当前实现的边界,之后再有目的地扩展:

  • 当前标记数量没有限制,也没有“剩余标记数”提示。
  • scanf 没有检查返回值,输入字母等内容时容错不完整。
  • 最大棋盘是 30 x 25,当前递归展开可用;若棋盘继续增大,更适合用队列实现广度优先搜索,减少深递归带来的栈风险。
  • 可以在控制台相应位置加上游戏时间与得分显示。

推荐的扩展顺序是:先修复输入校验,再实现首次点击安全,然后增加剩余标记数、计时和重新开始。每次只增加一个功能,并重复上一节的基础测试,才容易发现改动带来的问题。

附录(三个文件的完整代码)

game.h

#pragma once

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

//简单难度相关数值
#define EASY_ROW 9
#define EASY_COL 9
#define EASY_COUNT 10

//中等难度相关数值
#define MEDIUM_ROW 16
#define MEDIUM_COL 16
#define MEDIUM_COUNT 40

//困难难度相关数值
#define HARD_ROW 30
#define HARD_COL 25
#define HARD_COUNT 99

//棋盘大小相关数值
#define MAX_ROW HARD_ROW
#define MAX_COL HARD_COL
#define ROWS (MAX_ROW + 2)
#define COLS (MAX_COL + 2)

//初始化棋盘函数声明
void InitBoard(char board[ROWS][COLS], int rows, int cols, char s);

//展示棋盘函数声明
void DisplayBoard(char board[ROWS][COLS], int row, int col);

//更新棋盘函数声明
void RefreshBoard(char board[ROWS][COLS], int row, int col);

//递归展开周围无雷区域
int ExpandBlank(char mine[ROWS][COLS], char show[ROWS][COLS], int x, int y, int row, int col);

//标记/取消标记地雷 
void ToggleMark(char show[ROWS][COLS], int x, int y);

//设置地雷位置函数声明
void SetMine(char board[ROWS][COLS], int row, int col, int mineCount);

//排查地雷位置函数声明
void FindMine(char mine[ROWS][COLS], char show[ROWS][COLS], int row, int col, int mineCount);

main.c

#include "game.h"


//游戏开始菜单
void menu()
{
	printf("* * * * * * * * * * \n");
	printf("*     1.play      *\n");
	printf("*     0.exit      *\n");
	printf("* * * * * * * * * * \n");
}

//难度选择菜单
void difficultyMenu()
{
	printf("请选择游戏难度:\n");
	printf("1. 简单:9 * 9 棋盘,10 个雷\n");
	printf("2. 中等:16 * 16 棋盘,40 个雷\n");
	printf("3. 困难:30 * 25 棋盘,99 个雷\n");
}

//游戏进行过程
void game(int row, int col, int mineCount)
{
	char show[ROWS][COLS];
	char mine[ROWS][COLS];

	//初始化棋盘(show用于玩家展示,mine用于标记地雷位置)
	InitBoard(show, ROWS, COLS, '*');
	InitBoard(mine, ROWS, COLS, '0');

	//更新棋盘
	RefreshBoard(show, row, col);

	//设置地雷位置
	SetMine(mine, row, col, mineCount);

	//排查地雷位置k
	FindMine(mine, show, row, col, mineCount);
}

int main()
{
	int input = 0;
	srand((unsigned int)time(NULL));
	do
	{
		menu();
		scanf("%d", &input);
		switch (input)
		{
		case 1:
		{
			int difficulty = 0;
			difficultyMenu();
			scanf("%d", &difficulty);
			switch (difficulty)
			{
			case 1:
				game(EASY_ROW, EASY_COL, EASY_COUNT);
				break;
			case 2:
				game(MEDIUM_ROW, MEDIUM_COL, MEDIUM_COUNT);
				break;
			case 3:
				game(HARD_ROW, HARD_COL, HARD_COUNT);
				break;
			default:
				printf("难度输入错误,请重新选择\n");
				break;
			}
			break;
		}
		case 0:
			printf("游戏已退出");
			break;
		default:
			printf("输入错误,请重新输入\n ");
			break;
		}
	} while (input);

	return 0;
}

game.c

#include "game.h"

//使用指定字符初始化棋盘
void InitBoard(char board[ROWS][COLS], int rows, int cols, char s)
{
	for (int i = 0; i < rows; i++)
	{
		for (int j = 0; j < cols; j++)
		{
			board[i][j] = s;
		}
	}
}

//展示棋盘
void DisplayBoard(char board[ROWS][COLS], int row, int col)
{
	printf("* * * * 简易扫雷游戏* * * * \n");
	for (int i = 0; i <= col; i++)
	{
		printf("%-3d", i);  //占用3个字符位置(‘-’表示左对齐)
	}
	printf("\n");
	for (int i = 1; i <= row; i++)
	{
		printf("%-3d", i);
		for (int j = 1; j <= col; j++)
		{
			printf("%-3c", board[i][j]);
		}
		printf("\n");
	}
}

//排查雷的位置后刷新棋盘数据
void RefreshBoard(char board[ROWS][COLS], int row, int col)
{
	system("cls");
	DisplayBoard(board, row, col);
}

//设置雷的位置并标记
void SetMine(char board[ROWS][COLS], int row, int col, int mineCount)
{
	int count = mineCount;
	while (count != 0)
	{
		int x = rand() % row + 1;
		int y = rand() % col + 1;
		if (board[x][y] != '1')
		{
			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');
}

//递归展开周围没有地雷的区域
int ExpandBlank(char mine[ROWS][COLS], char show[ROWS][COLS], int x, int y, int row, int col)
{
	//判断周围的坐标合法性
	if (x < 1 || x > row || y < 1 || y > col || mine[x][y] == '1' || show[x][y] != '*')
	{
		return 0;
	}

	int count = GetMineCount(mine, x, y);
	int opened = 1;
	show[x][y] = count + '0';
	if (count == 0)
	{
		//遍历周围的区域
		for (int i = x - 1; i <= x + 1; i++)
		{
			for (int j = y - 1; j <= y + 1; j++)
			{
				if (i != x || j != y)
				{
					opened += ExpandBlank(mine, show, i, j, row, col);
				}
			}
		}
	}

	return opened;
}

//标记或取消标记未排查的格子
void ToggleMark(char show[ROWS][COLS], int x, int y)
{
	if (show[x][y] == '*')
	{
		show[x][y] = 'F';
	}
	else if (show[x][y] == 'F')
	{
		show[x][y] = '*';
	}
}

//排查雷
void FindMine(char mine[ROWS][COLS], char show[ROWS][COLS], int row, int col, int mineCount)
{
	int x = 0;
	int y = 0;
	int win = 0;
	int operation = 0;
	while (win < row * col - mineCount)
	{
		printf("请选择操作:1.排查  2.标记/取消标记\n");
		scanf("%d", &operation);
		printf("请输入坐标:");
		scanf("%d%*c%d", &x, &y);
		//首先判断输入坐标是否有效
		if (x >= 1 && x <= row && y >= 1 && y <= col)
		{
			if (operation == 1)
			{
				if (show[x][y] == 'F')
				{
					printf("该位置已标记,请先取消标记\n");
				}
				else if (mine[x][y] == '1')
				{
					RefreshBoard(mine, row, col);
					printf("踩到雷啦,游戏结束!\n");
					break;
				}
				else if (show[x][y] != '*')
				{
					printf("该位置已经排查过,请重新输入\n");
				}
				else
				{
					win += ExpandBlank(mine, show, x, y, row, col);
					RefreshBoard(show, row, col);
				}
			}
			else if (operation == 2)
			{
				if (show[x][y] == '*' || show[x][y] == 'F')
				{
					ToggleMark(show, x, y);
					RefreshBoard(show, row, col);
				}
				else
				{
					printf("已排查的格子不能标记\n");
				}
			}
			else
			{
				printf("操作输入错误,请重新输入\n");
			}
		}
		else
		{
			printf("坐标非法,请重新输入\n");
		}
		if (win == row * col - mineCount)
		{
			RefreshBoard(mine, row, col);
			printf("恭喜通关!\n");
		}
	}
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值