前阵子在一款 3D 动作游戏原型里,我用 UNITY 重做了角色技能系统。两周时间,从最初的单技能演示走到了五六个技能、多段伤害、位移和受击打断并存的状态。这篇算是 UNITY 开发记录,也是我对动作游戏技能系统架构设计的一次复盘。先说句当下最直接的判断:技能系统经过第一轮迭代后,真正的难点通常不再是怎么放出一个技能,而是怎么让每个技能的状态推进、数据配置与表现反馈不纠缠在一起。代码里一旦出现“先播动画,等三帧后伤害,然后回待机”这种顺序写死的逻辑,单看没问题,技能一多就会变成补丁叠补丁。
动作游戏里的技能,看似是一个动作、一串伤害、几秒冷却的组合,但放到工程里,它更像一台小型状态机加一组可配置的数据管道。如果只把技能当成角色身上的一堆方法,那么扩展一个角色、新增一个变身态、接一套连招时,都会被迫去改同一个类。真正值得花时间的,是把“技能请求”“技能逻辑”“技能表现”这三件事在结构上分开。分开之后,新技能才可能变成配置和节点,而不是每次重新写一套流程。
1. 先看看最常见的技能系统混乱是怎么出现的
1.1 从单个技能到连招,代码为什么会越写越难
在动作游戏原型阶段,最容易写出这样的代码:角色脚本里有一个DoAttack(),方法内部先animator.Play("Attack1"),等OnAnimatorEvent触发后生成伤害盒,再等播放结束回到待机。单技能时,这套代码非常直接,甚至比很多抽象系统更可靠。
问题往往出现在第二步。你需要在攻击后接第二段攻击,于是加状态标记;要支持跳劈落下后产生冲击波,于是在动画事件里多调用一个特效方法;要允许玩家在特定帧取消后摇用于闪避,就要在状态判断里不断追加条件。这个时候,角色脚本开始膨胀,动画事件开始跨越纯粹表现边界,直接修改战斗数据。
这不是某个细节写错了,而是结构没有给变化留位置。技能不再是一次调用,而是一个有开始、有阶段、可被打断、可被替换、可被复用的过程。过程就应该有过程的结构,不能继续伪装成一次性方法。
1.2 混乱信号:动画、逻辑和表现绑死在同一层
我从维护经验里总结了几个比较典型的信号。如果你接到一个 Unity 动作游戏技能系统,发现代码里大量使用yield return new WaitForSeconds去等动画帧,并且时常因为动画时长调整而伤害丢失,那基本可以判断:逻辑依赖了表现时间。
还有一个信号是:状态机里塞了太多职责。同一个SkillState枚举里,既表示角色的当前动作状态,又要承担伤害判定开关,还要处理镜头震动、武器拖尾、音效播放。角色逻辑类和表现类职责不分,最终表现在:技能逻辑无法脱离Animator和特效组件运行。单人游戏还能将就,一旦要做敌人训练场、AI 角色、多人联机同步,这套绑定会立刻成为瓶颈。
动作游戏技能系统的架构设计,不是为了写起来好看,而是为了回答一个问题:当策划希望把一个 60 帧动画中第 18 帧改成带有位移和霸体的攻击判定时,开发人员要改哪里?理想情况是只改技能配置或对应节点,而不需要在状态机里再做一次分支地狱。
2. 动作游戏技能系统的基础分层
2.1 表现层只做表现,不要替逻辑做决定
我把技能相关的代码分成三层:表现层、逻辑层和数据层。它们之间的依赖方向不是混乱的,而是从表现向逻辑单向通知,从数据向逻辑单向供应。
表现层包括Animator、VFX、音效、镜头震动、UI触发、受击闪白等。它接收逻辑层的指令,例如“开始一段名为 Attack_1 的前摇表现”“生成一次命中特效”,然后按自己的节奏播放。表现层可以有自己的动画事件处理,但事件里不应该携带“扣多少血”“是否暴击”“能否打断”这类规则性信息。
也就是说,动画事件能告诉我“右脚落地了”,但不应该替战斗系统决定“现在立即对前方敌人造成伤害”。更稳妥的做法是,动画事件只是一个时间标记,比如OnAnimatorEventHit,由逻辑层检查当前技能状态是否处于可判定帧,再决定是否真正触发命中。这样即使动画事件因为换皮多播了一次,也不会引发逻辑层的数据错乱。
2.2 逻辑层提供完整过程控制
逻辑层是这套系统的核心,它知道一个技能当前处于哪个阶段:待机、释放前摇、技能生效中、后摇、被强制打断、自然结束。这套阶段切换需要独立于动画状态机。为什么?因为动画状态机没有义务理解游戏规则,它只负责判断该播哪段动画。
你可以用枚举加状态机来实现:
public enum SkillPhase { None, Startup, Active, Recovery, Interrupted, Finished } public interface ISkillContext { SkillPhase CurrentPhase { get; } bool TryRequestSkill(SkillRequest request); void ForceInterrupt(SkillInterruptReason reason); }逻辑层负责更新SkillPhase,并对外广播阶段变化。表现层监听阶段变化来播放动画、粒子或音效;战斗层监听Active阶段来开启伤害判定;角色控制层监听Recovery阶段来决定是否允许连招。
这样拆分以后,“哪个阶段该做什么”被收敛到了逻辑层。玩家输入不再直接播放某个动画,而是先变成技能请求,由逻辑层判断当前角色是否允许接受这个请求。这个判断包括:当前技能是否还可以被取消、是否有足够资源、是否处于受击硬直等。判定通过后才进入新的技能流程。
2.3 数据层:技能不再是代码,而是描述
技能数据常见的做法是ScriptableObject或 JSON/表格。一个技能描述自己的持续时间、伤害帧、位移曲线、伤害盒配置、音效与特效引用。程序员最好不要在角色脚本里写死每个技能的参数,而是把技能当成可被外部检视的数据资产。
一个只做演示的技能配置可以非常朴素:
{ "skillId": "attack_03", "displayName": "上挑斩", "totalDurationFrames": 50, "startupFrames": 10, "activeFrames": [12, 18], "recoveryFrames": 19, "movementCurve": "curve_forward_01", "hitbox": { "type": "box", "offset": [0, 1.2, 0.8], "size": [1.6, 1.0, 1.0], "layerMask": "Enemy" } }数据层解决了两个问题:第一,同一个逻辑处理代码可以驱动多个技能;第二,调整参数时不需要重新编译逻辑。很多团队到这里会有个疑惑:如果只是单机原型,这么做是不是太重了?其实不重。你没有必要一开始就把技能做成可视化编辑器,但把参数从方法体里挪出来,几乎不增加成本,却能避免以后每调一次手感都要找代码。
3. 把玩家输入变成一个可控的技能流程
3.1 输入层:键位只是请求源
动作游戏通常会涉及连招。简单实现是检测到按键后,立刻切换到下一个攻击动画;但这会让连招判定散落在Update里。更好的做法是输入层只生成请求,由角色控制器统一决定当前请求要不要被接受。
我习惯做一个SkillRequest:
public struct SkillRequest { public string SkillId; public int ComboCounter; public Vector3 InputDirection; public bool IsPressed; }按键本身不触发技能,只是把请求放到了角色控制器入口。控制器会把SkillId映射到技能数据,再把请求传给技能状态机。为什么绕这么一层?因为有些技能是短按触发,有些是长按蓄力,有些需要结合方向键决定派生出不同动作。如果输入直接连到技能方法,这些差异会被一堆if吃掉,后面很难扩展。
在输入层和技能状态机之间保留一个“请求入口”,本质上是在给系统做缓冲。游戏程序设计里,缓冲往往意味着可以在不改调用方的情况下增加规则。例如跳跃刚落地的几帧内不想接受攻击指令,或者攻击后摇的最后几帧接受指令并预输入下一段连招,这些规则都可以在请求入口处统一处理。
3.2 阶段推进:前摇、生效、后摇与打断
我建议把技能阶段之间的转换点做成显式方法,而不是依赖帧循环里跳来跳去。一个最小实现包括:
EnterSkill(SkillRequest request)OnActiveStart()OnActiveEnd()OnRecoveryEnd()ForceInterrupt()
每次转换都广播事件,并让表现层响应。这一步非常关键:技能逻辑只关心阶段转换,不直接调用表现层的方法,表现层通过订阅事件的回调来播放动画和特效。这样一来,如果一个技能因为被击飞而中断,逻辑层会广播Interrupted,表现层就知道要停止当前动画并切换到受击姿态;而战斗层会知道要关闭当前伤害判定盒。所有模块基于同一个事实行动,而不是各自猜测状态。
阶段推进的时间基准也有讲究。典型动作游戏使用帧数据,因为帧可以让玩家感受到可预期的输入窗口。Unity 项目里不一定要硬编码 60 帧更新,但建议把阶段时间转换成基于秒的起止点,避免在不同帧率下出现误差。可以这样做:进入Startup时记录开始时间,每帧检查是否超过startupDuration,然后进入下一阶段。如果网络同步或回放需要,再在时间轴上额外记录帧序号。
3.3 主动取消与强制打断的判定优先级
动作游戏里,闪避取消后摇、跳跃取消攻击、受击打断攻击,都是非常常见的规则。这些规则决定了手感,也最容易做乱。架构上要区分:“主动取消”是由玩家发起的新技能请求对当前状态的中断;“强制打断”是由外部伤害或控制效果引发的状态重置。
主动取消要遵循严格的取消表。这个表可以由两个技能之间的派生关系描述,更简单的做法是在当前技能数据里列出一系列cancellableTo字段。例如当前攻击技能在Recovery阶段允许取消到Roll技能。每次新技能请求到来时,逻辑层先检查取消表,再检查资源条件。如果没有取消表,只靠逻辑层里的条件判断,取消关系很快会扩散到很多地方。
强制打断则不需要走取消表。它更像是系统级指令:角色受到击飞、眩晕、死亡等状态时,无论当前处于什么阶段,都要立刻进入Interrupted。在这个入口里,伤害判定盒要关闭,技能表现要停止,角色状态要切到硬直状态。注意别在伤害回调里直接操作控制器状态。伤害回调只负责告诉控制器“这次攻击造成了击飞”,再由控制器的打断逻辑接管,这样才不容易出现一个回调修改多个系统、互相覆盖的问题。
4. 数据配置与节点化:让技能可以被“长出”而不是被“复制”
4.1 新手可以从枚举技能做起,但别忘了提炼共性
技能系统最重要的是识别哪些部分会反复变。同一套技能流程里,变化的是:伤害盒位置、伤害数值、攻击位移、击退效果、命中音效、动画速度、打断帧。不变的是:从启动到生效、再到结束的过程。所以我不建议一开始就为每个技能重写一个技能类。更好的做法是定义一套通用技能流程,用配置区分差异。
例如“向前挥砍”“上挑斩”“冲刺突刺”表面上完全不一样,但底层都在做同一套事:进入启动、播放动画、开启一个可配置的伤害盒、通过碰撞检测结果回传命中、然后进入恢复期。差异只是位移曲线、命中盒形状和伤害数据不同。这类技能可以用同一个GenericSkillController处理,数据表提供参数差异即可。
很多团队会在这里走极端:一部分人追求所有技能都能用同一份数据描述,最终为了描述“连续斩击”“召唤陨石”这类特殊机制而设计出极其复杂的节点引擎;另一部分人则直接放弃数据驱动,每个技能都写一个类。对中小项目来说,更务实的路线是:常见技能走通用节点,特殊技能用专用逻辑节点,两种节点都在同一个技能流程框架里被调度。
4.2 行为节点是一种更稳定的抽象
节点化设计不是一上来就搞可视化蓝图。它指的是把技能流程拆成若干个可执行节点,例如:
- 播放动画节点
- 等待帧节点
- 生成伤害盒节点
- 位移节点
- 播放特效节点
- 调用外部动画事件节点
每个节点负责一小块规则,所有的技能都由节点序列组合出来。这个思路的优势是,新技能往往不需要新增代码,只需要调整节点顺序和参数。通用逻辑通过节点复用,独特技能需要新增节点时,也不会污染已有节点。
节点化的成本在于调试和排序。最初不需要做一个图形编辑器,可以用ScriptableObject序列化一个节点列表,或直接用 JSON 描述。在编辑器里能看到节点顺序和参数值,就足以让策划配合调参。把“节点创建工具”和“节点运行器”分开,会让架构边界更干净。
4.3 数据驱动不是银弹:哪些情况宁可写代码
这里要说清楚适用边界。技能完全数据驱动并不总是好事。如果技能的每一个细节都想在配置里暴露,配置表的字段会失去可读性,调参本身也会变成一场灾难。我通常的划分标准是:重复使用的普通技能走数据配置;机制性、一次性、与场景强相关的技能走专用代码节点。
例如一个 Boss 把地面砸裂,然后生成延迟爆裂岩浆,这种技能涉及到地形状态、事件序列、区域预警,往往很难用通用数据节点完全描述。硬用配置去拼,反而会耗掉大量开发时间。正确做法是通用框架里最多规定它可能被打断、可能播放受击动画,剩下的采用专用技能逻辑。架构设计不是要把业务全部装进统一格式,而是给扩展留好稳定接口。
5. 多技能、多角色扩展时的规则边界
5.1 角色主控制器只做仲裁,不背全套技能实现
当多个角色拥有不同技能组时,很多团队会倾向于在角色主控制器里暴露不同技能方法,然后通过判断角色类型来决定哪段代码能跑。这种写法在角色数量少时很直观,但角色越多,分支越膨胀。更好的做法是,角色主控制器只保存一份技能授权表:当前角色可以发起哪些技能,以及哪些状态允许取消到哪些技能。
实际的类结构可以拆成四部分:
- 角色输入仲裁器:接收玩家/敌人输入,生成技能请求。
- 技能状态机:负责维护当前技能阶段和状态转换。
- 技能数据表:按角色和技能 ID 索引配置。
- 表现协调器:监听技能阶段并驱动动画、特效和音效。
当技能系统需要知道角色能不能放某个技能时,问技能授权表;需要知道当前处于什么阶段时,问技能状态机;需要知道阶段转换后视觉上做什么时,看表现协调器;而不是让角色控制器里一个巨大的switch同时回答所有问题。
5.2 死亡、切场景和技能重置不要留下残留状态
动作游戏里角色很可能会在技能释放期间被击杀,或因为剧情演出被强制切换到另一套控制逻辑。这时最怕的是:技能状态机还在Active阶段,伤害盒还在开启,但角色已经倒下。要解决这个问题,需要在角色所有可能失去控制的入口统一调用一次ForceInterrupt(SkillInterruptReason.LoseControl)。
同时,技能表现层必须支持“终止当前所有挂起表现”。动画可以使用CrossFade切换到僵硬或死亡;特效可以立刻停止并回收到对象池;音效要停掉 Loop;伤害盒要关闭。若不做这个统一重置入口,后续就可能出现“角色已经倒地,但伤害动画事件还在触发”的诡异 Bug。
如果是对象池化角色或者技能表演完复用角色,还需要在每个技能流程结束时把状态机变量彻底清理干净。例如ComboCounter、IsBuffActive、CurrentPhase等字段如果没有在结束时归位,第二次使用同一角色时,第一次技能的残留变量会直接影响后续表现。
5.3 表现回调和逻辑回调,避免互相调用成环
动作游戏技能系统的扩展大部分都涉及回调。动画事件回调、伤害判定回调、特效结束回调都会出现在系统里。一个非常重要的架构原则是:表现层可以通知逻辑层“动画事件触发了”,但逻辑层不应该反过来要求表现层立刻返回数据。如果技能逻辑为了拿动画播放进度去直接读AnimatorStateInfo,这段关系就出现循环依赖了。
我建议始终遵循单向流动:
- 输入驱动逻辑层
- 逻辑层驱动表现层
- 表现层通过事件回报时间点
- 逻辑层决定是否响应
这样,表现层变得可以替换。你可以用一个临时模型、一堆点光源代替动画验证逻辑,也可以通过AnimationClip回放验证战斗帧。如果逻辑层与表现层深度绑定,所有这类验证都要等动画资产完成后才能进行,开发节奏会被严重拖慢。
6. 真实项目里最容易出现的几个坑与排查顺序
6.1 动画状态机和技能状态机不一致
我见过很多次这种情况:技能逻辑层已经进入Recovery阶段,但由于动画没有顺利切换到对应后摇片段,角色模型还停留在攻击动作,输入却已经判断为可接下一段。这会造成手感差和表现错位。
排查顺序要先确认技能状态机本身是否按帧数据推进。如果技能状态机没问题,就去检查动画状态机的转换条件。有一个简单的调试手段:在技能状态机每次转换阶段时记录日志,同时打印Animator.GetCurrentAnimatorStateInfo()的 fullPathHash。对比两份日志能很快看出是状态机过渡没触发,还是逻辑层提前推进。
动画状态机最好只用于播放节点和过渡,真正的逻辑判定以技能状态机的帧数据为准。动画片段中设置“事件窗口”用于表现,但可被判定的帧必须由逻辑层的时间轴控制。这样才能保证动画素材替换后,技能手感不再意外变化。
6.2 多段伤害重复触发
多段伤害是动作游戏里常见的坑。一次挥砍可能带两个活跃帧,甚至命中多个敌人。如果伤害判定是在碰撞检测里直接结算,没有做去重,那么同一段动画撞上同一个敌人,很可能触发多次伤害。
解决方法是在技能生效期间保存一份已经命中的目标表,基于目标 ID 去重。更严谨的做法是,一个技能流程内每次伤害判定事件都使用一个独立的HitRecordId,伤害系统只结算同一次判定触发的目标去重。如果技能需要同一只剑对同一角色在多个命中帧都可以造成伤害,那就把目标去重范围控制在“单次命中窗”,而不是整个技能持续时间。
伤害结果也不应直接在动画事件或碰撞回调里改动角色血量。回调应该生成一个HitResult数据,把它提交到战斗系统,由战斗系统统一做伤害计算、暴击修正和生命值修改。这样后面的“记录伤害数字”“播放命中反馈”都有统一出口,不会因为回调顺序导致数值被覆盖。
6.3 Unity 物体生命周期带来的残留和性能问题
技能系统经常和对象池、特效循环、射线检测、物理碰撞盒一起工作。一个常见问题是:技能在播放中被强制销毁,但伤害盒对应的子物体或碰撞器没有及时关闭,导致下一次创建同一特效时,残留的碰撞器仍然出现。要避免这个情况,建议所有伤害盒都由同一个管理器维护,管理器监听ForceInterrupt和技能结束事件,统一销毁或回收。
另一个常见问题是:目标位置使用Timer或Coroutine管理技能生命周期,当技能被强制中断时,协程并没有被终止,仍会把这次技能后续的阶段推进下去。更稳妥的方式是把阶段推进放在一个独立的技能状态机更新中,而不是散落在多个协程中。每次ForceInterrupt都退出当前技能节点运行列表,再重新进入Idle。如果需要协程处理某个技能的延迟表现,可以只放到表现层,并通过终止信号取消。
6.4 排查链路建议
遇到技能系统异常,我通常按下面顺序排查:
- 看日志确认技能请求是否到达了技能状态机。如果请求没到,问题在输入仲裁或取消表。
- 看状态机阶段是否按预期推进。如果阶段停住,查看帧数据起点和时长。
- 看动画是否与状态机同步。如果状态机前摇完成但动画还是待机,问题在动画状态机。
- 看伤害是否触发。如果触发但无伤害,检查 Hitbox 碰撞层和命中记录表。
- 看结果是否被多次结算。如果伤害重复,检查去重范围和战斗系统提交逻辑。
- 看角色对象被复用后是否存在残留状态。如果第二次使用才出现异常,基本可以判断是清理和重置遗漏。
排查时不要先怀疑随机 Bug,按这条链路查,通常几分钟内能定位到问题层级。
7. 中小团队落地建议:先跑通最小闭环,再谈宏大架构
7.1 不要从第一天就开始设计可视化技能编辑器
“先做一个技能编辑器”是动作游戏开发中常被高估的前置工作。编辑器真正有价值的地方在于让大量参数调整和版本对比变得更直观,但它依赖一套稳定的底层框架。如果底层的技能阶段、伤害判定、取消规则还没稳定,就先做编辑器,最后要么重复造轮子,要么编辑器生成的配置根本无法覆盖后续需求。
更可行的路线是先用代码定义通用的《技能过程》:包含SkillRequest、阶段状态机、伤害盒管理、表现事件回调。所有技能先从代码或 JSON 跑通。当技能数量达到一定规模,并且你已经能从数据配置中识别出重复字段,再考虑做一个简单的脚本化技能编辑器窗口,把字段暴露出来。这个顺序更稳,因为编辑器只是把已经验证过的数据结构可视化,而不是在验证设计之前就套上一个沉重外壳。
7.2 从“最小可用闭环”到“可复用闭环”,至少要验证三件事
先说标准。如果技能系统只能单次攻击,但不能调节阶段时长;如果技能结束后状态机没有稳定回到Idle;如果动画事件被删除后逻辑层不做自己的时间检查,导致判定被跳过。这些都不能算作最小可用闭环。
在把一个技能系统推广到多个技能之前,至少要验证三件事:
- 同一个技能控制器能不能通过配置跑通完全不同的攻击节奏。
- 一个技能在任意阶段被打断后,能否干净地进入新状态。
- 伤害判定的触达、去重与结算能否脱离动画表现单独验证。
这三件事跑通,再去考虑进阶设计。即使后面发现通用数据节点不足以表达特殊技能,由于流程框架稳定,你仍然可以按节点方式补代码,风险是可控的。
7.3 动作游戏技能系统是结构问题,不是堆料问题
最后说一点长期判断。动作游戏技能系统架构设计和很多游戏功能系统的差异在于,它处在“操作反馈”和“数值规则”的交叉口。玩家按一下键,几十毫秒内要完成:输入检测、阶段切换、动画过渡、碰撞判定、伤害结算、受击反馈、特效音效。这个链路天然要求多个模块协同,而最容易出问题的就是模块之间的调用顺序和生命周期。
如果一开始全靠一个角色类顺序执行,速度快但扩展困难;如果一开始就把所有模块拆到非常细,又会因为过度设计拖慢开发。最好的状态是,在中期迭代前找到一个平衡点:有一套稳定的技能状态机,有一份能描述普通技能的数据格式,有清晰的表现层和逻辑层回调边界。它可以不是最终形态,但它必须能让玩家快速试到新技能手感,也能让程序员快速定位新问题的发生阶段。
我在这个 Unity 动作游戏技能系统架构设计中收获最大的一点是:它看起来在解决“怎么放技能”,实际上在解决“怎么让角色过程中的每个模块都只做一件事”。只要边界清晰,技能多起来也只会增加配置和节点,而不是增加混乱。