前阵子有个做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 主动开启攻击碰撞体,进行命中检测,命中后生成 HitConfirmEvent,BuffSystem 根据技能配置结算伤害、附加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 和 assetVersion,schemaVersion 决定解析规则的兼容性,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、表现响应这一整套链路,再逐步铺开,会稳妥很多。