思考并回答以下问题:
- 策略模式的官方定义是什么?
- 策略这个词是什么意思?策略模式的类图是什么样子的?
- 策略模式为什么是行为模式?
- 如果有多种计算方法,就把这些计算方法每个都写成类,不要放在函数里面。怎么理解?
- 封装每个算法是什么意思?
- 其实策略模式的架构很简单,将每一个算法封装并组成一个类群组,封装成类。不要以为就干算法一件事就封装成类是不必要的。怎么理解?
- 策略模式比状态模式简单一点,相同点是都从一个接口有多个替代if else的子类,不同点是状态模式子类直接可以根据条件相互转换,但策略模式不是,各个子类是独立的,不能相互转换。如何理解?
- 改成策略模式的话就新增接口和算法子类。设计模式就是新增接口就对了。怎么理解?
本章涵盖:
- 角色属性的计算需求
- 策略模式
- 策略模式的定义
- 策略模式的说明
- 策略模式的实现范例
- 使用策略模式实现攻击计算
- 攻击流程的实现
- 实现说明
- 使用策略模式的优点
- 实现策略模式时的注意事项
- 策略模式面对变化时
- 结论
角色属性的计算需求
在《P级阵地》中,双方阵营的角色都有基本的属性:“生命力”和“移动速度”。角色之间可以利用不同的属性作为能力区分(如图1所示),但双方阵营也会有不同点:
- 玩家阵营的角色有等级属性,等级可通过“兵营升级”的方式来提升,等级提升可以增加防守优势,这些优势包含:角色等级越高,“生命力”就越高,生命力会按照等级加成;被攻击时,角色等级越高,可以抵御更多的攻击力。
- 敌方阵营的角色攻击时,有一定的概率会产生暴击,当暴击发生时,会将“暴击值”作为武器的额外攻击力,让敌方阵营角色增加攻击优势。
图1 攻击属性的计算
双方角色属性主要是使用在某单位受到攻击时,受攻击的角色需要计算这次攻击所产生的伤害值,然后利用这个伤害值去扣除角色的生命力。所以《P级阵地》针对攻击后的属性计算,需求如下:
“当单位A攻击单位B时,A单位使用当前装备上的武器属性扣除B单位角色属性中的生命力,当B单位的生命力扣除到0以下,B单位即阵亡,必须从战场上消失”。
综合上述游戏需求的分析,在不考虑单位A、B所属阵营的情况下,当一个攻击事件发生时,其流程如下:
- 1.单位A决定攻击单位B;
- 2.将单位A可以产生的“额外攻击加成值”设置给武器;
- 3.单位A使用当前装备的武器攻击单位B;
- 4.单位B受到攻击后,获取“单位A的武器攻击力”;
- 5.获取“单位B的生命力”;
- 6.“单位B的生命力”减去“单位A的武器攻击力”,并考虑单位B是否有“等级抵御攻击”;
- 7.如果“单位B的生命力”小于0,则单位B死亡。
但上述流程中,有些步骤的属性计算会因为单位所属的阵营而有不同的计算策略:
- 第2步骤中的“额外攻击加成值”,只有敌方阵营会产生,玩家阵营没有这个值。
- 第6步骤中的“等级抵御攻击”,只有玩家阵营具备,敌方阵营没有。
- 另外,双方阵营单位在初始化角色时,“单位的生命力”上限也是不同的,玩家阵营有等级加成,敌方阵营没有。
所以,要让一次攻击能够产生正确的计算,在3个事件点上会因为不同单位阵营而有不同的计算策略。将角色属性在角色类Character中声明是最直截了当的方法:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22// 角色类型
public enum ENUM_Character
{
Soldier = 0,
Enemy,
}
// 角色接口
public class Character
{
// 拥有一种武器
protected Weapon m_Weapon = null;
ENUM_Character m_CharacterType; // 角色类型
// 角色属性
int m_MaxHP = 0; // 最高生命力值
int m_NowHP = 0; // 当前生命力值
float m_MoveSpeed = 1.0f; // 当前移动速度
int m_SoldierLv = 0; // Soldier等级
int m_CritRate = 0; // 暴击概率
...
}
之后,针对“初始化设置”和“攻击流程”,在角色类Character中定义所需的操作方法: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
73
74
75
76
77
78
79
80
81// 角色接口
public class Character
{
...
// 初始化角色
public void InitCharacter()
{
// 按角色类型判断最高生命力值的计算方式
switch(m_CharacterType)
{
case ENUM_Character.Soldier:
// 最大生命力有等级加成
if(m_SoldierLv > 0 )
m_MaxHP += (m_SoldierLv-1)*2;
break;
case ENUM_Character.Enemy:
// 不需要
break;
}
// 重设当前的生命力
m_NowHP = m_MaxHP;
}
// 攻击目标
public void Attack( ICharacter theTarget)
{
// 设置武器额外攻击加成
int AtkPlusValue = 0;
// 按角色类型判断是否加成额外攻击力
switch(m_CharacterType)
{
case ENUM_Character.Soldier:
// 不需要
break;
case ENUM_Character.Enemy:
// 按暴击概率返回攻击加成值
int RandValue = UnityEngine.Random.Range(0,100);
if( m_CritRate >= RandValue )
AtkPlusValue = m_MaxHP*5; // 血量的5倍值
break;
}
// 设置额外攻击力
m_Weapon.SetAtkPlusValue( AtkPlusValue );
// 使用武器攻击目标
m_Weapon.Fire( theTarget );
}
// 被攻击
public void UnderAttack( ICharacter Attacker)
{
// 获取攻击力(会包含加成值)
int AtkValue = Attacker.GetWeapon().GetAtkValue();
// 按角色类型计算减伤害值
switch(m_CharacterType)
{
case ENUM_Character.Soldier:
// 会按照Soldier等级减少伤害
AtkValue -= (m_SoldierLv-1)*2;
break;
case ENUM_Character.Enemy:
// 不需要
break;
}
// 当前生命力减去攻击值
m_NowHP -= AtkValue;
// 是否阵亡
if( m_NowHP <= 0 )
Debug.Log ("角色阵亡");
}
...
}
在这3个操作方法中,都针对不同的角色类型进行了相对应的属性计算,但这样的实现方式有以下缺点:
- 每个方法都针对“角色类型”进行属性计算,所以这3个方法依赖“角色类型”,当新增“角色类型”时,必须修改这3个方法,因此会增加维护的难度。
- 同一类型的计算规则分散在角色类Character中,不易阅读和了解。
对于这些因角色不同而有差异的计算公式,该如何重新设计才能解决上述问题呢?GoF的策略模式为我们提供了解答。
策略模式
因条件的不同而需要有所选择时,刚入门的程序设计师会使用if else或多组的if elseif else来完成需求,或者使用switch case语句来完成。当然,这是因为入门的程序书籍大多是这样建议的,而且也是最快完成实现的方式。对于小型项目或快速开发验证用的项目而言,或许可以使用比较快速的条件判断方式来实现。但若遇到具有规模或产品化(需要长期维护)项目时,最好还是选择策略模式来完成,因为这将有利于项目的维护。
策略模式的定义
GoF对策略模式(Strategy)的解释是:1
定义一组算法,并封装每个算法,让它们可以彼此交换使用。策略模式让这些算法在客户端使用它们时能更加独立。
就“策略”一词来看,有当发生“某情况”时要做出什么“反应”的含义。从生活中可以举出许多在相同的环境下针对不同条件,要进行不同计算方式的例子:
当“购买商品满399”时,要加送“100元折价券”;
当“购买商品满699”时,要加送“200元折价券”。
当“客人是日本人”时,要“使用日元计价并加手续费1.5%”;
当“客人是美国人”时,要“使用美元计价并加手续费1%”。
当“超速未达10公里”时,“罚金3600元”;
当“超速10公里以上”时,“罚金3600元外,每公里再加罚1000元”。
当“选择换美金”时,“将输入的金额乘以美金汇率”;
当“选择换日元”时,“将输入的金额乘以日币汇率”。
……
在策略模式中,这些不同的计算方式就是所谓的“算法”,而这些算法中的每一个都应该独立出来,将“计算细节”加以封装隐藏,并让它们成为一个“算法”类群组。客户端只需要根据情况来选择对应的“算法”类即可,至于计算方式及规则,客户端不需要去理会。
策略模式的说明
将每一个算法封装并组成一个类群组,让客户端可以选择使用,其基本架构如图2所示。
图2 基本架构的示意图
参与者的说明如下:
- Strategy(策略接口类):提供“策略客户端”可以使用的方法。
- ConcreteStrategyA~ConcreteStrategyC(策略实现类):不同算法的实现。
- Context(策略客户端):拥有一个Strategy类的对象引用,并通过对象引用获取想要的计算结果。
策略模式的实现范例
首先定义Strategy的操作接口:
Listing1 算法的共享接口(Strategy.cs)
1
2
3
4
5 // Context通过此接口调用ConcreteStrategy实现的算法
public abstract class Strategy
{
public abstract void AlgorithmInterface();
}
接口中只定义一个计算方法AlgorithmInterface(),按设计的需要,可以将同一个领域下的演算方法都定义在同一个接口下。之后将真正要实现算法的部分写在Strategy的子类中:
Listing2 实现各种算法(Strategy.cs)
1 | // 算法A |
最后声明一个拥有Strategy对象引用的Context类:
Listing3 拥有Strategy对象的客户端(Strategy.cs)
1 | public class Context |
Context类提供了两个方法:SetStrategy可以用来设置要使用的算法;ContextInterface则用来测试当前算法的执行结果。测试程序如下:
Listing4 策略模式测试(StrategyTest.cs)
1 | void UnitTest() |
在测试程序中,将不同算法的类对象设置给Context对象,让Context对象去执行各种算法,得出不同的结果:
执行结果
1 | ConcreteStrategyA.AlgorithmInterface |
使用策略模式实现攻击计算
许多人在想到要应用策略模式时,常常会遇到不知从何切入的情况。究其原因通常是不知道如何在不使用if else或switch case语句的情况下,将这些计算策略配对调用。其实有时候处理方式是必须利用重构方法或搭配其他的设计模式来完成的,也就是先利用重构方法或搭配其他的设计模式将这些条件判断语句从程序代码中删除,再将策略模式加入到项目的设计方案中。否则,最常见的策略模式应用方式,还是会在if else或switch case语句中调用对应的策略类对象。
攻击流程的实现
根据本章开始时的说明可以得知,《P级阵地》的攻击计算中有3个事件点需要按条件(单位所属的阵营)来决定所使用的计算公式,这些公式当前共有6个(玩家阵营3个、敌方阵营3个),而这些计算公式可以利用策略模式加以封装。
在重新实现前,《P级阵地》先将角色属性(生命力、移动速度…)从角色类ICharacter中移出,放入专门存储角色属性的ICharacterAttr类中。使用专门的类,是因为要符合“单一职责原则(SRP)”的要求,让角色属性能够集中管理,同时也能减少角色类ICharacter的复杂度。而在ICharacterAttr中,拥有的是负责计算角色属性的“策略类对象”,并能在攻击流程中扮演属性计算的功能,如图3所示。
图3 角色属性放入专门存储角色属性的ICharacterAttr类
参与者的说明如下:
- ICharacterAttr:声明游戏内使用的角色属性、访问方法和声明攻击流程中所需要的方法,并拥有一个IAttrStrategy对象,通过该对象来调用真正的计算公式。
- IAttrStrategy:声明角色属性计算的接口方法,用来把ICharacterAttr与计算方法分离,让ICharacterAttr可轻易地更换计算策略。
- EnemyAttrStrategy:实现敌方阵营单位在攻击流程中所需的各项公式的计算。
- SoldierAttrStrategy:实现玩家阵营单位在攻击流程中所需的各项公式的计算。
实现说明
将角色属性从ICharacter类中独立出来,放入ICharacterAttr角色属性类中:
Listing5 定义角色属性接口(ICharacterAttr.cs)
1 | public abstract class ICharacterAttr |
在ICharacterAttr中,声明游戏角色需要使用的属性,并提供各个属性的访问方法,而声明ICharacterAttr为抽象类,是因为两个阵营有各自专用的属性类,必须在子类中加以定义:
Listing6 Soldier属性(SoldierAttr.cs)
1 | public class SoldierAttr : ICharacterAttr |
Listing7 Enemy属性(EnemyAttr.cs)
1 | public class EnemyAttr : ICharacterAttr |
IAttrStrategy类则定义了与攻击有关的计算方法:
Listing8 角色属性计算接口(IAttrStrategy.cs)
1 | public abstract class IAttrStrategy |
IAttrStrategy类包含属性的初始化InitAttr、获取攻击加成值GetAtkPlusValue和获取减少伤害值GetDmgDescValue3个方法,这3个方法都和角色在攻击流程中计算属性数值有关,所以被定义在同一个类下,可以减少产生过多类的问题。
IAttrStrategy被SoldierAttrStrategy和EnemyAttrStrategy两个子类继承,分别用来实现玩家阵营和敌方阵营角色的属性计算:
Listing9 玩家单位(士兵)的属性计算策略(SoldierAttrStrategy.cs)
1 | public class SoldierAttrStrategy : IAttrStrategy |
在SoldierAttrStrategy类中,实现初始化属性InitAttr和获取减少伤害值GetDmgDescValue应有的计算公式,让玩家阵营角色可以计算有防守优势的属性。
Listing10 敌方单位的属性计算策略(EnemyAttrStrategy.cs)
1 | public class EnemyAttrStrategy : IAttrStrategy |
在EnemyAttrStrategy类中,只针对获取攻击加成值GetAtkPlusValue,实现所需的计算公式。其中利用UnityEngine.Random类产生的概率值,将决定是否发生暴击。如果发生暴击,则按游戏设计的需求,返回攻击加成值,并减少暴击概率(这是一种游戏平衡的调整)。
当角色属性计算的相关算法类都封装好了之后,在ICharacterAttr类中,就可以加入IAttrStrategy的对象引用和相关操作方法,使其成为类成员:
Listing11 角色属性接口(ICharacterAttr.cs)
1 | public abstract class ICharacterAttr |
在ICharacterAttr类中,初始化角色属性时调用InitAttr,GetAtkPlusValue和CalDmgValue则是在攻击流程中被调用。从上面3个方法实现中可以发现,ICharacterAttr类不用理会当前记录的属性是属于玩家阵营还是敌方阵营,只需要通过IAttrStrategy的对象引用m_AttrStrategy来执行即可。而m_AttrStrategy对象是在SetAttStrategy方法中,被设置为“角色构建流程”中对应的计算策略类对象——SoldierAttrStrategy对象或EnemyAttrStrategy对象。随着游戏的开发进度,若有其他相关的计算策略类产生时,也可以使用相同的方式来设置新的计算策略,这一点将稍后说明。
ICharacterAttr类完成后,就可将其放入角色类ICharacter中,并修改攻击流程中的实现程序代码:
Listing12 角色接口(ICharacter.cs)
1 | public abstract class ICharacter |
运用策略模式的角色类ICharacter,以ICharacterAttr类对象来记录角色属性,并提供SetCharacterAttr方法,使得在角色构建流程中,可以设置该角色对应的属性(因为每个角色单位都有对应的生命力、移动速度),并且在其中调用ICharacterAttr类的InitAttr方法来进行第一次的角色属性初始化。
两个配合攻击流程的方法:Attack和UnderAttack,在修改后,将原本的角色阵营判断及公式计算都通过ICharacterAttr类来执行,而在ICharacterAttr类的方法中,会再通过IAttrStrategy类对象来调用对应的公式计算算法。
流程图可以让我们了解对象之间的互动流程。如图4所示为敌方阵营IEnemy攻击玩家阵营ISoldier时的攻击流程。
图4 敌方阵营IEnemy攻击玩家阵营ISoldier时的攻击流程
使用策略模式的优点
将角色属性计算运用策略模式有下列优点:
让角色属性变得好维护
对于改进后的角色类ICharacter来说,将角色属性有关的属性以专属类ICharacterAttr来取代可以使以后角色属性变动时,不会影响到角色类ICharacter。此外,随着游戏需求的复杂化,加入更多的角色属性是可预期的,所以让角色属性集中在同一个类下管理,将有助于后续游戏项目的维护,也可以减少角色类ICharacter的更改及降低复杂度。
不必再针对角色类型编写程序代码
通过ICharacterAttr与其子类的分工,将双方阵营的属性放置于不同的类中。对于角色类ICharacter而言,使用ICharacterAttr的对象引用时,完全不用考虑将使用哪一个子类对象,避免了使用switch case语句的编写方式及后续可能产生的维护问题。当有新的阵营类产生时,角色类ICharacter并不需要有任何改动。
计算公式的替换更为方便
在游戏开发的过程中,属性计算公式是最常变换的。运用策略模式后的ICharacterAttr,更容易替换公式,除了可以保留原来的计算公式外,还可以让所有公式同时并存,并且能自由切换,关于这一点的详细说明,将在策略模式面对变化时一节呈现。
实现策略模式时的注意事项
利用策略模式来管理算法群组,是一种有助于日后维护的好方法,但在使用时,仍有些需要注意的地方:
计算公式时的参数设置
当实现每一个策略类的计算公式时,可能需要外界提供相关的信息作为计算依据,所以IAttrStrategy中的每个方法都要求传入计算对象来作为依据,以《P级阵地》为例,SoldierAttrStrategy在计算角色初始化时,最高生命力(MaxHP)就是利用传入参数的ICharacterAttr对象转型为SoldierAttr类后来获取的。主要是因为玩家阵营角色的等级信息,是声明在SoldierAttr类而非其父类ICharacterAttr中。在当前的类设计规则下,唯有通过转换才能获取所需的等级信息,这其实违反了里氏替换原则(LSP),因此,这里留下了一个修改题目,让读者思考如何重构以符合里氏替换原则(LSP)。由于当前的实现存在转型失败的情况,所以在转型之后需要马上判断转型是否成功,必要的话可以加上警告信息。
与状态模式(State)的差别
如果读者仔细分析状态模式与策略模式的类结构图,可能会发现两者看起来非常相似,如图5和图6所示。
图5 Gof的状态模式
图6 Gof的策略模式
两者都被GoF归类在行为模式(Behavioral Patterns)分类下,都是由一个Context类来维护对象引用,并借此调用提供功能的方法。就笔者过去的实践经验,对于这两种模式,可归类出下面几点差异,供读者作为以后选择时的引用依据:
- State是在一群状态中进行切换,状态之间有对应和连接的关系;Strategy则是由一群没有任何关系的类所组成,不知彼此的存在。
- State受限于状态机的切换规则,在设计初期就会定义所有可能的状态,就算后期追加也需要和现有的状态有所关联,而不是想加入就加入;Strategy是由封装计算算法而形成的一种设计模式,算法之间不存在任何依赖关系,有新增的算法就可以马上加入或替换。
策略模式面对变化时
当策略模式日后遇到需求变化时,会如何呢?让我们来看一个可能的场景:
《P级阵地》开发中的某一天…
策划:“小程,可不可以帮我改一下,设置玩家阵营角色在受到攻击时,先不要受等级的影响,我想先测试平衡。”
小程:“可以啊!不过你是说‘先’改成,意思是你有可能再改回来吗?”
策划:“先测试再说。”
小程:“…好。”
这时的小程想了一下这个“先”字,因为按照过去的经验,“先”这个字有时候隐含着“改回来”的高度可能。不过所幸的是,现在的角色属性类ICharacterAttr已经运用了策略模式,可以保留现在的公式(SoldierAttrStrategy类)以便“改回来”时使用,新的公式只要再声明一个继承自SoldierAttrStrategy的子类,将获取减少伤害值的GetDmgDescValue方法,修正如下即可:
Listing13 玩家单位(士兵)的属性计算策略(应策划要求更改,没有减伤害值)
1 | public class SoldierAttrStrategy_NoDmgDescValue : SoldierAttrStrategy |
然后将原本设置给玩家阵营角色的SoldierAttrStrategy对象改成新的SoldierAttrStrategy_NoDmgDescValue对象就完成了。此时,小程心想“如果哪天策划测试完成又想要改回来,再将设置的对象改回SoldierAttrStrategy就可以了”。
又过了几天…
策划:“小程啊,前几天改的,玩家阵营角色在受到攻击时,不要受等级影响,我测试过了…但是好像效果不是很好,你可不可以再帮我改一下”。
小程笑了一下。
小程:“改回来吗? “
策划:“不是耶,原先的是乘以2,现在我想要改乘以1.5”策划歪着头说。
小程:“好的。”
小程马上按上次的修改方式又改好了,新增的程序代码如下:1
2
3
4
5
6
7
8
9
10
11
12// 获取减少伤害值
public override int SoldierAttrStrategy_PlusOneAndHalf(ICharacterAttr CharacterAttr)
{
// 是否为士兵类
SoldierAttr theSoldierAttr = CharacterAttr as SoldierAttr;
if (theSoldierAttr == null)
return 0;
// 返回减少伤害值
return (theSoldierAttr.GetSoldierLv()-1) * 1.5;
}
小程:“好了,请更新项目。”
策划:“这么快,那这样好了,上次改的两种公式都留着了吗?”
小程:“你是指原先乘以2和不要受等级影响的公式吗?”
策划:“是的,有吗? ”
小程:“有,都有。”
策划:“那这样好了,你可不可以做一个配置文件,让我可以选择这3个公式,我想做一下比较,但又不想每次都要请你改程序。”
小程:“这样也好,那再等一下,我修改一下。”
此时,小程在项目配置文件中增加了一个“玩家阵营公式选项”的设置参数,并在角色构建流程中增加了选择功能,该功能可以按照“玩家阵营公式选项”的参数设置值,从SoldierAttrStrategy、SoldierAttrStrategy_NoDmgDescValue、SoldierAttrStrategy_PlusOneAndHalf3个类中选择一个设置给玩家阵营角色。
看到了吗?类似的情节中,笔者亲身体验过,也就是在那时,深切体会到“策略模式”的好用之处。
结论
将复杂的公式计算从客户端中独立出来成为一个群组,之后客户端可以按情况来决定使用的计算公式策略,既提高了系统应用的灵活程度,也强化了系统中对所有计算策略的维护方式。让后续开发人员很容易找出相关计算公式的差异,同时修改点也会缩小到计算公式本身,也不会影响到使用的客户端。
与其他模式的合作
在角色的组装-建造者模式一章中,《P级阵地》将使用建造者模式(Builder)负责产生游戏中的角色对象。当角色产生时,会需要设置该角色要使用的“角色属性”,这部分将由各阵营的建造者(Builder)来完成。在之前曾经提及:若策略模式搭配其他设计模式一起应用的话,就可以不必使用if else或switch case来选择要使用的策略类。读者将在那篇文章中看到实际的案例。
其他应用方式
- 有些角色扮演型游戏(RPG)的属性系统,会使用“转换计算”的方式来获取角色最终要使用的属性。例如:玩家看到角色界面上只会显示“体力”“力量”“敏捷”…,但实际在运用攻击计算时,这些属性会被再转换为“生命力”“攻击力”“闪避率”…。之所以会这样设计的原因在于,该游戏有“职业”的设置,对于不同的“职业”,在计算转换时会有不同的转换方式,利用策略模式将这些转换公式独立出来是比较好的。
- 游戏角色操作载具时,会引用角色当前对该类型载具的累积时间,并将之转换为“操控性”,操控性越好,就越能控制该载具。而获取操控性的计算公式,也可以利用策略模式将其独立出来。
- 网络在线型游戏往往需要玩家注册账号,注册账号有多种方式,例如OpenID(Facebook、Google+)、自建账号、随机产生等。通过策略模式可以将不同账号的注册方式独立为不同的登录策略。这样做,除了可以强化项目的维护,也可以方便转换到不同的游戏项目上,增加重复利用的价值。