思考并回答以下问题:
- 访问者模式的官方定义是什么?
- 访问者模式属于什么类型的模式?应用场景有哪些?
- 为什么说访问者模式是最难的设计模式?
本章涵盖:
- 角色信息的提供
- 访问者模式
- 访问者模式的定义
- 访问者模式的说明
- 访问者模式的实现范例
- 使用访问者模式实现角色信息查询
- 角色信息查询的实现设计
- 实现说明
- 使用访问者模式的优点
- 实现访问者模式时的注意事
- 访问者模式面对变化时
- 结论
角色信息的提供
“角色”是游戏的重点,在《P级阵地》中也同样如此。游戏内提供了6种角色,分别属于不同的阵营,各有不同的造型和特色,再加上“角色属性”的设计,让每种角色在战场中的能力都不一样。因此,游戏要提供一个用户界面,让玩家可以了解每一个角色的状态。
角色信息界面
《P级阵地》游戏中的主角就是双方阵营的角色,玩家角色是通过玩家对兵营下达训练指令后,不断地产生新单位进入战场;而敌方角色则是由关卡系统(Stage System)产生。玩家一般是通过观察战场上各个角色的数量,来决定接下来要训练什么单位上场,所以如果能提供双方角色的信息作为引用,就能让玩家下达更正确的训练指令来防守玩家的阵地。
玩家阵营角色信息界面(SoldierInfoUI)是用来显示当前在战场上一个玩家阵营角色的信息,如图1所示。
图1 角色信息界面
玩家只要利用鼠标选中战场中的玩家角色,系统就会将该角色的信息显示在界面上,就和角色的组装-建造者模式的“利用增加鼠标单击判断脚本,在兵营对象上完成显示兵营信息”的运行原理是相同的。玩家角色在产生的过程中,有一个步骤会为角色加上鼠标单击判断的脚本组件(AddOnClickScript):
Listing1 利用Builder接口来构建对象(CharacterBuilderSystem.cs)
1 | public class CharacterBuilderSystem : IGameSystem |
在角色建造者系统(CharacterBuilderSystem)中插入了一个AddOnClickScript步骤,用来加入玩家单击判断的脚本组件。因为角色建造者系统实现了建造者模式(Builder),所以只需要在建造流程中加入此步骤即可。但当前只有玩家角色需要判断是否被玩家单击,即只有玩家角色的建造者(SoldierBuilder)重新实现了这个方法:
Listing2 Soldier各部位的构建(SoldierBuilder.cs)
1 | public class SoldierBuilder : ICharacterBuilder |
被加入的脚本组件是SoldierOnClick,用来负责判断玩家阵营的角色是否被单击:
Listing3 角色是否被单击(SoldierOnClick.cs)
1 | public class SoldierOnClick : MonoBehaviour |
脚本组件中声明的OnClick方法,会在系统判断“单击到某一个角色”时被调用。而该鼠标单击判断与兵营的单击判断是一样的,也是实现在PBaseDefenseGame类中用来负责判断玩家输入行为的方法(InputProcess):
Listing4 实现角色单击判断(PBaseDefenseGame.cs)
1 | public class PBaseDefenseGame |
如果判断被鼠标单击的GameObject包含的SoldierOnClick脚本组件是玩家阵营单位,就通过调用脚本组件中的OnClick方法,将玩家阵营角色的信息显示在玩家阵营角色信息界面(SoldierInfoUI)上:
Listing5 Soldier界面(SoldierInfoUI.cs)
1 | public class SoldierInfoUI : IUserInterface |
和实现其他界面相同,先获取界面上的显示组件后,通过ShowInfo方法将鼠标单击的角色信息显示出来。另外,类中也定义了几个提供给其他系统使用的方法。
角色数量的统计
当前双方角色在战场上的数量,是另一项玩家下决策时引用的依据,尤其对于攻防类型的游戏来说,当双方进入交战状态时,角色会交错站位、重叠显示,不容易看出当前双方角色的数量。因此,《P级阵地》决定在界面上增加一个“显示敌我双方数量”的信息,并且在兵营界面(CampInfoUI)上也显示由该兵营产生的角色当前还有多少存活于战场上,如图2所示。
图2 角色信息界面、兵营界面
在当前的《P级阵地》角色系统(CharacterSystem)中,已经将双方角色分别使用不同的泛型容器进行管理:
Listing6 管理产生出来的角色(CharacterSystem.cs)
1 | public class CharacterSystem : IGameSystem |
如果要满足第一个需求:将双方阵营的角色数量显示出来,那么简单的实现方式就是,增加角色系统(CharacterSystem)的操作方法,让外界可以获取这两个容器的数量:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16public class CharacterSystem : IGameSystem
{
...
// 获取Soldier数量
public int GetSoldierCount()
{
return m_Soldiers.Count;
}
// 获取Enemy数量
public int GetEnemyCount()
{
return m_Enemys.Count();
}
...
}
因为PBaseDefenseGame本身运用了多种设计模式,其中外观模式(Facade)和中介者模式(Mediator)分别作为各个游戏系统对外及对内的沟通接口,所以在PBaseDefenseGame类中,也必须增加对应的方法,让有需要的客户端或其他游戏系统来存取:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22public class PBaseDefenseGame
{
...
// 当前Soldier数量
public int GetSoldierCount()
{
if (m_CharacterSystem != null)
return m_CharacterSystem.GetSoldierCount();
return 0;
}
// 当前Enemy数量
public int GetEnemyCount()
{
if (m_CharacterSystem != null)
return m_CharacterSystem.GetEnemyCount();
return 0;
}
...
}
这样就完成了第一项需求:获取双方阵营的角色数量。那么对于第二项需求:兵营产生的角色当前还有多少存活在战场上,也可使用相同的步骤来完成。首先在角色系统(CharacterSystem)中增加方法:1
2
3
4
5
6
7
8
9
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
35public class CharacterSystem : IGameSystem
{
...
// 获取各Soldier单位的数量
public int GetSoldierCount(ENUM_Soldier emSoldier)
{
int Count = 0;
foreach (ISoldier pSoldier in m_Soldiers)
{
if (pSoldier == null)
continue;
if (pSoldier.GetSoldierType() == emSoldier)
Count++;
}
return Count;
}
...
}
然后在PBaseDefenseGame增加对应的方法:
```cs
public class PBaseDefenseGame
{
...
// 当前Soldier数量
public int GetSoldierCount(ENUM_Soldier emSoldier)
{
if (m_CharacterSystem != null)
return m_CharacterSystem.GetSoldierCount(emSoldier);
return 0;
}
...
}
如此,接口就可以通过这些方法来获取所需要的信息。但是,在完成这两项需求的同时,读者应该会发现,每加入一个与角色相关的功能需求时,就必须增加角色系统(CharacterSystem)的方法,也必须一并修改PBaseDefenseGame的接口。
然而,随着系统功能的增加,必须让两个类修改接口的实现方式就有缺点了。除了必须更改原本类的接口设计外,还增加了两个类的接口复杂度,使得后续的维护更为困难。假如现在系统又增加了第三个需求,要求统计当前场上敌方阵营不同角色的数量时,就势必得追加角色系统(CharacterSystem)的方法并修改PBaseDefenseGame类接口。
所以,针对角色系统中“管理双方的角色对象”,应该要提出一套更好的解决方式,将这种“针对每一个角色进行遍历或判断”的功能一致化,使其不随不同需求的增加而修改接口。
GoF的访问者模式提供了解决方案,让针对一群对象遍历或判断的功能,都能运用“同一组接口”方法来完成,过程中只会新增该功能本身的实现文件,对于原有的接口并不会产生任何更改。
访问者模式
笔者当初在学会访问者模式之后,第一个联想到的是C++ STL(STL是“Standard Template Library”的缩写,中文译为“标准模板库”,提供了通用的模板类和函数,这些模板类和函数可以实现多种流行和常用的算法和数据结构,如向量、链表、队列、栈。)当中的Function Object应用。
一个函数对象,即一个重载了括号操作符“()”的对象。当用该对象调用此操作符时,其表现形式如同普通函数调用一般,因此取名叫函数对象。
如果一个类将()运算符重载为成员函数,这个类就称为函数对象类,这个类的对象就是函数对象。函数对象是一个对象,但是使用的形式看起来像函数调用,实际上也执行了函数调用,因而得名。
下面是一个函数对象的例子。
1 |
|
程序的输出结果是:1
2.66667
()是数目不限的运算符,因此重载为成员函数时,有多少个参数都可以。
average 是一个对象,average(3, 2, 3)实际上就是average.operator(3, 2, 3),这使得average看上去像函数的名字,故称其为函数对象。
Listing7 计算某类型对象的数量并加总(C++程序代码)
1 | template <typename T> |
重新定义了一个具有计算功能(Accumulater\
访问者模式的定义
GoF对于访问者模式(Visitor)的定义是:1
定义一个能够在一个对象结构中对于所有元素执行的操作,访问者让你可以定义一个新的操作,而不必更改到被操作元素的类接口。
笔者认为上述定义的重点在于:定义一个新的操作,而不必更改到被操作元素的类接口,这完全符合“开一闭原则”(OCP)的要求,利用新增的方法来增加功能,而不是修改现有的程序代码来完成。下面通过实际例子来说明:
首先,我们回顾一下角色与武器的实现-桥接模式介绍桥接模式(Bridge)时所提到的范例,一个绘图引擎所使用到的IShape类群组,但此处我们另外再增加一些方法,类结构如图3所示。
图3 IShape类群组增加一些方法后的类结构图
Listing8 绘图引擎的实现
1 | public abstract class RenderEngine |
与那一章使用的IShape类一样,此处的IShape类也拥有一个RenderEngine的对象,用来在特定3D引擎下绘出形状。另外还增加了一些Shape类的方法,作为本章范例使用,同样也存在3个子类,所以基本上可以使用一个管理器类来管理所有产生的形状:1
2
3
4
5
6
7
8
9
10
11
12
13
14// 形状容器
public class ShapeContainer
{
List<IShape> m_Shapes = new List<IShape>();
public ShapeContainer()
{}
// 新增
public void AddShape(IShape theShape)
{
m_Shapes.Add ( theShape );
}
}
有了管理器之后,就可以将所有产生的形状都加入管理器中:1
2
3
4
5
6
7
8
9
10public void CreateShape()
{
DirectX theDirectX = new DirectX();
// 加入形状
ShapeContainer theShapeContainer = new ShapeContainer();
theShapeContainer.AddShape( new Cube(theDirectX) );
theShapeContainer.AddShape( new Cylinder(theDirectX) );
theShapeContainer.AddShape( new Sphere(theDirectX) );
}
接下来,如果想要将容器内所有的形状都绘制出来,就要增加形状容器类的方法:1
2
3
4
5
6// 绘出
public void DrawAllShape()
{
foreach(IShape theShape in m_Shapes)
theShape.Draw();
}
到当前为止,形状容器类新增的方法DrawAllShape符合定义中的前半段:“定义一个能够在一个对象结构中对于所有元素执行的操作”,DrawAllShape方法遍历了所有容器内的元素:IShape类对象,并执行了Draw方法。
但是,这个方法并不符合后半段的定义:“不必更改到被操作元素的类接口”,虽然定义指的是不更改IShape的接口,但我们要将其扩大引申为“同时也不能更改到管理容器类”。因为如果按当前的实现方式,那么所有新增在IShape类中的方法,一定会连带更改ShapeContainer形状容器类,或者要存取IShape方法就一定得通过ShapeContainer形状容器类。例如,现在要追加实现计算所有形状使用的顶点数:1
2
3
4
5
6
7
8
9
10
11
12
13
14// 形状容器
public class ShapeContainer
{
...
// 获取所有顶点数
public int GetAllVectorCount()
{
int Count = 0;
foreach (IShape theShape in m_Shapes)
Count += theShape.GetVectorCount();
return Count;
}
}
这样一来,又更改了ShapeContainer形状容器类的接口。而随着后续项目的更新或功能强化,将会不断增加ShapeContainer类的方法,这并不是很好的方式。运用访问者模式是比较好的选择,修正的步骤大致如下:
- 1.在ShapeContainer形状容器类中增加一个共享方法,这个方法专门用来遍历所有容器内的形状。
- 2.调用这个共享方法时,要带入一个继承自Visitor访问者接口的对象,而Visitor访问者接口内会提供不同的方法,这些方法会被不同的元素调用。
- 3.在IShape中新增一个RunVisitor抽象方法,让子类实现。而调用这个方法时,会将一个Visitor访问者接口对象传入,让IShape的子类,可以按情况调用Visitor类中特定的方法。
- 4.ShapeContainer新增的共享方法中,会遍历每一个IShape对象,并调用IShape新增的RunVisitor方法,并将Visitor访问者当成参数传入。
访问者模式的说明
在经过上述4个步骤的修改后,类结构会如图4所示。
图4 绘制形状类在运用访问者模式修改后的类结构图
参与者的说明如下:
- IShape(形状接口)
- 定义形状的接口操作。
- 包含了RunVisitor方法,来执行IShapeVisitor访问者中的方法。
- Sphere、Cylinder、Cube(各种形状)
- 3个实现形状接口的子类。
- 重新实现RunVisitor的方法,并根据不同的子类来调用IShapeVisitor访问者中的特定方法。
- ShapeContainer(形状容器)
- 包含所有产生的IShape对象。
- IShapeVisitor(形状访问者)
- 定义形状访问者的操作接口。
- 定义让每个不同形状可调用的方法。
- DrawVisitor、VectorCountVisitor、SphereVolumeVisitor(多个访问者)
- 实现IShapeVisitor形状访问者接口的子类。
- 实现与形状类功能有关的地方。
- 可以只重新实现特定的方法,建立只针对某个形状子类的操作功能。
访问者模式的实现范例
首先,定义形状访问者(IShapeVisitor)接口:
Listing9 定义访问者接口(ShapeVisitor.cs)
1 | public abstract class IShapeVisitor |
接口中针对现有的3个形状子类,定义了对应调用的方法。但比较特别的是,在这里都定义为虚函数(virtual funciton)而不是抽象函数(abstract function),原因在于,这样可以让每一个子类决定要重新实现的方法,让每一个子类可以更精确地实现该类所负责的功能。
将原本在形状容器(ShapeContainer)类定义的操作删除,然后增加一个可接受形状访问者(IShapeVisitor)对象的方法。
Listing10 形状容器(ShapeVisitor.cs))
1 | public class ShapeContainer |
在RunVisitor方法中,遍历了每一个List容器内的IShape对象,并调用每一个对象的RunVisitor方法,该方法定义在IShape类接口中:
Listing11 形状的定义(ShapeVisitor.cs)
1 | public abstract class IShape |
IShape类接口中的RunVisitor是个抽象函数(abstract function),必须由各个子类重新实现:
Listing12 各形状的重新实现(ShapeVisitor.cs)
1 | // 球体 |
每一个形状子类在重新实现的RunVisitor方法中,直接调用由参数传入的IShapeVisitor(形状访问者)对象的方法,调用的方法分别对应了自己所属的子类,并将自己对象(this)的引用传入调用的方法中。
经过上列的修改后,形状容器(ShapeContainer)类算是完成了访问者模式。而定义中的前半段:“定义一个能够在一个对象结构中对于所有元素执行的操作”,是由ShapeContainer类的RunVistor方法和IShape类中的方法来实现的,而定义的后半段:“访问者让你可以定义一个新的操作,而不必更改到被操伴元素的类接口”,则可以通过接下来的范例来进行说明。
同样是利用修改好的ShapeContainer类,如果想要让容器内所有的IShape对象执行绘图功能,只要定义一个继承IShapeVisitor的子类DrawVisitor,并且重新实现所有的方法,在这些方法中调用每一个传入对象的Draw函数就可以实现:
Listing13 绘图功能的Visitor(ShapeVisitor.cs)
1 | public class DrawVisitor : IShapeVisitor |
只增加一个类来负责实现调用每一个传入对象的Draw方法就可以实现目标,完全不必再更改到其他的类接口。通过下面的测试范例,可以完整地看到使用的流程:
Listing14 测试绘图功能的Visitor(ShapeVisitorTest.cs)
1 | void UnitTest () |
再继续实现原来范例中要求的“计算顶点数”功能,这项功能由新的IShapeVisitor子类VectorCountVisitor来完成:
Listing15 计数顶点数的Visitor(ShapeVisitor.cs)
1 | public class VectorCountVisitor : IShapeVisitor |
类中定义了一个成员,用来计算当前累计的顶点数,执行时与绘图功能的范例一样,不需要改动到其他的类接口就可以完成要求的功能:
Listing16 测试计算顶点数的Visitor(ShapeVisitorTest.cs)
1 | void UnitTest () |
最后,实现一个只针对球体(Sphere)计算的体积并加总的功能:
Listing17 计算球体体积的Visitor(ShapeVisitor.cs)
1 | public class SphereVolumeVisitor : IShapeVisitor |
因为只针对球体(Sphere),所以类中只重新实现了VisitSphere方法来进行加总操作:
Listing18 测试计算球体体积的Visitor(ShapeVisitorTest.cs)
1 | void UnitTest () |
与执行计算顶点的访问者一样,先产生SphereVolumeVisitor对象,再调用形状容器(ShapeContainer)的RunVisitor访问者方法,最后输出计算结果。
上面三项功能实现时,只新增了负责实现的类,并未更改到原有的类接口,符合了访问者模式定义后半段的要求:“访问者让你可以定义一个新的操作,而不必更改到被操作元素的类接口”。
执行绘图访问者的流程图
执行绘图访问者时的流程图如图5所示,每一个形状子类都先通过调用访问者中的对应方法,再来执行每一个类中被重新实现后的方法。
图5 执行绘图访问者时的流程图
使用访问者模式实现角色信息查询
笔者在每一款游戏的实现中,都会出现管理某一类对象的需求,除了基本的新增、删除、读取等操作之外,遍历容器并执行功能是另一个经常要做的事。也就是因为常常需要遍历管理容器,所以程序代码中常常看到foreach遍历某个管理容器的程序代码,而在每个功能的实现上,差别仅在于会影响到多少的现有类而已。
所以,在还没使用访问者模式之前,项目就会很像本章最前面的范例那样,必须连续更改好几个类才能获取新增的功能,或者是在管理容器类中加入单例模式(Singleton),让客户端能快速获取,并立即使用新增的功能。但若善用访问者模式则可以让项目更具有稳定性,尤其是在新增功能且不想影响现有功能实现的情况时,特别方便。
角色信息查询的实现设计
回到《P级阵地》中,分析“双方角色数量的统计”这个需求。如果只考虑单项功能的实现,那么原来的方式就已经完成了。但为了后续开发过程可能会增加的查询需求,我们将《P级阵地》运用访问者模式,让后续针对遍历所有角色并执行特定功能的需求,都能通过同一个接口方法来完成。
按照前一节提示的修改步骤,《P级阵地》的角色系统(CharacterSystem)在运用访问者模式后,其结构如图6所示。
图6 角色系统在运用访问者模式后的类结构图
参与者的说明如下:
- ICharacterVisitor:角色访问者接口,针对《P级阵地》的双方阵营角色类,声明了对应的调用方法。
- UnitCountVisitor:统计双方阵营角色数量的访问者。
- CharacterSystem:角色系统,定义了一个共享的方法RunVisitor来执行角色访问者。
- ICharacter:角色类,增加了一个让角色访问者(ICharacterVisitor)可以执行的方法:RunVisitor。该方法是抽象函数(abstract function),必须由子类重新实现。
- ISoldier、SoldierCaption、…、IEnemy、EnemyElf、…:双方阵营的角色类,其中都会重新实现RunVisitor方法,并按照类本身的特色,调用角色访问者(ICharacterVisitor)中对应的方法。
- PBaseDefenseGame:因为角色系统(CharacterSystem)是游戏的子系统,需通过PBaseDefenseGame的方法RunCharacterVisitor来传递信息。
- Client:《P级阵地》中,所有需要执行角色遍历功能的地方。
实现说明
先定义角色访问者的接口:
Listing19 定义角色Visitor接口(ICharacterVisitor.cs)
1 | public abstract class ICharacterVisitor |
类中针对《P级阵地》的每一个角色类(ICharacter)都定义了一个对应的虚函数(Virtual Function),比较特别的是,在每一个方法之中,都会调用父类的方法,会这样实现的原因是:可以让每一个最底层的子类角色对象被遍历时,也都可以一并执行到父类的访问方法,让每一层的类都可以被遍历到。
在角色接口(ICharacter)增加让角色访问者(ICharacterVisitor)可以执行的方法:
Listing20 角色接口(ICharacter.cs)
1 | public abstract class ICharacter |
在角色系统(CharacterSystem)中,删除原有角色数量统计的方法,然后加上一个能让所有战场上的角色来执行的角色访问者(ICharacterVisitor)方法:
Listing21 管理产生出来的角色(CharacterSystem.cs)
1 | public class CharacterSystem : IGameSystem |
因为角色系统(CharacterSystem)属于游戏子系统(IGameSystem),所以必须通过PBaseDefenseGame作为沟通的渠道。因此,在PBaseDefenseGame类中也增加执行角色系统访问者的方法,并一并删除之前角色数量统计所使用的方法:1
2
3
4
5
6
7
8
9
10
11
12
13public class PBaseDefenseGame
{
...
// 游戏系统
private CharacterSystem m_CharacterSystem = null; //角色管理系统
...
// 执行角色系统的Visitor
public void RunCharacterVisitor (ICharacterVisitor Visitor)
{
m_CharacterSystem.RunVisitor( Visitor);
}
}
新增了相关角色访问者(ICharacterVisitor)所需执行的方法后,就可以开始实现角色计数功能的访问者了:
Listing22 各单位计数访问者(UnitCountVisitor.cs)
1 | public class UnitCountVisitor : ICharacterVisitor |
角色单位计数访问者(UnitCountVisitor)重新实现了每一个虚函数,每一个函数在被调用时,都会增加该单位的计数器。因为之前的设计会调用父类的方法,所以包含父类层级ICharacter、ISoldier、IEnemy也都可以借助对应的成员,来获取当前场地内所有类角色的数量以及双方阵营单位的存活数量。最后,提供了方便的访问方法GetUnitCount,使得可按参数返回指定类的计数器属性。
在游戏状态信息界面(GameStateInfoUI)中显示出双方阵营角色的数量:
Listing23 游戏状态信息(GameStateInfoUI.cs)
1 | public class GameStateInfoUI : IUserInterface |
实现上,只需要产生角色访问者的对象,然后通过PBaseDefenseGame定义的接口,就能让角色系统(CharacterSystem)所管理的所有角色对象都能够被传送到角色计数访问者(UnitCountVisitor)中,进行角色数量的加总计数。
另外,还有一个要使用到计数功能的则是兵营界面(CampInfoUI):
Listing24 兵营界面(CampInfoUI.cs)
1 | public class CampInfoUI : IUserInterface |
同样地,我们可以利用如图7所示的流程图来了解各个对象之间互动的情形。
图7 各个对象之间互动的流程图
使用访问者模式的优点
使用的角色访问者(ICharacterVisitor)让遍历每一个角色对象并执行特定功能变得容易了许多。在不必更改任何类接口的情况下,新增的功能只需要实现新的角色访问者子类即可,大幅增加了系统的稳定性,也减少了对类接口不必要的修改。
实现访问者模式时的注意事项
访问者模式的优点正如上面所讲的,但在实现访问者模式时,还有一些需要注意的地方。
当增加了新的角色类时
访问者模式的缺点之一是,当角色类(ICharacter)群组增加子类时,那么角色访问者(ICharacterVistor)必须新增一个对应调用的方法,而这个新增的操作会引起所有子类进行相同的改动,并且需要对每一个子类进行检查,以确定是否需要重新实现新增的方法。
被访问类的封装性变差
在《P级阵地》中,被访问的类就是角色类群组。在运用访问者模式的情况下,被访问的类必须尽可能提供所有可能的操作和信息,这样才能在实现新的访问者时,不会因为缺少需要的方法,而连带修改角色类接口。但是过度地开放角色类的方法和信息,不仅会破坏类的封装性,也会增加其他系统与角色类的依赖度(或耦合度)。
访问者模式面对变化时
在《P级阵地》的某次项目会议上,有人提议到……
测试:“大家有没有觉得,如果在玩家每次过关时,系统能给予奖励的话,是不是能成为玩家想过关的诱因。”
企划:“你是说什么样的奖励?”
测试:“类似以前街机的射击游戏,过了一关,就会补满大炸弹,我们可以考虑要不要增加类似的过关奖励,或是给予存活单位增加什么功能,作为过关时的奖励,既可强化守护优势,又可增加玩家游戏时的策略选择。”
企划:“嗯…是个不错的构想,可以试试给予存活角色增加一个勋章的方式,就像获得特殊荣誉那样,存活的越多次累计越多,而勋章累计数可以对应到一个角色的加成属性,用来强化攻守能力。至于属性的设置,就交给我们企划来烦恼,不过是否能实现出来,以及所需要的时间,还是请小程评估一下。”
小程:“应该不难实现…”
小程脑子里转了一下,想了想当前项目的架构:
- 第一:应该是更改一下ISoldier的接口,让它增加一些与勋章有关的方法。
- 第二:增加勋章的触发点可以加在新关卡产生的时刻,可利用已经运用观察者模式(Observer)的游戏事件系统(GameEventSystem),新增一个过关主题(NewStageSubject)的观察者,就可以办到。
- 第三:至于怎么让存活在战场上的ISoldier都可以增加勋章,应该是让角色系统(CharacterSystem)遍历所有在战场上的ISoldier对象,通知他们都可以增加一个勋章数,这恰好可以利用最近完成的访问者模式来实现。
小程:“以当前的项目构架要实现没有太大问题,可以很容易串联相关功能。不过,玩家角色获得勋章可对应到一个角色加成属性,然后用来强化攻守能力,这一部分,我们系统还没有加入这样的机制,这一部分是不是…”
企划:“是的是的,这一部分我们企划还在规划中,等完成后,会再加入游戏需求中。可以先将流程都串接好,等属性加成的功能都设计好了,再加上去,好吗?”
程序:“好的”
于是小程在之后的实现中,先完成了ISoldier接口的修正,增加与勋章有关的成员和方法:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21// Soldier角色接口
public abstract class ISoldier : ICharacter
{
protected int m_MedalCount = 0; // 勋章数量
protected const int MAX_MEDAL = 3; // 最多勋章数量
...
// 增加勋章
public virtual void AddMedal()
{
if( m_MedalCount >= MAX_MEDAL)
return ;
// 增加勋章
m_MedalCount++;
// 获取对应的勋章加成值
// TODO: 等待企划完成规划
}
...
} // ISoldier.cs
然后,增加了一个ISoldier角色勋章的访问者:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19// 增加Solder勋章
public class SoldierAddMedalVisitor : ICharacterVisitor
{
PBaseDefenseGame m_PBDGame = null;
public SoldierAddMedalVisitor( PBaseDefenseGame PBDGame)
{
m_PBDGame = PBDGame;
}
public override void VisitSoldier(ISoldier Soldier)
{
base.VisitSoldier( Soldier);
Soldier.AddMedal();
// 游戏事件
m_PBDGame.NotifyGameEvent( ENUM_GameEvent.SoldierUpgate, Soldier);
}
}
新增的访问者类只重新实现了VisiSoldier方法,这是因为只有ISoldier类的对象才会进行增加勋章的操作,最后也通知了游戏事件系统(GameEventSystem),有玩家阵营单位要升级。
之后再新增一个过关主题(NewStageSubject)的观察者,用来串接“过关事件”与“ISoldien角色增加勋章”的关联:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25// 订阅新关卡-增加Solder勋章
public class NewStageObserverSoldierAddMedal : IGameEventObserver
{
private NewStageSubject m_Subject = null;
private PBaseDefenseGame m_PBDGame = null;
public NewStageObserverSoldierAddMedal(PBaseDefenseGame PBDGame)
{
m_PBDGame = PBDGame;
}
// 设置观察的主题
public override void SetSubject( IGameEventSubject Subject )
{
m_Subject = Subject as NewStageSubject;
}
// 通知Subject被更新
public override void Update()
{
// 增加勋章
SoldierAddMedalVisitor theAddMedalVisitor = new SoldierAddMedalVisitor(m_PBDGame);
m_PBDGame.RunCharacterVisitor( theAddMedalVisitor );
}
}// NewStageObserverSoldierAddMedal.cs
当收到过关通知(Update)时,产生ISoldier角色勋章访问者的对象,然后通过PBaseDefenseGame的方法,让角色系统(CharacterSystem)访问者遍历所有的角色对象。
最后,在角色系统(CharacterSystem)中,向游戏事件系统注册新的观察者,完成串接:1
2
3
4
5
6
7
8
9
10
11public class CharacterSystem : IGameSystem
{
...
public CharacterSystem(PBaseDefenseGame PBDGame) : base(PBDGame)
{
Initialize();
// 注册事件
m_PBDGame.RegisterGameEvent(ENUM_GameEvent.NewStage, new NewStageObserverSoldierAddMedal(m_PBDGame));
}
...
} // CharacterSystem.cs
通过小程这次对新增需求的实现过程,我们可以了解到,除了因为原本需求没有勋章功能所做的更改外,后续针对新增功能的部分,都是使用新增类的方式来完成的:
- 配合成就系统-观察者模式已运用观察者模式(Observer)来实现的游戏事件系统(GameEventSystem),利用新增观察者类的方式,就可以让特定游戏事件发生后串接新功能。
- 加上本章所说明的访问者模式(Visitor),利用新增访问者类的方式,就可以完成遍历所有角色对象并执行特定功能的实现需求。
至于应对“注册游戏事件”而更改的角色系统(CharacterSystem)则是必须更改的,但并不会影响系统的稳定性,必要时,更可独立出一个静态类,专门用来集中处理所有的注册事件。
结论
运用访问者模式后的系统,可以利用新增访问者类的方式,来遍历所有对象并执行特定功能的操作,过程中不需要更改任何其他的类。但是新增被访问者类时,会造成系统大量的修改,这是必须注意的,而被访问者对象需要开放足够的操作方法和信息则是访问者模式的另一个缺点。
其他应用方式
在一般的游戏中,除了角色系统之外,其他系统也常需要使用“遍历所有对象”的功能,如角色的道具包、当前已收集的卡片、可以使用的宠物等,针对装载这些对象的“管理容器”类,经常会需要更改类接口来满足游戏新增的需求,此时就可以选择使用访问者模式来减少“管理容器”类的更改。