From 3752a9a0e0cf94acf6673df761deaa835b335226 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 27 Jul 2026 11:20:09 +0800 Subject: [PATCH] =?UTF-8?q?docs(enemy):=20=E8=A7=92=E8=89=B2=E8=BA=AB?= =?UTF-8?q?=E4=BD=93=E7=A2=B0=E6=92=9E=E4=BD=93=E5=94=AF=E4=B8=80=E6=9D=83?= =?UTF-8?q?=E5=A8=81=E6=BA=90(IEnemyBody)=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-07-27-enemy-body-authority-design.md | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 Docs_Dev/superpowers/specs/2026-07-27-enemy-body-authority-design.md diff --git a/Docs_Dev/superpowers/specs/2026-07-27-enemy-body-authority-design.md b/Docs_Dev/superpowers/specs/2026-07-27-enemy-body-authority-design.md new file mode 100644 index 00000000..9800c347 --- /dev/null +++ b/Docs_Dev/superpowers/specs/2026-07-27-enemy-body-authority-design.md @@ -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()` **重复 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()`。 +两者都没有 → `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 +/// 角色身体碰撞体的唯一权威查询口。几何实时取自碰撞体 bounds(随位置/动画变化)。 +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) 脚底中点 + + /// 朝 dir 的前缘 X(dir≥0 取 max.x,否则 min.x)。 + float FrontEdgeX(float dir); + /// 朝 dir 的后缘 X(与前缘相反侧)。 + float RearEdgeX(float dir); + /// 身体是否已**整体**越过世界 X(dir≥0 时后缘 ≥ worldX;否则后缘 ≤ worldX)。 + 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()` 懒获取宿主(编辑器模式下 `Awake` 未执行亦可用), +与既有 `EnemyBase.GetComponent()` 的同级互查模式一致。 + +### 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` 已被占用,需另选名)。