三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Unity 3D动作手游战斗系统开发:从状态机到伤害判定的核心实现

Unity 3D动作手游战斗系统开发:从状态机到伤害判定的核心实现

1. 从零到一:一个3D战斗手游Demo的战斗系统核心是什么?

最近在整理过往的项目资料,翻到了一个几年前做的3D动作手游战斗系统Demo。这个Demo虽然体量不大,但麻雀虽小五脏俱全,它完整地跑通了从角色控制、技能释放、伤害判定到受击反馈的整个战斗闭环。今天,我想把这个Demo的核心实现思路、踩过的坑以及一些关键设计决策拆解出来,分享给对游戏战斗系统开发感兴趣的朋友。无论你是刚入行的新人,还是想从其他方向转过来的开发者,这篇文章或许能帮你理清一个可运行的战斗Demo到底需要关注哪些东西。

很多人一听到“战斗系统”,可能会立刻想到复杂的技能树、华丽的特效、深度的数值平衡。这些当然重要,但对于一个旨在验证核心玩法、跑通流程的Demo而言,我们的首要目标是“让战斗动起来,并且感觉对”。这意味着,我们需要优先解决输入响应、动作表现、逻辑判定这三个最基础也最核心的环节。这个Demo就是围绕这三点展开的,它没有庞大的世界观,没有复杂的养成线,只有一个主角、几个小怪,以及几套可以释放的技能。但正是这个简单的框架,包含了构建任何复杂战斗系统的基石。

2. 战斗循环的基石:输入、状态与动画的三角关系

一个流畅的战斗体验,底层依赖于一套严谨的状态机来管理角色的各种行为(站立、移动、攻击、受击、死亡等)。在Unity中,我们通常会使用Animator Controller配合脚本逻辑来实现。但这里有一个常见的误区:新手容易把所有的逻辑都写在动画事件里,或者试图用复杂的动画层来驱动逻辑,这会导致代码混乱,难以维护。

2.1 状态驱动的设计哲学

在这个Demo中,我采用了“脚本驱动状态,状态驱动动画”的设计。核心是一个挂在玩家角色上的PlayerCombatController脚本,它内部维护一个枚举型的状态变量,例如Idle,Moving,Attacking,SkillCasting,HitStun,Dead。所有的输入检测和逻辑判断都基于当前状态。

public enum PlayerState { Idle, Moving, Attacking, // 普通攻击状态 SkillCasting, // 技能释放状态 Dodge, // 闪避状态 HitStun, // 受击硬直状态 Dead } private PlayerState _currentState = PlayerState.Idle;

为什么要把状态集中管理?因为战斗中的行为是互斥的。你不能在释放技能的同时进行普通攻击,也不能在受击硬直时立刻闪避。通过一个中心化的状态机,我们可以清晰地定义状态转换规则。例如,从IdleMoving可以转换到Attacking,但从HitStun不能直接转换到Attacking,必须等硬直时间结束回到Idle才行。这些规则通过_currentState变量和一系列条件判断来强制执行,避免了逻辑冲突。

2.2 输入处理的优先级与缓冲

移动端(或PC)的输入是即时的,但角色的动作有前摇、后摇。直接每帧响应输入会导致操作失灵或动作抽搐。这里引入了“输入缓冲”机制。以攻击按钮为例,当玩家快速连点时,如果第一下攻击动画还没播放到可接受下一段连击的帧(即“连击窗口”),那么后续的点击不应该被忽略,而应该被暂时存储起来。

private bool _attackInputBuffered = false; private float _bufferTime = 0.2f; // 缓冲时间 private float _bufferTimer = 0f; void Update() { // 处理输入 if (Input.GetButtonDown("Fire1")) { _attackInputBuffered = true; _bufferTimer = _bufferTime; } // 更新缓冲计时器 if (_bufferTimer > 0) { _bufferTimer -= Time.deltaTime; if (_bufferTimer <= 0) { _attackInputBuffered = false; } } // 状态更新逻辑中检查缓冲输入 if (_currentState == PlayerState.Idle || _currentState == PlayerState.Moving) { if (_attackInputBuffered) { StartAttack(); _attackInputBuffered = false; // 消耗掉缓冲的输入 } } }

这样,即使玩家在攻击前摇时按下了按钮,系统也会记住这个输入,并在角色状态允许时立刻触发下一次攻击,从而让连招感觉更加跟手。这个机制对于移动端的虚拟摇杆+按钮操作模式尤为重要,因为触屏操作本身没有物理反馈,容易产生误操作或延迟感。

2.3 动画系统与逻辑的同步

Animator Controller负责视觉表现。我们为每个状态(Attack, SkillCast, Dodge)创建单独的动画层或子状态机。脚本通过Animator.SetTriggerAnimator.SetBool来触发动画转换。但这里的关键是动画事件根运动的处理。

  • 动画事件:在攻击动画的特定帧(如武器挥到最高点、产生冲击波的时刻)插入事件,调用脚本中的方法,如OnAttackHitFrame()来生成碰撞检测框。这确保了伤害判定的时机与视觉表现完全同步。
  • 根运动:对于位移技能(如冲锋、闪避),我倾向于在动画中启用根运动,让动画师来控制位移曲线。脚本则通过Animator.applyRootMotion = true来授权,并在OnAnimatorMove()回调中处理最终的位移,同时可以叠加额外的逻辑,如碰撞检测防止穿墙。

踩坑心得:不要过度依赖动画的过渡时间(CrossFade)来做逻辑延迟。逻辑状态切换应该立即发生,视觉过渡可以柔和。例如,按下闪避键,_currentState应立即变为Dodge(此时无敌帧开始计算),而动画可以从当前状态平滑过渡过去。如果等动画过渡完再改状态,会导致输入响应延迟。

3. 伤害与碰撞:看似简单,实则陷阱重重

战斗系统的“灵魂”之一在于伤害判定。在3D空间中,我们通常使用碰撞体(Collider)和触发器(Trigger)来检测攻击是否命中。

3.1 碰撞检测框的动态生成

最直接的方法是为武器绑定一个碰撞体,在攻击动画的特定帧激活它。但这种方法在应对多段攻击、范围技能时不够灵活。在这个Demo中,我采用了动态生成碰撞检测框的方式。

OnAttackHitFrame()事件中,根据当前攻击的段数、技能ID,从配置表中读取检测框的参数(形状、大小、位置偏移、持续时间)。

public void SpawnHitbox(int attackIndex) { // 从配置数据中读取 HitboxData data = attackConfig.GetHitboxData(attackIndex); GameObject hitboxObj = new GameObject("DynamicHitbox"); hitboxObj.transform.position = transform.position + transform.rotation * data.offset; hitboxObj.transform.rotation = transform.rotation; SphereCollider collider = hitboxObj.AddComponent<SphereCollider>(); collider.radius = data.radius; collider.isTrigger = true; HitboxComponent hitbox = hitboxObj.AddComponent<HitboxComponent>(); hitbox.Initialize(this, data.damage, data.hitType, data.lifetime); // 归属者,伤害值,命中类型,存在时间 // 一段时间后自动销毁 Destroy(hitboxObj, data.lifetime); }

HitboxComponent脚本负责在OnTriggerEnter中检测碰到的敌人,并调用敌人的受击接口。这种方式的好处是高度可配置,策划可以通过表格调整每一个攻击判定的范围、伤害,而无需程序员反复修改代码或调整预制体。

3.2 伤害流程与受击反馈

当检测到碰撞时,伤害流程开始:

  1. 命中检测HitboxComponent检测到带有EnemyHealth组件的物体。
  2. 伤害计算:这是一个简化的公式:最终伤害 = (基础攻击力 + 技能加成) * (1 - 目标防御力减免率)。在Demo中,攻击力和防御力都是直接配置在角色属性脚本中的。更复杂的系统会引入暴击、格挡、属性克制等。
  3. 传递伤害:调用目标的TakeDamage(float damage, Vector3 hitPoint, Vector3 hitDirection)方法。
  4. 受击表现:在TakeDamage方法中,除了扣减生命值,更重要的是触发受击反馈:
    • 播放受击动画:通常是一个短暂的、可被其他动作打断的动画片段。
    • 屏幕特效:如屏幕边缘泛红、轻微震动。
    • 硬直状态:将敌人状态设置为HitStun,在一段时间内无法行动。
    • 击退/浮空:根据攻击类型和力度,对敌人施加一个物理力或直接设置位移。
public class EnemyHealth : MonoBehaviour { public float currentHP; public Animator animator; public EnemyStateController stateController; public void TakeDamage(float damage, Vector3 hitPoint, Vector3 hitDirection) { if (stateController.CurrentState == EnemyState.Dead) return; // 防止鞭尸 currentHP -= damage; // 触发UI血条更新事件 OnHealthChanged?.Invoke(currentHP / maxHP); // 受击反馈 animator.SetTrigger("Hit"); stateController.ChangeState(EnemyState.HitStun, hitStunDuration); // 简单的击退效果 Rigidbody rb = GetComponent<Rigidbody>(); if (rb != null) { rb.AddForce(hitDirection.normalized * knockBackForce, ForceMode.Impulse); } if (currentHP <= 0) { Die(); } } }

3.3 性能优化与常见陷阱

动态生成碰撞体虽然灵活,但频繁的InstantiateDestroy会引发GC(垃圾回收)问题,导致卡顿。对象池是必须引入的优化手段。我们可以预先创建一定数量的各种形状的碰撞体对象,使用时激活,用完回收,避免反复创建销毁。

另一个陷阱是穿透问题。高速移动的物体(如冲锋中的角色、飞行道具)可能在一帧内穿越薄薄的碰撞体,导致OnTriggerEnter不被调用。解决方案有几种:

  • 连续碰撞检测:对Rigidbody启用CollisionDetectionMode.ContinuousContinuousDynamic,但性能开销较大。
  • 射线检测补偿:在移动前,从上一帧位置到当前帧位置发射一条射线,如果击中,则处理命中逻辑。
  • 增大碰撞体:适当增大检测框,用“体积”换“精度”,在动作游戏中通常是可接受的。

实操技巧:对于近战攻击,除了动态碰撞体外,还可以在攻击起始帧和结束帧各做一次OverlapSphere检测,取两次检测结果的并集,这样可以有效捕捉到快速移动过程中穿过的目标,是一种性价比很高的解决方案。

4. 技能系统的扩展性设计

Demo中的技能系统虽然简单,但为其设计了一个易于扩展的框架。核心思想是将技能抽象为一系列可配置、可组合的效果

4.1 技能数据与效果分离

每个技能是一个ScriptableObject资产,里面包含了技能的所有静态数据:

  • 基础信息:技能ID、名称、图标、描述。
  • 消耗:魔法值、冷却时间。
  • 施法条件:是否需要目标、最大施法距离。
  • 效果列表:一个List<BaseSkillEffect>。这是关键所在。

BaseSkillEffect是一个抽象基类,定义了ApplyEffect(GameObject caster, GameObject target)接口。具体的伤害、治疗、位移、 buff/debuff 效果都继承自这个基类。

[CreateAssetMenu(fileName = "NewSkill", menuName = "Combat/Skill")] public class SkillData : ScriptableObject { public string skillName; public Sprite icon; public float cooldown; public float manaCost; public float castRange; public List<BaseSkillEffect> effects; } public abstract class BaseSkillEffect : ScriptableObject { public abstract void ApplyEffect(GameObject caster, GameObject target); } [CreateAssetMenu(fileName = "DamageEffect", menuName = "Combat/Effects/Damage")] public class DamageEffect : BaseSkillEffect { public float damageAmount; public DamageType damageType; public override void ApplyEffect(GameObject caster, GameObject target) { Health targetHealth = target.GetComponent<Health>(); if (targetHealth != null) { targetHealth.TakeDamage(damageAmount); } } }

这样,策划可以在Unity编辑器里像搭积木一样,为一个技能组合多个效果。例如,“火焰斩”技能可以同时包含一个“直接伤害效果”、一个“目标位置生成火焰区域的效果”和一个“为自身附加攻击力buff的效果”。

4.2 技能释放流程

技能释放由SkillManager组件管理,它持有玩家当前可用的技能列表,并处理冷却和资源消耗。

public class SkillManager : MonoBehaviour { public List<SkillInstance> equippedSkills; // SkillInstance包装了SkillData和当前冷却时间 public bool TryCastSkill(int skillSlotIndex, GameObject target) { SkillInstance skillInst = equippedSkills[skillSlotIndex]; SkillData data = skillInst.skillData; // 检查条件:冷却、蓝量、距离、状态等 if (skillInst.currentCooldown > 0) return false; if (mana.current < data.manaCost) return false; if (!IsTargetInRange(target, data.castRange)) return false; if (_currentState != PlayerState.Idle && _currentState != PlayerState.Moving) return false; // 消耗资源,进入施法状态 mana.Consume(data.manaCost); skillInst.StartCooldown(); _currentState = PlayerState.SkillCasting; // 播放施法动画(动画事件触发ApplySkillEffects) animator.SetTrigger("Cast" + skillSlotIndex); // 记录目标和技能数据,供动画事件调用 _pendingSkillData = data; _pendingSkillTarget = target; return true; } // 由动画事件调用 public void OnSkillCastPoint() { if (_pendingSkillData != null) { foreach (var effect in _pendingSkillData.effects) { // 应用效果,这里简化处理,实际可能需要更复杂的逻辑选择目标 effect.ApplyEffect(gameObject, _pendingSkillTarget); } _pendingSkillData = null; _pendingSkillTarget = null; } // 施法结束,回归Idle或Moving状态 _currentState = PlayerState.Idle; } }

这种设计将技能的逻辑(效果)与表现(动画、音效)解耦。动画只负责在正确的时间点触发OnSkillCastPoint事件,而具体发生什么,完全由SkillData中的效果列表决定。

4.3 目标选择与范围指示器

对于非指向性技能(如范围攻击、扇形攻击),需要在释放前给玩家清晰的预期。Demo中实现了简单的范围指示器。当玩家长按技能按钮时,根据技能类型(圆形、扇形、直线)在角色脚下或鼠标位置生成一个半透明的Mesh或UI图形,实时预览技能范围。这极大地提升了操作感和策略性。

5. 敌人AI:让战斗对象“活”起来

没有敌人的战斗系统是不完整的。Demo中的敌人AI采用了经典的行为树简化版——有限状态机(FSM),实现了巡逻、追击、攻击、撤退等基本行为。

5.1 感知系统:如何发现玩家

敌人需要一个“眼睛”和“耳朵”。我们通过物理系统来实现:

  • 视觉:在敌人前方设置一个扇形的Trigger碰撞体,或者使用Physics.SphereCast进行扇形射线检测。当玩家进入这个扇形区域,且中间没有障碍物遮挡(通过Raycast检查)时,敌人就“看到”了玩家。
  • 听觉:当玩家在附近奔跑、攻击发出声音时,可以以玩家为中心,在一个半径内触发一个事件。处于该半径内的敌人AI会收到“听到声音”的通知,并可能向声源位置移动调查。
public class EnemyPerception : MonoBehaviour { public float sightRange = 10f; public float sightAngle = 90f; public LayerMask targetMask; public LayerMask obstacleMask; public bool CanSeeTarget(Transform target) { Vector3 dirToTarget = (target.position - transform.position).normalized; float dstToTarget = Vector3.Distance(transform.position, target.position); // 距离判断 if (dstToTarget > sightRange) return false; // 角度判断(是否在视野锥形内) if (Vector3.Angle(transform.forward, dirToTarget) > sightAngle / 2) return false; // 视线遮挡判断 if (Physics.Raycast(transform.position, dirToTarget, dstToTarget, obstacleMask)) { return false; } return true; } }

5.2 决策与行为状态机

敌人的EnemyStateController与玩家的类似,但状态转换由AI逻辑驱动。

public enum EnemyState { Idle, Patrol, Chase, Attack, Flee, Dead }
  • Idle/Patrol:默认状态。Patrol状态会在几个预设点之间移动。
  • Chase:当感知系统发现玩家后,状态切换到Chase。敌人会使用NavMeshAgent或简单的Transform.LookAt+Translate朝向玩家移动。
  • Attack:当进入攻击范围后,切换到Attack状态。这里会有一个攻击冷却计时,计时结束后播放攻击动画并触发伤害检测。攻击后根据情况决定是继续攻击还是后撤。
  • Flee:当生命值低于一定阈值时,可能触发Flee状态,试图逃离玩家。

AI设计心得:不要试图做一个“聪明”的AI,而是做一个“感觉合理”的AI。给AI加入一些随机性和延迟。例如,发现玩家后不要立刻100%精确地转向,可以加一个短暂的转身动画;攻击不是冷却一好就立刻释放,可以有一个小的随机延迟。这些不完美反而让敌人显得更真实、更有“性格”。

5.3 导航与移动

对于地面敌人,Unity的NavMesh系统是首选。它自动处理了路径寻找和障碍物规避。你需要烘焙场景的NavMesh,然后为敌人添加NavMeshAgent组件。在Chase状态中,只需设置agent.destination = player.position即可。

对于飞行或特殊移动规则的敌人,可能需要自己实现移动逻辑,比如使用Vector3.MoveTowards或物理力。

6. 打磨体验:那些让Demo“像回事”的细节

核心功能跑通后,一些细节的打磨能极大提升Demo的质感。

6.1 镜头控制与震动

战斗镜头不能死板。我实现了一个简单的CameraFollow脚本,它不只是跟在玩家后面,还会:

  • 滞后跟随:使用Vector3.SmoothDamp,让镜头移动略有延迟,感觉更平滑。
  • 目标偏移:镜头焦点不是固定在玩家脚底,而是根据玩家状态微微上移或前移(例如攻击时镜头稍微拉近)。
  • 屏幕震动:重击命中或释放大招时,触发一个简短的屏幕震动效果。这可以通过在几帧内随机偏移相机位置来实现,强度随时间衰减。
public IEnumerator ShakeCamera(float duration, float magnitude) { Vector3 originalPos = transform.localPosition; float elapsed = 0.0f; while (elapsed < duration) { float x = Random.Range(-1f, 1f) * magnitude; float y = Random.Range(-1f, 1f) * magnitude; transform.localPosition = originalPos + new Vector3(x, y, 0); elapsed += Time.deltaTime; yield return null; // 等待下一帧 } transform.localPosition = originalPos; }

6.2 音效与UI反馈

  • 音效:为攻击命中、受击、技能释放、脚步声等关键动作绑定音效。使用AudioSource.PlayClipAtPoint或对象池管理的音频源组件来播放。注意音效的空间化(3D Sound)设置,让声音随距离衰减,增强沉浸感。
  • UI反馈
    • 伤害数字:使用对象池生成UI Text或TextMeshPro,从受击点向上漂浮并渐隐。可以加入随机的小幅水平偏移和缩放动画,让数字跳动更有力。
    • 血条:敌人的血条使用World Space Canvas,始终面向相机(Billboard)。扣血时血条减少不是瞬间完成,而是有一个平滑的动画(Mathf.SmoothDamp)。
    • 技能冷却:技能图标上覆盖一个顺时针减少的填充图像(Image.fillAmount)来表示冷却进度,这是手游的标准做法。

6.3 性能分析与优化建议

在移动平台上,战斗场景是性能重灾区。在Demo开发后期,我使用Unity Profiler进行了简单分析:

  1. Draw Call:合并角色和环境的静态材质,使用尽可能少的材质球。
  2. 骨骼动画:控制单个角色的骨骼数量,对于非主角的敌人,可以使用更简单的动画或帧数更低的动画。
  3. 物理更新:确保不必要的刚体不要开启连续碰撞检测。动态生成的碰撞体使用对象池。
  4. GC Alloc:避免在Update中频繁new对象(如new Vector3()),改用缓存变量。使用StringBuilder拼接字符串。
  5. 粒子特效:控制同时存在的最大粒子数,使用简单的Mesh而非复杂的Shader。

这个Demo最终在几年前的中端安卓机上可以稳定运行在30帧以上,证明了这套基础架构的可行性。回过头看,构建一个3D战斗Demo就像搭积木,先确保最核心的几块(状态、输入、碰撞、动画)能严丝合缝地咬合在一起,然后再去丰富它的外观和细节。希望这次分享能帮你避开我当年踩过的一些坑,更顺畅地搭建起属于自己的那个“世界”。

← 返回列表