Files
zeling_v2/Docs_Dev/superpowers/specs/2026-07-27-enemy-body-authority-design.md
T

11 KiB
Raw Blame History

角色身体碰撞体:唯一权威数据源(IEnemyBody)设计

日期:2026-07-27  状态:设计(待实现) 关联:2026-07-23-enemy-pathfind-obstruction-handling-design.md(导航按身体宽度内缩目标点)、 后续 冲锋能力改造(需要"身体整体越过某点"判定)。

1. 背景与问题

角色(敌人)身体碰撞体的尺寸信息目前没有唯一来源,散落在各处:

位置 现状
EnemyMovement.GetGroundRayOrigin / GetWallRayOrigin / GetLedgeRayOrigin / GroundColliderOrSelf 同一句 _groundCheckCollider != null ? _groundCheckCollider : GetComponent<Collider2D>() 重复 4 遍
EnemyMovement.EdgeSafeMargin 自行从 bounds.extents.x 取半宽
EnemyNavAgent2 处:WalkToRandomOnSegment / ResolveStandablePoint 反向依赖 _enemyMovement.EdgeSafeMargin 获取身体宽度
即将新增的冲锋能力 需要"身体整体越过锁定点"判定,否则又要各算一份半宽

问题:

  1. 无单一权威:改一处(如换身体碰撞体)需同步多处,易漏。
  2. 覆盖不全EnemyMovement可选组件EnemyBase 全程判空,飞行敌人可无), 把身体尺寸挂在移动层会让"没有移动组件的敌人"拿不到身体数据。
  3. 跨层反向依赖:导航层为拿身体宽度去问移动层。

2. 目标 / 非目标

目标

  • 建立唯一权威:一个序列化字段 + 一次解析 + 一个查询接口。
  • 所有消费者(移动探测 / 导航 / 能力)都经该接口获取身体几何。
  • 覆盖所有敌人(含无 EnemyMovement 的飞行敌人)。
  • 消除 EnemyMovement 里 4 处重复解析。

非目标

  • 不改玩家角色(PlayerController 自成体系,本期不动;接口形状不做玩家适配的额外抽象)。
  • 不改变任何现有运行时行为(纯结构收敛,行为等价)。
  • 不引入新的 MonoBehaviour 组件(不增加预制体挂载负担)。

3. 设计

3.1 权威源:EnemyBase

// EnemyBase
[Header("身体碰撞体(唯一权威源)")]
[Tooltip("用于身体几何(宽高/边缘/探测起点)的碰撞体。留空 = 使用本物体上的 Collider2D。")]
[SerializeField] private Collider2D _bodyCollider;

private EnemyBody _body;
public IEnemyBody Body => _body ??= new EnemyBody(gameObject, _bodyCollider);

解析顺序_bodyCollider(显式指定)→ 本物体 GetComponent<Collider2D>()。 两者都没有 → Awake 显式 LogError(根因暴露,不静默兜底,遵 CLAUDE.md §6)。

缓存失效EnemyBase 已存在 protected virtual void OnValidate()(内部对 SuppressValidationWarnings 有提前返回)。在其最开头(早于该提前返回)加入 _body = null; ——缓存失效与"是否抑制警告"无关,必须无条件执行。不要新增第二个 OnValidate(会重复定义)。

校验位置(与既有模式对齐):

  • OnValidate 内(在 SuppressValidationWarnings 提前返回之后,与其余 LogWarning 并列): 编辑期提示"未找到身体碰撞体",可被脚手架批量创建时抑制;
  • Awake 内:运行期 LogError(同既有"未找到 EnemyLocomotion 组件"的处理方式)。

选择 EnemyBase 承载的理由:所有敌人都有它(含飞行敌人),是既有的角色门面 (已暴露 Nav / Movement / Stats / Abilities / AttackSelector)。

3.2 查询接口 IEnemyBody

新文件 Assets/_Game/Scripts/Enemies/EnemyBody.cs(接口 + 实现同文件,均属 BaseGames.Enemies)。

/// <summary>角色身体碰撞体的唯一权威查询口。几何实时取自碰撞体 bounds(随位置/动画变化)。</summary>
public interface IEnemyBody
{
    Collider2D Collider   { get; }
    Bounds     Bounds     { get; }   // 世界空间;实时查询,不缓存
    float      Width      { get; }
    float      Height     { get; }
    float      HalfWidth  { get; }
    float      HalfHeight { get; }
    Vector2    Center     { get; }
    Vector2    FootPoint  { get; }                   // (center.x, min.y) 脚底中点

    /// <summary>朝 dir 的前缘 Xdir≥0 取 max.x,否则 min.x)。</summary>
    float FrontEdgeX(float dir);
    /// <summary>朝 dir 的后缘 X(与前缘相反侧)。</summary>
    float RearEdgeX(float dir);
    /// <summary>身体是否已**整体**越过世界 X(dir≥0 时后缘 ≥ worldX;否则后缘 ≤ worldX)。</summary>
    bool  HasPassed(float worldX, float dir);
}

3.3 实现 EnemyBody

纯 C# 类(非 MonoBehaviour),构造时接收宿主 GameObject 与显式碰撞体(可空), 懒解析并缓存碰撞体引用 → 编辑器模式(Gizmo / OnValidateAwake 未执行)同样可用。

方向相关的判定抽为 static 纯函数,便于无场景依赖单测:

public static float FrontEdge(float minX, float maxX, float dir) => dir >= 0f ? maxX : minX;
public static float RearEdge (float minX, float maxX, float dir) => dir >= 0f ? minX : maxX;
public static bool  BodyPassed(float minX, float maxX, float worldX, float dir)
    => dir >= 0f ? minX >= worldX : maxX <= worldX;

碰撞体缺失时:几何属性返回退化值(Bounds = new Bounds(transform.position, zero)), 但错误已在 EnemyBase.AwakeLogError 暴露——不静默吞掉,也不每帧抛异常刷屏。

3.4 消费端改造

消费者 现状 改后
EnemyMovement.GetGroundRayOrigin 自行解析 collider,取 b.min.x/max.x/min.y Body.Bounds
EnemyMovement.GetWallRayOrigin 自行解析,取前缘 + center.y Body.FrontEdgeX(_facingDir) + Body.Bounds.center.y
EnemyMovement.GetLedgeRayOrigin 自行解析,前缘 ± 偏移 + min.y Body.FrontEdgeX(_facingDir) ± 偏移 + Body.Bounds.min.y
EnemyMovement.WouldHitWallAhead GroundColliderOrSelf().bounds Body.FrontEdgeX(dir) / Body.Bounds.center.y
EnemyMovement.WouldFallAhead GroundColliderOrSelf().bounds Body.FrontEdgeX(dir) / Body.Bounds.min.y
EnemyMovement.EdgeSafeMargin 自取 extents.x + 偏移 Body.HalfWidth + Mathf.Max(0, _ledgeCheckFwdOffset)
EnemyMovement._groundCheckCollider 字段 序列化字段(实测两个敌人都为 null) 删除;改由 EnemyBase._bodyCollider 指定
EnemyMovement.GroundColliderOrSelf() 私有方法 删除
EnemyNavAgent2 处) _enemyMovement.EdgeSafeMargin 保持不变(见 §3.5

EnemyMovement 通过 GetComponent<EnemyBase>() 懒获取宿主(编辑器模式下 Awake 未执行亦可用), 与既有 EnemyBase.GetComponent<EnemyMovement>() 的同级互查模式一致。

3.5 分层原则:身体尺寸 vs 站立余量

EdgeSafeMargin = 身体半宽 + 悬崖探测前偏移,其中探测前偏移是移动层自己的射线调参 不属于身体几何。因此:

  • 身体尺寸IEnemyBody 唯一权威。
  • 站立安全余量 → 由移动层用权威的 HalfWidth 组合得出,仍由移动层暴露。
  • 导航继续向移动层索取 EdgeSafeMargin——它要的正是"移动层不会把我夹停"的约束, 这是对移动层约束的合理查询,而非身体尺寸的旁路;其中的宽度部分已来自唯一权威。

4. 边界与错误处理

情形 行为
_bodyCollider 留空且本物体无 Collider2D Awake LogError + OnValidate 编辑期 LogWarning(指明物体名),几何返回退化值
脚手架批量创建敌人(组件尚未装齐) SuppressValidationWarnings=true 期间不刷编辑期警告(沿用既有机制)
敌人无 EnemyMovement(飞行敌人) 不受影响:Body 挂在 EnemyBase,照常可用
编辑器模式(Gizmo / OnValidateAwake 未跑) Body 懒创建、EnemyBody 懒解析碰撞体,正常工作
Inspector 中改 _bodyCollider OnValidate 置空缓存 → 下次访问重建
死亡时 EnemyBase 禁用所有碰撞体 Collider.enabled=false 不影响 bounds 读取;且死亡后无移动/探测需求
对象池复用 EnemyBody 缓存的是碰撞体引用(不随位置变化),无需重置

5. 测试

EditMode 单测(新增,无场景依赖 —— 沿用 WeightedPick 的纯函数测试范式):

  • FrontEdge / RearEdgedir>0、dir<0、dir=0(按 ≥0 归入正向)三类。
  • BodyPassed
    • 向右:未越过 / 恰好后缘等于目标(边界,判定为已越过)/ 已越过;
    • 向左:同上三类;
    • 确认左右方向的符号不会写反(这是最易出错处)。

回归:现有 EditMode 全套(当前 177 条)必须全绿——本改动触及 EnemyMovement 共享探测路径。

PlayMode 冒烟(行为等价性验证,因两敌人 _groundCheckCollider 实测均为 null,改后应完全一致):

  • 巡逻(Waypoints/Pace)走位、崖边夹停、撞墙掉头与改造前一致;
  • 导航寻路的目标点吸附(ResolveStandablePoint)仍能正确内缩、不再崖沿夹停。

6. 迁移与兼容性

结论:现有资产零改动、行为等价。 依据:

  • ENM_CaoZhi(场景实例)与 ENM_ChaoFeng(预制体)的 EnemyMovement._groundCheckCollider 实测均为 null → 当前实际使用的就是根物体上的 Collider2D
  • 新权威的解析顺序在 _bodyCollider 留空时同样落到根 Collider2D解析结果不变

删除 _groundCheckCollider 字段后,Unity 会丢弃该序列化数据(值为 null,无信息损失)。 若将来某敌人确需用非根碰撞体做身体几何,在 EnemyBase._bodyCollider 指定即可(语义更清晰: 它影响的是"身体",而不仅是"地面检测")。

7. 涉及文件

  • 新增Assets/_Game/Scripts/Enemies/EnemyBody.csIEnemyBody + EnemyBody
  • 新增Assets/Tests/EditMode/Enemies/EnemyBodyTests.cs
  • 修改Assets/_Game/Scripts/Enemies/EnemyBase.cs_bodyCollider 字段、Body 属性、Awake 校验、 扩展既有 OnValidate:开头加缓存失效 + 抑制检查后加编辑期警告)
  • 修改Assets/_Game/Scripts/Enemies/EnemyMovement.cs5 处探测改用 BodyEdgeSafeMargin 改组合、删字段与私有方法)

8. 后续(另立 spec,不在本次范围)

本权威源就位后,再做冲锋能力改造 + 重命名。会话中已确定的决策先记录于此,避免遗失:

  • 冲刺目标:能力开始时(转身/起手动画之前)锁定玩家 X 为 lockX,方向随之定死(committed 冲锋)。
  • 终点:Body.HasPassed(lockX, dir) —— 身体整体越过锁定点。
  • 终止条件:越过锁定点 / 前方有墙(只判墙,不判悬崖)/ 超过最大冲刺时长(SO 新增配置)/ 现有净位移卡死检测。
  • 悬崖:冲刺期间允许越过崖沿(经 EnemyMoveInput 新增的一次性意图标志放开 MoveWithSpeed 的悬崖夹紧), 敌人冲出边缘后按重力下落,不再停在崖边。
  • 重命名:ContactChaseAbility → 冲锋语义命名(注意 ChargeAbility 已被占用,需另选名)。