动作游戏技能系统,往往是项目从原型走向可玩版本的第一个分水岭。前期直接写单机逻辑确实爽快:按下攻击键、播放动画、碰撞体生效、数值掉血,一套连招打下来一气呵成。但一旦技能数量从两三个膨胀到几十个,加 Buff、打断、连招、霸体、受击反馈、技能表现、网络同步这些问题叠在一起,再回头改底层就会非常痛苦。这次我们来看一个偏工程向的 Unity 战斗技能架构设计,目标不是给一套完整插件,而是把一个动作游戏团队在技能模块设计时需要考虑的分层、模块边界、事件流和代码组织方式梳理清楚。
在动手之前,先给这个方案一个总体评价:它适合中小型 Unity 动作游戏项目,也适合独立开发者从零搭建战斗底层。核心思路是用“数据驱动技能属性 + 行为状态机管理表现 + Buff/事件系统处理逻辑”三者解耦,让策划可以改表调技能,程序不需要为了新角色频繁重写技能代码。这套方案的关键不是某个技巧,而是把技能的表现层、逻辑层、判定层、表现层分开,保证后续扩展连招、蓄力、派生技、切换武器这些需求时,不用推倒重来。
为了让这套架构可落地,本文会围绕一条完整的战斗链路展开:角色基础控制与状态机、技能数据层设计、输入缓存和连招判断、攻击判定与受击回调、Buff 系统的挂载方式,以及最后的事件驱动整合。看完之后你可以直接在工程里照着搭一套最小可运行结构,再把策划表和 Animation Event 接进去,就能跑出一套基础连招流程。
1. 技能系统架构核心能力评估
在做战斗系统之前,先明确这套架构关心的核心能力。动作游戏技能系统不是“能放技能就行”,而是要保证战斗手感、判定反馈、扩展能力和调试效率。
| 模块能力 | 设计目标 | 实现方式 |
|---|---|---|
| 角色状态机 | 控制技能状态的切换、打断、退出 | 有限状态机 + 状态优先级 |
| 技能类型 | 普攻、连招、技能、大招等 | 数据驱动技能配置基类 |
| 输入处理 | 按下按键后能正确排队或取消 | 输入缓存 + 取消点 |
| 伤害判定 | 正确处理挥砍、投射物、范围伤害 | 攻击判定事件 + 碰撞回调 |
| 命中反馈 | 命中顿帧、击退、摄像机震动 | 事件总线广播 |
| 状态效果 | 燃烧、流血、眩晕、冰冻等 | Buff 系统挂载与刷新 |
| 资源管理 | 加载技能效率、卸载时机 | Addressable / 资源引用解耦 |
| 扩展性 | 增加新角色/新技能不改底层 | 抽象接口 + 技能行为组件 |
这里补充一个关键认知:技能系统一定不要只为一款角色写死。哪怕你现在项目里只有一个主角,技能表也建议从第一天就做成可配置结构。因为后续做敌人、Boss、多角色切换时,边际成本会突然降低很多。
2. 技能系统整体分层设计
动作游戏的战斗框架,在逻辑上可以分成四层:表现层、决策层、逻辑层、数据层。如果这四个层面揉在一起,项目中期会出现“改了一个动画状态,结果伤害数值也跟着乱跳”的诡异 Bug。
| 层级 | 职责 | 涉及模块 |
|---|---|---|
| 表现层 | 动画、特效、音效、镜头反馈 | Animator、VFX、CameraManager |
| 决策层 | 角色状态切换、AI 意图 | 状态机、AI 控制器 |
| 逻辑层 | 技能执行、Buff 结算、伤害计算 | SkillController、BuffSystem |
| 数据层 | 技能配置、角色属性、玩家输入 | ScriptableObject、Excel 导出数据 |
表现层具体包括什么?Animator 负责播放动画,但动画不应该直接修改角色血量和移动速度。伤害逻辑、Buff 计时、数值变化必须放到逻辑层。不少团队的写法是把伤害写进 Animation Event 里,这个做法前期很方便,后期会有两个问题:一是动画状态重构后事件丢失,二是网络同步时事件难以追踪。
那逻辑层怎么感知表现层?通过事件和回调,而不是让逻辑层直接引用某个特效组件。攻击动作播放到第 12 帧,Animation Event 触发一次 “OnSkillHitFrame”,逻辑层拿到这个事件后才去生成攻击判定。这样即使把动画换成新的,也不影响判定时机。
决策层的核心是状态机。玩家角色可以通过状态机区分 Idle、Run、Attack、Jump、Hit、Die。Boss AI 也可以通过同样的状态机切换到连招技能。决策层只决定“现在该在哪个状态”,逻辑层负责“这个状态该怎么处理”。
3. 角色控制与状态机设计
3.1 为什么不用 Animator 直接做战斗状态
很多初学者会把游戏逻辑直接挂在 Animator State 的 StateMachineBehaviour 里,比如在 Attack 状态的 OnStateEnter 里扣血。看起来省事,实际上很难追踪状态流转路径。尤其是连招需要判断前一招是否结束、下一招是否被打断时,Animator 状态机的事件回调很难表达“当前招式 A 刚结束,我下一帧想切到招式 B”这种业务逻辑。
战斗状态机的第一原则:Animator 只负责显示,状态切换由角色控制器决定。
3.2 状态机基础实现
用 C# 实现一套轻量的状态机并不复杂。核心是三件套:枚举状态、状态基类、上下文对象。
public enum ActorStateType { Idle, Run, Jump, Attack, Hit, Die } public abstract class ActorStateBase { public abstract void OnEnter(ActorContext context, StateMachine machine); public abstract void OnUpdate(ActorContext context, StateMachine machine); public abstract void OnExit(ActorContext context, StateMachine machine); } public class ActorContext { public Animator Animator; public CharacterController CharacterController; public SkillController SkillController; public HealthComponent Health; public InputComponent Input; }此处不要写死具体状态跳转逻辑,而是用“转换定义”管理状态之间的切换条件。动作游戏里最麻烦的不是单状态逻辑,而是状态之间错综复杂的跳转关系。
public class StateTransition { public ActorStateType FromState; public ActorStateType ToState; public Func<ActorContext, bool> Condition; }每次 Update 时遍历当前状态的转换列表,条件满足就切换。切换完成后调用 OnExit 和 OnEnter,这样才能在重新进入 Attack 状态时重置连招计数,避免出现“连招打完第三段,第四段还继续触发”的问题。
3.3 可打断状态与优先级控制
动作游戏里,普攻可以被翻滚打断,跳跃可以取消部分后摇,但受击倒地时不能触发移动。这些需求的本质是“状态打断规则”。
实现方式有两个关键点:
- 每个状态定义 CanBeInterrupted 属性。
- 每种打断指令需要一个优先级。
public float Priority; public bool CanBeInterruptedBy(ActorStateType targetState) { return InterruptRules.ContainsKey(targetState); }比如普通攻击状态优先级为 10,翻滚优先级为 20。玩家在普攻后摇阶段按下翻滚键,系统先查询 Attack 是否允许被 Roll 打断,如果允许则切换到 Roll。连招系统则反过来,攻击状态需要按住连击键触发下一段,这个过程依赖优先级对“连招指令”放行。
优先级系统是动作手感的底层保证。如果没有优先级,玩家按下翻滚却因为当前在普攻状态无法响应,手柄操作会被角色动作延迟“吞掉”,这是动作游戏最忌讳的问题。
4. 技能数据层:从硬编码到配置驱动
4.1 技能基类数据设计
技能系统能扩展,根本在于技能数据是“描述”而不是“代码”。每个技能需要一套通用属性和特有属性,通用属性用基类保存。
[System.Serializable] public class SkillBaseData { public string SkillId; public string SkillName; public SkillType Type; // NormalAttack / ActiveSkill / UltimateSkill public float Damage; public float Radius; public float Range; public float CoolDown; public float Cost; public AnimationClip AttackClip; public GameObject HitVfxPrefab; public AudioClip HitAudioClip; }注意这里不要用布尔的 “IsRangeAttack” 这类字段去做技能差异化。遇到技能类型越来越多的情况,这种字段会膨胀成一张超大表。更好的做法是把差异逻辑放到行为组件里,数据层只保留通用参数。
4.2 用 ScriptableObject 配置技能
Unity 里最直观的技能配置载体是 ScriptableObject。策划可以在不打开代码的情况下创建技能资源,右键菜单就能生成一个新技能配置。
[CreateAssetMenu(menuName = "Game/Skill/SkillData", fileName = "NewSkillData")] public class SkillData : ScriptableObject { public SkillBaseData BaseData; public GameObject SkillPrefab; public bool IsComboSkill; public int ComboStep; // 技能后续行为可在 Prefab 上挂载不同组件来实现 public List<SkillBehaviourBase> SkillBehaviours; }SkillData 创建后,资产文件就可以被策划调整数值。伤害、冷却、范围等参数实时生效,避免反复编译。
使用 ScriptableObject 作为配置载体后,还有一个人力资源上的好处:程序不再需要频繁地因为“技能伤害改 5 点”这种需求被打断。策划直接改资产文件就能完成平衡性调整。
4.3 技能运行时实例与控制器
资源里的 SkillData 是静态配置,真正在战斗中执行时需要创建一个运行时实例 SkillRuntimeData。为什么不能直接用配置里的数值?因为技能在运行过程可能被 Buff 改变属性,比如力量增强 Buff 让当前技能伤害提高 50%,这就必须有一层运行时数值环境存在。
public class SkillRuntimeData { public SkillData ConfigData; public float CurrentDamage; public float CurrentCooldown; public float CurrentCost; public void Init(SkillData data) { ConfigData = data; CurrentDamage = data.BaseData.Damage; CurrentCooldown = data.BaseData.CoolDown; CurrentCost = data.BaseData.Cost; } }SkillController 是技能执行入口。角色进入 Attack 状态后,SkillController 根据状态机的切换参数查找对应的技能数据,创建运行时实例,再把技能执行命令分发给技能行为组件。
public class SkillController : MonoBehaviour { private Dictionary<string, SkillRuntimeData> skillRuntimeMap = new(); public SkillRuntimeData PrepareSkill(string skillId) { SkillData data = SkillDatabase.Instance.GetSkillData(skillId); if (data == null) { Debug.LogWarning($"SkillData not found: {skillId}"); return null; } SkillRuntimeData runtime = new SkillRuntimeData(); runtime.Init(data); skillRuntimeMap[skillId] = runtime; return runtime; } public void StartSkill(SkillRuntimeData skillRuntime, ActorContext context) { // 通知表现层播放动画 context.Animator.CrossFade(skillRuntime.ConfigData.BaseData.AttackClip.name, 0.05f); // 通知逻辑层开始监听命中帧 SkillBehaviourManager.StartSkillBehaviours(skillRuntime); } }这段代码引入了 SkillBehaviourManager,它的职责是把一个技能划分成多个行为阶段。例如“突进阶段-判定阶段-收招阶段”,每个阶段由 SkillBehaviourBase 的派生类处理,这样不同技能可以挂不同行为链,而不是在同一个类里写大量 switch case。
5. 连招系统:输入缓存与取消点
动作游戏的常用手感设计,是玩家在招式 A 还没打完时按下招式 B 的按键,系统需要把这次输入缓存下来,在招式 A 的某个可取消时间点自动执行招式 B。如果玩家按得太早或太晚,结果应该不同。
5.1 输入缓存实现
输入缓存就像一个小型队列,只记录最近一次有效战斗输入。
public class InputBuffer { private float maxBufferTime = 0.2f; private BufferedCommand cachedCommand; private struct BufferedCommand { public EInputType InputType; public float Timestamp; } public void CacheInput(EInputType inputType) { cachedCommand = new BufferedCommand { InputType = inputType, Timestamp = Time.time }; } public bool TryConsumeCommand(float windowTime, out BufferedCommand command) { command = cachedCommand; if (Time.time - cachedCommand.Timestamp > maxBufferTime) { return false; } cachedCommand = default; return true; } }maxBufferTime 控制输入缓存的宽容度。实际项目中这个值需要反复调试,太短玩家觉得输入不跟手,太长玩家会感到招式自己派生出去、按键没有掌控感。常见动作游戏建议从 0.12 秒到 0.18 秒之间做手感测试,体验差异非常大。
5.2 取消点与连招跳转
每个攻击状态需要定义哪些时间点允许执行下一段连招。最直接的做法是给攻击动画拆分成三段:前摇、命中帧、后摇。命中帧之后的一小段是连招窗口期,此时触发连招指令才能进入 Combo 状态。
public class ComboSkill : MonoBehaviour { public List<SkillData> comboSteps; private int currentStepIndex; public bool CanNextStep() { return currentStepIndex < comboSteps.Count - 1; } public SkillData NextStep() { if (!CanNextStep()) return null; currentStepIndex++; return comboSteps[currentStepIndex]; } public void ResetCombo() { currentStepIndex = 0; } }为了让这段逻辑和状态机配合,可以在 Attack 状态的外部维护一个 comboStepCount。角色进入 Attack 状态时检测输入缓存,如果已经是攻击状态并且处于取消窗口期,且连招未结束,则切换新一段攻击动画并递增连招段数。如果时间超过连招窗口,又没有任何新输入,则状态机回到 Idle,连招计数归零。
有一种常见踩坑是:动画重新播放后 Animator State 没有变化,导致 OnStateEnter 不触发,连招中断。这通常是因为使用了同一个 AnimationClip 的循环播放。正确做法是给每段连招单独配置一个动画状态或使用不同 Clip,让 Animator 状态机能感知到变化。
6. 攻击判定与命中事件流
6.1 判定生成与销毁
攻击判定不仅是碰撞体,而是“事件驱动的临时检测区域”。技能进入命中帧时,逻辑层生成一个攻击盒子;命中帧结束后,攻击盒子销毁。真实项目里可以用 GameObject 上的 Collider 开启/关闭来实现。
这里推荐一套比较清晰的实现方案:HitBox 是被攻击方身上的碰撞区,HitBox Responder 负责判断对方是否处于可受击状态。攻击方在命中帧执行一次范围检测,再把目标送到伤害结算流程。
public class HitBoxResponder : MonoBehaviour { public event System.Action<HitInfo> OnHitReceived; public void ApplyHit(HitInfo hitInfo) { if (!isVulnerable) return; OnHitReceived?.Invoke(hitInfo); } }6.2 伤害结算与命中断言
伤害计算应该和动画表现完全脱离。动画命中帧只是触发一次“命中请求”,实际能不能打中,取决于攻击范围、被击方闪避状态、被击方无敌帧等条件。
一个干净的 HitInfo 应该包含所有结算所需数据。
public struct HitInfo { public string SkillId; public float Damage; public Vector3 HitPoint; public Vector3 HitDirection; public GameObject Attacker; public GameObject Target; public bool IsCritical; }命中断言逻辑写到 HealthComponent 中,由它接收 HitInfo 后计算实际扣血数值。不要在 HitBox 的 OnTriggerEnter 里直接扣血,因为碰撞检测可能重复触发,伤害结算会重复执行。
重复命中的解决办法是攻击管理器中维护一个 Set,记录本次技能已命中的目标实例。
public class AttackHitRegister { private HashSet<GameObject> hitTargets = new HashSet<GameObject>(); public bool TryRegisterHit(GameObject target) { return hitTargets.Add(target); } }当技能实例被销毁或攻击结束后调用 Clear() 清空记录,否则下一次使用同一技能时伤害会丢失。
6.3 命中表现的事件广播
动作游戏的打击感,最后仍然依赖表现反馈。命中一个敌人后,通常需要同步播放:敌人受击动画、命中粒子、音效、顿帧、镜头震动。如果把所有这些逻辑都写在 HealthComponent 里,那 HealthComponent 会同时依赖 AudioManager、VFXManager、CameraManager,模块之间耦合严重。
正确做法是伤害结算流程只广播一个 OnActorDamaged 事件,由事件监听者各自处理。
public readonly struct ActorDamagedEvent { public HealthComponent TargetHealth; public ActorContext TargetContext; public HitInfo HitInfo; } public class EventBus { public static event System.Action<ActorDamagedEvent> OnActorDamaged; public static void Publish(ActorDamagedEvent evt) { OnActorDamaged?.Invoke(evt); } }VfxManager 监听这个事件后,在 HitInfo.HitPoint 生成粒子。CameraManager 监听事件后,如果目标生命值被削减超过阈值则触发震屏。AudioManager 监听事件后播放打击音频。这样不会因为某个命中特效报错而中断整个伤害流程。
7. Buff 与状态效果系统
7.1 Buff 的本质
动作游戏的 Buff 系统覆盖面很广:燃烧、中毒、眩晕、冰冻、霸体、增伤、减伤、攻速提升等。表面上看是“给角色加一个状态”,一旦扩展起来就会进入一个问题:Buff 是对属性加成的数值?是对状态机的限制?还是持续伤害的 Timer?答案是它三种都能表达。所以需要为 Buff 建立清晰的本体与行为分离设计。
7.2 Buff 数据与实例
BuffData 是配置,BuffInstance 是运行时实例。
[CreateAssetMenu(menuName = "Game/Skill/BuffData", fileName = "NewBuffData")] public class BuffData : ScriptableObject { public string BuffId; public string BuffName; public EBuffType BuffType; // OverTime / StatModifier / Control public float Duration; public bool IsStackable; public bool IsRefreshOnApply; // 刷新持续时间 public int MaxStack; }Buff 实例负责向 RoleAttributeComponent 挂载属性修改器,并在到期后移除。
public class BuffInstance { public BuffData BuffData; private double startTime; private int currentStack; public void Apply(AttributeComponent attributes) { startTime = Time.time; switch (BuffData.BuffType) { case EBuffType.StatModifier: attributes.AddModifier(this); break; case EBuffType.Control: // 通知状态机进入受控状态 break; case EBuffType.OverTime: DOTController.RegisterBuff(this); break; } } public void Refresh() { startTime = Time.time; } }BuffSystem 的职责是统一管理角色身上的所有 Buff,在技能命中或技能释放时调用 Apply。Buff 层要监听角色状态切换,例如角色死亡后立刻清除所有持续效果,避免出现“角色死亡后仍在燃烧结算”的笑话。
7.3 控制型 Buff 与状态机的联动
控制型 Buff(眩晕、冰冻、击飞)直接操作状态机。实现时需要把状态切换请求发送给 ActorStateMachine,而不是直接 Animator.SetBool。因为只有状态机知道角色当前是否处于不可被控制的 Burst 状态。
public class StunBuffInstance : BuffInstance { public override void OnBuffStart(ActorContext context) { base.OnBuffStart(context); context.StateMachine.RequestStateChange(ActorStateType.Stun); } public override void OnBuffEnd(ActorContext context) { base.OnBuffEnd(context); if (context.StateMachine.CurrentStateType == ActorStateType.Stun) { context.StateMachine.RequestStateChange(ActorStateType.Idle); } } }这里有一个细节需要注意:某些技能设计上有“霸体”。如果角色当前处于霸体状态,StunBuff 申请状态切换时应该被忽略。这个流程可以放在 Stun 的状态切换条件里,判断角色是否有霸体标记,而不是让 Buff 直接强制改状态。
8. 动作表现层与动画服务
8.1 动画状态层级设计
复杂动作游戏往往需要叠层播放:下半身走路动作、上半身攻击动作或持枪瞄准动作可以同时进行。Unity Animator 用 LayerMask 区分身体层。技能动画建议放在 Base Layer 或 Full Body Layer,移动动画可以放在 LowerBody Layer。
配置时要注意 Base Layer 的 Mask 不要勾选全身权重,要保留下半身权重给移动层。若角色播放攻击动画时下半身完全停止,会显得动作僵硬。
8.2 Animation Event 与技能事件对接
Animation Event 是最常见的招式帧事件方式。在动画 Clip 里为对应关键帧添加事件,事件名建议统一为 TriggerHit、EnableWeaponCollider、DisableWeaponCollider、StepFoot 这样的动词短语。
public class AnimationEventHandler : MonoBehaviour { public void TriggerHit() { // 通知当前技能行为管理器执行攻击判定 SkillBehaviourManager.TriggerCurrentHitFrame(); } public void EndOfAttack() { // 通知状态机离开攻击状态,不在这里直接切 Idle SkillBehaviourManager.OnAnimationComplete(); } }严格限定一点:不要在 Animation Event 里写任何访问敌人或伤害数据的逻辑。事件只是触发信号,永远由逻辑层响应它。否则动画师重做动画时很容易漏配事件,表现出 3D 模型疯狂播放动作却完全打不出伤害。
9. 敌人 AI 与通用技能复用
9.1 敌人共用技能框架
技能系统设计得好不好,一个重要评判标准是:能不能不用写新代码,就搭建一个 Boss 的连招。一旦技能数据层和 SkillBehaviourManager 已抽象完成,敌人 AI 只需要决定“何时释放技能”,而不需要关心“技能如何释放”。
敌人 AI 的思路其实和玩家类似,只是输入来源从手柄按键变成了 AI 行为树或状态机。
public class EnemyAIController : MonoBehaviour { public float detectRange; public float attackRange; private void Update() { if (currentState == EnemyState.Chase) { if (DistanceToPlayer() <= attackRange) { skillController.StartSkill("EnemyComboAttack1"); } } } }同一个“EnemyComboAttack1”可以挂不同的 AnimationClip、HitVfx、BuffData,生成一个完全不同的 Boss 技能。这套方案能减少技能的重复开发量,剩下的资源都留给战斗表现打磨。
9.2 玩家与敌人复用状态机
玩家和敌人的差异主要在使用技能的条件不同,技能执行本身高度类似。建议把 CommonActorStateMachine 写成公共组件,玩家角色和敌人角色都挂同一套状态机。后续目标锁定、位移、跳跃等都可以收敛到同一个 ActorContext 中。
这种统一设计在团队分工中还有一个好处:程序只需要维护一份状态机代码,新角色只是增加预制体和技能配置。新成员的接入成本会明显降低。
10. 实战验证:测试流程与性能观察
如果要从零验证这套技能架构能不能在 Unity 项目中跑通,建议按下面的顺序做一次最小链路测试。
10.1 最小可运行链路搭建
- 创建一个 Capsule 角色,挂上 CharacterController。
- 创建 Animator Controller,添加 Idle、Run、Attack_1、Attack_2、Hit、Die 六个状态。
- 创建两个 SkillData 资产,分别对应攻击第一段和第二段。
- 复制 ActorContext 挂载组件,把 Animator、CharacterController 等引用都拖进去。
- 写一个简单的 MonoBehaviour 接收攻击键输入,驱动状态机和技能控制器。
跑通的标准很简单:按一下攻击键,角色播放 Attack_1 动画;在第一段后摇阶段再次按下攻击键,角色接上 Attack_2;打中目标后目标受到伤害并播放受击动画。如果这个链路能跳出第四段的 bug,进一步排查状态切换条件是否彻底返回了 Idle,状态机里攻击状态是否是 Any State 转发导致的重复触发。
10.2 性能观察重点
Unity 战斗系统最容易出现性能问题的几个位置:
- 命中帧使用了 Physics.SphereCast 但每次开销过大。
- VFX 特效没回收,造成 GameObject 不断积累。
- HitBox 注册表没有清理,持有大量失效的对象引用。
- Buff 系统每个 Update 遍历全部 Buff,在角色很多时形成 O(n*m) 的消耗。
Buff 系统建议使用事件驱动的到期回调,而不是每个 Buff 每帧检查剩余时间。DOTController 也可以用 Timer 队列管理,而不是为每个 Buff 单独开协程。大量协程在角色死亡、场景切换时会形成不可控的清理成本。
10.3 调试指标
动作游戏调手感时,下面几个指标可以存档对比:
- 按键到动作输出延迟:建议低于 100ms。
- 招式 A 到招式 B 的平均派生时间:配合输入缓存共同决定。
- 连招失误率:测试玩家连续按键时的失败频率。
- 命中反馈触达时间:从攻击判定生效到顿帧/屏幕震动出现,建议不超过一帧。
如果这些指标没有数据化,技能系统手感优化就容易变成“拍脑袋”。每一次改动都应该记录后对比,而不是凭主观感觉“好像更好了”。
10.4 场景与素材规格
技能系统与画面特效用 URP 工程即可。项目中粒子一般不需要使用超高分辨率的贴图,VFX Graph 的初始化数量也要控制。技能特效是粒子数量最容易失控的地方,一次三个技能并发,每个生成 500 个粒子,耗电发热会很可观。建议给特效设置一个总预算,用 GPU Instancing 合并远处的受击特效。
11. 工程目录与最佳实践
项目的代码组织影响后期团队协作。建议按模块分目录,而不是按“玩家、敌人、UI”这种对象类型分目录。
Assets/ Scripts/ Combat/ Controller/ StateMachine/ Skill/ HitDetection/ Buff/ Damage/ Event/ Actor/ Component/ // HealthComponent、AttributeComponent Context/ AI/ EnemyController/ Animation/ Data/ Skills/ Buffs/ Characters/ Prefabs/ Characters/ Skills/ VFX/ Scenes/技能相关目录挂在 Combat 下,方便后续做战斗系统重构。PreloadManager 场景加载时可以只加载技能表,不用加载 UI 和音频配置,减少战斗测试场景的初始化负担。
工程最佳实践还有这些:
- 每次新增技能时先建立最小可用配置,验证跑通后再加特效。不要做完特效才发现判定位置错误。
- 所有战斗数值不写死在 MonoBehaviour 的 public 字段里,统一放到对应的 Data/Character 配置中。
- 状态机状态项不要过多,避免上百个状态挤在同一个枚举中。可以用子状态机分组。
- 技能中的计时器统一走 Time.timeScale,方便实现全局时停和慢动作。顿帧效果通过 Time.timeScale 短暂改为 0 即可,但注意别把 UI 动画也停了。
- UI 上显示技能冷却值时,不要直接读配置里的 CoolDown,应该读取 SkillRuntimeData 的 CurrentCooldown,因为 Buff 可能改变冷却速度。
- 日志要统一预留,尤其是技能状态切换和命中结算的关键路径。前期不打日志,后期出问题再补就很费劲。
动作项目开发过程中,最容易在第六个月左右遇到性能问题。此时如果技能系统的对象池和特效池没做好,场景里同时刷 10 个敌人时 FPS 就会掉得很夸张。特效是第一个需要建立对象池的模块,其次是 HitInfo 这类频繁分配的结构体。如果发现 GC 频繁,可以考虑把 HitInfo 存储为值类型,并对对象池重用部分引用对象。
12. 常见问题与排查方法
Unity 动作类项目技术问题中,有一半集中在动画和依赖配置上。这里列几个高频问题:
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 角色攻击动画播放了,但伤害没有触发 | Animation Event 未配置或事件名不统一 | 检查 Clip 的 Event 列表,对比事件名是否与代码一致 | 统一事件名规范,在 Animation Event 窗口中逐个检查 |
| 技能只能播放一次,第二次按键无反应 | Animator State 没有重新切换 | 确认两次攻击是否使用不同动画状态 | 为连招每段单独配置动画状态或使用强制交叉淡入 |
| 状态机突然切到受击状态后无法返回 AI | 受击状态退出条件未置位 | 在 OnExit 里打印日志,确认状态退出是否触发 | 给 Hit 状态增加最短持续时间和退出条件返回 Idle |
| Buff 到期后属性没恢复 | 属性修改器未正确移除 | 检查 BuffInstance 的 OnBuffEnd 是否注册到移除事件 | 统一由 AttributeComponent 维护修改器列表 |
| 同一技能对同一敌人多次扣血 | 命中注册表未清理 | 检查 AttackHitRegister 的失效时机 | 在攻击结束后清理,或使用帧序号作为判定时间窗口 |
| 打开项目报 dllnotfoundexception 错误 | 依赖库缺失或插件版本不匹配 | 查看异常堆栈对应的 DLL 名称 | 重新导入依赖插件,确认平台兼容性与 Unity 版本匹配 |
| 技能数值被 Buff 修改后难以回滚 | 数值修改直接写进字段 | 检查 Buff 是否直接修改了配置数据 | 改用独立 RuntimeData 加上修改器,不要改动原始 ScriptableObject 的值 |
从这些实际问题来看,技能系统的稳定不靠“写得更小心”,而靠“结构更清晰”。让每个模块只处理自己的事,问题发生时可以快速定位日志和事件链路,才是工程级战斗框架最核心的价值。
13. 一些更实际的建议
如果你正准备在 Unity 里做动作游戏战斗系统,真正该先做的不是把这里所有模块都写完,而是根据项目形态裁剪。中小型单机项目可以暂时不做敌人 AI 和 Buff 扩展,但建议至少保留技能数据驱动与 SkillController 的分层,因为后续加 Boss、多角色时重构成本是递增的。
如果项目偏原型验证,可以考虑先做一个最简单的“三段普攻 + 受击 + 死亡”闭环。把 InputBuffer、Animator 状态复用、HitBox 判定跑通后,再逐步加入技能冷却、Buff 和 AI 复用。每次只加一层,每层加完都验证一次性能与手感,避免一次性铺开导致 Bug 与手感问题混在一起无法定位。
最后提一个细节:项目里一定保留一份“可复用的最小演示场景”。这个场景不需要好美术,只需要一个 Box 敌人、一个带 Animator 的角色和一个攻击 UI,能在启动后立刻测试技能。有这样一个沙盒场景在,后续调手感、加技能、做性能测试都会比在完整关卡里反复加载快得多。建议把它作为工程第一个正式搭建的场景。