游戏不仅仅是简单的机械和底层引擎。它们由复杂的游戏系统组成,允许我们与游戏世界互动,让我们感到被包容和沉浸。这些系统通常需要大量的时间和开发人员的专业知识来实现。在这一章中,我们将看几个这样的高级游戏系统,以及当我们在自己的项目中实现它们时,我们如何给自己一层帮助。
本章包括以下主题:
- 实现脚本语言
- 构建对话系统
- 编写任务脚本
如前所述,实现一个先进的游戏系统通常需要花费许多编码时间,并且可能需要开发人员在该特定系统中拥有专业知识。然而,通过包含对脚本语言的支持,我们可以让自己和其他从事该项目的人更容易做到这一点。
你可能想知道为什么我们花时间谈论脚本语言,毕竟这是一本关于 C++ 的书。为什么要加入脚本语言?我们就不能用 C++ 构建整个引擎和游戏吗?是的,我们可以!然而,一旦你开始在越来越大的项目上工作,你会很快注意到每次你需要做出改变时,在编译和重新编译上所损失的时间。虽然有一些方法可以解决这个问题,比如将游戏和引擎分成更小的模块并动态加载它们,或者使用 JSON 或 XML 描述性文件系统,但是像这样的技术不能提供实现脚本系统的所有好处。
那么,在游戏引擎中添加脚本语言有什么好处呢?首先,您将使用的大多数脚本语言都是解释语言,这意味着与 C++ 不同,您不必编译代码。相反,您的代码是在运行时加载和执行的。这样做的最大好处是,您可以对脚本文件进行更改,并快速看到结果,而不必重新编译整个游戏。事实上,您可以在游戏运行时动态重新加载脚本,并立即看到更改。与 C++ 这样的语言相比,使用脚本语言的另一个可能的好处是使用起来感觉很容易。大多数脚本语言都是动态类型的,具有简化的语法和结构。这可以为团队中有创造力的一方,如艺术家和设计师,提供机会,使他们能够对项目进行小的更改,而不需要理解像 C++ 这样的语言的复杂性。想象一下,图形用户界面设计者能够创建、放置和修改图形用户界面元素,而不需要知道 IGUI 框架是如何实现的。添加脚本支持也为社区内容支持开辟了一条道路——认为地图、关卡和项目都是由游戏玩家设计的。这正在成为新游戏的一个巨大卖点,并为你的头衔提供了一些可能的寿命。关于长寿这个话题,DLC 的实现可以通过脚本来完成。这允许更快的开发周转,并且可以放入游戏中而不需要很大的补丁。
这些是使用脚本语言的一些好处,但它们并不总是每种情况下的最佳解决方案。脚本语言因运行速度比本机代码慢而臭名昭著,正如我们所知,在构建游戏时,性能很重要。那么,什么时候应该使用脚本而不是使用 C++?我们将仔细研究一些系统示例,但是作为一个简单的规则,您应该始终使用 C++ 来处理任何可以被认为是 CPU 密集型的事情。程序流和其他高级逻辑是脚本的绝佳选择。让我们看看脚本可以在我们的游戏引擎组件中使用的地方。
让我们从物理成分开始。当然,当我们想到物理时,我们会立即想到大量的 CPU 使用。在很大程度上,这是真的。物理系统的核心应该用 C++ 构建,但是也有机会将脚本引入这个系统。以物理材料的概念为例。我们可以在脚本中定义材料的属性,比如质量、摩擦力、粘度等等。我们甚至可以从脚本内部修改这些值。脚本在物理系统中的另一个潜在用途是定义对碰撞的反应。我们可以在脚本中处理声音、特效和其他事件的生成。
AI 系统怎么样?可以说,这是脚本语言在游戏引擎中最常见的用途之一,我们将在下一章中更深入地研究它。人工智能系统的许多组件都可以移动到脚本中。这些包括复杂行为定义,人工智能目标的规范,人工智能间的交流,人工智能个性和特征的定义,以及更多。虽然列表很大,但您应该注意到,给出的示例不是 CPU 密集型的,而且人工智能系统的复杂组件,如寻路、模糊逻辑和其他密集型算法,也应该用 C++ 代码来处理。
你甚至可以给看似 CPU 和 GPU 繁重的系统添加脚本,比如图形引擎。脚本可以处理照明参数的设置,调整像雾一样的效果,甚至可以在屏幕上添加和删除游戏元素。正如您所看到的,引擎中几乎没有什么是不能用某种形式的脚本抽象来补充的。
那么,应该使用什么脚本语言呢?有很多选择,从游戏特定的语言,如 GameMonkey(在撰写本书时似乎已经不存在),到更通用的语言,如 Python 和 JavaScript。选择真的要看你的具体需求。虽然像 Python 和 JavaScript 这样的语言有一些惊人的特性,但是它们增加了学习和执行的复杂性来获得这些特性。对于本书中的例子,我们将使用一种叫做 Lua 的语言。Lua 已经存在多年,虽然近年来它的受欢迎程度有所下降,但它在游戏开发行业有着非常强大的记录。在本章的下一部分,我们将更好地了解 Lua,并看看我们如何将其整合到现有的引擎系统中。
Lua,发音为 LOO-ah,是一种轻量级、可嵌入的脚本语言。它支持现代编程方法,如面向对象、数据驱动、函数和过程编程。Lua 是一种可移植的语言,几乎可以在所有提供标准 C 编译器的系统上构建。Lua 运行在所有风格的 Unix、Windows 和 Mac 上。Lua 甚至可以在运行 Android、iOS、Windows Phone 和 Symbian 的移动设备上找到。这使得它非常适合大多数游戏标题,也是包括暴雪娱乐在内的公司将其用于《魔兽世界》等标题的主要原因之一。Lua 也是免费的,根据麻省理工学院许可许可证分发,可以用于任何商业目的,不产生任何费用。
Lua 也是一种简单但强大的语言。在 Lua 中,只有一个称为表的数据结构。这种表数据结构可以像简单的数组、键值字典一样使用,我们甚至可以通过使用表作为原型来实现一种 OOP 形式。这非常类似于用 JavaScript 等其他语言来做 OPP。
虽然我们不会详细介绍这种语言,但是有一些很好的资源可以利用,包括 Lua 文档网站。我们要做的是略读一些关键的语言概念,我们将在整个例子中看到这些概念。
让我们从变量和简单的程序流程开始。在 Lua 中,所有的数字都是双精度的。您可以使用以下语法分配一个号码:
number = 42
请注意,缺少类型标识符和分号来表示语句结束。
Lua 中的字符串可以用几种方式定义。您可以用单引号来定义它们,如下所示:
string = 'single quote string'
您也可以使用双引号:
string = "double quotes string"
对于跨越多行的字符串,可以使用双方括号来表示字符串的开始和结束:
string = [[ multi-line
string]]
Lua 是一种垃圾收集语言。您可以通过将对象设置为nil
来移除定义,相当于 C++ 中的空:
string = nil
Lua 中的语句块用do
、end
等语言关键字表示。一个while
循环块如下所示:
while number < 100 do
number = number + 1
end
您可能注意到我们在这里使用了数字+ 1
,因为在 Lua 语言中没有递增和递减运算符(++
、--
)。
if
条件代码块如下所示:
if number > 100 then
print('Number is over 100')
elseif number == 50 then
print('Number is 50')
else
print(number)
end
Lua 中的函数以类似的方式构造,使用 end 表示函数代码语句块的完成。一个计算斐波那契数的简单函数类似于下面的例子:
function fib(number)
if number < 2 then
return 1
end
return fib(number - 2) + fib(number -1)
end
如上所述,表是 Lua 语言中唯一的复合数据结构。它们被认为是关联数组对象,非常类似于 JavaScript 对象。表是哈希查找字典,也可以被视为列表。将表用作地图/字典看起来像下面的示例:
table = { key1 = 'value1',
key2 = 100,
key3 = false }
处理表格时,还可以使用类似 JavaScript 的点符号。例如:
print (table.key1)
Prints the text value1
table.key2 = nil
这将从表格中删除key2
。
table.newKey = {}
这将向表中添加一个新的键/值对。
我们对 Lua 语言细节的快速了解到此结束;在我们构建示例的过程中,您将有机会了解更多信息。如果你想了解更多关于 Lua 的知识,我再次推荐你阅读官方网站http://www.lua.org/manual/5.3/上的文档。
在下一节中,我们将看看在我们的示例游戏引擎项目中包含 Lua 语言支持的过程。
为了在我们的示例引擎中使用 Lua,我们需要采取一些步骤。首先,我们需要获得 Lua 解释器作为一个库,然后我们可以将其包含在我们的项目中。接下来,我们必须获得或者构建我们自己的助手桥,以使我们的 C++ 代码和 Lua 脚本之间的交互更加容易。最后,我们将不得不公开或绑定函数、变量和其他我们希望访问 Lua 脚本的对象。虽然这些步骤对于每个实现可能略有不同,但这将为我们接下来的示例提供一个良好的起点。
首先,我们需要一个 Lua 的副本作为我们可以在引擎中使用的库。对于我们的例子,我们将使用 Lua 5.3.4,在编写本文时,它是该语言的最新版本。在示例中,我选择使用动态库。您可以在 Lua 项目网站(http://luabinaries.sourceforge.net/)上的预编译二进制文件页面下载库的动态和静态版本,以及必要的包含文件。下载预编译库后,提取它,然后在我们的项目中包含必要的文件。我不会再经历在我们的项目中包括一个库的过程。如果您确实需要复习,请翻到第二章、了解库,我们在这里详细介绍了各个步骤。
正如我们在整本书中看到的其他例子一样,有时创建助手类和函数以允许各种库和组件之间更容易的互操作是很重要的。当我们使用 Lua 时,情况又是这样。为了让作为开发人员的我们更容易进行交互,我们需要创建一个桥类和函数来提供我们需要的功能。我们可以使用 Lua 本身提供的接口来构建这个桥,Lua 本身有很好的文档,但是也可以选择使用为此目的而创建的众多可用库中的一个。对于本章和本书其余部分中的示例,我选择使用sol2
库(https://github.com/ThePhD/sol2,因为该库是轻量级的(仅头部库),速度快,并且提供了我们示例所需的所有功能。有了这个库,将会抽象出很多桥的维护,让我们能够专注于实现。要在我们的项目中使用这个库,我们所要做的就是将单头实现复制到我们的include
文件夹中,它就可以使用了。
现在我们已经有了 Lua 引擎和sol2
桥库,我们可以进入最后一步,脚本的实现。如上所述,为了让我们使用底层的游戏引擎组件,它们必须首先暴露给 Lua。这就是sol2
库的位置。为了演示如何在我们的示例引擎中实现这一点,我创建了一个名为Bind_Example
的小项目。您可以在代码库中的Chapter08
文件夹中找到完整的源代码。
首先,让我们看看 Lua 脚本本身。在这种情况下,我调用了我的BindExample.lua
,并将其放在我的示例项目父目录的Scripts
文件夹中:
player = {
name = "Bob",
isSpawned = false
}
function fib(number)
if number < 2 then
return 1
end
return fib(number - 2) + fib(number -1)
end
在这个例子中,我们的 Lua 脚本非常基础。我们有一个名为player
的表,它有两个元素。一个键为name
值为Bob
的元素,一个键为isSpawned
值为false
的元素。接下来,我们有一个名为fib
的简单 Lua 函数。该函数将计算斐波那契数列中的所有数字,直到传入的数字。我认为在这个例子中偷偷加入一些数学知识会很有趣。我应该注意到,这个计算在序列越高的情况下会变得越密集,所以如果你想让它快速处理,不要传入大于 20 的数字。
这给了我们一些使用 Lua 代码的快速例子。现在我们需要将我们的程序及其逻辑连接到这个新创建的脚本。对于这个例子,我们将把这个连接代码添加到我们的GameplayScreen
类中。
我们首先为sol2
库添加必要的包含:
#include <sol/sol.hpp>
接下来,我们将创建 Lua 状态。Lua 中的state
可以被认为类似于您的代码的操作环境。把它想象成一个虚拟机。这个state
是您的代码将被执行的地方,通过这个state
,您将可以访问在其中运行的代码:
sol::state lua;
然后,我们打开一些我们在 Lua 代码交互中需要的助手库。这些库可以看作是 C++ 中#include
的等价物。Lua 的理念是保持核心小,并通过这些库提供更多的功能:
lua.open_libraries(sol::lib::base, sol::lib::package);
打开库之后,我们可以继续加载实际的 Lua 脚本文件。我们通过调用我们之前创建的 Lua state
的script_file
方法来做到这一点。此方法接受一个参数:文件的位置为字符串。执行此方法时,文件将被自动加载和执行:
lua.script_file("Scripts/PlayerTest.lua");
现在加载了脚本,我们可以开始与它交互了。首先,让我们看看如何在 Lua 中从变量(表)中提取数据,并在我们的 C++ 代码中使用它:
std::string stringFromLua = lua["player"]["name"];
std::cout << stringFromLua << std::endl;
从 Lua 脚本中检索数据的过程非常简单。在这种情况下,我们创建了一个名为stringFromLua
的字符串,并为它分配了存储在 Lua 表玩家的name
元素中的值。语法看起来类似于调用数组元素,但这里我们用字符串指定元素。如果我们想要isSpawned
元素值,我们将使用lua["player"]["isSpawned"]
,在我们的例子中,它将返回一个布尔值false
。
调用 Lua 函数和检索值一样简单,非常相似:
double numberFromLua = lua["fib"](20);
std::cout << numberFromLua << std::endl;
这里我们创建了一个类型为 double 的变量,称为numberFromLua
,并赋予它 Lua 函数fib
的返回值。这里,我们将函数名指定为一个字符串,fib
,然后指定该函数所需的任何参数。在这个例子中,我们传递值 20 来计算斐波那契数列,直到第二十个数字。
如果您运行Bind_Example
项目,您将在引擎的命令窗口中看到以下输出:
虽然这涵盖了我们的 C++ 代码和 Lua 脚本系统之间交互的基础,但还有很多需要发现的地方。在接下来的几节中,我们将研究如何利用这种脚本结构来增强各种先进的游戏系统,并为我们提供一种灵活的方式来扩展我们的游戏项目。
与游戏世界互动的最常见形式之一是通过某种形式的对话。能够与NPC
类交流,获取信息和任务,当然,通过对话推动故事叙述是大多数现代游戏标题的必备条件。虽然您可以轻松地对交互进行硬编码,但这种方法留给我们的灵活性很小。每次我们想要对任何对话框或交互进行细微的更改时,我们都必须打开源代码,深入项目,进行任何必要的更改,然后重新编译以查看效果。显然,这是一个繁琐的过程。只要想想你玩过多少个出现拼写、语法或其他错误的游戏。好消息是我们可以采取另一种方法。使用脚本语言,比如 Lua,我们可以以动态的方式驱动我们的交互,这将允许我们进行快速的更改,而不需要前面描述的繁琐过程。在本节中,我们将了解构建对话系统的详细过程,在高级描述中,该系统将加载脚本,将其附加到NPC
上,向玩家呈现带有选项的对话,最后,基于返回的玩家输入驱动对话树。
首先,我们需要在示例引擎中构建基础设施,以支持对话系统的脚本编写。有数千种不同的方法可以实现这一点。对于我们的例子,我将尽最大努力保持简单。我们将使用前几章中学习的一些技术和模式,包括状态和更新模式,以及我们构建的用于处理交互和显示的图形用户界面系统。
他们说一张图片胜过千言万语,所以为了让您大致了解这个系统将如何连接,让我们看一下描述所有类之间连接的代码映射图:
这里发生了一点事情,所以我们将按类进行分类。首先,我们来看看DialogGUI
课。这个类建立在我们在前一章构建的 IGUI 示例的基础上。因为我们已经深入研究了 IGUI 类的设计,所以我们将只讨论我们正在添加的特定方面,以提供我们的对话系统所需的功能。
首先,我们需要一些变量来保存对话框和任何我们想提供给玩家的选择。在DialogGUI.h
中,我们有以下内容:IGUILabel
对象的矢量用于选择,单个IGUILabel
用于对话框。关于IGUILabel
类的实现,请看一下它的源代码:
std::vector<BookEngine::IGUILabel*> choices;
BookEngine::IGUILabel* m_dialog;
接下来,我们需要添加一些新的功能,为我们的图形用户界面和脚本提供的数据提供所需的交互。为此,我们将向我们的DialogGUI
类添加三种方法:
void SetDialog(std::string text);
void SetOption(std::string text, int choiceNumber);
void RemoveAllPanelElements();
SetDialog
功能,顾名思义,将处理每个交互屏幕的对话文本的设置。该函数只接受一个参数,也就是我们要放在图形用户界面上进行交互的文本:
void DialogGUI::SetDialog(std::string text)
{
m_dialog = new BookEngine::IGUILabel(glm::vec4(0, 110, 250, 30),
glm::vec2(110, -10),
text,
new BookEngine::SpriteFont("Fonts/Impact_Regular.ttf", 72),
glm::vec2(0.3f), m_panel);
AddGUIElement(*m_dialog);
}
在函数体中,我们将m_dialog
标签变量分配给一个IGUILabel
对象的新实例。构造函数看起来应该类似于前面看到的IGUIButton
,文本值被传入。最后,我们通过调用AddGUIElement
方法将标签添加到图形用户界面面板。
SetOption
功能,同样如其名称所示,为当前交互屏幕上的每个选项设置文本。这个函数有两个参数。第一个是我们想要设置IGUILabel
的文本,第二个是选项号,这是它在正在呈现的选项列表中的编号。我们用它来查看选择了哪个选项:
void DialogGUI::SetOption(std::string text, int choiceNumber)
{
choices.resize(m_choices.size() + 1);
choices[choiceNumber] =
new BookEngine::IGUILabel(glm::vec4(0, 110, 250, 20),
glm::vec2(110, 10),
text,
new BookEngine::SpriteFont("Fonts/Impact_Regular.ttf", 72),
glm::vec2(0.3f), m_panel);
AddGUIObject(*choices[choiceNumber]);
}
在函数体中,我们正在做一个与SetDialog
函数非常相似的过程。不同的是,我们将把IGUILabel
实例添加到选择向量中。首先,我们执行一个小技巧,将向量的大小增加一,这将允许我们将新的标签实例分配给传入的选择编号值处的向量位置。最后,我们使用AddGUIElement
方法调用将IGUILabel
添加到面板中。
我们添加到DialogGUI
类的最后一个函数是RemoveAllPanelElements
,它当然会处理移除我们添加到当前对话框屏幕的所有元素。我们删除了这些元素,这样我们就可以重用面板,避免每次更改交互时都重新创建面板:
void DialogGUI::RemoveAllPanelElements()
{
m_panel->RemoveAllGUIElements();
}
RemoveAllGUIElements
函数反过来只是在m_panel
对象上调用相同的方法。IGUIPanel
类的实现简单地调用向量上的 clear 方法,移除其所有元素:
void RemoveAllGUIObjects() { m_GUIObjectsList.clear(); };
这照顾到了我们的对话系统的图形用户界面设置,所以现在我们可以继续构建NPC
类,它将处理大部分脚本到引擎的桥接。
正如我之前提到的,我们将使用我们在前面例子中学习的一些模式来帮助我们构建我们的对话系统。为了帮助我们控制何时构建图形用户界面元素,何时等待玩家做出选择,我们将使用有限状态机和更新模式。首先,在NPC.h
文件中,我们有enum
,它将定义我们将使用的状态。在这种情况下,我们只有两个州,Display
和WaitingForInput
:
...
enum InteractionState
{
Display,
WaitingForInput,
};
...
当然,我们还需要一种方法来跟踪状态,所以我们有一个名为currentState
的InteractionState
变量,我们会将其设置为当前状态。稍后,我们将在Update
功能中看到这个状态机的完成:
InteractionState currentState;
我们还需要一个变量来保存我们的 Lua 状态,我们在本章的前一节中已经看到了:
sol::state lua;
您可能还记得之前显示的代码图,我们的NPC
将有一个DialogGUI
的实例,用于处理对话框内容的显示和与玩家的交互,因此我们还需要一个变量来保存它:
DialogGUI* m_gui;
接下来是NPC
类的实现,我们将首先查看NPC.cpp
文件中该类的构造函数:
NPC::NPC(DialogGUI& gui) : m_gui(&gui)
{
std::cout << "Loading Scripts n";
lua.open_libraries(sol::lib::base, sol::lib::package, sol::lib::table);
lua.script_file("Scripts/NPC.lua");
currentState = InteractionState::Display;
}
构造函数接受一个参数,一个我们将用于交互的对话框实例的引用。我们将此引用设置为成员变量m_gui
以备后用。然后,我们处理将要使用的 Lua 脚本的加载。最后,我们将内部状态机的当前状态设置为Display
状态。
让我们重新审视我们的代码图,看看我们需要实现哪些不同的连接来将NPC
类加载的脚本信息传递给我们附加的图形用户界面实例:
正如我们所看到的,我们有两种处理连接的方法。Say
功能是两者中最简单的。这里,NPC
类只是在附加的图形用户界面上调用SetDialog
方法,传递一个包含要显示的对话框的字符串:
void NPC::Say(std::string stringToSay)
{
m_gui->SetDialog(stringToSay);
}
PresentOptions
功能对其影响稍大。首先,该函数从 Lua 脚本中检索一个表,该表表示当前交互的选项,我们将很快看到脚本中是如何设置的。接下来,我们将遍历该表,如果它有效,只需在附加的图形用户界面上调用SetOption
方法,将选择文本作为字符串和用于选择的选择号传递:
void NPC::PresentOptions()
{
sol::table choices = lua["CurrentDialog"]["choices"];
int i = 0;
if (choices.valid())
{
choices.for_each([&](sol::object const& key, sol::object const& value)
{
m_gui->SetOption(value.as<std::string>(), i);
i++ ;
});
}
}
对话系统引擎端的最后一块我们需要放入的地方是Update
方法。正如我们多次看到的,这种方法将推动系统向前发展。通过连接到引擎的现有Update
事件系统,我们的NPC
类Update
方法将能够控制我们的对话系统在每一帧上发生的事情:
void NPC::Update(float deltaTime)
{
switch (currentState)
{
case InteractionState::Display:
Say(lua["CurrentDialog"]["say"]);
PresentOptions();
currentState = InteractionState::WaitingForInput;
break;
case InteractionState::WaitingForInput:
for (int i = 0; i < m_gui->choices.size(); i++)
{
if (m_gui->choices[i]->GetClickedStatus() == true)
{
lua["CurrentDialog"]["onSelection"](m_gui->
choices[i]->GetLabelText());
currentState = InteractionState::Display;
m_gui->choices.clear();
m_gui->RemoveAllPanelElements ();
}
}
break;
}
}
与我们之前的有限状态机实现一样,我们将使用一个开关案例来确定基于当前状态应该运行什么代码。对于这个例子,我们的Display
状态是我们将要调用连接方法Say
和PresentOptions
的地方。在这里,Say
调用正在单独传递它从已经加载的脚本文件中提取的文本。接下来我们将在脚本中看到这是如何工作的。在这个例子中,如果我们处于WaitingForInput
状态,我们将遍历我们加载的每个选项,看看玩家是否已经选择了其中的任何一个。如果找到一个,我们将回调脚本,告诉它选择了哪个选项。然后我们将状态切换到Display
状态,这将启动下一个对话屏幕的加载。然后,我们将在附加的DisplayGUI
中清除我们的选择向量,允许它加载下一组选择,最后调用RemoveAllPanelElements
方法来清理我们的图形用户界面以供重用。
有了Update
方法,我们现在有了处理NPC
交互脚本所需的加载、显示和输入处理的所有框架。接下来,我们将看看如何构建这些脚本中的一个,用于我们引擎新创建的对话系统。
对话或对话树可以被认为是确定的交互流。本质上,它的工作原理是首先提供一个声明,然后,基于所呈现的响应的选择,交互可以分支到不同的路径。下图显示了我们的示例对话流是如何确定的:
这里,我们以一个介绍开始对话树。然后给用户两个选择:是,需要帮助和不,别管我。如果用户选择是路径,那么我们进入快速帮助对话框。如果用户选择否,我们进入再见人对话框。在表达帮助对话框中,我们给出了三个选择:好、重新开始、弱。基于这个选择,我们再次进入对话树的下一个阶段。好的进入离开美好对话框。弱引出再见人对话框,重新开始嗯,重新开始。这是一个基本的例子,但是它展示了对话树如何工作的整体概念。
现在让我们看看如何在 Lua 脚本引擎中实现这个示例树。以下是完整的脚本,接下来我们将深入了解细节:
intro = {
say = 'Hello I am the Helper NPC, can I help you?',
choices = {
choice1 = "Yes! I need help",
choice2 = "No!! Leave me alone"
},
onSelection = function (choice)
if choice == CurrentDialog["choices"]["choice1"] then CurrentDialog = getHelp end
if choice == CurrentDialog["choices"]["choice2"] then CurrentDialog = goodbye_mean end
end
}
getHelp = {
say = 'Ok I am still working on my helpfulness',
choices = {
choice1 = "That's okay! Thank you!",
choice2 = "That's weak, what a waste!",
choice3 = "Start over please."
},
onSelection = function (choice)
if choice == CurrentDialog["choices"]["choice1"] then CurrentDialog = goodbye
elseif choice == CurrentDialog["choices"]["choice2"] then CurrentDialog = goodbye_mean
elseif choice == CurrentDialog["choices"]["choice3"] then CurrentDialog = intro end
end
}
goodbye = {
say = "See you soon, goodbye!"
}
goodbye_mean = {
say = "Wow that is mean, goodbye!"
}
CurrentDialog = intro
如你所见,整个剧本没那么长。我们有几个概念使这个脚本工作。首先是一个非常简单的状态机版本。我们有一个名为CurrentDialog
的变量,这个变量将指向活动对话框。在我们脚本的最后,我们首先将其设置为intro
对话框对象,这将在加载脚本时启动对话框树。我们在脚本设计中的下一个重要概念是每个交互屏幕都被描述为一个表对象的概念。让我们以介绍对话框表格为例:
intro = {
say = 'Hello I am the Helper NPC, can I help you?',
choices = {
choice1 = "Yes! I need help",
choice2 = "No!! Leave me alone"
},
onSelection = function (choice)
if choice == CurrentDialog["choices"]["choice1"] then CurrentDialog = getHelp end
if choice == CurrentDialog["choices"]["choice2"] then CurrentDialog = goodbye_mean end
end
}
每个对话框表格对象都有一个Say
元素,这个元素是当Say
函数向脚本询问其对话框内容时将显示的文本。接下来,我们有两个可选的元素,但如果你想与玩家互动,这是必需的。第一个是一个名为choices
的嵌套表,其中包含了对话系统请求时将呈现给玩家的选项。第二个选项元素实际上是一个函数。当用户选择一个选项时调用该函数,该函数由一些if
语句组成。这些if
语句将测试选择了哪个选项,并基于该选项将CurrentDialog
对象设置到对话框树路径上的下一个对话框。
真的是这样。以这种方式设计我们的对话树系统的最大好处是,在很少的指导下,即使是非程序员也可以设计一个像前面所示的简单脚本。
如果您继续使用Chapter08
解决方案运行Dialog_Example
项目,您将看到这个脚本正在运行,并且能够与之交互。以下是一些截图,显示了输出的样子:
虽然这是一个简单的系统实现,但它非常灵活。需要再次注意的是,这些脚本不需要重新编译来进行更改。你自己试试。对NPC.lua
文件做一些修改,重新运行示例程序,你会看到你的修改出现。
在下一节中,我们将看到如何通过实现由 Lua 脚本驱动的 quest 系统来进一步包含脚本系统。
另一个非常常见的高级游戏系统是任务系统。虽然在角色扮演游戏中更常见,但任务也可以出现在其他类型中。通常,这些其他的流派会用一个不同的名字来掩饰一个任务系统。比如有些游戏有挑战,本质上真的和任务一样。
一个探索可以被简单地认为是一个达到特定结果的尝试。通常,在任务被认为完成之前,任务会包含一定数量的必须执行的步骤。一些类型的常见任务包括杀死任务,玩家通常必须杀死特定数量的敌人,通常称为打磨和运送任务,玩家必须扮演快递员的角色,并且经常必须前往游戏世界的新地点运送货物。当然,这是让玩家在不强迫他们的情况下前往下一个期望地点的好方法。在收集任务时,玩家必须收集一定数量的特定物品。在护送任务中,由于历史上糟糕的实现,玩家经常害怕,玩家经常不得不伴随一个NPC
到一个新的位置,同时保护他们免受伤害。最后,混合任务通常是上述类型的混合,更典型的是更长的任务。
任务系统的另一个常见部分是支持所谓的任务链或任务线。在任务链中,每个任务的完成是开始序列中下一个任务的先决条件。随着玩家在链条中前进,这些任务通常会涉及越来越多的复杂任务。这些任务是逐渐揭示剧情的好方法。
这就解释了什么是任务。在下一节中,我们将讨论一些不同的方法,我们可以在我们的游戏项目中增加对任务的支持。然而,在我们看实现的细节之前,定义我们期望每个任务对象需要什么是有用的。
出于简单示例的目的,我们假设 quest 对象由以下内容组成:
- 任务名称:任务的名称
- 目标:完成任务必须采取的行动
- 奖励:玩家完成任务将获得什么
- 描述:关于任务的一点信息,也许是一些关于玩家为什么要承担任务的背景故事
- 任务给予者:给予任务的
NPC
有了这些简单的元素,我们可以构建我们的基本任务系统。
正如我们在以前的游戏系统示例中看到的,在示例引擎中,我们有许多不同的方法来实现我们的任务系统。现在让我们简单看一下其中的几个,并讨论它们的优缺点。
我们支持任务系统的一种方法是将它构建到游戏引擎本身中。整个系统将被设计成接近引擎代码,并且使用本地引擎语言,在我们的例子中是 C++。我们将使用我们见过无数次的技术来创建一个基础设施来支持这一探索。使用继承,我们可以公开所需的基本函数和变量,并让开发人员构建这个结构。一个简单的高级任务类可能看起来如下所示:
class Quest
{
public:
Quest(std::string name,
std::vector<GameObjects> rewards,
std::string description,
NPC questGiver);
~Quest();
Accept(); //accept the quest
TurnIn(); //complete the quest
private:
std::string m_questName;
std::vector<GameObjects> m_rewards;
std::string m_questDescription;
NPC m_questGiver;
Bool isActive;
};
当然,这只是一个简单的演示,在这种情况下,我们将跳过实现。
这种实现方法的优点是,它是用本机代码编写的,这意味着它会运行得很快,而且它离引擎很近,这意味着它可以更好地访问底层系统,而不需要接口层或其他库。
这种实现方法的缺点包括,因为它是游戏引擎或游戏代码本身的一部分,这意味着所做的任何更改都需要重新编译。这也使得非程序员更难为任务添加自己的想法,或者在发布后处理任务系统的扩展。
虽然这种方法确实有效,但它更适合较小的项目,在这些项目中,一旦任务或系统就位,您就不必或不想对其进行更改。
这个方法和我们之前实现NPC
对话系统的方法是一样的。在这个设计中,我们创建了一个接口类来处理脚本的加载和 quest 脚本之间的数据传递。因为我们以前见过类似的实现,所以我将跳过这里的示例代码,转而讨论这种方法的优缺点。
与仅引擎实现相比,这种实现方法的优点包括灵活性。如果我们想做任何更改,我们只需要在编辑器中加载脚本,进行更改,然后重新加载游戏。这也让非程序员更容易创建自己的任务。
这种实现方法的缺点包括它仍然部分地依赖于引擎本身。脚本只能访问引擎接口公开的元素和函数。如果你想给一个任务增加更多的功能,你必须在任何脚本使用它之前把它构建到引擎端。
这种方法更适合较大的项目,但如上所述,仍然有其缺点。
我们可以采取的另一种方法是用我们的脚本语言构建整个系统,只从引擎中公开通用方法。这些通用方法可能是模板函数的良好候选。在这种方法中,任务系统内部和任务脚本都是用脚本语言编写的。在一个脚本中编写的每个任务都会包含一个处理管理的任务系统脚本的引用。这种方法非常类似于只使用发动机的方法;它刚刚被移出引擎,进入脚本系统。
让我们来看看 quest 系统脚本的一个简单版本。为了简洁起见,省略了一些部分:
local questsys = {}
questsys.quest = {}
function questsys.new(questname, objectives, reward, description, location, level, questgiver)
for keys, value in ipairs(objectives) do
value.value = 0
end
questsys.quest[#questsys.quest+1] = {
questname = questname,
objectives = objectives,
reward = reward,
description = description,
questgiver = questgiver,
accepted = false,
completed = false,
isAccepted = function(self) return self.accepted end,
isCompleted = function(self) return self.completed end
}
end
function questsys.accept(questname)
for key, value in ipairs(questsys.quest) do
if value.questname == questname then
if not value.accepted then
value.accepted = true
end
end
end
...
function questsys.turnin(questname)
rejectMsg = "You have not completed the quest."
for key, value in ipairs(questsys.quest) do
if value.questname == questname then
for i, j in ipairs(questsys.quest[key].objectives) do
if j.value == j.maxValue then
value.completed = true
value.reward()
else return rejectMsg end
end
end
end
...
questsys.get(questname, getinfo)
for key, value in ipairs(questsys.quest) do
if value.questname == questname then
if getinfo == "accepted" then return value:isAccepted() end
if getinfo == "completed" then return value:isCompleted() end
if getinfo == "questname" then return value.questname end
if getInfo == "description" then return value.description end
if getInfo == "location" then return value.location end
if getInfo == "level" then return value.level end
if getInfo == "questgiver" then return value.questgiver end
else error("No such quest name!")
end
end
return questsys
同样,为了节省空间,我省略了一些功能,但是理解系统所需的核心组件在这里。首先,我们有一个创建新任务的功能,包括名称、目标、描述和任务给予者。然后我们有一个接受函数,将任务设置为活动的。请注意,我们是如何使用键/对查找方法来遍历我们的表的——我们将经常这样做。然后我们在任务中有一个函数要转,最后是一个返回所有任务信息的简单函数。这里没有描述的功能是获取和设置任务的各种目标值。要了解完整的实现,请看一下代码库的Chapter08
文件夹中的Quest_Example
项目。
现在,有了 quest 系统脚本,我们有几个选择。首先,我们可以通过使用require
系统中的 Lua 构建将这个系统添加到其他脚本中,这将允许我们在其他脚本中使用该脚本。其语法如下所示:
local questsys = require('questsys')
或者我们可以简单地在我们的游戏引擎中加载脚本,并使用一个界面,就像我们在前面的例子中所做的那样,并以这种方式与我们的 quest 系统交互。有了这种灵活性,选择取决于开发人员和情况。
这种实现方法的优点包括很大的灵活性。在这种方法中,不仅任务的变化,而且任务系统本身也可以动态修改,而不需要重建游戏或引擎。这通常是一种用于在产品发布后包含可下载内容(DLC)、游戏修改(mods)和其他额外内容的方法。
这种实现的缺点包括,尽管它非常灵活,但增加了额外的复杂性。它也可能更慢,因为系统是用解释的脚本语言编写的,性能可能会受到影响。它还要求开发人员对脚本语言有更多的了解,并且可能需要更多的学习时间。
像其他方法一样,这种方法也有它的位置和时间。虽然我倾向于在大型项目中使用这样的系统,但是如果团队没有做好准备,这种方法可能会增加更多的开销,而不是易用性。
在这一章中,我们谈到了实现高级游戏系统的大量内容。我们深入研究了如何在游戏项目中包含像 Lua 这样的脚本语言。然后,我们在这些知识的基础上,检查在示例引擎中实现对话和任务系统的方法。虽然我们确实讨论了很多,但我们几乎没有触及这个话题的表面。在下一章中,我们将继续利用这些新发现的知识为我们的游戏构建一些人工智能。