第6讲:数组和函数实践-----控制台扫雷

第6讲:数组和函数实践------写一个控制台扫雷

C语言主理人:老王爱玩车

适合人群 :已经学过 ifswitchwhilefor、数组和函数基础,想通过一个完整项目把知识串起来。

学习方式 :先理解设计,再逐个实现函数,最后组合成可运行程序。

本讲目标:用二维数组存储棋盘,用函数拆分逻辑,完成一个 9×9、10 个雷的控制台扫雷游戏。


目录

  • [1. 为什么用数组和函数写扫雷](#1. 为什么用数组和函数写扫雷)
  • [2. 扫雷游戏分析与设计](#2. 扫雷游戏分析与设计)
    • [2.1 功能说明](#2.1 功能说明)
    • [2.2 数据结构设计](#2.2 数据结构设计)
    • [2.3 文件结构设计](#2.3 文件结构设计)
  • [3. 核心函数逐个实现](#3. 核心函数逐个实现)
    • [3.1 初始化棋盘 InitBoards](#3.1 初始化棋盘 InitBoards)
    • [3.2 打印棋盘 PrintBoard](#3.2 打印棋盘 PrintBoard)
    • [3.3 布置地雷 PlaceMines](#3.3 布置地雷 PlaceMines)
    • [3.4 统计周围雷数 CountAdjacentMines](#3.4 统计周围雷数 CountAdjacentMines)
    • [3.5 排查地雷 PlayGame](#3.5 排查地雷 PlayGame)
  • [4. 完整代码](#4. 完整代码)
    • [4.1 game.h](#4.1 game.h)
    • [4.2 game.c](#4.2 game.c)
    • [4.3 main.c](#4.3 main.c)
    • [4.4 编译运行说明](#4.4 编译运行说明)
  • [5. 数组和函数知识点复盘](#5. 数组和函数知识点复盘)
  • [6. 扩展练习](#6. 扩展练习)
  • [7. 小结](#7. 小结)

学习目标

学完本讲,你应该能够:

  • 说清楚二维数组在棋盘类程序中的作用;
  • 会使用多文件编程组织一个完整项目;
  • 能定义、声明、调用自定义函数;
  • 理解形参和实参的值传递;
  • 掌握数组做函数参数时为什么要传行列长度;
  • 理解 return 的用法和函数出口;
  • 初步认识 staticextern 在链接属性上的区别;
  • 独立完成扫雷基础版,并知道如何继续扩展。

1. 为什么用数组和函数写扫雷

扫雷的棋盘天然适合用二维数组表示。

如果不用函数,所有逻辑都堆在 main 里,代码会又长又乱:初始化、打印、布置雷、统计、判断输赢全混在一起。

函数的作用就是"把一段完成特定任务的代码封装起来,起一个名字,需要时调用"。

在扫雷里,我们可以拆成:

  • 初始化棋盘
  • 打印棋盘
  • 布置雷
  • 统计周围雷数
  • 排查雷

每个函数只做一件事,主函数只负责"搭流程"。这样代码清晰,也方便调试和扩展。


2. 扫雷游戏分析与设计

2.1 功能说明

  • 控制台版经典扫雷;
  • 菜单:1. play 开始游戏,0. exit 退出;
  • 棋盘 9×9,默认随机布置 10 个雷;
  • 玩家输入坐标排查;
  • 不是雷:显示周围雷数;
  • 是雷:显示所有雷,游戏结束;
  • 把所有非雷位置都排查完,排雷成功。

2. 扫雷游戏分析与设计

2.1 功能说明

我们先明确基础版扫雷要完成哪些功能。这个功能列表就是我们的"需求文档",后续所有代码都是为实现这些需求服务的:

  • 使用控制台实现经典扫雷;
  • 游戏可以通过菜单选择继续玩或者退出;
  • 棋盘是 9×9 的格子;
  • 默认随机布置 10 个雷;
  • 玩家可以输入坐标排查雷;
  • 如果位置不是雷,就显示周围有几个雷;
  • 如果位置是雷,就显示所有雷,游戏结束;
  • 把除 10 个雷之外的所有非雷都找出来,排雷成功,游戏结束。

菜单可以设计成:

text 复制代码
******************************
********  1. 开始游戏  *******
********  0. 退出游戏  *******
******************************
请选择:>

玩家输入 1 进入游戏,输入 0 退出。这个菜单会循环显示,直到玩家选择退出。用 do-while 循环实现最自然:先显示菜单,读入选择,根据选择执行相应操作,只要用户没有选择 0,就继续循环。

2.2 数据结构设计

扫雷棋盘是一个网格,最自然的存储方式就是二维数组。这里有两个关键问题需要仔细解决,否则代码会写得很别扭,甚至出现越界、信息混淆等问题。

第一个问题:统计周围雷数时,边界格子会越界。

假设棋盘是 9×9,我们排查 (1,1) 这个坐标时,要访问它周围的 8 个格子,包括 (0,0)(0,1)(0,2)(1,0)(1,2)(2,0)(2,1)(2,2)。但 (0,0)(0,1)(0,2)(1,0)(2,0) 这些坐标已经超出了 9×9 的范围。如果数组是 board[9][9],访问 board[0][0] 还是合法的,但访问 board[-1][0] 就非法了。而排查 (1,1) 时,如果按"当前行减一到当前行加一、当前列减一到当前列加一"去访问,就会访问到 board[0][0],这虽然不越界,但并不是我们想要的位置。如果排查的是 (0,0),那么 x-1 就是 -1,直接越界,程序可能崩溃或出现不可预期的行为。

常见的解决办法是:把数组扩大一圈 ,变成 11×11。也就是说,实际数组是 board[11][11],但雷只布置在中间的 9×9 区域(行和列都从 1 到 9),周围一圈(第 0 行、第 10 行、第 0 列、第 10 列)不布置雷,专门作为"缓冲带"。这样,任何中间 9×9 的格子都有完整的 8 个邻居,统计时不需要写一堆 if 判断边界,只需要直接循环 x-1x+1y-1y+1 即可。这个技巧在图像处理、矩阵运算中也非常常见,叫做"加边"或"padding"。


第二个问题:雷的信息和排查出的数字信息如果放在同一个数组里,会互相干扰。

比如,我们用 1 表示"这里有雷",用 0 表示"这里没有雷"。当玩家排查一个位置后,如果这个位置不是雷,我们需要显示它周围有几个雷,比如"周围有 1 个雷"。那么,这个 1 到底表示"这里有雷"还是"周围有 1 个雷"?如果放在同一个数组里,就会产生歧义。打印棋盘时,我们无法区分一个 1 是雷还是数字。

解决办法是使用两个棋盘:

  • mine:存放布置好的雷。'0' 表示非雷,'1' 表示雷。
  • show:存放排查出的信息。'#' 表示未排查,数字字符 '0''8' 表示周围雷数,'*' 表示踩雷后显示的雷。

使用字符而不是整数,是为了打印时统一用 %c,并且可以用 '0''8' 表示周围雷数。mine 初始化为 '0',放雷后改成 '1'show 初始化为 '#',排查后改成数字字符或 '*'。这样两个数组各司其职,互不干扰。

相关宏定义如下:

c 复制代码
#define EASY_MINE_COUNT 10
#define BOARD_ROW 9
#define BOARD_COL 9
#define PAD_ROW (BOARD_ROW + 2)   // 11
#define PAD_COL (BOARD_COL + 2)   // 11

PAD_ROWPAD_COL 是扩大一圈后的数组尺寸,实际有效区域是 1BOARD_ROW1BOARD_COL。使用宏定义的好处是,如果以后想改成 16×16 或 30×16,只需要修改 BOARD_ROWBOARD_COLPAD_ROWPAD_COL 会自动跟着变。

2.3 文件结构设计

实际开发中,代码通常分文件管理。这里我们设计三个文件:

  • game.h:放宏定义、函数声明、头文件包含;
  • game.c:放游戏函数的实现;
  • main.c:放菜单和游戏测试逻辑。

这样结构清晰,便于多人协作和代码复用。编译时,三个文件分别编译,最后链接在一起。头文件里使用"头文件卫士"防止重复包含:

c 复制代码
#ifndef GAME_H
#define GAME_H

// ...

#endif

#ifndef GAME_H 的意思是"如果没有定义过 GAME_H 这个宏",#define GAME_H 就定义它,#endif 结束条件编译。这样,如果同一个头文件被多次包含,第二次包含时 GAME_H 已经定义,#ifndef 条件不成立,内容就不会被重复展开,避免重复声明的错误。

game.h 中只放声明,不放定义。game.c 中包含 game.h,然后实现函数。main.c 中也包含 game.h,这样就能调用 game.c 中定义的函数。头文件中包含 <stdio.h><stdlib.h><time.h>,因为 game.h 中的函数声明用到了 PAD_ROWPAD_COL 等宏,而 game.cmain.c 都需要这些头文件。


3. 核心函数逐个实现

3.1 初始化棋盘 InitBoards

设计思路

游戏开始时,mine 数组应该全部是 '0',表示还没有放雷;show 数组应该全部是 '#',表示所有位置都还没有被排查。因为数组扩大了一圈,所以循环范围是 PAD_ROWPAD_COL,而不是 BOARD_ROWBOARD_COL。这样,包括缓冲带在内的所有位置都被正确初始化,避免出现未初始化的"垃圾值"。

参数说明

  • mine:存放雷的二维数组;
  • show:存放显示信息的二维数组。

函数实现

c 复制代码
void InitBoards(char mine[PAD_ROW][PAD_COL], char show[PAD_ROW][PAD_COL]) {
    for (int i = 0; i < PAD_ROW; i++) {
        for (int j = 0; j < PAD_COL; j++) {
            mine[i][j] = '0';
            show[i][j] = '#';
        }
    }
}

逐行解释

  • 外层循环 i 从 0 到 PAD_ROW - 1,遍历每一行;
  • 内层循环 j 从 0 到 PAD_COL - 1,遍历每一列;
  • mine[i][j] = '0' 把当前位置设为非雷;
  • show[i][j] = '#' 把当前位置设为未排查。

注意事项

  • 二维数组做函数参数时,列数不能省略。char mine[PAD_ROW][PAD_COL] 中的 PAD_COL 必须写,因为编译器需要知道一行有多少个元素,才能正确计算地址。如果写成 char mine[][PAD_COL] 也可以,行数可以省略,但列数不行。如果写成 char mine[][] 就错了,编译器无法确定每一行的长度。
  • 形参看起来是数组,实际传递的是数组首元素的地址,函数内部操作的是同一块内存。所以 InitBoards 能直接修改 main 中的数组。这也是为什么数组做参数时,函数内部修改会影响调用者的原因。
  • 如果函数不需要修改数组,可以在参数前加 const,比如 const char mine[PAD_ROW][PAD_COL],表示函数不会修改这个数组。这是一个好习惯,可以提高代码的可读性和安全性。

3.2 打印棋盘 PrintBoard

设计思路

打印棋盘时,需要打印列标和行标,方便玩家输入坐标。只打印中间的 9×9 区域,周围一圈缓冲带不打印。列标从 1 到 9,行标也从 1 到 9,这样玩家看到的坐标和实际输入的坐标一致。因为 PrintBoard 只在 game.c 内部使用,不想暴露给其他文件,所以加 static 修饰。

参数说明

  • board:要打印的二维数组,可以是 mineshow
  • rowcol:实际有效区域的行数和列数,这里传 BOARD_ROWBOARD_COL

函数实现

c 复制代码
static void PrintBoard(char board[PAD_ROW][PAD_COL], int row, int col) {
    printf("   ");
    for (int j = 1; j <= col; j++) {
        printf("%d ", j);
    }
    printf("\n");
    for (int i = 1; i <= row; i++) {
        printf("%d  ", i);
        for (int j = 1; j <= col; j++) {
            printf("%c ", board[i][j]);
        }
        printf("\n");
    }
    printf("\n");
}

逐行解释

  • printf(" "); 打印三个空格,因为行标占两个字符(一个数字加一个空格),列标要对齐;
  • 第一个 for 循环打印列标 1col,每个数字后面跟一个空格;
  • printf("\n"); 列标打印完后换行;
  • 外层 for 循环 i 从 1 到 row,遍历每一行;
  • printf("%d ", i); 打印行标,数字后面跟两个空格,与列标对齐;
  • 内层 for 循环 j 从 1 到 col,遍历每一列;
  • printf("%c ", board[i][j]); 打印当前格子的字符,后面跟一个空格;
  • 内层循环结束后 printf("\n"); 换行;
  • 最后再 printf("\n"); 打印一个空行,让棋盘和后续输出之间有间隔。

注意事项

  • static 修饰函数表示"内部链接",该函数只能在本源文件 game.c 内使用,其他文件无法调用。这可以避免不同文件中的函数重名冲突。比如 main.c 中也可能有一个叫 PrintBoard 的函数,但因为 game.c 中的 PrintBoardstatic 的,两者不会冲突。
  • 打印时使用 %c,因为 board 中存放的是字符。如果使用 %d,打印出来的是字符的 ASCII 码,不是我们想要的。
  • 行标和列标从 1 开始,符合玩家习惯。实际数组下标从 0 开始,但有效区域是 19,所以打印时从 1 开始循环。
  • 如果棋盘较大,比如 16×16,列标可能需要调整格式,保证对齐。可以用 %2d 打印列标,每个数字占两个字符宽度。

3.3 布置地雷 PlaceMines

设计思路

随机生成坐标,落在中间 9×9 区域,且不能重复。使用 rand() % row + 1 生成 1row 之间的随机数。每成功放置一个雷,placed 加 1,直到达到指定的雷数。如果随机到的位置已经有雷,就跳过,重新生成,直到放满 mineCount 个雷。

参数说明

  • mine:存放雷的二维数组;
  • rowcol:实际有效区域的行数和列数;
  • mineCount:要布置的雷数。

函数实现

c 复制代码
void PlaceMines(char mine[PAD_ROW][PAD_COL], int row, int col, int mineCount) {
    int placed = 0;
    while (placed < mineCount) {
        int x = rand() % row + 1;
        int y = rand() % col + 1;
        if (mine[x][y] == '0') {
            mine[x][y] = '1';
            placed++;
        }
    }
}

逐行解释

  • int placed = 0; 记录已经放置的雷数;
  • while (placed < mineCount) 只要还没放够,就继续循环;
  • int x = rand() % row + 1; 生成 1row 的随机行号;
  • int y = rand() % col + 1; 生成 1col 的随机列号;
  • if (mine[x][y] == '0') 检查该位置是否还没有雷;
  • mine[x][y] = '1'; 放置一个雷;
  • placed++; 已放置雷数加 1。

注意事项

  • 使用 rand() 之前必须在 main 中调用 srand((unsigned int)time(NULL)) 初始化随机数种子,否则每次运行生成的雷位置都一样。time(NULL) 返回当前时间,srand 用这个时间作为种子,保证每次运行种子不同。
  • rand() % row 生成 0row - 1,加 1 后变成 1row。比如 row = 9rand() % 9 生成 08,加 1 后变成 19
  • 如果随机到的位置已经有雷,就跳过,重新生成,直到放满 mineCount 个雷。这种"拒绝采样"的方法在雷数远小于总格子数时效率很高。如果雷数接近总格子数,效率会下降,但基础版 9×9 放 10 个雷,效率完全没问题。
  • 如果 mineCount 大于等于 row * col,这个循环会变成死循环。实际使用中不会出现这种情况,但严谨的代码可以加一个判断,如果 mineCount >= row * col 就报错返回。

3.4 统计周围雷数 CountAdjacentMines

设计思路

遍历当前坐标周围的 8 个方向,统计 '1' 的个数。因为数组扩大了一圈,所以不用单独判断边界,直接循环 x-1x+1y-1y+1 即可。该函数只在 game.c 内部使用,所以加 static

参数说明

  • mine:存放雷的二维数组;
  • xy:当前排查的坐标。

返回值

周围雷的个数,整数。

函数实现

c 复制代码
static int CountAdjacentMines(char mine[PAD_ROW][PAD_COL], 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;
}

逐行解释

  • int count = 0; 记录周围雷数;
  • 外层循环 ix - 1x + 1,遍历当前行和上下两行;
  • 内层循环 jy - 1y + 1,遍历当前列和左右两列;
  • if (mine[i][j] == '1') 如果该位置是雷;
  • count++; 雷数加 1;
  • return count; 返回统计结果。

注意事项

  • 循环会包括 (x, y) 自己,但 mine[x][y] 在调用前已经确认不是雷,所以不会误统计。如果担心,可以在循环中加一个 if (i == x && j == y) continue;,但这里不需要,因为调用前已经判断过 mine[x][y] != '1'
  • 因为数组扩大了一圈,x-1y-1 最小是 0x+1y+1 最大是 PAD_ROW-1PAD_COL-1,不会越界。比如 x = 1x - 1 = 0,合法;x = 9x + 1 = 10PAD_ROW - 1 = 10,合法。
  • 返回的是整数,之后在 PlayGame 中通过 count + '0' 转成字符存入 show。比如 count = 3'0' 的 ASCII 码是 48,3 + 48 = 51,对应字符 '3'

3.5 排查地雷 PlayGame

设计思路

这是游戏的主循环。玩家不断输入坐标,程序判断坐标是否合法、是否重复、是否踩雷。如果踩雷,显示所有雷并结束;如果不是雷,显示周围雷数,并增加已打开格子的计数。当已打开的非雷格子数等于总格子数减去雷数时,排雷成功。

参数说明

  • mine:存放雷的二维数组;
  • show:存放显示信息的二维数组;
  • rowcol:实际有效区域的行数和列数;
  • mineCount:雷的数量。

函数实现

c 复制代码
void PlayGame(char mine[PAD_ROW][PAD_COL], char show[PAD_ROW][PAD_COL], int row, int col, int mineCount) {
    int x = 0;
    int y = 0;
    int opened = 0;
    int totalSafe = row * col - mineCount;

    while (opened < totalSafe) {
        PrintBoard(show, row, col);
        printf("请输入要排查的坐标(行 列):");
        if (scanf("%d %d", &x, &y) != 2) {
            while (getchar() != '\n') { }
            printf("输入格式错误,请重新输入。\n");
            continue;
        }
        if (x < 1 || x > row || y < 1 || y > col) {
            printf("坐标越界,请重新输入。\n");
            continue;
        }
        if (show[x][y] != '#') {
            printf("该位置已经排查过了,请换一个位置。\n");
            continue;
        }
        if (mine[x][y] == '1') {
            printf("很遗憾,你踩到雷了!\n");
            for (int i = 1; i <= row; i++) {
                for (int j = 1; j <= col; j++) {
                    if (mine[i][j] == '1') {
                        show[i][j] = '*';
                    }
                }
            }
            PrintBoard(show, row, col);
            return;
        } else {
            int count = CountAdjacentMines(mine, x, y);
            show[x][y] = count + '0';
            opened++;
        }
    }
    printf("恭喜你,排雷成功!\n");
    PrintBoard(mine, row, col);
}

逐行详细解释

  • int x = 0; int y = 0; 存储玩家输入的坐标;
  • int opened = 0; 记录已经打开的非雷格子数;
  • int totalSafe = row * col - mineCount; 总安全格子数,等于总格子数减去雷数。9×9 共 81 格,10 个雷,安全格子 71 个。
  • while (opened < totalSafe) 只要还没有打开所有安全格子,就继续循环;
  • PrintBoard(show, row, col); 打印当前棋盘,让玩家看到已排查的信息;
  • printf("请输入要排查的坐标(行 列):"); 提示玩家输入;
  • if (scanf("%d %d", &x, &y) != 2) 检查 scanf 是否成功读入两个整数。如果用户输入的不是两个整数,比如输入了字母,scanf 会返回小于 2 的值;
  • while (getchar() != '\n') { } 清空输入缓冲区,把错误输入读掉,避免影响下一次输入;
  • printf("输入格式错误,请重新输入。\n"); 提示错误;
  • continue; 跳过本次循环剩余部分,重新开始下一次循环;
  • if (x < 1 || x > row || y < 1 || y > col) 检查坐标是否在 1row1col 之间;
  • printf("坐标越界,请重新输入。\n"); 提示越界;
  • continue; 重新开始下一次循环;
  • if (show[x][y] != '#') 检查该位置是否已经排查过;
  • printf("该位置已经排查过了,请换一个位置。\n"); 提示重复;
  • continue; 重新开始下一次循环;
  • if (mine[x][y] == '1') 检查是否踩雷;
  • printf("很遗憾,你踩到雷了!\n"); 提示踩雷;
  • 双层 for 循环遍历所有格子,把 mine 中为 '1' 的位置在 show 中改成 '*',显示所有雷;
  • PrintBoard(show, row, col); 打印显示所有雷的棋盘;
  • return; 函数返回,游戏结束;
  • else 分支处理不是雷的情况;
  • int count = CountAdjacentMines(mine, x, y); 统计周围雷数;
  • show[x][y] = count + '0'; 把数字转成字符存入 show
  • opened++; 已打开安全格子数加 1;
  • 循环结束后,说明所有安全格子都打开了,打印 mine 显示所有雷的位置,宣布胜利。

重点知识

  • count + '0' 是把整数转换成对应字符的常用技巧。例如 count = 3'0' 的 ASCII 码是 48,3 + 48 = 51,对应字符 '3'。如果 count = 00 + 48 = 48,对应字符 '0'
  • returnvoid 函数中用于提前结束函数。踩雷后直接 return,不再继续循环。
  • 函数可以嵌套调用:PlayGame 调用了 PrintBoardCountAdjacentMines
  • scanf 的返回值是成功读入的变量个数。scanf("%d %d", &x, &y) 如果成功读入两个整数,返回 2;如果只读入一个,返回 1;如果一个都没读入,返回 0;如果遇到输入结束,返回 EOF。
  • getchar() 从输入缓冲区读一个字符。while (getchar() != '\n') { } 不断读取,直到读到换行符,把缓冲区清空。

4. 完整代码

4.1 game.h

c 复制代码
#ifndef GAME_H
#define GAME_H

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

#define EASY_MINE_COUNT 10
#define BOARD_ROW 9
#define BOARD_COL 9
#define PAD_ROW (BOARD_ROW + 2)
#define PAD_COL (BOARD_COL + 2)

void InitBoards(char mine[PAD_ROW][PAD_COL], char show[PAD_ROW][PAD_COL]);
void PlaceMines(char mine[PAD_ROW][PAD_COL], int row, int col, int mineCount);
void PlayGame(char mine[PAD_ROW][PAD_COL], char show[PAD_ROW][PAD_COL], int row, int col, int mineCount);

#endif

4.2 game.c

c 复制代码
#include "game.h"

static void PrintBoard(char board[PAD_ROW][PAD_COL], int row, int col) {
    printf("   ");
    for (int j = 1; j <= col; j++) {
        printf("%d ", j);
    }
    printf("\n");
    for (int i = 1; i <= row; i++) {
        printf("%d  ", i);
        for (int j = 1; j <= col; j++) {
            printf("%c ", board[i][j]);
        }
        printf("\n");
    }
    printf("\n");
}

static int CountAdjacentMines(char mine[PAD_ROW][PAD_COL], 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;
}

void InitBoards(char mine[PAD_ROW][PAD_COL], char show[PAD_ROW][PAD_COL]) {
    for (int i = 0; i < PAD_ROW; i++) {
        for (int j = 0; j < PAD_COL; j++) {
            mine[i][j] = '0';
            show[i][j] = '#';
        }
    }
}

void PlaceMines(char mine[PAD_ROW][PAD_COL], int row, int col, int mineCount) {
    int placed = 0;
    while (placed < mineCount) {
        int x = rand() % row + 1;
        int y = rand() % col + 1;
        if (mine[x][y] == '0') {
            mine[x][y] = '1';
            placed++;
        }
    }
}

void PlayGame(char mine[PAD_ROW][PAD_COL], char show[PAD_ROW][PAD_COL], int row, int col, int mineCount) {
    int x = 0;
    int y = 0;
    int opened = 0;
    int totalSafe = row * col - mineCount;

    while (opened < totalSafe) {
        PrintBoard(show, row, col);
        printf("请输入要排查的坐标(行 列):");
        if (scanf("%d %d", &x, &y) != 2) {
            while (getchar() != '\n') { }
            printf("输入格式错误,请重新输入。\n");
            continue;
        }
        if (x < 1 || x > row || y < 1 || y > col) {
            printf("坐标越界,请重新输入。\n");
            continue;
        }
        if (show[x][y] != '#') {
            printf("该位置已经排查过了,请换一个位置。\n");
            continue;
        }
        if (mine[x][y] == '1') {
            printf("很遗憾,你踩到雷了!\n");
            for (int i = 1; i <= row; i++) {
                for (int j = 1; j <= col; j++) {
                    if (mine[i][j] == '1') {
                        show[i][j] = '*';
                    }
                }
            }
            PrintBoard(show, row, col);
            return;
        } else {
            int count = CountAdjacentMines(mine, x, y);
            show[x][y] = count + '0';
            opened++;
        }
    }
    printf("恭喜你,排雷成功!\n");
    PrintBoard(mine, row, col);
}

4.3 main.c

c 复制代码
#include "game.h"

static void Menu(void) {
    printf("******************************\n");
    printf("********  1. 开始游戏  *******\n");
    printf("********  0. 退出游戏  *******\n");
    printf("******************************\n");
}

static void Game(void) {
    char mine[PAD_ROW][PAD_COL];
    char show[PAD_ROW][PAD_COL];
    InitBoards(mine, show);
    PlaceMines(mine, BOARD_ROW, BOARD_COL, EASY_MINE_COUNT);
    PlayGame(mine, show, BOARD_ROW, BOARD_COL, EASY_MINE_COUNT);
}

int main(void) {
    srand((unsigned int)time(NULL));
    int input = 0;
    do {
        Menu();
        printf("请选择:>");
        if (scanf("%d", &input) != 1) {
            while (getchar() != '\n') { }
            printf("输入错误,请重新选择。\n");
            input = -1;
            continue;
        }
        switch (input) {
            case 1:
                Game();
                break;
            case 0:
                printf("退出游戏。\n");
                break;
            default:
                printf("选择错误,请重新选择。\n");
                break;
        }
    } while (input != 0);
    return 0;
}

4.4 编译运行说明

Visual Studio

创建三个文件 game.hgame.cmain.c,把代码分别粘贴进去,直接运行。VS 会自动编译链接三个文件。

GCC

bash 复制代码
gcc main.c game.c -o minesweeper
./minesweeper

Clang

bash 复制代码
clang main.c game.c -o minesweeper
./minesweeper

5. 数组和函数知识点复盘

结合本讲代码,回顾几个关键点:

  1. 二维数组做函数参数

    char mine[PAD_ROW][PAD_COL] 作为形参时,列数不能省略。因为编译器需要知道一行有多少个元素,才能正确计算地址。行数可以省略,但列数必须确定。如果写成 char mine[][PAD_COL] 也可以,但 char mine[][] 是错的。

  2. 形参和实参的值传递

    函数里的数组形参看起来是数组,实际传递的是首行地址,函数内部操作的是同一块内存。所以 InitBoardsPlaceMines 能直接修改 main 中的数组。但普通变量如 int x 是值传递,形参是实参的临时拷贝,改变形参不影响实参。比如 Swap(int x, int y) 不能交换 main 中的变量,因为 xy 只是拷贝。

  3. return 的作用

    CountAdjacentMines 返回 intPlayGame 在踩雷时用 return 直接结束函数。每条分支都要有返回值,否则结果不确定。return 后面可以是一个值,也可以是一个表达式;return; 适合返回类型为 void 的函数。

  4. 嵌套调用与链式访问

    PlayGame 调用 PrintBoardCountAdjacentMinesGame 又调用 InitBoardsPlaceMinesPlayGame。函数可以嵌套调用,但不能嵌套定义。show[x][y] = count + '0' 将函数返回值直接参与表达式,也是一种链式访问。

  5. 声明和定义分离

    game.h 放函数声明,game.c 放函数定义,main.c 只包含头文件。这样编译器在调用前已经"见过"函数原型。函数声明只要交代函数名、返回类型、参数类型,参数名可以省略。

  6. staticextern 初识

    PrintBoardCountAdjacentMinesMenuGame 都加了 static,表示它们只在本源文件内可见,不会和其他文件冲突。全局变量和函数默认具有外部链接属性,其他源文件可以通过 extern 声明后使用;加上 static 后就变成内部链接属性,只能在本源文件内使用。extern 用于声明外部符号,比如一个全局变量在 A 文件定义,在 B 文件想用,就可以在 B 文件写 extern int g_val;

  7. 随机数

    srand((unsigned int)time(NULL)) 初始化随机数种子,rand() % row + 1 生成 1row 的随机数。如果不调用 srand,每次运行生成的雷位置都一样。time(NULL) 返回当前时间,作为种子保证每次运行种子不同。

  8. 输入校验

    scanf 的返回值表示成功读入的变量个数。如果用户输入的不是两个整数,scanf 会返回小于 2 的值。此时需要清空输入缓冲区,提示重新输入。while (getchar() != '\n') { } 是清空缓冲区的常用方法。

  9. ASCII 码转换

    count + '0' 把整数转换成对应字符。'0' 的 ASCII 码是 48,count = 33 + 48 = 51,对应字符 '3'。反过来,ch - '0' 把字符转换成整数。

  10. 数组扩大一圈的技巧

    在图像处理、矩阵运算、棋盘类程序中,经常把数组扩大一圈,避免边界判断。这是"空间换时间"的典型例子:多用一点内存,换来代码的简洁和效率。


6. 扩展练习

基础版完成后,可以继续挑战:

  1. 难度选择

    简单 9×9 10 雷;中等 16×16 40 雷;困难 30×16 99 雷。

    把行列和雷数做成参数,菜单里让用户选。需要把 BOARD_ROWBOARD_COLEASY_MINE_COUNT 改成变量,数组大小用最大尺寸定义。

  2. 递归展开

    如果排查位置不是雷,且周围雷数为 0,就自动展开周围一片。

    可以用递归或队列实现。注意递归时要判断边界和是否已经打开。这个功能是经典扫雷的核心体验之一。

  3. 标记雷

    增加"标记"功能,比如输入 flag 3 4 把某位置标记为 !,再次排查时不能打开。标记可以帮助玩家记住疑似雷的位置。

  4. 计时功能

    记录开始和结束时间,显示排雷用时。可以使用 time.h 中的 time()clock()clock() 返回程序运行的时钟周期数,除以 CLOCKS_PER_SEC 得到秒数。

  5. 排行榜

    把最快时间保存到文件,下次启动时读取。可以练习文件操作,比如 fopenfprintffscanffclose

  6. 优化打印

    使用更漂亮的符号,比如 表示未打开, 表示雷, 表示数字。需要注意控制台编码,Windows 下可能需要设置 chcp 65001 切换到 UTF-8。

  7. 首次点击保护

    经典扫雷中,第一次点击永远不会踩雷。可以在玩家第一次输入坐标后,再布置雷,或者如果第一次点击位置有雷,就重新布置。


7. 小结

这一讲我们把数组和函数结合起来,完成了一个完整的控制台扫雷游戏:

  • 用两个二维数组分别存放雷和显示信息;
  • 用扩大一圈的数组避免边界越界;
  • 用多个函数拆分初始化、打印、布置雷、统计、排查;
  • 用多文件编程组织代码;
  • static 限制内部函数作用域;
  • 所有代码均可直接编译运行。

函数是 C 语言模块化编程的起点。建议把上面的代码逐个敲一遍,再尝试修改参数、增加功能,真正把数组和函数用起来。


留个关注支持一下吧

相关推荐
是隼人1 小时前
buuctf-pwn [NewStarCTF 2023 公开赛道]orw&rop(shellcode+orw)题解(学习过程持续更新)
c语言·学习·安全·pwn入门·ctf入门
m0_734571761 小时前
深入理解C++ 多态<三>(Polymorphism)模板方法模式
开发语言·c++
君顾11 小时前
24小时自助健身房系统开发实战:从0到1完整技术架构指南
java·开发语言·健身房
艾莉丝努力练剑2 小时前
【AI大模型接入SDK】SQLite基础概念与C的API开发
网络·c++·人工智能·学习·架构
東隅已逝,桑榆非晚2 小时前
List(类函数学习)
c++·笔记·学习
江屿风2 小时前
【Linux系统】【Linux 进程程序替换机制解析及自定义 Shell 核心逻辑实现 】流食般投喂
linux·运维·服务器·开发语言·笔记
傲世仙尊2 小时前
从test.c到test.exe-编译和链接的全过程
c语言
SEO_juper2 小时前
2026年用Python检查多语言站点hreflang:自动揪出“回链缺失/标错语言“等致命错误(附完整代码)
开发语言·前端·seo·独立站·谷歌优化
aramae10 小时前
模拟实现strcmp()(C语言)
c语言·开发语言·后端