C#程式状态机及其Godot实践
前言
今天是周日,马上就要迎来新的一周了,前几周都没干什么事,为了减缓偷懒症状,立个Flag从今往后每周至少更新两次文章。内容虽然无法保证优质,但重在坚持,全当写周记了。希望不要三分钟热度吧。
今天记录的是老生常谈的状态机,灵感来源于我的新项目中为了编写人物逻辑采用的方式。
关于状态机
简述
状态机,StateMachine,是游戏开发中常用的AI之一。其具体又可以分为有限状态机,也就是FSM,还有分层状态机HSM,无限状态机等等。
个人以为状态机的核心就在于其状态的唯一性和隔离性,表示为同一时刻只能有一种状态,和状态之间的不·共享。
什么时候要应该用到状态机?我想大概就是发现控制逻辑中有大量Bool和if语句的时候。
Godot中的状态机
虽然Godot目前没有直接的AI支持。但是还是有多种途径去实现一个状态机功能的。比如:
1.通过节点实现。把每个状态以节点表示,在对应的节点上编写每个状态的具体逻辑,状态之间的切换就对相当于“使用哪个节点的上处理逻辑”,其他节点不用管。可以适当的抽象出状态和状态机脚本。采用普通的Node节点即可,因为我们不需要太多多余信息,只需要让节点能够被处理即可。
这种方式的好处就是开发效率高和比较直观。但是个人认为目前仅仅适用于GDScript开发,因为GDs可以快速获取节点结构,在编写每个状态的逻辑时可以很方便的引用场景中的其他节点,还有GDs支持内嵌脚本,可以直接为对应状态内嵌一个脚本给对应节点,方便文件管理。而C#没有这些优势,希望以后能支持吧。
2.采用AnimationTree实现,就是那个动画树节点。虽然我没有实操过,但是鉴于Unity的使用经验,理论上应该是可行的。因为动画树里确实内嵌了状态机这个东西,以后有时间再研究。
3.Resource实现。这算是我踩过的一个小坑,Godot的Resource很强大,个人认为比Unity的SO好些,因为可以方便的在Inspector中删改。但是如果要用到像状态机这种极大依赖于容器数据的场合,还是需要慎重考虑,什么意思呢?这里所说的“容器”就是指包含状态机的那个对象,比如我们的角色控制器,包含一个状态机。那么这个状态机必然依赖于控制器本身的一些数据,比如移动状态需要移动速度,跳跃状态需要跳跃速度之类的。
而Resource在这方面显得无力,因为目前Godot不支持在Resource中Export出节点,仅作为数据逻辑容器,而不知道什么东西将会用到它,必须依靠依赖注入才能获取上层数据。虽然对于上述情况还有解法,比如每个状态管理各自需要的数据,但又因为通过Resource实现的状态之间不好切换,或者每个状态都要对应一种资源,可能导致难于管理等等原因,所以Pass掉了。
不过也能看出,Resource在很多系统上大有可为,比如什么能力系统,组件系统,可以重点研究一下。
程式状态机
这个其实没有什么特别的说法,单纯就是我为了表示仅有代码实现状态机,没有任何可视化才这样叫的。
有时候状态机需求很低,仅仅两三个状态,用不得大动干戈,仅靠代码实现反而有助于开发。
using System;
using System.Collections.Generic;
namespace AdamDontCry;
public sealed class StateMachine<T>
{
public T Owner { get; private set; }
private readonly Dictionary<Type, State<T>> m_states = [];
public State<T> CurrentState { get; private set; }
public void Initialize<S>(T owner) where S : State<T>
{
Owner = owner;
ChangeState<S>();
}
public void ChangeState<S>() where S : State<T>
{
State<T> _ChangeState(State<T> state)
{
CurrentState?.Exit();
CurrentState = state;
CurrentState.Enter();
return state;
}
if (m_states.TryGetValue(typeof(S), out var state))
{
_ChangeState(state);
}
else
{
var newState = Activator.CreateInstance<S>();
newState.Initialize(this);
m_states.Add(typeof(S), _ChangeState(newState));
}
}
// Seems no need to add or remove ?
// public void AddState<S>() where S : State<T>, new()
// {
// if (m_states.ContainsKey(typeof(S))) return;
// else m_states.Add(typeof(S), new S());
// }
// public void RemoveState<S>() where S : State<T> => m_states.Remove(typeof(S));
public void Process(double delta) => CurrentState.Process(delta);
}
public abstract class State<T>()
{
public T Owner { get; private set; }
public StateMachine<T> StateMachine { get; private set; }
// TODO: Find a way to use constructor instead a new method?
// public State(T owner, StateMachine<T> stateMachine) : this()
// {
// Owner = owner;
// StateMachine = stateMachine;
// }
public void Initialize(StateMachine<T> stateMachine)
{
Owner = stateMachine.Owner;
StateMachine = stateMachine;
}
public virtual void Enter() { }
public virtual void Process(double delta) { }
public virtual void Exit() { }
public void ChangeState<S>() where S : State<T> => StateMachine.ChangeState<S>();
}
其实想法非常简单,就是通过泛型实现依赖注入以及约束状态范围。状态之间的切换直接对应于类型切换,需要为每个状态编写一种类型。
值得一提的是这里原本是想通过构造函数建立状态机和状态之间的依赖,结果发现一些关于泛型构造函数的尚不能解决的问题,遂转用一个“初始化”方法代替。
现在看不出什么还是得结合实践。
实践
下面是实际应用的例子。
public partial class Player : Unit
{
[ExportGroup("Properties")]
[Export] public float Speed = 5000.0f;
[Export] public float JumpVelocity = 300.0f;
[ExportGroup("Animation")]
[Export] public AnimatedSprite2D Sprite;
public override void _Ready()
{
StateMachine.Initialize<IdleState>(this);
}
public override void _PhysicsProcess(double delta)
{
StateMachine.Process(delta);
ApplyGravity(delta);
}
}
public partial class Player : Unit
{
public StateMachine<Player> StateMachine { get; private set; } = new();
public class IdleState : State<Player>
{
public override void Enter() => Owner.Sprite.Play("idle");
public override void Process(double delta)
{
if (Input.IsActionJustPressed("jump") && Owner.IsOnFloor())
{
ChangeState<JumpState>(); return;
}
if (Mathf.Abs(Input.GetAxis("move_left", "move_right")) > 0.25)
{
ChangeState<RunState>(); return;
}
if (Input.IsActionJustPressed("pick"))
{
ChangeState<PickState>(); return;
}
}
}
public class RunState : State<Player>
{
public override void Enter() => Owner.Sprite.Play("run");
public override void Process(double delta)
{
if (Input.IsActionJustPressed("jump") && Owner.IsOnFloor())
{
ChangeState<JumpState>(); return;
}
Vector2 velocity = Owner.Velocity;
var _delta = (float)delta;
Vector2 direction = new(Input.GetAxis("move_left", "move_right"), 0);
if (direction != Vector2.Zero)
{
velocity.X = direction.Normalized().X * Owner.Speed * _delta;
Owner.Sprite.FlipH = velocity.X < 0;
}
else
{
ChangeState<IdleState>(); return;
}
Owner.Velocity = velocity;
Owner.MoveAndSlide();
}
}
public class JumpState : State<Player>
{
public const double max_timer = 1.0;
public double timer = 0;
public bool isJumped = false;
public override void Enter()
{
Owner.Sprite.Play("jump_prepare");
timer = 0;
isJumped = false;
}
public override void Process(double delta)
{
timer += delta;
if (isJumped && Owner.IsOnFloor())
{
ChangeState<IdleState>(); return;
}
if (!isJumped && Input.IsActionJustReleased("jump"))
{
Owner.Sprite.Play("jump");
Owner.Velocity =
Input.GetVector("move_left", "move_right", "move_up", "move_down").Normalized() *
Owner.JumpVelocity * (float)Mathf.Min(timer / max_timer, 1.0);
Owner.MoveAndSlide();
isJumped = true;
}
}
}
为了美观采用了部分类的写法。然后为每个需要的状态编写一个类,只需继承容器对应的泛型状态,这里我直接写成内部类。
这里有一些设计上的小缺陷,就是每次调用切换状态方法后,实际还需要执行完当前状态剩下的代码,所以需要每次切换后return一下,避免后面的代码影响到整个逻辑流。
虽然看起来每次切换都要调用很麻烦,但是就算是可视化操作也得一个一个“连连看”呢,所以似乎可以接收。
还有既然来都来了就顺便记录移动处理那方面的内容:比如对于有输入强度相关的情况,就像上面的获取移动输入的向量值,其对应的是一个依赖输入强度的可变向量,即使方向不变,输入时强度改变(比如使用手柄摇杆)也会影响其计算结果,所以这里用了Normalized把向量值变得只与方向有关,从而避免了输入强度带来的影响,或者“强度”这个说法不太准确,实际上只要跟输入量化有关的都需要留意是否有对应需求。
结语
之后的文章可能会比较直接简洁,因为我觉得写文章也好累好麻烦,所以可能效率至上突出重点好点。