动画越过循环点时,脚步事件不能只比较归一化时间

一段循环跑步动画从归一化时间 0.92 前进到 0.08,右脚事件位于 0.95,左脚事件位于 0.05。若事件系统直接判断 eventTime > previous && eventTime <= current,当前时间小于上一时间,两个脚步声都会被跳过。把区间拆成 (0.92, 1.0][0.0, 0.08] 能补回事件,却仍没有回答另一个问题:异步动画任务重试并再次提交同一区间时,谁阻止两次脚步声再次进入玩法和音频系统?

循环跑步在两次落脚之间保持连续

这里需要保存的不是一个会周期回绕的小数,而是一条只向前推进的事件提交边界。研究探针把一圈定义为 1000 tick,两个标记分别位于 50950。播放从 920 前进到 1080,第二圈的左脚事件自然表示为绝对位置 1050,不再需要为循环点编写另一套包含关系。

展开时间让区间只有一种解释

LoopEventTrack.Sample 接受半开半闭区间 (fromTick, toTick]。左开避免连续两帧重复消费上一帧终点,右闭保证事件恰好落在当前终点时被本帧接收。每个标记的绝对位置为:

absoluteTick = cycle * durationTicks + marker.Tick

核心实现位于研究探针 Program.csLoopEventTrack.SamplePlaybackEventOwner.Commit

public EventBatch Sample(PlaybackToken token, long fromTick, long toTick)
{
    if (toTick < fromTick)
        throw new ArgumentOutOfRangeException(nameof(toTick));

    var hits = new List<EventOccurrence>();
    var firstCycle = Math.DivRem(fromTick, durationTicks, out _);
    var lastCycle = Math.DivRem(toTick, durationTicks, out _);

    for (var cycle = firstCycle; cycle <= lastCycle; cycle++)
    {
        foreach (var marker in events)
        {
            var absoluteTick = checked(cycle * durationTicks + marker.Tick);
            if (absoluteTick > fromTick && absoluteTick <= toTick)
                hits.Add(new EventOccurrence(marker.Name, absoluteTick));
        }
    }

    return new EventBatch(token, fromTick, toTick, hits.ToArray());
}

public CommitResult Commit(EventBatch batch)
{
    if (batch.Token != activeToken)
        return CommitResult.StalePlayback;
    if (batch.ToTick <= committedTick)
        return CommitResult.AlreadyCommitted;
    if (batch.FromTick != committedTick)
        return CommitResult.CursorGap;

    delivered.AddRange(batch.Events);
    committedTick = batch.ToTick;
    return CommitResult.Committed;
}

采样器只生产候选批次。PlaybackEventOwner 才拥有活动播放代次、已提交游标和已交付事件,因此它能区分三种表面相似的完成结果:旧动画实例的结果返回 StalePlayback;同一批次重试返回 AlreadyCommitted;跳过中间区间的批次返回 CursorGap。三者都不能修改游标或产生副作用。

循环正确不等于提交正确

仅用展开时间可以找全事件,却不能单独解决异步完成乱序。角色在槽位 2 上由播放代次 7 切到 8 后,代次 7 的采样结果即使区间合法,也已经不再描述当前动作。令牌把“哪个角色槽位”和“哪次播放”组成一个身份;精确的 batch.FromTick == committedTick 则保证批次之间没有空洞。

这条检查也解释了为什么不能收到较新的 toTick 就直接覆盖游标。若 920..1000 尚未完成,而 1000..1080 先返回,先提交后者会永久丢掉 FootRight@950。拒绝 CursorGap 之后,调度层可以等待缺失批次,或取消整次播放并开启新代次,但状态所有者不会猜测中间发生了什么。

展开时间采样与播放所有者的提交门

空区间同样必须提交。100..200 没有任何标记,但游标仍需前进到 200;否则下一批合法结果会被误判为空洞。事件数量不是提交是否成立的依据,连续区间才是。

固定样本同时暴露漏报与重复

探针使用当前环境的 .NET 9 Release 配置,不引入外部包。执行命令与决定性输出如下:

cd "$LAB_DIR"
dotnet build AnimationEventCursorLab.csproj -c Release --no-restore
dotnet run --project AnimationEventCursorLab.csproj -c Release --no-build
Build succeeded.
    0 Warning(s)
    0 Error(s)
RESULT range=920..1080 events=FootRight@950>FootLeft@1050 first=Committed retry=AlreadyCommitted cursor=1080
TESTS passed=8 failed=0 skipped=0 elapsed_ms=24

构建进程退出码为 0,耗时 6.27 秒;测试进程退出码为 0,耗时 1.15 秒。八个测试覆盖同圈命中、跨圈双事件、重试幂等、旧代次拒绝、游标空洞、无事件推进、倒退区间和非法标记。固定结果证明:跨圈采样没有漏掉任一落脚点,同批重试也没有把已交付数量从 2 增加到 4

当前实现只处理正向循环播放。反向播放需要重新定义区间方向和同 tick 的排序;乒乓播放还要把方向纳入事件身份;玩法回滚则必须决定已产生副作用如何撤销,不能把 AlreadyCommitted 当成回滚协议。对正向、可异步采样的循环动画而言,展开 tick 解决“事件在哪里”,播放令牌与精确游标解决“结果现在还能不能提交”。这两个职责分开后,循环点不再是特殊分支,重试也不再等于重复触发。