DYNAMIC EXPLAINER / ARTICLE FLOW

把章节变成可单步观察的过程

01 / 13
STEP 01 / CODE / LOGIC

先说结论

技能流程应把输入意图、激活预检、资源提交、运行实例、时间线、目标查询、Effect 和结束清理分开。预检可以失败而不产生副作用,一旦提交资源就必须保证后续成功结束或明确回滚。

  • 谁提出了请求?
  • 哪一道规则允许或拒绝了它?
  • 无论正常结束还是中断,谁负责收回本次运行产生的状态?
输入:SkillId=Fireball,目标 Enemy12
玩家 Mana=100,消耗=30,冷却可用
预检通过:Mana 仍为 100
提交:Mana 变为 70,创建 RuntimeInstance 501
0.3 秒:生成投射物
0.8 秒:命中 Enemy12,应用 40 伤害
1.0 秒:实例结束并清理标签、订阅和临时资源

先说结论

技能流程应把输入意图、激活预检、资源提交、运行实例、时间线、目标查询、Effect 和结束清理分开。预检可以失败而不产生副作用,一旦提交资源就必须保证后续成功结束或明确回滚。

一次火球技能的完整状态

输入:SkillId=Fireball,目标 Enemy12
玩家 Mana=100,消耗=30,冷却可用
预检通过:Mana 仍为 100
提交:Mana 变为 70,创建 RuntimeInstance 501
0.3 秒:生成投射物
0.8 秒:命中 Enemy12,应用 40 伤害
1.0 秒:实例结束并清理标签、订阅和临时资源

若目标在提交前死亡,应停在预检阶段;若提交后被眩晕打断,则要走中断语义,不能假装技能从未开始。

按下一个技能按钮,看起来只发生了“一次释放”。运行时真正要完成的,却是一条跨越输入、规则裁决、执行编排、目标查询、效果结算与清理的状态链。

这条链最重要的设计目标不是让技能“能播出来”,而是让每一次状态变化都能回答三个问题:

  1. 谁提出了请求?
  2. 哪一道规则允许或拒绝了它?
  3. 无论正常结束还是中断,谁负责收回本次运行产生的状态?

先看完整运行地图

输入请求
  ↓
同步预检
  ├─ 资源不足 / 标签阻止 / 冷却中 / 优先级不足 → 明确拒绝
  ↓
待激活请求
  ↓ 下一次系统更新再次裁决
消耗可用次数 + 挂载临时标签 + 创建运行实例与上下文
  ↓
Flow / Timeline 编排
  ↓
目标查询 → 命中约束 → Effect / Buff / 属性结算
  ↓
动画 / 音效 / 特效反馈
  ↓
正常结束 Finish ─────┐
高优先级打断 Interrupt ├→ 恢复标签、清上下文、注销实例、发出结果事件

“请求”和“激活”必须分开。调用方提交请求时,只能知道它通过了当前时刻的同步预检;真正产生副作用前还要再次裁决。这样可以避免同一更新周期内多个请求同时认为自己成功。

第一段:输入只负责表达意图

输入层可以立即提交,也可以在连段窗口、蓄力窗口或可打断窗口中排队。它不应该直接扣资源、播放伤害或修改目标属性。

ActivationRequest Submit(InputEvent input)
{
    Candidate candidate = ResolveCandidate(input);
    Target target = ResolveTarget(candidate);

    // 这里只提交意图,不产生技能副作用。
    return activationQueue.Enqueue(candidate, target);
}

输入层拒绝的通常是“请求本身无效”,例如操作者尚未就绪、候选技能不存在、目标无效且不允许空放。资源、标签、冷却与优先级属于激活规则,应由统一的裁决器判断。

第二段:激活条件是一组有顺序的门

一个可调试的裁决器不应只返回 false,而要返回明确原因。常见顺序如下:

ActivationResult CanActivate(Ability ability, Actor owner)
{
    if (!ability.IsValid || !owner.IsAlive)
        return ActivationResult.Invalid;

    if (ability.IsActive || ability.IsCancelling)
        return ActivationResult.Busy;

    if (owner.HasEqualOrHigherPriorityAbility(ability.Priority))
        return ActivationResult.PriorityBlocked;

    if (!owner.Tags.Match(ability.TagRequirement))
        return ActivationResult.TagBlocked;

    if (!ability.Cost.CanAfford(owner.Attributes))
        return ActivationResult.ResourceInsufficient;

    if (!ability.Cooldown.HasAvailableUse)
        return ActivationResult.Cooldown;

    return ActivationResult.Success;
}

顺序有两个价值:

  • 调试输出稳定,同一状态总会落到同一道门;
  • 前面的轻量判断可以阻止后面的属性试算与目标工作。

标签条件可以拆成三组:

  • all:必须全部拥有;
  • any:至少拥有一个;
  • none:一个都不能拥有。

此外,一个正在运行的技能还可以用自己的阻止标签,禁止带有某类资产标签的新技能激活。页面上应该同时展示“被什么阻止”和“阻止来自哪里”。

第三段:预检通过不等于副作用已经发生

真正消费待激活请求时,要再次运行相同裁决,然后才创建本次运行状态。

void ConsumeActivationRequest(ActivationRequest request)
{
    ActivationResult result = CanActivate(request.Ability, request.Owner);
    if (result != ActivationResult.Success)
    {
        PublishRejected(request, result);
        return;
    }

    request.Ability.Cooldown.ConsumeUse();
    request.Owner.Tags.AddTemporary(
        source: request.Ability,
        tags: request.Ability.OwnedTags);

    ActivationContext context = contexts.Begin(request);
    ActivationInstance instance = instances.Begin(request, context);

    request.Ability.MarkActive();
    request.Ability.Orchestrator.Start(instance);
}

这里的“消耗可用次数”和“实际扣资源”不是同一件事。运行时可以在激活成功时消耗一次使用次数,而把资源扣除放在 Timeline 或 Flow 的指定节点。这样可以表达前摇、蓄力或分段消耗,但也意味着中断时必须明确“扣费节点是否已经执行”。

第四段:运行实例保存“这一次”的状态

技能配置是模板,运行实例才代表一次释放。至少要区分:

  • 稳定的技能定义;
  • 本次激活实例标识;
  • 本次目标;
  • 本次临时上下文;
  • 当前编排位置;
  • 已经 Begin、Finish 或 Interrupt 的任务。

上下文适合保存“本次实际消耗量”“本次蓄力比例”“本次命中数量”等短期数据。它不能泄漏到下一次激活。

第五段:Flow 决定分支,Timeline 决定时序

Flow 适合表达条件、分支、等待与组合;Timeline 适合表达精确到帧的动作、音效、命中窗口与控制锁。两者可以组合,但职责不要混在一起。

NodeResult TickNode(ExecutionContext context)
{
    return currentNode.Tick(context) switch
    {
        Running   => NodeResult.Wait,
        Completed => NodeResult.Next,
        End       => NodeResult.EndAbility,
        Cancel    => NodeResult.CancelAbility
    };
}

Timeline 的完整编辑、构建与逐帧执行机制见数据驱动 Timeline:从编辑器到实体消费

第六段:从目标查询到最终命中

目标查询不应直接等于伤害结算。完整链路通常是:

世界中的单位
  → 形状粗筛(圆形 / 扇形 / 盒形)
  → 阵营、存活、距离、角度等精确约束
  → 同一命中窗口的去重或间隔限制
  → 最终目标集合
  → Effect 结算

先得到候选,再逐个说明入选或排除原因,调试页面才能回答“明明在范围里为什么没命中”。圆形、扇形和盒形的粗筛与精确判断过程,可在目标筛选与命中判定中逐步调试。

第七段:Effect 与反馈是编排任务的消费结果

命中任务把效果描述交给 Effect 管线。Effect 可以是即时属性变化,也可以拥有持续、周期、叠层与到期清理等独立生命周期。

技能结束不等于它创建的所有持续效果都要立刻消失。持续效果是否跟随来源技能结束,必须由明确规则决定,不能靠“同一个技能创建的”这一关系猜测。

动画、音效和特效同样是任务。正常到达结束点时执行 Finish;技能被打断时执行 Interrupt。某些视觉尾迹可以按明确配置自然消散,但控制锁、输入锁等状态必须立即释放。

Effect 的创建、叠层、刷新与移除将在Effect / Buff 生命周期中展开。

第八段:正常结束与中断必须走不同语义

正常结束表示编排已经完成:

void FinishAbility(ActivationInstance instance)
{
    instance.Orchestrator.FinishActiveTasks();
    CleanupActivation(instance);
    PublishEnded(instance);
}

中断表示编排未完成:

void InterruptAbility(ActivationInstance instance)
{
    instance.Orchestrator.InterruptActiveTasks();

    // 若使用次数已经消耗、但冷却节点尚未执行,
    // 中断路径必须补上冷却启动。
    instance.Cooldown.EnsureStarted();

    CleanupActivation(instance);
    PublishCancelled(instance);
}

二者最终都要清理本次激活状态:

void CleanupActivation(ActivationInstance instance)
{
    instance.Ability.MarkInactive();
    instance.Owner.Tags.RemoveTemporary(source: instance.Ability);
    contexts.End(instance.Ability);
    instances.End(instance.Ability);
}

临时标签按来源移除,比简单删除标签值更安全。两个系统都添加了同名标签时,一个系统结束不能误删另一个来源仍然持有的状态。

四种最小调试场景

交互实验使用的是明确的教学场景参数,不代表任何产品数值:

场景 教学参数 观察重点
正常释放 资源 100、花费 30、冷却 6 秒 请求、二次裁决、扣费节点、命中、Finish 清理
资源不足 资源 20、花费 30 在创建实例前拒绝,其他状态不变
标签阻止 当前拥有 沉默,技能要求 none: 沉默 展开 none 条件及阻止来源
高优先级打断 旧技能优先级 1,新技能优先级 3 旧任务 Interrupt、旧状态清理、新实例继续

不同失败路径不应该强行拥有相同步骤数。资源不足在资源门就结束;高优先级打断则需要展示新旧两个实例的交接。

边界与代价

同一更新周期收到多个请求

同步预检和待激活队列之间仍可能发生状态变化,因此消费请求时必须二次裁决。队列还应限制同一拥有者的并行待激活数量,或用明确优先级替换旧请求。

优先级相同

直接释放通常不允许同级互相打断。同级切换若被设计允许,应通过明确的可打断窗口兑现,而不是绕过优先级检查。

扣费节点之前被打断

资源可能尚未扣除,但可用次数已经消费。中断清理要分别观察“费用是否执行”和“冷却是否启动”,不要用一个布尔值代表全部。

目标在命中帧前失效

输入时选中的目标只是初始引用。到命中帧仍要检查实体是否存在、是否存活、是否满足阵营和空间规则。

运行成本

  • 激活裁决通常是 O(当前活动技能数 + 标签条件数 + Cost Modifier 数);
  • 目标查询的主要成本来自空间候选数,粗筛越有效,后续精确判断越少;
  • Timeline 每帧扫描全部 Clip 的实现简单但成本为 O(Clip 数),大规模使用时可按起止帧建立索引;
  • 可观察状态会增加少量内存,但它能显著降低跨系统调试成本。

最后记住

一次技能不是一个函数调用,而是一段有编号、有上下文、可结束也可中断的事务。

输入表达意图,裁决器保护规则,运行实例隔离状态,Flow / Timeline 编排过程,目标与 Effect 管线消费结果,清理路径负责让世界恢复一致。每一层都能单步观察时,复杂技能才真正可维护。