DYNAMIC EXPLAINER / ARTICLE FLOW

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

01 / 19
STEP 01 / CODE / LOGIC

先说结论

一次攻击不是“检测到重叠就扣血”,而是攻击实例、空间候选、合法性过滤、同次攻击去重、防御规则、伤害结算和反馈表现组成的流水线。每一层都应能解释目标为什么被命中或被拒绝。

AttackInstanceId:501
粗筛候选:[Enemy12, Enemy18, Ally21]
阵营过滤后:[Enemy12, Enemy18]
本次已命中集合:{ Enemy12 }
Enemy18 状态:Invincible
最终新增命中:0

分类:工程设计

先说结论

一次攻击不是“检测到重叠就扣血”,而是攻击实例、空间候选、合法性过滤、同次攻击去重、防御规则、伤害结算和反馈表现组成的流水线。每一层都应能解释目标为什么被命中或被拒绝。

一次横扫攻击的数据

AttackInstanceId:501
粗筛候选:[Enemy12, Enemy18, Ally21]
阵营过滤后:[Enemy12, Enemy18]
本次已命中集合:{ Enemy12 }
Enemy18 状态:Invincible
最终新增命中:0

Enemy12 因攻击 501 已处理过而被去重,Enemy18 因无敌被规则拒绝,Ally21 在阵营过滤阶段离开。最终没有扣血并不表示碰撞检测失败,而是后续规则正常生效。

先用生活模型理解战斗判定

想象两名练习者使用带感应区域的护具:

  • 武器上的感应区域表示攻击盒
  • 身体不同部位的感应区域表示受击盒
  • 两类区域重叠,只能说明“出现了候选接触”。
  • 裁判还要检查攻击是否有效、防御是否成立、同一次挥击是否已经命中过。
  • 裁判写下最终结果后,灯光、声音和动作表演再根据结果播放。

因此,完整战斗判定不是简单的:

碰撞 -> 扣数值

而是一条有输入、有规则、有结果、有表现消费方的流水线。

为什么要把判定拆成流水线

若所有逻辑都写在一次碰撞回调里,容易混在一起:

  • 空间是否接触;
  • 当前攻击是否处于有效时间;
  • 目标是否无敌;
  • 防御方向是否成立;
  • 是否触发霸体、硬直或反击;
  • 数值怎样变化;
  • 播放什么动画、声音和特效。

这些问题的证据和时间边界不同。拆开后,可以区分:

检测:发生了什么候选接触?
裁决:按当前状态,这次接触算什么结果?
提交:哪些逻辑状态真正改变?
表现:怎样把已确认结果展示出来?

本文提供一种教学模型,不声称它是所有动作游戏的唯一顺序。具体优先级和数值必须来自明确设计规则。

攻击盒与受击盒

攻击盒

攻击盒描述某次攻击在某个逻辑时刻覆盖的空间。

它通常至少需要关联:

攻击者;
攻击实例标识;
攻击定义;
当前有效阶段;
空间形状与位置。

受击盒

受击盒描述一个可被命中的空间区域。

它可能关联:

受击者;
部位标识;
受击倍率或规则标记;
所属阵营或关系;
当前是否可参与查询。

同一角色可能有多个受击盒。一次攻击也可能有多个攻击盒。

重叠只是候选

攻击盒与受击盒相交,只产生候选记录:

HitCandidate
{
    攻击实例,
    攻击者,
    受击者,
    攻击盒,
    受击盒,
    接触信息
}

候选记录还不能直接驱动扣减,因为同一逻辑帧中可能出现:

  • 一个攻击盒同时重叠同一目标的多个受击盒;
  • 多个攻击盒属于同一次攻击;
  • 物理查询重复报告同一对形状;
  • 攻击已经在较早逻辑帧命中过该目标。

需要先按攻击规则去重和筛选。

攻击实例与命中集合

一次“挥击动作”不能只靠攻击者和目标识别。攻击者可能连续执行相同攻击,因此需要攻击实例标识。

教学模型:

AttackInstance
├─ InstanceId
├─ AttackerId
├─ AttackDefinition
├─ ActiveWindow
└─ HitTargets

HitTargets 记录这次攻击实例已经命中的目标。

如果规则是“一次攻击对同一目标只命中一次”,则:

候选目标已在 HitTargets
    ↓
拒绝重复命中

如果规则允许多段命中,就不能简单移除去重。需要由攻击定义明确命中间隔、最大次数或每个命中窗口的独立标识。本文不提供生产默认值。

一条教学判定流水线

可以把一次候选接触分为以下阶段:

1. 收集候选
2. 规范化与去重
3. 验证攻击实例仍有效
4. 验证攻击者与目标关系
5. 检查目标特殊状态
6. 计算防御、霸体、硬直与反击结果
7. 原子提交逻辑结果
8. 记录已命中目标
9. 发布已确认战斗结果
10. 表现层消费结果

第 5、6 步的具体先后属于战斗规则。下面为了讲解给出一种显式教学优先级,但不能直接当作通用生产规则:

无敌
    ↓ 未生效
反击窗口
    ↓ 未生效
防御
    ↓ 未生效
普通命中

霸体不一定阻止受伤,它更常被建模为影响硬直响应。具体语义必须由设计定义。

逐步状态

教学示例中的一次攻击:

Created:攻击实例创建
    ↓
Startup:尚未生成有效攻击盒
    ↓
Active:攻击盒参与逻辑查询
    ↓
Recovery:攻击盒失效,动作仍在收招
    ↓
Finished:实例结束

这些名字表达常见概念,但每个阶段持续多少逻辑帧必须来自攻击定义。本文不提供默认持续时间。

候选接触进入裁决后:

Candidate
    ├─ Invalid:攻击或目标已无效
    ├─ Duplicate:本攻击实例已处理该目标
    ├─ Invulnerable:目标处于适用的无敌状态
    ├─ Countered:反击规则成立
    ├─ Guarded:防御规则成立
    └─ Hit:普通命中

每个结果都应能说明“为什么”,而不是只返回一个 bool

无敌

无敌表示某类攻击在某个时间范围内不能对目标形成普通命中结果。

需要明确的条件包括:

无敌影响哪些攻击类型;
是否仍产生接触反馈;
是否消耗攻击的一次命中资格;
是否允许触发其他规则;
开始和结束落在哪个逻辑边界。

“无敌”不等于让受击盒消失。保留候选接触有助于区分“没有碰到”和“碰到了但被规则拒绝”。

若无敌结果仍要播放轻微反馈,那也是表现层对明确逻辑结果的响应,不应伪装成普通命中。

防御

防御通常不只是一个布尔状态,还可能取决于:

  • 防御是否处于有效窗口;
  • 攻击来源方向是否位于防御范围;
  • 攻击是否允许被防御;
  • 防御者是否有足够的规则资源;
  • 攻击高度或类型是否匹配。

方向判断应使用明确坐标空间和向量定义。例如:

防御者朝向向量;
防御者指向攻击者的方向;
两者夹角或点积;
设计规定的有效范围。

具体角度阈值属于行为数值,不能从画面相似度猜测。

防御结果可能包含:

完全防御;
部分影响;
防御被破坏;
不满足防御条件。

这些必须是规则层输出,而不是由表现动画反推。

霸体与硬直

霸体和无敌不应混为一谈。

一种清晰的教学拆分是:

伤害结果:是否改变数值状态?
反应结果:是否进入硬直、击退或倒地?

在某些规则中,霸体允许伤害成立,但阻止普通硬直。另一些规则可能使用等级比较决定是否被打断。

因此裁决可以分别输出:

DamageOutcome
ReactionOutcome

而不是用一个 WasHit 同时代表受伤和播放硬直。

硬直本身也需要状态边界:

进入硬直;
硬直期间允许或禁止哪些输入;
被更高优先级状态替换;
在确定逻辑时刻结束。

这些状态转换应由逻辑系统确认,动画只负责展示。

反击

反击不是“收到攻击事件后随便再攻击一次”。它通常要求:

目标处于有效反击窗口;
来袭攻击满足可反击条件;
候选接触满足方向或距离规则;
反击尚未被当前窗口消耗;
裁决输出 Countered;
原攻击后续效果按规则终止或改变;
创建明确的反击动作请求。

反击请求属于命令或状态转换意图;“反击已成立”才是事实事件。

若原攻击已经提交伤害后才检查反击,就可能出现既受普通命中又反击成功的矛盾结果,除非设计明确允许这种组合。

最小 C# 教学示例

下面只展示规则裁决,不包含空间查询和表现播放。优先顺序是本文明确标注的教学示例,不是通用默认规则。

public enum HitOutcome
{
    Invalid,
    Duplicate,
    Invulnerable,
    Countered,
    Guarded,
    Hit
}

public readonly record struct AttackRules(
    bool CanBeGuarded,
    bool CanBeCountered,
    int PoiseDamage);

public readonly record struct DefenderState(
    bool IsInvulnerable,
    bool IsGuarding,
    bool IsInCounterWindow,
    int Poise);

public readonly record struct HitDecision(
    HitOutcome Outcome,
    bool AppliesDamage,
    bool AppliesStagger,
    string Reason);

public static class HitResolver
{
    public static HitDecision Resolve(
        AttackRules attack,
        DefenderState defender,
        bool attackIsActive,
        bool alreadyHitTarget,
        bool guardDirectionMatches)
    {
        if (!attackIsActive)
        {
            return new HitDecision(
                HitOutcome.Invalid,
                false,
                false,
                "攻击实例当前不在有效判定阶段。");
        }

        if (alreadyHitTarget)
        {
            return new HitDecision(
                HitOutcome.Duplicate,
                false,
                false,
                "同一攻击实例已处理过该目标。");
        }

        // 以下顺序仅是教学示例的显式规则。
        if (defender.IsInvulnerable)
        {
            return new HitDecision(
                HitOutcome.Invulnerable,
                false,
                false,
                "目标处于适用的无敌状态。");
        }

        if (defender.IsInCounterWindow && attack.CanBeCountered)
        {
            return new HitDecision(
                HitOutcome.Countered,
                false,
                false,
                "反击窗口与攻击条件同时成立。");
        }

        if (defender.IsGuarding
            && attack.CanBeGuarded
            && guardDirectionMatches)
        {
            return new HitDecision(
                HitOutcome.Guarded,
                false,
                false,
                "防御状态、攻击类型与方向条件均成立。");
        }

        bool appliesStagger = attack.PoiseDamage > defender.Poise;
        return new HitDecision(
            HitOutcome.Hit,
            true,
            appliesStagger,
            appliesStagger
                ? "普通命中成立,并达到教学示例的硬直条件。"
                : "普通命中成立,但未达到教学示例的硬直条件。");
    }
}

var attack = new AttackRules(
    CanBeGuarded: true,
    CanBeCountered: true,
    PoiseDamage: 3);

var defender = new DefenderState(
    IsInvulnerable: false,
    IsGuarding: false,
    IsInCounterWindow: false,
    Poise: 2);

// 数值 3 和 2、以及布尔组合都只是教学示例输入。
HitDecision decision = HitResolver.Resolve(
    attack,
    defender,
    attackIsActive: true,
    alreadyHitTarget: false,
    guardDirectionMatches: false);

Console.WriteLine($"{decision.Outcome}: {decision.Reason}");

这个示例把三件事分开:

Outcome:接触最终属于哪类结果。
AppliesDamage:是否提交伤害类逻辑。
AppliesStagger:是否提交硬直类逻辑。

真实规则可能更复杂,但不应把未定义情况悄悄归为普通命中。新增结果分支前,应先有明确规则来源。

判定与表现分离

逻辑判定输出已经确认的结果:

攻击实例标识;
攻击者与目标;
命中结果;
数值变化;
反应类型;
逻辑发生时刻;
接触位置与方向。

表现层根据结果播放:

  • 动画;
  • 声音;
  • 粒子;
  • 镜头反馈;
  • 界面提示。

正确依赖方向是:

逻辑结果 -> 表现

不应让逻辑依赖“某个特效是否播完”来决定命中是否成立。

表现失败时

若某个表现资源缺失或播放失败,已经提交的逻辑结果不会因此自动撤销。

表现失败也不应被静默转成“成功播放”。需要记录原始失败,并由明确规则决定是否重试、跳过或使用替代表现。

进阶:逻辑帧与动画事件边界

为什么动画事件不能单独充当权威判定

动画事件适合在播放时间轴上发出提示,但它可能受到:

  • 播放速度变化;
  • 动画混合;
  • 跳转与中断;
  • 帧率波动;
  • 重放和回滚;
  • 动画资源修改。

如果唯一的攻击生效时刻藏在动画事件中,逻辑时序会依赖表现播放状态。

清晰的边界

一种教学结构:

逻辑层:
根据攻击定义与逻辑帧,决定攻击窗口是否有效。

动画层:
根据同一攻击状态播放对应动画。

动画事件:
请求播放声音、轨迹等表现,或向开发工具报告时间点。

如果动画事件需要通知逻辑层,它应携带可验证的攻击实例和阶段信息;逻辑层仍要确认当前状态,不能无条件相信一个迟到事件。

逻辑帧推进

教学示例:

逻辑帧 N:
读取当前攻击状态
更新攻击窗口
生成攻击盒
收集候选
裁决并提交结果
发布已确认事件

渲染阶段:
读取最新逻辑状态
插值或播放表现

N 是符号化教学标记,不表示具体帧率。

同一逻辑帧内多个候选的顺序也必须确定。如果顺序会影响结果,可依据稳定标识排序或使用同时结算模型;选择哪一种需要来自明确战斗规则。

正常路径

教学正常命中流程:

1. 攻击实例进入有效窗口。
2. 逻辑查询发现攻击盒与受击盒相交。
3. 候选按攻击实例和目标规范化。
4. 目标不在本次攻击的已命中集合中。
5. 无敌、反击和防御条件均不成立。
6. 计算伤害与反应结果。
7. 原子提交目标状态变化。
8. 把目标加入本次攻击的已命中集合。
9. 发布 HitConfirmed 事实。
10. 表现层播放已确认命中的反馈。

“原子提交”表示外部不能观察到只完成一部分的战斗结果。具体实现方式取决于状态系统,本文不假设某种事务机制。

边界与失败路径

同一目标多个受击盒

若一次查询同时命中头部和身体,必须由攻击规则决定:

只选择一个优先部位;
合并为一次命中;
允许分别结算。

不能让物理回调顺序偶然决定结果。

双方同一逻辑帧命中

需要明确是:

按稳定顺序逐个结算;
先收集后同时结算;
由优先级规则裁决。

不同策略会产生不同结果,不能从实现便利性推断设计意图。

攻击者在结算前失效

候选收集与提交之间可能发生状态变化。裁决阶段应验证攻击实例和参与者仍满足规则。无效时返回明确原因,不应继续使用旧引用。

目标在同帧离开无敌

无敌边界必须绑定逻辑时刻。判定读取哪一个状态快照应固定,不能由脚本执行先后偶然决定。

防御方向临界值

浮点误差可能让临界角度在两种结果间抖动。角度表示、比较方式和容差必须由数学与设计规则共同确定,不能随意添加魔法偏移。

硬直与霸体同时变化

若霸体在同一逻辑帧被另一效果添加或移除,需要明确状态读取顺序或使用统一快照。

反击与普通命中都被触发

这通常说明裁决被拆散到多个监听者,各自独立提交。应让一个权威裁决输出单一或明确可组合的结果,再由事件广播事实。

动画事件迟到

迟到事件必须携带攻击实例标识。若实例已经结束,应暴露为无效时序,而不是作用到当前的新攻击实例。

表现回调修改逻辑

表现层若在播放过程中直接扣减数值或改变硬直,会产生第二条状态修改路径。应把这种请求送回明确的逻辑入口,并重新验证。

复杂度与代价

设当前参与查询的攻击盒数量为 A,受击盒数量为 H,候选接触数为 C

最朴素的两两检测是:

O(A × H)

空间划分或物理 Broad Phase 可以减少需要精确检测的配对,但具体复杂度取决于数据分布和结构。

判定阶段常见成本:

操作 常见时间复杂度 主要代价
候选去重(哈希集合) 平均 O(C) 保存候选键
查询是否已命中目标 平均 O(1) 每个攻击实例维护集合
单个候选规则裁决 规则数固定时 O(1) 状态读取与分支
稳定排序候选 O(C log C) 确定结算顺序
发布结果 与监听者数量相关 表现和派生系统处理

主要工程代价包括:

  • 攻击定义、状态快照和实例记录占用内存;
  • 多段攻击需要更精细的命中历史;
  • 回滚或重放要求逻辑结果可确定地重建;
  • 判定与表现分离后,需要稳定的结果数据契约;
  • 调试必须能关联攻击实例、候选、裁决原因和表现。

调试时应记录什么

一个可解释的命中记录可以包含:

逻辑帧;
攻击实例标识;
攻击者与目标标识;
攻击盒与受击盒标识;
候选接触信息;
裁决读取的状态;
最终 HitOutcome;
拒绝或成立原因;
提交的状态变化;
发布的结果事件。

这样才能区分:

根本没有产生候选;
产生候选但被去重;
无敌拒绝;
防御成立;
反击成立;
命中成立但表现失败。

只记录“没打到”无法定位流水线中的具体阶段。

小结

攻击盒与受击盒重叠只产生候选。
攻击实例和命中集合决定重复命中边界。
无敌、防御、反击、霸体与硬直应输出可解释的独立结果。
判定负责逻辑事实,表现负责展示事实。
逻辑帧决定权威时间,动画事件不能无条件改写逻辑。
同帧顺序、临界值和异常路径都必须有明确规则来源。

完整战斗判定的目标不是堆叠更多条件,而是让每次结果都能回答:

哪个攻击实例在什么逻辑时刻产生了候选?
读取了哪些状态?
按哪条明确规则得到结果?
提交了哪些逻辑变化?
哪些表现只是这个结果的消费者?

当这些问题都有可追踪答案时,复杂战斗规则才有继续扩展和验证的基础。