游戏客户端与编辑器代码重用设计杂谈
游戏客户端与编辑器代码重用设计杂谈
版本:0.1
最后修改:2010-12-10
撰写:李现民
引言
很多游戏都有配套的编辑器,或通用或专用,这样可以方便策划及时设计、修改游戏数据。当一个游戏方案确认实施时,如果需要设计配套编辑器,那么它往往先于游戏本身而设计。出于代码重用和方便维护的需要,大部分核心代码会在游戏客户端与编辑器中同时使用,因此有效提取这部分共用代码并尽量减少与项目其它部分的耦合就成为设计的重点。
关于良好程序架构设计的话题,比如设计模式、领域驱动设计等,相关论著恒河沙数。本文结合实践中遇到的问题,从工具与技术相结合的角度来阐述相关问题的解决方案。
以下假定程序运行环境为VC6+XP。从本文撰写时间看(2010-12-10),VC6无论如何都不是一个好的选择,但限于笔者所在公司环境如此,所以只好将就着来了╮(╯▽╰)╭。
使用宏控制代码生成策略
尽管我们追求代码的可重用性,但实际情况往往并不尽如人意,特别是在与特定于游戏客户端(或编辑器)的功能相结合比较紧密的代码部分。比如UI(界面),游戏客户端中有独立的界面模块,而编辑器界面可能使用MFC制作。即使同一个函数接口,游戏客户端与编辑器所需要的功能也可能是不一样的,这是因为它们拥有各自不同的应用倾向:游戏客户端倾向于使游戏画质更加平滑,而编辑器则需要考虑策划人员快速的编辑修改数据;再比如游戏客户端可能需要网络IO功能,而编辑器则一般不需要。
宏(具体的说,C++中的宏),此时可能是一种比较合适的工具。比如,通过在游戏客户端与编辑器中定义不同的宏变量,可以使游戏客户端专用的网络IO代码在编辑器中根本不生成。
某些情况下可能需要在同一个项目下建立多个configurations(配置),通过定义不同的宏变量以控制生成不同版本的程序,比如:简化版、完整版、内部版等。
宏在VC环境中有大量的应用案例,比如windows.h头文件中定义了大量的宏用于控制不同环境下的代码生成策略。
使用函数控制代码生成策略并信任编译器优化
宏控制的原理是将不需要的代码当作注释直接移除,因此编译器不会去审查该部分代码的正确性。这在某些情况下是必须的,比如编辑器没有网络IO相关的代码接口,因此相关代码必须被清除,否则编辑器项目将无法正确编译。
但宏控制有自己的问题:
宏变量通常定义在Project Settings(工程设置)中,因此不容易记忆或查找;
IDE工具通常无法像支持代码一样支持此类宏变量的快速查找,特别是存在多个项目相互引用的复杂工程中(比如Visual Assist X有Find Reference功能,可以快速搜索到所有引用指定变量或函数的代码,但此功能不支持在Project Settings中定义的宏变量);
编译器无法审查被移除部分代码的正确性,这可能导致一些代码修改同步的问题。
针对这些问题,笔者的解决方案是:宏控制变量只使用一次,用于定义一个简单函数,而该函数返回当前宏控制变量的存在情况,其它原本使用宏控制变量的地方都改为使用这个函数判断。这样间接的将宏变量控制转换为函数控制,从而获得IDE工具支持与编译器代码审查的双重好处。
比如如下代码:
namespace edition
{
#ifdef _EDITOR
inline bool IsEditor() { return true; }
#else
inline bool IsEditor() { return false; }
#endif
}
void Print()
{
if (edition::IsEditor())
{
puts("This is editor");
}
else
{
puts("This is not editor");
}
}
宏变量_EDITOR只使用一次,其余地方都使用edition::IsEditor()区分是编辑器代码还是游戏客户端代码。
请注意,我们并不会有任何的运行期性能损失,虽然看起来并非如此。由于在编译期edition::IsEditor()的值是确定的,因此当打开优化时编译器会移除不可达代码,从而得到与宏控制情况下相同的可执行文件。当然,在Debug版本下(优化关闭)所有的代码都被编译生成到最终可执行文件中,但我猜您应该不会将Debug版本给最终用户使用对吧?
使用delegate解耦
在MVC架构下,使用Observer(观察者)模式将核心逻辑代码与UI界面代码分离似乎天经地义的事,这样做的好处是核心逻辑代码可以独立于UI代码而存在,从而达到重用的目的。但不幸的是,从笔者经手的代码看,很多程序员并没有注意到这一点。主要问题可能包括以下两个方面:
第一是核心逻辑代码与UI界面代码相互调用关系错综复杂。由于核心逻辑代码不独立,因而很难进行提取复用。这种情况相对比较常见。
第二个问题解释起来可能更复杂一些。由于缺乏从核心逻辑代码到UI界面代码的回调机制,程序员可能会被迫使用一些极端的手法来达到侦测指定事件是否发生的目的。比如,我们知道游戏客户端都有一个主循环main_loop,方法名称通常叫Update()或Tick(),用于更新每一帧的游戏动画。这时,程序员可能会在该循环中埋伏一些代码以侦测核心逻辑状态的变化情况,从而达到触发事件的目的。这种手法实现了功能,保持了低耦合,却降低了代码执行效率。
这两个问题的解决之道在于观察者模式。这个模式在实现上还是比较复杂的,对每一个要处理事件都需要定义对应的观察者与被观察者接口。这种代码复杂性曾使很多人望而却步(包括本人-___-),为此java中内置了java.util.Observer与java.util.Observable接口,以降低使用该模式的代价。
笔者建议的方案是使用delegate(委托)。没错,就是那个C#中的delegate,它能够极低的设计复杂度实现与观察者模式相同的解耦效果。具体实例这里不再列举,因为网上可以找到很多。如果你使用的是C#,那么你是幸运的;如果你使用的是C++,那么网上同样可以找到设计好的仿真类库;如果你不幸使用了VC6,并且实在找到出路了,那么同学你也许可以去参考一下我的另一篇文章《VC6中简易delegate实现》,或许会有点帮助。
结语
本来还想加点静态变量与通用工厂的话题的,但我发现meyers singleton在VC6中的某种应用模式下会问题(singleton对象的构造函数会被调用两次,T__T),因此先欠着账,等待下次有成熟方案的时候再说吧。不过对此问题诸位看官如果有相关宝贵经验的话不妨提携一二,感激不尽中。