:使用 StateMachine 管理互斥与共存的角色状态)
游戏中的角色通常不只有一个简单的“当前状态”。角色可能正在行走同时拥有加速 Buff也可能正在攻击因此暂时不能移动进入死亡状态后还需要自动移除移动、攻击和受击等状态。MyFramework 使用StateManager和COMCharacterStateMachine统一处理状态生命周期、持续时间、同类状态冲突以及状态组之间的互斥关系。项目地址https://github.com/ZHOURUIH/MyFramework一、为什么不能只用一个状态枚举最简单的角色状态可能写成public enum CHARACTER_STATE { STAND, WALK, ATTACK, HIT, DIE, }角色只保存一个当前状态protected CHARACTER_STATE mState;这种写法只能表示站立 或者行走 或者攻击但实际游戏中角色状态往往需要同时存在行走 移动速度提升 攻击 伤害提升 站立 中毒 无敌 技能释放还有一些状态不能共存站立和行走不能同时存在 行走和攻击不能同时存在 死亡后不能继续移动和攻击 技能前摇、释放和后摇只能保留一个因此角色状态更适合使用多个独立对象表示再由状态机统一管理它们之间的关系。二、状态系统的基本结构MyFramework 中主要包含四部分StateManager 保存全局状态类型、参数类型和状态组配置 COMCharacterStateMachine 每个角色自己的状态机保存角色当前拥有的状态 CharacterState 一个具体的角色状态对象 StateParam 进入状态时使用的参数可以理解为StateManager 负责定义“状态之间是什么关系” COMCharacterStateMachine 负责记录“当前角色拥有哪些状态” CharacterState 负责执行“进入、更新和离开状态时做什么”Character已经封装了常用接口character.addStateStateActionWalk(); character.hasStateStateActionWalk(); character.getFirstStateStateActionWalk();第一次调用getStateMachine()时如果角色还没有状态机组件框架会自动创建。三、创建一个基础状态先创建站立状态public class StateActionStand : CharacterState { public override void enter() { base.enter(); // 播放站立动作 } public override void leave( bool isBreak, bool willDestroy, string param) { base.leave(isBreak, willDestroy, param); // 离开站立状态时的清理 } }进入站立状态character.addStateStateActionStand();查询角色是否处于站立状态if (character.hasStateStateActionStand()) { // 当前角色拥有站立状态 }移除状态character .getStateMachine() .removeFirstStateStateActionStand(false);isBreak表示这个状态是否因为加入了互斥状态而被打断。例如角色从站立切换到行走时站立状态通常是被新状态打断的。四、使用 StateParam 传递进入参数攻击状态可能需要动作编号、播放速度和持续时间。先定义参数public class StateActionAttackParam : StateParam { public int mAnimation; public float mAnimationSpeed; public override void resetProperty() { base.resetProperty(); mAnimation 0; mAnimationSpeed 0.0f; } }状态继承CharacterStateTTpublic class StateActionAttack : CharacterStateTStateActionAttackParam { protected int mAnimation; protected float mAnimationSpeed; public StateActionAttack() { mMutexType STATE_MUTEX.REMOVE_OLD; } public override void enter() { base.enter(); mAnimation mCustomParam.mAnimation; mAnimationSpeed mCustomParam.mAnimationSpeed; // 播放攻击动作 } public override void update(float elapsedTime) { // 更新攻击状态 // 父类会处理状态持续时间通常放在最后调用 base.update(elapsedTime); } public override void resetProperty() { base.resetProperty(); mAnimation 0; mAnimationSpeed 0.0f; } }添加攻击状态using var a new ClassScopeStateActionAttackParam(out var param); param.mAnimation 1001; param.mAnimationSpeed 1.0f; param.mBuffTime 0.8f; character.addStateStateActionAttack(param);这里的mBuffTime是状态持续时间小于 0 不限制持续时间 等于 0 只执行 enter随后立即离开 大于 0 到达指定时间后自动移除五、StateParam 只能在 enter 中使用状态机调用顺序大致为创建状态 ↓ 设置 StateParam ↓ 执行 enter() ↓ 清除状态对 StateParam 的引用 ↓ 调用方回收 StateParam因此mCustomParam只能在enter()中读取。需要在后续update()中继续使用的数据必须复制到状态自己的字段public override void enter() { base.enter(); mAnimation mCustomParam.mAnimation; mAnimationSpeed mCustomParam.mAnimationSpeed; }不能保存参数对象// 错误参数对象离开 ClassScope 后会被回收 protected StateActionAttackParam mAttackParam; public override void enter() { mAttackParam mCustomParam; }StateParam只是一次进入状态时的临时参数不属于状态的长期数据。六、同类型状态如何处理STATE_MUTEX控制的是再次添加同一种状态时如何处理。例如当前已经存在一个StateActionAttack此时再次添加攻击状态。常用类型包括NO_NEW 已有相同状态时不允许添加新状态 REMOVE_OLD 添加新状态并移除旧状态 COEXIST 同类型状态可以同时存在 KEEP_HIGH_PRIORITY 只保留优先级最高的状态 OVERLAP_LAYER 不保留新对象而是通知旧状态增加层数动作状态通常使用public StateActionAttack() { mMutexType STATE_MUTEX.REMOVE_OLD; }这样重复进入攻击状态时旧攻击状态会先退出再进入新的攻击状态。需要注意mMutexType通常在构造函数中设置而且不能在resetProperty()中重置。对象池复用对象时不会再次调用构造函数如果回收时把它重置为默认值下一次复用后的互斥规则就会错误。七、不同类型状态如何互斥STATE_MUTEX只能处理同一种状态。站立、行走和攻击是三个不同类型因此还需要使用状态组public class StateGroupBehaviour : StateGroup { }把所有动作状态放入这个组StateGroupBehaviour ├── StateActionStand ├── StateActionWalk ├── StateActionAttack ├── StateActionHit └── StateActionDie然后把状态组设置为GROUP_MUTEX.REMOVE_OTHERS添加组内的新状态时状态机会自动移除当前组中的其他状态当前StateActionStand 添加StateActionWalk 结果 移除 StateActionStand 保留 StateActionWalk调用方不需要手动先删除站立状态// 不需要手动移除站立 character.addStateStateActionWalk();状态组会统一完成切换。八、注册状态组创建状态组类型public class StateGroupBehaviour : StateGroup { } public class StateGroupNotAllowMove : StateGroup { } public class StateGroupNotAllowAttack : StateGroup { } public class StateGroupDieRemoved : StateGroup { }注册状态组及其关系using System; using static FrameBaseHotFix; public class StateGroupRegister { public static void registeAll() { Type groupBehaviour typeof(StateGroupBehaviour); Type groupNotAllowMove typeof(StateGroupNotAllowMove); Type groupNotAllowAttack typeof(StateGroupNotAllowAttack); Type groupDieRemoved typeof(StateGroupDieRemoved); registeGroup( groupBehaviour, GROUP_MUTEX.REMOVE_OTHERS); registeGroup(groupNotAllowMove); registeGroup(groupNotAllowAttack); registeGroup( groupDieRemoved, GROUP_MUTEX.MUTEX_WITH_MAIN); assignGroupStateActionStand( groupBehaviour); assignGroupStateActionWalk( groupBehaviour); assignGroupStateActionAttack( groupBehaviour); assignGroupStateActionDie( groupBehaviour); assignGroupStateActionAttack( groupNotAllowMove); assignGroupStateActionDie( groupNotAllowMove); assignGroupStateActionAttack( groupNotAllowAttack); assignGroupStateActionDie( groupNotAllowAttack); assignGroupStateActionDie( groupDieRemoved, true); assignGroupStateActionStand( groupDieRemoved); assignGroupStateActionWalk( groupDieRemoved); assignGroupStateActionAttack( groupDieRemoved); } protected static void registeGroup( Type groupType, GROUP_MUTEX mutex GROUP_MUTEX.COEXIST) { mStateManager.registeGroup( groupType, mutex); } protected static void assignGroupT( Type groupType, bool mainState false) where T : CharacterState { mStateManager.assignGroup( groupType, typeof(T), mainState); } }在GameHotFix.registerAll()中调用protected override void registerAll() { LayoutRegisterHotFix.registeAll(); StateGroupRegister.registeAll(); }状态组必须在使用角色状态之前完成注册。九、一个状态可以属于多个组StateActionAttack可以同时属于StateGroupBehaviour StateGroupNotAllowMove StateGroupNotAllowAttack StateGroupDieRemoved每个组表达不同含义。StateGroupBehaviour设置为GROUP_MUTEX.REMOVE_OTHERS负责保证站立、行走、攻击和死亡等动作不会同时存在。StateGroupNotAllowMove保持默认的GROUP_MUTEX.COEXIST它不用于移除状态只作为一个分类标签。移动前可以判断if (character.hasStateGroupStateGroupNotAllowMove()) { return; }只要角色拥有攻击、死亡或其他被分到该组的状态就不允许开始移动。StateGroupNotAllowAttack同样用于统一判断角色当前能否攻击if (character.hasStateGroupStateGroupNotAllowAttack()) { return; }不需要在攻击逻辑中分别判断character.hasStateStateActionDie(); character.hasStateStateActionHit(); character.hasStateStateActionAttack();以后增加新的禁攻状态时只需要把它加入状态组。十、使用主状态处理死亡关系死亡状态通常需要特殊规则进入死亡状态时 移除站立、移动、攻击等状态 死亡状态存在时 不能再添加站立、移动和攻击状态可以使用GROUP_MUTEX.MUTEX_WITH_MAIN把死亡状态指定为主状态mStateManager.assignGroup( typeof(StateGroupDieRemoved), typeof(StateActionDie), true);其他需要被死亡状态清理的状态也加入同一组mStateManager.assignGroup( typeof(StateGroupDieRemoved), typeof(StateActionStand)); mStateManager.assignGroup( typeof(StateGroupDieRemoved), typeof(StateActionWalk)); mStateManager.assignGroup( typeof(StateGroupDieRemoved), typeof(StateActionAttack));最终效果是添加死亡状态 ↓ 自动移除组中的其他状态 ↓ 死亡状态成为当前主状态 ↓ 死亡期间不能再添加组内其他状态调用死亡命令时只需要character.addStateStateActionDie();不需要手动逐个移除移动、攻击和站立状态。十一、状态生命周期CharacterState主要提供以下生命周期canEnter 判断当前是否允许进入状态 enter 成功加入状态时执行 update 每帧更新状态 fixedUpdate 固定帧更新状态 leave 状态被移除时执行 resetProperty 对象回收时重置全部字段可以通过canEnter()拒绝进入public override bool canEnter() { if (mCharacter null) { return false; } return !mCharacter.hasStateStateActionDie(); }但通用的互斥关系更适合放进状态组不要让每个状态都重复查询其他状态。状态自己的特殊限制才放在canEnter()中。十二、状态为什么使用 SafeList状态的update()中可能移除自己protected void removeSelf() { mCharacter ?.getStateMachine() .removeState(this, false); }一个状态也可能在更新过程中添加或移除其他状态。因此状态机内部使用SafeDictionaryType, SafeListCharacterState SafeListCharacterState进行管理。这与前一篇介绍的SafeList正好对应状态机正在遍历全部状态 ↓ 某个状态在 update 中移除自己 ↓ 当前遍历保持稳定 ↓ 下一次遍历应用最新状态列表状态被移除后会立即设置为非激活状态虽然它仍可能存在于当前读取快照中但后续更新会检测isActive()并跳过。十三、什么时候需要注册状态 ID直接通过类型添加状态时character.addStateStateActionStand();不需要注册状态 ID。只有需要根据配置表或网络协议中的数字 ID 动态创建状态时才需要建立映射mStateManager.registeState( 1001, typeof(BuffMoveSpeedUp), typeof(BuffMoveSpeedUpParam));收到状态 ID 后可以查询Type stateType mStateManager.getStateType(stateID); Type paramType mStateManager.getParamType(stateID);然后根据类型创建对应参数和状态。因此状态类型注册主要用于配置表驱动 Buff 服务器同步状态 存档恢复状态 通过数字 ID 动态创建状态普通动作状态通常可以直接按泛型类型添加。十四、总结MyFramework 的状态系统把状态行为、状态数据和互斥规则拆分开来CharacterState 负责一个状态的具体逻辑 StateParam 负责传递进入状态时的参数 COMCharacterStateMachine 负责角色当前状态的增删和更新 StateManager 负责全局状态类型和状态组关系两种互斥关系解决不同问题STATE_MUTEX 处理同一种状态重复添加 GROUP_MUTEX 处理不同状态类型之间的关系最终业务代码只需要表达想做什么character.addStateStateActionWalk(); character.addStateStateActionAttack(param); character.addStateStateActionDie();至于旧状态是否需要移除、新状态是否允许添加、死亡时需要清理哪些状态都由状态机和状态组配置统一处理。这样新增状态时不需要到移动、攻击、死亡等多个系统中继续增加分散的判断逻辑。