把章节变成可单步观察的过程
先说结论
状态标签不能只记录“有或没有”,还要记录由谁添加。多个技能都提供 Stunned 时,移除其中一个来源不应把另一个来源仍维持的状态一起清掉;技能仲裁则根据阶段和优先级决定新旧实例怎样共存。
两个来源共同维持 Stunned
来源 SkillA 添加 Stunned:来源集合 {A}
来源 TrapB 添加 Stunned:来源集合 {A, B}
SkillA 结束:来源集合 {B},角色仍然眩晕
TrapB 结束:来源集合 {},Stunned 才真正移除
同理,高优先级技能开始时是否取消低优先级技能,要在新实例确认可以成立后执行;先取消旧技能再发现新技能资源不足,会让两个技能都不存在。
“角色正在施法”“此时不能移动”“下一个输入能否接管当前动作”,看起来都是布尔判断。真正困难的地方在于:同一个状态可能由多个系统同时提供,而技能冲突又会因所处阶段不同而采用不同规则。
一个可靠的运行时需要同时回答:
- 这个状态由谁提供,还有多少来源没有释放?
- 当前输入走的是普通激活、预输入窗口,还是外部强制打断?
- 被拒绝、排队或取消时,哪一条规则做出了决定?
- 旧状态结束后,清理会不会误伤其他来源?
先把标签理解成来源账本
固定标签适合描述对象长期拥有的能力,例如“可以移动”。临时标签则应记录来源,例如某个运行实例和某个持续状态都提供“控制受限”。
控制受限
├─ 固定来源:角色规则
├─ 临时来源:运行实例 A
└─ 临时来源:持续状态 B
有效状态不是简单的一个布尔值:
bool HasEffectiveTag(Tag tag)
{
return fixedTags.Contains(tag)
|| temporarySources[tag].Count > 0;
}
临时来源更适合保存为集合而不是裸计数。集合既能回答“还有几个来源”,也能回答“该清理谁”。
bool AddTemporaryTag(Source source, Tag tag)
{
// 同一来源重复提供同一状态,不重复记账。
return temporarySources[tag].Add(source);
}
void RemoveTemporaryTags(Source source)
{
foreach (SourceSet sources in temporarySources.Values)
sources.Remove(source);
}
这样,运行实例 A 结束时只移除 A;持续状态 B 仍在,聚合标签就仍然有效。直接按标签值清空会让不同系统互相误删状态。
标签层级改变的是查询,不是所有权
标签可以有父子关系。拥有“动作.施法”时,查询“动作”可以返回真;但账本仍应保存真正添加的叶子标签和来源。
动作
└─ 动作.施法
层级查询适合判断规则:
all:所有要求都能由当前有效标签满足;any:至少一个要求被满足;none:要求中的标签一个也不能被满足。
清理则仍应按精确来源进行。不要因为父标签查询命中,就猜测应该删除哪些子标签。
有两个实现边界需要保留为可观察问题,而不是写成保证:
- 临时标签加入后,是否一定产生聚合状态 Add 通知,取决于通知机制是否覆盖该写入路径;
- 把临时状态提升为固定状态时,层级匹配与精确删除若使用不同规则,可能留下来源记录。
因此调试工具应同时展示“原始账本”“层级查询结果”和“已发布的状态事件”。
普通激活:先允许新实例成立,再取消低级旧实例
普通激活的优先级规则可以抽象为:
ActivationResult CheckDirectActivation(
Priority incoming,
IReadOnlyList<RunningAbility> active)
{
foreach (RunningAbility current in active)
{
if (current.Priority > Priority.None
&& current.Priority >= incoming)
return ActivationResult.PriorityBlocked;
}
return ActivationResult.Allowed;
}
高优先级输入通过,不代表立即删除旧实例。更安全的交接顺序是:
- 新请求完成资源、标签和冷却等全部裁决;
- 新运行实例成功建立;
- 严格低于新实例的旧能力收到取消请求;
- 旧能力执行中断语义并按来源清理;
- 新实例继续运行。
这避免了“先取消旧技能,新技能随后因其他条件失败,结果两边都没有”的空窗。
教学页面使用 LOW / MID / HIGH 表示相对顺序。它们只是场景值,不代表任何产品配置。
预输入窗口:阶段规则比数字更重要
同级切换不能简单绕过普通激活规则。预输入窗口保存当前能力、当前优先级、已排队输入和窗口阶段,并使用独立决策表。
| 阶段 | 更高输入 | 同级输入 | 更低输入 |
|---|---|---|---|
窗口内 InWindow |
请求取消当前能力 | 队列为空时缓存首个 | 队列为空时缓存首个 |
窗口后 PostWindow |
取消并排队 | 取消并排队 | 取消并排队 |
等待释放 AwaitingRelease |
替换已排队输入 | 拒绝替换 | 拒绝替换 |
特别要注意:PostWindow 不比较优先级。此时较低输入仍会进入取消与排队流程。这是阶段语义,不应被普通激活的“大于等于阻挡”规则覆盖。
窗口内的同级输入通常采用“首个缓存”:
QueueResult QueueDuringWindow(Input next)
{
if (phase == Phase.InWindow && queuedInput == null)
{
queuedInput = next;
return QueueResult.Buffered;
}
if (phase == Phase.InWindow)
return QueueResult.FirstInputWins;
// PostWindow 的切换由阶段规则决定,不比较优先级。
RequestCancel(current);
queuedInput = next;
return QueueResult.CancelAndQueue;
}
旧能力结束或取消后,窗口控制器要注销结束与取消监听,移除旧状态,然后释放缓存动作。先释放动作再移除监听,容易让重入事件再次消费同一输入。
外部强制打断:另一条轨道
Effect 或环境规则发起的强制打断不是“某个新技能完成普通激活”。它没有新技能实例接管,而是用过滤条件选择当前能力,再提交取消请求。
bool TryExternalInterrupt(
RunningAbility target,
PriorityRange range,
InterruptStrength strength)
{
if (!range.Contains(target.Priority))
return false;
if (strength.IsLimited
&& target.Priority > strength.Maximum)
return false;
target.RequestCancel();
return true;
}
因此调试页面应把三条轨道分开:
- 普通激活:新输入与活动能力比较;
- 预输入窗口:由窗口阶段决定缓存、替换或取消;
- 外部强制打断:按目标优先级区间与打断强度过滤。
把它们合成一个 CanInterrupt 会隐藏调用语义,也难以解释为什么相同优先级在不同阶段得到不同结果。
六个最小调试场景
| 场景 | 观察重点 |
|---|---|
| 来源账本 | 固定标签与两个临时来源共同维持有效状态 |
| 高优先级接管 | 新实例成立后,低级旧实例才进入取消 |
| 同级首个缓存 | 窗口内首个同级输入被缓存,后续输入被拒绝 |
| 低级输入阻挡 | 普通激活在产生副作用前明确拒绝 |
| PostWindow 切换 | 较低输入仍进入取消与排队,不进行优先级比较 |
| 来源清理冲突 | 一个来源结束时只删除自己,其他来源继续维持状态 |
失败场景不能只显示“false”。至少要同步呈现当前阶段、比较双方、命中的规则、状态变化前后、对应代码和下一步清理责任。
代价与边界
- 标签查询若直接扫描固定与临时缓冲,成本约为
O(查询标签数 × 当前标签记录数);频繁查询可建立索引,但来源账本仍是清理依据。 - 普通激活扫描活动能力,成本约为
O(活动能力数)。 - 预输入窗口应保持每个拥有者常量级状态;事件监听必须在结束、取消和全局清理时对称注销。
- 外部批量打断扫描活动能力,成本约为
O(活动能力数),并应保留每个目标未通过的具体过滤原因。 - 固定标签、临时标签和层级关系是三种不同维度。查询可以聚合,写入与删除不能靠查询结果反推。
最后记住
标签负责描述“当前世界认为对象处于什么状态”,来源账本负责回答“谁有权撤销它”;普通激活、预输入窗口和外部强制打断则分别回答三种不同的冲突问题。
把来源、阶段和决策理由同时保留下来,技能仲裁才不会退化成一组无法解释的布尔值。