一种基于DSL的数据驱动式大规模游戏战斗技能的实现方法

背景 我曾经为两款游戏开发战斗技能系统,使用的是传统OOP的方式,大概设计是有个基类BaseAbility,里面有一些基础的通用逻辑,然后不同的技能实现在不同的子类中。2020年我们立了一个新项目,策划告诉我这个项目以英雄战斗技能为主,可能多达500个技能。 过去两个项目中基于OOP的战斗系统已经让我陷入纠结和痛苦之中,因为一直在为类继承体系而纠结,虽然继承可以提高复用性,但也大大增加耦合性和复杂性,到了后期子类爆炸,逻辑在不同类中来回穿梭,属性计算也在庞大的类继承体系中迷失。 为了让战斗技能系统变的简单清晰,这个项目我准备用一种全新的基于DSL的数据驱动式的战斗技能系统,战斗框架采用ECS架构。数据驱动具体来讲是基于DSL来表述一个技能的全部逻辑,DSL在维基百科中的定义: 领域特定语言(英语:Domain-specific language,缩写:DSL),也称为特定域语言,是专门针对特定应用领域的计算机语言,和可以用在多种领域的通用语言(GPL)恰好相反。像HTML专门用在网页设计上,就属于领域特定语言。 战斗系统代码负责解析执行DSL中的逻辑,ECS是为了更好的实现逻辑和表现层分离,因为我们战斗系统是同一套代码同时运行在客户端和服务端,至于ECS带来的性能上的收益则是额外收获。 技能DSL设计 在参考了Dota2的技能描述语言后,我决定设计一套更简洁,功能更丰富的DSL来表达技能的配置系统。这套DSL必须足够简单,策划容易阅读和编写,不需要太多专业编程知识。经过研究,我发现yaml格式就可以很好的作为一种DSL的载体来定义技能。最终用这套系统实现了700多个技能,开发技能效率很高,上线后效果也不错。 后期基本是策划在做技能,程序偶尔辅助下。 先看个近战普攻技能示例: # MeleeBasicAttack.yaml # 近战普通攻击技能模板 # 技能每个等级的伤害数值 DamageTable: Ability: Type: PHYSICAL Levels: - 140% * $ATK + 600 - 280% * $ATK + 6000 - 320% * $ATK + 21000 - 400% * $ATK + 30000 # 技能主动作序列 MainActions: - PlayBasicAttackAnim: Sequence: - Attack1: 3 - Attack2: 1 - WaitCastPoint: Duration: $CAST_POINT - PlaySound: Name: $CAST_SOUND - PlayVfx: Target: $TARGET Prefab: Battle/Battlefield/VFX_General_AttackHit - AbilityApplyDamage: Target: $TARGET - PlaySound: Target: $TARGET RandomRange: - SFX_HIT_TARGET_1 - SFX_HIT_TARGET_2 - WaitRecoverTime: Duration: $RECOVER_TIME 第一段DamageTable用来定义技能的伤害,因为我们游戏技能可以升级所以写了4个等级的伤害,每一行都是一个伤害计算公式,公式里有常量和变量引用,上面技能DSL里$ATK用来引用英雄当前的物理攻击力,技能执行Runtime会动态替换里面的变量,上面技能1级伤害是140%的英雄物理攻击 + 600点伤害,后几级以此类推。DamageTable是一个Map结构,技能伤害的Key是Ability,技能可以有多种伤害,在DamageTable定义多个伤害配置。 ...

July 25, 2023 · 2 min · Jee

简单谈谈战斗系统中属性计算

英雄属性 单位状态(State) 单位状态(晕眩/冰冻/沉默/魅惑/嘲讽/石化/禁锢/隐身/无敌等),只需要一个bool标记,多种状态可以并存,因此我们用一个64位整数来存储,每个bit代表一种状态,最多可以有64种状态,足够游戏中战斗用了。 public enum UnitStateType { Stunned = 0; // 晕眩 Sleeping = 1; // 沉睡 Fronzen = 2; // 冰冻 Chaos = 3; // 混乱 Silence = 4; // 沉默 Disarm = 5; // 缴械 Taunted = 6; // 嘲讽 Stone = 7; // 石化 Rooted = 8; // 禁锢 Invisible = 9; // 隐藏 Invincible = 10; // 无敌 // 更多状态定义在下面,最多大值63 } public class UnitStates { public UInt64 Data { get; private set; } public void AddState(UnitStateType type) { Data |= GetStateBits(type); } public void RemoveState(UnitStateType type) { Data &= ~GetStateBits(type); } public bool HasState(UnitStateType type) => (Data & GetStateBits(type)) != 0; public UInt64 GetStateBits(UnitStateType type) => (1 << (int)type); } 单位属性(Attribute) 为了让战斗属性更加灵活,我们并不在一个类里定义所有属性,而是用表格的方式存储所有属性列表,方便以后增加新的属性。我们使用定点数来确保战斗计算结果的确定性,所以数值存储使用FixedNumber类。 public enum UnitAttributeType { Health, MaxHealth, PhysicalDamage, MagicalDamage, PhysicalArmor, MagicalArmor, CriticalChance, CriticalDamage, MoveSpeed, AttackSpeed, AttackRange, // 更多属性添加下面,数字递增 MaxCount } public class UnitAttributes { public List<FixedNumber> Data { get; private set; } public UnitAttributes() { Data = new List<FixedNumber>(); for (int i = 0; i < UnitAttributeType.MaxCount; i++) { Data.Add(0); } } public void SetAttribute(UnitAttributeType type, FixedNumber amount) { Data[(int)type].Set(amount); } public void AddAttribute(UnitAttributeType type, FixedNumber amount) { Data[(int)type].Add(amount); } } 战斗属性计算 属性的改变一般有两种:绝对值加量,百分比加量。我们的游戏中还有一种特殊的技能效果:属性拷贝,即一个英雄偷取另外一个英雄属性。由于属性计算过程比较复杂,被各种Buff/Debuff影响,之前我做的一个战斗系统中,偶尔会出现策划反应数值不大对劲,但又没有精确的证据证明数值有不错错误。 为了解决这个问题,新的战斗属性计算我采用这样的设计:所有属性的改变都单独存储,每帧都重新计算最终属性。 ...

May 11, 2023 · 2 min · Jee