Unity & MVC:如何提升游戏开发质量

程序员们经常以经典的Hello World开始他们的编程之旅。接下来才会接触更复杂的任务。每个新的挑战都体现出一个重要的结论:

工程越浩大,逻辑越复杂。
                               

使大规模易于维护就是软件设计模式存在的意义,这些模式可以用一些简单的规则来制定一个软件工程的整体架构,也可以让一些程序员完成一个大型工程中的独立模块,之后单独的模块以标准化的方式来组织,从而避免代码库遇到一些不熟悉的部分会产生混乱。


当所有人都在遵循这些规则的时候,不仅可以很好的维护和应付旧代码,还可以更快的加入新代码,在设计开发规划上花费的时间也会减。在面对即将来临的挑战的时候,我们必须仔细考虑每一种模式的优缺点,然后找出一个最适合的。
 
我把我的游戏开发经验和流行的Unity游戏开发平台以及MVC模型联系起来,展示在这个教程中。在七年的游戏开发过程中我也遇到不少挑战,通过使用这种设计模式,我已经在代码结构和开发速度方面取得了很大的进展。

我首先介绍一些unity的基础架构,Entity-Component模型。接下来以一个小型工程为例,解释MVC为何适用。


动机
在一些软件类文献中我们可以找到很多的设计模型。即使这些模型都有一系列规则,开发者经常会对这些规则做一些修改,这样会更好的适用于一些具体问题


我们目前为止还没有找到一个单一明确的方法来设计软件,因此才会有“自由编程”的说法。本文并不是给你提供一个最终解决方案,而是展示Entity-Component和MVC这两种大家很熟悉的模式可以利用和改进的地方。
Entity-Component模式
Entity-Component(EC)模式中,我们首先定义好元素的层次结构,这些元素组成了一个应用(即Entities),之后我们定义每一个实体(Entity)所包含的的功能和数据(即Components)。按照更多的程序员的说法,实体(Entity)是一个有着不定量(0个或者多个)的Components的对象。下面描绘了一个实体(Entity):
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,而类B和类C都继承了基类A,那么就会引进冲突,因为类B和类C可能会对基类A的同一个功能定义不同。
 
在经常广泛使用继承的游戏开发过程中这种问题是很常见的。

当这些功能和数据处理程序被分解成更小的组件时,它们就可以不依赖多重继承绑定并重用于不同的实体(但是在Unity上使用的主流语言C#或者Javascript中,并没有该功能)。


EC的不足

作为面向对象编程的上层,EC能够更好的整理组织你的代码结构。但是对我们来说,在大项目中我们发现在一个‘功能海洋’中漫游,我们很难找到正确的实体和组件或者弄清楚他们应该如何交互。对于给定任务有无数种实体结合组件的方式来完成。
 
为了避免混乱,我们可以在EC的开始就给定一些额外的指导,比如我喜欢在三个不同的方面来考虑软件项目:


原始数据增删改查的处理(例如CRUD概念

实现与其他元素交互的接口,这些接口会检测与它们范围内相关的事件并适时触发通知。


最后,一些元素负责接收这些通知,做出业务逻辑策略,决定如何处理数据

好在我们已经有了一个符合这样要求的模型。

MVC模型

MVC模型将软件分为3个重要部分:模型(数据的增删改查),视图(接口/监测)和控制器(决定/动作)。MVC十分灵活,可以在ECSOOP上层实现。


游戏和UI开发通常的工作流程是等待用户的输入或者其他触发条件,然后在适当的地方发送这些事件的通知,决定做什么,并相应地更新数据。从中我们可以很明显的看出使用MVC模型开发出的应用的灵活性。


这个方法引进了一个抽象层,这个抽象层有助于软件策划,还可以更好的引导新程序员即便代码库更大。当开发人员想要增加或者修复功能时,可以通过将思考过程分解为数据,接口和决策来减少必须搜索的源文件数量。

Unity和EC

首先我们来看看Unity能够给予我们什么。


Unity是一个基于EC的开发平台,所有的实体都是游戏对象的实例,一些可以使得它们‘可见’‘可移动’‘可交互’等等的功能都是由扩展类组件提供的。

Unity编辑器中的Hierarchy面板和Inspector面板提供了一个很强大的方法来组装你的应用程序,链接组件,配置初始化状态,并且不需太多代码就能实现你的游戏。
右侧的Hierarchy面板中有4个游戏对象
Inspector面板显示游戏对象上的组件

然而,我们也会面临功能过多问题,导致层次异常复杂,多功能都是零散的,加大了开发难度
MVC角度出发,我们可以根据事物的功能来划分,构建我们的应用程序,例如:



在游戏开发环境中采用MVC模型

现在我会介绍在通用的MVC模型上的两处小修改,这样的修改有利于用MVC模型建立Unity工程的时候遇到的特殊情况。

MVC类的引用会很容易在代码中分散开来。
Unity中,开发者通常必须拖拽实例来进行访问,否则就得通过繁琐的查找语句如GetComponent( ... )。
Lost-referencehell will ensue if Unity crashes or some bug makes all the dragged referencesdisappear.
如果Unity崩溃或者一些其它bug使得所有引用丢失,下场就比较悲催了。
因此我们需要一个单独可靠的根引用对象,通过该对象我们可以找到并恢复该应用程序中的所有实例。

一些封装了通用功能的元素应该高度可重用,不应该自然地分到三个重要的部分:模型,视图,控制器之中。可以将它们称之为简单组件。在EC意义中,它们也是组件,但充当的角色仅仅是MVC框架中的助手。

例如一个旋转组件,它只是通过给定的角速度来旋转物体,但不会通知、存储或者决定任何事情。

为了协调这两个问题,作者提出了一个改进的模式,称之为AMVCC,或者Application-Model-View-Controller-Component(应用—模型—视图—控制器—组件).
 
Application –应用程序的入口,所有关键的实例集合的入口,应用程序相关的数据入口
MVC – 现在你应该了解了
Component - 精简的,封装得很好的一些可重用的脚本

我将这个改进的模型应用在工程中,已经满足了我的需求。

举个简单的例子,我们来看一个叫做10 Bounces的小游戏,我将在这个游戏中利用AMVCC模式的核心元素。

这个游戏的设置很简单:一个有着SphereCollider和Rigidbody的小球(当你点击‘Play’之后就开始下落),一个立方体作为地面,5个脚本组成了AMVCC。

层次结构

编写脚本之前,我会先设计层次结构,创建类和资源的大纲。在设计的过程中我会一直遵循着这个新的AMVCC风格。
 
我们可以看到,在view游戏对象中包括了所有的可视化元素和一些其他视图的脚本。在一些小项目中,模型和控制器的游戏对象通常只包括与其相关的脚本。而在一些大项目中,模型和控制器的游戏对象会包含更多具体的脚本。

当有人浏览你的项目时希望看到的目录结构如下:
Data存放在application>model>下
Logic/Workflow存放在application > controller >下
Rendering/Interface/Detection存放在application > view > 下

如果所有团队都遵循这些简单的规则,旧版项目就不会成为问题。

值得注意的是,这里没有组件容器。如同我们之前讨论的,组件容器更加灵活,可以按照开发者的喜好来附加到不同的元素上。

编写脚本

注:下面展示的脚本是实际项目实现的抽象版本。授人以鱼不如授人以渔。如果你想了解更多,这里有我个人的专门为unity设计的MVC框架的一个链接。你可以找到实现AMVCC结构框架的核心类,大多数应用程序都会需要它。

我们来看一下10 Bounces的结构脚本。


在开始之前,我们来简要描述一下脚本和游戏对象如何一起工作的。在Unity中,MonoBehavior类代表EC意义中的组件。为了使得一个对象可以在运行期间中存在,开发者应该将源文件拖到一个游戏对象(也就是EC模型中的Entity)中,或者使用命令AddComponent<YourMonobehaviour>()。然后实例化该脚本,并准备在执行期间使用它。

首先我们定义应用类(AMVCC中的A), 它将作为包含所有实例化的游戏元素引用的主类。同时我们也要创建一个协助基类,称作Element。Element会让我们去访问应用程序中的实例以及其子节点的MVC实例。

在上面所述的基础上,我们来定义应用类(AMVCC中的A),这个类有一个特别的实例。在这个实例中有三个变量:模型、视图和控制器,这三者将会让我们在运行时候访问所有MVC实例。对于我们所需要的脚本来说,这些变量应该是有着公共引用的MonoBehavior。

接下来我们也要创建一个叫做Element的协助基类,我们可以通过它来访问应用程序的实例。同时每一个MVC类也可以访问其他MVC类。

需要注意的是,所有的类都扩展了MonoBehaviour。他们都是附加到游戏对象“实体”上的组件。
[C#]  纯文本查看  复制代码
?
 
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
// 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会有一个嵌套结构。Model和Controller这两者都可以分别由一个脚本来完成:
[C#]  纯文本查看  复制代码
?
 
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// 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()。无论如何,对于应用程序中所有的通知都去做app.controller.OnBallGroundHit()并不是‘错误’的。然而以我的经验来看,在AMVCC应用类上实现一个简单的通知系统要更好。

为了实现他,我们来更新BounceApplication的布局:
[C#]  纯文本查看  复制代码
?
 
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
// 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() {  /* ... */  }
}

接下来,我们需要一个新的脚本,所有的开发者都要在这个脚本中添加通知事件名称,这些名称可以在执行期间调用。
[C#]  纯文本查看  复制代码
?
 
01
02
03
04
05
06
07
08
09
10
11
// 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来处理这个新系统
[C#]  纯文本查看  复制代码
?
 
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 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三个元素的思维方式,并学会将实体看作一个有序的层次结构。

在大工程中,开发者都面临着更加复杂的场景,对一些物体是否应该是视图或控制器,或者是否应该将一个给定的类更彻底的分为多个小类等有太多疑问。

经验法则(Eduardo)

不存在任何“MVC分类通用指南”。但是还是有一些简单的规则,它们帮助我决定了是否去将一些事物定义为Model,View还是Controller,并且帮助我何时将一个给定的类切分为一个个小部分。

当我在思考软件架构或者编写脚本的时候经常会发生这样的事情。


分类

模型
  • 保存应用程序的核心数据和状态,例如玩家生命值或枪的弹药。
  • 序列化,反序列化,与/或类型之间的转换。
  • 从本地或网上进行载入或者保存数据。
  • 通知控制器操作的进度。
  • 有限状态机中存储游戏的状态。
  • 不访问视图。



视图
  •   可以从Model获得数据来表示用户的最新游戏状态。比如视图中的一个方法player.Run()可以在内部使用model.speed来展示游戏者的能力。
  • 不能改变Model。
  •   严格地实现类中功能,例如:

u  一个玩家视图(PlayerView)不能实现输入检测功能或者修改游戏状态功能。
u  视图应该是一个黑盒,只有一个接口和重要事件的通知。
u  不存储核心数据(比如速度,健康值,生命等)
控制器
  • 不存储核心数据。
  • 有时候能够过滤不期望的视图的通知。
  • 更新和使用Model的数据。
  • 管理unity的场景工作流程。




类层次结构


这方面我没有很多的步骤去遵循。通常我认为在变量前缀太多,或出现相同元素的多种不同形式(比如MMO中的Player 类或FPS中的Gun类)时,这些类就需要切分为更多的类。

例如,一个模型中包含的Player数据中有很多类型,比如playerDataA,playerDataB,…,或者一个控制器处理Player的通知有很多类型比如OnplayerDidA,OnplayerDidB,…。我们想减少脚本代码并摆脱player和Onplayer前缀。

为了更容易理解,我使用一个只包含数据的Model类来演示。

在编程过程中,我通常以一个包括所有游戏数据的类开始。
[C#]  纯文本查看  复制代码
?
 
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
// Model.cs
  
class Model
{
    public float  playerHealth;
    public int  playerLives;
  
    public GameObject playerGunPrefabA;
    public int  playerGunAmmoA;
  
    public GameObject playerGunPrefabB;
    public int  playerGunAmmoB;
  
    // Ops Gun[C D E ...] will appear...
    /* ... */
  
    public float  gameSpeed;
    public int  gameLevel;
}


我们可以看出游戏越复杂变量越多。当复杂到一定程度,最终会变成一个巨大的类,这个类包含了许多model.playerABCDFoo变量。嵌套元素会简化代码的实现,并且为数据变量之间的转换提供空间。
[C#]  纯文本查看  复制代码
?
 
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 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允许为每个种类创建一个预制的列表(在游戏中可以快速复制并重用一些预先配置的游戏对象),存储起来备用。


相反,如果枪支信息都在一个GunModel类中存放,那么这个类需要包含许多变量,比如gun0Ammo,gun1Ammo, gun0Clips等。当玩家需要存储特殊枪支信息时,将需要存储整个模型,包括一些不需要的玩家信息。在这种情形下,很显然建立一个新的GunModel类会更好。
改善类层次结构


任何事都有对立面。有时不需要划分过细以免增加代码的复杂性。只有实践才能够很好的锻炼技能,这样才能够为工程找到最好的MVC分类。


总结


目前有成千上万中软件模式,在本文中我试着展示一种模型,这个模型在我经历过大多数的项目中帮助良多。开发者应该积极接纳新事物,但同时要持有怀疑态度。我希望这个教程能让你有些收获,同时也可以作为一个跳板以便开发自己的风格。


同时,我鼓励你去研究其它模式,找到一个最适合你的模型。维基百科上的这篇文章是一个很好的学习起点。


如果你喜欢AMVCC模式,可以试用我的库Unity MVC,这里包含了一个AMVCC应用程序的所有核心类。


原文作者:EDUARDO DIAS DA COSTA

猜你喜欢

转载自blog.csdn.net/larry_zeng1/article/details/80141386