探秘扫雷游戏的C语言实现
1 引言
1.1 為什么寫這篇文章?
項目倉庫地址:基于 C 語言實現(xiàn)的掃雷游戲
我決定寫這篇文章的初衷是想分享我在使用C語言開發(fā)掃雷游戲的經(jīng)驗和心得。通過這篇文章,我希望能夠向讀者展示我是如何利用C語言的基礎(chǔ)知識和編程技巧,實現(xiàn)了這個經(jīng)典游戲的版本。
我相信這將對想要了解C應(yīng)用的程序員或者C的初學(xué)者們應(yīng)該會有所幫助。
1.2 什么是掃雷游戲?
掃雷游戲是一款經(jīng)典的單人電腦游戲,玩家需要根據(jù)數(shù)字提示推斷雷的位置,最終目標是揭開所有非雷方塊而不揭開任何雷方塊。游戲通常會在一個方塊陣列中隱藏一些地雷,玩家需要根據(jù)周圍方塊中的數(shù)字提示來推斷哪些方塊中包含地雷。這是一款考驗玩家邏輯思維和推理能力的游戲,也是許多人童年時的回憶。
推廣一下我寫的油猴插件,別人學(xué)掃雷實現(xiàn)的時候,我在寫掃雷的外掛,聽課屬實有點無聊hhh,寫個掃雷游戲的作弊工具提提神
點擊跳轉(zhuǎn)->跟著我一起掃雷吧
在掃雷游戲中,玩家可以通過揭開方塊來逐步推斷哪些方塊是安全的,哪些方塊可能包含地雷。游戲中的數(shù)字提示會告訴玩家周圍8個方塊中有多少個地雷,玩家需要根據(jù)這些提示來推斷出地雷的位置。游戲的目標是盡可能地揭開所有非雷方塊,而不揭開任何地雷方塊。
1.3 實現(xiàn)怎樣的掃雷游戲?
-
具備哪些功能?
- 基本的掃雷游戲,能夠做到最基礎(chǔ)的游戲勝利與失敗的判斷
- 可以選擇難度,不同的難度對應(yīng)著不同的棋盤大小、地雷數(shù)目
- 可以設(shè)置不同的用戶,每個用戶都能設(shè)置性別和進行計分
- 構(gòu)建分數(shù)排行榜,無論是同一用戶或者不同用戶,達到條件即上榜
-
怎樣去操作?
- 終端界面進行控制,理論上 Qt 界面也脫離不了其中核心,無非多了游戲循環(huán)處理以及信號和槽的使用等等
- 傳統(tǒng)功能選項的菜單,比如開始游戲/繼續(xù)游戲/設(shè)置用戶/選擇難度/查看排行榜等等功能,都通過選項去調(diào)用
- 游戲過程中,玩家輸入不同操作符和行列號操作游戲中的單元格,期間可以隨時退出,而且可以保存游戲
2 實現(xiàn)思路
開發(fā)工具: Microsoft Visual Studio
編譯器: MSVC ? ? C標準: C99
2.1 項目結(jié)構(gòu)劃分
游戲入口: main.c 該文件負責(zé)游戲的入口以及預(yù)加載游戲的一些數(shù)據(jù),通過玩家不同的輸入,對接不同的功能。
游戲模塊: game.h 與 game.c 其中頭文件聲明游戲中可能使用到的結(jié)構(gòu)體、宏定義、全局變量、函數(shù)聲明等等,而源文件則是游戲中各功能模塊的相關(guān)代碼實現(xiàn)。
顯示模塊: display.h 與 display.c 該模塊負責(zé)游戲過程中的游戲板元素顯示、錯誤提示、打印玩家信息、結(jié)算成績、打印排行榜等功能的聲明與實現(xiàn)。
菜單模塊: menu.h 與 menu.c 這個模塊負責(zé)呈現(xiàn)各級菜單以及用戶對于菜單功能選擇的輸入反饋處理等。
存儲模塊: storage.h 與 storage.c 該存儲模塊負責(zé)將游戲數(shù)據(jù)、排行榜數(shù)據(jù)的本地存儲和加載。
2.2 預(yù)置數(shù)據(jù)類型
// game.h
#define _CRT_SECURE_NO_WARNINGS
#include <stdbool.h> // 因為用到了bool類型
// 排行榜上存儲的玩家分數(shù)最大數(shù)量
#define MAX_PLAYERS 10
// 單元格
typedef struct {
bool is_mine; // 有沒有雷
bool is_revealed; // 有沒有探索
bool is_flagged; // 有沒有放小旗(掃雷游戲中的玩法: 當(dāng)質(zhì)疑是雷時,你可以防止旗幟標記)
short int adjacent_mines; // 附近的雷數(shù)量(0-8)
short int value; // 打印的時候顯示的值
} Cell;
// 游戲板的配置
typedef struct {
int rows; // 理論行數(shù)
int cols; // 理論列數(shù)
int real_rows; // 實際構(gòu)建的行數(shù)
int real_cols; // 實際構(gòu)建的列數(shù)
int mine_count; // 雷的數(shù)量
int base_score; // 當(dāng)前配置的基礎(chǔ)分
} BoardConfig;
// 游戲狀態(tài)
typedef enum {
GAME_INIT, // 待初始化,該狀態(tài)象征著新的一局游戲還沒開始
GAME_RUNNING, // 已經(jīng)初始化了,運行中,等待玩家下一步操作
GAME_ENTER, // 游戲接收了輸入,正在處理中,處理中可能進入更新界面狀態(tài)
GAME_UPDATE, // 更新界面狀態(tài),用戶對某個單元格進行操作后的反饋
GAME_WIN, // 游戲勝利,在游戲更新界面至游戲失敗的中間,會進行成功的檢查
GAME_LOSE // 游戲失敗,該狀態(tài)在更新界面時會對玩家操作后的行為檢測
} GameState;
// 游戲難度
typedef enum {
EASY, MEDIUM, HARD, // 3個難度
DIFFICULTY_COUNT // 計數(shù) 3
} Diffuculty;
// 游戲配置
typedef struct {
Cell** board; // 游戲板 二維矩陣
GameState state; // 游戲狀態(tài)
Diffuculty difficult; // 游戲難度等級
int time; // 某局游戲的用時
} Game;
// 玩家
typedef struct {
char name[64]; // 玩家昵稱
char gender[24]; // 玩家性別
int score; // 玩家當(dāng)局分數(shù)
int best_score; // 玩家歷史最高分數(shù)
short int right_flag; // 玩家標記的正確旗幟數(shù)量
short int error_flag; // 玩家標記的錯誤旗幟數(shù)量
} Player;
// 排行榜
typedef struct {
Player players[MAX_PLAYERS]; // 玩家列表
int player_count; // 當(dāng)前上榜的玩家數(shù)量
} Leaderboard;
2.3 核心功能實現(xiàn)
2.3.1 棋盤設(shè)定
我們先以難度為容易的掃雷游戲為研究方向,容易難度下,我們給予棋盤這樣一些參數(shù):9行 ? 9列,其中含有 10 個雷。
但是我們實際繪制的棋盤一定要在四周增加一行或一列才會更優(yōu),這是為什么呢?設(shè)想用戶自己明白什么是下標嗎?我們呈現(xiàn)給用戶看的時候,是否給游戲板注明清晰可見的行、列坐標是否會更好呢?我們作為程序員是否更加容易明了的去計算行、列坐標呢?
因此我在 BoardConfig 結(jié)構(gòu)體中才命名了cols rows real_cols real_rows這一系列的成員變量.
2.3.2 數(shù)據(jù)預(yù)加載
友友可能會在這里問了,這里需要預(yù)加載什么?為什么要預(yù)加載?
首先,咱們在game.h 中是否聲明了很多的結(jié)構(gòu)體呢?那我們還有什么東西沒有聲明呢?比如說一局游戲的配置列表,我們后續(xù)可以通過枚舉 Difficulty 進行切換,從而選擇當(dāng)前游戲的配置(容易/微難/困難等等),又或者 Game 結(jié)構(gòu)體對應(yīng)的游戲?qū)ο螅鎯χ鴴呃椎挠螒虬濉⒂螒驙顟B(tài)等等。我們聲明了全局變量,但是并未初始化,就以配置列表來說
// game.h
// 游戲板的配置列表
BoardConfig* board_configs;
你會發(fā)現(xiàn)我們并沒有給它賦予值,那么我們就要在預(yù)加載中,提前預(yù)加載一些全局的數(shù)據(jù),方便后續(xù)的功能模塊去共享、使用、操作這個數(shù)據(jù)。我們大致已經(jīng)明白了預(yù)加載的作用,那么預(yù)加載肯定是程序打開時進行加載的東西,后續(xù)都不需要重復(fù)去加載咯。
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];
}
我們應(yīng)該要明確哪些數(shù)據(jù)需要全局化并且預(yù)加載?
單元格顯示的值(未探索、周圍無雷、自身是雷、放了旗幟)
游戲板的配置列表(上述舉例)
當(dāng)前選擇的游戲配置
游戲?qū)ο?存儲得有游戲狀態(tài)、游戲板等)
當(dāng)前用戶操作的玩家對象
排行榜
其實從以上數(shù)據(jù)你會發(fā)現(xiàn),有些數(shù)據(jù)我們暫時用不到,僅僅只是先聲明著,隨著后續(xù)的文章,你會逐漸了解到這些需要預(yù)加載的變量并且如何去使用。
現(xiàn)在 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;
// 游戲?qū)ο?Game* game;
// 玩家對象
Player* player;
// 排行榜
Leaderboard* leaderboard;
// 預(yù)加載
void Preload();
緊接著我們在 game.c 的預(yù)加載函數(shù) 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 中導(dǎo)入 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 預(yù)加載創(chuàng)建游戲?qū)ο?}
目標功能是什么! 傳遞參數(shù)是什么! 可復(fù)用性考慮怎樣!
咱們創(chuàng)建一個函數(shù)名為 CreateGame,該函數(shù)返回一個 Game 結(jié)構(gòu)體的指針,可以用來干嘛呢?創(chuàng)建后返回并賦值給我們的全局變量(游戲?qū)ο?game)
// game.h
#include <stdbool.h>
#include <stdlib.h>
#include <stdio.h> // 因為涉及到打印和使用到了NULL
// ...省略中間代碼
// 創(chuàng)建游戲?qū)ο?Game* CreateGame(GameState state, Diffuculty level);
傳遞的參數(shù)應(yīng)該是游戲狀態(tài)以及難度等級,我認為這里的參數(shù)設(shè)計并不是唯一的哦!
我們來到 game.c 源文件中,編寫這個函數(shù)的內(nèi)部代碼:
// game.c
Game* CreateGame(GameState state, Diffuculty level) {
Game* game = (Game*)malloc(sizeof(Game)); // 動態(tài)創(chuàng)建
if (game == NULL) return;
game->time = 0; // 初始化時間為0
game->state = state; // 對接上外部賦予的狀態(tài),一般是 GAME_INIT
game->difficult = level; // 傳遞難度等級,我們這里會傳入 EASY
board_config = board_configs[game->difficult]; // 根據(jù)難度等級的枚舉值,獲取對應(yīng)的配置
// 接下來就利用配置去動態(tài)創(chuàng)建游戲板
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; // 動態(tài)創(chuàng)建成功后返回game指針
}
接下來我們又回到預(yù)加載的函數(shù)中:
// game.c
void Preload() {
// ...代碼省略
// TODO 預(yù)加載創(chuàng)建游戲?qū)ο? // 創(chuàng)建游戲?qū)ο? game = CreateGame(GAME_INIT, EASY);
}
2.3.4 初始化游戲
上述我們完成了核心數(shù)據(jù)的一些預(yù)加載,緊接著就是如何合理的利用預(yù)加載的數(shù)據(jù)。這一步,我們將對游戲板的各個單元格賦予 Cell 類型的對象。簡而言之,這一步就是對游戲板部署行列號、未探索、地雷等區(qū)域。
這一步的難點,你需要分清楚 Cell 結(jié)構(gòu)體中的所有成員屬性的意義,特別是對于 value 的理解。
一樣的,我們需要在 game.h 頭文件中先聲明函數(shù)。
// 預(yù)加載
void Preload();
// 新增:初始化游戲
void InitGame();
// 創(chuàng)建游戲?qū)ο?Game* CreateGame(GameState state, Diffuculty level);
來到 game.c 源文件中實現(xiàn)這個 InitGame 函數(shù)的相關(guān)功能.
這個函數(shù)是每次開始新的一局游戲都要調(diào)用的,因此我們要對 game->time 歸零操作,然后獲取配置的行號與列號信息,將棋盤的關(guān)鍵區(qū)域初始化成一系列沒有被探索的 Cell 單元格,可是在此之前,我們需要做那么一件事,還記得我們二維矩陣(游戲板)是什么樣的嗎?
是的,你千萬不要忘記,我們需要給左、上兩邊的區(qū)域填充一個特殊的 Cell,用于呈現(xiàn)我們行、列號信息。
void InitGame() {
// 我們每個過程都是檢測游戲狀態(tài)的,這樣更加嚴格的對過程控制
if (game->state != GAME_INIT) return;
// 新的游戲時初始化參數(shù)
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 埋雷/統(tǒng)計雷
}
接下來就是比較重要的事,隨機埋雷,我們需要利用srand 與 rand 兩個函數(shù)獲取隨機值,這一步非常的簡單。
void InitGame() {
// ...
// 埋雷
int mine_count = board_config.mine_count; // 獲取當(dāng)前配置指定的雷數(shù)量
int mine_row = 0, mine_col = 0; // 初始化埋雷的行列坐標
srand((unsigned int)time(NULL));
while (mine_count > 0) { // 當(dāng)數(shù)量為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; // 這個位置已經(jīng)埋雷了,就跳過
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 結(jié)構(gòu)體中的什么屬性在這里還沒有被處理過嗎?很簡單,答案是 Cell.adjacent_mines 還沒有處理,它僅僅只是被賦予得有一個無意義的初始值 0。
void InitGame() {
// ...
// 埋雷省略...
// 統(tǒng)計雷
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 我們要實現(xiàn)GetMineNearCell函數(shù)
}
}
game->state = GAME_RUNNING; // 初始化好后,更換游戲狀態(tài)到運行中,等待輸入
}
GetMineNearCell函數(shù)的功能比較簡單,它主要負責(zé)統(tǒng)計某個單元格附近8格的含雷數(shù)量。該函數(shù)你也需要在 game.h 頭文件中提前聲明完畢,然后我們來看看具體實現(xiàn):
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個坐標中出現(xiàn)不在范圍的,即不合法坐標會跳過
if (game->board[new_row][new_col].is_mine) {
mine_count++; // 如果周圍出現(xiàn)雷,就讓變量+1進行統(tǒng)計
}
}
return mine_count;
}
2.3.5 測試初始化
這里需要明白的是,我們僅僅只是從理論和人為的主觀考慮上,去實現(xiàn)對應(yīng)功能的代碼,但是還未進行功能的測試,現(xiàn)在我們先來到
display.h頭文件中,去聲明如下一些函數(shù):? void DisplayGameState(); // 這個函數(shù)用于顯示當(dāng)前棋盤信息
? void DisplayErrorMsg(const char* message); // 當(dāng)玩家輸入坐標不合法時提示有誤
聲明好后去到 display.c 文件中,一一實現(xiàn)對應(yīng)函數(shù)。
#include "game.h" # 需要導(dǎo)入 game.h 因為我們會使用到當(dāng)前配置 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值轉(zhuǎn)字符)
printf("%2c ", game->board[i][j].value);
}
}
printf("\n");
}
}
void DisplayErrorMsg(const char* message) {
printf("錯誤:");
printf("%s\n", message);
}
不要著急,我們順手把主菜單也實現(xiàn), 在 menu.h 頭文件中,增加如下函數(shù)的聲明:
// menu.h 文件中
#define _CRT_SECURE_NO_WARNINGS
// 過濾掉 scanf 在 msvc 下不安全的問題
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
// 顯示主菜單
void ShowMenu();
// 處理主菜單選項
int HandleMenuChoice();
一樣的,我們上方僅僅只是聲明功能函數(shù),還沒有實現(xiàn)功能函數(shù)的具體內(nèi)容。現(xiàn)在來實現(xiàn),非常簡單!
// 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;
}
現(xiàn)在我們來到 main.c 文件中,構(gòu)建好 main 函數(shù)。
#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;
}
如果每一步都按照我的步驟去做的話,那么正常情況應(yīng)該顯示如下界面:
2.3.6 增加操作控制
當(dāng)我們初始化游戲后,得到掃雷游戲的棋盤區(qū)域,但是我們還需要增加交互操作,我們要達到的效果如下:
輸入:f 1 1 這個將在有效的第一行第一列的區(qū)域進行插旗,更改的其實是 Cell 的 is_flagged 成員屬性;
輸入:e 1 1 這個指令將探索對應(yīng)的單元格,修改的其實是 Cell 的 is_revealed 成員屬性,并且還要根據(jù)是否踩雷等情況去區(qū)分;
上述命令都要考慮好已探索、已標記的情況。
在 game.h 文件聲明如下的一系列函數(shù):
// 處理游戲運行時的輸入
int HandleInput(char operate, int row, int col);
// 根據(jù)單元格情況更新游戲板
void UpdateGameState(int row, int col);
// 檢查判斷游戲結(jié)束
bool CheckGameOver();
然后我們在 game.c 文件中進行如下的實現(xiàn):
int HandleInput(char operate, int row, int col) {
// 該函數(shù)接收的參數(shù)分別是 操作符 行號 列號
if (row < 1 || row > board_config.rows || col < 1 || col > board_config.cols) {
DisplayErrorMsg("輸入的行號和列號并不在有效范圍內(nèi)!");
return -1; // 校驗行號和列號是否有效 返回-1表示不得行,外部檢測到可以要求重新輸入
}
switch (operate)
{
case 'e':
if (game->state == GAME_RUNNING) {
game->state = GAME_ENTER; // 修改游戲狀態(tài)
UpdateGameState(row, col); // 更新游戲界面信息
if (CheckGameOver()) { // 判斷游戲是否結(jié)束
EndGame(); // 這局游戲結(jié)束時干什么,本小節(jié)不講解
}
else {
game->state = GAME_RUNNING; // 游戲沒有結(jié)束,恢復(fù)游戲狀態(tài)
}
}
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顯示值對應(yīng) F
}
else {
game->board[row][col].value = cell_unexplored; // 如果是取消標記行為,改為未探索的單元格
}
}
game->state = GAME_RUNNING;
}
break;
case 'q': // 退出時依然需要給坐標(有點不合理,忍忍老鐵!)
game->state = GAME_INIT; // 退出游戲,意味著狀態(tài)恢復(fù)到待初始化
break;
default:
break;
}
return game->state; // 將游戲狀態(tài)拋出去,根據(jù)狀態(tài)干事
}
接下來我們看一下 UpdateGameState 函數(shù)的實現(xiàn)。
我們的游戲流程是以游戲狀態(tài)為主的,此時這個函數(shù)的開始應(yīng)該修改游戲狀態(tài)為 GAME_UPDATE,結(jié)束后我們恢復(fù)到 GAME_RUNNING 或者 GAME_LOSE 甚至是 GAME_WIN 都有可能,讀者請自行琢磨函數(shù)調(diào)用的關(guān)系。
此處的難點在于遞歸探索,請看這張圖結(jié)合代碼慢慢理解,總而言之就是探索再探索!
void UpdateGameState(int row, int col) {
game->state = GAME_UPDATE;
Cell* cell = &(game->board[row][col]);
// 探索過了 不能探索
if (cell->is_revealed) {
DisplayErrorMsg("你已經(jīng)探索過這個區(qū)域咯!");
return;
}
// 標記過了 不能探索
if (cell->is_flagged) {
DisplayErrorMsg("你已經(jīng)標記了這個區(qū)域,不能探索哦!");
return;
}
// 老鐵踩雷了。其實你應(yīng)該發(fā)現(xiàn),踩雷了好像沒做多少工作
// 但是你往后繼續(xù)研究,我是通過游戲狀態(tài)去做工作的!
if (cell->is_mine) {
game->state = GAME_LOSE; // 踩雷后修改游戲狀態(tài)
cell->is_revealed = true; // 修改為已探索
}
else
{
// 下方都是沒踩雷的情況
// 當(dāng)前格子附近有雷
if (cell->adjacent_mines != 0) {
cell->value = 48 + cell->adjacent_mines;
cell->is_revealed = true;
}
else // 當(dāng)前格子附近沒有雷,那還用玩家動腦嗎,無腦點四周8個,我們這里程序代勞
{
cell->value = cell_empty; // 當(dāng)前格子附近沒雷,給 cell_empty
cell->is_revealed = true; // 當(dāng)前格子改為已探索
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) {
// 四周導(dǎo)出偏移1位,然后挨個該狀態(tài)
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);
}
}
}
}
上面我們就成功實現(xiàn)了棋盤在用戶操作的影響下正確反饋信息,緊接著讀者請看我如何實現(xiàn)的判斷游戲是否結(jié)束,如何判斷游戲是勝利、失敗亦或者沒啥變化。
我們經(jīng)過上述的更新游戲信息的函數(shù)操作時,請讀者設(shè)想,我們點擊的是雷,那么游戲狀態(tài)是什么呢?答案是 GAME_LOSE;如果沒有失敗呢?那么此時的游戲狀態(tài)就是 GAME_UPDATE。
接下來的 CheckGameOver 函數(shù),我們就是依據(jù)這兩個狀態(tài)去判斷和執(zhí)行。首先判斷游戲失敗的情況,然后判斷狀態(tài)合不合法(如果是 GAME_UPDATE 就是合法),合法的話繼續(xù)判斷玩家是否勝利。
請知悉勝利條件:在掃雷游戲中,玩家勝利的條件通常是所有沒有地雷的單元格都被探索過。也就是說,如果所有的非地雷單元格都已經(jīng)被探索過,那么玩家就贏了游戲。
代碼如下:
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 開始和結(jié)束
一定要在 game.h 中聲明如下函數(shù):
// 將雷全部顯示
void ShowAllMines();
// 開始游戲
void StartGame();
// 游戲結(jié)束
void EndGame();
然后在 game.c 中實現(xiàn)相應(yīng)代碼,這 3 個函數(shù)的功能代碼其實比較簡單,讀者需要明白何時調(diào)用它們、發(fā)生了什么即可。
void ShowAllMines() {
// 調(diào)用:游戲結(jié)束時調(diào)用 EndGame函數(shù)中會調(diào)用它
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;
// 游戲結(jié)束時,將游戲板上是雷的value全部改為雷
}
}
}
void StartGame() {
// 調(diào)用:在main.c中被調(diào)用 也就是開始游戲的時候
char operate = 'e'; // 初始化操作符
int row = 0, col = 0, game_state = 0; // 初始化行列號、游戲狀態(tài)
InitGame(); // 初始化游戲,得到布滿雷的游戲板
while (true)
{
// 保證操作前 能看到棋盤
DisplayGameState();
printf("操作符:e 探索\tf 標記\tq 終止\t 格式[操作符 行號 列號]\n");
printf("操作:");
// 接收輸入并讓相應(yīng)函數(shù)處理操作
scanf(" %c %d %d", &operate, &row, &col);
game_state = HandleInput(operate, row, col);
if (game_state == 0) break; // 游戲狀態(tài)回到 GAME_INIT 就退出游戲咯
if (game_state == -1) continue; // 輸入的行列不合法跳過
}
}
void EndGame() {
// 調(diào)用:勝利或者失敗后調(diào)用 HandleInput函數(shù)中調(diào)用它
// 顯示游戲板的完整信息
ShowAllMines();
DisplayGameState();
// TODO 計分并總結(jié)成績
// TODO 計分后計入排行榜
// 狀態(tài)恢復(fù)到待初始化
game->state = GAME_INIT;
}
接下來回到 main.c 源文件中,我們修改入口函數(shù)中的代碼如下:
#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;
}
到此為止,這個掃雷游戲的基本核心我們就已經(jīng)實現(xiàn)完畢,現(xiàn)在可以正常的玩這個很基礎(chǔ)的部分了!
3 拓展功能
上述我們完成掃雷游戲的核心部分,從本章節(jié)開始,我將介紹菜單中其它功能的實現(xiàn),我們預(yù)計實現(xiàn)這么一些功能。
void ShowMenu() {
printf("------------菜 單------------\n");
printf(" 1 開始游戲\t2 繼續(xù)游戲 \n");
printf(" 3 設(shè)置用戶\t4 選擇難度 \n");
printf(" 5 保存游戲\t6 預(yù)覽排行 \n");
printf(" 你可以輸入 0 退出程序 \n");
printf("------------------------------\n");
}
相比之前的版本,看起來更加復(fù)雜了一點,在此我們增加了 保存游戲、繼續(xù)游戲、選擇難度、設(shè)置用戶、預(yù)覽排行 5大功能模塊。相對而言,這個掃雷項目并沒有涉及多么復(fù)雜的技術(shù),考驗的仍然是C語言的基本功以及微末的算法知識。接下來的小章節(jié)我會按照各個功能的難易程度的遞增順序去書寫。
3.1 增加難度可選
首先來看難度可選這個模塊部分,如何實現(xiàn),我們可以知道的是,在游戲未開始前,我們可以在主菜單的功能選項下輸入 4,然后進入到難度選擇菜單對難度進行選擇。因此需要在 menu.h 與 menu.c 文件中聲明和實現(xiàn)難度選擇菜單。
// menu.h
// ...之前的代碼已省略
// 顯示難度等級
void ShowLevelMenu();
// 設(shè)置難度等級
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 去設(shè)置難度
}
我們在上述的 HandleLevelChoice 函數(shù)末尾增加一個函數(shù)調(diào)用,稍后我們來實現(xiàn)所調(diào)用的這個函數(shù),這個函數(shù)將通過用戶所選擇的 choice 去設(shè)置游戲的難度。
// TODO 利用好int類型的變量choice 去設(shè)置難度
ModifyDifficulty(choice);
暫且沒有思路的讀者,可以回想,我們的游戲難度是怎么影響到游戲的,或者反向思維思考一下,游戲難度被什么影響呢?答案是:行數(shù)、列數(shù)、雷數(shù)量。那么這三個因素與什么相關(guān)呢?也就是我們預(yù)加載中的配置列表與當(dāng)前配置!
// 臨時配置列表
BoardConfig temp_configs[DIFFICULTY_COUNT] = {
// 理論行列數(shù)、實際行列數(shù)、雷數(shù)量、基礎(chǔ)分,暫且不理基礎(chǔ)分是什么!
{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];
}
我們通過從配置列表中獲取一個配置構(gòu)建我們的游戲?qū)ο螅?dāng)前配置即決定游戲難度,但是,還要再往前想想,當(dāng)前配置是怎么知道的呢?其實就是我們的 Difficulty 枚舉對象去決定的,選擇 EASY 難度,那么游戲難度就是第一檔,非常容易,我們可以怎樣利用用戶選擇的 choice 去改變呢?本質(zhì)上就是將 choice 進行類型轉(zhuǎn)換成對應(yīng)的枚舉類型,然后通過我們封裝好的函數(shù)接口 CreateGame(<state>, <difficulty>) 去修改全局游戲?qū)ο蟆?/p>
因此來到 game.c 文件中,ModifyDifficulty(choice) 的實現(xiàn)如下(不要忘記在頭文件中聲明):
// game.h
// ......
// 修改難度等級
void ModifyDifficulty(int choice);
// game.c
void ModifyDifficulty(int choice) {
game = CreateGame(GAME_INIT, (Diffuculty)(choice - 1));
}
當(dāng)玩家指定了難度后,當(dāng)前配置的行、列、雷量等各元信息都會發(fā)生改變,從而下次游戲開始時,都會基于這些信息去構(gòu)建我們的游戲板等等。
不要忘記了,還要在 main.c 源文件的 switch 分支中增加對應(yīng)的選項和函數(shù)調(diào)用!
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);
當(dāng)程序被運行起來后,你應(yīng)該在主菜單打印后選擇功能 4,然后去校驗不同難度下的掃雷游戲都能被正常的渲染出來。目前來看,我這邊暫時沒出現(xiàn)任何問題,程序可以正常運行。
3.2 增加用戶模塊
關(guān)于用戶對象,在預(yù)置數(shù)據(jù)類型中,我們已經(jīng)設(shè)計好了如下結(jié)構(gòu)體:
// 玩家
typedef struct {
char name[64]; // 玩家昵稱
char gender[24]; // 玩家性別
int score; // 玩家當(dāng)局分數(shù)
int best_score; // 玩家歷史最高分數(shù)
short int right_flag; // 玩家標記的正確旗幟數(shù)量
short int error_flag; // 玩家標記的錯誤旗幟數(shù)量
} Player;
// 全局對象,正在玩游戲的玩家對象
Player* player;
我們這個模塊需要實現(xiàn)玩家對象的初始化、玩家昵稱和性別的可修改、兩類分數(shù)的初始化。
在主菜單打印后,選擇功能3后可以進入到用戶設(shè)置的菜單,比如修改玩家的昵稱、性別,當(dāng)選擇修改昵稱時,軟件應(yīng)該要正確的從緩沖區(qū)中獲取到新的昵稱,并且要與舊的昵稱比較,當(dāng)昵稱不同時,意味著是一個新的賬號,需要初始化相關(guān)的數(shù)據(jù)信息。當(dāng)修改玩家的性別時,我們可以進入性別選擇子菜單中再度選擇,不同的選擇決定了 gender 的值是男、女或者不顯示。
首先在 menu.h 和 menu.c 中聲明和實現(xiàn)相關(guān)函數(shù)。
// menu.h
// ......
// 設(shè)置玩家的菜單
void ShowPlayerMenu();
// 設(shè)置玩家性別的菜單
void ShowGenderMenu();
// 處理設(shè)置玩家的選項
void HandlePlayerChoice();
// menu.c
// ......
void ShowPlayerMenu() {
DisplayPlayerInfo(); // 稍后在 display.c 中實現(xiàn)
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("[設(shè)置用戶]>>> ");
scanf("%d", &choice);
switch (choice)
{
case 1: {
char name[50] = "";
printf("請設(shè)置用戶昵稱:");
scanf("%s", name);
ModifyPlayerName(name); // 稍后在 game.c 中實現(xiàn)
break;
}
case 2: {
ShowGenderMenu();
int gender_choice = 0;
printf("請選擇序號設(shè)置性別:");
scanf(" %d", &gender_choice);
ModifyPlayerGender(gender_choice); // 稍后在 game.c 中實現(xiàn)
break;
}
default: {
break;
}
}
}
友友可能已經(jīng)看到了上方的3個函數(shù),DisplayPlayerInfo 函數(shù)負責(zé)打印玩家的信息。
// display.h
// ......
void DisplayPlayer(); // 打印 玩家性別 不換行,比如:圖圖女士、兔兔男士等等
void DisplayPlayerInfo(); // 打印 用戶的分數(shù)性別和當(dāng)前是哪個用戶
// diplay.c
// ......
ModifyPlayerName(name) 函數(shù)的作用是修改全局變量 player 的昵稱,其中會有一些細節(jié)的小處理。而 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) {
// 如果昵稱和之前的不一樣,重新初始化用戶的相關(guān)信息
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: {
// 修改結(jié)構(gòu)體中的字符串,務(wù)必使用 strcpy 函數(shù)
strcpy(player->gender, "男士");
break;
}
case 2: {
strcpy(player->gender, "女士");
break;
}
default: {
strcpy(player->gender, "");
break;
}
}
}
友友是否認為這里就結(jié)束了呢?
答案是,沒有那么簡單,還記得我們僅僅只是聲明了全局變量 player 嗎?但是我們對它賦予一定的空間了嗎?貌似什么初始化的操作都還沒做。因此我們需要對它進行預(yù)加載,后續(xù)就能夠方便的使用分配好的內(nèi)存空間。
// game.c 的 Preload 函數(shù)中
void Preload() {
// ...
// 預(yù)加載玩家缺省信息
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;
// 創(chuàng)建游戲?qū)ο? // ...
}
// game.c 的 InitGame 函數(shù)中
void InitGame() {
// ...
// 新的游戲時初始化參數(shù)
game->time = 0;
// 新增的,可以思考為什么要增加
// 答案:除了最高分、昵稱、性別,其它信息都是當(dāng)局游戲所有,而不是持續(xù)存在的,因此務(wù)必歸零!
player->score = 0;
player->error_flag = 0;
player->right_flag = 0;
// 初始化行號、列號、單元格
// ...
}
還有一個函數(shù)我們寫了,但是還沒用,誰呢?當(dāng)然是 DisplayPlayer() 咯。如下增加調(diào)用后,我們在游戲失敗或者勝利時都能夠加上稱謂,比如"無名大俠男士踩雷咯,游戲失敗!!!"
// 來到 game.c 的 CheckGameOver 函數(shù)中
bool CheckGameOver() {
if (game->state == GAME_LOSE) {
DisplayPlayer();
printf("踩雷咯,游戲失敗!!!\n");
return true;
}
// ....
// 勝利的情況
game->state = GAME_WIN;
DisplayPlayer();
printf("好厲害哦,人家好喜歡~\n");
return true;
}
最最最重要的來了,要在 main.c 源文件的 switch 分支中增加對應(yīng)的選項和函數(shù)調(diào)用!
case 3: {
ShowPlayerMenu();
HandlePlayerChoice();
break;
}
實現(xiàn)后的效果:
3.3 游戲后如何計分
- 難度因素:不同的難度級別應(yīng)該有不同的基礎(chǔ)分數(shù)。例如,簡單難度的基礎(chǔ)分數(shù)是1000,中等難度的基礎(chǔ)分數(shù)是2000,困難難度的基礎(chǔ)分數(shù)是3000。
- 時間因素:游戲的分數(shù)應(yīng)該和玩家完成游戲所花費的時間成反比。例如,每過一秒,玩家的分數(shù)就減少1%。這意味著,如果玩家在100秒內(nèi)完成游戲,那么他們的分數(shù)就會減少到原來的37%。
- 正確標記地雷的數(shù)量:每正確標記一個地雷,玩家的分數(shù)就增加一定的分數(shù)。例如,每正確標記一個地雷,玩家的分數(shù)就增加50分。
- 錯誤標記的數(shù)量:每錯誤標記一個地雷,玩家的分數(shù)就減少一定的分數(shù)。例如,每錯誤標記一個地雷,玩家的分數(shù)就減少100分。
以上的計分邏輯可以通過以下的公式來表示:
分數(shù) = 基礎(chǔ)分數(shù) * (0.99 ^ 時間) + 正確標記的地雷數(shù)量 * 50 - 錯誤標記的數(shù)量 * 100
來到 main.c 的 EndGame 函數(shù)中:
void EndGame() {
// ...
// TODO 計分并總結(jié)成績
CalFinalScore(); // 這個方法計算得分
DisplayGameOver(); // 這個方法結(jié)算游戲結(jié)束成績
// ...
}
根據(jù)我們的公式可知,基礎(chǔ)分數(shù)就是當(dāng)前配置結(jié)構(gòu)體中的成員——基礎(chǔ)分數(shù),一局游戲的時間可以很輕松的得到,我們聲明兩個全局變量 start_time 與 end_time,用來統(tǒng)計游戲開始和結(jié)束的時間結(jié)點,差值賦予給游戲?qū)ο?game 的成員變量 time 中。
// game.h
// ...
#include <math.h> // 要用到 pow 函數(shù)
#include <time.h> // 要用到 time 函數(shù)
// ...
// 聲明全局的時間變量
time_t start_time;
time_t end_time;
// game.c 中
void EndGame() {
end_time = time(NULL);
game->time += (int)(end_time - start_time);
// ...
}
接下來就是正確標記雷的數(shù)量以及錯誤標記雷的數(shù)量的獲取,這里有兩種方法,先來看第一種,第一種耦合度較低,并且你可以刪除掉player 對象中的right_flag和error_flag,直接在 CalFinalScore 函數(shù)中就可以完成統(tǒng)計,但是有一定的開銷。
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,我們之前已經(jīng)做了,然后在玩家標記單元格時合理判斷即可:
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 函數(shù)的實現(xiàn)。
// game.h
// ......
// 計算得分
int CalFinalScore();
// game.c
// ......
int CalFinalScore() {
int game_time = game->time;
int base_score = 0;
if (game->state == GAME_WIN) // 勝利了基礎(chǔ)分才有用
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;
// 當(dāng)前賬號的最高分判斷
if (score > player->best_score) player->best_score = score;
player->score = score;
return score;
}
然后在實現(xiàn)一局游戲的成績結(jié)算打印函數(shù) DisplayGameOver。
// display.h
// ......
void DisplayGameOver();
// display.c
void DisplayGameOver() {
printf("結(jié)算成績:\n");
printf("本局得分——%d\n", player->score);
printf("歷史最高——%d\n", player->best_score);
}
實現(xiàn)效果如下:
3.4 排行榜實現(xiàn)
我們之前就已經(jīng)定義好了排行榜的數(shù)據(jù)結(jié)構(gòu)和聲明了一個全局排行榜變量:
// game.h
// ...
// 排行榜上存儲的玩家分數(shù)最大數(shù)量
#define MAX_PLAYERS 10
// ...
// 排行榜
typedef struct {
Player players[MAX_PLAYERS]; // 玩家列表
int player_count; // 當(dāng)前上榜的玩家數(shù)量
} Leaderboard;
// 排行榜
Leaderboard* leaderboard;
// ...
我們這里使用的非常簡單,對鏈表亦或者順序表的選擇并無太大的要求,為什么?最大數(shù)據(jù)量僅為10,無論讀還是改的開銷,其實都很微弱,忽略不計。此處選擇順序表結(jié)構(gòu)。
排行榜的實現(xiàn)無非克服兩個方向的問題,一個是榜上人數(shù)沒有滿時怎么添加,一個是榜上人數(shù)滿了怎么添加。
-
當(dāng)榜上的人數(shù)沒有滿時,我們可以將玩家插入到
players數(shù)組中,然后進行倒序排序; -
當(dāng)榜上的人數(shù)已滿,我們將排行榜倒序排序,然后比較最后一名與當(dāng)前玩家的分數(shù),后者小則證明當(dāng)前玩家的分數(shù)無法上榜,反之我們從后往前遍歷的比較,直到出現(xiàn)第一個比當(dāng)前玩家分數(shù)大的排名,這個排名的后一位就是該玩家所能擁有的排名!
對于排序方法,我這里僅僅只是當(dāng)時想學(xué)習(xí)快速排序時而對應(yīng)的寫下快排算法,你可以根據(jù)興趣來。
// game.h
// ......
// 添加用戶到排行榜
void AddPlayerToLeaderboard(Player* player);
// 指定下標插入玩家
void MovePlayerToEnd(Player* player, int index);
// 對排行榜進行排序
void SortLeaderboard();
// 交換兩個Player
void SwapPlayer(Player* a, Player* b);
// 快速排序的分區(qū)函數(shù)
int Partition(Player arr[], int low, int high);
// 快速排序函數(shù)
void QuickSort(Player arr[], int low, int high);
// game.c
// 預(yù)加載
void Preload() {
// ......
// 預(yù)加載排行榜
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; // 分數(shù)比前個玩家高
}
}
// 當(dāng)上述不滿足,即分數(shù)霸榜
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;
}
主要調(diào)用的是 AddPlayerToLeaderboard(Player* player) 函數(shù),在何處調(diào)用呢?
void EndGame() {
// ...
// TODO 計分并總結(jié)成績
CalFinalScore(); // 這個方法計算得分
DisplayGameOver(); // 這個方法結(jié)算游戲結(jié)束成績
// TODO 計分后計入排行榜
AddPlayerToLeaderboard(player);
// 狀態(tài)恢復(fù)到待初始化
game->state = GAME_INIT;
}
主菜單的瀏覽排行榜功能非常簡單,在 diplay.h 頭文件中聲明函數(shù) 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 函數(shù),增加功能選項。
case 6: {
DisplayLeaderboard();
break;
}
效果預(yù)覽:
4 本地存儲
讀者可能在看上面的功能實現(xiàn)時,可能產(chǎn)生這樣的疑問,應(yīng)該還有一個保存游戲、繼續(xù)游戲的模塊沒有實現(xiàn)吧?上面的功能再怎么增加,貌似都只能在該程序的生命周期中玩,而不能持久的玩,排行榜實現(xiàn)了,但是意義不大。其實我們還差得比較多,未實現(xiàn)的還有保存游戲、加載游戲(繼續(xù)游戲也包含其中)、保存排行榜、加載排行榜。我們這一章的目的就是,如果不存在本地數(shù)據(jù),那么我們直接按照之前的函數(shù)功能去創(chuàng)建游戲數(shù)據(jù),如果已經(jīng)存在了,那么就加載本地的游戲數(shù)據(jù)覆蓋。
現(xiàn)在 storage.h 文件中聲明以下函數(shù)和宏:
#define GAME_FILE "minesweeper.dat"
// 游戲數(shù)據(jù)路徑
#define BOARD_FILE "leapboard.dat"
// 排行榜數(shù)據(jù)路徑
// 以下含義?看名字吧~家人
bool LoadGame();
bool SaveGame();
bool SaveLeaderboard();
bool LoadLeaderboard();
4.1 存儲游戲數(shù)據(jù)
何時存儲游戲數(shù)據(jù)呢?那當(dāng)然是玩家在主菜單功能選項選擇功能5時去人為保存。還有嗎?仔細想想,當(dāng)用戶保存上一次的游戲數(shù)據(jù),然后繼續(xù)游戲,玩了一會兒,玩家選擇中途退出,那么我們就要去保存繼續(xù)游戲的的數(shù)據(jù),玩家就不用在菜單中再次去手動保存咯。因此這就是開始游戲和繼續(xù)游戲的區(qū)別,開始游戲完全就是新的一盤游戲,而繼續(xù)游戲?qū)x取本地數(shù)據(jù),未讀取到則以開始游戲的核心方法去開始,讀取到了,則加載游戲,玩家中途退出時,自動保存。
先來看看這兩個加載保存的函數(shù)的實現(xiàn):
// storage.c
#include "game.h"
#include "storage.h"
bool LoadGame() {
FILE* file = fopen(GAME_FILE, "rb");
if (file == NULL) return false;
// 讀取當(dāng)前配置
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);
// 讀取游戲?qū)ο蟀ㄓ螒虬宓葦?shù)據(jù)
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;
// 向文件中寫入當(dāng)前配置
fwrite(&board_config, sizeof(BoardConfig), 1, file);
// 向文件中寫入當(dāng)前用戶
fwrite(player, sizeof(Player), 1, file);
// 向文件中寫入游戲?qū)ο笠约坝螒虬? 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(); // 如果加載失敗,則初始化,思考一下為什么預(yù)加載后要進行加載或者初始化
do
// ...
這里進行加載數(shù)據(jù)的原因,是我考慮到玩家在沒有任何數(shù)據(jù)的情況下,他第一次進入游戲,然后保存游戲,那么所保存的數(shù)據(jù)都將是未初始化的數(shù)據(jù),這并不好,因此我們這里增加這樣一行代碼。
然后我們看一下主菜單處保存游戲的調(diào)用:
// main.c
bool save_state = false;
// ...
case 5: {
save_state = SaveGame();
if (save_state) printf("保存成功\n");
else printf("保存失敗\n");
break;
}
緊接著來看一下主菜單繼續(xù)游戲的實現(xiàn)。
// game.h
// ...
// 繼續(xù)游戲
void ContinueGame();
// game.c
// 和開始游戲的函數(shù)非常相似,這里為什么不提取公共部分,我的考慮是因為這樣方便可定制部分功能。
void ContinueGame() {
char operate = 'e';
int row = 0, col = 0, game_state = 0;
start_time = time(NULL);
bool load_state = LoadGame();
if (!load_state) { // 加載成功的狀態(tài)
InitGame();
}
else {
// 如果加載成功更改狀態(tài),因為改為 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() {
// ...
// 上下初始狀態(tài)是為了防止繼續(xù)游戲后出現(xiàn)的還是游戲結(jié)束的畫面
game->state = GAME_INIT;
InitGame(); // 清空游戲板并恢復(fù)到初始
game->state = GAME_INIT;
}
// main.c 中如何調(diào)用?
switch (choice)
{
// ......
case 2: {
ContinueGame();
break;
}
// ......
我們可以看一下效果,還是非常棒的:
4.2 存儲排行榜數(shù)據(jù)
該章節(jié)是本篇博文的最后一部分,這部分的代碼其實非常簡單,當(dāng)你能夠?qū)ι鲜龅拇鎯τ螒驍?shù)據(jù)有一定的了解之后,存儲排行榜和讀取排行榜真的再簡單不過了!直接上代碼:
// 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 中調(diào)用 SaveLeaderboard 函數(shù),非常好理解,就是每次上榜后,我們就進行排行榜的保存。優(yōu)化建議:我比較懶,這里就不處理未上榜的情況啦,因為未上榜就無需在重新寫一遍文件了嘛!
void EndGame() {
// ...
// 計入排行榜
AddPlayerToLeaderboard(player);
SaveLeaderboard();
// ...
}
那什么時候加載排行榜呢?非常簡單!預(yù)加載之后立馬加載排行榜數(shù)據(jù)即可。
Preload();
LoadLeaderboard(); // 這里喲 親~
if (!LoadGame()) InitGame();
寫到這里,我也不知道讀者明白幾何,可曾注意到一些細節(jié),比如繼續(xù)游戲,每次都是從本地讀取出來數(shù)據(jù),當(dāng)玩家一會兒繼續(xù)游戲、一會兒退出的,那么我們的計分方式還有問題嗎?這就是我為何使用的 += 而不是=的意義,只有 += ,才能夠統(tǒng)計每次玩了多長時間并加到原來的時間上。又或者我這里的快排分區(qū)的思想你自己又還能怎么修改呢心里可有答案?我已經(jīng)在無資格上榜那里做了條件判斷,我所提到的優(yōu)化僅僅只是幾行代碼的問題,這些都留給讀者慢慢的細嚼慢咽,學(xué)習(xí)掃雷游戲,我的收獲還是頗豐的,將來有機會,寫個最優(yōu)決策的掃雷小掛玩玩。hh~生活愉快,友友們
總結(jié)
以上是生活随笔為你收集整理的探秘扫雷游戏的C语言实现的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 巧用CCMore插件:让控制中心应用更快
- 下一篇: 【UniApp】-uni-app-Com