11 KiB
角色身体碰撞体:唯一权威数据源(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 取半宽 |
EnemyNavAgent(2 处:WalkToRandomOnSegment / ResolveStandablePoint) |
反向依赖 _enemyMovement.EdgeSafeMargin 获取身体宽度 |
| 即将新增的冲锋能力 | 需要"身体整体越过锁定点"判定,否则又要各算一份半宽 |
问题:
- 无单一权威:改一处(如换身体碰撞体)需同步多处,易漏。
- 覆盖不全:
EnemyMovement是可选组件(EnemyBase全程判空,飞行敌人可无), 把身体尺寸挂在移动层会让"没有移动组件的敌人"拿不到身体数据。 - 跨层反向依赖:导航层为拿身体宽度去问移动层。
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 的前缘 X(dir≥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 / OnValidate,Awake 未执行)同样可用。
方向相关的判定抽为 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.Awake 以 LogError 暴露——不静默吞掉,也不每帧抛异常刷屏。
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() |
私有方法 | 删除 |
EnemyNavAgent(2 处) |
_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 / OnValidate,Awake 未跑) |
Body 懒创建、EnemyBody 懒解析碰撞体,正常工作 |
Inspector 中改 _bodyCollider |
OnValidate 置空缓存 → 下次访问重建 |
死亡时 EnemyBase 禁用所有碰撞体 |
Collider.enabled=false 不影响 bounds 读取;且死亡后无移动/探测需求 |
| 对象池复用 | EnemyBody 缓存的是碰撞体引用(不随位置变化),无需重置 |
5. 测试
EditMode 单测(新增,无场景依赖 —— 沿用 WeightedPick 的纯函数测试范式):
FrontEdge/RearEdge:dir>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.cs(IEnemyBody+EnemyBody) - 新增:
Assets/Tests/EditMode/Enemies/EnemyBodyTests.cs - 修改:
Assets/_Game/Scripts/Enemies/EnemyBase.cs(_bodyCollider字段、Body属性、Awake校验、 扩展既有OnValidate:开头加缓存失效 + 抑制检查后加编辑期警告) - 修改:
Assets/_Game/Scripts/Enemies/EnemyMovement.cs(5 处探测改用Body、EdgeSafeMargin改组合、删字段与私有方法)
8. 后续(另立 spec,不在本次范围)
本权威源就位后,再做冲锋能力改造 + 重命名。会话中已确定的决策先记录于此,避免遗失:
- 冲刺目标:能力开始时(转身/起手动画之前)锁定玩家 X 为
lockX,方向随之定死(committed 冲锋)。 - 终点:
Body.HasPassed(lockX, dir)—— 身体整体越过锁定点。 - 终止条件:越过锁定点 / 前方有墙(只判墙,不判悬崖)/ 超过最大冲刺时长(SO 新增配置)/ 现有净位移卡死检测。
- 悬崖:冲刺期间允许越过崖沿(经
EnemyMoveInput新增的一次性意图标志放开MoveWithSpeed的悬崖夹紧), 敌人冲出边缘后按重力下落,不再停在崖边。 - 重命名:
ContactChaseAbility→ 冲锋语义命名(注意ChargeAbility已被占用,需另选名)。