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

695 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 角色状态机开发说明
## 1. 这套架构是做什么的
这一套角色系统的目标,是把“输入处理”、“状态切换”、“动作定义”、“动画表现”和“位移推进”拆开,避免所有逻辑都堆在一个状态机函数里。
当前架构分成五层:
1. 输入层:接收键盘、手柄等事件
2. 命令层:把输入整理成统一命令和意图
3. 状态层:决定角色此刻处于什么状态
4. 动作层:从 `.act` / `.lst` 读取动作参数
5. 表现层:根据动作定义播放动画,并由 `CharacterMotor` 推进坐标
这意味着:
- 状态类负责“逻辑决策”
- `.act` 负责“动作配置和时序参数”
- 动画系统负责“资源表现”
- `CharacterMotor` 负责“x/y/z 逻辑坐标推进”
不要再把动作时长、动画标签、可否转向、可否移动这类信息硬编码回状态机里。
## 2. 整体驱动流程
角色每帧的主流程如下:
```text
输入事件
-> 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
- 但跳跃、击飞、下落这些逻辑,实际依赖 `z``verticalVelocity`
相关核心数据在 `CharacterActionTypes.h`
- `CharacterWorldPosition`
- `CharacterMotor`
- `CharacterIntent`
- `CharacterCommandBuffer`
- `CharacterStateId`
## 4. 目录结构与职责
当前状态系统相关代码主要在这些位置:
```text
Game/include/character/
Game/src/character/
Game/include/character/states/
Game/src/character/states/
```
### 4.1 核心文件
- `CharacterObject`
- 角色主对象
- 负责构建、更新、动画播放、受击入口
- `CharacterStateMachine`
- 负责注册状态、切换状态、分发更新、推进动作帧事件
- `CharacterStateBase`
- 所有状态类的基类
- 提供公共 helper
- `CharacterStateRegistry`
- 负责把通用状态和职业状态注册进状态机
### 4.2 状态目录分层
```text
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_1``skill_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 过渡,再回 `Idle``Move`
- `HurtState`
- 受击状态
- 推进受击动作帧,到结束后回地面或空中状态
- `DeadState`
- 死亡状态
- 当前只停止地面移动
### 8.2 剑士职业状态
- `SwordmanAttackState`
- 当前负责剑士攻击与技能入口
-`TryEnter()` 决定是否启动 `attack_1``skill_1`
-`OnUpdate()` 中推进动作帧、处理 cancel rule、结束后回 `Idle / Move / Fall`
注意:
- `Action` 是一个逻辑状态 id
- 但它的具体实现是职业可替换的
- 现在剑士把 `Action` 的实现接管到了 `SwordmanAttackState`
## 9. 动作和动画到底怎么关联
这是开发时最容易混的地方。
### 9.1 两层名字
当前系统里有两层名字:
1. 逻辑动作名:例如 `idle``move``attack_1`
2. 动画标签名:例如 `rest``move``attack1`
### 9.2 读取来源
逻辑动作定义从 `script.pvf` 中读取,路径关系是:
```text
character/<jobTag>/action_logic/action_list.lst
-> 指向多个 .act
-> 每个 .act 提供一个动作定义
```
例如:
```text
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` 里:
```text
[action id]
`attack_1`
[animation tag]
`attack1`
```
那状态系统理解为:
- 逻辑上现在执行的是 `attack_1`
- 表现上要播的动画是 `attack1`
## 10. `.lst` 与 `.act` 的当前约定
### 10.1 `action_list.lst` 格式
当前只支持新格式:
```text
#PVF_File
0 `swordman/action_logic/idle.act`
1 `swordman/action_logic/move.act`
2 `swordman/action_logic/jump.act`
```
注意:
- 第一列是索引
- 第二列是相对职业目录的 `.act` 路径
- 项目里当前只按“索引 / 路径”对来解析
### 10.2 `.act` 写法注意
字符串建议写成:
```text
`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新建状态类
在:
```text
Game/include/character/states/common/DashState.h
Game/src/character/states/common/DashState.cpp
```
类定义继承:
```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
```cpp
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. 如何新增一个职业动作状态
如果是职业差异比较大的状态,建议放到:
```text
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 id``animation tag`
4. 确保动画资源里真的有这个 tag
5. 在状态逻辑里通过逻辑动作 id 去触发它
最小示例:
```text
#PVF_File
0 `swordman/action_logic/idle.act`
1 `swordman/action_logic/move.act`
2 `swordman/action_logic/dash.act`
```
对应 `dash.act` 示例:
```text
#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. `.act``animation 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. 一个最小状态模板
下面是一个最小可抄的状态模板:
```cpp
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 设计
- 一份讲脚本如何接入状态生命周期