65.9K
CodeProject 正在变化。 阅读更多。
Home

Quake II .NET

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.72/5 (124投票s)

2003年7月14日

12分钟阅读

viewsIcon

820075

downloadIcon

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.txtgnu.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文件。操作步骤如下:

  1. 安装**Quake II .NET.msi**(参见上面的下载)。这将安装原生和托管版本的相关文件。

  2. ftp://ftp.idsoftware.com/idstuff/quake2/q2-314-demo-x86.exe下载并打开Quake II演示版。这会将文件解压到您的系统(默认文件夹为c:\windows\desktop\Quake2 Demo)。

  3. Quake2 Demo\Install\Data\baseq2文件夹中的pak0.pak文件复制到**%ProgramFiles%\Quake II .NET\managed\baseq2****%ProgramFiles%\Quake II.NET\native\baseq2**文件夹中。此文件很大,约48MB,您将创建两个副本。卸载Quake II .NET时,需要手动删除这两个副本。

  4. 通过单击开始菜单中的快捷方式来运行托管版或原生版。

如何构建代码

构建代码很简单,但您需要将生成的EXE和DLL复制到运行时才能运行应用程序。下面概述了步骤:

  1. 解压Quake II .NET源代码ZIP文件。其中包含已移植到Microsoft® Visual C++® .NET 2003的游戏引擎代码。
     
  2. 打开quake2.sln文件。
     
  3. 选择目标配置(发布或调试,原生或托管)并构建解决方案。文件将在指定的构建配置(例如Release Managed)中生成。
     
  4. 将引擎文件从源位置复制到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 ManagedDebug NativeRelease ManagedRelease 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

以下文章详细解释了此问题:

前向声明问题

Quake2可执行文件包含对定义在其他DLL中的结构的*.前向声明。Visual C++编译器无法为这些结构发出必要的元数据,并且在运行时会抛出System.TypeLoadException,表明在程序集中找不到该结构。

这会发生在image_smodel_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测试:

  1. 按**波浪号(~)**键显示命令窗口。
  2. 如果当前正在玩游戏,请输入disconnect。如果Quake处于演示模式,则无需此步骤。
  3. 输入timedemo 1并按回车键。
  4. 再次按**波浪号(~)**键关闭命令窗口。Quake将运行演示,测量帧率。
  5. 按**波浪号(~)**键停止测试。命令窗口中会显示帧率。
  6. 输入timedemo 0以关闭测试。

摘要

将C代码移植到原生C++大约需要4天,移植到托管C++又需要一天。扩展的实现大约需要两天,研究Quake代码以找出集成点也需要两天。总的来说,这次经历非常好,我们感觉在移植和扩展代码方面效率很高。很高兴能够在同一个应用程序中混合使用原生和托管代码,控制内存管理,并同时使用现有库和.NET Framework类。

Ralph Arvesen, Vertigo Software, Inc.

© . All rights reserved.