动画过渡打出两次伤害:事件身份应来自逻辑动作,不是 Clip
一次攻击从 attack-a 淡入 attack-b,画面只是更顺滑,伤害计数却从 1 变成 2。两个 Clip 都在挥剑接触点放了 damage 标记;过渡窗口内,它们都可能被动画系统求值。若事件监听器收到回调就直接扣血,表现层的一次混合会悄悄改变玩法结果。

问题不在于哪个 Clip 的权重更大。用 0.5 作为派发阈值,会让事件结果依赖过渡曲线、层混合和帧采样位置;把阈值调高只能换一种漏发。一次攻击是否已经结算,必须由发起这次攻击的逻辑动作回答。
两个动画回调需要共享一个事件身份
探针固定动作序号为 100。源片段在归一化时间 0.62 报告命中,目标片段在 0.38 报告同一命中。两者的片段名和时间不同,但它们都来自逻辑上的第 100 次动作,因此共享事件戳 (100, damage, 0)。
AnimationEventDispatcher.cs 中,EventStamp 由动作序号、事件名和发生序号组成。Clip 与归一化时间保留在候选数据里用于诊断,却不参与相等性。TryDispatch 只让账本中第一次出现的事件戳获得玩法副作用。
public readonly record struct EventStamp(
long ActionSequence,
string Marker,
int Occurrence);
public readonly record struct AnimationEventCandidate(
EventStamp Stamp,
string Clip,
float NormalizedTime);
public enum DispatchResult
{
Accepted,
Duplicate,
StaleAction
}
public sealed class AnimationEventDispatcher
{
private readonly HashSet<EventStamp> _dispatched = [];
public long ActiveActionSequence { get; private set; }
public int GameplayEffectCount { get; private set; }
public void BeginAction(long actionSequence)
{
if (actionSequence <= ActiveActionSequence)
throw new ArgumentOutOfRangeException(nameof(actionSequence));
ActiveActionSequence = actionSequence;
_dispatched.RemoveWhere(stamp =>
stamp.ActionSequence < actionSequence);
}
public DispatchResult TryDispatch(AnimationEventCandidate candidate)
{
if (candidate.Stamp.ActionSequence != ActiveActionSequence)
return DispatchResult.StaleAction;
if (!_dispatched.Add(candidate.Stamp))
return DispatchResult.Duplicate;
GameplayEffectCount++;
return DispatchResult.Accepted;
}
}
这里不能把 Clip 加进键。源片段与目标片段名称不同正是重复产生的原因,把名称加入键只会把同一次命中合法化两次。归一化时间也不适合作键:压缩、重定时和过渡采样会改变回调时间,却不应产生新的玩法事件。
发生序号解决另一侧边界。循环奔跑中的 footstep 可以在同一动作内发生多次,Occurrence 从 0 递增,使每一步都能派发;一次攻击只有一个命中窗口时,它保持为 0。去重范围由玩法语义决定,而不是统一压成“每个事件名永远一次”。
新动作开始后,迟到回调不能追上当前状态
跨淡入淡出重复不是唯一风险。动作 101 已经开始后,动作 100 的源片段仍可能在这一帧末尾送达回调。若账本只保存“见过哪些标记”,迟到事件可能在旧记录被清理后重新生效。
BeginAction 先推进 ActiveActionSequence,TryDispatch 再要求候选序号严格相等。于是旧动作的回调得到 StaleAction,没有机会触碰玩法效果。序号必须由拥有攻击生命周期的玩法系统分配;动画状态机只能携带和回传它,不能按 Clip 播放次数自行生成。

Program.cs 的失败回归先启动动作 100,再推进到 101,随后故意提交旧命中:
var dispatcher = new AnimationEventDispatcher();
dispatcher.BeginAction(100);
dispatcher.BeginAction(101);
var stale = dispatcher.TryDispatch(new(
new EventStamp(100, "damage", 0),
"attack-a",
0.62f));
Equal(DispatchResult.StaleAction, stale);
Equal(0, dispatcher.GameplayEffectCount);
这条边界也说明为什么清理旧账本是安全的:旧动作先被活动序号阻断,删除旧事件戳只回收内存,不会重新开放派发资格。当前探针只保留单个活动动作;若系统允许多个并行动作通道,例如上半身射击与下半身移动,应让每个通道拥有独立活动序号,或把通道标识加入事件戳,而不是共用一个全局当前值。
固定输出把动画采样与玩法结算分开
当天使用 Release 配置执行:
dotnet build $LAB_ROOT/AnimationEventProbe.csproj -c Release
dotnet run --project $LAB_ROOT/AnimationEventProbe.csproj -c Release --no-build
构建退出码为 0,0 警告、0 错误,耗时 7.20 秒;测试进程退出码为 0,4 项通过、0 项失败、0 项跳过,测试计时 29.2848 毫秒,进程墙钟时间 2.32 秒。决定性输出如下:
crossfade: action=100 source=Accepted target=Duplicate effects=1
stale: active=101 candidate=100 result=StaleAction effects=0
tests: passed=4 failed=0 skipped=0 elapsedMs=29.2848
另外两项测试证明循环发生序号 0 和 1 都能派发,以及新动作 101 可以再次使用 damage, 0。因此去重没有把事件永久锁死,也没有把合法循环误判为重复。
这份实现约束的是 CPU 侧玩法事件入口,不声称改变动画求值成本,也不处理网络回滚。接入预测或回滚时,ActionSequence 还需要绑定可重放的逻辑帧或命令编号,副作用账本也应进入回滚状态;仅在本地生成递增值无法跨重演保持身份稳定。纯视觉的脚步尘、拖尾或音效可以采用更宽松策略,但扣血、弹药消耗和技能阶段推进必须经过拥有动作序号的玩法层。
回到那次被计算两遍的攻击:源 Clip 和目标 Clip 都可以诚实地报告自己经过了标记,错误发生在接收方把“动画采样到一次”误当成“玩法事件新发生一次”。保留两份回调作为诊断事实,用同一个逻辑事件戳裁决派发资格,过渡曲线就只影响姿态,不再改变战斗结果。