paladin career
博客:http://blog.sina.com.cn/auroratony


  写下这个题目时我是为自己捏了把汗的,因为我极不愿意给自己写的程序冠上“框架”的名号。我更愿意以“模块”或“库”之类的名义开发一个东西。这并不是文字游戏,框架两个字拆开看的话,“框”是limit,是限制性的约束,“架”是support,意味着支撑,在我看来框架在提供了一定的功能同时限制了使用者:你要用它,就要按照我的规矩来,可能包括但不限于流程、逻辑对象交互机制、扩展手段、强制性的特殊命名等。这也就是为什么我更愿意去开发有更好的自由度的module或library而不是framework作为可复用单元。
 
  只要留心总能在各处找到和游戏engine、gameplay相关的大量资料,游戏tool相关的却很少,这篇文章我想如果它只是为引玉而抛出去的砖,也保证它的质量和含金量,是你捡回去能砌墙用上的砖。这次要谈的想法成型已久,但最近项目比较清闲我才有空把它实现出来,实践过后就分享出来吧。框架是基于 DotNET构建的,我曾经在《游戏工具四层结构》中说过这种架构的最初想法,基本结构没有变,但是一些模块耦合方式略有修订。我曾经认为有了层次划分清晰良好的结构用什么语言去实现它都是等价可行的,现在更进一步,虽然用单纯的C/C++这类死板静态的语言也能实现但一定要比结合使用包括一些更动态的语言(如C#)来得繁琐和耗时。最近听一个同事给我讲解了一下Unreal引擎中Unreal Script定义对象,生成相关C++代码,利用反射衔接到编辑器中这一套机制,我想,这套用Native C++实现的方案正是用DotNET就能更容易的等价解决的,想做灵活的编辑器一些运行时的动态特性是无法避开的;使用DotNET的另一个好处是做界面方便,在VS中直接WYSIWYG,剩下的工作就是配套自定义一些时间轴、Native到DotNET的类型信息反射绑定系统。
 
  我遇到的大多数游戏程序员都只擅长于或专用Native C++,在团队里推行DotNET的一些东西要有能帮不懂它的同事快速上手的执行力和十足把握,最近看了《Scala很难》一文很有同感,类似文中表达:
 
  当你的团队有相当的人数,你试图教会这些Native C++程序员使用DotNET,而他们又非真心的想学时,这成了相当讨厌的事。如果你的团队的技术水平很一般,多语言集成开发也许对你们公司来说并不是一个好的选项,DotNET很难。并不是它本身很难,而是因为它在水平一般的团队中不会产生那种由技术很好的人组成的团队中产生的短期或长期的益处。多语言的难度导致很陡的学习曲线,会遭到原有的程序员的反对,形成不了统一的风格。你需要一个强有力的CTO或架构师来强迫这种风格,而不是让他们自己从书中学习。
(对于上面一段话请勿反感,我把别人的原文拿过来把Scala、Java之类的改成了Native C++、DotNET等,尽量尊重了原文,我从来没有认为会的语言少=水平一般,我猜原作者也没有这个意思。言者无意,闻之自辨。)
 
  2009年春天我在盘龙OL项目中,我曾主张游戏工具在开发语言上使用Native C++ amp; CLI C++/C#的两层结构,类似下图:
 


  当时同事说CLI C++和Native C++都是C++,C#和C++是不同语言差异太大不好学,并且层次多了太麻烦,最终使用了一种Native C++ amp; CLI C++搅在一起的单层结构,如下:



  那之后我参与了两年多的PL项目工具开发和维护,自己也一直为完善编辑器开发的良好方式做尝试。我总结当初PL工具开发方式的优缺点如下。
 
优点:
(1) CLI C++能使用DotNET开发的特性,如WYSIWYG的界面布局,丰富的DotNET类库辅助;
(2) CLI C++能无缝集成Native C++写的引擎底层。
 
缺点:
(1) CLI C++细节繁多,不仅避免不了DotNET的关键概念,还要接受其另类的语法风格,总体上Native C++程序员学习CLI C++要比C#麻烦;
(2) 在VS中拖控件自动生成的代码是放在Form的.h文件里的,要手工把函数的定义从声明处分离开放到.cpp中;
(3) 当一个Form的.h中有大量控件信息时IDE经常出现假死、无法正常显示和操作、生成重复事件处理函数错误、生成数G到数十G的临时文件到硬盘等诡异现象;
(4) 层次不清,代码组织容易混乱,容易滋生异味代码。
 
  另外游戏工具开发应极力避免设计开发过度分散,没有一个总的规划和把控者。各自为政会成为软件工程混乱的开端。相对掌握新的编程语言而言,为特定的需求选定恰当的语言并向不熟悉它的同事讲解更重要和有难度。现在我想在有充足的理由说明某个技术方案的确适合项目和团队成员时,我会不遗余力的主张的。

  此篇框架的设计开发有如下几个目的原则:
 
(1) 只作为游戏工具的通用流程性解决方案,与具体引擎无关;
(2) 操作的触发和执行分离,应用开发者只关心界面或引擎;
(3) 模块和层次合理,界面和引擎层之间的部分属于黑盒,对应用开发者半透明。
 
开发语言的搭配如下图:
 

 
  C#的确有直接调用Native DLL中C接口的能力,但我认为CLI C++写的这一层仍是必要的,下文会详述理由。C#和CLI C++的接口均可方便的暴露给IronPython使用,脚本的加入一是为了充当配置文件二是给工具提供更灵活的控制和调试手段。
 
  框架的工程模块划分如下图:
 


  其中图片左侧的三层大框是框架的主体,也是上文所言的黑盒;图片右侧是应用程序员需要关心和扩展的部分,我把对引擎直接交互的部分叫service,是操作的执行模块,把做界面布局的操作触发部分叫做plugin。针对每个具体项目,都有唯一一个service实现,多个plugin模块。Plugin和 service与黑盒框架主体的沟通通过抽象的消息机制实现。以此达到写plugin时只关心界面布局,在触发一定的操作时只把必要的信息组装成消息传给底层,写service时只关心收到消息后解析它并做具体的执行;当然,还有反向的自底向上的传递途径。各个模块具体设计如下。
 
  (1) Service接口。用Native C++编写,定义service和支持撤销重做的command的接口。伪码如下:
 
class IService:
quot;quot;quot;service interfacequot;quot;quot;
registerMsgProc(self, group, type, func):
pass
unregisterMsgProc(self, group, type):
pass
setMsgBubbler(self, bubbler):
pass
sink(self, group, type, params):
quot;quot;quot;send message from upper to lower (plugin to service)quot;quot;quot;
bubble(self, group, type, params):
quot;quot;quot;send message from lower to upper (service to plugin)quot;quot;quot;
class ICommand:
quot;quot;quot;encapsulated operationsquot;quot;quot;
params = []
exec(self):
quot;quot;quot;execute this command to do some operationsquot;quot;quot;
undo(self):
quot;quot;quot;undo an executed commandquot;quot;quot;
redo(self):
quot;quot;quot;redo a undoed commandquot;quot;quot;

  上层每个plugin算一个group,每个group中有若干type的消息。虽是接口但要实现消息处理函数注册反注册这类通用功能。特别的,消息参数的值要支持多种数据类型,提供伪运行时泛型的功能,最简单的实现可以是struct obj_t { enum type; union data; } 这种结构体,某一消息的所有参数用一个std::vectorlt;obj_tgt;承装。ICommand的参数值也使用struct obj_t表示,每一参数项是一个键值对,某一命令的所有参数用一个dictionary型容器承装。在这一层还需要定义service的open和close函数签名。
 
  (2) Service实现。Native C++编写。要有继承IService的service implement,实现与(1)中定义的签名相同的open/close函数,命名固定,并且这个DLL只导出这两个函数供动态加载用;实现若干消息处理函数及在open/close中调用基类对这些函数的注册反注册操作。
 
  (3) DotNET Wrapper。Native C++/CLI C++混搭编写。写法上最繁琐的一层,也是最不可能变化的一层,代码量不会太大。把IService包装一遍做为Native和Managed的桥接,并提供动态加载某一service implement DLL的功能。大体的CLI C++写法如下:
 
public ref class Service sealed {

public:
delegate Void BubbledMsgEventHandler(UInt32 group, UInt32 type, cli::arraylt;System::Object^gt;^ param);

protected:
BubbledMsgEventHandler^ OnBubbledMsgEvent;

public:
event BubbledMsgEventHandler^ BubbledMsgEvent { 略 }

public:
Void Open(String^ servicePath);
Void Close();

// managed to native
Boolean Sink(UInt32 group, UInt32 type, cli::arraylt;System::Object^gt;^ param);
// native to managed, raises the BubbleMsgEvent
Boolean Bubble(UInt32 group, UInt32 type, const std::vectorlt;obj_tgt; amp;param);

static void _Bubble(UInt32 group, UInt32 type, const std::vectorlt;obj_tgt; amp;param);

static Service^ GetInstance();

};

  另外底层ICommand实例的管理也放到此处,上层plugin发消息到service,由service封装成ICommand并执行,然后把 ICommand发到wrapper这一层来管理;plugin发下来的undo/redo消息由wrapper截获并处理后就不再发向下层的service了。一些System::Object^与obj_t、cli::array^与std::vector、System::String^与 char*之间的互相转换也必在wrapper中实现,如果没有这一层一些复杂数据结构的转换就会异常艰难,这就是必须存在CLI C++ wrapper的最大原因。
 
  (4) Editor Shell。解决方案中唯一的exe,C#写的工具外壳,相当于UI的承载体和plugin与wrapper之间的中转站。注意要调试底层Native C++代码需要把工程属性调试中的调试非托管代码勾上,不然Native C++中是打不中断点的。exe初始化时要执行动态加载service DLL和利用反射机制动态加载plugin的操作。对于plugin与wrapper消息的中转,需要一个SinkProxy,直接调用 wrapper中的service.Sink(...);还需要一个Bubble事件回调,用于底层bubble上来的消息invoke plugin中的事件处理函数。Shell中最好专门有一个提供IronPython脚本使用的接口util集。主窗口上有一个TabControl分栏控件,每一个加载进来的插件都会对应0个或1个TabPage,当前选中的TabPage对应的plugin即为处于活动状态的plugin,在shell窗口中的一些操作消息会先传递给当前处于活动状态的plugin消息加工函数做处理,例如多个基本的鼠标操作消息会转换成一个实体操作消息,加工后的消息再传递到底层;底层bubble上来的消息也只会传递给当前活动plugin。做好用的工具一些特性是必不可少的,比如浮动面板和调试控制台。前者有很多现成的库可用,推荐DockPanel Suit http://sourceforge.net/projects/dockpanelsuite/;调试信息的输出很容易想到实现方案,调试命令的输入就可以充分利用shell对IronPython脚本的支持,几行代码就能完成调试命令的解析执行,具体能完成什么调试功能就看你都暴露了哪些脚本util接口了。
 
  (5) Plugin。最主要的plugin使用C#编写和工程没有引用关系,需要shell反射出一些信息并加载它们,所以一些东西在命名上要固定,比如我定义一个Plugin类如下:
 
public class Plugin
{
// appointed naming. main shell form holder for function invoking
public static object Host = null;

public delegate void MsgProcDelegate(uint group, uint type, object[] param);

// appointed naming. points to a function in the shell
public static MethodInfo Sink = null;

// appointed naming. registered to the shell
public static event MsgProcDelegate Bubble;

// appointed naming. will be called by the shell
public static void OnBubble(uint group, uint type, object[] param)
{
if (Bubble != null) Bubble(group, type, param);
}

public static void OnSink(uint group, uint type, object[] param)
{
object[] objs = { group, type, param };
Sink.Invoke(Host, objs);
}

// appointed naming
public static string Name
{
get
{
return quot;plugin namequot;;
}
}

// appointed naming
public static Listlt;Controlgt; GetControls()
{
Listlt;Controlgt; result = new Listlt;Controlgt;();
Control ctrl = null;
// ...
result.Add(ctrl);
// ...

return result;
}
}

  如果plugin会在shell的TabControl控件上占用一个page则在一个特定命名的UserControl中放置一个ToolStrip控件,子控件的布局和Sink出的消息都安排在它的代码中。上面的Plugin类中Name属性为固定命名,在TabPage上显示的文本就使用此文本。同样为固定命名的GetControls函数所返回的每一个控件都将被放置到shell中的一个浮动面板里,浮动面板的布局可以定义一种plugin与shell间的协议,在plugin中以字符串之类的通用数据结构表示。另外一类辅助plugin使用IronPython脚本编写,一般这类plugin的规模没有C#的大,在TabControl上专门开辟一个TabPage供所有IronPython plugin在上面显示一个简单按钮,这类plugin的加载使用简单的文件遍历和脚本执行即可,同样IronPython和shell间要有必要的元数据沟通协议。
 
  主要的内容都在这里了,就是你看到的这个样子,有一些条条框框的规矩在框架中,在实际编码实现过程中可能遇到各种问题,幸好网上都能搜到解决方案,多亏了stackexchange这类网站,不然遇到新的问题要纠结很久。
计划中这个系列有三部分:
 
(一)分层结构与多语言集成开发。就是本篇。
 
(二)Native C++反射。可支持DotNET属性控件,做游戏工具逻辑对象的一些元数据获取是必要的,Native C++的反射是个可以讨论很多的话题,还有Native C++到DotNET属性控件的无缝适配。
 
(三)扩展自定义控件。关键帧、轨道、可拖拽/连线等控件,在做材质、动画、特效、逻辑编辑时都是必要的东西。
锐亚教育

锐亚教育,游戏开发论坛|游戏制作人|游戏策划|游戏开发|独立游戏|游戏产业|游戏研发|游戏运营| unity|unity3d|unity3d官网|unity3d 教程|金融帝国3|8k8k8k|mcafee8.5i|游戏蛮牛|蛮牛 unity|蛮牛