Quake II .NET






4.72/5 (124投票s)
2003年7月14日
12分钟阅读

820075

6779
著名的C语言游戏引擎移植到Visual C++,并带有.NET托管抬头显示器。
引言
1997年,电脑游戏公司id Software发布了一款里程碑式的第一人称射击游戏QUAKE II,该游戏销量超过百万,并因获得年度最佳游戏而享有盛誉。之后,在2001年12月,id Software慷慨地根据GNU通用公共许可证(“GPL”)向公众开放了QUAKE II 3D引擎。
现在,在2003年7月,Vertigo Software, Inc.发布了Quake II .NET,这是C语言引擎移植到Visual C++并带有.NET托管抬头显示器。我们这样做是为了说明一个观点:可以将大量C代码轻松移植到C++,然后使用Microsoft通用语言运行时(CLR)将整个应用程序作为托管.NET应用程序运行,而不会有明显的性能延迟。一旦作为.NET托管应用程序运行,添加新功能就变得简单有趣了。
本文档讨论了将Quake II引擎移植和扩展到Quake II .NET所涉及的内容。
源代码和文件
Quake II引擎的完整源代码可在id Software的ftp://ftp.idsoftware.com/idstuff/source/quake2.zip上找到。源代码是在GNU通用公共许可证(“GPL”)条款下发布的。您应该阅读随附的readme.txt和gnu.txt文件,以获取有关GPL的更多信息。
Quake游戏包含两个部分:引擎和数据。
游戏引擎
引擎是运行游戏的代码,由以下文件组成:
文件 |
描述 |
quake2.exe |
主游戏可执行文件。 |
ref_soft.dll |
软件渲染引擎。 |
ref_gl.dll |
OpenGL渲染引擎。 |
gamex86.dll | 核心游戏引擎。 |
Quake II .NET的托管版本包含一个额外的文件,实现了雷达扩展:
文件 |
描述 |
Radar.dll |
托管C++雷达扩展。 |
游戏数据
地图、怪物、武器和其他游戏必需品包含在baseq2文件夹中的数据文件中(通常打包在一个PAK文件中)。多人游戏数据存储在baseq2\players文件夹中。
如何运行Quake II .NET
Vertigo提供了上述五个文件。但是,您只需要一个额外的文件pak0.pak即可运行Quake II .NET。PAK文件包含id Software的版权所有3D模型和图像,他们希望您从他们那里获取该文件。
所有Q2数据文件仍然受原始条款的版权保护和许可,因此您不能重新分发原始游戏的数据…… -John Carmack, id Software, 来自GPL源代码中的readme.txt
因此,您需要从官方Quake II演示版中获取PAK文件。操作步骤如下:
- 安装**Quake II .NET.msi**(参见上面的下载)。这将安装原生和托管版本的相关文件。
- 从ftp://ftp.idsoftware.com/idstuff/quake2/q2-314-demo-x86.exe下载并打开Quake II演示版。这会将文件解压到您的系统(默认文件夹为c:\windows\desktop\Quake2 Demo)。
- 将Quake2 Demo\Install\Data\baseq2文件夹中的pak0.pak文件复制到**%ProgramFiles%\Quake II .NET\managed\baseq2**和**%ProgramFiles%\Quake II.NET\native\baseq2**文件夹中。此文件很大,约48MB,您将创建两个副本。卸载Quake II .NET时,需要手动删除这两个副本。
- 通过单击开始菜单中的快捷方式来运行托管版或原生版。
如何构建代码
构建代码很简单,但您需要将生成的EXE和DLL复制到运行时才能运行应用程序。下面概述了步骤:
- 解压Quake II .NET源代码ZIP文件。其中包含已移植到Microsoft® Visual C++® .NET 2003的游戏引擎代码。
- 打开quake2.sln文件。
- 选择目标配置(发布或调试,原生或托管)并构建解决方案。文件将在指定的构建配置(例如Release Managed)中生成。
- 将引擎文件从源位置复制到Quake II .NET运行时安装(默认文件夹为%ProgramFiles%\Quake II .NET)。下表显示了要复制的文件:
文件
复制到
quake2.exe
\Program Files\Quake II .NET\
ref_soft.dll
\Program Files\Quake II .NET\
ref_gl.dll
\Program Files\Quake II .NET\
gamex86.dll
\Program Files\Quake II .NET\baseq2
Radar.dll
\Program Files\Quake II .NET\(仅托管版本需要)
可以使用自定义的后期构建步骤来自动完成文件复制。
我们是如何移植代码的
源代码已从id Software的FTP站点下载,如上所述。该代码包含一个名为quake2.dsw的Visual Studio 6工作区文件。打开此文件时,Visual Studio会提示您更新项目文件并生成一个名为quake2.sln的解决方案文件。对项目进行了以下更改:
-
通过移除汇编文件和禁用内联汇编例程来移除平台特定的代码。
-
修改了项目配置,以包含Debug Managed、Debug Native、Release Managed和Release Native构建。
-
所有文件都指定了Compile as C code (/TC)开关。而不是将所有源文件名重命名为CPP扩展名,而是指定了Compile as C++ code (/TP)开关。
设置好构建环境后,就可以开始将代码移植到C++了。
移植到原生C++
在从C到C++的移植过程中遇到了以下问题:
关键词
C++语言保留了C语言中不保留的关键字。例如,Quake代码使用了一个名为new
的变量,在C++版本中将其重命名为new_cpp
。
// C qboolean new; // C++ qboolean new_cpp;
Quake代码使用true
和false
定义了自己的布尔类型。这些是C++中的保留关键字,因此如以下所示,已将其typedef为bool
。
// C typedef enum {false, true} qboolean; // C++ typedef bool qboolean;
强类型
C++语言是强类型的,因此如果类型不匹配,赋值和函数参数需要进行类型转换。这是移植中最主要的部分。虽然繁琐,但移植起来很容易,因为编译器会精确地识别问题,包括源文件、行号和所需的类型转换。下面是一个例子。
// C pmenuhnd_t* hnd = malloc(sizeof(*hnd)); // C++ pmenuhnd_t* hnd = (pmenuhnd_t*)malloc(sizeof(*hnd));
Quake代码使用GetProcAddress
来动态检索其他DLL中函数的地址。所有调用都需要进行类型转换,并且很快注意到这类调用很多。为此创建了一个脚本,用于读取构建日志并使用适当的类型转换修改源代码。下面是一个所需类型转换的例子。
// C qwglSwapBuffers = GetProcAddress ( glw_state.hinstOpenGL, "wglSwapBuffers" ); // C++ qwglSwapBuffers = (BOOL (__stdcall *)(HDC)) GetProcAddress ( glw_state.hinstOpenGL, "wglSwapBuffers" );
C语言不要求声明与定义或其他源文件中的声明完全匹配,这导致了编译器错误:C2371(重定义;基本类型不同)和C2556(重载函数仅返回类型不同)。修改了函数声明和定义以解决任何冲突。例如,下面的函数声明已更改为返回rserr_t
而不是int
。
// C int GLimp_SetMode( int *pwidth, int *pheight, int mode, qboolean fullscreen ); // C++ rserr_t GLimp_SetMode( int *pwidth, int *pheight, int mode, qboolean fullscreen );
如果使用extern
声明在其他文件中定义的函数,此错误直到链接时才会被捕获,并显示为未解析的外部符号。
使用COM对象
COM接口的调用约定在C和C++之间是不同的,因为C++语言支持vtable(虚函数表)。对于C语言,COM接口的vtable被显式访问,并且“this指针”作为第一个参数传递。下面是一个调用COM对象的Unlock
方法的示例。
// C sww_state.lpddsOffScreenBuffer->lpVtbl->Unlock( sww_state.lpddsOffScreenBuffer, vid.buffer ); // C++ sww_state.lpddsOffScreenBuffer->Unlock( vid.buffer );
移植到托管C++
托管代码在.NET运行时环境的上下文中运行。使用托管代码不是强制性的,但这样做有很多好处。例如,使用Managed Extensions for C++编写的托管代码程序可以与通用语言运行时协同工作,提供内存管理、跨语言集成、代码访问安全以及对象自动生命周期控制等服务。
将原生C++移植到托管C++所需的第一步是设置** /CLR**编译开关,启用Managed Extensions for C++。根据您的项目,这可能是唯一需要的步骤,但在将Quake移植到托管C++时也遇到了以下错误。
不兼容的开关
** /clr**和**/YX**(预编译头文件自动使用)开关不兼容,因此在托管构建中关闭了**/YX**开关。
混合DLL加载问题
代码编译通过,但所有生成DLL的项目都出现了以下链接警告。
LINK : warning LNK4243: DLL containing objects compiled with /clr is not linked with /NOENTRY; image may not run correctly
当托管C++ DLL包含入口点时,会出现此警告,链接器会通知您加载过程中可能会发生死锁。通过执行以下操作来解决此问题:
- 添加**/NOENTRY**链接开关。
- 链接**msvcrt.lib**库。
- 包含符号引用
DllMainCRTStartup@12
。 - 在DLL中调用
__crt_dll_initialize
和__crt_dll_terminate
。
以下文章详细解释了此问题:
- PRB:构建Managed Extensions for C++ DLL项目时出现链接器警告 - http://support.microsoft.com/?id=814472。
- 混合DLL加载问题 - http://msdn.microsoft.com/library/default.asp?url=/library/en-us/dv_vstechart/html/vcconMixedDLLLoadingProblem.asp。
前向声明问题
Quake2可执行文件包含对定义在其他DLL中的结构的*.前向声明。Visual C++编译器无法为这些结构发出必要的元数据,并且在运行时会抛出System.TypeLoadException
,表明在程序集中找不到该结构。
这会发生在image_s
和model_s
结构上,可以通过在主可执行程序集中定义这些结构来修复。有关更多信息,请参阅http://www.winterdom.com/mcppfaq/archives/000262.html。
// in cl_parse.c // empty definitions for structs that are forward declared // this causes the compiler to emit the proper metadata // and not throw a System.TypeLoadException exception struct image_s {}; struct model_s {};
扩展Quake
现在Quake II已经在.NET运行时环境中运行,我们想要添加一个重要的、完全用.NET编写的新功能。在研究了我们现在玩的游戏(例如Halo)之后,我们决定开发一个抬头雷达显示器,以鸟瞰图显示敌人、能力提升和其他有趣物体。
雷达扩展是用托管C++创建的。由于这是一个托管类,使用了.NET Framework的GDI功能,包括箭头样式、渐变画笔、抗锯齿以及窗口透明度和不透明度。雷达项目通过Matrix.RotateAt围绕窗口中心旋转,而不是通过三角函数计算每个项目的位置。上下文菜单允许显示或隐藏视觉项目、准星和视野等。
最后一个菜单项(Overlay on Quake)会将雷达叠加在Quake窗口之上;雷达会隐藏窗口框架和状态栏,设置窗口透明度和不透明度,并调整大小以覆盖Quake窗口。
雷达项目存储在STLvector
列表中。下面的代码片段显示了如何使用iterator
循环遍历列表并在雷达上绘制每个项目。
// draw each item in the list ItemVector::iterator i; for (i = m_items->begin(); i != m_items->end(); i++) { // calculate location on radar rc.X = (int)center.X + ((*i).x/Const::Scale) - (Const::MonsterSize/2); rc.Y = (int)center.Y – ((*i).y/Const::Scale) - (Const::MonsterSize/2); switch ((*i).type) { case RadarTypeHealth: g->FillRectangle(Brushes::Green, rc); break; case RadarTypeMonster: g->FillEllipse(Brushes::Firebrick, rc); break; . . . } }
在使用STLvector
类进行扩展时遇到了一些构建问题:
编译器错误 C3633
第一个问题是当std::vector
类成员添加到托管类时出现的编译器错误。
private __gc class RadarForm : public System::Windows::Forms::Form
{
. . .
private:
std::vector<RadarItem> m_items;
. . .
};
\quake2-3.21\Radar\RadarForm.h(92): error C3633: cannot define 'm_items' as a
member of managed 'Radar::RadarForm'
此错误表明您不能将m_items
定义为托管类RadarForm
的成员,因为std::vector
包含一个复制构造函数。这通过使用指向vector列表的指针来解决。
private __gc class RadarForm : public System::Windows::Forms::Form
{
. . .
private:
std::vector<RadarItem>* m_items;
. . .
};
编译器错误 C3377 和 C3635
下一个问题涉及将非托管类型传递给扩展。Quake代码将std::vector
指针传递给扩展以更新雷达,但这导致了以下编译器错误。
// update method in the radar extension class static void Update(int x, int y, float angle, std::vector<RadarItem>* items) { . . . }
\quake2-3.21\client\cl_ents.c(1612): error C3377: 'Radar::Console::Update' :
cannot import method - a parameter type or the return type is inaccessible
\quake2-3.21\client\cl_main.c(305): error C3635: 'std::vector<RadarItem,std::
allocator<RadarItem> >': undefined native type used in 'Radar::Console';
imported native types must be defined in the importing source code
通过定义一个派生自std::vector
的空类来解决此问题,如下所示。
__nogc class ItemVector : public std::vector<RadarItem>
{
};
// pass an ItemVector instead of std::vector
static void Update(int x, int y, float angle,
ItemVector* items)
{
}
与Quake集成
与Quake代码有三个集成点:显示雷达、更新雷达以及在窗口位置更改时通知雷达。
显示雷达
在Quake词汇表中添加了一个名为radar
的新命令。当在Quake命令窗口中输入radar
命令时,以下代码会切换雷达的可见状态。
// check for our new radar command if (Q_stricmp(cmd, "radar") == 0) { // toggle the visible state of the radar cl_radarvisible = !cl_radarvisible; Radar::Console::Display(cl_radarvisible, cl_hwnd); return; }
更新雷达
雷达在某个时间间隔(500毫秒)过期后更新。下面的代码构造一个包含雷达项目的STLvector
列表,并将该列表传递给扩展。
void UpdateRadar(frame_t *frame)
{
// see if enough time has elapsed to update the radar
static int oldTime;
int newTime = timeGetTime();
if (newTime - oldTime < UPDATE_RADAR_MS)
return;
// update time so can detect next interval
oldTime = newTime;
// store radar items in an STL vector list
ItemVector* items = new ItemVector();
RadarItem item;
// get the players info
int playernum = cl.playernum+1;
entity_state_t* player = &cl_entities[playernum].current;
// loop through list and add items to the radar list
entity_state_t* s;
int pnum, num;
for (pnum = 0 ; pnum<frame->num_entities ; pnum++)
{
// get item entity_state
num = (frame->parse_entities + pnum)&(MAX_PARSE_ENTITIES-1);
s = &cl_parse_entities[num];
// make sure this is not the player
if (s->number != player->number)
{
// add item to the radar list
item.x = s->origin[0] - player->origin[0];
item.y = s->origin[1] - player->origin[1];
item.type = GetRadarType(s);
items->push_back(item);
}
}
// pass to the radar extension so it can update the display
Radar::Console::Update(
player->origin[0], player->origin[1],
player->angles[1], items);
// clean up list
delete items;
}
窗口位置更改
当雷达在叠加模式下显示时,需要知道Quake窗口的位置或大小是否已更改。Quake代码处理WM_WINDOWPOSCHANGED消息,并将事件传递给雷达扩展。
case WM_WINDOWPOSCHANGED:
// pass along to the radar
Radar::Console::WindowPosChanged(hWnd);
return DefWindowProc (hWnd, uMsg, wParam, lParam);
_MANAGED宏
Visual Studio C++编译器包含Microsoft特有的预定义宏_MANAGED
。当指定**/clr**开关时,此宏设置为1,并用于包装特定于托管的代码。
// setting the title of the window
#if _MANAGED
"Quake II (managed)",
#else
"Quake II (native)",
#endif
性能
将现有项目引入托管代码很有用,因为它提供了很大的设计自由度,例如:
- 使用垃圾回收或自行管理内存。
- 直接使用.NET Framework方法或Window API调用。
- 使用.NET Framework类或现有库(例如STL)。
然而,只有当托管应用程序达到所需的性能时,有用性才有意义。运行Quake II.NET的timedemo测试表明,托管版本的速度约为原生版本的85%。托管版本的性能是可以接受的,测试人员没有注意到两个版本之间的差异。您可以通过以下方式运行timedemo测试:
- 按**波浪号(~)**键显示命令窗口。
- 如果当前正在玩游戏,请输入disconnect。如果Quake处于演示模式,则无需此步骤。
- 输入timedemo 1并按回车键。
- 再次按**波浪号(~)**键关闭命令窗口。Quake将运行演示,测量帧率。
- 按**波浪号(~)**键停止测试。命令窗口中会显示帧率。
- 输入timedemo 0以关闭测试。
摘要
将C代码移植到原生C++大约需要4天,移植到托管C++又需要一天。扩展的实现大约需要两天,研究Quake代码以找出集成点也需要两天。总的来说,这次经历非常好,我们感觉在移植和扩展代码方面效率很高。很高兴能够在同一个应用程序中混合使用原生和托管代码,控制内存管理,并同时使用现有库和.NET Framework类。
Ralph Arvesen, Vertigo Software, Inc.