思考并回答以下问题:
- ICharacter接口是干嘛用的?你有写接口的意识吗?怎么写?
- 两类角色和三类武器这两个群组原先的架构是交互使用,这样会存在什么问题?新增角色类会怎么样?新增武器类型时呢?
- 桥接模式的官方定义是什么?类图是什么样的?
- 为什么不同的游戏角色驾驶不同的行动载具可以使用桥接模式?多个角色与多个法术类群组呢?
- 我方角色和敌方角色有什么共同点?
本章涵盖:
- 游戏角色的架构
- 角色类的规划
- 角色与武器的关系
- 桥接模式
- 桥接模式的定义
- 桥接模式的说明
- 桥接模式的实现范例
- 使用桥接模式实现角色与武器接口
- 角色与武器接口设计
- 实现说明
- 使用桥接模式的优点
- 实现桥接模式的注意事项
- 桥接模式面对变化时
- 结论
游戏角色的架构
《P级阵地》的世界中包含两个阵营:“玩家阵营”和“敌方阵营”。玩家阵营的角色必须通过训练的方式从兵营中产生;而敌方阵营的角色,则是不断地从地图上的某个地点自动出现,一次一队往玩家守护的营地前进。
双方阵营的角色也有一些共享的部分:
- 角色属性:每个角色都有“生命力”和“移动速度”两个属性,不同角色单位之间利用不同的属性进行区分。
- 装备武器:每个角色能装备一把武器用来攻击对手,每把武器利用“攻击力”和“攻击距离”来区分不同的武器。
- 人工智能(AI):由于玩家只决定玩家阵营要训练哪一个兵种出来防守阵营(玩家不负责如何防守攻击),而敌方阵营的角色则是会自动攻击的作战单位,所以双方角色都通过人工智能(AI)来协助移动和攻击。
在角色的表现上,《P级阵地》使用3D模型来呈现每一个角色,而每个角色也都有代表的2D图标(Icon)显示于玩家界面上,如图A和图B所示。
图A 使用Unity3D角色
图B 双方游戏角色
两方阵营不同之处在于:
- 产生方式:玩家阵营的角色必须经由训练的方式,从兵营中产生;敌方阵营的角色,则是会不断地从场景上产生。
- 等级:玩家阵营的单位可以通过“兵营升级”的方式,提高角色的等级来增加防守优势;敌方阵营的角色则没有等级的设置。
- 爆击能力:敌方阵营的角色有一定的概率会以“爆击”来增加攻击优势;玩家阵营的单位则没有爆击能力。
角色类的规划
按上述的需求说明,在Unity3D进行实现时,可以先抽象化双方阵营“角色”的属性和操作,成为一个角色接口(ICharacter)来定义双方阵营角色的共享操作接口,如图C所示。
图C 角色接口ICharacter
角色接口(ICharacter.cs)
1 | public abstract class ICharacter |
由于游戏玩法中设计了两个阵营角色,并且存在差异,所以在此阶段中,先规划出两个子类来继承ICharacter类。一个为代表玩家阵营的ISoldier类,另一个则是代表敌方阵营的IEnemy类,如图D所示。
图D 代表玩家阵营和敌方阵营的两个子类
Soldier角色接口(ISoldier.cs)
1 | public abstract class ISoldier : ICharacter |
Enemy角色接口(IEnemy.cs)
1 | public abstract class IEnemy : ICharacter |
在后续的章节中,我们将进行角色接口(ICharacter)中各项属性和功能的说明,并说明运用各种设计模式后所新增的子类。
角色与武器的关系
在《P级阵地》中设计了3种武器类型:手枪、散弹枪及火箭,并以“攻击力”和“攻击距离”,来区分它们的威力。此外,“武器发射”和“击中目标”时也会有不同的音效(Audio)和视觉(Effect)效果。双方阵营都可以装备这3种武器,但敌方角色使用武器攻击时,会有额外的加成效果来增加攻击时的优势,而玩家角色则没有额外的加成效果。
综上所述,这些游戏设计需求给程序设计人员的第一个印象是:这是两个群组类要一起合作完成的功能,如图1所示。
图1 角色与武器
图1中的每一个行列的交叉点都是可能的组合,所以刚开始实现时,最容易想到的方法就是将所有可能组合的程序代码都写出来,例如先将武器声明为一个类,并声明一个枚举类型来定义3种武器:
Listing1 第一次实现可能采用的方式
1 | // 武器类 |
在Weapon类中,将攻击力、攻击距离和额外加成的值都声明为类属性,并提供相关的操作方法。然后在角色类中增加一个“记录当前使用武器”的类成员:1
2
3
4
5
6
7
8
9// 角色接口
public abstract class ICharacter
{
// 拥有一把武器
protected Weapon m_Weapon = null;
// 攻击目标
public abstract void Attack(ICharacter theTarget);
}
声明一个抽象方法Attack(),让持有武器的角色利用这把武器去攻击另一个角色。因为不同武器在发射时,会产生不同的音效和特效,所以将此方法声明为抽象方法,以便让武器子类能够针对不同的需求重新定义这个方法。另一项需求则是:敌方阵营使用武器攻击时有额外的加成效果,所以在实现上,代表玩家阵营的角色ISoldier,以及代表敌方阵营的IEmeny在重新定义Attack()方法时,自然也会有不一样的实现内容:
Listing2 Enemy使用武器攻击
1 | // Enemy角色接口 |
Listing3 Soldier用武器攻击
1 | // Soldier角色接口 |
两个类在重新定义Attack()方法时,都先获取武器的类型,再按照类型播放不同的音效和特效(switch case)。另外,IEmeny还实现了“获取额外的加成值”的功能。
将两种角色与3种武器交叉组合,然后以上述方式实现,会存在以下两个缺点:
(1)每个继承自ICharacter角色接口的类,在重新定义Attack方式时,都必须针对每一种武器来实现(显示特效和播放音效),或者进行额外的公式计算。所以当要新增角色类时,也要在新的子类中重复编写相同的程序代码,如图2所示。
图2 两种角色与3种武器交叉组合编程的示意图
(2)当要新增武器类型时,所有角色子类中的Attack方法,都必须修改,针对新的武器类型编写新的对应程序代码。这样会增加维护的难度,使得武器类型不容易增加。
一般来说,上述的情况可以视为两个类群组交互使用所引发的问题。
GoF的设计模式中,桥接模式可以用来解决上述实现方式的缺点。
桥接模式
笔者认为,在GoF的23种设计模式中,桥接模式是最好应用但也是最难理解的,尤其是它的定义不长,其中关键的“抽象与实现分离(Decouple an abstraction from its implementation)”,常让程序设计师花费许多时间,才能慢慢了解它背后所代表的原则。
桥接模式的定义
桥接模式(Bridge),在GoF中的解释是:1
将抽象与实现分离,使二者可以独立地变化。
多数人会以为这是“只依赖接口而不依赖实现”原则的另外一个解释:
“定义一个接口类,然后将实现的部分在子类中完成。”
客户端只需要知道“接口类”的存在,不必知道是由哪一个实现类来完成功能的。而实现类则可以有好几个,至于使用哪一个实现类,可能会按照当前系统设置的情况来决定。程序设计师大多都可以按照这个原则进行系统实现,假设我们先按这个原则实现下面的案例,来看看会出现什么问题。
假设:我们要实现一个“3D绘画工具”,并且要支持当前最常见的OpenGL和DirectX两种3D绘图API。
首先,定义“球体”这个类和两个绘图引擎,如图3所示。
图3 定义“球体”类和两个绘图引擎
1 | // DirectX引擎 |
ISphere是一个抽象类(接口),在其中声明了一个Draw()方法,让子类可以重新实现要如何绘制这个球体。因为要支持两种3D绘图API,所以要再定义继承ISphere的两个子类,由这两个子类分别实现,以支持不同的3D绘图API,结构图如图4所示。
图4 定义继承ISphere的两个子类以支持OpenGL和DirectX两种3D绘图API
1 | // 球体使用Direct绘出 |
SphererDX代表使用DirectX绘制球体;SphereGL代表使用OpenGL绘制球体。因为满足“只依赖接口而不依赖实现”的原则,所以客户端只需要知道ISphere接口,至于由哪一个实现类负责完成所需功能,则交给系统决定。如果系统判断客户端当前在Windows操作系统下,那么就会选择使用DirectX绘制,即会指定SphererDX这个实现类;如果是处于Mac操作系统环境下,则会选择使用OpenGL绘制,即指定SphereGL这个实现类。
现在再增加一个“立方体”类, 且因为“球体”与“立方体”可以再一般化为一个“形状”(IShape)父类,类结构图如图5所示。
图5 定义“形状”父类后的类结构图
接下来,若系统再继续开发,继续增加“圆柱体”时,会变成如图6所示的设计。
图6 增加“圆柱体”子类后的设计图
发现了吗?我们每增加一个“形状”的子类,都必须为新的子类再实现两个孙类,两个孙类中再以DirectX和OpenGL实现Draw()方法。为什么会这样呢?原因是,每一个形状的Draw方法要在不同的引擎上绘制时,都必须先用“继承”的方式产生新的子类后,才能在各自的Draw()方式中调用对应的“绘图工具”来绘制该形状,例如:
- 想要在OpenGL上绘制一个球体,就先要“继承”球体类来产生一个子类,之后在子类的Draw()方法中调用“OpenGL引擎”函数来绘制球体;
- 想要在Directx上绘制一个球体,就先要“继承”球体类来产生一个子类,之后在子类的Draw()方法中调用“DirectX引擎”函数来绘制球体。
我们把实现“不同功能”交给“不同的子类”来完成,也就是利用“继承的方式”来完成“不同功能的实现”,这种方式看似直截了当,但在某些应用上并不是那么聪明。就以上述的“3D绘画工具”为例,这样利用“继承实现”的解法,反而增加系统维护的难度:也就是每增加一个“形状”子类,就必须连带增加“两个实现类”。
最麻烦的是,如果这个“3D绘画工具”想要在移动设备上运行,就必须支持“OpenGL ES”引擎,意思就是得再增加第3个绘图引擎作为实现的方法,所以设计会变成如图7所示。
图7 增加第3个绘图引擎后的设计图
更糟糕的是,“OpenGL ES”还会因为移动设备支持的程度,又分为OpenGL ES1、OpenGL ES2、OpenGL ES3…。此时,所有的“形状”子类都要加上GLES:SphereGLES1、CubeGLES1…。这会造成非常难维护的情况,因为系统扩充时会连带修改或新增许多类,而且每个绘图工具类还会不断地增加与其他形状类的耦合度(即依赖度)。
但在当前的架构下,不同功能的实现,当前仅采用“继承实现”这个方式。“继承”是“功能实现”的方式之一,但如果“功能实现”被限制在只能使用“继承”方式来达成,则是不乐观的。
桥接模式的说明
如果要避免被限制在只能以“继承实现”来完成功能实现,可考虑使用桥接模式。桥接模式是有别于上述解法的另一种解决方式。从先前的例子中可以看出,基本上这是两个类组群之间,关系呈现“交叉组合汇编”的情况:
- 群组一的“抽象类”指的是将对象或功能经“抽象”之后所定义出来的类接口,并通过子类继承的方式产生多个不同的对象或功能。例如上述的“形状”类,其用途是用来描述一个有“形状”的对象应该具备的功能和操作方式。所以,这个群组只负责增加“抽象类”,不负责实现“接口定义的功能”。
- 群组二的“实现类”指的是这些类可以用来实现“抽象类”中所定义的功能。例如上述例子中的OpenGL引擎类和DirectX引擎类,它们可以用来实现“形状”类中所定义的“绘出”功能,能将形状绘制到屏幕上。所以,这个群组只负责增加“实现类”。
“群组一类”中的每一个类,可以使用“群组二类”中的每一个类来实现所定义的功能。
在重新设计后,我们将绘图工具当作群组二中的“实现类”,所以先要一般化出一个接口类(“抽象类”),再分别继承不同的实现类,如图8所示。
图8 群组二中的“实现类”一般化出一个接口类
在“抽象类”中包含一个“实现类”的对象引用m_RenderEngine,如图9所示。
图9 “抽象类”中包含一个“实现类”的对象,引用m_RenderEngine
继承“抽象类”的子类需要实现功能时,只要通过“实现类”的对象引用m_RenderEngine来调用实现功能即可。这样一来,就真正让“抽象与实现分离”,也就是“抽象不与实现绑定”,让“球体”或“立方体”这种抽象概念的类,不再通过产生不同子类的方式去完成特定的“实现方式”(OpenGL或DirectX),将“抽象类群组”与“实现类群组”彻底分开。
运用桥接模式后的“形状”类,不必再考虑要使用OpenGL还是DirectX进行绘制,因为RenderEngine类接口,已经真正实现与客户端(IShape)分开了。
如图10所示为GoF定义的桥接模式的结构图。
图10 GoF定义的桥接模式的结构图
参与者的说明如下:
- Abstraction(抽象体接口)
- 拥有指向Implementor的对象引用。
- 定义抽象功能的接口,也可作为子类调用实现功能的接口。
- RefinedAbstraction(抽象体实现、扩充)
- 继承抽象体并调用Implementor完成实现功能。
- 扩充抽象体的接口,增加额外的功能。
- Implementor(实现体接口)
- 定义实现功能的接口,提供给Abstraction(抽象体)使用。
- 接口功能可以只有单一的功能,真正的选择则再由Abstraction(抽象体)的需求加以组合应用。
- ConcretelmplementorA/B(实现体)
- 实际完成实现体接口上所定义的方法。
桥接模式的实现范例
以下为“3D绘图工具”运用桥接模式后的范例。首先定义绘图引擎使用的接口:
Listing4 绘图引擎使用桥接模式的具体实现(实现体接口和实现体)
1 | // 绘图引擎 |
将绘图引擎定义为RenderEngine后,再分别继承出两个子类:DirectX和OpenGL。在两个子类中将父类定义的接口功能重新实现,然后在IShape类中增加一个RenderEngine的类成员,并提供一个SetRenderEngine()方法,让系统能指定当前使用的绘图引擎:
Listing5 绘图引擎使用桥接模式的具体实现(抽象体接口)
1 | // 形状 |
抽象体接口定义之后,其下所有的子类都可以通过m_RenderEngine对象来调用当前指定的绘图引擎:
Listing6 绘图引擎使用桥接模式的具体实现(抽象体接口的子类)
1 | // 球体 |
由于RenderEngine将绘图引擎的功能与使用接口类分离,让原本依赖实现的程度降到最低。
新的范例同样是在“只依赖接口而不依赖实现”的原则下实现的。只不过,重构后的3D绘图引擎工具中,同时存在着“抽象接口”与“实现接口”,而“抽象接口”中的实现类现在依赖“实现接口”的接口,不再依赖它的实现类了。
使用桥接模式实现角色与武器接口
定义哪个群组类是“抽象类”,哪个又是“实现类”并不容易。不过,如果从两个类群组的交叉合作开始分析,那么对于桥接模式的运用就不会那么困难了。
角色与武器接口设计
桥接模式除了能够应用在“抽象与实现”的分离之外,还可以应用在:1
当两个群组因为功能上的需求,想要连接合作,但又希望两组类可以各自发展不受彼此影响时
本章开始所描述的角色与武器的游戏功能需求满足上述的情况:“角色类群组”想要使用“武器类群组”的功能(攻击),并且希望避免游戏开发后期,因为新增角色或新增武器而影响到另一个类群组,所以采用了桥接模式来实现,设计后的类结构如图11所示。
图11 采用桥接模式实现“角色类群组”和“武器类群组”的情况
参与者的说明如下:
- ICharacter:角色的抽象接口拥有一个IWeapon对象引用,并且在接口中声明了一个武器攻击目标WeaponAttackTarget()方法让子类可以调用,同时要求继承的子类必须在Attack()中重新实现攻击目标的功能。
- ISoldier、IEnemy:双方阵营单位,实现攻击目标Attack()时,只需要调用父类的WeaponAttackTarget()方法,就可以使用当前装备的武器攻击对手。
- IWeapon**:武器接口,定义游戏中对于武器的操作和使用方法。
- WeaponGun、WeaponRifle、WeaponRocket:游戏中可以使用的3种武器类型的实现。
实现说明
将原先的武器类重新定义为IWeapon武器接口:
Listing7 桥接模式中的武器接口(IWeapon.cs)
1 | // 武器接口 |
除了定义武器的相关属性外,也将与特效有关的程序代码实现在父类中,供继承的子类调用。最后则是声明一个“攻击目标Fire()”抽象方法,让每个子类重新实现该武器在攻击对手时所需的功能:
Listing8 桥接模式中的武器实现
1 | // Gun |
每一种武器都重新实现了“攻击目标Fire()”这个方法。客户端(拥有武器的角色)调用该方法后,武器会对目标发动攻击,过程包含了显示特效和音效,最后则是通知目标受到攻击,并把攻击它的武器以参数方式传递过去。但是,在当前实现的程序代码中,每一种武器的实现内容仍相同,而且重复了3次,这里还有改进的空间,这一部分的改进方式将留在攻击特效与击中反应-模板方法模式中说明。
最后,在角色接口ICharacter的定义中增加一个类型为IWeapon的成员属性,用来记录当前装备的武器:
Listing9 桥接模式中的角色接口(ICharacter.cs)
1 | // 角色接口 |
除了增加一个IWeapon类成员m_Weapon外,也定义了和武器相关的方法,让客户端可以调用,并且声明了两个抽象方法,让继承的子类重新定义:
Listing10 桥接模式中的角色实现
1 | // Soldier角色接口 |
在玩家阵营ISoldier类重新实现Attack方法时,直接调用父类的WeaponAttackTarget方法,要求以当前装备的武器去攻击对手。但在敌方阵营IEnemy类中,重新实现的Attack方法在调用WeaponAttackTarget之前,会先将角色本身能造成的“额外加成效果”设置给装备的武器,以便后续“攻击效果计算”时,能使用到加成的属性。利用这样的方式,IEnemy类就可以达到游戏需求中提到的“敌方阵营使用武器攻击时,会有额外的加成效果,用来增加攻击时的优势”。
使用桥接模式的优点
运用桥接模式后的ICharacter(角色接口)就是群组一“抽象类”,它定义了“攻击目标”功能,但真正实现“攻击目标”功能的类,则是群组二IWeapon(武器接口)“实现类”。对于ICharacter及其继承类都不必理会IWeapon群组的变化,尤其是游戏开发后期可能增加的武器类型。而对于ICharacter来说,它面对的只有IWeapon这个接口类,相对地,IWeapon类群组也不必理会角色类群组内的新增或修改,让两个群组之间的耦合度降到最低。
实现桥接模式的注意事项
在实现角色接口ICharacter时,《P级阵地》将武器类IWeapon的变量定义为“私有成员”并提供一组操作函数。这些操作函数除了提供给外界的客户端操作使用外,另一层用意则是不让角色子类直接使用IWeapon成员。这项设计的好处在于,让武器系统的功能调用只限制在ICharacter类中,因此,武器类IWeapon只会和角色接口ICharacter产生耦合。这么做是因为当游戏制作进入后期时,下面几种情况是预期会出现的:
- 1.ICharacter类群组会产生变化,可能是增加角色类,也可能是增加角色的功能。
- 2.武器系统可能更复杂,攻击一个目标时可能需要设置更多的参数,而这些参数无法由角色子类提供。
- 3.可能将武器全部更换,换成另一种武器系统(如近战武器),所以需要引入另一组武器群组。
因为武器系统是《P级阵地》的核心系统之一,一旦产生变化很容易影响到其他系统,所以有必要在实现的初期,就将武器类IWeapon的操作与角色群组的子类加以解耦(解除依赖性)。
桥接模式面对变化时
应用了桥接模式的角色与武器系统,在后续的游戏系统设计上,增加了不少的弹性和灵活度。当需要新增武器类型时,继承IWeapon类并重新实现抽象方法后,就可让角色系统装备使用:
Listing11 新增一个武器类Cannon
1 | public class WeaponConnon : IWeapon |
而在角色群组的扩充上,也完全不必受到武器系统的限制。俘兵-适配器模式将会说明在《P级阵地》中角色群组顺应游戏需求而做的类扩充。
结论
桥接模式可以将两个群组有效地分离,让两个群组彼此互相不受影响。这两个群组可以是“抽象定义”与“功能实现”,也可以是两个需要交叉合作后才能完成某项任务的类。
与其他模式的合作
在角色的组装-建造者模式中,《P级阵地》将使用建造者模式(Builder)负责产生游戏中的角色对象,当角色产生时会设置需要装备的武器,而设置武器的操作则是由角色接口中的方法来完成。
其他应用方式
两组类群组需要搭配使用的实现方式,常见于游戏设计中,例如:
- 游戏角色可以驾驶不同的行动载具,如汽车、飞机、水上摩托车……。
- 奇幻类型游戏的角色可以施展法术,除了多样的角色之外,“法术”本身也是另一个复杂的系统,火系法术、冰系法术……,远程法术、近战法术、补血法术……,想额外加上使用限制的话,就必须使用桥接模式让角色与法术类群组妥善结合。