动画过渡打出两次伤害:事件身份应来自逻辑动作,不是 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 先推进 ActiveActionSequenceTryDispatch 再要求候选序号严格相等。于是旧动作的回调得到 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 都可以诚实地报告自己经过了标记,错误发生在接收方把“动画采样到一次”误当成“玩法事件新发生一次”。保留两份回调作为诊断事实,用同一个逻辑事件戳裁决派发资格,过渡曲线就只影响姿态,不再改变战斗结果。