使用遊戲概念學習 C 語言/角色扮演遊戲設計
現在我們已經涵蓋了編譯和模組化的基礎知識(我們稍後會完善該部分),讓我們繼續進行遊戲設計。我們的遊戲將使用終端實現,並且不依賴於任何第三方庫,除了 C 預設提供的庫。我們將採用自底向上的方法來建立我們的角色扮演遊戲(以下簡稱 RPG)。
在進行一個複雜的新專案時,對程式的功能和實現方式進行一些頭腦風暴非常重要。雖然這聽起來很明顯,但往往很容易直接跳入編碼,並根據腦海中出現的想法來實現。直到程式碼變得數百行長,才會發現組織思路是必要的。最初的頭腦風暴階段會將主要思想(在本例中為 RPG)分解成更基本的要素。對於每個元素,我們要麼將其分解成更小的元素,要麼簡要概述如何實現它。在商業環境中,這個頭腦風暴會議會產生一份規格說明文件。
每個 RPG 的核心都是玩家。
玩家應該:
- 擁有描述其能力的屬性。
- 能夠彼此互動(例如,對話、戰鬥)。
- 能夠從一個地點移動到另一個地點。
- 能夠攜帶物品。
屬性:簡單。只是一個變數,告訴我們屬性是什麼(例如,生命值),幷包含一個整數值。
對話:為了促進對話,玩家需要指令碼化的對話。這些對話可以與主要角色一起儲存,或者與主要角色將與其互動的人一起儲存,並且在後一種情況下,主要角色必須能夠訪問這些對話。
戰鬥:一個函式,當給定一個玩家(進行攻擊)時,會啟動一個戰鬥序列,該序列會持續到有人撤退或某個玩家的生命值降至 0 為止。
地圖:一次浩瀚而史詩般的旅程涉及許多地點。每個地點節點告訴我們玩家到達該地點後會看到什麼,以及他可以從那裡去哪裡。每個地點節點都具有相同的結構。這個新的節點將與第一個節點具有相同的結構,但包含不同的資訊。每個地點節點都分配了一個唯一的地址 ID,告訴計算機可以在哪裡找到另一個地點節點。“他可以去哪裡”傳統上是一個最多包含 10 個地址 ID 的陣列,以允許玩家最多向 10 個方向移動——上、下、北、南、東、東北等。
移動:玩家應該包含一個連結串列或二叉樹的節點。第一個地址 ID 告訴我們玩家當前所在的位置。第二個地址 ID 告訴我們玩家來自哪裡(這樣玩家就可以說“返回”)。移動將涉及將玩家的位置(例如,沼澤)更改為另一個節點的地址 ID(例如,森林)。如果這聽起來很令人困惑,別擔心,我會畫一些圖來說明這個概念。ː)
物品欄:物品欄將從一個雙向連結串列開始。一個物品節點包含一個物品(例如,生命藥水)、該物品的數量、物品的描述以及兩個連結。一個連結到列表中前一個物品,另一個連結到列表中下一個物品。
這個初步的規格說明充當下一階段(實際編碼部分)的藍圖。現在我們已經將主要思想分解成更小的元素,我們可以專注於建立單獨的模組來實現這些功能。例如,我們將實現玩家和玩家函式在 Main 檔案中進行測試。一旦我們確信我們的程式碼正常工作,我們就可以將我們的資料型別和函式遷移到 Header 檔案中,並在我們想要建立和操作玩家時呼叫該檔案。這樣做將大大減少檢視主檔案中程式碼的數量,並且將玩家函式儲存在 player 標頭檔案中將為我們提供一個查詢、新增、刪除和改進玩家函式的邏輯位置。隨著我們的進展,我們可能會想到在規格說明中新增新內容。
因為玩家過於複雜,無法用單個變量表示,所以我們必須建立一個結構體。結構體是複雜的資料型別,可以同時儲存多個數據型別。下面是一個玩家結構體的基本示例。
struct playerStructure {
char name[50];
int health;
int mana;
};
使用關鍵字struct,我們聲明瞭一個名為playerStructure的複雜資料型別。在花括號內,我們使用所有需要儲存的資料型別來定義它。此結構體可用於建立與之相同的新結構體。讓我們用它來建立一個英雄並顯示他的屬性。
player.c
#include <stdio.h>
#include <string.h>
struct playerStructure {
char name[50];
int health;
int mana;
} Hero;
// Function Prototype
void DisplayStats (struct playerStructure Target);
int main() {
// Assign stats
strcpy(Hero.name, "Sir Leeroy");
Hero.health = 60;
Hero.mana = 30;
DisplayStats(Hero);
return(0);
}
// Takes a player as an argument and prints their name, health, and mana. Returns nothing.
void DisplayStats (struct playerStructure Target) {
// We don't want to keep retyping all this.
printf("Name: %s\nHealth: %d\nMana: %d\n", Target.name, Target.health, Target.mana);
}
讓我們回顧一下我們的程式碼做了什麼。我們包含了一個名為<string.h>的新標準庫,其中包含一些有助於處理字串的函式。接下來,我們定義了複雜資料型別 playerStructure,並在其後立即聲明瞭一個名為 Hero 的 playerStructure。請注意,在定義結構體後,分號始終是必需的。與高階語言不同,在 C 中不能使用賦值運算子 = 來分配字串,只能分配構成字串的單個字元。由於 name 長度為 50 個字元,假設我們有 50 個空格。要將“Sir Leeroy”分配給我們的陣列,我們必須按順序將每個字元分配給一個空格,如下所示
name[0] = 'S'
name[1] = 'i'
name[2] = 'r'
name[3] = ' '
name[4] = 'L'
name[5] = 'e'
name[6] = 'e'
name[7] = 'r'
name[8] = 'o'
name[9] = 'y'
name[10] = '\0' // 字串結束標記
函式 Strcpy() 本質上會迴圈遍歷陣列,直到到達任一引數的字串結束標記,並一次分配一個字元,如果字串小於我們儲存它的陣列的大小,則用空格填充其餘部分。
我們結構體 Player 中的變數稱為成員,可以透過struct.member語法訪問它們。
現在,如果我們的遊戲只有英雄而沒有敵人,那將是乏味和平淡的。為了新增更多玩家,我們需要鍵入“struct playerStructure variableName”來宣告新玩家。這很繁瑣且容易出錯。相反,如果我們為玩家資料型別有一個特殊的名稱,我們可以像使用 char、int 或 float 一樣隨意地呼叫它,那就好多了。這可以透過使用關鍵字typedef輕鬆實現!與之前一樣,我們定義了複雜資料型別 playerstructure,但不是之後宣告一個 playerStructure,而是建立了一個關鍵字,可以在任何時候宣告它們。
player2.c
#include <stdio.h>
#include <string.h>
typedef struct playerStructure {
char name[50];
int health;
int mana;
} player;
// Function Prototype
void DisplayStats (player Target);
int main () {
player Hero, Villain;
// Hero
strcpy(Hero.name, "Sir Leeroy");
Hero.health = 60;
Hero.mana = 30;
// Villain
strcpy(Villain.name, "Sir Jenkins");
Villain.health = 70;
Villain.mana = 20;
DisplayStats(Hero);
DisplayStats(Villain);
return(0);
}
// Takes a player as an argument and prints their name, health, and mana. Returns nothing.
void DisplayStats (player Target) {
printf("Name: %s\nHealth: %d\nMana: %d\n", Target.name, Target.health, Target.mana);
}
仍然存在建立玩家的問題。我們可以在程式開始時定義遊戲中將出現的所有玩家。只要玩家列表很短,這可能是可以忍受的,但所有這些玩家都會佔用記憶體,無論它們是否被使用。從歷史上看,由於舊計算機上的記憶體稀缺,這將是一個問題。如今,記憶體相對豐富,但為了可擴充套件性,並且因為使用者在後臺執行其他應用程式,所以我們希望有效地使用記憶體並動態地使用它。
動態分配記憶體是透過使用malloc來完成的,這是一個包含在<stdlib.h>中的函式。給定要返回的位元組數,malloc 會找到未使用的記憶體並將該記憶體的地址交給我們。要使用此記憶體地址,我們使用一種稱為指標的特殊資料型別,該型別旨在儲存記憶體地址。指標的宣告方式與其他資料型別相同,只是我們在變數名前面加了一個星號 (*)。考慮以下程式碼行:
player *Hero = malloc(sizeof(player));
這是宣告指標併為其分配記憶體地址的標準方法。星號告訴我們,我們不想宣告一個具有固定、不可更改的記憶體地址的玩家,而是想要一個可以指向任何玩家地址的變數。未初始化的指標的值為 NULL,這意味著它們不指向任何地址。由於很難記住單個數據型別中有多少個位元組,更不用說我們的玩家結構體了,因此我們使用 sizeof 函式來為我們計算出來。Sizeof 返回 player 中的位元組數給 malloc,malloc 會找到足夠的空閒記憶體來容納一個 player 結構體,並將地址返回給我們的指標。
如果 malloc 返回記憶體地址 502,Hero 現在將指向位於 502 的玩家。指向結構體的指標有一種獨特的方式來呼叫成員。我們現在使用箭頭 (->) 代替句點。
player *Hero = malloc(sizeof(player));
strcpy(Hero->name, "Leeroy");
Hero->health = 60;
Hero->mana = 30;
請記住,指標不包含像整數和字元這樣的值,它們只是告訴計算機在哪裡可以找到這些值。當我們更改指標指向的值時,我們是在告訴計算機“嘿,我想讓你更改的值位於此地址 (502),我只是在指揮交通”。因此,當您想到指標時,請將其視為“指揮交通”。以下是一個表格,顯示了各種型別的指標宣告的含義:
| 宣告 | 含義。 |
|---|---|
| char *variable | 指向 char 的指標 |
| int *variable | 指向 int 的指標 |
| float *variable | 指向 float 的指標 |
| player *variable | 指向 player 的指標 |
| player **variable | 指向指向 player 的指標的指標 |
現在我們正在使用指標,我們可以編寫一個函式來動態分配玩家。同時,讓我們在規格說明中新增一些新想法。
玩家應該:
- 擁有描述其能力的屬性。已完成
- 能夠彼此互動(例如,對話、戰鬥)。
- 能夠從一個地點移動到另一個地點。
- 能夠攜帶物品。
- 擁有職業(戰士、法師、遊俠、會計師)。新增
- 職業在建立時具有獨特的屬性。例如:戰士擁有較高的生命值,法師擁有較低的生命值。新增
dynamicPlayers.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// Classes are enumerated. WARRIOR = 0; RANGER = 1, etc.
typedef enum ClassEnum {
WARRIOR,
RANGER,
MAGE,
ACCOUNTANT
} class;
typedef struct playerStructure {
char name[50];
class class;
int health;
int mana;
} player;
// Function Prototypes
void DisplayStats(player *target);
int SetName(player *target, char name[50]);
player* NewPlayer(class class, char name[50]); // Creates player and sets class.
int main() {
player *Hero = NewPlayer(WARRIOR, "Sir Leeroy");
player *Villain = NewPlayer(RANGER, "Sir Jenkins");
DisplayStats(Hero);
DisplayStats(Villain);
return(0);
}
// Creates player and sets class.
player* NewPlayer(class class, char name[50]) {
// Allocate memory to player pointer.
player *tempPlayer = malloc(sizeof(player));
SetName(tempPlayer, name);
// Assign stats based on the given class.
switch(class) {
case WARRIOR:
tempPlayer->health = 60;
tempPlayer->mana = 0;
tempPlayer->class = WARRIOR;
break;
case RANGER:
tempPlayer->health = 35;
tempPlayer->mana = 0;
tempPlayer->class = RANGER;
break;
case MAGE:
tempPlayer->health = 20;
tempPlayer->mana = 60;
tempPlayer->class = MAGE;
break;
case ACCOUNTANT:
tempPlayer->health = 100;
tempPlayer->mana = 100;
tempPlayer->class = ACCOUNTANT;
break;
default:
tempPlayer->health = 10;
tempPlayer->mana = 0;
break;
}
return(tempPlayer); // Return memory address of player.
}
void DisplayStats(player *target) {
printf("%s\nHealth: %d\nMana: %d\n\n", target->name, target->health, target->mana);
}
int SetName(player *target, char name[50]) {
strcpy(target->name, name);
return(0);
}
在進入下一個主要開發階段之前,你需要將已編寫的程式碼模組化。首先建立兩個標頭檔案,一個名為“gameProperties.h”,另一個名為“players.h”。在遊戲屬性檔案中,放置你的playerStructure和classEnum型別定義。此處定義的資料型別可能出現在我們可能建立的任何其他標頭檔案中。因此,這將始終是我們呼叫的第一個標頭檔案。接下來,所有與建立和修改玩家相關的函式,以及它們的原型,都將放在我們的players標頭檔案中。
羅馬不是一天建成的,優秀的戰鬥系統也不例外,但我們會盡力而為。現在我們有了敵人,我們有義務讓他參與一場友好的拳擊比賽。為了讓玩家戰鬥,我們需要在玩家結構中包含兩個額外的屬性,攻擊和防禦。在我們的規範中,所有戰鬥函式都包含兩個玩家的引數,但經過進一步思考,讓我們根據有效攻擊造成傷害,有效攻擊等於攻擊減去防禦。
在gameProperties標頭檔案中,為playerStructure修改兩個新的整型變數,“attack”和“defense”。
gameProperties.h
// Classes are enumerated. WARRIOR = 0; RANGER = 1, etc.
typedef enum ClassEnum {
WARRIOR,
RANGER,
MAGE,
ACCOUNTANT
} class;
// Player Structure
typedef struct playerStructure {
char name[50];
class class;
int health;
int mana;
int attack; // NEWː Attack power.
int defense; // NEWː Resistance to attack.
} player;
在players標頭檔案中,修改case語句以將值賦給attack和defense屬性。
players.h
// Creates player and sets class.
player* NewPlayer(class class, char name[50]) {
// Allocate memory to player pointer.
player *tempPlayer = malloc(sizeof(player));
SetName(tempPlayer, name);
// Assign stats based on the given class.
switch(class) {
case WARRIOR:
tempPlayer->health = 60;
tempPlayer->mana = 0;
tempPlayer->attack = 3;
tempPlayer->defense = 5;
tempPlayer->class = WARRIOR;
break;
case RANGER:
tempPlayer->health = 35;
tempPlayer->mana = 0;
tempPlayer->attack = 3;
tempPlayer->defense = 2;
tempPlayer->class = RANGER;
break;
case MAGE:
tempPlayer->health = 20;
tempPlayer->mana = 60;
tempPlayer->attack = 5;
tempPlayer->defense = 0;
tempPlayer->class = MAGE;
break;
case ACCOUNTANT:
tempPlayer->health = 100;
tempPlayer->mana = 100;
tempPlayer->attack = 5;
tempPlayer->defense = 5;
tempPlayer->class = ACCOUNTANT;
break;
default:
tempPlayer->health = 10;
tempPlayer->mana = 0;
tempPlayer->attack = 0;
tempPlayer->defense = 0;
break;
}
return(tempPlayer); // Return memory address of player.
}
void DisplayStats(player *target) {
printf("%s\nHealth: %d\nMana: %d\n\n", target->name, target->health, target->mana);
}
int SetName(player *target, char name[50]) {
strcpy(target->name, name);
return(0);
}
最後,在主程式中包含你的標頭檔案。我們不使用尖括號<>,而是使用雙引號。如果標頭檔案位於與可執行檔案相同的資料夾中,則只需要提供檔名。如果你的標頭檔案位於其他資料夾中,則需要提供檔案位置的完整路徑。
我們還將開發一個基本的戰鬥系統來利用攻擊和防禦屬性。
player3.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "gameProperties.h"
#include "players.h"
// Function Prototype
int Fight (player *Attacker, player ̈*Target);
int main () {
player *Hero = NewPlayer(WARRIOR, "Sir Leeroy");
player *Villain = NewPlayer(RANGER, "Sir Jenkins");
DisplayStats(Villain); // Before the fight.
Fight(Hero, Villain); // FIGHTǃ
DisplayStats(Villain); // After the fight.
return(0);
}
int Fight (player *Attacker, player *Target) {
int EffectiveAttack; // How much damage we can deal is the difference between the attack of the attacker
// And the defense of the target. In this case 5 - 1 = 4 = EffectiveAttack.
EffectiveAttack = Attacker->attack - Target->defense;
Target->health = Target->health - EffectiveAttack;
return(0);
}
如果我們編譯並執行它,我們會得到以下輸出:
Name: Sir Jenkins
Health: 35
Mana: 0
Name: Sir Jenkins
Health: 34 // An impressive 1 damage dealt.
Mana: 0
待辦事項:調整職業屬性使其更加多樣化。
現在我們已經弄清楚瞭如何造成傷害,讓我們擴充套件我們之前的規範:
Fight() 應該:
- 迴圈,直到某人的生命值降至零、撤退或投降。
- 在獲取輸入之前向用戶顯示一個選項選單。
- 攻擊、防禦、使用物品和逃跑應該是基本的選擇。
- 告訴我們是否輸入了錯誤的內容。
- 讓雙方都有機會行動。可能透過交換攻擊者和目標的記憶體地址。
- 這意味著我們需要區分使用者玩家和非使用者玩家。
- 如果戰鬥涉及兩個以上的角色,我們可能需要稍後修改交換的想法。然後我們可以使用某種列表輪換。
- 使用速度作為因素(敏捷性)的遊戲,可能會在戰鬥前根據屬性構建列表,以確定誰先行動。
我會盡量避免在可能的情況下發布整個程式,但我鼓勵你繼續進行增量更改,並在我們進行的過程中編譯/執行主程式。對於戰鬥序列,我們將修改Fight函式,使其迴圈直到目標的生命值降至0,然後宣佈獲勝者。一個“戰鬥選單”將提供使用者介面,該選單將一個數字與一個動作配對。我們有責任在新增新動作時修改此選單,並確保在呼叫時每個單獨的動作都能正常工作。
當用戶選擇一個動作時,關聯的數字將傳遞給一個**Switch**,該**Switch**將給定變數與一系列**Cases**進行比較。每個case都有一個數字或字元(不允許使用字串),用於上述比較。如果Switch找到匹配項,它將評估該Case中的所有語句。我們必須使用關鍵字**break**來告訴switch停止評估命令,否則它將移動到下一個case並執行這些語句(有時這很有用,但不是我們的目的)。如果Switch無法將變數與case匹配,則它會查詢一個名為**default**的特殊case並對其進行評估。我們總是希望有一個default來處理意外輸入。
int Fight(player *Attacker, player *Target) {
int EffectiveAttack = Attacker->attack - Target->defense;
while (Target->health > 0) {
DisplayFightMenu();
// Get input.
int choice;
printf(">> "); // Indication the user should type something.
fgets(line, sizeof(line), stdin);
sscanf(line, "%d", &choice);
switch (choice) {
case 1:
Target->health = Target->health - EffectiveAttack;
printf("%s inflicted %d damage to %s.\n", Attacker->name, EffectiveAttack, Target->name);
DisplayStats(Target);
break;
case 2:
printf("Running away!\n");
return(0);
default:
printf("Bad input. Try again.\n");
break;
}
}
// Victoryǃ
if (Target->health <= 0) {
printf("%s has bested %s in combat.\n", Attacker->name, Target->name) ;
}
return(0);
}
void DisplayFightMenu () {
printf("1) Attack\n2) Run\n");
}
測試程式的完整性需要在編譯後執行幾次。首先,我們可以看到,如果我們輸入像“123”或“Fish”這樣的隨機輸入,我們將呼叫default case並被迫選擇另一個答案。其次,輸入2將導致我們逃離戰鬥。第三,如果我們繼續輸入1,最終Sir Leeroy將削減Sir Jenkin的所有生命值並被宣佈為獲勝者。如果心急,可以修改Sir Leeroy的攻擊值:)
然而,Sir Jenkins仍然無法自衛,這使得比賽非常不公平。即使給Sir Jenkins一個回合,系統仍然會提示使用者代表他採取行動。回合制問題可以透過規範中提出的想法解決,即我們在每個迴圈中交換Attacker和Target指標的記憶體地址。解決自主權問題的方案是在我們的player結構中新增一個新屬性,即一個bool。bool具有二進位制值,true或false,對我們來說,它回答了一個簡單的問題“是否自動駕駛?”。當自動駕駛bool設定為true時,Fight函式(在我們修改它以檢查它時)將知道它們必須自動化這些角色的動作。要使用bool資料型別,我們需要包含一個名為**<stdbool.h>**的新標頭檔案。bool使用**bool**關鍵字宣告,並且只能分配true或false值。
在“gameProperties.h”中,將以下行新增到playerStructure中的int defense下方。
bool autoPilot;
接下來,將以下程式碼片段新增到“Players.h”中的NewPlayer函式,位於對SetName的呼叫下方。
static int PlayersCreated = 0; // Keep track of players created.
if (PlayersCreated > 0) {
tempPlayer->autoPilot = true;
} else {
tempPlayer->autoPilot = false;
}
++PlayersCreated;
上面的程式碼使用關鍵字**static**建立了一個持久變數。通常,一旦函式被呼叫,區域性變數就會消失。相反,靜態變數在函式的生命週期之外保持其值,並且當函式再次開始時,它的值不會被重置。僅為第一個主要角色之後的玩家開啟自動駕駛。
完成此操作後,請考慮以下程式。我們添加了bool和IF語句來確定是否需要自動或提示玩家。勝利IF被移動到while迴圈內部,並在條件滿足時宣佈勝利,否則,它將交換玩家以進行下一個迴圈。
player4.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include "gameProperties.h"
#include "players.h"
// Function Prototype
void DisplayStats(player Target);
int Fight(player *Attacker, player *Target);
void DisplayFightMenu(void);
// Global Variables
char line[50]; // This will contain our input.
int main () {
player *Hero = NewPlayer(WARRIOR, "Sir Leeroy");
player *Villain = NewPlayer(RANGER, "Sir Jenkins");
DisplayStats(Villain); // Before the fight.
Fight(Hero, Villain); // FIGHTǃ
return(0);
}
int Fight(player *Attacker, player *Target) {
int EffectiveAttack = Attacker->attack - Target->defense;
while (Target->health > 0) {
// Get user input if autopilot is set to false.
if (Attacker->autoPilot == false) {
DisplayFightMenu();
int choice;
printf(">> "); // Sharp brackets indicate that the user should type something.
fgets(line, sizeof(line), stdin);
sscanf(line, "%d", &choice);
switch (choice) {
case 1:
Target->health = Target->health - EffectiveAttack;
printf("%s inflicted %d damage to %s.\n", Attacker->name, EffectiveAttack, Target->name);
DisplayStats(Target);
break;
case 2:
printf("Running away!\n");
return(0);
default:
printf("Bad input. Try again.\n");
break;
}
} else {
// Autopilot. Userless player acts independently.
Target->health = Target->health - EffectiveAttack;
printf("%s inflicted %d damage to %s.\n", Attacker->name, EffectiveAttack, Target->name);
DisplayStats(Target);
}
// Once turn is finished, check to see if someone has one, otherwise, swap and continue.
if (Target->health <= 0) {
printf("%s has bested %s in combat.\n", Attacker->name, Target->name) ;
} else {
// Swap attacker and target.
player *tmp = Attacker;
Attacker = Target;
Target = tmp;
}
}
return(0);
}
void DisplayFightMenu (void) {
printf("1) Attack\n2) Run\n");
}
現在我們已經建立了一個非常基本的戰鬥系統,是時候再次進行模組化了。獲取Fight和DisplayFightMenu函式,並將它們放在一個名為“fightSys.h”的新標頭檔案中。這個新的標頭檔案將包含所有與戰鬥相關的函式,並將包含在我們主程式的下一個迭代中。