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