面向猛新的小游戏制作 - EasyX 超级马里奥
近些日子有同学与我聊起来学校的小学期任务是用 EasyX 做一款超级马里奥,不由得来了兴趣,毕竟在一个月左右的时间内,以大多数同学的编程水平,在只提供了图形接口功能的 EasyX 图形库基础上去实现一个包含渲染、音频、数据存档和物理模拟的一个卷轴关卡游戏,本身的难度还是有的,更何况即便是单个功能可以花费点时间探究予以实现,但当整个程序作为一个具有游戏逻辑的系统时,就考验语法之上的架构能力了,如何使用面向对象进行类的划分,系统之间如何通信进行数据的传递,如何将输入与渲染的结果动态地呈现在窗口中……如果要相对完美地实现这些功能,对编程经验还是有一定的考验的。
念及在初学编程时对 EasyX 的旧情,便想挑战一下,看看现在的自己多少时间可以完成一个功能相对完整的超级马里奥。结果便是差不多花费了总计三天左右,在工作之余的摸鱼时间中、牺牲了部分休息时间,完成了一份有效代码约 1.5K 行,稍具可玩性的游戏程序,想到可能自己在编程过程中的思路可能对使用 EasyX 的初学者具有启发意义,便写下这篇文章,希望借此抛砖引玉。
在开始正文之前,请注意以下提醒:
- 本文的读者是“猛新”而不是“萌新”,所以如果你对 C++ 的基础语法几乎一无所知,那么建议你先学习基础后再来阅读本文,否则配上本人特有的花里胡哨代码风格可能让你产生不必要的畏难情绪;
- 正如上文所述,由于本项目只是一次心猿意马的随写,尽管考虑到了减少不必要的技术依赖(如习惯的 XML/JSON 存档),但是在代码风格上,还是可能会用到些许的 C++ 新特性或 STL 模板,这不是坏事,但是可能需要希望理解此些部分的同学多搜索一下相关内容补课;
- 面向对象的设计和资源生命周期的管理等方面,站在完成者的角度来讲并不完美,但是考虑到深究会让代码体系变得更加复杂晦涩,且本身作为随写内容没有重构的必要,但是对于大部分初学的同学还是相对有参考价值的,如果你能注意到这些问题,并提出更优雅和安全的设计,那么恭喜你,你的编码水平已经脱离仅是让编译器不报错的阶段,开始向着代码架构的方向前进了;
- 本文只是以点带面地讲述思路和技术关键点,并不是一篇手把手教你如何用 C++ 实现一个超级马里奥的教程,但是项目完整的代码工程和可以发布单文件版本下载链接我会放到文章最后,感兴趣的同学可以下载体验,请确保自己的电脑上装有 VisualStudio 2019/2022,并且安装了 EasyX 图形扩展。
系好安全带,开始飙车~
三行代码实现——游戏最简模型
首先从“游戏”的最简模型入手开始讲述,好多同学在使用 EasyX 进行静态绘图的时候尚且可以 hold 住,但当程序转变为动态的游戏时便一头雾水了。其实游戏程序做的只有三件事:读取用户输入事件,根据输入事件和内部逻辑修改数据,最后根据数据绘制画面,而为了确保窗口不会退出,只需要将这三部分逻辑放在一个巨大的 while 循环中,那么考虑到程序退出条件即循环结束条件,就可以用如下的伪代码来表达上述逻辑:
1 | while (游戏不退出) |
在这里有几个小细节可以注意:
- 用户的输入的事件可能在两次处理之间存在多个,譬如你以极快的手速拖拽光标时就会产生大量鼠标移动的消息事件,比起很多教程中使用
flushmessage
对事件队列进行清空以舍弃多余的事件,我更推荐使用while(peekmessage(&msg))
对所有事件进行处理,这样可以确保在一次 Tick 循环,也就是大循环中,读取并处理所有的用户输入而不会有遗漏; - 绘制当前数据时,不要忘记先对上一帧画面进行清空,由于人眼的视觉暂留效应,一般确保循环每秒执行 60 次左右便可以实现十分流程的动画效果,所以在每次绘图渲染时,不需要对上一帧的绘图数据进行增量修改,而只需要根据当前的相关数据,绘制画面即可;
- 由于现代 CPU 执行速度极快,尽管可能你的游戏逻辑代码行数很多,但是只要没有时间复杂度较高的算法或者存在 IO,那么你的游戏循环将会以每秒几千次甚至上万次的速度执行,这不仅浪费了大量性能,而且如果是基于帧计数的计时器或动画设计,则会造成执行速度与设备强相关不可控的问题;解决此类问题可以简单粗暴地使用
Sleep(16);
的方式让循环在单次结束时强制停止指定时间,但是考虑到游戏逻辑在每次循环执行的时间可能会有偏差,更好的设计应该是计算循环开始和结束的时间,对时间作差后进行动态延时,具体实现可以后文代码。
所以在我编写的代码中,游戏的主循环是这样的实现:
1 | while (!(*GlobalContext::Pull<bool>("is_quit"))) |
好,既然已经理解了游戏程序的核心思想,那便要考虑如何对输入和渲染等内容进行解耦,让其在不同的游戏阶段各司其职,那就是接下来的内容——
抽象的第一步——游戏场景管理
很多同学在写 C 语言时喜欢一条道走到黑,把所有逻辑都写在 main
函数里面,这就导致了即使用上了 C++,对类和成员函数的划分也十分生硬,仅仅是在“用”语法,而不是在考虑如何进行抽象和设计。
考虑到游戏过程中存在不同的阶段,如主菜单界面、排行榜界面,以及局内的主体游戏部分,这些不同的阶段对事件的处理逻辑和对数据的渲染逻辑都不相同,这就需要我们对不同的阶段进行划分为不同的对象,这里参考现代游戏引擎的设计,提供一种可行的抽象设计思路:
游戏的不同阶段为不同的场景(Scene),场景类似于舞台,是一个可以包含各种游戏对象(GameObject)的容器,而各类游戏对象均通过重载实现自己的输入事件逻辑和渲染逻辑,场景在循环中进行更新时会遍历场景内所有的游戏对象进行更新。
在代码中的实现如下:
1 | class Scene |
细心的同学可能注意到了我使用了一些 static 变量,这样的目的其实是将
Scene
与SceneManager
的功能融合在了一起,比较好的设计思路是将二者的功能进行解耦,Scene
只提供容器的功能和对内部游戏对象管理的功能,而SceneManager
则负责场景调用的加载、卸载和初始化,以及控制跳转等功能。
真正的演员——游戏对象设计
既然我们需要用容器(喜闻乐见的数组,或 STL 中的 vector 模板容器)来包含元素,而又想让这些元素具备不同的逻辑,那么自然而然的思路就是定义 GameObject
基类,然后通过多态实现上述功能;面向对象的三大特性中,多态的特性可能是在学校的课程或作业中实践最少的部分,但是这却是在此处实现解耦的关键一步,其基于语言特性自然而然的设计更像是一种魔法,话不多说,先展示代码:
1 | class GameObject |
有点过于简单了对吧,似乎和一个自动生成的空 class 代码别无二致?这是因为 GameObject
基类在此处的设计更像是一个模板范本,玩家、怪物、道具甚至是地形这些元素的所属类均由 GameObject
派生得到,他们都可以通过 OnInput
函数处理玩家的事件输入逻辑,也可以在 OnRenderer
中实现自己的渲染逻辑,接下来我们挑一段逻辑较少的子类来举例:
1 | class Block : public GameObject |
如这段代码的字面意思所示,我们定义了派生自 GameObject 的子类 Block,来表示游戏中的障碍物;障碍物不需要接受玩家的输入事件,而只需要处理与玩家发生碰撞的各种逻辑,所以我们没有重载父类的 OnInput
函数,而新增了 Collide
函数,这里我们没有编写其函数体而是让其 = 0
,这就意味着如果所有派生自 Block 的子类均需要实现碰撞相关的函数逻辑,才能进行实例化;而在 OnRender
函数中,我们实现了简单的 DebugDraw 逻辑,它能让我们更方便地看到障碍物在世界中的边界,即时派生的子类不打算在画面中渲染自己,在开启 DebugDraw 时也可以看到白色的边界线(此处为进阶设计,与游戏主体逻辑无关,若理解有困难可以暂时跳过)。
面向对象的核心思想之一就是通过一层一层的抽象和继承,通过不断地增加各种限定,去更为精准地描述我们想要表达的物体。
让画面动起来——动画系统实现
在本文一开始便提及,游戏是一个尽可能维持在 60 fps 的巨大循环,以最简单的序列帧动画为例,如果想要实现游戏内的动画效果,只需要在每次调用 OnRender
函数时播放下一帧动画即可,但是如果为每一个动画设计每秒 60 张图片素材,那简直是一个灾难性的任务。其实,即便是成熟的游戏作品,在使用序列帧进行动画时,玩家对动画本身的帧数要求是相当宽容的,只需要确保每秒 24 帧左右即可实现动画的感觉,而在咱们以程序功能为主要目的的实践中,这个帧率的要求可以继续向下放宽(在我所提供的程序实现中,大多数动画只使用了 2-3 帧)。
说到这里很多同学可能就迷惑了,既然画面需要保持每秒 60 帧的刷新率,而动画却没有这么多素材,该怎么办呢?这就需要我们使用定时器(在我提供的代码实现中,称之为计数器更为合适)对动画帧的“切换”进行限制,如果我们玩家的行走动画只有三张图片素材轮播来实现,我们需要在 1 秒内播放一个循环,那么我们就在 OnRender
函数中对定时器变量进行累加,只有当定时器到达 20 时才会累加当前播放的动画帧在数组容器中的索引,同时重置定时器。
此处给出 Animation
动画类的实现,由于此类代码实现较长,此处只展示部分相关代码:
1 | class Animation : public GameObject |
在上述代码中,Animation
其实也充当了容器的概念,将单帧图片对象 Sprite
保存在了动态数组 std::vector
中;这里可能有同学会疑惑了:为什么明明 EasyX 提供了 IMAGE
类,你还要自己定义一个 Sprite
类来表示图片呢?这是因为,Sprite
在渲染时不仅调用了诸如 putimage
这种渲染函数,更是保存了当前游戏内“摄像机”的信息,图片渲染在屏幕上的具体坐标位置由物体在世界中的坐标和摄像机坐标共同决定。
获得“世界感”——摄像机的实现
考虑到超级马里奥此类游戏的卷轴场景,也就是会有一个虚拟的摄像机始终在跟随玩家,并将玩家尽可能放置在视野中心,所以,我们给出了 Camera
类的实现:
1 | class Camera |
很奇怪,摄像机类的实现似乎更像是一个结构体,并没有多余的逻辑,这是因为在我们的代码设计中,Camera
只负责存储当前摄像机的矩形信息,真正对摄像机位置进行跟踪的逻辑被放置在了玩家类的实现中,因为虽然在我们的游戏中可能不会出现这样的情况——玩家不希望摄像机跟随,而是让摄像机始终固定看向一个位置,那么这些逻辑就应该由外部类进行控制,而摄像机更像是一个没有任何自我行动力的物体,被玩家或者其他对象所操纵。
当然,这也是面向对象抽象过程中的一步,不同的人在编码时可能会选择不同的思路或设计。
那么摄像机究竟在何处被使用呢,除去先前在 DebugDraw 中有过浅显的提及,更重要的用途在于 Sprite
的渲染中:
1 | class Sprite : public GameObject |
PutTransparentImage
函数为我们自定义的带透明通道图片的渲染逻辑,参数含义与 putimage
函数相似,网络上有不少相关实现,此处碍于篇幅便不再赘述;我们看到,在实际渲染 Sprite
对象时,我们将其坐标与摄像机坐标进行了作差,这一个简单的减法,便实现了“世界坐标系”和“屏幕坐标系”之间的转换,世界坐标系中标示着场景中的各类游戏对象所处的绝对位置,而在实际渲染时,我们需要得到物体在屏幕(也就是摄像机)中的相对位置,所以我们对 x 和 y 坐标进行了转换。
其实在此处
Camera
类的实现中,Rect
成员的概念可以退化为POINT
,毕竟摄像机的宽度和高度数据并未使用;严谨来说,摄像机应该是一个目标IMAGE
对象,所有的渲染都绘制在其上,在渲染的最后一步再将其绘制到屏幕上,这个过程更接近现代图形学中渲染缓冲区的概念,这样摄像机的宽高便可以派上用场了,对摄像机所持有的IMAGE
成员进行处理的话便可实现后处理效果……扯远了,打住!
更逼真的操作体验——物理的实现
如果我们按下跳跃按键游戏中的角色只会向上移动而不会下坠,又或者玩家碰到障碍物后依然穿墙而过,甚至是明明已经碰触到金币了得分却没有增加……这种体验太糟糕了,而解决这些问题的方法,说得高级些叫“物理模拟”,在我们的需求中通俗而言,只需要实现矩形相交的判断即可。
这种碰撞检测叫 “AABB碰撞检测算法”,也就是对轴对齐碰撞箱的检测,这里借用一张网络图片:
而要对这种碰撞箱进行检测也很简单,只需要在 X 和 Y 轴方向上分别判断矩形是否相交,只有两个方向上均相交才可以判定两个矩形此时是相交的,下面给出此算法的代码实现,以及点在矩形内的代码实现:
1 | inline bool PointRectOverlap(const POINT& point, const RECT& rect) |
而要实现玩家跳跃后的下坠,我们只需要始终为玩家添加一个竖直向下(Y轴正方向!)的加速度,而当玩家所在的矩形与地面所在的矩形发生碰撞时,则将玩家的下落速度设置为 0 即可。
上述是较好的代码实现思路,而在我们的实际实现中,考虑到地面 Y 轴方向坐标固定,所以将地面的碰撞检测几何外形简化为一条直线而不是矩形,当玩家脚底坐标超过地面所在的 y 坐标时便发生了碰撞。
1 | // 检测与地面发生碰撞,结束跳跃状态 |
在对玩家与其他 Block
进行碰撞检测时,我们为玩家提供了一个 Block
类型指针的变长数组,从而可以通过 void AppendBlock(Block* block);
方法向玩家对象中注册会与其进行碰撞的物体,而在玩家的对象的 OnRender
函数中,我们通过如下代码依次检测各种障碍物的碰撞并调用其碰撞实现:
1 | // 检查玩家与障碍物碰撞 |
而在前文中提到的对摄像机位置进行调整的代码,也就显而易见啦:
1 | // 更新摄像机位置 |
一些不起眼但是比较进阶的设计
按钮实现
这是很多萌新同学容易碰到的误区,那就是会疑惑“EasyX 的按钮应该如何用呢?”。注意,EasyX 是对 Windows GDI 的封装,它的定位是图形库,而不是 GUI 库,所以不会有类似按钮、文本框、列表这种顶层封装的 GUI 组件,所以,如果我们不想用按键来实现 1、2、3 菜单选项的选取,那就自己封装一个相对美观实用的按钮好了!
其实在这里也可以通过 Win32 API 实用系统的 GUI 组件,但是我个人对 Win32 底层那种老旧繁琐的 API 向来是比较排斥的,想到用 EasyX 函数封装不麻烦,而且比起 Win32 API 或许对新手同学们更容易理解,所以便有了如下代码:
1 | class Button : public GameObject |
一句我很喜欢的 GUI 设计哲学:“一个按钮,不是因为他是个按钮才能被点击响应,而是因为它能够响应点击,才叫做按钮”
所以在上述的代码中,我们实现了一个悬停、按下都会有色彩响应的按钮,而使用它的代码如下(如果你对匿名函数的写法感到疑惑,那么或许是时候补充下新标准的 C++ 知识了):
1 | Button* btn_start = new Button("开 始 游 戏", { 100, 200, 300, 250 }); |
有同学可能会注意到我在处理 Unicode 编码字符串时没有使用诸如
LPCWSTR
这些类型的参数,而是使用了一个又臭又长的类型std::wstring_convert<std::codecvt_utf8<wchar_t>, wchar_t>
,好吧,原谅我不喜欢手搓 C 风格的缓冲区,用 modern C++ 的写法很多时候能省去很多麻烦。
音频实现
考虑到最小依赖的需求,我们在实现游戏音频播控时没有使用更为简明方便的专业音频库,而是用了喜闻乐见的 mciSendString
这个 Win CMD 媒体播控函数,所以一切都又臭又长地迎刃而解了:
1 |
|
或许你已经注意到了我把
Sound
类的构造函数私有化了,并提供了一个静态的Instance
函数用来返回Sound
类型的对象指针;其实在整个游戏的代码中,有大量诸如此类的使用,这是一种叫做 单例模式 的设计模式,或许它并不起眼甚至有些奇怪,但是可以让你的程序在某个类型的对象保持唯一性时干净又安全。
全局上下文实现
其实在前面展示的代码中,就存在着大量看起来很迷惑的一团代码,譬如咱们在一开始讲述大循环时展示的 while (!(*GlobalContext::Pull<bool>("is_quit")))
这一句;这就是咱们要说的“全局上下文”。
“上下文”这个词其实听起来就很抽象,英文翻译是“Context”,说实话在我一开始学习编程时,看到各种文档教程里面提及这个词时就会头大,这三个字每一个都认识,但是连起来就不明其所以然了;其实要理解这个也很简单,举个例子:咱们的玩家拥有一个 int
类型的变量,用来标识其当前剩余生命值,那么,根据面向对象的封装性原则,我需要将这个变量在 Player
类中私有化并对外提供 get/set 接口,那么这样就会出现另外一个问题:我如果需要在游戏的 HUD 面板中事实显示玩家的剩余生命值,就需要首先获取玩家对象,那么这个玩家对象又应该存放在何处呢?在这个问题中,生命值变量,或者是说玩家对象这个变量,就是咱们程序运行的“上下文”中的一部分,他作为程序运行所需要的环境,依赖关系可能彼此交叉,变量放在何处这个问题可能是导致大部分新手在编写代码时大量使用全局静态变量的原因之一。
而全局上下文则提供了一个相对优雅的实现,尤其是在多头文件中,我们不需要频繁使用 extern
来外部的全局变量,也不需要将全局变量的定义局限在编译期进行,而是可以在运行时动态增删,这里提供一种比较粗糙的全局上下文实现(是的,这里用到了模板类,是时候学点新东西啦):
1 | class GlobalContext |
当然,全局上下文本质同样是全局变量,与此相似的还有“黑板报”设计模式(Blackboard Design),感兴趣的同学可以自行了解。
可供优化细节
这部分内容是我在完工后反思项目代码时的一些总结,有些设计虽然在开发时就已经料到但是碍于体量便没有进行实现,也有些是马后炮梦中惊坐起,不过此部分仍然是相对进阶的内容,感兴趣的同学可以草草扫一眼,新手同学们不要过度深究接下来的内容。
渲染矩形与物理矩形
为了简化实现,在大部分继承自 Block
的子类实现中,均只使用了一个 Rect
成员变量,在出现菱形继承的类(这是,如继承自 Block
类和 Player
类的 Enemy
类,只使用了 Block
命名空间下的 Rect
成员,同时作用于渲染和物理碰撞检定,在大部分情况下(如砖块等障碍物)是没有问题的,物体的碰撞矩形的边缘即渲染矩形的边缘,但是对于玩家此类物体来说,如果想要提升进行碰撞检定的手感,就需要缩小其碰撞矩形略窄于渲染矩形;这种细节的优化对于提升手感十分重要,让游戏表现与玩家的心理期望一致就是胜利!
游戏对象的动态查询
在现有的代码设计中,并没有对 GameObject
提供兄弟物体的查询方法,也就是说,类似马里奥头部撞击问号方块时生成金币的需求,就需要在 UnknownBrick
类初始化时让其持有对应的 Coin
对象,这样不但极不灵活,也对资源生命周期的设计造成了一定影响;对 Scene
类提供静态的在当前场景中查询指定 tag 或 id 的对象方法,或许为不错的设计。
渲染层级的实现
EasyX 就像一个大画板,如果想要控制物体显示的前后顺序,就需要控制物体渲染的先后顺序,这就导致如果只是单纯使用数组对场景中的物体进行存储,添加时的顺序便与遍历时的顺序强绑定,导致较难在运行时对整个场景的物体的前后层级关系进行调整;可以考虑为 Sprite
提供一个 Z 轴坐标,该坐标只对渲染时的顺序造成影响,并且在调用成员函数对 z 值修改后对整个排序空间设置 Dirty 从而触发重排,也是一种可以接受的思路。
四叉树场景管理
在游戏中我们只实现了玩家与场景的碰撞,而没有设置非玩家物体之间的碰撞,即便是这样,我们对玩家进行碰撞检定时也是进行了 O(n) 的遍历;如果我们可以通过四叉树优化玩家进行碰撞检测的区域,设置激活状态,从而避免对整个长廊场景的物体进行大量无意义的碰撞检测,也是提升程序性能的一步。
总结
不得不说,在不借助引擎,只使用 EasyX 这类图形接口的情况下,凭空搓一个还算说得过去的游戏出来,还是很有挑战的;
最后,还是希望大家能从我跳跃式的只言片语中有所收获,参与相关选题的同学可以顺利交付,也不枉费这洋洋洒洒万字长文~
项目工程源码及单文件发布版本下载链接:
链接:https://pan.baidu.com/s/1s8g53IqYACxvZvh8E8cyXw?pwd=k0i9
提取码:k0i9
面向猛新的小游戏制作 - EasyX 超级马里奥