ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从硬编码到数据驱动:无代码战斗系统架构设计与实战优化

从硬编码到数据驱动:无代码战斗系统架构设计与实战优化 前阵子有个做ARPG的朋友跟我吐槽说他们战斗改版改了三个月每次策划调技能数值都要排队等程序改代码连招重做更是要动逻辑层改一版测一版发个包过去来回折腾。我听完跟他说这就是典型的战斗系统“硬编码”后遗症。后来我把这套基于数据驱动的无代码战斗系统思路整理了一下顺带把Combat - Spark Plugin的架构拆了个底朝天——这个插件最吸引我的点不是它替你把战斗逻辑写好了而是它把“战斗逻辑”从代码里搬到了数据里让策划在编辑器里就能完成大部分技能设计、连招编排、状态调整和表现配置。这篇帖子就详细讲讲这套架构是怎么设计的、数据模型长什么样、运行时怎么解析执行以及我在实际项目里踩过的坑和优化方案适合正在做动作游戏、卡牌战斗或想重构战斗模块的团队参考。1. 为什么我把战斗系统从“硬编码”改成“数据驱动”设计与动机1.1 硬编码战斗的三座大山早期项目里战斗逻辑大多长这样PlayerAttack 类里写死连招A的伤害、连招B的击退距离、技能C的冷却时间Buff 效果直接堆在 Switch 分支里。这种写法的核心问题不是代码乱而是“变更成本”完全落在了程序身上。第一座大山是调参成本。策划说“这个技能伤害高5点手感更好”听起来是小事但伤害数值往往关联着技能表、特效参数、音效时机、教学关卡里的提示文本改一处就要同步改四五个文件还得重新构建。第二座大山是逻辑耦合。技能表现播放动画、触发特效、逻辑判定伤害结算、命中检测、状态流转进入硬直、进入霸体全部交织在一起改动画时长就会影响判定判定改动又影响手感谁都不敢动。第三座大山是回归测试难。没有清晰的输入输出边界改一个连招分支可能触发潜在的全局状态异常测试用例永远补不完。Combat - Spark Plugin 给我最大的启示就是它没有试图去“重构代码”而是把战斗系统分层拆开让“做什么”以数据的形式单独存在“怎么做”交给运行时引擎。数据变了行为就变代码不用动。1.2 数据驱动到底驱动了什么数据驱动不是新鲜词但很多人误以为就是把数值抽到表格里。其实战斗系统里的数据驱动核心是驱动行为逻辑而不仅仅是数值。技能有几段连招、每一段的前摇后摇是多少、攻击命中后附带什么效果、满足什么条件才能触发下一段——这些全是行为全都能用数据描述。打个比方硬编码就像导演亲自写了每一场戏的分镜换台词就得改分镜脚本数据驱动则是写了一套“舞台执行规则”演员拿到的剧本是数据换剧本、换台词、换走位不需要换剧组。策划改剧本程序不用跟着改。这个插件的设计思路和我后来自己的实践高度一致决策层和执行层彻底分离。决策层就是各种各样的配置数据技能表、状态表、AI行为表、表现对照表执行层就是引擎负责读取数据、解析条件、触发效果、广播事件。我在这篇文章里会把它的技能数据结构、Buff 模型、时间轴判定机制、编辑器工具链都拆开讲重点会放在“为什么这么设计”上而不是贴一堆接口让你自己猜。2. Combat - Spark 的层与边界整体架构与数据流转2.1 插件的五层架构我把这个插件的架构梳理成了五层每一层职责单一层与层之间通过标准接口和数据契约通信。第一层是数据配置层。这一层存放所有可编辑的战斗资产包括 SkillAsset、StatusEffectAsset、AIConfigAsset、InputMappingAsset 等。它们可以是 ScriptableObject、JSON也可以是从 Excel 导入的数据表。关键点是这些资产完全不引用任何 MonoBehaviour 和场景对象只描述“世界是什么样的”。第二层是解析编译层。数据配置不能直接被战斗逻辑用因为配置里是字符串、浮点数、枚举引用运行时直接反射解析会有严重 GC 和性能开销。插件会在加载时把原始配置编译成 CompiledSkillData、CompiledEffect预先解析出效果类型、条件委托、命中窗口区间这一步是性能优化的核心后面会专门讲。第三层是运行时执行层。包含 CombatCore 状态中枢、SkillExecutor 技能执行器、HitDetectSystem 命中判定系统、BuffSystem 状态系统、EventBus 战斗事件总线。这一层只认编译后的数据结构不认原始配置。第四层是表现服务层。动画播放、特效触发、音效演出、镜头震动、受击反馈全部在这里。逻辑层不直接调用 Animator而是通过事件总线发送“SkillCastStart”“HitConfirmed”这类战斗事件表现层订阅并响应。第五层是工具层。主要是 Unity 编辑器下的自定义 Inspector、技能可视化编辑器、实时调试面板、数据校验工具、Excel 导入工具。这一层决定了一个无代码系统好不好用也是实际项目里最容易拖后腿的一部分。2.2 一条攻击指令的完整数据流理解了分层再来看数据在系统里怎么流转会更直观。以玩家按下轻击键触发“破风连斩”第一段为例完整链路是这样玩家按下攻击键InputHandler 先把输入写入 InputBuffer输入缓存同时向 EventBus 发送一个 RawInputEvent。CombatCore 收到事件后先检查当前角色状态是否允许攻击——如果是硬直状态输入会被直接丢弃或进入队列等待。通过状态检查后CombatCore 根据当前连招上下文去数据资产里查“轻击序列第一段”应该用哪个 SkillAsset这里涉及条件匹配比如体力是否足够、武器类型是否匹配。找到技能后SkillExecutor 加载对应的 CompiledSkillData进入技能生命周期。生命周期内部分前摇、判定窗口、后摇三个阶段每个阶段的时间来自配置数据里的 Timing 字段。在判定窗口内HitDetectSystem 主动开启攻击碰撞体进行命中检测命中后生成 HitConfirmEventBuffSystem 根据技能配置结算伤害、附加Buff、触发击退。表现层监听到这些事件后才去播放动画音效特效。这整个过程里有一个非常关键的设计逻辑层里的技能流只依赖时间轴数据驱动不依赖动画片段时长。动画只是表现层的演出。这套解耦逻辑在插件里贯彻得相当彻底也是我觉得最值得抄作业的部分。3. 核心数据模型设计技能、状态、AI行为的配置结构3.1 技能配置帧窗口、效果与分支技能数据是整个战斗系统的心脏。我按插件里常见的配置格式以 JSON 形式展示一个典型的连招技能{ skillId: combo_sword_01, skillName: 破风连斩, inputPattern: [lightAttack, lightAttack, lightAttack], timing: { windup: 0.15, activeStart: 0.15, activeEnd: 0.45, recovery: 0.25 }, conditions: [ { type: staminaCheck, operator: GreaterEqual, value: 20 }, { type: stateCheck, state: canAttack } ], effects: [ { type: damage, baseValue: 120, scale: 1.2, hitType: normal }, { type: knockback, distance: 2.0, duration: 0.4 }, { type: pawnShake, amplitude: 0.3 } ], branches: { nextSkillId: combo_sword_02, branchCondition: inputWindow }, cost: { stamina: 15 } }字段里的 timing 是整个设计最精妙的地方。前摇 windup、判定窗口 activeStart 到 activeEnd、后摇 recovery组成了这套时间轴。判定窗口不再依赖动画事件而是用独立的逻辑时间轴驱动这就避免了前面说的“动画一改判定全错”的问题。conditions 是技能可以施放的先决条件effects 是命中后要执行的“效果清单”。这里有个容易误解的地方effects 不是脚本逻辑不能在里面写 if、写循环、写自定义处理它是声明式的“类型参数”组合。任何需要复杂逻辑的技能拆解成多个简单效果的组合。这也是无代码系统能稳定运行的前提——越底层越要规范不给配置开乱写逻辑的口子。branches 字段负责连招树的跳转。玩家第一段打出去之后如果在某个时间窗口内再次输入攻击键系统就跳转到 combo_sword_02。这个分支用数据描述策划在编辑器里可视化连线完全不用程序参与。3.2 状态与Buff的数据描述战斗里除了技能还有频繁出现的异常状态和 Buff。插件里用 StatusEffectAsset 描述结构大概是这样的{ buffId: burn, duration: 5.0, maxStack: 3, tickInterval: 1.0, modifiers: [ { targetStat: defense, operation: subtract, value: 15 }, { targetStat: moveSpeed, operation: multiply, value: 0.8 } ], finiteEffects: [ { trigger: onExpire, effect: { type: explosion, radius: 2.0 } } ] }Buff 设计里最值得学习的是“修改器有限效果”的划分。modifiers 是持续存在的属性增减叠加规则由 maxStack 和 duration 控制finiteEffects 是单个事件触发的效果比如 Buff 结束时的爆炸。这个模型能覆盖绝大多数卡牌、ARPG 的状态需求而且数据化之后做数值平衡非常直观。AI 行为也可以数据化Combat - Spark 支持把行为树/状态机的节点配置成数据资产。敌方单位的行为节点比如“追击”“突进”“攻击”“后撤”每个节点带条件优先级的排序运行时会根据当前距离、血量百分比、玩家状态选择行为。无代码的 AI 设计重点不是做出多聪明的人工智能而是让策划能随时调整敌人的攻击意图、出手频率和战斗节奏。3.3 数据校验与版本管理数据驱动的战斗系统有个天然风险配置填错了运行时才炸。所以插件里设计了完善的数据校验链。编辑器下重写 OnValidate对每个 SkillAsset、StatusEffectAsset 做完整性检查时间轴必须满足 activeStart 不小于 windup、effects 里不能引用不存在的枚举值、条件字段的 operator 必须在合法集合内。这些校验全部可视化输出到 Inspector 底部配置错误直接在编辑阶段拦下来。版本管理也很重要。我在实际项目里遇到过数据更新后服务器和客户端配置不一致导致玩家技能表现和伤害完全对不上。建议每个数据资产都带 schemaVersion 和 assetVersionschemaVersion 决定解析规则的兼容性assetVersion 用来做热更对比。数据协议只能向后兼容地演进删除字段必须走弃用流程不能直接抹掉。4. 无代码不等于无设计编辑器工具与可视化配置方案4.1 为什么说“无代码不等于没代码”很多人听到无代码就以为策划不用写代码、程序也轻松了这是双重的误解。无代码战斗系统的本质是把“代码逻辑”转化成了“数据结构配置工具”程序的工作从写战斗逻辑变成了设计数据协议和编辑器工具。策划确实不用碰 C# 了但他们要理解帧窗口、条件分支、效果组合这些概念。这个过程不是没有代码而是代码被“封装”进了编辑器按钮后面。我在这套系统里最大的体会是无代码系统好不好用取决于工具链完整度而不是配置格式有多优雅。如果只给策划一个 JSON 文件让他们手填这系统基本等于没做。Combat - Spark 在编辑器层做得比较完整我也在项目里复用和扩展了它的思路。4.2 五件套Inspector、GraphView、预览、调试、表格导入一个能实际落地的无代码战斗配置系统我觉得至少要包含五块工具自定义 Inspector每个技能资产在 Unity 的 Inspector 里有清晰的分组展示时间轴用带刻度的滑动条拖拽效果列表支持动态增删改引用关系可视化显示。技能连招可视化编辑器基于 GraphView 或节点图框架把技能的 branches 连线画出来节点之间拖线建立连招关系并支持运行时高亮当前节点。策划在编辑器里就能看到完整连招树。实机预览窗口配置技能时可以直接在预览场景里播放当前角色动画显示攻击判定框、伤害数值预演、帧数据标尺。这一步能减少大量进游戏实测的返工。运行时调试面板进入 Play Mode 后Debug 面板实时显示当前技能状态、处于哪个阶段、命中列表、帧窗口进度、输入接收情况。调试面板在早期联调阶段几乎能替代一半的调试日志。Excel 导入导出很多团队策划习惯用 Excel 做数值表。我实现了 Excel 直接转 ScriptableObject、ScriptableObject 导出 Excel 的双向工具本质上是在同一份数据契约下做格式转换不改变核心数据模型。这五件套看着工作量大但选型上其实有捷径。Inspector 用 Odin Inspector 之类的插件节点图用开源的 XNode 或 Unity 官方 GraphView预览场景做好 Object Pool 和 Timeline 复用开发周期能压缩到一两周内。4.3 组件化的效果单元无代码配置最大的敌人是“面条式配置”。比如策划图省事把“伤害击退掉血Buff屏幕震动留残影”全部塞进一个叫 megaEffect 的复合效果里。一开始很快后面每个技能都要复制这个巨型效果再改几个参数配置文件膨胀到不可维护。正确的做法是借鉴组件化思想。每个效果单元只做一件事damage 就是伤害结算knockback 就是击退位移vfxSpawn 就是播放特效audioPlay 就是放音效。技能数据里的 effects 只是这些单元的组合。我在插件基础上扩展过一个“效果模板”功能把常用组合保存成可复用的 EffectTemplate策划拖拽模板再覆写少量参数既保证了复用性又避免了大而全的耦合效果。5. 运行时执行引擎事件总线、状态机与行为解析的配合5.1 执行引擎的三大件CombatCore、SkillExecutor、EventBus数据配置到了运行时需要三件核心组件配合才能“活”起来。CombatCore 是所有战斗角色的状态中枢持有当前状态、连招上下文、体力值、受击状态等运行时数据同时向外暴露可以安全变更状态的接口。SkillExecutor 是技能生命周期的驱动器接收编译后的技能数据用时间轴推进状态。EventBus 负责把战斗事件分发到逻辑层和表现层比如 SkillStart、ActiveWindowOpen、HitConfirm、SkillEnd。这三者配合的关键点在于“责任不能越界”。CombatCore 只做状态判定和输入仲裁不直接执行动画和特效SkillExecutor 只负责时间轴推进和效果结算不管玩家按了什么键EventBus 只做事件分发不知道谁在处理事件、处理多久。这种边界感让整个系统变得容易调试也容易做热更新和网络同步。核心执行逻辑我简化为下面这段示意代码真正的插件里代码会更复杂但骨架是类似的public sealed class SkillExecutor { private ICompiledSkill _current; private SkillPhase _phase; private float _phaseTimer; public void StartSkill(ICompiledSkill skill, CombatContext context) { _current skill; _phase SkillPhase.Windup; _phaseTimer 0f; EventBus.Publish(new SkillStartEvent(skill.SkillId)); } public void Tick(float deltaTime) { if (_current null) return; _phaseTimer deltaTime; switch (_phase) { case SkillPhase.Windup: if (_phaseTimer _current.WindupDuration) { _phase SkillPhase.Active; _phaseTimer 0f; EventBus.Publish(new ActiveWindowOpenEvent(_current.SkillId)); } break; case SkillPhase.Active: if (_phaseTimer _current.ActiveDuration) { _phase SkillPhase.Recovery; _phaseTimer 0f; EventBus.Publish(new ActiveWindowCloseEvent(_current.SkillId)); } break; case SkillPhase.Recovery: if (_phaseTimer _current.RecoveryDuration) { EventBus.Publish(new SkillEndEvent(_current.SkillId)); _current null; } break; } } }这里要注意阶段切换必须通过 EventBus 发布事件而不是直接调用表现层的动画播放。因为表现层的角色蓝图在换皮、换技能特效后可能会改变逻辑层不该关心这些同时事件驱动也让自动测试更容易测试用例可以直接订阅事件断言流程。5.2 攻击判定的时间轴扫描逻辑动作游戏的攻击判定往往是玩家最敏感的部分。常见做法是依赖 Animator 里的 Animation Event 来开合 Hitbox比如动画播到第 10 帧触发一个事件。这套方案在快速迭代时容易出问题动画动作重做以后时长变了事件帧也跟着变策划在引擎里拖来拖去判定手感莫名其妙地偏移。数据驱动的方案是把判定时间轴从动画里剥离出来以逻辑时间为准。判定系统里维护一个 ActiveHitboxList技能的帧窗口数据决定什么时候把 Hitbox 加入列表、什么时候移除。HitDetectSystem 在 FixedUpdate 里统一扫描 Hitbox 与受击方碰撞体的相交情况命中后做命中确认、伤害结算、受击反馈。动画只是“对应这个时间轴演出的视觉表现”动画偏移了逻辑判定不受影响。这里有个我踩过的坑物理扫描在低帧率卡顿时可能漏掉高速攻击 Hitbox导致技能穿模不命中。解决方案是给命中检测做“连续碰撞检测”或者叫 swept shape在上一物理帧和当前物理帧之间做插值扫描确保快速攻击也能稳定判定。5.3 数据驱动在联机同步下的额外红利如果做的是联机战斗数据驱动的架构会带来额外的好处在。因为技能的关键数据是纯数据天然可序列化服务端可以直接复用同一份技能定义做验证。比如玩家发起的技能请求包含技能ID、目标方向、帧窗口内的输入序列服务端用同一套数据计算伤害和状态变化比对客户端上报的结果就能在毫秒级别识别出数值修改或外挂行为。不过这要求技能效果必须是确定性的相同的输入、相同的时间轴、相同的角色状态必须产生相同的结算结果。插件在设计编译器时把随机数、时间戳这类变量收敛到效果参数里显式声明而不是让开发者在效果实现里随意读系统时间这点很值得学习。6. 性能、热更新与团队协作实战中遇到的坑和优化6.1 高频GC与反射访问的开销数据驱动如果不做性能规划运行时非常容易卡。最典型的坑是“每次执行技能时都反序列化配置”或者在 Update 里反复查询 SkillAsset 的字段。这类操作会产生高频 GC 分配表现就是战斗时偶发掉帧。我在用这套架构的第二个版本时做了三个关键优化。第一个是前面提到的“预编译”把原始数据在加载阶段全量编译成扁平结构体例如 CompiledSkillData 里用数组存帧窗口用枚举代替字符串效果类型用抽盒后的整数索引代替字典查询。第二个是对象池Hitbox、伤害数值飘字、VFX 挂点、特效实例全部走池子避免运行时频繁创建销毁。第三个是事件总线按类型分通道比如 HitChannel、BuffChannel、CameraChannel不同系统只订阅自己关心的通道避免事件风暴把所有订阅者都唤醒一遍。这三个优化做完之后我拿 50 个 AI 单位同时释放技能做过压测帧时间稳定在预期范围内接近硬编码战斗的性能表现这才是数据驱动方案能上线的底气。6.2 数据热更新的边界与协议兼容团队里经常讨论要不要做逻辑热更新我个人的建议是能不动逻辑就不动逻辑优先做数据热更。因为战斗系统 80% 以上的调整都是数值、持续时间、触发概率、连招分支这些全部是数据热更数据就能解决没必要冒险热更逻辑层。但数据热更要提前考虑协议兼容问题。服务器下发的是新 schemaVersion 的数据老客户端还在用旧解析规则解析出来的结果就会错乱。我给每个数据资产加了一个兼容性矩阵解析器只认自己支持的最大版本遇到更新的版本直接拒绝加载并走强制更新流程而不是静默忽略字段。静默忽略在战斗系统里特别危险伤害值、Buff 时长这些字段一旦被忽略客户端和服务器的表现就分叉了。6.3 程序、策划与美术的协作规范最后这部分不是技术问题但比技术更能决定这套架构的生死。数据驱动战斗系统上线后容易出现的怪现象是“策划觉得工具难用程序觉得策划乱配置美术觉得战斗团队天天改需求”。根因是大家没有在同一套数据契约下协作。我建议在项目启动时就定好三件事第一数据字段的命名规范和取值约束比如伤害类型必须是枚举、时间必须是秒、持续时间不能为负数这部分由程序做校验工具保证。第二数据变更走评审流程配置改动要在文档里注明原因和预期影响避免策划改了一个共享效果模板全项目几百个技能全部悄悄变数值。第三表现层和逻辑层彻底分离之后美术可以从逻辑需求里解放出来只对表现事件做响应但前提是事件节点命名稳定别今天叫 HitConfirm 明天叫 OnHit不然表现层订阅代码改到吐。我在实际项目里是这么落地协作规范的每周配置评审会策划列变更清单程序确认数据协议兼容性美术确认表现事件可用。流程不重但确实把因为“战斗配置瞎改”导致的线上事故从每月几次降到了几乎没有。说回我自己在整套架构里最深的一次踩坑早期我把攻击判定的时间轴绑在了动画事件上策划觉得前摇太长直接换了套动画判定帧全偏了。后来彻底改成纯数据帧窗口驱动才明白插件里那个独立 timing 设计的用意。数据驱动的战斗系统最大的价值就是让每个人都只改自己能该的东西程序管引擎、策划管行为、美术管表现互不牵绊。也建议你如果要在自己项目里落地这套思路不要一上来就全量重构先拿一个英雄角色做完整的数据驱动原型跑通技能编辑、判定、BUFF、表现响应这一整套链路再逐步铺开会稳妥很多。
返回列表