MVC: Priests and Evils
What’s MVC? 简单地说,MVC是将Model, Controller, View分离开来的设计。但对于一个之前没有了解过MVC的初学者,这种说法太过抽象,让人摸不着头脑。借牧师与魔鬼这个作业说一下我对于这个设计模式的初步思考与认识。在此感谢这篇博客给了我很大的启发~如果想看比较专业的讲解可以点它 :)
既然不太知道MVC的整体架构,就先从游戏本身入手:
Model有哪些?
首先你要思考游戏中有什么:魔鬼、牧师和船。但只有这些吗?这些的确是显而易见的玩家可控的游戏对象,但对于一个游戏设计者,游戏中的每一个对象都是你要负责的,比如:河岸和河水。虽然它们不会移动,从玩家的视角无法直接操控它们,但如果它们与玩家可控的对象有接触(或者概括为信息交换)就需要纳入考虑范围。
先从简单的河水说起:河水和谁有接触?船。但河水需要知道船的信息吗?船的位置完全可以用世界坐标由船自己控制,而且河水上面只有一艘船,河水也不需要为船安排位置。
用对河水的几个问题思考一下河岸呢?就会发现,河岸与魔鬼和牧师有直接接触,而且一方面要安排他们的站位(六个角色总不能挤在一起吧),另一方面要统计他们的人数来判断游戏输赢。
先用面向对象的方法思考游戏对象
如前文所说,这个游戏中的游戏对象有:
游戏对象 | 说明 |
---|---|
Priests | 游戏中的牧师,用正方体表示。有下船、上岸行为。 |
Evils | 游戏中的魔鬼,用球体表示。亦有下船、上岸行为。【可以和牧师作为一类】 |
Boat | 游戏中的船,用有木纹贴图的长方体表示。有左移、右移行为。 |
Coasts | 游戏中的岸,用有沙滩贴图的长方体。有计数行为。 |
water | 游戏中的水,用有水纹贴图的长方体表示。无行为。【不需要构造类】 |
当我们清楚了游戏中有哪些对象的时候,就可以开始考虑controller了。
Controller控制的是什么?
最初学习别人的代码时,发现这个游戏有很多带有controller
的类。包括:MyCharacterController
,CoastController
,BoatController
以及放在与他们不同的另一个文件中的FirstController
(在我的代码中叫mainController
)
所以,Controller可以被分为两种:第一种为GameObjectController,就我目前的理解,我认为controller封装了游戏对象(GameObject类)、控制游戏对象的方法和所需的一些变量(例如位置、标记状态、类别的bool或int等等);第二种为MainController,简单的说就是调用第一种Controller中定义的方法操作Models的。
声明GameObjectController中方法时需要考虑的两个重要问题
我真的觉得这里超级重要,理解了这个之后大概就能初步理解这个设计模式了。
Controller的隔离:一个Controller只能控制自己应该控制的游戏对象,比如:魔鬼上岸的时候,调用
getOnCoast(CoastController coast0)
控制魔鬼上coast0这个河岸,同时,我们导致了coast0河岸上多了一个魔鬼,但不能够在MyCharacterController
试图修改coast0的相关属性(如魔鬼的数量),而应当在CoastController
中定义相应的方法接收这一事件。Controller的自治:其实这个是第一条隔离导致的结果,正是不能在一个Controller中定义方法修改另一个Controller的字段,所以每个Controller要定义自己的方法解决这个问题。在上面的例子中:
CoastController
也有一个getOnCoast(MyCharacterController char0)
来让河岸自己应对char0游戏对象上岸这一事件。总结起来,这两个问题其实是一个问题,也就是一个变化产生的时候,所有关联的物体都要发生变化:getOnCoast()也要相应地在不同物体中被调用。
三个类中所声明的字段和方法如下,也有相应注释。
- MyCharacterController
1 | readonly GameObject character; |
- MyBoatController
1 | readonly GameObject boat; |
- MyCoastController
1 | private GameObject coast; |
FirstController需要做什么?
我暂且理解为对游戏整体的初始化,first顾名思义,应是游戏运行时第一个调用的脚本。
第一步:确定导演
在Awake()函数中先初始化导演。这里需要指出的是导演为单例模式,整个场景中只能有一个导演,否则一群导演一个往东一个往西就会乱套。而导演类中还需要一个场记的接口,可以理解为单例的导演需要时常呼叫场记跑来跑去去应对各种外界的事情以及安排演员。
第二步:指派场记
将现在FirstController赋值给导演里的场记,之后就可以调用FirstController的类成员函数:
1
2
3
4
5
6
7
8
9//加载资源
public void LoadResources();
private void LoadCharacters();
//控制船、对象点击时的变化View的部分会讲到,是IUserAction接口中的方法
public void moveBoat();
public void characterClicked(MyCharacterController charController0);
//控制游戏状态
public void restart();
void judge () ;第三步:动态加载资源
在Awake里调用场记里面的LoadResources().
第四步:初始化一些游戏变量
另外,考虑到GameObjectController之间最好不要彼此控制,所以在FirstController中,对于一个状态的改变,场记要通过调用不同控制器的相应方法,通知这一改变所涉及的所有游戏对象。如在魔鬼下岸上船这一改变中:
1 | if(boat.getBoatState() == 1 && charController0.getCoast().getType() == 1 || boat.getBoatState() == 0 && charController0.getCoast().getType() == 0) { //船在右侧且要从右岸上船,或船在左侧要从左岸上船,当然这个函数是用来响应Click事件的(在后一部分会说) |
View需要实现哪些功能?
View主要负责管理与用户交互的视图部分,包括Restart按钮、”You Win”/“Game Over”提示。需要声明IUserAction
接口来管理用户行为:
1 | public interface IUserAction { |
UserGUI
新建一个UserGUI
类,这个类的主要属性是一个由IUserAction
类实例化的action
,在Start()
函数中为:
1 | //将导演的场记强制转化为用户行为管理 |
(我本来想试着不用强制类型转化,在Director里面再建立一个IUserAction的属性,但会出现无法对接口实例化的问题,目前还没有想到更好的方法只能先强制转化)
这里Button和Label和上次作业中井字棋差不多用OnGUI()
实现,主要思路就是Restart Button被点击的时候,调用IUserAction
中的restart()
函数。当其中的标记状态的int值改变时,相应的按钮/标签表示即可。
ClickGUI
和UserGUI类,当游戏对象被点击时,触发游戏对象所在Controller的相应方法。比如,在boat这个游戏对象接收到点击事件时,会调用BoatController
中的moveBoat()
函数。这里为了MVC设计模式的层次性,在ClickGUI
中调用IUserAction
实例的characterClicked(MyCharacterController char0)
控制魔鬼和牧师被点击的事件或moveBoat()
来控制船的移动,在场记FirstController
类(上一步中已经把FirstController赋值给场记了)中再定义相应的方法。
这三者如何关联起来
首先,FirstController要继承MonoBehavior和定义的两个接口(IUserBehavior
和IScenceController
).
对于需要接收用户点击事件的游戏对象,需要将ClickGUI类作为一个组件添加上去:
1 | //在MyCharacterController中 |
用户界面类UserGUI
控制的是整个场景,和ClickGUI
的方法类似,只不过作为组件添加到IScenceController
的实例FirstController
上。
1 | public class MainController : MonoBehaviour, ISceneController, IUserAction { |
对运动的单独控制
最开始阅读这个游戏的代码时,会发现和所有Controller放在一个文件下的还有一个Moveable
用来控制有位置变化的对象的运动。
最开始觉得,将position的变化在定义每个改变状态的函数(如MyCharacterController.getOnBoat()
),但会发现这里需要改变牧师/魔鬼的onBoat
,而且还要将boat的空位作为参数传入或者是在MyCharacterController
的函数中调用BoatController
的getEmptyPosition()
。
虽然这样做看上去没有大问题,但这里的位置变化并不是简单地从起点到终点直线运动那么简单。因为魔鬼和牧师不可能穿越河岸到船上,所以对于一个折线运动的控制倘若在每个上岸下船、下岸上船的运动中都实现一遍,不仅会增加代码量、出错几率,假如将来运动的轨迹要变得更加复杂或者说有更多的对象要在更多的位置之间移动,还是单独建立一个控制运动的类Moveable
比较方便。
1 | public class Moveable : MonoBehaviour { |
另外从软件工程多人合作实现一个项目的角度看,将运动的实现单独分出一个人负责实现,而另一个人只需要调用相应的函数,告知目的地,游戏对象即可按照Moveable
中定义的轨迹运行而不需了解运动函数内部的实现原理。在更复杂的工程项目中,将各个控制板块分离会更易于分工合作,提高开发效率。