電子遊戲設計/結構
雖然電子遊戲種類繁多,但有些屬性是恆定的:每個遊戲都需要至少一名玩家,每個遊戲都給玩家至少一個挑戰,每個遊戲都使用顯示器,每個遊戲都至少有一種輸入/控制方式。
正如本章開頭所述,使用者介面由精靈、選單等組成。它是使用者用來控制遊戲內操作的東西。這些圖形被定義為按鈕,可以被按下,或者角色,可以用箭頭鍵移動。所有這些元素都是使用者介面的一部分。
首先,幾乎所有電子遊戲啟動時都會顯示一個主選單。這通常是一個帶有背景的螢幕,上面排列著用於執行諸如新遊戲或開始遊戲、選項、載入遊戲和退出遊戲等操作的按鈕。
此螢幕充當遊戲的控制面板,允許玩家更改設定、選擇模式或訪問實際遊戲。
有時,遊戲會將主選單用作遊戲內選單。遊戲內選單通常在遊戲過程中透過Esc 鍵或開始按鈕訪問。遊戲內選單允許玩家訪問大部分主選單操作,以及其他操作,如顯示角色屬性、分數、庫存等。不過,並非所有選單都必須是帶有文字的正方形。遊戲Mana 秘史使用了一個創意選單,其中關卡保持焦點,而選擇項圍繞玩家形成一個圓圈。
這些選單不是必需的,但傳統上會包含它們。
當你第一次啟動遊戲時,會顯示一系列啟動畫面。啟動畫面包含諸如徽標、電影等元素。這通常用於告知玩家參與遊戲開發的公司,有時還會介紹部分或全部情節。
當實際遊戲開始時,通常會播放一段介紹性電影,作為情節的前奏。這不像你在影院裡看到的電影,而是通常更好地利用遊戲自身圖形和聲音來渲染。
在大多數遊戲中,你接下來會被要求輸入你的姓名,而在一些遊戲中,你還可以自定義你的角色、設定等。
遊戲的這個階段稱為教程。它並不總是被認為是遊戲情節的一部分,但在某些遊戲中,它被整合到遊戲本身中,即使它是教程階段,它仍然是遊戲情節的一部分。我們將此稱為教程整合。它廣泛應用於諸如塞爾達傳說和超級馬里奧 64等遊戲中。
在遊戲過程中,幾乎所有遊戲都使用一些基本概念。它們列在下面
玩家在角色中的作用。玩家如何控制角色?通常有 3 種 PCR 型別,即第三人稱、第一人稱和影響
第三人稱:玩家不是角色,而是以非個人的方式控制角色/角色。
第一人稱:玩家就是角色/角色 - 並以個人和視覺的方式從角色的角度看待事物。
影響:玩家不與任何角色/角色繫結,而只是對遊戲有影響。這在諸如俄羅斯方塊等益智遊戲中,以及在 RTS 遊戲中可見。
遊戲描繪的“世界”是什麼?在這個問題中,有兩個考量。
角色作用:還有一個問題是角色在遊戲本身中扮演什麼角色,從這個意義上說,有 3 種類型。
主角:一切圍繞著角色/角色展開,拯救世界型別的事情。在諸如塞爾達、馬里奧、最終幻想等遊戲中可見。
街機傳統:非個人的街機角色。
影響:角色是遊戲中無形的影響。
法則 定義世界的法律、概念、規則等是什麼?
圖形 看到什麼以及風格的法則
聲音 聽到什麼以及風格的法則
遊戲玩法 玩什麼,如何玩遊戲
考慮到遊戲的儲存和載入,通常這可以是一個簡單的選單操作,玩家輸入一個儲存名稱,遊戲就會被儲存。不過,在某些遊戲中,採取了更具創意的方法,這樣玩家就不會被拉出遊戲體驗。銀河戰士就是透過其儲存站來實現這一點的。
然而,載入通常是選單操作。
我們遊戲的核心是主迴圈(或遊戲迴圈)。與大多數互動式程式一樣,我們的遊戲會一直執行,直到我們告訴它停止。每次迴圈都是遊戲心跳的象徵。即時遊戲的迴圈通常與影片更新(vsync)同步。如果我們的主迴圈與固定時間硬體事件同步,例如 vsync,那麼我們必須將每次更新呼叫的總處理時間控制在該時間間隔內,否則我們的遊戲會“卡頓”。
// a simple game loop in C++
int main( int argc, char* argv[] )
{
game our_game;
while ( our_game.is_running())
{
our_game.update();
}
return our_game.exit_code();
}
每個遊戲機製造商都有自己的遊戲釋出標準,但大多數都要求遊戲在啟動後的幾秒鐘內提供視覺反饋。作為一般設計準則,最好儘快向玩家提供反饋。
因此,大多數啟動和關閉程式碼通常都在主迴圈中處理。冗長的啟動和關閉程式碼可以在主更新() 中監視的子執行緒中執行,或者被切分成小塊,並在更新() 例程本身中按順序執行。
即使不考慮遊戲本身的各種遊戲模式,大多數遊戲程式碼也屬於幾種狀態之一。遊戲可能包含以下狀態和子狀態
- 啟動
- 許可證
- 介紹性電影
- 前端
- 遊戲選項
- 聲音選項
- 影片選項
- 載入螢幕
- 主遊戲
- 介紹
- 遊戲玩法
- 遊戲模式
- 暫停選項
- 遊戲結束電影
- 製作人員名單
- 關閉
使用狀態機可以對程式碼進行建模
class state
{
public:
virtual void enter( void )= 0;
virtual void update( void )= 0;
virtual void leave( void )= 0;
};
派生類可以重寫這些虛擬函式以提供特定於狀態的程式碼。主遊戲物件可以儲存指向當前狀態的指標,並允許遊戲在狀態之間流動。
extern state* shut_down;
class game
{
state* current_state;
public:
game( state* initial_state ): current_state( initial_state )
{
current_state->enter();
}
~game()
{
current_state->leave();
}
void change_state( state* new_state )
{
current_state->leave();
current_state= new_state;
current_state->enter();
}
void update( void )
{
current_state->update();
}
bool is_running( void ) const
{
return current_state != shut_down;
}
};
遊戲迴圈必須同時考慮經過了多少真即時間和經過了多少遊戲時間。將兩者分開使得慢動作(例如子彈時間)效果、暫停狀態和除錯變得更加容易。如果您打算製作一個可以倒轉時間的遊戲,比如《Blinx》或《沙漏》,那麼您需要能夠在遊戲時間倒退時向前運行遊戲迴圈。
另一個圍繞時間的考慮取決於您是想追求固定幀率還是可變幀率。固定幀率可以簡化遊戲中的大部分數學運算和計時,但它們會讓遊戲在國際上移植變得更加困難(例如,從美國 60 Hz 電視機轉換到歐洲 50 Hz 電視機)。出於這個原因,建議將幀時間作為變數傳遞,即使該值從未改變。當每幀的工作量達到極限時,固定幀率會導致卡頓,這種卡頓的感覺可能比低幀率更糟糕。
另一方面,可變幀率會自動補償不同的電視重新整理率。但與固定幀率遊戲相比,可變幀率的遊戲往往感覺很“粘滯”。除錯,特別是除錯計時和物理問題,在可變時間下通常更加困難。在程式碼中實現計時時,通常存在幾個特定於平臺的硬體計時器,它們通常具有不同的解析度、訪問它們所需的開銷和延遲。請特別注意可用的即時時鐘。您必須使用解析度足夠高的時鐘,同時不要使用過高的精度。您可能需要處理時鐘溢位的情況(例如,32 位納秒計時器每 2^32 納秒就會溢位回零,這僅僅是 4.2949673 秒)。
const float game::NTSC_interval= 1.f / 59.94f;
const float game::PAL_interval= 1.f / 50.f;
float game::frame_interval( void )
{
if ( time_system() == FIXED_RATE )
{
if ( region() == NTSC )
{
return NTSC_interval;
}
else
{
return PAL_interval;
}
}
else
{
float current_time= get_system_time();
float interval= current_time - last_time;
last_time= current_time;
if ( interval < 0.f || interval > MAX_interval )
{
return MAX_interval;
}
else
{
return interval;
}
}
}
void game::update( void )
{
current_state->update( frame_interval());
}
現代遊戲通常直接從 CD 或間接從硬碟載入。無論哪種方式,您的遊戲都可能在 I/O 訪問中花費大量時間。磁碟訪問,尤其是 CD 和 DVD 訪問,比遊戲的其他部分慢得多。許多遊戲機製造商將所有磁碟訪問必須以視覺方式指示作為一項標準;而無論如何,這也不是一個糟糕的設計選擇。
然而,大多數磁碟訪問 API 函式(特別是那些透過 C 執行時庫的標準 I/O 對映的函式)會使處理器停滯,直到傳輸完成。這被稱為同步訪問。
在訪問磁碟時獲得反饋的一種方法是在它們自己的執行緒中執行磁碟操作。這樣做的優點是允許其他處理繼續進行,包括繪製磁碟操作的一些視覺反饋。但代價是需要編寫更多程式碼,並且需要同步對資源的訪問。
一些遊戲機作業系統 API 透過允許以非同步讀取操作排程磁碟訪問來處理一些多執行緒程式碼。非同步讀取可以透過輪詢檔案控制代碼或使用回撥來告知它們已完成。
無論遊戲使用 2D 圖形、3D 圖形還是兩者的組合,引擎都應以類似的方式處理它們。主要要考慮三個方面。
- 某些物件可能需要一段時間才能載入,並可能暫時凍結遊戲。
- 有些機器的執行速度比其他機器慢,遊戲必須以低幀率繼續執行。
- 有些機器的執行速度更快,動畫可能比以更高幀率的時間間隔更流暢。
因此,建立一個作為介面的基類以分離這些函式是一個好主意。這樣,每個可繪製物件都可以以相同的方式對待,所有載入都可以同時完成(用於載入螢幕),所有繪製都可以獨立於時間間隔完成。OpenGL 還要求物件顯示列表具有唯一的整數識別符號,因此我們還需要支援分配該值。
class IDrawable
{
public:
virtual void load( void ) {};
virtual void draw( void ) {};
virtual void step( void ) {};
int listID() {return m_list_id;}
void setListID(int id) {m_list_id = id;}
protected:
int m_list_id;
};
一種常見的碰撞檢測方法是使用軸對齊包圍盒。為了實現這一點,我們將基於我們之前的介面 IDrawable。它應該與 IDrawable 保持分離,因為畢竟,並非螢幕上繪製的每個物件都需要碰撞檢測。3D 盒子應由六個值定義:x、y、z、寬度、高度和深度。該盒子還應返回物件在空間中的當前最小值和最大值。這是一個示例 3D 包圍盒類
class IBox : public IDrawable {
public:
IBox();
IBox(CVector loc, CVector size);
~IBox();
float X() {return m_loc.X();}
float XMin() {return m_loc.X() - m_width / 2.;}
float XMax() {return m_loc.X() + m_width / 2.;}
float Y() {return m_loc.Y();}
float YMin() {return m_loc.Y() - m_height / 2.;}
float YMax() {return m_loc.Y() + m_height / 2.;}
float Z() {return m_loc.Z();}
float ZMin() {return m_loc.Z() - m_depth / 2.;}
float ZMax() {return m_loc.Z() + m_depth / 2.;}
protected:
float m_x, m_y, m_z;
float m_width, m_height, m_depth;
};
IBox::IBox() {
m_x = m_y = m_z = 0;
m_width = m_height = m_depth = 0;
}
IBox::IBox(CVector loc, CVector size) {
m_x = loc.X();
m_y = loc.Y();
m_z = loc.Z();
m_width = size.X();
m_height = size.Y();
m_depth = size.Z();
}
雖然在大多數 API 中顯示影像或紋理立方體很簡單,但當您開始為遊戲新增更多複雜性時,任務自然會變得稍微困難一些。如果引擎結構不合理,隨著引擎變得越來越大,這種複雜性也會越來越大。可能會不清楚需要進行哪些更改,您最終可能會得到巨大的特殊情況 switch 塊,而在其中一些簡單的抽象就可以簡化問題。
這與上面提到的要點有關 - 隨著遊戲引擎的演變,您將希望新增新功能。對於結構不合理的引擎,這些新功能難以新增,並且可能會花費大量時間來找出為什麼該功能沒有按預期工作。可能是某些奇怪的函式正在中斷它。精心設計的引擎會將任務分開,以便擴充套件某個區域僅僅是擴充套件 - 而不是必須修改以前的程式碼。
透過精心設計的引擎設計,您將開始瞭解自己的程式碼。您會發現自己花在盯著(或者可能詛咒)空白螢幕上的時間越來越少,想知道為什麼您的程式碼沒有按您認為的方式工作。
DRY 是一個常用縮略詞(尤其是在極限程式設計環境中),意思是“不要重複自己”。這聽起來很簡單,但可以為您提供更多時間去做其他事情。此外,執行特定任務的程式碼位於一箇中心位置,因此您可以修改該小節並檢視您的更改在所有地方生效。
上面提到的要點可能對您來說並不令人難以置信 - 它們確實是常識。但是,如果沒有對遊戲引擎設計的思考和規劃,您會發現達到這些目標要困難得多。
