去年,在获得期待已久的
Lead C ++ Over-Engineer头衔的过程中,我决定使用现代C ++(C ++ 17)的精髓重写我在工作时间内开发的游戏(Candy Crush Saga)。 因此,
Meta Crush Saga诞生了:一种
在编译阶段运行的
游戏 。 马特·伯纳(Matt Birner)的
Nibbler游戏给我很大的启发,该游戏在模板上使用纯元编程,以诺基亚3310重现著名的Snake。
“
它在编译阶段正在运行哪种
游戏 ?”,“它看起来如何?”,“在该项目中您使用了
C ++ 17的哪些功能?”,“您学到了什么?” -您可能会想到类似的问题。 要回答这些问题,您要么必须阅读整个帖子,要么忍受内心的懒惰,并观看该帖子的视频版本-我在斯德哥尔摩的
Meetup活动中的报告:
注意:为了您的心理健康,并且由于
人道的错误 ,本文提供了一些替代事实。
一个在编译时运行的游戏?
我认为,为了理解
在编译阶段执行的
游戏的“概念”的含义,您需要将此类游戏的生命周期与普通游戏的生命周期进行比较。
常规游戏的生命周期:
作为具有正常生活的游戏的常规开发者,以正常的心理健康水平从事常规工作,您通常首先以自己喜欢的语言(当然是C ++)编写
游戏逻辑 ,然后运行
编译器将其转换,就像意大利面条一样
可执行文件中的逻辑。 双击
可执行文件 (或从控制台开始)后,操作系统会产生一个
进程 。 此
过程将执行
游戏逻辑 ,该
逻辑由99.42%的
游戏周期组成。
游戏周期根据某些规则和
用户输入来 更新游戏状态,一次又一次,一次又一次地
渲染新计算出的游戏状态。
在编译过程中运行的游戏的生命周期:
作为创建新的酷炫编译游戏的过度工程师,您仍然可以使用自己喜欢的语言(当然,还是C ++!)来编写
游戏逻辑 。 然后,和以前一样,
编译阶段继续
进行 ,但是有一个图样折衷:您在编译阶段
执行 游戏逻辑 。 您可以将其称为“执行”(编译)。 在这里,C ++非常有用。 它具有诸如
模板元编程(TMP)和
constexpr之类的功能 ,可让您在
编译阶段执行
计算 。 稍后我们将考虑可用于此目的的功能。 由于在此阶段我们执行游戏的
逻辑 ,因此在这一刻,我们还需要插入
玩家的输入 。 显然,我们的编译器仍会在输出中创建一个
可执行文件 。 它可以用来做什么? 可执行文件将不再包含
游戏循环 ,但其任务非常简单:显示新的
计算状态 。 让我们将此
可执行文件称为 渲染器 ,然后
渲染 其 渲染 的数据 。 在我们的
渲染中,不会包含美丽的粒子效果或环境光遮蔽阴影,而是ASCII。 新计算
状态的 ASCII
呈现是一种方便的属性,可以很容易地向播放器演示,但是我们还将其复制到文本文件中。 为什么是文本文件? 显然,因为它可以以某种方式与
代码结合并重新执行所有前面的步骤,从而获得一个
循环 。
如您所知,
在编译过程中
执行的游戏包含一个
游戏周期 ,其中
游戏的每个
帧都是一个
编译阶段 。
编译的每个
阶段都会计算出游戏的新
状态 ,可以将其显示给玩家并插入下一
帧 /
编译阶段 。
您可以随心所欲地构思一下这个宏伟的图表,直到了解我刚刚写的内容:
在我们详细介绍实现这种循环的细节之前,我确定您想问我唯一的问题...
“为什么要这么做?”
您是否真的认为破坏我的C ++元编程田园诗是一个基本问题? 是的,一辈子都没有!
- 首先也是最重要的一点是,在编译阶段执行的游戏将具有惊人的执行速度,因为大部分计算是在编译阶段执行的。 运行速度是使用ASCII图形的AAA游戏成功的关键!
- 您可以减少一些甲壳类动物出现在您的存储库中的可能性,并要求您用Rust重写游戏。 一旦您向他解释编译时就不会存在无效的指针,那么他准备充分的演讲就会崩溃。 Haskell自信的程序员甚至可以在您的代码中确认类型安全性 。
- 您将赢得Javascript时髦王国的尊重,只要重新命名的框架带有很酷的名字,任何具有强烈NIH综合症的经过重新设计的框架都可以统治该框架。
- 我的一个朋友曾经说过,实际上任何行的Perl代码都可以用作非常强大的密码。 我确信他从来没有尝试过用C ++编译时生成密码。
怎么了 您对我的回答满意吗? 那么也许您的问题应该是:“您甚至如何做到这一点?”
实际上,我真的很想尝试
C ++ 17中添加的功能。 它的许多功能旨在提高语言的有效性以及元编程(主要是constexpr)。 我认为,与其编写小的代码示例,不如将所有这些都变成游戏,将更加有趣。 宠物项目是学习不需要在工作中经常使用的概念的好方法。 再次在编译时执行基本游戏逻辑的能力再次证明模板和constepxr是C ++语言的
图灵完备子集。
元粉碎传奇游戏评论
三消游戏:
Meta Crush Saga是一款类似于
Bejeweled和
Candy Crush Saga的 游戏 。 游戏规则的核心是将具有相同图案的三个图块连接起来以获得得分。 快速浏览一下我“转储”
的游戏状态 (以ASCII转储很容易实现):
R“(
迷恋传奇
------------------------
| |
| RBGBBYGR |
| |
| |
| YYGRBGBR |
| |
| |
| RBYRGRYG |
| |
| |
| RYBY(R)YGY |
| |
| |
| BGYRYGGR |
| |
| |
| RYBGYBBG |
| |
------------------------
>得分:9009
招式:27
)“
这款Match-3游戏本身的玩法并不特别有趣,但是,它们都可以在其上运行的架构又如何呢? 为了让您理解它,我将尝试用代码解释这个
编译期游戏生命周期的每个部分。
游戏状态注入:
如果您是一个热情的C ++爱好者或书呆子,您可能已经注意到,以前的游戏状态转储以以下模式开头:
R“( 。实际上,这是
原始的C ++ 11字符串文字 ,这意味着我不需要转义特殊字符,例如,
翻译字符串 :原始字符串文字存储在名为
current_state.txt的文件中。
我们如何将游戏的当前状态注入编译状态? 让我们将其添加到循环输入中!
无论是
.txt文件还是
.h文件,C预处理器中的
include指令都将以相同的方式工作:它将文件的内容复制到其位置。 在这里,我将游戏状态的原始字符串文字(以ascii形式)复制到一个名为
game_state_string的变量中。
请注意,
loop_inputs.hpp头
文件还将键盘输入扩展到当前帧/编译步骤。 与游戏状态不同,键盘的状态非常小,可以很容易地将其作为预处理程序的定义。
在编译时计算新状态:
现在我们已经收集了足够的数据,我们可以计算新状态。 最后,我们到达了需要编写
main.cpp文件的地步:
奇怪,但是考虑到它的作用,此C ++代码看起来并不那么混乱。 大多数代码在编译阶段执行,但是它遵循传统的OOP和过程编程范例。 为了在编译时完全执行计算,只有最后一行(渲染)是一个障碍。 正如我们将在下面看到的,在正确的地方扔一些constexpr,我们可以在C ++ 17中获得相当优雅的元编程。 当在运行时和编译时执行混合执行时,C ++给我们带来了自由,这让我感到很高兴。
您还将注意到,此代码仅执行一帧,没有
游戏循环 。 让我们解决这个问题!
我们将所有内容粘合在一起:
如果您讨厌
C ++的技巧,那么希望您不要介意我的
Bash技能。 实际上,我的
游戏循环不过是一个不断编译的
bash脚本 。
实际上,从控制台获取键盘输入时遇到了一些麻烦。 最初,我想与编译并行进行。 经过多次尝试和错误,我设法从
Bash的
read
命令中获得或多或少的帮助。 我从不敢与决斗的巫师
Bash战斗-这种语言太险恶了!
因此,我必须承认,为了管理游戏周期,我不得不求助于另一种语言。 尽管从技术上讲,没有什么阻止我用C ++编写这部分代码。 此外,这并不否认我的游戏90%的逻辑是在
g ++编译团队内部执行的事实,这真是太了不起了!
一个小游戏,让您休息一下:
既然您已经经历了解释游戏架构的折磨,那么吸引眼球的绘画的时候到了:
这个像素化的gif记录了我如何玩
Meta Crush Saga 。 如您所见,游戏运行流畅,可以实时播放。 显然,她并没有那么吸引人,我可以流传她的Twitch并成为新的Pewdiepie,但是她可以工作!
在
.txt文件中存储
游戏状态的有趣方面之一是非常方便地作弊或测试极端情况的能力。
现在,我已经向您简要介绍了体系结构,我们将深入研究该项目中使用的C ++ 17功能。 我不会详细考虑游戏逻辑,因为它专门指的是Match-3,而是我将讨论可用于其他项目的C ++方面。
我有关C ++ 17的教程:
与主要包含次要修复程序的C ++ 14不同,新的C ++ 17标准可以为我们提供很多帮助。 希望最终会出现期待已久的功能(模块,协程,概念……),但是……总的来说……它们并没有出现。 它使我们许多人不高兴。 但是,在消除了哀悼之后,我们发现了许多意外的小型珍宝,但这些珍宝已成为标准品。
我敢说,喜欢元编程的孩子今年太宠坏了! 现在,对该语言进行了单独的细微更改和添加,使您可以编写在编译时以及运行后非常有效的代码。
Constepxr在所有领域:
正如Ben Dean和Jason Turner在其
有关C ++ 14的
报告中所预测的那样,C ++使您可以在编译时使用全能关键字
constexpr快速改进值的编译。 通过在正确的位置放置此关键字,可以告诉编译器表达式是常量,
可以在编译时直接
对其求值。 在
C ++ 11中,我们已经可以编写以下代码:
constexpr int factorial(int n)
尽管
constexpr关键字功能非常强大,但是它有很多使用限制,因此很难以这种方式编写表达性代码。
C ++ 14大大减少了
constexpr的需求,并且使用起来变得更加自然。 我们以前的阶乘函数可以重写如下:
constexpr int factorial(int n) { if (n <= 1) { return 1; } return n * factorial(n - 1); }
C ++ 14摆脱了
constexpr函数应仅包含一个return语句的规则,这迫使我们使用
三元运算符作为主要构造块。 现在,
C ++ 17带来了更多
constexpr关键字应用程序,我们可以探索!
在编译时分支:
您是否曾经遇到过根据所使用的模板参数需要采取不同行为的情况? 假设我们需要一个参数
serialize
函数
serialize
,如果对象提供了该函数,它将调用
.serialize()
,否则将诉诸于调用
to_string
。 如这篇
关于SFINAE的文章中更详细地解释的那样,很可能您将不得不编写这样的外来代码:
template <class T> std::enable_if_t<has_serialize_v<T>, std::string> serialize(const T& obj) { return obj.serialize(); } template <class T> std::enable_if_t<!has_serialize_v<T>, std::string> serialize(const T& obj) { return std::to_string(obj); }
只有在梦中,您才能将这个
从SFINAE技巧到
C ++ 14的丑陋
技巧重写为如此宏伟的代码:
不幸的是,当您醒来并开始编写真正的
C ++ 14代码时 ,编译器发出了有关调用
serialize(42);
的令人不愉快的消息
serialize(42);
。 它解释说
obj
类型为
int
的
obj
没有成员函数
serialize()
。 不管它如何激怒您,编译器都是正确的! 使用此代码,他将始终尝试编译两个分支
return obj.serialize();
和
return std::to_string(obj);
。 对于
int
分支,
return obj.serialize();
可能是某种
has_serialize(obj)
代码,因为
has_serialize(obj)
将始终返回
false
,但是编译器仍必须对其进行编译。
您可能已经猜到了,
C ++ 17使我们摆脱了这种令人不快的情况,因为它使在if语句后添加
constexpr以在编译时“强制”分支并丢弃未使用的构造成为可能:
显然,这是对我们之前必须应用
的SFINAE技巧的巨大改进。 此后,我们开始与Ben和Jason一样上瘾-我们开始在所有地方始终使用
constexpr 。 las,还有另一个适合
constexpr关键字但尚未使用的地方:
constexpr parameters 。
Constexpr参数:
如果小心,在前面的代码示例中可能会注意到一个奇怪的模式。 我说的是循环输入:
为什么将变量
game_state_string封装在constexpr lambda中? 她为什么不让她成为
全局变量constexpr ?
我想将此变量及其内容传递给一些函数。 例如,
您需要将其传递给我的
parse_board并在某些常量表达式中使用它:
constexpr int parse_board_size(const char* game_state_string); constexpr auto parse_board(const char* game_state_string) { std::array<GemType, parse_board_size(game_state_string)> board{};
如果我们采用这种方式,则
脾气暴躁的编译器将抱怨
game_state_string参数不是常量表达式。 创建切片数组时,需要直接计算其固定容量(由于需要内存分配,因此无法在编译时使用向量),并将其作为参数传递给
std :: array中的值模板。 因此,
parse_board_size(game_state_string)表达式必须是一个常量表达式。 尽管
parse_board_size被显式标记为
constexpr ,但
game_state_string不是,也不能是! 在这种情况下,有两个规则会干扰我们:
- constexpr函数的参数不是constexpr!
- 而且我们不能在它们前面添加constexpr!
所有这些归结为一个事实,即
constexpr函数必须适用于计算运行时和编译时间。 假设存在
constexpr参数 ,则不允许在运行时使用它们。
幸运的是,有一种方法可以解决此问题。 除了将值作为函数的常规参数接受之外,我们可以将此值封装为一个类型并将此类型作为模板参数传递:
template <class GameStringType> constexpr auto parse_board(GameStringType&&) { std::array<CellType, parse_board_size(GameStringType::value())> board{};
在此代码示例中,我将创建一个具有静态成员函数constexpr
value()的
GameString结构类型,该成员函数返回要传递给
parse_board的字符串文字。 在
parse_board中,我使用
GameStringType模板
参数 (使用提取模板参数的规则)获得此类型。 拥有
GameStringType ,由于
value()是constexpr的事实,即使在需要常量表达式的地方,我也可以在适当的时间简单地调用静态成员函数
value()以获取字符串文字。
我们设法封装了文字,以便使用constexpr将其传递给
parse_board 。 但是,每次需要发送新的
parse_board文字时,都需要定义一个新类型是非常烦人的:“ ... something1 ...”,“ ... something2 ...”。 为了解决
C ++ 11中的这个问题,您可以使用匿名联合和lambda应用一些丑陋的宏和间接寻址。 迈克尔•帕克(Michael Park)在
一篇帖子中很好地解释了这个话题。
在
C ++ 17中,情况甚至更好。 如果我们列出了传递字符串文字的要求,则会得到以下信息:
- 函数生成
- 那就是constexpr
- 具有唯一或匿名名称
这些要求应该给您提示。 我们需要的是
constexpr lambda ! 在
C ++ 17中,他们完全自然地增加了将
constexpr关键字用于lambda函数的功能。 我们可以如下重写示例代码:
template <class LambdaType> constexpr auto parse_board(LambdaType&& get_game_state_string) { std::array<CellType, parse_board_size(get_game_state_string())> board{};
相信我,这已经比以前使用
C ++ 11使用宏进行黑客攻击更加方便了。 感谢我参与的C ++ mitap小组的成员
Bjorn Fahler ,我发现了这个很棒的技巧。 在他的
博客中阅读有关此技巧的更多信息。 同样值得考虑的是,在这种情况下,实际上
constexpr关键字是可选的:默认情况下,所有具有成为
constexpr能力的
lambda都是它们。 显式添加
constexpr是一种签名,可以简化我们的故障排除。
现在,您必须了解为什么我被迫使用
constexpr lambda传递代表游戏状态的字符串。 查看此lambda函数,您将再次遇到另一个问题。 我还用来包装股票文字的
constexpr_string类型是什么?
constexpr_string和constexpr_string_view:
在处理字符串时,您不应以C风格处理它们,您需要忘记所有执行原始迭代并检查零完成的烦人算法!
C ++提供的替代方法是万能的
std :: string和
STL算法 。 不幸的是,
std :: string可能需要在堆上分配内存(即使使用Small String Optimization)也要存储其内容。 回到一两个标准,我们可以使用
constexpr new / delete ,也可以将
constexpr分配器传递给
std :: string ,但是现在我们需要找到另一个解决方案。
我的方法是编写一个具有固定容量的
constexpr_string类。 此容量作为参数传递给值模板。 这是我班的简要概述:
template <std::size_t N>
我的
constexpr_string类试图尽可能地模仿
std :: string接口(用于我需要的操作):我们可以请求
开始和结束的迭代器 ,获取
大小(大小) ,访问
数据(数据) ,
删除(擦除)其中的一部分,获取
substr使用
substr等。 这
使得将一段代码从
std :: string转换为
constexpr_string非常容易。 您可能想知道当我们需要使用通常需要在
std :: string中突出显示的操作时会发生什么。 在这种情况下,我被迫将它们转换为创建
constexpr_string的新实例的
不可变操作 。
让我们看一下
append操作:
template <std::size_t N>
您不需要菲尔兹奖就可以假设,如果我们有一个大小为
N的字符串和一个大小为
M的字符串,那么大小为
N + M的字符串就足以存储它们的串联。 我们可能会浪费“编译时存储库”的一部分,因为两条代码行可能都没有使用全部容量,但是为方便起见,这是一个相当小的价格。 显然,我还写了一个
std :: string_view的副本 ,称为
constexpr_string_view 。
在这两个类中,我准备编写优美的代码来解析我的
游戏状态 。 想像这样的事情:
constexpr auto game_state = constexpr_string(“...something...”);
在运动场上遍历珠宝非常容易-顺便说一句,您在此代码示例中注意到
C ++ 17的另一个重要功能吗?
是的 在构造它时,我不必显式指定
constexpr_string的容量。 以前,使用
类模板时 ,我们必须明确指出其参数。 为了避免这些麻烦,我们创建了
make_xxx函数,因为可以跟踪
函数模板的参数。 了解
跟踪类模板参数如何更好地改变我们的生活:
template <int N> struct constexpr_string { constexpr_string(const char(&a)[N]) {}
在某些困难的情况下,您将需要帮助编译器正确地计算参数。 如果遇到此类问题,请阅读
用户定义参数计算手册 。
来自STL的免费食物:
好吧,我们总是可以自己重写所有内容。 但是,也许委员会成员已经在标准库中为我们慷慨地准备了一些东西?
新的助手类型:
在
C ++ 17中 ,
std :: variant和
std :: optional被添加到基于
constexpr的标准字典类型中。 第一个非常有趣,因为它允许我们表达类型安全的关联,但是使用常量表达式时,使用
GCC 7.2的
libstdc ++库中的实现存在问题。 因此,我放弃了在代码中添加
std :: variant的想法,而只使用
std :: optional 。
对于类型T,类型std :: optional允许我们创建一个新的类型std :: optional <T>,它可以包含类型T的值或不包含任何值。这与有意义的类型非常相似,这些有意义的类型允许在C#中使用未定义的值。让我们看一下find_in_board函数,该函数返回第一个元素在确认谓词正确的字段上的位置。字段上可能没有这样的元素。要处理这种情况,头寸类型必须是可选的: template <class Predicate> constexpr std::optional<std::pair<int, int>> find_in_board(GameBoard&& g, Predicate&& p) { for (auto item : g.items()) { if (p(item)) { return {item.x, item.y}; }
以前,我们不得不求助于指针的语义,或者直接向位置类型添加“空状态”,或者返回布尔值并采用输出参数。诚然,这很尴尬!一些预先存在的类型也获得了constexpr支持:tuple和pair。我不会详细解释它们的用法,因为已经有很多关于它们的文章,但是我将分享我的失望之一。该委员会在标准中添加了语法糖,以提取元组或成对的值。这种新的声明类型称为结构化绑定,使用括号指定在其中存储拆分元组或对的变量: std::pair<int, int> foo() { return {42, 1337}; } auto [x, y] = foo();
很聪明!但是可惜的是,委员会成员[无法,不想,没有找到时间,忘记了]使他们对constexpr友好。我期望这样的事情: constexpr auto [x, y] = foo();
现在我们有了复杂的容器和帮助程序类型,但是如何方便地操作它们呢?算法:
升级容器以处理constexpr是一项非常单调的任务。与之相比,将constexpr移植到非修改算法似乎很简单。但是很奇怪,在C ++ 17中我们没有看到这方面的进展,它只会出现在C ++ 20中。例如,出色的std :: find算法未接收constexpr签名。但是不要害怕!正如Ben和Jason解释的那样,您只需复制当前实现即可轻松地将算法转换为constexpr(但不要忘记版权)。cppreference很好。女士们,先生们,我请您注意constexpr std ::查找: template<class InputIt, class T> constexpr InputIt find(InputIt first, InputIt last, const T& value) // ^ !!! constexpr. { for (; first != last; ++first) { if (*first == value) { return first; } } return last; }
我已经从看台上听到优化迷的尖叫声!是的,仅在cppreference提供的示例代码之前添加constexpr可能无法在运行时提供理想的速度。但是,如果我们必须改进此算法,则在编译时为了提高速度将需要它。据我所知,谈到编译速度,简单的解决方案是最好的。速度和错误:
任何AAA游戏的开发人员都应该投资解决这些问题,对吗?速度:
当我设法创建一个半工作版本的Meta Crush Saga时,工作进行得更加顺利。实际上,我的旧笔记本电脑将i5超频到1.80 GHz(在这种情况下,频率很重要),我设法达到了3 FPS(每秒帧数)以上。像在任何项目中一样,我很快意识到以前编写的代码令人讨厌,并开始使用constexpr_string和标准算法来重写游戏状态的解析。尽管这使代码的维护更加方便,但是更改严重影响了速度。新的上限为0.5 FPS。尽管关于C ++的说法很老套,但“零头抽象”不适用于编译时计算。如果我们将编译器视为某些“编译时间代码”的解释器,则这是非常合乎逻辑的。仍然可以对各种编译器进行改进,但是对于我们(此类代码的作者)来说,也存在增长的机会。这是我发现的观察结果和提示的不完整列表,可能特定于GCC:- C数组比std :: array更好。std :: array是在C样式数组之上的一些现代C ++外观,因此在这种情况下使用它必须付出一定的代价。
- , ( ) . , , , . : , , , , ( ) , .
- , . , .
- . GCC. , «».
:
很多时候,我的编译器喷出了可怕的编译错误,并且我的代码逻辑遭受了损失。但是,如何找到错误隐藏的地方?没有调试器和printf,事情将变得更加复杂。如果您的隐喻“程序员的胡须”尚未屈服(我的隐喻和真正的胡须都还远未达到这些期望),那么您可能没有动力使用templight或调试编译器。我们的第一个朋友是static_assert,它使我们有机会检查编译时间的布尔值。我们的第二个朋友将是一个在可能的情况下启用和禁用constexpr的宏: #define CONSTEXPR constexpr
使用此宏,我们可以使逻辑在运行时工作,这意味着我们可以将调试器附加到该宏。Meta Crush Saga II-在运行时完全追求游戏性:
显然,Meta Crush Saga今年不会赢得游戏大奖。它具有巨大的潜力,但是游戏性在编译时并未完全执行。这可能会使铁杆游戏玩家感到烦恼……除非有人在编译阶段添加键盘输入和不洁的逻辑,否则我无法摆脱bash脚本的困扰(坦白地说,这很疯狂!)。但是我相信有一天我将能够完全放弃渲染器可执行文件,并在编译时显示游戏状态:疯狂男子与化名saarraz GCC的扩展,添加到语言结构static_print。此构造应采用多个常量表达式或字符串文字,并在编译阶段将其输出。如果将这样的工具添加到标准中,或者至少将其扩展为static_assert以使其接受常量表达式,我将感到高兴。但是,在C ++ 17中,可能有一种方法可以实现此结果。编译器已经输出了两件事- 错误和警告!如果我们能够以某种方式管理或更改警告以适应我们的需求,我们将已经收到一个有价值的结论。我尝试了几种解决方案,特别是不推荐使用的属性: template <char... words> struct useless { [[deprecated]] void call() {}
虽然输出显然存在并且可以解析,但是不幸的是,代码无法播放!如果纯属巧合,如果您是可以在编译期间执行输出的C ++程序员秘密协会的成员,那么我将很乐意邀请您加入我们的团队来创建完美的Meta Crush Saga II!结论:
我最终把我的诈骗游戏卖给了你。希望您对此文章感到好奇,并在阅读过程中学到一些新东西。如果您发现错误或改进文章的方法,请与我联系。我要感谢SwedenCpp团队让我在他们的活动之一中进行项目报告。此外,我还要感谢Alexander Gurdeev,他帮助我改善了Meta Crush Saga的重要方面。