对于许多和我一样的编程爱好者来说,Windows系统自带的扫雷游戏或许是我们最早接触到的逻辑游戏之一。它规则简单,却又充满挑战。今天,我不想仅仅是玩这个游戏,而是想和大家分享一下我亲手用C语言创造它的完整过程。
这篇文章将详细记录我开发一个控制台版扫雷游戏的完整历程。我将深入剖析开发过程中的每个决策点、遇到的困难、解决问题的思路,以及对最终方案的反思,希望能为大家提供一个从“能用”到“好用”的完整开发蓝图。
第一章:奠定基石 - 棋盘的搭建
思路与决策
我的第一个设计决策是:如何存储棋盘状态?
起初我考虑过用一个二维数组,通过不同的数值(如-1代表雷,0代表空白,1-8代表数字,正负区分是否揭开)来表示格子的所有状态。但我很快意识到,这会让单个格子的状态变得过于复杂,后续的逻辑判断会非常混乱。
因此,我采用了经典的双棋盘结构:
-
mine
棋盘:作为后端,存储地雷的真实布局 ('1'
为雷,'0'
为安全)。 -
show
棋盘:作为前端,显示给玩家看 ('*'
为遮蔽)。
// game.h
typedef struct {
// ...
char mine[ROWS][COLS]; // 存储地雷的数组(答案)
char show[ROWS][COLS]; // 显示给玩家看的数组(视图)
// ...
} GameState;
-
优点:职责分离非常清晰。
mine
数组是静态的“数据源”,show
数组是动态的“视图”。检查是否踩雷 (if (gs->mine[x][y] == '1')
) 和更新玩家视图 (gs->show[x][y] = '...'
) 的逻辑互不干扰,代码简洁明了。 -
缺点:内存占用是单个棋盘的两倍。但对于扫雷这个规模的游戏来说,这点内存开销换来逻辑上的清晰是完全值得的。
困难与解决
如何生成每局都不同的随机地雷?我使用了C语言的 rand()
函数,但很快发现,如果不做任何处理,每次程序运行生成的地雷位置都完全一样。
解决思路:rand()
生成的是“伪随机数”,其序列由一个“种子(seed)”决定。种子不变,序列就不变。为了让种子变化,我需要一个每次运行程序都不同的值来初始化它。当前时间戳 time(NULL)
是最完美的解决方案。因此,我在 main
函数的开头加入了srand()
来设定种子,确保了游戏的随机性。
// main.c
// 这行代码在整个程序中只执行一次,确保了全局的随机性
srand((unsigned int)time(NULL));
第二章:核心玩法 - 递归展开的智慧
思路与决策
扫雷的精髓在于点开空白格后,安全区自动“涟漪”般展开。我立刻想到了用递归 (Recursion) 来实现这个功能,因为它能非常自然地模拟这个“扩散”的过程。
我的 ExpandMine
函数逻辑是:检查当前格子,如果为空,则把它揭开,然后对其周围的8个邻居重复相同的过程。
困难与解决
遇到的最大困难:程序直接崩溃,报错“堆栈溢出 (Stack Overflow)”。
解决思路:我立刻意识到这是无限递归。我的脑海里模拟了最简单的场景:两个相邻的空白格 A
和 B
。ExpandMine(A)
会调用 ExpandMine(B)
,而 ExpandMine(B)
又会回头调用 ExpandMine(A)
,程序陷入了 A -> B -> A -> B ...
的死亡循环。问题在于,我没有记录哪些格子已经被访问过。
我考虑过建立一个独立的 bool visited[ROWS][COLS]
数组来跟踪状态,但这会增加数据管理的复杂性。这时我灵光一闪:show
数组本身就可以作为“访问记录”!任何一个已经被揭开的格子(值不再是 '*'
),就意味着它已经被访问并处理过了。
于是,我在 ExpandMine
的入口处加入了这个关键的停止条件:
// game.c -> ExpandMine()
void ExpandMine(GameState* gs, int x, int y) {
// 任何越界或已被揭开的格子,都是递归的终点
if (x < 1 || x > gs->config.row || y < 1 || y > gs->config.col || gs->show[x][y] != '*') {
return;
}
// ...
}
这个利用现有状态来避免重复的方案,我认为非常优雅,它在不增加额外数据结构的情况下完美解决了问题。
第三章:体验升级 - 丰富游戏功能
思路与决策
-
标记地雷:如何让玩家在不增加额外交互步骤的情况下,选择“挖开”或“标记”?我考虑过几种方案:用负坐标标记、每次操作后提问等,但最终选择了扩展输入指令为
x y action
的方式。这最为直观且易于扩展。 -
增加计时器:我使用了
<time.h>
库。在游戏开始时记录一个时间戳,每次操作后和游戏结束时用当前时间戳与开始时间戳作差,即可得到游戏用时。
// game.c -> GameLoop()
// 在玩家每次有效操作后,都计算并显示时间
time_t current_time = time(NULL);
printf("Time elapsed: %.0f seconds.\n", difftime(current_time, gs->startTime));
第四章:从“能用”到“好用” - 彻底的代码重构
困难与解决
随着功能增多,我的代码开始变得臃肿。GameLoop
函数的参数列表越来越长(大概有7个!),DisplayBoard
里充满了重复的 switch
语句。这些都是典型的“代码坏味道”,表明代码的耦合度太高,且违反了 DRY (Don't Repeat Yourself) 原则。我决定进行一次彻底的重构。
解决思路:
-
引入
GameState
结构体:这是我解决高耦合问题的核心手段。我将所有零散的游戏状态变量(棋盘、行、列、地雷数等)全部封装进一个GameState
结构体。-
优点:函数签名立刻变得干净。所有函数都只传递一个
GameState*
指针,代码耦合度大幅降低。未来如果需要增加新的状态(比如剩余地雷数),我只需要修改GameState
结构,而不用修改几十个函数调用。 -
缺点:所有的数据访问都变成了
gs->
的形式,可能会让代码看起来稍微长一点,但这是完全值得的。
-
-
消除
DisplayBoard
的冗余: 我发现switch
语句中每个case
的代码几乎完全一样,唯一的区别是格式化字符串("%-3c"
或%-2c"
)。我用一个三元运算符在循环开始前就确定好这个字符串,从而彻底删除了整个switch
结构。
// game.c -> DisplayBoard()
// 用一行代码取代了20多行的switch-case结构
const char* col_padding = (gs->config.difficulty == 2 || gs->config.difficulty == 3) ? "%-3c" : "%-2c";
这个改动是 DRY 原则的经典应用,极大地提升了代码的可维护性。
第五章:画上句点 - 显示最终答案
思路与决策
游戏结束后,需要显示答案。我最初的想法是直接修改 show
棋盘来植入答案,但感觉这会“污染”show
棋盘的数据。我最终决定采纳一个职责更分离的方案:创建一个全新的 DisplayAnswerBoard
函数。
-
优点:
DisplayBoard
专心负责显示游戏过程,DisplayAnswerBoard
专心负责显示最终答案。两个函数的职责都非常单一,符合高质量软件设计的原则。 -
实现:这个函数直接读取
mine
棋盘,将'1'
打印为地雷符号'X'
,将'0'
打印为安全符号'_'
。
// game.c -> GameLoop() 失败和胜利时
// 直接调用专用的答案显示函数,逻辑清晰
DisplayAnswerBoard(gs);
最终代码以及细节讨论
经过这一系列的迭代与重构,我得到了结构清晰、功能完备的最终代码。下面是完整的项目文件,方便大家参考。
game.h
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#define MAX_ROW 30
#define MAX_COL 30
#define ROWS (MAX_ROW + 2)
#define COLS (MAX_COL + 2)
// Player operation
typedef enum {
ACTION_INVALID = 0,
ACTION_REVEAL = 1,
ACTION_FLAG = 2
} PlayerAction;
// Game configuration structure
typedef struct {
int row;
int col;
int mineCount;
int difficulty;
} GameConfig;
// Game state structure
typedef struct {
GameConfig config;
char mine[ROWS][COLS]; // Store the minefield
char show[ROWS][COLS]; // Store the player's view
time_t startTime; // Game start time
} GameState;
void InitBoard(char board[ROWS][COLS], int rows, int cols, char set);
void DisplayAnswerBoard(const GameState* gs);
void DisplayBoard(const GameState* gs);
void SetMine(GameState* gs);
void GameLoop(GameState* gs);
void ExpandMine(GameState* gs, int x, int y);
main.c
#define _CRT_SECURE_NO_WARNINGS 1
#include "game.h"
// Home Menu
void HomeMenu() {
printf("\n=== Minesweeper ===\n");
printf("===== 1. Start ====\n");
printf("===== 0. Exit =====\n");
printf("===================\n");
}
// Difficulty Selection Menu
void DifficultyMenu() {
printf("\n=== Select a difficulty level ===\n");
printf("1. Easy (9x9 with 10 mines)\n");
printf("2. Medium (16x16 with 40 mines)\n");
printf("3. Hard (24x24 with 99 mines)\n");
printf("4. test (6x6 with 2 mines)\n");
}
int ConfigureDifficulty(GameConfig* config) {
int level = 0;
DifficultyMenu();
printf("\nPlease select a difficulty level(1/2/3):>");
if (scanf("%d", &level) != 1) {
while (getchar() != '\n');
level = 0;
}
switch (level) {
case 1:
printf("Easy level\n");
*config = (GameConfig){ 9, 9, 10, 1 };
break;
case 2:
printf("Medium level\n");
*config = (GameConfig){ 16, 16, 40, 2 };
break;
case 3:
printf("Hard level\n");
*config = (GameConfig){ 24, 24, 99, 3 };
break;
case 4:
printf("Test level\n");
*config = (GameConfig){ 6, 6, 2, 0 };
break;
default:
printf("Invalid input, please try again.\n");
return 0;
}
return 1;
}
void StartGame(const GameConfig config) {
GameState gs = { 0 };
gs.config = config;
gs.startTime = time(NULL);
InitBoard(gs.mine, config.row + 2, config.col + 2, '0');
InitBoard(gs.show, config.row + 2, config.col + 2, '*');
SetMine(&gs);
GameLoop(&gs);
}
int main() {
int input = 0;
srand((unsigned int)time(NULL));
do {
HomeMenu();
printf("\nPlease enter your choice(1/0):>");
if (scanf("%d", &input) != 1) {
while (getchar() != '\n');
input = -1;
}
if (input == 1) {
GameConfig config = { 0 };
while (!ConfigureDifficulty(&config));
StartGame(config);
}
else if (input == 0) {
printf("\nExit game.\n");
}
else {
printf("\nInvalid input, please try again.\n");
}
} while (input != 0);
return 0;
}
game.c
#define _CRT_SECURE_NO_WARNINGS 1
#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;
}
}
}
int GetMineCount(const GameState* gs, int x, int y) {
const char (*mine)[COLS] = gs->mine;
return (mine[x - 1][y] + mine[x - 1][y - 1] + mine[x][y - 1] + mine[x + 1][y - 1] + mine[x + 1][y] +
mine[x + 1][y + 1] + mine[x][y + 1] + mine[x - 1][y + 1] - 8 * '0');
}
void DisplayBoard(const GameState* gs) {
printf("\n----------Mine Sweeper Game----------\n");
const char* col_padding = (gs->config.difficulty == 2 || gs->config.difficulty == 3) ? "%-3c" : "%-2c";
const char* header_padding = (gs->config.difficulty == 2 || gs->config.difficulty == 3) ? "%-3d" : "%-2d";
printf(" ");
for (int i = 1; i <= gs->config.col; i++) {
printf(header_padding, i);
}
printf("\n");
for (int i = 1; i <= gs->config.row; i++) {
printf("%-3d", i);
for (int j = 1; j <= gs->config.col; j++) {
printf(col_padding, gs->show[i][j]);
}
printf("\n");
}
}
void DisplayAnswerBoard(const GameState* gs) {
printf("\n---------- The Answer Key ----------\n");
const char* col_padding = (gs->config.difficulty == 2 || gs->config.difficulty == 3) ? "%-3c" : "%-2c";
const char* header_padding = (gs->config.difficulty == 2 || gs->config.difficulty == 3) ? "%-3d" : "%-2d";
printf(" ");
for (int i = 1; i <= gs->config.col; i++) {
printf(header_padding, i);
}
printf("\n");
for (int i = 1; i <= gs->config.row; i++) {
printf("%-3d", i);
for (int j = 1; j <= gs->config.col; j++) {
char display_char;
if (gs->mine[i][j] == '1') {
display_char = 'X'; // Mine
}
else {
display_char = '_'; // No mine
}
printf(col_padding, display_char);
}
printf("\n");
}
}
void SetMine(GameState* gs) {
int count = gs->config.mineCount;
while (count) {
int x = rand() % gs->config.row + 1;
int y = rand() % gs->config.col + 1;
if (gs->mine[x][y] == '0') {
gs->mine[x][y] = '1';
count--;
}
}
}
void GameLoop(GameState* gs) {
int revealed_count = 0;
DisplayBoard(gs);
printf("Time elapsed: 0 seconds.\n");
while (revealed_count < gs->config.row * gs->config.col - gs->config.mineCount) {
int x = 0, y = 0, action_choice = 0;
printf("Please enter the coordinates and action (row col 1:Reveal 2:Flag):>");
if (scanf("%d %d %d", &x, &y, &action_choice) != 3) {
while (getchar() != '\n');
printf("Invalid input format. Please enter three numbers (e.g., 1 1 1).\n");
continue;
}
PlayerAction action = (PlayerAction)action_choice;
if (x < 1 || x > gs->config.row || y < 1 || y > gs->config.col) {
printf("Coordinates are illegal, re-enter.\n");
continue;
}
if (action == ACTION_REVEAL) {
if (gs->show[x][y] == '#') {
printf("This coordinate is flagged. Unflag it first to reveal.\n");
continue;
}
if (gs->show[x][y] != '*') {
printf("This coordinate has already been revealed.\n");
continue;
}
if (gs->mine[x][y] == '1') {
time_t end_time = time(NULL);
printf("\n--- Game Over! ---\n");
printf("Final Time: %.0f seconds.\n", difftime(end_time, gs->startTime));
printf("I'm sorry you got blown up.\n");
DisplayAnswerBoard(gs);
return;
}
ExpandMine(gs, x, y);
}
else if (action == ACTION_FLAG) {
if (gs->show[x][y] == '*') {
gs->show[x][y] = '#';
}
else if (gs->show[x][y] == '#') {
gs->show[x][y] = '*';
}
else {
printf("This coordinate has been revealed and cannot be flagged.\n");
}
}
else {
printf("Invalid action. Please use 1 to reveal or 2 to flag.\n");
continue;
}
int current_revealed = 0;
for (int i = 1; i <= gs->config.row; i++) {
for (int j = 1; j <= gs->config.col; j++) {
if (gs->show[i][j] != '*' && gs->show[i][j] != '#') {
current_revealed++;
}
}
}
revealed_count = current_revealed;
DisplayBoard(gs);
time_t current_time = time(NULL);
printf("Time elapsed: %.0f seconds.\n", difftime(current_time, gs->startTime));
}
time_t end_time = time(NULL);
printf("\n--- You Win! ---\n");
printf("Final Time: %.0f seconds.\n", difftime(end_time, gs->startTime));
printf("Congratulations on the demining.\n");
DisplayAnswerBoard(gs);
}
void ExpandMine(GameState* gs, int x, int y) {
if (x < 1 || x > gs->config.row || y < 1 || y > gs->config.col || gs->show[x][y] != '*') {
return;
}
int count = GetMineCount(gs, x, y);
if (count > 0) {
gs->show[x][y] = count + '0';
}
else {
gs->show[x][y] = '0';
for (int i = x - 1; i <= x + 1; i++) {
for (int j = y - 1; j <= y + 1; j++) {
if (i == x && j == y) {
continue;
}
ExpandMine(gs, i, j);
}
}
}
}
细节讨论:
1. DisplayBoard 与 DisplayMineBoard 的代码重复
- 细节:DisplayBoard 与 DisplayMineBoard 两个函式在结构上几乎完全相同,都包含列印标题、行号、列号以及遍历棋盘的逻辑。唯一区别是内部 for 循环中获取 display_char 的方式不同。
- 讨论:可以考虑将这两个函式合并唯一,通过传入一个新参数来获取要显示的字元。
2. 轮回展开效率
- 细节:目前使用的 ExpandMine 递归展开在棋盘非常大且空白区域极多时,深度的递归可能造成堆栈溢出 (Stack Overflow) 的风险。
- 讨论:可以尝试使用队列 (Queue) 结构的广度有限搜寻 (BFS) 算法来替代递归。
3. GetMineCount 计算方式
- 细节:GetMineCount 是在需要时及时计算某个格子周围的地雷数。
- 讨论:可以尝试预先计算所有非地雷格子的周围地雷数,并将结果储存在另一个额外阵列或 mine 阵列的 '0' 位置上。
结语
从一个简单的想法到这个结构良好、功能完备的应用程序,这个过程本身就是编程最大的乐趣所在。对我而言,这不仅是关于语法和算法,更是关于如何分析问题、权衡不同的实现方案、并在遇到困难时如何思考和调试。希望我分享的这次旅程和其中的思考过程能对您有所启发。继续编码,继续创造!