角色状态:状态名不能同时充当物理事实和网络协议
自动关联目录:角色状态:状态名不能同时充当物理事实和网络协议
核心判断:接地、速度等物理事实,跳跃等玩法意图,层级状态,UE MovementMode,动画意图和网络确认必须是不同数据。状态机负责在事实与意图上做确定性归约;网络策略属于转换边,带副作用的提交还必须区分本地预测、服务端权威和远端重放。
一、业务冲突:一次“落地”暴露了状态、物理与网络混在一起
角色需要支持站立、移动、跳跃、自由落体、游泳、死亡和可扩展自定义状态。最初看,把这些枚举放进一棵状态树,再给每个目标状态配置“客户端预测”“客户端请求”或“仅服务端”似乎已经足够。
实际故障发生在自由落体到落地这条边:服务端能根据物理结果自主进入自由落体,但落地状态被配置为只接受客户端请求。若拥有者晚加入,错过了生成时的坠落过程,它不会再上报落地;服务端和其他观察者便长期停留在空中状态,即使 UE 的角色移动组件早已接地。问题不在落地检测,而在协议把“谁能进入目标状态”误当成了“这条转换由谁确认”。
同类冲突还出现在游泳。状态机从游泳父状态切到某个子状态时,如果只比较父状态名,系统会把它误判为离开游泳树,立刻把 UCharacterMovementComponent 切回 Falling,自定义水中物理随之停止。由此可见,字符串状态既不能替代物理事实,也不能可靠表达层级成员关系。
二、实际实现:不可变物理快照驱动层级状态,副作用在提交阶段发生
当前实现已经把每帧接地、竖直速度、输入强度、前一帧空中状态、速度和脚下对象整理成只读快照,明确不写进通用参数表。服务端和本地权威角色从 UE Movement/碰撞结果构造快照,层级状态机只读取它;转换规则包含来源、目标、优先级和条件,同帧多条规则命中时只选择最高优先级,再检查旧状态能否退出和新状态能否进入。
一次提交严格执行 旧状态退出 -> 更新当前状态与活动路径 -> 新状态进入 -> 发布状态变化。如果脚本在进入或退出回调里再次请求转换,新请求只进入队列,当前事务结束后重新校验,避免遍历状态树时修改状态树。跳跃输入则是一帧脉冲,完成本轮规则求值后才消费。
状态进入回调目前也承载部分业务事务:跳跃状态施加冲量,死亡状态统一提交死亡标志,进入或离开游泳状态树切换 UE MovementMode。网络重放跳跃时会显式抑制再次施加冲量;状态变化再映射成通用动画状态名,由角色表现层触发动画过渡,动画实例并不知道“死亡”或“游泳”的玩法含义。
flowchart LR
P[UE 物理事实快照] --> R[转换规则归约]
I[玩法意图与输入序号] --> R
R --> S[权威层级状态]
S --> M[MovementMode 投影]
S --> A[动画意图投影]
S --> N[网络状态快照]
S --> E[受执行上下文约束的副作用]三、值得保留:状态机已经具备“可归约、可重入、可恢复”的骨架
物理快照与参数表分离是最重要的正确选择。接地和速度是本帧观察事实,不应被动画参数或脚本属性反向覆盖。转换优先级和 CanExit/CanEnter 让冲突在一个位置归约,比各组件看到条件后自行 SetState 更容易解释。
层级活动路径也值得保留。父状态负责共享进入、退出和 MovementMode,叶状态只表达细分行为;修复游泳问题时从“只看父节点”改为“判断整棵子树成员关系”,方向是正确的。转换期间延迟重入请求、提交后重新校验,则解决了脚本回调可能销毁对象或改变条件的 UE 生命周期风险。
网络侧已经有输入序号、旧确认拒绝、网络应用保护和初次对账重试。拥有者收到自己预测请求的确认时可以只绑定权威身份而不重放物理副作用;远端或服务端修正进入跳跃时也能禁止第二次冲量。这些机制说明“状态一致”与“副作用只执行一次”已被当作两个问题处理。
四、结构性缺陷:策略挂在状态上,转换语义仍然不完整
最大问题是网络策略存于目标状态。自由落体 -> 落地 与 游泳 -> 落地 可能拥有完全不同的发起者、预测资格和超时接管规则,单个 Landed 状态无法同时表达这些边。后来增加的服务端超时接管能修已知故障,但它仍是对目标状态策略的补丁。
第二,层级成员关系仍有通过名字前缀推断的痕迹。名字是调试标签,不是不可变分类;重命名、创作者自定义状态或同名前缀都可能改变 MovementMode。第三,显式请求路径构造的物理快照可能为空,而 Tick 规则使用完整快照,同一 CanEnter 条件因调用入口不同得到不同结果。
第四,副作用仍和 OnEnter 绑得太紧。跳跃冲量需要额外的“这次网络应用不要执行”标志;状态、死亡布尔值、游泳布尔值、MovementMode 和动画状态又是多份投影,纠错代码必须逐一修正。根因是状态提交、权威确认和副作用执行没有统一事务 ID,也没有说明每个副作用是“预测可执行”“仅权威执行”还是“所有端表现执行”。
五、目标设计:转换边产生决定,提交器按上下文执行一次
目标状态机应把输入拆成 PhysicalSnapshot、GameplayIntent 和 AuthoritativeSnapshot,把规则编译成显式边。边声明来源集合、目标、优先级、预测策略、确认策略和副作用策略;状态定义只保留层级、进入约束和标签。MovementMode 与动画都是权威状态的投影,不参与状态真相的竞争。
TransitionResult Reduce(State current, PhysicalSnapshot facts,
Intent intent, ExecutionContext context)
{
Candidate best = SelectHighestPriorityEdge(current, facts, intent);
if (!best || !best.Guard(facts) || !best.Policy.Allows(context))
return NoChange;
TransitionTxn tx{NewTransitionId(), current, best.Target, context};
State next = ApplyPureTransition(tx); // 不操作 MovementComponent
Commands effects = BuildEffects(tx, facts); // JumpImpulse、MovementMode、事件
return {next, effects, BuildNetworkDelta(tx, intent.Sequence)};
}
void Commit(TransitionResult r)
{
if (!Ledger.Seen(r.TransitionId))
ExecuteAllowedEffects(r.Effects); // 按预测/权威/表现策略过滤
PublishState(r.State);
ProjectMovementMode(r.State.Tags);
PublishAnimationIntent(MapToAnimation(r.State));
}状态树成员关系应使用编译后的父索引或 GameplayTag,而不是字符串前缀。所有入口都必须携带同一结构的物理快照;没有事实时返回 Deferred/Unknown,不能把默认零值当作真实未接地。网络快照至少携带状态代际、确认的输入序号和转换 ID,使修正既能替换状态,也能对副作用去重。
六、决策规则:先问“谁知道事实”,再问“谁能提交转换”
状态机设计应遵守四条规则:物理由拥有模拟权的一端产生事实;玩法层只提交意图;转换边声明预测与确认;动画只消费结果。GA 一类能力系统可以承载技能生命周期,但不能替代接地、移动模式和网络纠错的角色状态归约器。
| 场景 | 预期决策 | 必须验证 |
|---|---|---|
| 客户端预测跳跃,服务端确认 | 状态可先预测,物理冲量每端至多一次 | 输入序号、转换 ID、副作用账本 |
| 服务端自主检测坠落后落地 | 边允许权威提交,不依赖迟到客户端 | 服务端最终状态与 MovementMode |
| 父状态切换到子状态 | 仍属于同一层级标签,不触发离树副作用 | 活动路径、MovementMode 不抖动 |
| 显式请求缺少物理事实 | 延迟或在权威端补全,不能用空快照通过 | Guard 的 Unknown 分支 |
| 旧状态包晚到 | 代际或输入序号拒绝,副作用不重放 | 状态、冲量、事件均不回退 |
| 自定义状态卸载后旧回调到达 | 注册代际失效,脚本引用已释放 | 无悬垂回调与重复转换 |
这段实践最值得反思的不是“状态机应该分层”,而是网络化角色状态的最小单位其实是一条转换边:它连接事实、意图、权威和副作用。把策略放在状态名上很省配置,但一旦同一状态有多条不同权威路径,协议就会失真。更好的架构不是增加更多特殊判断,而是让每条边把自己的提交条件和副作用语义说完整。