# 角色状态机开发说明

## 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 设计
- 一份讲脚本如何接入状态生命周期
