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 有助于整理和更好地组织您的代码架构。 然而,在大型项目中,我们仍然“太自由”,我们会发现自己处于“功能海洋”中,很难找到正确的实体和组件,或者弄清楚它们应该如何交互。 有无数种方法可以为给定任务组装实体和组件。

图片:EC 专题海洋

避免混乱的一种方法是在实体组件之上施加一些额外的指导方针。 例如,我喜欢思考软件的一种方式是将其分为三个不同的类别:

  • 有些处理原始数据,允许创建、读取、更新、删除或搜索(即,CRUD 概念)。
  • 其他人实现与其他元素交互的接口,检测与其范围相关的事件并在它们发生时触发通知。
  • 最后,一些元素负责接收这些通知,做出业务逻辑决策,并决定如何操作数据。

幸运的是,我们已经有了一个以这种方式运行的模式。

模型-视图-控制器 (MVC) 模式

模型-视图-控制器模式 (MVC) 将软件分为三个主要组件:模型(数据 CRUD)、视图(接口/检测)和控制器(决策/动作)。 MVC 足够灵活,甚至可以在 ECS 或 OOP 之上实现。

游戏和 UI 开发具有通常的工作流程,即等待用户输入或其他触发条件,在适当的地方发送这些事件的通知,决定要做什么作为响应,并相应地更新数据。 这些操作清楚地表明了这些应用程序与 MVC 的兼容性。

这种方法引入了另一个抽象层,这将有助于软件规划,并允许新程序员甚至在更大的代码库中导航。 通过将思维过程拆分为数据、界面和决策,开发人员可以减少为了添加或修复功能而必须搜索的源文件的数量。

团结和EC

让我们首先仔细看看 Unity 为我们提供了什么。

Unity 是一个基于 EC 的开发平台,其中所有实体都是GameObject的实例,并且使它们“可见”、“可移动”、“可交互”等特性由扩展Component的类提供。

Unity 编辑器的Hierarchy PanelInspector Panel提供了一种强大的方式来组装您的应用程序、附加组件、配置它们的初始状态并引导您的游戏,而源代码比通常少得多。

屏幕截图:层次结构面板
右侧有四个游戏对象的层次结构面板

屏幕截图:检查面板
带有游戏对象组件的检查面板

尽管如此,正如我们所讨论的,我们可能会遇到“功能太多”的问题,并发现自己处于一个巨大的层次结构中,功能分散在各处,使开发人员的生活变得更加困难。

以 MVC 的方式思考,我们可以从根据功能划分事物开始,像下面的示例那样构建我们的应用程序:

屏幕截图:Unity MVC 示例结构

使 MVC 适应游戏开发环境

现在,我想介绍对通用 MVC 模式的两个小修改,这有助于使其适应我在使用 MVC 构建 Unity 项目时遇到的独特情况:

  1. MVC 类引用很容易分散在整个代码中。 - 在 Unity 中,开发人员通常必须拖放实例以使其可访问,或者通过诸如GetComponent( ... )之类的繁琐查找语句来访问它们。 - 如果 Unity 崩溃或某些错误使所有拖动的引用消失,则会出现丢失引用地狱。 - 这使得必须有一个单一的根引用对象,通过它可以访问和恢复应用程序中的所有实例。
  2. 一些元素封装了应该高度可重用的一般功能,并且自然不属于模型、视图或控制器这三个主要类别之一。 这些我喜欢简称为Components 。 它们也是实体-组件意义上的“组件”,但仅充当 MVC 框架中的助手。 - 例如,一个Rotator组件,它只以给定的角速度旋转物体,而不通知、存储或决定任何事情。

为了帮助缓解这两个问题,我提出了一个修改后的模式,我称之为AMVCC或 Application-Model-View-Controller-Component。

图片:AMVCC 图表

  • 应用程序- 所有关键实例和应用程序相关数据的应用程序和容器的单一入口点。
  • MVC - 你现在应该知道了。 :)
  • 组件- 可以重复使用的小型、包含良好的脚本。

这两个修改已经满足了我使用它们的所有项目的需求。

示例: 10 次反弹

作为一个简单的例子,让我们看一个名为10 Bounces的小游戏,我将在其中使用 AMVCC 模式的核心元素。

游戏设置很简单:一个带有Ball和一个SphereCollider (在“播放”后将开始下降)的球、一个作为地面的Cube和 5 个组成Rigidbody的脚本。

等级制度

在编写脚本之前,我通常从层次结构开始并创建我的类和资产的大纲。 始终遵循这种新的 AMVCC 风格。

屏幕截图:建立层次结构

正如我们所见, view GameObject 包含所有视觉元素以及其他View脚本的元素。 对于小型项目, modelcontroller游戏对象通常只包含它们各自的脚本。 对于更大的项目,它们将包含带有更具体脚本的游戏对象。

当浏览您的项目的人想要访问时:

  • 数据:转到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”),它将有一个唯一的实例。 在其中, modelviewcontroller这三个变量将为我们提供运行时所有 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 核心类。 BounceModelBounceViewBounceController脚本通常充当更专业实例的容器,但由于这是一个简单的示例,因此只有 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
BounceModel带有bounceswinCondition字段。

设置好所有脚本并运行游戏后,我们应该在控制台面板中获得此输出。

屏幕截图:控制台输出

通知

如上例所示,当球落地时,它的视图执行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方法来了解在执行过程中会发生什么样的动作。 通过仅检查一个文件,可以了解应用程序的整体行为。

现在,我们只需要调整BallViewBounceController来处理这个新系统。

 // 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,... 我们希望减小脚本大小并去掉playerOnPlayer前缀。

让我演示如何使用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类中,在gun0Ammogun1Ammogun0Clips等变量中,那么用户在需要存储Gun数据时,需要存储整个包含不需要的Player数据的Model 。 在这种情况下,很明显一个新的GunModel类会更好。

图片:类层次结构
改进类层次结构。

与所有事物一样,硬币有两个方面。 有时可能会不必要地过度划分并增加代码复杂性。 只有经验可以磨练你的技能,才能为你的项目找到最好的 MVC 排序。

解锁了新的游戏开发者特殊能力:采用 MVC 模式的 Unity 游戏。
鸣叫

结论

那里有大量的软件模式。 在这篇文章中,我试图展示在过去的项目中对我帮助最大的那个。 开发人员应始终吸收新知识,但也应始终质疑它。 我希望本教程可以帮助您学习新知识,同时也可以作为您发展自己风格的垫脚石。

另外,我真的鼓励您研究其他模式并找到最适合您的模式。 一个很好的起点是这篇 Wikipedia 文章,其中包含了优秀的模式列表及其特征。

如果您喜欢 AMV​​CC 模式并想对其进行测试,请不要忘记试用我的库Unity MVC ,它包含启动 AMVCC 应用程序所需的所有核心类。


进一步阅读 Toptal 工程博客:

  • Unity AI 开发:有限状态机教程