新增通用状态类和职业专属状态类,将状态逻辑从状态机中解耦 添加状态注册机制,支持按职业配置状态 实现基础状态如待机、移动、跳跃、受击等 为剑士职业实现专属攻击状态 补充状态机开发说明文档
17 KiB
角色状态机开发说明
1. 这套架构是做什么的
这一套角色系统的目标,是把“输入处理”、“状态切换”、“动作定义”、“动画表现”和“位移推进”拆开,避免所有逻辑都堆在一个状态机函数里。
当前架构分成五层:
- 输入层:接收键盘、手柄等事件
- 命令层:把输入整理成统一命令和意图
- 状态层:决定角色此刻处于什么状态
- 动作层:从
.act/.lst读取动作参数 - 表现层:根据动作定义播放动画,并由
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
- 但跳跃、击飞、下落这些逻辑,实际依赖
z和verticalVelocity
相关核心数据在 CharacterActionTypes.h:
CharacterWorldPositionCharacterMotorCharacterIntentCharacterCommandBufferCharacterStateId
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():找到当前状态类并调用OnUpdateChangeState():做状态切换,并触发OnExit -> OnEnterTryStartAction():按动作 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 两层名字
当前系统里有两层名字:
- 逻辑动作名:例如
idle、move、attack_1 - 动画标签名:例如
rest、move、attack1
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 idanimation tagtotal framesloopcan movecan turncan 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. 当前动作帧是怎么推进的
动作状态下,核心流程是:
TryStartAction()启动某个逻辑动作- 状态机记录
currentAction_和currentActionId_ - 进入
Action状态的OnEnter() OnUpdate()每帧用deltaTime * 60推进逻辑帧- 根据动作定义应用帧事件和取消规则
- 到达
totalFrames后回收动作并切回后续状态
当前帧事件入口在:
CharacterStateMachine::ApplyFrameEvents()
当前已支持的关键帧事件类型包括:
SetVelocityXYSetVelocityZFinishAction
后面如果要加命中框、输入开放窗、特效事件,也优先沿着帧事件扩,而不是继续把时序写死到状态类里。
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.lstdash.act- 对应动画标签资源
13. 如何新增一个职业动作状态
如果是职业差异比较大的状态,建议放到:
states/jobs/<job>/
13.1 什么时候应该做成职业状态
满足以下情况时建议独立做:
- 输入入口不同
- 动作链不同
- 取消规则不同
- 动作帧事件不同
- 和其他职业共享价值不高
13.2 如果它承担动作入口
就让它同时继承:
CharacterStateBaseICharacterActionStateNode
然后实现:
TryEnter():决定什么输入启动什么动作OnEnter():进入动作表现OnUpdate():推进帧、处理 cancel、判断结束OnExit():退出收尾
13.3 参考当前剑士攻击状态
可以直接参考:
SwordmanAttackState::TryEnter()SwordmanAttackState::OnUpdate()
它已经演示了:
- 用
Skill1Pressed启动skill_1 - 用
AttackPressed启动attack_1 - 用 cancel rule 在窗口内切到
attack_2
14. 新增一个动作资源要做什么
如果只是给已有状态补一个新动作,通常要做这几件事:
- 在
action_list.lst中增加映射 - 新建对应
.act - 在
.act中写好action id和animation tag - 确保动画资源里真的有这个 tag
- 在状态逻辑里通过逻辑动作 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_enteron_updateon_exit
目前它还是空实现,意思是:
- 当前实际逻辑仍由 C++ 状态类负责
- 但未来可以把一部分进入、更新、退出逻辑下沉到脚本
如果后面要接脚本,建议保持下面的边界:
- C++ 继续控制状态切换主流程和底层安全逻辑
- 脚本负责更灵活的状态表现、特效、派生行为、部分判定
不要一开始就把所有底层切换权完全放给脚本,否则调试成本会很高。
17. 常见问题排查
17.1 按键了角色没反应
优先查:
- 输入事件有没有进
CharacterInputRouter - 有没有生成对应命令进入
CharacterCommandBuffer - 当前状态的
OnUpdate()有没有检查这个命令 - 命令有没有被过早消费
- 对应动作 id 有没有加载成功
17.2 状态切了但动画不对
优先查:
.act的animation tag是否正确- 动画资源里是否存在这个 tag
- 当前状态是否通过逻辑动作 id 正确拿到了动作定义
- 是否被 fallback 动画顶掉了
17.3 动作资源没读到
优先查:
action_list.lst路径格式是否正确.act路径是否相对于character/<jobTag>/jobTag是否和实际职业目录一致- 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. 新同事接入时建议先读什么
建议按这个顺序看:
CharacterObject::OnUpdate()CharacterStateMachineCharacterStateBaseCharacterStateRegistrystates/common/下的基础状态states/jobs/swordman/SwordmanAttackStateCharacterActionTypes.h- 对应职业的
action_list.lst和.act
这样能最快建立“输入 -> 状态 -> 动作 -> 动画 -> 位移”的完整认知。
如果后面架构继续扩展,建议再补两份专题文档:
- 一份讲
.act/ 帧事件 / cancel rule 设计 - 一份讲脚本如何接入状态生命周期