C++ 项目 Avsb 回顾与总结

下面有关游戏程序部分的内容,基于 v0.1.0-SeaOtter-patch.2 版本。

本博文刚建立时最新的版本是 v0.1.0-SeaOtter-patch.2,最终提交的产品版本是 v0.1.0-SeaOtter-patch.1。

本项目由于时间问题,未能提高项目的鲁棒性,因此错误的游戏方式可能导致不明原因的错误(如存档键值错误等等),即使是正确的游戏方式也无法保证一定不会出现问题。若有问题可前往 issue 进行反馈。

前言

姗姗来迟的回顾与总结,距离提交产物(1.19)已经过去近三周了,才终于开始给这个项目写点东西。

过了这么长时间,确实热情基本消磨干净了,而且才刚完成系统的重置,也有点身心俱疲。

但作为远比 RAMFShell 大的项目,也是我绝对绕不过去的里程碑,必须要给予其一个记录。

不过确实已经过去太久了,经历我也没记录下太多,即使记录下了也可能回忆不起来细节了。反正只能靠 commit 记录硬讲了,能写多少写多少。

然后跟 RAMFShell 一样,先讲一下为啥选「塔防游戏」,至于具体的选题细节,等到「历程」部分再行详细谈论。

原因很简单,一句话就能说完了,那就是我也就认识个塔防游戏了,其他的太复杂了,我没玩过也看不懂。虽然现在也不玩游戏,但塔防游戏好歹还是接触过一点的。最终项目成品也是类似「植物大战僵尸」式的塔防游戏。

游戏介绍

已单独抽离成一篇博文——Ants Vs. SomeBees (C++ 版本) 介绍

开发历程

那么就到了枯燥的读 commit 历史的时候了。

选题?Qt?

开题部分在 2024 年 11 月 29 日的记事中就有所记录了,由于时间隔得太久,未必能比当时记得更多。

先提一些选题前的东西吧。一开始看到项目要求要做游戏,我是挺绝望的,完全不想做游戏。

这些在 RAMFShell 大体都提及了,理由到现在也没怎么变化。我当时就是因为不想做游戏才选择了 RAMFShell(当然也不是说 RAMFShell 很不堪)。

然后我当时的记事也说了,相较于其他(大部分)同学来说,我有个劣势就是没学过 GUI。他们在做 C 项目的时候写过了 C 的 GUI,虽然可能不太一样,但思路是类似的,比起我这种两眼抹黑的好的不是一星半点。

所以当时其实也是情绪比较低落的,甚至不知道能否完成。

然后正式进入选题,早早(据聊天记录,应该是 11.23)就确定好要做一个塔防游戏(也只认得塔防了),但是具体的思路还是没有的。而且我也深知这种项目,我是断无可能突击短期内完成的,必须早早进行规划,于是在十一月底就大概开始操办了。

根据浏览器历史记录,在 11.25 的下午四点多,建立了 Git 仓库,当然是 NJU 的 Git 仓库,设置了私密。因为当时还没决定好选题,所以项目名称就是简单的 CPP-Project。现在名称已经改了,但是路径为了保证兼容性就没动了。

参考的文档提供了几个参考选项,我简单搜索了一下,最终还是选择了 Qt,开始在网上搜罗起了资源。

为啥选 Qt 呢?因为我就认得 Qt 了,Qt 之威名,从爱尔兰到契丹都有人耳闻。这样的话 Qt 的参考资料会比较多,学习起来也会比较方便。此外我还几次在 GoldenDict-ng 那边看到反馈 bug 时,开发者说是 Qt 的问题,因此也给我留下了一点印象。

按常理来说,这时候应该开始配置 Qt 的开发环境,然后准备一些参考资料来学习一下了。但我是个异类,还是想看看有没有什么别的工具。

参考了几个作品后,我看到了一个名字——cocos2d-x,这是一个开源的游戏开发工具。使用现成的游戏框架,这不是比使用 GUI 框架方便得多?于是我抛弃了 Qt,开始研究起了 cocos2d-x。

首先是安装 cocos2d-x 开发环境,来看看依赖吧。嗯,VS, CMake 什么的就不说了,Python 我也有……什么?Python2?

唉,行吧,给你整上了,VS 也开装。继续研究。

怎么可能!看了历史记录,搜了 cocos python3 相关关键词,看了 How to install Cocos2d (Python) into Python 3.3? 等问题。不过最终还是无解。

所以还是装上了 Python2,但还是不行,猜测是因为默认的 Python 环境还是 3 的。于是我再去找了脚本,改成了直接指派 2 的,总算可以了。

虽然整完了,但还是很难受,怎么看怎么不爽。而且文档我中英文来回翻,有中文很好,但我发现中英文有些地方有比较大的差异,结果看中文也看得不舒坦,总得去英文那看看机翻。

最后也如记事说的那样,看到了说使用游戏框架就失去了游戏项目的意义,于是就放弃了继续研究 cocos2d-x。

大概快晚餐时间了,11.25 这天就结束了,看了看浏览记录,晚上就愉快地开摆了,一点没研究。也可能是整破防了,一下午回到了原点。

11.26 是周二,上午没课。于是我就……愉快地开摆了。直到下午习概上了一会才继续开始找 Qt 的资料。嗯,果然是要上课做事效率才高。

于是开始装 Qt,这个其实不算特别复杂(可能需要另加上校园镜像),而且没有记录,我也就不详细说明了。

如果是其他人,那可能就算装完了环境,可以开始研究 Qt 了。但我的屁事比较多,我就是看它的编辑器哪哪不顺眼,想用 VS Code。于是边搜安装教程,边搜 VS Code 配环境。

可能是印象不深了,也可能是方法不对,我记得相关资料出奇的少。加上我有很多独特的地方,例如调试还用的是 LLDB,就更难搞了。

不过最后我还是胜利了,成功配置好了,并经过了简单的测试,可以顺利开启调试、打开 Qt Designer 打开 .ui 文件等等。

由于没有记录下过程与资料,只能口述几个关键点了。首先是安装时必须要装它那个调试信息,有 5G 以上,没有这个就调试不成,这也是我四处摸索才发现的。装完后整个 Qt 有 10G 以上。

然后要装啥插件我也不记得了,具体 VS Code 配置在下面,是从初始化 commit「初始化:完成编译、调试与发布的配置」摘的:

VS Code Qt 开发配置(部分)

自动生成的 .gitignore 就不管了,不过我还把 build 目录加上了。项目 DDL 的时候去 NJU Git 逛,很多人整个 build 都传上来了。虽然不是我该考虑的问题,但还是心疼空间。

c_cpp_properties.json 是 VS Code 的 C/C++ 插件提供一系列东西的配置文件,简单设置一下头文件 includePath 和编译器 compilerPath 就可以了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"configurations": [
{
"name": "qt",
"includePath": [
"D:/Software/Qt/6.7.3/mingw_64/include/**",
"${workspaceRoot}/**"
],
"cStandard": "c11",
"cppStandard": "c++17",
"compilerPath": "D:/Software/Qt/Tools/mingw1120_64/bin/g++.exe",
"intelliSenseMode": "windows-gcc-x64"
}
],
"version": 4
}

然后是调试,看来我上面是记错了,LLDB 不行,最后还是用的 GDB:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
"version": "0.2.0",
"configurations": [
{
"name": "GDB Debug",
"type": "cppdbg",
"request": "launch",
"program": "${workspaceFolder}/build/debug/${config:targetName}.exe",
"args": [],
"cwd": "${workspaceFolder}",
"environment": [],
"MIMode": "gdb",
"miDebuggerPath": "D:/Software/Qt/Tools/mingw1120_64/bin/gdb.exe",
"setupCommands": [
{
"description": "为 gdb 启用整齐打印",
"text": "-enable-pretty-printing",
"ignoreFailures": true
}
],
"preLaunchTask": "构建调试版本"
},
]
}

preLaunchTask 用的任务马上会提到,我是按文件名顺序列的。

然后是 settings.json,从这里能看出装的一些插件。然后就是其他设置了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
"qtConfigure.qtDir": "D:/Software/Qt",
"qtConfigure.qtKitDir": "D:/Software/Qt/6.7.3/mingw_64",
"qtConfigure.mingwPath": "D:/Software/Qt/Tools/mingw1120_64",
"cmake.cmakePath": "D:/Software/Qt/Tools/CMake_64/bin/cmake.exe",
"targetName": "MyToy",
"files.associations": {
"*.snippets": "vim-snippet",
"*.hsnips": "hsnips",
"*.json": "jsonl",
"qwidget": "cpp",
"qapplication": "cpp",
"qdate": "cpp"
},
"qttools.visualizerFile": "D:/Project/School/CPP/asset/qt6.natvis",
"qttools.searchMode": "path",
"qttools.useExternalBrowser": true,
"terminal.integrated.env.windows": {
"PATH": "D:/Software/Qt/Tools/mingw1120_64/bin;D:/Software/Qt/6.7.3/mingw_64/bin;${env:PATH}",
},
"python.terminal.activateEnvironment": false,
"qttools.extraSearchDirectories": [
"D:/Software/Qt/6.7.3/mingw_64/bin"
],
}

中间那个 files.associations 不用管,总是自动加上去的,我后面关掉了。

有几个可能需要特别注意的:targetName 就是构建产物名称,因为当时还没想好具体要做啥,等后面敲定了可能会改,要是写死了改起来比较麻烦(其实也没多麻烦,一个替换的事),于是定义了一个 settings.json 里的变量,后面在其他地方可以引用;terminal.integrated.env.windows 这个,即使在我抛弃了 Qt 后,也如同梦魇一般环绕在我身边,即使这个只在 details 标签里出现过,但我后面依旧会咬牙切齿地痛恨它;python.terminal.activateEnvironment 这个忘记了,避免自动激活环境,忘了为啥要这个了(哦想起来了,这个有记录,外面讲)。

然后就是几个任务,tasks.json 的配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
{
"version": "2.0.0",
"tasks": [
// Credit: https://www.cnblogs.com/RioTian/p/18281114
{
"label": "qmake-debug",
"type": "shell",
"options": {
"cwd": "${workspaceFolder}/build"
},
"command": "qmake",
"args": [
"../src.pro",
"-spec",
"win32-g++",
"\"CONFIG+=debug\"",
"\"CONFIG+=qml_debug\""
],
"hide": true
},
{
"label": "构建调试版本",
"type": "shell",
"options": {
"cwd": "${workspaceFolder}/build"
},
"command": "mingw32-make",
"args": [
"-f",
"Makefile.Debug",
"-j16"
],
"dependsOn": [
"qmake-debug"
],
"problemMatcher": []
},
{
"label": "构建并运行调试版本",
"type": "process",
"options": {
"cwd": "${workspaceFolder}/build/debug",
},
"command": "${config:targetName}.exe",
"dependsOn": [
"构建调试版本"
],
"problemMatcher": []
},
{
"label": "qmake-release",
"type": "shell",
"options": {
"cwd": "${workspaceFolder}/build"
},
"command": "qmake",
"args": [
"../src.pro",
"-spec",
"win32-g++",
"\"CONFIG+=release\"",
"\"CONFIG+=qtquickcompiler\""
],
"hide": true
},
{
"label": "构建发布版本",
"type": "shell",
"options": {
"cwd": "${workspaceFolder}/build"
},
"command": "mingw32-make",
"args": [
"-f",
"Makefile.Release",
"-j16"
],
"dependsOn": [
"qmake-release"
],
"problemMatcher": []
},
{
"label": "构建并运行发布版本",
"type": "process",
"options": {
"cwd": "${workspaceFolder}/build/release"
},
"command": "${config:targetName}.exe",
"dependsOn": [
"构建发布版本"
],
"problemMatcher": []
},
{
"label": "清理",
"type": "shell",
"options": {
"cwd": "${workspaceFolder}/build"
},
"command": "mingw32-make",
"args": [
"clean"
],
"problemMatcher": []
}
]
}

没啥好说的。

然后是 src.pro,似乎是 Qmake 的东西?配置了头文件源文件分离,细节不多说了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
QT       += core gui widgets
TARGET = MyToy
CONFIG += c++17
QMAKE_LFLAGS += -static-libgcc -static-libstdc++
CONFIG(debug, debug|release) {
DESTDIR = $$PWD/build/debug
} else {
DESTDIR = $$PWD/build/release
}
OBJECTS_DIR = $$DESTDIR/.obj
MOC_DIR = $$DESTDIR/.moc
RCC_DIR = $$DESTDIR/.rcc
UI_DIR = $$DESTDIR/.ui
win32-msvc*:QMAKE_CXXFLAGS += /utf-8
# QMAKE_LFLAGS += "/MANIFESTUAC:\"level='requireAdministrator' uiAccess='false'\""
INCLUDEPATH += $$PWD/include
SOURCES += \
$$files($$PWD/src/*.cpp)
HEADERS += \
$$files($$PWD/include/*.h)
FORMS += \
src/widget.ui
# Default rules for deployment.
qnx: target.path = /tmp/$${TARGET}/bin
else: unix:!android: target.path = /opt/$${TARGET}/bin
!isEmpty(target.path): INSTALLS += target

按理来说简单配完应该就没事了,但我确实是没想到,一个「简单」的环境变量 PATH 问题,能一直折磨着我,甚至在抛弃 Qt 后,甚至到重装完电脑、前两天随手编译一下,都还在发挥着「余热」。

首先是 Conda 的事情。可能很难想象一个 C++ Qt 项目,怎么会跟 Conda 扯上关系,我也很难绷。不过因为重装后没装 Conda,具体是什么原因我也不记得了。初步想的应该是 Conda 自己的环境,可能带了 MinGW?

起因是这样的,我当时 Windows 和 WSL 都装了 Miniconda,为了管理多个 Python 环境(现在 Windows 使用 Scoop)。而在 WSL 我在 ~/.condarc 设置了 auto_activate_base: false,即不自动激活 base 环境,但 Windows 感觉自动激活没太大问题,就懒得做了。

然后就是 Qt 根本弄不了。一开始我以为是 PATH 出了问题,一直在弄啊,一直在弄啊。后面用 ripgrep 检查了才发现其实是有的,这才发现了是 Conda 的问题。Conda 激活了环境后,会在 PATH 最前面写上自己的东西。所以后面关掉了 Conda 的默认激活 base。下面是当时的记录

  1. ** 的 conda 默认激活 base,会导致不管咋搞 PATH 前面都是 conda 自己的玩意。一开始没认真看以为没成功弄进去,后面才用 rg 发现是有的。关键是 WSL 我禁用了,Windows 懒得弄,结果就出事了。

不过记录的信息是真的少,我现在已经不太看得明白了。反正按记录,删掉环境变量(应该说的是 PATH 的东西,具体删了啥不记得了)后「加在 settings.json 里」,应该指的是上面的 terminal.integrated.env.windows,然后调试又出问题了。不过按最后的成果用的是 GDB?

  1. 结果我删掉环境变量,加在 settings.json 里,调试就寄了,又折磨我好一会(而且不是全寄,cppdbg 正常,就 cppvsdbg 和 lldb 寄了)

还有 VS Code 自己也会找 Python 环境激活终端,这个要关掉上面出现的设置 python.terminal.activateEnvironment

  1. 不止,VSCode 里也要关掉(LLDB Python 激活了)

后面还有个 4. 说的是要装个 5G 的调试信息,然后 Qt 相关的项目开发记录就到此结束了,再无与 Qt 相关的东西记录下来了。

因为我刚周二(11.26)一整天哼哧哼哧配完了 Qt VS Code 开发环境,周四(11.28)就推翻了,决定放弃了 Qt。

这个想法出现的具体时间自然不可考了,但是得益于浏览器历史记录,我大概了解了一下我思路的转变过程。

在 11.25 下午搜索 Qt 教程的同时,我还顺手打开了 SEC-Homework——这是我大一下软工计的作业——直奔 Ants Vs. SomeBees,并收藏了起来。看来是至少在这个时候,我就敲定了选题,那就是 Ants Vs. SomeBees。只不过我这时候还没发觉到可以复用它的前端,而是想着自己用图形化框架实现一个。

虽然跳过了周三满满当当的一天毫无记录,但是印象中这一天应该就是我萌生抛弃 Qt 想法的一天,只是因为时间不足而没有付诸实际。

但是想到这个想法的激动之情,也是忘不掉的。cocos2d-x 没有让我激动,Qt 没有让我激动,反而分别配完了这两个的环境后我心仿佛还是给什么压着,环境虽然配好了,但是前途似乎还是一片雾霾笼罩着,我还是看不到前进的方向。但是复用前端这个想法一出来我就立刻兴奋起来了,后面几天都情绪高涨,一扫之前的阴霾。

这个想法不仅解决了标题中「选题」的问题,还解决了「Qt」的问题。

周四上午是人工智能导论课,根据浏览器的历史记录,我也在课上开始了后端代码的撰写。不过记得课上写之前,就有一点框架了,不知道是 Qt 时期就创建了,还是那一天有了新想法后创建的。按键鼠统计来看,周三九、十点有少量打开 VS Code 进行互动的记录,也许就是周三晚上弄了个框架。

新思路,新气象

这时候我就大概敲定了思路,也是最终的思路,那就是 C++ 后端处理游戏逻辑,然后复用 CS61A 项目的前端。当然这时候我还知道前端方面肯定不能照搬,因为例如存档什么的都没有实现,这些都是需要额外去实现的。但是让它作为蓝本,在上面衍生,也已经够了。

可惜的是这时候仍然是项目早期,我还很谨慎地没有频繁提交,因为经常会大变,还会有很多问题没有解决。因此我一般是遇到很多问题,然后磕个一阵子解决完,再分批同一时间提交了,这也是 11.28 晚上三个小时内提交了 7 个 commits 的原因。之所以跨度还有三个小时这么长,是因为提交的时候还顺带扫一眼,又感觉有点问题,再去研究研究。

具体的研究细节我已经不记得了,想要回忆也不是不行,翻浏览历史记录就行了,但是这太痛苦了,所以我还是直接说结论就行了。

ClangFormat

之前我都是不用代码格式化工具的,RAMFShell 也是,因为现有配置很难满足我的需要,我暂时也不愿意委屈自己使用大厂的样式,所以还是手配。但是在 Avsb,我一开始就采用了 ClangFormat,嗯,真香。一个保存就全部格式化,确实不错。

不过这不意味着我写代码时就不管格式了,实际上还是有习惯的,空格还是会不由自主地敲上去,等等。

像是在记事记录的那样,我用 clang-format configurator v2 调了个 .clang-format 配置,基于 LLVM 样式。最终这个项目回馈到日常代码中,后面我的作业、机试代码也会带上 .clang-format 了。

当然也不是就完美无缺了,有些地方格式化得我觉得不太好看。

要配也许也能配,但是官方文档相当长,网上随便找的一个中文文章也是很长的。我只能定向搜几个,而比较个人向的定制想要找还是比较困难的,所以就暂且搁置了。

等到后面会给一个自动格式化很难看的例子。

剩下的我就不多说了,记事写得很详细我就搬过来了:

例如说之前 C/C++ 代码懒得去弄格式化的事情,因为现成的规则基本上跟我的喜好都不太一致,具体去调又比较麻烦,所以就不了了之了。然后呢这次弄了个 .clang-format,然后基于 LLVM 格式调了一下,调到了目前看来还算是可以的样子。用的是 clang-format configurator v2

网上还搜到有另一个,但是提供的示例代码比较短,而且没标注,我看不出效果,同时选项全显示「默认」,默认哪个我也不知道。

但即使是这个我用的也不太行,首先一样是示例代码不够,我看不出效果。只有一部分是有注释标注对应选项的,其他的看不出效果,我也没去看文档,毕竟选项太多了。简单配了点,还有一点就是,昨天我点右上角的「Config」,以为能给我配置,结果没反应,最后是我开了个新的 LLVM 默认选项,对照着自己加了。不过刚刚试了一下可以显示了,但选项是全的,我实际上只想要我修改过的部分。

所以说其实我是没找到一个令我满意的 clang-format 选项配置器的。我记得 CLion 里面有一个,似乎效果很不错,每个选项都有效果显示来着?

有了 .clang-format,自然就能开启保存自动格式化了。

因为这部分是讲开发历程,不会讲配置的细节。

Clang

你不得不说,LLVM 家族 Clang 的好工具确实多,除了 ClangFormat 外还有 Clang-Tidy 等。也就不怪我后面即使遇到多少困难,都要坚持 Clang 工具链不动摇了(虽然这几个大概是通用的)。

Clang-Tidy 是一个代码检查工具,后面我也写了点配置。

平时的东西我也早都投奔到 Clang 工具链了,都使用 clang++ 编译了。不过这时候我还用的是 MinGW64 的 clang.exe[1],这为后续埋下了祸患。

另外记住了,刚开始 C++ 的版本我选择的是 C++17。

循环包含

这个印象中也是折磨我好一会的东西,而且很长一段时间我还一直误会了错误的来源。

这时候项目起步,我大概也规划了一下项目的结构。不像我看其他人大部分都是将头文件和源文件放 src,但我是想头文件放 include,源文件放 src 的。前者似乎是 Qt 的行为,印象中我在配 Qt 时也因为这个折腾了一会,才终于实现了我的需求。

然后开始将 Python 代码转录为 C++ 代码。这部分都是参考我自己的代码实现,毕竟我自己的代码我最熟悉嘛。

然后头文件我采用的是

1
2
3
4
5
6
#ifndef HEADER_H
#define HEADER_H

...

#endif // HEADER_H

格式,而非 #pragma once,原因是 Copilot 就是这样提示的……不过这也不算全部原因,不然的话我完全可以一键换掉的。这个后面也会进行探讨的。

但是写着写着出现了循环包含的问题,反正就是 C/C++ 插件的 CodeAnalysis 报错。

我一开始没想到是头文件的问题,以为是 CodeAnalysis 的问题,于是一直在搜 CodeAnalysis 相关的关键词,我以为是它没识别到我 include 里面的头文件什么的,自然无功而返。

等到后面才意识到了这个问题,具体怎么意识到的也忘了。记得我还在草稿纸上画了下几个相互包含的图表,那叫一个乱啊。虽然现在也没多好,但是起码不会报错了。

当时主要是有几个头:GameState 标识游戏状态,需要 Place, Ant, BeePlace 需要 AntBeeAntBee 派生自 Insect,需要 Place, GameState 等。

然后我一开始就是哪个头需要什么就包含进行,结果包含的链条出现了循环,结果无法实现包含,就报错了。最后的解决方案就是把环形的改成线性的,需要的提前在高层次的头文件声明了,就解决了。

这是截至写到这一部分时的引用关系图,虽然乱得一坨,但是起码是线性的了,是树状了……吧?里面也有很多不必要的,但是要清理还真有点麻烦,有一些提示的多余的 include,一删掉就报错了,结果我现在也不敢动了。

另外别看引用这么乱,实际写大概还算有条理的,因为我即使是 include, src 目录也是有子目录的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
 include
├──  Ants
│ ├──  Ant.hpp
│ ├──  AntFactory.hpp
│ ├──  AntRemover.hpp
│ ├──  BodyguardAnt.hpp
│ ├──  ContainerAnt.hpp
│ ├──  FireAnt.hpp
│ ├──  HarvestAnt.hpp
│ ├──  HungryAnt.hpp
│ ├──  LaserAnt.hpp
│ ├──  LongThrower.hpp
│ ├──  NinjaAnt.hpp
│ ├──  QueenAnt.hpp
│ ├──  ScaryThrower.hpp
│ ├──  ScubaThrower.hpp
│ ├──  ShortThrower.hpp
│ ├──  SlowThrower.hpp
│ ├──  TankAnt.hpp
│ ├──  ThrowerAnt.hpp
│ └──  WallAnt.hpp
├──  Bees
│ ├──  Bee.hpp
│ ├──  Boss.hpp
│ ├──  NinjaBee.hpp
│ └──  Wasp.hpp
├──  Exceptions
│ ├──  AntsLoseException.hpp
│ ├──  AntsWinException.hpp
│ └──  GameOverException.hpp
├──  GUI
│ ├──  EventEmitter.hpp
│ ├──  Server.hpp
│ └──  WebSocket.hpp
├──  Places
│ ├──  AntHomeBase.hpp
│ ├──  Hive.hpp
│ ├──  Place.hpp
│ └──  Water.hpp
├──  Plans
│ ├──  AssaultPlan.hpp
│ └──  MakePlans.hpp
├──  Project
│ ├──  Constants.hpp
│ ├──  Declarations.hpp
│ ├──  Info.hpp
│ └──  Libraries.hpp
├──  CLI.hpp
├──  GameState.hpp
├──  Insect.hpp
├──  Serializable.hpp
└──  Utilities.hpp

 src
├──  Ants
│ ├──  Ant.cpp
│ ├──  AntFactory.cpp
│ ├──  ContainerAnt.cpp
│ ├──  FireAnt.cpp
│ ├──  HarvestAnt.cpp
│ ├──  HungryAnt.cpp
│ ├──  LaserAnt.cpp
│ ├──  NinjaAnt.cpp
│ ├──  QueenAnt.cpp
│ ├──  ScaryThrower.cpp
│ ├──  SlowThrower.cpp
│ ├──  TankAnt.cpp
│ └──  ThrowerAnt.cpp
├──  Bees
│ ├──  Bee.cpp
│ ├──  Boss.cpp
│ └──  NinjaBee.cpp
├──  GUI
│ ├──  EventEmitter.cpp
│ ├──  Server.cpp
│ └──  WebSocket.cpp
├──  Places
│ ├──  AntHomeBase.cpp
│ ├──  Hive.cpp
│ ├──  Place.cpp
│ └──  Water.cpp
├──  Plans
│ ├──  AssaultPlan.cpp
│ └──  MakePlans.cpp
├──  CLI.cpp
├──  GameState.cpp
├──  Insect.cpp
├──  Main.cpp
├──  Resource.rc
└──  Utilities.cpp

模板

还学到了一些课上没学到的东西(也可能是我漏掉了?),例如说模板只能定义在头文件上,不能在头文件声明然后源文件实现。

详细的可以参考当时读到的问题 Why can templates only be implemented in the header file?

XMake

还派上用场的就是 XMake 了,把这个作为构建工具,这样我就不用去写 CMake 了。依稀还记得刚学 C 时,使用 CLion,为了让项目目录下有多个 main 函数而付出的汗水。当然,使用 XMake 遇到的困难,会远比当时大,要付出的远比当时多。

VS Code 中的 XMake 默认会把 compile_commands.json 拉在 .vscode 目录,这个行为在前几天我修改掉了。compile_commands.json 往后也会多打交道,这里先丢一个 Clang 的文档 JSON Compilation Database Format Specification。简而言之,compile_commands.json 写了编译的命令,这样就可以不用去 c_cpp_properties.json 等文件详细进行配置了,直接引用这个 JSON 就行了,就可以告诉 C/C++ 抑或是 Clangd 等插件相关的信息了。

XMake 的配置也折腾了一会,毕竟我不会 Lua(现在也还没去学),同时对编译知识了解不多。不过最后是能编译运行调试了。

XMake 的调试倒不能直接在 launch.json 用 CodeLLDB,需要用 xmake,然后 settings.json 里面指明用 CodeLLDB。

然后看引入 XMake 的那个 commit「使用 XMake 作为构建工具,添加相关构建配置」,tasks.json 里面节选如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
...
{
"label": "Build Debug",
"type": "shell",
"command": "bash",
"args": [
"-c",
"xmake f -m debug && xmake build ${config:targetName}",
],
"group": {
"kind": "build",
"isDefault": true
},
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": [""]
},
...

也为后面埋下了祸患,而且这祸根还是上面已经遇到过的问题,只不过这个坑我往后还要踩好几次,甚至前几天还在踩。

Doxygen

写了一部分代码后,大概就是 CS61A 的作业需要填写的代码翻译了一部分后,我就开摆了,开始折腾点别的了。

12.2 这天是周一,下午正好是 C++ 的课,看完当天的课程文档后我就开始写代码了。

上面的说法不严谨,实际上是开始写代码文档了。没错,不想写代码,于是我开始写注释了。这着实令人忍俊不禁。开始在「添加函数文档与属性注释等以提高代码可读性;添加部分属性与方法等」。不过这时候还是英文注释,而且大部分都是 Copilot 生成的,写得非常臭。

但是不要紧啊,反正是写了注释。然后我在查代码文档相关的资料的时候,发现了这个格式原来叫 Doxygen。

本来没啥,但紧接着我发现了 Doxygen 还是一个工具。什么工具?生成代码文档的工具!

我去,这也太帅了吧。写项目哪里有看文档帅啊,于是我转头去研究 Doxygen 了。

可惜的是自动清理了之前的东西,不然我也想看看初代文档是什么样子。

不过还是能看出来当初截图的是哪部分的,可以放一下现在的效果。

这是部分类继承图,比起那会,把几个类整合在了一起,现在 Insect, Place, AssaultPlan 都是派生自 Serializable 了,即它们都是「可序列化」的,这是为了实现存档功能而做的。

此外聊天记录的图中还有一些红线,因为我继承的时候忘加 public 了,后面看图例才明白。要不是这个继承图,我可能很难在实际调试前意识到问题,因为这时候还是很早期,游戏逻辑都没实现,要测试的话非常困难。不过测试的事情后面也会提到。

这个是挑了一个 LaserAnt[2]截图了部分文档。哎呀,即使是现在看也还是好爽啊。而这仅仅是文档的一小部分而已,还有很多的东西还没呈现,我再去截几张很爽的。

我去,这个调用链也太爽了。

这个协作图也是我随便点的找到的最大的。

只是找了点比较帅的,倒没有呈现全部细节。

然后真要说这个有用吗?其实用处不大,情绪价值远高于使用价值。真正我在写代码时根本没看代码文档,不只是这个 Doxygen 生成的文档,文档注释也很少看。需要了解功能时,更多的是直接跳过去看实现。

但不得不说的是,确实爽啊,在我当时游戏逻辑还没完成时,就写了一点点代码,就有这么「宏伟」的东西,搞得我好爽,代码都不想写了,只想盯着这个傻乐。

不过后期赶代码比较急,文档就没来得及更新,新代码也比较少写文档,因此其实文档是比较过时的的。有机会再更新吧。

一开始文档注释是英文的,后面我发现 Doxygen 可以生成中文,就再花了周二习概一下午改成中文文档:「改用中文文档;添加 Doxygen 支持;修复一些问题;一些更新」。

改中文的时候,就不是像一开始一样 Copilot 包办了,因为它文档还是输出中文,除非给予指令。我自己改了几个文档,语言也比之前简洁一点,而不是复述一遍函数的实现。有了几个示例后,Copilot 生成的效果也不错了,后面也基本是沿用,只是稍加修改了。

然后改中文的时候,把一些游戏信息的字符串也改成中文了。这时候我忘记了 C++ 的宽字符问题,所以后面又改了回来。这也是日志用英文记录,以及游戏界面是英文的原因之一。

format

C++ 有个很不爽的地方就是没有很方便的字符串处理。先不说 f-string 这样好用的,连 format 都没有。

于是我找了一下,在 C++17 实现了一个 pollyfill 版的 string_format(一开始就叫 format,后面还是为了避免和标准库的冲突而改名):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* @brief 类 `printf` 的字符串格式化函数
*
* 该函数接受一个格式化字符串和一个可变数量的参数,
* 根据格式化说明符格式化字符串,并返回格式化后的字符串。
*
* @remark 引自 https://stackoverflow.com/questions/2342162/stdstring-formatting-like-sprintf
*
* @tparam Args 可变模板参数包,表示参数的类型。
* @param format 包含格式说明符的格式化字符串。
* @param args 用于格式化的参数。
* @return 格式化后的字符串。
* @throws runtime_error 若在格式化过程中发生错误。
*/
template <typename... Args> string string_format(const string &format, Args... args) {
int size_s = snprintf(nullptr, 0, format.c_str(), args...) + 1;
if (size_s <= 0) {
throw runtime_error("格式化字符串失败");
}
size_t size = static_cast<size_t>(size_s);
unique_ptr<char[]> buf = make_unique<char[]>(size);
snprintf(buf.get(), size, format.c_str(), args...);
return string(buf.get(), buf.get() + size - 1);
}

其实即使是格式化函数,我也有过几个版本的,这个是其中一个。另外这个实现在修正上面所说的宽字符问题时就给移除了。所以说抛出的错误是中文这个,一直到被移除时都是这样的。

后面我也放弃了,整这么麻烦干什么,我直接用 C++20 吧。上面的 string_format 用的还是类 printf 的,而 format 就跟 Python 的比较像了,虽然还是比 Python 的弱一点,但比起之前的还是更好用了。

然后使用就这样用(「重新调整所有类的架构(文档可能有待更新);改用 C++20 以使用更方便的 format 等
新架构还有待后面的测试,目前已经通过了编译与检查
」的状态):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* @brief 重载 std::string 类型转换运算符,将 Place 转换为 string
*
* @return Place 的名称
*/
Place::operator std::string() const {
return name;
}

...

/**
* @brief 容纳另一个 Ant
*
* 默认 Ant 不能容纳其他 Ant ,因此调用此函数会抛出异常。
*
* @param other 指向要容纳的另一只 Ant 的指针。
* @throws std::invalid_argument 当不能容纳另一只 Ant 时抛出异常。
*/
void Ant::storeAnt(Ant *other) {
throw std::invalid_argument(
std::format("{} cannot contain {}", (std::string) * this, (std::string)*other));
}

也就是说将对象转成 string 后用 format。不过这样写比较丑,后面(「添加 wetLayout dryLayout 布局函数;更新 Water;修改 string 转换的写法」)改用了 string(...) 的形式,就像调用函数了。

然后我后面又想了一下,format 可以不止 format 字符串,还可以整整型这些,说明应该是有一个方法为自定义的类实现 format 的。于是我再查了一下资料,在「InsectPlace 添加 formatter 特化,以直接在 format 中使用」实现了 formatter:

1
2
3
4
5
template <std::derived_from<Place> T> struct std::formatter<T> : std::formatter<string> {
auto format(const T &place, format_context &ctx) const {
return std::formatter<string>::format(static_cast<string>(place), ctx);
}
}

这里运用了 C++20 的另一个特性——概念 concepts,就是上面代码中的 std::derived_from。这约束了类型 T 必须是从 Place 中派生出来的,如果直接用 Place,那么对于派生类,例如 Water,就无法实现这个 formatter 了。

不过中间整这个还遇到过一些问题,例如 format 函数有两个 const,这一个都不能少,一少编译就会报错。

  1. formatter 始终编译不通过,一开始是 auto format(const T &insect, std::format_context &ctx) {,即没有 const,一直找不到错误,然后找到了 [libc++] std::format does not compile with formatter specialization of user-defined types.,看到了,加了还是报错。后面自己写了一个简单的文件也报错问 Copilot(前面一直在胡言乱语),说是没加 const(尽管报错不一致),后面加回来,然后 Place 那边也加,就过了。(当然,中间还删过 const T &&,最后还补回来才过了)测试代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <concepts>
#include <format>
#include <iostream>

class Base {
public:
std::string get_name() const {
return "Base";
}
};
class Derived : public Base {
public:
std::string get_name() const {
return "Derived";
}
};

template <std::derived_from<Base> T> struct std::formatter<T> : std::formatter<std::string> {
auto format(const T &t, std::format_context &ctx) const {
return std::formatter<std::string>::format(t.get_name(), ctx);
}
};

int main() {
Base b;
Derived d;
std::cout << std::format("{}", b) << std::endl;
std::cout << std::format("{}", d) << std::endl;
return 0;
}

一开始报错似乎在 Ant.cpp 里,我现在通过了,只改了 Insect 的就变成 FireAnt.cpp 了,无妨,看个样子。另外两个用的还不一样,测试用的是 MinGW64,正式代码用的是 msys2。

因为这部分都是 format 我就一起讲了,实际上看序号,这个是 16.,中间还有一点没讲。

这样以后就可以不用 string(...) 了,变成下面这样:

1
2
3
void Ant::storeAnt(Ant *other) {
THROW_EXCEPTION(invalid_argument, format("{} cannot contain {}", *this, *other));
}

当然可以看出来,除了 format 不一样外,抛出错误也不一样了,因为中间有宏的增加。不过这不是这段的重点,就不展开了。

现在就只用 *xxx 了,比之前方便多了。

为什么还要 * 呢?因为这个 formatter 针对的就是从 Place 派生出来的类 T[3],而 this 什么的是指针,就要用 * 了。

这个 * 也是后面 debug 时各种问题的根源。当然即使没有 *,对空或已释放的执行 format 一样会有问题就是了,说是根源有点不大合适。

那能不能更进一步?当然是可以的,中间也有过这样的想法,不过没记下来忘记了,刚刚在写这部分时候想到了,实现了。成功通过编译,然后人工简单游玩测试了一下,应该没有大问题(「更新 PlaceInsect 的 formatter 以简化 format;再加点 [[nodiscard]] 等,遵照部分建议」)。

现在的实现如下:

1
2
3
4
5
6
7
8
9
10
11
template <std::derived_from<Place> T> struct std::formatter<T *> : std::formatter<string> {
auto format(const T *place, format_context &ctx) const {
return std::formatter<string>::format(static_cast<string>(*place), ctx);
}
};

...

void Ant::storeAnt(Ant *other) {
THROW_EXCEPTION(invalid_argument, format("{} cannot contain {}", this, other));
}

可以看到,只是 T 换成了 T * 罢了,然后下面前面也要加上 *(因为我的 format 实际上就是转成 string)。

MSYS2

Sanitizer 是个好东西,RAMFShell 的时候就有用过了。于是我就想在这个项目也弄一下。没错,其实很多时间我都是在整和代码内容无关的内容。

反正也是折腾了好一阵子,具体过程没怎么记录。似乎 MinGW64 附带的 Clang 是无法实现 Sanitizer 的。于是我改用了 MSYS2。反正 WSL 上我当时好像就是用的 clang 了,当时就能用 Sanitizer。

  1. Sanitizer,所以说是 MinGW 没有?是我记错了,之前可行是在 WSL 上,Windows 似乎确实没试过。
  1. 改 msys2(sanitize MinGW64 不行)

具体报错没有存档,然后可能是因为搬迁,抑或是其他原因,项目过程中的所有 Copilot 记录都没了。可能可以恢复,但我懒得去弄了。

MSYS2 配置 Clang 就不细说了,重装 Windows的博文有写。

xrepo 依赖与 Catch2

下一个花活是测试,我游戏逻辑还没实现的时候就已经去想测试的事情了。我想弄一个测试框架,最终选定了 Catch2。最后是实现了,只是无疾而终,哪里有时间去写测试。

同时期的还有一个 nlohmann/json,这个很顺利,加上一行 add_requires("nlohmann_json") 就完事了。

而这个 Catch2 非常幽默,真的,幽默到我想笑。这个我目前在 xmake.lua 也是向上面一样一行 add_requires("catch2"),但是却不能像上面一样简简单单直接安装。

绷不住了,我刚刚测试了一下,在一台全新(大概)的 Windows,可以复现。

首先要用 MSYS2 Clang64 环境,要是用 PowerShell,那直接出师未捷身先死了,各种错误没商量,比如一个什么 mingw32-make.exe 找不到啊啥的,虽然说就在 PATH 里。要是用 Git Bash 可能也可以,因为这时候我似乎还没改 MSYS2?但是可能「会一直尝到生不如死的滋味,直到永远」。

然后你运行 xmake,它会报错 ar.exe 找不到。这时候你去看 CMakeCache.txt,会发现:

CMAKE_AR:FILEPATH=C:/Users/fesmoph/AppData/Local/.xmake/cache/packages/2502/c/catch2/v3.8.0/source/build_6845ef11/ar.exe

但是这个路径没有 ar.exe。其他的基本都是用的 MSYS2 路径的,虽然 MSYS2 Clang 也有 ar.exe,但它就是不用啊。

行吧行吧,那我就手动复制过来给你用吧。不过注意手速,重新构建时它要清空文件夹的,得掐准时间,在刚清完文件夹,然后还没用到时,快准狠丢进去。

这时候你就会得到第二条报错,这次是 ranlib.exe 找不到了。我上面为啥说「基本」?就在这里等着呢:

CMAKE_RANLIB:FILEPATH=C:/Users/fesmoph/AppData/Local/.xmake/cache/packages/2502/c/catch2/v3.8.0/source/build_6845ef11/ranlib.exe

这时候准备了 ar.exeranlib.exe,继续卡时间丢进去,是不是还有第三个找不到的呢?

它还挺好心,就差了这两个,所以到这就已经够了。

所以就是要提前准备好 ar.exeranlib.exe。我刚刚还成功复现了,太令人忍俊不禁了。

这一张图也是当时截图的,从终端来看,这是 PowerShell 成功安装了?我刚刚试了一下 PowerShell 不行,还是有一点错误,但我已经身心俱疲,不想弄了,所以用 MSYS2 的 Clang64 终端试了试复现了这个情况。

不过转念一想,要是我还打算弄 GitHub Action,总不能也像这样掐时间搞这种骚操作吧。哦,不用管,只需要禁用测试就可以了。

  1. (*** 的 To Do 吞了这个应该):ar.exe 与 ranlib.exe(即使换 msys2 也还需要)

测试的问题还没这么快就结束,这回是老朋友 Git Bash 登场。

弄完测试框架后就是写测试了,但是我不明白为何手动运行测试不通过呢?如上图,运行错误,返回 127。这个我只找到了一个相关的 issue:Exit code of 127 when all tests pass (only on Windows Release build),然而他说通过 Discord 解决了,是他的问题,除此以外没有提供任何有帮助的信息。

反正又是折腾了很久,根据历史纪录,光是这个退出码就花了起码半个小时。看起来并不多,但我不是立刻就想到检查退出码的,在此之前我还去看过各种奇奇怪怪的东西。翻了翻历史记录,有相关关键词的时间跨度就起码两个小时了。

后面我突然意识到这个终端是 Git Bash,也是我 VS Code 默认的内置终端。由于前面出过了一点 Git Bash 的事情,我已经隐隐有点对其不信任了。于是改用 PowerShell 试了一下:

看到这结果的时候,瞬间刀了 Git Bash 的心都有了。

于是直接把 VS Code 默认终端改成 PowerShell,尽管我连 PowerShell 命令都不会几个。而 WT 默认终端是 WSL,所以可以说 Git Bash 算是给「废黜」了。当然,脚本的 Win + T 这样大好的键还是占据着,同时即使到现在也用得很频繁。

那自然是要研究一下是什么原因了。

这个想都不用想,肯定是 PATH。所以我对照了两个的 PATH,逐个进行检验,最终确定了是 Git Bash 的 mingw64 文件夹在 PATH 里,就如下面当时记录的那样「干扰了运行」。这个文件夹即使重装了,现在还在我这(因为还没换 MinGit,不过也不知道 MinGit 有没有),咬牙切齿。

可惜没截图当时 PATH 战争的情形。

  1. XMake Catch2:好啊,居然是 Bash 的问题,很好,我寒假就研究转 pwsh,***。经检验发现是 Bash 的 mingw64 干扰了运行

此外还有装测试插件 C++ TestMate,挺好用,有点像写 Java 那会。

另外测试名称不能是中文名,这个倒也好理解,跟上面估计类似,我当时确实是脑抽了试过中文。

测试的鼎盛时期就是 12.8(「更新配置」)了,此后再没光顾过测试了。当时写的几个简单的用来测试测试的测试也已经用不了了。

再补充点关于测试的内容。Copilot 可以生成测试,不过我那会试了一下,生成的质量不敢恭维,非常差劲。不过可能跟用的模型也有关吧,用的似乎是 4o?4o 别说测试差劲了,代码也不大行。

调试

比起鸡肋的测试,调试就比较有用了,甚至可以说离不开它。

即使是项目的开发还没进入后期,还无法游玩的时候,调试也非常有用。它的作用在应该很快就能呈现了。

由于采用了 Clang 工具链,自然就别想什么 GDB 了。LLDB:你最好乖乖听话,这可是 LLVM 的地盘哦。

调试的问题也是诡异,原本 launch.json 配了应该就完事了,结果还有问题。

如上图所示,这个 Ant 的 foodCost 居然是 65536,这也太不正常了吧!这个算比较明显的,其实 buffedblocksPath 也是错的。

这个也算折腾我一阵子。后面才发现是 CodeLLDB 版本的问题。

当时[4] CodeLLDB 最新版是 1.11.1,现在(2025.2.9 零点出头)已经是 1.11.3 了。我不确定现在还有没有这个问题,反正我当时测试 1.11.x 都有问题,而且 1.11.0 无法在 VS Code 在线安装,我是手动下了 VSIX 安装后发现不行的。然后 1.10.0 就可以:

这个才是正常的。然后我看 CodeLLDB 的更新日志也没看出个所以然。

我印象也不深了,不确定这个会不会是其他地方的问题,后面一直不能更新 CodeLLDB,但是最后还是更了,忘了啥时候,反正现在是好的。原因不明。

上面两张图也显示了一个毒瘤,我现在看了都深恶痛绝的。

另外这个调试界面是在测试那里,算是上面的一个延申,既能测试又能调试,算我配得好。

这时候看右下角,还有 Qt 的一点痕迹——也只有这点痕迹了——那就是 c_cpp_properties.json 的配置名还叫 qt。而后面连这点痕迹都抹除了(「修改开发配置:格式化设置;将目标正式命名为 Avsb;彻底把 Qt 痕迹删除;修改打开文档的命令为系统默认(Windows)等」)。

哦,继续往下看,发现了为啥又继续更新了。

  1. 因为这个又将 CodeLLDB 更新到最新(修复了):Error: Invalid Value Object

应该是我又遇到一个问题,就是类似这个 issue 所说的,然后我再更新了,解决了。至于之前的问题,似乎也不翼而飞?模棱两可的说辞。

代码行军

crow

上一节的关键词是「新」,那啥时候不「新」呢?我也不知道,不过前面讲过了 xrepo 管理,那第二次讲是不是就不新了?

那么到这可以算项目中期了……吧?好吧,其实判断是比较随意、混乱的。

xrepo 另一个重量级就是 Crow 了。

因为我的 GUI 是 Web GUI,自然需要相应的库。而 Python 版本用的是 Flask(Python 版本的差异也会后面讲上一讲),然后来看看 Crow 自己的说明:

Crow is a C++ framework for creating HTTP or Websocket web services. It uses routing similar to Python's Flask which makes it easy to use. It is also extremely fast, beating multiple existing C++ frameworks as well as non-C++ frameworks.

当然,我对各种 C++ 库是不了解的,都是 Copilot 推荐的。还有一个如雷贯耳但我没实际用的就是 Boost 了。

看我 xmake.lua 的配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if is_plat("linux") then
-- Windows OpenSSL 一直失败 :(
-- 如果能使用 XMake 管理,就可以把这个 if 和下面的 dependencies 去掉
add_requires("crow")
end

...

if is_plat("linux") then
add_packages("crow")
else
add_includedirs(
"dependencies",
"dependencies/asio",
"dependencies/crow"
)
end

可以看到,只有 Linux 才是直接像上面一样 add_requires("crow"),而 Windows 却是手动添加依赖目录 dependencies 到 includeDirs。

上面的 Catch2 虽然过程挺曲折,但好歹还是成功用 XMake 管理了。而这个我是拼尽全力也没法管理成功,最后只得灰溜溜地下下来引入。

过程一样没有详细记录,只有粗略的记录,没有截图什么的:

  1. Crow 库在经过多重失败后,终于放弃了使用 XMake 管理,而是自己放到 include 里。错误包括但不限于(已经没精力截图记录了):
    • pwsh perl 错误(路径分隔符)
    • Git perl 错误(原因不明)
    • source 里面有个 NUL,一删就回来,得用 rm
    • msys2 perl 终于能跑了,结果 make 错误(实际上是 llvm-rc 错误)
    • 用 pwsh, Git bash 还有一些错误,忘记了
    • msys2 还有一些错误不太记得了
    • 等等等等

后面放弃了一开始是放在 include 目录里的,但是容易扰乱,经常展开,而且会降低性能。后面就另建了个 dependencies 搞了。

Crow 安装需要两个依赖,一个是 Asio,另一个是 OpenSSL。记得 Asio 似乎是能正常安装的?不过我最后还是手动管理了 Asio。

根据上面的记录,经常失败的是 OpenSSL。不过我刚刚测试了一下,PowerShell 还是一样会出问题,而且跟上面大差不差,而 MSYS2 Clang 成功安装了 OpenSSL,但是在安装 Crow 过程中又出现了 ar.exeranlib.exe 的乐子。然后在丢进去时又报错了,这次似乎是代码的错误。

所以说即使在新的 Windows,我依旧无法使用 XMake 管理 Crow。

properties

这个应该是折磨我之最,时间跨度之长,大动干戈之频繁,不管是上面已经写过的,还是下面将要提的,都不及这个十一。

当然客观来说,原因在于我对 C++ 语言特性的不熟悉,经过 properties 动乱,我实际上对 C++ 的理解比项目一开始更深刻了。

先拿出 Python 的部分代码,就拿 Insect 系举例吧(删除了一些冗余的东西):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Insect:
damage = 0
is_waterproof = False
...
class Ant(Insect):
food_cost = 0
...
class ThrowerAnt(Ant):
damage = 1
food_cost = 3
min_range = 0
max_range = float("inf")
...
class ScubaThrower(ThrowerAnt):
food_cost = 6
is_waterproof = True
...
class LongThrower(ThrowerAnt):
food_cost = 2
min_range = 5
...

在两根继承链条中出现了几种属性:

  • damage:伤害值,在 Insect 时默认为 0,一直到 ThrowerAnt 才更改为 1
  • food_cost:食物消耗,这个是 Ant 特有的,所以一开始出现在 Ant
  • is_waterproof:抗水性,类似的不多说了
  • min_range, max_range:射程,一样的

我也不知道怎么讲比较合适,所以先放一下目前的实现吧(删掉了部分标识符,函数压缩到了一行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Insect : public Serializable {
virtual double getDefaultDamage() const { return 0.0; }
virtual bool getIsWaterProof() const { return false; }
...
}
class Ant : public Insect {
virtual int getFoodCost() const { return 0; }
...
}
class ThrowerAnt : public Ant {
double getDefaultDamage() const override { return 1.0; }
int getFoodCost() const override { return 3; }
virtual int getMinRange() const { return 0; }
virtual int getMaxRange() const { return INT_MAX; }
...
}
class ScubaThrower : public ThrowerAnt {
int getFoodCost() const override { return 6; }
bool getIsWaterProof() const final { return true; }
...
};
class LongThrower final : public ThrowerAnt {
int getFoodCost() const final { return 2; }
int getMinRange() const final { return 5; }
...
};

嗯,采用了 getter,利用虚函数来实现覆盖,还可以吧。

但最初我并不是这样做的,因为有相当长一段时间我都比较排斥 getter(因为写得太丑了)。待我去翻翻最初的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Insect {
int damage = 0;
static const bool isWaterProof = false;
...
};
class Ant : public Insect {
static const int foodCost = 0;
...
};
class ThrowerAnt : public Ant {
int damage = 1;
static const int foodCost = 3;
int minRange = 0;
int maxRange = INT_MAX;
...
};
class ScubaThrower : public ThrowerAnt {
static const int foodCost = 6;
static const int isWaterProof = true;
...
};
class LongThrower : public ThrowerAnt {
static const int foodCost = 2;
int minRange = 5;
};

这是「添加多个 Ant 类及其基础实现;完善部分类的实现」时的代码,可以说是非常早期了,早期到还没意识到 damage 可以是浮点数,早期到 LongThrower 还没写 final

乍一看可能没什么问题,就是照着 Python 版本抄嘛,不如说用 getter 的上面那个版本才奇怪。

但是问题在于,在我构想中的游戏逻辑中,处理 Ant 是使用 Ant* 指针进行处理的。这意味着别管你什么 AntThrowerAnt 还是 ScubaThrower,只要用 Ant* 指针,那得到的 damage, foodCost 只会是 0,isWaterProof 只会是 false。

Python 可以 which_ant.property 就直接得到它的 property,而 C++ 中,别管你 ThrowerAnt* which_ant 还是 ScubaThrower* which_ant,只要你用的是 (Ant*) which_ant,那么 which_ant->property 只会是 Antproperty。这和我的期望是相悖的。

按照我的理解,Python 的属性就像是「虚属性」,它是直接「覆盖」了父类的属性。但又不太对,可以通过指定父类获得父类的属性。其实我更喜欢这种。

这种写法也能看出我对 C++ 了解确实不多,当时学 C++ 类部分也是囫囵吞枣,没能理解。

然后可能是做测试调试那会,震惊地发现了这个事情。还能咋办,想办法解决了呗。

当时搜了大量资料,问了数次 AI。可能就是意识到这个问题的时候,在床上也在想,结果久久无法入眠。后面起来拿手机第一件事情就是打开手机的 GitHub 问 Copilot。可惜的是似乎记录没有留下来。

Getter 就是当时给出的方案之一。但我确实是不喜欢这种方案,因为不好看。当然从结果来看我最终是屈服了。

然后就是本节的重点登场,我的脑短路想出了一个天才般的构想,这就是「臭名昭著」的 properties 方案。

在讲述什么是 properties 之前,我得先讲一下别的。

当时想到一种方法,那就是使用初始化表,只在属性出现的那一层进行初始化,而具体初始化的值通过函数的参数决定。

不过这种方法有一个缺陷,那就是需要的参数会很多,而且很多可能是不必要的。虽然可以通过默认值解决,但是 C++ 是没有命名参数的,只有顺序参数。例如可能只要修改一个 isWaterProof,但为了修改这一个,可能还要往前填 damage 等不必要的东西。这样会引入相当的不确定性,牵一发而动全身。

于是为了减少构造函数的参数数量,同时另辟蹊径地实现命名参数,我引入了 properties(「重新调整所有类的架构(文档可能有待更新);改用 C++20 以使用更方便的 format 等新架构还有待后面的测试,目前已经通过了编译与检查」),看了看日期,还是 12.8。

properties 就是一个结构体,它打包了一些可能被子代修改的、但需要在父代进行初始化的属性等。下面是 properties 的最终形态(仅包含 Ant 这边的部分,其实还有别的 properties。状态是「上传 static 与 templates 资源目录;更新 .gitignore;更新 xmake.lua 以复制资源目录」):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct insect_properties {
string name = "Insect"; //!< 类名
double damage = 0.0; //!< 伤害
bool isWaterProof = false; //!< 是否抗水
Place *place; //!< 所在地点
};
struct ant_properties : insect_properties {
string name = "Ant"; //!< 类名
int foodCost = 0; //!< 食物消耗
bool buffed = false; //!< 是否被加成
bool blocksPath = true; //!< 是否阻挡路径
};
struct thrower_ant_properties : ant_properties {
string name = "ThrowerAnt"; //!< 类名
double damage = 1.0; //!< 伤害
int foodCost = 3; //!< 食物消耗
int minRange = 0; //!< 最小射程
int maxRange = INT_MAX; //!< 最大射程
};
struct scuba_thrower_properties : thrower_ant_properties {
string name = "ScubaThrower";
int foodCost = 6;
bool isWaterProof = true;
};

这就是上面呈现的例子对应的完整 properties,除了示例还给出了一些其他的属性,例如 name 也是每层都要修改的家伙。

不过是不是每种 Ant 都要写一个 properties?其实不是的,例如 LongThrower 就没写。一般来说,继承的末端(上面有 final 标识)是没写 properties 的,因为就可以在上层定义的 properties 的基础上修改。

然后来看看怎么使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Insect::Insect(double health, Place *place, insect_properties properties)
: id(idCounter++), name(properties.name), damage(properties.damage), place(place),
health(health), isWaterProof(properties.isWaterProof) {}

Ant::Ant(double health, ant_properties properties)
: Insect(health, nullptr, properties), buffed(properties.buffed), foodCost(properties.foodCost),
blocksPath(properties.blocksPath) {}

ThrowerAnt::ThrowerAnt(double health, thrower_ant_properties properties)
: Ant(health, properties), minRange(properties.minRange), maxRange(properties.maxRange) {}

ScubaThrower::ScubaThrower(double health, scuba_thrower_properties properties)
: ThrowerAnt(health, properties) {}

LongThrower::LongThrower(double health)
: ThrowerAnt(health, {
.name = "LongThrower",
.damage = 1.0,
.foodCost = 2,
.minRange = 5,
}) {}

啧,丑得不堪入目,即使多占空间我都要多分几行。挺难想象我当时的精神状态的,这还不如 getter 呢。

另外最后的 LongThrower 就是这么诡异,自动格式化是有点丑。

另外当时还有添加抹除了 properties 的构造函数,是为了函数签名的事(取代了默认值),下面是部分:

1
2
3
explicit ScubaThrower(double health, scuba_thrower_properties properties);
explicit ScubaThrower(double health) : ScubaThrower(health, {}) {}
explicit ScubaThrower() : ScubaThrower(1.0) {}

这样我也能当作没看见底层肮脏丑陋的 properties 了。

由此也能看出 properties 的思路,底层只修改其中几个属性,然后向上传递使用默认值,最后到达顶层再来初始化。

如果真有这么美好,我估计现在还养着这坨。可惜的是,梦碎了。

我不知道为何想当然地认为「然后向上传递使用默认值」会成立。

我这里刚刚没展开讲,不过看上面构造的例子也能看出来我是这样想的:例如说 ScubaThrower 没有修改 damage,那么它的 properties 继承自 thrower_ant_properties,采取其默认值 1.0,然后维持这个默认值一直传回到 insect_properties,最后 damage 就是美美的 1.0。

「维持这个默认值」的假定是错误的。如果正好停在 thrower_ant_properties 进行构造,那确实会是 1.0。但是向上传递,它不为你填充这个值,所以这个值依旧是未指定的状态,一直到顶层 insect_properties,它见未指定,于是给了你 0.0。

因为调试而起用了 properties,却未使用调试检查其可行性。真是悲哀啊。

自从 12.8 开始使用 properties 后,最多只进行了修修补补,并没有动大刀,基本还是 properties 路线。加上游戏逻辑还未实现,于是后面就不再关注这方面了,认为继承问题已经解决了。后面又是期末,再过了很久。等到意识到问题的时候,已经是部署 Web GUI 关键时期了,已经是 1.16 了。

此时距离项目 DDL 不足四天,可想而知当时是多么崩溃——已经要弄前端的事情了,结果游戏逻辑出了大问题。

不过还好,最后还是来得及,果断弃用了 properties,采用 getter(「移除该死的 properties,全部换成 getter」),也算是摆脱了这一毒瘤。

properties 相关的记录只有一条,不过只是沾边罢了,主题并不是它:

  1. Designated initializers in C++20:懒得管了,我能保证不会出现重复就是了,强行去除警告

properties 的写法没那么简单,修改更高层次的值,需要进行嵌套,而这会引发警告。而上图中的注释部分就是消除警告的方式。看了看图片的创建日期,嗯,12.8。

本来可以算作是项目的「收获」之一,但是因为 properties 已经废弃了,自然这个也不了了之了。

任务与 sh

前面的任务配置中,为了执行多条命令,采用了 bash -c "..." 的方式。

不过这样有问题,有 WSL 的话这个 bash 其实会启动 WSL 的 bash.exe

于是我换成了 sh:「更新配置,改用 Cangd 插件等;任务改用 sh,避免启动 WSL 的 Bash;添加多个构造函数签名等」。

嗯,这个 commit 有个 typo 现在才发现。

不过这个实际上还是有问题,问题就是使用 sh 再次将 Git Bash 的东西引入了 PATH:

  1. 都结束多久了,git bash 还在追我(task 中的 sh)

前两天写的。于是打算换成 PowerShell。

不过转念一想,干脆直接就长命令得了,为何一定要作为参数呢?最终在「更新 VS Code 配置;避免 sh 注入环境变量;更新 static;更新 XMake 配置以支持 check 模式的单独构建产物」才步入正轨。

蚂蚁工厂

GameState 中有一个 deploy_ant 方法,用来部署蚂蚁单位:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class GameState:
def __init__(self, beehive, ant_types, create_places, dimensions, food=2):
self.ant_types = OrderedDict((a.name, a) for a in ant_types)
...
def deploy_ant(self, place_name, ant_type_name):
ant_type = self.ant_types[ant_type_name]
if ant_type.food_cost > self.food:
print('Not enough food remains to place ' + ant_type.__name__)
else:
ant = ant_type()
self.places[place_name].add_insect(ant)
self.food -= ant.food_cost
return ant
...

具体调用在 ants_plans.py 中(2024 秋版本):

1
2
3
4
5
import ants

def create_game_state():
...
return ants.GameState(beehive, ants.ant_types(), layout, dimensions, food)

ants.ant_types() 又是什么?回到 ants.py

1
2
3
4
5
6
7
def ant_types():
all_ant_types = []
new_types = [Ant]
while new_types:
new_types = [t for c in new_types for t in c.__subclasses__()]
all_ant_types.extend(new_types)
return [t for t in all_ant_types if t.implemented]

这下麻烦了,利用了 Python 灵活的机制获取了所有(实现的)Ant 的子类。这种运行时获取信息的是不是叫「反射」来着?我了解不多。

当然我的项目没那么麻烦,我不是拿来挖空练习的,所有子类我都进行了实现,所以可以手动创建一个 ant_types,而无需动态地实现。不过这样也代表了不便。

回到 deploy_ant,10 行运用了 ant_type() 直接创建了一个蚂蚁单位,但是很遗憾的是 C++ 中不能直接像 Python 一样将类存起来,然后来创建实例。

于是我向 Copilot 求助,学到了一个新东西——工厂模式。

在「修复 xmake.lua 添加 defines 的作用域问题;引入部分 std;添加 AntFactory;更改 name 以与类名匹配等」中,我创建了一个全新的类 AntFactory,这个类用来管理、创建蚂蚁单位。

具体是如何实现的呢?初版部分如下:

1
2
3
4
5
6
7
8
9
class AntFactory {
using ant_constructor = function<Ant *()>;
map<string, ant_constructor> antConstructors; //!> Ant 构造函数

static AntFactory &getInstance();
void registerAnt(const string &name, ant_constructor constructor);
Ant *createAnt(const string &name) const;
vector<string> getAntNames() const;
};

首先来看定义了一个 ant_constructor 类型,这是一个函数,什么函数呢?不接受参数,但是返回一个 Ant* 的函数。类无法存储,但是函数可以存储。待会可以将类构造函数转化为函数来存储。

getInstance 确保 AntFactory 是单例;registerAnt 用以注册构造函数,这是在一开始就要完成的事情;createAnt 实现了 Python 的 ant_type();而 getter getAntNames 则就是 ant.ant_types() 的返回值。

首先是如何实现单例呢?

1
2
3
4
AntFactory &AntFactory::getInstance() {
static AntFactory instance;
return instance;
}

使用静态变量,很好。

注册和创建也很简单:

1
2
3
4
5
6
7
8
9
10
void AntFactory::registerAnt(const string &name, ant_constructor constructor) {
antConstructors[name] = constructor;
}
Ant *AntFactory::createAnt(const string &name) const {
auto it = antConstructors.find(name);
if (it != antConstructors.end()) {
return it->second();
}
return nullptr;
}

那么该如何进行注册呢?

使用了宏技巧:

1
2
3
4
5
6
7
8
9
10
11
12
13
#define REGISTER_ANT_CLASS(AntClass)                                                         \
class AntClass##Register { \
public: \
AntClass##Register() { \
AntFactory::getInstance().registerAnt(#AntClass, \
[]() -> Ant * { return new AntClass(); }); \
} \
}; \
static AntClass##Register global_##AntClass##Register;

...
REGISTER_ANT_CLASS(ThrowerAnt)
...

就拿 ThrowerAnt 举例,那宏展开就是:

1
2
3
4
5
6
7
8
class ThrowerAntRegister {
public:
ThrowerAntRegister() {
AntFactory::getInstance().registerAnt("ThrowerAnt",
[]() -> Ant * { return new ThrowerAnt(); });
}
};
static ThrowerAntRegister global_ThrowerAntRegister;

它定义了 ThrowerAnt 的注册机 ThrowerAntRegister,这个注册机没什么,就是默认构造函数是为 AntFactory 注册一个函数 []() -> Ant * { return new ThrowerAnt(); },而这个函数恰恰类型就是 ant_constructor

然后它在下面静态声明了这个注册机,这意味着在运行程序的时候,它就会被创建,从而执行构造函数,完成注册。

这也要求了 Ant 子类必须要有原始的构造函数,这也是我上面多添加几个构造函数的原因,因为默认值不影响函数签名。QueenAnt(health = 1.0)QueenAnt() 并不一样。

当然这是早期的 AntFactory 了,现在还加了一项。不过思路依旧是没有变的。

不过这同时也要求了用来创建类的字符串必须和类名相同。为了避免混淆,我将 name 全都保持与类名一致了,这是和 CS61A 版本不一致的地方。这也就是上面所说的「更改 name 以与类名匹配」。

最初翻译代码时,将下面的类静态构造方法也翻译了,就没什么大用处了,因为蚂蚁单位的创建完全由蚂蚁工厂管理(2024 秋版本也没有这个方法):

1
2
3
4
5
6
7
8
class Ant(Insect):
@classmethod
def construct(cls, gamestate):
if cls.food_cost > gamestate.food:
print('Not enough food remains to place ' + cls.__name__)
return
return cls()
...
1
2
3
4
5
6
Ant *Ant::construct(GameState &gamestate) {
if (foodCost > gamestate.food) {
return nullptr;
}
return new Ant();
}

Clang-Tidy & Clangd

更新配置,添加基础的 clang-tidy 检查;添加 .clangd;暂时忽略测试;启用所有 hint 显示」引入了 Clang-Tidy 和 Clangd 的配置,很遗憾都是有问题的。

首先是 Clang-Tidy:

1
2
3
4
5
6
7
8
9
10
11
12
13
Checks: modernize-*,
-modernize-use-trailing-return-type,
cppcoreguidelines-*,
-cppcoreguidelines-avoid-magic-numbers,
-cppcoreguidelines-non-private-member-variables-in-classes,
-cppcoreguidelines-slicing,
-cppcoreguidelines-owning-memory, # Temporary disable before using smart pointers
readability-braces-around-statements,
readability-identifier-naming.ClassCase,
readability-identifier-naming.StructCase,
readability-identifier-naming.TypedefCase,
readability-identifier-naming.EnumCase,
readability-non-const-parameter,

这里本来是想用注释标记一下,后面开 verbose 发现似乎有问题。于是在「更新 .gitignore;修复 .clangd 与 .clang-tidy 的一些错误」中移除了。

然后是 Clangd,我只能说文档白看了,明明说了是正则,我还以为是通配符。而且都有示例,也是无语了。

不过即使后面改了,似乎还是没用。我这个配置是想不索引 dependencies 里面的依赖,不过似乎还是不奏效。

除去看文档不认真,我对 Clangd 的配置确实是一头雾水,摸不着头脑。下面两个是搜寻资料时看到的相关讨论,说明有这种感觉的不止我一人:

C++ 新特性

除了 format、概念,其实还用了一些其他新版本的特性。

协程

首先是 C++20 的「协程」。

首先是 GameState 关于游戏模拟的 simulate,下面的是我做的版本的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class GameState:
def simulate(self):
num_bees = len(self.bees)
try:
while True:
self.beehive.strategy(self) # Bees invade
self.strategy(self) # Ants deploy
for ant in self.ants: # Ants take actions
if ant.health > 0:
ant.action(self)
for bee in self.active_bees[:]: # Bees take actions
if bee.health > 0:
bee.action(self)
if bee.health <= 0:
num_bees -= 1
self.active_bees.remove(bee)
if num_bees == 0:
raise AntsWinException()
self.time += 1
except AntsWinException:
print('All bees are vanquished. You win!')
return True
except AntsLoseException:
print('The ant queen has perished. Please try again.')
return False
...

然后翻译成 C++:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
bool GameState::simulate() {
try {
while (true) {
beehive->strategy(*this);
strategy(*this);
for (auto ant : getAnts()) {
if (ant->health > 0) {
ant->action(*this);
}
}
bees_list beesCurrent(activeBees);
for (auto bee : beesCurrent) {
if (bee->health > 0) {
bee->action(*this);
}
if (bee->health <= 0) {
activeBees.erase(std::remove(activeBees.begin(), activeBees.end(), bee),
activeBees.end());
}
}
if (activeBees.empty()) {
antsWin();
}
time++;
}
} catch (AntsWinException &e) {
log(LOGINFO, "All bees are vanquished. You win!");
return true;
} catch (AntsLoseException &e) {
log(LOGINFO, "The ant queen has perished. Please try again.");
return false;
} catch (exception &e) {
log(LOGERROR, e.what());
return false;
}
}

但是在基础逻辑基本完善后,我因为决定采用 2024 秋版本的 Web GUI 实现,而不得不做出一点调整。其中一个就是改用 2024 秋的 GameState 实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class GameState:
def simulate(self):
num_bees = len(self.bees)
try:
while True:
self.beehive.strategy(self) # Bees invade from hive
yield None # After yielding, players have time to place ants
self.ants_take_actions()
self.time += 1
yield None # After yielding, wait for throw leaf animation to play, then ask bees to take action
num_bees = self.bees_take_actions(num_bees)
except AntsWinException:
print('All bees are vanquished. You win!')
yield True
except AntsLoseException:
print('The bees reached homebase or the queen ant queen has perished. Please try again :(')
yield False

这下可麻烦了,用的是 yield,这可在我的知识盲区。

不过非常幸运的是,C++20 提供了协程,其中就包括了 co_yield 关键词。只是需要对上面的做出一点小调整,不能再返回 bool 了。

下面就是现在的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Simulator GameState::simulate() {
bool result;
numBees = getBees().size();
log(LOGINFO, format("Simulation started with {} bees", numBees));
try {
while (true) {
log(LOGTEST, *this);
log(LOGTEST, format("{} bees remaining", numBees));
beehive->strategy(*this);
co_yield nullptr;
antsTakeActions();
time++;
co_yield nullptr;
beesTakeActions();
}
} catch (AntsWinException &e) {
result = true;
} catch (AntsLoseException &e) {
result = false;
} catch (exception &e) {
log(LOGERROR, e.what());
result = false;
}
log(LOGTEST, *this);
co_yield result;
}

Simulator 本质上是一个生成器,可惜的是 <generator> 在 C++23 才提供,因此只能让 Copilot 来 pollyfill 一下了。

Simulator 的实现

可以照抄官网的实现,也可以参考下面的可能可移植性欠缺的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
class Simulator {
public:
struct promise_type;
using handle_type = std::coroutine_handle<promise_type>;

struct promise_type {
Simulator get_return_object() {
return Simulator{handle_type::from_promise(*this)};
}
std::suspend_always initial_suspend() {
return {};
}
std::suspend_always final_suspend() noexcept {
return {};
}
void return_void() {}
void unhandled_exception() {
std::terminate();
}

std::suspend_always yield_value(std::nullptr_t) {
is_game_over = false;
return {};
}

std::suspend_always yield_value(bool value) {
is_game_over = true;
result = value;
return {};
}

bool is_game_over = false;
bool result = false;
};

explicit Simulator() : handle(nullptr) {}
explicit Simulator(handle_type h) : handle(h) {}
Simulator(const Simulator &) = delete;
Simulator &operator=(const Simulator &) = delete;
Simulator(Simulator &&other) noexcept : handle(other.handle) {
other.handle = nullptr;
}
Simulator &operator=(Simulator &&other) noexcept {
if (this != &other) {
if (handle) {
handle.destroy();
}
handle = other.handle;
other.handle = nullptr;
}
return *this;
}
~Simulator() {
if (handle) {
handle.destroy();
}
}
void next() {
if (!handle.done()) {
handle.resume();
}
}

[[nodiscard]]
bool isGameOver() const {
return handle.promise().is_game_over;
}
[[nodiscard]]
bool getResult() const {
return handle.promise().result;
}

private:
handle_type handle;
};

这其中还有一点小趣事去世:在开发 Server 时,发现这个 simulate 总是不行。

我一直以为是生成器实现的问题,换了好几个实现(最初的 Simulator 其实是 Generator<optional<bool>>,用 optional 也是因为上面的 Python 代码有返回 None)。然后最后才确定是 Server 的问题……

可惜的是 Server 登场时(「新增项目配置以控制调试日志;引入 Simulator 替代 Generator;更新游戏状态模拟逻辑;添加 Server」)已经修复了,因为这是开发后期相关文件第一次出现,还有没解决的问题的话我是不会提交的,不然我还能具体讲一下是哪里出的问题。

当然看一看 Server 的属性,有一个,应该也是目前项目唯一一个智能指针,即 unique_ptr<GameState>,凭印象大概能猜到是这里的问题了:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Server {
unique_ptr<GameState> gameState;
...
};

Server::Server(CLIConfig config, int port) : config(std::move(config)), gameState(nullptr), game() {
...
createNewGame();
}
void Server::createNewGame() {
gameState = make_unique<GameState>(createGameState(config));
game = gameState->simulate();
}

还有一件事情,看 Python 代码是在 except 里面就 yield 了,C++20 是不行的,于是我记录了 result,等处理完异常再 co_yield。不过 Copilot 似乎没意识到这一点。

erase

C++20 还提供了 eraseerase_if,用例如下:

1
2
3
4
5
6
7
8
9
10
/**
* @brief 移除连接
*
* 移除一个 WebSocket 连接。
*
* @param conn WebSocket 连接
*/
void EventEmitter::removeConnection(crow::websocket::connection *conn) {
connections.erase(std::remove(connections.begin(), connections.end(), conn), connections.end());
}

其实还是挺丑的。另外 Clangd 提示我用 Range 替代,有个 quickfix,然而它的 quickfix 是错的,语法都错了,我也是无语……

Clangd 提供错误的 quickfix 这应该是我见过的第三回了?

本来没啥要讲的,不过 erase_if 倒有点值得说道说道。

TankAnt 举个例子:

1
2
3
4
5
6
class TankAnt(ContainerAnt):
def action(self, gamestate):
for bee in self.place.bees[:]:
bee.reduce_health(self.damage)
super().action(gamestate)
...

self.place.bees[:] 弄个备份,避免迭代过程中因为被迭代对象被修改而出现问题。

下面是现在的 C++ 版本的实现:

1
2
3
4
5
6
7
8
9
10
11
void TankAnt::action(GameState &gamestate) {
if (place->bees.size() > 0) {
bees_list beesToDamage(place->bees);
for (auto bee : beesToDamage) {
if (bee->health > 0) {
bee->reduceHealth(getDamage());
}
}
}
ContainerAnt::action(gamestate);
}

嗯,没啥可说的,基本一模一样。

但是一开始并不是这样的,因为这个要弄一个拷贝,我就想用点奇技淫巧了。于是我看中了 erase_if,在「重命名类型别名以提高可读性,更新相关代码以使用新的类型定义;优化 Bee 的移除逻辑,使用 std::erase_if 简化代码等」中进行了负优化:

1
2
3
4
5
6
7
8
void TankAnt::action(GameState &gamestate) {
log(LOGINFO, format("{} attacks all bees in {}", *this, *place));
std::erase_if(place->bees, [&](auto &bee) {
bee->reduceHealth(damage);
return bee->health <= 0;
});
ContainerAnt::action(gamestate);
}

哦,为什么说是优化呢?因为之前是把要杀的存起来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void TankAnt::action(GameState &gamestate) {
bee_list killedBees;
for (auto bee : place->bees) {
if (bee->health <= damage) {
killedBees.push_back(bee);
} else {
bee->reduceHealth(damage);
}
log(LOGINFO, format("{} attacks {}", *this, *bee));
}
for (auto bee : killedBees) {
bee->reduceHealth(bee->health);
}
ContainerAnt::action(gamestate);
}

后面问题太多了,灰溜溜地回到拷贝的方案了。

还有日志的问题,可以看到现在的版本没有输出日志,日志也是后期调试问题中问题的一大根源。不过说是发现问题的好帮手可能更合适。

Range

想过用 C++20 引入的 <range>,但最终未果,没用上。主要还是对这个新概念不太熟悉吧。

C++23

C++23 还有更多特性,例如支持了性能比 cout 更强,而且更好用的 print,还有上面提到的生成器等等。加上我已经从 C++17 跳到 C++20 过了,再跳一次又何妨?

不过最后还是没跳,因为这些新特性必要性没那么充分,加上现在编译器对 C++23 的支持还不够完善?所以最终放弃了。

另外新版本的严谨性约束还是更强的。不记得是哪里的测试,C++17 可以顺利编译,C++20 就报错了,好像就少了个 const 还是啥。也可能是 C++20 和 C++23。这是好事,就是错误信息能再清晰点就更好了。

突击,突击!

嗯啊,摆了五天,连情人节都没写,而且还一直病怏怏的,这个暑假算是有了。在家的最后半天争取草草完结吧。

1.15 开始暑假部分的工作,也是跟最初的计划一致的。

然后暑假部分还是比较紧张的,没有之前那么悠哉悠哉,可以研究各种东西。暑假时连文档注释都没怎么更新,很多地方也是暂时能糊弄就先糊弄上去,先跑起来再说。感觉已经预见了我以后写屎山代码的样子。

就随便写点东西吧。

HPP

我写的是纯 C++ 代码,根据查到的资料.h 一般代表 C/C++ 兼容,.hpp 一般代表仅 C++,而我不考虑 C 的兼容性,于是在「添加 MakePlans;将 .h 改为 .hpp;更新 settings.json」与「..._H 更新为 ..._HPP」中将 .h 替换为了 .hpp,此外还将上面讲过的宏一起替换了。

上面一起提到的还有 #pragma once,在第二个 commit 也进行了解释:

见过讨论这样与 #pragma once 哪种更合适的,并没有压倒性的结论。

支持前者的认为这种方式支持性好,后者可能对于一些编译器没有得到支持(虽然说我不会遇到这种情况),同时二者其实并不完全等同,用后者可能会出现一点错误。

支持后者的认为这种方式短,我也很支持。但是因为已经使用了前者,懒得改了。因此暂时还是使用前者。

还有的同时使用二者,不过这种方式太麻烦了,我也不会考虑的。

大概看的是 #pragma once vs include guards? 及相关的讨论。

Hornet

上面反复提到过,我一开始是照着我写的版本的 CS61A 进行转换,直到 GUI 部分才转向 2024 秋的版本。不过这其中的界限并不分明。

其中一个明显的区别就是 Hornet。在之前的版本中还有一种蜜蜂单位叫 Hornet,它的特点就是免疫效果,同时 Boss 单位由 Wasp 和 Hornet 继承而来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Hornet(Bee):
"""Class of bee that is capable of taking two actions per turn, although
its overall damage output is lower. Immune to statuses.
"""
name = 'Hornet'
damage = 0.25

def action(self, gamestate):
for i in range(2):
if self.health > 0:
super().action(gamestate)

def __setattr__(self, name, value):
if name != 'action':
object.__setattr__(self, name, value)

class Boss(Wasp, Hornet):
"""The leader of the bees. Combines the high damage of the Wasp along with
status immunity of Hornets. Damage to the boss is capped up to 8
damage by a single attack.
"""
...

这也引发了菱形继承问题,之前也是有点问题。

最后的版本中,实现是下面这样的(并未完全实现,也未必能工作):

1
2
3
4
5
6
7
8
9
10
class Hornet : virtual public Bee {
...
void action(GameState &gamestate) final;
// TODO: immune
};

class Boss final : public Wasp, public Hornet {
using Wasp::action;
...
};

而再后面就直接移除了。大概有以下几点原因:

  1. 没有相应的现成的动图资源,这个应该是所有原因中最重要的一点。
  2. 2024 秋版本中没有。
  3. 引入了多继承与菱形继承,增加了复杂性。

于是干脆就在「更新 Doxyfile;移除 Hornet(反正也没资源,而且多继承折腾了一会);修改 Insect 的字符串表示,使之呈现更多信息」中移除了。

using vs. typedef

上面的代码中出现了这样的语句:

using ant_constructor = function<Ant *()>;

这个含义是定义了一个新类型 ant_constructor,这个类型跟 function<Ant *()> 等同。这语法不是比 typedef 好多了?

而且查了一下 using 甚至比 typedef 更强(当然后面也加强了 typedef,似乎在 C++23 就是等同的了),还更友好,那肯定用 using 啊。

我这个例子看不出来啥,举一个上面链接给出的例子:

1
2
typedef void (&MyFunc)(int, int);
using MyFunc = void(int, int);

孰优孰劣,高下立判。

C++ 插入谈

我这个历程本来就是想到哪写到哪的,写了上面的 using vs. typedef 后就想说点别的了。

怎么说呢,看了不少 C++17, C++20, C++23 甚至 C++26 的新变化,很「欣慰」的是感觉 C++ 确实是在与时俱进的。

这些版本还算新的吧,也都只是近十年不到的更新,但让我震惊的是,加的都是些很好用,但同时我觉得难道不是早该有的东西吗。像是其他地方司空见惯的特性,在这里还在探索追寻,就有点难以言喻的感受。

所以说呢,这些更新反而是促使我「快跑」,看到 C++ 的新特性越来越「现代」,我就在称赞之余越想要逃离。因为我感觉这仍旧只是在历史包袱上修修补补。

别的不说,就这个包管理已经让我欲仙欲死了。在其他语言中稀疏平常的事情,在我这里成功管理一个包,是一个能兴奋地跳起来的巨大成就。

总之呢,以后要是写 C++,也一定是被迫的。像两天后的下学期的操作系统似乎也要用 C++?这个也是被迫的。

命令行参数

命令行参数解析用的是 argparse,当时似乎遇到了点问题,现在包发新版本了也不知道修复没,没试过。具体的提交是「使用 workaround 暂时忽略 argparser 的问题(p-ranav/argparse#385)」。

根据当时的记录,requiredstore_into 是冲突的。按现在的实现,可以 ./Avsb.exe -f,即不给予值,我想要的是必须给予一个值,于是我找上了 required,按照文档的说法 "If the user does not provide a value for this parameter, an exception is thrown.",正是我想要的。

但按照提交给出的问题,这与 store_into 似乎是冲突的。于是后面我就只能移除 required 了。

不过后面看有一个 PR 似乎修复了,同时也发新版了,但是不见关闭那个 issue,我也还没尝试行不行得通。

日志

日志就是随便写啊,挺不规范的,好像也没啥好说的。日志等级随便分配,日志内容随便写,看日志也看不出个所以然。我也确实不知道日志该怎么写,大概就是依葫芦画瓢地装模做样一番吧。

日志的颜色实现用的是 ANSI。起初的日志是整行都上色,即 INFO 就是整行蓝色,后面想为不同信息上不同颜色,就改了点内容。如下所示,由于不像 f-string 这么强大,就只能写得很丑陋了:

format("{1}{3}{0}[{4}]({2}{5:.2f}{0}, {6})", ANSI_DEFAULT, ANSI_GREEN, ANSI_MAGENTA, getName(), id, health, place);

但是这个有个问题就是,一般来说 ANSI 上色或者格式化都是这样的:<ANSI_COLOR>text<ANSI_RESET>,但 reset 完后,先前的格式也没了。

我探索了一番,也没找到简易的保持原有颜色的方式,只好放弃了整行上色的想法,只上色日志等级,其他非关键信息都是原色,也就成了现在的日志上色法:「更改部分信息日志等级;让 Insect, Place 显示更醒目;日志不再整行着色(不是不想,是不能);将服务器启动移到 Server 内」。

犄角旮旯

还有很多边角料的东西都是最后关头才发觉的,属于是不自己跑一跑完全发现不了的问题。这些我也不想一一讲了,因为记录不多,而且时间不多、热情不多了。

另外在这个也是犄角旮旯的地方讲一讲,这个项目拿满分倒是没啥问题的,我自己申的是满分,最终得分也是满分(还有后面没有推迟提交而多加的十分)。不过这不影响我 C++ 总分就是一坨,这期末占比这么高是我没想到的(我不太关注课程分数比重的),结果就是最用心的项目影响不了应付的期末。

嘛,还是会心有不甘的,毕竟我认为这个项目的重要性远超那个一坨衬线字体代码的试卷,实操远比纸上谈兵要强,但结果就是这个项目全部扔掉的损失相较而言都可以说是微不足道。唉,也没啥好说的了。

大概就没什么了,因为后面基本是 Web GUI 部分,Copilot 帮助很大,我大概再挤出一点东西,差不多就算讲完这部分了。

内存泄漏

现在可以跑 Address Sanitizer,我跑过,毫无疑问是内存泄露流了一地。

在前期中期还想过采用智能指针,不过因为我对其不熟悉,后面还是用了裸指针,想着内存泄漏的事情还是后面再解决,优先的应该是游戏逻辑。

不过后面赶代码也来不及去搞智能指针了,然后项目完成后我在做什么也是有目共睹,不必多言。总之就是智能指针的事情就没啥眉目。

认真说的话这点内存泄漏我还真不放在心上,即使漏了不少,内存占用也才 2M 左右,这个量在 Edge 等大块头看来,连做误差的资格都没有。

如果用智能指针,似乎还不太能用 unique_ptr,得用不少 shared_ptr 以及 weak_ptr,因为我这个设计多的就是相互持有。而据我了解,这两者相较于 unique_ptr 似乎还是有点损失的,前者似乎是零成本抽象?

当然不管怎么说,对于我这个体量的程序来说无论是内存泄漏还是运行开销都可以说是微不足道的。

JavaScript WebSocket

看一下最初的模板文件 templates/index.html

1
2
3
4
5
...
<script src="/static/socket.io.min.js"></script>
<script src="/static/utility.js"></script>
<script src="/static/script.js"></script>
...

可以看到用了一个 socket.io.min.js,然后在 static/script.js 的开头有:

const socket = io.connect(); // connect to websocket

不过 C++ 后端版本就用不了了,因为记录丢失我现在也不清楚原因了。反正在 Copilot 的指导下进行了部分修改:

1
2
3
4
5
6
7
...
<script>
const ws = new WebSocket(`ws://${location.host}/ws`);
</script>
<script src="/static/utility.js"></script>
<script src="/static/script.js"></script>
...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const socket = {
callbacks: {},
on: function (event, callback) {
this.callbacks[event] = callback;
},
};
ws.onmessage = function (msg) {
const data = JSON.parse(msg.data);
const event = data.event;
const eventData = data.data;
if (socket.callbacks[event]) {
socket.callbacks[event](eventData);
}
};

然后就能删了 socket.io.min.js

静态库编译

这时候已经差不多完结了,当时正在写文档,想着还是在别的机子运行一下看看能否直接跑,结果直接出问题了。

小可爱电脑端微信清了图片,还好我手机端还没清聊天记录还能看到,报错是找不到 libc++.dll,这个我先不讲,后面还会提到的。然后再说点无关的,本来都是电脑端保留所有聊天记录(除了水群),手机端不定期清理,结果这样搞我手机端也不敢清理了,微信你什么时候进化啊?

我还没学编译相关的知识,不过问了问 Copilot 以及凭借我我之前了解过的信息,大概知道这大概是静态链接与动态链接的问题。

按我一个门外汉的说法就是,默认应该是动态链接的,我有 Clang 工具链,所以可以直接链接动态库运行,不需要绑定进去。但是别人的机子里没有这个动态库,所以就报错找不到了。解决方法也很简单,那就是改成静态链接,把要用的捆绑进去。缺点就是编译产物会大一点(但其实也没大多少),但优点就是可以满世界跑了:

1
2
3
4
if is_plat("mingw") and is_mode("release") then
add_cxflags("-static")
add_ldflags("-static", "-static-libgcc", "-static-libstdc++")
end

这个是「为 MinGW 平台的发布模式添加静态链接选项」紧急补丁的修正,其实似乎因为照抄,有点问题?

先不管这个。除此以外 Windows MinGW 还需要额外链接一个库:

1
2
3
if is_plat("mingw") then
add_syslinks("wsock32", "ws2_32")
end

因为用了 WebSocket 什么的。而 Linux 就不用,默认带的好像,因为交流记录丢失我也找不到详细信息了。

这个编译问题在别的同学的作品那我也屡屡遇到。19 号那天剩余时间我就去 NJU Git 那边逛了逛,玩了几个游戏。大部分同学都是用 Qt,结果下下来单个可执行文件玩不了,因为没 Qt。后面我只好把整个编译目录下下来才能玩。即便如此还是会遇到动态库的问题,所以说实际上没玩成几个。

工具链大危机

弄完上面的部分后,我也不知道干了啥,反正就是工具链出了大问题。遇到了 bug 想要去重新编译一下检查,结果折腾了两个多小时。

唉我也懒得讲了,直接把当时的记录截图过来吧。因为是实时记录,比较情绪化,也比较随便:

语无伦次,我也看不懂在说啥了。

录视频

另外录视频的时候,边录边修 bug,结果弄了挺长时间。因为担心弄不完而决定通宵,结果就是早上录完后,后面也封存了不管了(只要我不看就不会有 bug)。

一开始写演示文档是打算照着念的,毕竟我憋不出几个词。

但实际录的时候怂了,半夜录视频,还自言自语,声音太清晰以至于我不得不小点声,反正哪哪不舒服。

最后就是不说话了,反正也有文档,拿鼠标在文档上拖来拖去,唯一出现的声音就是 Avsb 那听吐了的背景音乐。

视频也挺低质量的,剪辑太麻烦了,而且我也没啥趁手的工具,就是简单的录几个片段然后拼接在一起,正好卡着线十四分出头。要修正肯定还是能修正的,但是太麻烦了,轻薄本整这个,光是拼接就用了我好久。所以就这样。

但是这个视频足足有 900M+ 好像。然后我也不知道其他同学怎么交的视频,我是不想用这种毫无意义的东西占据 Git 仓库的空间,于是我打算塞邮箱附件,显而易见是塞不进去的。

所以就开始了丧心病狂的视频摧残。最终压到了 23M 的大小才上传成功。

分辨率变成了 1024x640,码率 227kb/s。

翻了一下命令历史记录,用的命令大概是这样的:

$ ffmpeg -i out.mp4 -vf scale=1024:640 -c:v libx265 -r 20 -b:v 128k output.mp4

比例比较显然,然后视频用 libx265(因为 Copilot 说高效的编码方式也可以减小大小),-r 应该是设置帧率为 20,还有就是设置视频码率为 128k(最后 129k,音频码率 92k)。

来一张截图看看效果。

这个视频从开头快五点,一直到结尾六点多,中间多的时间应该是修了点 bug。

看代码的奇怪问题

VS Code 早换了 Clangd 插件,但其实我 C/C++ 也开着,因为 Clangd 有些地方不太好用。例如说 C/C++ 在写文档注释时换行能自动补充前面的 *,但 Clangd 不行,还是挺难受的。另外 Clangd 的文档显示不如 C/C++,当然其实我也没怎么看就是了。

然后写这个文的时候看代码,有试着用 Neovim 看代码。用的是 Mason,然后装了 clangd 语言服务器。

但是会报错,在引入的库中说 <coroutine> 找不到。然后去那里 gd go definition 的话,跳到的是 Asio 的库。

我很纳闷,Neovim 和 VS Code 用的都是 clangd 语言服务器,看的都是 build/compile_commands.json,甚至我看了一下日志里的命令都是一致的,怎么 VS Code 没问题,就你 Neovim 有问题?

后面折腾了一阵子后解决了,解决方法就是往 xmake.lua 加上这么一行:

add_cxxflags("-stdlib=libc++")

如果还记得上面动态链接的话,就可以发现这正是上面所缺少的 libc++.dll

然后我去了解了一下,知道了点东西,随便讲讲,反正无知者无畏。

大概就是这样的,这个 libc++ 是 C++ 标准库的实现,除了这个以外还有一个是 libstdc++,前者是 LLVM 搞的,后者是 GCC 搞的。然后 Clang 工具链是同时支持两个的,而 GCC 工具链只支持后者。

但是奇怪的是,我用的是 Clang 工具链,据我了解默认用的标准库实现就应该是 libc++,不需要额外指定,但是 Neovim 的 clangd 服务器就是要这个额外的,感觉有点多余的选项。难不成它跟 VS Code 的 clangd 还不一样,默认用的是 libstdc++?我搞不明白。不过好在最后是解决了,虽然说莫名其妙的。

匆匆结语

原定的代码的细节我也不想讲了,就这样吧。甚至还有点想封存了仓库,不过还是开着吧,毕竟我似乎没看到用 C++ 实现的 CS61A 这个项目(有别的似乎是别的课程的,也不太一样)。也许后面还会有人发 PR 呢(做个美梦),而且也许我后面有时间也会修修补补呢(梦中梦)。

不仅这个项目是庞然大物,后续的记录也是巨无霸。写到一半将项目介绍与回顾分拆了,然后后续写的部分也因为太卡了而等到提交时再合并。即便肢解成了两篇(其实还有计划第三篇的,不过放弃了),即便最后蛇尾了,草草敷衍结束了,但这篇的大小依旧是达到了 107KB 的大小,字数也是达到了 19k,勉强超过了重装记录(阅读时间 1:10,比重装记录多 1min),夺得字数桂冠。也算是不辜负了自己的努力吧。


  1. MinGW 里怎么会有 LLVM 呢?我是在 WinLibs 里下载的带有 LLVM/Clang/LLD/LLDB 的 MinGW64。 ↩︎

  2. 我还挺偏爱 LazerAnt 的,调试的时候经常也是第一个加 LazerAnt。 ↩︎

  3. 其实还有 Insect 等,不过一直拿 Place 举例子。 ↩︎

  4. 没错,还是 12.8。12.8 啊,你(编不出来)啊! ↩︎