技能系统详解(1)——技能
【前言】
技能系统是建立在其他基础系统之上的,这些基础系统包括属性系统、运动和动画系统、打击系统、特效系统、网络同步系统、资源管理系统等。
如果这些系统的实现提供了丰富的接口支持或者便捷扩展支持,那么技能系统的实现会简单很多。
然而,通常情况下是上层的技能系统会对下层的系统提出新的需求,已有的系统不能直接支持,需要对这些已有系统做改造。
技能系统本身又包括数值、技能、状态、buff、伤害、特效、运动等多个方面。先看一看技能。
【生命周期】
系统内的核心对象必然存在生命周期的相关问题,这里技能系统的核心对象是技能,生命周期如下:
- 第一步是Init:技能初始化
- 第二步是Check:即是否满足技能释放条件
- 第三步是Enter:即开始释放技能
- 第四步是Excute/Break:即技能释放和打断
- 第五步是Exit:即技能释放结束
- 第六步是Destroy
从业务逻辑考虑生命周期时,是没有Init和Destroy的。Init和Destroy是从程序实现上附加在业务逻辑上的数据和资源相关的步骤
不同的生命周期对应不同的阶段状态SkillState,进入不同状态时需要通过消息的方式发布出去,以便其他地方监听
技能数据SkillData
数据可以分为静态的配置数据SkillConfigData、动态的运行数据
其中运行数据又可以分为本地运行数据SKillLocalData、远程运行数据SkillRemoteData,也即有些运行数据需要网络同步
更进一步的,静态配置数据可以根据业务或功能做出来不同的划分。但这里只对数据做最上层的三类区分。
通常,这三类数据Data通过技能的唯一标识SkillID(也即主键)做关联,也,由不同的DataSystem做各自的管理,System和Data应该有一套生成规范
技能的生命周期逻辑和数据构成技能实体(SkillEntity)的主要内容
(上文中的生命周期和数据也可以是任何其他概念的)
技能组合
当谈论到组合时,需要知道系统中核心对象的最小粒度。
技能是技能系统中设计时的对象,不是实现时的对象。
如果认为技能是更基础的对象,不同技能就可以组合出能力Ability(叫天赋、特性什么的都OK)组合而成。而能力对玩家来说就是技能。
也难辞,一个能力,是由多段技能组成。
技能表现
释放技能时所看到的一切都可以看作是技能的表现:包括角色动作、角色移动、角色变身、技能特效、召唤物、环境变化、伤害飘字等
在技能释放过程中会有什么表现是多种多样的,取决于具体的需求,事先无法预料
因此,不能在Exceute中做固定的逻辑,其只是开启表现逻辑的入口,需要将具体的逻辑拆分开来
对于这种情况,通常会将不同表现的逻辑抽象为一个个组件,需要调整表现时,调整一系列组件即可,因此需要抽象出SkillComponent基类
技能类型
从持续时间划分:
- 瞬发类:指瞬间释放的技能,这类技能的表现通常都会随着技能释放过程全部触发,注意技能释放完成不等于技能表现完成,有些表现不跟随技能生命周期,例如王昭君大招
- 限时类:指需要在特定时间内释放的技能,释放的是瞬发类技能,通常只能在该段时间内释放一次
- 触发类:指需要在特定条件下才释放的技能,释放的是瞬发类技能
- 状态类:指会持续一段时间的技能,这类技能有部分表现是跟随生命周期,例如拖尾特效、隐身特效等,有部分表现是特殊情况下的触发。状态类能力更多的像是一个开关,其可以存在多个限时类或瞬发类或触发类技能,通常该技能打开时会有瞬发的表现
能力类型
从释放次数划分:
- 单次类:指能力只能释放一次,释放后就进入CD阶段,通常就是瞬发类技能
- 多段类:指能力可以做多次技能释放,通常是瞬发类搭配限时类技能
【技能具体数据(以王者为例)】
能力数据
- 能力Id
- 能力输入类型:普通技能1、普通技能2、普通技能3、大招、通用技能1、通用技能2
- 能力图标
- 能力的技能组合
- 能力名称
- 能力简要描述
- 能力详细描述
- 能力视频描述
- 能力语音描述
- 能力成长数据(可以展开成更为具体的)
技能数据
- 技能Id
- 技能图标
- 技能其他各类图标
- 技能功能类型:位移、强化、净化、回血、控制、伤害、免控等
- 技能CD
- 技能最小CD
- 技能持续时间:0表示释放就结束的技能
- 技能最大长按时间:0表示该技能不能长按
- 技能耗血值
- 技能耗蓝值
- 技能耗体力值
- 技能优先级
- 联动技能:例如大招放了后其他技能招式会变化或者基础数据会变化
- 其他各种和具体游戏相关的数据
- 技能逻辑组件:包括逻辑组件类型和配置数据ID
逻辑组件
每个逻辑组件都有各自的Data和System,具体数据是什么,需要具体的逻辑是什么,同时各类System可以用统一的映射和管理,即有个LogicSystemMgr
注意,这里的逻辑组件只是相对于技能组件是逻辑,其本身也有自己的逻辑和数据
运行时数据
此时只考虑本地数据,远程数据不考虑
- CD
- 剩余CD
- 当前持续时间
- 当前按住时间
- 等等
运行时数据与配置数据的区别在于:每个配置数据都对应有个当前数据,如果配置数据的默认值在机制上允许动态改变,那么运行时数据中需要存在默认值。
例如:技能会有个初始的默认CD,但通常技能CD可以改变,那么配置的CD数据有两个对应的运行时数据
【具体的生命周期】
初始化Init
初始化时有两个主要工作
- 给常用属性赋值,这些常用属性通常是在其他地方传递过来的,因此Init方法需要增加一些参数
- 数据组件初始化,需要给数据初始化传递必要的参数,因此其Init方法需要增加一些参数,与常用值的Init方法参数的区别是,前者是固定的,而数据同类不同,其初始化参数不同。
面对这样的情况,一种简单的方式是每种数据各自写初始化方法;另一种方式是将参数合并做上下文传递各取所需,我们先采用简单的方式
销毁Destroy
对于常用属性,其值设回初始值即可,因为每次都会重新初始化,有些值可以不必再设置
对于配置数据置空,对于运行时数据要销毁
对于逻辑要停止
检查Check
检查用于判断是否可以到Enter,具体有什么检查与具体的概念相关,对于技能而言,其意思是技能当前是否可以使用,包括以下检查:
通用检查:即CD、蓝量、血量、体力是否足够,考虑到不同技能检查的差异性,这些都要分开允许重写
特殊检查:即每个技能有各自可以释放的前提条件
进入Enter/执行Excute/退出Exit
技能释放时有各种各样的表现,所有这些表现都是在技能释放期间,也即Excute阶段触发的
注意,Execute阶段只管各种表现什么时候触发,不管表现什么时候出现或者结束,也即技能释放完成和技能的表现完成是两个不同的概念
例如,有的技能特效需要延迟一段时间才会出现,但该技能特效实际已经触发了;有的技能可以冰冻敌人,技能已经释放完成,但敌人可能还会被冻住5s不能移动
通常,会假设有个技能时间轴,可以在该时间轴上配置不同表现的触发时间以相关数据。
众多表现中最为特殊的表现是动作,因为玩家操作角色相当于控制了角色的动作表现,继而影响技能的动作表现,而特效等其他表现不受玩家控制。
此时,技能表现如何触发有两种方式:一是技能驱动动作;二是动作驱动技能。
简单来说,对于技能的动作,可以分为前摇施法后摇三个阶段,共三个动作(可根据实际需要增加更多的动作阶段)
技能驱动动作时,按照技能时间轴依次播三个不同的动作,一般在Enter时就需要播放动画,在技能时间轴某一时刻触发其他特效、音效等表现。例如攻击动画0.5s时有个剑气特效,就将剑气的触发改为0.5s
动作驱动技能时,动作系统控制播放三个不同的动作,并按照动作时间轴,在施法动作的某些时间点触发特效、音效等表现。
无论在什么游戏中,角色动作总是远多余角色技能的。两者方式的区别如下:
动作驱动:
- 可以打出各种丰富的技能表现
- 具有更好的操作感、打击感、爽感
- 输入延迟要求严格,通常需要有输入缓冲
- 网络同步较难
- 操作上线高,APM(每分钟操作数)在120-180
- 技能数值基本固定,获胜看玩家操作,会追求炫酷,皮肤/外观付费较高
技能驱动:
- 可以提供确定性反馈,例如在技能释放前摇阶段做可视化提示
- 动作与技能容易割裂不同步,技能的动作表现和其他特效表现容易出现不同步的情况
- 容易存在技能冲突,通常需要有技能优先级队列
- 操作有上限,技能释放需要有公共冷却机制,APM为60-80
- 技能的动作表现有限,获胜看技能数值,数值加成道具付费较多
- 网络同步简单些
通常动作类游戏用动作驱动的方式,例如鬼泣5、只狼
RPG和MOBA用技能驱动的方式,例如王者荣耀、英雄联盟、暗黑破坏神。
严格来说,动作游戏、RPG、MOBA中的技能并不是一回事,动作游戏中的技能更像是一种特殊的招式
这里我们以动作驱动为主
打断Break
动作驱动中,技能是否可以打断主要看动作是否可以被打断,动作被打断往往表示技能释放结束,进入Exit
技能驱动中,技能是否可以被打断看技能优先级和克制关系,技能被打断不一定释放结束
【触发逻辑】
主要看生命周期各方法在何时调用
初始化和销毁
角色在装备技能或动态切换技能时会调用初始化,卸载技能时调用销毁,通常伴随角色一块销毁,角色技能RoleSkillComponent可以看作是角色实体CombatEntity的一个组件
检查
检查需要在释放技能时才开始,技能释放通常是由玩家主动触发的,也即其调用最终来自与玩家交互的控件,玩家输入和技能是两个模块,为了隔离好,需要有个代理模式SkillInputAgent
交互输入基本有三种类型Down\Press\Up,不同的按钮区分出不同的输入类型,因为是两个不同的系统,需要有一个类型转换
进入/退出/执行/打断
在动作驱动中,通过动作系统调用,具体而言是在施法动作的某个时间点触发调用
对于状态类技能,其退出往往是自己调用
技能的某些表现可能需要在特定时机触发,做特殊调用
【代码实现】
public class CombatEntity{public int playerId;public GameObject roleObject;public Transform transform;public RoleSkillComponent roleSkillComponent;}public class RoleSkillComponent:Component{public AbilityConfigData abilityConfig;public List<SkillEntity> skillEntities = new List<SkillEntity>();public int roleId;public int playerId;private int curSkillIndex;//少量的运行时数据,可以不用专门的Datapublic void Init(int roleId,int playerId){this.roleId = roleId;this.playerId = playerId;abilityConfig = AbilityConfigDataSystem.Instance.GetOrCreateData(roleId);if(abilityConfig != null ){foreach (var item in abilityConfig.skillIds){SkillEntity skillEntity = new SkillEntity();skillEntity.Init(item, playerId, abilityConfig.skillType);skillEntities.Add(skillEntity);}}}public void SkillInput(){if(curSkillIndex >= skillEntities.Count) { return; }var skillEntity = skillEntities[curSkillIndex];switch(skillEntity.state){case SkillEntity.SKillState.Init:case SkillEntity.SKillState.Check:if (skillEntity.Check())//技能初始化完成,检查是否可以释放{skillEntity.EnterSkill();}break;case SkillEntity.SKillState.Exit://当前技能完成就开始下一个技能curSkillIndex++;SkillInput();break;case SkillEntity.SKillState.Enter:case SkillEntity.SKillState.Execute:skillEntity.ExecuteSkill();break;} }public void Destroy(){AbilityConfigDataSystem.Instance.Destroy();abilityConfig = null;foreach (var item in skillEntities){item.Destroy();}skillEntities.Clear();}}public class SkillEntity{#region 常用高频属性拿出来,避免调用栈过深/// <summary>/// 技能的ID/// </summary>public int skillId;/// <summary>/// 哪个角色的技能/// </summary>public int playerId;/// <summary>/// 技能状态/// </summary>public SKillState state;/// <summary>/// 技能类型/// </summary>public SkillType skillType;#endregion#region 技能数据public SkillLocalData localData;public SkillConfigData configData;public SKillRemoteData remoteData;#endregion#region 逻辑组件public List<SkillLogicComponent> logicComs = new List<SkillLogicComponent>();#endregionpublic enum SKillState{None,Destroy,Init,Check,Enter,Execute,Break,Exit,}public void Init(int skillId,int playerId,SkillType skillType){if (state >= SKillState.Init)return;this.playerId = playerId;this.skillId = skillId;this.skillType = skillType;OnInit();SendStateEvent(SKillState.Init);}#region 检查逻辑public bool Check(){SendStateEvent(SKillState.Check);bool res = OnCheck(); return res;}protected bool OnCheck(){return CommonCheck() && SpecialCheck();}public virtual bool CommonCheck(){return CheckCD() && CheckHp() && CheckMp() && CheckSp();}public virtual bool CheckCD(){return true;}public virtual bool CheckHp(){return true;}public virtual bool CheckMp(){return true;}public virtual bool CheckSp(){return true;}public virtual bool SpecialCheck(){return true;}#endregionpublic void EnterSkill(){SendStateEvent(SKillState.Enter);OnEnterSkill(); }public void ExitSkill(){SendStateEvent(SKillState.Exit);OnExitSkill(); }public void ExecuteSkill(){SendStateEvent(SKillState.Execute);OnEexcuteSKill(); }public void BreakSkill(){SendStateEvent(SKillState.Break);OnBreakSkill();}public void Destroy(){if (state <= SKillState.Destroy)return;SendStateEvent(SKillState.Destroy);OnDestroy();}protected virtual void OnInit(){configData = SkillConfigDataSystem.Instance.GetOrCreateData(skillId);localData = SKillLocalDataSystem.Instance.GetOrCreateData(playerId,true);localData.Init(playerId, configData);remoteData = SKillRemoteDataSystem.Instance.GetOrCreateData(playerId,true);if(configData != null){if(configData.skillLogics != null){foreach (var item in configData.skillLogics){var logicSystem = LogicComSystemMgr.Instance.GetLogicCom(item.type);var logic = logicSystem.GetOrCreateLogic(0, true);logicComs.Add(logic);logic.Init(item.type, item.configId);}}}}protected virtual void OnEnterSkill(){foreach (var item in logicComs){item.Start();//告知各技能表现逻辑可以开始释放}localData.curStartTime = Time.realtimeSinceStartup;}protected virtual void OnExitSkill(){foreach (var item in logicComs){item.End();//告知技能表现逻辑该技能释放完毕}}protected virtual void OnEexcuteSKill(){var time = Time.realtimeSinceStartup - localData.curStartTime;foreach (var item in logicComs){if(!item.executed)item.Excute(time);//告知技能释放完毕}}protected virtual void OnBreakSkill(){foreach (var item in logicComs){item.Break();}}protected virtual void OnDestroy(){skillId = 0;playerId = 0;configData?.Dispose();configData = null;localData?.Dispose();localData = null;remoteData?.Dispose();remoteData = null;foreach (var item in logicComs){Component.Destroy(item);}logicComs.Clear();}private void SendStateEvent(SKillState state){this.state = state;//调用消息系统发消息}}public class SkillLocalData : IData{public int playerId;public float cd;public float curCd;public float curStartTime;public float curPressTime;public void Init(int playerId,SkillConfigData configData){this.playerId = playerId;cd = configData.cd;curCd = 0;curStartTime = 0;curPressTime = 0;}public void Dispose(){SKillLocalDataSystem.Instance.ClearData(playerId);}}public class SKillRemoteData : IData{public int playerId;public void Dispose(){SKillRemoteDataSystem.Instance.ClearData(playerId);}}public class SkillConfigData : IData{public int skillId;public SkillPlayType skillPalyType;public SkillTimeType skillTimeType;public string skillIcon;public string skillOtherIcon1;public string skillOtherIcon2;public string skillOtherIcon3;public float cd;public float minCd;public float continueDuration;public float pressDuration;public int hpCost;public int mpCost;public int spCost;public int priority;public int relatedSkillId;public List<ComponentID> skillLogics = new List<ComponentID>();public void Dispose(){}}public struct ComponentID{public SkillLogicComType type;public int configId;}public enum SkillTimeType{Instantaneous,TimeLimited,LogicTrigger,ContinueTime,}public class SkillConfigDataSystem : DataSystem<SkillConfigDataSystem,SkillConfigData>{public override string dataPath => "Assets/ConfigData/SkillConfigData.asset";protected override bool ParseData(SkillConfigData data){return true;}}public class SKillLocalDataSystem:DataSystem<SKillLocalDataSystem,SkillLocalData> {}public class SKillRemoteDataSystem: DataSystem<SKillRemoteDataSystem, SKillRemoteData> {}public enum SkillType{NormalSkill1,NormalSkill2,NormalSkill3,SpecialSkill,CommonSkill1,CommonSkill2,}public enum SkillPlayType{Healing,Control,Shield,Mobility,}public enum SkillLogicComType{Move,Effect,}public class AbilityConfigData:IData{public int abilityId;public string abilityName;public string abilityIcon;public List<int> skillIds = new List<int>();public SkillType skillType;public string abilitySimpleDes;public string abilityDetailDes;public string abilityVideoDes;public string abilityAuidoDes;public void Dispose(){}}public class AbilityConfigDataSystem:DataSystem<AbilityConfigDataSystem, AbilityConfigData> {public override string dataPath => "Assets/ConfigData/AbilityConfigData.asset";}public class Singleton<T> where T : class,new(){protected Singleton() { }private static T _instance;public static T Instance => _instance??new T();public static void Disopse(){_instance = null;}}public interface IData{void Dispose();}public class DataSystem<T,Data>: Singleton<T> where T : class, new() where Data : IData,new(){protected Dictionary<int,Data> data = new Dictionary<int,Data>();public bool init;public virtual string dataPath{get{return null;}private set { }}public virtual void Init(){if(init) return;if(!string.IsNullOrEmpty(dataPath)){Data data = LoadData();if(ParseData(data)){init = true;}}}public virtual Data GetOrCreateData(int dataId,bool create = false){if(!init){Init();}if(!data.TryGetValue(dataId,out var dataValue)){if(create){dataValue = new Data();data.Add(dataId, dataValue);}else{Debug.LogError($"{GetType().Name} don not has data for :{dataId}");}}return dataValue;}public virtual void ClearData(int dataId){if (!init) return;var data = GetOrCreateData(dataId);if(data != null){data.Dispose();this.data.Remove(dataId);}}public virtual void Destroy(){if (!init) return;foreach (var item in data.Values){item.Dispose();}data.Clear();init = false;}protected virtual bool ParseData(Data data){//不同类型的数据做各自的解析return true;}protected virtual Data LoadData(){//调用资源管理系统的接口加载数据return default(Data);}}public class Component{private bool enable = false;public bool Enable{set{if (enable == value) return;enable = value;if (enable) OnEnable();else OnDisable();}get{return enable;}}public bool IsDisposed { get; set; }public long instanceId;public static long StartInstance = 0;public virtual void OnEnable() { if(StartInstance == 0){StartInstance = DateTime.UtcNow.Ticks;}StartInstance++;instanceId = StartInstance;}public virtual void OnDisable(){}public virtual void OnDestroy(){}private void Dispose(){Enable = false;IsDisposed = true;}public static void Destroy(Component com){try{com.OnDestroy();}catch (Exception e){Debug.LogError(e);}com.Dispose();}}public class SkillLogicComponent:Component{public SkillLogicComType type;public int dataId;public float startTime;//开始触发的时间public bool executed;public void Init(SkillLogicComType type, int dataId){this.type = type;this.dataId = dataId; OnInit();}public virtual void OnInit(){//根据逻辑type和dataId从其对应的DataSystem中获取数据}public void Start(){}public void Excute(float time){if (time < startTime) return;OnExcute();executed = true;}public void End(){}public void Break(){}protected virtual void OnExcute(){}public override void OnDestroy(){}} public interface ISystem<T>{T GetOrCreateLogic(long logicId, bool create);}public class ComponentSystem<T,Logic>:Singleton<T>,ISystem<Logic> where T:class,new() where Logic:SkillLogicComponent,new(){private Dictionary<long, Logic> logics = new Dictionary<long, Logic>();public Logic GetOrCreateLogic(long logicId,bool create = false){if(!logics.TryGetValue(logicId, out Logic logic)){if(create){logic = new Logic();logic.Enable = true;}else{Debug.LogError($"{GetType().Name} don not has logic for :{logicId}");}}return logic;}}public class MoveComponent : SkillLogicComponent{public override void OnInit(){base.OnInit();}}public class MoveComponentSystem: ComponentSystem<MoveComponentSystem,MoveComponent>{}public class LogicComSystemMgr:Singleton<LogicComSystemMgr>{public Dictionary<SkillLogicComType, ISystem<SkillLogicComponent>> typeMap = new Dictionary<SkillLogicComType, ISystem<SkillLogicComponent>> () {};public bool init;public void Init(){if (init) return;typeMap[SkillLogicComType.Move] = (ISystem<SkillLogicComponent>)MoveComponentSystem.Instance;init = true;}public ISystem<SkillLogicComponent> GetLogicCom(SkillLogicComType type){if(!init){Init();}typeMap.TryGetValue(type, out var logicSystem);return logicSystem;}}