探祕掃雷遊戲的C語言實現

2023-12-17 06:00:25

1 引言

1.1 為什麼寫這篇文章?

專案倉庫地址:基於 C 語言實現的掃雷遊戲

我決定寫這篇文章的初衷是想分享我在使用C語言開發掃雷遊戲的經驗和心得。通過這篇文章,我希望能夠向讀者展示我是如何利用C語言的基礎知識和程式設計技巧,實現了這個經典遊戲的版本。

在掃雷遊戲中,玩家可以通過揭開方塊來逐步推斷哪些方塊是安全的,哪些方塊可能包含地雷。遊戲中的數位提示會告訴玩家周圍8個方塊中有多少個地雷,玩家需要根據這些提示來推斷出地雷的位置。遊戲的目標是儘可能地揭開所有非雷方塊,而不揭開任何地雷方塊。

1.3 實現怎樣的掃雷遊戲?

  • 具備哪些功能?
    • 基本的掃雷遊戲,能夠做到最基礎的遊戲勝利與失敗的判斷
    • 可以選擇難度,不同的難度對應著不同的棋盤大小、地雷數目
    • 可以設定不同的使用者,每個使用者都能設定性別和進行計分
    • 構建分數排行榜,無論是同一使用者或者不同使用者,達到條件即上榜
  • 怎樣去操作?
    • 終端介面進行控制,理論上 Qt 介面也脫離不了其中核心,無非多了遊戲迴圈處理以及訊號和槽的使用等等
    • 傳統功能選項的選單,比如開始遊戲/繼續遊戲/設定使用者/選擇難度/檢視排行榜等等功能,都通過選項去呼叫
    • 遊戲過程中,玩家輸入不同操作符和行列號操作遊戲中的單元格,期間可以隨時退出,而且可以儲存遊戲

2 實現思路

但是我們實際繪製的棋盤一定要在四周增加一行或一列才會更優,這是為什麼呢?設想使用者自己明白什麼是下標嗎?我們呈現給使用者看的時候,是否給遊戲板註明清晰可見的行、列座標是否會更好呢?我們作為程式設計師是否更加容易明瞭的去計算行、列座標呢?

因此我在 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];
}

我們應該要明確哪些資料需要全域性化並且預載入?

是的,你千萬不要忘記,我們需要給左、上兩邊的區域填充一個特殊的 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 標頭檔案中提前宣告完畢,然後我們來看看具體實現:

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;
}

如果每一步都按照我的步驟去做的話,那麼正常情況應該顯示如下介面:

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 都有可能,讀者請自行琢磨函數呼叫的關係。

此處的難點在於遞迴探索,請看這張圖結合程式碼慢慢理解,總而言之就是探索再探索!

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;
}

到此為止,這個掃雷遊戲的基本核心我們就已經實現完畢,現在可以正常的玩這個很基礎的部分了!

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,然後去校驗不同難度下的掃雷遊戲都能被正常的渲染出來。目前來看,我這邊暫時沒出現任何問題,程式可以正常執行。

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;
    }

實現後的效果:

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);
}

實現效果如下:

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;
}

效果預覽:

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;
    }
    // ......

我們可以看一下效果,還是非常棒的:

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~生活愉快,友友們