Loading

探秘扫雷游戏的C语言实现

1 引言

1.1 为什么写这篇文章?

项目仓库地址:基于 C 语言实现的扫雷游戏

我决定写这篇文章的初衷是想分享我在使用C语言开发扫雷游戏的经验和心得。通过这篇文章,我希望能够向读者展示我是如何利用C语言的基础知识和编程技巧,实现了这个经典游戏的版本。

我相信这将对想要了解C应用的程序员或者C的初学者们应该会有所帮助。

1.2 什么是扫雷游戏?

扫雷游戏是一款经典的单人电脑游戏,玩家需要根据数字提示推断雷的位置,最终目标是揭开所有非雷方块而不揭开任何雷方块。游戏通常会在一个方块阵列中隐藏一些地雷,玩家需要根据周围方块中的数字提示来推断哪些方块中包含地雷。这是一款考验玩家逻辑思维和推理能力的游戏,也是许多人童年时的回忆。

推广一下我写的油猴插件,别人学扫雷实现的时候,我在写扫雷的外挂,听课属实有点无聊hhh,写个扫雷游戏的作弊工具提提神

点击跳转->跟着我一起扫雷吧

截图_20231215181516

在扫雷游戏中,玩家可以通过揭开方块来逐步推断哪些方块是安全的,哪些方块可能包含地雷。游戏中的数字提示会告诉玩家周围8个方块中有多少个地雷,玩家需要根据这些提示来推断出地雷的位置。游戏的目标是尽可能地揭开所有非雷方块,而不揭开任何地雷方块。

1.3 实现怎样的扫雷游戏?

  • 具备哪些功能?
    • 基本的扫雷游戏,能够做到最基础的游戏胜利与失败的判断
    • 可以选择难度,不同的难度对应着不同的棋盘大小、地雷数目
    • 可以设置不同的用户,每个用户都能设置性别和进行计分
    • 构建分数排行榜,无论是同一用户或者不同用户,达到条件即上榜
  • 怎样去操作?
    • 终端界面进行控制,理论上 Qt 界面也脱离不了其中核心,无非多了游戏循环处理以及信号和槽的使用等等
    • 传统功能选项的菜单,比如开始游戏/继续游戏/设置用户/选择难度/查看排行榜等等功能,都通过选项去调用
    • 游戏过程中,玩家输入不同操作符和行列号操作游戏中的单元格,期间可以随时退出,而且可以保存游戏

2 实现思路

开发工具: Microsoft Visual Studio

编译器: MSVC     C标准: C99

2.1 项目结构划分

游戏入口: main.c 该文件负责游戏的入口以及预加载游戏的一些数据,通过玩家不同的输入,对接不同的功能。

游戏模块: game.hgame.c 其中头文件声明游戏中可能使用到的结构体、宏定义、全局变量、函数声明等等,而源文件则是游戏中各功能模块的相关代码实现。

显示模块: display.hdisplay.c 该模块负责游戏过程中的游戏板元素显示、错误提示、打印玩家信息、结算成绩、打印排行榜等功能的声明与实现。

菜单模块: menu.hmenu.c 这个模块负责呈现各级菜单以及用户对于菜单功能选择的输入反馈处理等。

存储模块: storage.hstorage.c 该存储模块负责将游戏数据、排行榜数据的本地存储和加载。

2.2 预置数据类型

// game.h
#define _CRT_SECURE_NO_WARNINGS

#include <stdbool.h>  // 因为用到了bool类型

// 排行榜上存储的玩家分数最大数量
#define MAX_PLAYERS 10

// 单元格
typedef struct {
	bool is_mine;  // 有没有雷
	bool is_revealed;  // 有没有探索
	bool is_flagged;  // 有没有放小旗(扫雷游戏中的玩法: 当质疑是雷时,你可以防止旗帜标记)
	short int adjacent_mines;  // 附近的雷数量(0-8)
	short int value;  // 打印的时候显示的值
} Cell;

// 游戏板的配置
typedef struct {
	int rows;  // 理论行数
	int cols;  // 理论列数
	int real_rows;  // 实际构建的行数
	int real_cols;  // 实际构建的列数
	int mine_count;  // 雷的数量
	int base_score;  // 当前配置的基础分
} BoardConfig;

// 游戏状态
typedef enum {
	GAME_INIT,  // 待初始化,该状态象征着新的一局游戏还没开始
	GAME_RUNNING,  // 已经初始化了,运行中,等待玩家下一步操作
	GAME_ENTER,  // 游戏接收了输入,正在处理中,处理中可能进入更新界面状态
	GAME_UPDATE,  // 更新界面状态,用户对某个单元格进行操作后的反馈
	GAME_WIN,  // 游戏胜利,在游戏更新界面至游戏失败的中间,会进行成功的检查
	GAME_LOSE  // 游戏失败,该状态在更新界面时会对玩家操作后的行为检测
} GameState;

// 游戏难度
typedef enum {
	EASY, MEDIUM, HARD,  // 3个难度
	DIFFICULTY_COUNT  // 计数 3
} Diffuculty;

// 游戏配置
typedef struct {
	Cell** board;  // 游戏板 二维矩阵
	GameState state;  // 游戏状态
	Diffuculty difficult;  // 游戏难度等级
	int time;  // 某局游戏的用时
} Game;

// 玩家
typedef struct {
	char name[64];  	// 玩家昵称
	char gender[24];  	// 玩家性别
	int score;  		// 玩家当局分数
	int best_score;  // 玩家历史最高分数
	short int right_flag;  // 玩家标记的正确旗帜数量
	short int error_flag;  // 玩家标记的错误旗帜数量
} Player;

// 排行榜
typedef struct {
	Player players[MAX_PLAYERS]; // 玩家列表
	int player_count;	// 当前上榜的玩家数量
} Leaderboard;

2.3 核心功能实现

2.3.1 棋盘设定

我们先以难度为容易的扫雷游戏为研究方向,容易难度下,我们给予棋盘这样一些参数:9行 ✖ 9列,其中含有 10 个雷。

截图_20231215194801

但是我们实际绘制的棋盘一定要在四周增加一行或一列才会更优,这是为什么呢?设想用户自己明白什么是下标吗?我们呈现给用户看的时候,是否给游戏板注明清晰可见的行、列坐标是否会更好呢?我们作为程序员是否更加容易明了的去计算行、列坐标呢?

截图_20231215195119

因此我在 BoardConfig 结构体中才命名了cols rows real_cols real_rows这一系列的成员变量.

2.3.2 数据预加载

友友可能会在这里问了,这里需要预加载什么?为什么要预加载?

首先,咱们在game.h 中是否声明了很多的结构体呢?那我们还有什么东西没有声明呢?比如说一局游戏的配置列表,我们后续可以通过枚举 Difficulty 进行切换,从而选择当前游戏的配置(容易/微难/困难等等),又或者 Game 结构体对应的游戏对象,它存储着扫雷的游戏板、游戏状态等等。我们声明了全局变量,但是并未初始化,就以配置列表来说

// game.h
// 游戏板的配置列表
BoardConfig* board_configs;

你会发现我们并没有给它赋予值,那么我们就要在预加载中,提前预加载一些全局的数据,方便后续的功能模块去共享、使用、操作这个数据。我们大致已经明白了预加载的作用,那么预加载肯定是程序打开时进行加载的东西,后续都不需要重复去加载咯。

BoardConfig temp_configs[DIFFICULTY_COUNT] = {
	{9, 9, 11, 11, 10, 1000}, 
	{16, 16, 18, 18, 40, 2000},
	{24, 24, 26, 26, 99, 3000}
};
board_configs = (BoardConfig*)malloc(DIFFICULTY_COUNT * sizeof(BoardConfig));
for (int i = 0; i < DIFFICULTY_COUNT; ++i) {
	board_configs[i] = temp_configs[i];
}

我们应该要明确哪些数据需要全局化并且预加载?

单元格显示的值(未探索、周围无雷、自身是雷、放了旗帜)

游戏板的配置列表(上述举例)

当前选择的游戏配置

游戏对象(存储得有游戏状态、游戏板等)

当前用户操作的玩家对象

排行榜

其实从以上数据你会发现,有些数据我们暂时用不到,仅仅只是先声明着,随着后续的文章,你会逐渐了解到这些需要预加载的变量并且如何去使用。

现在 game.h 头文件中对这些变量进行全局声明。

// game.h
// cell显示值 使用的是ASCII编码
short int cell_unexplored;
short int cell_empty;
short int cell_mine;
short int cell_flagged;
// 游戏板的配置列表
BoardConfig* board_configs;
// 选择的游戏配置
BoardConfig board_config;
// 游戏对象
Game* game;
// 玩家对象
Player* player;
// 排行榜
Leaderboard* leaderboard;

// 预加载
void Preload();

紧接着我们在 game.c 的预加载函数 Preload 中对已声明的部分变量进行初始化赋值。

#include "game.h"  // 不要忘记包含头文件

void Preload() {
	BoardConfig temp_configs[DIFFICULTY_COUNT] = {
		{9, 9, 11, 11, 10, 1000}, 
		{16, 16, 18, 18, 40, 2000},
		{24, 24, 26, 26, 99, 3000}
	};
    // 这里使用到了 malloc,因此你需要在 game.h 中导入 malloc.h 或者 stdlib.h
	board_configs = (BoardConfig*)malloc(DIFFICULTY_COUNT * sizeof(BoardConfig));
	for (int i = 0; i < DIFFICULTY_COUNT; ++i) {
		board_configs[i] = temp_configs[i];
	}
	cell_unexplored = 46;  // .
	cell_empty = 48;  // 0
	cell_mine = 42;  // *
	cell_flagged = 70;  // F
	// TODO 预加载创建游戏对象
}

2.3.3 创建游戏对象

这里我将通过工厂函数创建游戏对象,这个工厂函数的功能比较简单,无非就是动态申请分配空间,初始化 game 对象的一些成员属性即可,然后返回相应的结构体指针。

通常,写一个功能函数要明白的是什么呢?目标功能是什么! 传递参数是什么! 可复用性考虑怎样!

咱们创建一个函数名为 CreateGame,该函数返回一个 Game 结构体的指针,可以用来干嘛呢?创建后返回并赋值给我们的全局变量(游戏对象 game)

// game.h
#include <stdbool.h>
#include <stdlib.h>
#include <stdio.h>  // 因为涉及到打印和使用到了NULL

// ...省略中间代码

// 创建游戏对象
Game* CreateGame(GameState state, Diffuculty level);

传递的参数应该是游戏状态以及难度等级,我认为这里的参数设计并不是唯一的哦!

我们来到 game.c 源文件中,编写这个函数的内部代码:

// game.c
Game* CreateGame(GameState state, Diffuculty level) {
	Game* game = (Game*)malloc(sizeof(Game));  // 动态创建
	if (game == NULL) return;
	
	game->time = 0; // 初始化时间为0
	game->state = state;  // 对接上外部赋予的状态,一般是 GAME_INIT
	game->difficult = level;  // 传递难度等级,我们这里会传入 EASY
	board_config = board_configs[game->difficult];  // 根据难度等级的枚举值,获取对应的配置

    // 接下来就利用配置去动态创建游戏板
	game->board = (Cell**)malloc(board_config.real_rows * sizeof(Cell*));
	if (game->board == NULL) {
		free(game);
		return;
	}

	for (int i = 0; i < board_config.real_rows; ++i) {
		game->board[i] = (Cell*)malloc(board_config.real_cols * sizeof(Cell));
		if (game->board[i] == NULL) {
			for (int j = 0; j < i; j++)
				free(game->board[j]);
			free(game->board);
			free(game);
			return;
		}
	}

	return game;  // 动态创建成功后返回game指针
}

接下来我们又回到预加载的函数中:

// game.c
void Preload() {
	// ...代码省略
	// TODO 预加载创建游戏对象
    // 创建游戏对象
	game = CreateGame(GAME_INIT, EASY);
}

2.3.4 初始化游戏

上述我们完成了核心数据的一些预加载,紧接着就是如何合理的利用预加载的数据。这一步,我们将对游戏板的各个单元格赋予 Cell 类型的对象。简而言之,这一步就是对游戏板部署行列号、未探索、地雷等区域。

这一步的难点,你需要分清楚 Cell 结构体中的所有成员属性的意义,特别是对于 value 的理解。

一样的,我们需要在 game.h 头文件中先声明函数。

// 预加载
void Preload();

// 新增:初始化游戏
void InitGame();

// 创建游戏对象
Game* CreateGame(GameState state, Diffuculty level);

来到 game.c 源文件中实现这个 InitGame 函数的相关功能.

这个函数是每次开始新的一局游戏都要调用的,因此我们要对 game->time 归零操作,然后获取配置的行号与列号信息,将棋盘的关键区域初始化成一系列没有被探索的 Cell 单元格,可是在此之前,我们需要做那么一件事,还记得我们二维矩阵(游戏板)是什么样的吗?

截图_20231215202845

是的,你千万不要忘记,我们需要给左、上两边的区域填充一个特殊的 Cell,用于呈现我们行、列号信息。

void InitGame() {
    // 我们每个过程都是检测游戏状态的,这样更加严格的对过程控制
	if (game->state != GAME_INIT) return;
	// 新的游戏时初始化参数
	game->time = 0;
	// 初始化行号、列号、单元格
	int rows = board_config.rows;
	int cols = board_config.cols;
	int real_rows = board_config.real_rows;
	int real_cols = board_config.real_cols;
	for (int i = 0; i < real_rows; ++i) {
		Cell edge_cell = { false, false, false, 0, i };
		game->board[i][0] = edge_cell;
	}
	for (int i = 0; i < real_cols; ++i) {
		Cell edge_cell = { false, false, false, 0, i };
		game->board[0][i] = edge_cell;
	}
	for (int row = 1; row < real_rows; ++row) {
		for (int col = 1; col < real_cols; ++col) {
			Cell init_cell = { false, false, false, 0, cell_unexplored };
			game->board[row][col] = init_cell;
		}
	}
    
    // TODO 埋雷/统计雷

}

接下来就是比较重要的事,随机埋雷,我们需要利用srandrand 两个函数获取随机值,这一步非常的简单。

void InitGame() {
    // ...
    // 埋雷
	int mine_count = board_config.mine_count;  // 获取当前配置指定的雷数量
   	int mine_row = 0, mine_col = 0;  // 初始化埋雷的行列坐标
    srand((unsigned int)time(NULL));
    while (mine_count > 0) {  // 当数量为0,就不再埋雷
        mine_row = rand() % rows + 1;  // 获取 1 ~ rows 范围的值(千万不要忘记,我们的游戏板是什么样的!!!)
        mine_col = rand() % cols + 1;  // 获取 1 ~ cols 范围的值
        if (game->board[mine_row][mine_col].is_mine) continue;  // 这个位置已经埋雷了,就跳过
        game->board[mine_row][mine_col].is_mine = true;  // 对没有布置过的地方布置雷
        game->board[mine_row][mine_col].value = cell_unexplored;  // 显示未探索,也就是隐藏嘛
        //game->board[mine_row][mine_col].value = cell_mine;  // 测试代码,解开注释后你能看到布置的雷
        mine_count--;  // 布置一颗地雷那就自减一
    }
}

Cell 结构体中的什么属性在这里还没有被处理过吗?很简单,答案是 Cell.adjacent_mines 还没有处理,它仅仅只是被赋予得有一个无意义的初始值 0。

void InitGame() {
    // ...
    // 埋雷省略...
    // 统计雷
    for (int row = 1; row < real_rows; ++row) {
		for (int col = 1; col < real_cols; ++col) {
			game->board[row][col].adjacent_mines = GetMineNearCell(row, col);  // TODO 我们要实现GetMineNearCell函数
		}
	}
    
    game->state = GAME_RUNNING;  // 初始化好后,更换游戏状态到运行中,等待输入
}

GetMineNearCell函数的功能比较简单,它主要负责统计某个单元格附近8格的含雷数量。该函数你也需要在 game.h 头文件中提前声明完毕,然后我们来看看具体实现:

截图_20231215205315

int GetMineNearCell(int row, int col) {
	int mine_count = 0, new_row = 0, new_col = 0;
	int row_offsets[] = { -1, -1, -1, 0, 0, 1, 1, 1 };
	int col_offsets[] = { -1, 0, 1, -1, 1, -1, 0, 1 };
	for (int i = 0; i < 8; ++i) {
		new_row = row + row_offsets[i];
		new_col = col + col_offsets[i];
		if (new_row < 1 || new_row > board_config.rows || new_col < 1 || new_col > board_config.cols)
			continue;  // 周围的8个坐标中出现不在范围的,即不合法坐标会跳过
		if (game->board[new_row][new_col].is_mine) {
			mine_count++;  // 如果周围出现雷,就让变量+1进行统计
		}

	}
	return mine_count;
}

2.3.5 测试初始化

这里需要明白的是,我们仅仅只是从理论和人为的主观考虑上,去实现对应功能的代码,但是还未进行功能的测试,现在我们先来到 display.h 头文件中,去声明如下一些函数:

​ void DisplayGameState(); // 这个函数用于显示当前棋盘信息
​ void DisplayErrorMsg(const char* message); // 当玩家输入坐标不合法时提示有误

声明好后去到 display.c 文件中,一一实现对应函数。

#include "game.h"  # 需要导入 game.h 因为我们会使用到当前配置 board_config

void DisplayGameState() {
	for (int i = 0; i <= board_config.rows; ++i) {
		printf("%2d ", i);  // 打印第一行的行号
	}
	printf("\n");
	for (int i = 1; i <= board_config.rows; ++i) {
		for (int j = 0; j <= board_config.cols; ++j) {
			if (j == 0) {  // 打印第一列的列号
				printf("%2d ", game->board[i][j].value);
			}
			else {  // 打印理论板上的值(ASCII值转字符)
				printf("%2c ", game->board[i][j].value);
			}
		}
		printf("\n");
	}
}

void DisplayErrorMsg(const char* message) {
	printf("错误:");
	printf("%s\n", message);
}

不要着急,我们顺手把主菜单也实现, 在 menu.h 头文件中,增加如下函数的声明:

// menu.h 文件中
#define _CRT_SECURE_NO_WARNINGS
// 过滤掉 scanf 在 msvc 下不安全的问题

#include <stdbool.h>
#include <stdio.h>
#include <string.h>

// 显示主菜单
void ShowMenu();

// 处理主菜单选项
int HandleMenuChoice();

一样的,我们上方仅仅只是声明功能函数,还没有实现功能函数的具体内容。现在来实现,非常简单!

// menu.c 文件中
#include "game.h"
#include "menu.h"

void ShowMenu() {
	printf("------------菜  单------------\n");
	printf("   1 开始游戏\t2 功能待定  \n");
	printf("    你可以输入 0 退出程序  \n");
	printf("------------------------------\n");
}

int HandleMenuChoice() {
	int choice = 0;
	printf(">>> ");
	scanf("%d", &choice);
    // 这里是6的原因,仅是一个粗略估计
	if (choice <= 6 && choice >= 0) return choice;
	else return -1;
}

现在我们来到 main.c 文件中,构建好 main 函数。

#include "menu.h"

int main() {
	int choice = 0;
	Preload();
	do
	{
		ShowMenu();
		choice = HandleMenuChoice();
		if (choice == 0) break;
		if (choice == -1) continue;
		switch (choice)
		{
		case 1: {
			InitGame();
			DisplayGameState();
			break;
		}
		default:
			break;
		}
	} while (true);

    return 0;
}

如果每一步都按照我的步骤去做的话,那么正常情况应该显示如下界面:

截图_20231215213612

2.3.6 增加操作控制

当我们初始化游戏后,得到扫雷游戏的棋盘区域,但是我们还需要增加交互操作,我们要达到的效果如下:

输入:f 1 1 这个将在有效的第一行第一列的区域进行插旗,更改的其实是 Cellis_flagged 成员属性;

输入:e 1 1 这个指令将探索对应的单元格,修改的其实是 Cellis_revealed 成员属性,并且还要根据是否踩雷等情况去区分;

上述命令都要考虑好已探索、已标记的情况。

game.h 文件声明如下的一系列函数:

// 处理游戏运行时的输入
int HandleInput(char operate, int row, int col);

// 根据单元格情况更新游戏板
void UpdateGameState(int row, int col);

// 检查判断游戏结束
bool CheckGameOver();

然后我们在 game.c 文件中进行如下的实现:

int HandleInput(char operate, int row, int col) {
    // 该函数接收的参数分别是 操作符 行号 列号
	if (row < 1 || row > board_config.rows || col < 1 || col > board_config.cols) {
		DisplayErrorMsg("输入的行号和列号并不在有效范围内!");
		return -1;  // 校验行号和列号是否有效 返回-1表示不得行,外部检测到可以要求重新输入
	}

	switch (operate)
	{
	case 'e':
		if (game->state == GAME_RUNNING) {
			game->state = GAME_ENTER;  // 修改游戏状态
			UpdateGameState(row, col);  // 更新游戏界面信息
			if (CheckGameOver()) {  // 判断游戏是否结束
				EndGame();  // 这局游戏结束时干什么,本小节不讲解
			}
			else {
				game->state = GAME_RUNNING;  // 游戏没有结束,恢复游戏状态
			}
		}
		break;
	case 'f':
		if (game->state == GAME_RUNNING) {
			game->state = GAME_ENTER;
			if (!game->board[row][col].is_revealed) {  // 如果单元格没有被探索过,因为探索了的标记起来没有意义
				game->board[row][col].is_flagged = !game->board[row][col].is_flagged;  // 那么可以标记或者取消标记
				if (game->board[row][col].is_flagged) {  // 如果是标记行为
					game->board[row][col].value = cell_flagged;  // 修改value显示值对应 F
				}
				else {
					game->board[row][col].value = cell_unexplored;  // 如果是取消标记行为,改为未探索的单元格
				}
			}
			game->state = GAME_RUNNING;
		}
		break;
	case 'q':  // 退出时依然需要给坐标(有点不合理,忍忍老铁!)
		game->state = GAME_INIT;  // 退出游戏,意味着状态恢复到待初始化
		break;
	default:
		break;
	}
	return game->state;  // 将游戏状态抛出去,根据状态干事
}

接下来我们看一下 UpdateGameState 函数的实现。

我们的游戏流程是以游戏状态为主的,此时这个函数的开始应该修改游戏状态为 GAME_UPDATE,结束后我们恢复到 GAME_RUNNING 或者 GAME_LOSE 甚至是 GAME_WIN 都有可能,读者请自行琢磨函数调用的关系。

此处的难点在于递归探索,请看这张图结合代码慢慢理解,总而言之就是探索再探索!

截图_20231215221731

void UpdateGameState(int row, int col) {
	game->state = GAME_UPDATE;
	Cell* cell = &(game->board[row][col]);
    // 探索过了 不能探索
	if (cell->is_revealed) {
		DisplayErrorMsg("你已经探索过这个区域咯!");
		return;
	}
    // 标记过了 不能探索
	if (cell->is_flagged) {
		DisplayErrorMsg("你已经标记了这个区域,不能探索哦!");
		return;
	}
    // 老铁踩雷了。其实你应该发现,踩雷了好像没做多少工作
    // 但是你往后继续研究,我是通过游戏状态去做工作的!
	if (cell->is_mine) {
		game->state = GAME_LOSE;  // 踩雷后修改游戏状态
		cell->is_revealed = true;  // 修改为已探索
	}
	else
	{
        // 下方都是没踩雷的情况
        // 当前格子附近有雷
		if (cell->adjacent_mines != 0) {
			cell->value = 48 + cell->adjacent_mines;
			cell->is_revealed = true;
		}
		else  // 当前格子附近没有雷,那还用玩家动脑吗,无脑点四周8个,我们这里程序代劳
		{
			cell->value = cell_empty;  // 当前格子附近没雷,给 cell_empty
			cell->is_revealed = true;  // 当前格子改为已探索
			int new_row = 0, new_col = 0;  // 附近格子的行列号初始化
			int row_offsets[] = { -1, -1, -1, 0, 0, 1, 1, 1 };
			int col_offsets[] = { -1, 0, 1, -1, 1, -1, 0, 1 };
			for (int i = 0; i < 8; ++i) {
                 // 四周导出偏移1位,然后挨个该状态 
				new_row = row + row_offsets[i];
				new_col = col + col_offsets[i];
				Cell new_cell = game->board[new_row][new_col];
				// 提前处理边界以及已探索和已标记等情况
				if (new_row < 1 || new_row > board_config.rows || new_col < 1 || new_col > board_config.cols) continue;
				if (new_cell.is_revealed || new_cell.is_flagged) continue;
                  // 递归的更新周围格子
				UpdateGameState(new_row, new_col);
			}
		}
	}
}

上面我们就成功实现了棋盘在用户操作的影响下正确反馈信息,紧接着读者请看我如何实现的判断游戏是否结束,如何判断游戏是胜利、失败亦或者没啥变化。

我们经过上述的更新游戏信息的函数操作时,请读者设想,我们点击的是雷,那么游戏状态是什么呢?答案是 GAME_LOSE;如果没有失败呢?那么此时的游戏状态就是 GAME_UPDATE

接下来的 CheckGameOver 函数,我们就是依据这两个状态去判断和执行。首先判断游戏失败的情况,然后判断状态合不合法(如果是 GAME_UPDATE 就是合法),合法的话继续判断玩家是否胜利。

请知悉胜利条件:在扫雷游戏中,玩家胜利的条件通常是所有没有地雷的单元格都被探索过。也就是说,如果所有的非地雷单元格都已经被探索过,那么玩家就赢了游戏。

代码如下:

bool CheckGameOver() {
	// 判断游戏失败
	if (game->state == GAME_LOSE) {
		printf("踩雷咯,游戏失败!!!\n");
		return true;
	}
	if (game->state != GAME_UPDATE) return;
	// 检测是否胜利
	for (int row = 1; row <= board_config.rows; ++row) {
		for (int col = 1; col <= board_config.cols; ++col) {
			if (!game->board[row][col].is_mine && !game->board[row][col].is_revealed)
				return false;   // 只要有一个非雷元素没有被探索,就属于没胜利情况
		}
	}
	// 胜利的情况
	game->state = GAME_WIN;
	printf("好厉害哦,人家好喜欢~\n");
	return true;
}

2.3.7 开始和结束

一定要在 game.h 中声明如下函数:

// 将雷全部显示
void ShowAllMines();

// 开始游戏
void StartGame();

// 游戏结束
void EndGame();

然后在 game.c 中实现相应代码,这 3 个函数的功能代码其实比较简单,读者需要明白何时调用它们、发生了什么即可。

void ShowAllMines() {
    // 调用:游戏结束时调用 EndGame函数中会调用它
	for (int row = 1; row <= board_config.rows; ++row) {
		for (int col = 1; col <= board_config.cols; ++col) {
			if (game->board[row][col].is_mine)
				game->board[row][col].value = cell_mine;
            	// 游戏结束时,将游戏板上是雷的value全部改为雷
		}
	}
}

void StartGame() {
    // 调用:在main.c中被调用 也就是开始游戏的时候
	char operate = 'e';  // 初始化操作符
	int row = 0, col = 0, game_state = 0;  // 初始化行列号、游戏状态
	InitGame();  // 初始化游戏,得到布满雷的游戏板
	while (true)
	{
         // 保证操作前 能看到棋盘
		DisplayGameState();
		printf("操作符:e 探索\tf 标记\tq 终止\t 格式[操作符 行号 列号]\n");
		printf("操作:");
         // 接收输入并让相应函数处理操作
		scanf(" %c %d %d", &operate, &row, &col);
		game_state = HandleInput(operate, row, col);  
		if (game_state == 0) break;  // 游戏状态回到 GAME_INIT 就退出游戏咯
		if (game_state == -1) continue;  // 输入的行列不合法跳过
	}
}

void EndGame() {
    // 调用:胜利或者失败后调用 HandleInput函数中调用它
    // 显示游戏板的完整信息
	ShowAllMines();
	DisplayGameState();  

	// TODO 计分并总结成绩

	// TODO 计分后计入排行榜

	// 状态恢复到待初始化
	game->state = GAME_INIT;

}

接下来回到 main.c 源文件中,我们修改入口函数中的代码如下:

#include "menu.h"

int main() {
	int choice = 0;
	Preload();
	do
	{
		ShowMenu();
		choice = HandleMenuChoice();
		if (choice == 0) break;
		if (choice == -1) continue;
		switch (choice)
		{
		case 1: {
			StartGame();  // 仅仅修改此处代码
			break;
		}
		default:
			break;
		}
	} while (true);

    return 0;
}

到此为止,这个扫雷游戏的基本核心我们就已经实现完毕,现在可以正常的玩这个很基础的部分了!

截图_20231216162241

3 拓展功能

上述我们完成扫雷游戏的核心部分,从本章节开始,我将介绍菜单中其它功能的实现,我们预计实现这么一些功能。

void ShowMenu() {
	printf("------------菜  单------------\n");
	printf("   1 开始游戏\t2 继续游戏  \n");
	printf("   3 设置用户\t4 选择难度   \n");
	printf("   5 保存游戏\t6 预览排行  \n");
	printf("    你可以输入 0 退出程序  \n");
	printf("------------------------------\n");
}

相比之前的版本,看起来更加复杂了一点,在此我们增加了 保存游戏继续游戏选择难度设置用户预览排行 5大功能模块。相对而言,这个扫雷项目并没有涉及多么复杂的技术,考验的仍然是C语言的基本功以及微末的算法知识。接下来的小章节我会按照各个功能的难易程度的递增顺序去书写。

3.1 增加难度可选

首先来看难度可选这个模块部分,如何实现,我们可以知道的是,在游戏未开始前,我们可以在主菜单的功能选项下输入 4,然后进入到难度选择菜单对难度进行选择。因此需要在 menu.hmenu.c 文件中声明和实现难度选择菜单。

// menu.h
// ...之前的代码已省略
// 显示难度等级
void ShowLevelMenu();

// 设置难度等级
void HandleLevelChoice();


// menu.c
// ...之前的代码已省略
void ShowLevelMenu() {
	printf("-----难度等级-----\n");
	printf("   1 非常轻松   \n");
	printf("   2 有点难度   \n");
	printf("   3 上点强度   \n");
	printf("-----------------\n");
}

void HandleLevelChoice() {
	int choice = 0;
	printf("[选择难度]>>> ");
	scanf("%d", &choice);
	// TODO 利用好int类型的变量choice 去设置难度
}

我们在上述的 HandleLevelChoice 函数末尾增加一个函数调用,稍后我们来实现所调用的这个函数,这个函数将通过用户所选择的 choice 去设置游戏的难度。

// TODO 利用好int类型的变量choice 去设置难度
ModifyDifficulty(choice);

暂且没有思路的读者,可以回想,我们的游戏难度是怎么影响到游戏的,或者反向思维思考一下,游戏难度被什么影响呢?答案是:行数列数雷数量。那么这三个因素与什么相关呢?也就是我们预加载中的配置列表与当前配置!

// 临时配置列表
BoardConfig temp_configs[DIFFICULTY_COUNT] = {
	// 理论行列数、实际行列数、雷数量、基础分,暂且不理基础分是什么!
    {9, 9, 11, 11, 10, 1000}, 
    {16, 16, 18, 18, 40, 2000},
    {24, 24, 26, 26, 99, 3000}
};
// 全局的配置列表
board_configs = (BoardConfig*)malloc(DIFFICULTY_COUNT * sizeof(BoardConfig));
for (int i = 0; i < DIFFICULTY_COUNT; ++i) {
    board_configs[i] = temp_configs[i];
}

我们通过从配置列表中获取一个配置构建我们的游戏对象,当前配置即决定游戏难度,但是,还要再往前想想,当前配置是怎么知道的呢?其实就是我们的 Difficulty 枚举对象去决定的,选择 EASY 难度,那么游戏难度就是第一档,非常容易,我们可以怎样利用用户选择的 choice 去改变呢?本质上就是将 choice 进行类型转换成对应的枚举类型,然后通过我们封装好的函数接口 CreateGame(<state>, <difficulty>) 去修改全局游戏对象。

因此来到 game.c 文件中,ModifyDifficulty(choice) 的实现如下(不要忘记在头文件中声明):

// game.h 
// ......
// 修改难度等级
void ModifyDifficulty(int choice);


// game.c
void ModifyDifficulty(int choice) {
	game = CreateGame(GAME_INIT, (Diffuculty)(choice - 1));
}

当玩家指定了难度后,当前配置的行、列、雷量等各元信息都会发生改变,从而下次游戏开始时,都会基于这些信息去构建我们的游戏板等等。

不要忘记了,还要在 main.c 源文件的 switch 分支中增加对应的选项和函数调用!

do
{
	ShowMenu();
	choice = HandleMenuChoice();
	if (choice == 0) break;
	if (choice == -1) continue;
	switch (choice)
	{
	case 1: {
		StartGame();
		break;
	}
    // 新增的功能4,修改游戏难度
	case 4: {
		ShowLevelMenu();
		HandleLevelChoice();
		break;
	}
	default:
		break;
	}
} while (true);

当程序被运行起来后,你应该在主菜单打印后选择功能 4,然后去校验不同难度下的扫雷游戏都能被正常的渲染出来。目前来看,我这边暂时没出现任何问题,程序可以正常运行。

截图_20231216185224

3.2 增加用户模块

关于用户对象,在预置数据类型中,我们已经设计好了如下结构体:

// 玩家
typedef struct {
	char name[64];  	// 玩家昵称
	char gender[24];  	// 玩家性别
	int score;  		// 玩家当局分数
	int best_score;  // 玩家历史最高分数
	short int right_flag;  // 玩家标记的正确旗帜数量
	short int error_flag;  // 玩家标记的错误旗帜数量
} Player;

// 全局对象,正在玩游戏的玩家对象
Player* player;

我们这个模块需要实现玩家对象的初始化、玩家昵称和性别的可修改、两类分数的初始化。

在主菜单打印后,选择功能3后可以进入到用户设置的菜单,比如修改玩家的昵称、性别,当选择修改昵称时,软件应该要正确的从缓冲区中获取到新的昵称,并且要与旧的昵称比较,当昵称不同时,意味着是一个新的账号,需要初始化相关的数据信息。当修改玩家的性别时,我们可以进入性别选择子菜单中再度选择,不同的选择决定了 gender 的值是男、女或者不显示。

首先在 menu.hmenu.c 中声明和实现相关函数。

// menu.h
// ......
// 设置玩家的菜单
void ShowPlayerMenu();

// 设置玩家性别的菜单
void ShowGenderMenu();

// 处理设置玩家的选项
void HandlePlayerChoice();


// menu.c
// ......
void ShowPlayerMenu() {
	DisplayPlayerInfo();   // 稍后在 display.c 中实现
	printf("1 修改昵称\t2 修改性别\t0 退出\n");
}

void ShowGenderMenu() {
	printf("-----性别选择-----\n");
	printf("   1 成为男士   \n");
	printf("   2 成为女士   \n");
	printf("   3 我都不要   \n");
	printf("-----------------\n");
}

void HandlePlayerChoice() {
	int choice = 0;
	printf("[设置用户]>>> ");
	scanf("%d", &choice);
	switch (choice)
	{
	case 1: {
		char name[50] = "";
		printf("请设置用户昵称:");
		scanf("%s", name);
		ModifyPlayerName(name);  // 稍后在 game.c 中实现
		break;
	}
	case 2: {
		ShowGenderMenu();
		int gender_choice = 0;
		printf("请选择序号设置性别:");
		scanf(" %d", &gender_choice);
		ModifyPlayerGender(gender_choice);  // 稍后在 game.c 中实现
		break;
	}
	default: {
		break;
	}
	}
}

友友可能已经看到了上方的3个函数,DisplayPlayerInfo 函数负责打印玩家的信息。

// display.h
// ......
void DisplayPlayer();  // 打印 玩家性别  不换行,比如:图图女士、兔兔男士等等
void DisplayPlayerInfo();  // 打印 用户的分数性别和当前是哪个用户

// diplay.c
// ......

ModifyPlayerName(name) 函数的作用是修改全局变量 player 的昵称,其中会有一些细节的小处理。而 ModifyPlayerGender(gender_choice) 修改的是玩家的性别,主要利用的还是 switch 语句。

// game.h
// ......
#include <string.h>  // 不要忘记了!!
// ......
// 修改玩家昵称
void ModifyPlayerName(char name[50]);

// 修改玩家性别
void ModifyPlayerGender(int gender);

// game.c
// ......
void ModifyPlayerName(char name[50]) {
	if (strcmp(player->name, name) != 0) {
        // 如果昵称和之前的不一样,重新初始化用户的相关信息
		strcpy(player->gender, "");
		player->score = 0;
		player->best_score = 0;
		player->right_flag = 0;
		player->error_flag = 0;
	}
	strcpy(player->name, name);

}

void ModifyPlayerGender(int gender) {
	switch (gender)
	{
	case 1: {
        // 修改结构体中的字符串,务必使用 strcpy 函数
		strcpy(player->gender, "男士");
		break;
	}
	case 2: {
		strcpy(player->gender, "女士");
		break;
	}
	default: {
		strcpy(player->gender, "");
		break;
	}
	}
}

友友是否认为这里就结束了呢?

答案是,没有那么简单,还记得我们仅仅只是声明了全局变量 player 吗?但是我们对它赋予一定的空间了吗?貌似什么初始化的操作都还没做。因此我们需要对它进行预加载,后续就能够方便的使用分配好的内存空间。

// game.c 的 Preload 函数中
void Preload() {
    // ...
	// 预加载玩家缺省信息
	player = (Player*)malloc(sizeof(Player));
	if (player == NULL) return;
	strcpy(player->name, "无名大侠");
	strcpy(player->gender, "");
	player->score = 0;
	player->best_score = 0;
	player->error_flag = 0;
	player->right_flag = 0;
	// 创建游戏对象
	// ...
}

// game.c 的 InitGame 函数中
void InitGame() {
	// ...
	// 新的游戏时初始化参数
	game->time = 0;
    // 新增的,可以思考为什么要增加
    // 答案:除了最高分、昵称、性别,其它信息都是当局游戏所有,而不是持续存在的,因此务必归零!
	player->score = 0;
	player->error_flag = 0;
	player->right_flag = 0;
	// 初始化行号、列号、单元格
    // ...
}

还有一个函数我们写了,但是还没用,谁呢?当然是 DisplayPlayer() 咯。如下增加调用后,我们在游戏失败或者胜利时都能够加上称谓,比如"无名大侠男士踩雷咯,游戏失败!!!"

// 来到 game.c 的 CheckGameOver 函数中

bool CheckGameOver() {
	if (game->state == GAME_LOSE) {
		DisplayPlayer();
		printf("踩雷咯,游戏失败!!!\n");
		return true;
	}
	// ....
	// 胜利的情况
	game->state = GAME_WIN;
    DisplayPlayer();
	printf("好厉害哦,人家好喜欢~\n");
	return true;
}

最最最重要的来了,要在 main.c 源文件的 switch 分支中增加对应的选项和函数调用!

    case 3: {
        ShowPlayerMenu();
        HandlePlayerChoice();
        break;
    }

实现后的效果:

截图_20231216193923

3.3 游戏后如何计分

  1. 难度因素:不同的难度级别应该有不同的基础分数。例如,简单难度的基础分数是1000,中等难度的基础分数是2000,困难难度的基础分数是3000。
  2. 时间因素:游戏的分数应该和玩家完成游戏所花费的时间成反比。例如,每过一秒,玩家的分数就减少1%。这意味着,如果玩家在100秒内完成游戏,那么他们的分数就会减少到原来的37%。
  3. 正确标记地雷的数量:每正确标记一个地雷,玩家的分数就增加一定的分数。例如,每正确标记一个地雷,玩家的分数就增加50分。
  4. 错误标记的数量:每错误标记一个地雷,玩家的分数就减少一定的分数。例如,每错误标记一个地雷,玩家的分数就减少100分。

以上的计分逻辑可以通过以下的公式来表示:

分数 = 基础分数 * (0.99 ^ 时间) + 正确标记的地雷数量 * 50 - 错误标记的数量 * 100

来到 main.cEndGame 函数中:

void EndGame() {
	// ...
	// TODO 计分并总结成绩
    CalFinalScore();  // 这个方法计算得分
	DisplayGameOver();  // 这个方法结算游戏结束成绩
	// ...
}

根据我们的公式可知,基础分数就是当前配置结构体中的成员——基础分数,一局游戏的时间可以很轻松的得到,我们声明两个全局变量 start_timeend_time,用来统计游戏开始和结束的时间结点,差值赋予给游戏对象 game 的成员变量 time 中。

// game.h
// ...
#include <math.h>  // 要用到 pow 函数
#include <time.h>  // 要用到 time 函数
// ...
// 声明全局的时间变量
time_t start_time;
time_t end_time;

// game.c 中
void EndGame() {
    end_time = time(NULL);
	game->time += (int)(end_time - start_time);
	// ...
}

接下来就是正确标记雷的数量以及错误标记雷的数量的获取,这里有两种方法,先来看第一种,第一种耦合度较低,并且你可以删除掉player 对象中的right_flag和error_flag,直接在 CalFinalScore 函数中就可以完成统计,但是有一定的开销。

short int right_flag = 0, error_flag = 0;
for (int row = 1; row <= board_config.rows; ++row) {
    for (int col = 1; col <= board_config.cols; ++col) {
        // 没有标记就跳过
        if (!game->board[row][col].is_flagged) continue;
		// 标记了
        if (game->board[row][col].is_mine) 
            right_flag++; // 是雷
        else 
            error_flag++;  // 不是雷
    }
}

第二种,比较乱,高耦合,但是开销比较低,思路很简单,给玩家对象绑定上这两个属性right_flag和error_flag,我们之前已经做了,然后在玩家标记单元格时合理判断即可:

int HandleInput(char operate, int row, int col) {
	// ...
	case 'f':
		if (game->state == GAME_RUNNING) {
			game->state = GAME_ENTER;
			if (!game->board[row][col].is_revealed) {
				game->board[row][col].is_flagged = !game->board[row][col].is_flagged;
                // 如果是标记
				if (game->board[row][col].is_flagged) {
					game->board[row][col].value = cell_flagged;
					if (game->board[row][col].is_mine)
						player->right_flag += 1;
					else
						player->error_flag += 1;

				}
				else {  // 如果是取消标记
					game->board[row][col].value = cell_unexplored;
					if (game->board[row][col].is_mine)
						player->right_flag -= 1;
					else
						player->error_flag -= 1;
				}
			}
			game->state = GAME_RUNNING;
		}
		break;
	// ...
	return game->state;
}

这里我选择第二种。接下来,看一下正主 CalFinalScore 函数的实现。

// game.h
// ......
// 计算得分
int CalFinalScore();


// game.c
// ......
int CalFinalScore() {
	int game_time = game->time;
	int base_score = 0;
	if (game->state == GAME_WIN)  // 胜利了基础分才有用
		base_score = board_config.base_score;
	short int right_flag = player->right_flag, error_flag = player->error_flag;
	int score = (int)(base_score * (pow(0.995, game_time)) + 50 * right_flag - 100 * error_flag);
    // 修正下限
	if (score < 0) score = 0;
    // 当前账号的最高分判断
	if (score > player->best_score) player->best_score = score;
	player->score = score;
	return score;
}

然后在实现一局游戏的成绩结算打印函数 DisplayGameOver

// display.h
// ......
void DisplayGameOver();


// display.c
void DisplayGameOver() {
	printf("结算成绩:\n");
	printf("本局得分——%d\n", player->score);
	printf("历史最高——%d\n", player->best_score);
}

实现效果如下:

截图_20231216201022

3.4 排行榜实现

我们之前就已经定义好了排行榜的数据结构和声明了一个全局排行榜变量:

// game.h
// ...
// 排行榜上存储的玩家分数最大数量
#define MAX_PLAYERS 10
// ...

// 排行榜
typedef struct {
	Player players[MAX_PLAYERS]; // 玩家列表
	int player_count;	// 当前上榜的玩家数量
} Leaderboard;

// 排行榜
Leaderboard* leaderboard;
// ...

我们这里使用的非常简单,对链表亦或者顺序表的选择并无太大的要求,为什么?最大数据量仅为10,无论读还是改的开销,其实都很微弱,忽略不计。此处选择顺序表结构。

排行榜的实现无非克服两个方向的问题,一个是榜上人数没有满时怎么添加,一个是榜上人数满了怎么添加。

  • 当榜上的人数没有满时,我们可以将玩家插入到 players 数组中,然后进行倒序排序;

  • 当榜上的人数已满,我们将排行榜倒序排序,然后比较最后一名与当前玩家的分数,后者小则证明当前玩家的分数无法上榜,反之我们从后往前遍历的比较,直到出现第一个比当前玩家分数大的排名,这个排名的后一位就是该玩家所能拥有的排名!

对于排序方法,我这里仅仅只是当时想学习快速排序时而对应的写下快排算法,你可以根据兴趣来。

// game.h
// ......
// 添加用户到排行榜
void AddPlayerToLeaderboard(Player* player);
// 指定下标插入玩家
void MovePlayerToEnd(Player* player, int index);
// 对排行榜进行排序
void SortLeaderboard();
// 交换两个Player
void SwapPlayer(Player* a, Player* b);
// 快速排序的分区函数
int Partition(Player arr[], int low, int high);
// 快速排序函数
void QuickSort(Player arr[], int low, int high);


// game.c
// 预加载
void Preload() {
	// ......
	// 预加载排行榜
	leaderboard = (Leaderboard*)malloc(sizeof(Leaderboard));
	leaderboard->player_count = 0;
}

// ...
void AddPlayerToLeaderboard(Player* player) {
	if (leaderboard->player_count < MAX_PLAYERS) {
		leaderboard->players[leaderboard->player_count] = *player;
		leaderboard->player_count++;
		SortLeaderboard(); // 增加完后,进行倒序排序
	}
	else {
		// 先倒序排序,然后进行判断和移动
		SortLeaderboard(); 
		int last_index = MAX_PLAYERS - 1;
		if (leaderboard->players[last_index].score >= player->score)
			return;  // 上榜资格的认定
		for (last_index; last_index >= 0; last_index--) {
			if (leaderboard->players[last_index].score < player->score) {
				continue;
			}
			else {
				int get_index = last_index + 1;
				MovePlayerToEnd(player, get_index);
				return; // 分数比前个玩家高
			}
		}
		// 当上述不满足,即分数霸榜
		MovePlayerToEnd(player, 0);
	}
}

void SwapPlayer(Player* a, Player* b) {
	Player t = *a;
	*a = *b;
	*b = t;
}

int Partition(Player arr[], int low, int high) {
	int pivot = arr[low].score;
	int i = low, j = high;
	while (i < j) {
		while (i < j && arr[j].score < pivot)
		{
			j--;
		}
		if (i < j) {
			SwapPlayer(&arr[j], &arr[i]);
			i++;
		}
		while (i < j && arr[i].score > pivot)
		{
			i++;
		}
		if (i < j) {
			SwapPlayer(&arr[i], &arr[j]);
			j--;
		}
		if (i >= j){
			return j;
		}

	}
	return j;
}

void QuickSort(Player arr[], int low, int high) {
	if (low < high) {
		int pi = Partition(arr, low, high);

		QuickSort(arr, low, pi - 1);
		QuickSort(arr, pi + 1, high);
	}
}

void SortLeaderboard() {
	QuickSort(leaderboard->players, 0, leaderboard->player_count - 1);
}

void MovePlayerToEnd(Player* player, int index) {
	for (int cur = MAX_PLAYERS - 1; cur > index; cur--) {
		leaderboard->players[cur] = leaderboard->players[cur - 1];
	}
	leaderboard->players[index] = *player;
}

主要调用的是 AddPlayerToLeaderboard(Player* player) 函数,在何处调用呢?

void EndGame() {
	// ...

	// TODO 计分并总结成绩
	CalFinalScore();  // 这个方法计算得分
	DisplayGameOver();  // 这个方法结算游戏结束成绩

	// TODO 计分后计入排行榜
	AddPlayerToLeaderboard(player);

	// 状态恢复到待初始化
	game->state = GAME_INIT;
}

主菜单的浏览排行榜功能非常简单,在 diplay.h 头文件中声明函数 void DisplayLeaderboard();:

// display.c
void DisplayLeaderboard() {
	for (int i = 0; i < leaderboard->player_count; i++) {
		printf("%d. %s: %d\n", i + 1, leaderboard->players[i].name, leaderboard->players[i].score);
	}
}

然后更改 main.c 文件中的 main 函数,增加功能选项。

case 6: {
    DisplayLeaderboard();
    break;
}

效果预览:

截图_20231216202715

4 本地存储

读者可能在看上面的功能实现时,可能产生这样的疑问,应该还有一个保存游戏、继续游戏的模块没有实现吧?上面的功能再怎么增加,貌似都只能在该程序的生命周期中玩,而不能持久的玩,排行榜实现了,但是意义不大。其实我们还差得比较多,未实现的还有保存游戏加载游戏(继续游戏也包含其中)保存排行榜加载排行榜。我们这一章的目的就是,如果不存在本地数据,那么我们直接按照之前的函数功能去创建游戏数据,如果已经存在了,那么就加载本地的游戏数据覆盖。

现在 storage.h 文件中声明以下函数和宏:

#define GAME_FILE "minesweeper.dat"
// 游戏数据路径
#define BOARD_FILE "leapboard.dat"
// 排行榜数据路径

// 以下含义?看名字吧~家人
bool LoadGame();
bool SaveGame();
bool SaveLeaderboard();
bool LoadLeaderboard();

4.1 存储游戏数据

何时存储游戏数据呢?那当然是玩家在主菜单功能选项选择功能5时去人为保存。还有吗?仔细想想,当用户保存上一次的游戏数据,然后继续游戏,玩了一会儿,玩家选择中途退出,那么我们就要去保存继续游戏的的数据,玩家就不用在菜单中再次去手动保存咯。因此这就是开始游戏和继续游戏的区别,开始游戏完全就是新的一盘游戏,而继续游戏将会读取本地数据,未读取到则以开始游戏的核心方法去开始,读取到了,则加载游戏,玩家中途退出时,自动保存。

先来看看这两个加载保存的函数的实现:

// storage.c
#include "game.h"
#include "storage.h"


bool LoadGame() {
    FILE* file = fopen(GAME_FILE, "rb");
    if (file == NULL) return false;
	// 读取当前配置
    fread(&board_config, sizeof(BoardConfig), 1, file);
	// 读取玩家
    player = (Player*)malloc(sizeof(Player));
    if (player == NULL) {
        printf("Failed to allocate memory for player.\n");
        return false;
    }
    fread(player, sizeof(Player), 1, file);
	// 读取游戏对象包括游戏板等数据
    game = (Game*)malloc(sizeof(Game));
    if (game == NULL) {
        printf("Failed to allocate memory for game.\n");
        return false;
    }
    fread(game, sizeof(Game), 1, file);
    game->board = (Cell**)malloc(board_config.real_rows * sizeof(Cell*));
    if (game->board == NULL) {
        printf("Failed to allocate memory for game board.\n");
        return false;
    }
    for (int i = 0; i < board_config.real_rows; i++) {
        game->board[i] = (Cell*)malloc(board_config.real_cols * sizeof(Cell));
        if (game->board[i] == NULL) {
            printf("Failed to allocate memory for game board row.\n");
            return false;
        }
        fread(game->board[i], sizeof(Cell), board_config.real_cols, file);
    }

    fclose(file);
    return true;
}

bool SaveGame() {
	FILE* file = fopen(GAME_FILE, "wb");
	if (file == NULL) return false;
	// 向文件中写入当前配置
    fwrite(&board_config, sizeof(BoardConfig), 1, file);
	// 向文件中写入当前用户
    fwrite(player, sizeof(Player), 1, file);
	// 向文件中写入游戏对象以及游戏板
    fwrite(game, sizeof(Game), 1, file);
    for (int i = 0; i < board_config.real_rows; i++) {
        fwrite(game->board[i], sizeof(Cell), board_config.real_cols, file);
    }

	fclose(file);
    return true;
}

bool SaveLeaderboard(){

}

bool LoadLeaderboard(){

}

可以看到,读取/保存成功与否都是返回 bool 值,这就很方便了。

// main.c
Preload();
if (!LoadGame()) InitGame();  // 如果加载失败,则初始化,思考一下为什么预加载后要进行加载或者初始化
do
// ...

这里进行加载数据的原因,是我考虑到玩家在没有任何数据的情况下,他第一次进入游戏,然后保存游戏,那么所保存的数据都将是未初始化的数据,这并不好,因此我们这里增加这样一行代码。

然后我们看一下主菜单处保存游戏的调用:

// main.c
	bool save_state = false;
// ...
    case 5: {
        save_state = SaveGame();
        if (save_state) printf("保存成功\n");
        else printf("保存失败\n");
        break;
    }

紧接着来看一下主菜单继续游戏的实现。

// game.h
// ...
// 继续游戏
void ContinueGame();


// game.c
// 和开始游戏的函数非常相似,这里为什么不提取公共部分,我的考虑是因为这样方便可定制部分功能。
void ContinueGame() {
	char operate = 'e';
	int row = 0, col = 0, game_state = 0;
	start_time = time(NULL);
	bool load_state = LoadGame();
	if (!load_state) {  // 加载成功的状态
		InitGame();
	}
	else {
        // 如果加载成功更改状态,因为改为 GAME_RUNNING,我们才能对其操作
		game->state = GAME_RUNNING;
	}
	while (true)
	{
		DisplayGameState();
		printf("操作符:e 探索\tf 标记\tq 终止\t 格式[操作符 行号 列号]\n");
		printf("操作:");
		scanf(" %c %d %d", &operate, &row, &col);
		game_state = HandleInput(operate, row, col);
		if (game_state == 0) {
			SaveGame();  // 保存游戏
			break;
		}
		if (game_state == -1) continue;
	}
}

void EndGame() {
	// ...

	// 上下初始状态是为了防止继续游戏后出现的还是游戏结束的画面
	game->state = GAME_INIT;
	InitGame();  // 清空游戏板并恢复到初始
	game->state = GAME_INIT;
}


// main.c 中如何调用?
    switch (choice)
    {
    // ......
    case 2: {
        ContinueGame();
        break;
    }
    // ......

我们可以看一下效果,还是非常棒的:

截图_20231216211844

4.2 存储排行榜数据

该章节是本篇博文的最后一部分,这部分的代码其实非常简单,当你能够对上述的存储游戏数据有一定的了解之后,存储排行榜和读取排行榜真的再简单不过了!直接上代码:

// storage.c
// ......
bool SaveLeaderboard() {
    FILE* file = fopen(BOARD_FILE, "wb");
    if (file == NULL) return false;

    fwrite(leaderboard, sizeof(Leaderboard), 1, file);
    fwrite(leaderboard->players, sizeof(Player), leaderboard->player_count, file);

    fclose(file);
    return true;
}

bool LoadLeaderboard(){
    FILE* file = fopen(BOARD_FILE, "rb");
    if (file == NULL) return false;

    fread(leaderboard, sizeof(Leaderboard), 1, file);
    fread(leaderboard->players, sizeof(Player), leaderboard->player_count, file);

    fclose(file);
    return true;
}

什么时候保存呢?真相只有一个——在 game.c 中的 EndGame 中调用 SaveLeaderboard 函数,非常好理解,就是每次上榜后,我们就进行排行榜的保存。优化建议:我比较懒,这里就不处理未上榜的情况啦,因为未上榜就无需在重新写一遍文件了嘛!

void EndGame() {
	// ...
	// 计入排行榜
	AddPlayerToLeaderboard(player);
	SaveLeaderboard();
	// ...
}

那什么时候加载排行榜呢?非常简单!预加载之后立马加载排行榜数据即可。

Preload();
LoadLeaderboard();  // 这里哟 亲~
if (!LoadGame()) InitGame();

写到这里,我也不知道读者明白几何,可曾注意到一些细节,比如继续游戏,每次都是从本地读取出来数据,当玩家一会儿继续游戏、一会儿退出的,那么我们的计分方式还有问题吗?这就是我为何使用的 += 而不是=的意义,只有 += ,才能够统计每次玩了多长时间并加到原来的时间上。又或者我这里的快排分区的思想你自己又还能怎么修改呢心里可有答案?我已经在无资格上榜那里做了条件判断,我所提到的优化仅仅只是几行代码的问题,这些都留给读者慢慢的细嚼慢咽,学习扫雷游戏,我的收获还是颇丰的,将来有机会,写个最优决策的扫雷小挂玩玩。hh~生活愉快,友友们

posted @ 2023-12-16 22:14  顾平安  阅读(326)  评论(0编辑  收藏  举报