如何开发一款战棋游戏?先设计一个灵活好用的核心玩法框架
编者按 如果你想做一个战棋游戏,该如何设计核心玩法框架?核心玩法的细节设计又有哪些?游戏策划和游戏程序又是如何分工的?欢迎留言说出你的看法,小编将在4月20日前,抽取5位送出鹅厂周边礼品。
作者:猴与花果山
(本文内容由公众号“千猴马的游戏设计之道”提供,转载请征得同意。文章仅为作者观点,不代表GWB立场)
战棋类游戏的核心数据
动画信息
动画信息的本质
时间:单位通常是Tick,即第几次Update(在Unity中为第几次fixedUpdate),游戏开发与现实世界不同,游戏中Tick是最小时间单位,而现实中我们通常用秒来作为最小时间单位。
事件:即这个节点要做的事情,这些事情通常包括几类:
创建一个视觉单位:比如角色、特效、跳出来的数字等,在画面的某处创建一个这个。
删除一个视觉单位:停止掉某个存在的视觉单位。
移动某个视觉单位:让某个视觉单位进行位移,比如我们创建了一个火球,要从发射者飞向攻击目标,这就只是一个火球状的视觉特效,经过一个轨迹,到达了目标位置,然后被移除掉,同时又创建了一个爆炸(即命中)的视觉特效单位。
改变某个视觉单位动作:比如角色更换一个动画序列。
地图动画与对战动画
关卡数据
回合数
目标条件
当前行动阵营
关卡剧本
触发回合(ATB):在第几个回合的时候触发这个剧本,也可以是在第几回合到第几回合。我们上面举的例子中,触发回合=20。
触发条件:满足这些条件才会出发事件,我们上面举的例子中,条件就是“鲁大师幸存”这一条,而实际游戏制作中,应该支持多条件。
触发事件:当条件被满足的时候,会触发一些事件,触发的事件对于程序来说主要做两类事情——其一是做一些数据处理,比如添加一些角色到场上或者删除一些角色等,其二便是生成动画信息,正如我们上面所说的,游戏的逻辑执行之后,应该生成一个Timeline,然后根据Timeline播放动画展现给玩家。
天气等一些游戏特有数据
地图数据
地图块数据
贴图信息:这个地图块的图形信息,比如泥泞的地面、森林等,这是给玩家肉眼所能看到的,甚至不一定得有名称之类文字信息。
移动消耗:这通常是一个key value的结构,因为在战棋类游戏中,我们用来区别单位的基本元素之一是单位的行动方式,比如步行、轮胎还是履带,或者骑马、飞行还是游泳等,根据不同的游戏设计,会有不同的行动方式,每一个行动方式经过这个地图块的时候,需要消耗的移动力就是移动消耗,这将被用在寻路上。
属性改变:在大多的战旗类游戏中,不同的地形都会对地形单位上的角色带来不同的属性变化,比如防御力增加等。
其他信息:根据游戏的实际需要还会有一些其他信息,比如名称等,如果需要显示就得有;而一些游戏中,也存在传送门的概念,比如《火焰之文章IF暗夜篇》中第11关的楼梯就是一种传送门——角色走上去可以传送到另外一个非邻接的地图块上。
AoE
视觉表现:通常我们会理解为一个aoe总是要有个表现的,比如我们刚才举例的火海,还有火山喷发出来的岩石等。但事实上绝大多数aoe是不需要视觉效果的,比如某个角色施展了“鼓舞军心”的技能,使以他为中心的8格范围内的所有友方攻击力提高,他可能完全不需要任何视觉表达这个aoe,只需要在角色身上播放一些特效表示被鼓舞了,而这个特效的“负责人”应当是角色被添加的buff,而非aoe(当然也不是不能用aoe来实现,方法是灵活的)。
范围:【运行时数据】,范围是一个运行时数据,而非策划填表数据。可能很多人看到这一点的时候会不能理解,为什么我策划不能在一开始定义好aoe的范围呢?实际上原因很简单,比如暴风雪的效果,他就是一个aoe,但是暴风雪范围有多大?可能低级法师和高级法师是不同的,而装备了某本秘籍的高级法师范围会更大,因此AoE的范围,实际上是在创建AoE的时候决定的,而非从策划填表数据决定, 因此策划不必因为一个技能可能打1格-16格而去填写16条数据。而范围还有另外一个“令人惊奇”的概念——那就是范围并非是地图单元格一种,包括在diablolike游戏中aoe范围也未必仅仅只是一个形状,比如“全场所有女性角色”也是一种范围。因为AoE的本质是抓取符合条件的角色,这些角色所在的位置(我们通常理解的“范围”)仅仅是条件之一。
位置:和范围呼应形成实际范围,并不是所有aoe都需要一个位置的,但是指向地图区域的aoe应该都有一个坐标。位置是可以运动的,比如《三国志11》中火焰会被风吹走,就是一个位置变化。
每回合工作事件:aoe允许在每个回合运行一次,因此会有OnTurn的这个触发点,这里指向了一段策划写的脚本(aoeObj, targets, turnId)=>Timeline,即传递给策划脚本的参数是这个aoe实体、aoe范围内的所有角色和当前处于第几个回合,脚本根据这些信息执行内容,并且返回一个Timeline用于表现。比如钉刺地形会在每回合给范围内的所有targets造成5点伤害,就在这里写这个逻辑。
角色进入时事件:当有角色落定在这个aoe的范围内的时候(或者途径此处,具体看游戏设计,因此这也是一个策划要设计的细节内容),会触发这个aoe的角色进入事件,执行(aoeObj, character, targets)=>Timeline,即给脚本的参数为这个aoe,进入aoe的角色和已经在aoe中的角色(不含character)。比如这是一个地雷,角色走上去就会爆炸,则脚本中执行的是对character造成伤害;再比如这是一对机关,一共有2个同id的aoe,每一个进入事件都是检查地图里这个id的aoe范围内character是否达标(比如需要至少有一个单位就是targets.length>0),都达标了,就会激活机关开启,比如将某个地形块变成宝箱。
角色离开时事件:当有角色从这个aoe的范围离开时触发(aoeObj, character, targets)=>Timeline,在这里character指的是离开的角色,而targets中也不会包含这个character。
角色数据
阵营
攻击权:A阵营是否可以攻击B阵营的角色,比如游戏中某一关刷出一批村民,剧情中这批村民受到帝国的迫害,然后玩家军队来消灭帝国军队。那么在这里村民阵营是不会攻击帝国阵营和玩家阵营的,这是为了表现村民的弱势;而玩家阵营不能攻击村民阵营只能攻击帝国阵营,这是为了表达玩家是来拯救村民的;但是帝国阵营可以攻击村民和玩家两个阵营。
治疗权:A阵营是否可以给B阵营进行治疗。在很多经典战棋类游戏中,都会有这么一些特殊的援军,他们是帮助玩家的,并且在战斗胜利后,如果还存活,就会加入玩家队伍,但是让玩家十分捉急的是,这些角色不能被治疗,保全他们的生死就会变得更加困难,这也是这类关卡有趣的地方之一。
跨越权:A阵营的角色是否会把B阵营的角色当做障碍物。这通常是在角色移动的时候才有的问题,如果不把B阵营的角色当做阻碍物,那么寻路的时候会把B角色所在格子视为可过单元格,尽管不能最终落在这个格子上。而在一些经典战棋游戏中,还有敌人会影响身边单元格移动力的设定,即当一个角色要绕过一个“敌对”单位的身边的时候,会消耗更多的移动力。
角色
造型:角色在地图上使用的精灵的信息,有些游戏里用的是角色特有造型,有些用的是代表职业的形象,总之造型是需要的,毕竟是一枚“棋子”。
角色属性:这其实应该是一个struct,具体如攻击力、防御力等都是这个struct的属性,当策划需要增加属性的时候,只需要在struct里面修改即可。角色属性在角色身上细分为好几个属性,具体包括:
角色基础的属性:即角色在当前等级下的“裸体”属性,有很多战棋游戏,比如高战系列,角色总是“裸体”的,因为没有但未装备系统。
来自装备的属性:在一些战棋游戏里,角色依然有装备,来自装备的属性之和。
来自Buff的属性:来自角色身上的buff的角色属性,这里可以有若干个,比如用于加法的和用于乘法的等,这些都跟具体游戏的数值设计有关。
移动力:包括一个移动类型和一个数字,对应地图块中的移动类型消耗。一个角色只能有一种移动类型,即便是类似机器人大战系列中的盖塔,可以变形而导致移动类型不同,那也是因为变形之后角色的移动类型发生了变化。
阵营:角色所属的阵营,结合阵营设计,就有了角色在这局游戏中的一些权限。
Buff:这取决于游戏的复杂程度,但是现代的游戏中,应该都有buff。比如角色的被动技能、角色的临时状态(流血、中毒、攻击强化、风怒等等)都是buff。
生命值:当前生命值,如果低于0就会挂掉,相信绝大多战棋游戏的角色都需要这个属性。
本回合行动完毕/当前ATB值:对于传统的战棋游戏,即每个阵营行动完毕之后下一个阵营行动的游戏,角色应该有一个标记(运行时的)本回合是否行动完毕,行动完毕的角色无法再继续行动,除非有条件把这个标志再次设为false。而对于ATB游戏来说,则是一个角色的ATB值(或者剩余ATB值),当ATB值达到标准的时候就可以行动,否则角色无法行动。
Buff
buff释放者:运行时数据,一个角色信息,当然可以为null。即造成这个buff的施法者是谁,由于一些buff来自于场景的脚本创造等各种因素,他们是没有“负责人”一说的,或者策划在设计的时候故意让一些buff不需要释放者,那么buff的释放者应当为null。
剩余回合/剩余ATB:运行时数据,即buff的生命周期(duration),每回合减少1(ATB中则是每个ATB减少1)。当一个buff的生命周期结束时(为0时),就会先走进buff的移除事件,如果移除事件返回的是true,则这个buff将被移除。
Tag:字符串数组,这里要表达的是这个buff是什么,这完全是由游戏设计决定的,正如很多网站上的内容有个tag,比如“鞋类”,再比如“Capcom出品”等。Tag的内容虽然是“自由”的,但用途是严肃的,比如当我们要设计“移除角色所有的中毒状态”的时候,那么什么是中毒状态呢?我们可能有很多buff(他们的id必然不同)在设计的时候都被认为是“中毒”,那该怎么移除呢?那就是他们的Tag里都有一个“poison”,我要移除的是GetBuffsByTag(character):Array<BuffObj>的返回内容。
自定义记录参数:这是一个开放的Object,用于记录一些buff特别需要的数据,比如说护盾类buff还能吸收多少伤害,都记录在这里。举个例子,我们给角色上了一个“炼狱护盾”,这个护盾可以吸收100火焰伤害和100物理伤害,我们不需要去做2个护盾buff来实现,只需要{"物理":100, "火焰":100}来记录就行了。
运行间隔、运行事件与运行次数:这3个属性是联合工作的,运行间隔是指每多少个回合(每过多少ATB)执行一次运行事件,运行事件则是一个脚本函数(buffObj)=>Timeline,即把buff实体抛给脚本,由脚本执行对应的功能;而每次执行之后,运行次数会自然+1。运行次数是一个运行时数据,而运行间隔和运行事件则是策划填表时产生的数据,在创建buff的时候克隆(Clone)到了buff实体上,之所以是克隆,因为我们可以有其他因素来改变,比如原本一个灼热效果是每2个回合对角色造成50伤害,当我们对他使用了催化魔法之后,变成了每1个回合造成25伤害,这时候运行间隔和运行次数都发生了变化。而之所以要运行次数这个,是策划可能会设计出类似魔兽世界中痛苦诅咒法术的效果——每回合对角色造成伤害,这个伤害值会逐渐递增。
发起攻击时(OnHit):在发起攻击时产生的事件,(buffObj, damageInfo)=>Timeline,将buff和伤害信息传递给脚本,由脚本来改写damageInfo以及创建要执行的Timeline。这个最常见的用法是“有30%几率造成双倍伤害”,即随机数<0.3时伤害信息的对应伤害值翻倍;“对于有燃烧效果的敌人造成额外40%伤害”,即目标如带有含“燃烧”Tag的buff,伤害值乘以1.4;“攻击不会落空”,即伤害信息中的是否命中设置为true。通过攻击发起时,可以给角色赋予很多特性,比如攻击女性角色时伤害降低20%等。
被击时(BeHurt):在角色受到攻击的时候触发的事件,(buffObj, damageInfo)=>Timeline,与发起攻击时相似,不同点在于流程(详见后文的伤害流程)。这个最常见的是“受到伤害降低50%”,“受到伤害时反弹伤害”等。
伤害信息
攻击者:即伤害的制造者,这可以是null,因为很有可能这次伤害来自于一些剧情或者事件触发,我们期望伤害能走一次buff流程,而非直接写死的扣除多少血,那么此时伤害的攻击者应该是null,而不是受击者自己。
受击者:最终伤害的承受人,这是必须存在的,不然一个DamageInfo将没有意义,而在实际运作流程中,如果受击者已经被击败等因素导致不必再对他进行伤害,那么就可以“优化”掉受击者是这个角色的大多伤害信息。
是否命中:通过脚本函数(attacker, defender, source)=>boolean获得命中结果,抛给脚本攻击者、受击者和伤害源(source,如“普通攻击”,“Buff反弹”等)。由脚本返回一个是否命中,值得一提的是,即时是否命中是false,也必须获取伤害值和是否暴击等数据,因为“是否命中”在这里只是一个参考,而非流程走完了,是否命中和伤害之所以没有关系,还因为在buff的流程中可能改变这项值(详见后文伤害流程),所以一事一议,命中是命中、伤害是伤害、暴击是暴击(而经典争论之一的“因为命中了所以才可能暴击”还是“因为暴击了所以命中”,则是由策划根据伤害信息和游戏设计规则来决定,无非是if的问题)。当然,在这里未必是一个boolean,如果策划设计中很多buff会有条件式增加命中率,比如“对生命值高于自己的目标命中率提高20%”,这里可以是一个number,即命中率,在最终结算的时候再去计算。
伤害值:通过脚本函数(attacker, defender, source)=>Object获得的一个伤害值,抛给脚本攻击者、受击者和伤害源。之所以返回值是一个Object,因为一个游戏中的伤害类型可能是多样的,比如{"物理":30, "火焰":20},而在buff中可能有一些buff是“提高火焰伤害50%”,结果就是让这个伤害信息的伤害值的“火焰”从20变成30。这个伤害值是最后“扣血”的依据,而非由这个伤害值直接去扣血。
是否暴击:通过脚本函数(attacker, defender, source)=>boolean获得暴击结果,原理同“是否命中”,也同样可以是一个number,具体取决于具体的游戏内容设计。
战棋类游戏的主要状态
回合开始状态
播报回合数以及哪个阵营行动:当然这更多的是UI设计问题,如果不需要播报,也可以完全忽略这一步,具体还是看游戏设计的需要,但是绝大多数经典的战棋游戏,都是由这一步的,从UI合理性来说,至少得告诉玩家轮到谁了。
回合数增加:根据回合数增加规则来增加回合数,并不是每个回合都会增加回合数,通常来说只有轮到第一个阵营(一般来说是玩家阵营,即轮到玩家行动)回合数才+1。但是“回合数”中的“回合”往往和这里所说的“(逻辑)回合”不是一件事情,请注意区分(下文提到的“回合”大多都是逻辑回合)。
执行关卡剧本:如果有关卡剧本到了需要执行的条件,就要执行这个关卡剧本,并产生Timeline。
执行地图上所有AoE的“每回合工作”事件。
遍历并执行所有角色的Buff中运行间隔(如果符合运行回合的话),一般来说,都是执行当前行动阵营的所有角色(即角色阵营==当前行动阵营)才执行。这如何执行最终取决于策划设计的游戏规则。
执行天气变化等游戏特有的、回合开始时候需要做的事情。
选择角色状态
选择移动范围状态
首先“下一格”的选取,不仅仅是邻接4格,邻接4格是基础规则,如果设计的是大战略的蜂巢式,还分横六角和纵六角,即取的另外两格是[x-1][y]还是[x][y-1]之类的区别。除此之外,因为地图块可能存在传送门,那么传送到的目的地也符合“下一格”,也应该被加入算法进行运算。
在战棋中,通常来说“敌对目标”(由策划在“阵营”设计中定义何为“敌对目标”,或者在这里定义“穿透规则”)是“不可穿透”的,其他单元格可以穿透但不可落下,即寻路的啥时候是否会把这个单元格视为可过加入“下一格”,不可过就不会加入“下一格”数组里。
运算的时候根据角色的移动类型、地图块的移动类型和消耗、敌人位置(看游戏具体设定中敌人对周围格子移动力的影响,通常来说没有额外影响,作为阻挡已经足够了,这还是看策划设计需要)来生成一个临时地图来运算移动范围。
条件:如果完成条件,则执行事件,算出结果(return)。
事件:计算方法得出结果数据。
否则:挂向另一个AI脚本片段。
移动目标单元格坐标:想要移动到哪儿。这是一个策划需要详细设计的地方,因为通常玩家和初级策划心中会有这样一个错觉——“我”的设计是“敌人血不多了就逃走”,这设计的很清楚了——但实际上这什么都没有设计,因为这句“暴雪更新文档”中用了2个概括“血不多”和“逃走”,什么是血不多?就是条件里HP/MaxHP <= 多少?那什么又是逃跑?这个问题就是这里的“移动目标单元格坐标”,哪一个格子算是“逃跑”呢?策划需要设计到一个能返回出具体坐标的函数的程度才算是游戏设计。
使用的行为:比如火纹系列中使用武器攻击敌人、使用法杖治疗友方;比如高战系列中攻击敌人等,都是这个行为,这些能使用的行为,也是游戏设计的核心内容之一。
行为的目标:根据使用的行为决定一个目标,大多时候行为的目标可能是null。
角色移动状态
选择目标状态
信息确认状态
对战状态
首先是选择出对战的角色:绝大多数的战棋类游戏中,都是攻击者和挨打者2个角色。在火纹系列中,还有邻接的角色,会根据规则(或者选择)进入战斗形成最多2对2的战斗。这些是现有的游戏的情况,那么我们再来假设一些没有的“创意”,比如能不能是邻接的所有角色进行小团体战?再比如,这个关卡中有一个敌人的狙击手,他不在地图上,但是如果我方角色与敌人发生对战(不论谁主动),并且所在地性不是森林,我方角色又不是比如潜行者、忍者之类的职业,那么这个狙击手就会加入,帮助敌人向我方角色开火。这些设计,其实都是多对多的,由此对战的第一件事情,是先确定参与对战的角色。
确定每个人的行为以及顺序:根据游戏的设计来决定每一个人的行动规则,一部分的战棋游戏中,攻击方不会被反击,所以行为很简单,就是攻击者对受击者发动攻击;另一部分战棋游戏中受击方也会发动反击。另一层来说,有些游戏有2动设计,即双方交手之后,有一方可以再度出手。此时,我们需要确定每一个角色的行动行为,最简单的比如谁打谁,如果击败了,是否就结束这次对战。
当一切确定好之后,开始演算:演算的过程通常就是互相使用技能攻击和防御,这时候会产生出很多“伤害信息”数据,通过伤害流程处理这些伤害信息,最后得到逻辑结果(比如角色扣血、被击败等),以及一个Timeline。
播放这个Timeline:即展现战斗过程给玩家看,在这里的Timeline可能是与地图上的不同的一套,这取决于战斗模式的表现设计。
伤害流程
开始一次攻击:即任何因素准备开始一次攻击,比如角色攻击角色,buff产生伤害等,这些都应该是通过脚本接口DoDamage来进行伤害。在这里,如伤害量计算等策划设计的公式,都可以抛出脚本函数来,最终调用这个函数产生的结果填充给伤害数值就行了。
产生基础的信息:脚本接口DoDamage返回一个伤害信息DamageInfo,这是作为交给伤害系统处理的“凭证”,由此“一次攻击”真正的产生了。
伤害系统开始处理伤害信息:即开始将DamageInfo经过buff流程来进行变化。
攻击者isNull判定:因为我们提到伤害信息的攻击者可以是空,所以在这流程里就反应出来了——如果攻击者不是空,我们才会判定攻击者身上所有的buff,否则将跳过攻击者的buff的判定。
遍历执行攻击者的所有buff的onHit:即按顺序执行攻击者身上携带的buff中的每一个onHit(攻击时事件)不为空的对应事件,由此来改变伤害信息和要产生的Timeline。这个顺序是严肃的,必须从头到尾,而不能是forEach等优化的算法,因为buff被添加后插入的位置是有讲究的——举个例子,一个buff是对目标产生割裂效果;另一个buff是对带有割裂的目标造成双倍伤害。这时候是否双倍伤害,就取决于buff顺序了。
遍历受击者身上所有的buff的beHurt:原理同攻击者的onHit,只是此时执行的是受击者的beHurt(受击时事件)来修改伤害信息和Timeline。这里需要强调的是,攻击和受击的buff先遍历谁的顺序看似可变,实则不可变,这与游戏设计本身逻辑有关,当然强行要调换位置也不是不行。
最终伤害信息处理:当伤害信息被最终过滤完毕之后,就可以根据伤害信息来决定最终的处理了,究竟是命中后才暴击还是暴击就命中,完全是看策划设计了。而这里的伤害信息,无非是这个伤害规则所必须依赖的数据。
当每一个伤害信息被处理完毕之后,也就是一场对战结束之后(这其实是一瞬间的事情,人类几乎感觉不到),就会产生一个Timeline,播放这个Timeline就形成了对战动画,让玩家看到对战过程(因为是Timeline,所以加速也好、跳过也好就很好实现了)。
回合结束状态
判断游戏胜负条件:一些游戏胜负条件在这时候有效,比如“坚守30回合”等。
确定下一个行动的阵营:根据策划设计的规则确定下一个行动的阵营是哪一个。
进入新回合的开始状态:如果没有结束游戏,那么就会进入下一个回合的开始状态。
程序在这个模块的工作
处理状态:实现每一个状态的流程和逻辑。
实现一些功能:比如AI脚本的支持、数值脚本的支持,同时实现一些寻路算法等。
提供脚本接口:如DoDamage、AddBuff等一些核心脚本,除此之外,还要根据游戏的具体设计提供一些基础的脚本功能支持。
策划在这个模块的工作
设计计算公式:包括伤害计算公式、AI算法公式(比如选择敌人的优先级等)。
决策一些细节:如文中提到的“回合开始状态”中的执行顺序,这些都是为游戏规则定型而存在的。
关卡设计:整个游戏好不好玩,全在关卡设计了。
脚本实现:辅助关卡设计,制作必要的脚本,包括buff、aoe等功能的脚本,以及关卡剧本的脚本。