C---游戏开发入门手册-一-

107 阅读24分钟

C++ 游戏开发入门手册(一)

原文:C++ Game Development Primer

协议:CC BY-NC-SA 4.0

一、为游戏开发者管理内存

内存管理是游戏开发中一个非常重要的话题。所有游戏都会经历一段内存不足的发展时期,美术团队需要更多额外的纹理或网格。内存的布局方式对游戏的性能也至关重要。了解何时使用堆栈内存,何时使用堆内存,以及每种内存的性能含义,对于优化程序的缓存一致性和数据局部性来说是非常重要的因素。在你理解如何处理这些问题之前,你需要理解 C++ 程序存储数据的不同位置。

在 C++ 中有三个地方可以存储内存:一个静态空间用于存储静态变量,一个堆栈用于存储局部变量和函数参数,还有一个堆(或自由存储区),从这里可以动态地为不同的目的分配内存。

静态存储装置

静态内存是由编译器处理的,没有太多要说的。当您使用编译器构建程序时,它会留出一块足够大的内存来存储程序中定义的所有静态和全局变量。这包括源代码中的字符串,它们包含在静态内存的一个区域中,称为字符串表。

关于静态内存没有什么可说的,所以我们将继续讨论堆栈。

C++ 堆栈内存模型

堆栈更难理解。每次调用函数时,编译器都会在后台生成代码,为被调用函数的参数和局部变量分配内存。清单 1-1 显示了一些简单的代码,然后我们用它们来解释栈是如何工作的。

清单 1-1。一个简单的 C++ 程序

void function2(int variable1)

{

int variable2{ variable1 };

}

void function1(int variable)

{

function2(variable);

}

int _tmain(int argc, _TCHAR* argv[])

{

int variable{ 0 };

function1(variable);

return 0;

}

清单 1-1 中的程序非常简单:它以_tmain开始,?? 调用function1,?? 调用function2。图 1-1 展示了主函数的堆栈。

A978-1-4842-0814-4_1_Fig1_HTML.jpg

图 1-1。

The stack for tmain

main的堆栈空间非常简单。它为名为variable的局部变量提供了一个单独的存储空间。这些用于单个函数的堆栈空间被称为堆栈帧。当function1被调用时,一个新的堆栈框架被创建在_tmain的现有框架之上。图 1-2 显示了这一点。

A978-1-4842-0814-4_1_Fig2_HTML.jpg

图 1-2。

The added stack frame for function1

当编译器创建代码将function1的堆栈帧推送到堆栈上时,它还确保参数variable用来自_tmainvariable中存储的值初始化。这就是参数通过值传递的方式。最后,图 1-3 显示了添加到堆栈中的function2的最后一个堆栈帧。

A978-1-4842-0814-4_1_Fig3_HTML.jpg

图 1-3。

The complete stack frame

最后一个堆栈帧稍微复杂一些,但是你应该能够看到_tmain中的文字值 0 是如何沿着堆栈传递的,直到它最终被用来初始化function2中的variable2

剩下的堆栈操作相对简单。当function2返回堆栈时,为该调用生成的帧从堆栈中弹出。这使我们回到图 1-2 所示的状态,当function1返回时,我们回到图 1-1 所示的状态。要理解 C++ 中堆栈的基本功能,你只需要知道这些。

不幸的是,事情实际上没有这么简单。C++ 中的堆栈是一件非常复杂的事情,要完全理解它需要一点汇编编程知识。这个主题超出了一本针对初学者的书的范围,但是一旦你掌握了基础知识,它就非常值得一读。《游戏开发者杂志》2012 年 9 月版中的文章《程序员反汇编》是一篇关于 x86 堆栈操作的优秀入门文章,值得一读,可从 http://www.gdcvault.com/gdmag 免费获得。

这一章并没有涉及栈中引用和指针是如何处理的,或者返回值是如何实现的。一旦你开始思考这个问题,你可能会开始理解它有多复杂。您可能还想知道为什么理解堆栈的工作方式是有用的。答案在于试图找出为什么你的游戏一旦进入真实环境就会崩溃。在开发过程中,找出游戏崩溃的原因是相对容易的,因为您可以在调试器中简单地重现崩溃。在已经启动的游戏上,您可能会收到一个称为崩溃转储的文件,它没有任何调试信息,只是有堆栈的当前状态。此时,您需要从构建中查找符号文件,以便计算出被调用函数的内存地址,然后您可以手动计算出哪些函数是从堆栈中的地址调用的,并尝试计算出哪个函数在堆栈中传递了无效的内存地址值。

这是一项复杂且耗时的工作,但在专业游戏开发中确实经常出现。iOS 和 Android 的 Crashlytics 或 Windows PC 程序的 BugSentry 等服务可以上传崩溃转储,并在 web 服务上为您提供调用堆栈,以帮助减轻手动解决游戏问题的痛苦。

C++ 中内存管理的下一个大主题是堆。

使用堆内存

手动管理动态分配的内存有时很有挑战性,比使用堆栈内存要慢,而且经常是不必要的。一旦你开始编写从外部文件加载数据的游戏,管理动态内存对你来说将变得更加重要,因为通常不可能知道你在编译时需要多少内存。我开发的第一个游戏完全阻止了程序员分配动态内存。我们通过分配对象数组并在用完时重用这些数组中的内存来解决这个问题。这是避免分配内存的性能成本的一种方法。

分配内存是一项开销很大的操作,因为它必须尽可能地防止内存损坏。在现代多处理器 CPU 架构上尤其如此,在这种架构中,多个 CPU 可能会同时尝试分配相同的内存。本章并不打算成为游戏开发中内存分配技术的详尽资源,而是介绍管理堆内存的概念。

清单 1-2 显示了一个使用newdelete操作符的简单程序。

清单 1-2。为一个class动态分配内存

class Simple

{

private:

int variable{ 0 };

public:

Simple()

{

std::cout << "Constructed" << std::endl;

}

∼Simple()

{

std::cout << "Destroyed" << std::endl;

}

};

int _tmain(int argc, _TCHAR* argv[])

{

Simple* pSimple = new Simple();

delete pSimple;

pSimple = nullptr;

return 0;

}

这个简单的程序展示了newdelete的运行。当你决定在 C++ 中使用new操作符分配内存时,所需的内存量是自动计算的。清单 1-2 中的new操作符将保留足够的内存来存储Simple对象及其成员变量。如果你向Simple添加更多的成员或者从另一个类继承它,程序仍然会运行,并且会为扩展的类定义保留足够的内存。

new 运算符返回一个指针,指向您请求分配的内存。一旦你有了一个指向动态分配的内存的指针,你就有责任确保这个内存也被适当地释放。您可以看到这是通过将指针传递给delete操作符来完成的。delete操作符负责告诉操作系统,我们预留的内存不再使用,可以用于其他用途。当指针被设置为存储nullptr时,最后一项内务处理就完成了。通过这样做,我们有助于防止我们的代码假设指针仍然有效,我们可以从内存中读取和写入,就好像它仍然是一个Simple对象。如果你的程序以看似随机和莫名其妙的方式崩溃,从没有被清除的指针访问释放的内存是一个常见的嫌疑。

分配单个对象时使用标准的newdelete运算符;然而,在分配和释放数组时,也应该使用特定的newdelete操作符。这些如清单 1-3 所示。

清单 1-3。数组newdelete

int* pIntArray = new int[16];

delete[] pIntArray;

new的调用将分配 64 字节的内存来存储 16 个int变量,并返回一个指向第一个元素地址的指针。您使用new[]操作符分配的任何内存都应该使用delete[]操作符删除,因为使用标准的delete会导致您请求的内存不能全部被释放。

Note

没有释放内存和没有正确释放内存被称为内存泄漏。以这种方式泄漏内存是不好的,因为您的程序最终将耗尽可用内存并崩溃,因为它最终将没有任何可用内存来完成新的分配。

希望您能从这段代码中明白为什么使用可用的 STL 类来避免自己管理内存是有益的。如果您发现自己不得不手动分配内存,STL 还提供了unique_ptrshared_ptr模板来帮助您在适当的时候删除内存。清单 1-4 更新了清单 1-2 和清单 1-3 中的代码,使用了unique_ptrshared_ptr对象。

清单 1-4。使用unique_ptrshared_ ptr

#include <memory>

class Simple

{

private:

int variable{ 0 };

public:

Simple()

{

std::cout << "Constructed" << std::endl;

}

∼Simple()

{

std::cout << "Destroyed" << std::endl;

}

};

int _tmain(int argc, _TCHAR* argv[])

{

using UniqueSimplePtr = std::unique_ptr<Simple>;

UniqueSimplePtr pSimple1{ new Simple() };

std::cout << pSimple1.get() << std::endl;

UniqueSimplePtr pSimple2;

pSimple2.swap(pSimple1);

std::cout << pSimple1.get() << std::endl;

std::cout << pSimple2.get() << std::endl;

using IntSharedPtr = std::shared_ptr<int>;

IntSharedPtr pIntArray1{ new int[16] };

IntSharedPtr pIntArray2{ pIntArray1 };

std::cout << std::endl << pIntArray1.get() << std::endl;

std::cout << pIntArray2.get() << std::endl;

return 0;

}

顾名思义,unique_ptr用于确保一次只有一个对已分配内存的引用。清单 1-3 展示了这一点。pSimple1被赋予一个new Simple指针,然后pSimple2被创建为空。你可以尝试通过传递pSimple1或者使用赋值操作符来初始化pSimple2,你的代码将无法编译。将指针从一个unique_ptr实例传递到另一个实例的唯一方法是使用swap方法。swap方法移动存储的地址,并将原始unique_ptr实例中的指针设置为nullptr。图 1-4 中输出的前三行显示了存储在unique_ptr实例中的地址。

A978-1-4842-0814-4_1_Fig4_HTML.jpg

图 1-4。

The output from Listing 1-4

这个输出显示调用了来自Simple类的构造器。在调用swap之前,存储在pSimple1中的指针被打印出来。在对swap pSimple1的调用之后,存储一个作为00000000输出的nullptr,并且pSimple2存储最初保存在那里的地址。输出的最后一行显示还调用了Simple对象的析构函数。这是我们从使用unique_ptrshared_ptr中得到的另一个好处:一旦对象超出范围,内存就会自动释放。

您可以从包含Destroyed的行之前的两行输出中看到,两个shared_ptr实例可以存储对同一个指针的引用。只有一个unique_ptr可以引用一个内存位置,但是多个shared_ptr实例可以引用一个地址。这种差异体现在对内存存储的删除调用的时间上。一旦超出范围,unique_ptr就会删除它引用的内存。它可以这样做,因为unique_ptr可以确保它是引用该内存的唯一对象。另一方面,A shared_ptr在超出范围时不会删除内存;相反,当指向该地址的所有shared_ptr对象不再被使用时,内存被删除。

这确实需要一点训练,就好像你在这些对象上使用get方法来访问指针,那么你仍然可以在内存被删除后引用它。如果你正在使用unique_ptrshared_ptr,确保你只是使用提供的swap和模板提供的其他访问器方法来传递指针,而不是手动使用get方法。

编写一个基本的单线程内存分配器

本节将向您展示如何重载newdelete操作符来创建一个非常基本的内存管理系统。这个系统将有很多缺点:它将在一个静态数组中存储有限数量的内存,它将遭受内存碎片问题,并且它还将泄漏任何释放的内存。这一节只是对分配内存时发生的一些过程的介绍,并强调了一些使编写一个功能完整的内存管理器成为一项困难任务的问题。

清单 1-5 首先向您展示了一个结构,它将被用作内存分配的标题。

清单 1-5。MemoryAllocation Header struct

struct MemoryAllocationHeader

{

void* pStart{ nullptr };

void* pNextFree{ nullptr };

size_t size{ 0 };

};

这个structpStart void*变量中存储一个指向返回给用户的内存的指针,在pNextFree指针中存储一个指向下一个空闲内存块的指针,在size变量中存储分配内存的大小。

我们的内存管理器不会使用动态内存来为用户程序分配内存。相反,它将从静态数组中返回一个地址。这个数组是在清单 1-6 所示的未命名空间中创建的。

清单 1-6。未命名的namespace来自Chapter1-MemoryAllocator.cpp

namespace

{

const unsigned int ONE_MEGABYTE = 1024 * 1024 * 1024;

char pMemoryHeap[ONE_MEGABYTE];

const size_t SIZE_OF_MEMORY_HEADER = sizeof(MemoryAllocationHeader);

}

这里你可以看到我们分配了一个 1 MB 大小的静态数组。我们知道这是 1 MB,因为在大多数平台上,char类型的大小是一个字节,我们分配的数组大小是 1,024 字节乘以 1,024 KB,总共是 1,048,576 字节。未命名的名称空间也有一个常量,存储我们的MemoryAllocationHeader对象的大小,使用sizeof函数计算。这个大小是 12 个字节:4 个字节用于pStart指针,4 个字节用于pNextFree指针,4 个字节用于size变量。

下一段重要的代码重载了新的操作符。到目前为止,您看到的newdelete函数都是可以隐藏的函数,就像您可以用自己的实现隐藏任何其他函数一样。清单 1-7 展示了我们的新函数。

清单 1-7。重载的new函数

void* operator new(size_t size)

{

MemoryAllocationHeader* pHeader =

reinterpret_cast<MemoryAllocationHeader*>(pMemoryHeap);

while (pHeader != nullptr && pHeader->pNextFree != nullptr)

{

pHeader = reinterpret_cast<MemoryAllocationHeader*>(pHeader->pNextFree);

}

pHeader->pStart = reinterpret_cast<char*>(pHeader)+SIZE_OF_MEMORY_HEADER;

pHeader->pNextFree = reinterpret_cast<char*>(pHeader->pStart) + size;

pHeader->size = size;

return pHeader->pStart;

}

new操作符传递我们想要保留的分配的size,并向用户可以写入的内存块的开头返回一个void*。该函数首先遍历现有的内存分配,直到找到第一个在pNextFree变量中带有nullptr的分配块。

一旦找到一个空闲内存块,pStart指针被初始化为空闲内存块的地址加上内存分配头的大小。这确保了每个分配也包括用于分配的pStartpNextFree指针以及size的空间。新函数通过返回存储在pHeader->pStart中的值来结束,确保用户不知道关于MemoryAllocationHeader struct的任何事情。他们只是收到一个指向他们所请求的size的内存块的指针。

一旦我们分配了内存,我们也可以释放内存。在清单 1-8 中,重载的delete操作符从我们的堆中清除分配。

清单 1-8。重载的delete函数

void operator delete(void* pMemory)

{

MemoryAllocationHeader* pLast = nullptr;

MemoryAllocationHeader* pCurrent =

reinterpret_cast<MemoryAllocationHeader*>(pMemoryHeap);

while (pCurrent != nullptr && pCurrent->pStart != pMemory)

{

pLast = pCurrent;

pCurrent = reinterpret_cast<MemoryAllocationHeader*>(pCurrent->pNextFree);

}

if (pLast != nullptr)

{

pLast->pNextFree = reinterpret_cast<char*>(pCurrent->pNextFree);

}

pCurrent->pStart = nullptr;

pCurrent->pNextFree = nullptr;

pCurrent->size = 0;

}

这个操作符使用两个指针pLastpCurrent遍历堆。遍历堆,直到传入pMemory的指针与存储在MemoryAllocationHeader structpStart指针中的分配内存块相匹配。一旦我们找到匹配的分配,我们设置pNextFree指针指向存储在pCurrent->pNextFree中的地址。这是我们制造两个问题的地方。我们通过在另外两个已分配内存块之间释放内存来对内存进行分段,这意味着只有相同大小或更小的分配才能从这个内存块中填充。在这个例子中,碎片是多余的,因为我们没有实现任何跟踪空闲内存块的方法。一种选择是使用一个列表来存储所有的空闲块,而不是将它们存储在内存分配头本身中。编写一个全功能的内存分配器是一项复杂的任务,可以写满一整本书。

Note

你可以看到我们有一个在newdelete操作符中使用reinterpret_cast的有效案例。这种类型的演员没有多少有效的案例。在这种情况下,我们希望使用不同的类型来表示相同的内存地址,因此reinterpret_cast是正确的选项。

清单 1-9 包含了这个部分的最后一个内存函数,它用于打印出堆中所有活动的MemoryAllocationHeader对象的内容。

清单 1-9。PrintAllocations功能

void PrintAllocations()

{

MemoryAllocationHeader* pHeader =

reinterpret_cast<MemoryAllocationHeader*>(pMemoryHeap);

while (pHeader != nullptr)

{

std::cout << pHeader << std::endl;

std::cout << pHeader->pStart << std::endl;

std::cout << pHeader->pNextFree << std::endl;

std::cout << pHeader->size << std::endl;

pHeader = reinterpret_cast<MemoryAllocationHeader*>(pHeader->pNextFree);

std::cout << std::endl << std::endl;

}

}

这个函数循环遍历我们头脑中所有有效的MemoryAllocationHeader指针,并打印它们的pStartpNextFreesize变量。清单 1-10 显示了一个使用这些函数的示例main函数。

清单 1-10。使用内存堆

int _tmain(int argc, _TCHAR* argv[])

{

memset(pMemoryHeap, 0, SIZE_OF_MEMORY_HEADER);

PrintAllocations();

Simple* pSimple1 = new Simple();

PrintAllocations();

Simple* pSimple2 = new Simple();

PrintAllocations();

Simple* pSimple3 = new Simple();

PrintAllocations();

delete pSimple2;

pSimple2 = nullptr;

PrintAllocations();

pSimple2 = new Simple();

PrintAllocations();

delete pSimple2;

pSimple2 = nullptr;

PrintAllocations();

delete pSimple3;

pSimple3 = nullptr;

PrintAllocations();

delete pSimple1;

pSimple1 = nullptr;

PrintAllocations();

return 0;

}

这是一个非常简单的函数。首先使用memset函数初始化内存堆的前 12 个字节。memset的工作原理是获取一个地址,然后是要使用的值,然后是要设置的字节数。然后将每个字节设置为作为第二个参数传递的字节值。在我们的例子中,我们将pMemoryHeap的前 12 个字节设置为0

然后我们第一次调用PrintAllocations,我运行的输出如下。

0x00870320

0x00000000

0x00000000

0

第一行是MemoryAllocationHeader struct的地址,对于我们的第一次调用,它也是存储在pMemoryHeap中的地址。下一行是存储在pStart,然后是pNextFree,然后是size的值。这些都是0因为我们还没有做任何分配。内存地址被打印为 32 位十六进制值。

然后分配我们的第一个Simple对象。原来因为Simple类只包含一个int变量,我们只需要分配 4 个字节来存储它。第二个PrintAllocations调用的输出证实了这一点。

Constructed

0x00870320

0x0087032C

0x00870330

4

0x00870330

0x00000000

0x00000000

0

我们可以看到Constructed文本,它被打印在Simple类的构造器中,然后我们的第一个MemoryAllocationHeader struct被填充。第一次分配的地址保持不变,因为它是堆的开始。pStart变量从开头之后的 12 个字节开始存储地址,因为我们已经留有足够的空间来存储头。pNextFree变量存储添加存储pSimple变量所需的 4 个字节后的地址,size 变量存储从 size 传递到new4。然后,我们得到第一个空闲块的打印输出,从00870330开始,方便地在第一个之后 16 个字节。

然后程序分配另外两个Simple对象来产生下面的输出。

Constructed

0x00870320

0x0087032C

0x00870330

4

0x00870330

0x0087033C

0x00870340

4

0x00870340

0x0087034C

0x00870350

4

0x00870350

0x00000000

0x00000000

0

在这个输出中,您可以看到三个分配的 4 字节对象,以及每个分配头中的每个起始地址和下一个地址。删除第二个对象后,输出会再次更新。

Destroyed

0x00870320

0x0087032C

0x00870340

4

0x00870340

0x0087034C

0x00870350

4

0x00870350

0x00000000

0x00000000

0

第一个分配的对象现在指向第三个,第二个分配的对象已经从堆中移除。分配第四个对象只是为了看看会发生什么。

Constructed

0x00870320

0x0087032C

0x00870340

4

0x00870340

0x0087034C

0x00870350

4

0x00870350

0x0087035C

0x00870360

4

0x00870360

0x00000000

0x00000000

0

此时pSimple1存储在地址0x0087032CpSimple20x0087035CpSimple30x0087034C。然后,程序通过逐个删除每个分配的对象而结束。

尽管存在一些问题,会妨碍您在生产代码中使用这个内存管理器,但它确实是一个关于堆如何操作的有用示例。使用某种跟踪分配的方法,以便内存管理系统可以知道哪个内存正在使用,哪个内存可以自由分配。

摘要

本章已经给了你一个非常简单的 C++ 内存管理模型的介绍。您已经看到,您的程序将使用静态内存、堆栈内存和堆内存来存储游戏要使用的对象和数据。

静态内存和堆栈内存是由编译器自动处理的,您已经使用过这些类型的内存,而不需要做任何特别的事情。堆内存具有较高的管理开销,因为它要求您在用完内存后也释放内存。您已经看到 STL 提供了unique_ptrshared_ptr模板来帮助自动管理动态内存分配。最后,向您介绍了一个简单的内存管理器。这个内存管理器可能不适合生产代码,但它确实为您提供了如何从堆中分配内存以及如何重载全局newdelete方法来挂钩您自己的内存管理器的概述。

要扩展这个内存管理器的功能,需要增加对重新分配空闲块的支持,对堆中连续的空闲块进行碎片整理,并最终确保分配系统是线程安全的。现代游戏也倾向于创建多个堆来服务于不同的目的。游戏创建内存分配器来处理网格数据、纹理、音频和在线系统并不少见。还可以有线程安全的分配器和非线程安全的分配器,它们可以用在不止一个线程进行内存访问的情况下。复杂的内存管理系统也有小块分配器来处理特定大小以下的内存请求,以帮助减轻内存碎片,这可能是由 STL 为字符串存储等频繁进行的小分配引起的。正如你所看到的,现代游戏中的内存管理是一个远比这一章所能涵盖的更复杂的问题。

二、对游戏开发有用的设计模式

设计模式就像你代码的蓝图。它们是你可以用来完成任务的系统,这些任务在本质上与你开发游戏时出现的任务非常相似。正如 STL 数据结构是可重用的集合,可以在需要解决特定问题时使用,设计模式也可以用来解决代码中的逻辑问题。

在游戏项目中使用设计模式有很多好处。首先,它们允许您使用许多其他开发人员能够理解的通用语言。这有助于减少新程序员在帮助您的项目时需要花费的时间,因为他们可能已经熟悉了您在构建游戏基础设施时使用的概念。

设计模式也可以使用公共代码来实现。这意味着您可以为给定的模式重用这些代码。代码重用减少了游戏中使用的代码行数,这导致了更稳定和更易于维护的代码库,这两者都意味着您可以更快地编写更好的游戏。本章向您介绍了三种模式:工厂、观察者和访问者。

在游戏中使用工厂模式

工厂模式是在运行时抽象出动态对象创建的有用方式。对于我们来说,工厂只是一个函数,它将一种类型的对象作为参数,并返回一个指向新对象实例的指针。返回的对象是在堆上创建的,因此调用者有责任确保适当地删除该对象。清单 2-1 显示了我创建的一个工厂方法,用来实例化文本冒险中使用的不同类型的Option对象。

清单 2-1。创建Option实例的工厂

Option* CreateOption(PlayerOptions optionType)

{

Option* pOption = nullptr;

switch (optionType)

{

case PlayerOptions::GoNorth:

pOption = new MoveOption(

Room::JoiningDirections::North,

PlayerOptions::GoNorth, "Go North");

break;

case PlayerOptions::GoEast:

pOption = new MoveOption(

Room::JoiningDirections::East,

PlayerOptions::GoEast, "Go East");

break;

case PlayerOptions::GoSouth:

pOption = new MoveOption(

Room::JoiningDirections::South,

PlayerOptions::GoSouth, "Go South");

break;

case PlayerOptions::GoWest:

pOption = new MoveOption(

Room::JoiningDirections::West,

PlayerOptions::GoWest, "Go West");

break;

case PlayerOptions::OpenChest:

pOption = new OpenChestOption("Open Chest");

break;

case PlayerOptions::AttackEnemy:

pOption = new AttackEnemyOption();

break;

case PlayerOptions::Quit:

pOption = new QuitOption("Quit");

break;

case PlayerOptions::None:

break;

default:

break;

}

return pOption;

}

如您所见,CreateOption工厂函数将一个PlayerOption enum作为参数,然后返回一个适当构造的Option。这依赖于多态来返回对象的基指针。多态使用的连锁效应是任何工厂函数只能创建从其返回类型派生的对象。许多游戏引擎通过让所有可创建的对象从一个公共基类派生来管理这个。出于我们的目的,在学习的背景下,最好涵盖几个例子。清单 2-2 显示了一个Enemy派生类的工厂。

清单 2-2。Enemy工厂

Enemy* CreateEnemy(EnemyType enemyType)

{

Enemy* pEnemy = nullptr;

switch (enemyType)

{

case EnemyType::Dragon:

pEnemy = new Enemy(EnemyType::Dragon);

break;

case EnemyType::Orc:

pEnemy = new Enemy(EnemyType::Orc);

break;

default:

assert(false); // Unknown enemy type

break;

}

return pEnemy;

}

如果你要在未来的某个时候为这些敌人类型创建新的继承类,你只需要更新工厂函数来将这些新类添加到你的游戏中。这是使用工厂方法利用多态基类的一个便利特性。

到目前为止,Text Adventure 中所有的OptionEnemy对象都是Game类中的成员变量。这对于工厂对象来说不太好,因为工厂将在堆上创建对象,而不使用堆栈内存;因此必须更新Game类来存储指向OptionEnemy实例的指针。您可以在清单 2-3 中看到这是如何实现的。

清单 2-3。更新Game以存储指向OptionEnemy实例的指针

class Game

: public EventHandler

{

private:

static const unsigned int m_numberOfRooms = 4;

using Rooms = std::array<Room::Pointer, m_numberOfRooms>;

Rooms m_rooms;

Player m_player;

Option::Pointer m_attackDragonOption;

Option::Pointer m_attackOrcOption;

Option::Pointer m_moveNorthOption;

Option::Pointer m_moveEastOption;

Option::Pointer m_moveSouthOption;

Option::Pointer m_moveWestOption;

Option::Pointer m_openSwordChest;

Option::Pointer m_quitOption;

Sword m_sword;

Chest m_swordChest;

using Enemies = std::vector<Enemy::Pointer>;

Enemies m_enemies;

bool m_playerQuit{ false };

void InitializeRooms();

void WelcomePlayer();

void GivePlayerOptions() const;

void GetPlayerInput(std::stringstream& playerInput) const;

void EvaluateInput(std::stringstream& playerInput);

public:

Game();

void RunGame();

virtual void HandleEvent(const Event* pEvent);

};

Game现在通过在各自的OptionEnemy类定义中定义的类型别名来引用OptionEnemy实例。这些别名如清单 2-4 所示。

清单 2-4。Option:: PointerEnemy::Pointer类型别名

class Option

{

public:

using Pointer = std::shared_ptr<Option>;

protected:

PlayerOptions m_chosenOption;

std::string m_outputText;

public:

Option(PlayerOptions chosenOption, const std::string& outputText)

: m_chosenOption(chosenOption)

, m_outputText(outputText)

{

}

const std::string& GetOutputText() const

{

return m_outputText;

}

virtual void Evaluate(Player& player) = 0;

};

class Enemy

: public Entity

{

public:

using Pointer = std::shared_ptr<Enemy>;

private:

EnemyType m_type;

bool m_alive{ true };

public:

Enemy(EnemyType type)

: m_type{ type }

{

}

EnemyType GetType() const

{

return m_type;

}

bool IsAlive() const

{

return m_alive;

}

void Kill()

{

m_alive = false;

}

};

两个类中的Pointer别名都是使用shared_ptr模板定义的。这意味着一旦工厂创建了实例,您就不需要担心应该在哪里删除对象。一旦您不再持有shared_ptr引用,shared_ptr就会自动删除该实例。

更新Game类构造器是使用两个工厂函数时的下一个重要变化。这个构造器如清单 2-5 所示。

清单 2-5。更新后的Game构造器

Game::Game()

: m_attackDragonOption{ CreateOption(PlayerOptions::AttackEnemy) }

, m_attackOrcOption{ CreateOption(PlayerOptions::AttackEnemy) }

, m_moveNorthOption{ CreateOption(PlayerOptions::GoNorth) }

, m_moveEastOption{ CreateOption(PlayerOptions::GoEast) }

, m_moveSouthOption{ CreateOption(PlayerOptions::GoSouth) }

, m_moveWestOption{ CreateOption(PlayerOptions::GoWest) }

, m_openSwordChest{ CreateOption(PlayerOptions::OpenChest) }

, m_quitOption{ CreateOption(PlayerOptions::Quit) }

, m_swordChest{ &m_sword }

{

static_cast<OpenChestOption*>(m_openSwordChest.get())->SetChest(&m_swordChest);

m_enemies.emplace_back(CreateEnemy(EnemyType::Dragon));

static_cast<AttackEnemyOption*>(m_attackDragonOption.get())->SetEnemy(m_enemies[0]);

m_enemies.emplace_back(CreateEnemy(EnemyType::Orc));

static_cast<AttackEnemyOption*>(m_attackOrcOption.get())->SetEnemy(m_enemies[1]);

}

构造器现在调用工厂方法来创建初始化每个OptionEnemyshared_ptr所需的适当实例。每个Option都有自己的指针,但是现在使用emplace_back方法将Enemy实例放入一个向量中。我这样做是为了向您展示如何使用shared_ptr::get方法和static_cast将多态基类转换为添加Enemy所需的派生类。将m_swordChest的地址添加到m_openSwordChest选项需要相同类型的转换。

这就是用 C++ 创建基本工厂函数的全部内容。这些函数在编写级别加载代码时发挥了自己的作用。您的数据可以存储您希望在任何给定时间创建的对象类型,并将其传递给知道如何实例化正确对象的工厂。这减少了加载逻辑中的代码量,有助于减少错误!这绝对是一个值得追求的目标。

与观察者模式解耦

观察者模式对于代码的解耦非常有用。耦合代码是与其他类共享太多自身信息的代码。这可能是其接口中的特定方法或在类之间公开的变量。耦合有几个主要缺点。第一个是它增加了对公开的方法或函数进行更改时必须更新代码的地方的数量,第二个是您的代码变得更不可重用。耦合代码的可重用性较低,因为当决定只重用一个类时,您必须接管任何耦合和依赖的类。

观察器通过为要派生的类提供接口来帮助解耦,这些类提供事件方法,当另一个类上发生某些变化时,将在对象上调用这些方法。前面介绍的Event系统有一个观察者模式的非正式版本。Event类维护了一个侦听器列表,每当它们侦听的事件被触发时,它们的HandleEvent方法就会被调用。observer 模式将这个概念形式化为一个Notifier模板类和接口,可以用来创建 observer 类。清单 2-6 显示了Notifier类的代码。

清单 2-6。Notifier模板类

template <typename Observer>

class Notifier

{

private:

using Observers = std::vector<Observer*>;

Observers m_observers;

public:

void AddObserver(Observer* observer);

void RemoveObserver(Observer* observer);

template <void (Observer::*Method)()>

void Notify();

};

Notifier类定义了一个指向Observer对象的指针向量。有一些补充的方法来添加和删除Notifier的观察者,最后还有一个名为Notify的模板方法,它将被用来通知Observer对象一个事件。清单 2-7 显示了AddObserverRemoveObserver方法的定义。

清单 2-7。AddObserverRemoveObserver方法定义

template <typename Observer>

void Notifier<Observer>::AddObserver(Observer* observer)

{

assert(find(m_observers.begin(), m_observers.end(), observer) == m_observers.end());

m_observers.emplace_back(observer);

}

template <typename Observer>

void Notifier<Observer>::RemoveObserver(Observer* observer)

{

auto object = find(m_observers.begin(), m_observers.end(), observer);

if (object != m_observers.end())

{

m_observers.erase(object);

}

}

添加一个Observer就像在m_observers vector上调用emplace_back一样简单。assert用于通知我们是否向向量中添加了每个Observer的多个副本。remove是通过使用find获得一个iterator给要移除的对象,如果iterator有效则调用erase来实现的。

Notify方法使用了一个你到目前为止还没有见过的 C++ 特性,方法指针。方法指针允许我们从一个类定义中传递一个方法的地址,这个方法应该在一个特定的对象上被调用。清单 2-8 包含了Notify方法的代码。

清单 2-8。Notifier<Observer>::Notify

template <typename Observer>

template <void(Observer::*Method)()>

void Notifier<Observer>::Notify()

{

for (auto& observer : m_observers)

{

(observer->*Method)();

}

}

Notify模板方法指定了一个方法指针参数。方法指针必须具有 void 返回类型,并且不带任何参数。方法指针的类型采用以下格式。

void (Class::*VariableName)()

这里的Class代表方法所属的类的名称,而VariableName是我们在代码中用来引用方法指针的名称。当我们使用Method标识符调用方法时,您可以在Notify方法中看到这一点。我们在这里调用方法的对象是一个Observer*,方法的地址是使用指针操作符解引用的。

一旦我们的Notifier类完成,我们就可以用它来创建Notifier对象。清单 2-9 继承了一个NotifierQuitOption类中。

清单 2-9。更新QuitOption

class QuitOption

: public Option

, public Notifier<QuitObserver>

{

public:

QuitOption(const std::string& outputText)

: Option(PlayerOptions::Quit, outputText)

{

}

virtual void Evaluate(Player& player);

};

QuitOption现在从Notifier类继承而来,传递给它一个新类作为它的模板参数。清单 2-10 显示了QuitObserver类。

清单 2-10。QuitObserver

class QuitObserver

{

public:

virtual void OnQuit() = 0;

};

QuitObserver只是一个为派生类提供方法OnQuit的接口。清单 2-11 显示了你应该如何更新QuitOption::Evaluate方法来利用Notifier功能。

清单 2-11。更新QuitOption:: Notifier

void QuitOption::Evaluate(Player& player)

{

Notify<&QuitObserver::OnQuit>();

}

现在你可以看到非常干净的模板方法调用。这个简单的调用将调用每个对象上的OnQuit方法,这些对象已经被添加为QuitOption上的观察者。这是我们的下一步:清单 2-12 中的Game类被更新为继承自QuitObserver

清单 2-12。GameQuitObserver

class Game

: public EventHandler

, public QuitObserver

{

private:

static const unsigned int m_numberOfRooms = 4;

using Rooms = std::array<Room::Pointer, m_numberOfRooms>;

Rooms m_rooms;

Player m_player;

Option::Pointer m_attackDragonOption;

Option::Pointer m_attackOrcOption;

Option::Pointer m_moveNorthOption;

Option::Pointer m_moveEastOption;

Option::Pointer m_moveSouthOption;

Option::Pointer m_moveWestOption;

Option::Pointer m_openSwordChest;

Option::Pointer m_quitOption;

Sword m_sword;

Chest m_swordChest;

using Enemies = std::vector<Enemy::Pointer>;

Enemies m_enemies;

bool m_playerQuit{ false };

void InitializeRooms();

void WelcomePlayer();

void GivePlayerOptions() const;

void GetPlayerInput(std::stringstream& playerInput) const;

void EvaluateInput(std::stringstream& playerInput);

public:

Game();

∼Game();

void RunGame();

virtual void HandleEvent(const Event* pEvent);

// From QuitObserver

virtual void OnQuit();

};

Game类继承自QuitObserver,现在有了一个析构函数,并重载了OnQuit方法。清单 2-13 显示了构造器和析构函数如何负责添加和删除作为QuitOption监听器的类。

清单 2-13。Game类的构造器和析构函数

Game::Game()

: m_attackDragonOption{ CreateOption(PlayerOptions::AttackEnemy) }

, m_attackOrcOption{ CreateOption(PlayerOptions::AttackEnemy) }

, m_moveNorthOption{ CreateOption(PlayerOptions::GoNorth) }

, m_moveEastOption{ CreateOption(PlayerOptions::GoEast) }

, m_moveSouthOption{ CreateOption(PlayerOptions::GoSouth) }

, m_moveWestOption{ CreateOption(PlayerOptions::GoWest) }

, m_openSwordChest{ CreateOption(PlayerOptions::OpenChest) }

, m_quitOption{ CreateOption(PlayerOptions::Quit) }

, m_swordChest{ &m_sword }

{

static_cast<OpenChestOption*>(m_openSwordChest.get())->SetChest(&m_swordChest);

m_enemies.emplace_back(CreateEnemy(EnemyType::Dragon));

static_cast<AttackEnemyOption*>(m_attackDragonOption.get())->SetEnemy(m_enemies[0]);

m_enemies.emplace_back(CreateEnemy(EnemyType::Orc));

static_cast<AttackEnemyOption*>(m_attackOrcOption.get())->SetEnemy(m_enemies[1]);

static_cast<QuitOption*>(m_quitOption.get())->AddObserver(this);

}

Game::∼Game()

{

static_cast<QuitOption*>(m_quitOption.get())->RemoveObserver(this);

}

构造器的最后一行将对象注册为 m_quitOption 上的观察者,并在析构函数中移除自己。清单 2-14 中的最后一次更新实现了OnQuit方法。

清单 2-14。Game:: OnQuit

void Game::OnQuit()

{

m_playerQuit = true;

}

这就是实现观察者模式的全部内容。这实现了QuitOption类和游戏中任何其他需要知道退出事件的类之间的解耦。observer 类在为在线功能等系统创建游戏框架代码时特别有用。您可以想象这样一种情况,您实现了一个从 web 服务器下载排行榜的类。这个类可以在多个游戏项目中使用,每个单独的游戏可以简单地实现自己的类来观察下载者,并在收到排行榜数据时采取适当的行动。

使用访问者模式轻松添加新功能

编写可重用游戏引擎代码的一个主要目标是尽量避免在类中包含特定于游戏的功能。用纯面向对象的方法很难做到这一点,因为封装的目的是将数据隐藏在接口后面的类中。这可能意味着您需要向类中添加方法来处理特定于某个类的数据。

我们可以通过放松对必须与游戏代码交互的类的封装来解决这个问题,但是我们是以一种非常结构化的方式来这样做的。您可以通过使用访问者模式来实现这一点。访问者是知道如何在一类对象上执行特定任务的对象。当您需要对许多可能继承自相同基类但具有不同参数或类型的对象执行类似任务时,这些方法非常有用。清单 2-15 显示了一个你可以用来实现Visitor对象的接口类。

清单 2-15。Visitor

class Visitor

{

private:

friend class Visitable;

virtual void OnVisit(Visitable& visitable) = 0;

};

Visitor类提供了一个纯虚拟方法OnVisit,它被传递了一个继承自名为Visitable的类的对象。清单 2-16 列出了Visitable类。

清单 2-16。Visitable

class Visitable

{

public:

virtual ∼Visitable() {}

void Visit(Visitor& visitor)

{

visitor.OnVisit(*this);

}

};

Visitable类提供了一个被传递给Visitor对象的Visit方法。Visit方法调用Visitor上的OnVisit方法。这允许我们将OnVisit方法设为私有,确保只有Visitable对象可以被访问,并且我们总是传递对OnVisit方法的有效引用。

访问者模式的设置非常简单。您可以在清单 2-17 中看到如何使用该模式的具体示例,其中 Text Adventure 中的 Option 类是从Visitable继承而来的。

清单 2-17。更新后的Option

class Option

: public Visitable

{

public:

using Pointer = std::shared_ptr<Option>;

protected:

PlayerOptions m_chosenOption;

std::string m_outputText;

public:

Option(PlayerOptions chosenOption, const std::string& outputText)

: m_chosenOption(chosenOption)

, m_outputText(outputText)

{

}

const std::string& GetOutputText() const

{

return m_outputText;

}

virtual void Evaluate(Player& player) = 0;

};

唯一需要的改变是从Visitable继承Option类。为了利用这一点,清单 2-18 中创建了一个名为EvaluateVisitorVisitor

清单 2-18。EvaluateVisitor

class EvaluateVisitor

: public Visitor

{

private:

Player& m_player;

public:

EvaluateVisitor(Player& player)

: m_player{ player }

{

}

virtual void OnVisit(Visitable& visitable)

{

Option* pOption = dynamic_cast<Option*>(&visitable);

if (pOption != nullptr)

{

pOption->Evaluate(m_player);

}

}

};

EvaluateListener::OnVisit方法使用一个dynamic_cast来确定提供的visitable变量是否是从Option类派生的对象。如果是,就调用Option::Evaluate方法。唯一剩下的更新是使用EvaluateVisitor类与Game::EvaluateInput中选择的选项接口。这个更新如清单 2-19 所示。

清单 2-19。Game::EvaluateInput

void Game::EvaluateInput(stringstream& playerInputStream)

{

PlayerOptions chosenOption = PlayerOptions::None;

unsigned int playerInputChoice{ 0 };

playerInputStream >>playerInputChoice;

try

{

Option::Pointer option =

m_player.GetCurrentRoom()->EvaluateInput(playerInputChoice);

EvaluateVisitor evaluator{ m_player };

option->Visit(evaluator);

}

catch (const std::out_of_range&)

{

cout << "I do not recognize that option, try again!" << endl << endl;

}

}

正如你所看到的,代码已经被更新为在Option上调用Visit方法,而不是直接调用Evaluate方法。这就是我们为文本冒险游戏添加Visitor模式所需要做的一切。

这个例子并不是对Visitor模式的最好使用,因为它相对简单。游客可以在 3d 游戏中的渲染队列等地方随心所欲。你可以在Visitor对象中实现不同类型的渲染操作,并使用它来决定单个游戏如何渲染它们的 3d 对象。一旦您掌握了以这种方式抽象出逻辑的诀窍,您可能会发现能够提供独立于数据的不同实现的许多地方非常有用。

摘要

本章已经向您简要介绍了设计模式的概念。设计模式非常有用,因为它们提供了现成的技术工具箱,可以用来解决许多不同的问题。你已经看到了本章中使用的FactoryObserverVisitor模式,但是还有更多。

事实上,软件工程设计模式的标准教科书是 Gamma、Helm、Johnson 和 Vlissides(也称为“四人帮”)的《设计模式:可重用面向对象软件的元素》。如果你觉得这个概念很有趣,你应该看看他们的书。它涵盖了这里展示的例子以及其他有用的模式。EA 的前软件工程师 Bob Nystrom 提供了一个免费的游戏开发相关设计模式的在线集合。你可以在这里找到他的网址: http://gameprogrammingpatterns.com/

当你试图解决游戏开发问题时,你会发现许多相关且有用的模式。对于精通设计模式提供的通用技术的其他开发人员来说,它们也使您的代码更容易使用。我们的下一章将着眼于 C++ IO 流,以及我们如何使用它们来加载和保存游戏数据。

三、使用文件 IO 保存和加载游戏

保存和加载游戏进度是今天除了最基本的游戏之外的所有游戏的标准功能。这意味着你需要知道如何加载和保存游戏对象。本章介绍了一种可能的策略,用于写出恢复玩家游戏所需的数据。

首先我们看一下SerializationManager类,它使用 STL 类ifstreamofstream来读写文件。然后,我们将介绍如何更新文本冒险游戏,以便能够保存玩家在哪个房间,哪些物品已被拾取,哪些敌人已死亡,以及哪些动态选项已被删除。

什么是序列化?

在我们序列化游戏的不同类之前,最好先了解一下什么是序列化。计算机编程中的序列化包括将数据转换为程序可以写出并在以后某个时间点读入的格式的过程。现代游戏中有三个主要系统利用了序列化。

首先是保存游戏系统,这也将是本章的基础。类被序列化成一个二进制数据文件,游戏可以在以后的某个时间点读取该文件。这种类型的串行化对于玩家能够在游戏的不同运行之间甚至在不同的计算机上保留他们的游戏数据是必不可少的。在不同机器之间转移保存的游戏现在是 Xbox Live、PlayStation Network、Steam 和 Origin 的一个关键功能。

序列化的第二个主要用途是在多人游戏中。多人游戏需要能够将游戏对象状态转换成尽可能小的字节数,以便在互联网上传输。然后,接收端的程序需要能够重新解释传入的数据流,以更新对手球员和投射物的位置、旋转和状态。多人游戏还需要对玩家参与的回合的获胜条件进行序列化,以便可以计算出赢家和输家。

剩下的系统在游戏开发过程中更有用。现代游戏工具集和引擎提供了在运行时更新游戏数据的能力。游戏设计者可以在游戏运行时更新玩家属性,如生命值或武器造成的伤害。使用序列化将工具中的数据转换成游戏可以用来更新其当前状态的数据流,这是可能的。这种序列化的形式可以加快游戏设计的迭代过程。我甚至开发了一个工具,允许设计师在多人游戏中更新所有当前连接的玩家。

这些不是你在游戏开发过程中遇到的唯一序列化形式,但它们可能是最常见的。这一章着重于使用 C++ 类ofstreamifstream来序列化游戏数据。这些类提供了将 C++ 的内置类型与存储在设备文件系统中的文件进行序列化的能力。本章向您展示了如何创建知道如何使用ifstreamofstream写出和读入数据的类。它还将向您展示一种方法,用于管理哪些对象需要序列化,以及如何使用惟一的对象 id 来引用对象之间的关系。

序列化管理器

SerializationManager类是一个Singleton类,它负责跟踪游戏中的每个对象,这些对象的状态可以流出或者被另一个可保存的对象引用。清单 3-1 涵盖了SerializationManager的类定义。

清单 3-1。SerializationManager

class SerializationManager

: public Singleton<SerializationManager>

{

private:

using Serializables = std::unordered_map<unsigned int, Serializable*>;

Serializables m_serializables;

const char* const m_filename{"Save.txt"};

public:

void RegisterSerializable(Serializable* pSerializable);

void RemoveSerializable(Serializable* pSerializable);

Serializable* GetSerializable(unsigned int serializableId) const;

void ClearSave();

void Save();

bool Load();

};

SerializationManager类将指向Serializable对象的指针存储在一个unordered_map中。每个Serializable对象将被赋予一个惟一的 ID,作为集合中的键。我们希望用于保存文件的文件名存储在m_filename变量中。

有三种方法用于管理由SerializationManager类处理的对象。清单 3-2 显示了RegisterSerializableRemoveSerializableGetSerializable方法。

清单 3-2。RegisterSerializableRemoveSerializableGetSerializable方法

void SerializationManager::RegisterSerializable(Serializable* pSerializable)

{

assert(m_serializables.find(pSerializable->GetId()) == m_serializables.end());

m_serializables.emplace{ pSerializable->GetId(), pSerializable };

}

void SerializationManager::RemoveSerializable(Serializable* pSerializable)

{

auto iter = m_serializables.find(pSerializable->GetId());

if (iter != m_serializables.end())

{

m_serializables.erase(iter);

}

}

Serializable* SerializationManager::GetSerializable(unsigned int serializableId) const

{

Serializable* pSerializable{ nullptr };

auto iter = m_serializables.find(serializableId);

if (iter != m_serializables.end())

{

pSerializable = iter->second;

}

return pSerializable;

}

这些方法都相当简单,管理从m_serializables unordered_map添加、删除和检索Serializable地址。

Save方法负责循环所有的Serializable对象,并要求它们将数据写入一个ofstream对象。清单 3-3 显示了Save方法以及ofstream对象是如何初始化和移动的。

清单 3-3。SerializableManager:: Save

void SerializationManager::Save()

{

std::ofstream file{ m_filename };

file << true;

file << std::endl;

for (auto& serializable : m_serializables)

{

Serializable* pSerializable = serializable.second;

file << pSerializable->GetId();

file << std::endl;

pSerializable->OnSave(file);

file << std::endl;

file << std::endl;

}

}

通过向一个ofstream对象传递您希望写入的文件名来初始化该对象。然后,您可以使用标准的<<操作符将数据写入文件。ofstream中的 o 代表输出,f 代表文件,而 stream 代表它传输数据的能力,这意味着我们正在处理一个输出文件流。

Save方法从写出一个true开始。此bool用于确定保存游戏中是否有可恢复的保存游戏。当玩家完成游戏后,我们会写出falseSave然后遍历所有存储的Serializable对象,写出它们唯一的 ID,并调用OnSave方法。写出std::endl只是为了让文本文件更易读,更容易调试。

Save相反的动作是Load,如清单 3-4 所示。

清单 3-4。SerializationManager:: Load

bool SerializationManager::Load()

{

std::ifstream file{ m_filename };

bool found = file.is_open();

if (found)

{

bool isValid;

file >> isValid;

if (isValid)

{

std::cout <<

"Save game found, would you like to load? (Type yes to load)"

<< std::endl << std::endl;

std::string shouldLoad;

std::cin >> shouldLoad;

if (shouldLoad == "yes")

{

while (!file.eof())

{

unsigned int serializableId{ 0 };

file >> serializableId;

auto iter = m_serializables.find(serializableId);

if (iter != m_serializables.end())

{

iter->second->OnLoad(file);

}

}

}

}

else

{

found = false;

}

}

return found;

}

Load方法比Save稍微复杂一些。你可以看到它正在使用一个ifstream,输入文件流,而不是一个ofstream。使用要加载的文件名来初始化ifstreamifstream中的is_open方法用于确定是否找到了具有给定名称的文件。如果玩家从未玩过这个游戏,那么没有保存文件存在;这项检查可以确保我们不会在没有保存游戏的情况下加载游戏。

下一个检查用于确定存在的保存文件中是否有有效的保存状态。这是使用>>操作符完成的,就像使用cin一样。这就是接下来发生的事情,当cin被用来询问玩家他或她是否愿意载入保存的游戏。如果玩家输入除了“是”以外的任何内容,那么游戏将在不加载的情况下开始。

然后有一个 while 循环,检查eof方法是否返回trueeof方法正在确定该方法是否到达了文件的末尾。这个循环的内部部分从文件中读取惟一的 ID,从地图中检索Serializable,然后在该对象上调用OnLoad方法。

最后一个SerializationManager方法是ClearSave,用来写出一个以false为唯一值的文件。清单 3-5 展示了这种方法。

清单 3-5。SerializationManager:: ClearSave

void SerializationManager::ClearSave()

{

std::ofstream file{ m_filename };

file << false;

}

SerializationManager类相当简单。Serializable类也很简单,如清单 3-6 所示。

清单 3-6。Serializable

class Serializable

{

unsigned int m_id{ 0 };

public:

explicit Serializable(unsigned int id)

: m_id{ id }

{

SerializationManager::GetSingleton().RegisterSerializable(this);

}

Serializable::∼Serializable()

{

SerializationManager* pSerializationManager =

SerializationManager::GetSingletonPtr();

if (pSerializationManager)

{

pSerializationManager->RemoveSerializable(this);

}

}

virtual void OnSave(std::ofstream& file) = 0;

virtual void OnLoad(std::ifstream& file) = 0;

unsigned int GetId() const { return m_id; }

};

Serializable类旨在由您希望能够在游戏会话之间保存的类继承,因此被实现为一个接口。这是通过使OnSaveOnLoad方法完全虚拟化来实现的。

每个Serializable还在m_id变量中存储一个 ID。构造器和析构函数通过Singleton模式自动添加和移除SerializationManager对象中的对象。

保存和加载文本冒险

能够保存和加载游戏的第一步是创建SerializationManager。清单 3-7 显示了更新后的 main 函数。

清单 3-7。更新后的main功能

int _tmain(int argc, _TCHAR* argv[])

{

new SerializationManager();

Game game;

game.RunGame();

delete SerializationManager::GetSingletonPtr();

return 0;

}

创建和删除main中的SerializationManager确保它存在于整个Game::方法中。当玩家选择退出时,游戏被保存,清单 3-8 显示了这是如何实现的。

清单 3-8。保存游戏

void Game::OnQuit()

{

SerializationManager::GetSingleton().Save();

m_playerQuit = true;

}

SerializationManager::Save的调用被添加到Game::OnQuit方法中。清单 3-9 中的Game::RunGame增加了LoadClearSave方法。

清单 3-9。Game::RunGame

void Game::RunGame()

{

InitializeRooms();

const bool loaded = SerializationManager::GetSingleton().Load();

WelcomePlayer(loaded);

bool playerWon = false;

while (m_playerQuit == false && playerWon == false)

{

GivePlayerOptions();

stringstream playerInputStream;

GetPlayerInput(playerInputStream);

EvaluateInput(playerInputStream);

for (auto& enemy : m_enemies)

{

playerWon = enemy->IsAlive() == false;

}

}

if (playerWon == true)

{

SerializationManager::GetSingleton().ClearSave();

cout << "Congratulations, you rid the dungeon of monsters!" << endl;

cout << "Type goodbye to end" << endl;

std::string input;

cin >> input;

}

}

现在更新了WelcomePlayer方法,询问玩家是否愿意载入清单 3-10 中的保存游戏。

清单 3-10。更新Game::WelcomePlayer

void Game::WelcomePlayer(const bool loaded)

{

if (!loaded)

{

cout << "Welcome to Text Adventure!" << endl << endl;

cout << "What is your name?" << endl << endl;

string name;

cin >> name;

m_player.SetName(name);

cout << endl << "Hello " << m_player.GetName() << endl;

}

else

{

cout << endl << "Welcome Back " << m_player.GetName() << endl << endl;

}

}

现在,当游戏载入并恢复玩家第一次玩游戏时输入的名字后,会给玩家一条欢迎回来的信息。

Game类代码的下一个更改是将一个惟一的 ID 传递给我们希望成为Serializable的每个对象的构造器。Game构造器是发生这种情况的地方之一,如清单 3-11 所示。

清单 3-11。Game类构造器

Game::Game()

: m_attackDragonOption{

CreateOption(

PlayerOptions::AttackEnemy,

SDBMCalculator<18>::CalculateValue("AttackDragonOption")) }

, m_attackOrcOption{

CreateOption(

PlayerOptions::AttackEnemy,

SDBMCalculator<15>::CalculateValue("AttackOrcOption")) }

, m_moveNorthOption{

CreateOption(

PlayerOptions::GoNorth,

SDBMCalculator<15>::CalculateValue("MoveNorthOption")) }

, m_moveEastOption{

CreateOption(

PlayerOptions::GoEast,

SDBMCalculator<14>::CalculateValue("MoveEastOption")) }

, m_moveSouthOption{

CreateOption(

PlayerOptions::GoSouth,

SDBMCalculator<15>::CalculateValue("MoveSouthOption")) }

, m_moveWestOption{

CreateOption(

PlayerOptions::GoWest,

SDBMCalculator<14>::CalculateValue("MoveWestOption")) }

, m_openSwordChest{

CreateOption(

PlayerOptions::OpenChest,

SDBMCalculator<20>::CalculateValue("OpenSwordChestOption")) }

, m_quitOption{

CreateOption(

PlayerOptions::Quit,

SDBMCalculator<10>::CalculateValue("QuitOption")) }

, m_swordChest{ &m_sword, SDBMCalculator<5>::CalculateValue("Chest") }

{

static_cast<OpenChestOption*>(m_openSwordChest.get())->SetChest(&m_swordChest);

m_enemies.emplace_back(

CreateEnemy(

EnemyType::Dragon,

SDBMCalculator<6>::CalculateValue("Dragon")));

static_cast<AttackEnemyOption*>(m_attackDragonOption.get())->SetEnemy(m_enemies[0]);

m_enemies.emplace_back(

CreateEnemy(

EnemyType::Orc,

SDBMCalculator<3>::CalculateValue("Orc")));

static_cast<AttackEnemyOption*>(m_attackOrcOption.get())->SetEnemy(m_enemies[1]);

static_cast<QuitOption*>(m_quitOption.get())->AddObserver(this);

}

如您所见,每个工厂函数现在都接受一个散列字符串,该字符串用于构造对象并为SerializationManagerunordered_map提供一个惟一的 ID。这个唯一的键对于游戏对象来说也是有用的,可以保存它们对其他对象的引用。你可以在清单 3-12 中看到这一点,其中显示了Player::OnSave的源代码。

清单 3-12。Player::OnSave

void Player::OnSave(std::ofstream& file)

{

file << m_name;

file << std::endl;

file << m_items.size();

file << std::endl;

for (auto& item : m_items)

{

file << item->GetId();

file << std::endl;

}

file << m_pCurrentRoom->GetId();

file << std::endl;

}

方法写出用户在开始游戏时提供的名字。然后写出m_items集合中的项目数。写出每个物品的 ID,最后写出m_pCurrentRoom ID。player的保存文件中的文本块如下所示:

1923481025

Bruce

1

3714624381

625001751

第一行是Player对象的唯一 ID,接着是m_nameItems的编号、一个物品的 ID,最后是玩家退出时所在的Room的 ID。

清单 3-13 中的Player::OnLoad方法映射了Player::OnSave方法。

清单 3-13。Player::OnLoad

void Player::OnLoad(std::ifstream& file)

{

file >> m_name;

unsigned int numItems;

file >> numItems;

for (unsigned int i = 0; i < numItems; ++i)

{

unsigned int itemId;

file >> itemId;

Item* pItem =

dynamic_cast<Item*>(

SerializationManager::GetSingleton().GetSerializable(itemId));

m_items.emplace_back{ pItem };

}

unsigned int roomId;

file >> roomId;

Room* pRoom =

dynamic_cast<Room*>(

SerializationManager::GetSingleton().GetSerializable(roomId));

m_pCurrentRoom = pRoom->GetPointer();

}

OnLoad方法从文件中读取m_name变量,然后是项目数。然后有一个for循环,它读出每个条目的 id,并从SerializationManager中检索一个指向Item的指针。使用dynamic_cast将每个Serializable指针转换为Item指针。

Room指针更具挑战性。Player类不存储指向Room对象的原始指针;而是用了一个shared_ptr。清单 3-14 显示了Room类是如何被更新来存储一个shared_ptr给它自己的,当从SerializationManager中检索对象时,它可以被用来检索一个有效的shared_ptr

清单 3-14。Room

class Room

: public Entity

, public Serializable

{

public:

using Pointer = std::shared_ptr<Room>;

enum class JoiningDirections

{

North = 0,

East,

South,

West,

Max

};

private:

using JoiningRooms = std::array<Pointer, static_cast<size_t>(JoiningDirections::Max)>;

JoiningRooms m_pJoiningRooms;

using StaticOptions = std::map<unsigned int, Option::Pointer>;

StaticOptions m_staticOptions;

unsigned int m_staticOptionStartKey{ 1 };

using DynamicOptions = std::vector<Option::Pointer>;

DynamicOptions m_dynamicOptions;

Pointer m_pointer{ this };

public:

explicit Room(unsigned int serializableId);

void AddRoom(JoiningDirections direction, Pointer room);

Pointer GetRoom(JoiningDirections direction) const;

Option::Pointer EvaluateInput(unsigned int playerInput);

void AddStaticOption(Option::Pointer option);

void AddDynamicOption(Option::Pointer option);

void PrintOptions() const;

virtual void OnSave(std::ofstream``&

virtual void OnLoad(std::ifstream``&

Pointer GetPointer() const { return m_pointer; }

};

现在,任何时候我们代码的任何部分想要存储一个shared_ptr到一个Serializable对象,它应该从一个共享位置获取指针。最容易的地方是对象本身,它通过唯一的 ID 向SerializationManager注册。

Room类必须保存和加载其动态选项的状态。清单 3-15 显示了保存和加载方法。

清单 3-15。Room::OnSaveRoom:: OnLoad

void Room::OnSave(std::ofstream& file)

{

file << m_dynamicOptions.size();

file << std::endl;

for (auto& dynamicOption : m_dynamicOptions)

{

file << dynamicOption->GetId();

file << std::endl;

}

}

void Room::OnLoad(std::ifstream& file)

{

m_dynamicOptions.clear();

unsigned int numDynamicOptions;

file >> numDynamicOptions;

if (numDynamicOptions > 0)

{

for (unsigned int i = 0; i < numDynamicOptions; ++i)

{

unsigned int optionId;

file >> optionId;

Option* pOption =

dynamic_cast<Option*>(

SerializationManager::GetSingleton().GetSerializable(optionId));

if (pOption)

{

Option::Pointer sharedPointer = pOption->GetPointer();

m_dynamicOptions.emplace_back{ sharedPointer };

}

}

}

}

OnSave方法循环遍历所有的动态选项,并在保存状态拥有的动态选项数量后保存它们唯一的 id。OnLoad方法首先清除现有的动态选项,然后从SerializationManager中恢复每个选项。再次使用一个dynamic_cast并从Option类实例中检索一个shared_ptr来完成。

Chest类和Enemy类是仅有的添加了OnSaveOnLoad方法的其他类。这些用来保存这些类中的m_isOpenm_alive变量,如清单 3-16 所示。

清单 3-16。Chest::OnSaveChest::OnLoadEnemy::OnSaveEnemy::OnLoad方法

virtual void Chest::OnSave(std::ofstream& file)

{

file << m_isOpen;

}

virtual void Chest::OnLoad(std::ifstream& file)

{

file >> m_isOpen;

}

virtual void Enemy::OnSave(std::ofstream& file)

{

file << m_alive;

}

virtual void Enemy::OnLoad(std::ifstream& file)

{

file >> m_alive;

}

这些简单的方法完成了最后的类更改,以支持文本冒险游戏的保存和加载。在这一点上,我鼓励您从附带的网站上获取示例代码,并在您的调试器中查看程序的执行情况,感受一下使用唯一 id 通过集中式系统引用对象的能力是多么有用。

摘要

这一章已经给了你一个简单的机制来实现保存和加载你的游戏。ifstreamofstream类为你的程序提供了一个简单的读写文件数据的机制。这些类遵循 C++ 中流类型的常规。

从这一章学到的最重要的一课是指针不能从一个游戏转移到下一个游戏。这对于试图实现一个加载和保存系统是正确的,对于实现一个多人游戏也是正确的。指针地址不能从一台计算机发送到另一台计算机来引用任何给定的对象。相反,对象需要用一致且持久的惟一 ID 来创建,并向集中式系统注册,这样可以确保没有键冲突,并且可以在代码中任何需要的地方提供对对象的访问。