回顧并為今天定下基調
上次我們結束的時候,基本上已經控制住了跳躍的部分,達到了我想要的效果,現在我們主要是在等待一些新的藝術資源。因此,等新藝術資源到位后,我們可能會重新處理跳躍的部分,因為現在的跳躍感覺已經做得差不多了,只是藝術資源還沒有完全到位,所以我把它縮小并且按需求進行了奇怪的縮放,實際上這是我希望或需要藝術資源最終呈現出來的樣子。正如我所說,這個問題最終需要通過藝術資產來解決,但目前的感覺還是挺不錯的,游戲的可玩性也覺得還算合理。雖然現在很難做最終評估,因為還沒有真正與游戲互動過,但至少目前的狀態足夠讓我感覺可以繼續推進游戲的其他部分,逐步完成。
接下來我們要做什么呢?我想我會著手推進實體系統和世界模式的結構。一直以來,它們都是一個雜亂無章的地方,我們就把各種東西都放進去,反正做什么都行,不太管它。但現在我們需要開始讓這些部分變得更實際一些,至少要把邏輯部分提升到我認為更合適的系統層次。以前我們一直沒有認真處理實體系統,但現在是時候認真去做了。
另外,我還想控制好渲染部分。現在我大致知道了我們將如何處理場景層次的合成問題,因此希望能夠清理掉不必要的Z軸以及其它相關內容。接下來,我打算將代碼分成幾個步驟來處理。首先,我會從清理實體處理開始,重點改善整個邏輯部分,將其整理到一個更合理的狀態。這包括實體存儲的方式,如何打包和解包這些數據等。我會對這些部分進行一次徹底的檢查。
然后,我打算切換到渲染部分,確保渲染效果正常,尤其是房間如何渲染的問題。我希望能夠把渲染部分做得更加完善,確保這一塊已經穩定后,再回頭進一步完善實體系統的部分。接下來我們將集中精力處理像世界生成、AI、游戲玩法等內容,這部分的代碼可能不太有趣,因為它比較隨意,也不太涉及到核心問題,但這些都是必須做的工作。
總的來說,這是接下來的工作計劃。目前我打算花些時間評估一下當前的進展,并和大家討論一下我的計劃。我知道這個計劃聽起來有點瘋狂,但我會詳細地告訴大家接下來的方向。
黑板:實體系統
實體系統有很多種實現方式,根據我的理解,實際上并沒有所謂的“標準”或“最佳”的方式,也沒有某種“圣杯”式的實體系統設計方法,一旦采用就能確保一切都能順利運行。我從未見過這樣一種方法可以解決所有問題。人們就這些問題展開爭論,涉及到面向對象的部分,也有很多關于組件和實體組件系統(Entity Component System,ECS)等的討論,這些只是一些術語,但很多時候這些方法實際上并沒有深入發展。并不像編譯器理論那樣有清晰的定義,實體系統到底是什么、它的作用是什么、實現這些系統的不同方法有哪些以及它們的復雜性如何,這些都沒有被很好的規范化,整個領域看起來更像是一種黑暗藝術。
很多時候,人們之所以會在這些問題上爭論,是因為他們喜歡就某個方法是否有效展開激烈討論,或者某些人聲稱某種方式是最好的,而其他方法則不可取。但是,在這些爭論中,只有少數情況下有人能提供理論依據或證明來支持他們的觀點。大多數時候,人們只是隨口說說而已,這些實體系統的設計方法大多也并沒有得到深刻驗證。
過去,很多人采用過不同的方式來實現這些系統。例如,有一種方式看起來像是傳統的面向對象編程(OOP)。這種方式可能會定義一個基類,表示一般的實體類型,然后在這個基類下再創建一些具體的類,例如“獸人”類、”人類“類等。這些類在繼承關系中層層分類,例如“王子”類、”騎士“類等。但這種做法如今幾乎沒有人認為是一個好的方法了,原因在于,大多數游戲中的實體并不像傳統面向對象編程那樣有嚴格的分類。游戲中創建的單位或實體通常會從多個地方借用不同的特性。舉個例子,“巫師”和“王子”之間可能有一些共用的代碼,而“騎士”和“巫師”之間也可能有一些共用的代碼,但“騎士”和“王子”之間通常沒有共用的代碼。
這種類層次結構的設計方式通常會遇到問題,尤其是在游戲中的單位具有多重特性時。很少有“王子”的代碼是獨立的,或者“巫師”代碼是完全獨立的。大多數情況下,它們是基于某些通用的實體代碼庫實現的。因此,嘗試把這些實體劃分到嚴格的類層次結構中往往是行不通的。所以現在,人們在設計實體系統時,更傾向于采用更加靈活和松散的結構,避免嚴格的繼承體系。
為了更好地說明這個問題,我們可以通過幾個具體的游戲實體例子來展示傳統的面向對象設計和現代更靈活的設計方式如何運作。
傳統面向對象設計的例子
假設我們正在開發一款角色扮演游戲,游戲中有多個類型的角色:王子、巫師、騎士等。在傳統的面向對象設計中,可能會有如下的類層次結構:
-
Entity (實體)
這是所有游戲實體的基類。 -
Character (角色)
繼承自Entity類,表示所有的角色。 -
Prince (王子)
繼承自Character類,表示王子角色。 -
Knight (騎士)
繼承自Character類,表示騎士角色。 -
Wizard (巫師)
繼承自Character類,表示巫師角色。
然后,你可能會為這些角色添加各種功能,比如攻擊、移動等。
- 王子可能有自己的特殊技能(例如領導力)。
- 騎士有攻擊力和防御力。
- 巫師可能有魔法技能。
但問題是,很多時候這些角色之間會有重疊的行為。例如,“王子”和“巫師”可能共享一些類似的能力,如“魔法攻擊”,而“騎士”也可能使用劍進行攻擊。但按照傳統的面向對象設計,所有這些重復的行為都必須在各個類中進行實現,即使它們有很多共同的代碼。
問題:這種繼承結構會導致代碼冗余和不靈活。假設“騎士”與“巫師”共享某些技能,而“王子”又有一部分技能可以與他們共享。那么我們就會面臨“代碼重復”和“類之間強耦合”的問題。
現代的組件化設計 (Entity-Component-System)
在現代的組件化設計中,我們不再用傳統的繼承來表示不同角色的關系,而是將功能和屬性拆分成多個組件,每個組件負責一部分行為。比如:
-
Entity (實體)
這是一個基礎的實體,什么都不做,只是一個標識。 -
Components (組件)
- HealthComponent (健康組件):管理實體的生命值。
- MovementComponent (移動組件):負責實體的移動。
- AttackComponent (攻擊組件):處理攻擊行為。
- MagicComponent (魔法組件):處理魔法技能。
- LeadershipComponent (領導力組件):王子獨有的能力。
-
Systems (系統)
- MovementSystem (移動系統):管理所有具有MovementComponent的實體的移動。
- CombatSystem (戰斗系統):管理所有具有AttackComponent的實體的攻擊行為。
- MagicSystem (魔法系統):處理所有具有MagicComponent的實體的魔法行為。
在這種設計下,王子、巫師和騎士都可以被看作是不同的實體,每個實體根據其特點組合不同的組件:
- 王子:一個實體,可能有HealthComponent(健康)、MovementComponent(移動)、AttackComponent(攻擊)、LeadershipComponent(領導力)。
- 巫師:一個實體,可能有HealthComponent(健康)、MovementComponent(移動)、AttackComponent(攻擊)、MagicComponent(魔法)。
- 騎士:一個實體,可能有HealthComponent(健康)、MovementComponent(移動)、AttackComponent(攻擊)。
優點:
- 組件化設計使得角色的行為更加靈活,可以根據需求動態地組合不同的組件,而不需要為每個角色創建大量的子類。
- 代碼復用性更高,因為行為(如攻擊、魔法、領導力)是被拆分到組件中的,不同角色可以共享這些組件。
- 系統與實體的解耦,使得程序更加模塊化。每個系統獨立管理特定的功能(如戰斗、移動),不需要關心實體的具體類型。
總結:
通過采用組件化設計,我們避免了傳統面向對象設計中類繼承層次過深、行為重復的問題。在組件化系統中,實體只是數據容器,行為通過組件和系統來定義,這使得代碼更加簡潔、靈活,并且更容易維護和擴展。
黑板:當前的組合方法
在很多情況下,傳統的面向對象編程(OOP)方法逐漸被更加組合化的方法所取代。組合化方法與傳統的繼承關系不同,它更側重于將功能模塊化,并且通過組合多個組件來實現復雜的行為,而不是通過繼承層次結構來建立類之間的關系。
傳統的繼承方法的問題
在傳統的面向對象設計中,通常會通過繼承關系來定義不同類型的實體。例如,假設我們有多個角色類型:王子、騎士、巫師等。這些角色共享一些共同的屬性和行為,例如“健康值”、“攻擊力”等。這些共同的行為通常會被放入父類中,然后子類(如王子、騎士、巫師)繼承這個父類,來復用這些通用的行為和屬性。
然而,這種方法會遇到一些問題。當不同的角色之間只需要共享部分行為時,繼承層次會變得冗長和復雜。例如,某些角色可能會共享“攻擊力”,但它們的攻擊方式卻不同。如果將所有這些功能放入繼承體系中,代碼將變得難以維護和擴展。而且,繼承關系過于緊密,導致了靈活性不足。
組合化方法的優勢
相比之下,組合化方法通過“組合”多個組件來定義一個角色的屬性和行為,而不是使用繼承。具體來說,每個角色(如巫師、騎士、王子等)都不直接繼承自一個父類,而是通過包含多個不同的組件來描述其特性。
舉個例子:
-
Necro(巫師):可能擁有多個組件,例如:
- Burnable(可燃組件):這個組件使得巫師可以被點燃并受到火焰傷害。
- Health(健康組件):巫師的生命值。
- Magic(魔法組件):巫師的魔法能力。
這些組件可以通過組合的方式,描述巫師這個角色的各項特性。通過組合,巫師的行為變得更加靈活和模塊化,不必通過繼承來定義一個固定的層級結構。
組件化的方式
在這種方法中,每個角色或者實體都被看作是由多個組件(例如健康、攻擊、魔法等)組成的。這些組件可以是獨立的模塊,通過組合來賦予角色特定的行為。例如:
- 王子:可以擁有Burnable(可燃組件)、Health(健康組件)、Leadership(領導力組件)等。
- 騎士:可以擁有Health(健康組件)、Attack(攻擊組件)等。
- 巫師:可以擁有Health(健康組件)、Magic(魔法組件)、Burnable(可燃組件)等。
這樣做的好處是,可以更加靈活地組合這些組件。每個角色可以根據需要選擇并組合不同的組件,而無需通過復雜的繼承關系來定義它們。
結果
通過組合不同的組件,我們可以避免傳統面向對象設計中的繼承層次問題。每個組件都是獨立的、可復用的功能模塊,可以在不同角色之間共享和組合。這樣不僅避免了代碼重復,還提高了系統的靈活性和可擴展性。每個角色的行為和屬性可以通過組件的組合來定義,而不需要依賴復雜的繼承層次結構。
總結來說,組合化的方法通過更加模塊化和靈活的設計,解決了傳統面向對象設計中的一些問題,使得代碼更加易于維護和擴展。
黑板:Looking Glass的Act / React模型
在很多系統設計中,實體系統的實現方法有很多種,其中一種是“行為反應模式”(Act-React Model),這種方法最初由Looking Glass公司在《The Dark Project》游戲中提出。它與“結構化數組”(SOA,Structure of Arrays)和“數組結構”(AOS,Array of Structures)有一些相似之處,但也有獨特的處理方式。
行為反應模型與數組結構方法的類比
首先,回顧一下“結構化數組”與“數組結構”的區別。在傳統的“數組結構”(AOS)中,像一個頂點(vertex)通常會有一個包含多個屬性的結構體,例如一個包含X、Y、Z和W坐標的結構體。每個數組的元素都是這種結構體的一個實例,這樣做可以直觀地將頂點的所有信息存儲在一個結構體中,并且每個頂點的所有屬性都在同一個地方。
但是,當我們需要批量處理這些數據時,例如同時操作所有頂點的X坐標、Y坐標等時,使用“結構化數組”的方法會更有效。在這種方法中,頂點的每個屬性(如X、Y、Z坐標)都會分別存儲在不同的數組中。通過這種方式,處理器可以更高效地并行處理相同類型的數據。
行為反應模式的核心思想
行為反應模式采用了類似的思路,但其核心是將實體的狀態和行為分開處理。具體來說,在這個模式中,游戲中的每個特性(如可燃性、健康值等)都會存儲在獨立的“表”中,而不是將這些信息存儲在單一的實體結構體中。例如:
- 可燃性表:記錄所有可燃對象的狀態。
- 健康值表:記錄所有實體的健康值。
每個表都獨立存儲特定的屬性,每個實體的狀態可以通過一個唯一的ID來索引和查找。這樣,系統可以分開處理每種屬性的更新,比如“可燃性更新”和“健康值更新”是分開進行的。這樣做的好處是,系統可以更加高效地處理每個獨立的屬性,避免了復雜的結構層次和冗余數據。
多表數據庫的類比
這種方法本質上類似于一個多表數據庫。在數據庫中,每個表都存儲不同類型的數據,查詢時可以通過ID等標識符來獲取相應的數據。在游戲中,這意味著當需要查詢某個實體的狀態時,可以通過查找對應的表格來獲取信息,而不必訪問整個實體的所有屬性。
解決復雜的游戲規則問題
在設計游戲時,尤其是面對復雜的互動規則時,這種方法非常有效。因為游戲設計通常需要非常靈活和多變的規則。例如,一個角色可能會在火中受傷,但這個傷害的程度可能取決于該角色是否擁有某種特殊的抗火能力。此外,火災可能會影響角色的健康,而角色的健康狀態也可能影響他是否能在火中存活。
這種靈活的規則設計非常具有挑戰性,尤其是當這些規則相互交織時。游戲的開發者需要考慮如何處理這些復雜的交互,并保證在代碼中能夠清晰地體現出來。
結論
行為反應模型通過將每種屬性分開存儲,避免了傳統面向對象編程中可能出現的繼承和類層次問題。它將不同的屬性存儲在獨立的表格中,從而使得每種屬性可以獨立更新和查詢。這種方法使得系統更加靈活,同時能夠有效處理復雜的游戲規則和交互關系。
舉個例子來更好地理解“行為反應模式”(Act-React Model)和如何運作:
假設一個角色(Entity)在游戲中包含多個屬性(比如健康值、火焰狀態等),而這些屬性的狀態和行為是分開管理的。
1. 游戲中的角色屬性:
我們有幾個角色,每個角色都有不同的屬性。比如:
- 角色1(Necromancer)有健康值、可燃性和攻擊力。
- 角色2(Prince)有健康值、可燃性和攻擊力。
- 角色3(Knight)有健康值、可燃性和防御力。
每個角色可能有不同的屬性,例如“健康值”可能與“火焰狀態”相關,而“可燃性”屬性決定了角色是否容易著火。
2. 傳統的做法:
在傳統的面向對象編程(OOP)方法中,可能會把這些屬性放入一個單獨的對象中。例如:
struct Entity {int health;bool isBurnable;int attackPower;// 其他屬性
};Entity necromancer = {100, true, 50};
Entity prince = {120, true, 60};
Entity knight = {150, false, 70};
但這種方法的問題是,某些屬性之間可能會有復雜的交互(比如健康和可燃性),而在不同的角色之間,屬性的共享和繼承可能變得非常復雜和不清晰。
3. 行為反應模式的做法:
在行為反應模式中,我們將這些屬性分開存儲,形成多個表格。每個表格只關注特定類型的屬性。
比如:
- 健康值表(Health Table):記錄所有角色的健康值。
- 可燃性表(Burnable Table):記錄哪些角色是可燃的。
- 攻擊力表(Attack Table):記錄每個角色的攻擊力。
struct HealthTable {int entityID;int health;
};struct BurnableTable {int entityID;bool isBurnable;
};struct AttackTable {int entityID;int attackPower;
};// 角色1的數據
HealthTable healthTable[] = { {1, 100}, {2, 120}, {3, 150} };
BurnableTable burnableTable[] = { {1, true}, {2, true}, {3, false} };
AttackTable attackTable[] = { {1, 50}, {2, 60}, {3, 70} };
現在,當我們要處理角色是否燃燒時,我們只需要檢查可燃性表,查看該角色是否有isBurnable
為true
。如果是,角色就可以被點燃。同樣,如果角色的健康值發生變化,我們只需要更新健康值表中的相應條目。
4. 更新操作:
在這種結構下,我們可以單獨處理每個屬性。例如,如果火災發生,我們只更新可燃性表中isBurnable
屬性的狀態,檢查角色是否著火。而健康值變化則會單獨處理。
// 處理火災更新:檢查可燃性
for (int i = 0; i < sizeof(burnableTable) / sizeof(BurnableTable); ++i) {if (burnableTable[i].isBurnable) {// 角色著火,減少健康值healthTable[i].health -= 10;}
}// 處理角色的攻擊
for (int i = 0; i < sizeof(attackTable) / sizeof(AttackTable); ++i) {// 角色攻擊,處理攻擊邏輯attackTable[i].attackPower -= 5;
}
5. 查詢狀態:
當我們想要查詢某個角色的狀態時,例如我們想知道角色1是否還在火中,可以通過其entityID
在可燃性表中查找:
int entityID = 1; // 查詢Necromancer
bool isBurning = burnableTable[entityID - 1].isBurnable;
優勢:
- 性能優化:通過這種方法,系統可以更高效地處理每種類型的屬性,因為每個屬性被單獨存儲和處理,可以批量更新某一類屬性,而不需要每次都訪問整個實體的所有屬性。
- 擴展性:如果想增加新屬性(比如“抗火能力”),可以直接添加到相應的表中,而不需要修改現有的類結構,減少了耦合。
- 靈活性:這種方法可以處理更加復雜的交互規則,如“火焰影響健康”和“健康影響火焰”,并且非常適合處理動態變化和復雜條件。
總結:
通過將每種屬性存儲在獨立的表格中,行為反應模型避免了傳統面向對象方法中可能出現的繼承復雜性,并且通過分離關注點,可以更靈活、有效地處理游戲中的狀態變化和復雜交互。
黑板:AOS方法的問題
在設計和實現游戲中的實體系統時,常常會面臨一些性能和靈活性之間的權衡。以下是幾種常見的處理方式及其優缺點:
1. 傳統的結構體存儲方法:
假設我們有一個“Necromancer”(死靈法師)結構體,在這種方法中,每個實體(比如Necromancer)會將所有相關的屬性直接包含在其結構體中:
struct Necro {int health;bool isBurnable;int attackPower;// 其他屬性...
};
優點:
- 性能:這種方式的主要優勢在于速度。由于所有屬性都保存在結構體中,編譯器可以直接訪問這些數據,避免了額外的查找開銷。處理速度非常快,尤其是在C語言或C++等編譯語言中。
- 簡潔明了:代碼簡單且易于理解,處理一個實體的每個屬性都非常直接。
缺點:
- 靈活性差:如果我們希望在運行時為某個實體動態添加新的屬性,修改結構體就變得困難。需要重新編譯整個系統,并且這些變更是固定的,不能在游戲過程中動態調整。
- 擴展性差:隨著實體種類和屬性的增加,結構體可能會變得非常復雜和臃腫。
2. 基于表格的動態屬性管理:
為了提高靈活性,另一個常見的做法是將每種屬性分開存儲在不同的表格中(如健康、可燃性、攻擊力等)。每個屬性都用一個獨立的表格來管理,可以動態地為不同實體添加不同的屬性。例如:
- 健康值表:記錄所有實體的健康值。
- 可燃性表:記錄哪些實體是可燃的。
- 攻擊力表:記錄每個實體的攻擊力。
struct HealthTable {int entityID;int health;
};struct BurnableTable {int entityID;bool isBurnable;
};struct AttackTable {int entityID;int attackPower;
};
在這種方法中,查詢一個實體的屬性時,需要通過其ID去查詢多個不同的表格,而不是直接從一個結構體中獲取所有信息。
優點:
- 靈活性高:通過將不同的屬性分開存儲,可以在運行時動態地為實體添加或移除屬性。例如,如果需要為某個實體添加“芭蕾舞”狀態,只需要在芭蕾舞表中為該實體添加一條記錄,而不需要修改實體的結構體。
- 擴展性強:這種方式允許開發人員根據需要隨時擴展新的屬性類型,而無需重新編譯和修改實體結構體。
缺點:
- 性能開銷:每次處理一個實體時,需要查詢多個表格,這會帶來一定的性能開銷,尤其是當實體有大量屬性時。每次訪問一個屬性都需要額外的查詢操作,可能導致效率低下。
- 復雜性增加:由于屬性分散在不同的表格中,代碼的可維護性可能變得更復雜,需要管理更多的表格和數據結構。
3. 動態添加屬性的解決方案:
在這種方法中,可以使用一種靈活的屬性管理方式,例如為每個實體維持一個“屬性列表”。這些屬性可能在游戲運行時動態增加。比如,可以通過簡單的方式將不同的屬性(如芭蕾舞狀態)添加到這個列表中。
這種方式允許實體在運行時獲得更多的動態屬性,同時避免了表格查詢的復雜性。然而,和傳統的結構體方法一樣,這種方式也可能導致一些性能上的問題,尤其是當屬性列表變得非常大時。
4. 綜合比較:
- 性能 vs 靈活性:傳統的結構體方法在性能上有顯著優勢,尤其是在需要頻繁訪問和更新實體屬性時,但它的靈活性差,無法在運行時動態地改變實體的屬性。而基于表格的方法雖然靈活性強,但在性能上可能存在瓶頸,尤其是當屬性數量龐大時。
- 擴展性和可維護性:基于表格的方案具有更好的擴展性和可維護性,尤其是在復雜的游戲設計中需要為實體添加各種各樣的動態屬性時。
- 具體需求的取舍:如果游戲的設計需要高度靈活和動態的屬性管理,那么基于表格的動態方法是更好的選擇;但如果對性能有嚴格要求,且屬性變化較少,使用傳統結構體的方式可能會更合適。
總的來說,選擇哪種方法取決于游戲的設計需求。如果需要快速處理并且實體屬性較為固定,結構體方法更為適合;如果游戲要求更高的靈活性和擴展性,表格方法則更加靈活和強大。
黑板:稀疏實體系統
在開發過程中,有時會遇到需要創造一個全新實體系統的情況,這種系統從基礎原則出發,以不同于傳統方法的方式設計。為了追求性能與靈活性的平衡,提出了一個創新的想法:稀疏實體系統(Sparse Entity System)。這個系統的目標是將實體管理系統的性能提升至最優,同時仍然保持靈活性,能夠讓實體具備在運行時隨意變化和擴展的能力。
設計思路
稀疏實體系統試圖在保證快速運行的同時,給實體系統帶來更多的靈活性。傳統上,實體系統往往要么偏重性能,導致靈活性不足,要么偏重靈活性,導致性能下降。而這種新的實體系統設計理念,試圖在兩者之間找到平衡。
稀疏實體系統的核心思想
-
性能優先:傳統的C語言代碼對于實體的處理非常高效,因為它能夠直接訪問結構體內的每個屬性,編譯器能夠準確地知道這些數據的存儲位置。這種方式是最快的,但在面對復雜、動態變化的實體屬性時,靈活性不足。
-
靈活性需求:另一方面,游戲中的實體常常需要具有高度的靈活性,可以在運行時根據需求動態地增加或修改屬性。這就需要一個能支持這種動態調整的系統。
-
解決方案的嘗試:稀疏實體系統的設計目標就是嘗試找到一種既能夠快速處理數據,又能支持實體屬性動態變化的方式。其核心理念就是稀疏存儲,通過將實體的各個屬性分離存儲并以稀疏方式訪問,從而避免了性能瓶頸,同時保持了靈活性。
系統的工作原理
稀疏實體系統的一個可能實現方式是將每個實體的屬性分散存儲,而不是將所有屬性放入同一個結構體中。這意味著每個屬性(如健康、攻擊力等)都可以存在不同的表格或數據結構中,每個實體的具體信息會通過查找這些表格中的記錄來獲取。
這種做法允許開發人員在運行時為實體動態地添加或修改屬性。例如,如果需要給某個實體添加一個新的狀態(比如“芭蕾舞”狀態),只需要在對應的屬性表中添加一條記錄,而不需要修改實體的結構體或重新編譯游戲代碼。這為游戲設計提供了巨大的靈活性,尤其是在復雜的游戲系統中,可以隨時為實體增添新的功能。
評估系統的優缺點
-
優點:
- 靈活性:可以動態地為實體增加新的屬性,不受結構體限制。
- 擴展性:可以方便地擴展和修改實體的屬性,而不需要重新定義和編譯結構體。
- 靈活的設計:實體可以根據需要在運行時變得不同,滿足不同游戲需求。
-
缺點:
- 性能開銷:每次查詢實體的屬性時,都需要通過查找不同的表格,可能導致性能下降,尤其在屬性非常多的情況下。
- 實現復雜:這種設計思路可能會增加系統的復雜性,管理多個表格和動態修改屬性的過程可能會讓代碼更加難以維護。
總的來說,稀疏實體系統的設計是一種創新的嘗試,旨在平衡性能和靈活性。雖然它帶來了新的靈活性,但也有可能在性能和維護性上遇到一些挑戰,是否能夠在實際開發中成功實施,還需要通過實驗和實際開發來驗證其可行性。
為了幫助理解稀疏實體系統的設計,我們可以通過一個簡單的游戲實體管理的例子來展示它如何工作。假設我們正在開發一個角色扮演游戲(RPG),其中有多個不同類型的角色,比如“巫師”、“戰士”和“弓箭手”。每個角色都會有一些共同的屬性,例如“生命值”、“攻擊力”和“防御力”,但也有一些特有的屬性,比如“魔法值”(僅巫師有)或“射擊精度”(僅弓箭手有)。
1. 傳統的結構體存儲方式
在傳統的方式中,我們可能會為每種角色類型定義一個結構體,如下所示:
struct Character {int health;int attack;int defense;int magic; // 僅巫師有int accuracy; // 僅弓箭手有
};
然而,這種方式有一個問題:如果我們要為角色動態添加新屬性(例如添加“飛行能力”或“冰凍技能”),每次都需要重新定義結構體,并且重新編譯整個程序。這就限制了游戲的靈活性。
2. 稀疏實體系統存儲方式
在稀疏實體系統中,角色的屬性將被分散存儲在不同的表格或數據結構中,而不是放在一個統一的結構體中。每個屬性(如健康、攻擊、魔法等)都會有一個獨立的表格來管理,實體本身的屬性則通過表格中的記錄進行訪問。
假設我們有如下的結構:
// 角色的唯一標識符(ID)
typedef int EntityID;// 存儲不同屬性的表格
int healthTable[MAX_ENTITIES];
int attackTable[MAX_ENTITIES];
int defenseTable[MAX_ENTITIES];
int magicTable[MAX_ENTITIES]; // 僅巫師有
int accuracyTable[MAX_ENTITIES]; // 僅弓箭手有
每個角色都有一個唯一的ID(EntityID
),這些表格會通過角色ID來存儲和訪問該角色的屬性。例如:
// 設置巫師的屬性
EntityID wizardID = 0; // 巫師的ID為0
healthTable[wizardID] = 100;
attackTable[wizardID] = 50;
defenseTable[wizardID] = 30;
magicTable[wizardID] = 200; // 巫師有魔法值// 設置弓箭手的屬性
EntityID archerID = 1; // 弓箭手的ID為1
healthTable[archerID] = 120;
attackTable[archerID] = 60;
defenseTable[archerID] = 25;
accuracyTable[archerID] = 90; // 弓箭手有射擊精度
3. 動態添加新屬性
在稀疏實體系統中,最重要的優勢之一是可以動態地添加新屬性。例如,如果我們想為某個角色添加一個新的技能,如“飛行能力”,我們只需要為這個技能創建一個新的表格,并且為所有相關的角色添加該屬性,而無需修改原有的結構體或重新編譯代碼。
int flightAbilityTable[MAX_ENTITIES]; // 新的飛行能力表格
然后我們可以動態地為角色添加“飛行能力”屬性:
// 為巫師添加飛行能力
flightAbilityTable[wizardID] = 1; // 巫師擁有飛行能力// 為弓箭手添加飛行能力
flightAbilityTable[archerID] = 0; // 弓箭手不具備飛行能力
4. 查詢屬性
當我們需要查詢某個角色的屬性時,我們只需使用角色的ID去查找相關的表格。例如,查詢巫師的健康值和魔法值:
int wizardHealth = healthTable[wizardID]; // 巫師的健康值
int wizardMagic = magicTable[wizardID]; // 巫師的魔法值
這種方法使得游戲中的實體非常靈活,可以根據需要隨時修改屬性,也可以隨時為角色添加新的能力或狀態,而不需要改變整個系統的結構。
5. 總結
稀疏實體系統的最大優點是它允許在游戲運行時動態地調整實體的屬性,而不需要修改原始的結構體定義或重新編譯代碼。雖然這種方法可能會帶來一定的性能開銷(因為每次查詢都需要進行查找操作),但它為游戲設計提供了極大的靈活性,尤其在開發復雜的、需要經常變動的游戲時,能夠大大提高開發效率和可擴展性。
黑板:繼承
在討論繼承的過程中,首先需要明確一個常見的誤解和概念。繼承通常被認為是面向對象編程中的一種常見結構,尤其在 C++ 中,它指的是從一個結構體(比如 struct foo
)派生出另一個結構體(比如 struct bar
)。通過繼承,我們可以在 bar
中繼承 foo
的屬性和方法,并且可以對其進行擴展或修改。
1. 基本的繼承機制
首先,假設我們有一個結構體 foo
,里面包含兩個成員:一個整數 X
和一個布爾值 fat
。這兩個成員常常一起出現,并且我們有一些操作依賴于這兩個成員:
struct Foo {int X; // 整數Xbool fat; // 布爾值fat,表示是否肥胖
};
接下來,我們寫一個函數 D_fatty
,這個函數根據 X
的值來決定是否將 fat
設為 false
:
void D_fatty(Foo* foo) {if (foo->X < 10) {foo->fat = false;}
}
這里,D_fatty
函數接受一個 Foo
結構體的指針,并根據 X
的值來修改 fat
。這時候,Foo
被視為一個封裝了兩個數據成員的實體,所有操作都是圍繞這兩個成員展開的。
2. 通過指針訪問成員的優化
現在,我們將這個問題進行簡化并優化。如果我們將 X
和 fat
獨立出來,作為單獨的指針傳遞,而不是通過一個 Foo
結構體傳遞,那么函數就變得更靈活,能夠操作任何 X
和 fat
對應的組合,而不僅僅是 Foo
類型。這種方式雖然增加了靈活性,但卻帶來了額外的復雜性,并且可能會導致性能下降,因為每次我們需要處理這些數據時,都必須解包這些數據。
void D_fatty(int* X, bool* fat) {if (*X < 10) {*fat = false;}
}
盡管這種方式更加靈活,但它失去了將相關數據封裝在一起的優點。與之相比,將 X
和 fat
保持在同一個結構體中,不僅便于編程,也讓編譯器能夠更高效地優化代碼,因為編譯器可以清楚地知道這些數據的內存布局,并且能夠在機器代碼層面上優化訪問。
3. 繼承的引入
在繼承的情況下,假設我們有一個 Foo
結構體,并且我們希望從它派生出一個 Bar
結構體。Bar
需要包含 Foo
的所有屬性,并且可以添加新的屬性(比如 swim
)。我們可以通過繼承來實現這一點:
struct Bar {Foo foo; // 繼承Foofloat swim; // 新增的屬性
};
在這里,Bar
包含了 Foo
,并且可以訪問 Foo
的所有成員。使用繼承的好處是,可以在 Bar
結構體中復用 Foo
的所有功能,而不必重復定義 X
和 fat
。
4. 指針與繼承的關系
繼承的強大之處在于我們可以將 Bar
的指針作為 Foo
的指針來使用,因為 Bar
總是包含一個 Foo
。這意味著我們可以通過傳遞 Bar
類型的指針來訪問 Foo
的成員,而不需要顯式地傳遞 Foo
的指針。例如,調用 D_fatty
函數時,我們可以直接傳遞 Bar
的指針,而不需要從中提取出 Foo
的指針。
void D_fatty(Bar* bar) {if (bar->foo.X < 10) {bar->foo.fat = false;}
}
由于 Bar
結構體中的 foo
是 Foo
類型的實例,我們可以像操作 Foo
一樣操作 Bar
,這讓代碼變得更加簡潔易用。
5. 繼承機制的優化
繼承機制的關鍵優勢在于,編譯器可以知道 Foo
在 Bar
結構體中的位置,進而優化內存布局和指針訪問。由于繼承的特性,編譯器知道 Foo
是 Bar
的第一個成員,因此,它可以直接通過 Bar
的指針訪問 Foo
。這種優化降低了程序的復雜度,并提高了性能。
通過繼承,我們不需要像之前那樣每次都顯式地解包 X
和 fat
,而是可以直接在 Bar
中使用它們,編譯器會自動處理這些訪問和內存布局。
6. 繼承機制的局限性
盡管繼承提供了很多便利,它也有一些局限性。繼承機制比較簡單,它通常假設子類只會繼承父類的屬性和行為,但如果我們需要更多的靈活性(例如多個父類的繼承),C++ 的單繼承機制可能就無法滿足需求。此外,繼承還可能導致類之間的緊耦合,使得維護和擴展變得更加復雜。
7. 總結
繼承通過將公共的屬性和行為封裝在基類中,使得子類可以繼承和擴展這些屬性,避免了代碼的重復。它的優勢在于提高了代碼的重用性、簡化了代碼結構,并且通過優化內存布局和訪問,提高了程序的性能。然而,繼承也有其局限性,尤其在需要多個父類的情況下,它的設計就變得不夠靈活。
黑板:當繼承失敗時
當討論繼承時,問題變得更為復雜,特別是在實際應用中。當涉及到多個屬性(如健康、可燃性等)時,繼承開始面臨挑戰。假設我們有一個角色(比如“necros”),我們希望這個角色能夠擁有多種功能,例如能夠擁有“健康”功能、可燃性功能等。這就要求我們能夠通過繼承機制把這些功能結合在一起,使得這個角色能夠兼容各種預期的功能。
1. 多重繼承的挑戰
在傳統的繼承機制中,如果我們嘗試從多個功能(如健康、可燃性等)繼承,就會遇到問題。多個繼承在 C++ 中是一個非常棘手的問題,因為繼承機制假設只有一個“基類”存在,且這些基類的成員順序是確定的。當我們使用多重繼承時,問題就出現了。繼承的順序變得重要,這樣可能導致我們無法像之前那樣,簡單地將一個指向 necros
的指針同時當做指向健康、可燃性等其他功能的指針使用。
例如,在單繼承時,我們可以將一個指針傳遞給預期使用某個特性(如健康)的函數,因為編譯器知道這個指針可以直接指向結構體的起始位置。但在多重繼承中,指針的相對位置會變化,因為繼承的各個成員的位置不同,這意味著不能直接將同一個指針傳遞給不同的功能模塊,必須調整指針的偏移量。
2. 指針調整問題
當使用多重繼承時,指針的調整變得更加復雜。由于每個基類在派生類中的位置不同,因此無法保證指針可以直接用于所有的功能模塊。例如,在上面的 necros
示例中,如果我們把 necros
類定義為繼承自多個功能(如 health
和 burnable
),那么編譯器并不知道如何處理指向 necros
的指針。指向 necros
的指針不能直接用作指向 health
或 burnable
的指針,因為這兩個類的內存位置不同,編譯器必須做額外的計算來調整指針,導致效率低下且增加了復雜性。
3. 運行時動態改變繼承結構的局限性
更大的問題是,C++ 的多重繼承是一個編譯時的概念,意味著類的繼承關系在編譯時就已經確定了,無法在運行時動態地修改。如果我們想要動態地給一個已經存在的對象添加新的功能(例如給一個 necros
添加一個新的屬性或技能),這在 C++ 中是無法實現的。因為繼承關系和內存布局都是在編譯時就決定的,而 C++ 不允許在運行時修改這些結構。
這就導致了問題:假如我們創建了一個 necros
對象,之后給它施加一個新的屬性(比如增加一個新的技能),我們無法在運行時動態地添加新的繼承關系或成員。這在 C++ 中是不可行的,因為 C++ 的繼承機制是以編譯時決定的結構為基礎的,而不是運行時可擴展的。
4. 為何 C++ 不支持這種動態繼承
C++ 不支持在運行時添加繼承層次的原因是,這種機制涉及到編譯器在編譯階段已經對內存布局做出了優化。繼承關系和成員的偏移量被硬編碼進了機器代碼,編譯器通過這些偏移量來直接訪問內存。如果在運行時動態地修改繼承關系,編譯器將無法繼續進行這種優化,導致程序變得非常低效。因此,C++ 不支持這種動態的繼承結構,也無法在運行時改變類的內存布局和繼承結構。
5. 總結
繼承在 C++ 中有其優勢,但也存在明顯的局限性,尤其是在多重繼承和運行時動態修改繼承關系的情況下。多重繼承會導致指針訪問的復雜性,無法像單一繼承那樣簡化代碼結構,并且需要進行額外的指針調整。而動態繼承則完全無法實現,因為 C++ 的繼承機制是基于編譯時確定的結構,無法在運行時進行調整。這樣的設計雖然有助于提高性能和優化代碼,但也限制了靈活性,特別是在需要動態修改類的行為時。
為了更好地理解繼承機制中的問題,下面通過一個具體的例子來展示繼承在多重繼承和運行時動態修改結構時的局限性。
1. 單一繼承的簡單例子
假設我們有一個 Creature
類,它包含 health
屬性,表示生物的健康狀況。并且我們有一個 Necro
類,它繼承自 Creature
類,表示具有特殊功能的生物,比如死靈法師。以下是單一繼承的代碼:
#include <iostream>struct Creature {int health;Creature(int h) : health(h) {}
};void heal(Creature* creature) {if (creature->health < 100) {creature->health += 10;}
}int main() {Creature c(50);heal(&c);std::cout << "Creature's health: " << c.health << std::endl;return 0;
}
解釋:
Creature
是一個基類,包含health
屬性和一個構造函數。heal
函數接受一個Creature
指針并增加它的健康值。main
函數創建了一個Creature
對象,初始化它的健康為 50,并調用heal
函數恢復健康。
在這種單一繼承的情況下,代碼非常簡潔,且運行時訪問內存沒有任何問題。我們只需要傳遞一個指向 Creature
對象的指針,函數就可以正常工作。
2. 多重繼承的復雜情況
現在我們嘗試使用多重繼承。假設我們有兩個功能:Burnable
和 Healable
,分別表示生物是否可以被燃燒和是否可以被治療。我們將 Necro
類同時繼承這兩個類。
#include <iostream>struct Burnable {bool isBurnable;Burnable(bool b) : isBurnable(b) {}
};struct Healable {int health;Healable(int h) : health(h) {}void heal() {if (health < 100) health += 10;}
};struct Necro : public Burnable, public Healable {Necro(bool b, int h) : Burnable(b), Healable(h) {}
};int main() {Necro necro(true, 50);necro.heal();std::cout << "Necro's health after healing: " << necro.health << std::endl;return 0;
}
解釋:
Burnable
類包含isBurnable
屬性,表示物體是否可以被燃燒。Healable
類包含health
屬性和一個heal
函數,表示物體是否可以治療。Necro
類繼承了Burnable
和Healable
,并在構造函數中初始化了這兩個類的成員。main
函數創建了一個Necro
對象,初始化它的isBurnable
為true
,健康值為 50,并調用heal
函數恢復健康。
此時 Necro
類擁有兩個獨立的功能:可以燃燒和可以治療。代碼看起來沒有問題,Necro
對象可以正常使用 heal
函數。
3. 多重繼承中的問題
但是,問題在于內存布局。當我們使用多個繼承時,Necro
類中實際上包含了 Burnable
和 Healable
的兩個子對象。假設我們再加入一個新的類 MagicUser
,并且我們希望給 Necro
增加一個額外的魔法屬性。為了實現這一點,我們需要將這個新屬性添加到 Necro
類中,但這時就會遇到指針調整的問題。
假設我們修改 Necro
類,使其包含 MagicUser
功能:
#include <iostream>struct MagicUser {bool hasMagic;MagicUser(bool magic) : hasMagic(magic) {}
};struct Necro : public Burnable, public Healable, public MagicUser {Necro(bool b, int h, bool m) : Burnable(b), Healable(h), MagicUser(m) {}
};int main() {Necro necro(true, 50, true);necro.heal();std::cout << "Necro's health after healing: " << necro.health << std::endl;std::cout << "Necro has magic: " << (necro.hasMagic ? "Yes" : "No") << std::endl;return 0;
}
問題:
Necro
類現在同時繼承了Burnable
、Healable
和MagicUser
。- 由于多重繼承,
Necro
類包含了三個基類的成員,它們的位置會受到影響。 - 如果我們傳遞
Necro
對象的指針給某些函數,這時我們無法保證指針始終指向正確的成員。因為每個基類的內存布局不同,所以要使用Necro
對象的指針時,需要進行額外的偏移調整。
4. 動態修改繼承結構的局限性
現在假設我們希望在運行時為 Necro
增加一個新的功能,比如增加一個新的魔法技能 SpellCaster
。然而,由于 C++ 的繼承結構是在編譯時就固定的,我們不能在運行時動態地給 Necro
對象添加新的繼承功能:
// 這是無法在運行時動態添加的
struct SpellCaster {void castSpell() {std::cout << "Casting a spell!" << std::endl;}
};Necro necro(true, 50, true);
SpellCaster* spellCaster = new SpellCaster();
// 無法動態將 SpellCaster 加入 necro 對象
問題:
- 在 C++ 中,繼承結構是靜態的,不能在運行時動態修改。這意味著,如果你想給
Necro
對象添加新的功能或改變它的行為,你必須重新編譯代碼,而不能像一些動態語言那樣隨時改變對象的屬性和功能。 - 在這個例子中,盡管我們創建了一個
SpellCaster
對象,但無法將其動態地“附加”到Necro
類實例上。
總結
- 單一繼承:簡單且高效,適用于簡單的對象關系。
- 多重繼承:提供了更大的靈活性,但也引入了指針調整問題,導致訪問內存時需要額外的復雜性。
- 動態修改繼承結構的局限性:C++ 不支持在運行時修改繼承結構,這使得在運行時動態添加功能變得困難,特別是在多重繼承的復雜情況下。
這些問題展示了繼承在 C++ 中的復雜性和局限性,尤其是在多重繼承和動態修改繼承結構時。
在 C++ 的多重繼承中,指針調整問題(pointer adjustment)之所以出現,是因為多重繼承會導致對象的內存布局變得復雜,基類子對象的地址可能與派生類對象的地址不完全相同。為了理解這個問題,我們需要深入探討多重繼承中的內存布局以及指針在訪問基類成員時的行為。
1. 多重繼承的內存布局
在多重繼承中,派生類(如 Necro
)會包含所有基類的子對象(Burnable
、Healable
、MagicUser
等)。這些基類的子對象在內存中按照繼承順序依次排列,且每個基類子對象都有自己的內存起始地址。
以你的例子中的 Necro
類為例:
struct Burnable {bool isBurnable;Burnable(bool b) : isBurnable(b) {}
};struct Healable {int health;Healable(int h) : health(h) {}
};struct MagicUser {bool hasMagic;MagicUser(bool magic) : hasMagic(magic) {}
};struct Necro : public Burnable, public Healable, public MagicUser {Necro(bool b, int h, bool m) : Burnable(b), Healable(h), MagicUser(m) {}
};
假設我們創建了一個 Necro
對象:
Necro necro(true, 50, true);
在內存中,Necro
對象的布局可能如下(假設 32 位系統,忽略填充字節):
Necro 對象內存布局:
+-------------------+
| Burnable 子對象 | // 包含 isBurnable (偏移 0)
+-------------------+
| Healable 子對象 | // 包含 health (偏移 1 或 4,取決于對齊)
+-------------------+
| MagicUser 子對象 | // 包含 hasMagic (偏移 5 或 8,取決于對齊)
+-------------------+
Necro
對象的起始地址是Burnable
子對象的起始地址。Healable
子對象的地址相對于Necro
對象的起始地址有一個偏移(例如,偏移 1 或 4 字節)。MagicUser
子對象的地址有更大的偏移(例如,偏移 5 或 8 字節)。
當你將 Necro
對象的指針傳遞給某個函數,并試圖將其作為某個基類的指針(如 Healable*
或 MagicUser*
)使用時,編譯器需要調整指針的地址,以確保它指向正確的基類子對象的起始地址。這就是所謂的指針調整。
2. 為什么需要指針調整?
指針調整的根本原因是:派生類對象的地址與每個基類子對象的地址可能不同。在單一繼承中,基類子對象通常位于派生類對象的開頭,因此派生類指針可以直接作為基類指針使用,無需調整。但在多重繼承中,基類子對象的地址會因為內存布局而偏移,導致需要調整指針。
舉個例子,假設我們有一個函數,接受 Healable
類型的指針:
void heal(Healable* healable) {if (healable->health < 100) {healable->health += 10;}
}
現在我們將 Necro
對象的指針傳遞給這個函數:
Necro necro(true, 50, true);
heal(&necro); // 隱式轉換為 Healable*
&necro
是Necro
對象的地址,指向內存布局的開頭(即Burnable
子對象的地址)。- 但是
heal
函數需要一個Healable*
類型的指針,而Healable
子對象在Necro
對象中的地址并不是Necro
對象的起始地址,而是有一定偏移(例如,偏移 4 字節)。 - 因此,編譯器會在將
Necro*
轉換為Healable*
時,自動調整指針的地址,增加必要的偏移量,使其指向Healable
子對象的正確位置。
這個過程是編譯器在幕后完成的,通常對程序員是透明的。但它引入了以下問題:
- 性能開銷:每次將派生類指針轉換為基類指針時,編譯器可能需要插入額外的指令來調整指針地址,這會增加運行時開銷。
- 復雜性:在多重繼承中,指針調整可能導致難以調試的錯誤,尤其是當你手動操作指針或涉及虛函數調用時。
- 潛在的錯誤:如果程序員錯誤地假設指針無需調整(例如,通過
reinterpret_cast
強制轉換),可能會導致訪問錯誤的內存位置,引發未定義行為。
3. 指針調整的具體場景
為了更清楚地說明指針調整,我們可以通過一個更具體的例子來展示:
#include <iostream>struct Burnable {bool isBurnable;Burnable(bool b) : isBurnable(b) {}virtual void print() { std::cout << "Burnable: " << isBurnable << std::endl; }
};struct Healable {int health;Healable(int h) : health(h) {}virtual void print() { std::cout << "Healable: " << health << std::endl; }
};struct Necro : public Burnable, public Healable {Necro(bool b, int h) : Burnable(b), Healable(h) {}
};int main() {Necro necro(true, 50);Burnable* burnable = &necro; // 指向 Burnable 子對象Healable* healable = &necro; // 指向 Healable 子對象std::cout << "Necro address: " << &necro << std::endl;std::cout << "Burnable address: " << burnable << std::endl;std::cout << "Healable address: " << healable << std::endl;burnable->print(); // 調用 Burnable::printhealable->print(); // 調用 Healable::printreturn 0;
}
輸出示例(實際地址會因系統而異):
Necro address: 0x7ffee4c0a4a0
Burnable address: 0x7ffee4c0a4a0
Healable address: 0x7ffee4c0a4a8
Burnable: 1
Healable: 50
解釋:
Necro
對象的地址是0x7ffee4c0a4a0
。Burnable*
指針指向Necro
對象的起始地址(0x7ffee4c0a4a0
),因為Burnable
子對象位于Necro
對象的開頭。Healable*
指針指向一個偏移后的地址(0x7ffee4c0a4a8
),因為Healable
子對象位于Burnable
子對象之后(假設Burnable
占 8 字節,包括對齊)。- 編譯器在將
&necro
轉換為Healable*
時,自動增加了 8 字節的偏移量,這就是指針調整。
4. 指針調整與虛函數
指針調整問題在涉及虛函數時會更加復雜。如果基類有虛函數,派生類對象會包含一個虛表指針(vptr),用于動態分派虛函數調用。在多重繼承中,每個基類子對象可能有自己的虛表指針,且虛表的內容可能不同。當你將派生類指針轉換為基類指針時,編譯器不僅需要調整指針地址,還需要確保虛表指針指向正確的虛表。
在上面的例子中,Burnable
和 Healable
都有虛函數 print
。當你調用 burnable->print()
或 healable->print()
時,編譯器會根據指針的類型(Burnable*
或 Healable*
)選擇正確的虛表和函數地址。這進一步增加了多重繼承的復雜性。
5. 如何避免指針調整問題?
指針調整是多重繼承的固有特性,但可以通過以下方式減少其帶來的問題:
-
使用單一繼承或接口式繼承:
- 如果可能,盡量使用單一繼承,或者通過虛繼承(
virtual
關鍵字)來避免重復的基類子對象。 - 使用純虛函數(抽象基類)來定義接口,減少多重繼承的復雜性。
- 如果可能,盡量使用單一繼承,或者通過虛繼承(
-
避免直接操作指針:
- 盡量避免手動轉換指針(如
static_cast
或reinterpret_cast
),讓編譯器自動處理指針調整。 - 使用
dynamic_cast
(如果需要運行時類型檢查)來確保類型轉換安全。
- 盡量避免手動轉換指針(如
-
使用組合代替繼承:
- 考慮使用組合(composition)而不是繼承。例如,將
Burnable
、Healable
和MagicUser
作為Necro
的成員變量,而不是基類。這樣可以避免復雜的內存布局和指針調整問題。
- 考慮使用組合(composition)而不是繼承。例如,將
-
明確內存布局:
- 如果必須使用多重繼承,仔細設計類的內存布局,了解每個基類子對象的偏移量,并使用調試工具(如
sizeof
和指針地址打印)驗證布局。
- 如果必須使用多重繼承,仔細設計類的內存布局,了解每個基類子對象的偏移量,并使用調試工具(如
6. 總結
-
為什么需要指針調整?
在多重繼承中,派生類對象包含多個基類子對象,這些子對象的內存地址相對于派生類對象的起始地址有偏移。當將派生類指針轉換為基類指針時,編譯器需要調整指針地址,使其指向正確的基類子對象。 -
指針調整帶來的問題:
- 性能開銷:指針調整增加了運行時指令。
- 復雜性:內存布局和虛函數調用變得更復雜,可能導致調試困難。
- 潛在錯誤:錯誤的指針操作可能導致未定義行為。
-
如何應對?
通過單一繼承、接口式繼承、組合代替繼承,或小心設計內存布局,可以減少指針調整帶來的問題。
希望這個解釋清楚地解答了你的疑問!如果還有其他問題,歡迎繼續提問。
黑板:“繼承是壓縮”
繼承可以看作是一種“壓縮算法”,它的本質是對數據結構的壓縮,而不僅僅是簡單的功能繼承。具體來說,繼承的作用是通過提取和合并常用的子配置,減少不必要的冗余,優化內存和存儲。
1. 繼承與數據壓縮
假設我們有一個實體,它包含多個屬性,數量可能非常龐大,甚至高達數百個。例如,實體可能包含位置、健康狀態、燃燒屬性、魔法能力等各種屬性。如果我們將所有可能的屬性直接放入一個巨大的結構體中,這個結構體可能會非常龐大,包含了所有可能的屬性。這樣,任何需要這些屬性的函數,都能直接從這個結構體中提取出所需的數據。
舉個例子:
struct GiantEntity {int x; // 位置int health; // 健康bool isBurnable; // 是否可以燃燒bool hasMagic; // 是否有魔法// 其他可能的屬性
};
在這個結構體中,所有可能的屬性都被包含了進去。如果我們將這個結構體傳遞給任何函數,無論該函數需要什么屬性,所有相關的屬性都已經在結構體中,可以隨時訪問。
2. 繼承的壓縮作用
而繼承的作用,就是將這個龐大的結構體“壓縮”成多個子結構體,通過繼承的方式,提取出常用的配置和組合。例如,如果一個實體只需要健康和燃燒屬性,我們可以將這兩個屬性提取成一個類,形成一個小的“壓縮結構”:
struct HealthAndBurnable : public GiantEntity {HealthAndBurnable(int health, bool isBurnable) {this->health = health;this->isBurnable = isBurnable;}
};
這里,HealthAndBurnable
只是從原本的巨大結構體中提取了健康和燃燒的屬性,其他無關的屬性(如魔法屬性)被剔除掉。這樣,不僅減少了內存的占用,還使得代碼更加清晰。
3. 繼承的靈活性與局限性
繼承使得我們能夠根據實際需求選擇需要的屬性組合,而無需為每種可能的組合都定義一個全新的類。例如,如果某個實體只需要健康屬性,可以定義一個專門的類來存儲健康,而不必添加其他不必要的功能。
然而,這也帶來了繼承的一些局限性:在 C++ 中,繼承結構在編譯時已經固定,不能動態地進行組合或修改。這意味著我們在設計時需要提前考慮好可能的所有組合,而不能在運行時根據實際情況自由地創建新的組合。如果一個實體需要臨時添加新的屬性或功能,比如一個新的魔法技能,我們就無法在運行時動態地為其“添加”這些功能,必須重新設計并編譯代碼。
4. 動態編程語言的解決方法
一些動態編程語言(如 Python)通過支持在運行時動態添加屬性和方法,來繞過這個問題。它們允許開發者在運行時靈活地為對象添加新的功能或改變對象的行為,而無需在編譯時就確定好所有可能的組合。盡管這種方式可能會帶來一定的性能開銷,但它解決了繼承在靜態語言中面臨的靈活性問題。
5. 總結
繼承的本質是對數據結構的“壓縮”,通過提取常用的組合和屬性來減少不必要的冗余,節省內存和存儲空間。它的優點在于能夠減少不必要的數據存儲,使得程序更加高效。缺點是,它要求在編譯時就確定好所有可能的組合,缺乏動態靈活性。這種靜態的設計方式雖然能夠提高性能,但也讓代碼在面對變化時變得不夠靈活。而動態語言通過在運行時允許修改對象的屬性和行為,解決了這一問題,雖然可能犧牲一定的性能。
黑板:一個極其龐大的實體結構體
我們打算嘗試構建一種非常極端的敵人系統,具體做法是:我們會設計一個巨大的 Entity
結構體,這個結構體會包含游戲中所有可能出現的屬性,大小可能達到 64KB,甚至更大。這個結構體將被稱為“過度實體”(over entity),它包含了游戲中所有可能存在的功能和數據,比如移動、血量、燃燒狀態、魔法狀態等等,任何一個實體可能擁有的屬性它都具備。
但我們不會讓每一個實體在任何時候都實際占用 64KB 內存,因為那樣太浪費,而且我們也不希望每次都處理這么大的數據結構。我們已經有了“模擬區域”(sim regions)的機制,即將實體臨時加載到一個統一的空間中進行處理,處理完再寫回去。所以我們打算利用這一機制來進行壓縮與解壓。
1. 實體解壓與壓縮流程
我們的做法是:
- 在模擬開始前,從“冷存儲”中將壓縮的數據解壓(decompress)為這個巨大的實體結構體(full
Entity
)。 - 在模擬結束后,把
Entity
再壓縮(compress)回去并寫入存儲。
這個過程就像一個稀疏矩陣處理器:解壓階段我們只填充當前需要的數據,壓縮階段我們只保留修改過或使用過的部分屬性。整個過程并不是傳統意義上的壓縮(比如 LZ 算法),而是一種屬性選擇式的壓縮機制。
我們每個實體可能有上千個屬性,但某個具體實體只會使用其中的幾十個,我們會通過位圖或標志位記錄哪些屬性被解壓或使用過,模擬過程中若添加了新屬性,也會標記出來。這樣在壓縮時我們只寫回那些實際存在的屬性,未使用的部分不會被存儲。
2. 為什么不直接用組件系統
你可能會問:為什么不采用傳統的組件系統(Component-based system),把各個屬性分開放在不同列表中,然后組合成一個實體?這不是更靈活更節省空間嗎?
我們的理由是:這樣會嚴重影響模擬代碼的執行效率。
如果采用組件系統,模擬邏輯就必須檢查某個實體是否具有某個屬性,然后再決定如何處理。這樣不僅增加了判斷邏輯,還會引入指針間接訪問、緩存命中率下降等問題,最終導致運行效率下降。
而如果我們把實體的數據都放在一個巨大的結構體中,并通過位圖控制其稀疏性,那么所有的模擬代碼都可以直接通過偏移訪問數據,不需要做任何條件判斷,也不需要查表,只要硬編碼訪問地址就可以。這種方式非常接近 C 語言風格的直接內存操作,運行效率非常高。
3. 實際運行中的流式處理機制
整個系統還與“世界區塊”(world chunks)機制結合。當我們寫入一個區塊時,會將該區塊中的所有實體壓縮寫入。當我們需要模擬該區塊時,則讀取并解壓這些實體。這個過程構成了一個流式的、稀疏的實體處理系統:
- 解壓階段只構造需要的屬性。
- 模擬代碼自由訪問整個 Entity。
- 模擬結束后,只壓縮使用或修改過的屬性。
- 通過位圖/稀疏索引保持數據的最小化存儲。
4. 結論
這種設計的核心思想是:
- 用一個超大的結構體表示一切可能的實體狀態;
- 在需要時解壓,只填充需要的數據;
- 模擬代碼直接訪問,不做任何抽象層判斷;
- 模擬結束后壓縮,僅保留使用過的部分;
- 通過流式處理和稀疏控制,兼顧運行效率與內存效率。
這種方式犧牲了一些抽象性和靈活性,但獲得了極高的性能和控制力,特別適合對 CPU 執行效率要求極高的游戲系統。
我們舉一個具體的例子,來說明這個“超大實體結構體 + 解壓/壓縮 + 稀疏標記”系統是如何工作的。
舉例:游戲中的敵人「火焰僵尸(FlameZombie)」
我們設想一個游戲敵人叫做「火焰僵尸」,它有以下幾個特性:
- 有生命值(Health)
- 可以被點燃(Burnable)
- 有當前位置(Position)
- 有動畫狀態(AnimationState)
- 沒有魔法屬性(MagicPower = 不存在)
- 沒有飛行能力(FlyingAbility = 不存在)
1. 超大結構體 Entity
我們定義一個巨大的 Entity
結構體,比如(偽代碼):
struct Entity {bool HasHealth;int Health;bool HasBurnable;float BurnTime;bool HasPosition;float X, Y;bool HasAnimationState;int FrameIndex;bool HasMagicPower;float Mana;bool HasFlyingAbility;float FlyHeight;// ...后面還有成百上千個其它可能的屬性
};
注意每個字段前都有一個 HasXXX
標志位,用于標記當前這個實體是否使用了該屬性。這就像一個稀疏矩陣中的“是否存在”標記。
2. 解壓(Decompress)
當我們要加載某個區域,模擬其中的實體時,我們讀取「火焰僵尸」的壓縮版本:
{"Health": 100,"BurnTime": 3.0,"X": 50.0,"Y": 100.0,"FrameIndex": 12
}
然后將其“解壓”進完整結構體中:
entity.HasHealth = true;
entity.Health = 100;entity.HasBurnable = true;
entity.BurnTime = 3.0;entity.HasPosition = true;
entity.X = 50.0;
entity.Y = 100.0;entity.HasAnimationState = true;
entity.FrameIndex = 12;entity.HasMagicPower = false;
entity.HasFlyingAbility = false;
// ...其它字段都初始化為 false
3. 模擬階段的訪問
游戲模擬代碼可以直接像普通 struct
一樣訪問字段:
if (entity.HasHealth) {entity.Health -= 10;
}if (entity.HasBurnable) {entity.BurnTime += deltaTime;
}
這段代碼無需做復雜的屬性查找或組件組合判斷,可以完全靜態展開,甚至直接在 SIMD 中進行處理。
4. 壓縮(Compress)
當該區域模擬完畢后,我們要將實體壓縮存儲:
我們遍歷所有屬性的標志位,發現這個實體使用了以下字段:
- Health
- BurnTime
- X, Y
- FrameIndex
于是我們只將這些寫入磁盤或存儲系統,跳過未使用的部分:
{"Health": 90,"BurnTime": 3.5,"X": 50.0,"Y": 100.0,"FrameIndex": 13
}
這樣既節省存儲,又保留了高速模擬時的完整數據訪問結構。
總結這個例子的優勢:
- 所有模擬代碼可直接訪問字段,沒有動態派發,沒有虛函數,沒有組件查詢。
- 運行階段只保留必要屬性,避免 64KB 全占用,靠標志位保持稀疏性。
- 存儲和加載時可壓縮,只保留用到的部分,便于存檔與區域流加載。
- 任意組合屬性都支持,不需要預先定義所有組合類型,避免了傳統繼承體系的爆炸增長問題。
這就是這種極端實體系統的應用場景和優勢。適合用于性能敏感、狀態多樣但變化稀疏的復雜游戲世界模擬中。
黑板:稀疏矩陣求解器
這個部分主要是在講一個概念類比:我們設計的“超大實體結構體 + 稀疏標記 + 解壓/壓縮”的系統,其思想與“稀疏矩陣求解器(sparse matrix solver)”非常相似。
我們詳細解釋如下:
類比的本質思想:
稀疏矩陣求解器的基本思路是這樣的:
- 在內存中,我們可能為一個非常大的矩陣預留了完整空間,比如說:
一個 64,000 x 64,000 的二維矩陣,理論上包含 40 多億個元素。 - 實際上,這個矩陣里大部分元素都是 0 或無效值,我們不會去填充它們。
- 我們只在需要的地方去寫入值,并且記錄我們寫入了哪些位置(例如:第 5 行第 7 列寫了一個非零值)。
存儲與訪問策略:
-
當我們要進行數值運算或直接訪問某個值(比如:獲取矩陣的第 m 行第 n 列的元素),我們仍然可以直接通過索引去訪問——因為我們在內存中已經為它們預留了完整結構,它們在正確的位置上,只是空的地方默認返回 0。
-
當我們要把矩陣存盤(或進行壓縮處理)時,我們不會寫出整個 40 億個值,而是:
- 只提取出我們記錄過的、實際寫入過的那些值。
- 所以最終的“壓縮矩陣”只包含很少的一部分內容,極大節省空間。
這個類比在我們實體系統中的應用:
我們使用類似策略設計實體系統:
1. 內存中結構體是完整的
我們定義了一個超大的結構體(比如 64K 大小),這個結構體有成百上千個字段,比如:生命值、燃燒狀態、動畫幀、坐標、魔法值、飛行高度、投擲物軌跡、對話文本、AI 狀態、任務標識符……等等。
即便這些字段幾乎不可能全部在一個實體中被用上,我們還是為它們預留好了完整的空間。
2. 使用時只激活需要的部分
某個實體如果只是個普通僵尸,只會設置其中幾個字段,比如:
- 生命值
- 坐標
- 動畫狀態
我們就只激活這幾個字段,并設置對應的 HasXxx = true
。
3. 讀取時是快速的
模擬邏輯訪問這些字段時,不需要查找、不需要組件系統調度,不需要運行時分發判斷屬性是否存在,而是直接按偏移地址讀取數據,這就像在稀疏矩陣中按位置直接讀取元素值一樣,速度極快。
4. 存儲時只壓縮活躍字段
和稀疏矩陣一樣,我們只會壓縮和存儲那些 HasXxx = true
的字段。其他字段雖然在結構體中占據空間,但它們不會被寫入,也不會影響 IO 和存儲。
總結類比的意義:
- 我們實現的結構體系統,本質上就是一個稀疏矩陣,結構體字段是矩陣元素。
- 我們通過標記哪些字段“存在”,實現了類似稀疏矩陣的稀疏性。
- 讀取和模擬邏輯保持滿速訪問,就像稀疏矩陣直接按索引讀取一樣。
- 存儲和序列化則跳過空字段,只處理實際使用的數據,節省資源。
這種設計思路就是借鑒了稀疏矩陣求解器的核心優勢:既能高效訪問,又能節省資源,非常適合復雜、動態、多樣的游戲實體系統。
黑板:計劃總結
我們計劃嘗試一種全新的實體系統設計思路,其核心是構建一個統一的、巨大的實體結構體。我們會為這個實體結構體預留出所有可能需要的屬性字段——無論是跳躍、行走、攻擊、發射子彈、噴火、施法、對話、變形等,還是其它任意游戲中可能出現的行為或狀態。所有這些能力都被整合進同一個結構體中,成為可選的組成部分。
系統架構核心流程如下:
-
世界區塊存儲機制(World Chunks):
- 游戲世界被劃分為多個區塊。
- 每個區塊在存儲時,使用“壓縮”形式保存,只保存實體當前實際用到的字段。
- 當區塊被載入進行模擬時,我們進行“解壓縮”操作,將實體恢復成完整結構體,只激活需要用到的屬性。
- 模擬完成后,再次壓縮寫回磁盤或緩存中。
-
實體結構體特點:
- 每個實體結構體尺寸巨大,假設為 64KB。
- 它包含了我們可能在游戲中使用的所有字段,即使當前實體并沒有用到它們。
- 實際運行時,只有被激活的字段才會被讀寫。
- 實體結構具有最大通用性,任何行為都可以被賦予任何實體。
靈活性舉例:
- 如果我們有一個“樹”實體,本來只是一個靜態物體;
- 某個技能施加在它身上,比如“賦予移動能力”;
- 我們只需將其結構體中的“可移動”字段激活;
- 它立刻變成一個“會走路的樹”;
- 不需要更換結構體或派生類,也不需要實體類型的變化,一切行為都通過結構體字段的激活來實現。
這就實現了最大程度的設計靈活性,允許游戲機制在運行時自由組合行為,而不受類型系統或繼承關系的限制。
系統運行中的關鍵操作:
-
解壓(Decompress):
- 從壓縮存儲中加載實體數據;
- 恢復為完整的實體結構體;
- 標記哪些字段有效,準備參與模擬。
-
壓縮(Compress):
- 將使用過或更新過的字段收集起來;
- 寫入壓縮存儲格式(節省空間);
- 用于保存或卸載區域。
這個機制類似稀疏矩陣求解器的壓縮-解壓思路。內存中保留完整結構,但壓縮與傳輸時只處理實際存在的數據,極大減少資源開銷。
潛在問題與預期風險:
雖然理論上這種方式非常靈活,但也存在一些潛在問題,我們必須在實踐中驗證其可行性:
-
解壓/壓縮過程中的數據流量:
- 如果實體數量多(如 500 個實體),壓縮與解壓頻率高,可能造成顯著的內存帶寬壓力。
-
內存占用問題:
- 即使只使用了少量字段,每個實體依舊要保留整個結構體(64KB),總體內存使用量可能過高。
-
緩存局部性問題:
- 由于結構體非常大,字段之間距離遠,可能導致緩存效率降低(所謂的“偽共享”或“false sharing”問題)。
- 不過考慮到我們是稀疏訪問的,緩存影響可能沒有表面看起來那么嚴重。
實施目標:
- 用最直接的結構體訪問方式,加快模擬代碼運行速度;
- 避免運行時的查找和調度邏輯,減少邏輯分支;
- 為游戲系統提供最大靈活性,使任何實體在任何時刻都可以擁有任意行為;
- 盡量避免繼承、類型分支、組件注冊等復雜的運行時邏輯。
最終意圖:
我們希望通過這種機制實現“極致靈活性 + 原始訪問速度”的統一。雖然這種做法還未經過驗證,但看起來沒有明顯的致命缺陷,因此值得嘗試實施并評估其實際效果。如果可行,它可能成為一種適用于大型復雜游戲項目的全新實體系統模型。
問答環節
今天的內容就到這里了,我們已經把整體的設計思路完整地梳理和講解了一遍。雖然最初并沒有特別計劃要講這些,但最終我們確實系統地說明了整個方案的核心理念與技術細節。這是一件好事,因為這意味著明天可以直接進入實際開發環節,節省時間,提高效率。
我們已經清晰地闡述了如下要點:
1. 實體系統的基本框架
- 構建一個包含所有可能字段的超大實體結構體;
- 所有的功能行為都通過這個結構體中的字段開關來控制;
- 實體不再依賴于類型劃分或繼承層次;
- 所有行為可以動態組合,具備完全的靈活性。
2. 壓縮與解壓機制
- 從磁盤或內存中加載實體數據時進行解壓,生成完整結構體;
- 模擬運行后,再次壓縮,僅存儲被使用或修改過的字段;
- 達到內存效率與模擬性能的平衡。
3. 與稀疏矩陣對比
- 結構體就像稀疏矩陣中的稠密內存區域;
- 實際只有少部分字段被訪問或修改;
- 類似于稀疏矩陣的索引方式,僅記錄活躍字段,避免資源浪費;
- 模擬階段仍然可以實現快速、直接的內存訪問。
4. 風險與權衡
- 潛在的內存壓力:每個實體占用大空間;
- 解壓與壓縮可能帶來數據流量瓶頸;
- 緩存效率受結構體體積影響可能降低;
- 但由于訪問稀疏、操作集中,實際影響可能遠小于理論值。
5. 總體目標
- 用最簡單的結構體直接映射所有實體狀態與行為;
- 模擬代碼在運行時無需判斷類型、查找組件或分派函數;
- 以犧牲一定內存換取最大靈活性與極致模擬性能;
- 建立一個可以支持任意行為組合的游戲運行基礎。
接下來的步驟將圍繞具體實現展開。我們將從存儲模塊著手,建立世界區塊的壓縮/解壓流程,然后構建實體模擬所需的接口與訪問方式。今天的講解奠定了堅實的理論基礎,明天可以正式進入實際開發階段。我們將繼續推進。
你能講講動態調度嗎?
我們現在來談談動態分派(dynamic dispatch),雖然之前主要關注的是數據層面的設計,但分派機制也是整個架構中不可忽視的一部分,它關系到如何調用行為、如何組織邏輯代碼以及系統運行的效率。
什么是動態分派?
動態分派的本質是一種運行時函數選擇機制。當我們有一組不同種類的實體,它們可能在邏輯上都需要“執行某個動作”,但這個動作的實現方式不同。動態分派允許我們在運行時根據具體的對象或實體類型,自動選擇并調用對應的實現函數。
我們為何需要動態分派?
在構建一個具備高靈活性和擴展性的游戲系統時,我們不希望寫一堆冗長的 if
或 switch
判斷去硬編碼每種類型的行為。特別是當我們構建一個超大結構體,允許所有實體行為共存時,我們更希望行為是“附著”在數據上的,而不是通過大量條件判斷來切換。
例如:
- 我們希望“如果實體具備
burnable
屬性,就可以對其進行燃燒處理”; - “如果實體具備
health
屬性,就可以接收傷害”。
這些行為的執行邏輯,應由屬性驅動,而不是類型驅動。
動態分派如何實現?
通常有以下幾種方式:
1. 虛函數表(vtable)機制(如 C++ 中的多態)
- 每個對象維護一個函數指針表;
- 調用方法時,通過該表的索引找到對應函數;
- 缺點是需要明確的繼承體系,不適合我們這種扁平、混合結構。
2. 函數指針字段
- 在我們的實體結構中,某些行為可以用函數指針字段表示;
- 比如
onDamage
、onUpdate
等; - 賦值時綁定具體函數,實現自由組合;
- 優點是靈活、輕量、無需繼承;
- 缺點是函數簽名必須統一。
3. 查表執行(函數查找表 + 數據驅動)
- 用屬性或標志位作為 key,在一個全局或局部的查找表中查詢對應的處理函數;
- 比如,
if (hasBurnable) -> dispatch(burnFuncTable)
; - 這種方式適合我們這種稀疏屬性設計,因為行為是“屬性驅動”的。
動態分派 vs 性能優化
使用動態分派可能帶來的問題是分支預測失敗或函數指針跳轉引發指令緩存命中率下降,不過我們當前的架構有天然優勢:
- 因為只會對活躍字段進行訪問,大多數情況下行為分派是稀疏且有明確指向的;
- 每種實體行為調用的函數也會相對集中,不會出現極其發散的調用路徑;
- 可以通過批處理或行為分組進一步減少動態分派帶來的開銷。
總結
我們當前的架構允許實體以最通用的方式表示,也就是說一個實體可以在運行時具備任意屬性集合。基于這種架構,傳統的類繼承和虛函數體系已無法適用,因此我們轉而采用屬性驅動 + 函數表 + 稀疏訪問的方式進行動態分派。
這種分派機制不再關心“這是什么類型”,而只關注“這個實體當前具備哪些功能”,并據此執行操作,真正實現了行為的組合式、數據驅動式設計。
這種設計同時兼顧了:
- 性能(直訪數據,無額外判斷);
- 靈活性(行為可組合、可切換);
- 可擴展性(添加新行為無需改動其他部分)。
后續如果有實際落地,也可以根據調試結果進一步選擇是否優化為靜態表、JIT 編譯、內聯緩存等更高級的分派機制。我們將視實際運行情況靈活調整。
黑板:調度
我們現在來詳細講解 動態分派(Dynamic Dispatch) 的核心思想和具體實現方式。
動態分派的核心目的
本質上,我們希望實現的是:根據某個內存位置的數據內容,來決定執行哪個函數。
這與常規做法相反——通常我們是調用一個已知函數,并傳入數據。而動態分派的做法是:我們先有數據,然后根據數據內容來“反推出”應當執行的函數。
傳統靜態調用 vs 動態分派
比如我們有如下兩種結構體:
struct Foo {// ...
};
struct Bar {// ...
};
我們可能寫了兩個函數:
void DoFoo(Foo* f);
void DoBar(Bar* b);
在程序中,當我們知道自己拿到的是 Foo
或 Bar
時,可以明確地調用 DoFoo()
或 DoBar()
。
但有些場景中,我們希望不提前知道數據的類型,而是通過數據本身來決定調用哪個處理函數。我們不想手動寫一堆 if/else,而是希望數據自己帶著“該怎么處理自己”的信息。
如何實現動態分派(函數與數據綁定)
可以通過在結構體中加入一個函數指針來實現:
struct Foo {void (*doFunc)(Foo*); // 函數指針// 其他成員
};
這樣,每個 Foo
實例就可以攜帶自己的行為函數。例如:
void FooHandler(Foo* f) {// 做一些事情
}Foo f;
f.doFunc = FooHandler;
f.doFunc(&f); // 調用函數,無需關心具體類型
這種方式的好處在于:我們把“數據”和“處理該數據的邏輯”放在了一起。每個內存塊都可以攜帶一個“行為指針”,當我們想對這個數據進行操作時,只需要調用這個函數指針即可。
多種結構共用分派機制
我們可以繼續擴展這個思路,比如說:
struct Foo {void (*doFunc)(void*); // 函數簽名統一,參數為 void*int a;
};struct Bar {void (*doFunc)(void*);float b;
};
現在,無論是 Foo
還是 Bar
,只要我們約定函數指針在結構體的首部(第一個字段),那么我們可以不關心具體類型,只要知道該結構體的開頭就是一個函數指針即可:
void DoGeneric(void* p) {void (**func)(void*) = (void (**)(void*))p;(*func)(p);
}
這樣,就可以實現統一入口,執行時根據數據本身攜帶的函數指針進行處理,實現面向行為的分派。
應用場景與適用范圍
- 在需要高擴展性的系統中,例如實體系統、插件系統等;
- 當數據類型可能非常多、行為可以動態綁定時;
- 不希望使用復雜的面向對象體系結構時,采用純 C 風格處理方式;
- 想把處理邏輯和數據打包成一體、提高模塊獨立性和封裝性時。
動態分派的簡明總結
- 將函數指針放在結構體中,實現函數和數據的綁定;
- 通過訪問結構體中的函數指針,實現按需調用;
- 如果函數指針作為結構體第一個字段,可以實現通用調用機制;
- 適合處理類型不確定但行為確定的系統邏輯;
- 不依賴類繼承或虛函數機制,是一種輕量級且高效的動態邏輯切換方式。
這就是動態分派的基本機制與應用方式。通過這種機制,我們可以讓每塊數據都擁有自己的行為定義,在執行時自動完成正確的行為調用。如此一來,無需手動判斷類型或構建復雜分支,整個系統的設計也更加清晰和高效。
黑板:C++的動態調度實現
我們在設計系統中使用動態分派,核心目的是希望能夠根據具體的數據對象,在運行時動態選擇并調用相應的處理函數。而我們認為,C++ 對于動態分派的實現是極其糟糕的,其設計放棄了大量靈活性,只保留了一點點壓縮空間的優勢,導致其實際用途極為有限。下面詳細解釋。
動態分派的本質
動態分派的本質是:我們有一個數據結構,希望能在運行時為這個結構動態決定該調用哪個函數。這與傳統調用方式相反,傳統方式是我們在代碼中明確指定調用哪個函數,然后傳入數據,而動態分派是數據中“自帶”它該執行的函數。
這類做法在手寫結構體時非常自然,比如我們在結構體中添加一個函數指針:
struct Foo {void (*Do)(Foo*);
};
這樣每個對象都可以獨立設置自己的 Do
函數指針,實現真正的動態行為。
C++ 動態分派的做法(虛函數)
C++ 使用虛函數(virtual function)來實現動態分派,其做法是:
- 每個含有虛函數的類,編譯器會為它生成一個靜態虛函數表(vtable);
- 每個對象實例的前面,會隱式保存一個指向該表的指針;
- 調用虛函數時,會通過這個表指針查找對應的函數地址,并跳轉執行。
聽上去似乎也做到了動態分派,但其實存在非常多的問題:
C++ 實現的問題和限制
-
不能訪問或修改虛表指針
無法直接訪問對象內部的虛表指針,也無法修改它。我們不能隨時替換對象的行為邏輯,也不能在運行時切換不同的 vtable。 -
無法為每個函數獨立切換行為
vtable 是整個函數表的集合,不能只修改其中某個函數的指針。只能整體切換,不支持更細粒度的控制。 -
不支持每個實例動態定義自己的行為
所有同一類型的實例都共享同一個 vtable,無法讓兩個實例具有不同的動態行為,完全喪失了動態性。 -
調度效率反而更低
調用虛函數需要做一次間接跳轉,也就是雙重指針解引用;同時,由于所有函數集中在一個表里,還可能引入緩存失效或局部性降低等性能問題。 -
語義被語言語法鎖死
所有行為都綁定在類定義中,運行時缺乏靈活性。我們不能根據運行條件動態改變對象的行為,這嚴重限制了系統的表達能力。
我們自定義實現的優勢
相對 C++ 的機制,我們手動在結構體中添加函數指針的方式擁有巨大的靈活性:
-
每個對象實例可獨立設置行為函數
每個對象的函數指針都是獨立的,隨時可以變更,完全支持運行時動態切換。 -
可自由組合行為函數
可以設置多個函數指針字段,例如Do
,Draw
,Update
等,各自可以綁定不同邏輯,互不影響。 -
可以動態切換對象“類型”
可以通過切換多個函數指針或表指針,讓對象在運行時“變形”,擁有不同的行為集合。 -
更清晰、直觀的控制模型
不依賴編譯器黑盒操作,行為綁定邏輯直接體現在結構體和初始化代碼中,更易于調試和理解。 -
無需繼承體系
不需要建立復雜的類繼承關系,單一結構體配合函數指針就可以實現所有多態特性,適用于更廣泛的場景。
C++ 的設計選擇:以“壓縮”為優先
可以推測,C++ 當初選擇 vtable 機制的初衷,可能是為了節省空間:
- 多個對象共享一個 vtable,節省內存;
- 所有虛函數集中管理,便于類型靜態推導。
但為此犧牲了動態性、靈活性、可控性、可擴展性,甚至降低了執行效率。我們認為這種折中方式是極其不值得的。
我們的建議與做法
我們明確選擇:不使用 C++ 的虛函數機制,而是自己在結構體中定義函數指針。這樣做可以獲得更高的動態性、更大的行為表達能力以及更符合需求的控制結構。
- 結構體首部放置行為函數;
- 根據對象類型、狀態,動態賦值;
- 可根據實際運行狀態修改行為邏輯;
- 支持混合、切換、合并不同行為邏輯集;
- 用一套極簡方式,替代語言級別的復雜特性。
總結
C++ 的虛函數機制本質上是一種受限的動態分派機制,幾乎是最弱的一種實現方式。我們棄用它的原因在于:
- 無法動態修改;
- 不支持對象級別的行為配置;
- 效率與靈活性都差;
- 難以擴展,不利于復雜系統架構設計。
我們更傾向于手動構建函數指針機制,它能滿足系統在動態行為、可控性與擴展性方面的全部需求,具有更強的實用價值。
黑板:為什么我們的系統不會使用動態調度
我們基本不會在這個系統中使用動態分派,原因非常明確:我們的實體系統追求的是一種極度靈活、可變、非結構化的設計方式。我們并不希望通過傳統的面向對象方法將實體類型事先定義清楚,比如“這個實體是主角”“那個實體是某種敵人”。相反,我們的目標是讓實體的本質完全模糊,它們只不過是64KB空間中散布著的一堆參數。
實體的設計哲學:極致的靈活性
我們的設計思路是:
- 實體僅由其擁有的參數定義;
- 這些參數以分散的方式存儲在內存中;
- 渲染時,我們從這些參數中提取可視化信息;
- 在任何階段,我們都不需要也不想知道這個實體“是什么”。
這意味著我們拒絕使用任何固定的類型分類系統。一旦我們引入了子類或函數指針來定義行為,就等于鎖定了實體的“類型”,從而喪失了流動性與可變性。我們追求的是:
- 每個實體都可以演化為任何其他類型的實體;
- 每個屬性都可以互相組合、融合;
- 整個系統像一個“熔爐”,沒有硬性的邊界。
目標:構建一個“萬物可變”的系統
我們愿意為此目標付出代價:
- 可以接受代碼更難寫;
- 可以接受調試更復雜;
- 可以接受系統在初期階段的低效。
因為我們相信,通過后續優化,這些問題是可以逐步解決的,但靈活性和高度動態性必須優先確保。我們追求的是一個**“神圣的動態結構”** —— 任何時候,任何實體都可以改變自己的特性,擁有不同的表現或行為,而不依賴于事先定義好的類型或邏輯綁定。
動態分派為何不適合我們的系統
動態分派,比如虛函數或者函數指針的方式,會將行為靜態綁定在某個結構上:
- 它要求事先定義某些函數;
- 然后通過函數指針或虛函數表來調用這些函數;
- 這就等于說“這個對象必須知道自己是個什么”。
這和我們要的完全背道而馳。
在我們系統中,不需要讓實體知道自己是誰,它只需要在被使用時暴露出自己所擁有的參數即可。我們希望系統可以:
- 基于多個屬性綜合判斷;
- 通過邏輯推導決定該做什么;
- 而不是直接跳轉到某個綁定好的函數。
取而代之的機制:枚舉 + 邏輯組合
我們更傾向于使用枚舉值配合邏輯判斷來驅動行為選擇:
- 每個實體擁有多個屬性(通過枚舉編碼);
- 系統根據多個屬性的組合判斷該執行什么行為;
- 例如判斷它的“移動類型”“動畫類型”等,然后推導出該怎么渲染、怎么響應輸入等。
這種做法雖然比直接調用函數指針更復雜,但它允許我們跨多個維度組合信息,從而生成行為,而不是依賴一對一的綁定。
舉例說明:
if (entity.move_type == WALK && entity.form == HUMANOID) {draw_legs(entity);
} else if (entity.move_type == FLY && entity.form == INSECT) {draw_wings(entity);
}
多維組合的優勢
使用這種模式我們可以獲得:
- 多態行為無需繼承結構;
- 行為可以根據任意數量的狀態組合變化;
- 運行時可以輕松添加新的組合邏輯而無需修改結構定義;
- 更貼近真實游戲邏輯中的非線性多樣性。
而這些是傳統函數指針機制難以表達的。
總結
我們放棄動態分派的根本原因在于:
- 它綁定行為于類型,限制了實體的可變性;
- 它不適合我們的“融合型”實體系統設計;
- 它無法支持任意組合屬性推導行為的邏輯流程;
- 它將“對象是誰”視為前提,而我們只關心“它現在該干什么”。
我們選擇了一種更加原始但也更強大的方式,通過枚舉、數據組合與邏輯判斷來驅動實體行為。這種方式盡管更難實現,但它帶來了真正的自由,是我們實現極度動態、混合、可塑實體系統的核心手段。
你在當前游戲中使用結構體條目方法嗎?
我們當前的游戲中還沒有實現任何實體系統。這意味著在目前的階段,游戲并沒有包括角色、敵人、物體等概念上的“實體”。換句話說,游戲世界中還沒有一個被定義為“實體”的對象存在。
當前階段的開發重點
我們暫時還沒有進入到需要實體結構的那一部分開發流程,當前關注的可能更多是底層系統、渲染機制或工具鏈的構建。因此:
- 沒有設計或實現“實體”所需要的數據結構;
- 沒有定義實體行為的邏輯代碼;
- 也沒有采用或開發任何和實體相關的系統框架,比如組件系統或繼承體系。
未來可能涉及的方向
雖然目前沒有實體,但未來一旦進入游戲邏輯層面的開發,我們可能會考慮構建一個符合我們設計理念的實體系統:
- 可能會是無類型、動態、靈活的實體結構;
- 每個實體通過屬性集合進行定義,而非通過類型分類;
- 使用統一的數據結構和邏輯判斷來驅動行為和渲染;
- 避免傳統“實體=類+行為”的設計,改為高度解耦的參數系統。
小結
當前的游戲尚處于前期架構搭建階段,還未進入涉及實體的開發工作,因此尚未實現或使用任何“實體”相關的機制或方法。未來一旦需要引入實體,將會按照我們此前確立的“極度靈活、參數驅動”的設計理念進行構建。
你見過我在直播中使用的偽黑客判別聯合繼承系統嗎?它非常酷
Discriminated union 這個不知道怎么翻譯
在游戲的開發過程中,有提到一種偽黑客的可區分聯合繼承系統。這種系統在某些情況下會被使用,而且似乎和**可區分聯合(Discriminated Union,簡稱DU)**有關。可區分聯合是一個非常強大的結構,能夠讓數據類型在運行時基于不同的條件進行選擇,從而適應不同的情境。
目前來看,這種方式在開發中可能得到了廣泛的應用,比如在游戲中,99% 的代碼都涉及到了使用可區分聯合的模式。這種設計理念非常適合用于處理不同的實體或對象類型,尤其是在靈活性和類型安全性之間做出平衡時。
雖然具體的偽黑客可區分聯合繼承系統細節未被詳細描述,但從已知信息來看,這種方法可能強調了靈活性和類型的動態管理。選擇這種方式的原因可能是為了減少傳統面向對象編程中的復雜繼承結構,避免過于僵化的類型約束。
Discriminated Unions(也稱為 Tagged Unions 或 Sum Types)是一種數據結構,用于表示一個值可以是多種不同類型中的一種,并且通過一個“標簽”(discriminator)來區分具體是哪種類型。它在編程語言中常用于類型安全地處理多種可能的變體,常見于函數式編程語言(如 Rust、Haskell、OCaml)以及一些支持高級類型系統的語言(如 TypeScript)。
在 C++ 中,Discriminated Unions 通常通過 std::variant
(C++17 引入)或手動實現的結構體/枚舉組合來實現。它的核心思想是:
- 存儲一個值,這個值可以是幾種類型之一。
- 提供一個標識(標簽),指示當前存儲的是哪種類型。
- 確保類型安全,避免錯誤地訪問不正確的類型。
Discriminated Union 的特點
- 互斥性:在一個 Discriminated Union 中,值在任意時刻只能是其可能類型之一。
- 類型安全:通過標簽或類型檢查,確保訪問的值與當前類型匹配。
- 高效性:內存占用通常是所有可能類型的最大尺寸加上標簽的開銷。
舉例說明
以下通過幾個例子展示 Discriminated Unions 在不同語言中的實現。
1. C++ 中的 Discriminated Union(使用 std::variant
)
假設我們需要表示一個值,它可以是 int
、 double
或 std::string
中的一種。我們可以使用 std::variant
實現:
#include <iostream>
#include <variant>
#include <string>int main() {// 定義一個 Discriminated Union,可以存儲 int、double 或 std::stringstd::variant<int, double, std::string> value;// 存儲一個 intvalue = 42;std::cout << "Value is int: " << std::get<int>(value) << std::endl;// 存儲一個 doublevalue = 3.14;std::cout << "Value is double: " << std::get<double>(value) << std::endl;// 存儲一個 stringvalue = std::string("Hello");std::cout << "Value is string: " << std::get<std::string>(value) << std::endl;// 使用 std::visit 安全地訪問值std::visit([](const auto& v) {std::cout << "Current value: " << v << std::endl;}, value);// 錯誤訪問示例(會拋出異常)try {std::get<int>(value); // 此時 value 是 string,拋出 std::bad_variant_access} catch (const std::bad_variant_access& e) {std::cout << "Error: " << e.what() << std::endl;}return 0;
}
輸出:
Value is int: 42
Value is double: 3.14
Value is string: Hello
Current value: Hello
Error: bad variant access
解釋:
std::variant<int, double, std::string>
是一個 Discriminated Union,value
可以存儲int
、double
或std::string
中的一種。std::variant
內部維護一個索引(標簽),記錄當前存儲的是哪種類型。- 使用
std::get<T>
或std::visit
訪問值,確保類型安全。如果訪問錯誤的類型,會拋出異常。 - 這避免了傳統 C 風格的
union
的不安全性(傳統union
不會跟蹤當前類型,容易導致未定義行為)。
2. Rust 中的 Discriminated Union(使用 enum
)
Rust 的 enum
是 Discriminated Union 的典型實現,廣泛用于類型安全的變體處理。假設我們想表示一個形狀,它可以是圓形、矩形或三角形:
#[derive(Debug)]
enum Shape {Circle { radius: f64 },Rectangle { width: f64, height: f64 },Triangle { base: f64, height: f64 },
}fn area(shape: &Shape) -> f64 {match shape {Shape::Circle { radius } => std::f64::consts::PI * radius * radius,Shape::Rectangle { width, height } => width * height,Shape::Triangle { base, height } => 0.5 * base * height,}
}fn main() {let circle = Shape::Circle { radius: 2.0 };let rectangle = Shape::Rectangle { width: 3.0, height: 4.0 };let triangle = Shape::Triangle { base: 3.0, height: 5.0 };println!("Circle: {:?}", circle);println!("Circle area: {}", area(&circle));println!("Rectangle: {:?}", rectangle);println!("Rectangle area: {}", area(&rectangle));println!("Triangle: {:?}", triangle);println!("Triangle area: {}", area(&triangle));
}
輸出:
Circle: Circle { radius: 2.0 }
Circle area: 12.566370614359172
Rectangle: Rectangle { width: 3.0, height: 4.0 }
Rectangle area: 12
Triangle: Triangle { base: 3.0, height: 5.0 }
Triangle area: 7.5
解釋:
Shape
是一個 Discriminated Union,包含三種變體:Circle
、Rectangle
和Triangle
。- 每個變體可以攜帶不同類型的數據(例如,
Circle
攜帶radius
,Rectangle
攜帶width
和height
)。 match
表達式用于安全地處理每個變體,編譯器會確保你處理了所有可能的變體(窮盡性檢查)。- Rust 的
enum
提供類型安全和內存效率,標簽(表示當前是哪種變體)由編譯器自動管理。
3. TypeScript 中的 Discriminated Union
TypeScript 使用帶有“標簽字段”的聯合類型來實現 Discriminated Union。假設我們想表示不同的消息類型:
type Message =| { kind: "text"; content: string }| { kind: "image"; url: string; caption?: string }| { kind: "error"; code: number; message: string };function processMessage(msg: Message) {switch (msg.kind) {case "text":console.log(`Text message: ${msg.content}`);break;case "image":console.log(`Image URL: ${msg.url}, Caption: ${msg.caption ?? "None"}`);break;case "error":console.log(`Error ${msg.code}: ${msg.message}`);break;}
}const textMsg: Message = { kind: "text", content: "Hello, world!" };
const imageMsg: Message = { kind: "image", url: "http://example.com/img.jpg" };
const errorMsg: Message = { kind: "error", code: 404, message: "Not found" };processMessage(textMsg);
processMessage(imageMsg);
processMessage(errorMsg);
輸出:
Text message: Hello, world!
Image URL: http://example.com/img.jpg, Caption: None
Error 404: Not found
解釋:
Message
是一個聯合類型,包含三種變體,每種變體通過kind
字段(標簽)區分。kind
字段充當 Discriminator,用于在運行時確定具體是哪種變體。- 使用
switch
或if
語句根據kind
處理不同的變體,TypeScript 的類型系統確保類型安全。 - TypeScript 的類型檢查會提示你處理所有可能的
kind
值。
Discriminated Union 與普通 Union 的區別
在 C/C++ 中,傳統的 union
也可以存儲多種類型之一,但它不安全,因為它不跟蹤當前存儲的類型。例如:
union UnsafeUnion {int i;double d;char* s;
};
UnsafeUnion
可以存儲int
、double
或char*
,但程序員需要手動跟蹤當前類型。- 如果錯誤地訪問了不正確的類型(例如,將
double
解釋為int
),會導致未定義行為。
相比之下,Discriminated Union(如 std::variant
或 Rust 的 enum
)通過標簽或類型系統確保類型安全,防止錯誤訪問。
總結
- Discriminated Union 是一種類型安全的聯合類型,通過標簽區分值的具體類型。
- 它在 C++ 中通過
std::variant
實現,在 Rust 中通過enum
實現,在 TypeScript 中通過帶標簽的聯合類型實現。 - 優點:類型安全、內存高效、表達力強,特別適合處理多種變體的場景。
- 應用場景:狀態機、消息處理、形狀計算、錯誤處理等。
為什么選擇這種方法,而不是AoS風格并在主實體結構體中存儲每個組件的索引?
這種方法比起 AO S(Array of Structures)風格以及每個組件在主要實體結構中的索引更具優勢。之所以不選擇這種方式,是因為希望代碼中的所有內容都能有硬編碼的偏移量,即所有的內存位置都是固定的。這意味著,所有代碼都將是純C代碼,能夠讓編譯器對其進行充分優化,直接在一塊連續的內存區域內進行操作,從而提高性能。如果理解了這一點,就能明白為什么會采用這種方法。
我錯過了。矩陣[i,j]是什么?我有點迷糊
在矩陣操作中,很多時候需要查看特定的元素,例如“我上面那個元素是什么”或者“我在矩陣中的鏡像元素是什么”。如果矩陣的存儲方式是稀疏存儲,只記錄那些非零元素的位置,那么每次查詢這些元素時就需要不斷地查找或掃描存儲結構,這兩者的效率都很低。因此,在過去,通常采用的做法是,創建一塊大的內存塊,把矩陣元素存放進去,并且初始化為零。然后,在進行操作時,把需要的元素放入內存塊中,操作完畢后再將它們取出。這樣做的好處是,可以直接進行查找,就像是為矩陣元素創建了一個完美的哈希表,從而提高了查找效率。
你會在什么地方檢查你的‘超級結構體’是否存在某個屬性?如果未定義,它們會是空指針嗎?
在進行操作時,可以通過檢查“mega struct”中的某個屬性是否存在來判斷。如果屬性存在,它的值是已定義的,而不是一個指針。所有數據都會直接存儲在結構體內部,并且每個屬性都會初始化為一個默認值。例如,如果某個屬性表示“是否著火”,其初始值為零,表示沒有火。當查詢該屬性時,會始終得到一個值,表示它是否著火。這種方式確保了每個屬性都有一個固定的值,無論它是否處于某個特定狀態。
你打算用什么來追蹤哪個結構體成員已被觸及或重新設置為空?
關于是否追蹤結構體成員是否被修改,目前還不確定,可能需要在開發過程中進行一些測試。根據測試的結果,如果性能允許,可能不會做任何優化,仍然會掃描整個64K的內存塊。雖然完全掃描可能會比較慢,但考慮到現代計算機的處理速度,這個過程并不會像預期的那樣緩慢。當開發進一步深入,開始關注優化問題時,可能會考慮根據實際需求來決定如何追蹤哪些部分已經被修改。
你預見到這個設計的哪些部分可能會造成性能問題?
一種潛在的問題是內存帶寬的浪費,尤其是在緩存利用方面。如果一個結構體被劃分為64字節的塊進行存儲,處理時每次訪問某個特定成員(例如一個4字節的變量)時,實際上需要加載整個64字節的塊進緩存。這樣即使只使用了4字節,其他60字節也會被加載到緩存中,造成內存帶寬的浪費。這種結構的優化效果取決于如何安排結構體成員以及成員的稀疏程度。如果結構體的內存布局不理想,可能會出現大量無用的內存帶寬被占用。
這種問題的影響并非致命,因為通常我們不會處理成千上萬的實體,通常只會處理當前視野內的幾個實體。例如,可能需要處理300個實體每秒60幀,或者30幀每秒的場景。這意味著如果內存帶寬的浪費比較嚴重,處理仍然可能保持在可接受的范圍內。然而,隨著實體數量的增加,尤其是在大型游戲中,內存帶寬的浪費可能變得不那么可忽視。尤其是在上千個實體的情況下,如果占用了過多的內存帶寬,可能會影響性能,甚至變得不太舒服。
雖然這種方式帶來的內存帶寬浪費是一個潛在問題,但考慮到實體數量相對較少,這個問題并不是致命的,仍然可以嘗試。但也不能掉以輕心,不能完全忽視這種可能導致問題的風險。
實體系統的替代方案有哪些?
在游戲開發中,實體系統幾乎是不可避免的,尤其是在涉及到具有多個可交互對象的游戲中。即使是比較簡單的游戲,例如經典的《吃豆人》,也可以看作擁有某種實體系統。雖然游戲的代碼可能只是簡單地處理幾個固定的幽靈,但這些幽靈在游戲中作為獨立的實體存在,并且它們之間可能會有碰撞、互動等操作。這種方式就是一個簡單的實體系統。
傳統的動作游戲通常都會依賴于實體系統,因為這些游戲的核心就是管理和操作這些獨立的實體。無論是射擊、碰撞還是其他交互,都會依賴實體系統來處理這些元素。
然而,并不是所有的游戲都需要實體系統。例如,一些類似“行走模擬器”的游戲,它們的核心玩法并不依賴于實體之間的交互,更多的是關于環境的探索、敘事的推進或者某種靜態世界的沉浸感。在這些游戲中,實體的概念可能就不再重要。
同樣,像《我們1935年》這類以電影風格呈現的互動游戲,玩法本質上是通過與對話互動來推進劇情,這時實體的作用也幾乎不存在。沒有需要進行碰撞檢測或實體之間交互的需求,更多的是通過敘事和視覺效果來進行互動。
總之,實體系統對于大多數傳統的動作游戲至關重要,但對于一些特定類型的游戲(如行走模擬器、互動電影等),則可能并不需要實體系統。
你是否已經在考慮備份計劃,并且如果必須轉換到該系統,你會如何進行?
目前并沒有具體的備份計劃來應對實體系統可能出現的問題。通常來說,如果系統出現問題,比如緩存問題,或者其他類型的問題,開發人員會通過監測來識別問題的發生位置,并據此進行調整和優化。系統的調整將會根據實際問題的性質來進行,解決方案會根據問題的發生情況逐步形成。
目前,游戲并沒有追求超高的處理能力,因此不太擔心系統在高負載下出現無法解決的問題。即便出現問題,預期中也可以通過某些方法解決。至于最終系統的具體形態,還不確定,因為無法完全預測各種權衡和選擇會如何影響系統的表現。因此,現階段并沒有預先確定一個最終的備份計劃,而是準備根據實際情況進行調整。
你玩過街頭霸王嗎?你最喜歡哪個角色?
并不玩《街頭霸王》,而是更喜歡《真人快打》,尤其是《真人快打1》。對于后來的《真人快打》系列,雖然玩過《2》和《3》,但并沒有繼續玩下去。喜歡最初的《真人快打》,但對于《街頭霸王》及其后續版本并不感興趣。原因之一是無法忍受動畫暫停效果,尤其是在格斗游戲中,當某個角色進行上勾拳時,動畫會突然暫停然后繼續,這種效果讓人無法接受,影響了游戲體驗。
因此,《街頭霸王》這款游戲的感覺并不對胃口,盡管它更受歡迎,也未能讓人真正沉迷其中。相反,《真人快打1》在自己的家鄉和周邊地區玩得非常輕松,幾乎可以成為當地的最佳玩家,而《街頭霸王》由于玩家數量更多,可能更難達到頂尖水平,因此并未投入太多精力去嘗試。
實體系統會包括像粒子特效這樣的東西嗎?
敵人系統包括了粒子效果等內容。粒子效果通常是圖形效果,它們通常會被觸發或生成,但并不由敵人系統來處理。這些粒子效果通常是為了高效的吞吐量而設計的,并且它們沒有可變的屬性。比如,如果某個物體生成了火焰粒子效果,那么這些火焰粒子一旦生成,就不會自行變成水等其他形態,任何變化都必須由外部因素觸發或修改。因此,粒子效果在敵人系統中僅用于生成,而不是進行復雜的屬性變動或處理。
你玩過炸彈人嗎?
有些人會讓人感到特別難以忍受,尤其是那些表現得過于甜美或虛偽的人。他們常常通過表面上的友好和甜言蜜語來掩飾自己真實的意圖或者行為。這種行為往往讓人感到不真實,甚至讓人產生一種被利用的感覺。雖然表面上他們可能表現得非常熱情和善良,但往往背后卻隱藏著某些不為人知的動機。對這些人來說,盡管外表的甜美和親切可能會讓一開始接觸的人產生好感,但隨著深入了解,往往會發現他們并不真誠,這種虛偽的行為使得與他們相處變得非常困難和不愉快。
你受不了街頭霸王的暫停,但卻喜歡惡搞戰士的旋轉畫面?
關于游戲的外觀和感覺,雖然《街頭霸王》和《真人快打》這兩款游戲在外觀上各有不同,但實際上它們的吸引力并不完全取決于視覺效果,而是游戲的操作感和流暢度。對于《街頭霸王》,雖然它的畫面更具吸引力,某些人可能會覺得它的外觀更為精致,但其中的“動畫暫停”設計是一個致命的缺點,尤其是在執行某些特殊動作時,角色動作會突然暫停,這種設計破壞了游戲的流暢感。而《真人快打》盡管視覺效果不如《街頭霸王》,但它的操作感更加連貫,不會因動畫暫停而中斷,給人一種更流暢、更緊湊的戰斗體驗,這使得《真人快打》在操作體驗上要優于《街頭霸王》。雖然這兩款游戲的畫面風格各有千秋,但最重要的還是游戲的手感和連貫性,尤其是在戰斗過程中,暫停的設計直接影響了游戲的流暢性和玩家的沉浸感。
是的,這看起來合理,的確很少會有300個實體需要60FPS的模擬,除非你說的那種子彈地獄,子彈作為實體,而不是作為獨立優化的對象
在考慮游戲中實體的數量時,雖然一開始聽起來可能覺得 300 個實體在 60 幀每秒的模擬中不太可能實現,但實際上,整個游戲世界是由大量實體組成的。例如,每個地面元素也可以視作一個實體,所以如果在一個 17x9 的瓦片區域,每個瓦片上有兩個實體(一個是瓦片本身,另一個是站在其上的物體),那么就已經有 306 個實體。而如果每個實體還配有一個子彈,可能就有 600 個實體,這個數量在游戲中是合理的。
因此,如果想要進行壓力測試,測試 1000 個實體在 60 幀每秒的情況下是有可能的。如果我們考慮到 60,000 個實體,這其實并不會消耗非常多的計算周期。假設使用的是一臺 3 GHz 的機器,并且要保持每秒 60 幀,那么每個實體所需的計算周期其實并不多,盡管如此,這依然是一個值得注意的因素。由于游戲是 GPU 加速的,圖形處理方面的工作負擔不大,這有助于緩解一些壓力,但每個實體大約需要 50,000 個計算周期,這并不是一個非常充裕的數字,因此仍然需要保持警覺。
總的來說,雖然看起來這些計算量不會引發嚴重問題,但仍然存在一定的不確定性,因此需要在實際開發中進行充分的測試和優化,以確保游戲能夠順利運行。