前缀字尾--装饰模式

思考并回答以下问题:

  • 装饰模式的官方定义是什么?
  • 装饰模式为什么是结构型模式?
  • 将“一个具有新功能的类,加入到现在类群组结构中,且不会破坏原有架构和接口”哪三种模式是这样的?
  • 装饰者模式是在开—闭原则下实现动态添加或减少功能的一种方式。怎么理解?
  • 装饰者模式就是不修改原类代码和继承的情况下动态扩展类的功能,传统的编程模式都是子类继承父类实现方法重载,使用装饰器模式,只需要加一个新的装饰器对象,更加灵活,避免数量和层次过多。怎么理解?

本章涵盖:

  • 前缀后缀系统
  • 装饰模式
    • 装饰模式的定义
    • 装饰模式的说明
    • 装饰模式的实现范例
  • 使用装饰模式实现前缀后缀的功能
    • 前缀后缀功能的架构设计
    • 实现说明
    • 使用装饰模式的优点
    • 实现装饰模式时的注意事项
  • 装饰模式面对变化时
  • 结论

调整与优化

有3个可用于“不同类整合”的模式。这3个设计模式都属于结构模式(Structural Patterns),它们都是如何将“一个具有新功能的类,加入到现在类群组结构中,且不会破坏原有架构和接口”的解决模式。这3个模式分别为:

  • Decorator装饰模式;
  • Adapter适配器模式;
  • Proxy代理模式。

由于这3个模式要解决的问题非常类似,因此初学者在第一次分别接触这些模式时,常会出现“这个问题好像也可以用另一个模式来解决?”或“这样解决跟另一个模式有什么不一样吗?”之类的疑问。因此,利用图解的方式来分辨这3个模式,这样就能够清楚了解到这3个模式在使用上的差异。

图解的分辨说明如下:在现有的系统中存在A和B两个类,并存在B继承A的关系。现在有一个新增功能要加入到这个系统中,而这个新功能以C类来实现,那么这个C类要如何加入到原有的系统之中(参考下图):

这3个模式都是用来说明:如何将类C加入到原有的类架构中,但加入的方式有些不同,如下图分解:

只要能分辨出类C要如何与类A、B之间建立关联,就可以了解3种模式之间的差异。

前缀后缀系统

角色信息查询-访问者模式中提到,在某一次的项目会议上,提出要以“增加ISoldier角色勋章”作为过关奖励,而勋章数量又会对应到某一个角色属性(CharacterAttr),并将这个属性加成到角色原有的属性上,来强化角色的能力,增加防守优势。

对于其中提到的“角色属性加成”规则,现在有了更明确的功能需求:

  • 能动态增加玩家角色的角色属性(CharacterAttr)。
  • 增加的属性分为两部分:
    • 前缀:当兵营训练完一个角色进入战场时,会出现给这个新角色一个角色属性加成的机会,而新增的角色属性名称,需置于现有属性名称的前方。
    • 后缀:当玩家通过一个关卡之后,让所有仍在场上存活的ISoldier角色,增加一个勋章数,最多累计三个,而每个勋章数都会对应到一个角色属性作为加成值,而新增的勋章属性名称,需置于现有属性名称的后方。
  • 完成的属性名称,需显示在角色信息界面上,让玩家能立即了解当前角色的能力值。

需求中提及的前缀、后缀的概念,很早就出现在电玩游戏中(读者也可以对比两张游戏截图图1和图12来了解),如暗黑破坏神、魔兽世界等,都大量使用了前缀、后缀系统来多样化它们的游戏道具系统。除了系统性的分配属性系统外,游戏企划人员可利用交叉汇编的方式,自动产生大量的道具,而这些加成属性也会反应在道具的名称上,让玩家可以分辨,例如:游戏的装备道具中有两双鞋:勇士鞋(速度+5)和战士靴(速度+7),两双鞋本身就带有增加“移动速度”的角色属性。今天如果希望再额外多设置三、四双道具鞋,而之间的差异可能只是想要多增加“闪避”的效果,那么可以先设计一系列有闪避属性的前缀,例如:轻巧(+1%)、灵活(+2%)、迅捷(+3%)、闪耀(+4%),当产生道具鞋时,就随机加上这些前缀及其所代表的闪避属性,而产生下列这些可能的组合:

  • 轻巧勇士鞋(闪避+1%,速度+5);
  • 灵活勇士鞋(闪避+2%,速度+5);
  • … …
  • 闪耀战士靴(闪避+4%,速度+7)。

如此就能组合出8种变化,再加上原本没有属性的两双,道具系统一共可动态产生的鞋种类就达10双。而之后无论是增加鞋子道具或前缀,在交叉组合之后,都能获得倍数以上的道具种类。所以对于游戏设计而言,“前缀后缀系统”是一种很常使用的设计工具。

在《P级阵地》中,前缀后缀功能只使用于玩家角色,用意在于:

  • 通过前缀的加成属性,玩家得以利用训练新作战单位的方式,产生属性较好的角色,另一方面也给玩家有一种“抽奖”的惊喜感;
  • 后缀则作为奖励玩家过关之用,也连带增加了玩家在游戏策略上的选择。

以上简单说明了在游戏设计层面为什么要增加这两项功能。回头来看看该如何以程序实现出这些功能呢?我们要思考的是如何让“角色属性(CharacterAttr)”做出“加成”的效果,而且还要能表现出“前缀”与“后缀”的文字呈现效果。

在现有的角色信息界面(SoldierInfoUI)中,角色下方显示的角色名称是如何设置的呢?在原本的设计中,是通过角色类ICharacter的获取名称方法GetName返回类成员m_Name的字符串,来代表要显示的角色名称,如图1所示。

图1 显示角色名称

角色类(ICharacter)内部,对于m_Name的设置则是发生在ICharacter设置角色属性(SetCharacterAttr)时,同时获取并设置的:

Listing1 角色接口(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
public abstract class ICharacter
{
...
protected string m_Name = ""; // 名称
protected ICharacterAttr m_Attribute = null; // 属性
...
// 设置角色属性
public virtual void SetCharacterAttr(ICharacterAttr CharacterAttr)
{
// 设置
m_Attribute = CharacterAttr;
m_Attribute.InitAttr();

// 设置移动速度
m_NavAgent.speed = m_Attribute.GetMoveSpeed();
// Debug.Log("设置移动速度: " + m_NavAgent.speed);

// 名称
m_Name = m_Attribute.GetAttrName();
}

// 获取角色名称
public string GetCharacterName()
{
return m_Name;
}
}

而在现有的角色属性类(ICharacterAttr)中,获取属性名称(GetAttrName)方法是如何返回属性名称的呢?是向基本属性类(BaseAttr)获取的:

Listing2 角色属性接口(ICharacterAttr.cs)

1
2
3
4
5
6
7
8
9
10
11
public abstract class ICharacterAttr
{
protected BaseAttr m_BaseAttr = null; // 基本角色属性
...
// 获取属性名称
public virtual string GetAttrName()
{
return m_BaseAttr.GetAttrName();
}
...
}

基本属性类(BaseAttr)是在游戏属性管理功能-享元模式说明享元模式(Flyweight)时新增的一个类,该类用来代表《P级阵地》中可以被企划设置的角色属性,成员包含了游戏角色所使用的属性:

Listing3 可以被共享的基本角色属性接口(BaseAttr.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
public class BaseAttr
{
private int m_MaxHP; // 最高HP值
private float m_MoveSpeed; // 当前移动速度
private string m_AttrName; // 属性的名称

public BaseAttr(int MaxHP, float MoveSpeed, string AttrName)
{
m_MaxHP = MaxHP;
m_MoveSpeed = MoveSpeed;
m_AttrName = AttrName;
}

public int GetMaxHP()
{
return m_MaxHP;
}

public float GetMoveSpeed()
{
return m_MoveSpeed;
}

public string GetAttrName()
{
return m_AttrName;
}
}

就当前的系统架构来看,如果要增加前缀、后缀功能的话,可以先增加几组基本属性(BaseAttr)对象来代表前缀和后缀加成值,然后在角色属性系统中,加入两个固定的字段来代表前缀和后缀加成值:

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
// 角色属性接口
public abstract class ICharacterAttr
{
protected BaseAttr m_BaseAttr = null; // 基本角色属性
protected BaseAttr m_PrefixAttr = null; // 前缀
protected BaseAttr m_SuffixAttr = null; // 后缀

...
// 设置前缀
public void SetPrefixAttr(BaseAttr PrefixAttr)
{
m_PrefixAttr = PrefixAttr;
}

// 设置后缀
public void SetSuffixAttr(BaseAttr SuffixAttr)
{
m_SuffixAttr = SuffixAttr;
}

public int GetMaxHP()
{
// 前缀
int MaxHP = 0;
if (m_PrefixAttr != null)
MaxHP += m_PrefixAttr.GetMaxHP();

MaxHP += m_BaseAttr.GetMaxHP();

// 后缀
if(m_SuffixAttr != null)
MaxHP += m_SuffixAttr.GetMaxHP();

return MaxHP;
}

// 移动速度
public float GetMoveSpeed()
{
// 前缀
float MoveSpeed = 0;
if (m_PrefixAttr != null)
MoveSpeed += m_PrefixAttr.GetMoveSpeed();

MoveSpeed += m_BaseAttr.GetMoveSpeed();

// 后缀
if(m_SuffixAttr != null)
MoveSpeed += m_SuffixAttr.GetMoveSpeed();

return MoveSpeed;
}

// 获取属性名称
public string GetAttrName()
{
// 前缀
int AttrName = 0;
if (m_PrefixAttr != null)
AttrName += m_PrefixAttr.GetAttrName();

AttrName += m_BaseAttr.GetAttrName();

// 后缀
if(m_SuffixAttr != null)
AttrName += m_SuffixAttr.GetAttrName();

return AttrName;
}
...
}

虽然上面的修改方式,只会更改角色属性接口(ICharacterAttr),增加两个各自代表前缀(m_PrefixAttr)和后缀(m_SuffixAttr)的基本属性类(BaseAttr)成员,并且在获取各个属性数值时,先后判断前缀或后缀是否被设置,如果已被设置的话,则加上从对象中获取的角色属性,而且也能完成“前缀+原本属性+后缀”的组合要求。这看似简单,却是一种缺乏灵活度的解决方案。

如果后续还想调整组合的方式,变为“前缀+后缀+原本属性”,或者想要再增加其他如质量、附魔、镶嵌等额外的加成功能时,就变得很麻烦,因为每次的增加或修改都会改动角色属性接口(ICharacterAttr),让类的成员变得更多。此外,因为不是每个角色都会使用到所有的加成效果,所以声明的变量成员可能从来都没有被这个对象使用过,这会造成内存的浪费。而每一个相关的计算公式,也会由于增加的附加属性越多而越加复杂。

因此,实现时可能要思考是,是否有比较灵活的方式来呈现这种属性累加,让这些角色属性对象之间,建立某种关联,当加成效果存在时,就让代表它们的属性对象相互连接,之后再从这个连接之间获取所需的属性,如图2所示。

图2 显示角色名称

查阅GoF的设计模式,寻找其中哪个模式可以让对象之间建立连接并且可以产生额外的加成效果,装饰模式可以符合上述的要求。

装饰模式

首先,让我们先来理解GoF的装饰模式及其实现方式。

装饰模式的定义

GoF对于装饰模式(Decorator)的定义是:

1
动态地附加额外的责任给一个对象。装饰模式提供了一个灵活的选择,让子类可以用来扩展功能。

我们同样以角色与武器的实现-桥接模式介绍的“3D绘画工具”作为范例来说明。新版本的3D绘画工具需要增加一个新功能,就是能够在某个形状外围增加一个“外框”作为标示或编辑提示之用,如图3所示。

图3 绘图工具中,针对某个形状加外框作为提示

因此,我们在系统中增加了一个称为“额外功能”(IAdditional)的类群组,作为后续类似功能的群组。因为这个外框(Border)也会使用成像系统,所以实现时与原有的形状(IShape)群组相似,同样也使用到RenderEngine,如图4所示。

图4 增加额外功能的类群组,与之前的IShape群组类似

有几种方式可以让球体(Sphere)加上外框功能,其中一种是,在支持多重继承的程序设计语言中,除了让球体继承形状之外,同时也让球体继承外框功能,如图5所示。

图5 球体继承外框功能

但这种解决方案并不好,第一个原因是因为C#没有多重继承的功能;第二个原因则是,如果一定想要靠继承方式来实现目标,那么就只能靠着改变继承顺序,让形状类先去继承外框类来获取想要的外框功能,但这样一来,由于继承时会将父类的功能全都一并包含进来,因此这样做会增加复杂度。在游戏实现中的设计模式中,我们针对了“少用继承多用组合”的设计做过说明,读者可以回顾一下,就能了解继承与组合的差异所在。

改用组合的方式看起来似乎会好一些,也就是在球体或形状中加入一个外框成员,如图6所示。

图6 在球体或形状中加入一个外框成员

这种方式虽然相对于使用继承的方式要好得多,但是缺少灵活性。由于增加的成员是固定在类中,而且随着功能的增加就势必要再增加成员,而增加成员的同时也代表了必须增加类的接口方法。若新增的功能是项目开发后期才出现的需求,那么,贸然地更改接口就很容易造成系统的不稳定。

如果想要在“项目开发后期”为形状类加上额外功能,在不更改现有类的前提下,可以采用新增一个“形状子类”的方式来完成,只不过,这个新的形状子类(IShapeDecorator)本身并不是真的代表任何一种“形状”,但它是专门用来负责“将其他功能加入现有的形状之上”,如图7所示。

图7 新增一个“形状子类”(IShapeDecorator)

新增的子类中,会有一个引用成员用来记录其他形状类,也就是通过这个引用,新增的子类就能够将额外增加的功能加到指向的对象上。而新增的子类,就被称为“形状装饰者”,被记录下来的对象被称为“被装饰者”。

形状装饰者只负责执行“将额外功能加上的操作”,真正包含额外功能的类,其实是形状装饰者的子类,如图8所示。

图8 形状装饰者及其子类

外框装饰者(BorderDecoator)是形状装饰者的子类,它将负责执行增加外框的绘制操作。一样使用组合的方式,将外框功能加入到类中,当外框装饰者被调用时,它可以利用父类(IShapeDecorator)中的被装饰者引用,要求被装饰者引用先被绘制出来,然后再调用外框功能让形状能显示外框。

这也是定义中提到的“提供了一个灵活的选择,让子类可以用来扩展功能”。也就是说,当有新的子类加入类群组时,新增加的类不一定要完全符合“类封装时的抽象定义”(即形状装饰者及其子类不是“形状”),而是可以更灵活地选择成为“另一种功能”,这个功能可以用来协助原有类的功能扩展(在形状上增加外框)。

装饰模式的说明

对于参与装饰模式的4大成员(如图9所示),我们可以就上一小节提到的形状装饰者来加以说明。

图9 参与装饰模式的四大成员

参与者的说明如下:

  • IShape(形状接口)
    • 定义形状的接口及方法。
  • Sphere(形状的实现:球体)
    • 实现系统中所需要的形状。
  • IShapeDecorator(形状装饰者接口)
    • 定义可用来装饰形态的接口。
    • 增加一个指向被装饰对象的引用成员。
    • 需要调用被装饰对象的方法时,可通过引用成员来完成。
  • BorderDecorator(形状装饰者的实现:外框装饰者)
    • 实现形状装饰者。
    • 在调用“被装饰者的方法”之后或之前,都可以执行本身提供的附加装饰功能,来达到装饰的效果。

虽然在此我们使用“3D绘图工具”来说明装饰模式,但与GoF使用的结构图(如图10)差异不大,读者可以自行替换思考。

图10 GoF使用的结构图

装饰模式的实现范例

形状接口及球体的实现与角色与武器的实现-桥接模式的实现并无太大差异:

Listing4 实现形状接口与图形类(ShapeDecorator.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
// 形状
public abstract class IShape
{
protected RenderEngine m_RenderEngine = null;

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

public abstract void Draw();
public abstract string GetPolygon();
}

// 球体
public class Sphere : IShape
{
public override void Draw()
{
m_RenderEngine.Render("Sphere");
}

public override string GetPolygon()
{
return "Sphere多边形";
}
}

利用新增形状子类的方式来扩展功能,但这个新增的子类是一个装饰者,用来为形状组件增加额外的功能,并且装饰者本身并不一定符合“形状”所封装的抽象定义:

Listing5 形状装饰者接口(ShapeDecorator.cs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public abstract class IShapeDecorator : IShape
{
IShape m_Component;

public IShapeDecorator (IShape theComponent)
{
m_Component = theComponent;
}
public override void Draw()
{
m_Component.Draw();
}
public override string GetPolygon()
{
return m_Component.GetPolygon();
}
}

类中多了一个指向形状的对象引用(m_Component),而所有必须重新定义的抽象方法,都是直接调用这个引用指向对象(也就是被装饰者)的方法。因为形状装饰者本身并不执行任何绘制形状的功能,所以可以解释为:这个类并不一定满足“形状”所封装的抽象定义。

接下来,定义能为形状增加功能的额外功能类(IAdditional):

Listing6 实现能附加额外功能的类(ShapeDecorator.cs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public abstract class IAdditional
{
protected RenderEngine m_RenderEngine = null;

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

public abstract void DrawOnShape(IShape theShape);
}

//外框
public class Border : IAdditional
{
public override void DrawOnShape(IShape theShape)
{
m_RenderEngine.Render("Draw Border On "+ theShape.GetPolygon());
}
}

额外功能(IAdditional)基本上也需要有绘图的功能,所以同样必须在类中包含了一个绘图引擎的引用(m_RenderEngine)。而接口方法DrawOnShape可以让额外功能以形状为目标进行绘制,之后再实现一个在形状上绘出一个外框的子类:Border。

有了可以在形状上绘制外框的类后,就可以利用形状装饰者的子类外框装饰者(BorderDecorator)来进行整合:

Listing7 外框装饰者(ShapeDecorator.cs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class BorderDecorator: IShapeDecorator
{
//外框功能
Border m_Border = new Border();

public BorderDecorator (IShape theComponent) : base (thecomponent)
{}

public virtual void SetRenderEngine( RenderEngine theRenderEngine)
{
base.SetRenderEngine(theRenderEngine);
m_Border.SetRenderEngine(theRenderEngine);
}

public override void Draw()
{
// 被装饰者的功能
base.Draw();
// 外框功能
m_Border.DrawOnShape(this);
}
}

外框装饰者使用组合的方式,将外框功能加入其中作为额外增加的功能。因此,在重新定义的绘制(Draw)方法中,先调用了被装饰者的原本功能(即在画面上绘制形状),之后将增加的外框绘制在形状上。

从测试程序代码就能看出它们之间的组装方式:

Listing8 测试形状装饰者(DecoratorTest.cs)

1
2
3
4
5
6
7
8
9
10
11
12
13
void UnitTestShape()
{
OpenGL theOpenGL = new OpenGL();

// 球体
Sphere theSphere = new Sphere();
thesphere.SetRenderEngine(theOpenGL);

// 在图形加外框
BorderDecorator theSpherewithBorder = new BorderDecorator( thesphere )
theSpherewithBorder.SetRenderEngine(theOpenGL);
theSpherewithBorder.Draw();
}

执行后的信息正确地反映出,外框装饰者除了将原本的球体绘制出来之外,也在其上增加了外框:

执行结果

1
2
OpenGL: Sphere
OpenGL: Draw Border on Sphere多边形

请注意,由于装饰模式具有透明性(Transparency),因此可以一直不断地包覆下去。例如还可以实现出更多的额外功能:显示顶点、显示向量、显示多边形等。一个包覆一个的最终结果就是,可以绘制出一个有外框且在其顶点上会显示向量,又能同时显示多边形的形状。

此外,由于在实现的过程中并没有因为增加功能的关系,而去更改形状(IShape)类的接口,所以对于现有单纯只使用形状类对象的客户端影响不大。对于处于开发后期或维护时期的项目来说,想要在现有的类上追加新功能,装饰模式是一个不错的选项。

使用装饰模式实现前缀后缀的功能

正如前文所提到的,对于处于开发后期或维护时期的项目来说,更改原有设计或实现是不太好的修改方式,除非更改或新增的部分会造成系统的改头换面,例如新增的部分可能成为一个基础系统,否则通常不应该对项目进行大幅度调整。但有时候又因为不能进行大幅度修正,所以新功能就只能东加一些、西加一些,最后同样也会让整个项目变得很“杂乱”。GoF中的几种设计模式很适合在这种场合下运用,装饰模式就是其中之一。以下我们使用它来完成《P级阵地》新增的前缀后缀功能。

前缀后缀功能的架构设计

了解《P级阵地》对于前缀后缀功能的需求后,可以运用装饰模式的“动态地附加额外的责任/功能给一个对象”原理,把前缀后缀当作是“一层层包覆在原有角色基本属性BaseAttr)的额外功能”来解释。那么,我们就可以设计出结构来满足前缀/后缀功能的要求,如图11所示。

图11 前缀后缀功能的架构设计示意图

参与者的说明如下:

  • BaseAttr:定义基本属性接口。
  • CharacterBaseAttr:实现角色基本属性。
  • BaseAttrDecorator:定义基本属性装饰者接口,类中有一个引用成员,用来指向被装饰的基本属性对象。
  • AdditionalAttr:加成用的属性,且有别于角色基本属性的设置及用途。
  • PrefixBaseAttr:前缀装饰者,会将本身的属性增加在被装饰的基本属性之前,可以实现属性名称显示在前的效果。
  • SuffixBaseAttr:后缀装饰者,会将本身的属性增加在被装饰的基本属性之后,可以实现属性名称显示在后的效果。

实现说明

基本属性类(BaseAttr)是在游戏属性管理功能-享元模式说明享元模式(Flyweight)时新增的一个类,其定义如下:

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
// 可以被共享的基本角色属性接口
public class BaseAttr
{
private int m_MaxHP; // 最高HP值
private float m_MoveSpeed; // 当前移动速度
private strinq m_AttrName; // 属性的名称

public BaseAttr(int MaxHP, float MoveSpeed, string AttrName)
{
m_MaxHP = MaxHP;
m_MoveSpeed = MoveSpeed;
m_AttrName = AttrName;
}

public int GetMaxHP()
{
return m_MaxHP;
}

public float GetMoveSpeed()
{
return m_MoveSpeed;
}

public string GetAttrName ()
{
return m_AttrName;
}
}

应对装饰模式的实现,我们将之提升为抽象类:

Listing9 可以被共享的基本角色属性接口(BaseAttr.cs)

1
2
3
4
5
6
public abstract class BaseAttr
{
public abstract int GetMaxHP();
public abstract float GetMoveSpeed();
public abstract string GetAttrName();
}

将原有的实现部分移到一个新的子类,也就是角色基本属性类(Character BaseAttr)中:

Listing10 实现可以被共享的基本角色属性(BaseAttr.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
public class CharacterBaseAttr : BaseAttr
{
private int m_MaxHP; // 最高HP值
private float m_MoveSpeed; // 当前移动速度
private string m_AttrName; // 属性的名称

public characterBaseAttr (int MaxHP, float MoveSpeed, string AttrName)
{
m_MaxHP = MaxHP;
m_MoveSpeed = MoveSpeed;
m_AttrName = AttrName;
}

public override int GetMaxHP()
{
return m_MaxHP;
}

public override float GetMoveSpeed()
{
return m_MoveSpeed;
}

public override string GetAttrName()
{
return m_AttrName;
}
}

随着前面的类分割,所以连带也必须更改“敌方角色基本属性类(Enemy BaseAttr)”,使其改为继承自角色基本属性类(CharacterBaseAttr):

Listing11 敌方角色的基本属性(BaseAttr.cs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class EnemyBaseAttr : CharacterBaseAttr
{
public int m_InitcritRate; // 爆击率

public EnemyBaseAttr (
int MaxHP, float MoveSpeed,
string AttrName, int CritRate)
: base (MaxHP, MoveSpeed, AttrName)
{
m_InitcritRate = CritRate;
}

public virtual int GetInitCritRate()
{
return m_InitcritRate;
}
}

这样更改的结果并未造成其他游戏系统(IGameSystem)有太多需要修正的地方,只有属性工厂(AttrFactory)在产生角色基本属性对象时必须更改:

Listing12 实现产生游戏使用的属性(AttrFactory.cs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class AttrFactory : IAttrFactory
{
private Dictionary<int, BaseAttr> m_SoldierAttrDB = null;
...

// 产生所有Soldier的属性
private void InitSoldierAttr()
{
m_SoldierAttrDB = new Dictionary<int, BaseAttr>(); // 基本属性

// 生命力,移动速度,属性名称
m_SoldierAttrDB.Add(1, new CharacterBaseAttr(10, 3.0f, "新兵"));
m_SoldierAttrDB.Add(2, new CharacterBaseAttr(20, 3.2f, "中士"));
m_SoldierAttrDB.Add(3, new CharacterBaseAttr(30, 3.4f, "上尉"));
}
...
}

完成了原有类,在运用装饰模式进行调整后,我们可以实现基本属性装饰者BaseAttrDecorator), 它应该继承自基本属性(BaseAttr)类,并在其中加入一个引用,用来指向将来要被装饰的对象:

Listing13 基本角色属性装饰者(BaseAtrDecorator.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
public abstract class BaseAttrDecorator : BaseAttr
{
protected BaseAttr m_Component; // 被装饰对象
protected AdditionalAttr m_AdditionalAttr; // 代表额外加成的属性

// 设置装饰的目标
public void SetComponent (BaseAttr theComponent)
{
m_Component = theComponent;
}

// 设置额外使用的属性
public void SetAdditionalAttr (AdditionalAttr theAdditionalAttr)
{
m_AdditionalAttr = theAdditionalAttr;
}

public override int GetMaxHP()
{
return m_Component.GetMaxHP();
}

public override fioat GetMoveSpeed()
{
return m_Component.GetMoveSpeed();
}

public override string GetAttrName()
{
return m_Component.GetAttrName();
}
}

我们新增了一个用来设置装饰目标的方法: SetComponent,指定被装饰的目标。另外,成员中也增加了一个加成属性类(AdditionalAttr)类型的对象引用mAdditionialAttr,这个成员将作为后续前缀和后缀加成角色属性的依据。至于加成属性类(AdditionalAttr)则是另一组有别于基本属性(BaseAttr)的属性类:

Listing14 用于加成用的属性(BaseAttrDecorator.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
public class AdditionalAttr
{
private int m_Strength; // 力量
private int m_Agility; // 敏捷
private string m_Name; // 属性的名称

public AdditionalAttr(int Strength, int Agility, string Name)
{
m_Strength = Strength;
m_Agility = Agility;
m_Name = Name;
}

public int GetStrength()
{
return m_Strength;
}

public int GetAgility()
{
return m_Agility;
}

public int GetName()
{
return m_Name;
}
}

在加成属性类中,包含的是力量(Strength)及敏捷(Agility)等属性。

一般来说,如果游戏设置了多种职业想让玩家体验的话,多会采用“转换计算”的属性系统,这样能够让装备系统设计起来相对方便,因为这样做可以让同一装备在不同职业身上有不同的效果。假设某个游戏设计的装备系统的属性是使用力量、敏捷等属性,而角色使用的是生命力、移动速度、攻击力、闪避率等。所谓的“转换计算属性系统”就是,当角色穿上装备之后,会将装备上的力量属性经公式计算后转换成生命力、攻击力,然后加成给角色;敏捷经过计算会转换成移动速度和闪避率加成给角色。同时又会因为职业的不同,而使得转换公式的参数有些不同,这样一来同一件装备在不同职业上就有不同的效果了。

在《P级阵地》中,前缀和后缀的加成采用的是简单的“转换计算属性”方式,而这一部分的计算公式都放在前缀装饰者(PrefixBaseAttr)与后缀装饰者(Suffix BaseAttr)类的实现中:

Listing15 前缀与后缀装饰者的实现(BaseAttrDecorator.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
// 前缀
public class PrefixBaseAttr : BaseAttrDecorator
{
public PrefixBaseAttr()
{}

public override int GetMaxHP()
{
return m_AdditionalAttr.GetStrength() + m_Component.GetMaxHP();
}

public override float GetMoveSpeed()
{
return m_AdditionalAttr.GetAgility()*0.2f + m_Component.GetMoveSpeed();
}

public override string GetAttrName()
{
return m_AdditionalAttr.GetName() + m_Component.GetAttrName(); // 后加上属性名称
}
}

// 后缀
public class SuffixAttr : BaseAttrDecorator
{
public SuffixBaseAttr()
{}

public override int GetMaxHP()
{
return m_Component.GetMaxHP() + m_AdditionalAttr.GetStrength();
}

public override float GetMoveSpeed()
{
return m_Component.GetMoveSpeed() + m_AdditionalAttr.GetAgility() * 0.2f;
}

public override string GetAttrName()
{
return m_Component.GetAttrName() + m_AdditionalAttr.GetName(); // 先加上属性名称
}
}

最大生命力(MaxHP)的加成会直接加上加成属性中的力量(Strength),而移动速度(MoveSpeed)则是加上敏捷(Agility)乘积之后的值。另外,这两个类也顺应前缀和后缀的特性,在获取名称的先后上有些差异,尤其是在获取名称GetAttrName的方法中,前后位置的不同会造成属性名称出现的位置也会不同,进而达到“前缀”“后缀”想要表现的显示效果。

因为加成属性(AdditionalAttr)是一个新定义的属性类,所以必须将其加入到属性工厂AttrFactory)中,也使用享元模式(Flyweight)的方式来管理,使其成为属性系统的一环:

Listing16 属性工厂内加入新的前缀后缀属性

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
// 产生游戏用的属性之接口
public abstract class IAttrFactory
{
...
// 获取加成用的属性
public abstract AdditionalAttr GetAdditionalAttr(int AttrID);
...
} // IAttrFactory.cs

// 实现产生游戏用的属性
public class AttrFactory : IAttrFactory
{
...
private Dictionary<int, AdditionalAttr> m_AdditionalAttrDB = null;
...
public AttrFactory()
{
...
InitAdditionalAttr();
}
...
// 产生加成用的属性
private void InitAdditionalAttr()
{
m_AdditionalAttrDB = new Dictionary<int, AdditionalAttr>();

// 前缀随机产生
m_AdditionalAttrDB.Add(11, new AdditionalAttr(3, 0, "勇士"));
m_AdditionalAttrDB.Add(12, new AdditionalAttr(5, 0, "猛将"));
m_AdditionalAttrDB.Add(13, new AdditionalAttr(10, 0, "英雄"));

// 后缀存活下来即增加
m_AdditionalAttrDB.Add(21, new AdditionalAttr(5, 1, "◇"));
m_AdditionalAttrDB.Add(22, new AdditionalAttr(5, 1, "☆"));
m_AdditionalAttrDB.Add(23, new AdditionalAttr(5, 1, "★"));
}

...

// 获取加成用的属性
public override AdditionalAttr GetAdditionalAttr(int AttrID)
{
if (m_AdditionalAttrDB.ContainsKey(AttrID) == false)
{
Debug.LogWarning("GetAdditionalAttr:AttrID[" + AttrID +"]属性不存在");
return null;
}

// 直接返回加成用的属性
return m_AdditionalAttrDB[AttrID];
}
} // AttrFactory.cs

有了加成属性对象及前缀后缀的功能后,准备运用装饰模式的所有类都已就位剩下的工作就是将这些部分加以组装。因为这一部分基本上还是与角色属性有关,并且也具备属性的概念,所以《P级阵地》将属性组装的实现放在“属性工厂(AttrFactory)”中。

首先,将要产生的类以枚举的方式加以定义,之后再增加一不可获取前缀后缀的Soldier属性(SoldierAttr)的方法:

Listing17 产生前缀后缀对象(IAttrFactory.cs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 装饰类型
public enum ENUM_AttrDecorator
{
Prefix,
Suffix,
}

// 产生游戏用的属性之接口
public abstract class IAttrFactory
{
...
// 获取Soldier的属性:有前缀后缀的加成
public abstract SoldierAttr GetEliteSoldierAttr(
ENUM_AttrDecorator emType,
int AttrID, SoldierAttr theSoldierAttr);
...
}

最后,在属性工厂的实现类中重新实现新增加的方法:

Listing18 实现产生游戏用的属性(AttFactory.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
public class AttrFactory : IAttrFactory
{
...
// 获取加成过的Soldier角色属性
public override SoldierAttr GetEliteSoldierAttr(ENUM_AttrDecorator emType, int AttrID, SoldierAttr theSoldierAttr)
{
// 1.获取加成效果的属性
AdditionalAttr theAdditionalAttr = GetAdditionalAttr(AttrID);

if(theAdditionalAttr == null)
{
Debug.LogWarning("GetEliteSoldierAttr:加成属性[" + AttrID +"]不存在");

return theSoldierAttr;
}

// 2.产生装饰者
BaseAttrDecorator theAttrDecorator = null;

switch(emType)
{
case ENUM_AttrDecorator.Prefix:
theAttrDecorator = new PrefixBaseAttr();
break;

case ENUM_AttrDecorator.Suffix:
theAttrDecorator = new SuffixBaseAttr();
break;

}

if (theAttrDecorator == null)
{
Debug.LogWarning("GetEliteSoldierAttr:无法针对[" + emType +"]产生装饰者");

return theSoldierAttr;
}

// 3.设置装饰对象及加成属性
theAttrDecorator.SetComponent(theSoldierAttr.GetBaseAttr());
theAttrDecorator.SetAdditionalAttr(theAdditionalAttr);

// 4.设置新的属性后返回
theSoldierAttr.SetBaseAttr(theAttrDecorator);

// 5.返回
return theSoldierAttr;
}
...
}

实现时包含五项先后的操作:先获取加成用的属性对象;按照客户端的指示,产生所需要的前缀或后缀属性装饰对象;将装饰对象及加成属性设置给新产生的对象;将新的对象来替代Soldier属性对象中的角色属性对象;返回给客户端。

最后,按照之前所提的游戏功能需求,将功能实现完成。首先是前缀的功能需求:当兵营训练完一个角色进入战场时,会出现给这个新角色一个角色属性加成的机会。这一部分将实现在训练Soldier的命令中,也就是把实现的程序代码加入在当兵营训练时间完成,通知执行训练命令(TrainSoldierCommand)实际产生Soldier对象之后:

Listing19 训练Soldier命令(TrainSoldierCommand.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
public class TrainSoldierCommand : ITrainCommand
{
ENUM_Soldier m_emSoldier; // 兵种
ENUM_Weapon m_emWeapon; // 使用的武器
int m_Lv; // 等级
Vector3 m_Position; // 出现位置

// 训练
public TrainSoldierCommand (
ENUM_Soldier emSoldier,
ENUM_Weapon emWeapon,
int Lv,
Vector3 Position)
{
m_emSoldier = emSoldier;
m_emWeapon = emWeapon;
m_Lv = Lv;
m_Position = Position;
}

// 执行
public override void Execute()
{
// 产生Soldier
ICharacterFactory Factory = PBDFactory.GetCharacterFactory();
ISoldier Soldier = Factory.CreateSoldier(m_emSoldier, m_emWeapon, m_Lv, m_Position);

// 按概率产生前缀能力
int Rate = UnityEngine.Random.Range(0, 100);
int AttrID = 0;

if (Rate > 90)
AttrID = 13;
else if (Rate > 80)
AttrID = 12;
else if (Rate > 60)
AttrID = 11;
else
return ;

// 加上前缀能力
IAttrFactory AttrFactory = PBDFactory.GetAttrFactory();
SoldierAttr PreAttr = AttrFactory.GetEliteSoldierAttr(ENUM_AttrDecorator.Prefix, AttrID, Soldier.GetSoldierValue());
Soldier.SetCharacterAttr(PreAttr);
}
}

先按照简单的概率判断,来决定要给新产生的Soldier对象哪一个前缀加成,之后向角色属性工厂(AttrFactory)获取加成用的前缀属性,并设置给新产生的角色。

至于后缀的功能需求:“当玩家通过一个关卡之后,让所有仍在场上存活的ISoldier角色增加一个勋章数”,这一部分的实现则是放在上一章未完成的“增加Soldier勋章方法”中:

Listing20 Soldier角色接口(ISoldier.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
public abstract class ISoldier : ICharacter
{
protected int m_MedalCount = 0; // 勋章数量
protected const int MAX_MEDAL = 3; // 最多勋章数量
protected const int MEDAL_VALUE_ID = 20; // 勋章属性起始值
...

// 增加勋章
public virtual void AddMedal()
{
if (m_MedalCount >= MAX_MEDAL)
return ;

// 增加勋章
m_MedalCount++;

// 获取对应的勋章加成值
int AttrID = m_MedalCount + MEDAL_VALUE_ID;
IAttrFactory theAttrFactory = PBDFactory.GetAttrFactory();

// 加上后缀能力
SoldierAttr SufAttr = theAttrFactory.GetEliteSoldierAttr(ENUM_AttrDecorator.Suffix, AttrID, m_Attribute as SoldierAttr);
SetCharacterAttr(SufAttr);
}
...
}

同样地,将勋章等级换算成加成能力属性,之后向角色属性工厂(AttrFactory)获取加成用的后缀属性,再重新设置给角色。

完成相关的实现后,玩家在游戏过程中就有机会看到包含前缀和后缀的作战单位出现在战场上,如图12所示。

图12 有前缀后缀的角色名称

使用装饰模式的优点

使用装饰模式的方式来新增功能,可避免更改已经实现的程序代码,增加系统的稳定性,也变得更灵活。善用装饰模式的透明性(Transparency),可以方便组装及加入想要的加成效果。

实现装饰模式时的注意事项

装饰模式就如同它的命名,其是用来装饰的,所以适用于已经有个装饰的目标。所以这些装饰应该是出现在“目标早已存在,而装饰需求之后才出现”的场合中,不该被滥用。过多的装饰堆砌在一起,难免也会眼花缭乱。

装饰模式适合项目后期增加系统功能时使用

对于项目进入后期或项目己上市的维护周期来说,使用装饰模式来增加现有系统的附加功能确实是较稳定的方式。但若是项目在早期就已规划要实现前缀后缀功能,那么可以将这种“附加于对象上的功能”列于早期的开发设计中,否则过度套叠附加功能,会造成调试上的困难,也会让后续维护者不容易看懂原始设计者最初的组装顺序。

早期规划时可以将附加功能加入设计之中

如果系统已预期某项功能会以大量的附加组件来扩展功能的话,那么或许可以采用Unity3D引擎中的“游戏对象”和“组件”的设计方式:游戏对象只是一个能在三维空间表示位置的类,但这个类可以利用不断地往上增加组件的方式来强化其功能。除了具备动态新增、删除组件的灵活性外,通过Unity3D界面查看组件列表中的类,也能轻易看出这个游戏对象具备了什么样的功能,提高了系统的维护性同时减少了调试的难度。

提示
Unity3D引擎采用的是ECS(Entity Component System)设计模式,这是一种被大量使用在游戏引擎开发上的一种模式。利用在主体(Entity)附加许多组件(Component)的方式来增加主体(Entity)的功能,而组件(Component)在执行模式或编辑模式下,都能被轻易地增加和删除。

装饰模式面对变化时

《P级阵地》应用了装饰模式来增加角色属性系统的可变性。当有任何属性加成功能想应用时,都可以利用产生一个基本属性装饰者(BaseAttr Decorator)的子类来完成。例如,后续的游戏需求中,又想设计一个“直接强化系统”,让玩家可以直接强化战场中的某一个角色,也就是玩家可以先选择三个强化属性,然后下达“强化”指令,将这三个强化属性加到某个单位上。这样的新需求,实现时可以先完成下面这个StrengthenBaseAttr类。

Listing21 直接强化(BaseAttrDecorator.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
public class StrengthBaseAttr : BaseAttrDecorator
{
protected List<AdditionalAttr> m_AdditionalAttr; // 多个强化的属性

public StrengthBaseAttr()
{}

public override int GetMaxHP()
{
int MaxHP = m_Component.GetMaxHP();

foreach(AdditionalAttr theAttr in m_AdditionalAttrs)
{
MaxHP += theAttr.GetStrength();
}
return MaxHP;
}

public override float GetMoveSpeed()
{
float MoveSpeed = m_Component.GetMoveSpeed();

foreach(AdditionalAttr theAttr in m_AdditionalAttrs)
{
MoveSpeed += theAttr.GetAgility() * 0.2f;
}
return MoveSpeed;
}

public override string GetAttrName()
{
return "直接强化" + m_Component.GetAttrName();
}
}

完成之后,只要再配合玩家界面设计与命令模式(Command),就能将强化功能加到单击鼠标而选中的玩家角色上。

结论

对于项目后期的系统功能强化,使用装饰模式的优点在于,可以不必更改太多现有的实现类就能完成功能强化。另外,“灵活度”和“透明性”是该模式的另一项优点,适合应用在系统功能是采用叠加不同小功能来完成实现的开发方式。但过多的装饰类容易造成系统维护的难度,而功能之间的交互堆砌,也会让程序人员在调试时增加不少困扰。

其他应用方式

  • 网络在线游戏中数据封包的加密解密,也是许多介绍设计模式书中会提到的范例。通过额外附加的“信息加密装饰者”,就可以让原本传递的信息增加安全性,而且可以实现不同的加密方式来层层包覆,而修改的过程中,都不会影响到原有的数据封包的传送架构。
  • 界面特效,有时候游戏界面中会特别提示玩家,某个事件发生了或是提醒玩家有个奖励可以领取,对于这类需求,可以在原有的界面组件上增加一个“接口特效装饰者”,而这样的实现方式会比较灵活,修改也较为方便。
0%