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

191 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 角色身体碰撞体:唯一权威数据源(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` 获取身体宽度 |
| 即将新增的冲锋能力 | 需要"身体整体越过锁定点"判定,否则又要各算一份半宽 |
问题:
1. **无单一权威**:改一处(如换身体碰撞体)需同步多处,易漏。
2. **覆盖不全**`EnemyMovement` 是**可选组件**`EnemyBase` 全程判空,飞行敌人可无),
把身体尺寸挂在移动层会让"没有移动组件的敌人"拿不到身体数据。
3. **跨层反向依赖**:导航层为拿身体宽度去问移动层。
## 2. 目标 / 非目标
**目标**
- 建立**唯一权威**:一个序列化字段 + 一次解析 + 一个查询接口。
- 所有消费者(移动探测 / 导航 / 能力)都经该接口获取身体几何。
- 覆盖**所有**敌人(含无 `EnemyMovement` 的飞行敌人)。
- 消除 `EnemyMovement` 里 4 处重复解析。
**非目标**
- 不改玩家角色(`PlayerController` 自成体系,本期不动;接口形状不做玩家适配的额外抽象)。
- 不改变任何现有运行时行为(纯结构收敛,行为等价)。
- 不引入新的 MonoBehaviour 组件(不增加预制体挂载负担)。
## 3. 设计
### 3.1 权威源:`EnemyBase`
```csharp
// 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`)。
```csharp
/// <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 / `OnValidate``Awake` 未执行)同样可用。
方向相关的判定抽为 **static 纯函数**,便于无场景依赖单测:
```csharp
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` 已被占用,需另选名)。