# 角色状态机开发说明 ## 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//`:职业专属状态 - 当前除攻击外,其余基础状态都在 `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//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// ``` ### 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//` 3. `jobTag` 是否和实际职业目录一致 4. PVF 中是否真的导入了这些文件 ### 17.4 朝左时时装和人物不契合 这个问题通常优先查: - Ani 锚点 - 左右翻转实现 - 各部件的挂点/偏移 它一般不是状态机逻辑问题。 ## 18. 推荐开发约定 为了后面大家都能维护,建议遵守下面几条: - 一个状态一个类 - 通用状态优先放 `common/` - 职业差异大的状态放 `jobs//` - 动作参数优先写 `.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 设计 - 一份讲脚本如何接入状态生命周期