Files
Frostbite2D/docs/角色状态机开发说明.md
Lenheart f64180ebed feat(角色状态机): 重构角色状态系统为基于类的设计
新增通用状态类和职业专属状态类,将状态逻辑从状态机中解耦
添加状态注册机制,支持按职业配置状态
实现基础状态如待机、移动、跳跃、受击等
为剑士职业实现专属攻击状态
补充状态机开发说明文档
2026-04-03 17:11:22 +08:00

17 KiB
Raw Blame History

角色状态机开发说明

1. 这套架构是做什么的

这一套角色系统的目标,是把“输入处理”、“状态切换”、“动作定义”、“动画表现”和“位移推进”拆开,避免所有逻辑都堆在一个状态机函数里。

当前架构分成五层:

  1. 输入层:接收键盘、手柄等事件
  2. 命令层:把输入整理成统一命令和意图
  3. 状态层:决定角色此刻处于什么状态
  4. 动作层:从 .act / .lst 读取动作参数
  5. 表现层:根据动作定义播放动画,并由 CharacterMotor 推进坐标

这意味着:

  • 状态类负责“逻辑决策”
  • .act 负责“动作配置和时序参数”
  • 动画系统负责“资源表现”
  • CharacterMotor 负责“x/y/z 逻辑坐标推进”

不要再把动作时长、动画标签、可否转向、可否移动这类信息硬编码回状态机里。

2. 整体驱动流程

角色每帧的主流程如下:

输入事件
  -> CharacterInputRouter
  -> CharacterCommandBuffer
  -> CharacterIntent
  -> CharacterStateMachine::Update()
  -> 当前状态类 OnUpdate()
  -> CharacterMotor::Update()
  -> CharacterObject::SetWorldPosition()
  -> CharacterObject::PlayAnimationTag()

可以按下面的理解来记:

  • CharacterInputRouter 负责把键盘、手柄输入统一成命令
  • CharacterCommandBuffer 负责缓冲按钮和移动轴
  • CharacterIntent 是这一帧角色“想做什么”
  • CharacterStateMachine 负责找到当前状态类并调用它
  • 状态类决定要不要切状态、要不要吃掉输入、要不要启动动作
  • CharacterMotor 负责真正推进世界坐标
  • CharacterObject 负责把逻辑位置投影到屏幕,并驱动动画表现

3. 坐标与表现

角色逻辑坐标是三维:x / y / z

  • x:横向
  • y:地面纵深
  • z:高度

当前渲染不是直接画三维,而是把逻辑坐标投影到屏幕:

  • 屏幕坐标约等于 Vec2(x, y - z)
  • 同层遮挡排序仍然主要看地面 y

因此:

  • 看起来像 2D
  • 但跳跃、击飞、下落这些逻辑,实际依赖 zverticalVelocity

相关核心数据在 CharacterActionTypes.h

  • CharacterWorldPosition
  • CharacterMotor
  • CharacterIntent
  • CharacterCommandBuffer
  • CharacterStateId

4. 目录结构与职责

当前状态系统相关代码主要在这些位置:

Game/include/character/
Game/src/character/
Game/include/character/states/
Game/src/character/states/

4.1 核心文件

  • CharacterObject
    • 角色主对象
    • 负责构建、更新、动画播放、受击入口
  • CharacterStateMachine
    • 负责注册状态、切换状态、分发更新、推进动作帧事件
  • CharacterStateBase
    • 所有状态类的基类
    • 提供公共 helper
  • CharacterStateRegistry
    • 负责把通用状态和职业状态注册进状态机

4.2 状态目录分层

states/
  common/
    IdleState
    MoveState
    JumpState
    FallState
    LandingState
    HurtState
    DeadState
  jobs/
    swordman/
      SwordmanAttackState

约定如下:

  • common/:所有职业都能复用的状态
  • jobs/<job>/:职业专属状态
  • 当前除攻击外,其余基础状态都在 common/
  • 剑士攻击逻辑在 jobs/swordman/

5. 状态机本体现在负责什么

CharacterStateMachine 现在是一个“调度器”,不是“所有逻辑的堆放点”。

它主要负责:

  • Configure():按职业注册状态
  • Reset():重置运行时状态
  • Update():找到当前状态类并调用 OnUpdate
  • ChangeState():做状态切换,并触发 OnExit -> OnEnter
  • TryStartAction():按动作 id 启动动作
  • ApplyFrameEvents():根据 .act 定义执行帧事件

它不应该再负责:

  • 直接写死所有状态的业务逻辑
  • 直接写死 idle/move/jump/attack 的完整流程
  • 直接把职业特有攻击逻辑塞在一个大 switch 里

6. 状态类生命周期

每个状态类都继承 CharacterStateBase,支持以下生命周期:

6.1 OnEnter

进入状态时调用,常见用途:

  • 重置计时器
  • 设置动作帧游标
  • 播放对应动画
  • 调用进入 hook

6.2 OnUpdate

每帧调用,常见用途:

  • 检查输入
  • 检查 grounded / velocity / 受击等条件
  • 切换状态
  • 推进动作帧
  • 执行取消规则
  • 消费输入命令

6.3 OnExit

离开状态时调用,常见用途:

  • 调用退出 hook
  • 做简单清理

6.4 TryEnter

这个不是所有状态都有。

只有“承担动作入口判定职责”的职业动作状态才需要实现 ICharacterActionStateNode,例如当前的:

  • SwordmanAttackState

它的作用是:

  • 在通用状态里,不直接写死“按攻击键就进哪个动作”
  • 而是统一调用 TryStartRegisteredAction()
  • 再由职业动作状态自己的 TryEnter() 决定是 attack_1skill_1 还是别的动作

这样职业差异就解耦开了。

7. CharacterStateBase 提供了什么

CharacterStateBase 是给所有状态类复用的基类,常用 helper 包括:

  • ChangeState()
    • 切换到另一个状态
  • PlayActionById()
    • 通过逻辑动作 id 查动作定义并播放动画
  • InvokeHook()
    • 触发状态 hook当前先走 C++ 预留接口
  • TryStartAction()
    • 按命令类型和动作 id 尝试启动动作
  • TryStartRegisteredAction()
    • 让职业动作状态决定是否启动动作
  • ApplyFrameEvents()
    • 执行动作的帧事件
  • GetCurrentAction() / SetCurrentAction() / ClearCurrentAction()
    • 读写当前动作定义
  • StateTime() / ActionFrameProgress() / ActionFrame() / LandingTimer()
    • 访问状态机内部运行时变量

开发新状态时,优先复用这些 helper不要重复去碰状态机内部字段。

8. 当前已有状态怎么分工

8.1 通用状态

  • IdleState
    • 地面待机
    • 检查移动、跳跃、职业动作入口
  • MoveState
    • 地面移动
    • 检查停止移动、跳跃、职业动作入口
  • JumpState
    • 上升阶段
    • 空中可控移动,升到顶后转 Fall
  • FallState
    • 下落阶段
    • 落地后转 Landing
  • LandingState
    • 落地收招阶段
    • 用短 landing timer 过渡,再回 IdleMove
  • HurtState
    • 受击状态
    • 推进受击动作帧,到结束后回地面或空中状态
  • DeadState
    • 死亡状态
    • 当前只停止地面移动

8.2 剑士职业状态

  • SwordmanAttackState
    • 当前负责剑士攻击与技能入口
    • TryEnter() 决定是否启动 attack_1skill_1
    • OnUpdate() 中推进动作帧、处理 cancel rule、结束后回 Idle / Move / Fall

注意:

  • Action 是一个逻辑状态 id
  • 但它的具体实现是职业可替换的
  • 现在剑士把 Action 的实现接管到了 SwordmanAttackState

9. 动作和动画到底怎么关联

这是开发时最容易混的地方。

9.1 两层名字

当前系统里有两层名字:

  1. 逻辑动作名:例如 idlemoveattack_1
  2. 动画标签名:例如 restmoveattack1

9.2 读取来源

逻辑动作定义从 script.pvf 中读取,路径关系是:

character/<jobTag>/action_logic/action_list.lst
  -> 指向多个 .act
  -> 每个 .act 提供一个动作定义

例如:

0    `swordman/action_logic/idle.act`
1    `swordman/action_logic/move.act`
2    `swordman/action_logic/attack_1.act`

9.3 .act 里关注哪些字段

当前最关键的是:

  • action id
  • animation tag
  • total frames
  • loop
  • can move
  • can turn
  • can be interrupted

其中:

  • action id 是玩法逻辑识别名
  • animation tag 是动画系统真正去播放的标签

所以状态类通常不直接写动画名,而是:

  • 先用逻辑动作 id 找动作定义
  • 再从动作定义里拿 animationTag
  • 最后调用 PlayAnimationTag()

9.4 一个例子

例如 attack_1.act 里:

[action id]
    `attack_1`

[animation tag]
    `attack1`

那状态系统理解为:

  • 逻辑上现在执行的是 attack_1
  • 表现上要播的动画是 attack1

10. .lst.act 的当前约定

10.1 action_list.lst 格式

当前只支持新格式:

#PVF_File
0    `swordman/action_logic/idle.act`
1    `swordman/action_logic/move.act`
2    `swordman/action_logic/jump.act`

注意:

  • 第一列是索引
  • 第二列是相对职业目录的 .act 路径
  • 项目里当前只按“索引 / 路径”对来解析

10.2 .act 写法注意

字符串建议写成:

`attack_1`

不要依赖源码层再做“去反引号”的兼容逻辑。

另外布尔项在 PVF 中不要写 C++ 风格的裸 true / false。要用当前 PVF 能稳定落地的格式,例如项目现有约定支持的 0/1 或能被脚本解析的文本形式。

11. 当前动作帧是怎么推进的

动作状态下,核心流程是:

  1. TryStartAction() 启动某个逻辑动作
  2. 状态机记录 currentAction_currentActionId_
  3. 进入 Action 状态的 OnEnter()
  4. OnUpdate() 每帧用 deltaTime * 60 推进逻辑帧
  5. 根据动作定义应用帧事件和取消规则
  6. 到达 totalFrames 后回收动作并切回后续状态

当前帧事件入口在:

  • CharacterStateMachine::ApplyFrameEvents()

当前已支持的关键帧事件类型包括:

  • SetVelocityXY
  • SetVelocityZ
  • FinishAction

后面如果要加命中框、输入开放窗、特效事件,也优先沿着帧事件扩,而不是继续把时序写死到状态类里。

12. 如何新增一个通用状态

下面以“新增 DashState”为例。

步骤 1新建状态类

在:

Game/include/character/states/common/DashState.h
Game/src/character/states/common/DashState.cpp

类定义继承:

class DashState : public CharacterStateBase {
public:
  DashState();

  void OnEnter(CharacterStateMachine& machine,
               CharacterStateContext& context,
               CharacterStateId previousState) override;
  void OnUpdate(CharacterStateMachine& machine,
                CharacterStateContext& context) override;
  void OnExit(CharacterStateMachine& machine,
              CharacterStateContext& context,
              CharacterStateId nextState) override;
};

步骤 2构造函数绑定状态 id

DashState::DashState()
  : CharacterStateBase(CharacterStateId::Dash) {
}

如果新增的是全新状态 id需要先扩展 CharacterStateId 枚举。

步骤 3实现状态逻辑

常规做法:

  • OnEnter():重置计时器、播 dash 动画
  • OnUpdate():推进位移,检查结束条件,决定切回哪个状态
  • OnExit():收尾或发 hook

步骤 4注册状态

CharacterStateRegistry 中注册:

  • 通用状态放 RegisterCommonCharacterStates()
  • 职业状态放 RegisterJobCharacterStates()

步骤 5补切换入口

仅注册状态还不够,还要在合适的已有状态里决定“什么时候能进这个状态”。

例如:

  • IdleState::OnUpdate() 检查 dash 输入
  • MoveState::OnUpdate() 检查 dash 输入
  • 或在职业动作状态中允许某些动作 cancel 到 dash

步骤 6准备动作资源

如果 dash 有专属动作,要补:

  • action_list.lst
  • dash.act
  • 对应动画标签资源

13. 如何新增一个职业动作状态

如果是职业差异比较大的状态,建议放到:

states/jobs/<job>/

13.1 什么时候应该做成职业状态

满足以下情况时建议独立做:

  • 输入入口不同
  • 动作链不同
  • 取消规则不同
  • 动作帧事件不同
  • 和其他职业共享价值不高

13.2 如果它承担动作入口

就让它同时继承:

  • CharacterStateBase
  • ICharacterActionStateNode

然后实现:

  • TryEnter():决定什么输入启动什么动作
  • OnEnter():进入动作表现
  • OnUpdate():推进帧、处理 cancel、判断结束
  • OnExit():退出收尾

13.3 参考当前剑士攻击状态

可以直接参考:

  • SwordmanAttackState::TryEnter()
  • SwordmanAttackState::OnUpdate()

它已经演示了:

  • Skill1Pressed 启动 skill_1
  • AttackPressed 启动 attack_1
  • 用 cancel rule 在窗口内切到 attack_2

14. 新增一个动作资源要做什么

如果只是给已有状态补一个新动作,通常要做这几件事:

  1. action_list.lst 中增加映射
  2. 新建对应 .act
  3. .act 中写好 action idanimation tag
  4. 确保动画资源里真的有这个 tag
  5. 在状态逻辑里通过逻辑动作 id 去触发它

最小示例:

#PVF_File
0    `swordman/action_logic/idle.act`
1    `swordman/action_logic/move.act`
2    `swordman/action_logic/dash.act`

对应 dash.act 示例:

#PVF_File

[action id]
    `dash`

[animation tag]
    `dash`

[total frames]
    12

[loop]
    0

[can move]
    1

[can turn]
    0

[can be interrupted]
    0

15. 如何理解当前输入接入

当前输入来源不只键盘,主要有:

  • 键盘
  • 手柄轴
  • 手柄按键

统一入口都收敛到 CharacterInputRouter,它负责把平台输入转成统一命令。

所以状态层不要关心:

  • 这是键盘来的
  • 还是手柄来的

状态层只关心:

  • 有没有 JumpPressed
  • 有没有 AttackPressed
  • 当前 moveX / moveY 是多少

这就是输入解耦的关键。

16. 当前脚本扩展点在哪里

CharacterObject::RunStateHook() 是当前预留给脚本系统的入口。

状态在以下时机会调用它:

  • on_enter
  • on_update
  • on_exit

目前它还是空实现,意思是:

  • 当前实际逻辑仍由 C++ 状态类负责
  • 但未来可以把一部分进入、更新、退出逻辑下沉到脚本

如果后面要接脚本,建议保持下面的边界:

  • C++ 继续控制状态切换主流程和底层安全逻辑
  • 脚本负责更灵活的状态表现、特效、派生行为、部分判定

不要一开始就把所有底层切换权完全放给脚本,否则调试成本会很高。

17. 常见问题排查

17.1 按键了角色没反应

优先查:

  1. 输入事件有没有进 CharacterInputRouter
  2. 有没有生成对应命令进入 CharacterCommandBuffer
  3. 当前状态的 OnUpdate() 有没有检查这个命令
  4. 命令有没有被过早消费
  5. 对应动作 id 有没有加载成功

17.2 状态切了但动画不对

优先查:

  1. .actanimation tag 是否正确
  2. 动画资源里是否存在这个 tag
  3. 当前状态是否通过逻辑动作 id 正确拿到了动作定义
  4. 是否被 fallback 动画顶掉了

17.3 动作资源没读到

优先查:

  1. action_list.lst 路径格式是否正确
  2. .act 路径是否相对于 character/<jobTag>/
  3. jobTag 是否和实际职业目录一致
  4. PVF 中是否真的导入了这些文件

17.4 朝左时时装和人物不契合

这个问题通常优先查:

  • Ani 锚点
  • 左右翻转实现
  • 各部件的挂点/偏移

它一般不是状态机逻辑问题。

18. 推荐开发约定

为了后面大家都能维护,建议遵守下面几条:

  • 一个状态一个类
  • 通用状态优先放 common/
  • 职业差异大的状态放 jobs/<job>/
  • 动作参数优先写 .act
  • 状态类只写决策,不写死资源细节
  • 新功能先找有没有通用 helper 能复用
  • 新状态一定补入口,不要只注册不切换

19. 一个最小状态模板

下面是一个最小可抄的状态模板:

class ExampleState : public CharacterStateBase {
public:
  ExampleState()
      : CharacterStateBase(CharacterStateId::Idle) {
  }

  void OnEnter(CharacterStateMachine& machine,
               CharacterStateContext& context,
               CharacterStateId previousState) override {
    (void)previousState;
    PlayActionById(context.owner, "idle");
    InvokeHook(machine, context.owner, context.owner.FindAction("idle"), "on_enter");
  }

  void OnUpdate(CharacterStateMachine& machine,
                CharacterStateContext& context) override {
    auto& motor = context.owner.GetMotorMutable();
    if (!motor.grounded) {
      ChangeState(machine, context, CharacterStateId::Fall);
      return;
    }
  }

  void OnExit(CharacterStateMachine& machine,
              CharacterStateContext& context,
              CharacterStateId nextState) override {
    (void)nextState;
    InvokeHook(machine, context.owner, context.owner.FindAction("idle"), "on_exit");
  }
};

20. 新同事接入时建议先读什么

建议按这个顺序看:

  1. CharacterObject::OnUpdate()
  2. CharacterStateMachine
  3. CharacterStateBase
  4. CharacterStateRegistry
  5. states/common/ 下的基础状态
  6. states/jobs/swordman/SwordmanAttackState
  7. CharacterActionTypes.h
  8. 对应职业的 action_list.lst.act

这样能最快建立“输入 -> 状态 -> 动作 -> 动画 -> 位移”的完整认知。


如果后面架构继续扩展,建议再补两份专题文档:

  • 一份讲 .act / 帧事件 / cancel rule 设计
  • 一份讲脚本如何接入状态生命周期