循环动画事件跨帧触发:取模后的相位会丢掉整圈

一段循环动作每 1000 tick 重复一次,在 0、250、750 处分别放着落脚、命中和拖尾标记。播放时间从 900 推进到 2250,画面仍能采到正确姿态,但事件系统只报告落脚和命中各一次。它把两端取模为 900 和 250,再按“跨过片尾”查询标记;中间完整经过的一圈已经从输入里消失了。

循环动作与展开时间轴中的重复事件标记

图中把循环片段展开,说明相同姿态可以出现在不同圈次,不承载精确时间测量。本文使用独立 C# 参考核验证事件枚举,运行环境为 .NET 9;没有调用 Unity Animator,也不推断某个引擎内置事件的行为。tick 是资产和播放时钟共同约定的整数单位,本例只固定每圈长度,不假定它等于毫秒或渲染帧。

同一个标记,在这次推进里出现了两次

保留绝对时间后,结果可以逐项列出:1000 foot、1250 hit、1750 trail、2000 foot、2250 hit。两次落脚虽然来自同一标记,却属于不同循环;用标记 ID 做整批去重同样会删掉合法事件。

设周期为 L,标记偏移为 m,第 k 圈的发生时刻为 k*L+m。要查询的对象是所有落在 (previous,current] 内的发生记录。偏移限定在 [0,L),片尾 L 不再放一份与下一圈零点同义的标记。圈次从零开始;启动时刻零不会被 (0,0] 自动触发,若产品要求入场即落脚,应由播放入口单独定义。

为什么左边不包含、右边包含?把主例拆成 (900,1250] 和 (1250,2250],1250 的命中归第一次推进,第二次从它之后继续。两段首尾相接,既没有空洞,也没有交叠。若两侧都包含,恰好停在标记上的帧会触发两次;两侧都排除,则该标记可能永远收不到。

这项性质依赖调用方传入连续区间。重复调用同一个区间仍会得到同一批记录,函数没有隐含的已消费集合。让查询结果可重算,也意味着播放控制器必须明确保存消费到哪里。

先算这批有多大,再返回完整记录

AnimationEvents.cs 的 Collect 从上一时刻所在圈计算每个标记的候选发生时刻,若候选不晚于上一时刻,就推进一圈。首项一旦确定,命中数就是 1+(current-first)/L;首项超过当前时刻时计零。这个写法直接覆盖多圈,无须把“没过片尾、过一次、多次”拆成三套查询。

下面是完整参考实现,调用链为 Program.cs → Read → AnimationEvents.Collect。MarkerIndex 保留资产顺序,使同一 tick 的不同标记有确定次序。输入标记集合在一次调用期间必须保持不变。

public readonly record struct Marker(long Tick, string Id);
public readonly record struct Occurrence(long Tick, long Loop, int MarkerIndex, string Id);

public static class AnimationEvents
{
    public static List<Occurrence> Collect(long previous, long current, long period,
        IReadOnlyList<Marker> markers, int budget)
    {
        if (previous < 0 || current < previous || period <= 0 || budget < 0)
            throw new ArgumentException("时间必须非负正向,周期为正,预算非负");
        ArgumentNullException.ThrowIfNull(markers);
        var windows = new List<(Marker Marker, int Index, Int128 First, Int128 Count)>();
        var ids = new HashSet<string>(StringComparer.Ordinal);
        Int128 total = 0;
        for (int i = 0; i < markers.Count; i++)
        {
            Marker marker = markers[i];
            if (marker.Tick < 0 || marker.Tick >= period ||
                string.IsNullOrWhiteSpace(marker.Id) || !ids.Add(marker.Id))
                throw new ArgumentException("标记必须位于周期内且标识唯一");
            Int128 first = (Int128)(previous / period) * period + marker.Tick;
            if (first <= previous) first += period;
            Int128 count = first > current ? 0 : ((Int128)current - first) / period + 1;
            total += count;
            windows.Add((marker, i, first, count));
        }
        // 先完整核算,不在容量不足时交付半批事件。
        if (total > budget) throw new InvalidOperationException("事件批次超过预算");
        var result = new List<Occurrence>((int)total);
        foreach (var window in windows)
        {
            for (Int128 n = 0; n < window.Count; n++)
            {
                long tick = (long)(window.First + n * period);
                result.Add(new(tick, tick / period, window.Index, window.Marker.Id));
            }
        }
        result.Sort((a, b) => a.Tick != b.Tick
            ? a.Tick.CompareTo(b.Tick) : a.MarkerIndex.CompareTo(b.MarkerIndex));
        return result;
    }
}

容量判断放在记录分配之前。主例需要 5 条,预算为 4 时整批拒绝;预算为 5 时全部返回。若先发出前四条,再发现第五条放不下,调用方很难分辨应该重试整段还是只补尾部。这个参考核只返回数据,不直接调用音效、伤害或特效回调,因此拒绝不会留下已经执行一半的副作用。

这里使用 Int128 保存候选时刻和计数。即使 previous 接近 long.MaxValue,计算“下一圈”的中间值也不先绕回负数;只有已确认落在输入时间区间内的时刻才转回 long。这是 .NET 9 参考核的明确依赖,接入其他运行时需要重新验证整数类型与溢出策略,不能把代码当作所有 Unity 版本均可直接编译的组件。

若有 M 个标记、返回 K 次发生,当前实现的时间复杂度为 O(M+K log K),额外存储为 O(M+K)。预算限制输出记录数,不限制资产本身的标记数,也不保证帧耗时;资产规模应在导入时另行约束。

固定区间的五次事件、分帧边界归属与容量拒绝

逐 tick 参照,检查的不只是五个数字

MultiLoopPhaseComparisonLosesThreeOccurrences 同时断言完整输出为五条、只看相位的错误输出为两条。测试通过表示这个漏报反例被成功复现,并非朴素算法已经正确。CycleSeamOwnedByArrival 检查到达 1000 时触发落脚,而从 1000 继续出发不会再触发一次;零推进、反向输入、非法偏移、重复标识和相同时刻的排序也分别覆盖。

ExhaustiveTickOracle 遍历周期 1 至 12,在三圈内组合所有整数区间。参照实现逐 tick 前进,再用取余判断该 tick 是否对应标记,不复用待测的首项与计数公式。另一个测试 PartitionPreservesCompleteSequence 把同一区间从不同位置切开,比较拼接后的完整记录,包括时间、圈次、标识与顺序。

2026-09-26 在包含上述源码的 $LAB_ROOT 中执行:

dotnet build "$LAB_ROOT/EventLab.csproj" -c Release
dotnet run --project "$LAB_ROOT/EventLab.csproj" -c Release --no-build
build: exit=0 warnings=0 errors=0 elapsed=9.25s
run: exit=0 passed=14 failed=0 skipped=0 elapsedMs=234.2775
exhaustiveCases=3288 partitionCases=6425
(previous,current]=(900,2250] period=1000
occurrences=1000:foot,1250:hit,1750:trail,2000:foot,2250:hit
naiveIds=foot,hit
budget=4: rejected; budget=5: count=5

运行耗时包含断言、穷举和分帧检查,不包含构建与 JSON 输出,不用于推断实际游戏帧成本。长整型上界测试还覆盖了最后两个可表示 tick,以及跨度巨大但预算很小的拒绝;它验证的是计算不会溢出,不意味着可以把任意长的暂停积压一次性播放出来。

回调是否执行过,需要另一份身份

对于必须保留的事件,超预算后控制器应保留消费游标,选择增加容量或分段追赶;如果业务决定跳过暂停期间的脚步声,则应显式提交跳过范围。不能在查询失败后悄悄把游标写成当前时刻,否则完整枚举也救不回已经丢弃的区间。跨多次调用的队列消费和副作用提交没有在本次参考核中实现。

同样,回滚重放、重新进入动作和混合树多路播放,都需要在发生记录外补充播放实例身份。可用“播放实例、资产版本、圈次、标记 ID”区分应当重试的同一事件与新播放的事件;具体去重寿命和伤害权威仍归业务系统决定。一个名为 hit 的表现标记不会因枚举准确就自动获得战斗结算权限。

本文只覆盖非负、正向、连续推进的时间。编辑器拖动进度、反向播放和热换片段应采用独立操作,不能都伪装成普通推进;从浮点秒换算 tick 时也需要保留余量,否则上游每帧取整仍会造成时间漂移。开头缺少的三条事件,靠保留圈次和区间边界已经找回。运行时接口还应把“完整查询一段时间”与“按业务策略消费这批记录”分开,让是否补播成为明确决策。