自定义操作指令集
在游戏系统中充满了各种各样的指令,玩家的操作输入,AI角色的行为输入,剧情的进展,战斗时的数据运算等等,这些看起来各自独立的部分在背后其实都被指令联系到了一起。
试想一下要是不使用指令,各种行为和运算都是硬编码写在游戏逻辑中,且不说后续拓展基本变为不可能的任务,光是编码过程中的白盒测试都难以进行,更让人无奈的是,这样的情况与编程人员的编码能力无关,仅仅是设计上的失误而已。
从设计模式开始——命令模式
命令模式(Command Pattern)是一种数据驱动的设计模式,它属于行为型模式。请求以命令的形式包裹在对象中,并传给调用对象。调用对象寻找可以处理该命令的合适的对象,并把该命令传给相应的对象,该对象执行命令。
命令模式的定义说得很清楚了,重点就在于将请求以命令的形式包裹起来,调用对象只管执行命令而不必关心这个命令究竟是什么。
那么如何将这种模式与游戏系统结合起来呢?设想一个场景,玩家拥有两项资源,比如金币和木头;然后有些行为可以获得资源或者物品,另外一些行为是消耗资源建造建筑,还有一些行为是消耗物品。
依照这个场景,可以设计如下的命令体系
// 命令接口
interface ICommand {
void execute(GameData data);
}
// 设计三种基本命令
class ResChangeCmd : ICommand { // 资源变化命令
public bool isConsume;
public int goldValue = 0;
public int woodValue = 0;
public void execute(GameData data) {
if(isConsume) {
data.consumeResource(goldValue, woodValue);
} else {
data.addResource(goldValue, woodValue);
}
}
}
class ItemChangeCmd : ICommand { // 物品变化命令
public bool isConsume;
public int itemID = -1;
public int itemCount = 0;
public void execute(GameData data) {
if(itemID != -1) {
if(isConsume) {
data.consumeItem(itemID, itemCount);
} else {
data.addItem(itemID, itemCount);
}
}
}
}
class BuildingChangeCmd : ICommand { // 建筑变化命令
public bool isDestroy;
public int buildingID = -1;
public Vector position;
public void execute(GameData data) {
if(buildingID != -1) {
if(isDestroy) {
data.destroyBuilding(buildingID, position);
} else {
data.constructBuilding(buildingID, position);
}
}
}
}
// 复杂命令合集
class ComplexCommand : ICommand {
private List<ICommand> cmdList = new List<ICommand>();
public void addCommand(ICommand cmd) {
cmdList.Add(cmd);
}
public void execute(GameData data) {
if(cmdList.Count > 0) {
foreach(ICommand cmd in cmdList) {
cmd.execute(data);
}
}
}
}
// 使用时可以构造普通命令或者复杂命令来执行
public void getItem(int id, int count, int goldCost, int woodCost) { // 消耗资源得到物品的指令
ComplexCommand command = new ComplexCommand();
ResChangeCmd cmd1 = new ResChangeCmd();
cmd1.isConsume = true;
cmd1.goldValue = goldCost;
cmd1.woodValue = woodCost;
ItemChangeCmd cmd2 = new ItemChangeCmd();
cmd2.isConsume = false;
cmd2.itemID = id;
cmd2.itemCount = count;
command.addCommand(cmd1);
command.addCommand(cmd2);
command.execute(gameData);
}
上述设计中的命令系统一方面利用命令模式将命令的执行方法包裹在命令对象中,另一方面使用复杂命令对象来构造功能更加丰富的命令,这样的设计对游戏的运算逻辑是很有好处的。
就上面的例子而言,命令类总共就只有四个,但是它们通过复杂命令类里的命令集合可以组合出许许多多的其它功能,比如花费金币购买物品,花费木头制造物品,拆除建筑获得金币,消耗物品获得木头等等。
这样一来,参考命令模式设计的命令系统就能在游戏开发中发挥应有的作用了,但是这样的命令系统局限性相当大,首先的问题就在于它的命令是通过对象来实现的,这就表示游戏中能运行的命令全部都要在对应的方法中设计好,包括创建命令对象或者集合,然后调用执行方法等。
如果希望有一种命令系统能根据外部输入来执行命令该怎么办呢?比如希望通过一连串的字符串输入来执行命令。
简单的拓展——操作协议
说起通过输入字符串来执行命令,很容易就能联想到网络编程,在最底下的物理层中,从网卡上得到的网络传输数据基本都是字节流,而解析这些字节流时需要用到的一个重要的东西就是协议。比如鼎鼎大名的TCP/IP协议,方便好用的UDP协议,网页传输的常客HTTP协议等等。
所谓协议,本质上是一种约定标准,在网络传输中由于双方接收和发送的都是字节流,因此如果不约定一种解析方式的话双方就无法交流。应用最广的TCP/IP协议就定义了自己的报头,特征码,分片等等一系列的标准,只要双方按照同一个标准进行发送和解析,那就能保证交流不出差错。
回到命令系统上来,既然要让命令系统可以接受一个字符串输入并且执行对应的指令,那么实际上就是要为命令系统设计一套通讯协议,这个通讯的双方分别是字符串输入方和命令执行对象。
基于这个思想,可以试着将前文的命令系统进行改造,设计一个简单的协议来对应三种不同的命令,并且通过定义字符串的标准格式来支持复杂命令的解析。
首先设计协议,简单起见可以使用单纯的字符串表示命令内容,后接参数,不同的命令之间用分号分割,复杂命令包含的子命令之间使用#符号分割。
- 资源变更命令:RESCHANGE
- 后接参数有三个:isConsume,goldValue和woodValue
- 范例:RESCHANGE 1 200 20;
- 物品变更命令:ITEMCHANGE
- 后接参数有三个:isConsume,itemID和itemCount
- 范例:ITEMCHANGE 0 1001 10;
- 建筑变更命令:BUILDCHANGE
- 后接参数有三个:isDestroy,buildingID和position
- 范例:BUILDCHANGE 0 9088 [10,10,1];
有了这样的命令协议,接着就可以编写命令解析模块了
class CommandCompiler {
public const string CMD_RESCHANGE = "RESCHANGE";
public const string CMD_ITEMCHANGE = "ITEMCHANGE";
public const string CMD_BUILDCHANGE = "BUILDCHANGE";
public static List<ICommand> compileCommand(string cmdStr) {
List<ICommand> cmdList = new List<ICommand>();
string[] splitStr = cmdStr.Split(';');
for(int i = 0; i < splitStr.Length; i++) {
ICommand cmd = null;
string singleCmd = splitStr[i];
string[] complexSplitStr = singleCmd.Split('#');
if(complexSplitStr.Length > 1) {
ComplexCommand complexCommand = new ComplexCommand();
for(int j = 0; i < complexSplitStr.Length; j++) {
string subCommandStr = complexSplitStr[j];
ICommand subCmd = compileSingleCommand(subCommandStr);
if(cmd != null) {
complexCommand.addCommand(subCmd);
}
}
cmd = complexCommand;
} else {
cmd = compileSingleCommand(singleCmd);
}
if (cmd != null) {
cmdList.Add(cmd);
}
}
return cmdList;
}
private static ICommand compileSingleCommand(string single) {
string[] subSplitStr = single.Split(' ');
ICommand result = null;
if (subSplitStr.Length == 3) {
switch (subSplitStr[0]) {
case CMD_RESCHANGE:
ResChangeCmd resCmd = new ResChangeCmd();
resCmd.isConsume = subSplitStr[1].Equals("1");
resCmd.goldValue = int.Parse(subSplitStr[2]);
resCmd.woodValue = int.Parse(subSplitStr[3]);
result = resCmd;
break;
case CMD_ITEMCHANGE:
ItemChangeCmd itemCmd = new ItemChangeCmd();
itemCmd.isConsume = subSplitStr[1].Equals("1");
itemCmd.itemID = int.Parse(subSplitStr[2]);
itemCmd.itemCount = int.Parse(subSplitStr[3]);
result = itemCmd;
break;
case CMD_BUILDCHANGE:
BuildingChangeCmd buildCmd = new BuildingChangeCmd();
buildCmd.isDestroy = subSplitStr[1].Equals("1");
buildCmd.buildingID = int.Parse(subSplitStr[2]);
buildCmd.position = Vector.parse(subSplitStr[3]);
result = buildCmd;
break;
default:
break;
}
}
return result;
}
}
通过这样一个命令解析器,字符串格式的命令就可以被解析成为游戏系统可以识别和运行的命令了,这也让游戏系统拥有了可以响应外部输入命令的能力。当然这也是有局限性的,因为协议的存在,相当于外部输入命令的格式被规定了,不按照格式输入的命令都无法正确解析,自然也就无法运行。
既然现在通过构造一个命令协议让游戏系统有了接受外部输入的命令并且正确执行的能力,那么有没有可能更进一步呢?换句话说,能不能让游戏系统本身变得“可编程”,外部输入的不光是命令,而是一整套程序呢?
答案是肯定的,事实上从前文的例子已经可以看出一些端倪了,遵循那个仅有三个命令的协议编写的命令字符串,如果按照分号拆成很多行,看起来不就很像是汇编语言了吗?
而要让游戏系统变得“可编程”,那么设计一门针对性的语言和相对应的编译器(或者解释器)就是无法回避的问题。前文中设计的协议其实就是一门非常非常简陋的语言,而编写的命令解析器也可以看成是一个很简单的编译器。
如果希望拥有更强更好的可编程性,那或许依照汇编语言的特性设计一门按行解析的语言会是一个很不错的开始,这种结构简单的语言几乎不怎么需要分词或者格式化之类的操作,流式解析就已经够用了。
继续往后也可以参考现在流行的一些脚本语言,设计一门针对当前游戏的脚本语言,字符集可以很小,编译器可以简单,这都会是很好的学习过程。