如何设计技能系统

文档版本修订记录修订日期
1.0.0init2023-1-7
1.0.1修复部分显示错误2023-1-7

在你开始阅读这篇文章之前,建议优先了解如下内容,否则会导致部分段落无法理解

推荐结合 hepta文档 一同进行阅读,方便理解数据是如何流转的,在正式开始介绍思路之前,我们要理清楚一个问题:

「当我们面对一个复杂系统时,无论采用何种设计模式进行拆分,到底在干什么?或者说到底在解决什么问题?」

曾经看到过别的项目在设计技能相关玩法时,几乎将所有可能的属性配置都集中在了一张表中,比如策划想要一个子弹效果,子弹包含移动速度等自身属性,那么这张表就需要跟着扩充,随着技能越来越复杂,想要正确的维护这张表几乎变成了一个不可能完成的任务

而我们真正在做的是,将技能的复杂性平摊到一个个简单节点中,让复杂性可控,理解了这一点,我们就可以正式开始了

目标

本文希望帮助 Unity 开发者,在面对复杂技能时,提供一套解题思路,理明白应当如何对策划的需求进行合理的拆分

当然这个思路不可能适合所有技能系统,所以具体怎么做、如何做,还需要开发者结合自身项目的实际情况进行甄别

PS:本篇文章关于表现层的内容会做大幅删减或者省略,关注的核心只有逻辑层架构设计以及具体问题如何解决上,在 ET 设计的基础上,逻辑层也无法看到表现层的具体实现,全都是一个个事件

具体技能的拆分

在我们项目开始对需求时,我有一个非常喜欢跟策划举的例子

角色 A 选择 最近的 3 个目标,分别释放一个 火球,当 火球 击中目标后,会在原地爆炸,造成一个半径为 1 米的 AOE,并对 AOE 范围内的所有 敌人 造成 角色 A 攻击力 * 150% 的伤害

后面我们简称为 火球术,针对这个技能,我们做如下系统的拆分

  • 选择器
    • 最近的 N 个目标
  • 碰撞检查器
    • 圆形碰撞器
  • 效果管理器
    • 飞行效果
    • AOE 效果
    • 伤害效果

按照这个拆分结果,我们重新对如何构建 火球术 进行解释

  • 选择器 中找到 最近的 3 个敌人
  • 然后对这 3 个敌人通过 效果管理器 释放 飞行效果 节点
    • 飞行效果 击中目标时
    • 飞行效果 自身的容器,通过 效果管理器 释放 AOE 效果 节点
      • 当满足 AOE 效果 释放时机时
      • 使用 碰撞检查器 检查合法的目标,最后再次流转到 碰撞检查器
      • 释放 伤害效果

这样我们就完成了对技能的初步分类,将一个大任务 火球术 合理的拆分成了小任务,理论上每个任务仅做一件事,任务和任务之间是相互 隔离 的,彼此互不影响,通过组合来达成策划的技能需求

核心内容数据结构 & 实现

得益于 Luban 强大的设计能力,我们可以在 Excel 中完成非常合理的数据结构规划,做到符合直觉的高度复用,下面我们仍然使用 火球术 这个技能,对节点的数据结构进行合理的定义

效果管理器 (SkillEffectComponent)

首先我们要完成节点的分发,如:飞行效果、AOE 效果、伤害效果,通过对配置表 ID 的分段,选择到底执行哪一个过程

无论这个节点是什么,统统都是过程,比如伤害效果,就是 A 对 B 造成了 N 点伤害,要注意的是,这里的所有节点均 无状态

此时需要对过程进行抽象,我们定义一个 ASkillEffectHandler 的抽象类,并添加 Handle 抽象方法

public abstract class ASkillEffectHandler
{
    public abstract void Handle(int effect_id, Unit from_unit, Unit to_unit);
}

这里的 Handle 就是不同节点的处理过程,而所有的节点,统一通过 SkillEffectComponent 进行管理,因此有如下定义

public sealed class SkillEffectComponent : Entity
{
    public static SkillEffectComponent Instance;
    public readonly Dictionary<SkillEffectType, ASkillEffectHandler> handlers = new();
}

public static class SkillEffectComponentSystem
{
    // handlers 的初始化按自己喜好, 这里就省略了
    public static void Fire(this SkillEffectComponent self, int effect_id, Unit from_unit, Unit to_unit) 
    {
        var type = (SkillEffectType)(effect_id / 你节点的分段);
        
        self.handlers.TryGetValue(type, out var handler);
        
        handler?.Handle(effect_id, from_unit, to_unit);
    }
    
    public static void Fire(this SkillEffectComponent self, List<int> effect_ids, Unit from_unit, Unit to_unit)
    {
        foreach(var id in effect_ids)
        {
            self.Fire(id, from_unit, to_unit);
        }
    }
}

到了这里,我们就完成了节点之间流转的功能,接下来我们深入到每个节点的具体实现中

伤害效果

对具体伤害构建数据结构时,我们需要考虑如下情况:

  • A 伤害 B,使用 A 的攻击 * 150% 造成伤害
  • A 伤害 B,使用 B 的最大血量 * 8% 造成伤害

这里同样都是 A 对 B 做了一件事,但是属性获取来源是完全不一样的,而这个属性获取方式,可能在未来扩充的节点中复用,于是便有了如下定义

下面的定义都是在 Luban 中进行的,代码只是展示方便

public class NumericParam
{
    // 使用 from_unit/to_unit 的属性
    public bool self_numeric;
    // 使用哪个属性
    public NumericType numeric_type;
    // 千分比
    public int param;
}

public class SkillEffectDamageConfig
{
    public int id;
    public NumericParam damage_param;
}

如此我们就完成了伤害节点的配表定义,但由于 NumericParam 我们打算作为通用的属性获取方式,所以可以考虑在 SkillEffectComponent 中增加相应的扩展方法,统一获取过程

public static class SkillEffectComponentSystem
{
    // ... 略
    public static long GetValue(this NumericParam param, Unit from_unit, Unit to_unit)
    {
        var numeric = param.self_numeric ? from_unit.GetComponent<NumericComponent>() : to_unit.GetComponent<NumericComponent>();
        
        var value = numeric.Get(param.numeric_type);
        
        return value * 1000 / param.param;
    }
}

最后实现伤害节点的过程,获取配置,然后交给结算组件即可完成

public sealed class SkillEffect_Damage_Handler : ASkillEffectHandler
{
    public override void Handle(int effect_id, Unit from_unit, Unit to_unit)
    {
        // luban 可以保证此处不为空, 所以不需要空判断
        var config = ConfigComponent.Instance.Get<SkillEffectDamageConfig>(effect_id);
        
        var damage = config.damage_param.GetValue(from_unit, to_unit);
        
        // 这里就按照策划给的公式,实现对应的过程
        结算组件.计算(from_unit, to_unit, damage);
    }
}

AOE 效果

到了这里我们要思考一下,在策划的规划中,到底 AOE 是什么。下面是一个简单的 AOE 要包含的功能

  • 开始时,做一件事
  • 持续时,每隔几帧,做一件事
  • 结束时,做一件事
  • 支持形状配置的多选

如果你项目中的需求更复杂,自己扩充功能,或者增加一个全新的 AOE 节点即可

这样我们定义数据结构就很清晰了,具体参考如下

public class SkillEffectAOEConfig
{
    public int id;
    // 持续多少帧
    public int duration_frame;
    // 间隔多少帧
    public int interval_frame;
    // 开始时干什么
    public List<int> start_effects;
    // 持续时干什么
    public List<int> duration_effects;
    // 结束时干什么
    public List<int> end_effects;
    // 这个 AOE 长什么样
    public AShape shape;
    // 怎么选, 这里选择器先省略定义, 后面会在选择器相关的内容提到具体数据结构
    public SkillSelectorParam selector;
}

注意这里的 effect id 是一个 List,可能策划希望 AOE 一边触发伤害,一边做其他的事情

接着我们要实现对应的 AOE 添加过程,这里要注意了,由于 所有 Handler 都是 无状态 的,所以无法表示一个持续的过程,此时我们需要将代码进行分离,Handler 负责创建一个 AOE 实体,而 AOE 实体去保存当前 AOE 的状态,具体参考如下

public sealed class SkillEffect_AOE_Handler : ASkillEffectHandler
{
    public override void Handle(int effect_id, Unit from_unit, Unit to_unit)
    {
        // 这里的过程非常简单,给释放 AOE 的单位添加一个 AOE 组件即可
        from_unit.AddChild<SkillEffect_AOE_Component, int>(effect_id, true);
    }
}

public sealed class SkillEffect_AOE_Component : Entity
{
    public SkillEffectAOEConfig config;
    public Unit self_unit => GetParent<Unit>();
    // 当前的持续帧数
    public int cur_duration_frames;
    // 当前的间隔帧数
    public int cur_interval_frames;
    
    public Vector3 position;
    public Quaternion rotation;
}

public sealed class SkillEffect_AOE_ComponentAwakeSystem : AwakeSystem<int>
{
    // 过程略
    // 初始化 config,并释放 AOE 中 start_effects
}

public sealed class SkillEffect_AOE_ComponentDestroySystem : DestroySystem<SkillEffect_AOE_Component>
{
    // 过程略
    // 因为我们所有 AOE 组件是进池的,所以一定要注意清除旧数据
}

// 因为 AOE 需要每帧进行判断,此处还需要增加 UpdateSystem
// 但如果你的项目对暂停等方式实现比较复杂,需要考虑如何设计此处更新方式
// 这里就省略了

public static class SkillEffect_AOE_ComponentSystem
{
    public static void Update(this SkillEffect_AOE_Component self)
    {
        if(在间隔内)
        {
            self.Fire(self.config.duration_effects);
            重置间隔;
        }
        
        if(持续时间结束)
        {
            self.Fire(self.config.end_effects);
            self.Dispose();
            return;
        }
        
        self.cur_duration_frames--;
        self.cur_interval_frames--;
    }
    
    public static void Fire(this SkillEffect_AOE_Component self, List<int> effects)
    {
        // 先拿到当前 AOE 能碰到哪些单位
        using var units = ListComponent<Unit>.Create();
        
        碰撞检测器.检查(units, self.config.shape, self.position, self.rotation);
        
        选择器.选择(self.self_unit, units.List, self.config.selctor, target=>
        {
            // 符合条件的回调
            // 接着进行节点状态的流转
            SkillEffectComponent.Instance.Fire(effects, self.self_unit, target)
        }));
    }
}

到这里你应该对效果如何设计有大概了解了,其核心就是通过不同方式的流转,在合适的时机告诉 SkillEffectComponent 释放下一个效果

飞行效果

这里的飞行也可以理解为一个子弹,具体实现本质上和 AOE 效果没区别,同样有一个 fly 实体,用于保存当前飞行物的运行状态,也同样在 Handler 中 对 from_unit 添加这个 fly 实体即可

而 fly 实体中具体实现,无非就是把 AOE Update 函数判断改为,当子弹飞到目标时 释放子弹配表中的效果

Effect 节点的额外说明

经过这 3 个不同节点的实现,我们可以发现,fly 和 AOE 拥有接着流转的权限,而伤害节点没有。在此设定中,我们认为 伤害节点 是 Final 节点,任何单项流转到此时,就会结束流转

这样的设计会导致 链表环 的问题,即 A->B->A,这样就死循环了,无法结束,这个问题会在后面 Luban 相关的内容展开

碰撞检查器

这里的碰撞器和 Unity 中的物理没有任何关系,请不要混淆,我们希望战斗代码可以原封跑在服务器上的,同时也跟 MonoBehaviour 没有任何关系

经常看到很多开发者离开 MonoBehaviour 就不会写代码了… 如果你也是,建议认真研究一下 ET 等优秀的框架

在我们游戏核心战斗中,单位并不是特别多,最多估计也就十来个,所以这里实现的碰撞可以非常粗暴,遍历所有 Unit,检查和当前形状是否相交即可,在此之前我们需要对形状碰撞过程进行抽象

public abstract class AShapeHandler
{
    public abstract void Check(ListComponent<Unit> result, AShape shape, Vector3 skill_position, Quaternion skill_rotation);
}

接着定义碰撞管理器

public sealed class CollisionComponent : Entity
{
    public static CollisionComponent Instance;
    // key = AShape 的具体类型
    public readonly Dictionary<Type, AShapeHandler> handlers = new();
}

// 初始化 略
public static class CollisionComponentSystem
{
    public static void Check(this CollisionComponent self, ListComponent<Unit> result, AShape shape, Vector3 skill_position, Quaternion skill_rotation)
    {
        self.handlers.TryGetValue(typeof(shape), out var handler);
        handler?.Check(result, shape, skill_position, skill_rotation);
    }
}

剩下的就是碰撞检测的过程了,圆和圆是否相交,圆和矩形是否相交等,篇幅限制这里就不展开说明了

选择器

在上面的介绍中,针对 AOE 碰撞到的单位,我们通过选择器对这一组单位进行过滤,决定哪些单位才是有效对象,但是在技能释放前,我们也需要使用 选择器 来选择有效目标,因此将其单独剥离成一个无状态的组件

同样的,我们依然从数据结构开始

public class SkillSelectorParam 
{
    // 用 Enum 标记当前选择器是什么类型
    // 如:最近的,最远的,血量最少的等等
    public SkillSelectorType type;
    // 是否也会选择自己
    public bool include_self;
    // 选多少个
    public int select_count;
    // 条件 ID,后面会在条件检查组件中说明
    public int condition_id;
    // 额外参数,一般使用在血量最少的选择器类型中
    public ASkillSelectorExtraParam extra;
}

// 最小血量参数使用示例
public class SkillSelectorExtraNumeric : ASkillSelectorExtraParam
{
    // 那个属性
    public NumericType numeric;
}

完成配表的数据结构后,我们同样需要对选择器进行抽象,定义 ASkillSelectorHandler 抽象类,并定义 Select 抽象函数

public delegate void SelectorValidTarget(Unit target);

public abstract class ASkillSelectorHandler
{
    public abstract bool Select(Unit                from_unit,
                                ICollection<Unit>   collection,
                                ASkillSelectorParam selector,
                                SelectorValidTarget execute);
}

为了统一管理,我们定义 SkillSelectorComponent 实体

public sealed class SkillSelectorComponent : Entity
{
    public static SkillSelectorComponent Instance;
    public readonly Dictionary<SkillSelectorType, ASkillSelectorHandler> all_handlers = new();
}

// 初始化 略

public static class SkillSelectorComponentSystem
{
    public static bool Select(this SkillSelectorComponent self,
                                    Unit                        from_unit,
                                    ASkillSelectorParam         selector,
                                    SelectorValidTarget         execute)
    {
        using var all_units = ListComponent<Unit>.Create();
        获取所有单位(all_units);
        return self.Select(from_unit, all_units.List, selector, execute);
    }

    public static bool Select(this SkillSelectorComponent self,
                                    Unit                        from_unit,
                                    ICollection<Unit>           collection,
                                    ASkillSelectorParam         selector,
                                    SelectorValidTarget         execute)
    {
        self.all_handlers.TryGetValue(type, out var handler);
        
        if(handler is null)
        {
            return false;
        }
        
        return handler.Select(from_unit, collection, selector, execute);
    }
}

接着我们选一个相对复杂的选择过程,从给出的 collection 组中,挑选 3 个血量最低的单位,我们将这个选择过程称为 最低属性选择器

public sealed class SkillSelector_MaxNumeric : ASkillSelectorHandler
{
     public override bool Select(Unit                from_unit,
                                 ICollection<Unit>   collection,
                                 ASkillSelectorParam selector,
                                 SelectorValidTarget execute)
    {
        using var valid = ListComponent<Unit>.Create();
        
        if(selector.extra is not SkillSelectorExtraNumeric numeric_extra)
        {
            throw 参数不对异常;
        }
        
        foreach(var unit in collection)
        {
            if(!条件检查器.合法校验(unit))
            {
                continue;
            }
            valid.Add(unit);
        }
        
        if(valid.Count <= 0)
        {
            return false;
        }
        
        _Sort(valid.List, numeric_extra);
        
        int max = selector.count >= valid.Count ? valid.Count : selector.count;
        
        for(int i = 0; i < max; i++)
        {
            execute?.Invoke(valid.List[i]);
        }
        
        return true;
    }
    
    private void _Sort(List<Unit> units, SkillSelectorExtraNumeric extra) 
    {
        // 通过 unit 上的 NumericComponent 动态获取 extra 中定义的类型数据,进行排序
        // 具体过程 略
    }
}

其他类型的选择器也是同理,根据不同的策划需求,定制不同的选择过程,如果有参数不统一的情况,借助 Luban 强大的继承体系,可以规划出非常合理的数据结构

条件检查器

这个组件就比较有趣了,通常为了解决一些条件表达式的问题,大家似乎很热衷于让策划在配置时手动填写一个 string,来解决一切表达式问题,但我个人对这种方式持反对态度

这种方式相当于把一部分代码写在 excel 中,这样非常不安全,而且有问题也非常不直观,尤其碰到一些复杂的表达式时,所有东西都写在一个单元格内,毕竟 excel 不是合格的 IDE,策划也不是合格的程序

曾经听过某个公司允许策划在配表时直接写 lua 代码,这位策划写完没测试,直接推上去了,这段有问题的代码居然还被发布到了线上,更神奇的时,后面策划发现这里有问题,又自己偷偷改回去了... emmm...

我给出的解决方案,依然是分发,和上面组件一样,只不过这次定义了一个 SkillConditionComponent,这样一切都是可控的,我们既保证了扩展性,同时也限制了策划配表的极限,同样的,我们先从数据结构开始

public class ConditionParam
{
    // true  = &&
    // false = ||
    // 这个功能之前考虑了很久,到底要不要支持 A && B || C 这种复杂情况
    // 在我们实际使用过程中,几乎就没碰到这种需求,所以一个简单的开关就 ok 了
    public bool one_by_one;
    // 检查哪些条件,比如:当前对象 A 血量 > 100 && A 处于眩晕状态
    // 这样就需要填两个 ID
    public List<int> conditions;
    // 同样是 A 攻击 B,判断条件可能完全不同
    // 可能是 A 的血量 > 100 才成立,
    // 也可能是 B 处于眩晕状态 才成立
    public bool use_from;
}

为了解决分发问题,我们这里也要对条件检查过程进行抽象,定义 ASkillConditionHandler 抽象类,并定义 Check 抽象方法

public abstract class ASkillConditionHandler
{
    public abstract bool Check(Unit unit, int id);
}

SkillConditionComponent 和上面其他组件一致,同样是无状态的分发实体

public sealed class SkillConditionComponent : Entity
{
     public static SkillConditionComponent Instance;
     // key = 条件类型的 enum
     public readonly Dictionary<SkillConditionType, ASkillConditionHandler> handlers = new();
}
// 初始化 略
public static class SkillConditionComponentSystem
{
    public static bool Check(this SkillConditionComponent self, Unit target, int condition_id)
    {
        if(condition_id == 0)
        {
            return true;
        }
        
        var type = (SkillConditionType) condition_id / 你的分段;

        self.handlers.TryGetValue(type, out var handler);

        return handler is null || handler.Check(target, condition_id);
    }
    
    // 这里提供对 ConditionParam 的支持
    public static bool Check(this SkillConditionComponent self,
                                  Unit                    from_unit,
                                  Unit                    to_unit,
                                  ConditionParam          param)
    {
        var target = param.use_from ? from_unit : to_unit;
        // 当参数为空时,默认认为条件成立
        if(param.conditions is null || param.conditions.Count <= 0)
        {
            return true;
        }
        // 所有条件成立
        if(param.one_by_one)
        {
            foreach(var id in param.conditions)
            {
                if(!self.Check(target, id))
                {
                    return false;
                }
            }

            return true;
        }

        // 任意条件成立
        foreach(var id in param.conditions)
        {
            if(self.Check(target, id))
            {
                return true;
            }
        }
        return false;
    }
}

接着我们来实现一个属性大小比较的过程,称之为 NumericValueConditionHandler,但是在此之前,我们先看一下属性比较条件配置的数据结构

public class NumericValueConditionConfig
{
    // 比较哪个属性
    public NumericType numeric;
    // 自定义的比较符 enum
    public CompareType compare;
    // 和哪个值比较
    public long value;
}

这里出现了一个 CompareType,我们需要做如下定义

public enum CompareType
{
    [LabelText("占位符")]
    None = 0,
    
    [LabelText("等于")]
    Equal = 1,
    
    [LabelText("大于")]
    Great = 2,
    
    [LabelText("大于等于")]
    GreatEqual = 3,
    
    [LabelText("小于")]
    Less = 4,
    
    [LabelText("小于等于")]
    LessEqual = 5,
    
    [LabelText("非")]
    Not = 6,
}

最后给 ComprateType 定义一个比较过程,这里以 long 和 long 的比较举例,其他类型也只是重载一下

public static class CompateUtil
{
    public static bool Compare(CompareType compare, long a, long b)
    {
        switch(compare)
        {
            case CompareType.Equal:      return a == b;
            case CompareType.Great:      return a > b;
            case CompareType.GreatEqual: return a >= b;
            case CompareType.Less:       return a < b;
            case CompareType.LessEqual:  return a <= b;
            case CompareType.Not:        return a != b;
            case CompareType.None:
            default:
                throw new ArgumentOutOfRangeException(nameof(compare), compare, null);
        }
    }
}

到了这里我们就可以开始实现属性大小比较了

public sealed class NumericValueConditionHandler : ASkillConditionHandler
{
    public override bool Check(Unit unit, int id)
    {
        var config = ConfigComponent.Instance.Get<NumericValueConditionConfig>(id);

        var numeric = unit.GetComponent<NumericComponent>();

        if(numeric is null)
        {
            return true;
        }

        var now = numeric.Get(config.numeric);

        return CompareUtil.Compare(config.compare, now, config.value);
    }
}

其他条件也是同样的道理,得益于 ET 的 ECS 设计,我们仅需传入 Unit 就几乎可以做到获取运行时单位的任意数据

主动技能

到了这里我们再次回看 火球术 这个技能,最开始我们希望火球术释放时,优先选择 3 个最近的敌人,然后释放 3 个飞行物火球,在最开始构建配置时,我们并没有在数据结构层面上体现在技能释放时,就发射 3 个火球

这里就涉及到具体职责的划分上了,这里的 选择 3 个最近的敌人,对应的选择器参数到底应该配置在哪里?由于我们配置方式相当灵活,即使技能配置还没有规划数据结构,这个需求仍然是可以实现的

前面的配置可以简化为, 火球飞行物节点 -> AOE 火球爆炸节点 -> 伤害节点,由于火球飞行物前面没有任何参数,导致我们只能发射一个火球,如果我们在这个节点最开始增加一个 AOE 节点,这个节点给了一个超大的圆形 shape,选 3 个最近的敌人,并在 start_effects 上配置 火球飞行物节点 的 ID,且无任何表现层内容,具体流转如下

AOE 选 3 个最近的敌人 -> 火球飞行物节点 -> AOE 火球爆炸节点 -> 伤害节点,这样我们就通过合理的组合实现了这个功能

这个例子主要还是介绍这套流转方式的灵活性,在面对策划提出的一些变态需求时,往往需要一些想象力~

我们回到主动技能中来,依然还是选择器这里,同样的选 3 个最近的敌人这个需求,由于每个技能都会有不同的选择条件,那么我们在处理主动技能数据结构时也一定会增加一个选择器参数

public class SkillConfig
{
    public int id;
    // 主动技能选择器参数
    public SkillSelectorParam selector;
}

如果我们把上面那个选择 3 个最近的敌人 AOE 中的选择器,原封不动配到这条技能里,这个技能释放时也能达到同样的效果

主动技能无论如何都会有选择器字段,所以推荐的做法还是在技能本身的选择器中完成这个需求

接下来最重要的事情就是对主动技能进行拆分了,不同项目这里的差异性会很大,同样的这里依然根据我们项目中的实际需求做一些简化后,再进行拆解

数据结构

在开始拆解数据结构之前,我们需要回答一个问题,什么是主动技能?

  • 拥有自己的释放条件
    • 当血量低于 30% 时,才可以释放
  • 可以选择单位作为释放对象
  • 持续时间和 CD
  • 前摇
    • 可以释放效果
  • 持续
    • 根据配置的具体帧释放效果
  • 后摇
    • 可以释放效果

这样一个简化版技能的数据结构就很清晰了,我们直接看配表代码

public class SkillConfig
{
    public int id;
    // 释放条件
    public ConditionParam condition_param;
    // 怎么选
    public SkillSelectorParam selector;
    // 持续多少帧
    public int duration_frames;
    // 冷却多少帧
    public int cd_frames;
    // 前摇结束帧
    public int pre_swing_end_frame;
    // 持续结束帧
    public int release_end_frame;
    // 后摇结束帧
    public int back_swing_end_frame;
    // 持续释放期间的具体帧
    // 比如一个技能,在 10 帧释放火球术,在 15 帧 给全体加血
    public List<int> release_gap_frames;
    // 前摇的效果链
    public List<int> pre_effects;
    // 持续的效果链
    public List<int> release_effects;
    // 后摇的效果链
    public List<int> back_effects;
}

具体实现

这里的实现与上面的实体稍有区别,我们一共需要做如下内容

  • Skill 实体,保存当前技能的状态
  • SkillComponent 实体,保存所有 Skill
  • SkillLifeCycleHandler 对象,处理技能状态流转过程
  • SkillLifeCycleComponet 实体,对技能类型进行分发

注意这里的划分,每个组件做的事情完全不一样,Skill 会以 AddChild 的方式,增加到 SkillComponent 中,SkillComponent 使用 unit.AddComponent 的方式进行增加,而 SkillLifeCycleComponent 是一个单例,全局唯一,SkillLifeCycleHandler 对象中无任何状态,根据 Skill 实体中保存的状态进行流转

我们先回看技能配表中的数据结构,会发现这里并没有对应的技能分类 field,我们希望技能配置本身并不知道自己是什么类型的技能,而是由具体的 Unit 配表中动态传入技能类型,这样做的好处是,技能本身的动态插拔特别灵活,你甚至可以将一个原本设计为大招的技能,放在普攻上

而我们上面的 LifeCycle 对象,就是为了解决不同类型技能所带来的差异性,这里的差异性大概是,普攻只走 CD,大招需要扣除能量等

由于此处不通游戏的技能分类、触发条件等差异性巨大,具体应该采取何种策略,需要认证结合自己项目的实际情况,后面我们以最通用的普通为例

按照实现顺序,我们先从 Skill 实体开始

public sealed class Skill : Entity
{
    // 当前技能对应的配置
    public SkillConfig skill_config;
    // 缓存来源
    public Unit from_unit;
    // 缓存去向
    public Unit to_unit;
    // 技能槽位 Enum, 这里指普攻
    public SkillSlot skill_slot;
    // 技能流转状态 Enum
    public SkillPendingState;
    // 当前 cd
    public cur_cd;
    // 当前前摇帧
    public int cur_pre_swing_frame;
    // 当前释放帧
    public int cur_releasing_frame;
    // 当前释放到第几组技能了
    public int cur_releasing_index;
    // 当前后摇帧
    public int cur_back_swing_frame;
}
// 初始化略

由于 Skill 实体本身仅存储状态,并不向外暴露任何方法,所以 System 中无任何扩展方法,我们接着看管理所有 Skill 的 SkillComponent 实现

public sealed class SkillComponent : Entity
{
    // Dic 嵌套,自行实现
    // key = 技能槽位
    // key = 配置表 id
    // value = 具体技能
    public readonly DicDic<SkillSlot, int, Skill> skills = new();
    
    // 当前正在运行的技能
    public Skill running_skill;
}

// 初始化略
public static class SkillComponentSystem
{
    // Add 技能 API
    // Remove 技能 API
    // 这里就略了
    
    // 尝试释放普通技能,其他类型的技能同理,此处略
    // 什么时机调用取决于游戏的业务逻辑
    // 比如:纯自动战斗类型的游戏,就可以让 AI 组件,对此 API 进行驱动
    public static bool TryFireNormal(this Unit self)
    {
        var skill_component = self.GetComponent<SkillComponent>();
        
        if(skill_component is null)
        {
            return false;
        }
        
        // 说明已经有技能占用了
        if(skill_component.running_skill != null)
        {
            return false;
        }
        
         skill_component.skills.TryGetValue(SkillSlot.Normal, out var dic);
         
         if(dis is null)
         {
            return false;
         }
         
         // 挨个询问所有普攻
         foreach(var skill in dic.Values)
         {
            // 如果任意技能
            if(skill.TryFire())
            {
                return true;
            }
         }
         
         return false;
    }
    
    // 在技能结束时调用
    public static void SetOver(this Skill self)
    {
        var skill_component = self.GetParent<SkillComponent>();

        if(skill_component is null)
        {
            return;
        }

        if(skill_component.running_skill != null && skill_component.running_skill != self)
        {
            return;
        }

        skill_component.running_skill = null;
    }

    // 在技能开始时调用
    public static void SetRunning(this Skill self)
    {
        var skill_component = self.GetParent<SkillComponent>();

        if(skill_component is null)
        {
            return;
        }

        skill_component.running_skill = self;
    }
    
    public static void Update(this SkillComponent self)
    {
        // 遍历所有当前技能,驱动技能生命周期组件进行更新
        foreach(var pair in self.skills)
        {
            SkillLifeCycleComponent.Instance.Update(pair.Key, pair.Value);
        }
    }
}

接着我们来看 SkillLifeCycleComponent 的具体实现,生命周期组件无状态,并管理所有技能类型的具体释放过程,因此我们还需要 ASkillLifeCycleHandler 这个抽象类

public sealed class SkillLifeCycleComponent : Entity
{
    public static SkillLifeCycleComponent Instance { get; private set; }

    public readonly Dictionary<SkillSlot, ASkillLifeCycleHandler> handlers = new();
}

// 初始化 略
public static class SkillLifeCycleComponentSystem
{
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static bool TryFire(this Skill self)
    {
        return SkillLifeCycleComponent.Instance._TryFire(self);
    }
    
    private static bool _TryFire(this SkillLifeCycleComponent self, Skill skill)
    {
        self.handlers.TryGetValue(skill.skill_slot, out var handler);

        if(handler is null)
        {
            throw new ArgumentException("[Skill] no handler of {skill.skill_slot}");
        }

        return handler.TryFire(skill);
    }
    
    public static void Update(this SkillLifeCycleComponent self, SkillSlot slot, Dictionary<int, Skill> skills)
    {
        self.handlers.TryGetValue(slot, out var handler);

        if(handler is null)
        {
            throw new ArgumentException("[Skill] no handler of {slot}");
        }

        foreach(var skill in skills.Values)
        {
            handler.Update(skill);
        }
    }
    
    // 技能默认的释放条件
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    protected bool _CheckCondition(Skill skill)
    {
        // 技能的检查仅检查自己
        return SkillConditionComponent.Instance.Check(
            skill.from_unit,
            skill.from_unit,
            skill.skill_config.condition_param
        );
    }
}

public enum SkillPendingState
{
    None,
    Start,
    PreWing,
    Releasing,
    BackWing,
    Interrupt,
    End
}

public abstract class ASkillLifeCycleHandler
{
    // 不同技能类型各自去实现怎么释放技能
    public abstract bool TryFire(Skill skill);

    // 不同类型技能各自拥有不同的释放条件限制
    // 这里会在异常状态进行说明
    public abstract bool IsSlotInLimit(Skill skill);
    
    public void Update(Skill skill)
    {
        // 只要技能 cd 大于 0, 不管是什么状态 一律扣减 cd
        if(skill.cur_cd > 0)
        {
            skill.cur_cd -= step;
        }
        
        switch(skill.pending_state)
        {
            case SkillPendingState.None:
                break;
            case SkillPendingState.Start:
                _PendingStart(skill);
                break;
            case SkillPendingState.PreWing:
                _PendingPreWing(skill);
                break;
            case SkillPendingState.Releasing:
                _PendingReleasing(skill);
                break;
            case SkillPendingState.Interrupt:
                _PendingInterrupt(skill);
                break;
            case SkillPendingState.End:
                _PendingEnd(skill);
                break;
        }
    }
    
    // 抽象类提供一个默认实现,并标记方法为虚方法
    // 不同类型的技能每个状态流转中的具体实现可能不同
    // 但整体设计是共通的
    protected virtual void _PendingStart(Skill skill)
    {
        // 开始时释放效果 skill.skill_config.pre_effects 略
        skill.pending_state = SkillPendingState.PreWing;
    }
    
    // 其他状态同理
}

到了这里,接着我们来实现普攻技能生命周期,整体实现很简单,具体还需要根据项目实际需求做修改,这里给出一个简单的示例,其他类型的技能也是同理

public sealed class NormalLifeCycleHandler : ASkillLifeCycleHandler
{
    public override bool TryFire(Skill skill)
    {
        if(skill.cur_cd > 0)
        {
            return false;
        }
        
        // 怎么样算死亡,自行扩展,这里不做解释
        if(skill.from_unit.IsDead())
        {
            return false;
        }
        
        // 如果普攻被限制了
        if(IsSlotInLimit(skill))
        {
            return false;
        }
        
        // 检查配表中的释放条件
        if(!_CheckCondition(skill))
        {
            return false;
        }
        
        // 根据技能配置中的选择器,选一个有效目标
        if(!SkillSelectorComponent.Instance.Select(
                skill.from_unit,
                skill.skill_config.selector,
                target =>
                {
                    // 此处为成功选择后的回调
                    skill.to_unit = target;
                    skill.pending_state = SkillPendingState.Start;

                    // 到了这里, 就需要标记当前技能为运行
                    skill.SetRunning();
                }
            ))
        {
            return false;
        }
        
        // 到了这里技能已经开始 padding,告诉外部,技能已经释放成功
        return true;
    }
    
    public override bool IsSlotInLimit(Skill skill) 
    {
        // 这里先不说明,会在下方异常状态中着重讲解
    }
}

异常状态

异常状态这里的处理非常有趣,比如大部分游戏中都存在的 眩晕,首先我们要搞清楚,什么是 眩晕?正常来说,当一个单位处于 眩晕 异常状态时,这个单位只能挨打,其他的事情什么都不能做,此时我们需要对这个 只能挨打 进行拆分,具体如下:

  • 不能移动
  • 不能释放技能
    • 这里的不能释放技能我们还可以进一步拆分
    • 不能释放普攻 (缴械)
    • 不能释放普通技能 (沉默)
    • 不能释放大招
  • 不能选择对象

如何表示异常状态

这样我们就将 眩晕 这个异常状态拆成了几个小的位,那么 冰冻 异常状态和 眩晕 几乎一致,同样也是 只能挨打,按照这个拆分,我们应该如何实现就非常清晰了,此时我们需要引入两组枚举,一个是 眩晕冰冻 具体异常状态,另一个是异常状态中具体的限制位

// 这里是我们项目中实际用到的限制位
// 通过组合几乎可以实现绝大多数的异常状态
[Flags]
public enum UnitLimitFlag
{
    None              = 0,                      // 占位符
    UnMove            = 1,                      // 限制移动
    UnNormalAttack    = UnMove            << 1, // 禁止普攻
    UnSkill           = UnNormalAttack    << 1, // 禁止释放技能
    UnUltimate        = UnSkill           << 1, // 禁止释放大招
    UnStoppable       = UnUltimate        << 1, // 无法被控制
    UnDamage          = UnStoppable       << 1, // 免疫伤害
    UnTeamSelectable  = UnDamage          << 1, // 无法被友方选中
    UnEnemySelectable = UnTeamSelectable  << 1, // 无法被敌人选中
    StopSelect        = UnEnemySelectable << 1, // 停止选择
    UnDead            = StopSelect        << 1, // 免疫死亡
    StopAnim          = UnDead            << 1, // 停止动画

    Beating = UnMove | UnNormalAttack | UnSkill | UnUltimate | StopSelect, // 只能挨打
}

// 异常状态具体限制位的 Attr
[AttributeUsage(AttributeTargets.Field)]
public class UnitLimitAttribute : Attribute
{
    public readonly UnitLimitFlag flag;
    
    public UnitLimitAttribute(UnitLimitFlag flag)
    {
        this.flag = flag;
    }
}

// 注意这里依然是 Flags,同一个单位可以同时处于多种异常状态
[Flags]
public enum UnitErrorState
{
    None = 0, // 占位符
    
    [UnitLimit(UnitLimitFlag.Beating)]
    Stun = 1, // 眩晕,只能挨打
    
    [UnitLimit(UnitLimitFlag.Beating | UnitLimitFlag.StopAnim)]
    Frozen = Stun << 1, // 冰冻,除了只能挨打以外,还需要停止动画
}

通过这种设计,我们新增异常状态不会破坏现有的任何代码,只需要使用 UnitLimit 对当前枚举进行行为限制即可

异常状态管理组件

除了上面的 ErrorState 枚举以外,我们想表示一个异常状态还需要如下内容

  • 谁给我加的异常状态
  • 这个异常状态是啥
  • 持续多久

由此便有了 UnitErrorStateParam,具体异常状态的载体

public sealed class UnitErrorStateParam : Entity
{
    public UnitErrorState state;
    public Unit srouce;
    public int frames;
}

为了统一管理所有异常状态,我们还需要声明一个 UnitErrorStateComponent

public sealed class UnitErrorStateComponent : Entity
{
    public readonly Dictionary<UnitErrorState, UnitErrorStateParam> error_state_duration = new();
}

// 初始化略

public static class UnitErrorStateComponentSystem
{
    // 这里要注意,因为 UnitErrorStateComponent 是每一个 Unit 身上都要挂载的
    // 但是对应的限制槽位等信息仅需要一份,图方便就放在这个 System 中了
    // 所以这个组件有两段初始化过程

    // 会多次遍历所有枚举,为了减少 GC,此处缓存
    private static readonly Array _ERROR_STATE = Enum.GetValues(typeof(UnitErrorState));
    
    // 所有异常状态对应的限制位
    private static readonly Dictionary<UnitErrorState, UnitLimitAttribute> _UNIT_LIMIT = new();
    
    static UnitErrorStateComponentSystem()
    {
        // 对 _UNIT_LIMIT 进行初始化,具体过程 略
    }
    
    // 当前单位是否被标记了某个限制位
    public static bool HasLimit(this Unit self, UnitLimitFlag flag)
    {
        if(self.unit_error_state == UnitErrorState.None)
        {
            return false;
        }

        foreach(UnitErrorState state in ERROR_STATE)
        {
            if(state == UnitErrorState.None)
            {
                continue;
            }
            
            // 当前单位没有这个异常状态
            if(!self.unit_error_state.HasFlag(state))
            {
                continue;
            }

            // 取出当前异常状态的限制位
            _UNIT_LIMIT.TryGetValue(state, out var limit);

            if(limit is null)
            {
                throw new ArgumentOutOfRangeException();
            }

            // limit attr 是否包含这个限制位
            if(limit.flag.HasFlag(flag))
            {
                return true;
            }
        }

        return false;
    }
    
    // self 是被增加异常状态的单位
    // from_unit 是异常状态的来源
    // 不要搞混了
    public static bool TryChangeState(this Unit        self,
                                      UnitErrorState   state,
                                      Unit             from_unit,
                                      int              frames)
    {
        var component = self.GetComponent<UnitErrorStateComponent>();

        if(component is null)
        {
            return false;
        }

        return component._TryChangeState(
            self,
            state,
            from_unit,
            frames
        );
    }
    
    private static bool _TryChangeState(this UnitErrorStateComponent self,
                                        Unit                        to_unit,
                                        UnitErrorState               state,
                                        Unit                     from_unit,
                                        int                          frames)
    {
        if(self.error_state_duration.TryGetValue(state, out var param))
        {
            // 比如当前单位被眩晕 10 秒,此时来了一个 0.01 秒的眩晕
            // 我们项目中认为这个 0.01 秒不合法,直接吞掉
            if(param.frames > frames)
            {
                return false;
            }

            // 此时说明需要替换持续时间 和 异常施加的对象
            param.frames    = frames;
            param.source      = from_unit;
        }
        else
        {
            // 此时说明没有创建过当前异常状态
            // 那么从池里面取一个, 并添加到 dic 中
            param = self.AddChild<UnitErrorStateParam, UnitErrorState, Unit, int, AErrorStateParam>(state, from_unit, frames, true);

            self.error_state_duration[state] = param;
        }

        if(!to_unit.unit_error_state.HasFlag(state))
        {
            to_unit.unit_error_state |= state;
        }

        return true;
    }
    
    // 移除一组异常状态
    // 一般由驱散或者异常状态到时间后,统一移除
    private static void _RemoveState(this UnitErrorStateComponent self, List<UnitErrorStateParam> list)
    {
        var unit = self.GetParent<Unit>();

        UnitErrorState final = unit.unit_error_state;

        foreach(var param in list)
        {
            self.error_state_duration.Remove(param.state);

            // 剔除当前 state
            final &= ~param.state;

            param.Dispose();
        }

        unit.unit_error_state = final;
    }
    
    // 每帧更新,自动移除到期的异常状态
    public static void Update(this UnitErrorStateComponent self)
    {
        using var list = ListComponent<UnitErrorStateParam>.Create();

        foreach(var param in self.error_state_duration.Values)
        {
            param.frames--;

            if(param.frames <= 0)
            {
                list.Add(param);
            }
        }

        if(list.Count <= 0)
        {
            return;
        }

        self._RemoveState(list.List);
    }
}

其他组件需要的改动

上面我们留了一个尾巴,在普通技能中,判断是否处于限制状态时,并没有写对应的实现,直接调用是否包含不让放普攻的 API 即可

public override bool IsSlotInLimit(Skill skill) => skill.from_unit.HasLimit(UnitLimitFlag.UnNormalAttack);

那么同理,在技能生命值周期 Update 时,前摇、释放、后摇,这三段实现中,我们同样可以增加是否处于 xxx 限制中,当条件成立,直接将技能的 state 置为 Interrupt 即可

还需要对 SkillEffect 增加一个可以控制异常状态的节点 SkillEffect_State_Handler,以及 SkillEffectErrorStateConfig 配置,具体实现也是非常简单

internal sealed class SkillEffect_State_Handler : ASkillEffectHandler
{
    public override void Handle(int effect_id, Unit from_unit, Unit to_unit)
    {
        var config = ConfigComponent.Instance.Get<SkillEffectErrorStateConfig>(effect_id);

        to_unit.TryChangeState(
            config.state,
            from_unit,
            config.frames
        );
    }
}

public class SkillEffectErrorStateConfig
{
    public int id;
    public UnitErrorState state;
    public int frames;
}

棘手的异常状态补充

这样整体设计下来,普通的异常状态我们都可以实现了,但是像一些需要额外进行处理的异常状态仍然无法满足,比如 恐惧魅惑嘲讽 等,所以我们需要额外引入新的抽象类AUnitErrorStateHandler

注意此处的 Handler 仍然是无状态的!

public abstract class AUnitErrorStateHandler
{
    public abstract void WhenAdd(UnitErrorStateParam param);

    public abstract bool IsValid(UnitErrorStateParam param);

    public abstract void WhenRemove(UnitErrorStateParam param);
}

这里我们分为三段实现,当添加时,当移除时,以及是否有效,我们以 嘲讽 举例,当进入这个异常状态时,当前单位只能对这个源 Unit 进行普通攻击。但是!当源死亡时, 嘲讽 异常状态就地结束,这种需要额外处理的异常状态,我们分为两种

  • 无状态的额外处理,如:击飞,把单位抬高
  • 有状态的额外处理,如:嘲讽等

一般而言,有状态的额外处理,基本上都是强制改变当前单位的目标,比如 恐惧 改变的是移动到哪里,不过这里实现方式多种多样,仅供参考

public sealed class UnitErrorState_Taunt_Handler : AUnitErrorStateHandler
{
    public override void WhenChange(UnitErrorStateParam param)
    {
        if(param?.source is null)
        {
            return;
        }

        var unit_self = param.Domain2Unit();

        if(unit_self is null)
        {
            return;
        }

        // 这里的 _GetOrAdd 先不做解释
        var force_target = _GetOrAdd<UnitTauntTarget>(param);

        force_target.target = param.source;
    }

    public override bool IsValid(UnitErrorStateParam param) 
    {
        param.source is not null && !param.source.IsDead();
    }

    public override void WhenRemove(UnitErrorStateParam param) 
    { 
        // 这里的 _Remove 也先不做解释
        _Remove<UnitTauntTarget>(param); 
    }
}

注意!我们引入了一个新的实体 UnitTauntTarget,所有的状态均存放在这个实体中,我们再来重新分析一下需求,当前角色可以处于任意一种异常状态中,也就是说当前角色可以处于 恐惧魅惑嘲讽 这三个异常状态的叠加状态上

但是!真正生效的永远只有一种,所以我们需要引入 order 进行排序,按照上面的分类,还需要引入一个 target 来控制当前单位真正需要去攻击谁,那么为了满足上述需求,我们又需要引入一个新的管理组件,UnitTargetRedirectComponent 单位目标重定向组件,这个组件的功能非常简单,对异常状态实体进行排序,并返回当前目标

因为这些异常状态需要对选择目标进行重定向,此时需要侵入 选择器 组件代码中,比如我们可以使用当前单位是否包含了 禁止 选择的 limit,如果有,此时需要从 UnitTargetRedirectComponent 获取有效目标

// 这里我就懒得组件化了,麻烦,直接继承解决
public abstract class ATargetRedirectEntity : Entity
{
    public abstract int order { get; }

    public abstract Unit GetTarget();
}

public sealed class UnitTauntTarget : ATargetRedirectEntity
{
    public Unit target;

    public override int order => 200;

    public override Unit GetTarget() => target;
}

public sealed class UnitTargetRedirectComponent : Entity
{
    private readonly List<ATargetRedirectEntity> _special_entities = new();
    
    // 需要侵入 选择器的 API
    public Unit GetTarget()
    {
        // 从 ATargetRedirectEntity 中取一个优先级最高的 target
    }
    
    public T Add<T>() where T : ATargetRedirectEntity, new()
    {
        // 对异常状态实体进行添加,并排序
    }
    
    public void Remove<T>() where T : ATargetRedirectEntity
    {
        // 移除
    }
}

此时对于 AUnitErrorStateHandler 这个处理过程,我们需要增加上面没有提到的 _GetOrAdd_Remove 方法了,本质上就是获取 UnitTargetRedirectComponent 组件,调用 AddRemove 方法

至此我们将异常状态解决的差不多了,但是对于棘手的异常状态来说,并不是一个统一的设计,对我们项目当前情况比较合适。开发时需要结合自己项目,认真思考应该如何设计!

Buff

Buff 也是一个很有趣的组件,一般来说 Buff 会分好多种,我们项目中就分了 4 种

类型解释
叠层添加 5 个 Buff,实际修改的是层数
覆盖添加 5 个 Buff,最终只有一个
低值待命添加 5 个 Buff,只有实际效果最高的生效
独立添加 5 个 Buff,实际生效 5 个

这样不同类型的 Buff,我们就划分好了。但是 Buff 到底是啥?我们应该如何理解 Buff?按照我们现有的规划,Buff 的拆分也非常简单

Buff 是开始时,做一件事,持续时,每隔 N 帧,做一件事,结束时,再做一件事,但是在结束后,会对所有做过的事情,选择性的回滚,如:每秒增加 5% 的攻击力,持续 5s。结束时,我们需要对所有增加的内容进行统一的回滚

按照这个定义,我们有如下功能需要实现(以覆盖 Buff 为例)

  • 回滚功能
  • Buff 实体
  • Buff 效果节点
  • Buff 节点配表
  • 覆盖 Buff 组件
  • 覆盖 Buff 效果节点
  • 覆盖 Buff 配表

注意看这里,我们实现覆盖 Buff,实际上创建了两个效果节点 Buff 节点覆盖 Buff 节点,我们可以将前者理解为一个容器,该容器仅允许 Buff 类的效果节点调用(可通过 Luban 的 refgroup 进行限制,后续介绍),这样做的好处是,4 类 Buff 中存在一些通用的效果,比如显示 Hud、挂载一些受击特效等,我们都可以通过 SkillEffectType.Buff 这一个枚举进行判断,同样的回滚逻辑也只需要写在 Buff 节点

这里可能会有一些理解问题,如果不理解也没关系,接着向下看

还有一个实现细节比较有趣,A 给 B 加了一个 Buff,这个 Buff 到底属于 A 还是属于 B?要回答这个问题,需要结合自身项目的实际需求,这里在 ECS 的设计下,最重要的区别是,当 A 死亡后,这个 Buff 是否还要继续生效?

这里的实现是属于 B

回滚

Buff 中最重要的一个概念,就是这个回滚,注意!同一个节点,如果由其他非 Buff 节点触发时,是不需要回滚的。那么为了解决回滚这个问题,我们需要回到 ASkillEffectHandler 这个抽象类中,增加一个新的虚方法

public virtual void Revert(int effect_id, Unit from_unit, Unit to_unit)
{
}

接着我们要考虑,那些节点可以回滚,上面例子中的修改属性节点,一定是需要回滚的,我们以修改属性节点为例

public sealed class SkillEffect_Numeric_Handler : ASkillEffectHandler
{
    public override void Handle(int effect_id, Unit from_unit, Unit to_unit)
    {
        var config = ConfigComponent.Instance.Get<SkillEffectNumericConfig>(effect_id);

        var numeric = config.numeric_param.self_numeric
            ? from_unit.GetComponent<NumericComponent>()
            : to_unit.GetComponent<NumericComponent>();

        numeric.Add(
            config.numeric_param.numeric_source,
            config.numeric_param.param,
            true,
            (int) effect_source
        );
    }
    
    public override void Revert(int effect_id,Unit from_unit,Unit to_unit)
    {
        var config = ConfigComponent.Instance.Get<SkillEffectNumericConfig>(effect_id);

        var target = config.numeric_param.self_numeric? from_unit : to_unit;

        if(target.IsDead())
        {
            return;
        }
            
        var numeric = target.GetComponent<NumericComponent>();

        // 此处直接取反
        numeric?.Add(
            config.numeric_param.numeric_source,
            -config.numeric_param.param,
            true,
            (int) effect_source
        );
    }
}

此时,我们还需要对 SkillEffectComponent 进行扩展,提供调用 Revert 的方法,具体实现略

Buff 实体相关实现

对于 SkillBuff 来说,本质上就是一个存储状态的壳子,如果添加到 覆盖 Buff 组件上,那么这个 SkillBuff 就是覆盖 Buff,我们先看 Buff 实体的具体内容

public sealed class SkillBuff : Entity
{
    // 覆盖 Buff 中配表通用内容,后续解释
    public BuffParam buff_param;
    // 覆盖 Buff 的配置 ID
    public int parent_buff_id;
    // 当前的持续时间
    public int cur_duration;
    // 当前的间隔时间
    public int cur_interval;
    // 谁给我加的 buff
    public Unit buff_from;
    // 已触发的 buff_id
    public readonly List<int> triggered_buff = new();
}

// 初始化 略

public static class SkillBuffSystem
{
    public static bool UpdateInterval(this SkillBuff self)
    {
        if(self.buff_param.interval <= 0)
        {
            return false;
        }

        self.cur_interval--;

        if(self.cur_interval > 0)
        {
            return false;
        }

        self.cur_interval = self.buff_param.interval;

        return true;
    }
    
    public static bool UpdateDuration(this SkillBuff self)
    {
        self.cur_duration--;

        return self.cur_duration <= 0;
    }
    
    public static void Trigger(this SkillBuff self, int buff_id, Unit to_unit = null)
    {
        if(buff_id <= 0)
        {
            return;
        }

        SkillEffectComponent.Instance.Fire(buff_id, self.buff_from, to_unit ?? self.SepDomain2Unit());

        self.triggered_buff.Add(buff_id);
    }
    
    public static void Revert(this SkillBuff self)
    {
        foreach(int id in skill_buff.triggered_buff)
        {
            SkillEffectComponent.Instance.Revert(
                id,
                skill_buff.buff_from,
                skill_buff.Domain2Unit()
            );
        }

        self.Dispose();
    }
}

对应的效果配表如下,这里移除了一些是否显示 hud 啥的属性,所以非常简单,理解为容器即可

public class SkillEffectBuffConfig
{
    public int id;
    // 需要触发那些效果
    public List<int> effects;
}

覆盖 Buff 相关实现

我们将这个组件称为 SkillBuffOverrideComponent,我们先从配表开始

public class SkillEffectBuffOverrideConfig
{
    public int id;
    // Buff 统一行为,上述 4 种 Buff
    // 无论哪一种,都会用到这些属性
    public BuffParam buff_param;
    // 由于覆盖 buff 没有其他多余的配置,所以此处非常简单
    // 但是其他的,比如叠层 buff,一定会多一些层数等配置
    // 因为叠层 buff 也是一个独立的数据结构,直接定义即可
}

// Buff 实体中保持的 BuffParam 引用,就是这个结构
public class BuffParam
{
    // 持续时间
    public int duration;
    // 间隔时间
    public int interval;
    // 对应 SkillEffectBuffConfig 中的 ID
    public int start_buff;
    public int duration_buff;
    public int end_buff;
    // 是否可以驱散
    public bool dispellable;
}

接着我们来看 SkillEffect_BuffOverride_Handler 效果节点的具体内容,也非常简单

public sealed class SkillEffect_BuffOverride_Handler : ASkillEffectHandler
{
    public override void Handle(int effect_id, Unit from_unit,Unit to_unit)
    {
        var component = to_unit.GetComponent<SkillBuffOverrideComponent>();

        component?.Add(
            effect_id,
            from_unit
        );
    }
}

最后我们对 SkillBuffOverrideComponent 组件进行实现,这个组件管理所有自己添加的 SkillBuff 实体

public sealed class SkillBuffOverrideComponent : Entity
{
    internal readonly Dictionary<int, SkillBuff> buffs = new();
}

// 初始略
public static class SkillBuffOverrideComponentSystem
{
    public static void Add(this SkillBuffOverrideComponent self, int buff_id, Unit from_unit)
    {
        var config = ConfigComponent.Instance.Get<SkillEffectBuffOverrideConfig>(buff_id);

        self.buffs.TryGetValue(buff_id, out var skill_buff);

        // 如果当前不存在此 Buff
        if(skill_buff is null)
        {
            // 对 buff 进行初始化
            skill_buff = self.AddChild<SkillBuff, SkillBuffAwakeParam>(
                new SkillBuffAwakeParam(
                    config.buff_param,
                    from_unit,
                    source_id,
                    buff_id
                ),
                true
            );

            self.add_caching[buff_id] = skill_buff;

            // 然后对初始触发 Buff 进行 Trigger
            skill_buff.Trigger(skill_buff.buff_param.start_buff);
        }

        // 每次到这里都将持续时间恢复为最大值
        skill_buff.cur_duration = skill_buff.buff_param.duration;
    }
    
    private static void _Remove(this SkillBuffOverrideComponent self, int buff_id)
    {
        if(buff_id <= 0)
        {
            return;
        }

        self.buffs.TryGetValue(buff_id, out var skill_buff);

        if(skill_buff is null)
        {
            return;
        }
        
        self.buffs.Remove(skill_buff.parent_buff_id);
        skill_buff.Revert();
    }
    
    public static void Update(this SkillBuffOverrideComponent self)
    {
        var unit = self.Domain2Unit();

        using var disposed = ListComponent<SkillBuff>.Create();

        foreach(SkillBuff skill_buff in self.buffs.Values)
        {
            // 挂载 Buff 的单位死亡
            if(unit.IsDead())
            {
                disposed.Add(skill_buff);
                continue;
            }

            if(skill_buff.UpdateInterval())
            {
                skill_buff.Trigger(skill_buff.buff_param.duration_buff);
            }

            if(skill_buff.UpdateDuration())
            {
                disposed.Add(skill_buff);
            }
        }

        // 对已经结束了的 buff 进行移除
        foreach(var skill_buff in disposed.List)
        {
            self._Remove(skill_buff);
        }
    }
}

其他类型的 Buff 也是同样的道理,篇幅限制就不一一介绍了

被动技能

被动技能在我们项目中,非常非常复杂,比如:A 攻击了 B,C 要知道,并对 A、B、C 任意一个单位做一件事,由于理解成本非常高,这部分的介绍会做大量的删减和简化,一般来说,被动可以粗暴的划分为两个大类

分类解释
时机类被动当敌人死亡时,回血
持续检测被动当附近有 2 个友军时,增加攻击力

为了减少这部分的代码量,接下来的内容,会移除 持续检测被动 的相关内容,项目有实际需求,可以自行实现

我们以 时机类被动 做详细展开,首先是时机的分类,比如:属性发生变化、暴击后、闪避后、角色死亡等等。在此基础上,被动应当拥有自己的 CD,以及最大触发次数,部分被动还要有额外的触发条件。而且为了满足策划实际技能的实现,往往需要多个被动同时配合才能实现具体需求,因此我们需要实现如下内容

  • 被动实体
  • 被动管理组件
  • 被动释放组件
    • 和主动技能中的生命周期组件较为相似
  • 被动时机触发的数据结构
  • 每种被动的配表
  • 统一被动配表参数

后面我们以闪避被动为例

闪避被动相关配表

前面提到,我们需要将多个被动合并为一个被动的功能,为了实现这个,我们需要定义 PassiveSkillConfig 配表,此表代表了真正的被动,而一个被动由多个 condition config 组成

这里是为了策划配表方便,并且实际业务逻辑上有一些需要统一管理的需求,这样聚合之后,统一管理起来非常方便

public class PassiveSkillConfig
{
    public int id;
    public List<int> condition_ids;
}

接着我们看闪避表的具体配置,由于闪避被动相对来说非常简单,仅使用 PassiveSkillParam 这个通用结构,即可覆盖所有需求,其他被动如果有特殊字段,可各自定义在自己的数据结构中

public class DodgePassiveSkillConfig
{
    public int id;
    // 通用的被动参数
    public PassiveSkillParam passive_skill_param;
}

public class PassiveSkillParam
{
    // 被动的冷却时间
    public int cd_frames;
    // 被动的最大触发次数,-1 表示无限次
    public int max_time;
    // 被动的额外触发条件
    public ConditionParam condition_param;
    // 从 A、B、C 中选择谁作为 target 的数据结构此处省略
}

被动实体具体实现

这里我们将其称为 PassiveSkill 实体,具体内容如下:

public sealed class PassiveSkill : Entity
{
    // 此 ID 为 PassiveSkillConfig 的 ID
    public int config_id;
    // 此 ID 为 具体被动配置的 ID
    // 可以理解为 DodgePassiveSkillConfig 的 ID
    // 也就是 闪避被动配置表的 ID
    public int condition_id;
    // 被动通用的参数,后续详细说明
    public PasskiveSkillParam param;
    // 最大 CD
    public int cd_max => param.cd_frames;
    // 当前 CD 
    public int cur_cd;
    // 当前释放次数
    public int count;
    // 当前被动时机的枚举
    public BattleTiming when => (BattleTiming) (condition_id / (int) BattleTiming.Division);
}
// 初始化略
public static class PassiveSkillSystem
{
    public static void Update(this PassiveSkill self)
    {
        // <=0 判断省略
        self.cur_cd--;
    }
}

被动实体的整体实现还是非常简单的,其主要功能就是存储当前被动的状态

被动管理组件

这里我们将其称为 PassiveSkillComponent,但是在看这个组件之前,我们要先将被动触发的数据结构定义出来

public struct BattleTimingResult
{
    // 什么时机
    public BattleTiming when;
    // 这里引入了战场的概念,后面进行解释
    public BattleField battle_field;
    // 来源
    public Unit from_unit;
    // 去向
    public Unit to_unit;
}

接着我们看具体实现,整体过程也比较简单,但是战场的内容需要额外说明一下,首先,最终希望代码可以直接跑在服务器上,而服务器中同时跑 N 个战斗,每个战斗的载体我们统一称为 BattleField,这里涉及到的业务逻辑过多,我们不做详细展开

下方出现的 UnitComponent 就是挂载在 BattleField 这个实体上

public sealed class PassiveSkillComponent : Entity
{
    // key = 时机
    // key = condition_id
    // value = PassiveSkill
    // 这里的 DicDic 就是 Dictionary<BattleTiming, Dictionary<int, PassiveSkill>>
    // ET 中有对应的实现,只是改了个名字,比较好记
    public readonly DicDic<BattleTiming, int, PassiveSkill> when_skills = new();
}

// 初始化略
public static class PassiveSkillComponentSystem
{
    // Add、Remove 过程略

    // 这里我们将 API 暴露给战场,因为被动的触发时机并不和某个单位绑定
    // 而是和每个单位都有关系,所以需要获取所有单位
    // 然后从每个单位上获取对应的被动组件
    // 最后挨个传递过去
    public static void TransmitPassiveSkill(this BattleField self, BattleTimingResult args)
    {
        var unit_component = self.GetComponent<UnitComponent>();

        using var units = ListComponent<Unit>.Create();

        unit_component.GetAll(UnitGroup.All, units, true);

        foreach(var unit in units.List)
        {
            if(unit.IsDead())
            {
                continue;
            }

            unit.GetComponent<PassiveSkillComponent>()?._Trigger(args);
        }
    }
    
    private static void _Trigger(this PassiveSkillComponent self, BattleTimingResult args)
    {
        // 拿到当前时机对应的被动技能
        self.when_skills.TryGetValue(args.when, out var skills);

        if(skills is null || skills.Count <= 0)
        {
            return;
        }

        // 然后挨个触发
        foreach(var skill in skills.Values)
        {
            PassiveSkillFireComponent.Instance.TryFire(skill, args);
        }
    }
}

被动释放组件

这里跟技能生命周期组件很像,组件涉及到的代码本身都没有状态,只有处理的过程,我们将其称为 PassiveSkillFireComponent,为了解决不同被动的差异化,我们需要定义 APassiveSkillFireHandler 抽象类,具体实现如下

public sealed class PassiveSkillFireComponent : Entity
{
    public static PassiveSkillFireComponent Instance { get; set; }

    public readonly Dictionary<BattleTiming, APassiveSkillFireHandler> handlers = new();
}

// 初始化略
public static class PassiveSkillFireComponentSystem
{
    public static bool TryFire(this PassiveSkillFireComponent self, PassiveSkill passive_skill, BattleTimingResult args)
    {
        BattleTiming timing = passive_skill.when;

        if(timing == BattleTiming.None)
        {
            return false;
        }

        if(timing != args.when)
        {
            return false;
        }

        if(passive_skill.cur_cd > 0)
        {
            return false;
        }

        self.handlers.TryGetValue(timing, out var handler);

        if(handler is null)
        {
            return false;
        }

        var result = handler.TryFire(passive_skill, args);

        if(result)
        {
            passive_skill.cur_cd = passive_skill.cd_max;
        }

        return result;
    }
}

public abstract class APassiveSkillFireHandler
{
    // 获得被动通用部分的参数
    public abstract bool TryGetParam(int condition_id, out PassiveSkillParam param);
     
    public virtual bool TryFire(PassiveSkill passive_skill, BattleTimingResult args)
    {
        var self_unit = passive_skill.Domain2Unit();

        if(self_unit is null)
        {
            return false;
        }

        if(!TryGetParam(passive_skill.condition_id, out var param))
        {
            return false;
        }

        if(param.max_time != -1 && passive_skill.count <= 0)
        {
            return false;
        }

        if(!SkillConditionComponent.Instance.Check(args.from_unit, args.to_unit, param.condition_param))
        {
            return false;
        }
        
        // 这里的 target 的获取过程被我隐去了,其过程就是根据配表
        // 在 A 打 B,被 C 监听到时,从这三个单位获得一个合法 target
        // 可能在你的项目中不需要如此复杂的被动判断,自行修改即可
        SkillEffectComponent.Instance.Fire(
            param.target_param.effect_ids,
            self_unit,
            target
        );

        passive_skill.count--;

        return true;
    }
}

最后我们以闪避被动为例,实现对应的 Handler,过程非常简单,获取对应配置中的 PassiveSkillParam 即可

public sealed class DodgePassive_PassiveSkillFireHandler : APassiveSkillFireHandler
{

    public override bool TryGetParam(int condition_id, out PassiveSkillParam param)
    {
        var config = ConfigComponent.Instance.Get<DodgePassiveSkillConfig>(condition_id);

        param = config?.passive_skill_param;
        return param != null;
    }
}

Luban 相关

此处技能相关的实现和 Luban 是高度相关的,如果你还没有听过说 Luban,那真的太遗憾了

refgroup

上面我们提到,不同的 Effect 类型,可以 ref 的权限是完全不一样的,如下面几张图所示,比如上文提到的 BuffParam 中 开始、持续、结束,三个节点仅能引用 SkillEffectBuffConfig,而 SkillEffectBuffConfig 能引用的节点又是收到限制的

这个根据自己项目实际需求做调整即可

链表环

在这种设计下,会存在一个致命缺陷,当策划配出来 A->B->C->B 这种技能时,你会发现技能永远不会结束,这种问题,我们称为 链表环,为了解决这个问题,我们需要在 Luban 的基础上,对配表引入单元测试

具体如何使用,在 示例仓库 中有详细的介绍,此处不做展开

辅助工具

到了这里,我们仅仅完成了整体技能系统的逻辑实现,但是这样仍然是不够的,因为所有技能的配置全部在 Excel 中,想要整体看到技能的实际效果非常繁琐,因此我们需要开发一套技能的描述工具

最开始设计时,我第一考虑也是使用行为树的方式来对技能进行构建,但是我们策划特别喜欢 Excel

除此之外,为了有效的验证 bug,以及给策划一个合理的测试环境,我们需要引入 测试战场这套工具。在设计这套工具时,我们需要注意,所有的测试数据,均不可出现在 dev 和 release 环境中,所有测试代码,只能在 Editor 下完成,也就是说此处出现的所有内容在真机 build 时,完全不会参与编译

技能描述工具

CliToolkit 工具 这篇文章中,详细描述了使用过程

这里我截一个项目中实际英雄的所有技能描述展示,你可以结合上面的设计,看自己是否能够将这个英雄的所有技能实现出来

测试战场

Luban 介绍 这篇文章中,提到了 test 流程的具体使用,建议结合阅读。这里的测试战场,在我们最开始设计时就考虑到了,地位非常高,无论遇到任何问题,都不能影响到这个工具的开发,可以参考下图的设计方式

这个工具在查一些疑难杂症时,非常非常有用,因为游戏是全自动的,客户端可以完全复现一个线上的战斗,这样我们去 Debug 排查问题的过程会非常简单。

在项目开始阶段,策划各种公式给到程序时,很可能因为不仔细导致最终结果算错了,我们在这套工具中,增加了分类型暂停的功能,比如当发生一次伤害,然后暂停游戏,直接看面板中的结算结果,策划就可以自己验证是否正确,如果有问题,策划只需要把这个测试用例上传,程序本地运行一下,就可以复现策划碰到的问题

还有一些新的机制,会修改当前单位的基础属性,策划需要时刻看着这个单位的属性是否真的生效了,以及对应的值是否真的正确,我们在战场控制里,增加了所有单位核心属性的 监视面板

此处的功能在 ET Entity Tree 工具 中有详细介绍,只是打开的方式不同

最后

在我们实际项目各个模块的实现比上面代码展示的部分要复杂的多,本篇文章也是做了大量的删减,因为很多需求并不具备技能的普适性,作为技能系统的一种解题思路来看是比较合适的

除此之外,我们还将战斗相关的所有代码封装进一个单独的 dll 中,这样好处是非常多的,首先这个 dll 的引用级别非常低,所以不会引入 任何业务逻辑,这样如果后续项目有类似的实现可以用一个非常低的成本来复制一个新的战斗

对这个部分感兴趣可以参考 内网 Package 管理一些关于代码积累的记录 这两篇文章

这篇博客内容非常多,因为技能系统本身就不是一个小问题,整体也是写了 5w 多字,希望对你有所帮助吧~ 如果文章中有任何错误或者你有更好的设计方式,欢迎留言交流讨论