learn, think, share, communication
Posts tagged 抽象
简述游戏逻辑及编辑器的抽象
May 16th
最近一段时间以来,本人参与了公司下一代游戏编辑器的开发,从而有机会针对编辑器设计做一些简单的思考——如何设计更好的抽象,从而达到在客户端,服务端,以及游戏编辑器中复用尽可能多的代码?如何能够尽可能的缩短游戏设计师(策划)及美术设计师(3D/2D场景美术)的工作流程?市面上优秀的引擎往往都附带有所见即所得编辑器,这样的编辑器应当如何设计?网络游戏编辑器又有哪些可以从中借鉴和学习之处?
将尚不是很成熟的思考结果总结成本文。本人水平所限,许多错漏之处难免考虑不周全,如果有同学对本文所述的问题有任何想法,亦或是有其他文章与此相关,欢迎一起交流。
客户端逻辑的复用
把游戏客户端进行拆解,可以将其看成一个拥有输入、输出及内部逻辑循环的系统。玩家通过鼠标键盘发送消息给客户端,服务器通过网络接口发送消息给客户端,然后客户端于每一帧把当前对应的表现绘制到屏幕上。
客户端输入包括:Windows消息(键盘,鼠标,windows的其他消息,快捷键,定时器,摇杆等),网络消息(通过服务器或者其他玩家发送来的消息);输出包括:屏幕显示,网络消息(发送给服务器的响应或请求)。
事实上一个游戏编辑器也可以简单的看成类似于上述的系统,其输入包括:Windows消息(键盘,鼠标,windows的其他消息,菜单快捷键等,定时器等);输出则包括了屏幕显示,正常情况下编辑器不需要接受或发出任何网络消息(这里指与服务器进行逻辑上的通信,而非指通过版本控制系统进行数据的同步管理)。
实际上编辑器与客户端程序实际上都拥有相似的输入接口(Windows消息),只是同样的消息引发了不同的逻辑——在这里我们可以对输入接口做以抽象,并使用不同的实现来分别实现其逻辑,从而达到在编辑器中拥有热切换编辑状态和游戏状态逻辑的能力。(许多知名的游戏引擎都拥有类似上述的可在编辑器中切换编辑/游戏状态的功能,比如Crysis的SandBox编辑器,再如RunicGames的TorchLight编辑器等)。
具体来说,通过设计一个类似如下的接口(只考虑位于单个窗口中操作的情况):
class Operation
{
public:
virtual bool OnLButtonDown(POINT pt, UINT uFlags) = 0;
virtual bool OnLButtonUp(POINT pt, UINT uFlags) = 0;
virtual bool OnMouseMove(POINT pt, UINT uFlags) = 0;
virtual bool OnKeyDown(UINT nChar, UINT nRepCounts, UINT nFlags) = 0;
};
(此处仅起示意作用,就不将全部的接口都列出了)
实际程序中,我们在消息处理线程中将收到的Windows消息交给该接口,该接口背后的实现是编辑器逻辑呢?(左键按下是在选取场景中的某个物体,拖动其位置等)还是客户端逻辑呢?(左键按下是在尝试点击某个NPC或者怪物,并引发下一步动作,攻击之?对话之?)我们的前端程序并不关心接口背后的实现逻辑为何种。这样我们就可以达到动态替换逻辑的目的——在编辑器中一键切换运行模式。
这样做(所见即所玩)有什么好处?好处很多——如果不能在编辑器中立即看到游戏效果,那么游戏编辑人员(国内通常为策划人员,国外的游戏开发者中颇多为关卡设计师)必须首先在编辑器中将各类物体,对象,事件安放好,然后导出数据,再在游戏客户端中找到对应场景,并一一测试在编辑器中设置的对象。这种做法相比于在编辑器中立时可见(所编辑即所玩)的设计,其缺陷在于增加了中间环节,增加了出错可能性,当出现了与预想结果不同的现象时,定位问题所在需要花费更多时间,从而降低了游戏开发效率。此外,由于游戏开发设计的需要,往往编辑器编辑的许多数据并不是游戏最终直接使用的数据,因此存在编辑器导出数据的环节,编辑器需要将编辑好的数据导出,客户端程序需要将导出后数据进行加载,导出和加载都可能存在错误(比如编辑器中新增了功能(版本增加,文件结构改变),客户端必须增加或修改对应代码才能支持,这些环节的出错,都会降低游戏开发效率)。
良好的设计架构,通过把逻辑封装在一套预定义好的接口背后,使得同一套客户端/服务端逻辑,可以被复用于编辑器中,也可以复用于客户端/服务端程序中(逻辑代码的复用存在两个级别——代码级与二进制级,如果没有平台差异,那么在合理的设计中,我们理应做到二进制级的复用——即通过一个设计好的二进制动态运行库交给不同的进程加载;某些逻辑可能只能在源码级进行复用——比如服务器端逻辑;当然倘若采用脚本语言编写逻辑,我们可以忽略这里的差别)。当我们切换编辑器模式,程序从编辑状态转入游戏状态时,接口背后的实现被替换为客户端逻辑,最终达到在编辑器中伪造出客户端操作及逻辑的目的。
除了获得所见即所玩的好处,这种将输入接口抽象的做法,还有一个额外的好处——录像重播功能。将游戏视为输入输出系统的设计,给了我们将所有的输入事件按照时间顺序记录(通过输入设备的鼠标键盘事件,系统/定时器消息,网络消息等)。如果将所有的输入事件,按照时间记录保存下来,后再按照时间顺序作为输入重新调用对应的接口,我们就获得了整个游戏过程的重演。星际争霸,以及魔兽争霸等游戏中都有录像功能,可以将游戏过程记录下来,并重新播放,应当是基于类似的技术。
更进一步,由于网络游戏中的绝大部分逻辑是运行在服务器上的,例如与NPC交互时有什么反应,NPC会说什么话,会提供什么可选任务,会提供什么服务,NPC会如何行动,怪物会如何行动,怪物被击杀主角获得什么奖励,主角有什么技能,可以做什么事情(移动,施放技能,击杀怪物,拾取物品等)。诸如此类的逻辑实际上并非客户端逻辑,而是服务器逻辑,如何在编辑器中模拟这些东西呢?
服务端逻辑的复用
不同于单机游戏——游戏逻辑运行于本机上,网络游戏往往多数逻辑都运行在服务器上。这就造成游戏开发中的困扰——编辑好的场景,NPC,怪物无法立即看到实际状况,游戏设计师们往往并不知道该NPC是否一切正常。设计师需要将编辑器中制作的场景导出给服务器端程序,服务端可能需要重启一次服务器,加载这部分数据,然后设计者再从客户端启动游戏,连接服务器并测试新加入的内容。(这其中的流程包括,编辑器导出数据给服务端,服务端重启,服务端读取新数据,启动客户端并连接服务器:每一个步骤都可能存在其他因素引入的错误,降低开发效率;类似的道理,当出现表现出来的状况与预想不同时,游戏设计师难以定位问题所在)。
由于本人没有编写过服务端代码,对网络游戏服务端的架构并不是很熟悉,因此以下的分析仅从个人理想化的角度出发,简单谈谈服务端逻辑在编辑器中的整合。服务端运行的游戏逻辑,主要包括玩家逻辑(客户端发送的让玩家做某某事的请求,其他玩家的状态广播,玩家状态改变,玩家数据改变),怪物逻辑(怪物的行为,技能,AI,掉落以及击杀奖励等),技能逻辑(技能的时间,伤害,状态等),可达地点判定(什么地方可以到达,什么地方不能走)。服务端逻辑也可以简单的看成一个大循环,一方面通过网络收到的消息和内部触发机制创造事件,另一方面不断的循环处理事件(期间可能要读数据库,读服务器数据等),并最终做以输出——通过网络发送消息给玩家,或者写数据库。
将服务端进行拆解:我们得到一个读取(从数据文件/网络/数据库),逻辑循环(处理事件),输出(写文件/写数据库/发送网络消息)的模型。其中数据文件——往往是由游戏设计师在场景中编辑的数据,或者技能数据,或者游戏里的其他模板(物品,人物,怪,NPC等)数据,数据库往往保存了玩家的数据,人物技能/人物等级/属性/物品等。
思考一下,我们发现,服务器逻辑所需要的游戏数据文件,各类数据模板,定时器,触发器等正是编辑器所拥有的数据。服务端所需要的网络访问,数据库访问,则可以通过Proxy模式伪造一个网络层和数据库层,让服务器逻辑运行在非真实服务器的环境下(虽然服务器逻辑会以为自己运行于服务器环境中)。这就给我们在编辑器环境中运行服务器逻辑提供了可能性。
最后,要想达到服务端代码复用,那么第一就是要求逻辑代码本身不具有任何的平台相关性——调用了LinuxAPI或者WindowsAPI都是应该避免的——理想化的做法是使用java/python之类的跨平台语言。(在同做服务端程序的同事的交流中,了解到服务端程序运行的瓶颈往往并不在于CPU计算,而在于网络IO上,因此采用非平台相关语言,未必会增加多少运行上的效率损失,反而带来了代码复用的便利性,换句话说,就算我们不采用平台无关语言编写服务端逻辑,比如采用c或者c++编写服务端逻辑,只要这部分逻辑代码不沾染任何平台相关API即可。)这么做的目的是为了服务端逻辑的跨平台复用。
设计中尚存在的问题
前面谈到的目标是好的,但事实上还存在许多尚且不确定的地方。接下来简单分析一下上述做法还有哪些问题尚未考虑周全:
1. 数据格式不统一的问题。客户端逻辑需要读取数据(美术制作的模型,凸包,场景地形等等),服务器逻辑也需要读取数据(策划设置的触发器,技能,NPC,怪等等)。这些数据虽然都是由编辑器提供的,但是基于诸多理由,编辑阶段的数据与实际运行阶段的数据完全可能并不相同。如何在运行时动态的将数据转化为客户端及服务器认可的数据是一个问题。特别的,如果采用光照图之类的技术进行渲染,编辑器在导出数据的时候,还要进行费时的光照图计算;类似的还有通过图的计算,也是颇为耗费时间的工作。如何让编辑器中热启动的服务器和客户端能够在足够短的时间里获得准备好的数据,是一个需要仔细考虑的问题。
谈到游戏数据的管理(创造,编辑,保存,读取),完全可以另列一文单独讨论,此前曾经拜读了MiloYip老大的《从头开始思考游戏数据管理系统》一文,其中提及了颇多关于游戏数据管理的思考,获益良多。在此对有兴趣的同学做以推荐。
2. 客户端逻辑并不简单只有输入逻辑,事实上还有玩家逻辑,动画播放,资源动态加载与释放,界面逻辑等等,都是复杂的模块。这些逻辑如何进行复用,接口需要如何设计,尚缺乏详尽的思考。
3. 由于本人对服务端逻辑的结构尚缺乏足够的了解,服务器逻辑的复用或存在许多未考虑的问题。尽管之前提过可以通过Proxy模式为服务器逻辑提供一个虚拟的运行环境,但是相信实际的困难依旧有许多。
简要设计图
我将上述的想法做了简单的汇总,下图是一个简要的设计概览:
上图假定服务器端运行在Linux下,客户端以及编辑器运行于Windows平台。
整体架构依赖于三个抽象接口,分别为服务端编辑器所依赖的数据库接口,客户端编辑器共同依赖的网络接口和用户输入接口。围绕这三个抽象接口,我们将许多耦合进行分解,使得服务器逻辑,客户端逻辑被独立成单独的模块,不仅可以用于服务端程序或者客户端程序当中,还可以复用于编辑器中,从而使得编辑器拥有了伪造一个服务端和客户端的能力,通过这种手段达到所编辑即所玩的目的。
(更进一步,通过将编辑器中的FakeNetwork层替换为RealNetwork网络层,可以让编辑器直接连接到实际运行的服务器上,我们便得以在编辑器中直接启动一个客户端程序)
基于上述架构进行开发时,开发人员在许多地方避免了重复代码的编写(比如服务器端逻辑决定了NPC的行为,则编辑器编写者无需在编辑器中编写任何伪造NPC行为的代码,我们可以直接获得真实的服务器端NPC的表现——通过源于实际的服务端逻辑编写者编写的服务端逻辑代码;类似的,客户端逻辑决定了界面,消息响应等,既可用于实际的客户端,也可用于编辑器中模拟客户端的行为)
后记
本文实际上是针对网络游戏编辑器设计的一个尚未成熟的思路。倘若按此思路进行实做,还有不少需要考虑、充实的细节,以及需要克服的困难。但从缩短游戏开发流程,提高代码复用率的角度,本人以为文中所述的方向应当是日后网络游戏开发的趋势。
小议Command模式 抽象在实际编程中的应用
May 8th
在许多编辑器中,大都实现了Undo / Redo操作。从界面出发,Undo/Redo仅仅只是一种抽象的”提供单一接口行为“的操作,尽管实际的Undo/Redo工作可能会涉及各种不同的具体操作。举个例子,在3dMax这样的软件中,我们可以创建3D实体,可以编辑其自身的各种属性(譬如立方体有长宽高,球有半径),可以移动实体的位置,对其做旋转,缩放操作,乃至对个别的网格顶点,面片的细微调节。其中的每一步,都可以撤销或重做。
我们仔细思考一下,就会明白,撤销和重做实际上是需要保存每一步操作的”被操作实体的两个状态“(分别是该实体在操作发生前和发生后的状态)。
譬如对一个物体做以移动,我们实际上是保存了移动前的位置,和移动后的位置,这样在Undo/Redo时,只需要分别将其置于所需的状态即可。
而不管具体的状态是如何转变的,对于用户来说,撤销/重做都只是一个简单的命令,表现为一个按钮,或者一次Ctrl+Z。这其实就是一种具有同一性表现的动作,在软件实现中,非常适合使用Command模式对其进行抽象。
譬如,我们只需要定义这样的一个基类即可明晰撤销/重做操作的概念接口:
class Command {
public:
virtual ~ Command() = 0 {}
virtual bool Do() = 0;
virtual bool Undo() = 0;
bool Redo() { Do(); }
};
所有的Undo/Redo的状态,都派生自这个Command抽象类,它定义了两个纯虚函数,用于做和撤销。Redo函数只是简单的调用Do()实现其功能。
有了上面这个接口,当我们想添加更多类型操作的Undo/Redo支持的时候,只需要这样做:
class MoveBoxCommand{
public:
MoveBoxCommand(VECTOR3 vNewPos, int boxid)
: m_iID(boxid), m_vNewPos(vNewPos) {}
~MoveBoxCommand() {}
bool Do()
{
m_vOldPos = SomeManager->GetObject(m_iID)->GetPos();
return SomeManager->GetObject(m_iID)->SetPos(m_vNewPos);
}
bool Undo()
{
return SomeManager->GetObject(m_iID)->SetPos(m_vOldPos);
}
private:
int m_iID;
VECTOR3 m_vNewPos;
VECTOR3 m_vOldPos;
};
现在我们就可以对任意的对象支持Move操作的撤销/重做了,事实上,旋转,缩放等其他操作大都与此类似,对添加删除之类的操作需要做一点特别的处理(因为我们不能简单的把实际对象释放掉,否则其他引用了该对象的Command就会出现问题,一种良好的做法是设置删除标志)。
除此之外,我们需要将所有的操作产生的Command实例放在一个管理器中统一管理,假设其被命名为CommandManager。则CommandManager需要负责维护当前的Command,需要在用户调用Undo时,判断是否能够Undo,如果能够,则取出下一个Undo的Command,调用其Undo,并将当前Command的指针或者索引更新位置。
如果用户在某个点做出了新的操作,则需要将当前Command之后的所有Command清空。(如果用户没有做新操作,则可以使用当前Command之后的Command来实现Redo操作的,但是新操作会冲掉位于Redo序列中的元素)
如果撤销/重做的实现有了以上理解,就不难理解为什么几乎所有的软件在用户Undo多步,再采取新动作之后,就无法Redo了。
通过这种方式,我们就可以实现一个具有可扩展性的撤销重做机制了,不论再加入何种类型的新操作,我们都可以设法将其纳入到上述的框架当中。
具体的撤销重做实现还牵扯到许多细节,不过那都与主题关系不大,我们再来看一个例子:线程池。
有时我们可能会想编写一个线程池,用于处理各种并发的任务。线程池从概念上说是一个很简单的东西,有一个线程的集合,里面的很多线程都睡着,当有任务来的时候,一个线程跳出来把任务领走并执行,执行结束后,线程继续回到池子里睡觉。如果有多个任务,那么就会有许多个线程都跳出来,直到池子里没有更多的空闲线程或者任务队列被领空为止。
在Win32平台下编写多线程程序,大致会像这样:
void ThreadProc(PVOID param)
{
// do something
}
// somewhere in some func, [...]