1. 如何实现一个强大的MMO技能系统——序章

前言

技能系统可以说是游戏中广泛存在而又最重要的系统了,它是整个游戏战斗体验的核心。一套强大的技能系统可以让游戏的策略性,可玩性得到极大的提升。然而技能系统牵涉到的模块非常多,又会面对策划各种各样的奇葩需求,同时技能模块中很多系统的设计不仅仅对程序员的编码能力提出了一定的要求,而且还需要程序员对游戏机制有着深入的理解。可以说要实现一套强力的高可用高扩展的技能系统还是有一定复杂度的。

如果作为一个游戏玩家,谈论到技能系统,我们一定会对War3的技能编辑器印象深刻,通过设计一个高扩展的技能系统编辑器,它打造了无数的经典游戏。各种对战地图,RPG守图,TD塔防,趣味闯关,甚至产生了其中最经典的风靡全球的Dota,为我们展示了War3技能系统的强大。后来Valve甚至因此专门开发了Dota2,而且它提供了更强大的地图编辑器,基于该编辑器开发的自走棋更是开创了一个新的游戏品类。这就是技能系统所展现的魅力。

如果论网络游戏领域技能系统之集大成者,相信魔兽世界,守望先锋,Dota2都可占得一席之地。深入玩过这几款游戏的话,都会被他们技能系统之精巧所震撼。但是前两款游戏的技术分享实在太少,所流传在外的资料并不足以让人去深入研究他们的技能系统的架构是怎样的。而Dota2因为地图编辑器的存在,有大量的资料可以去获取[1][2]。你甚至可以直接写一个技能脚本去运行一下,验证自己的猜测是否正确。本人的技能系统实现,可以说极大的受益于此,正因为有着可以大量阅读的文档和技能示例,才让我对技能系统的认识有了质的飞跃,在此深表感谢。

互联网上关于游戏开发相关的技术文章浩若烟海,佳作繁多,本人也因此所获良多,受益匪浅。然而对于技能系统设计这块尚有遗珠之憾,本系列的文章将围绕如何打造一个强大的MMO技能系统这个主题展开,对技能系统涉及到的各个模块一一详细讲解,从而让读者对技能系统有深入的了解和认识。

本系列将技能系统分为如下模块依次讲解:

  1. AOI系统

  2. 技能(Ability)

  3. Buff

  4. 子弹(Projectile)

  5. 特效

  6. 运动

  7. 动画

2. 如何实现一个强大的MMO技能系统——AOI

前言

AOI(area of interest),它是MMO游戏中一个极其重要的概念,主要解决游戏中多人同屏的问题。本文并不打算去讲AOI相关的算法,如九宫格,十字链表,六边形网格之类的具体算法实现,因为此类文章网络上已经有很多了。虽然大多数开发者都接触过AOI,但是我发现其中很多人并没有真正深入领会AOI的本质。因此本文会从设计的角度来阐释什么是AOI,为什么要有AOI,以及如何去设计一个高质量的AOI系统。

正文

很多开发者在设计AOI系统的时候通常是这样设计的(以最常见的九宫格为例),比如把场景按照一定的Size划分出很多格子,然后由AOI系统根据玩家位置来更新玩家的所在的网格索引,每个玩家获取周围9个格子的玩家信息来进行显示。

这样就在细节上出现了很多如下问题:

  1. 玩家位置变化的时候如何处理,立即更新或者等到下一帧update的时候更新?

  2. 玩家位置变化或者周边其他玩家变化导致所在网格索引变化后,带来的对象的增加、删除如何处理?

  3. 由于显示列表的容量限制,玩家A的显示列表里面添加了玩家B,但此时玩家B的显示列表满了,该怎么处理呢?

这样的问题林林总总,很多开发者都花了不少心思去处理这样的问题。但是请让我们更深入的考虑一下,为什么会出现这样的问题呢?这样的设计就够了吗?

又比如说某天策划脑洞大开,说我们来做个暗杀玩法吧。并绘声绘色地给你描绘了如下让人心潮澎湃的情境:

“你从酒馆老板处接到暗杀任务后,展开羊皮纸一看,上面只记录了目标所在的场景,并没有具体位置信息,你需要传送到目标场景之后,自己去发现目标的踪迹。于是你使用传送卷轴到达了目标所在的场景,然后站在高山之上,开启了鹰眼术,目光如电刺穿远方层层的迷雾,随着你细致地搜寻,突然间发现远方的天际一个黑点缓缓浮现,你知道他就是此次的任务目标了,随即你立刻翻身上马,向目标疾驰而去……”

那这个时候作为程序,你该如何实现这个需求呢?

很明显,此时目标离你的距离远超你九宫格的距离,你依然要将他显示出来。那这个时候该怎么办呢?

所以,我们顺其自然地有了一个新的概念——“关注者“,AOI搜寻到的列表并不能就完全等同于关注者列表。而客户端显示的就是关注者列表里面的内容。AOI搜寻到的列表只是用来获得关注者的手段。

为了让读者有个更直观的理解,我举个例子。大家都很熟悉微博,假如我们把游戏当作微博来看的话。

  1. 登录场景:当我们注册账号进入微博(游戏场景)的时候,大家都没有关注者。这个时候我的微博首页的时间线上没有任何其他人的动态(游戏中此时只看得到自己)。

  2. 获得其他玩家信息:这个时候为了让大家互相交互,微博提供了附近的人的选项(假设微信上的这个功能移动到了微博)。这个时候你点击附近的人(游戏中向AOI系统拉取周边九宫格的玩家列表),然后获得了一系列的用户信息列表。此时你一一关注,瞬间你的时间线动态就丰富了起来(关注者列表新增周边的玩家并显示在本地客户端)。

  3. 消息同步:假设你的用户名是A,并且你关注了B,C。那么此时你的关注者列表里面是BC。而BC此时并不会说就关注你了,而是收到系统通知,获得了新的粉丝A。那么我们现在看A的时间线,会不停的收到B和C的动态,如果是在游戏场景下,那么B和C最常发送的动态就是玩家B或C移动到了某位置P1(Obj:MultiCastPktPosChanged),玩家B或C使用了技能(Obj:MultiCastPktCastAbility)等等(此处仅为让读者能够直观理解概念,实际上的同步优化处理有很多方法和技巧,在此并不展开)。

  4. 关注机制:虽然上例中A能收到B和C的各种动态,但是A发出来的动态只有自己看的到,B和C是看不到的,也就是说AOI并不需要双向连接。这并不仅仅只是为了解决前面提到的第3个问题(细细思考一下)。同时这也是符合现实感受的。比如说每天上班的时候地铁里面有很多人,你会关注谁呢,你关注的目标会关注你吗?但此时如果周围有人拍了一下你的肩膀,喊了你的名字,那么你就会立即关注他是谁,对吧。(以游戏场景为例的话,假如周边有很多玩家的话,你不一定需要严格按照位置远近来排序,而是需要把跟你关联度最高的玩家加到你的关注者列表中。比如不远处的某个玩家向你发起了切磋请求,那么此时的解决方案就应该是把他加入到你的关注者列表,而不会说根据距离远近再来把这个玩家增添移除掉)。

此时,回到最开始的问题,那么前面的问题的答案就显而易见了。

  1. AOI的信息需要立即更新吗?不一定需要,我们未必需要时时刻刻关注离我最近的目标,附近的人这个功能里面的用户列表更新频率慢一点也没关系。

2. 因为我们并不需要时时刻刻关心附近的人,所以自然也不会有某个玩家在边界进进出出导致的AOI不停的创建和删除的开销问题。(如果还需要优化就加上lazy time处理下吧)

3. 这个问题已经显而易见了,没必要一定得互粉,看缘分啦。我附近如果没其他人,又太寂寞的话说不定也会关注你,关注列表满了就算了。

最后,策划的需求解决起来也很简单了。搜寻目标的过程就是使用技能给自己加了个Buff,Buff创建的时候将目标加到关注者列表中,目标死了后buff销毁,并将目标移除出关注者列表。这在微博系统中相当于你直接搜索该玩家的账号,直接加关注,任务达成后再直接取关就行了。并不需要与”附近的人“这个功能发生任何关系。

总结

因此,当我们设计AOI系统的时候,并不需要严格只显示九宫格范围内的对象,九宫格只是一个手段,一个让我们获得关注者的手段,它只是个过程,并不是结果。大家在设计的时候多考虑这一点,使用起来就方便很多了。甚至我们可以让技能改变关注者列表,从而达成一些特殊效果。

3. 如何实现一个强大的MMO技能系统——技能

前言

一个完整的技能系统逻辑层主要包括三个模块:技能模块,buff模块,projectile(子弹)模块。然而在很多项目中,这三个模块可能并不是同一个人负责的,所以会发现这些模块间的联系并不够紧密,然而技能的需求是非常复杂的,这样就有可能随着技能需求的越来越复杂,每个模块都搞了一套自己的脚本化的逻辑,最后维护难度急剧增加,导致开发成本大大提高。本章将主要讲述一个高扩展性的技能模块该如何设计,使得技能支持尽可能多的效果,同时又能够保持架构的精简和高扩展性。

正文

首先,我们大致思考一下平常玩的游戏,大致会发现有如下几种技能类型:

  1. 被动类型的技能(PassiveAblity)

  2. 主动施法技能(最常见的通用主动施法类技能,如普通攻击等一次性触发效果类技能(GeneralAbility),引导类持续施法技能(ChannelAbility),如大法师暴风雪)

  3. 开关类技能((ToggleAbility)点击技能开启/关闭效果,类似于恶魔猎手献祭)

  4. 激活类技能((ActivateAbility)点下右键激活/停止,一般是给普通攻击附加特殊效果)

在这里我们采用标记位(可进行或操作)的形式来表明这些技能的类型。这意味着一个技能可能被检测既是被动又是引导类技能(我们将在最后的示例部分讲述为什么需要这样设计)。

下面我们一步步讲解这些技能类型该如何设计。在我们的技能系统中,所有的技能里面不包含任何具体技能效果及逻辑,每个技能根据其类型提供若干抽象接口,具体执行效果由策划去配置。

当一个技能标记为被动技能时,则它是被动技能,被动技能一般会在技能初始化时生效,技能初始化时会有抽象行为接口Ability::OnAbilityInit()。那么被动技能就可以在这个接口中执行一些行为,具体执行那些功能由策划配置,一般情况下策划会配置给角色添加Buff来监听各种事件以触发各种效果。那么技能的各种效果该如何实现呢?我们将在下一章buff系统中讲到。

当一个技能标记为主动施法的技能时,它可手动释放,同时在施法前需要有目标信息,这里一般有三种情况:

  1. 技能释放时不需要目标即可释放(如群疗,踩地板技能) -> 1 << 1

  2. 技能释放时需要选定目标(单体指向性技能) -> 1 << 2

  3. 技能释放时需要以指定地点为目标(常用于AOE技能) -> 1 << 3

虽然技能系统千变万化,但是初始阶段选择目标就是这三种情况的组合。有些复杂的技能会有两种组合情况:

比如策划有个需求是闪现技能,如果当前没有选择目标就朝前方闪现五米,有目标时则闪现到目标身后。则我们的目标选择配置项就是1|2,当释放技能的时候我们保存下当前的目标信息(有无目标),后续在技能效果执行的时候去获取目标是否存在,然后实现不同的效果。(Dota2中Lion的穿刺和先知的招树就是2|3。)

如果标记为普通施法技能,我们将技能划分为如下几个阶段:

当我们满足技能释放的各种前置条件检查后,会进入如下几个阶段:

  1. 技能起手Ability::OnAbilityStart(),这个时候通常情况下策划一般配置为播放动画,技能开始转CD,执行完成后技能进入前摇阶段。

  2. 技能前摇阶段不允许其他技能释放,除非技能可强制立即释放(bImmediately=true)(如有些游戏要求滚动还有解控技可以立即打断当前技能)。前摇阶段提供一个配置时长CastPoint(Spell时间点),一般为动画抬手到攻击帧时长。比如说播放一个挥刀动画0.3秒后动画到攻击点,这时策划就配置CastPoint为0.3秒。

  3. Spell阶段一般为技能具体逻辑执行阶段。通常会根据配置选择相应目标造成相应效果。当执行完操作之后进入后摇阶段。

  4. 后摇阶段一般不能被其他技能打断,除非是连招或者强制立即释放类技能。我们的技能系统不需要引入公共CD这个概念,通过前摇后摇阶段的划分就足够满足各种需求了。

如果标记为持续施法类技能(Channel),我们将技能分为如下几个阶段:

  1. 起手前摇阶段同普通技能。

2. 引导阶段提供3个接口供策划配置逻辑(ChannelStart, ChannelThink, ChannelFinish),提供两个时间参数以供配置:ThinkInterval (引导触发间隔)及 ChannelTime(引导阶段总时长)。如策划配置CastPoint为0.1秒,ThinkInterval为0.3秒,ChannelTime为1.4秒时。则技能将会在AbilityStart0.1秒后调用ChannelStart,将在第0.4秒->0.7秒->1.0秒->1.3秒调用ChannelThink,将在第1.5秒调用ChannelFinish。这样的接口设计在保证简洁的情况下基本能覆盖几乎所有的需求。

3.后摇阶段同普通技能。

如果标记为开关类技能,我们提供两个接口:OnAbilityToggleOn和OnAbilityToggleOff,一般通过调用添加/移除buff实现具体效果。

如果标记为激活类技能,我们提供两个接口:OnAbilityActivate和OnAbilityDeactivate,一般通过调用添加/移除buff实现具体效果。

总结

本章主要讲述技能的类型和阶段划分。通过良好的阶段划分和逻辑抽象就可以让技能有非常高的扩展性,这样在后续的开发中仅仅只需要做一些功能的扩展,而不会影响整体的架构。

示例

我会举出一些常见而特殊的技能例子,以便让读者有个直观的理解。

  1. 问:如果被动触发后会进入CD,此时被动不再生效如何实现?(Dota2白牛重击效果)答:策划在被动触发时配置StartCooldown(duration)即可,技能的CD是个可配置行为,执行StartCooldown就转CD,什么技能都能走CD,而且可以在任意情况下走CD。走不走CD策划看着配就行。技能走CD后然后在被动行为里面检测技能是否在CD状态,如果在CD状态则不执行任何效果。

  2. 问:技能既可以主动释放又能有被动效果如何实现?(Dota2敌法师护盾)答:被动效果其实是由Buff具体负责监听各类事件触发效果,主动技能也能在OnAblityInit的时候执行行为,策划在此处配置加buff就行了。那么问题继续来了,如果有一个效果是禁用被动技能的话,前面的带被动效果的主动类型技能该不该禁用呢?答:这时候技能类型为标记为位(bit)的作用就体现出来了,如果标记为被动(AbilityFlag & AbilityPassive为true)的话就禁用。

  3. 问:蓄力类技能也是一种常见的技能类型啊,为什么没有被提到?答:蓄力类技能在我们的技能系统中直接简化为普通技能了。它只需要策划在技能抬手的时候播一个蓄力动画,然后CastPoint配成蓄力时长,在Spell的时候再播一个动画就行了。继续问:那蓄力后的攻击动画到攻击效果还有一个间隔时长怎么办?继续答:配置技能为引导类技能,例如配置ChannelInterval为10000秒,ChannelTime为0.3秒,这样技能引导阶段就只调用ChannelFinish,蓄力的效果在ChannelFinish里面触发即可。

  4. 问:各类轻功,击退,击飞,冲刺攻击等各种位移效果怎么实现,他们之间万一存在各种复杂的打断规则又怎么处理?答:我们通过Buff去改变角色运动,状态,属性等。下一章将会具体讲述这些情况。

4. 如何实现一个强大的MMO技能系统——BUFF

前言

Buff模块可以说是技能中最核心又最复杂的系统了。一个优秀的Buff系统能够让策划的创意得到最大限度的发挥,大幅增强游戏的战斗深度和可玩性,并且同时也能让开发者轻易的扩展维护,支持更多的效果和功能。本章将为你详细讲述一个强大的Buff系统是如何实现的。(长文预警)

正文

第一节:Buff定义

首先我们将Buff系统分为三个层次,具体继承关系如下:

Buff:所有Buff的基类,包含各类成员函数和基本接口。

Modifier:继承于Buff,代表这个Buff是一个修改器,它可以用来修改当前目标的各种属性,状态等等。抽象Modifier这个类的目的是出于性能优化的考虑。因为当Buff修改角色的属性或者状态时,会导致重新计算角色的动态属性, 而在游戏中我们很多的Buff并不需要修改角色的属性状态,仅仅用来提供一段逻辑。那么如果它是一个Buff不是Modifier,就不需要重新计算角色的动态属性。

MotionModifier:继承于Modifier,代表此类Buff提供修改玩家运动效果的功能。因为牵涉到与运动组件的交互,所以抽象出一个新的类。

Buff类层次结构划分了之后,那么Buff需要包含那些成员数据呢?

我们提供BuffTypeId(Buff类型Id), Caster(Buff施加者),Parent(Buff当前挂载的目标), Ability(Buff由哪个技能创建),BuffLayer(层数), BuffLevel(等级)BuffDuration(时长),BuffTag,BuffImmuneTag(免疫BuffTag)以及Context(Buff创建时的一些相关上下文数据)等等。

在这里,我将说明一下Caster,Ability以及Context这三个成员,这也可能是我们Buff系统中一些独特的点。

Caster代表Buff的施加者,它有可能为空,也有可能不为空,视具体构造时是否传Caster参数而定。但是Buff有一个配置项bNoCaster(是否强制设置Caster为空)。如果bNoCaster = true。则Buff的Caster一定为空。

为什么要有一个bNoCaster设置呢?那是因为我们的Caster不仅仅是一个成员项,它还关系到Buff合并问题。如果存在两个TypeId类型相同的Buff时候,当他们的Caster相同才可以走合并流程(Buff层数增加),如果Caster不同,则不能合并。当策划有一些玩法需求可以多人给BOSS叠Buff时就可以配置Buff的bNoCaster=true,这样就不需要开发者在写代码添加Buff的时候小心翼翼的设置Caster参数为空了。另外还有几种情况也需要设置bNoCaster=true,比如存在一个熔岩地图,或者冰雪地图,玩家每秒掉多少血量,这个时候也可以配置bNoCaster=true。再比如说一些活动buff,如双倍经验buff,红名惩罚buff,都可以由策划配置bNoCaster=true。类似于双倍经验,还有红名Buff这种所有需要存盘的Buff,我们都需要设置bNoCaster=true。也许会有人有疑问,这样能满足需求吗?完全可以,我会在最后的示例部分举出一个例子来解答这个疑问。

Ability代表Buff是由哪个技能创建,它有可能为空,也有可能不为空,视具体构造时是否传Ability参数而定。通过Ability这个成员类型,我们就将Buff与技能联系起来了,我们能在Buff中取得技能的各种数据,通过获取技能的数据,然后由Buff来实现各种各样的技能效果。

BuffTag,BuffImmuneTag由策划配置(基于标记位),标注这个Buff属于那些种类以及免疫哪些种类。策划可以定义一些Tag如下:

  1. Metal = 1 << 1 (金系)

  2. Wood = 1 << 2(木系)

  3. Water = 1 << 3(水系)

  4. Fire = 1 << 4(火系)

  5. Earth = 1 << 5(土系)

当策划配置BuffTag为Meta | Wood时,则代表这个Buff归属为金系和木系Buff。如果策划配置BuffImmuneTag为Wood | Fire时,则代表这个Buff可以免疫所有木系和火系Buff。由于Tag的实际定义由策划控制,策划可以根据他们的需求组合出各种各样的免疫效果。我将在后面的示例里面描述一些基于Tag和ImmuneTag用法的例子来让读者体会Tag和ImmuneTag者两个概念抽象的简洁之美。

Context代表Buff创建时候的一些上下文数据,它是一个不确定的项,通过外部传入各种自定义的数据,然后在Buff逻辑中使用这些自定义数据。

第二节:Buff执行流程

在Buff从创建到销毁的过程中,我们划分为如下几个阶段:

  1. Buff创建前检查当前Buff是否可创建。一般主要是检测目标身上是否存在免疫该Buff的相关Buff,如果被免疫则不会创建该Buff。

  2. Buff在实例化之后,生效之前(还未加入到Buff容器中)时会抛出一个OnBuffAwake事件。如果存在某种Buff的效果是:受到负面效果时,驱散当前所有负面效果,并给自己加一个护盾。那么这个时候就需要监听BuffAwake事件了,此时会给自己加护盾,并且把所有负面Buff驱散。这意味着一个Buff可能还未生效之前即销毁了(小心Buff的生命周期)。

  3. 当Buff生效时(加入到Buff容器后),我们提供给策划一个抽象接口OnBuffStart,由策划配置具体效果。

  4. 当Buff添加时存在相同类型且Caster相等的时候,Buff执行刷新流程(更新Buff层数,等级,持续时间等数据)。我们提供给策划一个抽象接口OnBuffRefresh,由策划配置具体效果。

  5. 当Buff销毁前(还未从Buff容器中移除),我们提供给策划一个抽象接口OnBuffRemove,由策划配置具体效果。

  6. 当Buff销毁后(已从Buff容器中移除),我们提供给策划一个抽象接口OnBuffDestroy,由策划配置具体效果。

  7. Buff还可以创建定时器,以触发间隔持续效果。通过策划配置时调用StartIntervalThink操作,提供OnIntervalThink抽象接口供策划配置具体效果。

  8. Buff还可以通过请求改变运动来触发相关效果。通过策划配置时调用ApplyMotion操作,提供OnMotionUpdate和OnMotionInterrupt接口供策划配置具体效果。

Buff由于其有着生命周期可控,低耦合(通过监听事件修改逻辑),高内聚、易于扩展的特性,因此通过使用Buff来管理逻辑的话,不仅方便处理各种复杂的行为,同时还能有效的减少开发者的维护难度。

例如延迟触发伤害是游戏中非常常见的需求,在一些开发者的设计中就是直接给角色挂个定时器触发伤害。简单的游戏里这样做没什么大问题,但是如果技能逻辑稍微复杂点,这样就会带来很多问题。例如某天策划提出需求,如果受到控制效果时需要取消该延迟伤害。此时你怎么办,直接干掉timer?结果策划过了两天又提出了个新的需求,还是受到控制效果时,需要这个延迟伤害立即触发,你又怎么办?再又比如说,当角色受到伤害超过1000点时,这个延迟伤害立即触发,你又该怎么做?

这里就体现出Buff的方便之处了,我们可以直接添加一个持续时间为N秒的Buff。Buff销毁时触发伤害。如果需求变更为受到控制时取消伤害,那么我们就在Buff中检查当前是否包含有Tag为Control的Buff。如果有,则设置Buff.bTriggerDamage=false,同时自我销毁。然后在BuffDestroy触发的时候检查是否触发伤害,如果bTriggerDamage为false则不触发伤害。同理,当需求为Buff监听伤害超过1000点伤害立即触发时,我们只需要通过Buff监听OnTakeDamage事件,检查当前受到的伤害值是否大于1000点,如果是则销毁Buff,此时立即触发BuffDestroy并执行伤害效果。

从上面的例子我们可以看出整个控制逻辑都是在Buff内部完成的,不需要各种手动开启/取消定时器。只需要Buff扩展下逻辑检查即可,具有非常好的扩展性和高内聚性。

第三节:Buff修改状态(ModifyState)

Buff可以通过修改状态去影响角色行为逻辑。以下列举一些最常见的状态:

  1. Stun(眩晕状态——目标不再响应任何操控)

  2. Root(缠绕,又称定身——目标不响应移动请求,但是可以执行某些操作,如施放某些技能)

  3. Silence (沉默——目标禁止施放技能)

  4. Invincible (无敌——几乎不受到所有的伤害和效果影响)

  5. Invisible (隐身——不可被其他人看见)

这些状态是高度凝练的精华,抽象到极致的代表。非常多的游戏效果实际上都是这几种状态+运动+动画的组合。这里很多开发者都会有一个设计误区就是把Buff的状态跟运动和动画耦合在一块,比如:眩晕状态一定就是播个眩晕动画,然后击退状态就是击退位移+击退动画。这样最后导致的问题就是状态膨胀,而且各种逻辑耦合,Bug频出,最后维护成本大大提高。

以Stun为例,很多人第一眼看过去就觉得它是个Debuff,是个敌人给我方加的控制Buff。实际上并非如此,Stun可以用到的地方非常多。例如有个技能是野蛮冲撞,释放后2秒内向前移动10米并将敌人推开。那这个Buff的实现就是技能Spell的时候给角色加个Buff,这个Buff会有个Stun状态同时带位移突进效果。挂上这个Buff后,技能施放后角色2秒内就不会响应角色按键移动和释放其他技能的请求了,同时往前突进的效果由Buff控制,将来处理各种位移打断效果也很方便。再比如说有个技能叫寒冰屏障:你被一道寒冰屏障所笼罩,在十秒内不会受到任何物理和法术伤害,但这期间无法移动、攻击或施法。那这个技能的实现也很简单,就是一个十秒的Buff同时添加了眩晕和无敌这两个状态,如果还需要每秒回血,则StartIntervalThink(interval),然后OnIntervalThink的时候Heal当前角色即可。

除了各类战斗效果之外,我们的Buff甚至可以扩展到一些其他场景。比如说打BOSS前有个播过场动画的需求,此时策划希望隐藏Boss和玩家的血条和姓名。那么此时我们完全可以做个Buff,这个Buff扩展个状态HideHpBar,当有这个状态时即隐藏血条和名字就行了。而且我们还可以让这个Buff加上无敌状态,毕竟播过场动画的时候我们不希望玩家或者BOSS真的受到什么伤害。

总而言之,Buff状态除了上面提到几种高度凝练抽象的状态外,我们还可以根据具体游戏的需求去扩展各种特殊状态,以满足策划的需求,同时方便开发者管理逻辑。

第四节:Buff修改属性(ModifyAttribute)

在游戏中Buff的添加与移除是一个频繁的过程。而玩家的属性来源有很多,如等级,装备,成就,任务,时装等等各种各样的来源。相比于Buff,这些模块修改属性的频率要远低于Buff,所以我们一般将玩家的属性划分为两层,第一层时Core(核心层),第二层是External(外部层)。Core层是玩家各个其他模块的属性总和,而External层则是Buff修改属性的总和。两者相加既为玩家的实时属性。

图文无关

第五节:Buff修改运动(ModifyMotion)

现在的MMO中为了增加动作表现力,经常会有很多位移效果,如突进,翻滚,千斤坠,击退,击飞,拖拽,吸引等等。那么这些效果该如何实现呢?而且有时候会遇到各种复杂的运动打断效果,比如击飞时不能被击退,击飞过程又能被冰冻效果定住,然后又有破冰技能击退冰冻物体并解除冰冻效果。面对这些复杂的情况,我们该如何设计呢?

在我们的系统中,运动都是统一通过MovementComponent来管理。因此通过使用MotionModifier来与MovementComponent交互。MovementComponent中有一个CustomMotion,用来具体实现各种运动位移。具体运动实现相关细节我们将在后面的运动章节讲述。

在MotionModifier中,我们会提供一个接口ApplyMotion(motionTypeId,priority, forceInterrupt)来向运动组件请求运动效果。同时通过设置回调UpdateBeforeMovement和UpdateAfterMovement来触发运动前和运动后的Buff效果。下面我们初步介绍下ApplyMotion函数的三个参数:

  • motionTypeId:运动类型id,配置项。包含运动位移参数及相关数据。

  • priority:运动优先级,每个运动都有优先级,低优先级不能打断高优先级。

  • forceInterrrupt:是否忽略优先级,强制打断当前的Motion。

通过这三个参数,我们就能实现各类打断需求了。

比如说击退的运动优先级是100,击飞的运动优先级是200。那么在击飞过程中,施加击退Buff调用ApplyMotion的时候会返回false,这时可以销毁掉这个击退Buff,即击飞时无法击退。如果击飞时被冰冻,且冻在半空中停止不动,那么我们就需要设计一个静止Buff:运动优先级是300,作用效果是速度设置为0,不受重力影响,同时修改Stun状态并挂载冰冻特效。当破冰技消除冰冻效果时,则设置破冰Buff的位移效果为击退,设置运动优先级为100,forceInterrupt为true。此时ApplyMotion强制打断运动,冰冻Buff会触发OnMotionInterrupt回调,在此接口中冰冻Buff自我销毁即可。

Buff修改运动仅代表修改运动轨迹。比如说击退仅仅只是以直线移动一段距离。而击飞是以曲线移动一段距离。同理轻功的翻滚,突刺其实都与击退是相同的运动轨迹。他们都是在一定的时间内以直线到达目标地点,且都设置Stun状态。它们不一样的地方其实仅仅只是动画层的表现的不同。(可能策划还会设置不同的Tag和ImmuneTag标记下)

我们要牢牢记住,玩家看起来各种花哨的轻功击退击飞等位移效果实际上是State+Motion+Animation的组合。掌握住了这一点,我们就可以通过简单的组合实现各种丰富的效果了,而不会被各种花哨的效果所迷惑,以为他们都是不一样的效果,导致最后设计出无比庞杂且难以维护的系统了。

第六节:Buff监听事件

Buff可以通过监听各类事件,执行特定逻辑或者修改事件数据来实现各种效果。

最常见的事件监听一般有:

  • OnAbilityExecuted,监听某个主动技能执行成功。常用于被动技能Buff,比如说角色施法时有10%概率获得30%的攻速提升。那么我们通常是Buff-A监听OnAbilityExcuted事件,然后10%概率添加Buff-B。Buff-B的作用是修改玩家属性,增加30%攻速。

  • OnBeforeGiveDamage,OnAfterGiveDamage监听我方给目标造成伤害时触发。比如说对目标造成的伤害有10%概率无法被闪避,那么这个效果我们就可以通过监听OnBeforeGiveDamage的流程来实现。当执行伤害流程时,在计算伤害前我们抛出一个事件event。event里面有当前伤害数据。Buff在调用OnBeforeGiveDamage(event)时,修改event.Damage.DamageFlag |= DamageFlag_NotMiss,标注该伤害无法被闪避就行了。又或者如果有一个需求是给目标造成伤害后有10%几率触发DOT伤害效果,那么我们在OnAfterGiveDamage的时候取出event.Target并给这个目标加个DOT类Buff即可。

  • OnBeforeTakeDamage,OnAfterTakeDamage监听我方受到伤害时触发。如护盾类Buff通常在OnBeforeTakeDamage的时候修改伤害数据。又或者有某些Buff在受到伤害后可以触发各类效果就可以通过监听OnAfterTakeDamage事件来触发指定逻辑。

  • OnBeforeDead,OnAfterDead监听我方死亡时触发。如免疫致死效果可以通过监听OnBeforeDead事件修改角色当前的Hp>0,从而让角色提前退出死亡流程以避免死亡。死亡后触发额外效果,如爆炸或者召唤其他生物都可以通过监听OnAfterDead事件来执行。

  • OnKill事件,监听我方击杀目标时触发。如当击杀目标后获得治疗效果回复即可通过监听到Kill事件时给自己加一个HOT的Buff来实现。

开发者可以通过扩展各类事件列表,让Buff通过监听对应事件就能执行任意逻辑。不需要与任何模块耦合,只需要抛出事件,监听事件,执行逻辑即可获得Buff功能上的扩展。

总结

以上我们通过六个小节讲述了Buff系统主要模块的实现方法。通过这样的设计,我们让Buff的深度和扩展性都能够得到了极大的提升,几乎能实现各种各样的效果。足以让策划的创意得到最大限度的发挥。

示例

为了让读者便于直观理解,我会提出一些具体实现的例子以供参考:

  • 问:Buff互斥效果也很常见,怎么做?

  • 答:BuffTag和BuffImmuneTag可轻松实现。比如说火系Buff和水系Buff互斥。无论策划的需求是存在水系Buff的时候无法添加火系Buff,还是存在水系Buff的时候添加火系Buff会驱散水系Buff都可以实现。第一种情况最简单,水系Buff配置Tag 为Water的时候配置ImmuneTag为Fire。此时存在水系Buff的时候即可免疫火系Buff了。第二中情况也好办。配置BuffTag为Water。当OnBuffStart的时候调用驱散接口DispelByTag(Fire),驱散掉所有火系Tag相关Buff即可。

  • 问:霸体效果怎么实现,而且假如说存在破霸体效果又怎么实现,而且Boss的霸体效果完全不受影响又怎么实现?万一还存在特殊效果可以让Boss受到控制怎么办?

  • 答:我们可以定义两个BuffTag:WeakControl(弱控制)和StrongControl(强控制),普通霸体效果通过Buff配置ImmuneTag:WeakControl即可免疫控制效果。如果是破霸体效果,我们给这个Buff的Tag标记StrongControl就行,同时Boss的Buff配置ImmuneTag为WeakControl | StrongControl(免疫弱控制和强控制)就满足需求了。如果存在某个特殊的效果能让Boss受到控制效果的话,那这个Buff的Tag不要标记WeakControl和StrongControl就行了,这样它就无法被免疫掉了。看起来复杂的霸体破霸体效果实际实现就这么简单,就这么清晰,不需要引入任何新的系统。

  • 问:Buff存盘那块如何处理跟施法者相关的属性数据?如施法者可以给目标添加一个强力的毒Buff,具体伤害数值有施法者属性决定,离线后依旧生效,直到Buff时间结束才移除。

  • 答:这块我们的处理依旧很简单,Buff依然设置bNoCaster=true。但是在Buff创建的Context里面我们设置Context.DamageValue为根据施法者属性计算出来的伤害数值。然后Buff持续造成伤害的时候直接取Context.DamageValue即可。至于说想要玩家离线再上线,Caster离线再上线后,毒的伤害数值还能实时修改的话,这样的需求是不存在的,如果一定要做,当然也能做,只是麻烦一点而且也没有必要。这样的需求一般仅仅存在测试的大脑中,策划是不会有这样的玩法需求了。

  • 问:常见的基于指定地点延迟触发的AOE效果怎么实现?当技能施法成功后就延迟触发,不会被打断AOE效果。(如果能被打断,我们可以用引导类技能轻松实现)

  • 答:我们将技能标记为可指定目标地点释放,当技能Spell的时候我们先给自己加一个Buff,这个Buff仅仅用于延迟效果(当然可以有更多的可能性,如监听到某种事件立即结束并触发AOE效果),当Buff持续时间到了的时候在OnBuffDestroy的时候创建AOE效果Buff。这个AOE Buff会调用StartIntervalThink函数,在OnIntervalThink的时候通过Buff:GetAbility():GetCastPosition()为基准位置检查周围的敌方单位是否在AOE半径内,如果是,则施加作用效果。

5. 如何实现一个强大的MMO技能系统——子弹

前言

子弹又称抛射物(Projectile),是游戏中非常常见的一个概念。它常见于各大远程职业的技能效果:通过施法创建出一个带有弹道的物体,经过飞行后命中目标并触发各类效果。在游戏中,我们经常能看见各种各样复杂多变的子弹效果,那么它们是如何实现的呢?

正文

游戏,是欺骗视觉的艺术。对渲染有了解的开发者对此一定有深刻的体会,而子弹系统的实现,也正是完美的贯彻了这种理念。

看起来变幻莫测的子弹系统本质上只有两类:

  1. 追踪子弹(TrackingProjectile)。子弹具有一个目标Target,它创建出来后以直线速度飞向指定目标。命中目标或者到达目标位置后调用技能执行OnProjectileHit(projHandle, hitTarget, hitPosition)触发效果并自我销毁,hitTarget可为空(目标有可能在飞行途中死亡或消失)。

  2. 线性子弹(LinearProjectile)。子弹为等腰梯形检测盒子,无需目标,创建出来后沿着特定方向飞行一段距离。飞行途中进行相交测试(SweepTest),对检测范围内的目标调用技能执行OnProjectileHit触发效果。到达目的地后触发OnProjectileHit(此时hitTarget为空)并自我销毁。

相信有很多开发者看到这里,可能会比较疑惑为什么没有抛物线子弹,这种像炮弹一样经过抛物线轨迹飞行,落地后会爆炸的子弹难道不是非常典型的子弹吗?然而这事实上并不需要用子弹来实现,我将在后文中讲述它的实现方式。

子弹系统的实现大致可以划分为如下几个流程:

1. 子弹的创建

我们通过ProjectileManager::CreateTrackingProjectile(…)和ProjectileManager::CreateLinearProjectile(…)来创建一个子弹,它返回子弹的Handle以供调用方使用。

创建追踪子弹需要的参数主要有:

  • Owner:表示子弹的创建者

  • Ability:表示子弹关联的技能

  • FromPosition:子弹的出发地点

  • Target:子弹追踪的目标

  • Speed:子弹的飞行速率

创建线性子弹需要的参数主要有:

  • Owner:表示子弹的创建者

  • Ability:表示子弹关联的技能

  • FromPosition:子弹的出发地点

  • Velocity:子弹的飞行速度和方向

  • StartWidth,EndWith,Distance:(等腰梯形检测盒)起点宽度,终点宽度,飞行距离

  • FilterTargetInfo:子弹筛选目标信息

2. 子弹的位置更新

追踪子弹基于直线速度飞向目标,运动轨迹如下图:

线性子弹基于插值更新位置,运动轨迹如下图:

3. 子弹的命中处理

由于子弹在创建时传入了Ability参数,那么当子弹检测到命中目标后便可以通过调用GetAbility().OnProjectileHit(projHandle, hitTarget, hitPosition)在Ability Class中执行命中逻辑,这样它就可以在技能模块中通过执行策划配置来实现各种效果了。子弹本身是没有任何特殊逻辑的,它只有位置更新,检查命中目标和是否结束并销毁这几个简单的功能。

子弹系统的逻辑本质上就是这么简单。当然,这并不意味着子弹系统到此就结束了。以上所讲述的仅仅是子弹的逻辑运行部分,这些过程都是完全运行在服务端的,客户端并不会收到任何消息。

4. 子弹的客户端表现

子弹系统如果仅仅包含这些逻辑代码当然是完全不够的。假如不通过某些手段将这个过程在客户端渲染出来,那么玩家感知到的情况可能就是这样的:角色抬手施法后什么也没发生,过了几秒后目标突然就受到了一个伤害。这样很明显是不符合直观感受的,那么为了让这个过程看起来合乎情理,我们就需要借助一个媒介在客户端向玩家来展示子弹的行为。

我们选择通过特效系统来向客户端展现绚丽的子弹效果。这就意味着整个客户端其实是没有Projectile这个概念的,有的只是各种各样的自定义特效。

开发者非常容易陷入的思维误区就是服务器与客户端子弹是一致的,它们必须有相同的逻辑。而实际上这完全没有必要。

比如说最常见的子弹需求:法师释放一个小火球飞向目标。如果为了让这个技能表现效果更好,我们会让火球从法师的手上(攻击挂点)创建出来,然后飞向目标的胸前(受击挂点),而且我们可能还会给火球加一些随机弧线来让每一个火球的轨迹都不一样,同时火球命中目标后还会有个火焰溅散开来的效果。

那么在上面的需求中,我们也需要让服务器的子弹做这些复杂的模拟吗?当然没必要了。服务器只需要创建一个线性子弹,同时告诉客户端创建一个特效:这个特效需要在施法者手部的攻击挂点位置被创建,目的点是目标的胸口受击挂点,特效的飞行速度为N米/秒。特效是纯客户端的,而且有自己的行为脚本。本例中的特效可能会挂载一个TrackingMoveScript,它会在创建的时候读取服务器发过来的数据来确定特效的初始位置,速度和目标,同时它还有一些自己的配置参数,比如说随机弧线偏移,碰撞时是否触发其他特效等等信息。

这样整个的实际流程就是:

服务器创建追踪子弹直线飞向目标,同时告诉客户端创建火球特效并设置相关数据。子弹经过一段飞行时间命中后调用Ability::OnProjectileHit造成伤害。

客户端创建一个火球特效,特效读取数据运行自定义特效脚本,从角色手上创建出来并以不同的弧线飞向目标,命中目标后火球溅射然后飞散消失。

整个过程都是完全独立并行的。

也许会有开发者会觉得这样不就会有位置误差吗?也许服务器命中目标后客户端的火球还没有到达目标地点呢?

然而这并不会有什么问题。MMO并不是格斗游戏,也不是FPS有爆头需求,它不需要保证严格的位置一致性,只要肉眼看起来没有明显误差就可以。事实上以正常的网速,基本不会出现不符合直观感受的情况。但是如果存在一个大型BOSS,比如向恐龙那样身体前后很长,并且从嘴里发射子弹的话,那么此时我们就需要在子弹的创建参数FromPosition那里取恐龙的位置并加上ForwardOffset才行。

为什么抛物线炮弹不是子弹

我们知道游戏中发射炮弹的需求通常都是这样的:

有个炸弹哥布林,它的技能就是朝玩家所在的地点附近扔炮弹,炮弹落地后会发生爆炸并对周围的玩家造成一定量的伤害。

通常开发者第一反应就是创建个抛物线子弹,然后在飞行途中检查是否碰到了地面,如果碰撞则直接触发一个爆炸范围伤害。这样的设计是很直观的,然而游戏往往是欺骗视觉的艺术。

让我们再细细思考下这个技能的核心是什么?爆炸,落地的瞬间爆炸。让我们摒除画面的误导,想想如果一个技能仅仅是N秒后指定地点造成范围伤害,那么我们需要用到子弹吗?很明显不需要,一个buff延迟触发伤害就解决了。那么炮弹本质上不正是这样的逻辑吗?我们有起始点和目标点,有飞行速度,自然而然的整个炮弹的生命周期就可以求出来了。我们创建一个Buff,设置它的持续时间为炮弹的生命周期,Buff销毁时触发爆炸逻辑,Buff初始化时创建一个特效,由这个特效负责在客户端去模拟抛物线运动。

所以在这个需求中,其实根本没有什么抛物线子弹,有的只是服务器Buff和客户端模拟抛物线飞行的炮弹特效。

同理,类似于龙持续喷火这样的技能也不需要用到子弹。服务器只需要创建一个buff持续触发范围伤害,同时客户端则由特效根据服务器给出的位置数据去模拟喷火效果就行了。

总结

在我们的系统中,子弹实际上是分为两部分。服务器逻辑层和客户端表现层,逻辑层实现简单,表现层实现复杂,由特效系统统一实现各种表现效果。同时子弹的伤害效果由技能触发,从而保证与技能系统具有一致的扩展性。

我们在开发的过程中不要被表面的绚丽多彩的特效迷花了双眼,应该去探求它真正的内在逻辑是怎样的。它是否真的是一个子弹,还是说仅仅只需要一个Buff就能实现了。

示例

我们将举出一些子弹相关的例子供大家直观理解。

问:闪电链这样的技能如何实现?

答:技能在施法(Spell)的时候的创建一个追踪子弹,目标为当前技能选择的目标A,同时告诉客户端创建一个闪电特效(特效Handle由服务器创建后下发),客户端设置闪电特效的初始点为角色的手,终点为目标A的胸口受击点,服务器记录特效的Handle用于在后面更新闪电特效的位置。当追踪子弹命中目标A的时候,技能保存当前的目标A信息,同时筛选下一个其他单位B。此时再创建一个新的追踪子弹设置起始点为当前目标A的位置,追踪目标为B,并且通过特效Handle通知客户端更新特效的起始点为A的胸口受击点,终点为B的胸口受击点。这样依次执行,就达到闪电链的效果了。

问:一个技能创建多种子弹并且命中效果不一样怎么实现?

答:你可以在创建子弹的时候保存下子弹的Handle,然后在OnProjectileHit的时候对比下Handle,从而决定是否执行哪种效果。

问:如果存在一个Buff,能够监听某个技能创建的子弹。并让该子弹在命中目标的时候有几率分叉创建出两个新的子弹怎么办?

答:让子弹在Ability::OnProjectileHit的时候抛出事件,此时Buff监听子弹命中事件,获取这个子弹的Ability,检查该Ability是否为Buff指定的Ability,如果是,则让该技能再创建出新的子弹即可。

问:像钩子这样能钩住人并拖回来的技能用子弹怎么实现?

答:技能施法时创建线性子弹向前方运动,同时创建一个钩子特效设置起始点为手,目标点为子弹终点位置,钩子以一定速度向前飞行。

当线性子弹飞行到终点并且没有命中到任何单位时:

此时创建一个新的线性子弹,初始点为终点位置,目标点为施法者位置。同时更新钩子特效的速度方向为返回方向。当线性子弹回到施法者位置后触发OnProjectileHit,此时手动Destroy钩子特效。

当线性子弹飞行过程中命中单位时:

此时立即销毁该子弹并给命中目标添加一个拖拽Buff,它修改目标的运动,让目标沿直线向施法者的位置移动,速度和钩子飞行速度一致,到达目标点后Buff自我销毁。子弹命中目标的同时会创建一个新的线性子弹,初始点为子弹当前位置,目标点为施法者位置,以相同的速度返回施法者位置,同时修改钩子特效参数,设置特效的末端钩子为绑定目标胸口受击点,特效速度为向施法者位置飞行。当线性子弹到达施法者的位置时销毁钩子特效。


to be continued…