攻击窗口:Timer只能调度,不能定义一次技能事务

自动关联目录:攻击窗口:Timer只能调度,不能定义一次技能事务

核心判断:循环 Timer 可以决定“何时再检查一次”,一次性 Timer 可以提示“时间到了”,但攻击是否仍有效、由谁结算、如何取消以及表现何时收口,必须属于同一个有代际的攻击事务。

一、业务冲突:命中、状态和表现不是同一条时间线

短时范围攻击通常同时包含四件事:在有效窗口内重复查询命中、避免同一目标重复受击、驱动角色动作状态、播放手持模型和粒子表现。DS 负责命中事实,拥有端负责输入和部分状态,其他客户端只消费表现;取消、道具移除和 Actor 停用又可能在截止时间前发生。

因此“攻击持续一小段时间”并不是一个 Timer,而是一笔跨权威、状态机和表现层的事务。

二、实际实现:清理意识正确,终态仍被拆散

实际代码在使用入口清空已命中集合,创建临时表现 Actor,启动一个循环命中 Timer 和一个一次性结束 Timer,再绑定角色动作状态变化。时间结束只停止两只 Timer;角色状态回调负责恢复手持模型或销毁临时 Actor;停用路径则解绑角色事件、清理该对象全部 Timer 并销毁表现 Actor。

其中有三点值得保留:

  1. 循环与截止 Timer 使用成员句柄,重复设置不会无界叠加同一通道;
  2. 停用路径不假设截止回调一定发生,能够处理服务端先移除、客户端 Timer 被清掉的次序;
  3. 命中逻辑不依赖客户端角色状态,避免把表现状态误当成 DS 权威资格。

问题是攻击结束被分给了三个入口:Timer 结束命中查询,状态回调结束手持表现,停用回调兜底清理。任一路径单独完成,都不能证明本次攻击已经完整终止。后来命中实现迁到脚本层后,C++ Timer 还在驱动跨语言回调,事务身份却没有随调用一起传递,边界更加隐含。

三、失败机制:同一 Actor 活着,不代表还是同一次攻击

FTimerManager 在 Game Thread 上回调,解决了线程归属;成员句柄解决了定点取消;ClearAllTimersForObject 解决了对象停用后的兜底。它们都没有回答回调属于哪一次使用。

当连续使用、主动取消、状态纠正和对象复用交错时,旧回调可能面对同一个仍然有效的 UObject。此时弱指针不会失效,Timer 也无法判断旧表现是否该销毁、新攻击是否已开始。World Timer 还会受暂停和时间膨胀影响,不能直接充当跨端权威截止时间。

四、目标方案:用 AttackSession 收敛所有终态

struct FAttackSession {
    uint64 Generation;
    double AuthorityStartTime;
    double AuthorityEndTime;
    EAttackPhase Phase;
    FTimerHandle SampleTimer;
    FTimerHandle DeadlineTimer;
    FDelegateHandle OwnerStateHandle;
    TSet<FEntityId> HitTargets;
};

void StartAttack(const FAttackCommand& Command) {
    EndAttack(Current.Generation, EEndReason::Superseded);

    Current = MakeSession(++NextGeneration, Command.ServerTime);
    ScheduleSample(Current.Generation);       // DS 产生命中事实
    ScheduleDeadline(Current.Generation);     // 只产生截止信号
    StartPresentation(Current.Generation);    // Client 可预测、可撤销
}

void OnSample(uint64 Generation) {
    if (!IsCurrentActive(Generation)) return;
    ResolveAuthoritativeHits(Current);
}

void EndAttack(uint64 Generation, EEndReason Reason) {
    if (!IsCurrentActive(Generation)) return; // 幂等且拒绝旧代
    Current.Phase = EAttackPhase::Ending;
    ClearTimer(Current.SampleTimer);
    ClearTimer(Current.DeadlineTimer);
    UnbindOwnerState(Current.OwnerStateHandle);
    StopPresentation(Generation, Reason);
    Current.Phase = EAttackPhase::Ended;
}

截止、取消、状态纠正、道具移除、回池和 EndPlay 全部调用同一个 EndAttack。命中窗口以 DS 的会话时间为准;客户端 Timer 只用于及时收表现,权威快照到达后仍可纠正。脚本回调接收 Generation,而不是从 Actor 当前字段猜测调用身份。

五、取舍与验证

独立 Session 增加了少量状态和一次代际比较,但换来的是明确的幂等终态、跨语言身份和可解释日志。对于完全本地、不可重入的一次性表现,单个 Timer 仍然足够;只要涉及命中权威、对象复用或多入口取消,就应该升级为事务。

验证不能只等自然播放结束。应主动制造“启动后立即取消”“截止与状态纠正同帧发生”“旧回调晚于第二次启动”“Actor 停用后重新激活”“客户端表现先结束、DS 快照后到”等次序,并记录 Generation、EndReason、ActiveTimerCount、LateCallbackRejected。完成条件不是没有崩溃,而是每一代只有一个终态,终态后不再产生命中或残留表现。

六、反思

过去容易把两只 Timer 看成完整的攻击窗口,因为正常时序下它确实能工作。实际代码已经通过停用兜底和权威分离修掉了不少问题,下一步不是再补一个 Clear,而是承认“这次攻击”本身需要身份。Timer 是调度器,状态机是局部投影,真正应该拥有结束语义的是攻击事务。