把章节变成可单步观察的过程
先说结论
技能流程应把输入意图、激活预检、资源提交、运行实例、时间线、目标查询、Effect 和结束清理分开。预检可以失败而不产生副作用,一旦提交资源就必须保证后续成功结束或明确回滚。
一次火球技能的完整状态
输入:SkillId=Fireball,目标 Enemy12
玩家 Mana=100,消耗=30,冷却可用
预检通过:Mana 仍为 100
提交:Mana 变为 70,创建 RuntimeInstance 501
0.3 秒:生成投射物
0.8 秒:命中 Enemy12,应用 40 伤害
1.0 秒:实例结束并清理标签、订阅和临时资源
若目标在提交前死亡,应停在预检阶段;若提交后被眩晕打断,则要走中断语义,不能假装技能从未开始。
按下一个技能按钮,看起来只发生了“一次释放”。运行时真正要完成的,却是一条跨越输入、规则裁决、执行编排、目标查询、效果结算与清理的状态链。
这条链最重要的设计目标不是让技能“能播出来”,而是让每一次状态变化都能回答三个问题:
- 谁提出了请求?
- 哪一道规则允许或拒绝了它?
- 无论正常结束还是中断,谁负责收回本次运行产生的状态?
先看完整运行地图
输入请求
↓
同步预检
├─ 资源不足 / 标签阻止 / 冷却中 / 优先级不足 → 明确拒绝
↓
待激活请求
↓ 下一次系统更新再次裁决
消耗可用次数 + 挂载临时标签 + 创建运行实例与上下文
↓
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 管线消费结果,清理路径负责让世界恢复一致。每一层都能单步观察时,复杂技能才真正可维护。