Skip to content

状态机追问链

为什么不用一堆 bool?

为什么不用一堆 bool?

因为一堆 bool 很容易产生非法组合。如果状态是互斥的,比如角色只能处于 IdleMoveAttackDead 其中一种,用多个 bool 就可能出现 isAttack = true 同时 isDead = true,代码会越来越难维护。

why-not-many-bools

标准回答

bool 适合表示独立开关,比如 isGroundedhasKeyisMuted。 但如果你要表达的是“当前处于哪个状态”,就不适合用一堆 bool,而应该用 enum 或状态机。

比如角色状态:

isIdle
isMoving
isAttacking
isDead

这几个状态互斥,但多个 bool 可以同时为 true,于是就会出现不存在的状态。n 个 bool 理论上有 2^n 种组合,状态越多,非法组合越多。

更推荐写法:每行注释代码

c
using UnityEngine; // 引入 Unity 类型,用来使用 MonoBehaviour 和 Debug。
public enum CharacterState // 定义角色互斥状态。
{ // 枚举开始。
    Idle, // 角色待机状态。
    Move, // 角色移动状态。
    Attack, // 角色攻击状态。
    Dead // 角色死亡状态。
} // 枚举结束。
public class CharacterStateController : MonoBehaviour // 定义角色状态控制器。
{ // 类开始。
    private CharacterState currentState = CharacterState.Idle; // 当前状态只保存一个值,避免多个 bool 冲突。
    public void ChangeState(CharacterState nextState) // 提供统一状态切换入口。
    { // 方法开始。
        if (currentState == CharacterState.Dead) // 如果已经死亡。
        { // if 开始。
            return; // 死亡后不允许切换到移动或攻击。
        } // if 结束。
        if (currentState == nextState) // 如果目标状态和当前状态一样。
        { // if 开始。
            return; // 不重复进入同一个状态。
        } // if 结束。
        Debug.Log($"Exit {currentState}"); // 输出退出旧状态的日志,方便调试。
        currentState = nextState; // 更新当前状态。
        Debug.Log($"Enter {currentState}"); // 输出进入新状态的日志,方便调试。
    } // 方法结束。
    public void Kill() // 定义死亡接口。
    { // Kill 开始。
        currentState = CharacterState.Dead; // 直接把状态切到死亡。
        Debug.Log("Enter Dead"); // 输出死亡状态日志。
    } // Kill 结束。
} // 类结束。

Unity 里怎么用

角色逻辑状态建议用 enum 或 FSM,比如 IdleRunAttackHurtDead。 Animator 参数里可以有 bool,比如 IsGroundedIsAiming,但它们更像条件,不应该替代角色逻辑状态机。

一句话总结

TIP

bool 适合表示独立条件;互斥流程状态应该用 enum/FSM。这样能避免非法组合、状态爆炸和到处散落的 if 判断。

状态之间如何切换?

状态之间如何切换?

状态之间不要到处直接写 currentState = xxx,而应该通过统一的 ChangeState 入口切换。标准流程是:请求切换 -> 检查能否切换 -> 退出旧状态 -> 修改当前状态 -> 进入新状态

state-transition-flow

标准回答

状态切换本质上是一个受控流程。输入、AI、受击、动画事件都只是“提出切换请求”,真正能不能切换,要由状态机统一判断。

比如角色从 Move 切到 Attack,要检查当前状态能不能被攻击打断;从任何状态切到 Dead,通常死亡优先级最高;从 Attack 回到 Idle,可能要等动画事件或攻击后摇结束。

每行注释代码

c
using UnityEngine; // 引入 Unity 类型,用来使用 MonoBehaviour 和 Debug。
public enum RoleStateType // 定义角色状态类型。
{ // 枚举开始。
    Idle, // 待机状态。
    Move, // 移动状态。
    Attack, // 攻击状态。
    Hurt, // 受击状态。
    Dead // 死亡状态。
} // 枚举结束。
public class RoleStateMachine : MonoBehaviour // 定义角色状态机组件。
{ // 类开始。
    private RoleStateType currentState = RoleStateType.Idle; // 保存当前状态,初始为待机。
    public void ChangeState(RoleStateType nextState) // 统一状态切换入口。
    { // 方法开始。
        if (currentState == nextState) // 如果目标状态和当前状态一样。
        { // if 开始。
            return; // 不重复切换,避免重复 Enter。
        } // if 结束。
        if (!CanChangeTo(nextState)) // 如果当前状态不允许切到目标状态。
        { // if 开始。
            Debug.Log($"Can not change {currentState} to {nextState}"); // 输出非法切换日志,方便调试。
            return; // 拒绝这次切换。
        } // if 结束。
        OnExit(currentState); // 退出旧状态,清理旧状态逻辑。
        RoleStateType oldState = currentState; // 记录旧状态,方便打印日志或调试。
        currentState = nextState; // 修改当前状态为新状态。
        Debug.Log($"State change: {oldState} -> {currentState}"); // 输出状态切换日志。
        OnEnter(currentState); // 进入新状态,初始化新状态逻辑。
    } // 方法结束。
    private bool CanChangeTo(RoleStateType nextState) // 判断是否允许切换到目标状态。
    { // 方法开始。
        if (currentState == RoleStateType.Dead) // 如果当前已经死亡。
        { // if 开始。
            return false; // 死亡后不允许切到其他状态。
        } // if 结束。
        if (nextState == RoleStateType.Dead) // 如果目标状态是死亡。
        { // if 开始。
            return true; // 死亡通常优先级最高,允许从任何非死亡状态进入。
        } // if 结束。
        if (currentState == RoleStateType.Attack && nextState == RoleStateType.Move) // 如果攻击中想切到移动。
        { // if 开始。
            return false; // 攻击前摇或攻击中通常不允许被普通移动打断。
        } // if 结束。
        return true; // 其他情况默认允许切换。
    } // 方法结束。
    private void OnExit(RoleStateType state) // 退出旧状态时调用。
    { // 方法开始。
        if (state == RoleStateType.Attack) // 如果退出的是攻击状态。
        { // if 开始。
            Debug.Log("Stop attack timer"); // 清理攻击计时器或攻击判定窗口。
        } // if 结束。
    } // 方法结束。
    private void OnEnter(RoleStateType state) // 进入新状态时调用。
    { // 方法开始。
        if (state == RoleStateType.Attack) // 如果进入攻击状态。
        { // if 开始。
            Debug.Log("Play attack animation"); // 播放攻击动画。
        } // if 结束。
        if (state == RoleStateType.Dead) // 如果进入死亡状态。
        { // if 开始。
            Debug.Log("Disable movement and play death animation"); // 禁用移动并播放死亡动画。
        } // if 结束。
    } // 方法结束。
} // 类结束。

Unity 里怎么落地

Update 里不要直接改状态,而是收集输入和条件:

移动输入来了,请求 ChangeState(Move)

按攻击键,请求 ChangeState(Attack)

血量归零,请求 ChangeState(Dead)

攻击动画播放结束,通过动画事件请求 ChangeState(Idle)

这样状态切换逻辑都集中在状态机里,后面要加硬直、霸体、打断、死亡优先级,也不会散落在各个脚本里。

常见坑

到处直接改 currentState,导致切换规则失控。

只进入新状态,不退出旧状态,导致旧计时器、协程、判定盒还在跑。

动画状态和逻辑状态互相抢控制权,比如 Animator 已经进死亡动画,但逻辑状态还在攻击。

没有优先级,导致死亡、受击、攻击、移动互相覆盖。

一句话总结

NOTE

状态切换要走统一入口:先判断能不能切,再 OnExit 清旧状态,再改 currentState,最后 OnEnter 初始化新状态

状态切换条件放在哪里?

state-transition-condition-location

状态切换条件放在哪里?

状态切换条件不要散落在输入脚本、AI 脚本、Animator、UI 按钮里。更好的做法是:输入和 AI 只产生“切换意图”,条件事实放在 Context,切换规则集中放在状态机,状态自己的边界放在 State 里

标准回答

我会这样分:

Context 放事实,比如 isGroundedhpcooldownenemyDistancehasMoveInput

StateMachine 放全局规则,比如死亡优先级最高、硬直时不能移动、攻击前摇不能随便打断。

State 自己放状态边界,比如攻击状态的 CanExit 判断是否进入可取消帧,跳跃状态的 CanEnter 判断是否在地面。

输入、AI、动画事件只负责发请求,比如 RequestChange(Attack),不要直接改 currentState

每行注释代码

c
using UnityEngine; // 引入 Unity 类型,用来使用 MonoBehaviour 和 Debug。
public enum RoleState // 定义角色状态枚举。
{ // 枚举开始。
    Idle, // 待机状态。
    Move, // 移动状态。
    Attack, // 攻击状态。
    Hurt, // 受击状态。
    Dead // 死亡状态。
} // 枚举结束。
public class RoleContext // 定义状态机上下文,专门保存判断条件需要的事实。
{ // 类开始。
    public bool IsGrounded; // 是否在地面,用于判断能否跳跃或移动。
    public bool AttackCancelable; // 当前攻击是否进入可取消帧。
    public bool SkillCooldownReady; // 技能 CD 是否已经完成。
    public int Hp; // 当前血量,用于判断是否进入死亡状态。
} // 类结束。
public class RoleStateMachine : MonoBehaviour // 定义角色状态机。
{ // 类开始。
    private RoleState currentState = RoleState.Idle; // 保存当前状态,初始为待机。
    private readonly RoleContext context = new RoleContext(); // 保存状态判断需要的上下文数据。
    public void RequestChange(RoleState nextState) // 外部只能请求切换状态。
    { // 方法开始。
        if (!CanChangeTo(nextState)) // 统一检查是否允许切换到目标状态。
        { // if 开始。
            Debug.Log($"Reject state change: {currentState} -> {nextState}"); // 输出拒绝日志,方便排查非法切换。
            return; // 不允许切换就直接返回。
        } // if 结束。
        ExitState(currentState); // 先退出旧状态,清理旧状态逻辑。
        RoleState oldState = currentState; // 记录旧状态,方便打印日志。
        currentState = nextState; // 修改当前状态。
        EnterState(currentState); // 进入新状态,初始化新状态逻辑。
        Debug.Log($"State changed: {oldState} -> {currentState}"); // 输出成功切换日志。
    } // 方法结束。
    private bool CanChangeTo(RoleState nextState) // 集中管理状态切换条件。
    { // 方法开始。
        if (currentState == nextState) // 如果目标状态就是当前状态。
        { // if 开始。
            return false; // 不重复切换。
        } // if 结束。
        if (context.Hp <= 0) // 如果血量已经小于等于 0。
        { // if 开始。
            return nextState == RoleState.Dead; // 只允许切到死亡状态。
        } // if 结束。
        if (currentState == RoleState.Dead) // 如果当前已经死亡。
        { // if 开始。
            return false; // 死亡后不允许切到其他状态。
        } // if 结束。
        if (currentState == RoleState.Attack && !context.AttackCancelable) // 如果正在攻击且还没到可取消帧。
        { // if 开始。
            return nextState == RoleState.Hurt || nextState == RoleState.Dead; // 只允许受击或死亡这种高优先级状态打断。
        } // if 结束。
        if (nextState == RoleState.Attack && !context.SkillCooldownReady) // 如果想进入攻击但技能 CD 没好。
        { // if 开始。
            return false; // 拒绝进入攻击状态。
        } // if 结束。
        if (nextState == RoleState.Move && !context.IsGrounded) // 如果想移动但角色不在地面。
        { // if 开始。
            return false; // 拒绝进入移动状态。
        } // if 结束。
        return true; // 其他情况允许切换。
    } // 方法结束。
    private void ExitState(RoleState state) // 退出状态时调用。
    { // 方法开始。
        Debug.Log($"Exit {state}"); // 输出退出状态日志。
    } // 方法结束。
    private void EnterState(RoleState state) // 进入状态时调用。
    { // 方法开始。
        Debug.Log($"Enter {state}"); // 输出进入状态日志。
    } // 方法结束。
} // 类结束。

Unity 里怎么理解

输入系统只说:“玩家按了攻击键”。 AI 只说:“我想追击目标”。 动画事件只说:“攻击前摇结束了”。 真正能不能从 Move 切到 Attack,从 Attack 切到 Move,要由状态机判断。

Animator 更适合做表现同步,不建议让 Animator 成为逻辑状态的唯一权威。否则动画切了,但逻辑没切,或者逻辑死了动画还在攻击,就会很难排查。

一句话总结

WARNING

状态切换条件的推荐位置是:事实放 Context,规则放 StateMachine,状态自己的进出条件放 State;输入、AI、动画只发请求,不直接改状态

如何处理攻击中被打断?

attack-interrupt-handling

如何处理攻击中被打断?

攻击中被打断,本质是一次高优先级状态切换。不能只把动画切到受击,而是要先判断当前攻击阶段能不能被打断,然后清理攻击残留,最后进入受击、硬直、击退或死亡状态。

标准回答

我会把攻击拆成几个阶段:前摇、判定帧、后摇。不同阶段能否被打断不一样。

比如前摇通常比较容易被打断,判定帧要看是否允许相杀,后摇可能允许闪避取消或连招取消。如果角色有霸体,可能吃伤害但不进硬直;如果是无敌,可能连伤害都不吃。

如果允许打断,就执行 ExitAttack:关闭攻击判定盒、停止攻击计时器、取消连招窗口、停止 Root Motion 或攻击位移、清理攻击特效,然后切到 Hurt/Stun/Knockback/Dead

每行注释代码

c
using UnityEngine; // 引入 Unity 类型,用来使用 MonoBehaviour、Animator、Debug。
public enum CombatState // 定义战斗状态。
{ // 枚举开始。
    Idle, // 待机状态。
    Attack, // 攻击状态。
    Hurt, // 受击状态。
    Stun, // 硬直状态。
    Dead // 死亡状态。
} // 枚举结束。
public enum AttackPhase // 定义攻击阶段。
{ // 枚举开始。
    None, // 没有处于攻击阶段。
    Startup, // 前摇阶段。
    Active, // 判定帧阶段。
    Recovery // 后摇阶段。
} // 枚举结束。
public class CombatStateMachine : MonoBehaviour // 定义战斗状态机。
{ // 类开始。
    [SerializeField] private Animator animator; // 保存 Animator 引用,用来播放动画。
    [SerializeField] private Collider hitBox; // 保存攻击判定盒引用,用来关闭攻击判定。
    private CombatState state = CombatState.Idle; // 保存当前战斗状态。
    private AttackPhase attackPhase = AttackPhase.None; // 保存当前攻击阶段。
    private bool superArmor; // 是否霸体,霸体通常受伤但不进入硬直。
    private bool invincible; // 是否无敌,无敌通常不吃伤害也不被打断。
    public void OnHit(int damage, int interruptPower) // 收到受击时调用。
    { // 方法开始。
        if (invincible) // 如果当前处于无敌状态。
        { // if 开始。
            return; // 无敌时忽略伤害和打断。
        } // if 结束。
        if (state == CombatState.Attack) // 如果当前正在攻击。
        { // if 开始。
            if (!CanInterrupt(interruptPower)) // 判断这次受击能不能打断当前攻击。
            { // if 开始。
                ApplyDamageOnly(damage); // 不能打断时,只扣血,不切受击状态。
                return; // 结束处理。
            } // if 结束。
            CancelAttack(); // 能打断时,先清理攻击残留。
        } // if 结束。
        ApplyDamageOnly(damage); // 扣除伤害。
        ChangeState(CombatState.Hurt); // 切换到受击状态。
    } // 方法结束。
    private bool CanInterrupt(int interruptPower) // 判断当前攻击是否能被打断。
    { // 方法开始。
        if (superArmor) // 如果当前有霸体。
        { // if 开始。
            return false; // 霸体状态下不进入硬直。
        } // if 结束。
        if (attackPhase == AttackPhase.Startup) // 如果处于攻击前摇。
        { // if 开始。
            return true; // 前摇通常允许被打断。
        } // if 结束。
        if (attackPhase == AttackPhase.Active) // 如果处于判定帧。
        { // if 开始。
            return interruptPower >= 2; // 判定帧需要更高打断强度。
        } // if 结束。
        if (attackPhase == AttackPhase.Recovery) // 如果处于攻击后摇。
        { // if 开始。
            return true; // 后摇通常可以被受击或闪避取消。
        } // if 结束。
        return true; // 默认允许打断。
    } // 方法结束。
    private void CancelAttack() // 取消当前攻击。
    { // 方法开始。
        attackPhase = AttackPhase.None; // 清空攻击阶段。
        if (hitBox != null) hitBox.enabled = false; // 关闭攻击判定盒,避免被打断后继续打到敌人。
        StopAllCoroutines(); // 停止攻击状态里可能开启的计时器或协程。
        Debug.Log("Attack canceled"); // 输出日志,方便调试打断流程。
    } // 方法结束。
    private void ApplyDamageOnly(int damage) // 只处理扣血逻辑。
    { // 方法开始。
        Debug.Log($"Take damage: {damage}"); // 示例里只打印伤害,真实项目会修改血量。
    } // 方法结束。
    private void ChangeState(CombatState nextState) // 统一切换战斗状态。
    { // 方法开始。
        state = nextState; // 更新当前状态。
        if (nextState == CombatState.Hurt) // 如果进入受击状态。
        { // if 开始。
            animator.CrossFade("Hurt", 0.08f); // 平滑切换到受击动画。
        } // if 结束。
    } // 方法结束。
} // 类结束。

Unity 里要注意

攻击被打断时,最容易漏清的是攻击判定盒。动画已经进受击了,但 hitBox 还开着,就会出现“被打断后还能打中别人”的 bug。

第二个坑是协程或动画事件残留。比如攻击协程里延迟开启连招窗口,但角色已经被打断,如果不停止协程,后面还会把角色切回攻击逻辑。

第三个坑是 Animator 和逻辑状态不一致。逻辑上已经进入 Hurt,但 Animator 还在攻击状态,这时应该由逻辑状态驱动 Animator,而不是让 Animator 自己决定战斗逻辑。

面试加分说法

我会用“打断等级”和“抗打断等级”来做数据化处理。比如普通攻击打断等级是 1,重击是 2,击飞是 3;角色霸体或技能配置里有抗打断等级。只有 interruptPower > resistPower 时才真正打断。这样策划可以通过配置调整手感,而不是程序写死。

一句话总结

NOTE

攻击中被打断要走完整流程:判断阶段和优先级 -> 检查霸体/无敌 -> 允许后退出攻击 -> 关闭判定和计时器 -> 切到受击/硬直/死亡状态

如何处理状态嵌套?

一句话定义 状态嵌套一般用 HFSM 分层状态机:父状态处理公共逻辑,子状态处理具体细节。

state-nesting-hfsm

底层原理 普通 FSM 如果状态很多,很容易变成 IdleRunJumpFallAttackIdleAttackRun 这种状态爆炸。 HFSM 会把共同逻辑抽到父状态,比如:

Movement 父状态:处理移动输入、速度更新、落地检测。 Grounded 子状态:处理站立、走路、跑步。 Airborne 子状态:处理跳跃、下落。

事件通常从最深的子状态开始处理;子状态处理不了,就向父状态冒泡。进入状态时一般是 先父后子,退出时一般是 先子后父

Unity / 游戏项目里怎么用 角色移动适合嵌套:Movement -> Grounded -> Idle/MoveMovement -> Airborne -> Jump/Fall。 但移动、攻击、受击如果是独立维度,不要全部塞进一个巨大的嵌套状态机,可以拆成并行状态机:

MoveFSM 管位移。 CombatFSM 管攻击、技能、受击。 AnimFSM 或 Animator 管表现同步。

暂停、眩晕、菜单覆盖这种临时状态,可以用 状态栈Push(Stun),结束后 Pop() 回到原状态。

简单代码示例:子状态先处理,处理不了再交给父状态

c
using UnityEngine; // 引入 Unity 的 Debug 日志功能。

public abstract class HFSMState // 定义一个分层状态机的状态基类。
{ // 类定义开始。
    protected HFSMState child; // 保存当前状态下面的子状态。

    public virtual void Enter() // 定义进入状态时执行的逻辑。
    { // Enter 方法开始。
        child?.Enter(); // 如果有子状态,就继续进入子状态。
    } // Enter 方法结束。

    public virtual void Tick() // 定义每帧更新状态的逻辑。
    { // Tick 方法开始。
        child?.Tick(); // 如果有子状态,就让子状态先更新。
    } // Tick 方法结束。

    public virtual void Exit() // 定义退出状态时执行的逻辑。
    { // Exit 方法开始。
        child?.Exit(); // 退出时先退出子状态,避免子状态残留。
    } // Exit 方法结束。

    public virtual bool HandleEvent(string evt) // 定义事件处理方法,返回是否处理成功。
    { // HandleEvent 方法开始。
        if (child != null && child.HandleEvent(evt)) // 优先让最里面的子状态处理事件。
        { // if 代码块开始。
            return true; // 子状态已经处理,父状态不用重复处理。
        } // if 代码块结束。

        return false; // 当前状态没有处理,交给更上层状态处理。
    } // HandleEvent 方法结束。
} // HFSMState 类结束。

public class GroundedState : HFSMState // 定义一个地面状态,代表角色在地上。
{ // GroundedState 类开始。
    public override bool HandleEvent(string evt) // 重写事件处理逻辑。
    { // 方法开始。
        if (base.HandleEvent(evt)) // 先让子状态处理,比如 Idle 或 Move。
        { // if 代码块开始。
            return true; // 子状态已经处理,直接返回。
        } // if 代码块结束。

        if (evt == "Jump") // 如果子状态没处理,并且事件是跳跃。
        { // if 代码块开始。
            Debug.Log("Grounded -> Airborne"); // 打印从地面状态切到空中状态。
            return true; // 表示跳跃事件已经被地面父状态处理。
        } // if 代码块结束。

        return false; // 其他事件不处理,继续向上冒泡。
    } // HandleEvent 方法结束。
} // GroundedState 类结束。

NOTE

面试加分说法 “状态嵌套不是为了把状态机写得更复杂,而是为了复用父状态的公共逻辑。共享逻辑用父状态,具体行为用子状态;独立维度用并行状态机;临时覆盖用状态栈。”

状态爆炸怎么办?

state-explosion-solutions

标准回答 状态爆炸就是状态数量因为“组合维度”太多而急剧膨胀。解决思路不是继续加状态,而是 拆维度

把有父子关系的状态做成 分层状态机 HFSM。 把互相独立的维度拆成 并行状态机。 把临时覆盖状态做成 状态栈。 最后用 优先级 / 仲裁规则 合并结果。

比如不要写:

IdleAttackRunAttackJumpAttackRunHurtRunStunJumpStun

而是拆成:

MoveFSM:Idle / Run / Jump CombatFSM:Normal / Attack / Hurt BuffSystem:Normal / Slow / Stun

底层原理 状态爆炸的根源是多个维度被强行塞进同一个状态名里。 如果移动有 5 种状态,战斗有 6 种状态,Buff 有 4 种状态,那么组合状态可能变成:

5 × 6 × 4 = 120 个状态。

但真实项目里,这三个维度很多时候不是同一个维度。移动状态管位移,战斗状态管攻击流程,Buff 状态管限制和属性修改。它们应该各自维护,再把结果写到同一个角色上下文里。

Unity 工程实践 角色控制里我一般会这样拆:

MoveFSM 只管能不能移动、速度、跳跃、落地。 CombatFSM 只管攻击、技能、受击、打断。 BuffSystem 只管眩晕、减速、沉默、无敌。 Animator 根据最终结果播放动画,而不是让动画状态反过来绑死逻辑状态。

如果一个状态是“临时压住原状态”,比如暂停、眩晕、打开菜单,可以用状态栈。 如果两个状态是“同时存在”,比如跑步时攻击,就不要强行做成 RunAttackState,而应该让移动和攻击两个状态机并行。

代码示例:用多个状态模块避免组合状态爆炸

c
using UnityEngine; // 引入 Unity 的基础类型和 Debug 功能。
public sealed class CharacterStateContext // 定义角色状态上下文,用来合并多个状态模块的结果。
{ // CharacterStateContext 类开始。
    public bool CanMove = true; // 表示角色当前是否允许移动。
    public bool CanAttack = true; // 表示角色当前是否允许攻击。
    public float SpeedMultiplier = 1f; // 表示角色当前速度倍率。
    public string AnimationName = "Idle"; // 表示最终要播放的动画名。
    public void ResetFrame() // 每帧开始前重置临时结果。
    { // ResetFrame 方法开始。
        CanMove = true; // 默认允许移动。
        CanAttack = true; // 默认允许攻击。
        SpeedMultiplier = 1f; // 默认速度倍率为 1。
        AnimationName = "Idle"; // 默认动画为 Idle。
    } // ResetFrame 方法结束。
} // CharacterStateContext 类结束。
public interface IStateModule // 定义状态模块接口。
{ // IStateModule 接口开始。
    void Tick(CharacterStateContext ctx); // 每帧更新状态并写入上下文。
} // IStateModule 接口结束。
public sealed class MoveStateModule : IStateModule // 定义移动状态模块。
{ // MoveStateModule 类开始。
    public void Tick(CharacterStateContext ctx) // 更新移动维度状态。
    { // Tick 方法开始。
        if (!ctx.CanMove) // 如果其他模块禁止移动。
        { // if 代码块开始。
            return; // 直接不处理移动。
        } // if 代码块结束。
        ctx.AnimationName = "Run"; // 这里示例写入移动动画结果。
    } // Tick 方法结束。
} // MoveStateModule 类结束。
public sealed class CombatStateModule : IStateModule // 定义战斗状态模块。
{ // CombatStateModule 类开始。
    public bool IsAttacking; // 表示当前是否正在攻击。
    public void Tick(CharacterStateContext ctx) // 更新战斗维度状态。
    { // Tick 方法开始。
        if (IsAttacking) // 如果当前正在攻击。
        { // if 代码块开始。
            ctx.CanAttack = false; // 攻击中不能再次发起攻击。
            ctx.AnimationName = "Attack"; // 攻击状态优先驱动攻击动画。
        } // if 代码块结束。
    } // Tick 方法结束。
} // CombatStateModule 类结束。
public sealed class BuffStateModule : IStateModule // 定义 Buff 状态模块。
{ // BuffStateModule 类开始。
    public bool IsStunned; // 表示角色是否被眩晕。
    public void Tick(CharacterStateContext ctx) // 更新 Buff 维度状态。
    { // Tick 方法开始。
        if (IsStunned) // 如果角色处于眩晕。
        { // if 代码块开始。
            ctx.CanMove = false; // 眩晕禁止移动。
            ctx.CanAttack = false; // 眩晕禁止攻击。
            ctx.AnimationName = "Stun"; // 眩晕动画优先级最高。
        } // if 代码块结束。
    } // Tick 方法结束。
} // BuffStateModule 类结束。
public sealed class CharacterStateDriver : MonoBehaviour // 定义角色状态驱动器。
{ // CharacterStateDriver 类开始。
    private readonly CharacterStateContext ctx = new CharacterStateContext(); // 创建角色状态上下文。
    private readonly MoveStateModule move = new MoveStateModule(); // 创建移动模块。
    private readonly CombatStateModule combat = new CombatStateModule(); // 创建战斗模块。
    private readonly BuffStateModule buff = new BuffStateModule(); // 创建 Buff 模块。
    private void Update() // Unity 每帧调用 Update。
    { // Update 方法开始。
        ctx.ResetFrame(); // 每帧先重置上下文。
        move.Tick(ctx); // 更新移动维度。
        combat.Tick(ctx); // 更新战斗维度。
        buff.Tick(ctx); // 更新 Buff 维度。
        Debug.Log(ctx.AnimationName); // 输出最终仲裁后的动画名。
    } // Update 方法结束。
} // CharacterStateDriver 类结束。

常见坑 第一,别把所有组合都写成状态类,否则后期加一个 Buff 就会改一大片。 第二,别滥用嵌套,层级太深会让调试困难。 第三,并行状态机一定要有统一仲裁规则,比如 Stun > Hurt > Attack > Move。 第四,动画状态和逻辑状态不要完全绑死,动画是表现层,逻辑状态才是规则层。

TIP

面试加分说法 “我处理状态爆炸时会先判断哪些是同一维度、哪些是独立维度。同一维度用 HFSM 抽父状态,独立维度用并行状态机,临时覆盖用状态栈,最后用优先级规则把多个模块的结果合并到角色上下文里。”

动画状态和逻辑状态谁驱动谁?

animation-vs-logic-state-driving

标准回答 一般是 逻辑状态驱动动画状态,动画状态不要反过来驱动核心逻辑。

更准确地说:

逻辑状态是“规则真相”:角色能不能移动、能不能攻击、是否死亡、技能 CD 是否结束。 动画状态是“表现结果”:播放 Idle、Run、Attack、Hurt、Death。 动画事件可以反向通知“时机”,比如攻击第 12 帧开判定,但它不能决定“这次攻击是否成立”。

底层原理 如果让 Animator 当前状态决定逻辑,会有很多坑:

Animator 有过渡时间,角色可能已经从 Attack 过渡到 Idle,但动画还在混合。 Animator 有 Layer 和 Blend,当前播放的可能不是唯一状态。 动画可以被打断,Animation Event 可能不触发。 美术改动画长度、过渡条件,可能直接影响战斗规则。 网络游戏里,服务端通常不关心客户端动画,只关心逻辑状态和输入。

所以更稳的做法是: 输入进入逻辑状态机,逻辑状态机更新角色规则,然后设置 Animator 参数。动画事件只作为表现时机回调,回调时还要检查逻辑状态是否合法。

Unity 实践 例如攻击流程应该是:

玩家按攻击键。 逻辑状态从 Idle 切到 Attack。 代码调用 animator.SetTrigger("Attack")。 Animator 播放攻击动画。 动画事件 OnAttackHitFrame 到达命中帧。 代码检查当前逻辑状态仍然是 Attack。 检查通过后,才开启碰撞盒或计算伤害。

这样即使动画被打断、切层、CrossFade,也不会让逻辑失控。

代码示例:逻辑驱动动画,动画事件只通知时机

c
using UnityEngine; // 引入 Unity 引擎基础 API。

public enum LogicState // 定义角色的逻辑状态枚举。
{ // 枚举开始。
    Idle, // 空闲状态,表示角色没有执行特殊动作。
    Run, // 跑步状态,表示角色正在移动。
    Attack, // 攻击状态,表示角色正在执行攻击逻辑。
    Hurt, // 受击状态,表示角色正在受击硬直。
    Dead // 死亡状态,表示角色已经死亡。
} // 枚举结束。

public sealed class PlayerAnimationBridge : MonoBehaviour // 定义逻辑和动画之间的桥接脚本。
{ // 类开始。
    [SerializeField] private Animator animator; // 在 Inspector 中绑定 Animator 组件。
    private LogicState state = LogicState.Idle; // 保存当前逻辑状态,默认是 Idle。
    private static readonly int SpeedHash = Animator.StringToHash("Speed"); // 缓存 Speed 参数哈希,避免每帧字符串查找。
    private static readonly int AttackHash = Animator.StringToHash("Attack"); // 缓存 Attack 触发器哈希。
    private static readonly int HurtHash = Animator.StringToHash("Hurt"); // 缓存 Hurt 触发器哈希。
    private static readonly int DeadHash = Animator.StringToHash("Dead"); // 缓存 Dead 布尔参数哈希。

    private void Update() // Unity 每帧调用 Update。
    { // Update 方法开始。
        float input = Input.GetAxisRaw("Horizontal"); // 读取水平输入作为示例。
        if (state != LogicState.Attack && state != LogicState.Hurt && state != LogicState.Dead) // 只有非攻击、非受击、非死亡时才允许移动逻辑改状态。
        { // if 代码块开始。
            LogicState nextState = Mathf.Abs(input) > 0.01f ? LogicState.Run : LogicState.Idle; // 根据输入决定是 Run 还是 Idle。
            SetLogicState(nextState); // 通过逻辑状态接口切换状态。
        } // if 代码块结束。
        animator.SetFloat(SpeedHash, Mathf.Abs(input)); // 逻辑结果驱动 Animator 的 Speed 参数。
    } // Update 方法结束。

    public void TryAttack() // 外部调用这个方法尝试攻击。
    { // TryAttack 方法开始。
        if (state == LogicState.Dead) // 死亡状态不能攻击。
        { // if 代码块开始。
            return; // 直接返回,不播放攻击动画。
        } // if 代码块结束。
        if (state == LogicState.Hurt) // 受击硬直状态不能攻击。
        { // if 代码块开始。
            return; // 直接返回,避免受击中出招。
        } // if 代码块结束。
        SetLogicState(LogicState.Attack); // 逻辑先进入攻击状态。
        animator.SetTrigger(AttackHash); // 再通知 Animator 播放攻击动画。
    } // TryAttack 方法结束。

    public void OnAttackHitFrame() // 这个方法由 Animation Event 在命中帧调用。
    { // OnAttackHitFrame 方法开始。
        if (state != LogicState.Attack) // 如果逻辑状态已经不是攻击,说明动画事件已经失效。
        { // if 代码块开始。
            return; // 不开判定,避免被打断后仍然造成伤害。
        } // if 代码块结束。
        Debug.Log("打开攻击判定,并由逻辑层计算伤害"); // 命中帧只作为时机通知,真正伤害仍由逻辑计算。
    } // OnAttackHitFrame 方法结束。

    public void OnAttackAnimationEnd() // 这个方法可以由动画结束事件调用。
    { // OnAttackAnimationEnd 方法开始。
        if (state == LogicState.Attack) // 只有仍处于攻击逻辑时才允许回到 Idle。
        { // if 代码块开始。
            SetLogicState(LogicState.Idle); // 攻击结束后回到空闲逻辑状态。
        } // if 代码块结束。
    } // OnAttackAnimationEnd 方法结束。

    public void TakeHit() // 外部调用这个方法让角色受击。
    { // TakeHit 方法开始。
        if (state == LogicState.Dead) // 死亡后不再进入受击。
        { // if 代码块开始。
            return; // 直接返回。
        } // if 代码块结束。
        SetLogicState(LogicState.Hurt); // 逻辑先进入受击状态。
        animator.SetTrigger(HurtHash); // 再通知 Animator 播放受击动画。
    } // TakeHit 方法结束。

    private void SetLogicState(LogicState nextState) // 统一切换逻辑状态的方法。
    { // SetLogicState 方法开始。
        if (state == nextState) // 如果目标状态和当前状态一样。
        { // if 代码块开始。
            return; // 不重复切换,避免重复触发动画。
        } // if 代码块结束。
        state = nextState; // 更新逻辑状态。
        animator.SetBool(DeadHash, state == LogicState.Dead); // 用逻辑状态同步死亡动画参数。
    } // SetLogicState 方法结束。
} // 类结束。

特殊情况 Root Motion 看起来像“动画驱动移动”,但本质上仍然应该由逻辑决定“能不能使用 Root Motion”。 动画事件看起来像“动画驱动攻击”,但它只应该通知“命中帧到了”,不能决定“是否能攻击、伤害是多少、目标是否合法”。

IMPORTANT

面试加分说法 “我的理解是逻辑状态是权威,动画状态是表现。逻辑先决定角色规则,再设置 Animator 参数;动画事件可以反馈表现时机,但必须经过逻辑状态校验。这样可以避免动画过渡、打断、混合层或者美术改动画导致战斗规则出问题。”

联机时状态机如何同步?

network-state-machine-sync

标准回答 联机时状态机一般同步的是 逻辑状态,不是 Animator 当前动画状态。 常见做法是:客户端上传输入,服务器运行权威状态机,服务器广播状态快照,客户端根据快照校正表现

一句话说就是:

客户端:负责输入、预测、表现。 服务器:负责校验、状态切换、最终结果。 Animator:只根据逻辑状态本地播放动画,不作为同步权威。

底层原理 如果客户端直接同步“我现在是 Attack 状态”,那就很危险。因为玩家可以篡改客户端,让自己无 CD 攻击、无限霸体、跳过受击。所以服务端要根据输入和规则自己判断:

攻击键是否按下。 技能 CD 是否好了。 距离是否够。 目标是否合法。 当前状态能不能切到攻击。 有没有被眩晕、沉默、击飞。

服务端确认后,再广播权威状态,比如:

entityId:哪个角色。 stateId:当前逻辑状态。 stateStartTick:状态开始于哪一帧。 stateVersion:状态版本号。 lastInputSeq:服务器处理到哪个输入。 position / velocity:位置和速度。 skillId / targetId:技能和目标。

Unity 实践 本地玩家为了手感,可以先预测进入攻击状态并播放动画。 等服务器快照回来,如果服务器也确认攻击,就继续播放。 如果服务器拒绝,比如 CD 没好或距离不够,就校正回服务器状态。

远程玩家一般不做预测,而是接收服务器快照,然后插值播放状态和位置。

注意: 不要同步 Animator 当前状态。 不要用 Animation Event 当网络权威。 不要让客户端直接告诉服务器“我打中了”。 客户端最多说“我在第 N 帧按了攻击”,服务器判断是否命中。

代码示例:客户端预测 + 服务器快照校正

c
using System.Collections.Generic; // 引入 List,用来保存还没被服务器确认的输入。
using UnityEngine; // 引入 Unity 的 MonoBehaviour、Vector3 和 Debug。

public enum NetLogicState // 定义网络同步用的逻辑状态。
{ // 枚举开始。
    Idle, // 空闲状态。
    Move, // 移动状态。
    Attack, // 攻击状态。
    Hurt, // 受击状态。
    Dead // 死亡状态。
} // 枚举结束。

public struct InputCommand // 定义客户端上传给服务器的输入命令。
{ // 结构体开始。
    public int Seq; // 输入序号,用来区分每一次输入。
    public int Tick; // 输入发生在哪个逻辑帧。
    public float MoveX; // 水平移动输入。
    public bool Attack; // 是否按下攻击键。
} // 结构体结束。

public struct StateSnapshot // 定义服务器下发的权威状态快照。
{ // 结构体开始。
    public int Tick; // 服务器快照对应的逻辑帧。
    public int LastProcessedSeq; // 服务器已经处理到的客户端输入序号。
    public NetLogicState State; // 服务器确认的逻辑状态。
    public Vector3 Position; // 服务器确认的位置。
    public float StateTime; // 当前状态已经持续的时间。
} // 结构体结束。

public sealed class NetworkFsmClient : MonoBehaviour // 定义客户端状态机同步示例。
{ // 类开始。
    private readonly List<InputCommand> pendingInputs = new List<InputCommand>(); // 保存客户端预测过但服务器还没确认的输入。
    private NetLogicState state = NetLogicState.Idle; // 保存客户端当前逻辑状态。
    private Vector3 position; // 保存客户端当前预测位置。
    private int nextSeq = 1; // 保存下一个输入序号。

    private void Update() // Unity 每帧调用 Update。
    { // Update 方法开始。
        InputCommand cmd = BuildInputCommand(); // 根据当前按键生成输入命令。
        pendingInputs.Add(cmd); // 把输入保存起来,等待服务器确认。
        SendInputToServer(cmd); // 把输入发送给服务器。
        SimulateLocally(cmd); // 客户端先本地预测,提升操作手感。
    } // Update 方法结束。

    private InputCommand BuildInputCommand() // 构造输入命令。
    { // 方法开始。
        InputCommand cmd = new InputCommand(); // 创建一个新的输入命令。
        cmd.Seq = nextSeq++; // 分配递增输入序号。
        cmd.Tick = Time.frameCount; // 示例中用当前帧号当逻辑帧。
        cmd.MoveX = Input.GetAxisRaw("Horizontal"); // 读取水平输入。
        cmd.Attack = Input.GetKeyDown(KeyCode.J); // 读取攻击键输入。
        return cmd; // 返回输入命令。
    } // 方法结束。

    private void SimulateLocally(InputCommand cmd) // 客户端本地预测状态机。
    { // 方法开始。
        if (cmd.Attack && state != NetLogicState.Hurt) // 如果按下攻击,并且当前没有受击。
        { // if 开始。
            state = NetLogicState.Attack; // 客户端先预测进入攻击状态。
        } // if 结束。
        else if (Mathf.Abs(cmd.MoveX) > 0.01f) // 如果有移动输入。
        { // else if 开始。
            state = NetLogicState.Move; // 客户端预测进入移动状态。
            position += Vector3.right * cmd.MoveX * Time.deltaTime * 5f; // 客户端预测移动位置。
        } // else if 结束。
        else // 如果没有移动也没有攻击。
        { // else 开始。
            state = NetLogicState.Idle; // 客户端预测为空闲状态。
        } // else 结束。
    } // 方法结束。

    public void OnServerSnapshot(StateSnapshot snapshot) // 收到服务器权威快照时调用。
    { // 方法开始。
        state = snapshot.State; // 用服务器状态覆盖本地状态。
        position = snapshot.Position; // 用服务器位置覆盖本地位置。
        pendingInputs.RemoveAll(input => input.Seq <= snapshot.LastProcessedSeq); // 删除服务器已经确认的输入。
        foreach (InputCommand input in pendingInputs) // 遍历还没被服务器确认的输入。
        { // foreach 开始。
            SimulateLocally(input); // 从服务器状态重新预测未确认输入。
        } // foreach 结束。
    } // 方法结束。

    private void SendInputToServer(InputCommand cmd) // 模拟发送输入给服务器。
    { // 方法开始。
        Debug.Log($"Send input seq = {cmd.Seq}"); // 示例打印输入序号。
    } // 方法结束。
} // 类结束。

NOTE

面试加分说法 “联机状态机同步我不会同步 Animator,而是同步逻辑状态和状态版本。客户端上传输入并做预测,服务器校验后运行权威状态机,再下发快照。客户端收到快照后用 lastProcessedSeq 丢弃已确认输入,重放未确认输入做校正。这样既保证手感,也能保证服务器权威和反作弊边界。”

如何调试状态机?

debug-state-machine

标准回答 调试状态机的核心是:让状态机可观测、可复现、可断言

也就是说,不只是看“现在在哪个状态”,还要能看到:

从哪个状态切过来。 为什么切过来。 哪个输入触发的。 守卫条件有没有通过。 状态持续了多久。 有没有非法切换。 Bug 能不能用输入回放复现。

底层原理 状态机 Bug 难查,通常不是因为代码多,而是因为缺少上下文。比如角色为什么从 Attack 切到 Idle?是动画结束?被打断?CD 不够?输入被覆盖? 所以我会给状态机加一层调试信息:fromStatetoStatereasontickdurationownerId。这样出问题时,不是猜,而是看完整轨迹。

Unity 工程实践 项目里可以这样做:

在 Inspector 或运行时 UI 上显示当前状态。 在 Scene 视图用 Gizmos 标出 AI 当前状态。 每次状态切换写入环形日志,避免大量字符串日志影响性能。 非法切换用 Debug.Assert 或自定义断言直接报错。 网络或战斗偶现问题,记录输入序列和随机种子,支持回放。 复杂 AI 或战斗状态机,可以做一个 EditorWindow,把当前节点高亮。

代码示例:记录状态切换历史

c
using System.Collections.Generic; // 引入 List,用来保存状态切换历史。
using UnityEngine; // 引入 Unity 的 Debug 和 MonoBehaviour。

public sealed class StateTrace // 定义一条状态切换记录。
{ // StateTrace 类开始。
    public int Frame; // 记录切换发生在哪一帧。
    public string From; // 记录旧状态名。
    public string To; // 记录新状态名。
    public string Reason; // 记录切换原因。
    public float Time; // 记录切换发生时的游戏时间。
} // StateTrace 类结束。

public sealed class StateMachineDebugger : MonoBehaviour // 定义状态机调试器。
{ // 类开始。
    private readonly List<StateTrace> traces = new List<StateTrace>(); // 保存最近的状态切换记录。
    private const int MaxTraceCount = 20; // 最多保留 20 条记录,避免日志无限增长。
    private string currentState = "Idle"; // 当前状态名,默认是 Idle。

    public void ChangeState(string nextState, string reason) // 统一切换状态的方法。
    { // ChangeState 方法开始。
        if (currentState == nextState) // 如果目标状态和当前状态一样。
        { // if 代码块开始。
            return; // 不重复切换,避免产生无意义日志。
        } // if 代码块结束。

        StateTrace trace = new StateTrace(); // 创建一条新的切换记录。
        trace.Frame = Time.frameCount; // 记录当前帧号。
        trace.From = currentState; // 记录切换前状态。
        trace.To = nextState; // 记录切换后状态。
        trace.Reason = reason; // 记录切换原因。
        trace.Time = Time.time; // 记录切换时间。
        traces.Add(trace); // 把记录加入历史列表。

        if (traces.Count > MaxTraceCount) // 如果历史记录超过最大数量。
        { // if 代码块开始。
            traces.RemoveAt(0); // 删除最旧的一条记录。
        } // if 代码块结束。

        Debug.Log($"FSM: {trace.From} -> {trace.To}, reason = {trace.Reason}"); // 打印关键切换日志。
        currentState = nextState; // 更新当前状态。
    } // ChangeState 方法结束。

    private void OnGUI() // Unity 绘制简单调试 UI。
    { // OnGUI 方法开始。
        GUILayout.Label($"Current State: {currentState}"); // 显示当前状态。
        foreach (StateTrace trace in traces) // 遍历最近的切换记录。
        { // foreach 代码块开始。
            GUILayout.Label($"{trace.Frame}: {trace.From} -> {trace.To}, {trace.Reason}"); // 显示一条切换记录。
        } // foreach 代码块结束。
    } // OnGUI 方法结束。
} // 类结束。

常见坑 第一,只打印“进入 Attack”,但不打印“为什么进入 Attack”,等于没调试。 第二,状态切换散落在各处,导致很难统一加断点。最好所有切换都走 ChangeState。 第三,线上或移动端不要狂打字符串日志,可以用环形缓冲区,只在异常时导出。 第四,动画状态机和逻辑状态机要分别看,别把 Animator 当前状态当成逻辑真相。

NOTE

面试加分说法 “我调试状态机不会只看当前状态,而是会记录状态切换轨迹,包括 from、to、reason、tick、duration 和输入事件。复杂问题我会做运行时 Overlay 或 EditorWindow,高亮当前节点;偶现问题会记录输入序列和随机种子做回放。这样状态机问题就能从‘猜原因’变成‘看证据’。”

行为树和状态机怎么选?

标准回答 行为树和状态机不是谁替代谁,而是看问题类型:

状态机 FSM 适合“流程稳定、状态互斥、切换明确”的逻辑。 比如玩家移动、技能释放、攻击前摇/命中/后摇、UI 页面切换。

行为树 BT 适合“决策复杂、条件很多、优先级明显”的逻辑。 比如怪物 AI:血量低就逃跑,距离近就攻击,看不到玩家就巡逻。

项目里常见组合是:行为树负责决策,状态机负责执行

behavior-tree-vs-fsm-choice

底层原理 FSM 的核心是:当前状态 + 切换条件。 它很像流程图:Idle -> Chase -> Attack -> BackToIdle。 优点是简单、确定、好断点;缺点是 AI 行为多了以后容易状态爆炸。

BT 的核心是:从根节点开始 Tick,节点返回 SuccessFailureRunning。 它常用 Selector 表示优先选择,Sequence 表示顺序执行。 优点是扩展 AI 行为方便,优先级清楚;缺点是树太深会难调试,Tick 太频繁也有性能开销。

怎么选 玩家核心操作:优先 FSM,因为玩家动作要求确定、响应清晰。 技能释放流程:优先 FSM,因为前摇、命中、后摇、打断是严格流程。 怪物 AI 决策:优先 BT,因为条件分支多,优先级会经常调整。 复杂 Boss:BT + FSM 混合,BT 选择战术,FSM 执行具体动作。 大量怪物:BT 需要降频、分帧,不能所有怪每帧完整 Tick。

Unity 工程实践 比如怪物 AI 可以这样设计:

行为树判断:血量低不低、目标近不近、技能 CD 好没好。 行为树输出意图:逃跑追击攻击巡逻。 状态机执行动作:进入 ChaseStateAttackState。 Animator 只负责播放表现,不决定 AI 逻辑。

代码示例:BT 决策,FSM 执行

c
using UnityEngine; // 引入 Unity 基础 API。

public enum AIIntent // 定义行为树输出的意图。
{ // 枚举开始。
    None, // 没有明确意图。
    Patrol, // 巡逻意图。
    Chase, // 追击意图。
    Attack, // 攻击意图。
    Flee // 逃跑意图。
} // 枚举结束。

public enum AIState // 定义状态机真正执行的状态。
{ // 枚举开始。
    Patrol, // 巡逻状态。
    Chase, // 追击状态。
    Attack, // 攻击状态。
    Flee // 逃跑状态。
} // 枚举结束。

public sealed class MonsterAI : MonoBehaviour // 定义怪物 AI 控制器。
{ // 类开始。
    [SerializeField] private float hpPercent = 1f; // 当前血量百分比。
    [SerializeField] private float distanceToTarget = 10f; // 怪物到目标的距离。
    [SerializeField] private float attackRange = 2f; // 攻击范围。
    [SerializeField] private bool skillReady = true; // 技能是否冷却完成。
    private AIState currentState = AIState.Patrol; // 当前执行状态,默认巡逻。

    private void Update() // Unity 每帧调用 Update。
    { // Update 方法开始。
        AIIntent intent = DecideByBehaviourTree(); // 行为树负责选择意图。
        ExecuteByStateMachine(intent); // 状态机负责执行意图。
    } // Update 方法结束。

    private AIIntent DecideByBehaviourTree() // 用简单代码模拟行为树决策。
    { // 方法开始。
        if (hpPercent < 0.2f) // 如果血量低于 20%。
        { // if 开始。
            return AIIntent.Flee; // 优先逃跑。
        } // if 结束。
        if (distanceToTarget <= attackRange && skillReady) // 如果目标在攻击范围内并且技能好了。
        { // if 开始。
            return AIIntent.Attack; // 选择攻击。
        } // if 结束。
        if (distanceToTarget < 8f) // 如果目标进入感知范围。
        { // if 开始。
            return AIIntent.Chase; // 选择追击。
        } // if 结束。
        return AIIntent.Patrol; // 默认巡逻。
    } // 方法结束。

    private void ExecuteByStateMachine(AIIntent intent) // 用状态机执行行为树给出的意图。
    { // 方法开始。
        AIState nextState = ConvertIntentToState(intent); // 把意图转换成状态。
        if (currentState == nextState) // 如果状态没有变化。
        { // if 开始。
            return; // 不重复进入状态。
        } // if 结束。
        Debug.Log($"{currentState} -> {nextState}"); // 打印状态切换日志。
        currentState = nextState; // 更新当前状态。
    } // 方法结束。

    private AIState ConvertIntentToState(AIIntent intent) // 把行为树意图映射成状态机状态。
    { // 方法开始。
        if (intent == AIIntent.Attack) // 如果意图是攻击。
        { // if 开始。
            return AIState.Attack; // 返回攻击状态。
        } // if 结束。
        if (intent == AIIntent.Chase) // 如果意图是追击。
        { // if 开始。
            return AIState.Chase; // 返回追击状态。
        } // if 结束。
        if (intent == AIIntent.Flee) // 如果意图是逃跑。
        { // if 开始。
            return AIState.Flee; // 返回逃跑状态。
        } // if 结束。
        return AIState.Patrol; // 其他情况返回巡逻状态。
    } // 方法结束。
} // 类结束。

常见坑 不要用 FSM 硬写所有 AI 决策,否则状态会爆炸。 不要用 BT 直接控制动画细节,否则动作流程会乱。 不要让大量怪物每帧完整 Tick 行为树,可以分帧、降频、按距离激活。 不要把黑板数据随便写,最好区分感知数据、决策结果、执行状态。

NOTE

面试加分说法 “我一般不会把行为树和状态机对立起来。FSM 适合稳定流程,BT 适合复杂决策。比如怪物 AI 我会用 BT 根据黑板数据选择意图,再交给 FSM 执行追击、攻击、受击这些有生命周期的动作。这样决策可扩展,执行也稳定。”

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0