使用-C---C--和-Lua-的-CryENGINE-游戏编程-全-

使用 C++、C# 和 Lua 的 CryENGINE 游戏编程(全)

原文:zh.annas-archive.org/md5/9DE4C1E310A0B5A13812B9CEED44823A

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

开发和维护游戏的过程在过去几年中发生了非常快速的变化。越来越普遍的是游戏开发者许可第三方游戏引擎,如 CryENGINE,以便完全专注于游戏本身。

作为第一个以纯所见即所得WYSIWYP)理念出货的游戏引擎,CryENGINE 专注于通过允许开发人员直接进入他们的游戏,预览变化并且不等待级别和资产构建来提高生产力和迭代。

对于程序员来说,CryENGINE 是理想的工具集。可以使用慷慨的 API 在 C++中进行开发,使开发人员可以直接进入代码并编写性能优越的代码,而不受限于晦涩的脚本语言。有了想法?启动 Visual Studio,立即开始工作。

本书涵盖的内容

第一章,“介绍和设置”,涵盖了通过简要概述引擎,详细介绍其优势,提供的可能性,并逐步指南设置您的环境来加快速度的过程。

第二章,“使用 Flowgraph 进行可视化脚本编程”,向您介绍了可视化脚本工具,以便以一种易于访问的视觉方式创建游戏逻辑。

第三章,“创建和利用自定义实体”,涵盖了实体系统以及如何利用它来为您带来好处。用从简单的物理化对象到复杂的天气模拟管理器的实体填充您的游戏世界。

第四章,“游戏规则”,为您提供了对游戏规则系统的深入了解,为您提供了一个统一的模板,用于全面的游戏和会话逻辑。它还教授如何在各种语言中实现自定义游戏模式。

第五章,“创建自定义角色”,详细介绍了为玩家控制的实体和人工智能的基础创建自定义角色类的过程。

第六章,“人工智能”,涵盖了使用内置人工智能解决方案创建一个生动而有活力的世界的过程。

第七章,“用户界面”,详细介绍了使用 Flash 和 Autodesk Scaleform 来为您的界面增添色彩,从简单的屏幕位图到在游戏世界中呈现交互式 Flash 元素。

第八章,“多人游戏和网络”,涵盖了将引擎在线化的工作,并学习如何在网络上同步游戏世界的背后工作。

第九章,“物理编程”,涵盖了物理系统的内部工作原理,以及为从最大的车辆到最小的粒子效果的一切物理相互作用的创建过程。

第十章,“渲染编程”,帮助您了解渲染系统的工作原理,以及如何使用它来创建和扩展从渲染节点到多个视口的一切。

第十一章,“效果和声音”,详细介绍了 CryENGINE 使用的 FMod 声音引擎的工作原理,使您能够为您的项目实现令人信服的声音。

第十二章,“调试和性能分析”,涵盖了调试游戏的常见方法,以及使用控制台的基础知识。

您需要为本书做好的准备

  • CryENGINE 3 免费 SDK v3.5.4

  • CryMono v0.7 for CryENGINE 3.5.4

  • Visual Studio Express 2012

  • Notepad++

  • FMod

本书的受众

这本书是为具有基本 CryENGINE 和其编辑器使用知识的开发人员编写的,在某些情况下,会假设读者了解非常基本的功能,比如在编辑器中加载关卡和放置实体。如果您以前从未使用过 CryENGINE,我们建议您自己玩一下 CryENGINE Free SDK,或者购买 Sean Tracy 和 Paul Reindell 的《CryENGINE 3 游戏开发:初学者指南》。

约定

在这本书中,您将找到许多不同类型信息的文本样式。以下是这些样式的一些示例,以及它们的含义解释。

文本中的代码单词显示如下:"GFx元素确定应加载哪个 Flash 文件用于该元素。"

代码块设置如下:

<events>
  <event name="OnBigButton" fscommand="onBigButton" desc="Triggered when a big button is pressed">    
    <param name="id" desc="Id of the button" type="string" />
  </event>
</events>
}

新术语重要单词以粗体显示。您在屏幕上看到的单词,比如菜单或对话框中的单词,会以这种形式出现在文本中:"一旦启动,UI 图将被激活,假设它包含一个UI:Action:Start节点,如下所示:"

注意

警告或重要提示会以这样的方式出现在一个框中。

提示

提示和技巧会以这种形式出现。

第一章:介绍和设置

CryENGINE 因其展示各种令人印象深刻的视觉效果和游戏玩法而被认为是最具可扩展性的引擎之一。这使得它成为程序员手中的无价工具,唯一的限制就是创造力。

在本章中,我们将涵盖以下主题:

  • 安装Visual Studio Express 2012 for Windows Desktop

  • 下载 CryENGINE 示例安装或使用自定义引擎安装

  • www.crydev.net注册账户,这是官方的 CryENGINE 开发门户网站

  • 编译精简的 CryGame 库

  • 附加和使用调试器

安装 Visual Studio Express 2012

为了编译游戏代码,您需要一份 Visual Studio 的副本。在本演示中,我们将使用 Visual Studio Express 2012 for Windows Desktop。

注意

如果您已经安装了 Visual Studio 2012,则可以跳过此步骤。

安装 Visual Studio Express 2012

要安装 Visual Studio,请按照以下步骤操作:

  1. 访问www.microsoft.com/visualstudio/,然后下载 Visual Studio Express 2012 for Windows Desktop。

  2. 下载可执行文件后,安装该应用程序,并在重新启动计算机后继续下一步。

选择 CryENGINE 安装类型

现在我们已经安装了 Visual Studio,我们需要下载一个 CryENGINE 版本进行开发。

我们为本书创建了一个精简的示例安装,推荐给刚开始使用引擎的用户。要下载,请参阅下载本书的 CryENGINE 示例安装部分。

如果您更愿意使用 CryENGINE 的其他版本,比如最新的 Free SDK 版本,请参阅本章后面的使用自定义或更新的 CryENGINE 安装部分。本节将介绍如何自行集成 CryMono。

下载本书的 CryENGINE 示例安装

对于本书,我们将使用自定义的 CryENGINE 示例作为学习引擎工作原理的基础。本书中的大多数练习都依赖于这个示例;然而,您从中获得的工作知识可以应用于默认的 CryENGINE Free SDK(可在www.crydev.net上获得)。

要下载示例安装,请按照以下步骤操作:

  1. 访问github.com/inkdev/CryENGINE-Game-Programming-Sample,然后单击Download ZIP按钮,以下载包含示例的压缩存档。

  2. 下载完成后,将存档内容提取到您选择的文件夹中。为了示例,我们将其提取到C:\Crytek\CryENGINE-Programming-Sample

刚才发生了什么?

现在您应该有我们示例 CryENGINE 安装的副本。您现在可以运行和查看示例内容,这将是本书大部分内容的使用内容。

使用自定义或更新的 CryENGINE 安装

本节帮助选择使用自定义或更新版本的引擎的读者。如果您对此过程不确定,我们建议阅读本章中的下载本书的 CryENGINE 示例安装部分。

验证构建是否可用

在开始之前,您应该验证您的 CryENGINE 版本是否可用,以便您可以在本书的章节中运行和创建基于代码。

注意

请注意,如果您使用的是旧版或新版引擎,某些章节可能提供了更改系统的示例和信息。请记住这一点,并参考前面提到的示例,以获得最佳的学习体验。

一个检查的好方法是启动编辑器和启动器应用程序,并检查引擎是否按预期运行。

集成 CryMono(C#支持)

如果您有兴趣使用以 C#为主题编写的示例代码和章节内容,您需要将第三方 CryMono 插件集成到 CryENGINE 安装中。

注意

请注意,CryMono 默认集成在我们专门为本书创建的示例中。

要开始集成 CryMono,请打开引擎根文件夹中的Code文件夹。我们将把源文件放在这里,放在一个名为CryMono/的子文件夹中。

要下载源代码,请访问github.com/inkdev/CryMono并单击Download Zip(或者如果您更喜欢使用 Git 版本控制客户端,则单击Clone in Desktop)。

下载后,将内容复制到我们之前提到的Code/CryMono文件夹中。如果该文件夹不存在,请先创建它。

文件成功移动后,您的文件夹结构应该类似于这样:

集成 CryMono(C#支持)

编译 CryMono 项目

现在我们有了 CryMono 源代码,我们需要编译它。

首先,使用 Visual Studio 打开Code/CryMono/Solutions/CryMono.sln

注意

确保使用CryMono.sln而不是CryMono Full.sln。后者仅在需要重新构建整个 Mono 运行时时使用,该运行时已经与 CryMono 存储库预编译。

在编译之前,我们需要修改引擎的SSystemGlobalEnvironment结构(这是使用全局gEnv指针公开的)。

为此,请在Code/CryEngine/CryCommon/文件夹中打开ISystem.h。通过搜索结构SSystemGlobalEnvironment的定义来找到结构的定义。

然后将以下代码添加到结构的成员和函数的最后:

struct IMonoScriptSystem*
  pMonoScriptSystem;

注意

不建议修改接口,如果您没有完整的引擎源代码,因为其他引擎模块是使用默认接口编译的。但是,在这个结构的末尾添加是相对无害的。

完成后,打开您打开CryMono.sln的 Visual Studio 实例并开始编译。

注意

项目中的自动化后构建步骤应在成功编译后自动将编译文件移动到构建的Bin32文件夹中。

要验证 CryMono 是否成功编译,请在您的Bin32文件夹中搜索CryMono.dll

通过 CryGame.dll 库加载和初始化 CryMono

现在我们在我们的Bin32文件夹中有了 CryMono 二进制文件,我们只需要在游戏启动时加载它。这是通过 CryGame 项目,通过CGameStartup类来完成的。

首先,打开位于Code/Solutions/中的Code/Solutions/中的 CryEngine 或 CryGame 解决方案文件(.sln)。

包括 CryMono 接口文件夹

在修改游戏启动代码之前,我们需要告诉编译器在哪里找到 CryMono 接口。

首先,在 Visual Studio 的Solution Explorer中右键单击 CryGame 项目,然后选择Properties。这将显示以下CryGame Property Pages窗口:

包括 CryMono 接口文件夹

现在,点击C/C++并选择General。这将显示一屏幕一般的编译器设置,我们将使用它来添加一个额外的包含文件夹,如下面的屏幕截图所示:

包括 CryMono 接口文件夹

现在我们只需要将..\..\CryMono\MonoDll\Headers添加到Additional Include Directories菜单中。这将告诉编译器在使用#include宏时搜索 CryMono 的Headers文件夹,从而使我们能够找到 CryMono 的 C++接口。

在启动时初始化 CryMono

在 CryGame 项目中打开GameStartup.h,并将以下内容添加到类声明的底部:

static HMODULE
m_cryMonoDll;

然后打开GameStartup.cpp并在CGameStartup构造函数之前添加以下内容:

HMODULE CGameStartup::m_cryMonoDll = 0;

现在导航到CGameStartup析构函数并添加以下代码:

if(m_cryMonoDll)
{
  CryFreeLibrary(m_cryMonoDll);
  m_cryMonoDll = 0;
}

现在导航到CGameStartup::Init函数声明,并在REGISTER_COMMAND("g_loadMod", RequestLoadMod,VF_NULL,"");片段之前添加以下内容:

m_cryMonoDll = CryLoadLibrary("CryMono.dll");
if(!m_cryMonoDll)
{
  CryFatalError("Could not locate CryMono DLL! %i", GetLastError());
  return false;
}

auto InitMonoFunc = (IMonoScriptSystem::TEntryFunction)CryGetProcAddress(m_cryMonoDll, "InitCryMono");
if(!InitMonoFunc)
{
  CryFatalError("Specified CryMono DLL is not valid!");
  return false;
}

InitMonoFunc(gEnv->pSystem, m_pFramework);

现在我们只需编译 CryGame,就可以在启动时加载和初始化 CryMono。

注册流节点

由于流系统的最近更改,流节点必须在游戏启动的某个时刻注册。为了确保我们的 C#节点已注册,我们需要从IGame::RegisterGameFlowNodes中调用IMonoScriptSysetm::RegisterFlownodes

要做到这一点,打开Game.cpp并在CGame::RegisterGameFlowNodes函数内添加以下内容:

GetMonoScriptSystem()->RegisterFlownodes();

现在,在编译后,所有托管流节点应该出现在 Flowgraph 编辑器中。

注册您的 CryDev 帐户

CryENGINE 免费 SDK 需要 CryDev 帐户才能启动应用程序。这可以通过www.crydev.net轻松获取,方法如下:

  1. 在您选择的浏览器中访问www.crydev.net

  2. 单击右上角的注册

  3. 阅读并接受使用条款。

  4. 选择您的用户名数据。

刚刚发生了什么?

您现在拥有自己的 CryDev 用户帐户。在运行 CryENGINE 免费 SDK 应用程序(参见运行示例应用程序)时,您将被提示使用刚刚注册的详细信息登录。

运行示例应用程序

在开始构建游戏项目之前,我们将介绍默认 CryENGINE 应用程序的基础知识。

注意

所有可执行文件都包含在Bin32Bin64文件夹中,具体取决于构建架构。但是,我们的示例只包括一个Bin32文件夹,以保持简单和构建存储库的大小。

编辑器

这是开发人员将使用的主要应用程序。编辑器作为引擎的直接接口,用于各种开发人员特定的任务,如关卡设计和角色设置。

编辑器支持WYSIWYP所见即所得)功能,允许开发人员通过按下快捷键Ctrl + G或导航到游戏菜单,并选择切换到游戏来预览游戏。

启动编辑器

打开主示例文件夹,并导航到Bin32文件夹。一旦到达那里,启动Editor.exe

启动编辑器

编辑器加载完成后,您将看到 Sandbox 界面,可用于创建游戏的大多数视觉方面(不包括模型和纹理)。

要创建新关卡,打开文件菜单,并选择新建选项。这应该呈现给您新建关卡消息框。只需指定您的关卡名称,然后单击确定,编辑器将创建并加载您的空关卡。

要加载现有关卡,打开文件菜单,并选择打开选项。这将呈现给您打开关卡消息框。选择您的关卡并单击打开以加载您的关卡。

启动器

这是最终用户看到的应用程序。启动器启动时显示游戏的主菜单,以及允许用户加载关卡和配置游戏的不同选项。

启动器的游戏上下文通常称为纯游戏模式

启动启动器

打开主示例文件夹,并进入Bin32文件夹。一旦到达那里,启动Launcher.exe

启动启动器

当您启动应用程序时,您将看到默认的主菜单。此界面允许用户加载关卡并更改游戏设置,如视觉和控制。

当您想要像最终用户一样玩游戏时,启动器比编辑器更可取。另一个好处是快速启动时间。

专用服务器

专用服务器用于启动其他客户端连接的多人游戏服务器。专用服务器不会初始化渲染器,而是作为控制台应用程序运行。

专用服务器

编译 CryGame 项目(C++)

CryENGINE Free SDK 提供了对游戏逻辑库CryGame.dll的完整源代码访问。这个动态库负责游戏功能的主要部分,以及初始游戏启动过程。

注意

库是一组现有的类和函数,可以集成到其他项目中。在 Windows 中,库的最常见形式是动态链接库,或DLL,它使用.dll文件扩展名。

首先,打开主样本文件夹,并导航到Code/Solutions/,其中应该存在一个名为CE Game Programming Sample.sln的 Visual Studio 解决方案文件。双击该文件,Visual Studio 应该启动,并显示包含的项目(请参阅以下分解)。

注意

解决方案是 Visual Studio 中组织项目的结构。解决方案包含关于项目的信息,存储在基于文本的.sln文件中,以及一个.suo文件(用户特定选项)。

要构建项目,只需按下F7或右键单击解决方案资源管理器中的 CryGame 项目,然后选择构建

刚刚发生了什么?

您刚刚编译了CryGame.dll,现在应该在二进制文件夹中存在(32 位编译为Bin32,64 位为Bin64)。启动示例应用程序现在将加载包含您编译的源代码的.dll文件。

CE 游戏编程示例解决方案分解

解决方案包括以下三个项目,其中一个编译为.dll文件。

CryGame

CryGame 项目包括引擎使用的基础游戏逻辑。这将编译为CryGame.dll

CryAction

CryAction 项目包括对CryAction.dll的部分源代码,它负责大量的系统,如演员、UI 图形和游戏对象。这个项目不会编译为.dll文件,而是仅用于接口访问。

CryCommon

CryCommon 项目是一个助手,包含所有共享的 CryENGINE 接口。如果有子系统需要访问,请在这里查找其公开的接口。

CryENGINE 文件夹结构

请参阅以下表格,了解 CryENGINE 文件夹结构的解释:

文件夹名称 描述
Bin32 包含引擎使用的所有 32 位可执行文件和库。
Bin64 包含引擎使用的所有 64 位可执行文件和库。
Editor 编辑器配置文件夹,包含常见的编辑器助手、样式等。
Engine 用作引擎本身使用的资产的中央文件夹,而不是任何特定的游戏。着色器和配置文件存储在这里。
Game 每个游戏都包含一个游戏文件夹,其中包括所有的资产、脚本、关卡等。不一定要命名为Game,但取决于sys_game_folder控制台变量的值。
Localization 包含本地化资产,如每种语言的本地化声音和文本。

PAK 文件

引擎附带CryPak模块,允许以压缩或未压缩的存档中存储游戏内容文件。存档使用.pak文件扩展名。

当游戏内容被请求时,CryPak 系统将查询所有找到的.pak文件,以找到文件。

文件查询优先级

PAK 系统优先考虑松散文件夹结构中找到的文件,而不是 PAK 中的文件,除非引擎是在发布模式下编译的。在这种情况下,PAK 系统中存储的文件优先于松散的文件。

如果文件存在于多个.pak存档中,则使用具有最近文件系统创建日期的存档。

附加调试器

Visual Studio 允许您将调试器附加到应用程序。这使您可以使用断点等功能,让您在 C++源代码中的特定行停止,并逐步执行程序。

要开始调试,请打开CE Game Programming Sample.sln并按下F5,或者单击 Visual Studio 工具栏上的绿色播放图标。如果出现找不到 Editor.exe 的调试符号消息框,只需单击确定

刚刚发生了什么?

CryENGINE Sandbox 编辑器现在应该已经启动,并且已连接了 Visual Studio 调试器。我们现在可以在代码中设置断点,并且当执行特定行的代码时,程序执行会暂停。

总结

在本章中,我们已经下载并学习了如何使用 CryENGINE 安装。您现在应该了解了编译和调试 CryGame 项目的过程。

我们现在已经掌握了继续学习 CryENGINE 编程 API 的基本知识。

如果您想了解更多关于 CryENGINE 本身的知识,除了编程知识之外,可以随时启动 Sandbox 编辑器并尝试使用级别设计工具。这将帮助您为将来的章节做好准备,在那里您将需要利用编辑器视口等工具。

第二章:使用 Flowgraph 进行可视脚本编写

CryENGINE flowgraph 是一个强大的基于节点的可视脚本系统,帮助开发人员快速原型化功能,并创建特定于关卡的逻辑,而无需处理复杂的代码库。

在本章中,我们将:

  • 讨论 flowgraph 的概念

  • 创建新的 flowgraph

  • 调试我们的 flowgraph

  • 在 Lua、C#和 C++中创建自定义 flowgraph 节点(flownode)

flowgraph 的概念

多年来,编写代码一直是创建游戏行为和逻辑的主要方法,如果不是唯一的方法。让我们以一个关卡设计师为例,为最新的游戏创建一个战斗部分。

传统上,设计师必须要求程序员为这种情况创建逻辑。这有几个问题:

  • 这会导致设计和实现之间的脱节

  • 程序员被迫花费时间,这实际上是设计师的工作

  • 设计师无法立即了解他/她的部分如何进行

这是 CryENGINE 的flowgraph,通常称为FG,解决的问题。它提供了一组 flownode,最好将其视为方便的逻辑乐高积木,设计师可以利用它们来拼凑整个场景。不再需要向游戏代码团队发送请求;设计师可以立即实现他们的想法!我们将稍后更详细地讨论创建节点本身,但现在让我们看一些简单的 flowgraph,这样您就可以迈出 CryENGINE 游戏逻辑的第一步!

打开 Flowgraph 编辑器

要开始,我们需要打开 Sandbox。Sandbox 包含 Flowgraph 编辑器作为其众多有用工具之一,可以通过视图|打开视图窗格来打开它。

注意

在打开 Flowgraph 编辑器时,您应该始终加载一个关卡,因为 flowgraph 是与关卡相关的。如果您忘记了如何创建新关卡,请返回到第一章介绍和设置

打开 Flowgraph 编辑器

您刚刚访问了您的第一个 Sandbox 工具!您应该会看到一个新窗口,其中有许多子部分和功能,但不要担心,让我们逐个解决它们。

Flowgraph 编辑器之旅

flowgraph 被保存在磁盘上作为 XML 文件,但可以被 Flowgraph 编辑器解析和编辑,以提供创建游戏逻辑过程的可视界面。

Flowgraph 编辑器之旅

组件

编辑器的这一部分包含项目中的所有 flownode,组织成整洁的类别。让我们快速查看一下这个,打开Misc文件夹。您应该会看到一组节点,分配到不同的类别:

组件

术语

  • :这指的是包含一组相互链接的节点的上下文。

  • 节点:这是一个类的可视表示,它可以从其输入端口接收数据和事件,也可以通过其输出端口发送数据。它连接到图中的其他节点以创建逻辑。

  • 端口:这是一个函数的可视表示。节点可以指定多个输入和输出端口,然后可以从中发送或接收事件。

组件类别

您可能会错过这里标记为调试的节点;CryENGINE 为节点分配类别,以指示它们适合在哪里使用。

  • 发布:此节点适用于生产

  • 高级:虽然此节点适用于生产,但在某些情况下可能具有复杂的行为

  • 调试:此节点只应用于内部测试

  • 过时:不应使用此节点,此节点将不会在组件列表中可见

例如,在制作一个打算发布给公众的关卡时,您可能不希望意外包含任何调试节点!我们可以通过视图|组件来启用或禁用 Flowgraph 编辑器中的前三个类别的查看:

组件类别

流程图类型

在创建新的流程图之前,我们需要知道我们的目的最相关的类型是什么。不同的流程图类型允许专门化,例如,创建处理玩家用户界面的UI 图形

流程图类型

AI 操作

这些是您可以创建的流程图,将 AI 行为封装成方便的节点,可以在其他地方重复使用。当您学习人工智能AI)时,我们稍后会讨论这些。

UI 操作

CryENGINE 允许您使用流程图脚本化用户界面和游戏中的抬头显示,通过 UI 事件系统。我们将在第七章中讨论这些,用户界面

材质 FX

CryENGINE 支持方便的可设计的流程图,用于控制如何处理材质事件,例如,在附近射击地面时生成一个灰尘粒子并用一层灰尘遮挡玩家的屏幕。

FG 模块

您可以将流程图打包成方便的模块,以便在不同情况下重复使用。我们稍后会详细描述这些。

实体

这是我们在本章中将花费大部分时间的地方!90%的时间,流程图都分配给一个实体,也就是图实体,这个逻辑发生在游戏世界中。

预制件

CryENGINE 支持预制件,这是一组实体打包成一个方便的文件以供重复使用。预制件中的任何实体流程图都将显示在此文件夹中。

创建流程图

现在我们对流程图编辑器的工作原理有了基本的了解,让我们立即开始创建我们的第一个流程图!您可以暂时关闭流程图编辑器。

流程图实体

流程图实体是一个极其轻量级的 CryENGINE 对象,设计用于在您需要一个不应用于任何特定实体的流程图时使用。与所有实体一样,它可以在 Sandbox 的RollupBar中找到。

注意

如果您不确定实体是什么,请跳过本节,直到您阅读完第三章为止,创建和使用自定义实体

流程图实体

生成 FlowgraphEntity

选择流程图实体,然后双击并再次单击视口,或单击并将其拖动到级别中。您现在应该在RollupBar中看到一整套新选项,包括实体参数、材质层,但对我们来说最重要的是实体:流程图实体部分。

附加一个新的流程图

实体:流程图实体部分,我们需要找到流程图子部分,然后单击创建按钮:

附加新的流程图

从这里,您将有选择将您的流程图分配给一个组。现在是否这样做并不重要,但在处理较大项目时,将相关图形分组在一起是很有用的。

注意

组用于为流程图创建结构,允许开发人员将不同的图形分类到文件夹中。

完成后,您应该看到流程图编辑器出现在背景上叠加了淡淡的网格。我们现在准备开始创建逻辑!

将节点添加到流程图中

将节点添加到新图形的最简单方法是浏览组件列表并拖动新节点。但是,如果您知道要添加的节点的名称,这并不是很有效。因此,您还可以在流程图编辑器中使用Q快捷键来调出搜索功能,然后只需输入要添加的节点的名称。

在我们的情况下,我们将从Misc:Start节点开始,这是一个简单的节点,用于在加载级别时或编辑器测试会话启动时触发其他事件:

将节点添加到流程图中

输入和输出端口

放置节点后,您应该看到节点输入和输出端口的第一个示例。在这种情况下,我们有两个输入值InGameInEditor,以及一个单一的输出端口,在这种情况下方便地命名为output

输入和输出端口

输入端口用于向节点提供数据或触发事件,输出端口用于将数据和事件传递给图中的其他节点。在这个例子中,Misc:Start节点可以被编辑以定义它将在哪些游戏上下文中实际执行。也许您有一些调试逻辑只想在编辑器中运行,这种情况下我们可以将InGame设置为 false 或零。

端口类型

为了指定端口将处理什么类型的数据,我们需要知道它的端口类型。我们可以通过查看端口的颜色在 Flowgraph 编辑器中确定端口的类型。

以下是可用端口类型的列表:

  • Void:用于不传递特定值的端口,但激活以发出事件信号

  • Int:当端口应该只接收整数值时使用

  • Float:用于指示端口处理浮点值

  • EntityId:这表示端口期望一个实体标识符。(有关实体 ID 的更多信息,请参阅第三章,“创建和利用自定义实体”)

  • Vec3:用于处理三维向量的端口

  • String:在这种情况下,端口期望一个字符串

  • Bool:当端口期望真或假的布尔值时使用

注意

链接具有不同类型的端口将自动转换值。

目标实体

流节点可以具有目标实体,允许用户将当前级别中的实体链接到流节点。这对于旨在影响游戏世界中的实体的节点非常有用,例如Entity:GetPos节点,如下面的截图所示,获取指定实体的世界变换。

注意

我们还可以通过将EntityId输出端口链接到Choose Entity端口来动态指定实体。

目标实体

有两种分配实体给支持它的节点的方法:

  • 通过将另一个流节点的EntityId输出链接到Choose Entity输入

  • 通过右键单击Choose Entity输入并选择:

  • 分配选定的实体:这将链接节点到编辑器视口中当前选定的实体

  • 分配图形实体:这将链接节点到分配给该图形的实体

链接流节点

单个流节点并不能做太多事情;让我们连接两个,并构建一个适当的图!为了演示目的,我们将使用Time:TimeOfDay节点:

链接流节点

要创建端口之间的链接,只需单击输出端口,按住鼠标按钮拖动光标到输入端口,然后释放鼠标,连接就会创建!

我们还编辑了Time输入端口的值;输入端口可以通过输出端口提供数据,也可以直接在编辑器中编辑它们的值。只需单击节点,查看 Flowgraph 编辑器的Inputs部分。从那里,您可以简单地编辑这些值:

链接流节点

您还可以查看有关节点的有价值的信息:例如,在这里我们可以看到这个节点用于设置白天的时间,以及游戏中时间流逝的速度。

完成这一步后,您可以暂时关闭 Flowgraph 编辑器。Flowgraphs 不需要手动保存;它们会自动保存在关卡中。

注意

尽管流图与关卡一起保存,但最好经常手动保存,以避免丢失工作。

测试我们的流图

正如我们在上一章中学到的,使用 Sandbox 在 CryENGINE 中测试逻辑非常简单。只需按下Ctrl + G快捷键组合,然后观察您进入游戏模式。现在,当您这样做时,您应该看到级别的照明和一般氛围发生变化,因为您刚刚改变了白天的时间!

恭喜,您刚刚迈出了使用 CryENGINE 创建游戏的第一步!现在看起来可能不是很多,但让我们让这个图表做更多事情。

存储的 flownode 概述

为了做一些更复杂的事情,我们需要了解 CryENGINE 默认提供的节点。

构建时钟

我们可以访问的最有用的节点之一,至少用于调试目的,是HUD:DisplayDebugMessage节点。它允许您在游戏窗口中显示信息,可选地带有超时。考虑到这一点,让我们基于我们之前学到的时间信息构建一个小的调试时钟。

Time:TimeOfDay节点以 CryENGINE 时间格式输出当前时间,该格式定义为小时加上分钟除以 60。例如,下午 1:30 会在 CryENGINE 时间中表示为 13.5。我们现在知道我们将需要一些数学运算,所以是时候检查 Math flownode 类别了。

我们要做的第一件事是通过将当前时间向下取整来获取小时数。为此,将Math:Floor放置在Time:TimeOfDay节点的CurTime输出上,然后将其连接到 Floor 的A输入端口。然后,将其馈送到 Debug Message 节点:

构建时钟

现在立即进入游戏,您应该在屏幕上看到当前的小时数。

然后我们需要从原始值中减去我们的新值以获得分钟部分。为此,我们需要Math:Sub来从原始CurTime值中减去四舍五入的小时数。之后,Math:Mul节点将新时间放大 60 倍,因此您的图应该如下所示:

构建时钟

记得将第二个 Debug 节点的posY设置为向下移动,这样您就可以同时看到两者。

如果您再次进入游戏,现在应该看到当前的小时和分钟被打印出来!

监听玩家输入

如果现在我们想要允许玩家测试不同时间的移动怎么办?一般来说,设置一个按键监听器是最简单的方法,在这里我们在按下某个键时触发一个事件。幸运的是,CryENGINE 将这个功能封装得很好,放入了一个单一的节点Input:Key

现在让我们设置按下P键会使时间快速移动,按下O键会再次停止时间。

注意

Input:Key节点是一个调试节点。通常认为在生产中使用调试节点是一个不好的做法,因为可能会出现意外的结果,所以请不要将此节点用于实际游戏逻辑。

我们需要设置Time:TimeOfDay节点的Speed值,但在这种情况下,我们还需要输入两个值!CryENGINE 提供了一个名为Logic:Any的节点,它具有多个输入端口,并且只是传递给它的任何数据,我们可以在这里使用它来接收两个输入值。我们使用两个调用Math:SetNumber节点的关键节点,然后Logic:Any节点将这些信息传递给我们的Time:TimeOfDay节点,并调用SetSpeed

监听玩家输入

现在进入游戏,按P键开始一天的运行!再次按O键,白天的时间应该会停止。

在循环中执行

您可能已经注意到我们的时钟不再正确更新。这是因为大多数节点不会输出数据,除非触发;在这种情况下,如果我们不触发GetTimeSetTime,我们将得不到任何输出。我们有两种调用的选择:我们可以使用Time:Time每帧执行它,或者Time:Timer

后者可以控制 tick 的粒度,但在这种情况下,我们可能希望在快速移动时每帧更新,所以让我们保持简单。将tick输出连接到我们的GetTime输入,我们的时钟应该再次正确更新!

在循环中执行

流程图模块

流程图模块系统允许将流程图导出为可以从另一个图中触发的模块。

通过创建模块,我们可以在多个级别中重用逻辑,而无需维护相同图的多个版本。还可以以非常模块化的方式发送和接收模块的唯一数据,实现动态逻辑。

创建模块

要开始创建自己的模块,打开流程图编辑器,选择文件 | 新建 FG 模块... | 全局

创建模块

在结果的保存对话框中,使用您选择的名称保存模块。然后,您将看到模块的默认视图:

创建模块

该模块默认包含两个节点;Module:Start_MyModuleModule:End_MyModule

  • Module:Start_MyModule包含三个输出端口:

  • 开始:当模块加载时调用

  • 更新:当模块应更新时调用

  • 取消:当模块应取消时调用,它默认连接到Module:End_MyModule取消输入

  • Module:End_MyModule包含两个输入端口:

  • 成功:当完成模块时应调用此函数,并将“成功”状态传递给调用者

  • 取消:用于提前结束模块,并将“取消”状态传递给调用者

最后,要填充您的模块逻辑,只需将Start输出端口连接到您的逻辑节点。

调用模块

要调用现有模块,请在模块节点类别中找到相关节点。调用节点的名称为Module:Call_<ModuleName>

调用模块

然后简单地触发Call端口以激活您的模块,Cancel以中止它。

模块参数/端口

根据我们之前学到的知识,我们能够使用 void 端口调用模块。这在所有情况下都不是最佳选择,因为您可能希望向模块传递附加数据。

为了实现这一点,模块系统公开了模块参数。通过在流程图编辑器中选择工具 | 编辑模块...,我们可以为我们的模块添加一组参数:

模块参数/端口

此操作将打开模块端口窗口,允许我们添加和删除端口:

模块参数/端口

通过选择新输入新输出,我们将能够添加新的端口,可以在激活模块时使用。

模块参数/端口

添加新的输入或输出将自动输出其Module:Start_MyModuleModule:End_MyModule节点,允许您接收数据:

模块参数/端口

所有Module:Call_MyModule节点也会自动更新,让您立即访问新参数:

模块参数/端口

自定义流节点

总之,CryENGINE 默认提供了许多有用的节点,涵盖了整个功能范围。然而,作为程序员,您经常会发现设计师会要求访问一些默认情况下流程图无法提供的隐藏功能。

例如,假设您正在创建一个角色扮演游戏,并且有一个经验系统。在您编写的代码中,有很多方法可以奖励玩家的经验,但级别设计师还希望能够在关卡的任意点使用这个功能。

在这种情况下,你可以很好地创建一个自定义流节点;你可以创建一个简化的代码中存在的系统的表示,也许允许设计师简单地指定在触发节点时奖励给玩家的经验点数。

不过,现在我们要看一些更简单的东西。假设我们没有现有的 CryENGINE 节点可供使用,我们想要实现我们之前看到的Math:Mul节点。简而言之,它只是一个在流程图中实现乘法的简单节点。

自定义流节点

在 C++中创建自定义节点

回到第一章, 介绍和设置,我们首次编译和运行 GameDLL,这里打包为 Visual Studio 的MiniMonoGameSample.sln。让我们再次加载它,确保任何 CryENGINE 实例,比如启动器或沙盒,都已关闭,因为我们将要覆盖运行时使用的CryGame.dll文件。

组织节点

CryENGINE 游戏的标准做法是在 GameDLL 项目CryGame中有一个名为Nodes的过滤器。如果不存在,现在就创建它。

组织节点

创建一个新的节点文件

节点在项目的其他区域中从未被引用,所以可以简单地将节点实现为一个单独的.cpp文件,而不需要头文件。

在我们的情况下,让我们只添加一个新文件TutorialNode.cpp,并创建基本结构:

#include "stdafx.h"
#include "Nodes/G2FlowBaseNode.h"

  class CTutorialNode : public CFlowBaseNode<eNCT_Instanced>
  {

  };

  REGISTER_FLOW_NODE("Tutorial:Multiplier", CTutorialNode);

代码分解

首先,我们包含了stdafx.h;这提供了文件的常见功能和一些标准化的“包含”。这也是编译文件所需的。

之后,我们包含了第二个文件,Nodes/G2FlowBaseNode.h。虽然它不是严格意义上的 CryENGINE 组件,但这个文件在 CryENGINE 游戏中被广泛使用,将节点功能封装成一个易于访问的基类。

然后我们创建我们的实际类定义。我们从前面提到的基本节点继承,然后指定我们的节点是一个实例化节点;一般来说,你会在 CryENGINE 中使用实例化节点。

注意

CryENGINE 使用一些有限的匈牙利命名前缀,就像你在这里看到的那样。类是CMyClass,结构体变成SMyData,接口是IMyInterface

对于字段,如m_memberVariable,通常使用m_前缀,对于指针变量,如*pAnInstance,通常使用p

为了使节点注册更容易,CryENGINE 暴露了REGISTER_FLOW_NODE预处理宏。这个系统将在启动时自动处理节点的注册。

节点函数概述

对于我们正在创建的节点,我们不需要存储任何私有信息,所以只需使用 C++修饰符将所有节点信息公开为类内的第一行:

public:

然后我们开始实现两个函数,构造函数和Clone方法。我们在这两个函数中都不需要任何逻辑,所以实现非常简单;构造函数不初始化任何东西,Clone只是返回当前节点的一个新实例:

  CTutorialNode(SActivationInfo *pActInfo)
  {
  }

  virtual IFlowNodePtr Clone(SActivationInfo *pActInfo)
  {
    return new CTutorialNode(pActInfo);
  }

在这里,我们还第一次介绍了SActivationInfo。这个结构包含了关于节点当前状态的信息,以及它所包含的图表,我们稍后会在其他地方使用它。

现在,我们的节点至少需要三个额外的函数才能编译:

  virtual void ProcessEvent(EFlowEvent evt, SActivationInfo *pActInfo)
  {
  }

  virtual void GetConfiguration(SFlowNodeConfig &config)
  {
  }

  virtual void GetMemoryUsage(ICrySizer *s) const
  {
    s->Add(*this);
  }

ProcessEvent是我们将要做大部分节点逻辑的地方;当有有趣的事情发生在我们的节点上时,比如端口被触发,就会调用这个函数。GetConfiguration控制节点的显示方式,以及它包含的输入和输出端口。GetMemoryUsage不需要我们额外的实现,所以我们可以只是为内存使用跟踪添加对这个节点的引用。

现在,验证你的代码是否能编译是一个好的起点;如果不能,检查你是否正确声明了所有函数签名,并包含了头文件。

实现 GetConfiguration

如前所述,GetConfiguration是我们设置节点在 Flowgraph Editor 中如何使用的地方。首先,让我们设置enum来描述我们的输入端口;我们将使用两个值,左和右,以及一个激活端口来触发计算。在类内部声明:

  enum EInput
  {
    EIP_Activate,
    EIP_Left,
    EIP_Right
  };

当然,我们还需要一个用于计算的输出端口,因此让我们也创建一个单一值的enum。虽然不是必需的,但保持一致是一个好习惯,大多数节点将具有多个输出:

  enum EOutput
  {
    EOP_Result
  };

创建端口

有了这些声明,我们就可以开始构建我们的节点。端口被定义为GetConfiguration中声明的常量静态数组中的条目,并且使用一些辅助函数进行构造,即InputPortConfig<T>用于特定类型的值,以及InputPortConfig_AnyType用于允许所有值,以及InputPortConfig_Void用于不使用数据的端口。

考虑到这一点,我们知道除了两个浮点模板端口外,我们的触发输入还需要一个 void 输入。我们还需要一个浮点输出。

  virtual void GetConfiguration(SFlowNodeConfig &config)
  {
    static const SInputPortConfig inputs[] =
    {
      InputPortConfig_Void("Activate", "Triggers the calculation"),
      InputPortConfig<float>("Left", 0, "The left side of the calculation"),
      InputPortConfig<float>("Right", 0, "The right side of the calculation"),
      {0}
    };
  }

正如您所看到的,我们可以指定端口的名称、描述,以及对使用数据的端口设置默认值。它们应该与我们之前声明的枚举的顺序相匹配。

注意

更改已使用的节点的端口名称将破坏现有的图表。填写可选的humanName参数以更改显示名称。

现在我们重复该过程,只是使用输出函数集:

  static const SOutputPortConfig outputs[] =
  {
    OutputPortConfig<float>("Result", "The result of the calculation"),
    {0}
  };

将数组分配给节点配置

在创建端口的过程之后,我们需要将这些数组分配给我们的config参数,并提供描述和类别:

  config.pInputPorts = inputs;
  config.pOutputPorts = outputs;
  config.sDescription = _HELP("Multiplies two numbers");
  config.SetCategory(EFLN_APPROVED);

如果现在编译代码,节点应该完全显示在编辑器中。但是正如您将看到的那样,它还没有做任何事情;为了解决这个问题,我们必须实现ProcessEvent

flownode 配置标志

SFlowNodeConfig结构允许您为 flownode 分配可选标志,如下所示列出:

  • EFLN_TARGET_ENTITY:这用于指示此节点应支持目标实体。要获取当前分配的目标实体,请查看SActivationInfo::pEntity

  • EFLN_HIDE_UI:这将在 flowgraph UI 中隐藏节点。

  • EFLN_UNREMOVEABLE:这禁用了用户删除节点的功能。

要在GetConfiguration中添加一个标志,以支持目标实体,只需将标志添加到nFlags变量中:

  config.nFlags |= EFLN_TARGET_ENTITY;

实现 ProcessEvent

ProcessEvent是我们捕获节点的所有有趣事件的地方,例如触发端口。在我们的情况下,我们希望在触发我们的Activate端口时执行计算,因此我们需要检查端口的激活。不过,首先,我们可以通过检查我们想要处理的事件来节省一些处理时间。

  virtual void ProcessEvent(EFlowEvent evt, SActivationInfo *pActInfo)
  {
    switch (evt)
    {
      case eFE_Activate:
      {

      }
      break;
    }
  }

通常,您将处理多个事件,因此养成在此处使用switch语句的习惯是很好的。

在其中,让我们看一下我们用来检查激活、检索数据,然后触发输出的各种 flownode 函数:

  if (IsPortActive(pActInfo, EIP_Activate))
  {
    float left = GetPortFloat(pActInfo, EIP_Left);
    float right = GetPortFloat(pActInfo, EIP_Right);
    float answer = left * right;

    ActivateOutput(pActInfo, EOP_Result, answer);
  }

总之,我们在所有这些函数中使用我们的激活信息来表示当前状态。然后,我们可以使用GetPort*函数检索各种端口类型的值,然后触发带有数据的输出。

是时候加载编辑器并进行测试了;如果一切顺利,您应该能够在教程类别中看到您的节点。恭喜,您刚刚为 CryENGINE 编写了您的第一个 C++代码!

实现 ProcessEvent

在 C#中创建自定义节点

CryMono 还支持使用 C#开发人员熟悉的习惯用法来创建自定义节点,例如属性元编程。要开始使用 C# CryENGINE 脚本,请打开Game/Scripts/CryGameCode.sln中的示例脚本解决方案。在 flownodes 文件夹中添加一个新的.cs文件,然后我们将开始在 C#中创建相同的节点,以便您可以看到创建方式的不同。

首先,让我们创建一个基本的骨架节点。我们需要为我们的节点引入正确的命名空间,以及为我们的节点设置一些基本属性:

  using CryEngine.Flowgraph;

  namespace CryGameCode.FlowNodes
  {
    [FlowNode(Name = "Multiplier", Category = "CSharpTutorial", Filter = FlowNodeFilter.Approved)]
    public class TutorialNode : FlowNode
    {

    }
  }

与 C++一样,节点在项目中没有其他引用,因此我们为我们的节点分配了一个单独的命名空间,以防止它们污染主要命名空间。

我们使用FlowNodeAttribute类来设置节点的元数据,例如正确的类别和可见性级别,而不是使用GetConfiguration。您的节点必须包括此属性,并从FlowNode继承,以便被 CryENGINE 注册;不需要任何手动注册调用。

注意

请记住,属性可以放置在其名称的最后一个Attribute之外。例如,FlowNodeAttribute可以放置为[FlowNodeAttribute][FlowNode]

添加输入

在 CryMono 中,输入被定义为函数,并且它们接受定义数据类型的单个参数,或者对于 void 端口,不接受参数。它们还需要用Port属性进行修饰。在我们的情况下,让我们设置与节点的 C++版本中相同的三个输入:

  [Port]
  public void Activate()
  {
  }

  [Port]
  public void Left(float value)
  {
  }

  [Port]
  public void Right(float value)
  {
  }

我们将在接下来的实现中回到Activate。虽然你可以通过在属性中设置可选参数来覆盖端口名称,但更容易的方法是让你的函数名称定义节点在编辑器中的显示方式。

添加输出

输出被存储为OutputPortOutputPort<T>的实例,如果需要值。让我们现在将我们的Result输出作为类的属性添加进去:

  public OutputPort<float> Result { get; set; }

实现激活

让我们回到我们的Activate输入;同样,我们需要检索我们的两个值,然后触发一个输出。FlowNode类有方便的函数来实现这些:

  var left = GetPortValue<float>(Left);
  var right = GetPortValue<float>(Right);
  var answer = left * right;

  Result.Activate(answer);

就是这样!下次您打开流程图编辑器时,您将看到您的新的CSharpTutorial:Multiplier节点,具有与您之前实现的 C++等效节点完全相同的功能:

实现激活

再次恭喜你,因为你已经迈出了使用.NET 平台和 CryENGINE 编写游戏代码的第一步!

目标实体

在 CryMono 中添加对目标实体的支持很容易,只需将您的FlowNode属性中的TargetsEntity属性设置为 true 即可。

  [FlowNode(TargetsEntity = true)]

然后,您可以通过FlowNode.TargetEntity获取实体实例,假设它是在包含节点的流程图中分配的。

摘要

在本章中,我们已经学会了为什么流程图对设计师有用,并创建了我们自己的流程图。

我们还调查了 CryENGINE 提供的一些现有节点,然后用两种编程语言创建了我们自己的节点。现在,您应该对流程图系统有了很好的理解,并且知道如何利用它。

在未来的章节中,我们将探讨流程图可以实现的一些其他功能,包括设计用户界面、实现材质效果、创建特殊的流节点来表示世界中的实体,并将 AI 功能封装成方便的可重用模块。

如果您想更多地探索流程图的世界,为什么不试着找出如何实现更多的标准节点呢?熟悉一下编写 C++和 C#节点之间的区别,看看你更喜欢哪个。

如果您特别想尝试 CryMono,请尝试编辑您的节点脚本,并在运行 Sandbox 时保存它们;您可能会惊喜地发现它们在后台重新编译和重新加载!这应该帮助您测试新的节点想法,而不会因为编译时间和重新启动而受到阻碍。

第三章:创建和利用自定义实体

CryENGINE 实体系统提供了创建从简单的物理对象到复杂的天气模拟管理器的一切的手段。

在本章中我们将:

  • 详细介绍实体系统的基本概念和实现

  • 在 Lua、C#和 C++中创建我们的第一个自定义实体

  • 了解游戏对象系统

介绍实体系统

实体系统存在是为了在游戏世界中生成和管理实体。实体是逻辑容器,允许在运行时进行行为上的重大改变。例如,实体可以在游戏的任何时刻改变其模型、位置和方向。

考虑一下;你在引擎中与之交互的每个物品、武器、车辆,甚至玩家都是实体。实体系统是引擎中最重要的模块之一,经常由程序员处理。

通过IEntitySystem接口访问的实体系统管理游戏中的所有实体。实体使用entityId类型定义进行引用,允许在任何给定时间有 65536 个唯一实体。

如果实体被标记为删除,例如IEntity::Remove(bool bNow = false),实体系统将在下一帧开始更新之前删除它。如果bNow参数设置为 true,则实体将立即被移除。

实体类

实体只是实体类的实例,由IEntityClass接口表示。每个实体类都被分配一个标识其的名称,例如,SpawnPoint。

类可以通过IEntityClassRegistry::RegisterClass注册,或者通过IEntityClassRegistry::RegisterStdClass注册以使用默认的IEntityClass实现。

实体

IEntity接口用于访问实体实现本身。IEntity的核心实现包含在CryEntitySystem.dll中,不能被修改。相反,我们可以使用游戏对象扩展(查看本章中的游戏对象扩展部分)和自定义实体类来扩展实体。

entityId

每个实体实例都被分配一个唯一的标识符,该标识符在游戏会话的持续时间内保持不变。

EntityGUID

除了entityId参数外,实体还被赋予全局唯一标识符,与entityId不同,这些标识符可以在游戏会话之间持续存在,例如在保存游戏等情况下。

游戏对象

当实体需要扩展功能时,它们可以利用游戏对象和游戏对象扩展。这允许更多的功能可以被任何实体共享。

游戏对象允许处理将实体绑定到网络、序列化、每帧更新以及利用现有(或创建新的)游戏对象扩展,如库存和动画角色。

在 CryENGINE 开发中,游戏对象通常只对更重要的实体实现(如演员)是必要的。演员系统在第五章中有更详细的解释,以及IActor游戏对象扩展。

实体池系统

实体池系统允许对实体进行“池化”,从而有效地控制当前正在处理的实体。这个系统通常通过流图访问,并允许根据事件在运行时基于事件禁用/启用实体组。

注意

池还用于需要频繁创建和释放的实体,例如子弹。

一旦实体被池系统标记为已处理,它将默认隐藏在游戏中。在实体准备好之前,它不会存在于游戏世界中。一旦不再需要,最好释放实体。

例如,如果有一组 AI 只需要在玩家到达预定义的检查点触发器时被激活,可以使用AreaTrigger(及其包含的流节点)和Entity:EntityPool流节点来设置。

创建自定义实体

现在我们已经学会了实体系统的基础知识,是时候创建我们的第一个实体了。在这个练习中,我们将演示在 Lua、C#和最后 C++中创建实体的能力。

使用 Lua 创建实体

Lua 实体相当简单设置,并围绕两个文件展开:实体定义和脚本本身。要创建新的 Lua 实体,我们首先必须创建实体定义,以告诉引擎脚本的位置:

<Entity
  Name="MyLuaEntity"
  Script="Scripts/Entities/Others/MyLuaEntity.lua"
/>

只需将此文件保存为MyLuaEntity.ent,放在Game/Entities/目录中,引擎将在Scripts/Entities/Others/MyLuaEntity.lua中搜索脚本。

现在我们可以继续创建 Lua 脚本本身!首先,在之前设置的路径创建脚本,并添加一个与实体同名的空表:

  MyLuaEntity = { }

在解析脚本时,引擎首先搜索与实体相同名称的表,就像您在.ent定义文件中定义的那样。这个主表是我们可以存储变量、编辑器属性和其他引擎信息的地方。

例如,我们可以通过添加一个字符串变量来添加我们自己的属性:

  MyLuaEntity = {
    Properties = {
      myProperty = "",
    },
  }

注意

可以通过在属性表中添加子表来创建属性类别。这对于组织目的很有用。

完成更改后,当在编辑器中生成类的实例时,您应该看到以下屏幕截图,通过RollupBar默认情况下位于编辑器的最右侧:

使用 Lua 创建实体

常见的 Lua 实体回调

脚本系统提供了一组回调,可以用于触发实体事件上的特定逻辑。例如,OnInit函数在实体初始化时被调用:

  function MyEntity:OnInit()
  end

在 C#中创建实体

第三方扩展CryMono允许在.NET 中创建实体,这使我们能够演示在 C#中创建我们自己的实体的能力。

首先,打开Game/Scripts/Entities目录,并创建一个名为MyCSharpEntity.cs的新文件。这个文件将包含我们的实体代码,并且在引擎启动时将在运行时编译。

现在,打开您选择的脚本(MyCSharpEntity.cs)IDE。我们将使用 Visual Studio 来提供IntelliSense和代码高亮。

一旦打开,让我们创建一个基本的骨架实体。我们需要添加对 CryENGINE 命名空间的引用,其中存储了最常见的 CryENGINE 类型。

  using CryEngine;

  namespace CryGameCode
  {
    [Entity]
    public class MyCSharpEntity : Entity
    {
    }
  }

现在,保存文件并启动编辑器。您的实体现在应该出现在RollupBar中的默认类别中。将MyEntity拖到视口中以生成它:

在 C#中创建实体

我们使用实体属性([Entity])作为为实体注册进度提供额外信息的一种方式,例如,使用Category属性将导致使用自定义编辑器类别,而不是默认

  [Entity(Category = "Others")]

添加编辑器属性

编辑器属性允许关卡设计师为实体提供参数,也许是为了指示触发区域的大小,或者指定实体的默认健康值。

在 CryMono 中,可以通过使用EditorProperty属性来装饰支持的类型(查看以下代码片段)。例如,如果我们想添加一个新的string属性:

  [EditorProperty]
  public string MyProperty { get; set; }

现在,当您启动编辑器并将MyCSharpEntity拖到视口中时,您应该看到MyProperty出现在RollupBar的下部。

C#中的MyProperty字符串变量将在用户通过编辑器编辑时自动更新。请记住,编辑器属性将与关卡一起保存,允许实体在纯游戏模式中使用关卡设计师定义的编辑器属性。

添加编辑器属性

属性文件夹

与 Lua 脚本一样,CryMono 实体可以将编辑器属性放置在文件夹中以进行组织。为了创建文件夹,您可以使用EditorProperty属性的Folder属性,如下所示:

  [EditorProperty(Folder = "MyCategory")]

现在您知道如何使用 CryMono 创建具有自定义编辑器属性的实体!这在为关卡设计师创建简单的游戏元素并在运行时进行放置和修改时非常有用,而无需寻找最近的程序员。

在 C++中创建实体

在 C++中创建实体比使用 Lua 或 C#更复杂,可以根据实体所需的内容进行不同的操作。在本例中,我们将详细介绍通过实现IEntityClass来创建自定义实体类。

创建自定义实体类

实体类由IEntityClass接口表示,我们将从中派生并通过IEntityClassRegistry::RegisterClass(IEntityClass *pClass)进行注册。

首先,让我们为我们的实体类创建头文件。在 Visual Studio 中右键单击项目或其任何筛选器,并转到上下文菜单中的添加 | 新项目。在提示时,创建您的头文件(.h)。我们将称之为CMyEntityClass

现在,打开生成的MyEntityClass.h头文件,并创建一个从IEntityClass派生的新类:

  #include <IEntityClass.h>

  class CMyEntityClass : public IEntityClass
  {
  };

现在,我们已经设置了类,我们需要实现从IEntityClass继承的纯虚拟方法,以便我们的类能够成功编译。

对于大多数方法,我们可以简单地返回空指针、零或空字符串。但是,有一些方法我们必须处理才能使类正常运行:

  • Release(): 当类应该被释放时调用,应该简单执行"delete this;"来销毁类

  • GetName(): 这应该返回类的名称

  • GetEditorClassInfo(): 这应该返回包含编辑器类别、帮助和图标字符串的ClassInfo结构到编辑器

  • SetEditorClassInfo(): 当需要更新刚才解释的编辑器ClassInfo时调用

IEntityClass是实体类的最低限度,尚不支持编辑器属性(稍后我们将稍后介绍)。

要注册实体类,我们需要调用IEntityClassRegistry::RegisterClass。这必须在IGameFramework::CompleteInit调用之前完成。我们将在GameFactory.cpp中的InitGameFactory函数中执行:

  IEntityClassRegistry::SEntityClassDesc classDesc;

  classDesc.sName = "MyEntityClass";
  classDesc.editorClassInfo.sCategory = "MyCategory";

  IEntitySystem *pEntitySystem = gEnv->pEntitySystem;

  IEntityClassRegistry *pClassRegistry = pEntitySystem->GetClassRegistry();

  bool result = pClassRegistry->RegisterClass(new CMyEntityClass(classDesc));

实现属性处理程序

为了处理编辑器属性,我们将不得不通过新的IEntityPropertyHandler实现来扩展我们的IEntityClass实现。属性处理程序负责处理属性的设置、获取和序列化。

首先创建一个名为MyEntityPropertyHandler.h的新头文件。以下是IEntityPropertyHandler的最低限度实现。为了正确支持属性,您需要实现SetPropertyGetProperty,以及LoadEntityXMLProperties(后者需要从Level XML 中读取属性值)。

然后创建一个从IEntityPropertyHandler派生的新类:

  class CMyEntityPropertyHandler : public IEntityPropertyHandler
  {
  };

为了使新类编译,您需要实现IEntityPropertyHandler中定义的纯虚拟方法。关键的方法可以如下所示:

  • LoadEntityXMLProperties: 当加载关卡时,启动器会调用此方法,以便读取编辑器保存的实体的属性值

  • GetPropertyCount: 这应该返回注册到类的属性数量

  • GetPropertyInfo: 这是在编辑器获取可用属性时调用的,应该返回指定索引处的属性信息

  • SetProperty: 这是用来设置实体的属性值的

  • GetProperty: 这是用来获取实体的属性值的

  • GetDefaultProperty:调用此方法以检索指定索引处的默认属性值

要使用新的属性处理程序,创建一个实例(将请求的属性传递给其构造函数)并在IEntityClass::GetPropertyHandler()中返回新创建的处理程序。

我们现在有了一个基本的实体类实现,可以很容易地扩展以支持编辑器属性。这种实现非常灵活,可以用于各种用途,例如,稍后看到的 C#脚本只是简单地自动化了这个过程,减轻了程序员的很多代码责任。

实体流节点

在上一章中,我们介绍了流图系统以及流节点的创建。您可能已经注意到,在图表内右键单击时,上下文选项之一是添加所选实体。此功能允许您在级别内选择一个实体,然后将其实体流节点添加到流图中。

默认情况下,实体流节点不包含任何端口,因此在右侧显示时基本上没有用处。

然而,我们可以很容易地创建自己的实体流节点,以在所有三种语言中都针对我们选择的实体。

实体流节点

在 Lua 中创建实体流节点

通过扩展我们在使用 Lua 创建实体部分中创建的实体,我们可以添加其自己的实体流节点:

  function MyLuaEntity:Event_OnBooleanPort()
  BroadcastEvent(self, "MyBooleanOutput");end

  MyLuaEntity.FlowEvents =
  {
    Inputs =
    {
      MyBooleanPort = { MyLuaEntity.Event_OnBooleanPort, "bool" },
    },
    Outputs =
    {
      MyBooleanOutput = "bool",
    },
  }

在 Lua 中创建一个实体流节点

我们刚刚为我们的MyLuaEntity类创建了一个实体流节点。如果您启动编辑器,生成您的实体,然后在流图中单击添加所选实体,您应该会看到节点出现。

使用 C#创建实体流节点

由于实现几乎与常规流节点完全相同,因此在 C#中创建实体流节点非常简单。要为您的实体创建一个新的流节点,只需从EntityFlowNode<T>派生,其中T是您的实体类名称:

  using CryEngine.Flowgraph;

  public class MyEntity : Entity { }

  public class MyEntityNode : EntityFlowNode<MyEntity>
  {
    [Port]
    public void Vec3Test(Vec3 input) { }

    [Port]
    public void FloatTest(float input) { }

    [Port]
    public void VoidTest()
    {
    }

    [Port]
    OutputPort<bool> BoolOutput { get; set; }
  }

使用 C#创建实体流节点

我们刚刚在 C#中创建了一个实体流节点。这使我们可以轻松地使用我们从上一章学到的内容,并在新节点的逻辑中利用TargetEntity

在 C++中创建实体流节点

注意

本节假定您已阅读了第二章中的在 C++中创建自定义节点部分。

简而言之,实体流节点在实现上与常规节点相同。不同之处在于节点的注册方式,以及实体支持TargetEntity的先决条件(有关更多信息,请参阅上一章)。

注册实体节点

我们使用与以前注册实体节点相同的方法,唯一的区别是类别必须是实体,节点名称必须与其所属的实体相同:

REGISTER_FLOW_NODE("entity:MyCppEntity", CMyEntityFlowNode);

最终代码

最后,根据我们现在和上一章学到的知识,我们可以很容易地在 C++中创建我们的第一个实体流节点:

  #include "stdafx.h"

  #include "Nodes/G2FlowBaseNode.h"

  class CMyEntityFlowNode : public CFlowBaseNode<eNCT_Instanced>
  {
    enum EInput
    {
      EIP_InputPort,
    };

    enum EOutput
    {
      EOP_OutputPort
    };

  public:
    CMyEntityFlowNode(SActivationInfo *pActInfo)
    {
    }

    virtual IFlowNodePtr Clone(SActivationInfo *pActInfo)
    {
      return new CMyEntityFlowNode(pActInfo);
    }

    virtual void ProcessEvent(EFlowEvent evt, SActivationInfo *pActInfo)
    {
    }

    virtual void GetConfiguration(SFlowNodeConfig &config)
    {
      static const SInputPortConfig inputs[] =
      {
        InputPortConfig_Void("Input", "Our first input port"),
        {0}
      };
      static const SOutputPortConfig outputs[] =
      {
        OutputPortConfig_Void("Output", "Our first output port"),
        {0}
      };

      config.pInputPorts = inputs;
      config.pOutputPorts = outputs;
      config.sDescription = _HELP("Entity flow node sample");

      config.nFlags |= EFLN_TARGET_ENTITY;
    }

    virtual void GetMemoryUsage(ICrySizer *s) const
    {
      s->Add(*this);
    }
  };

  REGISTER_FLOW_NODE("entity:MyCppEntity", CMyEntityFlowNode);

游戏对象

正如在本章开头提到的,当实体需要绑定到网络时,游戏对象用于需要更高级功能的实体。

有两种实现游戏对象的方法,一种是通过IGameObjectSystem::RegisterExtension直接注册实体(从而在实体生成时自动创建游戏对象),另一种是通过利用IGameObjectSystem::CreateGameObjectForEntity方法在运行时为实体创建游戏对象。

游戏对象扩展

通过创建扩展来扩展游戏对象,可以让开发人员钩入多个实体和游戏对象回调。例如,这就是演员默认实现的方式,我们将在第五章中进行介绍,创建自定义演员

我们将在 C++中创建我们的游戏对象扩展。我们在本章前面创建的 CryMono 实体是由CryMono.dll中包含的自定义游戏对象扩展实现的,目前不可能通过 C#或 Lua 创建更多的扩展。

在 C++中创建游戏对象扩展

CryENGINE 提供了一个辅助类模板用于创建游戏对象扩展,称为CGameObjectExtensionHelper。这个辅助类用于避免重复常见代码,这些代码对于大多数游戏对象扩展是必要的,例如基本的 RMI 功能(我们将在第八章中介绍,多人游戏和网络)。

要正确实现IGameObjectExtension,只需从CGameObjectExtensionHelper模板派生,指定第一个模板参数为你正在编写的类(在我们的例子中为CMyEntityExtension),第二个参数为你要派生的IGameObjectExtension

注意

通常,第二个参数是IGameObjectExtension,但对于特定的实现,比如IActor(它又从IGameObjectExtension派生而来),可能会有所不同。

  class CMyGameObjectExtension
    : public CGameObjectExtensionHelper<CMyGameObjectExtension, IGameObjectExtension>
    {
    };

现在你已经从IGameObjectExtension派生出来,你需要实现所有它的纯虚方法,以避免一堆未解析的外部。大多数可以用空方法重写,返回空或 false,而更重要的方法已经列出如下:

  • Init: 这是用来初始化扩展的。只需执行SetGameObject(pGameObject);然后返回 true。

  • NetSerialize: 这是用来在网络上序列化东西的。这将在第八章中介绍,多人游戏和网络,但现在它只会简单地返回 true。

你还需要在一个新的类中实现IGameObjectExtensionCreatorBase,这个类将作为你实体的扩展工厂。当扩展即将被激活时,我们工厂的Create()方法将被调用以获取新的扩展实例:

  struct SMyGameObjectExtensionCreator
    : public IGameObjectExtensionCreatorBase
  {
    virtual IGameObjectExtension *Create() { return new CMyGameObjectExtension(); }

    virtual void GetGameObjectExtensionRMIData(void **ppRMI, size_t *nCount) { return CMyGameObjectExtension::GetGameObjectExtensionRMIData(ppRMI, nCount); }
  };

现在你已经创建了你的游戏对象扩展实现,以及游戏对象创建者,只需注册扩展:

static SMyGameObjectExtensionCreator creator;
  gEnv->pGameFramework->GetIGameObjectSystem()->RegisterExtension("MyGameObjectExtension", &creator, myEntityClassDesc);

注意

通过将实体类描述传递给IGameObjectSystem::RegisterExtension,你告诉它为你创建一个虚拟实体类。如果你已经这样做了,只需将最后一个参数pEntityCls传递为NULL,以便它使用你之前注册的类。

激活我们的扩展

为了激活你的游戏对象扩展,你需要在实体生成后调用IGameObject::ActivateExtension。一种方法是使用实体系统接收器IEntitySystemSink,并监听OnSpawn事件。

我们现在已经注册了我们自己的游戏对象扩展。当实体生成时,我们的实体系统接收器的OnSpawn方法将被调用,允许我们创建我们的游戏对象扩展的实例。

总结

在本章中,我们学习了核心实体系统的实现和暴露,并创建了我们自己的自定义实体。

你现在应该了解为你的实体创建附加的流程节点,并了解围绕游戏对象及其扩展的工作知识。

我们将在后面的章节中介绍现有的游戏对象扩展和实体实现,例如,通过创建我们自己的角色并实现基本的 AI。

如果你想更熟悉实体系统,为什么不试着自己创建一个稍微复杂的实体呢?

在下一章中,我们将介绍游戏规则系统。

第四章:游戏规则

角色和实体是游戏的组成部分,但游戏规则是将它们联系在一起的东西。游戏规则系统管理所有初始玩家事件,如 OnConnect、OnDisconnect 和 OnEnteredGame。

使用游戏规则系统,我们可以创建自定义游戏流程来控制和联系我们的游戏机制。

在本章中,我们将:

  • 学习游戏模式的基本概念

  • 在 C++中创建我们的IGameRules实现

  • 用 Lua 和 C#编写游戏规则脚本

游戏规则简介

在考虑游戏时,我们通常会将思绪引向游戏机制,如处理死亡和游戏结束条件。根据我们在前几章中学到的知识,由于每个实体和角色都不影响更大的方案,我们实际上无法实现这一点。

游戏规则确切地做了其名称所暗示的事情;控制游戏的规则。规则可以很简单,比如一个角色射击另一个角色时会发生什么,或者更复杂,例如开始和结束回合。

CryENGINE 游戏规则实现围绕着两种听起来非常相似但实际上有很大不同的类型:

  • 游戏规则:这是通过 C++中的IGameRules接口实现的,它处理诸如OnClientConnectOnClientDisconnect之类的回调。

  • 游戏模式:这依赖于游戏规则实现,但通过添加额外的功能(如支持多个玩家)扩展了游戏规则实现的默认行为。例如,我们可以有两种游戏模式;单人游戏和死亡竞赛,它们都依赖于IGameRules实现提供的默认行为,但每种游戏模式都添加了额外的功能。

IGameRules 接口 - 游戏规则

在第三章结束时,我们学习了游戏对象扩展。在本章中,我们将利用这些知识来实现IGameRules,这是一个游戏对象扩展,用于初始化游戏上下文并将游戏机制联系在一起。

注意

始终记住当前活动的游戏模式是一个实体。这有时可以通过请求实体事件来滥用。例如,在 Crytek 游戏 Crysis 中,一个常见的黑客技巧是围绕在游戏模式上发送子弹或杀死事件。这实质上“杀死”了游戏规则实体,并导致服务器严重崩溃。

IGameRules实现通常负责游戏模式的最基本行为,并将其他所有内容转发到其 C#或 Lua 脚本。

脚本 - 游戏模式

在注册我们的IGameRules实现之后,我们需要注册一个使用它的游戏模式。这是使用IGameRulesSystem::RegisterGameRules函数完成的(通常在IGame::Init中完成)。

pGameRulesSystem->RegisterGameRules("MyGameMode", "GameRules");

在处理了前面的片段之后,游戏规则系统将意识到我们的游戏模式。当sv_gamerules控制台变量更改为MyGameMode时,系统将创建一个新的实体,并激活其名为GameRules的游戏对象扩展(在前一节中注册)。

注意

sv_gamerules控制台变量在 CryENGINE 启动时设置为sv_gamerulesdefault的值,除非在专用服务器上运行。

此时,游戏将自动搜索名为你的游戏模式的 Lua 脚本,位于Scripts/GameRules/中。对于前面的片段,它会找到并加载Scripts/GameRules/MyGameMode.lua

通过使用脚本,游戏规则实现可以将游戏事件(如新玩家连接)转发到 Lua 或 C#,允许每个游戏模式根据其内部逻辑专门化行为。

加载关卡

当使用地图控制台命令加载关卡时,游戏框架会在Game/Levels中搜索关卡。

通过使用IGameRulesSystem::AddGameRulesLevelLocation,我们可以在Game/Levels中添加子目录,当寻找新关卡时将会搜索这些子目录。例如:

gEnv->pGameFramework->GetIGameRulesSystem()->AddGameRulesLevelLocation("MyGameMode", "MGM_Levels");

当加载一个将 sv_gamerules 设置为 MyGameMode 的关卡时,游戏框架现在会在 Levels/MGM_Levels/ 目录中搜索关卡目录。

这允许游戏模式特定的关卡被移动到 Game/Levels 目录中的子目录中,这样可以更容易地按游戏模式对关卡进行排序。

实现游戏规则接口

现在我们知道了游戏规则系统的基本工作原理,我们可以尝试创建一个自定义的 IGameRules 实现。

注意

在开始之前,请考虑你是否真的需要为你的游戏创建一个自定义的 IGameRules 实现。随游戏一起提供的默认 GameDLL 是专门为第一人称射击游戏FPS)专门化的 IGameRules 实现。如果你的游戏前提类似于 FPS,或者你可以重用现有功能,那么可能更好地编写一个实现。

首先,我们需要创建两个新文件;GameRules.cppGameRules.h。完成后,打开 GameRules.h 并创建一个新的类。我们将命名为 CGameRules

类就位后,我们必须从 IGameRules 派生。如前所述,游戏规则被处理为游戏对象扩展。因此,我们必须使用 CGameObjectExtensionHelper 模板类:

class CGameRules 
  : public CGameObjectExtensionHelper<CGameRules, IGameRules>
  {
  };

第三个可选的 CGameObjectExtensionHelper 参数定义了这个游戏对象支持多少个 RMIs。我们将在第八章 多人游戏和网络中进一步讨论它。

有了这个类,我们可以开始实现 IGameRulesIGameObjectExtension 结构中定义的所有纯虚方法。与实体一样,我们可以实现返回空、nullptr、零、false 或空字符串的虚拟方法。需要单独处理的方法如下:

函数名 描述
IGameObjectExtension::Init 用于初始化游戏对象扩展。应该调用 IGameRulesSystem::SetCurrentGameRules(this)
IGameRules::OnClientConnect 当新客户端连接时在服务器上调用,必须使用 IActorSystem::CreateActor 创建一个新的角色
IGameRules::OnClientDisconnect 当客户端断开连接时在服务器上调用,必须包含对 IActorSystem::RemoveActor 的调用
IGameObjectExtension::Release / 析构函数 Release 函数应该删除扩展实例,并通过其析构函数调用 IGameRulesSystem::SetCurrentGameRules(nullptr)

注册游戏对象扩展

完成后,使用 REGISTER_FACTORY 宏注册游戏规则实现。

游戏对象扩展必须在游戏初始化过程中尽早注册,因此通常在 IGame::Init 函数中完成(通过默认 GameDLL 中的 GameFactory.cpp):

  REGISTER_FACTORY(pFramework, "GameRules", CGameRules, false);

创建自定义游戏模式

要开始,我们需要注册我们的第一个游戏模式。

注意

注意 IGameRules 实现和游戏模式本身之间的区别。游戏模式依赖于 IGameRules 实现,并且需要单独注册。

要注册自定义游戏模式,CryENGINE 提供了 IGameRulesSystem::RegisterGameRules 函数:

  gEnv->pGameFramework->GetIGameRulesSystem()->RegisterGameRules("MyGameMode", "GameRules");

前面的代码将创建一个名为 MyGameMode 的游戏模式,它依赖于我们之前注册的 GameRules 游戏对象扩展。

当加载一个将 sv_gamerules 设置为 MyGameMode 的地图时,游戏规则实体将被创建并分配名称 MyGameMode。生成后,我们之前创建的 IGameRules 扩展将被构造。

注意

如果你只是创建一个现有游戏模式的副本或子类,例如从 SinglePlayer.lua 派生的默认 DeathMatch.lua 脚本,你需要单独注册 DeathMatch 游戏模式。

脚本

游戏模式通常是面向脚本的,游戏流程如生成、杀死和复活通常委托给 Lua 或 C# 等第二语言。

Lua 脚本

由于 Lua 脚本已集成到 CryENGINE 中,我们无需进行任何额外的加载即可使其工作。要访问您的脚本表(基于与您的游戏模式同名的 Lua 文件在Game/Scripts/GameRules中):

  m_script = GetEntity()->GetScriptTable();

调用方法

要在您的脚本表上调用方法,请参阅IScriptSystem BeginCallEndCall函数:

  IScriptSystem *pScriptSystem = gEnv->pScriptSystem;

  pScriptSystem->BeginCall(m_script, "MyMethod");
  pScriptSystem->EndCall();

执行上述代码时,我们将能够在我们游戏模式的脚本表中包含的名为MyMethod的函数中执行 Lua 代码。表的示例如下所示:

  MyGameMode = { }

  function MyGameMode:MyMethod()
  end

调用带参数的方法

要为您的 Lua 方法提供参数,请在脚本调用的开始和结束之间使用IScriptSystem::PushFuncParam

  pScriptSystem->BeginCall(m_script, name);
  pScriptSystem->PushFuncParam("myStringParameter");
  pScriptSystem->EndCall();

注意

IScriptSystem::PushFuncParam是一个模板函数,尝试使用提供的值创建一个ScriptAnyValue对象。如果默认的ScriptAnyValue构造函数不支持您的类型,将出现编译器错误。

恭喜,您现在已经使用字符串参数调用了 Lua 函数:

  function MyGameMode:MyMethod(stringParam)
  end

从 Lua 获取返回值

你还可以通过向IScriptSystem::EndCall传递一个额外的参数来从 Lua 函数中获取返回值。

  int result = 0;
  pScriptSystem->EndCall(&result);
  CryLog("MyMethod returned %i!", result);

获取表值

有时直接从 Lua 表中获取值可能是必要的,可以使用IScriptTable::GetValue来实现:

  bool bValue = false;
  m_script->GetValue("bMyBool", &bValue);

上述代码将在脚本中搜索名为bMyBool的变量,如果成功,则将其值设置为本机bValue变量。

CryMono 脚本

要在IGameObjectExtension::Init实现中创建 CryMono 脚本的实例,请参阅IMonoScriptSystem::InstantiateScript

  IMonoObjaect *pScript = GetMonoScriptSystem()->InstantiateScript(GetEntity()->GetClass()->GetName(), eScriptFlag_GameRules);

这段代码将查找一个具有当前游戏模式名称的 CryMono 类,并返回一个新的实例。

注意

无需同时使用 Lua 和 CryMono 游戏规则脚本。决定哪种对您的用例最好。

调用方法

现在您已经有了类实例,可以使用IMonoObject::CallMethod助手调用其中一个函数:

  m_pScript->CallMethod("OnClientConnect", channelId, isReset, playerName)

这段代码将搜索具有匹配参数的名为OnClientConnect的方法,并调用它:

  public bool OnClientConnect(int channelId, bool isReset = false, string playerName = "")
  {
  }

返回值

IMonoObject::CallMethod默认返回一个mono::object类型,表示一个装箱的托管对象。要获取本机值,我们需要将其解包:

  mono::object result = m_pScript->CallMethod("OnClientConnect", channelId, isReset, playerName);

  IMonoObject *pResult = *result;
  bool result = pResult->Unbox<bool>();

属性

要获取托管对象的属性值,请查看IMonoObject::GetPropertyValue

  mono::object propertyValue = m_pScript->GetPropertyValue("MyFloatProperty");

  if(propertyValue)
  {
    IMonoObject *pObject = *propertyValue;

    float value = pObject->Unbox<float>();
  }

也可以直接设置属性值:

  float myValue = 5.5f;

  mono::object boxedValue = GetMonoScriptSystem()->GetActiveDomain()->BoxAnyValue(MonoAnyValue(myValue));

  m_pScript->SetPropertyValue("MyFloatProperty", boxedValue);

字段

也可以通过使用IMonoObject方法GetFieldValueSetFieldValue以与属性相同的方式获取和设置字段的值。

在 C#中创建基本游戏模式

现在我们已经掌握了创建迷你游戏所需的基本知识,为什么不开始呢?首先,我们将致力于创建一个非常基本的用于生成演员和实体的系统。

定义我们的意图

首先,让我们明确我们想要做什么:

  1. 生成我们的演员。

  2. 将我们的演员分配给两个可能的团队之一。

  3. 检查当演员进入对方的Headquarters实体时,并结束它。

创建演员

我们需要做的第一件事是生成我们的演员,这在我们拥有演员之前是无法完成的。为此,我们需要在Game/Scripts目录中的某个地方创建一个MyActor.cs文件,然后添加以下代码:

  public class MyActor : Actor 
  {
  }

这段代码片段是注册演员所需的最低限度。

我们还应该更新我们演员的视图,以确保玩家进入游戏时能看到一些东西。

  protected override void UpdateView(ref ViewParams viewParams)
  {
    var fov = MathHelpers.DegreesToRadians(60);

    viewParams.FieldOfView = fov;
    viewParams.Position = Position;
    viewParams.Rotation = Rotation;
  }

上述代码将简单地将摄像机设置为使用玩家实体的位置和旋转,视野为 60。

注意

要了解更多关于创建演员和视图的信息,请参阅第五章,创建自定义演员

现在我们有了我们的演员,我们可以继续创建游戏模式:

  public class ReachTheHeadquarters : CryEngine.GameRules
  {
  }

Game/Scripts/目录中找到的所有 CryMono 类型一样,我们的游戏模式将在 CryENGINE 启动后不久自动注册,即在调用IGameFramework::Init之后。

在继续创建特定于游戏的逻辑之前,我们必须确保我们的角色在连接时被创建。为此,我们实现一个OnClientConnect方法:

  public bool OnClientConnect(int channelId, bool isReset = false,  string playerName = "Dude")
  {
    // Only the server can create actors.
    if (!Game.IsServer)
      return false;

    var actor = Actor.Create<MyActor>(channelId, playerName);
    if (actor == null)
    {
      Debug.LogWarning("Failed to create the player.");
      return false;
    }

    return true;
  }

然而,由于脚本函数不是自动化的,我们需要修改我们的IGameRules实现的OnClientConnect方法,以确保我们在 C#中接收到这个回调:

  bool CGameRules::OnClientConnect(int channelId, bool isReset)
  {
  const char *playerName;
  if (gEnv->bServer && gEnv->bMultiplayer)
  {
    if (INetChannel *pNetChannel = gEnv->pGameFramework->GetNetChannel(channelId))
      playerName = pNetChannel->GetNickname();
  }
    else
      playerName = "Dude";

  return m_pScript->CallMethod("OnClientConnect", channelId, isReset, playerName) != 0;
  }

现在,当新玩家连接到服务器时,我们的IGameRules实现将调用ReachTheHeadquarters.OnClientConnect,这将创建一个新的MyActor类型的角色。

注意

请记住,游戏模式的OnClientConnect在非常早期就被调用,就在新客户端连接到服务器时。如果在OnClientConnect退出后没有为指定的channelId创建角色,游戏将抛出致命错误。

生成角色

当客户端连接时,角色现在将被创建,但是如何将角色重新定位到一个SpawnPoint呢?首先,在Scripts目录中的某个地方创建一个新的SpawnPoint.cs文件:

  [Entity(Category = "Others", EditorHelper = "Editor/Objects/spawnpointhelper.cgf")]
  public class SpawnPoint : Entity
  {
    public void Spawn(EntityBase otherEntity)
    {
      otherEntity.Position = this.Position;
      otherEntity.Rotation = this.Rotation;
    }
}

在重新启动编辑器后,这个实体现在应该出现在RollupBar中。我们将调用spawnPoint.Spawn函数来生成我们的角色。

首先,我们需要打开我们的ReachTheHeadquarters类,并添加一个新的OnClientEnteredGame函数:

  public void OnClientEnteredGame(int channelId, EntityId playerId, bool reset)
  {
    var player = Actor.Get<MyActor>(channelId);
    if (player == null)
    {
      Debug.LogWarning("Failed to get player");
      return;
    }
    var random = new Random();

 // Get all spawned entities off type SpawnPoint
    var spawnPoints = Entity.GetByClass<SpawnPoint>();

// Get a random spawpoint
    var spawnPoint = spawnPoints.ElementAt(random.Next(spawnPoints.Count()));
    if(spawnPoint != null)
    {
     // Found one! Spawn the player here.
      spawnPoint.Spawn(player);
    }
  }

这个函数将在客户端进入游戏时被调用。在启动器模式下,这通常发生在玩家完成加载后,而在编辑器中,它是在玩家按下Ctrl + G后切换到纯游戏模式时调用的。

在当前状态下,我们首先会获取我们玩家的MyActor实例,然后在随机选择的SpawnPoint处生成。

注意

不要忘记从你的IGameRules实现中调用你的脚本的OnClientEnteredGame函数!

处理断开连接

我们还需要确保玩家断开连接时角色被移除:

  public override void OnClientDisconnect(int channelId)
  {
    Actor.Remove(channelId);
  }

不要忘记从你的IGameRules实现中调用OnClientConnect函数!

注意

在断开连接后未能移除玩家将导致角色在游戏世界中持续存在,并且由于相关玩家不再与服务器连接,可能会出现更严重的问题。

将玩家分配到一个队伍

现在玩家可以连接和生成了,让我们实现一个基本的队伍系统,以跟踪每个玩家所属的队伍。

首先,让我们向我们的游戏模式添加一个新的Teams属性:

  public virtual IEnumerable<string> Teams
  {
    get
    {
      return new string[] { "Red", "Blue" };
    }
  }

这段代码简单地确定了我们的游戏模式允许的队伍,即红队蓝队

现在,让我们还向我们的MyActor类添加一个新属性,以确定角色所属的队伍:

  public string Team { get; set; }

太好了!然而,我们还需要将相同的片段添加到SpawnPoint实体中,以避免生成相同队伍的玩家相邻。

完成这些操作后,打开ReachTheHeadquarters游戏模式类,并导航到我们之前创建的OnClientEnteredGame函数。我们要做的是扩展SpawnPoint选择,只使用属于玩家队伍的生成点。

看一下以下片段:

// Get all spawned entities of type SpawnPoint
  var spawnPoints = Entity.GetByClass<SpawnPoint>(); 

现在,用以下代码替换这个片段:

// Get all spawned entities of type SpawnPoint belonging to the players team
  var spawnPoints = Entity.GetByClass<SpawnPoint>().Where(x => x.Team == player.Team);

这将自动删除所有Team属性与玩家不相等的生成点。

但等等,我们还需要把玩家分配到一个队伍!为了做到这一点,在获取生成点之前添加以下内容:

  player.Team = Teams.ElementAt(random.Next(Teams.Count()));

当玩家进入游戏时,我们将随机选择一个队伍分配给他们。如果你愿意,为什么不扩展这一点,以确保队伍始终保持平衡?例如,如果红队比蓝队多两名玩家,就不允许新玩家加入红队。

注意

在继续之前,随意玩弄当前的设置。你应该能够在游戏中生成!

实现总部

最后,让我们继续创建我们的游戏结束条件;总部。简单来说,每个队伍都会有一个总部实体,当玩家进入对方队伍的总部时,该玩家的队伍就赢得了比赛。

添加游戏结束事件

在创建Headquarters实体之前,让我们在ReachTheHeadquarters类中添加一个新的EndGame函数:

  public void EndGame(string winningTeam)
  {
    Debug.LogAlways("{0} won the game!", winningTeam);
  }

我们将从Headquarters实体中调用此函数,以通知游戏模式游戏应该结束。

创建总部实体

现在,我们需要创建我们的Headquarters实体(请参阅以下代码片段)。该实体将通过 Sandbox 放置在每个级别中,每个队伍一次。我们将公开三个编辑器属性;TeamMinimumMaximum

  • Team:确定Headquarters实例属于哪个队伍,在我们的例子中是蓝队或红队

  • Minimum:指定触发区域的最小大小

  • Maximum:指定触发区域的最大大小

  public class Headquarters : Entity
  {
    public override void OnSpawn()
    {
      TriggerBounds = new BoundingBox(Minimum, Maximum);
    }

    protected override void OnEnterArea(EntityId entityId, int areaId, EntityId areaEntityId)
    {
    }

    [EditorProperty]
    public string Team { get; set; }

    [EditorProperty]
    public Vec3 Minimum { get; set; }

    [EditorProperty]
    public Vec3 Maximum { get; set; }
  }

太棒了!现在我们只需要扩展OnEnterArea方法,在游戏结束时通知我们的游戏模式:

  protected override void OnEnterArea(EntityId entityId, int areaId, EntityId areaEntityId)
  {
    var actor = Actor.Get<MyActor>(entityId);
    if (actor == null)
      return;

    if (actor.Team != Team)
    {
      var gameMode = CryEngine.GameRules.Current;
      var rthGameRules = gameMode as ReachTheHeadquarters;

      if (rthGameRules != null)
        rthGameRules.EndGame(actor.Team);
    }
  }

Headquarters实体现在将在对立队伍的实体进入时通知游戏模式。

绕道 - 触发器边界和实体区域

实体可以通过注册区域接收区域回调。这可以通过将实体链接到形状实体或手动创建触发器代理来完成。在 C#中,您可以通过设置EntityBase.TriggerBounds属性来手动创建代理,就像我们在之前的代码片段中所做的那样。

当实体位于或靠近该区域时,它将开始接收该实体上的事件。这允许创建特定实体,可以跟踪玩家何时以及何地进入特定区域,以触发专门的游戏逻辑。

请参阅以下表格,了解可通过 C++实体事件和 C# Entity类中的虚拟函数接收的可用区域回调列表:

回调名称 描述
当一个实体进入与该实体链接的区域时调用OnEnterArea
OnLeaveArea 当存在于与该实体链接的区域内的实体离开时触发
OnEnterNearArea 当实体靠近与该实体链接的区域时触发
OnMoveNearArea 当实体靠近与该实体链接的区域时调用
OnLeaveNearArea 当实体离开与该实体链接的附近区域时调用
OnMoveInsideArea 当实体重新定位到与该实体链接的区域内时触发

填充级别

基本示例现在已经完成,但需要一些调整才能使其正常工作!首先,我们需要创建一个新级别,并为每个队伍放置Headquarters

首先,打开 Sandbox 编辑器,并通过导航到文件 | 新建来创建一个新级别:

Populating the level

这将弹出新级别对话框,在其中我们可以设置级别名称和地形设置。

点击确定后,您的级别将被创建,然后加载。完成后,现在是时候开始向我们的级别添加必要的游戏元素了!

首先,打开RollupBar并通过将其拖入视口中生成Headquarters实体:

Populating the level

生成后,我们必须设置在Headquarters类中创建的编辑器属性。

Team设置为红色Maximum设置为10,10,10。这会告诉类Headquarters属于哪个队伍,并且我们将查询以检测另一个玩家是否进入了该区域的最大大小。

Populating the level

完成后,生成另一个Headquarters实体(或复制现有实体),然后按照相同的过程进行操作,只是这次将Team属性设置为蓝色

现在,我们只需要为每个队伍生成一个 SpawnPoint 实体,然后我们就可以开始了!再次打开RollupBar,然后转到其他 | SpawnPoint

Populating the level

现在,将实体拖放到视口中,以与生成Headquarters相同的方式生成它。生成后,将Team属性设置为红色,然后为蓝队重复该过程:

Populating the level

完成了!现在你应该能够使用 Ctrl + G 进入游戏,或者通过导航到 游戏 | 切换到游戏。然而,由于我们还没有添加任何类型的玩家移动,玩家将无法朝着敌方总部移动以结束游戏。

学习如何处理玩家输入和移动,请参考下一章,第五章,创建自定义角色

总结

在本章中,我们学习了游戏规则系统的基本行为,并创建了自己的IGameRules实现。

在注册了自己的游戏模式并在 C#中创建了Headquarters示例之后,你应该对游戏规则系统有了很好的理解。

我们已经创建了第一个游戏模式,现在可以继续下一章了。记住未来章节中游戏规则的目的,这样你就可以将需要在游戏中创建的所有游戏机制联系在一起。

对游戏规则还不满意?为什么不尝试在你选择的脚本语言中创建一个基本的游戏规则集,或者扩展我们之前创建的示例。在下一章中,我们将看到如何创建自定义角色。

第五章:创建自定义角色

使用 CryENGINE 角色系统,我们可以创建具有自定义行为的玩家或 AI 控制实体,以填充我们的游戏世界。

在本章中,我们将涵盖以下主题:

  • 了解角色的目的以及实现它们背后的核心思想

  • 在 C++和 C#中创建自定义角色

  • 创建我们的第一个玩家摄像头处理程序

  • 实现基本玩家移动

介绍角色系统

我们在第三章中学习了游戏对象扩展是什么,以及如何使用它们,创建和利用自定义实体。我们将在此基础上创建一个 C++和 C#中的自定义角色。

角色由IActor结构表示,它们是核心中的游戏对象扩展。这意味着每个角色都有一个支持实体和一个处理网络和IActor扩展的游戏对象。

角色由IActorSystem接口处理,该接口管理每个角色的创建、移除和注册。

通道标识符

在网络上下文中,每个玩家都被分配一个通道 ID 和 Net Nub 的索引,我们将在第八章中进一步介绍,多人游戏和网络

角色生成

玩家角色应在客户端连接到游戏时生成,在IGameRules::OnClientConnect中。要生成角色,请使用IActorSystem::CreateActor如下所示:

IActorSystem *pAS = gEnv->pGameFramework->GetIActorSystem();

pAS ->CreateActor(channelId, "MyPlayerName", "MyCppActor", Vec3(0, 0, 0), Quat(IDENTITY), Vec3(1, 1, 1));

注意

请注意,先前的代码仅适用于由玩家控制的角色。非玩家角色可以随时创建。

移除角色

为了确保在客户端断开连接时正确删除玩家角色,我们需要通过IGameRules::OnClientDisconnect回调手动删除它:

pActorSystem->RemoveActor(myActorEntityId);

在玩家断开连接后忘记移除玩家角色可能会导致崩溃或严重的伪影。

视图系统

为了满足处理玩家和其他摄像头来源的视图的需求,CryENGINE 提供了视图系统,可通过IViewSystem接口访问。

视图系统是围绕着任意数量的视图,由IView接口表示,每个视图都有更新位置、方向和配置(如视野)的能力。

注意

请记住,一次只能激活一个视图。

可以使用IViewSystem::CreateView方法创建新视图,如下所示:

IViewSystem *pViewSystem = gEnv->pGame->GetIGameFramework()->GetIViewSystem();

IView *pNewView = pViewSystem->CreateView();

然后,我们可以使用IViewSystem::SetActiveView函数设置活动视图:

pViewSystem_>SetActiveView(pNewView);

一旦激活,视图将在每一帧更新系统相机。要修改视图的参数,我们可以调用IView::SetCurrentParams。例如,要更改位置,请使用以下代码片段:

SViewParams viewParams = *GetCurrentParams();
viewParams.position = Vec3(0, 0, 10);
SetCurrentParams(viewParams);

当前视图的位置现在是(0,0,10)。

将视图链接到游戏对象

每个视图还可以将自己链接到游戏对象,允许其游戏对象扩展订阅UpdateViewPostUpdateView函数。

这些函数允许每帧更新的视图的位置、方向和配置很容易地更新。例如,这用于角色,以提供为每个玩家创建自定义相机处理的可访问方式。

有关相机操作的更多信息,请参见本章后面的相机操作部分。

创建自定义角色

现在我们知道角色系统是如何工作的,我们可以继续在 C#和 C++中创建我们的第一个角色。

注意

默认情况下,无法仅使用 Lua 脚本创建角色。通常,角色是在 C++中创建的,并处理自定义回调以包含在Game/Scripts/Entities/Actors文件夹中的 Lua 脚本。

在 C#中创建角色

使用 CryMono,我们可以完全在 C#中创建自定义角色。为此,我们可以从Actor类派生,如下所示:

public class MyActor : Actor
{
}

上面的代码是在 CryMono 中创建演员的最低要求。然后你可以转到你的游戏规则实现,并在客户端连接时通过Actor.Create静态方法生成演员。

CryMono 类层次结构

如果你对各种 CryMono/C#类感到困惑,请参阅以下继承图:

CryMono 类层次结构

注意

请注意,当使用Entity.Get(或通过Actor.Get查询演员)查询实体时,你将得到一个EntityBaseActorBase类型的对象。这是因为本地实体和演员存在于托管系统之外,当查询时返回了有限的表示。

同时使用本地和 CryMono 演员

如果你更喜欢在 C++中自己创建你的演员,你仍然可以通过使用NativeActor类在 CryMono 代码中引用它。为此,只需在 C#中创建一个新的类,名称与你注册的IActor实现相同,并从NativeActor派生,如下所示:

C++演员注册

演员注册是使用注册工厂完成的。这个过程可以使用REGISTER_FACTORY宏自动化,如下所示:

REGISTER_FACTORY(pFramework, "Player", CPlayer, false);

C#声明

在 C#中声明基于本地的演员非常简单,只需要从CryEngine.NativeActor类派生,如下所示:

public class Player : NativeActor
{
}

这允许 C#代码仍然可以使用,但保持大部分代码在你的 C++ IActor实现中。

注意

CryEngine.NativeActor直接派生自CryEngine.ActorBase,因此不包含常见的CryEngine.Actor回调,比如 OnEditorReset。要获得这个额外的功能,你需要在你的IActor实现中创建它。

在 C++中创建演员

要在 C++中创建一个演员,我们依赖于IActor接口。由于演员是核心中的游戏对象扩展,我们不能简单地从IActor派生,而是必须像下面的代码中所示使用CGameObjectExtensionHelper模板:

class CMyCppActor
  : public CGameObjectExtensionHelper<CMyCppActor, IActor>
{
};

注意

第三个CGameObjectExtensionHelper参数定义了这个游戏对象支持的最大 RMI(远程机器调用)数量。我们将在第八章中进一步介绍,多人游戏和网络

现在我们有了这个类,我们需要实现IActor结构中定义的纯虚方法。

注意

请注意,IActor派生自IGameObjectExtension,这意味着我们还需要实现它的纯虚方法。有关此信息,请参阅第四章中的实现游戏规则接口部分,游戏规则

对于大多数IActor方法,我们可以实现虚拟方法,要么返回空,要么返回虚拟值,比如 nullptr,零,或者空字符串。以下表格列出了例外情况:

函数名称 描述
IGameObjectExtension::Init 用于初始化游戏对象扩展。应该调用IGameObjectExtension::SetGameObjectIActorSystem::AddActor
类析构函数 应该始终调用IActorSystem::RemoveActor
IActor::IsPlayer 用于确定演员是否由人类玩家控制。我们可以简单地返回GetChannelId() != 0,因为通道标识符只对玩家非零。
IActor::GetActorClassName 用于获取演员类的名称,例如,在我们的情况下是CMyCppActor
IActor::GetEntityClassName 获取实体类的名称的辅助函数。我们可以简单地返回GetEntity()->GetClass()->GetName()

当你解决了纯虚函数后,继续下一节注册你的演员。完成后,你可以在IGameRules::OnClientConnect中为连接的玩家创建你的演员。

注册演员

要在游戏框架(包含在CryAction.dll中)中注册一个演员,我们可以使用与在GameFactory.cpp中注册 C++游戏规则实现时相同的设置:

REGISTER_FACTORY(pFramework, "MyCppActor", CMyCppActor, false);

在执行前面的代码之后,您将能够通过IActorSystem::CreateActor函数生成您的演员。

摄像机处理

玩家控制的演员在IActor::UpdateView(SViewParams &viewParams)IActor::PostUpdateView(SViewParams &viewParams)函数中管理视口摄像机。

SViewParams结构用于定义摄像机属性,如位置、旋转和视野。通过修改UpdateView方法中的viewParams引用,我们可以将摄像机移动到游戏所需的位置。

注意

CryMono 演员以与 C++演员相同的方式接收和处理UpdateView(ref ViewParams viewParams)PostUpdateView(ref ViewParams viewParams)事件。

实现 IGameObjectView

为了获得视图事件,我们需要实现并注册一个游戏对象视图。要做到这一点,首先从IGameObjectView派生,并实现它包括的以下两个纯虚函数:

  • UpdateView:用于更新视图位置、旋转和视野

  • PostUpdateView:在更新视图后调用

在实现游戏对象视图之后,我们需要确保在演员扩展初始化时捕获它(在 Init 中):

if(!GetGameObject()->CaptureView(this))
  return false;

您的演员现在应该接收视图更新回调,可以利用它来移动视口摄像机。不要忘记在析构函数中释放视图:

GetGameObject()->ReleaseView(this);

创建俯视摄像机

为了展示如何创建自定义摄像机,我们将扩展我们在上一章中创建的示例,添加一个自定义的俯视摄像机。简单来说,就是从上方查看角色,并从远处跟随其动作。

首先,打开您的 C#演员的UpdateView方法,或者在您的.cs源文件中实现它。

视图旋转

为了使视图朝向玩家的顶部,我们将使用玩家旋转的第二列来获取向上的方向。

注意

四元数以一种允许轻松插值和避免万向节锁的方式表示玩家的旋转。您可以获得代表每个四元数方向的三列:0(右)、1(前)、2(上)。例如,这非常有用,可以获得一个面向玩家前方的向量。

除非您自上次函数以来对演员的UpdateView函数进行了任何更改,否则它应该看起来与以下代码片段类似:

protected override void UpdateView(ref ViewParams viewParams)
{
  var fov = MathHelpers.DegreesToRadians(60);

  viewParams.FieldOfView = fov;
  viewParams.Position = Position;
  viewParams.Rotation = Rotation
}

这只是将视角摄像机放在与玩家完全相同的位置,具有相同的方向。我们需要做的第一个改变是将摄像机向上移动一点。

为此,我们将简单地将玩家旋转的第二列附加到其位置,并将摄像机放置在与玩家相同的 x 和 y 位置,但略高于玩家:

var playerRotation = Rotation;

float distanceFromPlayer = 5;
var upDir = playerRotation.Column2;

viewParams.Position = Position + upDir * distanceFromPlayer;

随时随地进入游戏并查看。当您准备好时,我们还必须将视图旋转为直接向下:

// Face straight down
var angles = new Vec3(MathHelpers.DegreesToRadians(-90), 0, 0);

//Convert to Quaternion
viewParams.Rotation = Quat.CreateRotationXYZ(angles);

完成!我们的摄像机现在应该正对着下方。

视图旋转

这大致是您应该看到的新摄像机。

请注意视图中缺少玩家角色。这是因为我们还没有将对象加载到玩家实体中。我们可以通过在OnSpawn函数中调用EntityBase.LoadObject来快速解决这个问题:

public override void OnSpawn()
{
  // Load object
  LoadObject("Objects/default/primitive_cube.cgf");

  // Physicalize to weigh 50KG
  var physicalizationParams = new PhysicalizationParams(PhysicalizationType.Rigid);
  physicalizationParams.mass = 50;
  Physicalize(physicalizationParams);
}

现在您应该能够在场景中看到代表玩家角色的立方体。请注意,它也是物理化的,允许它推动或被其他物理化的对象推动。

视图旋转

现在您应该对玩家视图功能有了基本的了解。要了解更多,为什么不尝试创建您自己的摄像机,即 RPG 风格的等距摄像机?

现在我们可以继续下一节,玩家输入

玩家输入

当您无法控制演员时,演员往往会变得相当无聊。为了将事件映射到输入,我们可以利用以下三个系统:

系统名称 描述
IHardwareMouse 当需要直接获取鼠标事件时使用,例如 x/y 屏幕位置和鼠标滚轮增量。
IActionMapManager 允许注册与按键绑定相关的回调。这是首选的键盘和鼠标按钮输入方法,因为它允许每个玩家通过他们的行动地图配置文件自定义他们喜欢的输入方式。行动地图通常通过游戏界面公开,以简化最终用户的按键映射。
IInput 用于监听原始输入事件,例如检测空格键何时被按下或释放。除了在聊天和文本输入等极少数情况下,不建议使用原始输入,而是使用行动地图更可取。

硬件鼠标

硬件鼠标实现提供了IHardwareMouseEventListener结构,允许接收鼠标事件回调。在派生并实现其纯虚函数后,使用IHardwareMouse::AddListener来使用它:

gEnv->pHardwareMouse->AddListener(this);

监听器通常在构造函数或初始化函数中调用。确保不要注册两次监听器,并始终在类析构函数中移除它们以防止悬空指针。

行动地图

在前面的表中简要提到,行动地图允许将按键绑定到命名动作。这用于允许从不同的游戏状态简单重新映射输入。例如,如果你有一个有两种类型车辆的游戏,你可能不希望相同的按键用于两种车辆。

行动地图还允许实时更改动作映射到的按键。这允许玩家自定义他们喜欢的输入方式。

监听行动地图事件

默认的行动地图配置文件包含在Game/Libs/Config/defaultProfile.xml中。游戏发布时,默认配置文件会被复制到用户的个人文件夹(通常在My Games/Game_Title),用户可以修改它来重新映射按键,例如更改触发截图动作的按键。

监听行动地图事件

要监听行动地图事件,我们首先要么在配置文件中创建一个新的动作,要么选择一个现有的动作并修改它。在这个例子中,我们将利用现有的截图动作。

IActionListener

行动地图系统提供了IActionListener结构来支持为需要行动地图事件的类提供回调函数。

利用监听器相对容易:

  1. 派生自IActorListener结构。

  2. 实现OnAction事件。

  3. 注册你的监听器:

gEnv->pGameFramework->GetIActionMapManager()->AddExtraActionListener(this);

监听器应该只注册一次,这就是为什么注册最好在构造函数或初始化函数中进行。

确保在类实例销毁时移除你的监听器。

启用行动地图部分

行动地图系统允许在同一个配置文件中创建多个行动地图部分,使游戏代码能够实时切换不同的行动地图部分。这对于具有多个玩家状态的游戏非常有用,比如行走和使用车辆。在这种情况下,车辆和行走行动地图将包含在不同的部分中,然后在退出或进入车辆时启用/禁用它们。

<actionmap name="walk" version="22">
  <action name="walkBack" onPress="1" keyboard="s" />
</actionmap>

<actionmap name="drive" version="22">
  <action name="break" onPress="1" keyboard="s" />
</actionmap>

要启用自定义的行动地图,调用IActionMapManager::EnableActionMap

gEnv->pFramework->GetIActionMapManager()->EnableActionMap("walk", true);

这应该在玩家应该能够接收这些新动作的确切时刻完成。在前面的例子中,当玩家退出车辆时启用“行走”动作。

动画角色

IAnimatedCharacter是一个游戏对象扩展,允许对象进行运动和物理整合。通过使用它,角色可以请求物理移动请求,利用动画图功能等。

由于该扩展是可选的,任何游戏对象都可以通过简单获取它来激活它,如第三章中所述,创建和利用自定义实体

m_pAnimatedCharacter = static_cast<IAnimatedCharacter*>(GetGameObject()->AcquireExtension("AnimatedCharacter"))

一旦获取,动画角色可以立即使用。

注意

动画角色功能,如移动请求,需要通过IGameObject::EnablePhysicsEvent启用 eEPE_OnPostStepImmediate 物理事件。

移动请求

当动画角色作为生物实体物理化时,可以请求移动。这本质上是 pe_action_move 物理请求的包装(有关更多信息,请参见第九章,“物理编程”)以允许更简单的使用。

处理高级机制,如玩家移动时,角色移动请求非常有用。

注意

请注意请求移动和直接设置玩家位置之间的区别。通过请求速度变化,我们能够使我们的实体自然地对碰撞做出反应。

添加移动请求

要添加移动请求,利用IAnimatedCharacter::AddMovement,需要一个SCharacterMoveRequest对象:

SCharacterMoveRequest request;

request.type = eCMT_Normal;
request.velocity = Vec3(1, 0, 0);
request.rotation = Quat(IDENTITY);

m_pAnimatedCharacter->AddMovement(request);

在上面的代码中看到的是一个非常基本的移动请求示例,它将目标设置为无限制地向前(世界空间)(如果连续提交)。

注意

移动请求必须通过物理循环添加,参见通过IGameObjectExtension::ProcessEvent发送的 ENTITY_EVENT_PREPHYSICSUPDATE。

模特动画系统

CryENGINE 3.5 引入了高级模特动画系统。该系统旨在解耦动画和游戏逻辑,有效地作为 CryAnimation 模块和游戏代码之间的附加层。

注意

请记住,模特可以应用于任何实体,而不仅仅是演员。但是,默认情况下,模特集成到IAnimatedCharacter扩展中,使演员更容易利用新的动画系统。

在开始使用之前,模特依赖一组类型,这些类型应该在开始使用之前清楚地理解:

名称 描述
片段 片段指的是一个状态,例如,“着陆”。每个片段可以在多个层上指定多个动画,以及一系列效果。这允许在同时处理第一人称和第三人称视图时,动画更加流畅。对于这个问题,每个片段将包含一个全身动画,一个第一人称动画,然后额外的声音,粒子和游戏事件。
片段 ID 为了避免直接传递片段,我们可以通过它们的片段 ID 来识别它们。
范围 范围允许解耦角色的部分,以便保持处理,例如,上半身和下半身动画分开。在创建新的范围时,每个片段将能够向该范围添加额外的动画和效果,以扩展其行为。对于 Crysis 3,第一人称和第三人称模式被声明为单独的范围,以允许相同的片段同时处理这两种状态。
标签 标签是指选择标准,允许根据活动的标签选择子片段。例如,如果我们有两个名为“空闲”的片段,但一个分配给“受伤”标签,我们可以根据玩家是否受伤动态地在两个片段变化之间切换。
选项 如果我们最终有多个共享相同标识和标签的片段,我们有多种选择。默认行为是在查询片段时随机选择其中一个,从而有效地创建实体动画的变化。

模特编辑器

模特编辑器用于通过沙盒编辑器实时调整角色动画和模特配置。

预览设置

模特编辑器使用存储在Animations/Mannequin/Preview中的预览文件,以加载默认模型和动画数据库。启动模特编辑器时,我们需要通过选择文件 | 加载预览设置来加载我们的预览设置。

加载后,我们将得到预览设置的可视表示,如下面的截图所示:

预览设置

我们预览文件的内容如下:

<MannequinPreview>
  <controllerDef filename="Animations/Mannequin/ADB/SNOWControllerDefinition.xml"/>
  <contexts>
    <contextData name="Char3P" enabled="1" database="Animations/Mannequin/ADB/Skiing.adb" context="Char3P" model="scripts/config/base.cdf"/>
  </contexts>
  <History StartTime="-4.3160208e+008" EndTime="-4.3160208e+008"/>
</MannequinPreview>

我们将在本章后面进一步介绍控制器定义、上下文数据等详细信息。

创建上下文

如本章前面提到的,上下文可用于根据角色状态应用不同的动画和效果。

我们可以通过选择文件 | 上下文编辑器人体模型编辑器中访问上下文编辑器,来创建和修改上下文。

创建上下文

要创建新上下文,只需单击左上角的新建,将打开新上下文对话框,如下屏幕截图所示:

创建上下文

这使我们能够在创建之前调整上下文,包括选择要使用的动画数据库和模型。

完成后,只需单击确定即可查看您创建的上下文。

创建片段

默认情况下,我们可以在人体模型编辑器的左上部看到片段工具箱。这个工具是我们将用来创建和编辑片段的工具,还可以添加或编辑选项。

创建片段

在上一个屏幕截图中,可以看到片段工具箱中打开了BackFlip片段,显示了两个选项。

要创建新片段,请单击新 ID…按钮,在新打开的消息框中输入所需的名称,然后单击确定

现在您应该在人体模型片段 ID 编辑器对话框中看到如下屏幕截图所示:

创建片段

现在我们将能够选择该片段应在哪些范围内运行。在我们的情况下,我们只需要检查Char3P并单击确定

现在您应该能够在片段工具箱中看到您的片段:

创建片段

添加选项

有两种方法可以向片段添加新选项:

  • 打开角色编辑器,选择您的动画,然后将其拖放到人体模型片段上。

  • 在片段工具箱中单击新建按钮,然后手动修改选项。

创建和使用标签

如前所述,人体模型系统允许创建标签,允许根据标签当前是否激活来选择每个片段的特定选项。

要创建新标签,请打开人体模型编辑器,然后选择文件 -> 标签定义编辑器

创建和使用标签

一旦打开,您将看到人体模型标签定义编辑器。编辑器为您提供了两个部分:标签定义标签

我们需要做的第一件事是创建一个标签定义。这是一个跟踪一组标签的文件。要这样做,请在标签定义部分按加号(+)符号,然后指定您的定义的名称。

创建和使用标签

太棒了!现在您应该在人体模型标签定义编辑器中看到您的标签定义。要创建新标签,请选择MyTags.xml,然后单击标签创建图标(在标签部分的第三个)。

这将为您呈现一个标签创建对话框,在其中您只需要指定您的标签的名称。完成后,单击确定,您应该立即在标签部分看到该标签(如下屏幕截图所示):

创建和使用标签

向选项附加标签

现在您已经创建了自定义标签,我们可以在片段编辑器中选择任何片段选项,然后向下查找标签工具箱:

向选项附加标签

只需在选择片段选项时简单地选中每个标签旁边的复选框,我们就告诉动画系统在指定标签激活时应优先考虑该选项。

保存

要保存你的Mannequin Editor更改,只需点击文件 | 保存更改,并在出现的Mannequin 文件管理器对话框中验证你的更改(如下截图所示):

Saving

当你准备好保存时,只需点击保存,系统将更新文件。

开始片段

在 C++中,片段由IAction接口表示,可以由每个游戏自由实现或扩展。

通过调用IActionController::Queue函数来排队一个片段,但在这之前,我们必须获取我们片段的FragmentId

获取片段标识符

要获取片段标识符,我们需要获取当前的动画上下文,以便从中获取当前的控制器定义,从中获取片段 ID:

SAnimationContext *pAnimContext = GetAnimatedCharacter()->GetAnimationContext();

FragmentID fragmentId = pAnimContext->controllerDef.m_fragmentIDs.Find(name);
CRY_ASSERT(fragmentId != FRAGMENT_ID_INVALID);

注意我们如何调用IAnimatedCharacter::GetAnimationContext。正如本章前面提到的,动画角色扩展为我们实现了 Mannequin 功能。

排队片段

现在我们有了片段标识符,我们可以简单地创建我们选择使用的动作的新实例。在我们的情况下,我们将使用通过TAction模板公开的默认 Mannequin 动作:

int priority = 0;
IActionPtr pAction = new TAction<SAnimationContext>(priority, id);

现在我们有了优先级为 0 的动作。动画系统将比较排队动作的优先级,以确定应该使用哪个。例如,如果两个动作同时排队,一个优先级为 0,另一个优先级为 1,那么优先级为 1 的第二个动作将首先被选择。

现在要排队动作,只需调用IActionController::Queue

IActionController *pActionController = GetAnimatedCharacter()->GetActionController();

pActionController->Queue(pAction);

设置标签

要在运行时启用标签,我们首先需要获取我们标签的标识符,如下所示:

SAnimationContext *pAnimationContext = pActionController->GetContext();

TagID tagId = pAnimationContext->state.GetDef().Find(name);
CRY_ASSERT(tagId != TAG_ID_INVALID);

现在我们只需要调用CTagState::Set

SAnimationContext *pAnimContext = pActionController->GetContext();

bool enable = true;
pAnimContext->state.Set(tagId, enable);

完成!我们的标签现在已激活,并将在动画系统中显示为活动状态。如果你的动作设置为动态更新,它将立即选择适当的选项。

强制动作重新查询选项

默认的IAction实现在更改标签时不会自动选择相关选项。要更改这一点,我们需要创建一个从中派生的新类,并用以下代码覆盖其Update函数:

IAction::EStatus CUpdatedAction::Update(float timePassedSeconds)
{
  TBase::Update(timePassedSeconds);

  const IScope &rootScope = GetRootScope();
  if(rootScope.IsDifferent(m_fragmentID, m_fragTags))
  {
    SetFragment(m_fragmentID, m_fragTags);
  }

  return m_eStatus;
}

之前的代码所做的是检查是否有更好的选项可用,并选择那个选项。

调试 Mannequin

要启用 Mannequin 调试,我们需要向动作控制器附加AC_DebugDraw标志:

pActionController->SetFlag(AC_DebugDraw, g_pGameCVars->pl_debugMannequin != 0);

现在你将看到可视片段和标签选择调试信息。在使用 Mannequin 时非常有用。

为自定义实体设置 Mannequin

正如本章前面提到的,动画角色游戏对象扩展默认集成了 Mannequin。在使用演员时非常方便,但在某些情况下,可能需要在自定义实体上使用 Mannequin 提供的功能。

首先,我们需要在实体扩展中存储指向我们的动作控制器和动画上下文的指针,如下所示:

IActionController *m_pActionController;
SAnimationContext *m_pAnimationContext;

然后,我们需要初始化 Mannequin;这通常在游戏对象扩展的PostInit函数中完成。

初始化 Mannequin

首先要做的是获取 Mannequin 接口:

// Mannequin Initialization
IMannequin &mannequinInterface = gEnv->pGame->GetIGameFramework()->GetMannequinInterface();
IAnimationDatabaseManager &animationDBManager = mannequinInterface.GetAnimationDatabaseManager();

加载控制器定义

接下来,我们需要加载为我们实体创建的控制器定义:

const SControllerDef *pControllerDef = animationDBManager.LoadControllerDef("Animations/Mannequin/ADB/myControllerDefinition.xml");

太棒了!现在我们有了控制器定义,可以用以下代码创建我们的动画上下文:

m_pAnimationContext = new SAnimationContext(*pControllerDef);

现在我们可以创建我们的动作控制器:

m_pActionController = mannequinInterface.CreateActionController(pEntity, *m_pAnimationContext);

设置活动上下文

现在我们已经初始化了我们的动作控制器,我们需要设置默认的上下文。

首先,获取上下文标识符:

const TagID mainContextId = m_pAnimationContext->controllerDef.m_scopeContexts.Find("Char3P");

CRY_ASSERT(mainContextId != TAG_ID_INVALID);

然后加载我们将要使用的动画数据库:

const IAnimationDatabase *pAnimationDatabase = animationDBManager.Load("Animations/Mannequin/ADB/myAnimDB.adb");

加载后,只需调用IActionController::SetScopeContext

m_pActionController->SetScopeContext(mainContextId, *pEntity, pCharacterInstance, pAnimationDatabase);

一旦上下文设置好,Mannequin 就初始化好了,可以处理你实体的排队片段。

记住,你可以随时使用之前使用过的IActionController::SetScopeContext函数来改变作用域上下文。

摘要

在这一章中,我们学习了演员系统的功能,并在 C#和 C ++中创建了自定义演员。通过查看输入和摄像头系统,我们将能够处理基本的玩家输入和视图设置。

您还应该对 Mannequin 的用例有很好的理解,并知道如何设置自定义实体来利用它们。

现在,我们已经拥有了游戏所需的所有核心功能:流节点、实体、游戏规则和演员。在接下来的章节中,我们将在现有知识的基础上进行扩展,并详细介绍这些系统如何一起使用。

如果您想在继续之前继续研究演员,请随时尝试并实现自己定制的演员,以适应新的情景;例如,配备基本 RPG 玩家元素的等距摄像头。

在下一章中,我们将利用在演员身上学到的知识来创建人工智能AI)。

第六章:人工智能

CryENGINE AI 系统允许创建在游戏世界中漫游的非玩家控制角色。

在本章中我们将:

  • 了解 AI 系统如何与 Lua 脚本集成

  • 了解目标管道是什么,以及如何创建它们

  • 使用 AI 信号

  • 注册自定义 AIActor

  • 学习如何使用行为选择树

  • 创建我们自己的 AI 行为

人工智能(AI)系统

CryENGINE AI 系统的设计是为了方便创建灵活到足以处理更大量的复杂和不同世界的自定义 AI 角色。

在我们开始研究 AI 系统的本地实现之前,我们必须提到一个非常重要的事实:AI 不同于角色,不应该混淆。

在 CryENGINE 中,AI 仍然依赖于底层的角色实现,通常与玩家使用的完全相同。然而,AI 本身的实现是通过 AI 系统单独完成的,该系统将移动输入等发送给角色。

脚本

CryENGINE 的 AI 系统的主要思想是基于大量的脚本编写。可以使用Scripts/AIScripts/Entities/AI目录中包含的 Lua 脚本来创建新的 AI 行为,而不是强迫程序员修改复杂的 CryAISystem 模块。

注意

AI 系统目前主要是硬编码为使用.lua脚本,因此我们将无法在 AI 开发中更大程度地使用 C#和 C++。

AI 角色

正如我们之前提到的,角色与 AI 本身是分开的。基本上这意味着我们需要创建一个IActor实现,然后指定角色应该使用哪种 AI 行为。

如果您的 AI 角色应该与您的玩家行为大致相同,您应该重用角色实现。

如前一章所述,注册一个角色可以通过REGISTER_FACTORY宏来完成。AI 角色的唯一区别是最后一个参数应该设置为 true 而不是 false:

  REGISTER_FACTORY(pFramework, "MyAIActor", CMyAIActor, true);

一旦注册,AI 系统将在Scripts/Entities/AI中搜索以您的实体命名的 Lua 脚本。在前面的片段中,系统将尝试加载Scripts/Entities/AI/MyAIActor.lua

这个脚本应该包含一个同名的表,并且与其他 Lua 实体的功能相同。例如,要添加编辑器属性,只需在 Properties 子表中添加变量。

目标管道

目标管道定义了一组目标操作,允许在运行时触发一组目标。例如,一个目标管道可以包括 AI,增加其移动速度,同时开始搜索玩家控制的单位。

目标操作,如 LookAt,Locate 和 Hide 是在CryAISystem.dll中创建的,不能在没有访问其源代码的情况下进行修改。

创建自定义管道

管道最初是在PipeManager:CreateGoalPipes函数中在Scripts/AI/GoalPipes/PipeManager.lua中注册的,使用AI.LoadGoalPipes函数:

  AI.LoadGoalPipes("Scripts/AI/GoalPipes/MyGoalPipes.xml");

这段代码将加载Scripts/AI/GoalPipes/MyGoalPipes.xml,其中可能包含以下目标管道定义:

<GoalPipes>
  <GoalPipe name="myGoalPipes_findPlayer">
    <Locate name="player" />
    <Speed id="Run"/>
    <Script code="entity.Behavior:AnalyzeSituation(entity);"
  </GoalPipe>
</GoalPipes>

当选择了这个管道时,分配的 AI 将开始定位玩家,切换到Run移动速度状态,并调用当前选定的行为脚本中包含的AnalyzeSituation函数。

目标管道可以非常有效地推动一组目标,例如基于前面的脚本,我们可以简单地选择myGoalPipes_findPlayer管道,以便 AI 寻找玩家。

选择管道

目标管道通常使用 Lua 中的实体函数SelectPipe来触发:

  myEntity:SelectPipe(0, "myGoalPipe");

或者也可以通过 C++触发,使用IPipeUser::SelectPipe函数。

信号

为了为 AI 实体提供直观的相互通信方式,我们可以使用信号系统。信号是可以从另一个 AI 实体或从 C++或 Lua 代码的其他地方发送到特定 AI 单元的事件。

信号可以使用 Lua 中的AI.Signal函数或 C++中的IAISystem::SendSignal发送。

AI 行为

每个角色都需要分配行为,并且它们定义了单位的决策能力。通过在运行时使用行为选择树选择行为,角色可以给人一种动态调整到周围环境的印象。

使用放置在Scripts/AI/SelectionTrees中的 XML 文件创建行为选择树。每个树管理一组行为叶子,每个叶子代表一种可以根据条件启用的 AI 行为类型。

AI 行为

样本

例如,如下所示,查看选择树 XML 定义的非常基本形式:

<SelectionTrees>
  <SelectionTree name="SelectionTreeSample" type="BehaviorSelectionTree">
    <Variables>
      <Variable name="IsEnemyClose"/>
    </Variables>
    <SignalVariables>
      <Signal name="OnEnemySeen" variable="IsEnemyClose" value="true"/>
      <Signal name="OnNoTarget" variable="IsEnemyClose" value="false"/>
      <Signal name="OnLostSightOfTarget" variable="IsEnemyClose" value="false"/>
    </SignalVariables>
    <LeafTranslations />
    <Priority name="Root">
      <Leaf name="BehaviorSampleCombat" condition="IsEnemyClose"/>
      <Leaf name="BehaviorSampleIdle"/>
    </Priority>
  </SelectionTree>
</SelectionTrees>

为了更好地理解示例,我们将对其进行一些分解:

  <SelectionTree name="SelectionTreeSample" type="BehaviorSelectionTree">

这个第一个片段只是定义了选择树的名称,并且在 AI 初始化期间将被 AI 系统解析。如果要重命名树,只需更改name属性:

<Variables>
  <Variable name="IsEnemyClose"/>
</Variables>

每个选择树可以定义一组变量,这些变量可以根据信号(请参见下一个片段)或在每个行为脚本内部进行设置。

变量只是可以查询的布尔条件,以确定下一个叶子或行为选择:

<SignalVariables>
  <Signal name="OnEnemySeen" variable="IsEnemyClose" value="true"/>
  <Signal name="OnNoTarget" variable="IsEnemyClose" value="false"/>
  <Signal name="OnLostSightOfTarget" variable="IsEnemyClose" value="false"/>
</SignalVariables>

每个行为树还可以监听诸如OnEnemySeen之类的信号,以便轻松设置变量的值。例如,在我们刚刚看到的片段中,当发现敌人时,IsEnemyClose变量将始终设置为 true,然后在目标丢失时设置为 false。

然后我们可以在查询新叶子时使用变量(请参见下面的代码片段),允许 AI 根据简单的信号事件切换到不同的行为脚本:

<Priority name="Root">
  <Leaf name="BehaviorSampleCombat" condition="IsEnemyClose"/>
  <Leaf name="BehaviorSampleIdle"/>
</Priority>

通过在Priority元素内指定叶子,我们可以根据简单的条件在运行时启用行为(叶子)。

例如,前面的片段将在敌人接近时启用BehaviorSampleCombat行为脚本,否则将退回到BehaviorSampleIdle行为。

注意

行为选择树系统将按顺序查询叶子,并退回到最后剩下的叶子。在这种情况下,它将首先查询BehaviorSampleCombat,然后在IsEnemyClose变量设置为 false 时退回到BehaviorSampleIdle

IAIObject

已向 AI 系统注册的实体可以调用IEntity::GetAI来获取它们的IAIObject指针。

通过访问实体的 AI 对象指针,我们可以在运行时操纵 AI,例如设置自定义信号,然后在我们的 AI 行为脚本中拦截:

if(IAIObject *pAI = pEntity->GetAI())
{
  gEnv->pAISystem->SendSignal(SIGNALFILTER_SENDER, 0, "OnMySignal", pAI);
}

创建自定义 AI

创建自定义 AI 的过程相对简单,特别是如果您对上一章介绍的角色系统感到满意。

每个角色都有两个部分;它的IActor实现和 AI 实体定义。

注册 AI 角色实现

AI 角色通常使用与玩家相同的IActor实现,或者至少是共享的派生。

在 C#中

在 C#中注册 AI 角色与我们在第五章中所做的非常相似,创建自定义角色。基本上,我们只需要从CryEngine.AIActor派生,而不是CryEngine.Actor

AIActor类直接从Actor派生,因此不会牺牲任何回调和成员。但是,必须明确实现它,以使 CryENGINE 将此角色视为由 AI 控制。

public class MyCSharpAIActor
: CryEngine.AIActor
{
}

现在,您应该能够在 Sandbox 中的Entity浏览器中的AI类别中放置您的实体:

在 C#中

在 C++中

与我们刚刚看到的 C#角色一样,注册一个角色到 AI 系统并不需要太多工作。只需从我们在上一章创建的角色实现派生即可:

class CMyAIActor
  : public CMyCppActor
{
};

然后打开你的 GameDLL 的GameFactory.cpp文件,并使用相同的设置来注册角色,只是最后一个参数应该是 true,告诉 CryENGINE 这种角色类型将由 AI 控制:

  REGISTER_FACTORY(pFramework, "MyAIActor", CMyAIActor, true);

在重新编译后,你的角色现在应该出现在实体浏览器中的AI实体类别中。

创建 AI 实体定义

当我们的 AI 角色生成时,AI 系统将搜索 AI 实体定义。这些定义用于设置角色的默认属性,例如其编辑器属性。

我们需要做的第一件事是打开Scripts/Entities/AI并创建一个与我们的Actor类同名的新的.lua文件。在我们的情况下,这将是为了刚刚创建的 C++实现的MyAIActor.lua,以及为了 C#角色的MyCSharpAIActor.lua

脚本保持了最少量的代码,因为我们只需要加载基本 AI。基本 AI 是使用Script.ReloadScript函数加载的。

默认情况下,CryENGINE 使用Scripts/Entities/AI/Shared/BasicAI.lua作为基本 AI 定义。我们将使用自定义实现,Scripts/Entities/AI/AISample_x.lua,以减少与本章节无关的不必要代码:

  Script.ReloadScript( "SCRIPTS/Entities/AI/AISample_x.lua");
--------------------------------------------------------------

  MyCSharpAIActor = CreateAI(AISample_x);

就是这样!你的 AI 现在已经正确注册,现在应该可以通过编辑器放置。

注意

有关基本 AI 定义的更多信息,请参见本章后面的AI 基本定义分解部分。

AI 行为和角色

当我们生成自定义 AI 角色时,默认情况下应该出现四个实体属性。这些属性确定 AI 应该使用哪些系统进行决策:

AI 行为和角色

理解和使用行为选择树

行为选择树是我们的 AI 角色最重要的实体属性,因为它确定了角色使用哪个行为选择树。如果我们的项目包含多个行为选择树,我们可以轻松生成行为非常不同的多个 AI 角色,因为它们使用了不同的选择树。选择树系统存在是为了提供一种在运行时查询和选择行为脚本的方法。

要查看当前可用的树,或创建自己的树,请导航至Scripts/AI/SelectionTrees。对于我们的示例,我们将使用Scripts/AI/SelectionTrees/FogOfWar.xml中的FogOfWar选择树:

<SelectionTree name="FogOfWar" type="BehaviorSelectionTree">
  <Variables>
    <Variable name="IsFar"/>
    <Variable name="IsClose"/>
    <Variable name="AwareOfPlayer"/>
  </Variables>
  <SignalVariables>
    <Signal name="OnEnemySeen" variable="AwareOfPlayer" value="true"/>
    <Signal name="OnNoTarget" variable="AwareOfPlayer" value="false"/>
    <Signal name="OnLostSightOfTarget" variable="AwareOfPlayer" value="false"/>
  </SignalVariables>
  <LeafTranslations />
  <Priority name="Root">
    <Leaf name="FogOfWarSeekST" condition="IsFar"/>
    <Leaf name="FogOfWarEscapeST" condition="IsClose"/>
    <Leaf name="FogOfWarAttackST" condition="AwareOfPlayer"/>
    <Leaf name="FogOfWarIdleST"/>
  </Priority>
</SelectionTree>

变量

每个选择树都公开一组可以在运行时设置的变量。叶子将查询这些变量,以确定激活哪种行为。

信号变量

信号变量提供了一种在接收到信号时设置变量的简单方法。

例如,在前面的树中,我们可以看到当接收到OnEnemySeen信号时,AwareOfPlayer会动态设置。然后当 AI 失去对玩家的追踪时,这些变量将被设置为 false。

叶子/行为查询

叶子确定根据变量条件播放哪种行为。

在前面的树中,我们可以看到当所有其他条件都设置为 false 时,默认情况下会激活FogOfWarIdleST行为。但是,假设IsFar变量设置为 true,系统将自动切换到FogOfWarSeekST行为。

注意

行为从Scripts/AI/Behaviors/Personalities/目录中加载,在我们的情况下,它将在Scripts/AI/Behaviors/Personalities/FogOfWarST/中找到参考行为。

角色

Character属性用于设置角色的 AI 角色。

注意

在我们的示例中,Character属性将默认为空字符串,因为自从引入行为选择树以来,该系统被视为已弃用(请查看理解和使用行为选择树部分)。

AI 角色包含在Scripts/AI/Characters/Personalities中,以.lua脚本的形式。例如,我们可以打开并修改Scripts/AI/Characters/Personalities/FogOfWar.lua以修改我们的默认个性。

您还可以通过在Personalities目录中添加新文件,以FogOfWar作为基线,来创建新的个性。

Character属性定义了所有适用的行为,在我们的例子中是FogOfWarAttackFogOfWarSeekFogOfWarEscapeFogOfWarIdle。角色将能够在运行时根据内部和外部条件在这些行为之间切换。

导航类型

NavigationType属性确定要使用哪种类型的 AI 导航。这允许系统动态确定哪些路径适用于该类型的 AI。

在我们的示例中,默认为 MediumSizedCharacter,并且可以设置为包含在Scripts/AI/Navigation.xml中的任何导航定义。

创建自定义行为

我们快要完成了!唯一剩下的步骤是理解如何创建和修改 AI 行为,使用我们之前描述的行为选择树来激活。

首先,使用您选择的文本编辑器打开Scripts/AI/Behaviors/Personalities/FogOfWarST/FogOfWarIdleST.lua。由于之前描述的行为树设置,这是在所有其他变量都设置为 false 时将被激活的行为。

通过调用CreateAIBehavior函数来创建行为,第一个参数设置为新行为的名称,第二个包含行为本身的表。

因此,行为的最低要求是:

local Behavior = CreateAIBehavior("MyBehavior",
{
  Alertness = 0,

  Constructor = function (self, entity)
  end,

  Destructor = function(self, entity)
  end,
})

这段代码片段会始终将 AI 的Alertness设置为 0,并且在行为开始(Constructor)和结束(Destructor)时什么也不做。

通过查看FogOfWarIdleST行为定义,我们可以看到它的作用:

  Constructor = function (self, entity)
    Log("Idling...");
    AI.SetBehaviorVariable(entity.id, "AwareOfPlayer", false);
    entity:SelectPipe(0,"fow_idle_st");
  end,

当激活行为时,我们应该在控制台中看到“Idling…”,假设日志详细程度足够高(使用log_verbosity CVar设置)。

在记录之后,该行为将通过AI.SetBehaviorVariable函数将AwareOfPlayer变量重置为 false。我们可以随时使用该函数来改变变量的值,有效地告诉行为选择树应该查询另一个行为。

将变量设置为 false 后,构造函数会选择fow_idle_st目标管道。

监听信号

要在行为中监听信号,只需创建一个新函数:

OnMySignal = function(self, entity, sender)
{
}

当发送OnMySignal信号时,将调用此函数,并附带相关的实体和行为表。

AI 基本定义分解

在本章中,我们之前创建了依赖于Scripts/Entities/AI/AISample_x.lua基本定义的自定义 AI 定义。本节将描述基本定义的作用,以便更好地理解定义设置。

首先,使用您选择的文本编辑器(例如 Notepad++)打开定义。

AISample_x 表

当打开AISample_x.lua时,我们将看到的第一行代码是其表定义,它定义了每个角色的默认属性。

注意

每个 AI 定义都可以覆盖基本定义中设置的属性。

属性表

属性表的工作方式与标准 Lua 实体相同,用于定义在编辑器中选择实体时出现的属性。

注意

我们基本 AI 定义中的默认属性是从CryAISystem.dll中读取的。不支持删除这些属性,否则会导致 AI 初始化失败。

AIMovementAbility 表

AIMovementAbility子表定义了我们角色的移动能力,例如行走和奔跑速度。

CreateAI 函数

CreateAI函数将基本 AI 表与指定子表合并。这意味着在 AI 基本定义中存在的任何表都将存在于从中派生的任何 AI 定义中。

CreateAI函数还使实体可生成,并通过调用 AI 的Expose()函数将其暴露给网络。

RegisterAI 函数

RegisterAI函数在应该将角色注册到 AI 系统时调用。这在实体生成时和编辑器属性更改时会自动调用。

总结

在本章中,我们已经了解了 AI 系统的核心思想和实现,并创建了自定义的 AI 角色实现。

通过创建我们自己的 AI 实体定义和行为选择树,您应该了解到在 CryENGINE 中如何创建 AI 角色。

现在,您应该对如何利用 AI 系统有了很好的理解,从而可以创建巡逻游戏世界的 AI 控制单位。

如果您对 AI 还没有完全掌握,为什么不尝试利用您新获得的知识来创建自己选择的更复杂的东西呢?

在下一章中,我们将介绍创建自定义用户界面的过程,允许创建主菜单和抬头显示HUD)。

第七章:用户界面

CryENGINE 集成了 Scaleform GFx,允许呈现基于 Adobe Flash 的用户界面、HUD 和动画纹理。通过在运行时使用 UI 流程图解决方案将 UI 元素直观地连接在一起,开发人员可以迅速创建和扩展用户界面。

在本章中,我们将涵盖以下主题:

  • 了解 CryENGINE Scaleform 实现及其带来的好处。

  • 创建我们的主菜单。

  • 实施 UI 游戏事件系统

Flash 电影剪辑和 UI 图形

为了为开发人员提供创建用户界面的解决方案,CryENGINE 集成了 Adobe Scaleform GFx,这是一个用于游戏引擎的实时 Flash 渲染器。该系统允许在 Adobe Flash 中创建用户界面,然后可以导出以立即在引擎中使用。

注意

还可以在材质中使用 Flash .swf文件,从而在游戏世界中的 3D 对象上呈现 Flash 电影剪辑。

通过 UI 流程图系统,创建模块化动态用户界面所需的工作大大简化。

UI 流程图系统基于两种类型的概念:元素动作。每个元素代表一个 Flash 文件(.swf.gfx),而每个动作是一个表示 UI 状态的流程图。

元素

UI 元素通过Game/Libs/UI/UIElements/中的 XML 文件进行配置,并表示每个 Flash 文件。通过修改 UI 元素的配置,我们可以更改它接收的事件和对齐模式,以及公开导出的 SWF 文件中存在的不同函数和回调。

XML 分解

元素的最低要求可以在以下代码中看到:

<UIElements name="Menus">
  <UIElement name="MyMainMenu" mouseevents="1" keyevents="1" cursor="1" controller_input="1">

    <GFx file="Menus_Startmenu.swf" layer="3">
      <Constraints>
        <Align mode="fullscreen" scale="1"/>
      </Constraints>
    </GFx>

    <functions>
    </functions>

    <events>
    </events>
    <Arrays>
    </Arrays>

    <MovieClips>
    </MovieClips>
  </UIElement>
</UIElements>

前面的 XML 代码可以保存为Game/Libs/UI/UIElements/MyMainMenu.xml,并将 Flash 文件Menus_Startmenu.swf加载到Game/Libs/UI/文件夹中。

创建完成后,我们将能够通过流程图节点选择我们的新 UI 元素,例如UI:Display:Config(用于重新配置任何元素,例如在运行时启用元素的鼠标事件)。

XML 分解

既然我们知道它是如何工作的,让我们来详细了解一下:

<UIElements name="Menus">

这第一个元素定义了文件的开始,并确定了我们的元素应该放在哪个类别中。

<UIElement name="MyMainMenu" mouseevents="1" keyevents="1" cursor="1" controller_input="1">

UIElement XML 元素用于决定初始配置,包括默认名称,并确定默认应接收哪些事件。

如前所述,每个元素都可以通过一组属性进行配置,允许开发人员定义要监听的事件类型:

属性名称 描述
name 定义元素的名称(字符串)。
mouseevents 确定是否将鼠标事件发送到 Flash 文件(0/1)。
cursor 确定在元素可见时是否显示光标(0/1)。
keyevents 确定是否将键事件发送到 Flash 文件(0/1)。
console_mouse 确定在控制台硬件上拇指杆是否应该作为光标(0/1)。
console_cursor 确定在控制台硬件上运行时是否显示光标(0/1)。
layer 定义元素显示顺序,以防多个元素存在。
alpha 设置元素的背景透明度(0-1)。允许在游戏中使用透明度,例如在主菜单后显示游戏关卡。

注意

请注意,先前提到的属性可以通过使用UI:Display:Config节点实时调整。

<GFx file="Menus_Startmenu.swf" layer="3">

GFx元素确定应加载哪个 Flash 文件用于该元素。可以加载多个 GFx 文件并将它们放入不同的层。

这允许在运行时选择要使用的元素层,例如,通过UI:Display:Config节点上的layer输入,如前面截图所示。

<Constraints>
  <Align mode="fullscreen" scale="1"/>
</Constraints>

Constraints允许配置 GFx 元素在屏幕上的显示方式,使开发人员能够调整元素在不同显示分辨率下的表现方式。

目前有三种模式如下:

模式名称 描述 附加属性
固定 在固定模式下,开发人员可以使用四个属性来设置距离顶部和左侧角的像素距离,以及设置所需的分辨率。 顶部、左侧、宽度和高度
动态 在动态模式下,元素根据锚点对齐,允许水平和垂直对齐。halign 可以设置为leftcenterright,而 valign 可以设置为topcenterbottom。如果比例设置为1,元素将按比例缩放到屏幕分辨率。如果最大设置为1,元素将被最大化,以确保覆盖屏幕的 100%。 halign、valign、比例和最大
全屏 在此模式下激活时,元素视口将与渲染视口完全相同。如果比例设置为1,元素将被拉伸到屏幕分辨率。 比例

动作

UI 动作是 UI 流程图实现的核心。每个动作都由一个流程图表示,并定义了一个 UI 状态。例如,主菜单中的每个屏幕都将使用单独的动作来处理。

所有可用的 UI 动作都可以在流程图工具箱中看到,在流程图编辑器中。

动作

要创建新的 UI 动作,导航到文件 | 新 UI 动作,并在新打开的另存为对话框中指定您的新动作的名称:

动作

通过使用UI:Action:Control节点启动动作,并在UIAction输入端口中指定待处理动作的名称,然后激活Start输入来启动动作。

动作

一旦启动,具有指定名称的 UI 图将被激活,假设它包含一个如下所示的UI:Action:Start节点:

动作

然后,图表可以通过监听StartAction输出端口来初始化请求的 UI。一旦动作完成,应该调用UI:Action:End,如下所示:

动作

就是这样。UI 图表以 flowgraph XML 文件的形式保存在Game/Libs/UI/UIActions/中。初始 UI 动作称为Sys_StateControl,并且始终处于活动状态。状态控制器图应负责根据系统事件(如关卡加载)加载和启用菜单。

系统状态控制动作(Sys_StateControl.xml)始终处于活动状态,并且用于启动初始动作,例如在引擎启动时显示主菜单。

创建主菜单

现在我们对 UI 流程图实现有了基本的了解,让我们开始创建我们自己的主菜单吧。

创建菜单元素

我们需要做的第一件事是创建我们的 UI 元素定义,以便为引擎提供加载我们导出的 SWF 文件的手段。

为此,在Game/Libs/UI/UIElements/中创建一个名为MainMenuSample.xml的新 XML 文档。我们菜单所需的最低限度的代码可以在以下代码中看到:

<UIElements name="Menus">
  <UIElement name="MainMenuSample" mouseevents="1" keyevents="1" cursor="1" controller_input="1">
    <GFx file="MainMenuSample.swf" layer="3">
      <Constraints>
        <Align mode="dynamic" halign="left" valign="top" scale="1" max="1"/>
      </Constraints>
    </GFx>
  </UIElement>
</UIElements>

有了上面的代码,引擎就会知道在哪里加载我们的 SWF 文件,以及如何在屏幕上对齐它。

注意

SWF 文件可以通过使用GFxExport.exe(通常位于<root>/Tools/目录中)重新导出,以便在引擎中更高效地使用。这通常是在发布游戏之前完成的。

暴露 ActionScript 资产

接下来,我们需要暴露我们在 Flash 源文件中定义的函数和事件,以便允许引擎调用和接收这些函数和事件。

在暴露函数和事件时,我们创建了简单的流程图节点,可以被任何流程图使用。

创建后,函数节点可以通过导航到UI | 函数来访问,如下面的截图所示:

暴露 ActionScript 资产

通过导航到UI | Events,可以找到事件。

注意

还可以在 C++中创建 UI 操作和元素,有效地使用户界面能够从本机代码发送和接收事件。我们将在本章后面的创建 UI 游戏事件系统部分中介绍这一点。

函数

要公开一个方法,我们需要像以下代码中所示,在UIElement定义中添加一个新的<functions>部分:

<functions>
  <function name="SetupScreen" funcname="setupScreen" desc="Sets up screen, clearing previous movieclips and configuring settings">
    <param name="buttonX" desc="Initial x pos of buttons" type="int" />
    <param name="buttonY" desc="Initial y pos of buttons" type="int" />
    <param name="buttonDividerSize" desc="Size of the space between buttons" type="int" />
  </function>

  <function name="AddBigButton" funcname="addBigButton" desc="Adds a primary button to the screen">
    <param name="id" desc="Button Id, sent with the onBigButton event" type="string" />
    <param name="title" desc="Button text" type="string" />
  </function>
</functions>

使用上述代码,引擎将创建两个节点,我们可以利用这些节点来调用 UI 图表中的setupScreenaddBigButton ActionScript 方法。

注意

函数始终放置在相同的 flowgraph 类别中:UI:Functions:ElementName:FunctionName

函数

当触发前一个截图中显示的任一节点上的Call端口时,将调用 ActionScript 方法并使用指定的参数。

注意

instanceID输入端口确定要在哪个元素实例上调用函数。如果值设置为-1(默认值),则将在所有实例上调用,否则如果设置为-2,则将在所有初始化的实例上调用。

事件

设置事件的方式与函数类似,使用<events>标签如下:

<events>
  <event name="OnBigButton" fscommand="onBigButton" desc="Triggered when a big button is pressed">    
    <param name="id" desc="Id of the button" type="string" />
  </event>
</events>

上述代码将使引擎创建OnBigButton节点可用,当 Flash 文件调用onBigButton fscommand 时触发,同时会有相关的按钮 ID。

事件

从 Flash 中调用fscommand相对容易。以下代码将触发onBigButton事件,并附带相关的按钮 ID 字符串。

fscommand("onBigButton", buttonId);

注意

与函数类似,事件始终放置在UI:Events:ElementName:EventName中。

变量

还可以通过元素定义定义访问 Flash 源文件中存在的变量。这允许通过使用UI:Variable:Var节点获取和设置变量的值。

首先,在元素定义的<variables>块中定义数组:

<variables>
  <variable name="MyTextField" varname="_root.m_myTextField.text"/>
</variables>

重新启动编辑器后,放置一个新的UI:Variable:Var节点,并按照以下截图中所示浏览您的新变量:

变量

然后我们可以通过 flowgraph 随时设置或获取我们的变量的值:

变量

数组

在上一节中,我们在运行时设置了 Flash 变量的值。通过使用UI:Variable:Array节点,也可以对数组进行相同操作。

首先,按照以下方式公开元素<arrays>块中的数组:

<arrays>
  <array name="MyArray" varname="_root.m_myArray"/>
</arrays>

然后简单地重新启动数组并重复之前的过程,但使用UI:Variable:Array节点。要通过 UI 图表创建新数组,请使用UI:Util:ToArray节点:

数组

将 MovieClip 实例公开给 flowgraph

与变量可以公开类似,也可以通过 UI 图表直接访问 MovieClips。这允许跳转到特定帧,更改属性等。

所有允许 MovieClip 交互的节点都可以在 Flowgraph 编辑器中的UI | MovieClip中找到,如下截图所示:

将 MovieClip 实例公开给 flowgraph

首先,按照以下方式添加或编辑元素定义中的<movieclips>块:

<movieclips>
  <movieclip name="MyMovieClip" instancename="_root.m_myMovieclip"/>
</movieclips>

这将使 flowgraph 可以访问 Flash 文件中存在的m_myMovieClip MovieClip。

编辑器重新启动后,我们可以使用UI:MovieClip:GotoAndPlay节点,例如直接跳转到指定剪辑中的不同帧,如下截图所示:

将 MovieClip 实例公开给 flowgraph

创建 UI 操作

现在我们已经配置了主菜单元素,是时候创建 UI 操作,使菜单出现在启动器应用程序中了。

创建状态控制图

首先打开 Sandbox 和 Flowgraph Editor。一旦打开,通过导航到文件|新建 UI 动作来创建一个新的 UI 动作。将动作命名为Sys_StateControl。这将是我们触发初始菜单和处理关键系统事件的主要 UI 动作。

创建动作后,我们将使用以下三个系统事件:

  • OnSystemStarted

  • OnLoadingError

  • OnUnloadComplete

这些事件一起表示我们的主菜单应该何时出现。我们将把它们绑定到一个UI:Action:Control节点中,该节点将激活我们稍后将创建的 MainMenu UIAction。

创建状态控制图

创建 MainMenu 动作

完成后,创建另一个 UI 动作,并命名为MainMenu。一旦打开,放置一个UI:Action:Start节点。当我们之前创建的UI:Action:Control节点被执行时,它的StartAction输出端口将自动激活。

现在我们可以将Start节点连接到UI:Display:DisplayUI:Display:Config节点,以初始化主菜单,并确保用户可以看到它。

创建 MainMenu 动作

我们的 flash 文件现在将在游戏启动时显示,但目前还缺少来自 flowgraph 的任何额外配置。

添加按钮

现在我们的主菜单文件已初始化,我们需要在 Flash 文件中添加一些 ActionScript 代码,以允许从 UI 图中动态生成和处理按钮。

本节假定您有一个 MovieClip 可以在运行时实例化。在我们的示例中,我们将使用一个名为BigButton的自定义按钮。

添加按钮

注意

我们的主菜单的 Flash 源文件(.fla)位于我们示例安装的Game/Libs/UI/文件夹中,可从github .com/inkdev/CryENGINE-Game-Programming-Sample/下载。

本节还假定您有两个 ActionScript 函数:SetupScreenAddBigButton

SetupScreen应该配置场景的默认设置,并删除所有先前生成的对象。在我们的情况下,我们希望使用AddBigButton生成的按钮在调用SetupScreen时被移除。

AddBigButton应该只是一个生成预先创建的按钮实例的函数,如下所示:

var button = _root.attachMovie("BigButton", "BigButton" + m_buttons.length, _root.getNextHighestDepth());

当单击按钮时,它应该调用一个事件,我们在 flowgraph 中捕获:

fscommand("onBigButton", button._id);

有关创建功能和事件的信息,请参阅前面讨论的公开 ActionScript 资产部分。

完成后,将节点添加到 MainMenu 动作中,并在配置元素后调用它们:

添加按钮

我们的主菜单现在应该在启动启动器应用程序时出现,但是对于用户与之交互没有任何反馈。

为了解决这个问题,我们可以利用前面在本章中公开的 OnBigButton 节点。该节点将在按钮被点击时发送事件,以及一个字符串标识符,我们可以用来确定点击了哪个节点:

添加按钮

在前面的图中,我们拦截按钮事件,并使用String:Compare节点来检查我们需要对输入做什么。如果点击了IDD_Quit按钮,我们退出游戏,如果点击了IDD_Start节点,我们加载Demo关卡。

最终结果

假设您没有创建自己的菜单设计,现在启动启动器时应该看到以下截图:

最终结果

现在您已经学会了创建一个简单菜单有多么容易,为什么不继续创建一个在玩家生成时显示的HUDHeads-Up Display)呢?

引擎 ActionScript 回调

引擎会自动调用一些 ActionScript 回调。只需在 Flash 源文件根目录中定义这些函数,引擎就能够调用它们。

  • cry_onSetup(isConsole:Boolean): 当 SWF 文件被引擎初始加载时调用此函数。

  • cry_onShow(): 当 SWF 文件显示时调用此函数。

  • cry_onHide(): 当 SWF 文件隐藏时调用此函数。

  • cry_onResize(_iWidth:Number, _iHeight:Number): 当游戏分辨率更改时调用此函数。

  • cry_onBack(): 当用户按下返回按钮时调用此函数。

  • cry_requestHide(): 当元素隐藏时调用此函数。

创建 UI 游戏事件系统

UI 系统利用IUIGameEventSystem接口与流程图进行通信,允许以与公开 ActionScript 资源相同的方式定义自定义函数和事件。

这用于允许用户界面访问游戏和引擎功能,例如获取可玩关卡列表。每个游戏事件系统都指定其类别,然后在流程图编辑器中用于定义注册的函数和事件的类别。

例如,如果我们使用IFlashUI::CreateEventSystem创建名为 MyUI 的事件系统,可以通过导航到UI | Functions | MyUI找到所有函数。

实现IUIGameEventSystem

实现IUIGameEventSystem不需要太多工作;我们只需要分配以下三个纯虚函数:

  • GetTypeName: 不直接重写;而是使用UIEVENTSYSTEM宏。

  • InitEventSystem: 调用此函数初始化事件系统。

  • UnloadEventSystem: 调用此函数卸载事件系统。

因此,最低要求如下(以下文件保存为MyUIGameEventSystem.h):

class CMyUIGameEventSystem
  : public IUIGameEventSystem
{
public:
  CMyUIGameEventSystem() {}

  // IUIGameEventSystem
  UIEVENTSYSTEM("MyUIGameEvents");
  virtual void InitEventSystem() {}
  virtual void UnloadEventSystem() {}
  // ~IUIGameEventSystem
};

现在我们已经解析了类定义,可以继续进行代码本身。首先创建一个名为MyUIGameEventSystem.cpp的新文件。

创建此文件后,使用REGISTER_UI_EVENTSYSTEM宏注册事件系统。这用于从CUIManager类内部自动创建您的类的实例。

将宏放在 CPP 文件的底部,超出方法范围,如下所示:

REGISTER_UI_EVENTSYSTEM(CMyUIGameEventSystem);

注意

请注意,REGISTER_UI_EVENTSYSTEM宏仅在 CryGame 项目中有效。

我们的事件系统现在应该编译,并将与 CryGame 中包含的其他事件系统一起创建。

我们的事件系统目前没有任何功能。阅读以下部分以了解如何将函数和事件公开给 UI 流程图。

接收事件

事件系统可以公开与我们通过主菜单元素注册的节点相同方式工作的函数。通过公开函数,我们可以允许图形与我们的游戏交互,例如请求玩家健康状况。

首先,我们需要向CMyUIGameEventSystem类添加两个新成员:

SUIEventReceiverDispatcher<CMyUIGameEventSystem> m_eventReceiver;
IUIEventSystem *m_pUIFunctions;

事件分发器将负责在流程图中触发其节点时调用函数。

要开始创建函数,请将以下代码添加到类声明中:

void OnMyUIFunction(int intParameter) 
{
  // Log indicating whether the call was successful
  CryLogAlways("OnMyUIFunction %i", intParameter);
}

要注册我们的函数,请在InitEventSystem函数中添加以下代码:

// Create and register the incoming event system
m_pUIFunctions = gEnv->pFlashUI->CreateEventSystem("MyUI", IUIEventSystem::eEST_UI_TO_SYSTEM);
m_eventReceiver.Init(m_pUIFunctions, this, "MyUIGameEvents");

// Register our function
{
  SUIEventDesc eventDesc("MyUIFunction", "description");

  eventDesc.AddParam<SUIParameterDesc::eUIPT_Int>("IntInput", "parameter description");

  m_eventReceiver.RegisterEvent(eventDesc, &CMyUIGameEventSystem::OnMyUIFunction);
}

重新编译并重新启动 Sandbox 后,您现在应该能够在流程图编辑器中看到您的节点。

接收事件

分派事件

能够向 UI 图形公开事件非常有用,允许您处理基于事件的 UI 逻辑,例如在用户请求时显示记分牌。

首先,让我们向您的类添加以下代码:

enum EUIEvent
{
	eUIE_MyUIEvent
};

SUIEventSenderDispatcher<EUIEvent> m_eventSender;
IUIEventSystem *m_pUIEvents;

EUIEvent枚举包含我们要注册的各种事件,并且作为事件发送方知道您要发送到 UI 系统的事件的一种方式。

现在我们需要在InitEventSystem函数中添加一些代码来公开我们的事件,如下所示:

// Create and register the outgoing event system
m_pUIEvents = gEnv->pFlashUI->CreateEventSystem("MyUI", IUIEventSystem::eEST_SYSTEM_TO_UI);

m_eventSender.Init(m_pUIEvents);

// Register our event
{
	SUIEventDesc eventDesc("OnMyUIEvent", "description");
	eventDesc.AddParam<SUIParameterDesc::eUIPT_String>("String", "String output description");
	m_eventSender.RegisterEvent<eUIE_MyUIEvent>(eventDesc);
}

成功重新编译后,OnMyUIEvent节点现在应该出现在编辑器中:

分派事件

分派事件

要分派您的 UI 事件,请使用SUIEventSenderDispatcher::SendEvent

m_eventSender.SendEvent<eUIE_MyUIEvent>("MyStringParam");

摘要

在本章中,我们学习了在 CryENGINE 中创建用户界面,并利用这些知识创建了自己的主菜单。

您现在已经掌握了实现自己的 UI 和 UI 事件系统所需的基本知识。

如果您更喜欢在进入下一章之前更多地与用户界面一起工作,为什么不扩展我们之前创建的主菜单呢?一个很好的起点可能是实现一个关卡选择屏幕。

在下一章中,我们将介绍创建网络游戏的过程,以实现多人游戏功能。

第八章:多人游戏和网络

使用 CryENGINE 网络系统,我们可以从单人游戏转移到创建具有大量人类玩家的生动世界。

在本章中,我们将:

  • 学习网络系统的基础知识

  • 利用远程方法调用(RMIs)

  • 使用方面在网络上序列化流动数据

网络系统

CryENGINE 的网络实现是一种灵活的设置,用于与游戏服务器和其他客户端通信。

所有网络消息都是从独立的网络线程发送的,以避免网络更新受游戏帧速率的影响。

网络标识符

在本地,每个实体都由实体标识符(entityId)表示。然而,在网络环境中,将它们传输到网络上是不可行的,因为不能保证它们指向远程客户端或服务器上的相同实体。

为了解决这个问题,每个游戏对象都被分配了一个由SNetObjectID结构表示的网络对象标识符,其中包含标识符及其盐的简单包装器。

在编写游戏代码时,将实体和实体 ID 序列化到网络上时,我们不必直接处理SNetObjectID结构,因为将entityId转换为SNetObjectID(并在远程机器上再转换为entityId)的过程是自动的。

要确保您的实体 ID 映射到远程机器上的相同实体,请在序列化时使用eid压缩策略。在本章后面的压缩策略部分中,了解有关策略以及如何使用它们的更多信息。

网络通道

CryENGINE 提供了INetChannel接口来表示两台机器之间的持续连接。例如,如果客户端 A 和客户端 B 需要相互通信,则在两台机器上都会创建一个网络通道来管理发送和接收的消息。

通过使用通道标识符来引用每个通道,通常可以确定哪个客户端属于哪台机器。例如,要检索连接到特定通道上的玩家角色,我们使用IActorSystem::GetActorByChannelId

网络 nubs

所有网络通道都由INetNub接口处理,该接口由一个或多个用于基于数据包的通信的端口组成。

设置多人游戏

要设置多人游戏,我们需要两台运行相同版本游戏的计算机。

启动服务器

有两种方法可以创建远程客户端可以连接的服务器。如下所述:

专用服务器

专用服务器存在的目的是有一个不渲染或播放音频的客户端,以便完全专注于支持没有本地客户端的服务器。

要启动专用服务器,请执行以下步骤:

  1. 启动Bin32/DedicatedServer.exe

  2. 输入map,然后输入要加载的级别名称,然后按Enter专用服务器

启动器

还可以通过启动器启动服务器,从而允许您与朋友一起玩而无需启动单独的服务器应用程序。

要通过启动器启动服务器,请按照以下步骤操作:

  1. 启动您的启动器应用程序。

  2. 打开控制台。

  3. 输入map <level name> s

注意

map命令后添加s将告诉 CryENGINE 以服务器的多人游戏上下文加载级别。省略s仍将加载级别,但在单人状态下加载。

启动器

通过控制台连接到服务器

要使用控制台连接到服务器,请使用connect命令:

  • connect <ip> <port>

注意

默认连接端口是 64089。

还可以通过cl_serveraddr控制台变量设置 IP 地址,通过cl_serverport设置端口,然后简单地调用connect

注意

请记住,您可以同时运行多个启动器,这在调试多人游戏时非常有用。

调试网络游戏

在调试网络游戏时非常有用的一个控制台变量是netlog 1,这将导致网络系统在控制台中记录更多关于网络问题和事件的信息。

使用游戏对象扩展进行网络连接

游戏对象有两种通过网络进行通信的方法:RMIs 和通过Aspects进行网络序列化。基本上,RMIs 允许基于事件的数据传输,而 Aspect 则在数据失效时连续同步数据。

在能够通过网络通信之前,每个游戏对象都必须使用IGameObject::BindToNetwork函数绑定到网络。这可以通过IGameObjectExtensionInit实现来调用。

远程方法调用(RMI)

远程方法调用RMI)用于在远程客户端或服务器上调用函数。这对于在网络上同步状态非常有用,例如,让所有客户端知道名为“Dude”的玩家刚刚生成,并且应该移动到特定位置和方向。

RMI 结构

要声明 RMI,我们可以使用列出的宏:

  • DECLARE_SERVER_RMI_NOATTACH

  • DECLARE_CLIENT_RMI_NOATTACH

  • DECLARE_SERVER_RMI_PREATTACH

  • DECLARE_CLIENT_RMI_PREATTACH

  • DECLARE_SERVER_RMI_POSTATTACH

  • DECLARE_CLIENT_RMI_POSTATTACH

例如:

DECLARE_CLIENT_RMI_NOATTACH(ClMoveEntity, SMoveEntityParams, eNRT_ReliableUnordered);

注意

最后一个参数指定数据包的可靠性,但在最新版本的 CryENGINE 中已大部分被弃用。

在创建时要记住你正在暴露哪种类型的 RMI。例如,DECLARE_CLIENT仅用于将在远程客户端上调用的函数,而DECLARE_SERVER定义了将在服务器上调用的函数,在客户端请求后。

参数

RMI 声明宏需要提供三个参数:

  • 函数名称:这是确定方法名称的第一个参数,也是在声明函数和调用 RMI 本身时将使用的名称。

  • RMI 参数:RMI 必须指定一个包含将与方法一起序列化的所有成员的结构。该结构必须包含一个名为SerializeWith的函数,该函数接受一个TSerialize参数。

  • 数据包传递可靠性枚举:这是定义数据包传递可靠性的最后一个参数。

我们刚刚看到的宏之间有三种不同之处:

附加类型

附加类型定义了 RMI 在网络序列化期间何时附加:

  • NOATTACH:当 RMI 不依赖游戏对象数据时使用,因此可以在游戏对象数据序列化之前或之后附加。

  • PREATTACH:在此类型中,RMI 将在游戏对象数据序列化之前附加。当 RMI 需要准备接收的数据时使用。

  • POSTATTACH:在此类型中,RMI 在游戏对象数据序列化后附加。当新接收的数据与 RMI 相关时使用。

服务器/客户端分离

从 RMI 声明宏中可以看出,RMI 不能同时针对客户端和服务器。

因此,我们要么决定哪个目标应该能够运行我们的函数,要么为每个目标创建一个宏。

这是一个非常有用的功能,当处理服务器授权的游戏环境时,由于可以持续区分可以在服务器和客户端上远程触发的函数。

函数定义

要定义 RMI 函数,我们可以使用IMPLEMENT_RMI宏:

  IMPLEMENT_RMI(CGameRules, ClMoveEntity)
  {
  }

该宏定义了在调用 RMI 时调用的函数,具有两个参数:

  • params:这包含从远程机器发送的反序列化数据。

  • pNetChannel:这是一个INetChannel实例,描述了源和目标机器之间建立的连接。

RMI 示例

为了演示如何创建基本的 RMI,我们将创建一个 RMI,允许客户端请求重新定位实体。这将导致服务器向所有客户端发送ClMoveEntity RMI,通知它们新实体的情况。

首先,我们需要打开我们的头文件。这是我们将定义 RMI 和我们的参数的地方。首先创建一个名为SMoveEntityParams的新结构。

然后我们将添加三个参数:

  • EntityID entityId:这是我们想要移动的实体的标识符

  • Vec3 位置:这确定了实体应该移动到哪个位置

  • Quat 方向:这用于在生成时设置实体的旋转

添加参数后,我们需要在SMoveEntityParams结构内定义SerializeWith函数。这将在发送数据到网络时调用,然后再次接收数据时调用。

  void SerializeWith(TSerialize ser)
  {
    ser.Value("entityId", entityId, 'eid');
    ser.Value("position", position, 'wrld');
    ser.Value("orientation", orientation, 'ori0');
  }

注意

eid压缩策略的使用需要特别注意,它确保entityId指向相同的实体。有关为什么需要该策略的更多信息,请参阅本章的网络标识符部分。

现在我们已经定义了我们的 RMI 参数,我们需要声明两个 RMI:一个用于客户端,一个用于服务器:

  DECLARE_SERVER_RMI_NOATTACH(SvRequestMoveEntity, SMoveEntityParams, eNRT_ReliableUnordered);

  DECLARE_CLIENT_RMI_NOATTACH(ClMoveEntity, SMoveEntityParams, eNRT_ReliableUnordered);

现在我们所要做的就是创建函数实现,我们可以在我们的 CPP 文件中使用IMPLEMENT_RMI宏来实现。

  IMPLEMENT_RMI(CGameRules, SvRequestMoveEntity)
  {
    IEntity *pEntity = gEnv->pEntitySystem->GetEntity(params.entityId);
    if(pEntity == nullptr)
      return true;

    pEntity->SetWorldTM(Matrix34::Create(Vec3(1, 1, 1), params.orientation, params.position));

    GetGameObject()->InvokeRMI(ClMoveEntity(), params, eRMI_ToAllClients | eRMI_NoLocalCalls);

    return true;
  }

这段代码定义了我们的SvRequestMoveEntity函数,当客户端执行以下操作时将调用该函数:

  GetGameObject()->InvokeRMI(SvRequestMoveEntity(), params, eRMI_Server);

尝试自己实现ClMoveEntity函数。它应该以与我们在SvRequestMoveEntity中所做的相同方式设置实体的世界变换(IEntity::SetWorldTM)。

网络方面序列化

游戏对象扩展可以实现IGameObjectExtension::NetSerialize函数,该函数用于在网络上序列化与扩展相关的数据。

方面

为了允许特定机制相关数据的分离,网络序列化过程公开了方面。当服务器或客户端将方面声明为“脏”(更改)时,网络将触发序列化并调用具体方面的NetSerialize函数。

要将您的方面标记为脏,请调用IGameObject::ChangedNetworkState

  GetGameObject()->ChangedNetworkState(eEA_GameClientF);

这将触发NetSerialize来序列化您的方面,并将其数据发送到远程机器,然后在同一函数中对其进行反序列化。

注意

当方面的值与上次发送到远程客户端或服务器的值不同时,该方面被认为是“脏”。

例如,如果我们想序列化与玩家输入相关的一组标志,我们将创建一个新的方面,并在客户端的输入标志发生变化时将其标记为脏:

  bool CMyGameObjectExtension::NetSerialize(TSerialize ser, EEntityAspects aspect, uint8 profile, int flags)
  {
    switch(aspect)
    {
      case eEA_GameClientF:
        {
          ser.EnumValue("inputFlags", (EInputFlags &)m_inputFlags, EInputFlag_First, EInputFlag_Last);
        }
        break;
    }
  }

注意

TSerialize::EnumValueTSerialize::Value的一种特殊形式,它计算枚举的最小值和最大值,有效地充当动态压缩策略。

EnumValue和压缩策略应尽可能使用,以减少带宽使用。

现在,当客户端上的eEA_GameClientF方面被标记为脏时,将调用NetSerialize函数,并将m_inputFlags变量值写入网络。

当数据到达远程客户端或服务器时,NetSerialize函数将再次被调用,但这次将值写入m_inputFlags变量,以便服务器知道客户端提供的新输入标志。

注意

方面不支持条件序列化,因此每个方面在每次运行时都必须序列化相同的变量。例如,如果在第一个方面序列化了四个浮点数,那么你将始终需要序列化四个浮点数。

仍然可以序列化复杂对象,例如,我们可以写入数组的长度,然后迭代读取/写入数组中包含的每个对象。

压缩策略

TSerialize::Value使能够传递一个额外的参数,即压缩策略。此策略用于确定在同步数据时可以使用哪些压缩机制来优化网络带宽。

压缩策略定义在Scripts/Network/CompressionPolicy.xml中。现有策略的示例如下:

  • eid:这用于在网络上序列化entityId标识符,并将游戏对象的SNetObjectID与远程客户端上的正确entityId进行比较。

  • wrld:这在序列化代表世界坐标的Vec3结构时使用。由于默认情况下被限制在 4095,这可能需要针对更大的级别进行调整。

  • colr:这用于在网络上序列化ColorF结构,允许浮点变量表示 0 到 1 之间的值。

  • bool:这是布尔值的特定实现,并且可以减少大量冗余数据。

  • ori1:这用于在网络上序列化Quat结构,用于玩家方向。

创建一个新的压缩策略

添加新的压缩策略就像修改CompressionPolicy.xml一样简单。例如,如果我们想要创建一个新的 Vec3 策略,其中 X 和 Y 轴只能达到 2048 米,而 Z 轴限制为 1024 米:

<Policy name="wrld2" impl="QuantizedVec3">
  <XParams min="0" max="2047.0" nbits="24"/>
  YParams min="0" max="2047.0" nbits="24"/>
  <ZParams min="0" max="1023.0" nbits="24"/>
</Policy>

将 Lua 实体暴露给网络

现在我们知道如何在 C++中处理网络通信,让我们看看如何将 Lua 实体暴露给网络。

Net.Expose

为了定义 RMIs 和服务器属性,我们需要在.lua脚本的全局范围内调用Net.Expose

Net.Expose({
  Class = MyEntity,
  ClientMethods = {
    ClRevive             = { RELIABLE_ORDERED, POST_ATTACH, ENTITYID, },
  },
  ServerMethods = {
    SvRequestRevive          = { RELIABLE_UNORDERED, POST_ATTACH, ENTITYID, },
  },
  ServerProperties = {
  },
});

前一个函数将定义ClReviveSvRequestRevive RMIs,可以通过使用为实体自动创建的三个子表来调用:

  • allClients

  • otherClients

  • server

函数实现

远程函数定义在实体脚本的ClientServer子表中,以便网络系统可以快速找到它们,同时避免名称冲突。

例如,查看以下SvRequestRevive函数:

  function MyEntity.Server:SvRequestRevive(playerEntityId)
  end

调用 RMIs

在服务器上,我们可以触发ClRevive函数,以及我们之前定义的参数,对所有远程客户端进行触发。

在服务器上

要在服务器上调用我们的SvRequestRevive函数,只需使用:

  self.server:SvRequestRevive(playerEntityId);

在所有客户端

如果您希望所有客户端都收到ClRevive调用:

  self.allClients:ClRevive(playerEntityId);

在所有其他客户端上

ClRevive调用发送到除当前客户端之外的所有客户端:

  self.otherClients:ClRevive(playerEntityId);

将我们的实体绑定到网络

在能够发送和接收 RMI 之前,我们必须将我们的实体绑定到网络。这是通过为我们的实体创建一个游戏对象来完成的:

  CryAction.CreateGameObjectForEntity(self.id);

我们的实体现在将拥有一个功能性的游戏对象,但尚未设置为网络使用。要启用此功能,请调用CryAction.BindGameObjectToNetwork函数:

  CryAction.BindGameObjectToNetwork(self.id);

完成!我们的实体现在已绑定到网络,并且可以发送和接收 RMI。请注意,这应该在实体生成后立即进行。

总结

在本章中,我们已经学习了 CryENGINE 实例如何在网络上远程通信,并且还创建了我们自己的 RMI 函数。

现在您应该了解网络方面和压缩策略函数,并且对如何将实体暴露给网络有基本的了解。

如果您想在进入下一章之前继续进行多人游戏和网络游戏,为什么不创建一个基本的多人游戏示例,其中玩家可以向服务器发送生成请求,结果是玩家在所有远程客户端上生成?

在下一章中,我们将介绍物理系统以及如何利用它。

第九章:物理编程

CryENGINE 物理系统是一个可扩展的物理实现,允许创建真正动态的世界。开发人员将发现,在实现物理模拟时有很大的灵活性。

在本章中,我们将:

  • 了解物理系统的工作原理

  • 发现如何调试我们的物理化几何体

  • 学习如何进行射线投射和相交基元,以发现接触点、地面法线等

  • 创建我们自己的物理化实体

  • 通过模拟爆炸使事物爆炸

CryPhysics

物理实体系统围绕物理实体的概念而设计,可以通过IPhysicalEntity接口访问。物理实体代表具有物理代理的几何体,可以影响和受到交叉、碰撞和其他事件的影响。

虽然可以通过IPhysicalWorld::CreatePhysicalEntity函数创建没有基础实体(IEntity)的物理实体,但通常会调用IEntity::Physicalize以启用当前由实体加载的模型的物理代理。

注意

物理代理是渲染网格的简化模型。这用于减少物理系统的负担。

当调用IEntity::Physicalize时,将创建一个新的实体代理,通过调用IPhysicalWorld::CreatePhysicalEntity来处理其物理化表示。CryENGINE 允许创建多种物理实体类型,具体取决于物理化对象的目的。

物理化实体类型

以下是 CryENGINE 当前实现的物理化实体类型:

  • PE_NONE:当实体不应物理化时使用,或者当我们想要去物理化时传递给IEntity::Physicalize。在未物理化时,实体将没有物理代理,因此无法与其他对象进行物理交互。

  • PE_STATIC:这告诉物理系统利用实体的物理代理,但永远不允许通过物理交互移动或旋转它。

  • PE_RIGID:将刚体类型应用于对象,允许外部对象发生碰撞并移动目标。

  • PE_WHEELEDVEHICLE:用于车辆的专用类型。

  • PE_LIVING:用于生物演员,例如需要地面对齐和地面接触查询的人类。

  • PE_PARTICLE:这是基于SEntityPhysicalizeParams中传递的粒子进行物理化的,对于避免快速移动物体(如抛射物)的问题非常有用。

  • PE_ARTICULATED:用于由几个刚体通过关节连接的关节结构,例如布娃娃。

  • PE_ROPE:用于创建可以将两个物理实体绑在一起或自由悬挂的物理化绳索对象。也用于 Sandbox 绳索工具。

  • PE_SOFT:这是一组连接的顶点,可以与环境进行交互,例如布料。

引入物理实体标识符

所有物理实体都被分配唯一的标识符,可以通过IPhysicalWorld::GetPhysicalEntityId检索,并用于通过IPhysicalWorld::GetPhysicalEntityById获取物理实体。

注意

物理实体 ID 被序列化为一种将数据与特定物理实体关联的方式,因此在重新加载时应保持一致。

绘制实体代理

我们可以利用p_draw_helpers CVar 来获得关卡中各种物理化对象的视觉反馈。

要绘制所有物理化对象,只需将 CVar 设置为 1。

绘制实体代理

对于更复杂的用法,请使用p_draw_helpers [Entity_Types]_[Helper_Types]

例如,要绘制地形代理几何:

  p_draw_helpers t_g

绘制实体代理

实体类型

以下是实体类型的列表:

  • t:这显示地形

  • s:这显示静态实体

  • r:这显示休眠刚体

  • R:这显示活动刚体

  • l:这显示生物实体

  • i:这显示独立实体

  • g:这显示触发器

  • a:这显示区域

  • y:这显示RayWorldIntersection射线

  • e:这显示爆炸遮挡地图

辅助类型

以下是辅助类型列表:

  • g:这显示几何体

  • c:这显示接触点

  • b:这显示边界框

  • l:这显示可破碎物体的四面体晶格

  • j:这显示结构关节(将在主几何体上强制半透明)

  • t(#):这显示直到级别#的边界体积树

  • f(#):这只显示设置了此位标志的几何体(多个 f 叠加)

物理实体动作、参数和状态

IPhysicalEntity接口提供了三种改变和获取实体物理状态的方法:

参数

物理实体参数确定几何体的物理表示应在世界中如何行为。可以通过IPhysicalEntity::GetParams函数检索参数,并通过使用IPhysicalEntity::SetParams设置。

所有参数都作为从pe_params派生的结构传递。例如,要修改实体受到的重力,我们可以使用pe_simulation_params

  pe_simulation_params simParams;

  simParams.gravity = Vec3(0, 0, -9.81f);
  GetEntity()->GetPhysics()->SetParams(&simParams);

此代码将更改应用于实体的重力加速度为-9.81f。

注意

大多数物理实体参数结构的默认构造函数标记某些数据为未使用;这样我们就不必担心覆盖我们未设置的参数。

动作

与参数类似,动作允许开发人员强制执行某些物理事件,例如脉冲或重置实体速度。

所有动作都源自pe_action结构,并可以通过IPhysicalEntity::Action函数应用。

例如,要对我们的实体施加一个简单的冲量,将其发射到空中,请使用:

  pe_action_impulse impulseAction;
  impulseAction.impulse = Vec3(0, 0, 10);

  GetEntity()->GetPhysics()->Action(&impulseAction);

状态

还可以从实体获取各种状态数据,例如确定其质心位置或获取其速度。

所有状态都源自pe_status结构,并可以通过IPhysicalEntity::GetStatus函数检索。

例如,要获取玩家等生物实体的速度,请使用:

  pe_status_living livStat;
  GetEntity()->GetPhysics()->GetStatus(&livStat);

  Vec3 velocity = livStat.vel;

物理化实体类型详细信息

默认物理化实体实现有许多参数、动作和状态。我们列出了它们最常用的类型的一些选择:

常见参数

  • pe_params_pos:用于设置物理实体的位置和方向。

  • pe_params_bbox:这允许将实体的边界框强制为特定值,或在与GetParams一起使用时查询它,以及查询交集。

  • pe_params_outer_entity:这允许指定外部物理实体。如果在其边界框内发生碰撞,则将忽略与外部实体的碰撞。

  • pe_simulation_params:为兼容实体设置模拟参数。

常见动作

  • pe_action_impulse:这对实体施加一次性冲量。

  • pe_action_add_constraint:用于在两个物理实体之间添加约束。例如,可以使用忽略约束使幽灵穿过墙壁。

  • pe_action_set_velocity:用于强制物理实体的速度。

常见状态

  • pe_status_pos:请求实体或实体部分的当前变换

  • pe_status_dynamics:用于获取实体运动统计数据,如加速度、角加速度和速度

静态

将实体物理化为静态类型会创建基本物理化实体类型,从中派生所有扩展,如刚性或生物。

静态实体是物理化的,但不会移动。例如,如果将球扔向静态物体,它将在不移动目标物体的情况下反弹回来。

刚性

这指的是基本的物理实体,当受到外部力的影响时可以在世界中移动。

如果我们使用相同的先前示例,向刚性物体投掷球将导致刚性物体被推开

轮式车辆

这代表了一个轮式车辆,简单地说,实现是一个刚体,具有车轮、刹车和 CryENGINE 等车辆功能。

独特参数

  • pe_params_car:用于获取或设置特定于车辆的参数,例如 CryENGINE 功率、RPM 和齿轮数

  • pe_params_wheel:用于获取或设置车辆车轮的特定参数,例如摩擦、表面 ID 和阻尼

独特状态

  • pe_status_vehicle:用于获取车辆统计信息,允许获取速度、当前档位等

  • pe_status_wheel:获取特定车轮的状态,例如接触法线、扭矩和表面 ID

  • pe_status_vehicle_abilities:这允许检查特定转弯的最大可能速度

独特动作

  • pe_action_drive:用于车辆事件,如刹车、踏板和换挡。

生物

生物实体实现是处理演员及其移动请求的专门设置。

生物实体有两种状态:在地面上和在空中。在地面上,玩家将被“粘”在地面上,直到尝试将其与地面分离(通过施加远离地面的显著速度)。

注意

还记得来自第五章创建自定义演员的动画角色移动请求吗?该系统在核心中使用生物实体pe_action_move请求。

独特参数

  • pe_player_dimensions:用于设置与生物实体的静态属性相关的参数,例如 sizeCollider,以及是否应该使用胶囊或圆柱体作为碰撞几何体

  • pe_player_dynamics:用于设置与生物实体相关的动态参数,例如惯性、重力和质量

独特状态

  • pe_status_living:获取当前生物实体状态,包括飞行时间、速度和地面法线等统计信息

  • pe_status_check_stance:用于检查新尺寸是否引起碰撞。参数的含义与 pe_player_dimensions 中的相同

独特动作

  • pe_action_move:用于提交实体的移动请求。

粒子

还可以使用对象的粒子表示。这通常用于应该以高速移动的对象,例如抛射物。基本上,这意味着我们实体的物理表示只是一个二维平面。

独特参数

  • pe_params_particle:用于设置特定于粒子的参数

关节

关节结构由几个刚体通过关节连接而成,例如布娃娃。这种方法允许设置撕裂限制等。

独特参数

  • pe_params_joint:用于在设置时在两个刚体之间创建关节,并在与GetParams一起使用时查询现有关节。

  • pe_params_articulated_body:用于设置特定于关节类型的参数。

绳索

当您想要创建将多个物理化对象绑在一起的绳索时,应该使用绳索。该系统允许绳索附着到动态或静态表面。

独特参数

  • pe_params_rope:用于更改或获取物理绳索参数

软是一种非刚性连接的顶点系统,可以与环境进行交互,例如布料物体。

独特参数

  • pe_params_softbody:用于配置物理软体

独特动作

  • pe_action_attach_points:用于将软实体的一些顶点附加到另一个物理实体

射线世界交叉

使用IPhysicalWorld::RayWorldIntersection函数,我们可以从世界的一个点向另一个点投射射线,以检测到特定对象的距离、表面类型、地面的法线等。

RayWorldIntersection很容易使用,我们可以证明它!首先,看一个射线投射的例子:

  ray_hit hit;

  Vec3 origin = pEntity->GetWorldPos();
  Vec3 dir = Vec3(0, 0, -1);

  int numHits = gEnv->pPhysicalWorld->RayWorldIntersection(origin, dir, ent_static | ent_terrain, rwi_stop_at_pierceable | rwi_colltype_any, &hit, 1);
  if(numHits > 0)
  {
    // Hit something!
  }

ray_hit 结构

我们将ray_hit hit变量的引用传递给RayWorldIntersection,这是我们将能够检索有关射线命中的所有信息的地方。

常用的成员变量

  • float dist:这是从原点(在我们的例子中是实体的位置)到射线命中位置的距离。

  • IPhysicalEntity *pCollider:这是指向我们的射线碰撞的物理实体的指针。

  • short surface_idx:这是我们的射线碰撞的材料表面类型的表面标识符(请参见IMaterialManager::GetSurfaceType以获取其ISurfaceType指针)。

  • Vec3 pt:这是接触点的世界坐标。

  • Vec3 n:这是接触点的表面法线。

  • ray_hit *next:如果我们的射线多次命中,这将指向下一个ray_hit结构。有关更多信息,请参阅允许多次射线命中部分。

起点和方向

RayWorldIntersection函数的第一个和第二个参数定义了射线应该从哪里投射,以及在特定方向上的距离。

在我们的例子中,我们从实体的当前位置向下移动一个单位来发射射线。

对象类型和射线标志

请注意,在dir之后,我们向RayWorldIntersection函数传递了两种类型的标志。这些标志指示射线应该如何与对象相交,以及要忽略哪些碰撞。

对象类型

对象类型参数需要基于entity_query_flags枚举的标志,并用于确定我们希望允许射线与哪种类型的对象发生碰撞。如果射线与我们未定义的对象类型发生碰撞,它将简单地忽略并穿过。

  • ent_static:这指的是静态对象

  • ent_sleeping_rigid:这表示睡眠刚体

  • ent_rigid:这表示活动刚体

  • ent_living:这指的是生物体,例如玩家

  • ent_independent:这表示独立对象

  • ent_terrain:这表示地形

  • ent_all:这指的是所有类型的对象

射线标志

射线标志参数基于rwi_flags枚举,并用于确定投射应该如何行为。

允许多次射线命中

正如前面提到的,也可以允许射线多次命中对象。为此,我们只需创建一个ray_hit数组,并将其与命中次数一起传递给RayWorldIntersection函数:

  const int maxHits = 10;

  ray_hit rayHits[maxHits];
  int numHits = gEnv->pPhysicalWorld->RayWorldIntersection(origin, direction, ent_all, rwi_stop_at_pierceable, rayHits, maxHits);

  for(int i = 0; i < numHits; i++)
  {
    ray_hit *pRayHit = &rayHits[i];

// Process ray
  }

创建一个物理实体

现在我们知道了物理系统是如何工作的,我们可以创建自己的物理实体,可以与场景中的其他物理几何体发生碰撞:

注意

本节假设您已阅读了第三章,创建和使用自定义实体

在 C++

根据我们之前学到的,我们知道可以通过PE_STATIC类型来使静态实体物理化:

  SEntityPhysicalizeParams physicalizeParams;
  physicalizeParams.type = PE_STATIC;

  pEntity->Physicalize(physicalizeParams);

假设在调用IEntity::Physicalize之前已为实体加载了几何体,现在其他物理化的对象将能够与我们的实体发生碰撞。

但是如果我们想要允许碰撞来移动我们的物体呢?这就是PE_RIGID类型发挥作用的地方:

  SEntityPhysicalizeParams physicalizeParams;
  physicalizeParams.type = PE_RIGID;
  physicalizeParams.mass = 10;

  pEntity->Physicalize(physicalizeParams);

现在,CryENGINE 将知道我们的对象重 10 千克,并且在与另一个物理化实体发生碰撞时将被移动。

在 C#

我们还可以在 C#中使用EntityBase.Physicalize函数以及PhysicalizationParams结构来做到这一点。例如,如果我们想要给一个静态对象添加物理属性,我们可以使用以下代码:

  var physType = PhysicalizationType.Static;
  var physParams = new PhysicalizationParams(physType);

  Physicalize(physParams);

当然,这假设通过EntityBase.LoadObject方法加载了一个对象。

现在,如果我们想要创建一个刚性实体,我们可以使用:

  var physType = PhysicalizationType.Rigid;

  var physParams = new PhysicalizationParams(physType);
  physParams.mass = 50;

  Physicalize(physParams);

我们的实体现在重 50 公斤,当与其他物理化的物体发生碰撞时可以移动。

模拟爆炸

我们知道你在想,“如果我们不能炸毁东西,所有这些物理知识有什么用?”,我们已经为你准备好了!

物理世界实现提供了一个简单的函数,用于在世界中模拟爆炸,具有广泛的参数范围,允许自定义爆炸区域。

为了演示,我们将创建一个最大半径为 100 的爆炸:

  pe_explosion explosion;
  explosion.rmax = 100;

  gEnv->pPhysicalWorld->SimulateExplosion(&explosion);

注意

SimulateExplosion函数仅仅模拟爆炸并产生一个推动实体远离的力,不会产生任何粒子效果。

总结

在本章中,我们已经学习了物理世界实现的基本工作原理,以及如何在视觉上调试物理代理。

有了你的新知识,你应该知道如何使用射线世界交叉点来收集关于周围游戏世界的信息。哦,我们已经炸毁了一些东西。

如果你觉得还不准备好继续前进,为什么不创建一个扩展的物理实体或物理修改器,比如重力枪或蹦床呢?

在下一章中,我们将涵盖渲染管线,包括如何编写自定义着色器,以及如何在运行时修改材质。

第十章:渲染编程

CryENGINE 渲染器很可能是引擎中最著名的部分,为 PC、Xbox 360 和 PlayStation 3 等平台提供高度复杂的图形功能和出色的性能。

在本章中,我们将涵盖以下主题:

  • 学习渲染器的基本工作原理

  • 了解每一帧如何渲染到世界中

  • 学习着色器编写的基础知识

  • 学习如何在运行时修改静态对象

  • 在运行时修改材质

渲染器细节

CryENGINE 渲染器是一个模块化系统,允许绘制复杂的场景,处理着色器等。

为了方便不同的平台架构,CryENGINE 存在多个渲染器,都实现了IRenderer接口。我们列出了一些选择,如下所示:

  • DirectX:用于 Windows 和 Xbox

  • PSGL:用于 PlayStation 3

很可能也正在开发OpenGL渲染器,用于 Linux 和 Mac OS X 等平台。

着色器

CryENGINE 中的着色器是使用基于 HLSL 的专门语言 CryFX 编写的。该系统与 HLSL 非常相似,但专门用于核心引擎功能,如材质和着色器参数,#include宏等。

注意

请注意,本书撰写时,Free SDK 中未启用着色器编写;但这在未来可能会改变。

着色器排列组合

每当材质改变着色器生成参数时,基本着色器的一个排列组合将被创建。引擎还公开了将引擎变量暴露给着色器的功能,以便在运行时禁用或调整效果。

这是由于 CryFX 语言允许#ifdef#endif#include块,允许引擎在运行时剥离着色器代码的某些部分。

着色器排列组合

着色器缓存

由于在运行时编译着色器在所有平台上都不可行,CryENGINE 提供了着色器缓存系统。这允许存储一系列预编译的着色器,为最终用户的设备节省了相当多的工作。

注意

如前一节所述,着色器可以包含大量的变体。因此,在设置缓存时,有必要确保所有所需的排列组合都已经编译。

PAK 文件

渲染器可以从Engine文件夹加载四个.pak文件,包含着色器定义、源文件等。

存档名称 描述
Shaders.pak 包含着色器源文件和.ext(定义)文件。在使用预编译着色器缓存时,着色器源通常被排除在此存档之外。
ShadersBin.pak 包含着色器源代码的二进制解析信息。
ShaderCache.pak 包含所有已编译的着色器;仅在当前级别的着色器缓存中找不到着色器时使用。
ShaderCacheStartup.pak 在启动时加载以加快启动时间;应该只包含主菜单所需的着色器。

渲染节点

提供IRenderNode接口,以便为 Cry3DEngine 系统提供管理对象的方法。

这允许生成对象可见性层次结构(允许轻松地剔除当前未见的对象)和对象的渲染。

渲染分解

游戏的渲染分为两个步骤:

  1. 预更新

  2. 后更新

预更新

渲染每一帧到场景的初始步骤发生在IGameFramework::PreUpdate函数中。预更新负责更新大多数游戏系统(如流程图、视图系统等),并首次调用ISystem::RenderBegin

注意

PreUpdate最常从CGame::Update中调用,在原始的CryGame.dll中。请记住,这个过程只适用于启动器应用程序;编辑器处理游戏更新和渲染的方式是独特的。

RenderBegin 表示新帧的开始,并告诉渲染器设置新的帧 ID,清除缓冲区等。

更新后

更新游戏系统后,是时候渲染场景了。这一初始步骤通过IGameFramework::PostUpdate函数完成。

在渲染之前,必须更新对游戏更新中和之后从新信息中检索到的关键系统。这包括闪烁 UI、动画同步等。

完成后,PostUpdate将调用ISystem::Render,然后使用I3DEngine::RenderWorld函数渲染世界。

渲染世界后,系统将调用诸如IFlashUI::UpdatePostUpdate等函数,最终以调用ISystem::RenderEnd结束。

更新后

使用渲染上下文渲染新视口

渲染上下文本质上是本机窗口句柄的包装器。在 Windows 上,这允许您指定一个HWND,然后让渲染器直接绘制到它上面。

渲染上下文的本质是特定于平台的,因此不能保证在一个渲染模块(如 D3D)到另一个渲染模块(如 OpenGL)之间的工作方式相同。

注意

注意:渲染上下文目前仅在 Windows 的编辑器模式下受支持,用于在工具窗口中渲染视口。

为了使用您的窗口句柄创建新的上下文,请调用IRenderer::CreateContext

注意

请注意,上下文在创建时会自动启用;调用IRenderer::MakeMainContextActive来重新启用主视图。

渲染

在渲染上下文时,你需要做的第一件事是激活它。这可以通过使用IRenderer::SetCurrentContext来完成。一旦启用,渲染器就会意识到应该传递给 DirectX 的窗口。

接下来你需要做的是使用IRenderer::ChangeViewport来更新上下文的分辨率。这指示渲染器关于应该渲染的区域的位置和大小。

这样做后,只需调用典型的渲染函数,如IRenderer::BeginFrame(参见渲染分解部分),最后通过IRenderer::MakeMainContextActive使主上下文在最后处于活动状态。

使用 I3DEngine::RenderWorld 函数

在某些情况下,手动调用I3DEngine::RenderWorld而不是依赖游戏框架的更新过程可能是有意义的。

为此,我们需要稍微改变流程。首先,调用IRenderer::SetCurrentContext,然后调用IRenderer::MakeMainContextActive如下所示:

gEnv->pRenderer->SetCurrentContext(hWnd);
// Restore context
gEnv->pRenderer->MakeMainContextActive();

很好,现在我们的上下文将被激活。但为了实际渲染,我们需要填补之间的空白。首先,我们必须在SetCurrentContext之后直接调用IRenderer::ChangeViewport如下所示:

gEnv->pRenderer->ChangeViewport(0, 0, width, height, true);

这将视口设置为00的坐标和我们指定的widthheight变量。

设置视口大小后,您将需要根据新的分辨率配置您的摄像机,并调用IRenderer::SetCamera如下所示:

CCamera camera;
// Set frustrum based on width, height and field of view (60)
camera.SetFrustum(width, height, DEG2RAD(60));
// Set camera scale, orientation and position.
Vec3 scale(1, 1, 1);
Quat rotation = Quat::CreateRotationXYZ(Ang3(DEG2RAD(-45), 0, 0));
Vec3 position(0, 0, 0);
camera.SetMatrix(Matrix34::Create(scale, rotation, position));
gEnv->pRenderer->SetCamera(m_camera);

太好了!渲染器现在知道应该使用哪个摄像机进行渲染。我们还需要在稍后提供给I3DEngine::RenderWorld。但首先我们必须清除缓冲区,以删除之前的帧,使用以下代码:

// Set clear color to pure black
ColorF clearColor(0.f)

gEnv->pRenderer->SetClearColor(Vec3(clearColor.r, clearColor.g, clearColor.b));
gEnv->pRenderer->ClearBuffer(FRT_CLEAR, &clearColor);

然后调用IRenderer::RenderBegin来指示开始渲染:

gEnv->pSystem->RenderBegin();
gEnv->pSystem->SetViewCamera(m_camera);

// Insert rendering here

gEnv->pSystem->RenderEnd();

现在我们所要做的就是在SetViewCameraRenderEnd调用之间渲染场景:

gEnv->pRenderer->SetViewport(0, 0, width, height);
gEnv->p3DEngine->Update();

int renderFlags = SHDF_ALLOW_AO | SHDF_ALLOWPOSTPROCESS | SHDF_ALLOW_WATER | SHDF_ALLOWHDR | SHDF_ZPASS;

gEnv->p3DEngine->RenderWorld(renderFlags, &camera, 1, __FUNCTION__);

完成!世界现在根据我们的摄像机设置进行渲染,并应该在通过IRenderer::SetCurrentContext设置的窗口中可见。

I3DEngine::RenderWorld 标志

渲染标志确定如何绘制世界。例如,我们可以排除SHDF_ALLOW_WATER来完全避免渲染水。下表列出了可用标志及其功能:

标志名称 描述
SHDF_ALLOWHDR 如果未设置,将不使用 HDR。
SHDF_ZPASS 允许 Z-Pass。
SHDF_ZPASS_ONLY 允许 Z-Pass,而不允许其他通道。
SHDF_DO_NOT_CLEAR_Z_BUFFER 如果设置,Z 缓冲区将永远不会被清除。
SHDF_ALLOWPOSTPROCESS 如果未设置,所有后期处理效果将被忽略。
SHDF_ALLOW_AO 如果设置,将使用环境光遮蔽
SHDF_ALLOW_WATER 如果未设置,所有水体将被忽略并且不会渲染。
SHDF_NOASYNC 无异步绘制。
SHDF_NO_DRAWNEAR 排除所有在近平面的渲染。
SHDF_STREAM_SYNC 启用同步纹理流式传输。
SHDF_NO_DRAWCAUSTICS 如果设置,将不绘制水光。

着色器

在 CryENGINE 中创建自定义着色器相对容易,只需通过复制现有着色器(.cfx)及其扩展文件(.ext)即可完成。举例来说,从Engine/Shaders复制Illum.ext并命名为MyShader.ext。然后复制Engine/Shaders/HWScripts/CryFX/Illum.cfx并将其重命名为MyShader.cfx

请注意,创建自定义着色器应该经过深思熟虑;如果可能的话,最好使用现有的着色器。这是因为 CryENGINE 已经接近着色器排列的可行极限。

注意

正如本章前面所述,本书撰写时,CryENGINE Free SDK 中未启用自定义着色器编写。

着色器描述

每个着色器都需要定义一个描述,以设置其选项。选项设置在全局Script变量中,如下面的代码所示:

float Script : STANDARDSGLOBAL
<
  string Script =        
           "Public;"
           "SupportsDeferredShading;"
           "SupportsAttrInstancing;"
           "ShaderDrawType = Light;"
           "ShaderType = General;"
>;

纹理插槽

每个材质可以在一组纹理插槽中指定纹理的文件路径,如下所示:

纹理插槽

我们可以通过使用一组助手(如下所示)在着色器中访问这些纹理插槽,然后将其添加到自定义采样器中,然后可以使用GetTexture2D函数加载它们。

插槽名称 助手名称
扩散 $扩散
光泽(高光) $光泽
凹凸
凹凸高度图 $凹凸高度
环境 $环境
环境立方体贴图 $环境立方体贴图
细节 $细节
不透明度 $不透明度
贴花 $贴花叠加
次表面 $次表面
自定义 $自定义贴图
自定义次要 $自定义次要贴图

着色器标志

通过使用#ifdef#endif预处理器命令,可以定义在编译或运行时可以删除的代码区域。这允许使用单个超级着色器具有多个可切换的子效果,如 Illum。

例如,我们可以通过以下方式检查用户是否正在运行 DX11:

#if D3D11
// Include DX11 specific shader code here
#endif

材质标志

材质标志是通过材质编辑器设置的,允许每个材质使用不同的效果,如视差遮挡映射和镶嵌。材质标志在编译时进行评估。

要创建新的材质标志,请打开您的着色器的.ext文件,并使用以下代码创建一个新的属性:

Property
{
  Name = %MYPROPERTY
  Mask = 0x160000000
  Property    (My Property)
  Description (My property is a very good property)
}

现在当您重新启动编辑器时,您的属性应该出现在材质编辑器中。

以下是可能的属性数据列表:

属性数据 描述
名称 定义属性的内部名称,并且是您应该通过使用#ifdef块进行检查的名称。
掩码 用于识别您的属性的唯一掩码。不应与着色器定义(.ext)中其他属性的掩码冲突。
属性 属性的公共名称,在材质编辑器中显示。
描述 在材质编辑器中悬停在属性上时显示的公共描述。
依赖设置 当用户修改纹理插槽的值时,该属性被设置,材质标志将被激活。这在与隐藏标志结合使用时最常见。
依赖重置 当用户修改纹理插槽的值时,将清除该属性。用于避免与其他材质标志冲突。
隐藏 如果设置,属性将在编辑器中不可见。

引擎标志

引擎标志由引擎直接设置,并包含诸如当前支持的着色器模型或引擎当前运行的平台等信息。

运行时标志

运行时标志由%_RT_前缀定义,并且可以由引擎在运行时设置或取消设置。所有可用标志都可以在RunTime.ext文件中查看。

采样器

采样器是特定纹理类型的单个纹理的表示。通过创建自定义采样器,我们可以在着色器内引用特定纹理,例如加载包含预生成噪音的纹理。

预加载采样器的一个示例如下所示:

sampler2D mySampler = sampler_state
{
  Texture = EngineAssets/Textures/myTexture.dds;
  MinFilter = LINEAR;
  MagFilter = LINEAR;
  MipFilter = LINEAR;
  AddressU = Wrap;
  AddressV = Wrap;
  AddressW = Wrap;
}

我们现在可以在我们的代码中引用mySampler

使用采样器的纹理槽

在某些情况下,最好让采样器指向材质中定义的纹理槽之一。

为此,只需用您首选的纹理槽的名称替换纹理的路径:

sampler2D mySamplerWithTextureSlot = sampler_state
{
  Texture = $Diffuse;
  MinFilter = LINEAR;
  MagFilter = LINEAR;
  MipFilter = LINEAR; 
  AddressU = Wrap;
  AddressV = Wrap;
  AddressW = Wrap;
}

加载后,纹理将是材质在漫反射槽中指定的纹理。

获取纹理

现在我们有了一个纹理,我们可以学习如何在着色器中获取纹理数据。这是通过使用GetTexture2D函数来完成的,如下所示:

half4 myMap = GetTexture2D(mySampler, baseTC.xy);

第一个参数指定要使用的采样器(在我们的情况下,我们之前创建的采样器),而第二个参数指定纹理坐标。

在运行时操作静态对象

在这一部分,我们将学习如何在运行时修改静态网格,从而允许在游戏过程中操纵渲染和物理网格。

为此,首先我们需要获取我们对象的IStatObj实例。例如,如果您正在修改一个实体,您可以使用IEntity::GetStatObj,如下所示:

IStatObj *pStatObj = pMyEntity->GetStatObj(0);

注意

请注意,我们将0作为第一个参数传递给IEntity::GetStatObj。这样做是为了获取具有最高细节级别LOD)的对象。这意味着对这个静态对象所做的更改不会反映在其其他 LOD 中。

现在您有一个指向保存模型静态对象数据的接口的指针。

我们现在可以调用IStatObj::GetIndexedMeshIStatObj::GetRenderMesh。后者很可能是最好的起点,因为它是从优化的索引网格数据构建的,如下所示:

IIndexedMesh *pIndexedMesh = pStatObj->GetIndexedMesh();
if(pIndexedMesh)
{
  IIndexedMesh::SMeshDescription meshdesc;
  pIndexedMesh->GetMesh(meshdesc);
}

现在我们可以访问包含有关网格信息的meshdesc变量。

请注意,我们需要调用IStatObj::UpdateVertices以传递我们对网格所做的更改。

注意

请记住,更改静态对象将传递更改到使用它的所有对象。在编辑之前使用IStatObj::Clone方法创建其副本,从而允许您只操纵场景中的一个对象。

在运行时修改材质

在这一部分,我们将在运行时修改材质。

注意

IStatObj类似,我们还可以克隆我们的材质,以避免对当前使用它的所有对象进行更改。为此,请调用IMaterialManager::CloneMaterial,可通过gEnv->p3DEngine->GetMaterialManager()访问。

我们需要做的第一件事是获取我们想要编辑的材质的实例。如果附近有一个实体,我们可以使用IEntity::GetMaterial,如下所示:

IMaterial *pMaterial = pEntity->GetMaterial();

注意

请注意,如果没有设置自定义材质,IEntity::GetMaterial将返回 null。如果是这种情况,您可能希望依赖于诸如IStatObj::GetMaterial之类的函数。

克隆材质

请注意,IMaterial实例可以用于多个对象。这意味着修改对象的参数可能会导致检索对象之外的对象发生变化。

为了解决这个问题,我们可以简单地在通过IMaterialManager::Clone方法修改之前克隆材质,如下所示:

IMaterial *pNewMaterial = gEnv->p3DEngine->GetMaterialManager()->CloneMaterial(pMaterial);

然后我们只需将克隆应用于我们检索到原始实例的实体:

pEntity->SetMaterial(pNewMaterial);

现在我们可以继续修改材质的参数,或者与其分配的着色器相关的参数。

材料参数

在某些情况下修改我们材料的参数是很有用的。这使我们能够调整每种材料的属性,比如不透明度Alpha 测试漫反射颜色,如下截图所示:

材料参数

要设置或获取材料参数,请使用IMaterial::SetGetMaterialParamFloatIMaterial::SetGetMaterialVec3

例如,要查看我们材料的 alpha 值,使用以下代码:

float newAlpha = 0.5f;
pMaterial->SetGetMaterialParamFloat("alpha",  0.5f, false);

材料现在应该以半强度绘制 alpha。

以下是可用参数的列表:

参数名称 类型
"alpha" 浮点数
"不透明度" 浮点数
"发光" 浮点数
"光泽度" 浮点数
"漫反射" Vec3
"发光" Vec3
"高光" Vec3

着色器参数

正如我们之前学到的,每个着色器都可以公开一组参数,允许材料调整着色器的行为,而不会影响全局着色器。

着色器参数

要修改我们材料的着色器参数,我们首先需要获取与该材料关联的着色器项目:

const SShaderItem& shaderItem(pMaterial->GetShaderItem());

现在我们有了着色器项目,我们可以使用以下代码访问IRenderShaderResources::GetParameters

DynArray<SShaderParam> params = shaderItem.m_pShaderResources->GetParameters();

我们现在可以修改其中包含的参数,并调用IRenderShaderResources::SetShaderParams,如下所示:

// Iterate through the parameters to find the one we want to modify
for(auto it = params.begin(), end = params.end(); it != end; ++it)
{
  SShaderParam param = *it;

  if(!strcmp(paramName, param.m_Name))
  {
    UParamVal paramVal;
    paramVal.m_Float = 0.7f;

    // Set the value of the parameter (to 0.7f in this case)
    param.SetParam(paramName, &params, paramVal);

    SInputShaderResources res;
    shaderItem.m_pShaderResources->ConvertToInputResource(&res);

    res.m_ShaderParams = params;

    // Update the parameters in the resources.
    shaderItem.m_pShaderResources->SetShaderParams(&res,shaderItem.m_pShader);
    break;
  }
}

示例-植被动态 Alpha 测试

现在让我们来测试一下您的知识!

我们已经包含了一个树的设置,用于使用 alpha 测试属性与示例(如下截图所示)。当增加 alpha 测试时,模拟叶子掉落。

示例-植被动态 Alpha 测试

为了展示这一点,我们将编写一小段代码,在运行时修改这些参数。

首先创建一个名为CTreeOfTime的新类。要么创建一个新的游戏对象扩展,要么从我们在第三章中创建的示例中派生一个。

创建后,我们需要在实体生成时加载我们的树对象,如下所示:

void CTreeOfTime::ProcessEvent(SEntityEvent& event)
{
  switch(event.event) 
  { 
    case ENTITY_EVENT_INIT:
    case ENTITY_EVENT_RESET:
    case ENTITY_EVENT_START_LEVEL:
    {
      IEntity *pEntity = GetEntity();

      pEntity->LoadGeometry(0, "Objects/nature/trees/ash/tree_ash_01.cgf");
    }
    break;
  }
}

我们的实体现在应该在生成时将Objects/nature/trees/ash/tree_ash_01.cgf对象加载到其第一个槽(索引 0)中。

接下来,我们需要重写实体的Update方法,以便根据当前时间更新 alpha 测试属性。完成后,添加以下代码:

if(IStatObj *pStatObj = GetEntity()->GetStatObj(0))
{
  IMaterial *pMaterial = pStatObj->GetMaterial();
  if(pMaterial == nullptr)
    return;

  IMaterial *pBranchMaterial = pMaterial->GetSubMtl(0);
  if(pBranchMaterial == nullptr)
    return;

  // Make alpha peak at 12
  float alphaTest = abs(gEnv->p3DEngine->GetTimeOfDay()->GetTime() - 12) / 12;
  pBranchMaterial->SetGetMaterialParamFloat("alpha", alphaTest, false);
}

您现在应该有一个时间周期,在这个周期内,您的树会失去并重新长出叶子。这是通过在运行时修改材料可能实现的众多技术之一。

示例-植被动态 Alpha 测试

摘要

在本章中,我们已经学习了引擎如何使用着色器,并且已经分解了渲染过程。您现在应该知道如何使用渲染上下文,在运行时操纵静态对象,并以编程方式修改材料。

如果您还没有准备好继续下一章关于特效和声音的内容,为什么不接受一个挑战呢?例如,您可以创建一个在受到攻击时变形的自定义对象。

第十一章:效果和声音

CryENGINE 拥有非常模块化的效果系统,允许在运行时轻松生成效果。引擎还具有 FMOD 集成,为开发人员提供了动态播放音频、音乐和本地化对话的工具。

在本章中,我们将涵盖以下主题:

  • 学习有关效果和声音系统

  • 发现如何创建和触发材料效果

  • 学习如何通过 FMOD Designer 导出和自定义声音

  • 播放自定义声音

  • 学习如何将声音集成到粒子和物理事件中

引入效果

没有 FX,游戏世界通常很难相信,并且被认为是没有生命的。简单地添加声音和粒子等效果有助于使世界变得生动,给玩家带来更加沉浸式的世界感。

尽管引擎中没有一个统一的系统来处理所有类型的效果,但我们将涵盖处理各种效果的多个系统。这包括材料效果、粒子效果、音效等。

材料效果

材料效果系统处理材料之间的反应,例如,根据岩石落在的材料播放不同的粒子和声音效果。

表面类型

每种材料都被分配了一个表面类型,表示其是什么类型的表面。例如,如果我们正在创建一个岩石材料,我们应该使用mat_rock表面类型。

通过分配表面类型,物理系统将能够收集有关碰撞应如何行为的信息,例如,通过获取表面类型的摩擦值。多个表面类型之间的相互作用还允许根据彼此接触的表面类型动态改变效果。

可以很容易地通过编程方式查询表面类型,从而允许各种系统创建基于表面类型触发的不同代码路径。

在 C++中,表面类型由ISurfaceType接口表示,可通过IMaterial::GetSurfaceType获得。

使用 C#,表面类型由CryEngine.SurfaceType类表示,并且可以通过CryEngine.Material.SurfaceType属性检索。

添加或修改表面类型

表面类型在Game/Libs/MaterialEffects/SurfaceTypes.xml中定义。引擎在启动时解析该文件,允许材料使用加载的表面类型。

每种表面类型都是通过使用SurfaceType元素定义的,例如,如下代码所示的mat_fabric

<SurfaceType name="mat_fabric">
  <Physics friction="0.80000001" elasticity="0" pierceability="7" can_shatter="1"/>
</SurfaceType>

当发生碰撞时,物理系统会查询物理属性。

粒子效果

粒子效果由IParticleManager接口处理,可通过I3DEngine::GetParticleManager访问。要获得IParticleEffect对象的指针,请参阅IParticleManager::FindEffect

通过Sandbox Editor中包含的Particle Editor创建粒子效果,并通常保存到Game/Libs/Particles中。

音效

CryENGINE 声音系统由游戏音频内容创建工具 FMOD 提供支持。通过使用 FMOD,引擎支持轻松创建和操作声音,以立即在游戏中使用。

声音系统可以通过ISoundSystem接口访问,通常通过gEnv->pSoundSystem指针检索。声音由ISound接口表示,可以通过ISoundSystem::CreateSoundISoundSystem::GetSound检索到指针。

通过访问ISound接口,我们可以更改语义、距离倍增器等,以及通过ISound::Play实际播放声音。

FMOD Designer

设计师是我们每次想要向项目中使用的不同声音库添加更多声音时使用的工具。

FMOD Designer

设计师允许创建和维护声音库,本质上是创建不同声音之间的分离的库。声音库中包括事件、声音定义和音乐。这些可以被赋予静态和动态修饰符,例如根据游戏环境给声音赋予独特的 3D 效果。

创建和触发材料效果

有两种触发自定义材料效果的方法,如下节所述。

基于物理相互作用的自动播放

当两种材料由于物理事件发生碰撞时,引擎将根据分配给材料的表面类型在Game/Libs/MaterialEffects/MaterialEffects.xml中查找材料效果。这允许在发生某些交互时播放各种粒子和声音。

例如,如果岩石与木材发生碰撞,我们可以播放特定的声音事件以及木屑粒子。

首先,用 Microsoft Excel 打开MaterialEffects.xml

注意

虽然可以手动修改材料效果文档,但由于 Excel 格式的复杂性,这并不推荐。

基于物理相互作用的自动播放

现在您应该在 Excel 应用程序中看到材料效果表。各种表面类型以网格形式布置,行和列的交叉点定义了要使用的效果。

例如,根据上一个屏幕截图中显示的表,如果一个具有表面类型mat_flesh的材料与mat_vegetation表面发生碰撞,引擎将加载collision:vegetation_spruce效果。

注意

可以通过Libs/MaterialEffects/SurfaceTypes.xml查看(或修改)完整的表面类型列表。

添加新的表面类型

如果需要向材料效果文档中添加新的表面类型,只需添加一个相应的行和一个带有表面类型名称的列,以便引擎加载它。

注意

请记住,表面类型的名称必须按照相同的顺序出现在行和列中。

效果定义

现在我们知道系统如何为各种表面类型的碰撞找到效果,那么我们如何找到并创建效果呢?

效果以纯 XML 文件的形式包含在Libs/MaterialEffects/FXLibs/中。例如,先前使用的collision:vegetation_spruce效果的定义包含在Libs/MaterialEffects/FXLibs/Collision.xml中,内容如下:

<FXLib>
  <Effect name="vegetation_spruce">
    <Particle>
      <Name>Snow.Vegetation.SpruceNeedleGroup</Name>
    </Particle>
  </Effect>
</FXLib>

这告诉引擎在触发效果时播放指定的粒子。例如,如前所述,如果一个具有mat_flesh表面类型的材料与另一个mat_vegetation类型的材料发生碰撞,引擎将在碰撞位置生成Snow.Vegetation.SpruceNeedleGroup效果。

但是声音呢?声音可以通过事件以类似的方式播放,只需用声音的名称替换Particle标签,如下面的代码所示:

<Sound>
  <Name>Sounds/Animals:Animals:Pig</Name>
</Sound>

现在当效果播放时,我们应该能够听到猪的挣扎声。这就是当你撞到植被时会发生的事情,对吧?

注意

值得记住的是,一个效果不必包含一种特定类型的效果,而可以在触发时同时播放多种效果。例如,根据前面的代码,我们可以创建一个新的效果,在触发时播放声音并生成粒子效果。

触发自定义事件

还可以触发自定义材料效果,例如,当创建应基于交互名称不同的脚步效果时非常有用。

触发自定义事件

注意

冒号(':')代表效果类别,这是我们在Libs/MaterialEffects/FXLibs/文件夹中创建的效果库的名称。

上一个屏幕截图是以编程方式触发的自定义材料效果的较小选择。

要获取效果的 ID,请调用IMaterialEffects::GetEffectId,并提供交互名称和相关表面类型,如下所示。

IMaterialEffects *pMaterialEffects = gEnv->pGame->GetIGameFramework()->GetIMaterialEffects();

TMFXEffectId effectId = pMaterialEffects->GetEffectId("footstep_player", surfaceId);

注意

有许多获取表面标识符的方法。例如,使用RayWorldIntersection投射射线将允许我们通过ray_hit::surface_idx变量获取碰撞表面 ID。我们也可以简单地在任何材质实例上调用IMaterial::GetSurfaceTypeId

现在,我们应该有footstep_player效果的标识符,基于我们传递给GetEffectId的表面类型。例如,通过与先前的截图交叉引用,并假设我们传递了mat_metal标识符,我们应该有footstep_player:metal_thick效果的 ID。

然后,我们可以通过调用IMaterialEffects::ExecuteEffect来执行效果,如下所示:

SMFXRunTimeEffectParams params;
params.pos = Vec3(0, 0, 10);

bool result = gEnv->pGame->GetIGameFramework()->GetIMaterialEffects()->ExecuteEffect(effectId, params);

还可以通过调用IMaterialEffects::GetResources来获取效果资源,如下所示:

if(effectId != InvalidEffectId)
{
  SMFXResourceListPtr->pList = pMaterialEffects->GetResources(effectId);

  if(pList && pList->m_particleList)
  {
    const char *particleEffectName = pList->m_particleList->m_particleParams.name;
  }
}

基于动画的事件和效果

基于动画的事件可用于在动画的特定时间触发特定效果。例如,我们可以使用这个来将声音链接到动画,以确保声音始终与其对应的动画同步播放。

首先,通过Sandbox Editor打开Character Editor,加载任何角色定义,然后选择任何动画。

基于动画的事件和效果

在窗口底部中央选择Animation Control选项卡,并选择动画期间的任何时间,您想要播放声音的时间。

当滑块定位在应播放声音的时间上时,单击New Event

事件的Name字段应为sound,并将Parameter字段设置为要播放的声音路径。

基于动画的事件和效果

单击Save后,声音应该会在指定的时间与动画一起播放。

生成粒子发射器

Particle effects部分所述,粒子效果由IParticleEffect接口表示。但是,粒子效果与粒子发射器不同。效果接口处理默认效果的属性,并可以生成显示游戏中的视觉效果的单个发射器。

发射器由IParticleEmitter接口表示,通常通过调用IParticleEffect::Spawn来检索。

通过使用 FMod 导出声音

所以你想要将一些声音导出到引擎?我们需要做的第一件事是通过FMOD Designer创建一个新的 FMod 项目。要这样做,首先通过<Engine Root>/Tools/FmodDesigner/fmod_designer.exe打开设计师。

要创建新项目,请单击File菜单,选择New Project,然后将项目保存到您认为合适的位置。我们将保存到Game/Sounds/Animals/Animals.fdp

注意

有关 FMOD 音频系统的更深入教程,请参阅 CryENGINE 文档docs.cryengine.com/display/SDKDOC3/The+FMOD+Designer

向项目添加声音

现在我们有一个声音项目,是时候添加一些声音了。要这样做,请确保您在Events菜单中,Groups选项卡处于激活状态,如下截图所示:

向项目添加声音

现在,要添加声音,只需将.wav文件拖放到您选择的组中,然后它应该出现在那里。现在,您可以导航到Project | Build,或按Ctrl + B,以构建项目的波形库,这是引擎将加载以检测声音的内容。

向项目添加声音

通过向事件组添加更多声音,系统将在请求组时随机选择一个声音。

通过在 FMOD 中选择事件组,我们还可以修改其属性,从而调整声音在播放时的播放方式。

将声音添加到项目中

大多数属性静态影响声音,而以随机化结尾的属性会在运行时随机应用效果。例如,通过调整音高随机化,我们可以确保声音的音高会随机偏移我们选择的值,给声音增添独特的风格。

播放声音

在播放音频时,我们必须区分由程序员触发的动态声音和由关卡创建者触发的静态声音。

有多种触发音频事件的方式,应根据声音的目的进行评估。

使用 SoundSpots

声音点实体存在是为了让关卡设计师轻松地放置一个实体,以便在特定区域播放预定义的声音。声音实体支持循环声音,或者在从脚本事件触发时每次播放一次。

要使用声音点,首先通过 Rollupbar 放置一个新的SoundSpot实体的实例,或导航到Sound | Soundspot。放置后,您应该看到类似于以下屏幕截图的示例:

使用 SoundSpots

现在我们可以分配应该在该位置播放的声音。要这样做,请单击Source实体属性,然后通过Sound Browser窗口选择一个声音,如下面的屏幕截图所示:

使用 SoundSpots

然后可以设置SoundSpot以始终播放声音,或者通过流程图触发。例如,在下面的屏幕截图中,当玩家使用K键时,声音点将播放其声音。

使用 SoundSpots

以编程方式播放声音

要以编程方式播放声音,我们首先需要通过ISoundSystem::CreateSound检索与我们感兴趣的特定声音相关的ISound指针,如下所示:

ISound *pSound = gEnv->pSoundSystem->CreateSound("Sounds/Animals:Animals:Pig", 0);

然后可以通过ISound::Play直接播放声音,或将其附加到实体的声音代理:

IEntitySoundProxy *pSoundProxy = (IEntitySoundProxy *)pEntity->CreateProxy(ENTITY_PROXY_SOUND);

if(pSoundProxy)
  pSoundProxy->PlySound(pSound);

通过使用实体声音代理,我们可以确保声音在游戏世界中移动时跟随该实体。

声音标志

通过使用ISoundSystem::CreateSound接口创建声音时,我们可以指定一组标志,这些标志将影响我们声音的播放。

注意

在使用之前,需要在 FMOD 中设置一些标志。例如,具有 3D 空间效果的声音必须在 FMOD 中设置好才能在引擎中使用。

这些标志包含在ISound.h中,作为带有FLAG_SOUND_前缀的预处理器宏。例如,我们可以在我们的声音中应用FLAG_SOUND_DOPPLER标志,以便在播放时模拟多普勒效应。

声音语义

语义本质上是应用于声音的修饰符,每个声音都需要它才能播放。

不同的声音语义可以在ISound.h(在 CryCommon 项目中)中查看,其中包括ESoundSemantic枚举。

摘要

在这一章中,我们已经将声音从 FMOD 导入到引擎中,并学会了如何调整它们。

现在您应该知道如何通过 Sandbox 编辑器和以编程方式触发声音,并且对材质效果有了工作知识。

如果您还没有准备好进入下一章,为什么不尝试扩展您的知识呢?一个可能的选择是深入研究粒子编辑器,并创建自己的粒子,包括自定义效果和声音。

在下一章中,我们将介绍调试和分析游戏逻辑的过程,帮助您更高效地工作。

第十二章:调试和性能分析

创建高效且无 bug 的代码可能很困难。因此,引擎提供了许多工具来帮助开发人员,以便轻松识别错误并可视化性能问题。

在编写游戏和引擎逻辑时,始终牢记调试和性能分析工具非常重要,以确保代码运行良好并且可以轻松地扫描问题。在解决未来问题时,添加一些游戏日志警告可能非常重要,可以节省大量时间!

在本章中,我们将涵盖以下主题:

  • 学习调试 CryENGINE 应用程序的常见方法

  • 利用内置性能分析工具

  • 创建我们自己的控制台变量和命令

调试游戏逻辑

保持代码无 bug 可能非常困难,特别是如果你只依赖于调试器。即使没有连接到运行中的进程,CryENGINE 也会暴露一些系统来帮助调试代码。

始终牢记使用哪种配置构建 GameDll。在构建项目之前,可以在 Visual Studio 中更改此配置,如下面的屏幕截图所示:

调试游戏逻辑

默认情况下,有三种主要配置,如下表所示:

配置名称 描述
配置文件 在开发应用程序时使用,确保生成调试数据库。
调试 当您需要关闭编译优化以及专门为此模式打开的额外 CryENGINE 助手时使用。
发布 此模式旨在用于发送给最终用户的最终构建。此配置执行一系列操作,包括禁用调试数据库的生成和多个仅用于调试的 CryENGINE 系统。CryENGINE 游戏通常会将所有库(如 CryGame)链接到一个启动器应用程序中以确保安全。

记录到控制台和文件

日志系统允许将文本打印到控制台和根文件结构中包含的.log文件中。日志的名称取决于启动了哪个应用程序:

日志名称 描述
Game.log 由启动器应用程序使用。
Editor.log 仅供沙盒编辑器应用程序使用。
Server.log 用于专用服务器。

日志功能通常用于非常严重的问题,或者警告设计人员不支持的行为。

记录严重错误和初始化统计信息的最大好处是,通过简单地阅读用户的游戏日志,您通常可以弄清楚为什么您的代码在最终用户的计算机上无法正常工作。

日志冗长度

通过使用log_verbosity控制台变量(用于控制台的可视部分)和log_writeToFileVerbosity(用于写入磁盘的日志)来设置日志冗长度。

冗长度确定应该记录/显示哪些消息,并且对于过滤掉不太严重的消息非常有用。

冗长度级别 描述
-1(无日志记录) 抑制所有已记录的信息,包括CryLogAlways
0(始终) 抑制所有已记录的信息,不包括使用CryLogAlways记录的信息。
1(错误) 与级别 0 相同,但包括额外的错误。
2(警告) 与级别 1 相同,但包括额外的警告。
3(消息) 与级别 2 相同,但包括额外的消息。
4(注释) 最高冗长度,记录之前提到的所有内容以及额外的注释。

全局日志函数

以下是全局日志函数列表:

  • CryLog:此函数将消息记录到控制台和文件日志中,假设日志冗长度为 3 或更高。
CryLog("MyMessage");
  • CryLogAlways:此函数将消息记录到控制台和文件中,假设日志冗长度为 0 或更高。
CryLogAlways("This is always logged, unless log_verbosity is set to -1");
  • CryWarning:此函数向日志和控制台输出一个警告,前缀为[Warning]。它还可用于警告设计人员他们错误地使用功能。只有在日志详细程度为 2 或更高时才会记录到文件中。
CryWarning(VALIDATOR_MODULE_GAME, VALIDATOR_WARNING, "My warning!");
  • CryFatalError:此函数用于指定发生了严重错误,并导致消息框后跟程序终止。
CryFatalError("Fatal error, shutting down!");
  • CryComment:此函数输出一个注释,假设日志详细程度为 4。
CryComment("My note");

注意

注意:在 C#中,通过使用静态的Debug类来记录日志。例如,要记录一条消息,可以使用Debug.Log("message");

要使用 Lua 进行记录,可以使用System.Log函数,例如,System.Log("MyMessage");

持久调试

持久调试系统允许绘制持久性辅助工具,以在游戏逻辑上提供视觉反馈。例如,该系统在以下截图中用于在每一帧上绘制玩家在其世界位置面对的方向,其中每个箭头在消失之前持续了指定数量的秒数。

该系统可以带来非常有趣的效果,例如一种查看玩家旋转和物理交互的方式,如在免费游戏 SNOW 中显示的那样:

持久调试

C++

可以通过游戏框架访问IPersistantDebug接口,如下所示:

IPersistantDebug *pPersistantDebug = gEnv->pGame->GetIGameFramework()->GetIPersistantDebug();

在调用各种绘图函数之前,我们需要调用IPersistantDebug::Begin来表示应该开始新的持久调试组。

pPersistantDebug->Begin("myPersistentDebug", false);

最后一个布尔参数指定系统是否应清除所选范围内所有先前绘制的持久调试对象("myPersistentDebug")。

现在我们可以使用各种Add*函数,例如AddSphere

pPersistantDebug->AddSphere(Vec3(0, 0, 10), 0.3f, ColorF(1, 0, 0),2.0f);

在上一个片段中,系统将在游戏世界中的0010处绘制一个半径为0.3的红色球体。球体将在2秒后消失。

C#

在 C#中,可以通过使用静态的Debug类来访问持久调试接口。例如,要添加一个球体,可以使用以下代码:

Debug.DrawSphere(new Vec3(0, 0, 10), 0.3f, Color.Red, 2.0f);

CryAssert

CryAssert 系统允许开发人员确保某些变量保持在边界内。通过进行仅在开发构建中编译的检查,可以不断测试系统如何与其他系统交互。这对性能和确保功能不容易出错都很有好处。

可以通过使用sys_asserts CVar 来切换系统,并且可能需要在StdAfx头文件中定义USE_CRYASSERT宏。

要进行断言,请使用CRY_ASSERT宏,如下所示:

CRY_ASSERT(pPointer != nullptr)

然后每次运行代码时都会进行检查,除了在发布模式下,并且当条件为假时会输出一个大的警告消息框。

分析

在处理实时产品(如 CryENGINE)时,程序员不断地需要考虑其代码的性能。为了帮助解决这个问题,我们可以使用profile控制台变量。

CVar 允许获取代码最密集部分的可视化统计信息,如下截图所示:

分析

在上一个截图中,profile 设置为1,默认模式,对每一帧调用的最密集的函数进行排序。

使用情况分析

目前,profile 变量支持以下表中列出的 13 种不同状态:

描述
0 默认值;当设置为此值时,分析系统将处于非活动状态。
1 自身时间
2 分层时间
3 扩展自身时间
4 扩展分层时间
5 峰值时间
6 子系统信息
7 调用次数
8 标准偏差
9 内存分配
10 内存分配(以字节为单位)
11 停顿
-1 用于启用分析系统,而不将信息绘制到屏幕上。

在 C++中进行分析

在 C++中进行分析,我们可以利用FUNCTION_PROFILER预处理器宏定义,如下所示:

FUNCTION_PROFILER(GetISystem(), PROFILE_GAME);

该宏将设置必要的分析器对象:一个静态的CFrameProfiler对象,该对象保留在方法中,以及一个CFrameProfilerSection对象,每次运行该方法时都会创建(并在返回时销毁)。

如果分析器检测到您的代码与其他引擎功能的关系密切,它将在分析图表中显示更多,如下面的截图所示:

C++中的分析

如果要调试代码的某个部分,还可以使用FRAME_PROFILER宏,其工作方式与FUNCTION_PROFILER相同,只是允许您指定受监视部分的名称。

FRAME_PROFILER的一个示例用例是在if块内部,因为帧分析器部分将在块完成后被销毁:

if (true)
{
  FRAME_PROFILER("MyCheck", gEnv->pSystem, PROFILE_GAME);

  auto myCharArray = new char[100000];
  for(int i = 0; i < 100000; i++)
    myCharArray[i] = 'T';

  // Frame profiler section is now destroyed
}

现在我们可以在游戏中对先前的代码进行分析,如下面的截图所示:

C++中的分析

在 C#中进行分析

也可以以大致相同的方式对 C#代码进行分析。不同之处在于我们不能依赖托管代码中的析构函数/终结器,因此必须自己做一些工作。

我们首先要做的是创建一个CryEngine.Profiling.FrameProfiler对象,该对象将在实体的生命周期内持续存在。然后只需在每次需要对函数进行分析时在新的帧分析器对象上调用FrameProfiler.CreateSection,然后在使用以下代码时在生成的对象上调用FrameProfilerSection.End

using CryEngine.Profiling;

public SpawnPoint()
{
  ReceiveUpdates = true;

  m_frameProfiler = FrameProfiler.Create("SpawnPoint.OnUpdate");
}

public override void OnUpdate()
{
  var section = m_frameProfiler.CreateSection();

  var stringArray = new string[10000];
  for(int i = 0; i < 10000; i++)
    stringArray[i] = "is it just me or is it laggy in here";

  section.End();
}

FrameProfiler m_frameProfiler;

然后,分析器将列出SpawnPoint.OnUpdate,如下面的截图所示:

C#中的分析

控制台

尽管与调试没有直接关联,但 CryENGINE 控制台提供了创建命令的手段,这些命令可以直接从游戏中执行函数,并创建可以修改以改变世界行为方式的变量。

注意

有趣的是:通过在控制台中使用井号(#)符号,我们可以直接在游戏中执行 Lua,例如,#System.Log("My message!");

控制台变量

控制台变量,通常称为CVars,允许在 CryENGINE 控制台中公开代码中的变量,有效地允许在运行时或通过配置(.cfg)文件中调整设置。

几乎每个子系统都在运行时使用控制台变量,以便在不需要代码修改的情况下调整系统的行为。

注册 CVar

在注册新的 CVar 时,重要的是要区分引用变量和包装变量。

不同之处在于引用 CVar 指向您自己代码中定义的变量,当通过控制台更改值时会直接更新。

包装变量包含专门的ICVar(C++)实现中的变量本身,位于CrySystem.dll中。

引用变量最常用于 CVars,因为它们不需要每次想要知道控制台变量的值时都调用IConsole::GetCVar

在 C++中

要在 C++中注册引用控制台变量,请调用IConsole::Register,如下所示:

gEnv->pConsole->Register("g_myVariable", &m_myVariable, 3.0f, VF_CHEAT, "My variable description!");

现在,g_myVariable CVar 的默认值将是3.0f。如果我们通过控制台更改了值,m_myVariable将立即更新。

注意

要了解VF_CHEAT标志的作用,请参阅标志部分的进一步讨论。

要注册包装的控制台变量,请使用IConsole::RegisterStringRegisterFloatRegisterInt

在 C#中

要通过 CryMono 注册引用控制台变量,请使用CVar.RegisterFloatCVar.RegisterInt,如下面的代码所示:

float m_myVariable;

CVar.RegisterFloat("g_myCSharpCVar", ref m_myVariable, "My variable is awesome");

注意

由于 C++和 C#字符串的后端结构不同,因此无法创建引用字符串 CVars。

如果您喜欢使用包装变量,请使用CVar.Register

标志

在注册新的 CVar 时,开发人员应指定默认标志。标志控制变量在修改或查询时的行为。

  • VF_NULL: 如果没有其他标志存在,则将此标志设置为零。

  • VF_CHEAT: 此标志用于在启用作弊时防止更改变量,例如在发布模式或多人游戏中。

  • VF_READONLY: 用户永远无法更改此标志。

  • VF_REQUIRE_LEVEL_RELOAD: 此标志警告用户更改变量将需要重新加载级别才能生效。

  • VF_REQUIRE_APP_RESTART: 此标志警告用户更改将需要重新启动应用程序才能生效。

  • VF_MODIFIED: 当变量被修改时设置此标志。

  • VF_WASINCONFIG: 如果变量是通过配置(.cfg)文件更改的,则设置此标志。

  • VF_RESTRICTEDMODE: 如果变量应在受限制(发布)的控制台模式中可见和可用,则设置此标志。

  • VF_INVISIBLE: 如果变量不应在控制台中对用户可见,则设置此标志。

  • VF_ALWAYSONCHANGE: 此标志始终接受新值,并在值保持不变时调用更改回调。

  • VF_BLOCKFRAME: 此标志在使用变量后阻止执行更多控制台命令一帧。

  • VF_CONST_CVAR: 如果变量不应通过配置(.cfg)文件进行编辑,则设置此标志。

  • VF_CHEAT_ALWAYS_CHECK: 如果变量非常脆弱并且应该持续检查,则设置此标志。

  • VF_CHEAT_NOCHECK: 此标志与VF_CHEAT相同,只是由于对其进行的更改是无害的,因此不会进行检查。

控制台变量组

为了便于创建不同的系统规格(低/中/高/非常高的图形级别),也称为Sys Spec,我们可以利用 CVar 组。这些组允许在更改规范时同时更改多个 CVars 的值。

注意

如果您不确定 Sys Specs 的作用,请阅读本章后面讨论的系统规格部分。

要更改系统规范,用户只需更改sys_spec控制台变量的值。一旦更改,引擎将解析Engine/Config/CVarGroups/中的链接规范文件,并设置定义的 CVar 值。

例如,如果更改了sys_spec_GameEffects CVar,引擎将打开Engine/Config/CVarGroups/sys_spec_GameEffects.cfg

注意

sys_spec_Full组被视为根组,并且在更改sys_spec CVar 时触发。当更改时,它将更新所有子组,例如sys_spec_Quality

Cfg 结构

CVar 组配置文件的结构相对容易理解。例如,查看以下sys_spec_GameEffects文件:

[default]
; default of this CVarGroup
= 3

i_lighteffects = 1
g_ragdollUnseenTime = 2
g_ragdollMinTime = 13
g_ragdollDistance = 30

[1]
g_ragdollMinTime = 5
g_ragdollDistance = 10

[2]
g_ragdollMinTime = 8
g_ragdollDistance = 20

[3]

[4]
g_ragdollMinTime = 15
g_ragdollDistance = 40

前三行定义了此配置文件的默认规范,本例中为高(3)。

在默认规范之后是高规范中 CVars 的默认值。除非被覆盖,否则这些值将被用作基线并应用于所有规范。

在默认规范之后是低规范([1])、中等规范([2])和非常高规范([4])。在定义之后放置的 CVars 定义了在该规范中应将变量设置为的值。

系统规格

当前系统规范由sys_spec CVar 的值确定。更改变量的值将自动加载为该规范专门调整的着色器和 CVar 组。例如,如果游戏在您的 PC 上运行得有点糟糕,您可能想将规范更改为低(1)。

  • 0: 自定义

  • 1: 低

  • 2: 中等

  • 3: 高

  • 4: 非常高

  • 5: Xbox 360

  • 6: PlayStation 3

控制台命令

控制台命令(通常称为CCommands)本质上是已映射到控制台变量的函数。但是,与将命令输入控制台时更改引用变量的值不同,调用将触发在注册命令时指定的函数。

注意

请注意,控制台变量还可以指定On Change回调,在值更改时会自动调用。当内部变量与您的意图无关时,请使用控制台命令。

在 C#中注册控制台命令

要在 C#中注册控制台命令,请使用ConsoleCommand.Register,如下面的代码所示:

public void OnMyCSharpCommand(ConsoleCommandArgs e)
{
}

ConsoleCommand.Register("MyCSharpCommand", OnMyCSharpCommand, "C# CCommands are awesome.");

在控制台中触发MyCSharpCommand现在将导致调用OnMyCSharpCommand函数。

参数

当触发回调时,您将能够检索在命令本身之后添加的参数集。例如,如果用户通过键入MyCommand 2来激活命令,我们可能希望检索字符串的2部分。

为此,请使用ConsoleCommandArgs.Args数组,并指定要获取的参数的索引。对于前面的示例,代码将如下所示:

string firstArgument = null;
if(e.Args.Length >= 1)
  firstArgument = e.Args[0];

注意

要检索使用命令指定的完整命令行,请使用ConsoleCommandArgs.FullCommandLine

在 C++中创建控制台命令

要在 C++中添加新的控制台命令,请使用IConsole::AddCommand,如下所示:

void MyCommandCallback(IConsoleCmdArgs *pCmdArgs)
{
}
gEnv->pConsole->AddCommand("MyCommand", MyCommandCallback, VF_NULL, "My command is great!");

编译并启动引擎后,您将能够在控制台中键入MyCommand并触发您的MyCommandCallback函数。

摘要

在本章中,我们有:

  • 学会了如何使用引擎的一些调试工具

  • 对我们的代码进行了性能优化

  • 学习了什么是控制台变量(CVars),以及如何使用它们

  • 创建自定义控制台命令

现在,您应该对如何在 CryENGINE 中进行最佳编程有了基本的了解。请确保始终牢记性能分析和调试方法,以确保您的代码运行良好。

假设您按顺序阅读了本书的章节,现在您应该了解最重要的引擎系统的运作方式。我们希望您喜欢阅读,并祝您在使用您新获得的 CryENGINE 知识时一切顺利!

posted @ 2024-05-17 17:51  绝不原创的飞龙  阅读(40)  评论(0编辑  收藏  举报