从0开始做个Brotato(2):避免开发坑点

发表于2023-03-06
评论0 6.6k浏览

01 项目开发中常见的坑点

 

在游戏开发过程中通常会存在一些刚开始产生游戏想法时候考虑不周的坑,不同的游戏会有各自不同的坑,其中一些甚至可能导致游戏开发不出来或者始终只是个demo(到后期简直无法维护)。

 

如果我们光是心血来潮有个想法就急于动手设计甚至开发游戏,那么难免会掉进这些坑里,因此在真正开始动手开发前,我们需要在设计层,做一些设计来避免这些坑。这并不是说因为有坑的存在,我们就必须牺牲掉好的设计,而是好的设计,通常会考虑周全坑的情况——结合开发团队的实力和游戏玩法,来找到一个最佳的平衡点,把游戏设计的更合适。

 

那么通常来说,粗略的想法会有一些什么样的坑,而通常又是怎么通过设计解决的呢?这些坑主要包括且不限于:

 

NO.1可能存在的性能坑和后期优化坑

 

有一些玩法可能乍一想是非常爽快的,比如海量的怪物潮水般的涌来,然后被玩家使用大范围的AoE技能一波一波的消灭掉,这种“肉酱游戏”的快感相信玩游戏的人都不陌生,但是如果我们只考虑海量的怪物,而不进一步加以限制,那么就会在项目后期给游戏性能和优化带来天坑。经典的“万人国战”说的就是这个——加入一屏幕有一万个单位在各做各的事情,那么游戏也许会卡到无法运作,即使是比较高配的电脑,也可能会出现FPS低于24的情况,而“欺骗人眼”的FPS数是24——即当每秒渲染24帧或更高的时候,人眼会认为游戏是“流畅”的,否则就会有“卡顿感”。

 

当我们真的需要设计海量的角色、子弹等元素出现在同一个小区域内的时候,先不考虑后期我们可以通过隐藏一些不显眼单位(很多游戏使用这样的策略进行“优化”)比如不活跃的角色,不重要的特效等来实现优化。但是在设计层,其实也是有很多细节可以设计,来使得游戏保持“海量单位”的感受,比如约束怪物最大的数量,只有当怪物数量小于这个数量的时候才会刷新等。

 

除了刷新规则的约束以及渲染时候根据规则“裁剪”掉“不重要的单位”,通常还可以采用2D卡通风格的美术设定。当我们使用3D写实美术风格的时候,玩家眼里会很在意很多细节,衣服布料等稍有不真实就可能遭到嫌弃,但是如果我们采用2D卡通风格,人们不仅不会去看细节的问题,甚至会接受一些“简陋”的风格。也正是因此,所以2D卡通风格的美术资源,在项目后期更容易进行优化,来大幅度提高游戏性能的同时,不在美术上丢“细节分”。

 

图片

左是1997年FF7原版克劳德,尽管简陋但是因为采用了Q版风格很多问题就被包容了,右侧是2020年FF7Remake的克劳德,做到这样得费多大成本,不言而喻

 

NO.2过于放开的设计导致性质被淡化的坑

 

这是一个设计的坑,通常来说很多时候我们看一个玩法中的元素,可能产生出很多很多的脑洞,如果不对这些脑洞加以限制,就会开发出很多很多乍一看眼前一亮很有意思,但是经不起时间考验的“创意”,而当游戏中充满了这样的“创意”的时候,非但不会让游戏变得更有意思,还会因此导致游戏的一些性质惨遭淡化,以至于原本有策略的地方变得没有策略而损失乐趣了。

 

最常见也是最典型的,就是在Moba游戏中设计英雄。如果不加以限制,我们可以想出很多“新鲜玩意儿”,当然这很自然,因为随着项目的推进,我们不得不新增英雄投放给玩家,以保持游戏的内容更新。每个英雄都应该有新的玩法,甚至有新的机制,这样玩家才会感觉到新英雄是有趣的,并且愿意花钱去购买他们,但是如果我们肆无忌惮的去“创新”,就会忽略(或者说因为也没什么别的想法了,不得不去“突破”)游戏原有的调性,来设计出新的玩意儿。举个例子设计一个蓄力型英雄,技能按住时间越长丢的越远——这本身就已经脱离了RTS(dotalike moba游戏的模板)的范畴了,只是恰好看起来并不是那么的不和谐而已。

 

图片

lol中的蓄力型英雄等,与原本RTS调性不符的英雄

 

加入新的机制和玩法,本身并不是坏事,但是加的过分了反而影响游戏性,因此我们在设计游戏的初期,一定要定好“设计范式”,类似于编程中的Design Patterns——即在我们游戏中,哪些性质是允许的,哪些是不允许的。这些范式约束的是性质,而非确切的设计,例如我们可以约束“所有对法力条产生变化的技能都是不允许的”,但是我们不能说游戏中只有什么,让策划从中pick出来组合就行了。

 

NO.3数据表无法维护的深坑

 

数据表是一个无法忽视的存在,之所以我们需要数据表,是因为有一些数据内容只有人类的思维可以填写,他们没有规律,无法用公式表达,也没有任何理性,完全是感性的,比如某个怪物用什么样的外观等,这些都是必须由策划去配表的。当设计数据表的时候,就会发生一些数据内容能让程序运行起来毫无问题,但是这些数据结构本身和数据量在一起是地球人无法填写的。因此我们只是程序开发层逃避了“无法实现”的问题是不够的,但是人填写不了的数据表一样是“无法实现”的,在过去的20年中,可以说有绝大多数在研发阶段胎死腹中的项目,都存在数据表没有策划能填写的问题。

 

所以在设计数据结构的时候,我们还必须优先考虑一些数据表的内容量。如果一些数据可以用算法去生成,比如升级所需经验值,只需要一个公式——f(目标等级)即可实现的,就不应该需要人类去填表。这当中并不存在“填表比公式更可控”的说法,首先,对于这个业务来说本身需要的就是一个函数返回一个数据,填表本身只是提供一个“写分段函数的环境”;其次,最差最差的分段函数(数学上的分段函数正是编程中if else的起源)总是能实现填表能实现的东西的。

 

02 Brotato中需要预设定来避免的坑

 

在brotato这个游戏中,也有很多乍一想想不到,但是真的到后期就可能成为深坑的因素,brotato的设计师也在设计和开发的时候注意到了他们,所以做出了很多细节设计,来规避这些坑点。接着就让我们详细看看到底有些什么坑,brotato的作者又是如何规避的,如果我们来做的话又需要如何设计去规避。

 

NO.1同时存在的怪物数量坑

 

我们不难发现,这个游戏会不断地刷新怪物,而怪物中也有会发射子弹的。除了怪物和子弹这些“活着”的东西,包括被击败的怪物掉落的钱币,也会不断地增加。假如玩家故意不去击败怪物,或者“不小心”击败了个别怪物,留下了大多怪物,而被击败的怪物掉落的钱币玩家又故意不去拾取,就会致使有海量的单位存在,这究竟会造成什么样的坑呢?我们又如何去规避?

 

ZY4PGBS5tgjsAsvXYWly.gif

brotato中会出现同时有海量怪物和子弹

 

海量怪物、子弹和掉落物是如何导致性能问题的

 

导致海量怪物、子弹和掉落物产生性能问题的关键点是3个——碰撞运算、寻路和渲染。

 

在brotato中,子弹和单位之间是存在碰撞检测的,因此子弹才能击败玩家或者怪物,玩家角色与掉落物之间也存在碰撞检测,因此玩家才能拾取掉落物。当我们做碰撞检测的时候,就会要用到循环,每一帧我们都需要循环遍历角色和子弹的位置关系、玩家角色和掉落物之间的碰撞关系,当循环的列表变大的时候,自然开销就会变大——假如我们有1000个角色和1000个子弹存在,那么同一帧,我们至少要进行一个1000*1000=1000000次的循环来遍历碰撞关系。

 

除了直接的碰撞判断有开销之外,AI和寻路也会有较大的开销。在brotato中怪物之间是不会重叠的,也就是由怪物之间互相让路产生出了一种“碰撞和挤开”的“假象”——即玩家看起来是“碰撞”,但实际上技术实现手段完全不是“碰撞”,但是和“碰撞”一样,这就需要怪物之间互相进行遍历,来确认其他怪物的位置,以至于不会挤到一起去。这样每帧光怪物之间的遍历次数就是n*(n-1)次(n是怪物数量),假如有1000个怪物就要999000次遍历,开销也不小。

 

DHviQjDCLBjNyuNHCf9s.gif

brotato中海量怪物追着玩家但是没有重叠的画面

 

最后就是渲染问题,尽管实际游戏中所有的怪物、子弹、掉落物都同屏的可能性非常小,但是作为游戏(或者说是软件)的开发者,我们从编程的角度就不能忽视问题存在的可能性,即便对于人类来说十分“极端”的情况,对于计算机科学来说,也是必须要注意的。当同屏出现海量单位,并且每个单位在播放自己的动画的情况下,会对FPS(渲染帧率,Frame per Second)带来巨大的杀伤力。

 

了解了这些可能存在的坑之后,我们就看brotato在这些坑上是如何作出解决策略的。当然在这里,brotato也做了一个很聪明的设计——就是约束了同时出现的怪物数量上限为100,当怪物数量达到100之后,部分符合规则的老的怪物会自己消亡,给新的怪物让道,以确保怪物最大的数量为100,这样一来也就限制了遍历次数,从上面举例的1000次变成了100次,大幅度降低了遍历次数是很好的解决方案,但是光这个还不够,还需要将便利执行的内容也进行一番优化。
 

圆形碰撞带来的优势

 

通常没有游戏开发经验、并且躺在引擎功能上的小伙伴们会觉得,碰撞并不是什么大问题,只要使用碰撞组件就行了。但是实际上碰撞组件也是人写出来的,很多碰撞组件为了符合“模型碰撞”的需求,实际上用的是多边形碰撞——将所有多边形根据旋转得到这一帧在世界坐标系里的逻辑多边形(旋转后的多边形)然后去和其他多边形碰撞。多边形碰撞的数学算法这里就不复述了,如果采用多边形碰撞也并不是没有优化的余地,比如我们可以首先用外接AABB(axis-aligned bounding box,3d中为外接长方体)是否有碰撞(AABB碰撞这个概念可以通过百度或者google搜索了解,大意是“世界中”所有的矩形的每条边都是平行或者垂直于横轴或者纵轴的,然后进行碰撞判断,这样算起来只需要比大小),但是这样的碰撞仅仅适合于“静态的世界”。

 

image-3.png

AABB碰撞的概念

 

在brotato中,或者说即便不是brotato,只要有快速移动的物体(通常是子弹,因为子弹的飞行速度很快)需要做碰撞的游戏中,我们就无法采用这种每一帧所有物体之间的坐标直接进行碰撞检测的算法,因为这会导致非常快的物体发生穿透现象:

图片

两帧子弹的位置穿透了较小的碰撞物

 

因此我们需要使用“射线检测”来做碰撞判断,这时候如果全都采用圆形的碰撞体,就会带来巨大的优势:

 

首先“静态的”圆形之间碰撞检测算法本身就很快,我们只要判断2个圆形之间的距离是否小于等于两个圆形的半径之和,就可以得出是否碰撞了。在这个计算中,我们首先可以判断2个圆心之间的x距离+y距离之和,是否小于等于两个圆的半径之和,如果是,那么他们就直接碰撞了,否则我们则利用勾股定律可以得出,若是x距离平方+y距离平方小于等于两个半径之和的平方,也是碰撞了,逃避根号运算(根号要比平方开销更大)。

 

其次则是在“射线”运算中,由于Brotato中只需要子弹是射线,角色移动可以忽略不计,所以如果子弹和角色都是圆形,就是一个胶囊体和圆形的运算问题:

 

图片

子弹射线是上一帧和这一帧子弹坐标2个圆形成的一个胶囊体

 

而我们可以利用数学性质,将子弹的半径,加到世界上所有的角色(或者说子弹可以碰撞的物体)圆形半径上,就把问题简化为了线段和圆的碰撞问题了:

image-4-1024x590.png

利用数学性质,我们把胶囊体的圆的半径加给其他圆的对象,他们的碰撞结果是一样的

 

这样一来算法就更加简单了(这个算法的代码在本文中不进行讲解,在今后的代码篇中会有详细解说,当然这仅仅只是一个初中到高中的数学问题,相信不用讲解各位也能知道具体怎么搞)。


2D卡通的优势

 

2D卡通美术可以逃避很多精细动画问题,甚至只用scale等transform的内容就可以做到很多表现,即使表现的不够好,也可以被认为是风格。因为需要更少的美术资源去做动画等,所以batch也更好分,并且美术工作量也被海量的降低了,同时程序性能优化坑也被解决,还完全不影响游戏性。

 

image-5.png

brotato的几种怪物

 

在brotato中,美术就采用了2D卡通的风格,因此角色动画只需要scale和改变着色进行,因此大多怪物都只需要一帧的美术资源,这样怪物就可以尽可能多的在一张图集上——“图集”并不是一个新的概念,只是一个新的名称,传统称呼为Batchnode,在2D渲染中,每一帧每切换一次图集会产生一次drawcall,所以将需要绘制在屏幕上的元素尽可能的放在一个图集上,并且在一帧的渲染中尽可能避免图集之间的切换,就会大幅度降低drawcall,提高性能。

 

移动与挤开的坑

 

在brotato中角色之间是存在“碰撞”的,我们上面提到了圆形的碰撞的优势,可以很大程度的降低角色之间“挤开”效果的开销。我们只需要将其他角色的所在圆形当做场地上不可移动的范围,也就是寻路中常说的“阻挡”,就可以吃到圆形碰撞的优势。

 

而在brotato中,寻路本身被设计成一件非常简单的事情,他不像很多RPG和ARPG游戏需要做AStar寻路,他的角色只是每一帧单纯的根据规则得到一个要移动的方向,然后判断方向上是否会被其他角色阻挡,如果不会就移动——这实际上和子弹碰撞算法是几乎一样的。

 

zf3ObK7ceky7NJYFskin.gif

brotato中一大片角色跟随玩家但不会重叠配合他的寻路做法会导致冲锋会其他怪物被阻挡

 

这样的设计虽然会让怪物看起来十分弱智,但是由于是2D卡通的美术风格,以及“肉酱”式海量击杀怪物的乐趣作为游戏的特性,反而使得这样“弱智”的怪物变得好接受了——正是这样互相呼应一气呵成的设计,不仅没有辜负游戏性,还解决了程序的坑点,才可以被称为好的设计。

 

NO.2“效果”之间BUILD的坑

 

brotato的武器和道具有着多种多样的效果,从实现上来说,从子弹到道具,都可以设计出五花八门的玩法来。但是越有设计空间,就越是难设计,如果一旦效果设计过度,以至于脱离了brotato的框架和调性,非但不会增加游戏性,反而会使得游戏变得不好玩——因为当使用的性质过多的时候,玩家反而不容易找到自己想玩的build,在很多DBG卡牌游戏中就会犯类似的错误,过多的卡牌、过多的效果导致卡牌之间反而更不容易组合出有趣的build。

 

在brotato,或者说几乎所有的允许开放设计效果的游戏中(比如moba的英雄设计),我们要如何去做好效果之间的设计,以尽可能增加具体效果的同时不丢失build的可能性呢?


归类性质而非归类效果

 

我们要归类的是性质,而不是具体的效果,这通常是很多团队约束策划设计的一个错误方式——典型的是技能设计中,很多团队通常会由主策甚至是主程约定好一些效果,比如“造成持续性伤害”,“产生爆炸效果”等,然后做设计的策划只能从这些主策或主程挑选好的效果之中挑选并且组合,比如设计出“产生爆炸效果,爆炸命中的目标受到伤害并且收到一个持续性伤害效果,在5秒内每秒损失20点生命值”,但是如果你想做“受到伤害的目标在之后的5秒内每秒产生一次爆炸,对范围内的友军造成20点伤害”就不允许,因为主策或者主程没有选择这个效果,你就无法组合。

 

这样约束的方式美其名曰“效果是可以控制的,不至于收不住”,但通常这不光是程序不会实现技能效果,也包括策划对于设计没有任何认知——因为这个约束中,设计师只能从已经有的具体效果中挑选并组合,这样已经没有任何创作空间了,并且对于玩家来说,效果也是重复无味的,无非就是比数字大小(比如常能听到玩家们说的“DPS高低”)。

 

image-6.png

Brotato中即使效果非常独特的道具,也是利用了游戏的性质,比如萌萌猴,利用的是“拾取材料”这个触发点,配合“恢复生命”的这个性质在游戏中是被允许的,就有了这样的设计,并非是主策规定好了就有一个“拾取材料时概率回血”的效果。

 

真正合适的做法,是归纳出游戏中允许使用的事件触发点,比如在brotato中,就有子弹命中时(怪物碰撞到玩家也可以理解为子弹命中时),怪物死亡时这些触发点,在每一个触发点,我们都约束了允许使用的性质以及明令禁止的性质,比如“允许命中时附带持续性伤害”,“允许持续性伤害产生范围效果”,“允许范围效果产生持续性伤害”,“禁止命中时对武器等级进行修改”等规范,那么当设计师设计的时候,利用这些性质,就能组合出“命中时产生一个持续性伤害(火焰伤害),这个持续性伤害每次生效时会产生一个aoe,这个aoe寻找附近一名距离最近的队友,并给他添加一个一样的持续性伤害”,也就有了火焰的“传染”效果了,这并不是说主程或者主策约定了“允许做传染效果”,而是设计师基于性质作出了设计,但是如果设计师设计了“持续性伤害每次生效都会导致武器等级降低1级”这就是不允许的,因为触碰到了明令禁止的性质。

 

归类性质而非归类具体效果,然后将这些性质开放给设计师,或者约束禁止某些设计,这才是合理的设计范式,正如软件开发中编程也需要设计范式(design patterns)一样,在设计中,我们也要先做好设计范式(这也是身为主策的主要工作之一),然后再展开进一步设计,当我们在设计中发现新的问题或者细节的时候,也需要进一步对设计范式进行补充,比如我们发现有人的设计为brotato引入了子弹数量的概念,这个概念和游戏调性不符合——“肉酱游戏”本身讲究的是“清场”的爽快感,而弹药限制则是在约束“清场”的可能性,反其道行之,所以不符合,因此我们要明令禁止,于是在设计范式中就追加了“不允许设计子弹数量”的条目。

 

NO.3刷怪算法坑

 

刷怪看起来是一件容易被忽略的事情,包括我们参考Brotato的时候,可能根本就不会去深入想这些怪是按照一个什么样的规则在每一关刷出来的,或者说不以为然,不觉得是一件事情。但是当问题被具体到“下一个怪刷什么刷在哪儿为什么”的问题的时候,这个坑就被提上了议事日程。

 

刷怪表可能无法填写

 

当我们粗看Brotato的刷怪的时候,会觉得他是完全随机刷怪的,但是仔细看,又会发现,在不同的难度的每一关,他的怪其实是有规则的,并且出现的顺序都是有规则的,无论是正常刷怪,还是堆到怪物+100%(这是一个属性属性),只要怪物数量+x%这个x相等,他刷怪都是必然相等的,除了大树和背着宝箱的奖励怪。

那么这里我们就会想到,是不是让策划去配置每一关每一波怪怎么刷呢?因为理论上只要让策划去配置,那么一波一波下来,肯定就能符合Brotato的设计了——保证每一波出来的怪都是按照策划意图的。我们再随便一想,因为每一关的时间长度是确定的,而刷怪的波数又是和时间点挂钩的,因此完全可以让策划去配置波数。

但是我们深入一想,这要怎么配置才能一样呢?

 

首先这个配置要解决的几个问题——在第几波,大约什么位置,刷一个什么样的怪,这个怪的出现条件是“怪物+x%”的x>=多少的时候。当有这些数据的时候,我们就能做到像Brotato一样精确地控制刷怪了。但是问题来了,之所以Brotato的设计中会有同场100个怪物上限的设定,是因为关卡中一定会出现刷怪超过100的情况,那么这样一来,每一关策划都可能要手工配置100条以上的刷怪数据,游戏有6个难度,每个难度20关,就是12000条数据,这看起来似乎是“策划辛苦点”就能完成的工作,但实际上即便完成了,要进行调整(比如某一条不满意的时候)也是超乎人类工作能力的事情,且不说让人填写还会出错。

 

到这里,我们就应该进一步思考,我们是不是可以通过算法来生成,只是让策划去配置一些参数,然后就能轻易地实现复杂的刷怪?回答是肯定的——当我们在设计数据表的时候,发现有海量数据要填写的时候,就得观察,数据表里是不是有一些什么原本应该是计算公式(或者说函数)来表的东西我们却让人去人工枚举了。

 

因此我们要把思维带回到正确的轨道上去,这个思考过程是这样的:

 

首先,为什么我们需要刷怪数据?是因为我们要刷怪。

 

那么,我们要的刷怪数据的本质是什么?就是:要刷的怪物(数组)=f(难度,关卡,波数)这样一个函数,换而言之,我们需要的只是一个List<刷怪>,也就是应变量就可以了,而策划需要的是难度、关卡、波数这3个运行时数据作为自变量,如果策划能写这个函数就让他写就完事儿了。

 

然后,我们进一步分析,“刷怪”这个数据到底是怎样的。刷怪中含有的信息,无非是什么怪,在哪儿这两个信息,而在哪儿是一个运行时数据,他所需要的无非是一个规则和规则的参数,既然是参数难以填写,那么我们就把思路调整一下——如果我们把刷怪和位置分开,比如我们先预设了一波怪大约要刷多少个,以及他们的坐标规则,即把场景分为m*n个block,在每个block中均匀的去刷怪,比如这一波要刷18个怪,我们把场景划分为3*3=9个block,其中我们按照规则挑选3个block,这3个block分别从距离玩家所在block1、2、3个曼哈顿距离的block中随机挑选,然后每个block刷6个怪,这样坐标点的问题就解决了。

 

*曼哈顿距离:两点在南北方向上的距离加上在东西方向上的距离,即d(i,j)=|x-x|+|y-y|

 

image-7.png

当我们有了刷怪坐标的时候,接下来我们就需要知道刷什么怪了,这个完全可以根据怪物的种类,Brotato中一共有20多种怪物,特殊的比如boss、大树和背着宝箱的不在此列,我们为2多种怪物在每个难度每个关卡每一波设置一个权重,这个权重完全可以是f(难度,关卡,波数)算出来的,根据这个权重,我们再设置一个算法,来生成他们即可(这个具体的算法将在今后的代码篇内详细讲解)。

 

于是,我们重新整理思路,该如何设计这个表呢?我们只需要设计一个20多行(怪物数量)的表,这个表的列数,则根据实际需要运算权重的常量来决定,大约是2-5列的样子,最多100多个单元格,就能做出原本12000的单元格的数据的效果来。尽管最终可能和Brotato原版的有些出入,也并没那么的“固定”。

 

03 宗旨设计“我们需要的”而非“brotato是怎样的”

 

到这里,我们过了一遍Brotato这个游戏中设计可能存在的坑,也想法进行了避免,这是一个工程级游戏开发中必要的工作。而在这个工作中,我们也注意到了一个细节——我们并不是原班不动的去照抄一个Brotato,无论是设计意图还是做法上,我们只是参考了原来的Brotato,而未必是要做的一模一样。

 

之所以这样做的原因,并不是说我们技术上或者设计能力上无法复现一个一模一样的Brotato,而是我们本身就想做的更好,做出属于自己的风格,这才是我们要去做一个Brotato的动机。不光是Brotato,我们做任何游戏的时候都是如此——我们只是参考一些我们喜欢的游戏,而不是要一模一样的做一个,没必要在一些细节上“一定要按照某个游戏”来较劲,做出自己的风格才最重要。

 

image-8-1024x248.pngBdFjEAGIgk768HyF6xB2.jpg
  • 允许他人重新传播作品,但他人重新传播时必须在所使用作品的正文开头的显著位置,注明用户的姓名、来源及其采用的知识共享协议,并与该作品在磨坊上的原发地址建立链接
  • 不可对作品做出任何形式的修改
  • 不可将作品进行商业性使用

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引