1. 项目概述与核心思路最近在重构一个老项目的角色控制器看着脚本里动辄五六百行、充斥着大量if-else和switch-case的代码每次想加个新动作都头疼不已。这种“面条式”代码在Unity角色控制开发里太常见了尤其是处理跑、跳、攻击、受击等复杂状态切换时逻辑耦合严重维护和扩展简直是噩梦。这次我决定彻底重构采用状态模式State Pattern来设计一个清晰、可维护的角色控制系统并以经典的UnityChan模型作为实操案例。状态模式是游戏开发中处理角色行为的“利器”。它的核心思想很简单将每一个独立的行为如闲置、行走、跳跃封装成一个独立的状态类。角色在任何时刻都只处于某一个具体状态中由当前状态对象来负责处理输入、更新逻辑和播放动画。状态的切换则通过一个中心化的状态机来管理。这样做的好处是每个状态的逻辑被隔离在自己的类里添加新状态比如“翻滚”或“滑铲”时你几乎不需要修改现有代码只需要新建一个状态类并注册到状态机即可完全符合“开闭原则”。为什么选择UnityChan首先它是Unity官方提供的免费高质量角色模型动画资源丰富闲置、跑、跳等一应俱全社区认知度高便于大家理解和复现。其次它的骨骼和动画设置标准能让我们更专注于状态逻辑本身而不是解决模型导入或动画重定向的兼容性问题。通过这个案例你不仅能掌握状态模式在Unity中的落地方法更能学会如何设计一个健壮、易扩展的角色控制框架这对于开发任何类型的动作游戏、RPG甚至是一些需要复杂行为树的AI都大有裨益。2. 状态模式框架设计与核心类解析2.1 状态模式在Unity中的架构设计在开始敲代码之前我们先要把顶层架构想清楚。一个基于状态模式的角色控制系统通常包含以下几个核心部分状态基类IState / StateBase定义所有具体状态必须实现的接口或抽象方法例如OnEnter进入状态、OnUpdate状态更新、OnExit退出状态。这是多态性的基础。具体状态类Concrete States继承自状态基类实现特定行为的逻辑。例如IdleState、RunState、JumpState。状态机StateMachine这是系统的大脑。它持有当前状态对象的引用并负责驱动状态的切换。它提供一个方法如ChangeState来安全地从一个状态过渡到另一个状态。上下文Context通常就是我们的角色控制器PlayerController。它持有状态机的实例并将自身的引用如Animator、Rigidbody传递给各个状态使得状态对象能够操作角色。在Unity中实现时我们还需要考虑与MonoBehaviour生命周期的结合。我的设计是让角色控制器Context作为一个MonoBehaviour在Start中初始化状态机和各个状态在Update中调用状态机的更新。而具体的状态类可以是普通的C#类也可以是ScriptableObject如果你希望更灵活地配置这里我们先采用经典的C#类实现。2.2 核心代码实现定义状态接口与基类首先我们创建所有状态的契约。我习惯定义一个接口IState因为它比抽象类更灵活允许状态类继承自其他类。// IState.cs public interface IState { // 进入该状态时调用 void OnEnter(); // 每帧更新时调用 void OnUpdate(); // 退出该状态时调用 void OnExit(); }接下来实现一个简单的状态机。这个状态机需要知道当前是哪个状态并能处理状态切换。在切换时必须严格按照当前状态.OnExit() - 切换状态 - 新状态.OnEnter()的顺序执行这是保证状态清理和初始化正确性的关键。// StateMachine.cs public class StateMachine { private IState _currentState; // 改变状态这是状态机最核心的方法 public void ChangeState(IState newState) { // 如果试图切换到同一个状态则忽略除非你明确需要重新进入 if (_currentState ! null _currentState.GetType() newState.GetType()) return; // 退出当前状态 _currentState?.OnExit(); // 切换状态引用 _currentState newState; // 初始化新状态 _currentState?.OnEnter(); } // 在角色的Update方法中调用驱动当前状态的逻辑更新 public void Update() { _currentState?.OnUpdate(); } // 获取当前状态类型可用于调试或逻辑判断 public Type GetCurrentStateType() { return _currentState?.GetType(); } }注意这里的状态机是简易版。在复杂项目中你可能需要处理状态切换时的过渡时间、条件检查、或者同时运行多个状态分层状态机。但简易版足以覆盖80%的需求并且是理解更复杂状态机的基础。2.3 角色控制器Context的搭建现在我们来创建角色的“身体”和“大脑”——PlayerController。它将作为状态模式的上下文Context持有状态机、角色组件Animator, Rigidbody的引用并负责初始化整个系统。// PlayerController.cs using UnityEngine; public class PlayerController : MonoBehaviour { // 状态机实例 private StateMachine _stateMachine; // 角色依赖的组件 private Animator _animator; private Rigidbody _rigidbody; private CharacterController _characterController; // 可选用于更复杂的地面检测和移动 // 状态实例也可以延迟初始化 private IState _idleState; private IState _runState; private IState _jumpState; // 公开的属性供状态类访问 public Animator Animator _animator; public Rigidbody Rigidbody _rigidbody; public CharacterController CharacterController _characterController; // 输入值简化处理实际项目可能用Input System public Vector2 MoveInput { get; private set; } public bool IsJumpPressed { get; private set; } void Start() { // 获取组件引用 _animator GetComponentAnimator(); _rigidbody GetComponentRigidbody(); _characterController GetComponentCharacterController(); // 初始化状态机 _stateMachine new StateMachine(); // 初始化各个具体状态并将自身this作为上下文传入 _idleState new IdleState(this); _runState new RunState(this); _jumpState new JumpState(this); // 设置初始状态 _stateMachine.ChangeState(_idleState); } void Update() { // 收集输入 MoveInput new Vector2(Input.GetAxis(Horizontal), Input.GetAxis(Vertical)); IsJumpPressed Input.GetButtonDown(Jump); // 驱动状态机更新 _stateMachine.Update(); } // 提供一个公共方法允许状态触发状态切换也可以通过事件 public void RequestStateChange(IState newState) { _stateMachine.ChangeState(newState); } }这个控制器现在看起来非常清爽它只做三件事收集输入、持有状态机和组件、驱动状态机更新。所有具体的行动逻辑都下放到了各个状态类中。3. 具体状态类的实现与动画融合3.1 闲置状态IdleState的实现我们从最简单的IdleState开始。它的职责是当角色没有移动输入时播放闲置动画并持续检测是否满足切换到“奔跑”或“跳跃”状态的条件。// IdleState.cs public class IdleState : IState { private PlayerController _player; public IdleState(PlayerController player) { _player player; // 注入上下文 } public void OnEnter() { // 进入闲置状态播放对应的动画 // UnityChan的闲置动画Trigger参数可能是 Idle _player.Animator.SetTrigger(Idle); Debug.Log(Enter Idle State); } public void OnUpdate() { // 每帧检测切换条件 // 条件1如果有移动输入则切换到奔跑状态 if (_player.MoveInput.magnitude 0.1f) { _player.RequestStateChange(new RunState(_player)); return; // 状态即将切换后续逻辑不再执行 } // 条件2如果按下跳跃键则切换到跳跃状态 if (_player.IsJumpPressed) { _player.RequestStateChange(new JumpState(_player)); return; } // 可以在这里添加其他逻辑比如缓慢恢复生命值等 } public void OnExit() { // 退出闲置状态时可能需要重置动画触发器避免冲突 _player.Animator.ResetTrigger(Idle); Debug.Log(Exit Idle State); } }实操心得在OnExit中重置Animator的Trigger是一个好习惯。因为Trigger一旦被触发其布尔值会保持到下一帧如果不在离开状态时重置可能会导致动画状态机误判。对于Bool或Float参数则通常不需要在OnExit中重置因为它们由新状态显式控制。3.2 奔跑状态RunState与输入处理奔跑状态需要处理移动逻辑。我们使用CharacterController或直接操作Rigidbody来实现移动并同步更新动画参数。// RunState.cs public class RunState : IState { private PlayerController _player; private float _moveSpeed 5f; public RunState(PlayerController player) { _player player; } public void OnEnter() { _player.Animator.SetBool(IsRunning, true); Debug.Log(Enter Run State); } public void OnUpdate() { // 1. 处理移动 Vector3 moveDirection new Vector3(_player.MoveInput.x, 0, _player.MoveInput.y); // 将输入方向从本地空间转换到世界空间如果角色会旋转 moveDirection _player.transform.TransformDirection(moveDirection); moveDirection.y 0; // 确保y轴为0除非是飞行角色 if (moveDirection.magnitude 0.1f) { // 使用CharacterController移动更稳定自带碰撞 if (_player.CharacterController ! null) { _player.CharacterController.Move(moveDirection.normalized * _moveSpeed * Time.deltaTime); } // 或者使用Rigidbody物理交互更强 else if (_player.Rigidbody ! null) { _player.Rigidbody.MovePosition(_player.transform.position moveDirection.normalized * _moveSpeed * Time.deltaTime); } // 让角色面向移动方向平滑旋转 Quaternion targetRotation Quaternion.LookRotation(moveDirection); _player.transform.rotation Quaternion.Slerp(_player.transform.rotation, targetRotation, 10f * Time.deltaTime); } // 2. 更新动画的移动速度参数用于Blend Tree混合 float currentSpeed moveDirection.magnitude * _moveSpeed; _player.Animator.SetFloat(MoveSpeed, currentSpeed); // 3. 检测状态切换条件 // 条件如果移动输入很小切换回闲置状态 if (_player.MoveInput.magnitude 0.1f) { _player.RequestStateChange(new IdleState(_player)); return; } // 条件如果按下跳跃键切换到跳跃状态 if (_player.IsJumpPressed) { _player.RequestStateChange(new JumpState(_player)); return; } } public void OnExit() { _player.Animator.SetBool(IsRunning, false); // 也可以将MoveSpeed重置为0 _player.Animator.SetFloat(MoveSpeed, 0); Debug.Log(Exit Run State); } }这里的关键是动画参数与逻辑状态的同步。我们通过Animator.SetFloat(“MoveSpeed”, …)来驱动动画混合树Blend Tree实现从走到跑的平滑过渡。UnityChan的动画控制器通常已经配置好了对应的Blend Tree。3.3 跳跃状态JumpState与物理交互跳跃状态涉及物理和瞬时力逻辑稍复杂。我们需要处理起跳瞬间的力施加、空中状态检测以及落地判定。// JumpState.cs public class JumpState : IState { private PlayerController _player; private float _jumpForce 6f; private bool _isGrounded false; private float _groundCheckDistance 0.2f; public JumpState(PlayerController player) { _player player; } public void OnEnter() { // 播放跳跃起跳动画 _player.Animator.SetTrigger(Jump); // 施加向上的力 if (_player.Rigidbody ! null) { // 先确保垂直速度归零避免连续跳更高 Vector3 velocity _player.Rigidbody.velocity; velocity.y 0; _player.Rigidbody.velocity velocity; // 添加冲量 _player.Rigidbody.AddForce(Vector3.up * _jumpForce, ForceMode.Impulse); } Debug.Log(Enter Jump State); } public void OnUpdate() { // 1. 持续检测是否落地 _isGrounded CheckGrounded(); // 2. 在空中时依然可以接受少量水平输入空中控制 Vector3 airMove new Vector3(_player.MoveInput.x, 0, _player.MoveInput.y) * 2f * Time.deltaTime; if (_player.Rigidbody ! null airMove.magnitude 0) { _player.Rigidbody.MovePosition(_player.transform.position _player.transform.TransformDirection(airMove)); } // 3. 更新动画可选的空中姿态动画 _player.Animator.SetBool(IsGrounded, _isGrounded); // 4. 落地后切换状态 if (_isGrounded _player.Rigidbody.velocity.y 0.1f) // 确保是下落触地而不是起跳瞬间 { // 可以短暂播放落地动画然后根据输入决定下一个状态 if (_player.MoveInput.magnitude 0.1f) { _player.RequestStateChange(new RunState(_player)); } else { _player.RequestStateChange(new IdleState(_player)); } } } public void OnExit() { _player.Animator.ResetTrigger(Jump); _player.Animator.SetBool(IsGrounded, true); // 确保退出时重置 Debug.Log(Exit Jump State); } private bool CheckGrounded() { // 简单的射线检测地面 RaycastHit hit; Vector3 rayStart _player.transform.position Vector3.up * 0.1f; // 从脚部稍上方开始 if (Physics.Raycast(rayStart, Vector3.down, out hit, _groundCheckDistance)) { return true; } return false; } }注意事项跳跃的物理手感调优是个细活。_jumpForce的大小、空中控制系数、地面检测的距离和频率都会影响最终体验。建议将_jumpForce、_groundCheckDistance等参数暴露为public或[SerializeField]方便在Inspector中实时调整。地面检测推荐使用Physics.SphereCast替代Raycast检测范围更大容错性更好。4. 状态切换优化与动画过渡处理4.1 使用枚举或类型替代直接实例化在上面的代码中每次切换状态我们都new了一个新的状态实例如new IdleState(_player)。对于简单项目没问题但如果状态需要持有大量数据或频繁切换反复创建和销毁对象可能带来不必要的GC垃圾回收压力。更常见的优化是使用对象池思想或者让状态机持有所有状态的单例引用。一种简洁的改进方式是使用状态工厂或状态字典// 在PlayerController中 private DictionarySystem.Type, IState _stateCache; void Start() { _stateCache new DictionarySystem.Type, IState { { typeof(IdleState), new IdleState(this) }, { typeof(RunState), new RunState(this) }, { typeof(JumpState), new JumpState(this) } }; _stateMachine.ChangeState(_stateCache[typeof(IdleState)]); } // 修改RequestStateChange方法 public void RequestStateChange(System.Type stateType) { if (_stateCache.ContainsKey(stateType)) { _stateMachine.ChangeState(_stateCache[stateType]); } }这样每个状态只在游戏开始时创建一次后续切换只是改变状态机的引用性能更优。4.2 与Unity Animator State Machine的协作我们的代码状态机逻辑状态机和Unity的Animator Controller动画状态机是两套系统需要让它们保持同步。我们目前采用的方式是逻辑驱动动画在代码状态的OnEnter和OnUpdate中通过Animator.SetXXX方法来触发或改变动画状态。同步策略Trigger用于一次性动作如Jump、Attack在逻辑状态进入时触发在退出时重置。Bool用于持续性状态如IsRunning、IsGrounded在逻辑状态的整个生命周期内保持其值。Float用于混合与过渡如MoveSpeed在OnUpdate中根据逻辑计算实时更新驱动Blend Tree。处理动画事件有时动画本身需要回调到逻辑代码例如在攻击动画的某一帧产生伤害判据。你可以在Animation Clip中添加事件调用PlayerController的公共方法。但要注意这个公共方法应该只做“通知”具体的逻辑如切换状态仍应由当前活跃的逻辑状态来处理以保持职责清晰。// 在PlayerController中 public void OnAnimationEvent_AttackHit() { // 只是将事件转发给当前状态 (_stateMachine.GetCurrentState() as IAttackState)?.OnAttackHit(); } // 攻击状态接口 public interface IAttackState { void OnAttackHit(); }4.3 实现状态切换的条件封装目前我们的状态切换条件如if (_player.MoveInput.magnitude 0.1f)是硬编码在状态的OnUpdate里的。当条件变多或变复杂时这会使得OnUpdate方法臃肿。我们可以引入条件类Condition或过渡Transition的概念来解耦。一个简单的实现是为每个状态定义一个ListTransition每个Transition包含一个目标状态类型和一个判断条件委托。public class Transition { public System.Type TargetState { get; } public System.Funcbool Condition { get; } public Transition(System.Type targetState, System.Funcbool condition) { TargetState targetState; Condition condition; } } // 在状态基类或具体状态中 public class IdleState : IState { private ListTransition _transitions; // ... 其他字段 public IdleState(PlayerController player) { _player player; SetupTransitions(); } private void SetupTransitions() { _transitions new ListTransition { new Transition(typeof(RunState), () _player.MoveInput.magnitude 0.1f), new Transition(typeof(JumpState), () _player.IsJumpPressed) }; } public void OnUpdate() { foreach (var transition in _transitions) { if (transition.Condition()) { _player.RequestStateChange(transition.TargetState); return; } } // ... 本状态的其他更新逻辑 } }这种方式将状态转移逻辑模块化更易于管理和扩展尤其是在实现复杂的行为树或AI时优势明显。5. 扩展实践添加攻击与受击状态为了展示状态模式的扩展性我们快速添加一个攻击状态AttackState和一个受击状态HurtState。你会看到添加新状态对现有代码的侵入性极小。5.1 攻击状态AttackState实现攻击状态通常有冷却时间并且可能包含连击。我们实现一个简单的单次攻击。// AttackState.cs public class AttackState : IState { private PlayerController _player; private float _attackCooldown 0.5f; private float _cooldownTimer 0f; private bool _attackTriggered false; public AttackState(PlayerController player) { _player player; } public void OnEnter() { _player.Animator.SetTrigger(Attack); _cooldownTimer _attackCooldown; _attackTriggered true; Debug.Log(Enter Attack State); // 这里可以播放音效、禁用移动输入等 } public void OnUpdate() { _cooldownTimer - Time.deltaTime; // 攻击动画播放期间锁定其他状态切换 // 一种常见做法是在动画播放完之前不允许切换状态。 // 我们可以通过Animator.GetCurrentAnimatorStateInfo来获取当前动画的播放进度。 AnimatorStateInfo stateInfo _player.Animator.GetCurrentAnimatorStateInfo(0); bool isAttacking stateInfo.IsName(Attack) stateInfo.normalizedTime 1.0f; if (!isAttacking _cooldownTimer 0) { // 攻击动画播放完毕且冷却结束根据输入返回之前的状态 if (_player.MoveInput.magnitude 0.1f) { _player.RequestStateChange(typeof(RunState)); } else { _player.RequestStateChange(typeof(IdleState)); } } } public void OnExit() { _player.Animator.ResetTrigger(Attack); Debug.Log(Exit Attack State); } }然后在IdleState和RunState的OnUpdate里添加切换到攻击状态的条件例如按下鼠标左键// 在IdleState和RunState的OnUpdate中增加 if (Input.GetMouseButtonDown(0)) // 假设鼠标左键攻击 { _player.RequestStateChange(typeof(AttackState)); return; }5.2 受击状态HurtState与状态优先级受击状态通常具有高优先级即无论当前在什么状态攻击、跳跃一旦受到伤害都应立即切换到受击状态。这需要在状态机或控制器层面进行特殊处理。我们修改PlayerController增加一个公共方法TakeDamage该方法强制切换到受击状态。// HurtState.cs public class HurtState : IState { private PlayerController _player; private float _hurtDuration 0.8f; // 受击硬直时间 private float _timer; public HurtState(PlayerController player) { _player player; } public void OnEnter() { _player.Animator.SetTrigger(Hurt); _timer _hurtDuration; Debug.Log(Enter Hurt State); // 可能的效果屏幕震动、角色短暂无敌、扣血等 } public void OnUpdate() { _timer - Time.deltaTime; if (_timer 0) { // 硬直时间结束回到闲置或移动状态 if (_player.MoveInput.magnitude 0.1f) { _player.RequestStateChange(typeof(RunState)); } else { _player.RequestStateChange(typeof(IdleState)); } } // 受击状态下通常不接受玩家输入 } public void OnExit() { _player.Animator.ResetTrigger(Hurt); Debug.Log(Exit Hurt State); } }在PlayerController中public void TakeDamage(int damage) { // 扣血逻辑... // 强制切换到受击状态无视当前状态 _stateMachine.ChangeState(_stateCache[typeof(HurtState)]); }重要技巧对于这种高优先级的状态打断直接调用状态机的ChangeState方法而不是通过RequestStateChange。因为RequestStateChange可能包含一些条件判断而受击应该是无条件的。同时要确保HurtState的OnEnter里不会触发另一个受击避免循环。6. 调试技巧与常见问题排查6.1 状态机可视化调试纯代码的状态机在调试时不够直观。我们可以通过简单的OnGUI或Debug.Log来输出当前状态但更好的方法是在编辑器里可视化。一个快速的方法是使用自定义Inspector显示当前状态。// 在PlayerController中添加 [SerializeField] private string _currentStateName; // 用于Inspector显示 void Update() { // ... 原有逻辑 _currentStateName _stateMachine?.GetCurrentStateType()?.ToString(); }这样在Unity编辑器的Inspector窗口中你就能实时看到角色当前处于哪个状态对于调试状态切换逻辑非常有用。6.2 常见问题与解决方案速查表问题现象可能原因解决方案动画不播放或闪烁1. Animator参数名拼写错误。2. Trigger未在OnExit中重置导致误触发。3. 动画状态机中的过渡条件设置不当。1. 检查代码中的参数名与Animator Controller中的是否完全一致大小写敏感。2. 确保在每个使用Trigger的状态的OnExit中调用ResetTrigger。3. 在Animator窗口检查状态之间的过渡Transition是否有Exit Time、Has Exit Time等限制尝试简化过渡条件。状态切换不生效1. 切换条件判断逻辑有误如阈值不合适。2.RequestStateChange后没有立即return导致同一帧内可能执行了其他状态切换。3. 状态机ChangeState方法中新旧状态类型比较逻辑有问题。1. 使用Debug.Log输出条件判断的中间值检查阈值。2. 在调用RequestStateChange后立即使用return跳出当前状态的OnUpdate。3. 检查ChangeState中的类型比较逻辑确保它是你期望的行为有时允许重新进入同一状态是需要的。角色移动或旋转卡顿1. 在Update中直接修改Transform.position与物理引擎冲突。2. 使用Rigidbody.MovePosition但未考虑连续调用。3. 旋转插值速度过快或过慢。1. 对于物理交互的角色坚持使用Rigidbody的相关方法MovePosition,AddForce进行移动。2. 确保移动逻辑在FixedUpdate中或与Time.deltaTime相乘。3. 调整旋转插值系数如Quaternion.Slerp的第三个参数找到一个平滑且响应迅速的值。跳跃手感“飘”或“沉”1.JumpForce力模式ForceMode选择不当。2. 重力缩放或角色质量Mass不合适。3. 空中控制系数太大导致违反物理直觉。1. 对于瞬间起跳使用ForceMode.Impulse对于持续升力如飞行使用ForceMode.Force。2. 在Project Settings - Physics中调整全局重力或微调Rigidbody的Mass和Drag。3. 减小空中移动的系数确保跳跃轨迹以抛物线为主。新状态添加后编译错误新状态类没有实现IState接口的所有方法。检查是否完整实现了OnEnter,OnUpdate,OnExit三个方法。使用IDE的“实现接口”功能可以自动生成方法骨架。6.3 性能考量与最佳实践状态对象的数量如果状态非常多超过几十个且每个状态都持有大量数据考虑使用struct实现状态如果状态无引用类型字段或采用ScriptableObject来共享配置数据。状态切换频率避免每帧都进行状态切换。可以通过设置一个最小状态持续时间例如攻击后摇期间不可切换或使用状态切换冷却来稳定状态机。与Unity ECS/DOTS的兼容性本文是基于面向对象的MonoBehaviour框架。如果你计划使用Unity的ECS架构状态模式的思想仍然适用但实现方式会变为基于数据和Job的系统状态信息可以存储在IComponentData中。日志输出在开发阶段可以在每个状态的OnEnter和OnExit中加入Debug.Log这是理解状态流最直接的方式。发布时可以通过条件编译#if UNITY_EDITOR来移除这些日志。通过以上步骤我们完成了一个基于状态模式的、结构清晰的UnityChan角色控制器。它从混乱的if-else中解放出来每个行为职责单一扩展新状态比如“下蹲”、“翻滚”、“使用技能”变得非常容易——只需要新建一个类实现IState然后在适当的地方添加切换条件即可。这种设计模式带来的代码可读性和可维护性提升在长期项目迭代中会体现巨大的价值。
Unity状态模式实战:重构角色控制器,告别if-else
1. 项目概述与核心思路最近在重构一个老项目的角色控制器看着脚本里动辄五六百行、充斥着大量if-else和switch-case的代码每次想加个新动作都头疼不已。这种“面条式”代码在Unity角色控制开发里太常见了尤其是处理跑、跳、攻击、受击等复杂状态切换时逻辑耦合严重维护和扩展简直是噩梦。这次我决定彻底重构采用状态模式State Pattern来设计一个清晰、可维护的角色控制系统并以经典的UnityChan模型作为实操案例。状态模式是游戏开发中处理角色行为的“利器”。它的核心思想很简单将每一个独立的行为如闲置、行走、跳跃封装成一个独立的状态类。角色在任何时刻都只处于某一个具体状态中由当前状态对象来负责处理输入、更新逻辑和播放动画。状态的切换则通过一个中心化的状态机来管理。这样做的好处是每个状态的逻辑被隔离在自己的类里添加新状态比如“翻滚”或“滑铲”时你几乎不需要修改现有代码只需要新建一个状态类并注册到状态机即可完全符合“开闭原则”。为什么选择UnityChan首先它是Unity官方提供的免费高质量角色模型动画资源丰富闲置、跑、跳等一应俱全社区认知度高便于大家理解和复现。其次它的骨骼和动画设置标准能让我们更专注于状态逻辑本身而不是解决模型导入或动画重定向的兼容性问题。通过这个案例你不仅能掌握状态模式在Unity中的落地方法更能学会如何设计一个健壮、易扩展的角色控制框架这对于开发任何类型的动作游戏、RPG甚至是一些需要复杂行为树的AI都大有裨益。2. 状态模式框架设计与核心类解析2.1 状态模式在Unity中的架构设计在开始敲代码之前我们先要把顶层架构想清楚。一个基于状态模式的角色控制系统通常包含以下几个核心部分状态基类IState / StateBase定义所有具体状态必须实现的接口或抽象方法例如OnEnter进入状态、OnUpdate状态更新、OnExit退出状态。这是多态性的基础。具体状态类Concrete States继承自状态基类实现特定行为的逻辑。例如IdleState、RunState、JumpState。状态机StateMachine这是系统的大脑。它持有当前状态对象的引用并负责驱动状态的切换。它提供一个方法如ChangeState来安全地从一个状态过渡到另一个状态。上下文Context通常就是我们的角色控制器PlayerController。它持有状态机的实例并将自身的引用如Animator、Rigidbody传递给各个状态使得状态对象能够操作角色。在Unity中实现时我们还需要考虑与MonoBehaviour生命周期的结合。我的设计是让角色控制器Context作为一个MonoBehaviour在Start中初始化状态机和各个状态在Update中调用状态机的更新。而具体的状态类可以是普通的C#类也可以是ScriptableObject如果你希望更灵活地配置这里我们先采用经典的C#类实现。2.2 核心代码实现定义状态接口与基类首先我们创建所有状态的契约。我习惯定义一个接口IState因为它比抽象类更灵活允许状态类继承自其他类。// IState.cs public interface IState { // 进入该状态时调用 void OnEnter(); // 每帧更新时调用 void OnUpdate(); // 退出该状态时调用 void OnExit(); }接下来实现一个简单的状态机。这个状态机需要知道当前是哪个状态并能处理状态切换。在切换时必须严格按照当前状态.OnExit() - 切换状态 - 新状态.OnEnter()的顺序执行这是保证状态清理和初始化正确性的关键。// StateMachine.cs public class StateMachine { private IState _currentState; // 改变状态这是状态机最核心的方法 public void ChangeState(IState newState) { // 如果试图切换到同一个状态则忽略除非你明确需要重新进入 if (_currentState ! null _currentState.GetType() newState.GetType()) return; // 退出当前状态 _currentState?.OnExit(); // 切换状态引用 _currentState newState; // 初始化新状态 _currentState?.OnEnter(); } // 在角色的Update方法中调用驱动当前状态的逻辑更新 public void Update() { _currentState?.OnUpdate(); } // 获取当前状态类型可用于调试或逻辑判断 public Type GetCurrentStateType() { return _currentState?.GetType(); } }注意这里的状态机是简易版。在复杂项目中你可能需要处理状态切换时的过渡时间、条件检查、或者同时运行多个状态分层状态机。但简易版足以覆盖80%的需求并且是理解更复杂状态机的基础。2.3 角色控制器Context的搭建现在我们来创建角色的“身体”和“大脑”——PlayerController。它将作为状态模式的上下文Context持有状态机、角色组件Animator, Rigidbody的引用并负责初始化整个系统。// PlayerController.cs using UnityEngine; public class PlayerController : MonoBehaviour { // 状态机实例 private StateMachine _stateMachine; // 角色依赖的组件 private Animator _animator; private Rigidbody _rigidbody; private CharacterController _characterController; // 可选用于更复杂的地面检测和移动 // 状态实例也可以延迟初始化 private IState _idleState; private IState _runState; private IState _jumpState; // 公开的属性供状态类访问 public Animator Animator _animator; public Rigidbody Rigidbody _rigidbody; public CharacterController CharacterController _characterController; // 输入值简化处理实际项目可能用Input System public Vector2 MoveInput { get; private set; } public bool IsJumpPressed { get; private set; } void Start() { // 获取组件引用 _animator GetComponentAnimator(); _rigidbody GetComponentRigidbody(); _characterController GetComponentCharacterController(); // 初始化状态机 _stateMachine new StateMachine(); // 初始化各个具体状态并将自身this作为上下文传入 _idleState new IdleState(this); _runState new RunState(this); _jumpState new JumpState(this); // 设置初始状态 _stateMachine.ChangeState(_idleState); } void Update() { // 收集输入 MoveInput new Vector2(Input.GetAxis(Horizontal), Input.GetAxis(Vertical)); IsJumpPressed Input.GetButtonDown(Jump); // 驱动状态机更新 _stateMachine.Update(); } // 提供一个公共方法允许状态触发状态切换也可以通过事件 public void RequestStateChange(IState newState) { _stateMachine.ChangeState(newState); } }这个控制器现在看起来非常清爽它只做三件事收集输入、持有状态机和组件、驱动状态机更新。所有具体的行动逻辑都下放到了各个状态类中。3. 具体状态类的实现与动画融合3.1 闲置状态IdleState的实现我们从最简单的IdleState开始。它的职责是当角色没有移动输入时播放闲置动画并持续检测是否满足切换到“奔跑”或“跳跃”状态的条件。// IdleState.cs public class IdleState : IState { private PlayerController _player; public IdleState(PlayerController player) { _player player; // 注入上下文 } public void OnEnter() { // 进入闲置状态播放对应的动画 // UnityChan的闲置动画Trigger参数可能是 Idle _player.Animator.SetTrigger(Idle); Debug.Log(Enter Idle State); } public void OnUpdate() { // 每帧检测切换条件 // 条件1如果有移动输入则切换到奔跑状态 if (_player.MoveInput.magnitude 0.1f) { _player.RequestStateChange(new RunState(_player)); return; // 状态即将切换后续逻辑不再执行 } // 条件2如果按下跳跃键则切换到跳跃状态 if (_player.IsJumpPressed) { _player.RequestStateChange(new JumpState(_player)); return; } // 可以在这里添加其他逻辑比如缓慢恢复生命值等 } public void OnExit() { // 退出闲置状态时可能需要重置动画触发器避免冲突 _player.Animator.ResetTrigger(Idle); Debug.Log(Exit Idle State); } }实操心得在OnExit中重置Animator的Trigger是一个好习惯。因为Trigger一旦被触发其布尔值会保持到下一帧如果不在离开状态时重置可能会导致动画状态机误判。对于Bool或Float参数则通常不需要在OnExit中重置因为它们由新状态显式控制。3.2 奔跑状态RunState与输入处理奔跑状态需要处理移动逻辑。我们使用CharacterController或直接操作Rigidbody来实现移动并同步更新动画参数。// RunState.cs public class RunState : IState { private PlayerController _player; private float _moveSpeed 5f; public RunState(PlayerController player) { _player player; } public void OnEnter() { _player.Animator.SetBool(IsRunning, true); Debug.Log(Enter Run State); } public void OnUpdate() { // 1. 处理移动 Vector3 moveDirection new Vector3(_player.MoveInput.x, 0, _player.MoveInput.y); // 将输入方向从本地空间转换到世界空间如果角色会旋转 moveDirection _player.transform.TransformDirection(moveDirection); moveDirection.y 0; // 确保y轴为0除非是飞行角色 if (moveDirection.magnitude 0.1f) { // 使用CharacterController移动更稳定自带碰撞 if (_player.CharacterController ! null) { _player.CharacterController.Move(moveDirection.normalized * _moveSpeed * Time.deltaTime); } // 或者使用Rigidbody物理交互更强 else if (_player.Rigidbody ! null) { _player.Rigidbody.MovePosition(_player.transform.position moveDirection.normalized * _moveSpeed * Time.deltaTime); } // 让角色面向移动方向平滑旋转 Quaternion targetRotation Quaternion.LookRotation(moveDirection); _player.transform.rotation Quaternion.Slerp(_player.transform.rotation, targetRotation, 10f * Time.deltaTime); } // 2. 更新动画的移动速度参数用于Blend Tree混合 float currentSpeed moveDirection.magnitude * _moveSpeed; _player.Animator.SetFloat(MoveSpeed, currentSpeed); // 3. 检测状态切换条件 // 条件如果移动输入很小切换回闲置状态 if (_player.MoveInput.magnitude 0.1f) { _player.RequestStateChange(new IdleState(_player)); return; } // 条件如果按下跳跃键切换到跳跃状态 if (_player.IsJumpPressed) { _player.RequestStateChange(new JumpState(_player)); return; } } public void OnExit() { _player.Animator.SetBool(IsRunning, false); // 也可以将MoveSpeed重置为0 _player.Animator.SetFloat(MoveSpeed, 0); Debug.Log(Exit Run State); } }这里的关键是动画参数与逻辑状态的同步。我们通过Animator.SetFloat(“MoveSpeed”, …)来驱动动画混合树Blend Tree实现从走到跑的平滑过渡。UnityChan的动画控制器通常已经配置好了对应的Blend Tree。3.3 跳跃状态JumpState与物理交互跳跃状态涉及物理和瞬时力逻辑稍复杂。我们需要处理起跳瞬间的力施加、空中状态检测以及落地判定。// JumpState.cs public class JumpState : IState { private PlayerController _player; private float _jumpForce 6f; private bool _isGrounded false; private float _groundCheckDistance 0.2f; public JumpState(PlayerController player) { _player player; } public void OnEnter() { // 播放跳跃起跳动画 _player.Animator.SetTrigger(Jump); // 施加向上的力 if (_player.Rigidbody ! null) { // 先确保垂直速度归零避免连续跳更高 Vector3 velocity _player.Rigidbody.velocity; velocity.y 0; _player.Rigidbody.velocity velocity; // 添加冲量 _player.Rigidbody.AddForce(Vector3.up * _jumpForce, ForceMode.Impulse); } Debug.Log(Enter Jump State); } public void OnUpdate() { // 1. 持续检测是否落地 _isGrounded CheckGrounded(); // 2. 在空中时依然可以接受少量水平输入空中控制 Vector3 airMove new Vector3(_player.MoveInput.x, 0, _player.MoveInput.y) * 2f * Time.deltaTime; if (_player.Rigidbody ! null airMove.magnitude 0) { _player.Rigidbody.MovePosition(_player.transform.position _player.transform.TransformDirection(airMove)); } // 3. 更新动画可选的空中姿态动画 _player.Animator.SetBool(IsGrounded, _isGrounded); // 4. 落地后切换状态 if (_isGrounded _player.Rigidbody.velocity.y 0.1f) // 确保是下落触地而不是起跳瞬间 { // 可以短暂播放落地动画然后根据输入决定下一个状态 if (_player.MoveInput.magnitude 0.1f) { _player.RequestStateChange(new RunState(_player)); } else { _player.RequestStateChange(new IdleState(_player)); } } } public void OnExit() { _player.Animator.ResetTrigger(Jump); _player.Animator.SetBool(IsGrounded, true); // 确保退出时重置 Debug.Log(Exit Jump State); } private bool CheckGrounded() { // 简单的射线检测地面 RaycastHit hit; Vector3 rayStart _player.transform.position Vector3.up * 0.1f; // 从脚部稍上方开始 if (Physics.Raycast(rayStart, Vector3.down, out hit, _groundCheckDistance)) { return true; } return false; } }注意事项跳跃的物理手感调优是个细活。_jumpForce的大小、空中控制系数、地面检测的距离和频率都会影响最终体验。建议将_jumpForce、_groundCheckDistance等参数暴露为public或[SerializeField]方便在Inspector中实时调整。地面检测推荐使用Physics.SphereCast替代Raycast检测范围更大容错性更好。4. 状态切换优化与动画过渡处理4.1 使用枚举或类型替代直接实例化在上面的代码中每次切换状态我们都new了一个新的状态实例如new IdleState(_player)。对于简单项目没问题但如果状态需要持有大量数据或频繁切换反复创建和销毁对象可能带来不必要的GC垃圾回收压力。更常见的优化是使用对象池思想或者让状态机持有所有状态的单例引用。一种简洁的改进方式是使用状态工厂或状态字典// 在PlayerController中 private DictionarySystem.Type, IState _stateCache; void Start() { _stateCache new DictionarySystem.Type, IState { { typeof(IdleState), new IdleState(this) }, { typeof(RunState), new RunState(this) }, { typeof(JumpState), new JumpState(this) } }; _stateMachine.ChangeState(_stateCache[typeof(IdleState)]); } // 修改RequestStateChange方法 public void RequestStateChange(System.Type stateType) { if (_stateCache.ContainsKey(stateType)) { _stateMachine.ChangeState(_stateCache[stateType]); } }这样每个状态只在游戏开始时创建一次后续切换只是改变状态机的引用性能更优。4.2 与Unity Animator State Machine的协作我们的代码状态机逻辑状态机和Unity的Animator Controller动画状态机是两套系统需要让它们保持同步。我们目前采用的方式是逻辑驱动动画在代码状态的OnEnter和OnUpdate中通过Animator.SetXXX方法来触发或改变动画状态。同步策略Trigger用于一次性动作如Jump、Attack在逻辑状态进入时触发在退出时重置。Bool用于持续性状态如IsRunning、IsGrounded在逻辑状态的整个生命周期内保持其值。Float用于混合与过渡如MoveSpeed在OnUpdate中根据逻辑计算实时更新驱动Blend Tree。处理动画事件有时动画本身需要回调到逻辑代码例如在攻击动画的某一帧产生伤害判据。你可以在Animation Clip中添加事件调用PlayerController的公共方法。但要注意这个公共方法应该只做“通知”具体的逻辑如切换状态仍应由当前活跃的逻辑状态来处理以保持职责清晰。// 在PlayerController中 public void OnAnimationEvent_AttackHit() { // 只是将事件转发给当前状态 (_stateMachine.GetCurrentState() as IAttackState)?.OnAttackHit(); } // 攻击状态接口 public interface IAttackState { void OnAttackHit(); }4.3 实现状态切换的条件封装目前我们的状态切换条件如if (_player.MoveInput.magnitude 0.1f)是硬编码在状态的OnUpdate里的。当条件变多或变复杂时这会使得OnUpdate方法臃肿。我们可以引入条件类Condition或过渡Transition的概念来解耦。一个简单的实现是为每个状态定义一个ListTransition每个Transition包含一个目标状态类型和一个判断条件委托。public class Transition { public System.Type TargetState { get; } public System.Funcbool Condition { get; } public Transition(System.Type targetState, System.Funcbool condition) { TargetState targetState; Condition condition; } } // 在状态基类或具体状态中 public class IdleState : IState { private ListTransition _transitions; // ... 其他字段 public IdleState(PlayerController player) { _player player; SetupTransitions(); } private void SetupTransitions() { _transitions new ListTransition { new Transition(typeof(RunState), () _player.MoveInput.magnitude 0.1f), new Transition(typeof(JumpState), () _player.IsJumpPressed) }; } public void OnUpdate() { foreach (var transition in _transitions) { if (transition.Condition()) { _player.RequestStateChange(transition.TargetState); return; } } // ... 本状态的其他更新逻辑 } }这种方式将状态转移逻辑模块化更易于管理和扩展尤其是在实现复杂的行为树或AI时优势明显。5. 扩展实践添加攻击与受击状态为了展示状态模式的扩展性我们快速添加一个攻击状态AttackState和一个受击状态HurtState。你会看到添加新状态对现有代码的侵入性极小。5.1 攻击状态AttackState实现攻击状态通常有冷却时间并且可能包含连击。我们实现一个简单的单次攻击。// AttackState.cs public class AttackState : IState { private PlayerController _player; private float _attackCooldown 0.5f; private float _cooldownTimer 0f; private bool _attackTriggered false; public AttackState(PlayerController player) { _player player; } public void OnEnter() { _player.Animator.SetTrigger(Attack); _cooldownTimer _attackCooldown; _attackTriggered true; Debug.Log(Enter Attack State); // 这里可以播放音效、禁用移动输入等 } public void OnUpdate() { _cooldownTimer - Time.deltaTime; // 攻击动画播放期间锁定其他状态切换 // 一种常见做法是在动画播放完之前不允许切换状态。 // 我们可以通过Animator.GetCurrentAnimatorStateInfo来获取当前动画的播放进度。 AnimatorStateInfo stateInfo _player.Animator.GetCurrentAnimatorStateInfo(0); bool isAttacking stateInfo.IsName(Attack) stateInfo.normalizedTime 1.0f; if (!isAttacking _cooldownTimer 0) { // 攻击动画播放完毕且冷却结束根据输入返回之前的状态 if (_player.MoveInput.magnitude 0.1f) { _player.RequestStateChange(typeof(RunState)); } else { _player.RequestStateChange(typeof(IdleState)); } } } public void OnExit() { _player.Animator.ResetTrigger(Attack); Debug.Log(Exit Attack State); } }然后在IdleState和RunState的OnUpdate里添加切换到攻击状态的条件例如按下鼠标左键// 在IdleState和RunState的OnUpdate中增加 if (Input.GetMouseButtonDown(0)) // 假设鼠标左键攻击 { _player.RequestStateChange(typeof(AttackState)); return; }5.2 受击状态HurtState与状态优先级受击状态通常具有高优先级即无论当前在什么状态攻击、跳跃一旦受到伤害都应立即切换到受击状态。这需要在状态机或控制器层面进行特殊处理。我们修改PlayerController增加一个公共方法TakeDamage该方法强制切换到受击状态。// HurtState.cs public class HurtState : IState { private PlayerController _player; private float _hurtDuration 0.8f; // 受击硬直时间 private float _timer; public HurtState(PlayerController player) { _player player; } public void OnEnter() { _player.Animator.SetTrigger(Hurt); _timer _hurtDuration; Debug.Log(Enter Hurt State); // 可能的效果屏幕震动、角色短暂无敌、扣血等 } public void OnUpdate() { _timer - Time.deltaTime; if (_timer 0) { // 硬直时间结束回到闲置或移动状态 if (_player.MoveInput.magnitude 0.1f) { _player.RequestStateChange(typeof(RunState)); } else { _player.RequestStateChange(typeof(IdleState)); } } // 受击状态下通常不接受玩家输入 } public void OnExit() { _player.Animator.ResetTrigger(Hurt); Debug.Log(Exit Hurt State); } }在PlayerController中public void TakeDamage(int damage) { // 扣血逻辑... // 强制切换到受击状态无视当前状态 _stateMachine.ChangeState(_stateCache[typeof(HurtState)]); }重要技巧对于这种高优先级的状态打断直接调用状态机的ChangeState方法而不是通过RequestStateChange。因为RequestStateChange可能包含一些条件判断而受击应该是无条件的。同时要确保HurtState的OnEnter里不会触发另一个受击避免循环。6. 调试技巧与常见问题排查6.1 状态机可视化调试纯代码的状态机在调试时不够直观。我们可以通过简单的OnGUI或Debug.Log来输出当前状态但更好的方法是在编辑器里可视化。一个快速的方法是使用自定义Inspector显示当前状态。// 在PlayerController中添加 [SerializeField] private string _currentStateName; // 用于Inspector显示 void Update() { // ... 原有逻辑 _currentStateName _stateMachine?.GetCurrentStateType()?.ToString(); }这样在Unity编辑器的Inspector窗口中你就能实时看到角色当前处于哪个状态对于调试状态切换逻辑非常有用。6.2 常见问题与解决方案速查表问题现象可能原因解决方案动画不播放或闪烁1. Animator参数名拼写错误。2. Trigger未在OnExit中重置导致误触发。3. 动画状态机中的过渡条件设置不当。1. 检查代码中的参数名与Animator Controller中的是否完全一致大小写敏感。2. 确保在每个使用Trigger的状态的OnExit中调用ResetTrigger。3. 在Animator窗口检查状态之间的过渡Transition是否有Exit Time、Has Exit Time等限制尝试简化过渡条件。状态切换不生效1. 切换条件判断逻辑有误如阈值不合适。2.RequestStateChange后没有立即return导致同一帧内可能执行了其他状态切换。3. 状态机ChangeState方法中新旧状态类型比较逻辑有问题。1. 使用Debug.Log输出条件判断的中间值检查阈值。2. 在调用RequestStateChange后立即使用return跳出当前状态的OnUpdate。3. 检查ChangeState中的类型比较逻辑确保它是你期望的行为有时允许重新进入同一状态是需要的。角色移动或旋转卡顿1. 在Update中直接修改Transform.position与物理引擎冲突。2. 使用Rigidbody.MovePosition但未考虑连续调用。3. 旋转插值速度过快或过慢。1. 对于物理交互的角色坚持使用Rigidbody的相关方法MovePosition,AddForce进行移动。2. 确保移动逻辑在FixedUpdate中或与Time.deltaTime相乘。3. 调整旋转插值系数如Quaternion.Slerp的第三个参数找到一个平滑且响应迅速的值。跳跃手感“飘”或“沉”1.JumpForce力模式ForceMode选择不当。2. 重力缩放或角色质量Mass不合适。3. 空中控制系数太大导致违反物理直觉。1. 对于瞬间起跳使用ForceMode.Impulse对于持续升力如飞行使用ForceMode.Force。2. 在Project Settings - Physics中调整全局重力或微调Rigidbody的Mass和Drag。3. 减小空中移动的系数确保跳跃轨迹以抛物线为主。新状态添加后编译错误新状态类没有实现IState接口的所有方法。检查是否完整实现了OnEnter,OnUpdate,OnExit三个方法。使用IDE的“实现接口”功能可以自动生成方法骨架。6.3 性能考量与最佳实践状态对象的数量如果状态非常多超过几十个且每个状态都持有大量数据考虑使用struct实现状态如果状态无引用类型字段或采用ScriptableObject来共享配置数据。状态切换频率避免每帧都进行状态切换。可以通过设置一个最小状态持续时间例如攻击后摇期间不可切换或使用状态切换冷却来稳定状态机。与Unity ECS/DOTS的兼容性本文是基于面向对象的MonoBehaviour框架。如果你计划使用Unity的ECS架构状态模式的思想仍然适用但实现方式会变为基于数据和Job的系统状态信息可以存储在IComponentData中。日志输出在开发阶段可以在每个状态的OnEnter和OnExit中加入Debug.Log这是理解状态流最直接的方式。发布时可以通过条件编译#if UNITY_EDITOR来移除这些日志。通过以上步骤我们完成了一个基于状态模式的、结构清晰的UnityChan角色控制器。它从混乱的if-else中解放出来每个行为职责单一扩展新状态比如“下蹲”、“翻滚”、“使用技能”变得非常容易——只需要新建一个类实现IState然后在适当的地方添加切换条件即可。这种设计模式带来的代码可读性和可维护性提升在长期项目迭代中会体现巨大的价值。