07-Ability生命周期与异步任务

3990 字
20 分钟
07-Ability生命周期与异步任务

第 7 章:Ability 生命周期与异步任务#

导入#

上一章已经让 Tag、Attribute 和 Effect 拥有了明确来源与生命周期。 但一次火球术并不是同步执行到底的函数。它可能等待:

  • 施法动画到达释放帧;
  • 玩家确认目标或松开输入;
  • 服务器接受或拒绝请求;
  • 投射物命中、超时或被销毁;
  • 角色被眩晕、死亡或主动取消;
  • 收招动画结束。

假设玩家连续释放两次火球,第一次的动画事件迟到,第二次已经开始;如果回调只知道“Fireball Cast Point 到了”,它就可能错误地为第二次施法生成投射物。

因此,异步步骤必须绑定到一次明确的 Ability 生命周期,并在取消后彻底失效。等待完成、取消、超时、服务器拒绝和对象销毁,都只能让这次执行收束一次。

7.1 四个层次#

7.1.1 Ability Definition#

Definition 保存火球术的静态规则:

AbilityId = Ability.Fireball
Mana Cost = 30
Cooldown = 2 秒
Direct Damage = 120
Burning = 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。它保存本次执行的可变状态:

ExecutionId
AbilitySpecHandle
当前生命周期状态
是否已经 Commit
本次 TargetData
RequestId / 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

状态迁移如下:

+----------------------+
| v
Activating -> 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 = Running
Committed = 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 创建 AbilityRuntime
2. 分配 ExecutionId
3. 构造 AbilityExecutionContext
4. 状态设为 Activating
5. 检查 Spec、Tag、Attribute、Cooldown 和并发策略
6. 获取本次执行需要的临时资源句柄
7. 进入 Running
8. 创建第一个 Ability Task 或执行同步步骤
9. 在明确边界 Commit
10. 等待完成、取消或失败
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 可以包括:

WaitGameplayEventTask
WaitAnimationEventTask
WaitTargetConfirmationTask
WaitInputReleaseTask
WaitProjectileHitTask
WaitServerResultTask
DelayAbilityTask

7.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 EventExecutionId + 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 取消原因结构化#

建议使用稳定枚举或错误码:

Completed
Rejected
CancelledByInput
CancelledByTag
Interrupted
TargetInvalid
TimedOut
OwnerDestroyed
NetworkDisconnected

原因会影响:

  • 是否播放失败、打断或正常收招表现;
  • 是否回滚本地预测;
  • 是否请求服务器快照;
  • 是否保留已经 Commit 的成本与冷却;
  • 日志和遥测如何分类。

7.6.2 推荐清理顺序#

与前文的通用执行链一致,取消时先切断异步入口,再释放状态:

1. State -> Cancelling
2. 触发 CancellationToken
3. 停止并注销全部 Ability Task
4. 移除本次执行持有的临时 Tag
5. 释放输入、移动、转向等资源句柄
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.Action
Channel.Movement
Channel.Targeting
Channel.Passive

AbilitySpec 可以声明:

Fireball:
Acquires Channel.Action
Acquires Channel.Targeting
Blocks abilities requiring exclusive Channel.Action

AbilityRuntime 激活时获取通道句柄,结束时按来源释放。通道只解决并发所有权,State.Stunned、Cooldown.Fireball 等规则仍由 Tag Query 处理。

7.7.2 输入按下和释放#

持续施法、蓄力和引导技能不能只监听按下。输入桥接层应把以下意图送入能力系统:

Pressed
Released
Cancelled

WaitInputReleaseTask 绑定 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 = M3001
ExecutionId = A9001
Event = Event.Animation.CastPoint

WaitAnimationEventTask 同时验证三个字段。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 = Ended

AbilityRuntime 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 的具体协议将在后文展开。

07-Ability生命周期与异步任务
https://blog.rouming-fei.top/posts/07-ability-lifecycle-and-asynchronous-tasks/
作者
废江流
发布于
2026-09-11
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
废江流
提灯寻影,灯到影灭。
分类
标签
站点统计
文章
19
分类
3
标签
16
总字数
62,531
运行时长
0 天
最后活动
0 天前

文章目录