记使用EasyX编写的黄金矿工游戏
前言
大一C语言程序设计的课设 (其实是C++混用),使用EasyX图形库完成的黄金矿工游戏,在原版机制上加入了多人联机等创新点,在此回忆大致思路并记录一下。
最后我的作品获得了高分,本文不会放出实现代码,仅提供一些思路和方案,防止抄袭。
(其实还有一个原因是时间有点久了,我懒得回去详细翻代码,但是大致思路还是记得的)

素材来源
游戏中用到了一些贴图和音频,如果贴图都从网上找的话可能风格不统一,且不够清晰。
我选择了一种比较聪明的办法,直接从原版Flash格式的游戏中提取资源,具体来说是用JPEXS软件对swf进行反编译,得到各贴图的svg矢量图,和游戏音效原声。
这样还有一个好处就是,我也获得了一些元素的逐帧贴图 (如小猪的脚步移动动画),能让我的游戏动画效果更流畅。
当然也有一些原版没有的贴图,我是用AI绘图+PS自己处理制作的。
基础功能
首先复刻一下原版游戏的基础玩法。
使用EasyX绘制每一帧画面,一遍主循环更新一轮,同时用EasyX的peekmessage函数监听事件,在按键按下时根据vkcode判断是哪个键。
主循环内部每轮结尾要加入Sleep,否则CPU占用率会极高 (毕竟一直在工作啊,而且对肉眼来说区别不大),为了保证画面流畅性,几毫秒就够了。
玩家没有操作时,绳子是一直在限定角度范围内摆动的,我感觉匀速摆动没什么意思,于是就想把这个变成一个类似单摆的运动 (大概模拟出慢->快->慢的效果就行了,也没必要精确计算),设最低点时基准角度 $x=0$,左边角度为负,右边为正,我用了一个这样的函数来模拟角加速度效果:
$$\Delta x=a (1 - \frac{|x|}{b}), a, b > 0$$
由于是来回摆动,所以角度到头就取反绳子摆动方向,从右往左时采用 $-\Delta x$ 增量。
按下键盘"下"键之后,绳子不再是摆动状态,而是朝着当前角度匀速伸长,直至碰到实体或者画面边界为止。
绳子起点固定,伸长就是让终点每帧更新时加上一个方向向量。碰撞检测则简化为,绳子终点与实体中心点距离小于一个阈值的问题。
由于课程要求,实体对象使用链表管理。最开始做的时候是让矿物完全随机生成的。
抓到实体后将其标记为被抓取,之后更新时就让其坐标跟随钩子移动。一个细节是,抓到不同实体的收钩速度是不一样的 (比如说大石头就很笨重),所以我还设计了一个重量属性,可以根据重量换算对应的绳子速度。
绳子恢复原长后,将实体对象从链表中删除,并增加相应的分数。倒计时用时间差值算就可以了,结束之后根据当前钱数判断是成功还是失败。这样基础的游戏功能就差不多了。
扩展功能
钩子绘制
绳子末尾还有个钩子要绘制,且需要跟随轴旋转,要想贴合好,这里得精确设计一下,比如说我的轴就差不多在贴图水平中心点。
游戏暂停
通常来说游戏应该设置一个暂停功能,让画面静止,停止更新就可以了,前面说到倒计时是根据时间差值算的,所以暂停后恢复要补偿修正一下开始时间。
登录注册
既然是用EasyX,那么所有控件都得自己绘制 (没有现成的啊,只能自己造),考虑到游戏中可能多处用到控件,我就把TextBox、Button和Checkbox都手动实现,并封装成类了,比如说TextBox就是绘制一个矩形框,然后需要考虑文本的绘制、密码掩码模式、光标绘制和位置移动等。这个过程中我尽量让其接近QT、C#这种UI框架事件绑定的模式,这样用得顺手。
虽然只是一个课设作业,但是密码还是不要明文存的好,我实现一个AccountManager类,用djb2 hash算法给密码哈希了一下,存到本地的txt文件中,一行一个账号。

排行榜
既然有了登录注册,那么本地就有多个不同玩家,以最高通关关卡数作为第一关键字,钱数为第二关键字,排序一下就行。

界面切换
一个循环负责一个界面逻辑处理和绘制,界面切换就是从一个死循环跳到另一个死循环(
但是注意潜在的栈溢出风险,内层循环适时退出,尽量不要让循环加深。
生物
生物与矿物的区别就是生物会动,我设计了三种生物:
- 小猪 (鼹鼠):在一定水平范围内来回移动,如果碰到钻石会自动拾取并变成钻石猪,钻石猪的价格会叠加钻石的原价。
- 蝙蝠:在一定范围内随机水平和竖直飞行,数量较多。
- 妖精:只能往一个方向水平移动,速度较慢,会破坏沿途所有实体中心离自己一定范围内的物品。
为了方便把生物和矿物大部分属性 (如类别、坐标、价值等) 统一,我写了实体基类GameObject,让矿物和生物类继承它,各自实现update成员函数,方便外部调用进行每帧的更新。
像小猪是来回移动的,贴图不用单独准备反向的,直接把图像镜像一下就能复用。
由于动画帧素材的差异,小猪的贴图来自原版游戏动画就比较流畅,蝙蝠就只有我自己做的翅膀上下摆动的两帧贴图,帧贴图更新时间间隔也是不同的。
TNT爆炸
有一种特殊的"矿物"是TNT,钩子碰到它时会发生爆炸,此时绳子快速收缩,需要特殊控制速度,周围一定半径内的实体需要删除,最重要的是爆炸的视觉效果。
我采用的方法是,爆炸贴图逐渐放大 (即每一帧的贴图大小和时间有关),维持一段时间后消失,同时播放爆炸音效,实测效果还挺好的。考虑到场上可能有多个TNT同时爆炸,所以爆炸对象也是有多个的,再封装成类,每次更新同时清理掉过期的 (已经消失的) 爆炸对象,我用了一个滚动数组减少内存浪费。

音效管理
音效播放用的是mciSendString,由于直接写在主线程里会影响绘制,导致开始播放时画面卡顿一下,我还单独做了一个音频管理类AudioManager,在一个额外的线程中执行播放操作。
商店、道具、物品栏
为了丰富游戏玩法,增加了道具功能,道具可以通过商店购买。

左右键控制当前选中的道具,空格键使用。
也是受了MC的启发,我加入了"力量药水",使用后获得强壮效果,提高抓取速度。炸药是原版就有的,将当前抓取的物品炸掉以快速收钩,比如说抓到廉价的大石头就可以这样操作。
"魔法雪花"是我自认为一个比较华丽的道具,可以创造全屏冰封效果,背景画一个旋转的大雪花图案,冻结当前所有移动生物,便于抓取。这种晶莹剔透的冰块效果,实际上是由一个蓝色长方体贴图 (我用PS随手画的) 以一定透明度叠加上去的。至于下图的实体边界框,是我后面加的一个"调试模式"。

"时间胶囊"很好理解了,就是让游戏倒计时加长。
当然道具也不应该能一直连续使用,我还设计了一个禁用的CD时间。
关卡难度设计
矿物生成采用分层概率分布+固定数量平衡的设计模式,确保游戏的随机性与难度平衡性。
地下空间被划分为表层、中层、深层三个深度区域,为每种类型的矿物都分配了其在不同地层的出现可能权重。矿物分布特点大致为:
- 石头: 表层较多,产生干扰
- 小金块: 表层最多,随深度递减
- 中金块: 中层最多
- 大金块: 深层最多,体现"越深越有价值"
- 钻石: 仅在深层出现,稀有
- TNT: 中深层出现,增加策略性
为了尽量保证同一关卡的内容一致性,同一种难度的同一关卡各种矿物数量固定,而分布概率表也是固定的,从理论和实际测试来看,都达到了较好的游戏平衡性。
我还设计了三种难度模式,差异如下:
- 简单模式: 无TNT和危险生物,基础矿物较多
- 中等模式: TNT后期出现,部分危险生物
- 困难模式: 全类型矿物和生物,最具挑战性
为了防止矿物大量重叠,让矿物在随机情况下也能有较好的稀疏性,先生成大体积矿物,确保避免被小矿物占位,再生成小的矿物。同时采用一个渐进式搜索的放置策略,如果无法放置,逐步减小最小间距,并尝试扩大Y轴搜索范围确保能够放置。

存档实现
其实我一开始是想用JSON格式来保存数据的,后来发现其实可以直接用二进制存啊,我直接把类对象数据写入文件,这样存档文件体积小,读取时还不会有浮点精度损失,又方便又好。
做这一部分主要就是要考虑哪些数据要存 (如实体数据、玩家状态、钩子位置),这样才能保证存档读取后与原来进度的一致性。
开始游戏时,如果检测到本地已有保存的存档,就提示选择是继续上次进度,还是新一局游戏。
其他
其实不止于此,我还加入了彩蛋功能和一些细节效果,总体能算是一个功能完整的小游戏了,受限于课程时间,后面就没有接着做了。
绘制
为了让游戏画面体验更好,支持抗锯齿、旋转角度、透明度等,我实际用GDI封装了一些我自定义的绘图函数。
还有一个重点是最好用上EasyX的批量绘图功能BatchDraw,这样每绘制一帧才整体刷新一下,避免出现闪烁 (由于不同对象绘制顺序的先后导致,可能出现短暂覆盖)。
资源打包
一般用EasyX都是从本地读取贴图,但是生成exe之后还要附带一堆资源文件就很不优雅了,有办法可以把资源全部打包进exe文件。
Visual studio C++向导可以自动为项目生成.rc文件,菜单栏->项目->添加资源。
resource.h中有自动生成的针对每个资源的编号,使用MAKEINTRESOURCE就可以通过EasyX的loadimage加载图片,也就是运行时直接从内存加载,而非再从本地文件中加载贴图。
由于我音频播放用的是mciSendString,不支持直接从内存中加载,必须要从一个文件路径载入,所以一种解决方案是运行时把音频释放到系统的临时文件目录,这样还是保证了分发release的时候只有一个单独的exe文件,打开即用。
多人联机
在此之前我也在网上找了一些灵感,发现还有双人版,于是我就想尝试做一个多人版,可以通过网络进行联机的,这样在限定时间内多人进行竞赛,最后按总积分进行排行。
虽说是多人联机,但是由于游戏窗口空间的限制,我不能放一排矿工 (人太多了怕是这游戏也没法玩了),于是我就限制了最多三人。另外为了防止矿物很快被抓完,我还在多人模式下加入了矿物补充生成机制,每间隔一段时间就产生新的矿物。
这个时候就有个问题了,游戏的更新逻辑在哪里处理呢?一种可能的方案是每个玩家的客户端独立处理所有逻辑,并将该玩家的交互操作和数据广播给其他客户端。但是这就有个问题,由于网络延迟等因素,各客户端有可能不能完全保持同步,想象一下如果两个玩家几乎同时抓取到一个金块,那么到底算谁的呢?
所以一种合适的方法应该是所有逻辑仅由一个服务端统一处理,这个服务端接收所有客户端玩家的交互逻辑,并将需要渲染的内容下发给客户端。这样不仅保证了画面的绝对统一,还防止了客户端作弊上报错误数据。
考虑类似 Minecraft 服务器那样,应该把处理逻辑的服务端和客户端分开写。但我为了省事 (最开始整个框架也没考虑到要支持多人的) 就直接把服务端的逻辑嵌入到开服的主客户端中 (也就是一个人的客户端处理所有人的更新逻辑),并设计了邀请码系统,由一个人开服,其他人通过邀请码加入,把服务端IP通过36进制编码,字母不区分大小写,这样邀请码就能简短一些。
具体实现时,我投入精力做了大量改造,比如说增加一个Role类,用来表示不同玩家,其通过指针关联到对应的钩子对象。另外现在钩子对象有多个,实体不仅要标记被抓取状态,还应该记录对应的钩子对象指针。

我用可靠的TCP来传输,刚开始尝试的时候,我让服务端向客户端逐条发送绘制指令 (比如说在哪绘制直线、矩形或者贴图),但是由于网络延迟和接收顺序的不可控,这样会导致客户端画面混乱。最后我解决的想法是把一帧画面打包成一条指令广播出去,这样由客户端解包后分条绘制,再用FlushBatchDraw刷新一次,这样画面就稳定了,成功达成了联机的效果。
总结
这是我第一个真正意义上做出的完整游戏 (之前主要还是开发窗体软件和Web)。花费大概一个半月时间 (期间闲暇时间就写一点),看着功能一点一点完善,修修补补,也挺有成就感的。
虽然EasyX不如现代的图形库 (比如说OpenGL),其实不适合用来做生产,只能CPU渲染,但是这确实是极大地锻炼了我写项目的工程能力。