《提瓦特幸存者》双人对战版游戏开发
在2023年11月,我开始在B站更新了提瓦特幸存者项目的视频教程,作为《从零开始的C++游戏开发》系列的第一个稍具游戏性的实战项目,代码内容和游戏玩法都极度轻量,只实现了残缺的类吸血鬼幸存者游戏的玩法,近期在相同的技术基础之上,复用进阶版游戏素材,扩展制作了本地双人对战的玩法,提供了更多游戏机制,本文介绍此项目的前因后果和开发思路。
灵感来源
其实在已有的视频教程内容之上做扩展的想法早就已经有所尝试了,在提瓦特幸存者项目阶段的视频更新完成后,我便制作了视频教程项目的进阶版,扩展了新的游戏角色,新增了血量和能量以及技能机制,并添加了更多种类的敌人,考虑到源码上架工房,依然是作为循序渐进教程中的一环,所以在代码编写和内容添加上还是感觉束手束脚的——毕竟无引擎手搓实现一套完整的吸血鬼幸存者玩法的游戏还是个不小的工程,所以进阶版项目更多地是先于下一个阶段的教程,分享一些常见游戏玩法设计的实现,譬如游戏中简单的多玩家选择实现思路,以及不同敌人类的抽象结构。
直到前些日子,群友在跟随教程实践的时候,整出了一个神奇的Bug:派蒙可以变身为野猪,这让我联想到了2023年GMTK的GameJam主题“REVERSE(反转)”,把常见的游戏玩法倒反天罡一下,基本上都会是一个让人眼前一亮的点子——那么,如果是提瓦特幸存者的玩法的话,该如何reverse呢?
首先想到的其实是地图中刷新的是周身旋转子弹的派蒙,玩家是一只野猪,可以在合适的时候猪突猛进秒杀派蒙敌人,但是这种玩法还是有些单调,与原版差别并不大,反转的奇妙并没有体现出来;然后便是具有自主行动逻辑的派蒙AI在地图中间替代原有的玩家角色,而玩法是释放各种怪物击败中间的派蒙,但是这种玩法的话AI行动逻辑太过单调,玩家作为上帝角色的操作也十分有限,很难通过挑战性带来足够的乐趣。
这时,前些日子制作的双人塔防游戏的经验给了我灵感,如果说双人塔防是协作的模式,那能提瓦特幸存者的反转版本,能否和植物明星大乱斗一样,将两位玩家的立场变成对抗呢?一位玩家是控制敌人刷新及其属性成长的敌人,另一位玩家则是经典的派蒙角色,躲避并击杀敌人,通过经验能量兑换更多随机的技能效果!
碰巧有天出远门给了我摸鱼的时间,便随身带着笔记本,用了一天左右的时间从头实现了这款双人对战的《提瓦特幸存者》。
玩法设计
首先明确的是两位玩家角色各自的玩法职能:派蒙玩家需要使用近战攻击或远程攻击对源源不断出现的敌人造成伤害,击败敌人后可以获得不同数值的能量奖励,能量蓄满后可以挑选获得随机出现的技能效果;上帝玩家则需要控制敌人的刷新机制,使用数量有限的金币选择升级不同类型的敌人属性或是直接将他们生成在场景中,敌人对派蒙造成伤害后可以获得金币奖励。
核心玩法确定后,随后便是开始在此基础上查漏补缺打补丁了,美其名曰“完善玩法”——其实很容易可以发现,如果仅有这些规则存在的话,上帝玩家会太过羸弱,但凡派蒙玩家会一点走位和拉扯,那么初始属性刷新出的敌人便无法对派蒙造成伤害,那么自身的金币费用就无法得到增长,出现了死局。自然,我们便需要为上帝玩家增加一个金币随着时间成长的机制,即便刷新出的敌人暂时无法对派蒙造成伤害,那也可以借助时间的力量,对敌人进行属性升级,再投放到战场发挥更大作用。
但是,新增了这种机制之后,又会出现一种新的“不平衡”——如果上帝玩家一直积攒金币,在一段时间后一股脑刷新大量敌人,那派蒙玩家必然会被集火围剿难以逃脱和拉扯,于是乎“面多了加水,水多了加面”,我们便给上帝玩家的金币增长增加一个上限,金币到达上限后便不再增长,需要上帝通过释放怪物或升级属性消耗金币来避免溢出的损失。
接下来考虑的就是派蒙玩家的成长机制了,游戏中内置了十余种技能效果,一类是数值增长,譬如移速的提升或攻击伤害的提升,另一类是属性增长,譬如吸血、秒杀或者穿透等效果,派蒙玩家每次能量蓄满后都会随机刷新三种不同的技能供玩家挑选。这样的设计看似完美,其实还会出现特殊情况,由于属性技能的成长是标记性质的,不会因为多次选择而累加,那么如果某次刷新出的技能都是之前已经加点过的属性,那么本次能量攒满的奖励便没有了意义,或是在对局的后期,自身数值增长的速度已经无法应对此时敌人成长的速度,数值技能选取的意义并不大,那么我们便需要引入另外新的游戏内容,来给派蒙玩家更多的选择。
于是,我便在地图中心设计了一个激光防御塔,派蒙玩家在自身能量攒满后,可以选择激活一段时间的防御塔而不是去升级技能,防御塔在激活期间会持续攻击场上的敌人,且DPS较高,更多地是在游戏后期为派蒙玩家提供一个清场的可能,为自身恢复或发展创造机会,就像很多防守游戏提供的全图轰炸一样。
这样看起来似乎足够完美了,但是,在实际测试过程中,我们却发现了全新的玩法漏洞:如果上帝玩家一直将自动增长的金币用于升级敌人属性,在一段时间后一股脑刷新出大量高等级高属性的敌人,那么派蒙玩家便难以在零成长的情况下击杀这些敌人获得奖励,从而被群殴致死。那么,就需要打上新的补丁,来确保上帝玩家不刷新敌人导致派蒙玩家无法在游戏初期获得成长。
一个两全其美的点子便诞生了:在游戏对局过程中,派蒙可以在防御塔水晶旁,原地罚站蓄力获得能量奖励,这既可以逼迫上帝玩家不断刷新怪物从而驱赶派蒙玩家被迫移动,为派蒙玩家创造杀怪升级的机会,同时,也可以为操作较好的玩家提供了更高的游戏上限,他们可以在整局游戏中,通过调整自身移动轨迹,甩开周围的怪物,来到地图中间获取额外的能量奖励,这样,也恰好创造了新的加点策略,在派蒙玩家初期选择数值增长的技能时,可以优先选取提升自身速度而不是攻击力,从而更好地走位和风筝,从水晶中获取能量,进而选择更多的属性成长。
当然,对于上帝玩家来说,如果不同类型的怪物仅是数值上的差异,那便很难有什么策略可言了。所以,在提供的三种怪物中:蜜蜂移速较快,在对玩家造成伤害后可以造成一段时间的致盲效果,让玩家视野受限无法很好地判断场上怪物情况,充当突进的角色;野猪的数值则较为平衡,移速略慢但是血量也会更多,主打一个量大管饱;而蜗牛则是较为恶心的存在,虽然移速慢一些,但它初始生命值较高,数量多起来较难获得击杀后奖励,滞场时间较长,最重要的是,在死亡后蜗牛会小范围爆炸,从而逼迫玩家避免使用近战子弹来击杀蜗牛。
而游戏的胜负取决于游戏结束后派蒙玩家是否存活,上帝玩家如果在游戏结束前便把派蒙玩家击杀便可以获得胜利,反之派蒙玩家坚持到了最后,则派蒙玩家获胜。为了让游戏的进程更加刺激,我们把整个对局根据时间划分为了三个阶段,随着游戏进行,上帝玩家每秒增加的金币数会越来越多,金币上限也在逐渐提高。这样使得上帝玩家有更多机会根据玩家的技能选择方向升级或释放不同的怪物,也让最佳进攻策略变为“持续升级,波次释放”。
这样,双方玩家紧张刺激的对战策略便应运而生了。
技术细节
在底层库支持上,没什么好说的,依然是EasyX,时至今日教程系列做了不少内容了,在已经有代码积累的情况下,拿来主义是最便捷的。更何况,这种游戏体量还远未达到EasyX的渲染性能瓶颈。当然,为了加速开发,这次试着将半成品的框架Boidmachine赶鸭子上架用了起来。
什么?你问什么是“机械造物”?那必然不会在启动时和你说“We carry the bow of light.”。在设计初期时,我希望把它当成一套低能渲染接口的RHI,在EasyX、Ege、SDL(Renderer),OpenGL之上作抽象层,但后面发现,消息系统、窗口系统和RenderPass在不同的后端上都有绑定较深的最佳实践,所以索性将它当做引擎的平台层进行了封装,可惜时至本项目编写时,只完成了EasyX和SDL后端的内核,前端编辑器还未来得及完工,所以只好化身人肉编码器来手写一些费时费力的、本应该由生成器进行处理的繁琐代码(这又何尝不是一种 Body-Machine。
对于统一2D精灵纹理的渲染接口,我十分喜欢SDL_RendererCopy的设计风格,所以,在EasyX上,简化后的通用精灵纹理渲染接口源码就是这样:
1 | struct Rect |
使用源矩形和目标矩形描述的渲染方式,可以很好地解决EasyX自身的putimage接口在裁剪和拉伸透明图时的局限性,当然,这种硬边缘的拉伸仅限于像素风格的游戏,什么?你说你的游戏不是像素风格?那就在加载时通过loadimage在加载时指定拉伸尺寸吧,或许线性过滤导致的“糊一点”的风格也会有人比较钟情。
在场景中游戏对象的渲染上,使用了十分经典的 Y-sort 设计,在很多2D游戏中,都使用了游戏对象定位锚点的Y坐标来决定其渲染次序,这样便可以成本低廉地模拟出伪3D的遮挡效果。如下图所示,红点为防御塔和玩家的排序定位锚点,在计算时其实只是使用了他们各自的AABB包围盒底边的Y坐标。
对应到代码实现上,简化后的示例可以这样表示:
1 | std::vector<GameObject*> y_sort_list; |
但是,对于上图中防御塔这样底部体积较大的物体,一般的思路会在基座处添加一个矩形的碰撞箱,从而让玩家无法直接从防御塔前方竖直向上移动到防御塔后方,毫无碰撞的自由移动配合Y-sort会让防御塔显得像一个因为渲染Bug导致的虚影。
但是,短平快的开发节奏让我想在这个地方偷一下懒,那就直接替换游戏素材好了,让防御塔变成悬浮在半空中的水晶,这样就不会有底部碰撞的问题了,在画面表现上就变得自然了许多。
对于游戏中的激光渲染,其实是一种廉价且巧妙的实现思路,激光本质上是一条红色的直线,只是线的颜色和粗细会不断随着时间变化,这样便可以实现持续瞄准攻击的动态效果。那么,粗细的变化很简单,那么颜色的变化遵循何种规律呢?我们知道,RGB(255, 0, 0)
是纯正的红色,而RGB(255, 255, 255)
是纯白色,也就是说在保证红色分量不变的情况下,剩余的两个分量同比增长越大,合成的颜色就越接近白色,所以我们就只需要让这两个值在一定范围之间循环就好了。要实现循环渐变的效果,这里有一个使用游戏时间进行控制的优雅实现,这种方式在着色器的编写中十分常见:
我们知道,正弦函数sin
可以将传入的任何值限定在[-1, 1]
之间,对其使用abs
取绝对值后,便可以将计算得到的结果限定在[0, 1]
之间,我们可以使用GetTickCount()
获取与游戏时间有关的自变量传入其中,就可以得到连续波动的值,简化后的代码就可以这样实现:
1 | // 对时间乘以系数以获取合适的变更速度 |
而水晶防御塔的索敌逻辑则是优先攻击距离自身最近的单位,这就意味着,在清屏策略上,水晶会优先将自身周围敌人消灭掉,从而创建安全区供派蒙玩家收集额外能量。简化后的代码可以这样实现:
1 | if (!tower.check_active()) |
当然,从上面的代码设计中也可以看出,虽然游戏中的水晶和激光看起来是一个整体,但是在程序层面它们却是两个对象,只有当is_show_laser
的值为真时我们才去绘制激光对象,这是因为当场上没有敌人时,即便水晶防御塔处于激活状态中,我们也不应该处理激光对象的渲染。除此之外,激光效果的渲染和数据同样是两个维度的事情,从程序角度讲,当我们找到了“目标敌人”,便削减它的血量,同时让激光照射在这个怪物身上,这与直觉上的激光伤害还是有所偏差的。
那么,水晶防御塔的动画思路也是如此,使用对时间缩放后的正弦值获取竖直方向的位置偏移,就可以很轻松地实现水晶的连贯浮动了,对应地,水晶底部的阴影也应该随之进行放缩,在前述的通用渲染接口上,固定其渲染的中心位置坐标,调整目标矩形的尺寸即可。
而对于派蒙受到攻击后的视野受限效果,虽然我们可以使用屏幕缓冲区的后处理思路将视野外的像素降低亮度,但是软渲染光照这种费力不讨好的事情,完全可以借助Ps在素材层面处理掉。所以,当游戏中派蒙玩家进入致盲状态后,游戏便会根据玩家位置覆盖一张中心透明的阴影图片,渲染次序在Y-sort对象之后,且在GUI元素之前;除此之外,如果只是简单地覆盖整张素材图片,还需要注意这张阴影图片的尺寸应该至少为2倍的视口宽高,来应对派蒙玩家移动到地图边界的情况。
什么?你说游戏的数值不平衡?
—— 菜,就多练!
当然,为了避免测试场次太少导致游戏数据不够平衡,我为程序添加了可配置的数据文件,在 config.ini 中,玩家可以在对抗尝试中根据双方的操作水平选择适合自己的游戏难度(
虽然Boidmachine实现了自己的存档配置系统,但是考虑到易读性,最终还是选取了ini这种配置数据格式,并借助 inih 开源库实现了解析操作。
游戏下载
快去折磨你的朋友吧!
什么?你说没有朋友……
夸克网盘:
链接:https://pan.quark.cn/s/b3ab1c800f0e
提取码:vBgJ
百度云盘:
链接:https://pan.baidu.com/s/1ePk9rROaTV1l6v3j14g_2w?pwd=8848
提取码:8848
《提瓦特幸存者》双人对战版游戏开发