思考并回答以下问题:
- “适配”这个词是什么意思?适配器是什么?为什么要适配?举一个生活中的物品。
- 适配器模式的官方定义是什么?背下来。
- 适配器模式为什么是结构型模式?
- 使用俘兵的例子来理解适配器模式是个很好方法,为什么?
- 原先是有3个类,一个是客户端,一个是Target接口类,还有一个是与预期不同的类。适配器模式就是增加一个适配器类。就像二孔插头和插座,希望三孔插头可以使用,需要增加一个二孔到三孔的转换器。一旦想到适配器模式,就是增加一个适配器类。这个类要保持需要适配的类的引用,继承Target接口类,就像电源适配器连接两头一样。然后客户端使用里氏替换原则调用,怎么理解?
本章涵盖:
- 游戏的宠物系统
- 适配器模式
- 适配器模式的定义
- 适配器模式的说明
- 适配器模式的实现范例
- 使用适配器模式实现俘兵系统
- 俘兵系统的架构设计
- 实现说明
- 与俘兵相关的新增部分
- 使用适配器模式的优点
- 适配器模式面对变化时
- 结论
游戏的宠物系统
“宠物系统”一直是吸引玩家进入游戏的重点系统,想象在打怪冲关的过程中,旁边伴随着一只宠物协同一起作战和探险,除了跟随着玩家的简单行为外,有些游戏也会设计一些辅助功能给宠物,如捡宝、补血、提示信息等之类的操作。
就笔者参与过的项目来说,宠物系统的需求多半会在游戏开发的中后时期才出现,因为这通常是顺应市场变化而增加的新需求。当然,近几年开发的游戏只要内容合适,就会提早在游戏企划的初期就决定加入宠物系统。因此,对于一早就有计划要实现宠物系统的游戏来说,会在一开始就在设计内加入与宠物相关的系统架构及类,例如:
- 负责控制宠物的角色类。
- 战斗时要使用的AI状态类。
- 工厂类提供对应的宠物工厂方法。
- 宠物专用的角色属性。
- 3D成像规则等等。
若在项目中后期才决定要加入宠物系统,系统新增及修改的相关工作同样也是避免不了的,至于是否要大量更改原设计,就要看原有架构是否设计得够灵活。
当然,上述要求不管是初始时期或中后期才加入,都是专门设计一个宠物系统所要满足的,即美术部门需要产生项目的3D模型,编程部门需要编写新的宠物相关功能的类。还有另一种比较复杂的情况是,宠物系统也能将敌人(或所谓的怪物)收为己用,简单地说,就是被玩家打败过的敌人会被收录/记录下来(“招于麾下”),之后玩家在通关打怪时,就可以将之召唤出来成为宠物,一起帮助玩家通关。会有这样的设计想法不外乎是因为:
- 提供玩家收集的乐趣,但不只是收集,收集之后还能够被使用。
- 当打败的对手可以被重新召唤成为自己的手下时,玩家会有另一种成就感。其实在大型多人在线角色扮演游戏(MMORPG)的设计中,让其他玩家看到自己身边带一只非常难以打倒的Boss,会有一种炫耀的满足感。因此这样的宠物系统设计,在大型多人在线角色扮演游戏(MMORPG)中,多半都会列为重点系统之一。
- 当发现原本的宠物设置不足或上市后发现宠物系统为主要收入来源时,为了快速增加宠物数量以确保持续的营收收入,直接选择将“敌人”设置成为宠物,是最直觉的想法。
综合上述的情况,会发现功能需求多半是想将已经设计好的“敌人怪物”转换成“宠物”或“玩家可操控的单位”,但同时也希望保留敌人怪物的原始设置,而不是重新设计一组新的设置数据,这些设置数据包括角色属性、攻击方式、攻击能力等。简单来说,就是宠物在原本的“敌人”状态下,所呈现的外形或使用的招式,当它被玩家收服后,玩家也会希望成为宠物的它,也同样能做出相同的攻击方式以及发出同样绚丽的招式,如图1所示。
图1 宠物系统图解
俘兵系统
当前《P级阵地》项目算是进入了开发的后期,所有的系统和接口大多已经设计完成,系统架构也都实现完成了,但此时项目增加了下列需求,主要是想让游戏更具趣味性:
- 当玩家击倒对手达到一定数量时,地图上会出现一个特殊兵营——“俘兵兵营”,这个兵营可以训练出敌方的角色。
- 俘兵的角色属性和攻击方式不改变。
- 由“俘兵兵营”训练出的单位会为玩家效力,一同守护玩家阵营。
- “俘兵兵营”不提供升级功能,所以只能训练同一等级且使用同种武器的作战单位。
由上述的说明可知,新的需求是希望玩家能够训练出原本应该是敌方阵营的作战单位,而且训练出来的敌方作战单位要能保留原本的设置属性和攻击力,训练完成后进入到战场上时,也要能帮忙防护玩家阵营。类似这样的需求,就像章节一开始所提到的,《P级阵地》想要敌方角色直接改为玩家单位来使用。
《P级阵地》面对这样的需求时,应该如何进行调整才能满足这一项需求呢?就《P级阵地》当前的系统架构来看,或许可以增加一个“俘兵角色”类,且这个类必须具备两边阵营的部分行为。例如:俘兵角色产生时,必须运用敌方阵营的属性(EnemyAttr),所以不会有等级上的优势,也不能升级;AI行为则必须采用玩家阵营的AI行为——防护阵营而非攻击阵营;显示上则是使用敌方阵营的角色模型,如图2所示。
图2 游戏范例俘兵示意图
之后还需要新增一个“俘兵角色建造者(SoldierCaptiveBuilder)”,让已经运用建造者模式(Builder)的角色建造者系统(CharacterBuilderSystem),可产生“俘兵角色”对象。这个新增的“俘兵角色建造者(SoldierCaptiveBuilder)”内部,就会按照需求从当前双方阵营的功能中拼装出来:
Listing1 俘兵功能的实现
1 | // 建立俘兵时所需的参数 |
但是,这样的设计方式并不好,因为就像俘兵角色建造者(SoldierCaptiveBuilder)的程序代码所显示的,功能都来自双方阵营中不同的部分所拼装出来的,不像是个完整封装的类,而且大部分的功能可能还使用了“复制+粘贴”的方式来处理。如果不想产生过多重复的程序代码,那么针对现有的建造者(SoldierBuilder、EnemyBuilder)就必须再进行重构,让程序代码通过共享来解决“复制+粘贴”的问题,但这样一来又必须更改两个原有的类。
如果想要实现具有“完整性”概念的封装类,那么连带属性系统和AI系统,也都必须新增与“俘兵”相关的对应类,修改的工程就更为庞大了。
所以,应该思考的是,有没有更简单的方式让敌方类直接就能“假装”成玩家类,然后加入玩家类群组中。
在GoF的设计模式中,是否有合适的模式可以让《P级阵地》进行这样的修改呢?是否有某种模式,能够将一个类(敌方阵营)通过一个转换,就可直接被当成是另外一个类(玩家阵营)来使用呢?答案是有的,适配器模式正是用来解决这种情况,其功能也正如其名,适合用来进行“转接”两个类。
适配器模式
适配器模式如同字面上的意思,能将“接口”完全不符合的东西转换成符合的状态。换句话说,被转换的类一定与原有类的“接口不合”,这一点可以用来分辨与另外两个相似模式之间的差异。
适配器模式的定义
GoF对于适配器模式(Adapter)的定义是:1
将一个类的接口转换成为客户端期待的类接口。适配器模式让原本接口不兼容的类能一起合作。
解释适配器模式,最常举的例子就是一般生活中很容易遇到的“插头适配器”,如图3所示。
图3 插头适配器
出国旅游,尤其是到欧美国家,想要为携带的3C产品充电时,就必须考虑充电插头是否能插入当地国家的插座中。如果不行,最简单的方式就是买一个能够转接到当地插座的“适配器”。一般家中也会常遇到这样的情况,买的电器用品附带的插头是三头的,但是墙上的插座却是两孔的,如果不想折断三头插头上的接地线,那么同样也必须到电器行买一个“适配器”来进行转换。
运用相同的概念,软件设计上的“适配器”做的也是同样的转换工作,当出现一个不符合客户端接口的情况时,在不想破坏接口的前提下(例如不想折断三头插头上的接地线),就必须设计一个适配器来进行转换,将原本不符合的接口,转换到客户端预期的接口上,所以概念上是非常简单的。
适配器模式的说明
适配器模式的类结构如图4所示。
图4 适配器模式的类结构
GoF参与者的说明如下:
- Client(客户端):客户端预期使用的是Target目标接口的对象。
- Target(目标接口):定义提供给客户端使用的接口。
- Adaptee(被转换类):与客户端预期接口不同的类。
- Adapter(适配器)
- 继承自Target目标接口,让客户端可以操作;
- 包含Adaptee被转换类,可以设为引用或组合;
- 实现Target的接口方法Request时,应调用适当的Adaptee方法来完成实现。
适配器模式的实现范例
适配器模式的实现不难理解,首先要定义一个Client预期使用的类接口:
Listing2 应用领域(Client)所需的接口(AdapterTest.cs)
1 | public abstract class Target |
另外,就是一个已经实现完整的类,它可能是第三方函数库或项目内的一个已经设计完整的功能类,且当前可能无法更改或修改这个已经实现完成的类,这个类就是需要被转换的类:
Listing3 不同于应用领域(Client)的实现,需要被转换(AdapterTest.cs)
1 | public class Adaptee |
所以,在无法修改Adaptee的情况下,可以另外声明一个类,这个类继承自Target目标接口,其中包含一个Adaptee类对象;
Listing4 将Adaptee转换成Target接口(AdapterTest.cs)
1 | public class Adapter : Target |
Adapter在实现Target的接口方法Request时,则是调用Adaptee类中合适的方法来完成“转接”的工作。
对于Client端(测试程序)而言,面对的对象一样是Target接口,但内部已经被转换为由另一个类来执行:
Listing5 测试适配器模式(AdapterTest.cs)
1 | void UnitTest () |
从执行信息上可以看出,真正的功能是由Adaptee类执行的:
执行结果
1 | 调用Adaptee.SpecificRequest |
使用适配器模式实现俘兵系统
因为游戏实现已进入了后期,所以在当前的实现情况下使用适配器模式,将“敌人角色接口”转接成“玩家角色接口”会比较方便。如果是在游戏开发初期,笔者则建议将这个开发需求一并列入角色的设计中,这样才是比较好的开发规划。
俘兵系统的架构设计
适配器模式在应用上非常简单:当有一个类与预期使用的接口不同时,就实现一个适配器类,使用这个适配器类将接口不合的类转换成预期的类接口。在《P级阵地》新增的需求中,希望将敌方角色的类对象“转换/转接”成为玩家角色来使用,那么就可以直接实现一个“角色适配器”,来将敌方角色类转接为玩家角色类(的子类),因此《P级阵地》针对新增的需求,运用适配器模式后类结构如图5所示。
图5 《P级阵地》针对新增的需求,运用适配器模式后的类结构
参与者的说明如下:
- OtherGameSystem:《P级阵地》中其他的游戏系统,这些系统预期使用“俘兵”单位时,要和玩家阵营单位有一样的接口。
- ISoldier:玩家阵营角色的接口,新的需求是“俘兵”的概念,敌方角色单位要被转换成玩家阵营来使用。
- Enemy:敌方阵营角色类会被当作俘兵使用,但在适配器模式之下,接口不需要做任何调整。
- SoldierCaptive:俘兵类作为适配器类,负责将敌方角色类转换为玩家角色类来使用。
实现说明
《P级阵地》在运用适配器模式实现时,先取消之前使用俘兵角色建造者(BuilderSoldierCaptiveBuilder)的写法,改为只新增一个俘兵角色类(SoldierCaptive)作为类转接之用:
Listing6 实现俘兵类(SoldierCaptive.cs)
1 | public class SoldierCaptive : ISoldier |
在实现的内部转换过程中,无论是角色设置值得更换,还是播放音效、显示特效时的转换,都比旧方式更明确,也未破坏原有类的接口和设计概念。所以就这次新增的需求来看,“单纯的转换”比起重新设计组装一个新的类要好得多。
与俘兵相关的新增部分
当俘兵角色类(SoldierCaptive)被实现之后,无论采用的是哪一种方式(转接方式或是组装方式),《P级阵地》中,还有其他需要配合的部分。而修改的部分大多以“增加”类的方式来完成较少更改到现有的类接口。首先是新增一个可以训练俘兵角色单位的俘兵兵营(CaptiveCamp):
Listing7 新增俘兵兵营(CaptiveCamp.cs)
1 | public class CaptiveCamp : ICamp |
与玩家阵营中的其他兵营一样,继承自ICamp类后,再重新定义需要的方法。而在关键的训练方法Train中,则是产生一个新的训练俘兵(TrainCaptiveCommand)的命令:
Listing8 新增训练俘兵命令(TrainCaptiveCommand.cs)
1 | public class TrainCaptiveCommand : ITrainCommand |
因为兵营类使用命令模式(Command)来对训练作战单位的命令进行管理,因此新的“训练俘兵命令”必须继承自ITrainCommand,才能配合原有的设计模式。而在训练命令执行方法Execute中,可以看到实现上是先将原有的敌方角色对象产生后,再利用转接概念产生俘兵角色,之后顺应角色管理系统(CharacterSystem)的要求,将新产生的俘兵角色加入到玩家角色管理器中。
接下来,要修改的是兵营系统(CampSystem),因为现有的三座玩家兵营是由该系统来管理和初始化的,所以新增的俘兵兵营(CaptiveCamp)一样要放在兵营系统中来管理:
Listing9 兵营系统(CampSystem.cs)
1 | public class CampSystem : IGameSystem |
与玩家兵营的初始化过程类似,先搜索场景内由场景设计人员安排好的俘兵兵营对象,之后再新增一个俘兵兵营(CaptiveCamp)对象来和游戏对象对应,接着将兵营先隐藏并加入到管理器中。先隐藏的原因是,让俘兵兵营(CaptiveCamp)的出现由是否符合某项条件来决定。
而当前的规划是将条件设置为“当玩家击退敌方角色达到一定数量以上”时,而为了得知当前敌方角色的阵亡情况,所以针对“敌人角色阵亡主题(EnemyKilled Subject)”注册了一个观察者EnemyKilledObserverCaptiveCamp:
Listing10 兵营观察Enemey阵亡事件(EnemyKilledObserverCaptiveCamp.cs)
1 | public class EnemyKilledObserverCaptiveCamp : IGameEventObserver |
这个观察者会累计当前敌人单位阵亡的计数,当发现已达设置的上限时,就会将场景内的俘兵兵营(CaptiveCamp)显示出来。之后玩家就能够通过俘兵兵营(CaptiveCamp)下达训练指令,来产生俘兵角色(SoldierCaptive)。
针对这次需求的修改,兵营系统(CampSystem)增加了类成员及方法,但并未更改到其他实现,所以不会影响其他的客户端。这次的修改全都是由“新增类”的方式来完成的,类结构图如图6所示,其中标有底色的类,就是为这次的需求而新增的类。
图6 新增了俘兵相关的类成员及方法后的类结构图
对于系统的修改都能以“新增类”的方式来实现,这代表着符合“开一闭原则(OCP)”,也就是,在不更改现有接口的前提下,完成功能的新增。
角色访问者(CharacterVisitor)也因为这次修改,新增了一个成员方法VisitSoldierCaptive。所以在修正上,还必须去查看所有的访问者子类,判断是否都需要重新实现这个方法。而这个缺点在角色信息查询-访问者模式中已经提过,这是访问者模式(Visitor)在提供遍历对象功能时,所必须面对的取舍。
在完成上述的实现之后,当玩家在成功击退一定数量的敌方角色时,阵地中就会出现一座“俘兵兵营”,让玩家可以训练敌方角色,如图7所示。
图7 可以训练俘兵的兵营
使用适配器模式的优点
虽然适配器模式看似只不过是将同项目下的不同类进行转换,但适配器模式其实也具有减少项目依赖于第三方函数库的好处。游戏项目在开发时常常会引入第三方工具函数库来强化游戏功能,但一经引用就代表项目被绑定在第三方工具/函数库了。此时若没有适当的方式,将项目与第三方工具/函数库进行隔离,那么当第三方工具/函数库进行大规模改动时,或是想要替换成另一套有相同功能的工具/函数库时,都可能引发项目的大规模修改。
在这个时候,适配器模式可以适时扮演分离项目与第三方工具函数库的角色。在项目内先自行定义功能使用接口,再利用适配器模式,将真正执行的第三方工具运用在子类的实现之中,以此来形成隔离。若有多个第三方函数库可以选择时,对于项目而言,就不会造成太多转换上的困扰。
以项目常用的XML工具来说,当前常使用的有.Net Framework中的System.Xml工具以及Mono版的XML工具。如果不想让项目依赖于任何一个实现的话,那么可以先定义一个XMLInterface作为客户端使用的接口,之后再针对使用不同的工具库进行子类的实现,类结构图如图8所示。
图8 定义一个XMLInterface作为客户端使用的接口,即为“适配器”
适配器模式面对变化时
《P级阵地》中的敌方角色可成为俘兵被玩家训练使用,那么反过来,玩家单位其实也可以被敌方阵营所用。虽然《P级阵地》的游戏设计上,并不容易设计出一个合理的情况,让玩家单位转而成为敌方阵营。但随着游戏持续开发和维护,这也不是不可能发生的需求(例如某一天,敌方阵营改为在线由另一个玩家来操作,而非计算机自动操作时),所以,同样可以使用适配器类,来将玩家角色类转换成敌方角色类使用:
Listing11 玩家俘兵
1 | public class EnemyCaptive : IEnemy |
结论
适配器模式的优点是不必使用复杂的方法,就能将两个不同接口的类对象交换使用。此外,它也可以作为隔离项目与第三方工具/函数库的一个方式。
其他应用方式
早期Unity(4.6版本之前)官方的2D界面效果不容易转为使用其他接口工具,如NGUI。因此,在游戏界面开发上,可以使用适配器模式作为转换,让界面上的组件都能先定义一个专用的UI类,与这些第三方套装软件隔离。笔者就亲身遭遇过,因为使用了适配器,所以将NGUI从2.6版转换到3.8版时,只调整了几个UI类与NGUI 3.8版对应,就将项目的UI系统顺利地升级到NGUI 3.8版。
载具的驾驶系统会因为载具类型的不同而有所差异。早期设计时若是没有通盘考虑,很可能设计出让角色很难驾驭的驾驶系统。同样地,如果是在游戏开发后期才发现这个问题,游戏企划可能会希望某角色能去操控原本没有规划在内的载具,那么此时也可以利用适配器模式来作为两个驾驶系统之间的转接。