动画事件提交:幂等键必须包含实例、循环与帧
同一动画帧可能因混合树求值、回滚重放或状态重入多次触发回调。若脚步声、尘土和伤害窗口直接消费回调,重复求值就会变成重复副作用。2026-07-29-2000 探针把回调先写入幂等队列,再按循环号提交;事件源可以重投,表现副作用只能交付一次。
// .tmp/daily-labs/2026-07-29-2000/Program.cs
readonly record struct AnimationEvent(
long ClipInstance,
int Loop,
int Frame,
string Kind);
readonly record struct EventKey(
long ClipInstance,
int Loop,
int Frame,
string Kind);

幂等边界
队列拥有待交付事件、已接受键集合和最后提交循环号;Animator 或 Playable 只负责生产事实,音频、特效与玩法系统只消费提交结果。键不能只用帧号:两个动画实例的第 12 帧不是同一事件,同帧的脚步与尘土也不能互相覆盖。由实例、循环、帧、类型组成的键保证重复定义足够窄。
ClipInstance 必须在一次播放生命周期内稳定,并在重新播放时换代。若只使用资源 ID,角色第二次播放同一片段会与第一次共享键;若使用对象引用,跨回放序列化又无法复算。单调实例号同时满足运行时隔离与证据重建。
// Program.cs: AnimationEventQueue.Enqueue
public bool Enqueue(AnimationEvent item)
{
if (item.Loop < committedLoop)
return false;
var key = new EventKey(
item.ClipInstance,
item.Loop,
item.Frame,
item.Kind);
if (!accepted.Add(key))
return false;
pending.Enqueue(item);
return true;
}
循环提交
正常时序是动画求值、事件入队、幂等键判重、循环完成、批量交付。提交循环 0 时,循环 1 的事件仍留在队列;这使预取和重放不会提前播放未来脚步。提交号不得回退,因为一旦副作用已经交付,重新开放旧循环会破坏“至多一次”不变量。
// Program.cs: AnimationEventQueue.CommitLoop
public IReadOnlyList<AnimationEvent> CommitLoop(int loop)
{
if (loop < committedLoop)
throw new InvalidOperationException("loop rollback");
var result = new List<AnimationEvent>();
while (pending.Count > 0 && pending.Peek().Loop <= loop)
result.Add(pending.Dequeue());
committedLoop = loop;
accepted.RemoveWhere(k => k.Loop < committedLoop);
return result;
}

失败时序
失败路径有两种。相同键再次到达时,HashSet.Add 返回 false,队列长度不变;旧循环事件迟到时,在进入集合前直接拒绝。看似简单的“消费后立刻删除所有键”会让同一循环的迟到重复再次进入,因此只清除严格早于当前提交循环的键,当前循环仍承担去重窗口。
// Program.cs: 迟到与未来循环回归
Check("committed_loop_rejects_late_event", () =>
{
var q = new AnimationEventQueue();
q.CommitLoop(2);
if (q.Enqueue(new(41, 1, 12, "footstep")))
throw new Exception("late accepted");
});
Check("future_loop_waits_for_commit", () =>
{
var q = new AnimationEventQueue();
q.Enqueue(new(41, 1, 12, "footstep"));
if (q.CommitLoop(0).Count != 0 || q.PendingCount != 1)
throw new Exception("early dispatch");
});
固定输入
固定流包含四次回调:循环 0 的脚步重复两次、同帧尘土一次、循环 1 脚步一次。预期结果是接受三次、抑制一次;提交循环 0 交付两次,未来循环保留一次。该数据同时证明“同帧不同类型保留”和“同键重复抑制”没有混为一谈。
// Program.cs: Probe.Main 固定流
var stream = new[]
{
new AnimationEvent(41, 0, 12, "footstep"),
new AnimationEvent(41, 0, 12, "footstep"),
new AnimationEvent(41, 0, 12, "dust"),
new AnimationEvent(41, 1, 12, "footstep")
};
var accepted = stream.Count(fixedQueue.Enqueue);
var loop0 = fixedQueue.CommitLoop(0);
if (accepted != 3) throw new Exception("accepted mismatch");
if (loop0.Count != 2) throw new Exception("dispatch mismatch");
if (fixedQueue.PendingCount != 1) throw new Exception("pending mismatch");
执行结果
本次 .NET 9 Release 运行完成四个断言。5.961 ms 包含断言、集合操作与证据文件写入,不代表 Unity 主线程成本;本文只据此确认固定输入的状态变化和失败语义。
cd $WORK_DIR/.tmp/daily-labs/2026-07-29-2000
dotnet run -c Release
# exit code: 0
PASS duplicate_callback_is_suppressed
PASS same_frame_different_kind_survives
PASS committed_loop_rejects_late_event
PASS future_loop_waits_for_commit
FIXED callbacks=4 accepted=3 suppressed=1 dispatched=2 pending=1
RESULT passed=4 failed=0 skipped=0 elapsed_ms=5.961 exit=0
演进条件
当前实现假设事件按循环号进入 FIFO;乱序网络回放会让队首未来事件阻塞其后的可提交事件。发现乱序输入或单实例队列持续积压时,应改为按循环分桶,并为跨帧玩法事件增加确认协议。纯表现事件仍可使用本队列,因为它避免把动画求值次数错误地解释为副作用次数。
队列容量也需要监测。若单实例长期保留多个未来循环,说明生产者预取范围与提交水位失衡;此时应拒绝超出窗口的事件并记录实例号,而不是无限扩大集合。伤害等玩法事件还需要由逻辑帧确认,不能仅凭表现循环提交。
架构判断是:动画系统拥有采样时刻,不拥有副作用提交权。把幂等键与循环水位放在独立队列中,才能让重放、混合和状态重入保持可恢复,同时不吞掉同帧的不同语义事件。