C---游戏开发入门手册-全-
C++ 游戏开发入门手册(全)
一、为游戏开发者管理内存
内存管理是游戏开发中一个非常重要的话题。所有游戏都会经历一段内存不足的发展时期,美术团队需要更多额外的纹理或网格。内存的布局方式对游戏的性能也至关重要。了解何时使用堆栈内存,何时使用堆内存,以及每种内存的性能含义,对于优化程序的缓存一致性和数据局部性来说是非常重要的因素。在你理解如何处理这些问题之前,你需要理解 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 展示了主函数的堆栈。
图 1-1。
The stack for tmain
main
的堆栈空间非常简单。它为名为variable
的局部变量提供了一个单独的存储空间。这些用于单个函数的堆栈空间被称为堆栈帧。当function1
被调用时,一个新的堆栈框架被创建在_tmain
的现有框架之上。图 1-2 显示了这一点。
图 1-2。
The added stack frame for function1
当编译器创建代码将function1
的堆栈帧推送到堆栈上时,它还确保参数variable
用来自_tmain
的variable
中存储的值初始化。这就是参数通过值传递的方式。最后,图 1-3 显示了添加到堆栈中的function2
的最后一个堆栈帧。
图 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 显示了一个使用new
和delete
操作符的简单程序。
清单 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;
}
这个简单的程序展示了new
和delete
的运行。当你决定在 C++ 中使用new
操作符分配内存时,所需的内存量是自动计算的。清单 1-2 中的new
操作符将保留足够的内存来存储Simple
对象及其成员变量。如果你向Simple
添加更多的成员或者从另一个类继承它,程序仍然会运行,并且会为扩展的类定义保留足够的内存。
new 运算符返回一个指针,指向您请求分配的内存。一旦你有了一个指向动态分配的内存的指针,你就有责任确保这个内存也被适当地释放。您可以看到这是通过将指针传递给delete
操作符来完成的。delete
操作符负责告诉操作系统,我们预留的内存不再使用,可以用于其他用途。当指针被设置为存储nullptr
时,最后一项内务处理就完成了。通过这样做,我们有助于防止我们的代码假设指针仍然有效,我们可以从内存中读取和写入,就好像它仍然是一个Simple
对象。如果你的程序以看似随机和莫名其妙的方式崩溃,从没有被清除的指针访问释放的内存是一个常见的嫌疑。
分配单个对象时使用标准的new
和delete
运算符;然而,在分配和释放数组时,也应该使用特定的new
和delete
操作符。这些如清单 1-3 所示。
清单 1-3。数组new
和delete
int* pIntArray = new int[16];
delete[] pIntArray;
对new
的调用将分配 64 字节的内存来存储 16 个int
变量,并返回一个指向第一个元素地址的指针。您使用new[]
操作符分配的任何内存都应该使用delete[]
操作符删除,因为使用标准的delete
会导致您请求的内存不能全部被释放。
Note
没有释放内存和没有正确释放内存被称为内存泄漏。以这种方式泄漏内存是不好的,因为您的程序最终将耗尽可用内存并崩溃,因为它最终将没有任何可用内存来完成新的分配。
希望您能从这段代码中明白为什么使用可用的 STL 类来避免自己管理内存是有益的。如果您发现自己不得不手动分配内存,STL 还提供了unique_ptr
和shared_ptr
模板来帮助您在适当的时候删除内存。清单 1-4 更新了清单 1-2 和清单 1-3 中的代码,使用了unique_ptr
和shared_ptr
对象。
清单 1-4。使用unique_ptr
和shared_
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
实例中的地址。
图 1-4。
The output from Listing 1-4
这个输出显示调用了来自Simple
类的构造器。在调用swap
之前,存储在pSimple1
中的指针被打印出来。在对swap pSimple1
的调用之后,存储一个作为00000000
输出的nullptr
,并且pSimple2
存储最初保存在那里的地址。输出的最后一行显示还调用了Simple
对象的析构函数。这是我们从使用unique_ptr
和shared_ptr
中得到的另一个好处:一旦对象超出范围,内存就会自动释放。
您可以从包含Destroyed
的行之前的两行输出中看到,两个shared_ptr
实例可以存储对同一个指针的引用。只有一个unique_ptr
可以引用一个内存位置,但是多个shared_ptr
实例可以引用一个地址。这种差异体现在对内存存储的删除调用的时间上。一旦超出范围,unique_ptr
就会删除它引用的内存。它可以这样做,因为unique_ptr
可以确保它是引用该内存的唯一对象。另一方面,A shared_ptr
在超出范围时不会删除内存;相反,当指向该地址的所有shared_ptr
对象不再被使用时,内存被删除。
这确实需要一点训练,就好像你在这些对象上使用get
方法来访问指针,那么你仍然可以在内存被删除后引用它。如果你正在使用unique_ptr
或shared_ptr
,确保你只是使用提供的swap
和模板提供的其他访问器方法来传递指针,而不是手动使用get
方法。
编写一个基本的单线程内存分配器
本节将向您展示如何重载new
和delete
操作符来创建一个非常基本的内存管理系统。这个系统将有很多缺点:它将在一个静态数组中存储有限数量的内存,它将遭受内存碎片问题,并且它还将泄漏任何释放的内存。这一节只是对分配内存时发生的一些过程的介绍,并强调了一些使编写一个功能完整的内存管理器成为一项困难任务的问题。
清单 1-5 首先向您展示了一个结构,它将被用作内存分配的标题。
清单 1-5。MemoryAllocation
Header struct
struct MemoryAllocationHeader
{
void* pStart{ nullptr };
void* pNextFree{ nullptr };
size_t size{ 0 };
};
这个struct
在pStart 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
变量。
下一段重要的代码重载了新的操作符。到目前为止,您看到的new
和delete
函数都是可以隐藏的函数,就像您可以用自己的实现隐藏任何其他函数一样。清单 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
指针被初始化为空闲内存块的地址加上内存分配头的大小。这确保了每个分配也包括用于分配的pStart
和pNextFree
指针以及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;
}
这个操作符使用两个指针pLast
和pCurrent
遍历堆。遍历堆,直到传入pMemory
的指针与存储在MemoryAllocationHeader struct
的pStart
指针中的分配内存块相匹配。一旦我们找到匹配的分配,我们设置pNextFree
指针指向存储在pCurrent->pNextFree
中的地址。这是我们制造两个问题的地方。我们通过在另外两个已分配内存块之间释放内存来对内存进行分段,这意味着只有相同大小或更小的分配才能从这个内存块中填充。在这个例子中,碎片是多余的,因为我们没有实现任何跟踪空闲内存块的方法。一种选择是使用一个列表来存储所有的空闲块,而不是将它们存储在内存分配头本身中。编写一个全功能的内存分配器是一项复杂的任务,可以写满一整本书。
Note
你可以看到我们有一个在new
和delete
操作符中使用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
指针,并打印它们的pStart
、pNextFree
和size
变量。清单 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 传递到new
的4
。然后,我们得到第一个空闲块的打印输出,从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
存储在地址0x0087032C
,pSimple2
在0x0087035C
,pSimple3
在0x0087034C
。然后,程序通过逐个删除每个分配的对象而结束。
尽管存在一些问题,会妨碍您在生产代码中使用这个内存管理器,但它确实是一个关于堆如何操作的有用示例。使用某种跟踪分配的方法,以便内存管理系统可以知道哪个内存正在使用,哪个内存可以自由分配。
摘要
本章已经给了你一个非常简单的 C++ 内存管理模型的介绍。您已经看到,您的程序将使用静态内存、堆栈内存和堆内存来存储游戏要使用的对象和数据。
静态内存和堆栈内存是由编译器自动处理的,您已经使用过这些类型的内存,而不需要做任何特别的事情。堆内存具有较高的管理开销,因为它要求您在用完内存后也释放内存。您已经看到 STL 提供了unique_ptr
和shared_ptr
模板来帮助自动管理动态内存分配。最后,向您介绍了一个简单的内存管理器。这个内存管理器可能不适合生产代码,但它确实为您提供了如何从堆中分配内存以及如何重载全局new
和delete
方法来挂钩您自己的内存管理器的概述。
要扩展这个内存管理器的功能,需要增加对重新分配空闲块的支持,对堆中连续的空闲块进行碎片整理,并最终确保分配系统是线程安全的。现代游戏也倾向于创建多个堆来服务于不同的目的。游戏创建内存分配器来处理网格数据、纹理、音频和在线系统并不少见。还可以有线程安全的分配器和非线程安全的分配器,它们可以用在不止一个线程进行内存访问的情况下。复杂的内存管理系统也有小块分配器来处理特定大小以下的内存请求,以帮助减轻内存碎片,这可能是由 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 中所有的Option
和Enemy
对象都是Game
类中的成员变量。这对于工厂对象来说不太好,因为工厂将在堆上创建对象,而不使用堆栈内存;因此必须更新Game
类来存储指向Option
和Enemy
实例的指针。您可以在清单 2-3 中看到这是如何实现的。
清单 2-3。更新Game
以存储指向Option
和Enemy
实例的指针
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
现在通过在各自的Option
和Enemy
类定义中定义的类型别名来引用Option
和Enemy
实例。这些别名如清单 2-4 所示。
清单 2-4。Option::
Pointer
和Enemy::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]);
}
构造器现在调用工厂方法来创建初始化每个Option
和Enemy
的shared_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 显示了AddObserver
和RemoveObserver
方法的定义。
清单 2-7。AddObserver
和RemoveObserver
方法定义
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 继承了一个Notifier
到QuitOption
类中。
清单 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。Game
类QuitObserver
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 中创建了一个名为EvaluateVisitor
的Visitor
。
清单 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 对象。一旦您掌握了以这种方式抽象出逻辑的诀窍,您可能会发现能够提供独立于数据的不同实现的许多地方非常有用。
摘要
本章已经向您简要介绍了设计模式的概念。设计模式非常有用,因为它们提供了现成的技术工具箱,可以用来解决许多不同的问题。你已经看到了本章中使用的Factory
、Observer
和Visitor
模式,但是还有更多。
事实上,软件工程设计模式的标准教科书是 Gamma、Helm、Johnson 和 Vlissides(也称为“四人帮”)的《设计模式:可重用面向对象软件的元素》。如果你觉得这个概念很有趣,你应该看看他们的书。它涵盖了这里展示的例子以及其他有用的模式。EA 的前软件工程师 Bob Nystrom 提供了一个免费的游戏开发相关设计模式的在线集合。你可以在这里找到他的网址: http://gameprogrammingpatterns.com/
当你试图解决游戏开发问题时,你会发现许多相关且有用的模式。对于精通设计模式提供的通用技术的其他开发人员来说,它们也使您的代码更容易使用。我们的下一章将着眼于 C++ IO 流,以及我们如何使用它们来加载和保存游戏数据。
三、使用文件 IO 保存和加载游戏
保存和加载游戏进度是今天除了最基本的游戏之外的所有游戏的标准功能。这意味着你需要知道如何加载和保存游戏对象。本章介绍了一种可能的策略,用于写出恢复玩家游戏所需的数据。
首先我们看一下SerializationManager
类,它使用 STL 类ifstream
和ofstream
来读写文件。然后,我们将介绍如何更新文本冒险游戏,以便能够保存玩家在哪个房间,哪些物品已被拾取,哪些敌人已死亡,以及哪些动态选项已被删除。
什么是序列化?
在我们序列化游戏的不同类之前,最好先了解一下什么是序列化。计算机编程中的序列化包括将数据转换为程序可以写出并在以后某个时间点读入的格式的过程。现代游戏中有三个主要系统利用了序列化。
首先是保存游戏系统,这也将是本章的基础。类被序列化成一个二进制数据文件,游戏可以在以后的某个时间点读取该文件。这种类型的串行化对于玩家能够在游戏的不同运行之间甚至在不同的计算机上保留他们的游戏数据是必不可少的。在不同机器之间转移保存的游戏现在是 Xbox Live、PlayStation Network、Steam 和 Origin 的一个关键功能。
序列化的第二个主要用途是在多人游戏中。多人游戏需要能够将游戏对象状态转换成尽可能小的字节数,以便在互联网上传输。然后,接收端的程序需要能够重新解释传入的数据流,以更新对手球员和投射物的位置、旋转和状态。多人游戏还需要对玩家参与的回合的获胜条件进行序列化,以便可以计算出赢家和输家。
剩下的系统在游戏开发过程中更有用。现代游戏工具集和引擎提供了在运行时更新游戏数据的能力。游戏设计者可以在游戏运行时更新玩家属性,如生命值或武器造成的伤害。使用序列化将工具中的数据转换成游戏可以用来更新其当前状态的数据流,这是可能的。这种序列化的形式可以加快游戏设计的迭代过程。我甚至开发了一个工具,允许设计师在多人游戏中更新所有当前连接的玩家。
这些不是你在游戏开发过程中遇到的唯一序列化形式,但它们可能是最常见的。这一章着重于使用 C++ 类ofstream
和ifstream
来序列化游戏数据。这些类提供了将 C++ 的内置类型与存储在设备文件系统中的文件进行序列化的能力。本章向您展示了如何创建知道如何使用ifstream
和ofstream
写出和读入数据的类。它还将向您展示一种方法,用于管理哪些对象需要序列化,以及如何使用惟一的对象 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 显示了RegisterSerializable
、RemoveSerializable
和GetSerializable
方法。
清单 3-2。RegisterSerializable
、RemoveSerializable
和GetSerializable
方法
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
用于确定保存游戏中是否有可恢复的保存游戏。当玩家完成游戏后,我们会写出false
。Save
然后遍历所有存储的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
。使用要加载的文件名来初始化ifstream
。ifstream
中的is_open
方法用于确定是否找到了具有给定名称的文件。如果玩家从未玩过这个游戏,那么没有保存文件存在;这项检查可以确保我们不会在没有保存游戏的情况下加载游戏。
下一个检查用于确定存在的保存文件中是否有有效的保存状态。这是使用>>
操作符完成的,就像使用cin
一样。这就是接下来发生的事情,当cin
被用来询问玩家他或她是否愿意载入保存的游戏。如果玩家输入除了“是”以外的任何内容,那么游戏将在不加载的情况下开始。
然后有一个 while 循环,检查eof
方法是否返回true
。eof
方法正在确定该方法是否到达了文件的末尾。这个循环的内部部分从文件中读取惟一的 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
类旨在由您希望能够在游戏会话之间保存的类继承,因此被实现为一个接口。这是通过使OnSave
和OnLoad
方法完全虚拟化来实现的。
每个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
增加了Load
和ClearSave
方法。
清单 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);
}
如您所见,每个工厂函数现在都接受一个散列字符串,该字符串用于构造对象并为SerializationManager
的unordered_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_name
、Items
的编号、一个物品的 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::OnSave
和Room::
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
类是仅有的添加了OnSave
和OnLoad
方法的其他类。这些用来保存这些类中的m_isOpen
和m_alive
变量,如清单 3-16 所示。
清单 3-16。Chest::OnSave
、Chest::OnLoad
、Enemy::OnSave
和Enemy::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 通过集中式系统引用对象的能力是多么有用。
摘要
这一章已经给了你一个简单的机制来实现保存和加载你的游戏。ifstream
和ofstream
类为你的程序提供了一个简单的读写文件数据的机制。这些类遵循 C++ 中流类型的常规。
从这一章学到的最重要的一课是指针不能从一个游戏转移到下一个游戏。这对于试图实现一个加载和保存系统是正确的,对于实现一个多人游戏也是正确的。指针地址不能从一台计算机发送到另一台计算机来引用任何给定的对象。相反,对象需要用一致且持久的惟一 ID 来创建,并向集中式系统注册,这样可以确保没有键冲突,并且可以在代码中任何需要的地方提供对对象的访问。
四、使用并发编程加速游戏
处理器制造商的 CPU 每秒执行的周期数已经达到上限。这可以从台式电脑、平板电脑和手机中的现代 CPU 中看出,在这些设备中,CPU 速度很少超过 2.5 Ghz。
CPU 制造商已经开始向他们的 CPU 添加越来越多的内核,以提供越来越多的性能。Xbox One、PlayStation 4、三星 Galaxy 手机和桌面 CPU 都可以访问八个 CPU 内核来执行程序。这意味着,如果现代软件的程序员希望他们的程序能够从现代计算设备中获得最大的价值,并对他们的用户感到流畅和响应,他们就需要采用多线程、并发编程。游戏程序员不得不考虑跨不同处理器的并发性。Xbox One 和 PlayStation 4 实际上有两个四核 CPU、音频 CPU 和 GPU,它们都在同时执行代码。
本章将介绍多核 CPU 编程,以便您可以基本了解 C++ 如何允许您在多个线程上执行代码,如何确保这些线程负责任地共享资源,以及如何确保在程序结束前销毁所有线程。
在自己的线程中运行文本冒险
在这一节中,我将向您展示如何创建一个线程来执行Game::RunGame
方法。这将意味着主游戏循环运行在自己的执行线程中,我们的主要功能是执行其他任务。清单 4-1 显示了如何创建一个游戏线程。
清单 4-1。创建一个Thread
#include "GameLoop.h"
#include <thread>
void RunGameThread(Game& game)
{
game.RunGame();
}
int _tmain(int argc, _TCHAR* argv[])
{
new SerializationManager();
Game game;
std::thread gameThread{ RunGameThread, std::ref{ game } };
assert(gameThread.joinable());
gameThread.join();
delete SerializationManager::GetSingletonPtr();
return 0;
}
C++ 提供了thread
类,该类将自动创建一个本机操作系统线程,并执行您传递给其构造器的函数。在这个例子中,我们正在创建一个名为gameThread
的thread
,它将运行RunGameThread
函数。
RunGameThread
将对Game
对象的引用作为参数。你可以看到我们正在使用std::ref
将game
对象传递给gameThread
。您需要这样做,因为thread
类构造器复制了传入的对象。一旦它有了这个副本并启动了thread
,析构函数就会在这个副本上被调用。调用∼Game
将调用∼Player
,这将从SerializationManager
中注销我们的m_player
对象。如果发生这种情况,我们的游戏将崩溃,因为每当游戏试图加载用户的保存游戏时,m_player
对象将不存在。std::ref
对象通过在内部存储对game
对象的引用并复制自身来避免这种情况。当析构函数被调用时,它们在ref
对象上被调用,而不是在传递的对象上。这可以防止您可能会遇到的崩溃。
一旦新的thread
被创建并运行您提供的函数,执行将在您原来的线程上继续。此时,您可以执行一些其他任务。Text Adventure 目前没有其他任务要完成,因此执行会继续,删除SerializationManager
和return
。这将导致另一个崩溃,因为您的gameThread
将超出范围并试图破坏您正在运行的线程。你真正想要发生的是_tmain
停止执行,直到gameThread
中正在执行的任务完成。线程在它们的函数返回时完成,在我们的情况下,我们将等待玩家退出或赢得游戏。
通过在另一个线程的对象上调用join
,可以让一个正在运行的线程等待另一个线程。提供joinable
方法是为了确保您想要等待的线程是有效的并且正在运行的。您可以通过在delete SerializationManager
行放置一个断点来测试这一点。在你完成游戏之前,你的断点不会被命中。
这就是 C++ 中创建、运行和等待线程的全部内容。下一个任务是解决如何确保线程之间可以共享数据而不会导致问题。
使用互斥体在线程间共享数据
多线程编程带来了问题。如果两个线程试图同时访问相同的变量会发生什么?数据可能不一致,数据可能错误,更改可能丢失。在最糟糕的情况下,你的程序会崩溃。清单 4-2 中更新后的 main 函数展示了一个当两个线程同时访问相同的函数时程序崩溃的例子。
清单 4-2。一个会崩溃的版本
int _tmain(int argc, _TCHAR* argv[])
{
new SerializationManager();
Game game;
std::thread gameThread{ RunGameThread, std::ref{ game } };
assert(gameThread.joinable());
while (!game.HasFinished())
{
// Stick a breakpoint below to see that this code
// is running at the same time as RunGame!
int x = 0;
}
gameThread.join();
delete SerializationManager::GetSingletonPtr();
return 0;
}
这段代码会崩溃,因为Game::HasFinished
方法被重复调用。可以保证主游戏thread
和游戏thread
会同时尝试访问HasFinished
中的变量。清单 4-3 包含了Game::
和HasFinished
方法。
清单 4-3。Game::HasFinished
bool HasFinished() const
{
return (m_playerQuit || m_playerWon);
}
Game
类试图在每个循环中向m_playerWon
变量写入一次。最终主thread
将尝试在游戏线程写入变量m_playerWon
的同时读取变量,程序将关闭。你用互斥来解决这个问题。C++ 提供了一个mutex
类,该类可以阻止多线程对共享变量的访问。通过添加清单 4-4 中的代码,你可以在Game
类中创建一个mutex
。
清单 4-4。创建一个mutex
std::mutex m_mutex;
std::unique_lock<std::mutex> m_finishedQueryLock{ m_mutex, std::defer_lock };
我们的mutex
有两个部分,互斥体本身和一个名为unique_lock
的包装器模板,它提供了对mutex
行为的方便访问。unique_lock
构造器将一个mutex
对象作为它的主要参数。这是它作用的mutex
。第二个参数是可选的;如果它没有被提供,unique_lock
立即获得对mutex
的锁定,但是通过传递std::defer_lock
我们可以防止这种情况发生。
此时,你可能想知道mutex
到底是如何工作的。一个mutex
可以锁定和解锁。我们将锁定一个mutex
的过程归类为获取一个锁。unique_lock
模板提供了三种方法来处理互斥:lock
、unlock
和try_lock
。
lock
方法是一个阻塞调用。这意味着你的线程的执行将会停止,直到mutex
被你调用lock
的thread
成功锁定。如果mutex
已经被另一个线程锁定,您的线程将等待mutex
解锁后再继续。
unlock
方法解锁一个锁定的mutex
。最佳实践是在尽可能少的代码行中保持锁定。一般来说,这意味着您应该在获得锁之前进行任何计算,获得锁,将结果写入共享变量,然后立即解锁以允许其他线程锁定mutex
。
try_lock
方法是lock
的非阻塞版本。如果获得了锁,该方法返回true
,如果没有获得锁,则返回false
。这允许你做其他工作,通常是在线程内的循环中,直到try_lock
方法返回true
为止。
现在您已经看到了创建锁的代码,我可以向您展示如何使用unique_lock
模板来防止您的文本冒险游戏崩溃。清单 4-5 使用lock
来保护对HasFinished
方法中m_playerQuit
和m_playerWon
变量的访问。
清单 4-5。用unique_lock
更新Game::
HasFinished
bool HasFinished() const
{
m_finishedQueryLock.lock();
bool hasFinished = m_playerQuit || m_playerWon;
m_finishedQueryLock.unlock();
return hasFinished;
}
HasFinished
方法现在在计算存储在hasFinished
变量中的值之前,调用 m_ finishedQueryLock
上的lock
方法。在方法中的 return 语句之前释放锁,以允许任何等待的threads
能够锁定mutex
。
这只是能够保护我们的程序免于崩溃的第一步。在主thread
上调用HasFinished
方法,但是从游戏thread
中写入m_playerWon
和m_playerQuit
变量。我在清单 4-6 中添加了三个新方法来保护游戏中的这些变量。
清单 4-6。Game::
SetPlayerQuit
和Game::
SetPlayerWon
的方法
void SetPlayerQuit()
{
m_finishedQueryLock.lock();
m_playerQuit = true;
m_finishedQueryLock.unlock();
}
void SetPlayerWon()
{
m_finishedQueryLock.lock();
m_playerWon = true;
m_finishedQueryLock.unlock();
}
bool GetPlayerWon()
{
m_finishedQueryLock.lock();
bool playerWon = m_playerWon;
m_finishedQueryLock.unlock();
return playerWon;
}
这意味着我们需要更新清单 4-7 所示的Game::OnQuit
方法。
清单 4-7。Game::OnQuit
法
void Game::OnQuit()
{
SerializationManager::GetSingleton().Save();
SetPlayerQuit();
}
Game::
OnQuit
方法现在调用SetPlayerQuit
方法,该方法使用m_finishedQueryLock
来保护变量访问。需要更新RunGame
方法来使用SetPlayerWon
和GetPlayerWon
方法,如清单 4-8 所示。
清单 4-8。更新Game::
RunGame
void Game::RunGame()
{
InitializeRooms();
const bool loaded = SerializationManager::GetSingleton().Load();
WelcomePlayer(loaded);
while (!HasFinished())
{
GivePlayerOptions();
stringstream playerInputStream;
GetPlayerInput(playerInputStream);
EvaluateInput(playerInputStream);
bool playerWon = true;
for (auto``&
{
playerWon``&
}
if (playerWon)
{
SetPlayerWon();
}
}
if (GetPlayerWon())
{
SerializationManager::GetSingleton().ClearSave();
cout << "Congratulations, you rid the dungeon of monsters!"<< endl;
cout << "Type goodbye to end" << endl;
std::string input;
cin >> input;
}
}
粗线显示了对该方法的更新,以支持对共享变量的mutex
保护。尝试遵循最佳实践,在调用SetPlayerWon
方法之前,使用一个局部变量来计算玩家是否赢得了游戏。您可以将整个循环封装在一个mutex
锁机制中,但是这会降低程序的速度,因为两个线程都将花费更长的时间处于等待锁被解锁而不执行代码的状态。
这种额外的工作是为什么将一个程序分成两个独立的线程并不能带来 100%的性能提升的一个原因,因为等待lock
来同步线程之间对共享内存的访问会有一些开销。减少这些同步点是从多线程代码中提取尽可能多的性能的关键。
线程和互斥体构成了多线程编程的底层视图。它们代表操作系统线程和锁的抽象版本。C++ 还提供了更高级别的线程抽象,您应该比线程更经常地使用它。这些在promise
和future
类中提供。
利用未来和承诺
future
和promise
类成对使用。promise
执行一个任务并将其结果放入一个future
中。一个future
阻塞线程上的执行,直到promise
结果可用。幸运的是,C++ 提供了第三个模板来为我们创建一个promise
和future
,这样我们就不必一次又一次地手动创建。
清单 4-9 更新了Game::RunGame
来使用一个packaged_task
来加载用户保存的游戏数据。
清单 4-9。使用packaged_task
bool LoadSaveGame()
{
return SerializationManager::GetSingleton().Load();
}
void Game::RunGame()
{
InitializeRooms();
std::packaged_task< bool() > loaderTask{ LoadSaveGame };
std::thread loaderThread{ std::ref{ loaderTask } };
auto loaderFuture = loaderTask.get_future();
while (loaderFuture.wait_for(std::chrono::seconds{ 0 }) != std::future_status::ready)
{
// Wait until the future is ready.
// In a full game you could update a spinning progress icon!
int x = 0;
}
bool userSaveLoaded = loaderFuture.get();
WelcomePlayer(userSaveLoaded);
while (!HasFinished())
{
GivePlayerOptions();
stringstream playerInputStream;
GetPlayerInput(playerInputStream);
EvaluateInput(playerInputStream);
bool playerWon = true;
for (auto& enemy : m_enemies)
{
playerWon &= enemy->IsAlive() == false;
}
if (playerWon)
{
SetPlayerWon();
}
}
if (GetPlayerWon())
{
SerializationManager::GetSingleton().ClearSave();
cout << "Congratulations, you rid the dungeon of monsters!" << endl;
cout << "Type goodbye to end" << endl;
std::string input;
cin >> input;
}
}
第一步是创建一个函数LoadSaveGame
,在另一个线程中执行。LoadSaveGame
调用了SerializationManager::Load
方法。LoadSaveGame
函数指针被传入packaged_task
构造器。packaged_task
模板已经专用于bool()
类型。这是函数的类型;它返回一个bool
并且不带任何参数。
然后使用std::ref
将packaged_task
传递给一个线程。当一个packaged_task
被传递给一个线程时,它可以被执行,因为一个线程对象知道如何处理packaged_task
对象。这是真的,因为一个packaged_task
对象重载了一个操作符,这允许它像函数一样被调用。这个重载的函数调用操作符调用用于构造packaged_task
的实际函数。
主线程现在可以调用packaged_task
上的get_future method
。在线程程序中使用了一个future
,允许你设置任务,这些任务将在未来的某个时刻提供返回值。您可以在future,
上立即调用get
,但是由于get
是一个阻塞调用,您的线程将会暂停,直到future
结果可用。清单 4-9 显示了另一个实现,其中wait_for
用于检查future
结果是否可用。
future::wait_for
方法从持续时间类的std::c
hrono 集中获取一个值。在本例中,我们传入了std::chrono::seconds{ 0 }
,这意味着该方法将立即返回结果。在我们的例子中,可能的返回值来自std::future_st
状态enum class
,是ready
或timeout
。将返回timeout
值,直到玩家的游戏被加载或者他或她选择开始新游戏。此时,我们可以调用future::get
方法,该方法通过传递给loaderTask
的LoadSaveGame
函数存储从SerializationManager::Loa
d 返回的值。
这就结束了您对多线程 C++ 编程的简要介绍。
摘要
在这一章中,你已经了解了 C++ 提供的一些类,这些类允许你在你的程序中添加多个执行线程。您首先看到了如何创建线程来执行函数。以这种方式调用函数允许操作系统在多个 CPU 线程上运行您的线程,并加快程序的执行。
当您使用线程时,您需要确保您的线程在访问变量和共享数据时不会冲突。您已经看到互斥体可以用来手动提供对变量的互斥访问。在展示了一个正在运行的mutex
之后,我向您介绍了packaged_task
模板,它自动创建了一个承诺和一个未来,以便在比基本线程和互斥体更高的层次上更好地管理您的并发任务。
像这样使用线程可以让你更好地响应玩家。在基于文本的游戏中,它们在这项任务中并不特别有效,但它们可以用于在基于 3D 图形的游戏中提供更多的每帧 CPU 执行时间,或者用于在其他 CPU 上执行长时间运行的任务时不断更新加载或进度条的情况。更好的响应速度或更快的帧速率可以提高游戏的可用性和玩家对游戏的感知。
本书的下一章将向你展示可以用来编写在多种平台上编译的代码的技术。如果你发现自己想要编写可以在 iOS、Android 和 Windows 手机上运行的游戏,或者可以在 Windows 和 Linux 上运行的游戏,甚至可以在 Xbox One 和 PlayStation 4 等游戏机上运行的游戏,这将非常有用。你甚至可以编写一个游戏,它可以像 Unity 这样的引擎一样运行在所有这些平台上。
五、在 C++ 中支持多种平台
在你的游戏开发生涯中,总有一天你不得不编写只能在单一平台上运行的代码。这些代码将不得不在其他平台上编译。您很可能还必须为您将要工作的每个平台找到替代实现。此类代码的经典示例通常可以在您的游戏与在线登录和微交易解决方案之间的交互中找到,如 Game Center、Google+、Xbox Live、PlayStation Network 和 Steam。
不同平台之间可能会有更复杂的问题。iOS 设备运行在 Arm 处理器上,Android 支持 Arm、x86 和 MIPS,大多数其他操作系统可以运行在不止一个指令集上。可能出现的问题是,这些 CPU 指令集的编译器可以为它们的内置类型使用不同的大小。从 32 位 CPU 迁移到 64 位 CPU 时尤其如此,在这种情况下,指针的大小不再是 32 位,而是 64 位。如果假设类型和指针是固定大小的,这可能会导致各种各样的可移植性问题。这些问题可能很难跟踪,通常会导致图形损坏,或者您会看到您的程序只是在随机时间崩溃。
确保类型在多个平台上大小相同
确保您的程序在多个平台上使用相同大小的类型比您最初想象的要容易。C++ STL 提供了一个名为 cstdint 的头文件,其中包含大小一致的类型。这些类型是:
int8_t and uint8_t
int16_t and uint16_t
int32_t and uint32_t
int64_t and uint64_t
int8_t
和uint8_t
提供长度为 8 位或一个字节的整数。u 版是unsigned
,而non-u
版是signed
。其他类型类似,但长度相等。整数有 16 位版本、32 位和 64 位版本。
您应该暂时避免使用 64 位整数,除非您明确需要不能存储在 32 位内的数字。大多数处理器在进行算术运算时仍然对 32 位整数进行操作。即使 64 位处理器有 64 位内存地址用于指针,仍然使用 32 位整数进行普通运算。64 位值使用的内存是 32 位值的两倍,这增加了执行程序所需的 RAM。
下一个可能出现的问题是char
类型可能在所有平台上都不相同。C++ 不提供固定大小的char
类型,所以我们需要随机应变。我开发游戏的每个平台都使用了 8 位char
类型,所以我们只考虑这一点。然而,我们将定义我们自己的char
类型别名,这样,如果你曾经使用大于 8 位的字符对一个平台进行移植编码,那么你将只需要在一个地方解决这个问题。清单 5-1 显示了新标题 FixedTypes.h 的代码。
清单 5-1。固定类型. h
#pragma once
#include <cassert>
#include <cstdint>
#include <climits>
static_assert(CHAR_BIT == 8, "Compiling on a platform with large char type!");
using char8_t = char;
using uchar8_t = unsigned char;
FixedTypes.h 文件包含cstdint
,它让我们可以访问 8–64 位固定宽度的整数。然后我们有一个 stati c_a
ssert,它确保CHAR_BIT
常量等于 8。CHAR_BIT 常量由climits
头提供,包含目标平台上的char
类型使用的位数。这个static_assert
将确保我们包含FixedTypes
头的代码不会在使用超过 8 位的char
的平台上编译。然后这个头定义了两个类型别名,char8_t
和uchar8_t
,当你知道你特别需要 8 位字符时,你应该使用它们。这不一定到处都是。一般来说,当在另一台使用 8 位字符值的计算机上加载使用工具写出的数据时,您将需要 8 位char
类型,因为数据中的字符串长度是每个字符一个字节,而不是更多。如果你不确定是否需要 8 位字符,你最好坚持使用 8 位字符。
cstdint 头中解决的最后一个问题是在具有不同大小的整数指针的平台上使用指针。考虑清单 5-2 中的代码。
清单 5-2。一个错误指针转换的例子
bool CompareAddresses(void* pAddress1, void* pAddress2)
{
uint32_t address1 = reinterpret_cast<uint32_t>(pAddress1);
uint32_t address2 = reinterpret_cast<uint32_t>(pAddress2);
return address1 == address2;
}
在少数情况下,您可能需要比较两个地址的值,您可以将指针指向 32 位无符号整数来实现这种比较。然而,这个代码是不可移植的。以下两个十六进制值表示 64 位计算机上的不同内存位置:
0xFFFFFFFF00000000
0x0000000000000000
如果将这两个值强制转换为 uint32_t,存储在无符号整数中的两个十六进制值将是:
0x00000000
0x00000000
对于两个不同的地址,CompareAddresses
函数将返回 true,因为 64 位地址的高 32 位已经被reinterpret_cast
在没有警告的情况下缩小了。这个函数总是在 32 位或更少的系统上工作,只在 64 位系统上中断。清单 5-3 包含了这个问题的解决方案。
清单 5-3。一个好的指针比较的例子
bool CompareAddresses(void* pAddress1, void* pAddress2)
{
uintptr_t address1 = reinterpret_cast<uintptr_t>(pAddress1);
uintptr_t address2 = reinterpret_cast<uintptr_t>(pAddress2);
return address1 == address2;
}
cstdint 头提供了intptr_t
和uintptr_t
,它们是具有足够字节的signed
和unsigned
整数,可以在目标平台上完整地存储一个地址。如果您想编写可移植的代码,那么在将指针转换为整数值时,您应该总是使用这些类型!
既然我们已经讨论了在不同平台上使用不同大小的整数和指针可能遇到的不同问题,我们将看看如何在不同平台上提供不同的类实现。
使用预处理器确定目标平台
在这一节中,我将立即向您展示一个头文件,它定义了预处理器宏来确定您当前的目标平台。清单 5-4 包含 Platforms.h 头文件的代码。
清单 5-4。平台. h
#pragma once
#if defined(_WIN32) || defined(_WIN64)
#define PLATFORM_WINDOWS 1
#define PLATFORM_ANDROID 0
#define PLATFORM_IOS 0
#elif defined(__ANDROID__)
#define PLATFORM_WINDOWS 0
#define PLATFORM_ANDROID 1
#define PLATFORM_IOS 0
#elif defined(TARGET_OS_IPHONE)
#define PLATFORM_WINDOWS 0
#define PLATFORM_ANDROID 0
#define PLATFORM_IOS 1
#endif
这个头文件完成了将 Windows、Android 和 iOS 构建工具提供的预处理符号转换成我们现在可以在自己的代码中使用的单一定义的任务。在 Windows 机器上,_WIN32
和_WIN64
宏被添加到你的构建中,而__ANDROID__
和TARGET_OS_IPHONE
在构建 Android 和 iOS 应用程序时存在。这些定义会随着时间的推移而改变,一个明显的例子是在 64 位版本的 Windows 操作系统之前不存在的_WIN64
宏,这就是我们想要创建自己的平台宏的原因。我们可以添加或删除 Platforms.h,只要我们认为合适,而不会影响我们程序的其余部分。
我已经更新了Enemy
类,使其具有特定于平台的实现,向您展示如何将这些特定于平台的类付诸实践。清单 5-5 显示了Enemy
类已经被重命名为EnemyBase
。
清单 5-5。将Enemy
重命名为EnemyBase
#pragma once
#include "Entity.h"
#include "EnemyFactory.h"
#include "Serializable.h"
#include <memory>
class
EnemyBase
: public Entity
, public Serializable
{
public:
using Pointer = std::shared_ptr<``EnemyBase
private:
EnemyType m_type;
bool m_alive{ true };
public:
EnemyBase
(EnemyType type, const uint32_t serializableId)
: m_type{ type }
, Serializable(serializableId)
{
}
EnemyType GetType() const
{
return m_type;
}
bool IsAlive() const
{
return m_alive;
}
void Kill()
{
m_alive = false;
}
virtual void OnSave(std::ofstream& file)
{
file << m_alive;
}
virtual void OnLoad(std::ifstream& file)
{
file >> m_alive;
}
};
该类不是纯虚拟的,因为我们实际上没有任何平台特定的代码要添加,因为这是一个用于说明目的的练习。你可以想象一个合适的平台抽象基类会有纯虚拟方法,这些方法会添加特定于平台的代码。
下一步是为我们不同的平台创建三个类。这些如清单 5-6 所示。
清单 5-6。WindowsEnemy
、AndroidEnemy
和iOSEnemy
class WindowsEnemy
: public EnemyBase
{
public:
WindowsEnemy(EnemyType type, const uint32_t serializableId)
: EnemyBase(type, serializableId)
{
std::cout << “Created Windows Enemy!” << std::endl;
}
};
class AndroidEnemy
: public EnemyBase
{
public:
AndroidEnemy(EnemyType type, const uint32_t serializableId)
: EnemyBase(type , serializableId)
{
std::cout << "Created Android Enemy!" << std::endl;
}
};
class iOSEnemy
: public EnemyBase
{
public:
iOSEnemy(EnemyType type, const uint32_t serializableId)
: EnemyBase(type, serializableId)
{
std::cout << "Created iOS Enemy!" << std::endl;
}
};
这三个类依靠多态来允许程序的其余部分使用EnemyBase
类,而不是平台特定的实现。最后要解决的问题是如何创建这些类,幸运的是工厂模式给了我们一个现成的解决方案。清单 5-7 更新了EnemyFactory
,为我们的实现创建了正确类型的EnemyBase
。
清单 5-7。用平台特定类型更新EnemyFactory
namespace
{
#if PLATFORM_WINDOWS
#include "WindowsEnemy.h”
using Enemy = WindowsEnemy;
#elif PLATFORM_ANDROID
#include "AndroidEnemy.h"
using Enemy = AndroidEnemy;
#elif PLATFORM_IOS
#include "iOSEnemy.h"
using Enemy = iOSEnemy;
#endif
}
EnemyBase* CreateEnemy(EnemyType enemyType, const uint32_t serializableId)
{
Enemy* pEnemy = nullptr;
switch (enemyType)
{
case EnemyType::Dragon:
pEnemy = new Enemy(EnemyType::Dragon, serializableId);
break;
case EnemyType::Orc:
pEnemy = new Enemy(EnemyType::Orc, serializableId);
break;
default:
assert(false); // Unknown enemy type
break;
}
return pEnemy;
}
CreateEnemy
函数本身只有一个方面的变化。它的返回类型现在是EnemyBase
而不是Enemy
。这是因为我使用了一个类型别名来将敌人关键字映射到正确的特定于平台的敌人版本。您可以在函数前的未命名空间中看到这一点。我检查每个平台定义,包括适当的头,最后添加using Enemy =
将类型别名设置为正确的类型。
当您需要实现特定于平台的类版本时,工厂模式是一种完美的方法。工厂允许您对程序的其余部分隐藏创建对象的实现细节。这使得代码更容易维护,并减少了代码库中需要修改以添加新平台的地方。缩短移植到新平台的时间可能是一个有利可图的商机,并为您的公司开辟新的潜在收入来源。
摘要
本章展示了一些对你的跨平台游戏开发项目有用的技术。我建议抽象出所有你知道的使用特定平台 API 或任何需要包含特定平台头文件的类的东西。即使您开始游戏开发项目时没有移植到另一个平台的计划,如果您在游戏的原始版本中采取了一些基本的预防措施,也总是更容易决定支持更多的平台。一旦你养成了总是抽象出特定于平台的代码的习惯,添加平台就会变得容易得多。
可以找到平台特定代码的经典领域是 DirectX、OpenGL、Mantle 和 Metal 等图形 API、文件处理系统、控制器支持、成就和好友列表等在线功能以及商店微交易支持。所有这些系统都可以隐藏在你自己的类接口后面,一个工厂可以用来在运行时实例化类的正确版本。编译器预处理器标志应该用于防止编译和链接错误,这些错误是由于包含了只能与特定平台的 API 一起工作的代码而导致的。一个容易理解的例子是 PlayStation 4 控制器代码不能在 Xbox One 目标上编译。
六、文本冒险
C++ 编程语言是一种工具,当你试图构建视频游戏时,它会很好地为你服务。它提供了对处理器的低级访问,允许您为各种各样的计算机处理器编写高效的代码。
这一章非常简要地概述了用 C++ 编写的老派文本冒险。所提供的代码可在本书的网页 www.apress.com/9781484208151
上在线获得。该代码旨在作为各种 C++ 技术的示例,而不是商业 C++ 代码应该如何编写的示例。
文本冒险概述
这是一个非常简单的文本冒险游戏,如果你愿意的话,你可以把它扩展成一个完整的游戏。清单 6-1 显示了Game
类的定义。这个类封装了 C++ 提供的所有编程类型。
清单 6-1。Game class
定义
class Game
: public EventHandler
, public QuitObserver
{
private:
static const uint32_t 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<EnemyBase::Pointer>;
Enemies m_enemies;
std::mutex m_mutex;
mutable std::unique_lock<std::mutex> m_finishedQueryLock{ m_mutex, std::defer_lock };
bool m_playerQuit{ false };
void SetPlayerQuit()
{
m_finishedQueryLock.lock();
m_playerQuit = true;
m_finishedQueryLock.unlock();
}
bool m_playerWon{ false };
void SetPlayerWon()
{
m_finishedQueryLock.lock();
m_playerWon = true;
m_finishedQueryLock.unlock();
}
bool GetPlayerWon()
{
m_finishedQueryLock.lock();
bool playerWon = m_playerWon;
m_finishedQueryLock.unlock();
return playerWon;
}
void InitializeRooms();
void WelcomePlayer(const bool loaded);
void GivePlayerOptions() const;
void GetPlayerInput(std::stringstream& playerInput) const;
void EvaluateInput(std::stringstream& playerInput);
public:
Game();
virtual ∼Game();
void RunGame();
virtual void HandleEvent(const Event* pEvent);
// From QuitObserver
virtual void OnQuit();
bool HasFinished() const
{
m_finishedQueryLock.lock();
bool hasFinished = m_playerQuit || m_playerWon;
m_finishedQueryLock.unlock();
return hasFinished;
}
};
Game
类展示了如何在 C++ 中构造类。有一个Game
派生自的父类。这个类提供了一个包含虚方法的接口。Game
类用自己的特定实例覆盖了这些虚方法。一个很好的例子就是HandleEvent
方法。
也展示了你如何为自己的使用专门化 STL 模板。有一个Room::Pointer
实例的数组和一个EnemyBase::Pointer
实例的向量。这些类型的指针是使用类型别名创建的。C++ 中的类型别名允许您创建自己的命名类型,通常是个好主意。如果以后需要更改对象的类型,只需更改类型别名就可以了。如果您没有使用别名,您将需要手动更改使用该类型的每个位置。
游戏班还有一个mutex
在场。这个mutex
提示了一个事实,即 C++ 允许你编写可以同时在多个 CPU 内核上执行的程序。mutex
是一个互斥对象,它允许你确保一次只有一个线程访问一个变量。
清单 6-2 包含了Game::
RunGame
方法的最终源代码。这个方法由代码组成,展示了如何迭代集合和使用期货。
清单 6-2。Game::RunGame
法
void Game::RunGame()
{
InitializeRooms();
std::packaged_task< bool() > loaderTask{ LoadSaveGame };
std::thread loaderThread{ std::ref{ loaderTask } };
auto loaderFuture = loaderTask.get_future();
while (loaderFuture.wait_for(std::chrono::seconds{ 0 }) != std::future_status::ready)
{
// Wait until the future is ready.
// In a full game you could update a spinning progress icon!
int32_t x = 0;
}
bool userSaveLoaded = loaderFuture.get();
loaderThread.join();
WelcomePlayer(userSaveLoaded);
while (!HasFinished())
{
GivePlayerOptions();
stringstream playerInputStream;
GetPlayerInput(playerInputStream);
EvaluateInput(playerInputStream);
bool playerWon = true;
for (auto& enemy : m_enemies)
{
playerWon &= enemy->IsAlive() == false;
}
if (playerWon)
{
SetPlayerWon();
}
}
if (GetPlayerWon())
{
SerializationManager::GetSingleton().ClearSave();
cout << "Congratulations, you rid the dungeon of monsters!" << endl;
cout << "Type goodbye to end" << endl;
std::string input;
cin >> input;
}
}
基于范围的 for 循环可以与关键字auto
结合使用,在许多 STL 集合上提供简单、可移植的迭代。你可以在RunGame
中看到它的作用,在m_enemies vector
上有一个环。
一个paired_t
ask 用于在一个单独的执行线程上执行保存游戏加载。std::thread::get_f
future 方法用于获取一个future
对象,让您知道您正在执行的任务何时完成。这种加载方法可以让你在更新动态加载屏幕的同时加载游戏。
还有一个如何使用cin
和cout
读取玩家输入并将消息写到控制台的例子。输入和输出是游戏开发者的基本概念,因为它们对于提供玩家期望从游戏中获得的交互性是必不可少的。
摘要
游戏开发是一个有趣但要求很高的领域。有许多领域需要探索、学习和尝试掌握。很少有人精通游戏开发的所有领域,但是他们的编程技能通常是可以转移的。程序员可以专攻图形编程、网络编程、游戏性编程或者音频、动画等其他领域。程序员永远不会缺少任务,因为大多数大型游戏都是用 C++ 编写的,代码库已经存在了十到二十年。Cryengine、Unreal、Unity 等引擎都是用 C++ 编写的,提供脚本语言的支持来创建游戏逻辑。对于希望开始游戏开发职业生涯的人来说,C++ 是一个完美的选择,这将在某个时候带他们进入 AAA 游戏开发工作室。
我希望你已经发现这本书是你选择职业道路的一个愉快的开端。