DYNAMIC EXPLAINER / ARTICLE FLOW

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

01 / 09
STEP 01 / CODE / LOGIC

先说结论

状态标签不能只记录“有或没有”,还要记录由谁添加。多个技能都提供 Stunned 时,移除其中一个来源不应把另一个来源仍维持的状态一起清掉;技能仲裁则根据阶段和优先级决定新旧实例怎样共存。

  • 这个状态由谁提供,还有多少来源没有释放?
  • 当前输入走的是普通激活、预输入窗口,还是外部强制打断?
  • 被拒绝、排队或取消时,哪一条规则做出了决定?
  • 旧状态结束后,清理会不会误伤其他来源?
来源 SkillA 添加 Stunned:来源集合 {A}
来源 TrapB 添加 Stunned:来源集合 {A, B}
SkillA 结束:来源集合 {B},角色仍然眩晕
TrapB 结束:来源集合 {},Stunned 才真正移除

先说结论

状态标签不能只记录“有或没有”,还要记录由谁添加。多个技能都提供 Stunned 时,移除其中一个来源不应把另一个来源仍维持的状态一起清掉;技能仲裁则根据阶段和优先级决定新旧实例怎样共存。

两个来源共同维持 Stunned

来源 SkillA 添加 Stunned:来源集合 {A}
来源 TrapB 添加 Stunned:来源集合 {A, B}
SkillA 结束:来源集合 {B},角色仍然眩晕
TrapB 结束:来源集合 {},Stunned 才真正移除

同理,高优先级技能开始时是否取消低优先级技能,要在新实例确认可以成立后执行;先取消旧技能再发现新技能资源不足,会让两个技能都不存在。

“角色正在施法”“此时不能移动”“下一个输入能否接管当前动作”,看起来都是布尔判断。真正困难的地方在于:同一个状态可能由多个系统同时提供,而技能冲突又会因所处阶段不同而采用不同规则。

一个可靠的运行时需要同时回答:

  1. 这个状态由谁提供,还有多少来源没有释放?
  2. 当前输入走的是普通激活、预输入窗口,还是外部强制打断?
  3. 被拒绝、排队或取消时,哪一条规则做出了决定?
  4. 旧状态结束后,清理会不会误伤其他来源?

先把标签理解成来源账本

固定标签适合描述对象长期拥有的能力,例如“可以移动”。临时标签则应记录来源,例如某个运行实例和某个持续状态都提供“控制受限”。

控制受限
├─ 固定来源:角色规则
├─ 临时来源:运行实例 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;
}

高优先级输入通过,不代表立即删除旧实例。更安全的交接顺序是:

  1. 新请求完成资源、标签和冷却等全部裁决;
  2. 新运行实例成功建立;
  3. 严格低于新实例的旧能力收到取消请求;
  4. 旧能力执行中断语义并按来源清理;
  5. 新实例继续运行。

这避免了“先取消旧技能,新技能随后因其他条件失败,结果两边都没有”的空窗。

教学页面使用 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(活动能力数),并应保留每个目标未通过的具体过滤原因。
  • 固定标签、临时标签和层级关系是三种不同维度。查询可以聚合,写入与删除不能靠查询结果反推。

最后记住

标签负责描述“当前世界认为对象处于什么状态”,来源账本负责回答“谁有权撤销它”;普通激活、预输入窗口和外部强制打断则分别回答三种不同的冲突问题。

把来源、阶段和决策理由同时保留下来,技能仲裁才不会退化成一组无法解释的布尔值。