攻击特效与击中反应--模板方法模式

思考并回答以下问题:

  • 模板方法模式的官方定义是什么?
  • 模板方法模式属于什么类型的模式?应用场景有哪些?

本章涵盖:

  • 武器的攻击流程
  • 模板方法模式
    • 模板方法模式的定义
    • 模板方法模式的说明
    • 模板方法模式的实现范例
  • 使用模板方法模式实现攻击与击中流程
    • 攻击与击中流程的实现
    • 实现说明
    • 运用模板方法模式的优点
    • 修改击中流程的实现
  • 模板方法模式面对变化时
  • 结论

武器的攻击流程

角色与武器的实现-桥接模式一章中,《P级阵地》的武器系统在运用桥接模式(Bridge)之后,产生了一系列的武器类(WeaponGun、WeaponRifle、WeaponRocket),而这些类都重新实现了父类IWeapon的“攻击目标Fire()”方法:

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
public class WeaponGun : IWeapon 
{
public WeaponGun()
{}

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

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

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

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

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

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

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

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

因为游戏的需求,每一次武器攻击目标时,都要先进行:①开火/枪口特效;②子弹特效;③武器音效,之后再通知目标被击中了。所以在现有的实现方式下可以看到,每种武器类的攻击目标Fire方法中的实现方式都“非常类似”,差别仅在于每种武器所需要的特效不一样而已。

在上面的范例程序中可以看出“重复”是最大的缺点,虽然IWeapon类已经将大部分的重复功能:ShowShootEffect、ShowBulletEffect…,写成了类方法并提供参数让子类调用,但从中仍可以看到,攻击目标时的“流程”重复了。

重复的缺点在于,如果面临“演算流程需要改动”,那么势必要将所有相同演算流程的程序代码一起修正,但有些演算流程动辄数十行以上,实在不容易分别将其找出来修改。

所以,改进的关键在于如何让这些流程(或称为算法)只需要编写一遍,当需要变化时,就由实现的类来负责变化。遇到这样的需求,可以使用GoF中的模板方法模式来解决。

模板方法模式

程序代码中的“流程”,有时候不太容易观察出来,尤其是当原有的程序代码还没有经过适当重构。有个很好的判断技巧,如果程序设计师发现更新一段程序代码之后,还有另一段程序代码也使用相同的“演算流程”,且实现的内容不太一样,那么这两段程序代码就可以用模板方法模式加以重写。

模板方法模式的定义

GoF对于模板方法模式(Template Method)的定义是:

1
在一个操作方法中定义算法的流程,其中某些步骤由子类完成。模板方法模式让子类在不变更原有算法流程的情况下,还能够重新定义其中的步骤。

从上述的定义来看,模板方法模式包含以下两个概念:

1.定义一个算法的流程,即是很明确地定义算法的每一个步骤,并写在父类的方法中,而每一个步骤都可以是一个方法的调用。

2.某些步骤由子类完成,为什么父类不自己完成,却要由子类去实现呢?

  • 定义算法的流程中,某些步骤需要由执行时“当下的环境”来决定。
  • 定义算法时,针对每一个步骤都提供了预设的解决方案,但有时候会出现“更好的解决方法”,此时就需要让这个更好的解决方法,能够在原有的架构中被使用。

以下提供几个例子跟大家说明:

以面包的配方和制作方法为例,大概是这样写的:
食材:A1.xxx、A2.xxx、A3.xxxx…B1.yyy、B2.yyy

步骤:
(1) 将材料A1-A5混合在一起搅拌至光滑;
(2) 置于密闭空间醒面30-50分钟;
(3) 分成5等份,整形滚圆再静置约10-20分钟;
(4) 包入B1-B3内馅,整形成长条形状;
(5) 置于密闭空间做二次发酵,约30-50分钟;
(6) 烤焙:预热180°,进炉降温至165℃(或上火150℃/下火180℃),烘烤15-20分钟至表面上色即可。

如果将面包配方和制作方法看成是“算法的流程”,那么其中的1-6就是每一个步骤,而且每一个步骤遵循着一定的先后顺序。做过面包的读者应该可以了解,面包要好吃,发酵的时间长度是关键,而温度、湿度等都会影响发酵所需的时间。所以,上述制作面包的步骤中,第2、3、5项是需要实现面包的人按照当天的环境情况来决定发酵的时间,这也是为什么食谱上常出现xx-xx分钟,而不是明确告诉你一定要多少分钟,也就是GoF定义中所提示的“定义算法的流程中,某些步骤需要由执行时‘当下的环境’来决定”

3D渲染技术(Shader)是现代3D计算机绘图重要的功能之一。它在整个3D成像的过程中,开放出两个步骤:Vertices Shader和Pixels Shader,让开发者能够加入自己编写的Shader Code,来优化游戏所需要呈现的视觉效果,如图1所示。

图1 3D计算机绘图中的Shader技术流程图

所以绘图引擎(DirectX、OpenGL)中,都事先定义了所有的绘图流程,并且开放两个步骤给程序设计师进行优化设计,让更好的成像效果能在原有的架构中被使用。近十年来,电子游戏的视觉效果越来越好,其原因之一是除了绘图引擎(DirectX、OpenGL)本身不断强化之外,也从原有的成像流程中(Rendering Pipeline)开放出两个步骤,让实现者进行优化,以达到最佳效果。

Unity3D除了提供默认的材质功能外,也提供了让开发者自行编写渲染程序(Shader Code )的功能,而这些渲染程序会在上述两个步骤中扮演重要的角色,如图2所示。

图2 Unity3D的Shader编辑环境

模板方法模式的说明

模板方法模式的类结构如图3所示。

图3 模板方法模式的类结构图

参与者的说明如下:

  • AbstractClass(算法定义类)
    • 定义算法架构的类。
    • 可以在某个操作方法(TemplateMethod)中定义完整的流程。
    • 定义流程中会调用到的方法(PrimitiveOperation),这些方法将由子类重新实现。
  • ConcreteClass(算法步骤的实现类)
    • 重新实现父类中定义的方法,并可按照子类的执行情况反应步骤实际的内容。

模板方法模式的实现范例

模板方法模式在实现上并不复杂,首先将算法架构定义于AbstractClass中:

Listing2 定义完整算法的各个步骤及执行顺序(TemplateMethod.cs)

1
2
3
4
5
6
7
8
9
10
public abstract class AbstractClass
{
public void TemplateMethod()
{
PrimitiveOperation1();
PrimitiveOperation2();
}
protected abstract void PrimitiveOperation1();
protected abstract void PrimitiveOperation2();
}

类中定义了一个方法TemplateMethod,其中将算法流程定义为两个步骤:PrimitiveOperation1和PrimitiveOperation2,这两个方法也接着被声明为抽象方法,让继承的子类重新实现这两个方法。

声明两个子类来实现AbstractClass类中的各个步骤:

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
// TemplateMethod.cs
public class ConcreteClassA : AbstractClass
{
protected override void PrimitiveOperation1()
{
Debug.Log("ConcreteClassA.PrimitiveOperation1");
}

protected override void PrimitiveOperation2()
{
Debug.Log("ConcreteClassA.PrimitiveOperation2");
}
}

public class ConcreteClassB : AbstractClass
{
protected override void PrimitiveOperation1()
{
Debug.Log("ConcreteClassB.PrimitiveOperation1");
}

protected override void PrimitiveOperation2()
{
Debug.Log("ConcreteClassB.PrimitiveOperation2");
}
}

每个子类都重新实现了AbstractClass类中的两个抽象方法。测试程序简单地产生对象,并且通过调用父类的TemplateMethod来让子类重新实现的方法能够被执行:

Listing4 测试模板方法模式(TemplateMethodTest.cs)

1
2
3
4
5
6
7
8
void UnitTest() 
{
AbstractClass theClass = new ConcreteClassA();
theClass.TemplateMethod();

theClass = new ConcreteClassB();
theClass.TemplateMethod();
}

执行结果

1
2
3
4
ConcreteClassA.PrimitiveOperation1
ConcreteClassA.PrimitiveOperation2
concreteClassB.PrimitiveOperation1
ConcreteClassB.PrimitiveOperation2

使用模板方法模式实现攻击与击中流程

很难找出程序代码中相同的演算流程,是程序设计放弃使用模板方法模式的原因之一;另一种更常见的情况是,有时这些演算流程中会有一些小变化,也是因为这些小变化,导致程序设计放弃使用模板方法模式。而那个小变化可能是,A流程中有一个if判断语句用以决定是否执行某项功能,但在B流程中却没有这个if判断语句。当笔者在遇到这种情况时,会连同这个if判断语句一起设置为步骤的一部分,只是重构后的B类(B流程)不去重新定义这一步骤所调用的方法。

攻击与击中流程的实现

在《P级阵地》中,我们先将IWeapon类中原本一定要由子类重新实现的“攻击目标Fire”方法,设计为将原本在子类中实现的代码移到IWeapon类中,并找出需要由子类去执行的步骤,将这些步骤声明为“抽象方法”。而原本继承的子类(WeaponGun、WeaponRifle、WeaponRocket)则改成重新实现这些新的步骤方法。运用模板方法模式后的结构图并无改变,但是多了一些必须重新实现的抽象方法,如图4所示。

图4 多了重新实现的抽象方法的模板方法模式类结构图

参与者的说明如下:

  • IWeapon:在攻击目标Fire方法中定义流程,也就是要执行的各个步骤,并将这些步骤声明为抽象方法。
  • WeaponGun、WeaponRifle、WeaponRocket:实现IWeapon类中需要重新实现的抽象方法。

实现说明

运用模板方法模式后,IWeapon类如下:

Listing5 使用模板方法模式的武器接口(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
public abstract class IWeapon
{
// 属性
protected int m_AtkPlusValue = 0; // 额外增加的攻击力
protected int m_Atk = 0; // 攻击力
protected float m_Range = 0.0f; // 攻击距离

// 攻击目标
public void Fire(ICharacter theTarget)
{
// 显示武器发射/枪口特效
ShowShootEffect();

// 显示武器子弹特效(子类实现)
DoShowBulletEffect(theTarget);

// 播放音效(子类实现)
DoShowSoundEffect();

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

// 显示武器子弹特效
protected abstract void DoShowBulletEffect(ICharacter theTarget);

// 播放音效
protected abstract void DoShowSoundEffect();
}

攻击目标Fire方法将武器攻击目标分为4个执行步骤。这4个步骤都以“方法调用”的方式来完成,其中显示武器子弹特效DoShowBulletEffect和播放音效DoShowSoundEffect两个方法需要由子类来重新实现,所以声明为抽象方法。而原本继承IWeapon的3个武器类,也都要重新实现这两个方法,并且删除攻击目标Fire的程序代码:

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
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
public class WeaponGun : IWeapon
{
public WeaponGun()
{}

// 显示武器子弹特效
protected override void DoShowBulletEffect(ICharacter theTarget)
{
ShowBulletEffect(theTarget.GetPosition(), 0.03f, 0.2f);
}

// 播放音效
protected override void DoShowSoundEffect()
{
ShowSoundEffect("GunShot");
}
} // WeaponGun.cs

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

// 显示武器子弹特效
protected override void DoShowBulletEffect(ICharacter theTarget)
{
ShowBulletEffect(theTarget.GetPosition(), 0.5f, 0.2f);
}

// 播放音效
protected override void DoShowSoundEffect()
{
ShowSoundEffect("RifleShot");
}
} // WeaponRifle.cs


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

// 显示武器子弹特效
protected override void DoShowBulletEffect(ICharacter theTarget)
{
ShowBulletEffect(theTarget.GetPosition(), 0.8f, 0.5f);
}

// 播放音效
protected override void DoShowSoundEffect()
{
ShowSoundEffect("RocketShot");
}
} // WeaponRocket.cs

运用模板方法模式的优点

在IWeapon类中,将“攻击目标Fire方法”重新修改后,攻击目标的“算法”只被编写了一次,需要变化的部分,则由实现的子类负责,这样一来,原本需要在子类中“重复实现算法”的缺点就不会再出现了。

修改击中流程的实现

在《P级阵地》中,除了IWeapon及其子类采用模板方法模式来设计之外,原本实现在角色类ICharacter中的角色受击方法UnderAttack也同时一起修改,并实现几项游戏设计的需求:

  • 受到攻击时反应。
    • 当玩家阵营角色(ISoldier)受到攻击后,只有阵亡时才产生特效和音效,以提示玩家有我方角色阵亡;
    • 敌方阵营角色(IEnemy)受到攻击时,必定产生特效和音效,以提示玩家有敌方角色受到有效攻击。
  • 不同类型的单位产生的特效和音效是不同的。

关于第一点的实现,只要将范例中角色类ICharacter中的角色受击方法UnderAttack改为抽象方法,并要求两个子类重新定义各自的受击流程即可:

Listing7 使用模板方法模式的角色接口(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
public abstract class ICharacter
{
...
// 被武器攻击
public abstract void UnderAttack(ICharacter Attacker);
...
} // ICharacter.cs

// Soldier角色接口
public abstract class ISoldier : ICharacter
{
// 被武器攻击
public override void UnderAttack(ICharacter Attacker)
{
// 计算伤害值
m_Attribute.CalDmgValue(Attacker);

// 是否阵亡
if (m_Attribute.GetNowHP() <= 0)
{
DoPlayKilledSound(); // 音效
DoShowKilledEffect(); // 特效
Killed(); // 阵亡
}
}

// 播放音效
public abstract void DoPlayKilledSound();

// 显示特效
public abstract void DoShowKilledEffect();
} // ISoldier.cs

// Enemy角色接口
public abstract class ISoldier : ICharacter
{
// 被武器攻击
public override void UnderAttack(ICharacter Attacker)
{
// 计算伤害值
m_Attribute.CalDmgValue(Attacker);

DoPlayHitSound(); // 音效
DoShowHitEffect(); // 特效

// 是否阵亡
if(m_Attribute.GetNowHP() <= 0)
Killed(); // 阵亡

}

// 播放音效
public abstract void DoPlayHitSound();

// 显示特效
public abstract void DoShowHitEffect();
} // IEnemy.cs

ISoldier类和IEnemy类都重新实现了UnderAttack方法,且两个方法都运用了模板方法模式,各自要求继承的子类必须重新实现相关的抽象方法。这些抽象方法将满足新增的第二项需求:“不同类型的单位所产生的特效和音效是不同的”。

另外,由于游戏设计的需要,让双方阵营各自拥有3种角色类型:

  • ISoldier阵营:Captain、Rookie、Sergeant;
  • IEnemy阵营:Elf、Ogre、Troll。

重新实现后的结构如图5所示。

图5 用模板方法模式重新实现后的类结构图

模板方法模式面对变化时

小程将游戏架构修正完成,过了几天之后……

策划:“小程啊……”
小程头抬了一下,这个语调听起来像是有什么麻烦事了……,
小程:“什么事?”
策划:“你会不会觉得攻击时的枪口特效太明显了,不容易看到子弹从武器发出去的位置?”
小程:“嗯…是有点。”
策划:“那可以先关掉吗?我现在只能一个个地调,这样做很慢,等看完后再调回来。”

小程此时心想:“还好我先将武器的攻击目标方法用模板方法模式重构过了,只要将方法中的ShowShootEffect()先注释起来就好了,这样所有的武器都不会发出枪口特效了。”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 攻击目标
public void Fire(ICharacter theTarget)
{
// 显示武器发射/枪口特效
// ShowShootEffect();

// 显示武器子弹特效(子类实现)
DoShowBulletEffect(theTarget);

// 播放音效(子类实现)
DoShowSoundEffect();

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

修改好之后,提交推送给策划去测试了。

又过了没多久……

策划:“小程。”
小程:“测试完要改回来了吗?”
策划:“差不多了,但是我发现新的问题。”
小程: “是Bug吗?!”
策划:“不是,不用那么紧张,我是觉得武器的音效好像慢了一点,怎么好像是特效出来后,音效延迟了一下才出来。”
小程:“哦~因为流程上,是先显示特效再播放音效,所以有可能是因为加载延迟的关系。”
策划:“那可以改一下流程,让我测试看看吗?”
小程:“那是要…..”
策划:“就是先播放音效再显示特效,可以吗?”
小程:“哈~还好我前阵子重构过了,如果你更早之前找我,这个修改大概要变动3个类的程序代码,现在只要改一个就可以了。”

小程指的是,只要在IWeapon的攻击方法Fire中将DoShowSoundEffect的执行位置往前移至最前面,就可以一次性完成所有武器的攻击流程的修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 攻击目标
public void Fire(ICharacter theTarget)
{
// 播放音效(子类实现)
DoShowSoundEffect();

// 显示武器发射/枪口特效
// ShowShootEffect();

// 显示武器子弹特效(子类实现)
DoShowBulletEffect(theTarget);

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

如果是在之前,那么所有的子类(WeaponGun、WeaponRifle、WeaponRocket)都要一起修改,而重新设计后的架构,还可以因为测试结果没有新的变化,再更改回原本的流程。所以,只需要修改算法的结构而不必更改子类的程序代码,这是减少“重复流程”程序代码后带来的好处。

结论

运用模板方法模式的优点是,将可能出现重复的“算法流程”,从子类提升到父类中,减少重复的发生,并且也开放子类参与算法中各个步骤的执行或优化。但如果“算法流程”开放太多的步骤,并要求子类必须全部重新实现的话,反而会造成实现的困难,也不容易维护。

其他应用方式

  • 奇幻类角色扮演游戏(RPG),对于游戏角色要施展一个法术时,会有许多特定的检查条件,如魔力是否足够、是否还在冷却时间内、对象是否在法术施展范围内等。如果这些检查条件会按照施展法术的类型而有所不同,那么就可以使用模板方法模式将检查流程固定下来,真正检查的功能则交给各法术子类去实现。另外,一个法术的施展流程和击中计算也可以如同本章范例一样,将流程固定下来,细节交给各法术子类去实现。
  • 在线游戏的角色登录,也可以使用模板方法模式将登录流程固定下来,例如:显示登录画面、选择登录方法、输入账号密码、向Server请求登录等,然后让登录功能的子类去重新实现其中的步骤。另外,也可以实现不同的登录流程样板来对应不同的登录方式(OpenID、自动创建、快速登录等)。
0%