立即注册找回密码

QQ登录

只需一步,快速开始

微信登录

微信扫一扫,快速登录

手机动态码快速登录

手机号快速注册登录

搜索

图文播报

查看: 191|回复: 0

[讨论] 实现高扩展性的通用游戏技能系统

[复制链接]
发表于 2025-5-30 14:27 | 显示全部楼层 |阅读模式

登陆有奖并可浏览互动!

您需要 登录 才可以下载或查看,没有账号?立即注册 微信登录 手机动态码快速登录

×
本文所述其实并不仅限于技能,而是一个通用的游戏架构设计。技能仅仅是因为其复杂度高而更需要得到架构的支持,如果解决了技能的问题,其他部分都可以看作是技能实现的一个子集。
就像学习哲学最好先学习哲学史一样,本文会先从最简陋的设计开始讲起,通过方案的更替让大家不仅能知其所以,并且知其所以然。

STEP1
只有一个可运动的单位,那么建立一个Actor并操控移动即可。
STEP2
单位变成多个。这时候需要处理单位之间的交互,就需要在外部放置一个Manager类以便相互之间能够进行通讯,同时还需要管理类来维护Actor单位的增加和减少。
STEP3
单位可能具有不同的行为,有的物体只有阻挡功能,有的可以移动,有的可以攻击,我方和敌方的逻辑也不相同。因此Actor需要通过继承派生出不同的版本。
STEP4
子弹这种单位出现了。子弹本身和Actor并不是同一级的概念,诸如其攻击力是由攻击方决定的。并且其逻辑和Actor具有非常大的区别,比如运动逻辑和人物完全不同,而且具有伤害判定。派生至Actor已经不合适了,需要建立一个独立的子弹管理器。
STEP5
如果子弹是可以被击落的呢?如果子弹也有血量呢?Bullet看上去又变得和Actor变得相似了。
即使不谈这个问题。那么,召唤类技能呢?重新生成一个Actor加入Actor列表?这个操作明明和子弹如此相似却不使用同样的逻辑而是各写一套?
为了简化,不生成召唤物,而是选择发射特殊运动规则的子弹假装自己是一个召唤物?那如果这个“召唤物”也会发射子弹呢?
子弹碰触到敌人的时候会产生碰撞伤害,那单位之间相互碰撞难道不需要有碰撞伤害吗?非要多此一举在身上绑定一个永久存在的子弹来模拟?
修正主义是愚蠢的。所以,我们将Actor和子弹剥离的决定是错误的。把Bullet当作一个具有特殊运动规则的Actor才是正道。
那么,既然子弹都是角色,那么特效是否也应该是一个不能运动出现时间特定没有任何交互的Actor?
异常效果是不是也应该是一个Actor?毕竟异常效果会被附加到角色身上,也有机会以特效方式显示出来。特效都是Actor了,Buff为啥就不能也是呢?毕竟它也需要管理出现以及删除啊,也可能会周围的目标产生伤害/生成子弹。
角色/子弹既然有攻击判定,那仅进行一次的纯粹的攻击判定框,是不是也应该被处理成只生存一帧的不可见的Actor?
万物归一。我们将Actor更换为另一个更合适的名称——Entity(实体)。
之前遇到的问题其实是继承的局限性。继承适用于增添功能,但不适用与修改功能。在需要任意混搭逻辑的情况下,组合是唯一解。
STEP6
Entity可能具有非常多的功能:逻辑状态机,移动模块,攻击模块,受攻击模块,显示模块,这些内容是最基本的。由于每个模块差异都可能很大,不可能都放在Entity内部实现,便必须拆分成多个Component,以组合形式存在。
每个Component会有一些接口供它们之间相互进行调用。同类的Component可以用继承,也可以不用,取决于其共用逻辑的多寡。
最初,Component会储存在Entity固定的插口上。但是一个插槽只能插约定好的固定好的一个组件,而你总会遇到一些意外的额外需求。比如说,我在某个单位在它原来的逻辑上增加一个水平的左右往返移动。而它的原逻辑并没有这个功能,强行加入这个功能并设置开关会增加不必要的复杂度。又比如,定时死亡。所以这时候会增加一个不定长的“插件”数组,把这个模块当作插件,允许加到任何一个Entity下,然后通过操作那些固定组件实现功能。
STEP7
那既然如此,为何不让所有Component都基于插件呢?虽然没有固定组件可能会让基于固定组件的插件无效。但这可以在编辑器里通过约定进行限制(RequireComponent)。运行时的效率可以用初始化时的缓存解决。
这样一来,插件和固定组件就成了同一种东西,只是相互之间存在着依赖关系。没有任何一个组件是必要的,Entity只是一个容器。这就是我们熟悉的Entity-Component模式,也就是Unity默认提供的功能。
Manager更多关心的不是Entity,而是对应的Component。你可能有大量的Entity存在,但是其中包含HP组件的Component并不会有很多。那么Manager就应该直接管理HP Component数组。这只需要在Component的Add,Remove逻辑里修改对应Manager的Component数组就能够实现。这也是Entity-Component模式的一般实现方法。
此外,我们还需要一个消息系统。
消息系统更多不是用于不同类型Component之间的通讯,因为这部分内容完全可以通过GetComponent解决。它处理的是不同Entity之间的通讯,比如说,角色死亡的时候,可能需要让它发射的所有子弹消失。子弹命中敌人的时候,可能需要角色做出特殊的嘲讽动作。消息系统虽然不是常态功能,确实是非常必要的通讯手段。发送目标可以是子级,父级,固定ID,固定group。
值得一提的是,Unity也提供了SendMessage这样一个功能。由于这部分内存对性能不敏感,字符串消息也并非不可行。所以这个默认功能也不是不能用。但如果消息发送确实比较频繁的话,还是重新实现一个与反射和字符串无关的消息广播系统更好。
STEP8
但……具体怎么做呢?
我们允许组件之间相互之间进行自由组合以及通讯,那么,该如何指定这个过程?如果指定发送的事件名和具体要执行的逻辑?肯定不能继承一份直接写在代码里吧?
——用持久化的数据。
Component的具体组合方式可以用数据描述,因为很容易理解就不再臃述。
而Component自身的具体逻辑也可以由数据进行指定。比如上面说的死亡后销毁所有子集。我们可以创建一个EventComponent,并在其中存储一个数组,建立一组事件以及其对应要执行的逻辑,并用枚举给予区分。然后你就可以添加一个event是“死亡”,以及command是“让所有子级执行死亡”的数据行。也可以改成执行“向所有子级发送AAA事件”,同时在每个子级上添加(event:"AAA",command:destorySelf)这种更灵活的处理方式,将是否销毁自身的决定权放在子级上。
这一切也不需要过于教条化。数据表达的缺陷就在于过于强调灵活可能导致数据量过大,编辑困难,所以可以为常用逻辑创建一些“快捷方式”,比如提供一个destoryChildrenWhenDie的Component,或者提供一个destorySelfWhenParentDie的Component,或许将两者合并并提供选项进行区分。内部逻辑依然和上面所说的相同,但是编辑起来就简单了很多。
然而,数据自定制逻辑这个需求,坑其实是非常深的。你可以只提供少量的自定制功能,大部分需求通过逻辑Component配合一些固定参数的方式完成。而你越是想有更多“自由逻辑组合”,其数据表达就越是复杂。最开始的时候,你需要的是一个顺序执行队列。比如接收到skill1事件时候,执行一些动画播放,声音播放,检测碰撞,创建特效的指令来完成一个基本技能的功能,数据结构自然就是一个Queue数组。
但假如在执行这组指令的同时还要并行另外一组指令呢?比如希望在技能释放过程给自己添加一个持续的无敌效果并显示?比如控制镜头?或者干脆就是其他更实际的指令?那我们可以创建一个Parallel队列,然后把前面的两个Queue作为它的两个子项,就是在数组里再套数组。那么,假如希望释放一个技能的时候具有一定随机性呢?比如在三个逻辑里随机选择?那就是增加一个RandomGroup队列,将你的三个子技能放在它的子集。那如果有些技能有条件分支呢?比如有目标的时候是一种,没目标的时候是另一种,近距离是一种,远距离是另一种?那我们创建一个包含三个分支的SelectGroup,并且在每个分支增加一个Condition条件指令来让这条分支提前中断(这样在处理多个条件的时候数据结构会更简单)。
说到这里,用过行为树的人就会明白,上面的方案就是行为树。行为树就是这个问题的终点。如果你打算用数据表达你在开发中所遇到的这些“常见”需求,最便利以及自然的方式就是直接使用行为树。如果你拒绝使用行为树,那么你就肯定有一部分需求无法通过配置完成。而假如想自己开发其他的方案来实现这些需求,最终也很难逃出行为树的框架。
行为树本身其实是一个数组套数组的结构,也就是一颗树,而非连连看。所以它也完全可以用普通的列表来显示,只是在内容复杂的时候会比较难以辨识。
如果你并没有这么复杂的需求,并且讨厌单独打开一个窗口来编辑逻辑,其实也可以在内部使用行为树的Runtime,仅在编辑器里提供一个列表来添加逻辑节点,逻辑只需要Queue的话和传统做法是差不多的。但在遇到复杂需求的时候,你依然可以在现有的框架下完成工作。
至于Event本身是否要用行为树来处理则见仁见智。把Event也包含在行为树框架里会导致即使最简单的指令起码也需要涉及到多个类型的节点,对不想编辑复杂逻辑的编辑人员可能不太友好。而行为树这个“灵活”的优点也可以描述成“缺乏规矩”,“可控性差”。但毕竟行为树本身就拥有事件机制,相比再创建另一个独立的事件系统,仅有统一的一个事件系统显然是更加优雅的做法。
STEP9
我们解决了单个实体的逻辑执行问题和多实体的通信问题(包括创建和删除它们),但我们依然还是缺少了一个重要的部分:状态切换(更多用在BOSS的多阶段实现上)。
简单的状态切换可以用行为树的变量以及条件分支来处理。但别忘了,我们的系统是基于Component的,不同的状态下有可能需要截然不同的Component数据组。与其修正,彻底替换在处理逻辑的时候会更加简洁。
最土的方法,是召唤一个新的Entity,同时删除旧的Entity,然后让他们使用同样的ID并且沿用原本的可视部分,虽然丑陋,但是可用。但事情变成这样无非是因为我们的Components没有分组必须同生共死。如果通过创建“子Entity”的方式把一些Component分组在一起,然后执行指令移除他们并且换上一个新的“子Entity”,就可以更优雅地处理好这个问题。
“子Enitiy”在Unity里的做法就是GameObject里面再套一个GameObject,只是这个GameObject上面的所有Component操作的目标都是父GameObject,添加一套公共逻辑处理就好了。更换“子Enitiy”就是直接移除它并加载一个新的"Enitiy"加入原本的子集。在这个过程内,“子Enitiy”上的状态会被清除(当然了),而“父Enitiy”上的并不会,正好符合我们的需求。
还不能理解的话……其实就是把原本的角色看做一个只有可视部分的空壳,它的所有行为都是由附着于它的一个不可见寄生体在遥控,切换状态就是更换一个新的寄生体。同样的,也可以用这种方法来处理可视部分变化,来应对不同阶段的形象更替。
STEP10
到现在,我们其实还有一个问题没有解决。
最经典的例子是:我们的技能具有多个等级的强度,各个等级之间大部分逻辑是一样的,只有少部分不同,比如特效需要更换,比如发射数从2个变成3个,比如颜色从白变成黄。处理多技能的时候为了更加自由,往往也和状态变化一样需要整体替换,导致你每个技能要创建一套单独的配置。
也就是说,你需要复制数据。
复制一时爽,改时火葬场。为了避免这个问题,我们需要让数据之间具有“继承”关系。我们建立一个技能母版,其他技能都是在这个母版上添加新的内容,这样只要修改母版,所有技能都可以获得这个新的修改。
虽然这也可以通过拆分Enitiy来实现,但是“继承”在处理计划外的修改的时候,更加自由,看起来也更直观。只有操作简单,甚至比复制一份更加简单,才容易让编辑者自然地使用这种方法。并且如果修改的位置比较分散,也很难通过拆解Enity实现。拆解的Enity太多也不方便管理。
但这个实现起来稍微有点麻烦。基本上分为三类:增加,修改,删除,用一个变化列表来描述变体和母版的差异。而最麻烦的其实是如何制作一个直观的编辑器。
STEP11
最后我们剩下一个问题。我们的数据具体应该如何储存?我们需要编辑这个数据,游戏运行时也需要读取数据,所以需要决定它在内存外的持久化形式。
如果希望有较高的加载效率,一个二进制的存储方式是需要的。但如果只是在需要的时候才加载数据,其实性能要求也没有这么高。
而我们的数据结构基本就是一颗树。
储存方法有很多,就算重写一个二进制的持久化其实也不算特别困难。这里其实更难的还是对应数据的编辑器实现,比如最基本的复制,粘贴,顺序调整,折叠,查询,编辑时的运行效率。这方面假如做得不好,甚至不如直接实例化成XML等文本格式,然后借用现成的XML编辑工具修改,虽然问题很多但起码复制,查询操作效率很高。
另外就是在游戏运行期间修改数据,应该可以即时生效。否则不停重启游戏浪费的时间太多,这其实才是最影响编辑效率的地方。
这里我倒是有个“大胆”的想法。虽然我从来没有这样做过,但我们可否直接用Unity的预制体功能来储存树状数据结构?
用预制体的话,我们就可以完整地利用编辑器的所有功能,并且同时拥有很高的编辑性能。我们储存的数据本身就是Enitiy-Component的结构的数据,正好和Unity的一模一样。而Unity的预制体现在也支持嵌套引用以及变体。再使用Odin强化编辑面板上的数据编辑部分,我们几乎什么都不需要做。
甚至于,我们可以直接将运行期间的GameObject直接二进制化后储存,然后运行时直接实例化。
当然,这样就过激了。直接存储GameObject然后运行时实例化的话,里面包含的数据会多出一次额外的复制,如果动辄生成100以上的副本这样做性能就有些浪费了。而且直接用GameObject储存,仅仅加载数据就必须同时加载其引用的资源(当然这些都可以处理)。此外,GameObject这种形式虽然基本是数据,还是有一些额外内容的,性能上必然不是最优。
但,至少,我们可以选择用这种形式编辑,然后再将它转换成我们需要的其他持久化格式。即使这些编辑用的GameObject没有任何可视内容,能够使用树状列表和编辑器面板,以及预制体的嵌套功能,我们需要的大部分编辑用功能就已经有了。剩下的就是写一个转换成数据的通用代码以及从数据生成我们运行时对象结构的通用代码就可以了。
虽然正常的做法当然是重写编辑器,但毕竟需要花时间,还很容易写得不好用,人手不充裕的时候很容易出问题。比如没有基本的剪切复制数据的功能,导致操作人员每次都要删除了重填。比如数据量大的时候每次编辑都要等待很长时间的数据加载。

以上。

原文地址:https://zhuanlan.zhihu.com/p/92651085
楼主热帖
回复

使用道具 举报

发表回复

您需要登录后才可以回帖 登录 | 立即注册 微信登录 手机动态码快速登录

本版积分规则

关闭

官方推荐 上一条 /3 下一条

快速回复 返回列表 客服中心 搜索 官方QQ群 洽谈合作
快速回复返回顶部 返回列表