思考并回答以下问题:
- 命令模式的官方定义是什么?类图是什么样的?
- 命令模式是行为型模式吗?为什么?
- 将请求封装成为对象,什么是请求?怎么封装成对象?
- 将客户端的不同请求参数化怎么理解?
- 如果让你用命令模式更改旧系统,第一件事就是新建一个命令接口怎么理解?
- 命令模式的核心是命令管理器类和其维护的一个可以增删的List<Command>泛型列表,然后foreach执行。怎么理解?
本章涵盖:
- 兵营界面上的命令
- 命令模式
- 命令模式的定义
- 命令模式的说明
- 命令模式的实现范例
- 使用命令模式实现兵营训练角色
- 训练命令的实现
- 实现说明
- 执行流程
- 实现命令模式时的注意事项
- 命令模式面对变化时
- 结论
兵营界面上的命令
兵营系统及兵营信息显示讲解了《P级阵地》中,兵营系统与兵营信息显示的方式及流程。而在兵营界面(CampInfoUI)上,除了显示基本的信息外,还有4个功能按钮(如图1所示),让玩家可以针对兵营执行不同的操作。
图1 兵营界面
- 兵营升级:提升兵营等级,让该兵营产生角色的等级(SoliderLv)提升,可用来增加防守优势。
- 武器升级:每个兵营产生角色时,身上装备的武器等级为“枪”,通过升级功能可以让新产生的角色装备长枪和火箭筒加强角色的攻击能力。
- 训练:对兵营下达训练角色,并且可以连续下达命令,兵营会记录当前还没训练完成的数量。训练成功后,界面会显示准备训练的作战单位数。
- 取消训练:因为每个兵营都可以下达多个训练作战单位,所以也提供取消训练单位的功能,让资源(生产能力)能重新分配给其他命令。
配合Unity3D的界面设计,可以在兵营界面中放置4个UI按钮(Button)供玩家选择,如同在Unity3D的界面设计-组合模式中提到的,我们希望界面组件的响应能够在程序中进行设置,以让UI组件能在编辑模式下,“不”与任何一个游戏系统进行绑定。所以在《P级阵地》的玩家界面上,单击按钮后要执行哪一段功能,也会在每一个界面初始化时决定。以兵营界面(CampInfoUI)为例,在界面初始化方法(Initialize)中,除了将显示信息用的文字(Text)组件的引用记录下来之外,也会针对画面上的4个命令按钮(Button),设置它们被“单击”时分别要调用的是哪一种方法:
Listing1 兵营界面(CampInfoUI.cs)
1 | public class CampInfoUI : IUserInterface |
通过UITool的GetUIComponent\
Listing2 兵营界面(CampInfoUI.cs)
1 | public class CampInfoUI : IUserInterface |
通过实时获取玩家界面上的按钮(Button)组件,再指定监听函数的方式,就可以将界面上的按钮与《P级阵地》的功能加以连接。
训练作战单位的命令
《P级阵地》对兵营角色进行训练时,要求提供“训练时间”功能,也就是对兵营下命令时,不能马上产生角色并立即放入战场中,而是要给定某长度的训练时间,当训练时间到达后,角色才能产生并放入战场。因为游戏流畅度的要求,所以可以对兵营下达多个训练命令,让兵营在训练完一个作战单位之后,能接着产生下一个。因此,在实现上,需要设计一个管理机制来管理这些“排队”中的“训练命令”,这些训练命令还可以通过“取消训练”的指令,来减少排队中的命令数量。
如果只是单纯地想将玩家界面按钮与功能的执行分开,那么可以在每一个按钮的监听函数中调用功能提供者的方法,这样就能达成“命令”与“执行”分开的目标。但是如果还要加上能对这些命令“进行管理”,如新增、删除、调度等功能,则需要加入其他设计模式才行。而GoF提出的设计模式中,命令模式可以解决这样的设计需求。
界面上显示当前用鼠标单击兵营的基本信息包含名称、等级、武器等级等,另外还有4个功能按钮,为玩家提供对兵营下达命令的界面。而这些从界面下达的命令将会使用命令模式,让玩家的操作与游戏的功能产生关联并执行。
命令模式
在本节使用软件开发作为范例说明设计模式之前,我们先举个较为生活化的例子来说明命令模式。例如,在餐厅用餐就是命令模式的一种表现,当餐厅的前台服务人员接收到客人的点餐之后,就会将餐点内容记载在点餐单(命令)上,这张点餐单就会随着其他客人的点餐单一起排入厨房的“待做”列表(命令管理器)内。厨房内的厨师(功能提供者)根据先到先做的原则,将点餐单上的内容一个个制作(执行)出来。当然,如果餐厅不计较的话,那么等待很久的客人,也可以选择不继续等待(取消命令),改去其他餐厅用餐。
命令模式的定义
GoF对于命令模式(Command)的定义如下:1
将请求封装成为对象,让你可以将客户端的不同请求参数化,并配合队列、记录、复原等方法来执行请求的操作。
上述定义可以简单分成两部分来看待:
- 请求的封装;
- 请求的操作。
请求的封装
所谓的请求,简单来说就是某个客户端组件,想要调用执行某种功能,而这个某种功能是被实现在某个类中。一般来说,如果想要使用某个类的方法,通常最直接的方式就是通过直接调用该类对象的方法。但有的时候,调用一个功能的请求需要传入许多参数,让功能执行端能够正确地按照客户端的需求来执行,常见的做法是使用参数行的方式,将这些调用时要引用的设置传入方法中。但是,当功能执行端提供过多的参数让客户端选择时,就会发生参数行过多的情况。因此为了方便阅读,通常会建议将这些参数行上的设置以一个类加以封装,参数封装的示意图如图2所示。利用这样的方式,将调用功能时所需的参数加以封装,就是“请求的封装”。如果以餐厅点餐的例子来看,请求的封装就如同前台服务人员将客人的点餐内容写在点餐单上。
图2 参数封装
在介绍角色的组装-建造者模式时,针对产生每个角色时所需要的设置参数,在“请求”SoliderBuilder执行建造功能前,都先使用SoldierBuildParam类对象,将所有参数集中设置在其中,除了方便阅读外,这样的方式也可视为简易的“请求的封装”: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 CharacterFactory : ICharacterFactory
{
// 产生Soldier
public override ISoldier CreateSoldier( ... )
{
// 产生Soldier的參數
SoldierBuildParam SoldierParam = new SoldierBuildParam();
// 产生对应的Character
switch( emSoldier)
{
case ENUM_Soldier.Rookie:
SoldierParam.NewCharacter = new SoldierRookie();
break;
case ENUM_Soldier.Sergeant:
SoldierParam.NewCharacter = new SoldierSergeant();
break;
case ENUM_Soldier.Captain:
SoldierParam.NewCharacter = new SoldierCaptain();
break;
default:
Debug.LogWarning("CreateSoldier:无法产生[" + emSoldier + "]");
return null;
}
if( SoldierParam.NewCharacter == null)
return null;
// 设置共享参数
SoldierParam.emWeapon = emWeapon;
SoldierParam.SpawnPosition = SpawnPosition;
SoldierParam.Lv = Lv;
// 产生对应的Builder及设置参数
SoldierBuilder theSoldierBuilder = new SoldierBuilder();
theSoldierBuilder.SetBuildParam( SoldierParam );
// 产生
m_BuilderDirector.Construct( theSoldierBuilder );
return SoldierParam.NewCharacter as ISoldier;
}
}
如果将“封装”的操作再进一步的话,也就是连同要调用的“功能执行端”一起被封装到类中,如图3所示。这种情况通常发生在功能执行端(类)不确定、有多个选择或客户端是一个通用组件不想与特定实现绑在一起时。
图3 调用的对象也可以被封装进去
请求的操作
当请求可以被封装成一个对象时,那么这个请求对象就可以被操作,例如:
- 存储:可以将“请求对象”放入一个“数据结构”中进行排序、排队、搬移、删除、暂缓执行等操作,如图4所示。
- 记录:当某一个请求对象被执行后,可以先不删除,将其移入“已执行”数据容器内。通过查看“已执行”数据容器的内容,就可以知道系统过去执行命令的流程和轨迹。
- 复原:延续上一项记录功能,若系统针对每项请求命令实现了“反向”操作时,可以将已执行的请求复原,这在大部分的文字编辑软件和绘图编辑软件中是很常见的。
图4 请求被放入容器内时,可执行的操作
命令模式的说明
命令模式的结构如图5所示。
图5 命令模式的结构图
GoF参与者的说明如下:
- Command(命令接口):定义命令封装后要具备的操作接口。
- ConcreteCommand(命令实现):实现命令封装和接口,会包含每一个命令的参数和Receiver(功能执行者)。
- Receiver(功能执行者):被封装在ConcreteCommand(命令实现)类中,真正执行功能的类对象。
- Client(客户端/命令发起者):产生命令的客户端,可以视情况设置命令给Receiver(功能执行者)。
- Invoker(命令管理者):命令对象的管理容器或是管理类,并负责要求每个Command(命令)执行其功能。
命令模式的实现范例
实现上,在运用命令模式之前,功能执行的类通常都已经在项目中实现好了。假设现存的系统中已有两个功能执行的类:Receiver1和Receiver2。
Listing3 两个可以执行功能的类(Command.cs)
1 | // 负责执行命令1 |
如果想要让这两个类的“功能执行”能够被“管理”,则需要将它们分别封装进“命令类”中。首先定义命令接口:
Listing4 执行命令的接口(Command.cs)
1 | public abstract class Command |
接口只定义了一个执行(Execute)方法,让命令管理者(Invoker)能够要求Receiver(功能执行者)执行命令。因为有两个功能执行类,所以分别实现两个命令子类来封装它们:
Listing5 将命令和Receiver1对象绑定起来(Command.cs)
1 | public class ConcreteCommand1 : Command |
Listing6 将命令和Receiver2对象绑定起来(Command.cs)
1 | public class ConcreteCommand2 : Command |
每个命令在构建时,都会指定“功能执行者”的对象引用和所需的参数。而传入的对象引用及参数都会定义为命令类的成员,封装在类中。每一个命令对象都可以加入Invoker(命令管理者)中。
Listing7 命令管理者(Command.cs)
1 | public class Invoker |
Invoker(命令管理者)中,使用List泛型容器来暂存命令对象,并在执行命令(ExecuteCommand)方法被调用时,才一次执行所有命令,并清空所有已经被执行的命令,等待下一次的执行。
测试程序本身就是Client(客户端),用来产生命令并加入Invoker(命令管理者):
Listing8 测试命令模式
1 | void UnitTest () |
产生两个命令之后,再将它们分别加入Invoker(命令管理者)中,然后一次执行所有的命令,信息窗口上可以看到下列信息,表示命令都被正确执行:1
2Receiver1.Action:Command[你好]
Receiver2.Action:Param[999]
上述范例看似颇为简单,也正因为如此,让命令模式在实现上的弹性非常大,也出现许多变化的形式。在实际分析时,可以着重在“命令对象”的“操作行为”加以分析:
- 如果希望让“命令对象”能包含最多可能的执行方法数量,那么就加强在命令类群组的设计分析。以餐厅点餐的例子来看,就是要思考,是否将餐点与饮料的点餐单合并为一张。
- 如果希望能让命令可以任意地执行和撤销,那么就需要着重在命令管理者(Invoker)的设计实现上。以餐厅点餐的例子来看,就是要思考这些点餐单是要用人工管理还是要使用计算机系统来辅助管理。
- 此外,如果让命令具备任意撤销或不执行的功能,那么系统对于命令的“反向操作”的定义也必须加以实现,或者将反向操作的执行参数,也一并封装在命令类中。
读者可以试着分析任何一套文字编辑工具或程序开发用的集成开发环境IDE工具中的“撤销”和“取消撤销”功能。可以假设这些工具都将用户的操作或“功能请求”加以记录(封装),并在下达“撤销”指令时,将原有的操作取消,而取消时的操作本身必须由原操作执行了什么行为而定。
使用命令模式实现兵营训练角色
将“玩家指令(请求)封装”后再显示给玩家看的游戏还挺多的。笔者最先想到的是早期的即时战略游戏(RTS:Realtime Strategy Game),每次下达兵营训练士兵的命令或兵工厂下达生产战车的命令时,画面上都排满了图标——表示等待被生产的单位,近期的城镇经营游戏也常看到相同的呈现方式。因此,《P级阵地》也以类似的手法,将训练命令实现在游戏中。
训练命令的实现
分析《P级阵地》对于兵营命令的需求如下:
- 每个兵营都有自己的等级以及可训练的兵种,必须按照不同兵营,下达不同的训练命令。
- 有“训练时间”的功能,所以每一个训练命令都会先被暂存而不是马上被执行。
- 可以对兵营下达多个训练命令,所以会有多个命令同时存在必须被保存的需求。
- “取消训练”来减少训练命令发出的数量。
按照上述的分析,对于《P级阵地》中的训练命令,我们可以规划出几个实现目标:可以将命令封装成类,让每一个兵营能针对本身的属性下达训练命令;使用训练命令管理者将所有命令进行暂存,并按照训练时间的设置,执行每一个训练命令;提供接口让命令可以被添加或删除。
所以,我们在《P级阵地》的兵营(ICamp)类中增加了“命令管理容器”和操作接口来完成命令模式的实现,如图6所示。
图6 兵营(ICamp)类中增加了“命令管理容器”和操作接口来完成命令模式的实现
参与者的说明如下:
- ITrainCommand:训练命令接口,定义了《P级阵地》中训练一个作战单位应有的命令格式和执行方法。
- TrainSoldierCommand:封装训练玩家角色的命令,将要训练角色的参数定义为成员,并在执行时调用“功能执行类”去执行指定的命令。
- ICharacterFactory:角色工厂,实际产生角色单位的“功能执行类”。
- ICamp:兵营接口,包含“管理训练作战单位的命令”的功能,即担任Invoker(命令管理者)的角色。使用泛型来暂存所有的训练命令,并且使用相关的操作方法来添加、删除训练命令。
- SoldierCamp:Soldier兵营界面,负责玩家角色的作战单位训练。当收到训练命令时,会产生命令对象,并按照当前兵营的状态来设置命令对象的参数,最后使用ICamp类提供的接口,将命令加入管理器内。
实现说明
在《P级阵地》的游戏设计需求中,因为只有“训练角色”需要管理命令的功能,所以先定义一个名为ITrainCommand的训练命令接口:
Listing9 执行训练命令的接口(ITrainCommand.cs)
1 | public abstract class ITrainCommand |
接口中只定义了一个操作方法:Execute执行命令。后续从ITrainCommand延伸出一个子类——TrainSoldierCommand,用来封装训练玩家阵营角色的命令:
Listing10 训练Soldier(TrainSoldierCommand.cs)
1 | public class TrainSoldierCommand : ITrainCommand |
Soldier训练命令类(TrainSoldierCommand)中,将产生玩家角色时所需的参数设置为类成员,并在命令被产生时就全部指定。而TrainSoldierCommand的“功能执行类”就是角色工厂(ICharacterFactory),这些参数在执行命令(Execute)方法中被当成参数传入角色工厂类的方法中,执行产生角色的功能。
在同时担任“命令管理者”的兵营(ICamp)类中,使用List泛型容器来暂存训练命令:
Listing11 兵营接口(ICamp.cs)
1 | public abstract class ICamp |
除了新增的命令管理容器之外(m_TrainCommands),另外还新增了4个与命令管理容器有关的操作方法供客户端使用。在执行命令方法RunCommand中,会先判断当前训练的冷却时间到了与否,如果到了,则执行命令管理容器(m_TrainCommands)的第一个命令,执行完成后就将命令从命令管理容器(m_TrainCommands)中删除。
至于定期调用每一个兵营(ICamp)类的RunCommand方法,则由兵营系统的定时更新(Update)来负责:
Listing12 兵营系统(CampSystem.cs)
1 | public class CampSystem : IGameSystem |
训练命令的产生点,则是由Soldier兵营类(SoldierCamp)来负责:
Listing13 Soldier兵营(SoldierCamp.cs)
1 | public class SoldierCamp : ICamp |
在训练Soldier的Train方法中,直接产生一个训练Soldier单位(TrainSoldierCommand)的命令对象,并以当前兵营记录的状态设置封装命令的参属性,最后利用父类定义的增加训练命令AddTrainCommand方法,将命令加入父类ICamp的命令管理器中,并等待系统的调用来执行命令。
最后,在兵营界面(CampInfoUI)中,将“训练按钮”和“取消训练按钮”的监听函数,设置为调用Soldier兵营界面(SoldierCamp)中对应的“训练方法”和“取消训练的方法”,来完成整个玩家通过界面下达训练作战单位的命令流程:
Listing14 兵营界面(CampInfoUI.cs)
1 | public class CampInfoUI : IUserInterface |
执行流程
各类对象之间的执行流程,可通过如图7所示的流程图来了解。
图7 各类对象之间的执行流程
实现命令模式时的注意事项
命令模式并不难理解与实现,但在实现上仍须多方考虑。
命令模式实现上的选择
《P级阵地》的兵营界面上,除了与训练单位有关的两个命令(训练、取消训练)之外,另外还有两个与升级有关的命令按钮(兵营升级、武器升级)。但针对这两个界面命令,《P级阵地》并没有运用命令模式来实现:
Listing15 兵营界面(CampInfoUI.cs)
1 | public class CampInfoUI : IUserInterface |
调用ICamp接口中的方法,这些方法并没有使用命令模式来封装:
Listing16 Soldier兵营(SoldierCamp.cs)
1 | public class SoldierCamp : ICamp |
不运用命令模式的主要原因在于:
- 类过多:如果游戏的每一个功能请求都运用命令模式,那么就有能会出现类过多的问题。每一个命令都将产生一个类来负责封装,大量的类会造成项目不易维护。
- 请求对象并不需要被管理:指的是兵营升级和武器升级两个命令,在执行上并没有任何延迟或需要被暂存的需求,也就是当请求发出时,功能就要被立即执行。因此,在实现上,只要通过接口类提供的方法(ICamp.LevelUp、ICamp.WeaponLevelUp)来执行即可,让功能的实现类(SoldierCamp)与客户端(CampInfoUI)分离,就可以达成这些功能的设计目标了。
因此,在《P级阵地》中,选择实现命令模式的标准在于:1
当请求被对象化后,对于请求对象是否有“管理”上的需求。如果有,则以命令模式实现。
需要实现大量的请求命令时
随着实现游戏的类型越来越多,可能会遇到需要使用大量请求命令的项目,比如需要与游戏服务器(Game Server)沟通的多人在线游戏(MMO)。大部分在设计服务器(Server)与客户端(Client)的信息沟通时,也会以请求命令的概念来设计,所以实现上也大多会使用命令模式来完成。
但是,一个中小型规模的多人在线游戏,Server与Client之间的请求命令可能多达上千个,若每一个请求命令都需产生类的话,那么就真的会发生“类过多”的问题。为了避免这样的问题发生,可以改用下列的方式来实现:
- 1.使用“注册回调函数(Callback Function)”:同样将所有的命令以管理容器组织起来,并针对每一个命令,注册一个回调函数(Callback Function),并将“功能执行者”(Receiver)改为一个“函数/方法”,而非类对象。最后,将多个相同功能的回调函数(Callback Function)以一个类封装在一起。
- 2.使用泛型程序设计:将命令接口(ICommand)以泛型方式来设计,将“功能执行者”(Receiver)定义为泛型类,命令执行时调用泛型类中的“固定方法”。但以这种方式实现时,限制会比较大,必须限定每个命令可以封装的参数数量,而且封装参数的名称比较不直观,也就是将参数以Parm1、Param2的方式命名。
因为固定调用“功能执行者(Receiver)”中的某一个方法,所以方法名称会固定,比较不容易与实际功能联想。
话虽如此,但如果系统中的每个命令都很“单纯”时,使用泛型程序设计可以省去重复定义类或回调函数的麻烦。
命令模式面对变化时
当“请求”可以被封装对象化之后,那么对于可产生“请求”的地点,灵活度就比较大:
企划:“小程啊,是这样的,最近测试人员反应,他们在进行极限值测试时,不是很方便。因为要将每个兵营的等级提升到最高级,需要花点时间。他们是想,我们能不能提供一个可以快速、马上就能产生玩家角色的功能。”
小程:“可以啊,那么我另外提供一个测试界面,这个界面上可以指定要产生的单位兵种、等级、武器等信息,单击指令后,就可以马上在战场上产生角色”,于是小程新增了一个测试界面(TestToolUI),如图8所示。
图8 新增的测试界面
在界面的“产生单位按钮Create”的监听测试中,获取界面的设置值之后,直接产生Soldier训练命令,并且立即执行:1
2
3
4
5
6
7
8
9
10
11
12
13private void OnAddSoldier()
{
ENUM_Weapon emWeapon = GetWeaponType(); // 武器等级
int Lv = GetLv(); // 兵营等级
Vector3 Position = GetPosition(); // 训练完成后的集合点
ENUM_Soldier emSoldier = GetSoldierType(); // 兵种
// 产生一个训练命令
TrainSoldierCommand NewCommand = new TrainSoldierCommand(emSoldier, emWeapon, Lv, Position);
// 马上执行
NewCommand.Execute();
}
因为《P级阵地》已经将“训练角色命令”对象化了。因此,只要将所需要的参数,在命令产生时都正确设置的话,那么在任何功能需求点,都能快速产生训练命令并执行。另外,因为不必指定命令功能执行的对象,所以当系统因需求改变而要更换功能执行的类时,不需要修改所有命令产生的地点,这一部分已经被命令类(ITrainCommand)隔离了。
结论
命令模式的优点是,将请求命令封装为对象后,对于命令的执行,可加上额外的操作和参数化。但因为命令模式的应用广泛,在分析时需要针对系统需求加以分析,以避免产生过多的命令类。
其他应用方式
实现网络在线型游戏时,对于Client/Server间数据封包的传递,大多会使用命令模式来实现。但对于数据封包命令的管理,可能不会实现撤销操作,一般比较侧重于执行和记录上。而“记录”则是网络在线型游戏的另一个重点,通过记录,可以分析玩家与游戏服务器之间的互动,了解玩家在操作游戏时的行为,另外也有防黑客预警的作用。