C 语言项目复盘:用数组和函数实现扫雷游戏
学完二维数组和函数模块化编程之后,终于动手完成了第一个完整的小项目 —— 控制台版扫雷游戏。整个过程把数组操作、函数拆分、多文件工程写法都串起来了,也踩了不少细节坑。这篇博客完整复盘设计思路、核心实现和高频易错点,既是内化总结,也方便以后回头复习。
一、游戏整体设计与思路
1.1 基础功能需求
我们实现的是经典初级扫雷,核心功能如下:
控制台交互,主菜单选择开始游戏 / 退出
9×9 的游戏棋盘,默认随机布置 10 个雷
玩家输入坐标排查雷:
踩到雷:游戏失败,展示全部雷的位置
没踩到雷:显示该位置周围 8 格的雷数
排查完所有非雷格子,游戏胜利
1.2 核心数据结构设计
这是整个项目最关键的设计点,一开始没想明白很容易写乱。
两个独立棋盘
我们用两个二维数组分别存储数据,互不干扰:
mine 数组:存放雷的布置信息,字符 ‘0’ 表示非雷,‘1’ 表示雷
show 数组:展示给玩家的棋盘,初始全是 ‘*’,排查后显示周围雷数
用两个数组的好处是数据逻辑分离,而且初始化、打印这类通用函数可以复用一套代码,不用写两遍。
为什么数组是 11×11,不是 9×9?
这是新手最容易忽略的设计细节。
如果只开 9×9 的数组,当排查边缘格子(比如第一行、最后一列)时,统计周围 8 个格子的雷数会出现数组下标越界,导致程序异常。
解决方案:在 9×9 棋盘外围多扩一圈缓冲区,数组大小变成 11×11。雷只布置在中间 1~9 行、1~9 列的区域,外围一圈永远是 ‘0’。这样统计任何位置的周围雷数时,都不会越界访问,代码写起来也不用额外写边界判断。
我们用宏定义统一管理尺寸,改难度也方便:
#defineROW9// 实际游戏行数#defineCOL9// 实际游戏列数#defineROWSROW+2// 数组总行数#defineCOLSCOL+2// 数组总列数#defineEASY_COUNT10// 雷的数量1.3 多文件工程拆分
按照模块化编程的思路,把代码拆成 3 个文件:
game.h:头文件,放宏定义、函数声明、需要的标准头文件
game.c:游戏所有功能函数的具体实现
test.c:主程序入口,写菜单、游戏主流程控制
二、核心功能函数实现拆解
2.1 棋盘初始化函数
因为两个棋盘都需要初始化,所以做成通用函数,通过参数 set 指定初始化的字符:
mine 数组初始化为全 ‘0’
show 数组初始化为全 ‘*’
voidInitBoard(charboard[ROWS][COLS],introws,intcols,charset){inti=0;for(i=0;i<rows;i++){intj=0;for(j=0;j<cols;j++){board[i][j]=set;}}}2.2 打印棋盘函数
为了方便玩家输入坐标,打印时要带上行号和列号,且都从 1 开始显示,和玩家输入对齐。
voidDisplayBoard(charboard[ROWS][COLS],introw,intcol){inti=0;printf("--------扫雷游戏-------\n");// 打印列号for(i=0;i<=col;i++){printf("%d ",i);}printf("\n");// 打印每行数据和行号for(i=1;i<=row;i++){printf("%d ",i);intj=0;for(j=1;j<=col;j++){printf("%c ",board[i][j]);}printf("\n");}}2.3 随机布置雷函数
生成随机坐标,在合法范围内布置雷,注意要判重,避免同一个位置重复放雷。
voidSetMine(charboard[ROWS][COLS],introw,intcol){intcount=EASY_COUNT;while(count){// 生成1~row、1~col的随机坐标intx=rand()%row+1;inty=rand()%col+1;if(board[x][y]=='0'){board[x][y]='1';count--;}}}2.4 统计周围雷数函数
这是最巧妙的一个小函数。
因为 mine 数组存的是字符 ‘0’ 和 ‘1’,我们把周围 8 个格子的字符值加起来,再减去 8 个字符 ‘0’ 的 ASCII 值,得到的就是数字形式的雷的总数。
intGetMineCount(charmine[ROWS][COLS],intx,inty){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');}2.5 排查雷主逻辑
游戏的核心循环,负责接收玩家输入、判断胜负、更新棋盘。
voidFindMine(charmine[ROWS][COLS],charshow[ROWS][COLS],introw,intcol){intx=0;inty=0;intwin=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{// 统计周围雷数,更新展示棋盘intcount=GetMineCount(mine,x,y);show[x][y]=count+'0';// 数字转字符存入DisplayBoard(show,ROW,COL);win++;}}else{printf("坐标非法,重新输入\n");}}// 排雷成功判断if(win==row*col-EASY_COUNT){printf("恭喜你,排雷成功\n");DisplayBoard(mine,ROW,COL);}}三、复盘:我踩过的高频易错点
这部分是写项目最有价值的收获,很多语法点学的时候觉得懂了,一写代码就踩坑。
- 数组下标越界
这是新手写扫雷最容易犯的错。
一开始直接用 9×9 数组,统计边缘格子周围雷数时,x-1 或 y+1 会跑到下标 0 或者 9 的位置,越界访问内存,轻则输出乱码,重则程序直接崩溃。
解决方式:扩大一圈数组做缓冲区,11×11 的设计从根源上避免越界判断。 - 字符 ‘0’ 和数字 0 混淆
mine 数组存的是字符型的 ‘0’ 和 ‘1’,不是整型 0 和 1。
字符 ‘0’ 的 ASCII 码是 48,直接相加得到的是 ASCII 值的和,不是雷的数量。一开始没注意,统计出来的雷数永远是几百,完全不对。
解决方式:
字符转数字:‘1’ - ‘0’ = 1
数字转字符:count + ‘0’ 得到对应字符 - 随机数种子放置错误
一开始把 srand((unsigned int)time(NULL)) 写在了 game() 函数里,每开一局就调用一次。快速连续开两局的话,时间戳几乎一样,生成的雷的位置完全相同。
解决方式:srand 整个程序只调用一次,放在 main 函数开头即可。 - 布置雷没有判重
如果随机生成了重复的坐标,不做判断就直接赋值,会导致实际雷的数量比设定值少。
解决方式:布置雷前先判断当前位置是不是 ‘0’,确认没有雷再布置,只有布置成功才让计数器减一。 - 胜利条件逻辑写错
总共有 ROW*COL 个格子,减去 EASY_COUNT 个雷,剩下的就是需要排查的非雷格子总数。
一开始把胜利条件写反了,或者 win 计数器累加的位置放错了,导致排查完所有格子也不提示胜利。
注意:只有排查非雷格子成功的时候,才让 win++。 - 多文件头文件重复包含
拆文件初期,经常出现重复定义的报错。
解决方式:头文件开头加上 #pragma once,防止头文件被重复包含;每个 .c 文件都包含对应的 .h 文件,不要漏写。
四、可扩展的优化方向
基础版写完之后,还可以继续拓展功能,练手更多语法:
多难度选择:简单(9×9,10 雷)、中等(16×16,40 雷)、困难(30×16,99 雷),通过菜单选择,用变量控制数组尺寸和雷数
空白区域自动展开:如果当前位置周围雷数为 0,递归 / 循环展开周围一圈的格子,直到遇到有数字的边界,还原经典扫雷体验
标记雷功能:支持玩家输入指令标记疑似雷的位置
计时功能:记录排雷用时,增加游戏性
总结
这次扫雷项目做下来,最大的收获不是写出了一个游戏,而是学会了怎么把一个完整的需求,拆成数据结构 + 一个个独立功能函数,再用多文件组织成工程。
很多知识点比如数组传参、函数封装、边界处理,上课听的时候觉得很简单,真正动手写才会发现各种细节坑。尤其是字符和数字的转换、数组越界、逻辑边界判断,都是 C 语言编程里的高频易错点。纸上得来终觉浅,多动手写项目,踩坑再复盘,才是巩固知识最有效的方式。