角色与武器的实现--桥接模式

思考并回答以下问题:

  • ICharacter接口是干嘛用的?你有写接口的意识吗?怎么写?
  • 两类角色和三类武器这两个群组原先的架构是交互使用,这样会存在什么问题?新增角色类会怎么样?新增武器类型时呢?
  • 桥接模式的官方定义是什么?类图是什么样的?
  • 为什么不同的游戏角色驾驶不同的行动载具可以使用桥接模式?多个角色与多个法术类群组呢?
  • 我方角色和敌方角色有什么共同点?

本章涵盖:

  • 游戏角色的架构
  • 角色类的规划
  • 角色与武器的关系
  • 桥接模式
    • 桥接模式的定义
    • 桥接模式的说明
    • 桥接模式的实现范例
  • 使用桥接模式实现角色与武器接口
    • 角色与武器接口设计
    • 实现说明
    • 使用桥接模式的优点
    • 实现桥接模式的注意事项
  • 桥接模式面对变化时
  • 结论

游戏角色的架构

《P级阵地》的世界中包含两个阵营:“玩家阵营”和“敌方阵营”。玩家阵营的角色必须通过训练的方式从兵营中产生;而敌方阵营的角色,则是不断地从地图上的某个地点自动出现,一次一队往玩家守护的营地前进。

双方阵营的角色也有一些共享的部分:

  • 角色属性:每个角色都有“生命力”和“移动速度”两个属性,不同角色单位之间利用不同的属性进行区分。
  • 装备武器:每个角色能装备一把武器用来攻击对手,每把武器利用“攻击力”和“攻击距离”来区分不同的武器。
  • 人工智能(AI):由于玩家只决定玩家阵营要训练哪一个兵种出来防守阵营(玩家不负责如何防守攻击),而敌方阵营的角色则是会自动攻击的作战单位,所以双方角色都通过人工智能(AI)来协助移动和攻击。

在角色的表现上,《P级阵地》使用3D模型来呈现每一个角色,而每个角色也都有代表的2D图标(Icon)显示于玩家界面上,如图A和图B所示。

图A 使用Unity3D角色

图B 双方游戏角色

两方阵营不同之处在于:

  • 产生方式:玩家阵营的角色必须经由训练的方式,从兵营中产生;敌方阵营的角色,则是会不断地从场景上产生。
  • 等级:玩家阵营的单位可以通过“兵营升级”的方式,提高角色的等级来增加防守优势;敌方阵营的角色则没有等级的设置。
  • 爆击能力:敌方阵营的角色有一定的概率会以“爆击”来增加攻击优势;玩家阵营的单位则没有爆击能力。

角色类的规划

按上述的需求说明,在Unity3D进行实现时,可以先抽象化双方阵营“角色”的属性和操作,成为一个角色接口(ICharacter)来定义双方阵营角色的共享操作接口,如图C所示。

图C 角色接口ICharacter

角色接口(ICharacter.cs)

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
public abstract class ICharacter
{
protected string m_Name = ""; // 名称
protected GameObject m_GameObject = null; // 显示的Unity模型
protected NavMeshAgent m_NavAgent = null; // 用于控制角色移动
protected AudioSource m_Audio = null;
protected string m_IconSpriteName = ""; // 显示Icon

protected bool m_bKilled = false; // 是否阵亡
protected bool m_bCheckKilled = false; // 是否确认过阵亡事件
protected float m_RemoveTimer = 1.5f; // 阵亡后多久删除
protected bool m_bCanRemove = false; // 是否可以删除

// 构造函数
public ICharacter(){}

// 设置Unity模型
public void SetGameObject( GameObject theGameObject )
{
m_GameObject = theGameObject ;
m_NavAgent = m_GameObject.GetComponent<NavMeshAgent>();
m_Audio = m_GameObject.GetComponent<AudioSource>();
}

// 获取Unity模型
public GameObject GetGameObject()
{
return m_GameObject;
}

// 释放
public void Release()
{
if( m_GameObject != null)
GameObject.Destroy( m_GameObject);
}

// 名称
public string GetName()
{
return m_Name;
}

// 设置Icon名称
public void SetIconSpriteName (string SpriteName)
{
m_IconSpriteName = SpriteName;
}

// 获取Icon名称
public string GetIconSpriteName()
{
return m_IconSpriteName ;
}
}

由于游戏玩法中设计了两个阵营角色,并且存在差异,所以在此阶段中,先规划出两个子类来继承ICharacter类。一个为代表玩家阵营的ISoldier类,另一个则是代表敌方阵营的IEnemy类,如图D所示。

图D 代表玩家阵营和敌方阵营的两个子类

Soldier角色接口(ISoldier.cs)

1
2
3
4
5
6
public abstract class ISoldier : ICharacter
{
...
public ISoldier(){}
...
}

Enemy角色接口(IEnemy.cs)

1
2
3
4
5
6
public abstract class IEnemy : ICharacter
{
...
public IEnemy(){}
...
}

在后续的章节中,我们将进行角色接口(ICharacter)中各项属性和功能的说明,并说明运用各种设计模式后所新增的子类。

角色与武器的关系

在《P级阵地》中设计了3种武器类型:手枪、散弹枪及火箭,并以“攻击力”和“攻击距离”,来区分它们的威力。此外,“武器发射”和“击中目标”时也会有不同的音效(Audio)和视觉(Effect)效果。双方阵营都可以装备这3种武器,但敌方角色使用武器攻击时,会有额外的加成效果来增加攻击时的优势,而玩家角色则没有额外的加成效果。

综上所述,这些游戏设计需求给程序设计人员的第一个印象是:这是两个群组类要一起合作完成的功能,如图1所示。

图1 角色与武器

图1中的每一个行列的交叉点都是可能的组合,所以刚开始实现时,最容易想到的方法就是将所有可能组合的程序代码都写出来,例如先将武器声明为一个类,并声明一个枚举类型来定义3种武器:

Listing1 第一次实现可能采用的方式

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// 武器类
public enum ENUM_Weapon
{
Null = 0,
Gun = 1,
Rifle = 2,
Rocket = 3,
Max ,
}

// 武器接口
public class Weapon
{
// 属性
protected ENUM_Weapon m_emWeapon = ENUM_Weapon.Null; // 类型
protected int m_AtkValue = 0; // 攻击力
protected int m_AtkRange = 0; // 攻击距离
protected int m_AtkPlusValue = 0; // 额外加成值

// 构造函数
public Weapon(ENUM_Weapon Type, int AtkValue, int AtkRange)
{
m_emWeapon = Type;
m_AtkValue = AtkValue;
m_AtkRange = AtkRange;
}

// 得到武器类型
public ENUM_Weapon GetWeaponType()
{
return m_emWeapon;
}

// 攻击目标
public void Fire( ICharacter theTarget )
{
...
}

// 设置额外攻击力
public void SetAtkPlusValue(int AtkPlusValue)
{
m_AtkPlusValue = AtkPlusValue;
}

// 显示子弹特效
public void ShowBulletEffect(Vector3 TargetPosition, float LineWidth, float DisplayTime)
{
...
}

// 显示枪口特效
public void ShowShootEffect()
{
...
}

// 播放音效
public void ShowSoundEffect(string ClipName)
{
...
}
}

在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
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// Enemy角色接口
public class IEnemy : ICharacter
{
public IEnemy()
{}

// 攻击目标
public override void Attack( ICharacter theTarget)
{
// 发射特效
m_Weapon.ShowShootEffect();
int AtkPlusValue = 0;

// 按当前武器決定攻击方式
switch(m_Weapon.GetWeaponType())
{
case ENUM_Weapon.Gun:
// 显示武器特效及音效
m_Weapon.ShowBulletEffect(theTarget.GetPosition(),0.03f,0.2f);
m_Weapon.ShowSoundEffect("GunShot");

// 有概率增加额外加成
AtkPlusValue = GetAtkPlusValue(5,20);
break;

case ENUM_Weapon.Rifle:
// 显示武器特效及音效
m_Weapon.ShowBulletEffect(theTarget.GetPosition(),0.5f,0.2f);
m_Weapon.ShowSoundEffect("RifleShot");

// 有概率增加额外加成
AtkPlusValue = GetAtkPlusValue(10,25);
break;

case ENUM_Weapon.Rocket:
// 显示武器特效及音效
m_Weapon.ShowBulletEffect(theTarget.GetPosition(),0.8f,0.5f);
m_Weapon.ShowSoundEffect("RocketShot");

// 有概率增加额外加成
AtkPlusValue = GetAtkPlusValue(15,30);
break;
}

// 设置额外加成值
m_Weapon.SetAtkPlusValue( AtkPlusValue );

// 攻击
m_Weapon.Fire( theTarget );
}

// 获取额外的加成值
private int GetAtkPlusValue(int Rate, int AtkValue)
{
int RandValue = UnityEngine.Random.Range(0,100);
if( Rate > RandValue )
return AtkValue;
return 0;
}
}

Listing3 Soldier用武器攻击

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
35
36
37
38
// Soldier角色接口
public class ISoldier : ICharacter
{
public ISoldier()
{}

// 攻击目标
public override void Attack( ICharacter theTarget)
{
// 发射特效
m_Weapon.ShowShootEffect();

// 按当前武器決定攻击方式
switch(m_Weapon.GetWeaponType())
{
case ENUM_Weapon.Gun:
// 显示武器特效及音效
m_Weapon.ShowBulletEffect(theTarget.GetPosition(),0.03f,0.2f);
m_Weapon.ShowSoundEffect("GunShot");
break;

case ENUM_Weapon.Rifle:
// 显示武器特效及音效
m_Weapon.ShowBulletEffect(theTarget.GetPosition(),0.5f,0.2f);
m_Weapon.ShowSoundEffect("RifleShot");
break;

case ENUM_Weapon.Rocket:
// 显示武器特效及音效
m_Weapon.ShowBulletEffect(theTarget.GetPosition(),0.8f,0.5f);
m_Weapon.ShowSoundEffect("RocketShot");
break;
}

// 攻击
m_Weapon.Fire( theTarget );
}
}

两个类在重新定义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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// DirectX引擎 
public class DirectX
{
public void DXRender(string ObjName)
{
Debug.Log ("DXRender:"+ObjName);
}
}

// OpenGL引擎
public class OpenGL
{
public void GLRender(string ObjName)
{
Debug.Log ("OpenGL:"+ObjName);
}
}


// 球体
public abstract class ISphere
{
public abstract void Draw();
}

ISphere是一个抽象类(接口),在其中声明了一个Draw()方法,让子类可以重新实现要如何绘制这个球体。因为要支持两种3D绘图API,所以要再定义继承ISphere的两个子类,由这两个子类分别实现,以支持不同的3D绘图API,结构图如图4所示。

图4 定义继承ISphere的两个子类以支持OpenGL和DirectX两种3D绘图API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 球体使用Direct绘出
public class SphereDX : ISphere
{
DirectX m_DirectX;

public override void Draw()
{
m_DirectX.DXRender("Sphere");
}
}

// 球体使用Direct绘出
public class SphereGL : ISphere
{
OpenGL m_OpenGL;

public override void Draw()
{
m_OpenGL.GLRender("Sphere");
}
}

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
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
// 绘图引擎
public abstract class RenderEngine
{
public abstract void Render(string ObjName);
}

// DirectX引擎
public class DirectX : RenderEngine
{
public override void Render(string ObjName)
{
DXRender(ObjName);
}

public void DXRender(string ObjName)
{
Debug.Log ("DXRender:"+ObjName);
}
}

// OpenGL引擎
public class OpenGL : RenderEngine
{
public override void Render(string ObjName)
{
GLRender(ObjName);
}

public void GLRender(string ObjName)
{
Debug.Log ("OpenGL:"+ObjName);
}
}

将绘图引擎定义为RenderEngine后,再分别继承出两个子类:DirectX和OpenGL。在两个子类中将父类定义的接口功能重新实现,然后在IShape类中增加一个RenderEngine的类成员,并提供一个SetRenderEngine()方法,让系统能指定当前使用的绘图引擎:

Listing5 绘图引擎使用桥接模式的具体实现(抽象体接口)

1
2
3
4
5
6
7
8
9
10
11
12
// 形状
public abstract class IShape
{
protected RenderEngine m_RenderEngine = null;

public virtual void SetRenderEngine( RenderEngine theRenderEngine )
{
m_RenderEngine = theRenderEngine;
}

public abstract void Draw();
}

抽象体接口定义之后,其下所有的子类都可以通过m_RenderEngine对象来调用当前指定的绘图引擎:

Listing6 绘图引擎使用桥接模式的具体实现(抽象体接口的子类)

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
// 球体
public class Sphere : IShape
{
public override void Draw()
{
m_RenderEngine.Render("Sphere");
}
}

// 立方体
public class Cube : IShape
{
public override void Draw()
{
m_RenderEngine.Render("Cube");
}
}

// 圆柱体
public class Cylinder : IShape
{
public override void Draw()
{
m_RenderEngine.Render("Cylinder");
}
}

由于RenderEngine将绘图引擎的功能与使用接口类分离,让原本依赖实现的程度降到最低。

新的范例同样是在“只依赖接口而不依赖实现”的原则下实现的。只不过,重构后的3D绘图引擎工具中,同时存在着“抽象接口”与“实现接口”,而“抽象接口”中的实现类现在依赖“实现接口”的接口,不再依赖它的实现类了。

使用桥接模式实现角色与武器接口

定义哪个群组类是“抽象类”,哪个又是“实现类”并不容易。不过,如果从两个类群组的交叉合作开始分析,那么对于桥接模式的运用就不会那么困难了。

角色与武器接口设计

桥接模式除了能够应用在“抽象与实现”的分离之外,还可以应用在:

1
当两个群组因为功能上的需求,想要连接合作,但又希望两组类可以各自发展不受彼此影响时

本章开始所描述的角色与武器的游戏功能需求满足上述的情况:“角色类群组”想要使用“武器类群组”的功能(攻击),并且希望避免游戏开发后期,因为新增角色或新增武器而影响到另一个类群组,所以采用了桥接模式来实现,设计后的类结构如图11所示。

图11 采用桥接模式实现“角色类群组”和“武器类群组”的情况

参与者的说明如下:

  • ICharacter:角色的抽象接口拥有一个IWeapon对象引用,并且在接口中声明了一个武器攻击目标WeaponAttackTarget()方法让子类可以调用,同时要求继承的子类必须在Attack()中重新实现攻击目标的功能。
  • ISoldier、IEnemy:双方阵营单位,实现攻击目标Attack()时,只需要调用父类的WeaponAttackTarget()方法,就可以使用当前装备的武器攻击对手。
  • IWeapon**:武器接口,定义游戏中对于武器的操作和使用方法。
  • WeaponGun、WeaponRifle、WeaponRocket:游戏中可以使用的3种武器类型的实现。

实现说明

将原先的武器类重新定义为IWeapon武器接口:

Listing7 桥接模式中的武器接口(IWeapon.cs)

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
// 武器接口
public abstract class IWeapon
{
// 属性
protected int m_AtkPlusValue = 0; // 额外增加的攻击力
protected int m_Atk = 0; // 攻击力
protected float m_Range= 0.0f; // 攻击距离

protected GameObject m_GameObject = null; // 显示的Uniyt模型
protected ICharacter m_WeaponOwner = null; // 武器的拥有者

// 发射特效
protected float m_EffectDisplayTime = 0;
protected ParticleSystem m_Particles;
protected LineRenderer m_Line;
protected AudioSource m_Audio;
protected Light m_Light;

...

// 显示子弹特效
protected void ShowBulletEffect(Vector3 TargetPosition,
float LineWidth,
float DisplayTime)
{
if( m_Line ==null)
return ;
m_Line.enabled = true;
m_Line.SetWidth( LineWidth,LineWidth);
m_Line.SetPosition(0,m_GameObject.transform.position);
m_Line.SetPosition(1,TargetPosition);
m_EffectDisplayTime = DisplayTime;
}

// 显示枪口特效
protected void ShowShootEffect()
{
if( m_Particles != null)
{
m_Particles.Stop ();
m_Particles.Play ();
}

if( m_Light != null)
m_Line.enabled = true;
}

// 播放音效
protected void ShowSoundEffect(string ClipName)
{
if(m_Audio==null)
return ;

// 获取音效
IAssetFactory Factory = PBDFactory.GetAssetFactory();
AudioClip theClip = Factory.LoadAudioClip( ClipName);
if(theClip == null)
return ;
m_Audio.clip = theClip;
m_Audio.Play();
}

...

// 攻击目标
public void Fire( ICharacter theTarget )
{
...
}

...
}

除了定义武器的相关属性外,也将与特效有关的程序代码实现在父类中,供继承的子类调用。最后则是声明一个“攻击目标Fire()”抽象方法,让每个子类重新实现该武器在攻击对手时所需的功能:

Listing8 桥接模式中的武器实现

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// Gun
public class WeaponGun : IWeapon
{
public WeaponGun()
{}

// 攻击目标
public void Fire( ICharacter theTarget )
{
// 显示武器特效和音效
ShowShootEffect();
ShowBulletEffect( theTarget.GetPosition(),0.03f,0.2f );
ShowSoundEffect("GunShot");

// 攻击直接命中
theTarget.UnderAttack( m_WeaponOwner );
}
} // WeaponGun.cs

public class WeaponRifle : IWeapon
{
public WeaponRifle()
{}

// 攻击目标
public void Fire( ICharacter theTarget )
{
// 显示武器特效和音效
ShowShootEffect();
ShowBulletEffect( theTarget.GetPosition(),0.5f,0.2f );
ShowSoundEffect("RifleShot");

// 攻击直接命中
theTarget.UnderAttack( m_WeaponOwner );
}
} // WeaponRifle.cs

public class WeaponRocket : IWeapon
{
public WeaponRocket()
{}

// 攻击目标
public void Fire( ICharacter theTarget )
{
// 显示武器特效和音效
ShowShootEffect();
ShowBulletEffect( theTarget.GetPosition(),0.8f,0.5f );
ShowSoundEffect("RocketShot");

// 攻击直接命中
theTarget.UnderAttack( m_WeaponOwner );
}
} // WeaponRocket.cs

每一种武器都重新实现了“攻击目标Fire()”这个方法。客户端(拥有武器的角色)调用该方法后,武器会对目标发动攻击,过程包含了显示特效和音效,最后则是通知目标受到攻击,并把攻击它的武器以参数方式传递过去。但是,在当前实现的程序代码中,每一种武器的实现内容仍相同,而且重复了3次,这里还有改进的空间,这一部分的改进方式将留在攻击特效与击中反应-模板方法模式中说明。

最后,在角色接口ICharacter的定义中增加一个类型为IWeapon的成员属性,用来记录当前装备的武器:

Listing9 桥接模式中的角色接口(ICharacter.cs)

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// 角色接口
public abstract class ICharacter
{
private IWeapon m_Weapon = null; // 使用的武器

// 设置使用的武器
public void SetWeapon(IWeapon Weapon)
{
if( m_Weapon != null)
m_Weapon.Release();
m_Weapon = Weapon;

// 设置武器拥有者
m_Weapon.SetOwner(this);

// 设置Unity GameObject的层级
UnityTool.Attach(
m_GameObject,
m_Weapon.GetGameObject(),
Vector3.zero
);
}

// 获取武器
public IWeapon GetWeapon()
{
return m_Weapon;
}

// 设置额外攻击力
protected void SetWeaponAtkPlusValue(int Value)
{
m_Weapon.SetAtkPlusValue( Value );
}

// 武器攻击目标
protected void WeaponAttackTarget( ICharacter Target)
{
m_Weapon.Fire( Target );
}

// 计算攻击力
public int GetAtkValue()
{
// 武器攻击力 + 角色属性的加成
return m_Weapon.GetAtkValue();
}

// 获取攻击距离
public float GetAttackRange()
{
return m_Weapon.GetAtkRange();
}

// 攻击目标
public abstract void Attack(ICharacter Target);

// 被其他角色攻击
public abstract void UnderAttack(ICharacter Attacker);
}

除了增加一个IWeapon类成员m_Weapon外,也定义了和武器相关的方法,让客户端可以调用,并且声明了两个抽象方法,让继承的子类重新定义:

Listing10 桥接模式中的角色实现

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
35
36
37
38
// Soldier角色接口
public class ISoldier : ICharacter
{
...
// 攻击目标
public override void Attack(ICharacter Target)
{
// 武器攻击
WeaponAttackTarget(Target);
}

// 被武器攻击
public override void UnderAttack(ICharacter Attacker)
{
...
}
} // ISoldier.cs

// IEnemy角色接口
public class IEnemy : ICharacter
{
...
// 攻击目标
public override void Attack(ICharacter Target)
{
// 设置武器的额外攻击力加成
SetWeaponAtkPlusValue(m_Weapon.GetAtkPlusValue());

// 武器攻击
WeaponAttackTarget(Target);
}

// 被武器攻击
public override void UnderAttack(ICharacter Attacker)
{
...
}
} // IEnemy.cs

在玩家阵营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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class WeaponConnon : IWeapon
{
public WeaponConnon(){}

// 攻击目标
public override void Fire(ICharacter theTarget)
{
// 显示武器特效和音效
ShowShootEffect();
ShowBulletEffect(theTarget.GetPosition(), 0.1f, 0.5f);
ShowSoundEffect("CannonShot");

// 直接命中攻击
theTarget.UnderAttack(m_WeaponOwner);
}
}

而在角色群组的扩充上,也完全不必受到武器系统的限制。俘兵-适配器模式将会说明在《P级阵地》中角色群组顺应游戏需求而做的类扩充。

结论

桥接模式可以将两个群组有效地分离,让两个群组彼此互相不受影响。这两个群组可以是“抽象定义”与“功能实现”,也可以是两个需要交叉合作后才能完成某项任务的类。

与其他模式的合作

角色的组装-建造者模式中,《P级阵地》将使用建造者模式(Builder)负责产生游戏中的角色对象,当角色产生时会设置需要装备的武器,而设置武器的操作则是由角色接口中的方法来完成。

其他应用方式

两组类群组需要搭配使用的实现方式,常见于游戏设计中,例如:

  • 游戏角色可以驾驶不同的行动载具,如汽车、飞机、水上摩托车……。
  • 奇幻类型游戏的角色可以施展法术,除了多样的角色之外,“法术”本身也是另一个复杂的系统,火系法术、冰系法术……,远程法术、近战法术、补血法术……,想额外加上使用限制的话,就必须使用桥接模式让角色与法术类群组妥善结合。
0%