docs(enemy): 角色身体碰撞体唯一权威源(IEnemyBody)设计
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
# 角色身体碰撞体:唯一权威数据源(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 的前缘 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 纯函数**,便于无场景依赖单测:
|
||||
|
||||
```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` 已被占用,需另选名)。
|
||||
Reference in New Issue
Block a user