Unity MyFramework 用法说明(十三):使用 StateMachine 管理互斥与共存的角色状态

游戏中的角色通常不只有一个简单的"当前状态"。

角色可能正在行走,同时拥有加速 Buff;也可能正在攻击,因此暂时不能移动;进入死亡状态后,还需要自动移除移动、攻击和受击等状态。

MyFramework 使用 StateManagerCOMCharacterStateMachine,统一处理状态生命周期、持续时间、同类状态冲突以及状态组之间的互斥关系。

项目地址:

github.com/ZHOURUIH/My...

一、为什么不能只用一个状态枚举

最简单的角色状态可能写成:

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>();

至于旧状态是否需要移除、新状态是否允许添加、死亡时需要清理哪些状态,都由状态机和状态组配置统一处理。

这样新增状态时,不需要到移动、攻击、死亡等多个系统中继续增加分散的判断逻辑。

相关推荐
SmalBox15 小时前
【节点】[UnpackWaterData节点]原理解析与实际应用
unity3d·游戏开发·图形学
_zhourui_h_1 天前
Unity MyFramework 用法说明(十二):使用 DoubleBuffer 在多线程之间传递数据
unity3d
SmalBox1 天前
【节点】[PackWaterVertexData节点]原理解析与实际应用
unity3d·游戏开发·图形学
_zhourui_h_2 天前
Unity MyFramework 用法说明(十一):使用 SafeList 在遍历过程中安全增删
unity3d
SmalBox2 天前
【节点】[EvaluateTipThickness节点]原理解析与实际应用
unity3d·游戏开发·图形学
_zhourui_h_3 天前
Unity MyFramework 用法说明(十):使用 ClassObject 和 ClassScope 管理可复用对象
unity3d
SmalBox4 天前
【节点】[EvaluateSimulationDisplacement节点]原理解析与实际应用
unity3d·游戏开发·图形学