动画过渡中断:完成回调必须服从代际所有权
角色正从待机混合到奔跑时受击,玩法层立即要求受击动画接管;若先启动的奔跑混合稍后触发完成回调,并直接把逻辑状态写成 Run,最终骨骼姿态与玩法状态就会分裂。证据标识 2026-08-03-1930 的最小 C# 探针把一次过渡表示为不可变代际令牌。
// .tmp/daily-labs/2026-08-03-1930/AnimationTransitionArbiter.cs
// TransitionToken / AnimationTransitionArbiter fields
public readonly record struct TransitionToken(
int Generation,
string From,
string To
);
public sealed class AnimationTransitionArbiter
{
private int generation;
private string current = "Idle";
private TransitionToken? pending;
}
所有权边界
AnimationTransitionArbiter 唯一拥有已提交状态、待提交令牌与单调代际;玩法层只能提出目标,混合器只负责采样权重和回报完成。两个不变量是:任一时刻至多一个令牌拥有提交权;完成回调只有与 pending 完全相等时才能发布最终状态。实际骨骼、PlayableGraph 节点和渲染插值位于边界外,不能反向决定玩法状态。

让每个混合回调直接写目标状态看似省掉仲裁层,却把状态所有权分散到按完成时间排序的异步回调。停止旧 Playable 也不充分:已经排队的回调、跨帧事件或包装层任务仍可能到达。令牌校验把正确性绑定到过渡身份,而不是依赖取消恰好及时。
代际提交
Begin 每次都增加代际,并以当前已提交状态作为来源。新令牌覆盖 pending,表示更高优先级请求已经取得唯一提交权。Complete 不识别动画名称或优先级,只比较完整令牌;匹配后先写当前状态,再清空待提交引用。
// .tmp/daily-labs/2026-08-03-1930/AnimationTransitionArbiter.cs
// Begin / Complete
public TransitionToken Begin(string target)
{
pending = new TransitionToken(
++generation,
current,
target
);
return pending.Value;
}
public bool Complete(TransitionToken token)
{
if (pending != token) return false;
current = token.To;
pending = null;
return true;
}
正常时序是 Idle→Run 获取第 1 代并完成,状态提交为 Run。中断时序中,第 2 代 Idle→Hit 在第 1 代完成前覆盖提交权;旧回调被拒绝,新回调提交 Hit。来源仍为 Idle,因为 Run 从未成为已提交状态,这一点阻止未完成姿态污染逻辑历史。
中断回归
回归用例固定制造最危险顺序:开始奔跑过渡,随后开始受击过渡,再让奔跑先完成。若实现只比较目标名或只保存布尔 isTransitioning,第一个回调就可能清空第二个请求。测试同时覆盖正常完成、陈旧完成和重复完成。
// .tmp/daily-labs/2026-08-03-1930/AnimationTransitionArbiter.cs
// interrupted completion is rejected
Test("interrupted completion is rejected", () =>
{
var arbiter = new AnimationTransitionArbiter();
var run = arbiter.Begin("Run");
var hit = arbiter.Begin("Hit");
True(!arbiter.Complete(run));
True(arbiter.Complete(hit));
});
Test("duplicate completion is rejected", () =>
{
var arbiter = new AnimationTransitionArbiter();
var run = arbiter.Begin("Run");
True(arbiter.Complete(run));
True(!arbiter.Complete(run));
});

固定行为
探针使用 .NET 9 标准库且没有外部包。Release 构建为 0 警告、0 错误;三个用例通过,失败 0、跳过 0,测试逻辑耗时 6.284 毫秒,完整运行进程耗时 1.21 秒,退出码为 0。时间仅说明本次证据执行完成,不支持帧耗或吞吐结论。
$ cd "$WORK_DIR"
$ dotnet build .tmp/daily-labs/2026-08-03-1930/AnimationTransitionProbe.csproj \
-c Release --no-restore
Build succeeded.
0 Warning(s)
0 Error(s)
$ /usr/bin/time -p dotnet run \
--project .tmp/daily-labs/2026-08-03-1930/AnimationTransitionProbe.csproj \
-c Release --no-build --no-restore
PASS latest transition commits
PASS interrupted completion is rejected
PASS duplicate completion is rejected
tests=3 passed=3 failed=0 skipped=0 duration_ms=6.284
real 1.21
user 0.28
sys 0.09
exit_code=0
{
"evidenceId": "2026-08-03-1930",
"locomotion": {
"Generation": 1,
"From": "Idle",
"To": "Run"
},
"reaction": {
"Generation": 2,
"From": "Idle",
"To": "Hit"
},
"staleAccepted": false,
"reactionAccepted": true,
"state": {
"generation": 2,
"current": "Hit",
"pending": null
}
}
容量边界
开始与完成都是常数时间,仲裁器只保存一个待提交令牌,空间为 O(1)。当前合同适用于单角色、单状态层、串行调用入口;它不处理上半身与下半身并行层,也不定义动画采样曲线。若角色需要两个以上独立层、同帧请求由多线程产生,或完成回调必须跨网络传输,演进触发点就是把代际扩展为 (layer, epoch, generation),并让请求先进入确定性队列。
正常:begin gen 1 -> blend -> complete gen 1 -> current Run
中断:begin gen 1 -> begin gen 2 -> reject gen 1 -> complete gen 2
当前边界:单角色、单状态层、单线程提交入口
拒绝信号:stale completion count > 0
演进结构:layer key + graph epoch + generation + deterministic queue
为每个动画层各设一个仲裁器比全局序列号更清楚:全局号会让互不冲突的面部与移动层互相取消;完全取消旧图则增加图重建和生命周期协调。当前取舍允许旧混合自然退出,但剥夺其状态提交权,并把陈旧完成计数作为优先级抖动或回调延迟的诊断信号。
架构结论
动画混合的视觉采样权与玩法状态的提交权必须分离。中断请求通过新代际取得提交权,完成回调只能携令牌回到唯一仲裁入口;旧回调可以到达,但不能改变状态。该边界解决单层过渡竞态,并向多层动画图交付可扩展的 (layer, epoch, generation) 身份合同,而不是把取消时机误当成一致性保证。