C++ 游戏开发入门手册(二)
四、使用并发编程加速游戏
处理器制造商的 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_ffuture 方法用于获取一个future对象,让您知道您正在执行的任务何时完成。这种加载方法可以让你在更新动态加载屏幕的同时加载游戏。
还有一个如何使用cin和cout读取玩家输入并将消息写到控制台的例子。输入和输出是游戏开发者的基本概念,因为它们对于提供玩家期望从游戏中获得的交互性是必不可少的。
摘要
游戏开发是一个有趣但要求很高的领域。有许多领域需要探索、学习和尝试掌握。很少有人精通游戏开发的所有领域,但是他们的编程技能通常是可以转移的。程序员可以专攻图形编程、网络编程、游戏性编程或者音频、动画等其他领域。程序员永远不会缺少任务,因为大多数大型游戏都是用 C++ 编写的,代码库已经存在了十到二十年。Cryengine、Unreal、Unity 等引擎都是用 C++ 编写的,提供脚本语言的支持来创建游戏逻辑。对于希望开始游戏开发职业生涯的人来说,C++ 是一个完美的选择,这将在某个时候带他们进入 AAA 游戏开发工作室。
我希望你已经发现这本书是你选择职业道路的一个愉快的开端。