动画过渡中断:完成回调必须服从代际所有权

角色正从待机混合到奔跑时受击,玩法层立即要求受击动画接管;若先启动的奔跑混合稍后触发完成回调,并直接把逻辑状态写成 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) 身份合同,而不是把取消时机误当成一致性保证。