把章节变成可单步观察的过程
分类:工程设计
先说结论
一次攻击不是“检测到重叠就扣血”,而是攻击实例、空间候选、合法性过滤、同次攻击去重、防御规则、伤害结算和反馈表现组成的流水线。每一层都应能解释目标为什么被命中或被拒绝。
一次横扫攻击的数据
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;
拒绝或成立原因;
提交的状态变化;
发布的结果事件。
这样才能区分:
根本没有产生候选;
产生候选但被去重;
无敌拒绝;
防御成立;
反击成立;
命中成立但表现失败。
只记录“没打到”无法定位流水线中的具体阶段。
小结
攻击盒与受击盒重叠只产生候选。
攻击实例和命中集合决定重复命中边界。
无敌、防御、反击、霸体与硬直应输出可解释的独立结果。
判定负责逻辑事实,表现负责展示事实。
逻辑帧决定权威时间,动画事件不能无条件改写逻辑。
同帧顺序、临界值和异常路径都必须有明确规则来源。
完整战斗判定的目标不是堆叠更多条件,而是让每次结果都能回答:
哪个攻击实例在什么逻辑时刻产生了候选?
读取了哪些状态?
按哪条明确规则得到结果?
提交了哪些逻辑变化?
哪些表现只是这个结果的消费者?
当这些问题都有可追踪答案时,复杂战斗规则才有继续扩展和验证的基础。