用 C++ 编写超级马里奥兄弟游戏
开始
ECS架构
将事物组合在一起
结论
我决定学习 C++,以此作为了解计算机科学、编程语言和图形编程基础知识的第一步。
游戏开发一直是我的爱好。编写游戏似乎是个不错的入门项目。在阅读了一些 C++ 书籍后,我决定复刻这款意大利水管工平台游戏。源代码在这里。
在这篇文章中,我想谈谈我从JVM世界转到C++后发现的一些有趣的方面。我还想简要介绍一下使用实体组件系统架构(ECSA)构建一个“简单”游戏的过程。如果您不是游戏开发者,我希望这篇文章能对您如何构建这类简单游戏起到一个大致的介绍作用。
开始
我非常喜欢 JetBrains 的 IDE,所以我的第一步就是下载 CLion。CLion 默认使用 CMake 构建 C++ 项目。CMake 是一个构建系统,它构建的是构建系统本身。它是跨平台的,但极其不直观,不过如果你想让你的软件在多个平台上运行,它就是你的最佳选择。让我感到惊讶的是,一门拥有如此悠久历史的语言竟然没有更完善的解决方案。我之前一直用 Java(Kotlin),现在我对 Gradle 有了全新的认识。
我决定使用简单直接媒体层2 (SDL2) 来访问音频、键盘、鼠标和图形硬件。我考虑过直接使用 OpenGL,但我觉得项目规模已经很快失控了。
接下来,我开始为游戏定义架构。纯粹的面向对象设计似乎非常适合游戏。游戏中有很多对象需要相互交互。你可以将这些对象映射到类,并利用继承来避免代码重复。然而,这种简单的方法在构建相对复杂的游戏时会带来一些问题。我不想深入探讨这些问题,但这里做一个简要概述:
- 使用继承通常会导致架构缺乏灵活性,迟早会引发致命的菱形问题。
- 效率。对象通常分散在内存中,对 CPU 缓存的利用率很低,无论是缓存一致性还是预取方面都是如此。(这可能不是什么大问题,具体取决于您要开发的游戏类型。)
- 封装。很难定义逻辑应该放在哪里,以及哪些部分可以访问哪些部分,这会导致代码混乱不堪。
我可能会在后续文章中详细阐述这些观点。现在,我想先向大家展示我选择的架构是如何运作的,以及它是如何克服这些问题的。
ECS架构
过去,我一直苦于如何设计出可扩展的游戏架构。当我第一次了解到实体-组件-系统(ECS)架构时,就迫不及待地想要尝试一下。我们先来逐一解释一下每个术语的含义。
实体
游戏世界中的一切事物都由一个实体来表示。这包括玩家、敌人、声音、音乐,甚至包括摄像机。实体只是组件的集合,本身并不包含任何逻辑。
成分
组件就是普通的结构体,不包含任何逻辑,它们只是数据的载体。大多数游戏中常见的组件包括 PositionComponent、PlayerComponent 和 TextureComponent。
请注意,有些组件甚至不保存数据(例如 PlayerComponent),它们只是作为“标签”来帮助识别游戏世界中的某些实体。
组件可以在运行时分配给实体或从实体中移除。
以上组件比较通用,但您很可能需要创建特定于您游戏的组件。例如,SuperMarioComponent当马里奥吃蘑菇时,会为其分配一个组件。
系统
最后,系统是行动发生的地方。它们与特定领域密切相关。它们承载逻辑并将其封装起来。我认为通过一些我在游戏中定义的系统类型的例子会更容易理解:
- The render system
- The camera system
- The physics system
- The animation system
- etc.
系统类并不比架构中的其他类复杂多少。它最基本的形式是重写了 `update(world* world)` 函数,该函数接受一个游戏世界作为参数。接下来我们将讨论 `World` 类。
请参考我游戏中的动画系统,这是一个简单实际的例子,可以理解上面阐述的逻辑。
将事物组合在一起
如上所述,系统、实体和组件通过 World 类相互交互。
正如您可能已经猜到的,世界级模型构建了我们的游戏世界,因此包含了我们所有的游戏实体。我们可以通过将系统注册到游戏世界来定义这些实体的行为。实体和系统都可以在运行时添加/删除。
世界级建筑的等级划分与其他建筑等级划分一样简单明了:
我想你应该能猜到这些方法的作用。第一个方法是默认构造函数。接下来的两个方法分别用于向世界添加/移除系统。再接下来的两个方法则对实体执行类似的操作。
最后一个方法 find() 或许最值得详细解释。它负责查找游戏世界中所有符合特定条件的实体。这些条件通常是分配给它们的组件类型:
// Find all entities with Texture and Position data.
auto entities = world->find<TextureComponent, PositionComponent>();
Find 函数利用函数模板来定义搜索条件。在 Java/Kotlin 中,如果希望一个类或函数能够操作任意类,可以使用泛型。函数模板和类模板与泛型有些类似,但功能更强大。函数模板是指示编译器为你编写代码的指令,也称为元编程。这使得我们可以创建一个函数模板,其功能可以适配多种类型或类。
这是我的代码中查找函数的定义:
这里有一些有趣的地方。首先是template<typename… Components>函数签名中的使用。这告诉编译器在编译时生成一个名为 find 的函数。编译器每次遇到对 find 的不同调用时都会生成这个函数:
对于上面的代码,编译器会自动为我们生成以下内容:
void find(const std::function<void(Entity*)>& callback) {
for (auto entity : entities) {
if (entity->has<AnimationComponent, TextureComponent>) {
callback(entity);
}
}
}
正如我之前提到的,这发生在编译时,所以不会造成运行时性能损失。我必须承认,我第一次接触到 C++ 的这个特性时并不喜欢它。现在依然不喜欢,但我理解它带来的性能提升。我不喜欢它的原因是,我认为它会导致代码难以阅读。我不喜欢弱类型系统,而这看起来很像弱类型系统。
函数的其余部分应该比较容易理解。如果遇到任何问题,请在评论区告诉我。
结论
现在你应该对 ECS 的工作原理有了大致的了解。我们不再依赖继承来定义行为,而是依赖组合。这意味着我们摒弃了可能冗长且缺乏灵活性的层级结构,转而使用轻量级、可重用的组件,这些组件可以在运行时添加和移除。
我之前提到的效率问题也得到了缓解。我们的实体和组件现在紧密地排列在连续的内存中,因此遍历它们可以更有效地利用 CPU 缓存。虽然还有一些技术可以进一步提升效率,但我在这个游戏中还没有用到它们。
我们的第三个问题,封装,也已解决。现在,逻辑应该放在哪里、架构中各个部分应该如何通信以及哪些部分应该访问哪些资源,都变得非常清晰。例如,渲染系统使用 renderer.cpp 或 window.cpp 之类的类是合理的,但如果物理系统(PhysicsSystem.cpp)或声音系统(SoundSystem.cpp)也需要这些类,那就明显有问题了。
现在你应该对我的代码结构有所了解了。欢迎随意查看并提交 PR 来改进我可能犯的任何错误或漏洞。我对 C++ 还很陌生,所以我的代码肯定还有很大的改进空间。
学习 C++ 是一次有趣的经历,但我感觉自己离真正熟练运用它还有很长的路要走。这个项目的后续计划包括使用 Rust 或 Kotlin-native 重写。我还想把这个项目编译成 WASM,让它能在浏览器中运行。如果您有任何相关经验,我很乐意与您交流!
感谢阅读,祝您编程愉快!
文章来源:https://dev.to/feresr/writing-super-mario-bros-in-c-4726









