DYNAMIC EXPLAINER / ARTICLE FLOW

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

01 / 15
STEP 01 / CODE / LOGIC

先说结论

投射物每帧应从旧位置扫掠到新位置,候选按命中时间排序后依次经过阵营、重复命中、穿透次数和目标间隔等规则。命中后的击退也要按时间曲线的增量推进,不能每帧重复叠加累计总量。

上一帧位置:x=0
本帧目标位置:x=12
候选:EnemyA 在 x=4,EnemyB 在 x=8,EnemyC 在 x=10
最大命中数:2

先说结论

投射物每帧应从旧位置扫掠到新位置,候选按命中时间排序后依次经过阵营、重复命中、穿透次数和目标间隔等规则。命中后的击退也要按时间曲线的增量推进,不能每帧重复叠加累计总量。

一颗可穿透两人的子弹

上一帧位置:x=0
本帧目标位置:x=12
候选:EnemyA 在 x=4,EnemyB 在 x=8,EnemyC 在 x=10
最大命中数:2

扫掠结果按进入时间得到 A、B、C。A 和 B 通过过滤后消耗两个命中名额,C 即使几何相交也因穿透上限被拒绝。若 A 在 0.2 秒内不能重复受同来源伤害,还要查询上次命中时间。

一次命中不是“目标进入半径”这么简单。对会移动的投射物来说,系统还要回答:生成时保存了什么、这一帧从哪里扫到哪里、哪些候选可以命中、同一目标能否再次命中、命中后如何结算,以及什么时候清理运行实例。击退则要回答另一个容易出错的问题:配置的是一条累计位移曲线,为什么每帧只能应用其中的增量?

本文把两条链路放在同一个专题中,是因为它们共享同一个调试核心:期望发生什么、实际发生了什么、差值由谁解释

> 页面中的速度、半径、穿透次数、命中间隔和障碍位置都是教学场景数据。它们帮助观察状态变化,不代表任何运行环境的真实默认值或阻挡阈值。

1. 完整运行地图

生成投射物
  → 保存施法者、初始位置、目标快照与运动策略
  → 计算本帧期望位移
  → 在水平平面做胶囊扫掠
  → 过滤自身、阵营、失效目标和命中历史
  → 按候选返回顺序逐个消费
  → 应用效果 / 发起击退
  → 更新每目标命中历史与剩余穿透
  → 完成、创建失败或被取消
  → 释放运行实例与临时状态

候选集合来自空间查询。它不保证按“距离最近”排序,因此有限穿透不能偷偷依赖最近优先。如果设计需要最近目标优先,必须在消费前明确排序,并承担额外的时间成本。

2. 生成快照:创建阶段就固定运行输入

运行实例需要保存后续帧会用到的最小信息:

ProjectileRun CreateRun(SpawnRequest request)
{
    if (!TryCreateEntity(request, out ProjectileRun run))
        return CreateFailure("创建失败"); // 失败保持可观察

    run.Source = request.Source;
    run.Position = request.Origin;
    run.TargetId = request.TargetId;
    run.TargetSnapshot = request.TargetPosition;
    run.MoveMode = request.MoveMode;
    run.RemainingHits = request.PierceLimit;
    return run;
}

这里的“目标快照”很重要。追踪模式每帧可以读取目标当前位置;当目标失效时,运行实例仍能朝最后一次有效位置前进,而不是突然得到一个默认坐标。创建失败同样不能伪装成成功:失败原因要保留,且不能进入后续移动与命中阶段。

3. 两种已确认的运动策略

3.1 追踪

追踪模式在目标有效时更新目标快照,并朝当前目标位置计算方向:

Vector2 ResolveHomingDirection(ProjectileRun run)
{
    if (TryReadTarget(run.TargetId, out Vector2 current))
        run.TargetSnapshot = current;

    return Normalize(run.TargetSnapshot - run.Position);
}

目标丢失并不意味着凭空选择新目标。已保存的最后位置仍然是确定输入,因此投射物继续飞向那里。

3.2 锁定方向并允许穿透

另一种策略在生成时锁定方向,随后不再根据目标转向。它可以配合有限穿透,让一次扫掠返回的多个候选依次进入消费:

Vector2 ResolveLockedDirection(ProjectileRun run)
{
    return run.SpawnDirection;
}

已验证链路只支持把这两种策略写成事实。不能因为它们看起来覆盖了常见需求,就声称另有一个独立的“普通直线”实现。

4. 从线段变成平面胶囊扫掠

高速物体若只检查帧末位置,很容易穿过目标。更稳妥的做法是把“上一位置 → 下一位置”的线段与投射物半径组合成平面胶囊:

Vector2 start = run.Position;
Vector2 expected = direction * speed * deltaTime;
Vector2 end = start + expected;

IReadOnlyList<Candidate> candidates =
    QueryCapsuleOnPlane(start, end, projectileRadius);

胶囊扫掠解决的是水平平面上的连续候选查询。现有事实不足以证明这条链路还处理环境碰撞或弹道高度,因此不能把障碍、落体或三维地形命中写成既有行为。交互页中的“障碍”只用于解释击退的期望位移与实际位移差。

5. 候选还要经过四道门

空间查询返回候选后,至少还要过滤:

  1. 来源自身;
  2. 阵营或目标类型不匹配;
  3. 已失效、已销毁或不可被命中的目标;
  4. 同一目标仍处于命中间隔内。
foreach (Candidate candidate in candidates)
{
    if (candidate.Id == run.Source) continue;
    if (!TargetRule.Accept(candidate)) continue;
    if (!candidate.IsAlive) continue;
    if (!run.HitHistory.CanHit(candidate.Id, now)) continue;

    ApplyHit(run, candidate);
}

命中历史必须按目标记录。全局冷却会错误地让目标 A 的命中阻止目标 B;只记录一个“上次目标”又无法处理穿透和多个目标交错出现。

6. 有限穿透与候选顺序冲突

有限穿透通常在每次成功消费后递减:

void ApplyHit(ProjectileRun run, Candidate target)
{
    effects.Apply(run.Source, target);
    run.HitHistory.Record(target.Id, now);
    run.RemainingHits--;

    if (run.RemainingHits == 0)
        run.Complete("穿透次数耗尽");
}

这里有一个很容易遗漏的冲突:如果本帧候选顺序是 B → A,而只剩一次命中,那么 B 会先消费,哪怕 A 在空间上更近。查询返回顺序没有最近优先保证。要获得最近优先,必须显式计算沿扫掠方向的投影距离并排序,复杂度从一次线性消费变为 O(k log k);其中 k 是本帧候选数。

7. 同目标间隔门控

持续穿过较大目标时,同一目标可能连续多帧出现在胶囊候选中。按目标保存下一次可命中时刻,可以阻止每帧重复结算:

bool CanHit(TargetId id, Time now)
{
    if (!nextAllowedTime.TryGetValue(id, out Time next))
        return true;

    return now >= next;
}

门控失败是明确结果,不应被改成命中,也不应消耗剩余穿透次数。不同目标各自拥有独立记录,因此 A 被门控时,B 仍可以命中。

8. 击退:累计曲线求差,而不是重复累加总量

击退曲线常表达“从开始到归一化时间 t 为止,累计应该移动多少比例”。设总距离为 D,累计曲线为 C(t)

累计期望位移 S(t) = D × C(t)
本帧增量 ΔS = S(t_now) - S(t_prev)

因此每帧要保存上次累计值:

float cumulative = distance * curve.Evaluate(normalizedTime);
float expectedDelta = cumulative - run.LastCumulative;
run.LastCumulative = cumulative;

Vector2 requested = run.Direction * expectedDelta;
Vector2 actual = mover.Move(requested);
run.PendingBlockCheck = (requested, actual);

若错误地每帧都应用 S(t_now),前面已经移动过的部分会被再次累加,最终距离远大于配置值。

9. 为什么在下一次逻辑更新判定受阻

移动接口返回实际位移,但碰撞解算和位置提交可能与请求发生在不同阶段。已确认的链路在下一次逻辑更新中比较上一帧的期望位移与实际位移;当差异达到运行规则时提前停止:

void NextLogicUpdate(KnockbackRun run)
{
    if (IsBlocked(run.PendingExpected, run.PendingActual))
    {
        run.Finish("实际位移不足,提前停止");
        return;
    }

    ApplyNextCurveDelta(run);
}

阻挡规则的真实阈值和默认值不在知识页公开。关键不是记住某个数,而是看清判定输入来自“上一帧期望—实际差值”,且停止发生在下一次逻辑更新。

10. 同来源新击退替换旧请求

同一来源再次发起击退时,新请求替换旧运行实例:

void StartKnockback(SourceId source, KnockbackSpec next)
{
    if (activeBySource.TryGetValue(source, out KnockbackRun previous))
        previous.Interrupt("同来源新请求替换");

    activeBySource[source] = CreateRun(next);
}

替换需要显式结束旧请求,避免两个运行实例同时写入移动。这里的键是来源,不应扩展推断为所有来源互斥。

11. 完成与清理

以下路径都必须收束到可观察的结束状态:

  • 投射物达到目标快照;
  • 剩余穿透耗尽;
  • 运行被主动取消;
  • 创建实体失败;
  • 击退曲线结束;
  • 击退检测到受阻;
  • 同来源新击退替换旧请求。

清理要释放运行实体、命中历史、目标引用和回调所有权。创建失败没有实体可回收,但仍要保留失败结果,不能吞掉错误或进入“看似运行中”的状态。

12. 复杂度与边界

设一帧空间查询返回 k 个候选,当前命中历史有 h 个目标:

  • 胶囊查询的成本取决于空间索引;
  • 候选过滤与按返回顺序消费为 O(k)
  • 哈希命中历史的平均查询、写入为 O(1),空间为 O(h)
  • 如果显式做最近优先排序,则为 O(k log k)
  • 击退曲线求值、求差和阻挡比较每帧为 O(1)

需要特别保留的边界:

  • 候选顺序没有最近优先保证;
  • 没有足够证据声称投射物处理环境碰撞或弹道高度;
  • 没有足够证据声明第三种普通直线运动实现;
  • 创建失败必须保持可见;
  • 阻挡阈值与真实运行默认值不作为公开知识固定值。

13. 与相邻专题的关系

  • 目标筛选与命中判定解释世界坐标如何经过粗筛与精确几何得到候选;
  • Effect / Buff 生命周期解释命中后如何创建效果规格、结算属性、叠层与移除;
  • 本专题负责两者之间的运动、连续扫掠、命中历史、穿透消费,以及击退的期望—实际差值。

最后记住

投射物用旧位置到新位置的扫掠避免穿透。
候选按命中时间排序后再经过合法性和次数门控。
有限穿透必须定义排序和停止规则。
击退曲线按本帧增量推进,不重复叠加累计总量。