从 0 到 1 写一个扫雷游戏

扫雷大概是很多人的童年回忆:一个 9×9 的棋盘里藏着 10 颗雷,点开格子看数字,数字告诉你周围八格有几颗雷,把雷全部找出来就赢了。它练逻辑、练细心,也是学 C 语言时非常经典的练手项目------控制台就能跑,但里面该有的设计问题一个不少:数据结构、随机数、边界处理、输入校验、循环控制,全都用得上。

这篇文章把我写这个扫雷游戏的过程完整记录下来:先讲设计思路,再按顺序讲每一步代码是怎么写出来的,最后附上完整代码。跟着文章走一遍,你也能写出属于自己的扫雷。

先想清楚:扫雷到底在玩什么

动手写代码之前,先把规则翻译成程序能理解的需求。扫雷的规则可以拆成几条:

  • 棋盘:9 行 9 列,共 81 个格子;
  • 布雷:随机在其中 10 个格子里埋雷,玩家不知道位置;
  • 排查:玩家输入坐标,翻开一个格子;
  • 踩雷:翻开的格子是雷,游戏失败;
  • 数字:翻开的格子不是雷,显示它周围 8 格里有几颗雷;
  • 胜利:把 81 − 10 = 71 个非雷格子全部翻开,游戏胜利。

把规则翻译成需求,程序要干的事就很清楚了:

  1. 初始化:把棋盘全部初始化好;
  2. 布雷:随机埋下 10 颗雷;
  3. 排查:读入玩家坐标,判断踩没踩雷;
  4. 统计:没踩雷时数出周围雷数并显示;
  5. 胜负判断:踩雷失败,翻完 71 格胜利。

整体设计:三个文件 + 两块棋盘

模块划分:三个文件各管一摊

写之前先想清楚怎么组织代码。我把整个程序拆成三个文件,职责清清楚楚:

文件 职责
test.c 主函数与菜单:负责和玩家交互,选择开始游戏或退出
game.h 头文件:宏定义(棋盘大小、雷数)和函数声明,是整个程序的"约定"
game.c 游戏逻辑实现:初始化、布雷、打印、排查雷

一句话总结:game.h 定约定,game.c 干活,test.c 喊开始。

两块棋盘:把"真相"和"展示"分开

这个程序里最核心的设计,是用两块棋盘。

  • mine(布雷数组):记录雷的真实位置,'0' 表示没雷,'1' 表示有雷,只给程序自己看;
  • show(展示数组):记录玩家看到的内容,初始全是 '*',翻开的格子会变成数字。

为什么不用一块棋盘?因为玩家的视角和真相是两回事:show 要给玩家看,mine 要保密。把"真相"和"展示"分开,逻辑才清楚------排查时从 mine 读雷,显示时写 show,互不干扰。

3. ## 关键设计:为什么棋盘要开成 11×11

还有一个非常关键的设计:棋盘不是开 9×9,而是开 11×11(ROWS = ROW + 2)。

原因在统计周围雷数:点开 (x, y) 后,要检查 (x−1..x+1, y−1..y+1) 一共 8 个格子。如果棋盘只有 9×9,最外一圈格子(比如 (1, 1))检查时会越界访问。把棋盘四周多围一圈"哨兵",初始都是 '0',任何格子都能统一用 8 方向遍历,边界格子也不会出错。

游戏只在 1, 9 这个范围内布雷、排查;第 0 行、第 10 行这些边界格子永远不参与游戏,只负责"兜底"。

程序跑起来的整体流程

3. # 一步步实现

第一步:game.h,先把约定定下来

头文件是整个程序的"契约"。棋盘多大、几颗雷、每个函数长什么样,都先在这里定好;game.c 和 test.c 只要 include 它,就知道该怎么配合。

arduino 复制代码
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

#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 row, int col, 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);
//统计周围雷的个数
int CountMine(char mine[ROWS][COLS], char show[ROWS][COLS], int x, int y);

几个宏的含义:ROW、COL 是玩家看到的棋盘 9×9;ROWS、COLS 是带边界的实际数组大小 11×11;EASY_COUNT 是雷数 10。后面所有函数都按 ROWS、COLS 声明,实际游戏范围用 ROW、COL。

第二步:test.c,菜单和主循环

主程序做两件事:打印菜单,然后根据玩家选择进入游戏或退出。用一个 do...while 循环,保证先执行一次再判断,正好符合"先问再走"的游戏流程。

这里有个容易忽略的细节:scanf 读入失败(比如玩家输入了字母)时,缓冲区里会残留内容,导致循环卡死。所以用 if (scanf(...) != 1) 判断读入是否成功,失败时用 while (getchar() != '\n') 把缓冲区清空,再 continue 重新选。

arduino 复制代码
#define _CRT_SECURE_NO_WARNINGS 1
#include <stdio.h>
#include "game.h"

menu()
{
	printf("**************************\n");
	printf("***** 1. 扫雷游戏  *******\n");
	printf("***** 0. 退出游戏  *******\n");
	printf("**************************\n");
}

void game()
{
	char mine[ROWS][COLS];//布雷数组
	char show[ROWS][COLS];//排查出雷的信息
	//初始化棋盘
	InitBoard(mine, ROWS, COLS, '0');
	InitBoard(show, ROWS, COLS, '*');

	//打印棋盘
	DisplayBoard(show, ROW, COL);
	//DisplayBoard(mine, ROW, COL);

	//布雷
	SetMine(mine, ROWS, COLS);
	//DisplayBoard(mine, ROW, COL);

	//排查雷
	FindMine(mine, show, ROW, COL);
}

int main()
{
	int input = 2;
	do
	{
		menu();
		printf("请选择:>");
		if (scanf("%d", &input) != 1)
		{
			printf("输入错误,请重新输入!\n");
			while (getchar() != '\n'); // 清空输入缓冲区
			continue;
		}
		switch (input)
		{
		case 1:
			game();
			break;
		case 0:
			printf("退出游戏\n");
			break;
		default:
			printf("选择错误,只能输入0或1,请重新选择!\n");
			break;
		}
	} while (input);

	return 0;
}
  1. 第三步:InitBoard,初始化棋盘

初始化没什么技巧,双循环把每个格子填成指定的字符:mine 填 '0',show 填 '*'。把"填什么"也作为参数传进来,一个函数就能初始化两块棋盘,不用写两遍。

ini 复制代码
void InitBoard(char board[ROWS][COLS], int row, int col, char set)
{
	for (int i = 0; i < row; i++)
	{
		for (int j = 0; j < col; j++)
		{
			board[i][j] = set;
		}
	}
}
  1. 第四步:DisplayBoard,把棋盘打印出来

打印棋盘要解决一个阅读问题:玩家输入坐标时,怎么知道 (3, 5) 是哪一格?所以在棋盘上方和左侧打印行列号:先打印一行列号,然后每一行开头打印行号,再打印格子内容。

ini 复制代码
void DisplayBoard(char board[ROWS][COLS], int row, int col)
{
	int i = 0;
	int j = 0;
	printf("-------扫雷游戏--------\n");
	for (j = 0; j < col + 1; j++)
	{
		printf("%d ", j);
	}
	printf("\n");
	for (i = 1; i < row + 1; i++)
	{
		printf("%d ", i);
		for (j = 1; j < col + 1; j++)
		{
			printf("%c ", board[i][j]);
		}
		printf("\n");
	}
}

注意打印时循环从 1 开始、到 row 和 col 结束,正好跳过最外圈的边界行,玩家看到的就是一个干净的 9×9 棋盘。

第五步:SetMine,随机布雷

布雷用 rand() 生成随机坐标。这里有个容易踩的坑:rand() 是伪随机,如果不设置随机种子,每次运行生成的"随机"序列是完全一样的------也就是每次玩,雷的位置都一样。所以 main 里要先调用一次 srand((unsigned)time(NULL)) 初始化种子(头文件里已经 include 了 time.h)。

另外布雷时用 while (count) 循环:每成功埋下一颗雷,count 就减一,直到 10 颗雷全部埋完;如果随机到的格子已经有雷,就重新随机,保证不重复。

ini 复制代码
void SetMine(char board[ROWS][COLS], int row, int col)
{
	int count = 10;
	while (count)
	{
		int x = rand() % row + 1;
		int y = rand() % col + 1;
		if (board[x][y] == '0')
		{
			board[x][y] = '1';
			count--;
		}
	}
}
  1. 第六步:CountMine,统计周围雷数

这是"数字"的来源:数一数 (x, y) 周围 8 个格子里有几个 '1',周围就有几颗雷。因为棋盘有边界圈,这里可以放心地遍历 x−1 到 x+1、y−1 到 y+1,不会越界。

ini 复制代码
int CountMine(char mine[ROWS][COLS], char show[ROWS][COLS], int x, int y)
{
	int count = 0;
	for (int i = x - 1; i <= x + 1; i++)
	{
		for (int j = y - 1; j <= y + 1; j++)
		{
			if (mine[i][j] == '1')
			{
				count++;
			}
		}
	}
	return count;
}
  1. 第七步:FindMine,把排查雷串起来(核心)

最后把所有零件组装起来。FindMine 是游戏的主循环,逻辑是这样:

  1. 玩家输入坐标 (x, y);
  2. 先校验坐标是否在 1~9 范围内,不在就提示重输;
  3. 在范围内就查 mine:是 '1' 就踩雷了,打印雷区,游戏结束;
  4. 不是雷,再看 show 对应位置是不是 '*':已经翻过就提示重复;没翻过就调用 CountMine 算出周围雷数,把数字写进 show,win 加一;
  5. win 达到 71(81 − 10),所有非雷格子都翻完了,恭喜通关。
ini 复制代码
void FindMine(char mine[ROWS][COLS], char show[ROWS][COLS], int row, int col)
{
	int x = 0;
	int 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");
				DisplayBoard(mine, row, col);
				break;
			}
			else
			{
				if (show[x][y] == '*')
				{
					show[x][y] = CountMine(mine, show, x, y) + '0';
					DisplayBoard(show, row, col);
					win++;
				}
				else
				{
					printf("该位置已经排查过了,请重新输入!\n");
					continue;
				}
			}
		}
		else
		{
			printf("输入坐标有误,请重新输入!\n");
		}
		if (win == (row * col - EASY_COUNT))
		{
			printf("恭喜你,排查成功!\n");
			DisplayBoard(mine, row, col);
		}
	}
}

注意这句:showxy = CountMine(mine, show, x, y) + '0';------CountMine 返回的是整数,加上字符 '0' 就变成了对应的数字字符,可以直接存进字符数组。这就是用字符存棋盘的好处。

几个值得回味的细节

  • 字符 '0'、'1' 的小聪明:棋盘用字符而不是整数,'1' − '0' = 1,整数转字符只需要 + '0',打印时也直接 %c 输出,省去一堆类型转换;
  • 边界圈的含金量:没有 ROWS、COLS 的设计,CountMine 就要写一堆 if 判断边界,代码瞬间变丑;
  • scanf 输入缓冲:读入失败要清空缓冲区,否则循环卡死;
  • 记得 srand:rand 不设种子,每次游戏雷的位置都一样,这就不是"扫雷"而是"背答案"了;
  • 小瑕疵提醒:menu() 没写返回类型(C 语言默认按 int 处理,会出警告),建议写成 void menu();CountMine 的 show 参数其实没用到,可以去掉。
相关推荐
用户094248568034 天前
第3章:Redis字符串 String 实战:验证码、计数器与分布式开关
redis·源码
Code_Artist5 天前
论反射编程的魅力——程序如何在运行时认识自己?
后端·源码·编程语言
爱笑的源码基地8 天前
云 PACS 源码实践:HL7 对接 HIS / 电子病历
源码·开发·信创·pacs·医学影像
源码宝10 天前
产科电子病历门诊模块实现:自助建档、孕周计算与产检计划生成
大数据·源码·产科电子病历·产科系统·智慧产科·专科病历
源码宝14 天前
云HIS系统源码,HIS全套源码,支持监管系统对接与二次开发
java·源码·电子病历·云his系统·程序代码·全套源码
vx-程序开发18 天前
【计算机毕设】51300童装超市管理系统的设计与实现
java·vue.js·spring boot·源码·课程设计·毕设·大作业
vx-程序开发18 天前
【计算机毕设】52744基于微信小程序的民宿智能预订系统
java·spring boot·毕业设计·源码·课程设计·毕设·大作业
嘎嘎风19 天前
MiniMind 学习笔记之 06 一张图、一百行:MiniMind 的骨架
算法·源码
源码宝23 天前
Web‑PACS 云影像源码|Java+Vue3 医院影像管理系统完整方案
java·源码·影像pacs·成品源码·医院影像系统