07-Ability生命周期与异步任务
第 7 章:Ability 生命周期与异步任务
导入
上一章已经让 Tag、Attribute 和 Effect 拥有了明确来源与生命周期。 但一次火球术并不是同步执行到底的函数。它可能等待:
- 施法动画到达释放帧;
- 玩家确认目标或松开输入;
- 服务器接受或拒绝请求;
- 投射物命中、超时或被销毁;
- 角色被眩晕、死亡或主动取消;
- 收招动画结束。
假设玩家连续释放两次火球,第一次的动画事件迟到,第二次已经开始;如果回调只知道“Fireball Cast Point 到了”,它就可能错误地为第二次施法生成投射物。
因此,异步步骤必须绑定到一次明确的 Ability 生命周期,并在取消后彻底失效。等待完成、取消、超时、服务器拒绝和对象销毁,都只能让这次执行收束一次。
7.1 四个层次
7.1.1 Ability Definition
Definition 保存火球术的静态规则:
AbilityId = Ability.FireballMana Cost = 30Cooldown = 2 秒Direct Damage = 120Burning = 5 秒,Period = 1 秒Blocked Tags = State.Dead / State.Stunned / State.Silenced它可以由多个角色共享,不能保存当前目标、等待中的动画或本次 RequestId。
7.1.2 AbilitySpec
public sealed class AbilitySpec{ public AbilitySpecHandle Handle { get; init; } public AbilityDefinition Definition { get; init; } public int Level { get; init; } public EntityId GrantedBy { get; init; } public AbilityInputBinding Input { get; init; } public AbilityConcurrencyPolicy Concurrency { get; init; }}它回答:
这个角色是否拥有 Ability.Fireball?由什么来源授予?等级和输入绑定是什么?是否允许并发激活?AbilitySpec 的生命周期通常从授予持续到撤销。它不是一次施法对象,因此不应该保存某次投射物、某次 Task 或当前取消原因。
7.1.3 AbilityRuntime
一次激活创建一个 AbilityRuntime。它保存本次执行的可变状态:
ExecutionIdAbilitySpecHandle当前生命周期状态是否已经 Commit本次 TargetDataRequestId / PredictionKey(如适用)正在运行的 Ability Task临时 Tag 与输入锁句柄取消或结束原因本次执行创建的预测记录7.1.4 Execution Context
Execution Context 把 AbilityRuntime 需要的依赖显式传入,而不是让它到处查找全局单例。
public sealed class AbilityExecutionContext{ public CombatRuntime Source { get; init; } public IAbilityRuleService Rules { get; init; } public IEffectApplicationService Effects { get; init; } public IGameplayEventRouter Events { get; init; } public IGameplayCueSink Cues { get; init; } public ICombatClock Clock { get; init; } public ITargetResolver Targets { get; init; }}这些接口都是本文自定义边界。AbilityExecutionContext 不是 Unity 的 Context API,也不要求项目使用依赖注入框架。
上下文应该提供规则能力,不应直接暴露所有场景对象。例如 Ability 可以请求解析 TargetData,但不应在纯 C# 核心中调用 GameObject.Find。
7.2 Ability 状态机
7.2.1 四个核心状态
本文使用以下状态:
Activating -> 正在建立本次执行并完成激活检查
Running -> 激活成立,正在执行同步步骤或等待 Task
Cancelling -> 已停止接受正常推进,正在释放本次执行资源
Ended -> 生命周期关闭,不再接受任何 Task 或 Event状态迁移如下:
+----------------------+ | vActivating -> Running -> Cancelling -> Ended | | ^ | +-----------------------+ +------------------------------------+具体路径包括:
Activating -> Running 激活检查通过,开始执行
Activating -> Ended 激活前失败,尚未建立需要取消的资源
Activating -> Cancelling -> Ended 已创建临时状态后发生拒绝或取消
Running -> Ended 正常完成
Running -> Cancelling -> Ended 被打断、超时、主动取消或服务器拒绝有些实现会增加 Created、Waiting、Committed 或 Completing 等状态。可以增加,但不能用更多枚举掩盖职责不清。本章的四态模型已经足以建立外层生命周期。
7.2.2 Commit 是里程碑,不是生命周期终态
Commit 回答“成本与冷却是否已经正式提交”,不回答“Ability 是否仍在运行”。
因此,AbilityRuntime 可以同时处于:
State = RunningCommitted = true火球术采用前文约定:
服务器验证完成 -> Commit Mana 30 与 Cooldown 2 秒 -> 生成权威投射物 -> 等待命中或超时投射物飞行期间,Ability 仍然 Running。即使最终打空,已经 Commit 的 Mana 和冷却通常保留。Ability Ended 后,冷却 Effect 也继续到自身 2 秒到期。
7.2.3 生命周期入口需要单向
核心入口可以保持很少:
public interface IAbilityRuntime{ AbilityRuntimeState State { get; } bool IsCommitted { get; }
AbilityStartResult Start(); void Cancel(AbilityEndReason reason); void End(AbilityEndReason reason);}Start 只能从 Activating 推进,Cancel 只能把未结束实例带入 Cancelling,End 负责最终关闭。任何外部系统都不应直接给 State 赋值。
7.3 一次激活的执行骨架
一个简化的激活流程如下:
1. 根据 AbilitySpec 创建 AbilityRuntime2. 分配 ExecutionId3. 构造 AbilityExecutionContext4. 状态设为 Activating5. 检查 Spec、Tag、Attribute、Cooldown 和并发策略6. 获取本次执行需要的临时资源句柄7. 进入 Running8. 创建第一个 Ability Task 或执行同步步骤9. 在明确边界 Commit10. 等待完成、取消或失败11. 统一进入 End一个自定义 Runtime 骨架可以这样组织:
public sealed class AbilityRuntime{ private readonly List<AbilityTaskBase> tasks = new(); private readonly List<IAbilityLease> leases = new(); private readonly CancellationTokenSource cancellation = new(); private bool isEnding;
public AbilityRuntimeState State { get; private set; } public bool IsCommitted { get; private set; } public AbilityEndReason? EndReason { get; private set; }
public void MarkCommitted() { if (State != AbilityRuntimeState.Running) throw new InvalidOperationException("Ability is not running.");
IsCommitted = true; }
public void RegisterTask(AbilityTaskBase task) { if (State != AbilityRuntimeState.Activating && State != AbilityRuntimeState.Running) { throw new InvalidOperationException( "Ability no longer accepts new tasks."); }
tasks.Add(task); }}
AbilityTaskBase、IAbilityLease和AbilityRuntimeState都是本文自定义类型。CancellationTokenSource来自 .NET,用于传播取消信号,但它本身不等于完整的 Ability Task 生命周期。这里故意只允许Activating和Running注册 Task:一旦进入Cancelling,清理边界就已经关闭,迟到回调不能再把新等待塞回即将结束的 Runtime。
7.3.1 为什么要保存资源句柄
如果只在取消时写一串 RemoveTag()、EnableInput(),很难保证释放的是本次执行创建的那一份。更稳妥的方式是获取可释放句柄:
IAbilityLease castingTag = tagService.Acquire( source: executionId, tag: Tags.StateCasting);
IAbilityLease inputLock = inputLocks.Acquire( owner: executionId, channel: InputChannel.Ability);释放句柄只撤销对应来源,重复释放也不产生第二次副作用。IAbilityLease 是本文自定义的资源所有权抽象。
7.4 Ability Task
7.4.1 Task 解决的是什么
常见 Task 可以包括:
WaitGameplayEventTaskWaitAnimationEventTaskWaitTargetConfirmationTaskWaitInputReleaseTaskWaitProjectileHitTaskWaitServerResultTaskDelayAbilityTask7.4.2 Task 自己也需要状态
一个最小 Task 生命周期可以是:
Created -> Active -> Completed -> Cancelled -> TimedOut所有终态都必须执行同一套解绑逻辑:
public abstract class AbilityTaskBase : IDisposable{ private bool activated; private bool finished;
protected AbilityTaskBase(AbilityExecutionId owner) { Owner = owner; }
public AbilityExecutionId Owner { get; }
public void Activate(CancellationToken cancellationToken) { if (finished) throw new InvalidOperationException("Task already finished.");
if (activated) throw new InvalidOperationException("Task already activated.");
activated = true; Register(cancellationToken); }
protected void Complete() { Finish(); }
public void Cancel() { Finish(); }
private void Finish() { if (finished) return;
finished = true; Unregister(); }
protected abstract void Register(CancellationToken cancellationToken); protected abstract void Unregister();
public void Dispose() { Finish(); }}这段代码只展示幂等结束骨架。实际 Task 还需要结果、失败原因、超时调度和回调到 AbilityRuntime 的安全入口。
7.4.3 协程、async/await 与 Ability Task 的差别
三者不在同一个抽象层:
| 机制 | 主要解决 |
|---|---|
| Unity Coroutine | 在多个帧之间暂停和继续 |
C# async/await | 组合异步操作和结果 |
| Ability Task | 谁在等待、等待什么、如何取消和解绑 |
因此,Ability Task 内部可以使用协程,也可以包装 C# Task,还可以只订阅事件。关键不是技术形式,而是它必须受 AbilityRuntime 所有。
例如 WaitAnimationEventTask 可以由 Unity 适配器接收动画事件,但核心 Task 只消费带执行标识的消息:
Animation Adapter -> GameplayEvent EventId = Event.Animation.CastPoint ExecutionId = A9001
WaitAnimationEventTask(A9001) -> 只接受 A9001 的 CastPoint -> 完成并注销第一次施法的迟到事件带 ExecutionId = A9000,不会唤醒第二次施法 A9001。
7.5 事件关联、超时与迟到回调
7.5.1 每个异步结果都需要关联键
不同等待使用不同关联信息:
| 等待 | 建议关联 |
|---|---|
| 动画事件 | ExecutionId + AnimationPhase |
| 目标确认 | ExecutionId + TargetingSessionId |
| 输入释放 | ExecutionId + InputBinding |
| 投射物命中 | ExecutionId + ProjectileId |
| 服务器结果 | RequestId + PredictionKey |
| Gameplay Event | ExecutionId + EventTag |
注意:只有 EventTag 而没有执行标识,连续施法和并发 Ability 很容易串线。
7.5.2 超时不是异常日志,而是生命周期结果
网络等待、目标选择和投射物等待都可能永远没有结果。Task 必须定义超时:
WaitServerResultTask 超时 -> 不把请求当作成功 -> 标记网络结果未知 -> 按协议决定重试、请求快照或结束本地预测
WaitProjectileHitTask 超时 -> 结束权威投射物等待 -> 不应用 120 点直接伤害 -> 不应用 Burning
WaitTargetConfirmationTask 超时 -> 取消本次未 Commit Ability -> 释放瞄准 UI 和输入锁超时策略属于具体 Ability 或协议配置,Task 负责准确报告结果,不擅自决定退款和伤害。
7.5.3 迟到回调必须被丢弃
Ability Ended 后,以下回调都可能迟到:
- 动画状态结束;
- 目标选择器返回;
- 投射物碰撞;
- 服务器确认或拒绝;
- 延迟计时完成;
- 场景对象销毁通知。
安全入口至少检查:
Runtime 是否仍存在State 是否允许推进ExecutionId 是否匹配Task 是否仍为 Active结果是否已经消费注意:不能依靠“我已经 StopCoroutine 了”证明所有回调都停止,因为事件总线、网络消息和投射物组件可能完全不在同一个协程里。
7.6 取消和幂等 End
7.6.1 取消原因结构化
建议使用稳定枚举或错误码:
CompletedRejectedCancelledByInputCancelledByTagInterruptedTargetInvalidTimedOutOwnerDestroyedNetworkDisconnected原因会影响:
- 是否播放失败、打断或正常收招表现;
- 是否回滚本地预测;
- 是否请求服务器快照;
- 是否保留已经 Commit 的成本与冷却;
- 日志和遥测如何分类。
7.6.2 推荐清理顺序
与前文的通用执行链一致,取消时先切断异步入口,再释放状态:
1. State -> Cancelling2. 触发 CancellationToken3. 停止并注销全部 Ability Task4. 移除本次执行持有的临时 Tag5. 释放输入、移动、转向等资源句柄6. 释放 TargetData、目标选择器和投射物等待上下文7. Confirm / Reject / Correct 本次预测记录8. 收束动画、VFX、UI 等预测表现9. 清空实例引用10. State -> Ended已经作为独立 Active Effect 提交的状态,不在这里无条件删除:
保留: 已 Commit 的 2 秒 Cooldown 已经应用到目标的 5 秒 Burning
按策略决定: 未 Commit 的预测 Mana 与预测冷却 只由 Ability 临时持有的 State.Casting 尚未确认的预测投射物7.6.3 End 必须可以重复调用
取消、超时和对象销毁可能在同一帧先后到达。End 必须幂等:
public void End(AbilityEndReason reason){ if (State == AbilityRuntimeState.Ended || isEnding) return;
isEnding = true; State = AbilityRuntimeState.Cancelling;
EndReason ??= reason;
AbilityTaskBase[] ownedTasks = tasks.ToArray(); tasks.Clear();
IAbilityLease[] ownedLeases = leases.ToArray(); leases.Clear();
cancellation.Cancel();
foreach (AbilityTaskBase task in ownedTasks) task.Dispose();
foreach (IAbilityLease lease in ownedLeases) lease.Dispose();
ReleaseExecutionContext(); FinishPrediction(reason); FinishPresentation(reason);
State = AbilityRuntimeState.Ended; isEnding = false;}这里在触发 CancellationToken 前先建立 isEnding 门槛,并把 Task 与 Lease 转移到局部快照。这样同步取消回调即使重入 End(),也会立即返回;即使回调尝试注册新 Task,也会因为状态已经是 Cancelling 而被拒绝。ReleaseExecutionContext、FinishPrediction 和 FinishPresentation 是本文自定义的收束入口。生产实现还应使用 try/finally 或分阶段异常隔离,避免某个 Dispose() 抛错后阻断剩余清理。
7.6.4 Cancel 与 End 的关系
Cancel 表达“请求中断”,End 表达“生命周期已经关闭”。
public void Cancel(AbilityEndReason reason){ if (State == AbilityRuntimeState.Ended || State == AbilityRuntimeState.Cancelling) return;
State = AbilityRuntimeState.Cancelling; End(reason);}正常完成也调用同一个 End(Completed),这样 Task、句柄和实例引用只有一套清理路径。
7.7 互斥能力、输入释放与动画事件
7.7.1 互斥不是一个全局 isCasting
较为稳定的互斥方式是定义并发策略或执行通道:
Channel.ActionChannel.MovementChannel.TargetingChannel.PassiveAbilitySpec 可以声明:
Fireball: Acquires Channel.Action Acquires Channel.Targeting Blocks abilities requiring exclusive Channel.ActionAbilityRuntime 激活时获取通道句柄,结束时按来源释放。通道只解决并发所有权,State.Stunned、Cooldown.Fireball 等规则仍由 Tag Query 处理。
7.7.2 输入按下和释放
持续施法、蓄力和引导技能不能只监听按下。输入桥接层应把以下意图送入能力系统:
PressedReleasedCancelledWaitInputReleaseTask 绑定 Ability 的 ExecutionId。玩家松开按键时,它只结束当前仍在等待释放的那次执行。
火球术如果采用“按下开始瞄准,释放确认目标”的交互,可以这样推进:
Pressed -> AbilityRuntime 进入 Running -> 创建 WaitInputReleaseTask -> 显示瞄准表现
Released -> Task 返回当前 TargetData -> 进入服务器验证与 Commit若角色在 Released 前被眩晕,则取消 Task、隐藏瞄准表现并结束未 Commit 执行。
7.7.3 动画事件只能推进所属执行
Unity 动画事件常见的错误接口是:
public void OnCastPoint(){ currentAbility.ReleaseProjectile();}currentAbility 很容易在连招、切换 Avatar 或并发执行时指向错误实例。更可靠的适配器应携带动画播放句柄或 ExecutionId,再发布 Gameplay Event:
AnimationPlaybackId = M3001ExecutionId = A9001Event = Event.Animation.CastPointWaitAnimationEventTask 同时验证三个字段。Ability 已取消时,适配器即使收到迟到动画事件,也找不到 Active Task。
动画只决定流程何时进入下一步。它不直接扣除 30 Mana,也不确认 120 点伤害。
7.8 用状态机走完一发火球术
下面把火球术放入本章模型。先假设施法者当前 Mana 为 80,并且没有冷却和阻断 Tag。
7.8.1 正常完成
输入按下 -> 找到 AbilitySpec(Ability.Fireball) -> 创建 AbilityRuntime A9001 -> State = Activating
激活检查 -> Mana >= 30 -> 不包含 State.Stunned / State.Silenced -> 不包含 Cooldown.Fireball -> 并发通道可获取
建立执行 -> 获取 State.Casting 来源句柄 -> 获取 Action 输入通道句柄 -> State = Running -> 创建 WaitAnimationEventTask(A9001)
动画释放点 -> Task 完成并注销 -> 服务器验证 TargetData -> Commit: Mana -= 30 添加 2 秒 Cooldown Effect -> IsCommitted = true -> 创建权威投射物 -> 创建 WaitProjectileHitTask(A9001, Projectile P5001)
权威命中 -> Task 完成并注销 -> 对目标应用 120 点 Direct Damage -> 对目标应用 Burning: Duration = 5 秒 Period = 1 秒 -> End(Completed)
结束清理 -> 注销残余 Task -> 移除 State.Casting -> 释放输入通道 -> 清空目标和投射物等待上下文 -> State = EndedAbilityRuntime A9001 已经 Ended,但 Cooldown Effect 与 Burning Effect 按各自生命周期继续存在。
7.8.2 Commit 前被眩晕
AbilityRuntime A9002 -> State = Running -> 正在等待动画释放点 -> IsCommitted = false
收到 State.Stunned -> Cancel(CancelledByTag) -> State = Cancelling -> 取消动画 Task -> 移除 State.Casting -> 释放输入通道 -> 收束施法表现 -> State = Ended因为尚未 Commit,权威端没有扣除 30 Mana,也没有创建 2 秒冷却。客户端若显示过预测资源和冷却,应由对应预测记录撤销。
7.8.3 Commit 后投射物打空
AbilityRuntime A9003 -> IsCommitted = true -> Mana 已扣除 30 -> 2 秒 Cooldown 已存在 -> 正在等待 Projectile P5003
投射物超时 -> WaitProjectileHitTask 返回 TimedOut -> 不应用 120 点直接伤害 -> 不应用 Burning -> End(Completed 或 Missed,按项目原因枚举)这条路径通常不退款,因为施法已经合法 Commit 并生成权威投射物。成本策略应由 Ability Definition 或 Commit Policy 明确,不能由 Task 自己决定。
7.8.4 客户端预测被服务器拒绝
客户端 AbilityRuntime A9004 -> 已进入预测 Running -> 正在等待服务器结果
Server Result -> Reject(InvalidTarget)
WaitServerResultTask -> 返回 Rejected -> Cancel(Rejected) -> 停止全部本地等待 -> 释放临时 Tag 和输入锁 -> 撤销预测 Mana / Cooldown -> 移除预测火球和瞄准状态 -> State = Ended服务器端没有 Commit,因此不存在权威 30 Mana 消耗和 2 秒冷却。客户端执行的是预测回滚,不是等待服务器退款。Request、Result 和 Prediction Journal 的具体协议将在后文展开。