Unity 與 MVC:如何升級您的遊戲開發
已發表: 2022-03-11初學者通常從經典的Hello World
程序開始學習交易。 從那裡開始,必將接踵而至的是越來越大的任務。 每一個新的挑戰都會給我們一個重要的教訓:
項目越大,意大利面就越大。
很快,很容易看出,無論是大團隊還是小團隊,都不能隨心所欲地肆意妄為。 代碼必須維護,並且可能會持續很長時間。 您工作過的公司不能只查找您的聯繫信息並在每次他們想要修復或改進代碼庫時詢問您(您也不希望他們這樣做)。
這就是軟件設計模式存在的原因; 他們強加簡單的規則來規定軟件項目的整體結構。 它們幫助一個或多個程序員分離大型項目的核心部分並以標準化的方式組織它們,從而在遇到代碼庫的某些不熟悉部分時消除混亂。
當每個人都遵循這些規則時,可以更好地維護和導航遺留代碼,並更快地添加新代碼。 更少的時間花在規劃開發方法上。 由於問題不會以一種方式出現,因此沒有靈丹妙藥的設計模式。 必須仔細考慮每種模式的優勢和劣勢,並找到最適合手頭挑戰的方式。
在本教程中,我將介紹我對流行的 Unity 遊戲開發平台和用於遊戲開發的模型-視圖-控制器 (MVC) 模式的體驗。 在我 7 年的開發中,與我相當多的遊戲開發意大利面作鬥爭後,我一直在使用這種設計模式實現出色的代碼結構和開發速度。
我將首先解釋一點 Unity 的基本架構,即實體-組件模式。 然後我將繼續解釋 MVC 如何適應它,並使用一個小模擬項目作為示例。
動機
在軟件文獻中,我們會發現大量的設計模式。 即使他們有一套規則,開發人員通常會做一些規則彎曲,以便更好地使模式適應他們的特定問題。
這種“編程自由”證明我們還沒有找到一種單一的、確定的軟件設計方法。 因此,本文並不是要成為您問題的最終解決方案,而是要展示兩種眾所周知的模式的好處和可能性:實體-組件和模型-視圖-控制器。
實體組件模式
實體組件(EC)是一種設計模式,我們首先定義構成應用程序的元素的層次結構(實體),然後定義每個將包含的特徵和數據(組件)。 用更多的“程序員”術語來說,一個實體可以是一個包含 0 個或多個組件的數組的對象。 讓我們描繪一個這樣的實體:
some-entity [component0, component1, ...]
這是一個 EC 樹的簡單示例。
- app [Application] - game [Game] - player [KeyboardInput, Renderer] - enemies - spider [SpiderAI, Renderer] - ogre [OgreAI, Renderer] - ui [UI] - hud [HUD, MouseInput, Renderer] - pause-menu [PauseMenu, MouseInput, Renderer] - victory-modal [VictoryModal, MouseInput, Renderer] - defeat-modal [DefeatModal, MouseInput, Renderer]
EC 是緩解多重繼承問題的一個很好的模式,其中復雜的類結構可能會引入類似菱形問題的問題,其中類 D 繼承兩個類 B 和 C,具有相同的基類 A,可能會引入衝突,因為如何B 和 C 以不同的方式修改 A 的特徵。
這類問題在經常廣泛使用繼承的遊戲開發中很常見。
通過將功能和數據處理程序分解為更小的組件,它們可以在不同的實體中附加和重用,而無需依賴多重繼承(順便說一下,這甚至不是 Unity 使用的主要語言 C# 或 Javascript 的功能)。
實體組件的不足之處
作為 OOP 之上的一個級別,EC 有助於整理和更好地組織您的代碼架構。 然而,在大型項目中,我們仍然“太自由”,我們會發現自己處於“功能海洋”中,很難找到正確的實體和組件,或者弄清楚它們應該如何交互。 有無數種方法可以為給定任務組裝實體和組件。
避免混亂的一種方法是在實體組件之上施加一些額外的指導方針。 例如,我喜歡思考軟件的一種方式是將其分為三個不同的類別:
- 有些處理原始數據,允許創建、讀取、更新、刪除或搜索(即,CRUD 概念)。
- 其他人實現與其他元素交互的接口,檢測與其範圍相關的事件並在它們發生時觸發通知。
- 最後,一些元素負責接收這些通知,做出業務邏輯決策,並決定如何操作數據。
幸運的是,我們已經有了一個以這種方式運行的模式。
模型-視圖-控制器 (MVC) 模式
模型-視圖-控制器模式 (MVC) 將軟件分為三個主要組件:模型(數據 CRUD)、視圖(接口/檢測)和控制器(決策/動作)。 MVC 足夠靈活,甚至可以在 ECS 或 OOP 之上實現。
遊戲和 UI 開發具有通常的工作流程,即等待用戶輸入或其他觸發條件,在適當的地方發送這些事件的通知,決定要做什麼作為響應,並相應地更新數據。 這些操作清楚地表明了這些應用程序與 MVC 的兼容性。
這種方法引入了另一個抽象層,這將有助於軟件規劃,並允許新程序員甚至在更大的代碼庫中導航。 通過將思維過程拆分為數據、界面和決策,開發人員可以減少為了添加或修復功能而必須搜索的源文件的數量。
團結和EC
讓我們首先仔細看看 Unity 為我們提供了什麼。
Unity 是一個基於 EC 的開發平台,其中所有實體都是GameObject
的實例,並且使它們“可見”、“可移動”、“可交互”等特性由擴展Component
的類提供。
Unity 編輯器的Hierarchy Panel和Inspector Panel提供了一種強大的方式來組裝您的應用程序、附加組件、配置它們的初始狀態並引導您的遊戲,而源代碼比通常少得多。
儘管如此,正如我們所討論的,我們可能會遇到“功能太多”的問題,並發現自己處於一個巨大的層次結構中,功能分散在各處,使開發人員的生活變得更加困難。
以 MVC 的方式思考,我們可以從根據功能劃分事物開始,像下面的示例那樣構建我們的應用程序:
使 MVC 適應遊戲開發環境
現在,我想介紹對通用 MVC 模式的兩個小修改,這有助於使其適應我在使用 MVC 構建 Unity 項目時遇到的獨特情況:
- MVC 類引用很容易分散在整個代碼中。 - 在 Unity 中,開發人員通常必須拖放實例以使其可訪問,或者通過諸如
GetComponent( ... )
之類的繁瑣查找語句來訪問它們。 - 如果 Unity 崩潰或某些錯誤使所有拖動的引用消失,則會出現丟失引用地獄。 - 這使得必須有一個單一的根引用對象,通過它可以訪問和恢復應用程序中的所有實例。 - 一些元素封裝了應該高度可重用的一般功能,並且自然不屬於模型、視圖或控制器這三個主要類別之一。 這些我喜歡簡稱為Components 。 它們也是實體-組件意義上的“組件”,但僅充當 MVC 框架中的助手。 - 例如,一個
Rotator
組件,它只以給定的角速度旋轉物體,而不通知、存儲或決定任何事情。
為了幫助緩解這兩個問題,我提出了一個修改後的模式,我稱之為AMVCC或 Application-Model-View-Controller-Component。
- 應用程序- 所有關鍵實例和應用程序相關數據的應用程序和容器的單一入口點。
- MVC - 你現在應該知道了。 :)
- 組件- 可以重複使用的小型、包含良好的腳本。
這兩個修改已經滿足了我使用它們的所有項目的需求。
示例: 10 次反彈
作為一個簡單的例子,讓我們看一個名為10 Bounces的小遊戲,我將在其中使用 AMVCC 模式的核心元素。
遊戲設置很簡單:一個帶有Ball
和一個SphereCollider
(在“播放”後將開始下降)的球、一個作為地面的Cube
和 5 個組成Rigidbody
的腳本。
等級制度
在編寫腳本之前,我通常從層次結構開始並創建我的類和資產的大綱。 始終遵循這種新的 AMVCC 風格。
正如我們所見, view
GameObject 包含所有視覺元素以及其他View
腳本的元素。 對於小型項目, model
和controller
遊戲對象通常只包含它們各自的腳本。 對於更大的項目,它們將包含帶有更具體腳本的遊戲對象。
當瀏覽您的項目的人想要訪問時:
- 數據:轉到
application > model > ...
- 邏輯/工作流程:轉到
application > controller > ...
- 渲染/界面/檢測:轉到
application > view > ...
如果所有團隊都遵循這些簡單的規則,遺留項目不應該成為問題。
請注意,沒有Component
容器,因為正如我們所討論的,它們更靈活,並且可以在開發人員空閒時附加到不同的元素。
腳本
注意:下面顯示的腳本是實際實現的抽象版本。 詳細的實現不會使讀者受益匪淺。 但是,如果您想探索更多,這裡是我個人的 Unity MVC 框架的鏈接,Unity MVC。 您將找到實現大多數應用程序所需的 AMVCC 結構框架的核心類。
讓我們看一下10 Bounces的腳本結構。
在開始之前,對於那些不熟悉 Unity 工作流程的人,讓我們簡要說明一下腳本和 GameObjects 是如何協同工作的。 在 Unity 中,實體組件意義上的“組件”由MonoBehaviour
類表示。 要讓一個在運行時存在,開發人員應該將其源文件拖放到一個 GameObject(這是 Entity-Component 模式的“實體”)或使用命令AddComponent<YourMonobehaviour>()
。 在此之後,腳本將被實例化並準備好在執行期間使用。
首先,我們定義 Application 類(AMVCC 中的“A”),它將是包含對所有實例化遊戲元素的引用的主類。 我們還將創建一個名為Element
的輔助基類,它使我們能夠訪問 Application 的實例及其子 MVC 實例。
考慮到這一點,讓我們定義Application
類(AMVCC 中的“A”),它將有一個唯一的實例。 在其中, model
、 view
和controller
這三個變量將為我們提供運行時所有 MVC 實例的訪問點。 這些變量應該是具有對所需腳本的public
引用的MonoBehaviour
。
然後,我們還將創建一個名為Element
的輔助基類,它使我們能夠訪問應用程序的實例。 這種訪問將允許每個 MVC 類相互訪問。
請注意,這兩個類都擴展了MonoBehaviour
。 它們是將附加到遊戲對象“實體”的“組件”。
// BounceApplication.cs // Base class for all elements in this application. public class BounceElement : MonoBehaviour { // Gives access to the application and all instances. public BounceApplication app { get { return GameObject.FindObjectOfType<BounceApplication>(); }} } // 10 Bounces Entry Point. public class BounceApplication : MonoBehaviour { // Reference to the root instances of the MVC. public BounceModel model; public BounceView view; public BounceController controller; // Init things here void Start() { } }
從BounceElement
我們可以創建 MVC 核心類。 BounceModel
、 BounceView
和BounceController
腳本通常充當更專業實例的容器,但由於這是一個簡單的示例,因此只有 View 將具有嵌套結構。 模型和控制器可以在一個腳本中完成:
// BounceModel.cs // Contains all data related to the app. public class BounceModel : BounceElement { // Data public int bounces; public int winCondition; }
// BounceView .cs // Contains all views related to the app. public class BounceView : BounceElement { // Reference to the ball public BallView ball; }
// BallView.cs // Describes the Ball view and its features. public class BallView : BounceElement { // Only this is necessary. Physics is doing the rest of work. // Callback called upon collision. void OnCollisionEnter() { app.controller.OnBallGroundHit(); } }
// BounceController.cs // Controls the app workflow. public class BounceController : BounceElement { // Handles the ball hit event public void OnBallGroundHit() { app.model.bounces++; Debug.Log(“Bounce ”+app.model.bounce); if(app.model.bounces >= app.model.winCondition) { app.view.ball.enabled = false; app.view.ball.GetComponent<RigidBody>().isKinematic=true; // stops the ball OnGameComplete(); } } // Handles the win condition public void OnGameComplete() { Debug.Log(“Victory!!”); } }
創建所有腳本後,我們可以繼續附加和配置它們。

層次佈局應該是這樣的:
- application [BounceApplication] - model [BounceModel] - controller [BounceController] - view [BounceView] - ... - ball [BallView] - ...
以BounceModel
為例,我們可以看到它在 Unity 的編輯器中的樣子:
BounceModel
帶有bounces
和winCondition
字段。
設置好所有腳本並運行遊戲後,我們應該在控制檯面板中獲得此輸出。
通知
如上例所示,當球落地時,它的視圖執行app.controller.OnBallGroundHit()
方法。 無論如何,對應用程序中的所有通知執行此操作都不是“錯誤的”。 但是,根據我的經驗,我使用在 AMVCC 應用程序類中實現的簡單通知系統取得了更好的效果。
為了實現這一點,讓我們將BounceApplication
的佈局更新為:
// BounceApplication.cs class BounceApplication { // Iterates all Controllers and delegates the notification data // This method can easily be found because every class is “BounceElement” and has an “app” // instance. public void Notify(string p_event_path, Object p_target, params object[] p_data) { BounceController[] controller_list = GetAllControllers(); foreach(BounceController c in controller_list) { c.OnNotification(p_event_path,p_target,p_data); } } // Fetches all scene Controllers. public BounceController[] GetAllControllers() { /* ... */ } }
接下來,我們需要一個新腳本,所有開發人員將在其中添加通知事件的名稱,可以在執行期間分派。
// BounceNotifications.cs // This class will give static access to the events strings. class BounceNotification { static public string BallHitGround = “ball.hit.ground”; static public string GameComplete = “game.complete”; /* ... */ static public string GameStart = “game.start”; static public string SceneLoad = “scene.load”; /* ... */ }
很容易看出,這樣一來,代碼的易讀性得到了提高,因為開發人員不需要在整個源代碼中搜索controller.OnSomethingComplexName
方法來了解在執行過程中會發生什麼樣的動作。 通過僅檢查一個文件,可以了解應用程序的整體行為。
現在,我們只需要調整BallView
和BounceController
來處理這個新系統。
// BallView.cs // Describes the Ball view and its features. public class BallView : BounceElement { // Only this is necessary. Physics is doing the rest of work. // Callback called upon collision. void OnCollisionEnter() { app.Notify(BounceNotification.BallHitGround,this); } }
// BounceController.cs // Controls the app workflow. public class BounceController : BounceElement { // Handles the ball hit event public void OnNotification(string p_event_path,Object p_target,params object[] p_data) { switch(p_event_path) { case BounceNotification.BallHitGround: app.model.bounces++; Debug.Log(“Bounce ”+app.model.bounce); if(app.model.bounces >= app.model.winCondition) { app.view.ball.enabled = false; app.view.ball.GetComponent<RigidBody>().isKinematic=true; // stops the ball // Notify itself and other controllers possibly interested in the event app.Notify(BounceNotification.GameComplete,this); } break; case BounceNotification.GameComplete: Debug.Log(“Victory!!”); break; } } }
更大的項目會有很多通知。 因此,為避免獲得較大的 switch-case 結構,建議創建不同的控制器並讓它們處理不同的通知範圍。
現實世界中的 AMVCC
此示例顯示了 AMVCC 模式的簡單用例。 根據 MVC 的三個要素調整您的思維方式,並學習將實體可視化為有序的層次結構,是應該磨練的技能。
在更大的項目中,開發人員將面臨更複雜的場景,並且會懷疑某個東西應該是視圖還是控制器,或者是否應該將給定的類更徹底地分離為較小的類。
經驗法則(愛德華多)
任何地方都沒有任何“MVC 排序通用指南”。 但是我通常遵循一些簡單的規則來幫助我確定是否將某些東西定義為模型、視圖或控制器,以及何時將給定的類拆分為更小的部分。
通常,這是在我考慮軟件架構或編寫腳本時自然發生的。
類排序
楷模
- 保存應用程序的核心數據和狀態,例如玩家
health
或槍支ammo
。 - 序列化、反序列化和/或類型之間的轉換。
- 加載/保存數據(本地或網絡)。
- 通知控制器操作進度。
- 為遊戲的有限狀態機存儲遊戲狀態。
- 從不訪問視圖。
意見
- 可以從模型中獲取數據,以便向用戶表示最新的遊戲狀態。 例如,View 方法
player.Run()
可以在內部使用model.speed
來顯示玩家的能力。 - 永遠不應該改變模型。
- 嚴格實現其類的功能。 例如:
-
PlayerView
不應實現輸入檢測或修改遊戲狀態。 - 視圖應充當具有接口並通知重要事件的黑匣子。
- 不存儲核心數據(如速度、健康、生命……)。
-
控制器
- 不要存儲核心數據。
- 有時可以過濾來自不需要的視圖的通知。
- 更新和使用模型的數據。
- 管理 Unity 的場景工作流程。
類層次結構
在這種情況下,我沒有遵循很多步驟。 通常,當變量開始顯示太多“前綴”或同一元素的太多變體開始出現時(例如 MMO 中的Player
類或 FPS 中的Gun
類型),我認為需要拆分某些類。
例如,包含 Player 數據的單個Model
將有很多 playerDataA、playerDataB、 playerDataA, playerDataB,...
或處理 Player 通知的Controller
將有OnPlayerDidA,OnPlayerDidB,...
我們希望減小腳本大小並去掉player
和OnPlayer
前綴。
讓我演示如何使用Model
類,因為僅使用數據更容易理解。
在編程期間,我通常從一個保存遊戲所有數據的Model
類開始。
// Model.cs class Model { public float playerHealth; public int playerLives; public GameObject playerGunPrefabA; public int playerGunAmmoA; public GameObject playerGunPrefabB; public int playerGunAmmoB; // Ops Gun[CDE ...] will appear... /* ... */ public float gameSpeed; public int gameLevel; }
不難看出,遊戲越複雜,變量就越多。 有了足夠的複雜性,我們最終可以得到一個包含model.playerABCDFoo
變量的巨大類。 嵌套元素將簡化代碼完成,並為在數據變化之間切換提供空間。
// Model.cs class Model { public PlayerModel player; // Container of the Player data. public GameModel game; // Container of the Game data. }
// GameModel.cs class GameModel { public float speed; // Game running speed (influencing the difficulty) public int level; // Current game level/stage loaded }
// PlayerModel.cs class PlayerModel { public float health; // Player health from 0.0 to 1.0. public int lives; // Player “retry” count after he dies. public GunModel[] guns; // Now a Player can have an array of guns to switch ingame. }
// GunModel.cs class GunModel { public GunType type; // Enumeration of Gun types. public GameObject prefab; // Template of the 3D Asset of the weapon. public int ammo; // Current number of bullets public int clips; // Number of reloads possible }
通過這種類配置,開發人員可以一次直觀地在源代碼中導航一個概念。 讓我們假設一個第一人稱射擊遊戲,其中武器及其配置可以變得非常多。 GunModel
包含在一個類中的事實允許為每個類別創建一個Prefabs
列表(預先配置的遊戲對象,以便在遊戲中快速復制和重用)並存儲以供以後使用。
相反,如果槍支信息全部存儲在單個GunModel
類中,在gun0Ammo
、 gun1Ammo
、 gun0Clips
等變量中,那麼用戶在需要存儲Gun
數據時,需要存儲整個包含不需要的Player
數據的Model
。 在這種情況下,很明顯一個新的GunModel
類會更好。
與所有事物一樣,硬幣有兩個方面。 有時可能會不必要地過度劃分並增加代碼複雜性。 只有經驗可以磨練你的技能,才能為你的項目找到最好的 MVC 排序。
結論
那裡有大量的軟件模式。 在這篇文章中,我試圖展示在過去的項目中對我幫助最大的那個。 開發人員應始終吸收新知識,但也應始終質疑它。 我希望本教程可以幫助您學習新知識,同時也可以作為您發展自己風格的墊腳石。
另外,我真的鼓勵您研究其他模式並找到最適合您的模式。 一個很好的起點是這篇 Wikipedia 文章,其中包含了優秀的模式列表及其特徵。
如果您喜歡 AMVCC 模式並想對其進行測試,請不要忘記試用我的庫Unity MVC ,它包含啟動 AMVCC 應用程序所需的所有核心類。
進一步閱讀 Toptal 工程博客:
- Unity AI 開發:有限狀態機教程