程序员们经常以经典的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十分灵活,可以在ECS和OOP上层实现。
游戏和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应用程序的所有核心类。