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

23 KiB
Raw Blame History

角色身体碰撞体唯一权威源(IEnemyBody)实现计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 把散落在 EnemyMovement 里 4 处重复的"身体碰撞体解析"与各处身体尺寸取用,收敛为 EnemyBase 持有、经 IEnemyBody 接口暴露的唯一权威源。

Architecture: 新增纯 C# 类 EnemyBody(实现 IEnemyBody,懒解析碰撞体、实时读 bounds、方向判定抽为 static 纯函数便于单测);EnemyBase 持有它并按 _bodyCollider → 根 Collider2D 顺序解析,缺失时显式报错;EnemyMovement 的 5 处探测与 EdgeSafeMargin 全部改走该接口,删除自己的 _groundCheckCollider 字段与 GroundColliderOrSelf()

Tech Stack: Unity 2022.3、C#、NUnit EditMode 测试(Unity Test Runner,经 MCP unity_testing_run_tests 运行)。

设计文档: Docs_Dev/superpowers/specs/2026-07-27-enemy-body-authority-design.md

通用验证方式(每个 Task 都要做):

  • 编译门:改 C# 后先 MCP unity_execute_menu_item("Assets/Refresh"),等约 8 秒,再 unity_get_compilation_errors,预期 0 错误。
  • 测试门MCP unity_testing_run_tests({mode:"EditMode"})unity_testing_get_job(jobId)基线 177 条全绿(本计划新增测试后基线上升,见各 Task)。
  • 提交:中文 + 类型前缀(feat(enemy): / refactor(enemy): / test(enemy):)。
  • 重要:本计划触及所有敌人共用的移动探测路径,每个 Task 结束都必须跑全量 EditMode,不能只跑新增测试。

关键既有事实(已核实,实现时可直接依赖):

  • EnemyBase 已有 protected virtual void OnValidate()EnemyBase.cs:758#if UNITY_EDITOR 内), 开头是 if (SuppressValidationWarnings) return; —— 不要新增第二个 OnValidate
  • EnemyBase.AwakeEnemyBase.cs:531-552 一带做组件收集与 Debug.Assert 校验。
  • EnemyMovement._groundCheckColliderEnemyMovement.cs:46)在 ENM_CaoZhi(场景)与 ENM_ChaoFeng(预制体)上实测均为 null → 删除它不改变现有行为。
  • EnemyMovement 目前不持有 EnemyBase 引用,需要新增懒获取。

文件结构

新建:

  • 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.cs — 5 处探测改走 BodyEdgeSafeMargin 改组合、删字段与私有方法。

Task 1: IEnemyBody 接口与 EnemyBody 实现(含纯函数单测,TDD

Files:

  • Test: Assets/Tests/EditMode/Enemies/EnemyBodyTests.cs

  • Create: Assets/_Game/Scripts/Enemies/EnemyBody.cs

  • Step 1: 先写失败测试

创建 Assets/Tests/EditMode/Enemies/EnemyBodyTests.cs

using NUnit.Framework;
using BaseGames.Enemies;

namespace BaseGames.Tests.EditMode.Enemies
{
    /// <summary>
    /// EnemyBody 方向相关纯函数测试(无场景依赖)。
    /// 这三个函数的左右符号最易写反,故对两个方向与边界相等情形都做断言。
    /// </summary>
    public class EnemyBodyTests
    {
        // 身体占据 [2, 4],中心 3,半宽 1
        const float MinX = 2f, MaxX = 4f;

        [Test]
        public void FrontEdge_Right_IsMaxX()
            => Assert.AreEqual(MaxX, EnemyBody.FrontEdge(MinX, MaxX, 1f));

        [Test]
        public void FrontEdge_Left_IsMinX()
            => Assert.AreEqual(MinX, EnemyBody.FrontEdge(MinX, MaxX, -1f));

        [Test]
        public void FrontEdge_ZeroDir_TreatedAsRight()
            => Assert.AreEqual(MaxX, EnemyBody.FrontEdge(MinX, MaxX, 0f));

        [Test]
        public void RearEdge_Right_IsMinX()
            => Assert.AreEqual(MinX, EnemyBody.RearEdge(MinX, MaxX, 1f));

        [Test]
        public void RearEdge_Left_IsMaxX()
            => Assert.AreEqual(MaxX, EnemyBody.RearEdge(MinX, MaxX, -1f));

        [Test]
        public void RearEdge_ZeroDir_TreatedAsRight()
            => Assert.AreEqual(MinX, EnemyBody.RearEdge(MinX, MaxX, 0f));

        // ── BodyPassed:向右 ─────────────────────────────────────────────
        [Test]
        public void BodyPassed_Right_NotReached_False()
            => Assert.IsFalse(EnemyBody.BodyPassed(MinX, MaxX, 5f, 1f));   // 目标在身体右侧外

        [Test]
        public void BodyPassed_Right_OverlappingTarget_False()
            => Assert.IsFalse(EnemyBody.BodyPassed(MinX, MaxX, 3f, 1f));   // 目标仍在身体内,未整体越过

        [Test]
        public void BodyPassed_Right_ExactlyAtRearEdge_True()
            => Assert.IsTrue(EnemyBody.BodyPassed(MinX, MaxX, 2f, 1f));    // 后缘恰好等于目标 = 已越过

        [Test]
        public void BodyPassed_Right_FullyPassed_True()
            => Assert.IsTrue(EnemyBody.BodyPassed(MinX, MaxX, 1f, 1f));

        // ── BodyPassed:向左 ─────────────────────────────────────────────
        [Test]
        public void BodyPassed_Left_NotReached_False()
            => Assert.IsFalse(EnemyBody.BodyPassed(MinX, MaxX, 1f, -1f));  // 目标在身体左侧外

        [Test]
        public void BodyPassed_Left_OverlappingTarget_False()
            => Assert.IsFalse(EnemyBody.BodyPassed(MinX, MaxX, 3f, -1f));

        [Test]
        public void BodyPassed_Left_ExactlyAtRearEdge_True()
            => Assert.IsTrue(EnemyBody.BodyPassed(MinX, MaxX, 4f, -1f));   // 后缘(max)恰好等于目标

        [Test]
        public void BodyPassed_Left_FullyPassed_True()
            => Assert.IsTrue(EnemyBody.BodyPassed(MinX, MaxX, 5f, -1f));
    }
}
  • Step 2: 运行确认失败

MCPunity_execute_menu_item("Assets/Refresh") → 等 8 秒 → unity_get_compilation_errors。 预期:编译失败,报 EnemyBody 不存在(CS0103/CS0246)。

  • Step 3: 实现 EnemyBody.cs

创建 Assets/_Game/Scripts/Enemies/EnemyBody.cs

using UnityEngine;

namespace BaseGames.Enemies
{
    /// <summary>
    /// 角色身体碰撞体的唯一权威查询口。
    /// 几何实时取自碰撞体 bounds(随位置/动画变化,不缓存数值),仅缓存碰撞体引用。
    /// 唯一数据源见 <see cref="EnemyBase.Body"/>——其它组件不得自行解析身体碰撞体。
    /// </summary>
    public interface IEnemyBody
    {
        Collider2D Collider   { get; }
        /// <summary>世界空间包围盒;实时查询。</summary>
        Bounds     Bounds     { get; }
        float      Width      { get; }
        float      Height     { get; }
        float      HalfWidth  { get; }
        float      HalfHeight { get; }
        Vector2    Center     { get; }
        /// <summary>脚底中点 (center.x, min.y)。</summary>
        Vector2    FootPoint  { get; }

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

    /// <summary>
    /// <see cref="IEnemyBody"/> 的实现(纯 C# 类,非 MonoBehaviour)。
    /// 懒解析碰撞体 → 编辑器模式(Gizmo / OnValidateAwake 未执行)同样可用。
    /// </summary>
    public sealed class EnemyBody : IEnemyBody
    {
        private readonly GameObject _owner;
        private readonly Collider2D _explicitCollider;
        private Collider2D _resolved;

        /// <param name="owner">宿主 GameObject(未显式指定碰撞体时从其上取 Collider2D)。</param>
        /// <param name="explicitCollider">显式指定的身体碰撞体;可为 null。</param>
        public EnemyBody(GameObject owner, Collider2D explicitCollider)
        {
            _owner            = owner;
            _explicitCollider = explicitCollider;
        }

        public Collider2D Collider
        {
            get
            {
                if (_explicitCollider != null) return _explicitCollider;
                if (_resolved == null && _owner != null) _resolved = _owner.GetComponent<Collider2D>();
                return _resolved;
            }
        }

        /// <summary>
        /// 碰撞体缺失时返回以宿主位置为中心的零尺寸包围盒。
        /// 该错误已由 <see cref="EnemyBase"/> 在 Awake/OnValidate 显式报出,此处不重复每帧刷屏。
        /// </summary>
        public Bounds Bounds
        {
            get
            {
                var col = Collider;
                if (col != null) return col.bounds;
                return new Bounds(_owner != null ? _owner.transform.position : Vector3.zero, Vector3.zero);
            }
        }

        public float   Width      => Bounds.size.x;
        public float   Height     => Bounds.size.y;
        public float   HalfWidth  => Bounds.extents.x;
        public float   HalfHeight => Bounds.extents.y;
        public Vector2 Center     => Bounds.center;
        public Vector2 FootPoint  { get { var b = Bounds; return new Vector2(b.center.x, b.min.y); } }

        public float FrontEdgeX(float dir) { var b = Bounds; return FrontEdge(b.min.x, b.max.x, dir); }
        public float RearEdgeX (float dir) { var b = Bounds; return RearEdge (b.min.x, b.max.x, dir); }
        public bool  HasPassed(float worldX, float dir)
        {
            var b = Bounds;
            return BodyPassed(b.min.x, b.max.x, worldX, dir);
        }

        // ── 方向纯函数(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;
    }
}
  • Step 4: 编译门 + 运行测试确认通过

MCPAssets/Refresh → 等 8 秒 → unity_get_compilation_errors(预期 0 错误) → unity_testing_run_tests({mode:"EditMode"})unity_testing_get_job(jobId)。 预期:191 条全绿(原 177 + 新增 14)。

  • Step 5: 提交
git add Assets/_Game/Scripts/Enemies/EnemyBody.cs Assets/Tests/EditMode/Enemies/EnemyBodyTests.cs
git commit -m "feat(enemy): 新增 IEnemyBody 身体几何权威接口与 EnemyBody 实现(+14 纯函数单测)"

Task 2: EnemyBase 持有权威源

Files:

  • Modify: Assets/_Game/Scripts/Enemies/EnemyBase.cs

  • Step 1: 加序列化字段与 Body 属性

EnemyBase.cs 的"子组件(Prefab Inspector 绑定)"Header 区块内、_hurtBox 字段之后(约 EnemyBase.cs:44)追加:


        [Header("身体碰撞体(唯一权威源)")]
        [Tooltip("用于身体几何(宽高/边缘/探测起点)的碰撞体。留空 = 使用本物体上的 Collider2D。" +
                 "移动探测、导航内缩、冲刺终点等全部以此为准,不要在各处自行取碰撞体。")]
        [SerializeField] private Collider2D _bodyCollider;

_colliders 字段声明附近(约 EnemyBase.cs:70)追加缓存字段:

        // 身体几何权威(懒创建;OnValidate 改引用后置空重建)
        private EnemyBody _body;

在公开属性区(Abilities / AttackSelector 属性附近)追加:

        /// <summary>身体碰撞体几何的唯一权威查询口。移动/导航/能力一律经此获取身体尺寸。</summary>
        public IEnemyBody Body => _body ??= new EnemyBody(gameObject, _bodyCollider);
  • Step 2: Awake 加运行期校验

EnemyBase.Awake_colliders = GetComponentsInChildren<Collider2D>(true);(约 EnemyBase.cs:544)之后追加:

            // 身体几何权威校验:解析不到碰撞体 → 显式报错(根因暴露,不静默兜底)
            if (Body.Collider == null)
                Debug.LogError($"[EnemyBase] {name} 找不到身体碰撞体:请在 Inspector 指定 _bodyCollider" +
                               "或确保本物体上挂有 Collider2D。移动探测/导航内缩将不可用。", this);
  • Step 3: 扩展既有 OnValidate(不要新增第二个)

EnemyBase.cs:758 已有 protected virtual void OnValidate(),把它改为:

        protected virtual void OnValidate()
        {
            _body = null;   // 身体碰撞体引用可能在 Inspector 被改,缓存无条件失效(与警告抑制无关)
            if (SuppressValidationWarnings) return;
            if (_statsSO == null)
                Debug.LogWarning($"[EnemyBase] {gameObject.name} 缺少 EnemyStatsSO 配置(运行时会 NullRef)。", this);
            if (_stats == null)
                Debug.LogWarning($"[EnemyBase] {gameObject.name} 未绑定 EnemyStats 组件引用。", this);
            if (_animancer == null)
                Debug.LogWarning($"[EnemyBase] {gameObject.name} 未绑定 AnimancerComponent 引用。", this);
            if (_bodyCollider == null && GetComponent<Collider2D>() == null)
                Debug.LogWarning($"[EnemyBase] {gameObject.name} 找不到身体碰撞体(未指定 _bodyCollider 且本物体无 Collider2D)。", this);
        }
  • Step 4: 编译门 + 全量测试

MCPAssets/Refresh → 等 8 秒 → unity_get_compilation_errors(预期 0 错误) → unity_testing_run_tests({mode:"EditMode"})。预期:191 条全绿

  • Step 5: 提交
git add Assets/_Game/Scripts/Enemies/EnemyBase.cs
git commit -m "feat(enemy): EnemyBase 持有身体几何权威(_bodyCollider + Body),Awake/OnValidate 双重校验"

Task 3: EnemyMovement 全部改走权威接口

Files:

  • Modify: Assets/_Game/Scripts/Enemies/EnemyMovement.cs

本 Task 是行为等价重构:改完后巡逻/夹停/掉头行为必须与改前完全一致。

  • Step 1: 加 EnemyBase 懒获取与 Body 便捷属性

EnemyMovement 类的私有字段区追加(放在 _groundCheckCollider 附近即可):

        // 身体几何权威(EnemyBase.Body)。懒获取以支持编辑器模式(Awake 未执行)下的 Gizmo/探测预览。
        private EnemyBase _enemy;
        private IEnemyBody Body
        {
            get
            {
                if (_enemy == null) _enemy = GetComponent<EnemyBase>();
                return _enemy != null ? _enemy.Body : null;
            }
        }
  • Step 2: 三个射线起点改用 Body

EnemyMovement.cs 中这三个方法整体替换(原实现各自解析碰撞体,约在 185–229 行):

        private Vector2 GetGroundRayOrigin(int index)
        {
            var body = Body;
            if (body == null || body.Collider == null) return (Vector2)transform.position;

            Bounds b = body.Bounds;
            float x = _groundCheckCount <= 1
                ? b.center.x
                : Mathf.Lerp(b.min.x, b.max.x, (float)index / (_groundCheckCount - 1));
            return new Vector2(x, b.min.y);
        }

        private bool IsGroundedCheck()
        {
            for (int i = 0; i < _groundCheckCount; i++)
            {
                if (Physics2D.Raycast(GetGroundRayOrigin(i), Vector2.down, _groundCheckDist, _groundMask))
                    return true;
            }
            return false;
        }

        // 墙体射线起点:身体朝向侧边缘、中心高度
        private Vector2 GetWallRayOrigin()
        {
            var body = Body;
            if (body == null || body.Collider == null) return (Vector2)transform.position;
            return new Vector2(body.FrontEdgeX(_facingDir), body.Bounds.center.y);
        }

        // 悬崖射线起点:身体前下角再向前偏移 _ledgeCheckFwdOffset
        private Vector2 GetLedgeRayOrigin()
        {
            var body = Body;
            if (body == null || body.Collider == null) return (Vector2)transform.position;
            float sign = _facingDir >= 0 ? 1f : -1f;
            return new Vector2(body.FrontEdgeX(_facingDir) + sign * _ledgeCheckFwdOffset, body.Bounds.min.y);
        }
  • Step 3: 删除 GroundColliderOrSelf()_groundCheckCollider 字段

删除私有方法(原 EnemyMovement.cs:231-232):

        private Collider2D GroundColliderOrSelf()
            => _groundCheckCollider != null ? _groundCheckCollider : GetComponent<Collider2D>();

删除序列化字段及其 [Tooltip](原 EnemyMovement.cs:45-46):

        [Tooltip("用于确定射线起点宽度和底边的 Collider2D;留空则 Awake 时自动查找")]
        [SerializeField] private Collider2D _groundCheckCollider;
  • Step 4: 墙/悬崖判定与 EdgeSafeMargin 改用 Body

替换这三个成员(原 EnemyMovement.cs:243-278):

        /// <summary>朝 <paramref name="dir"/> 前缘水平方向是否有墙。</summary>
        public bool WouldHitWallAhead(float dir)
        {
            var body = Body;
            if (body == null || body.Collider == null || _wallCheckDist <= 0f) return false;
            float sign = Mathf.Sign(dir);
            return Physics2D.Raycast(new Vector2(body.FrontEdgeX(dir), body.Bounds.center.y),
                                     new Vector2(sign, 0f), _wallCheckDist, _wallMask);
        }

        /// <summary>朝 <paramref name="dir"/> 前缘外是否为悬崖(脚边地面缺失,会让碰撞体越出地形边缘)。</summary>
        public bool WouldFallAhead(float dir)
        {
            var body = Body;
            if (body == null || body.Collider == null || _ledgeCheckDownDist <= 0f) return false;
            float sign = Mathf.Sign(dir);
            return !Physics2D.Raycast(
                new Vector2(body.FrontEdgeX(dir) + sign * _ledgeCheckFwdOffset, body.Bounds.min.y),
                Vector2.down, _ledgeCheckDownDist, _groundMask);
        }

        /// <summary>
        /// 身体中心到"能站住的平台边缘最近点"的水平安全余量 = 身体半宽 + 悬崖探测前向偏移。
        /// 半宽取自唯一权威 <see cref="EnemyBase.Body"/>;探测偏移是本移动层自己的射线调参。
        /// WouldFallAhead 在"身体前缘 + _ledgeCheckFwdOffset"处探地,故身体中心必须离平台边缘至少此距离,
        /// 否则前缘探出崖沿被夹停。供 nav 段内随机游走/目标点吸附把点从段两端内缩此值。
        /// </summary>
        public float EdgeSafeMargin
        {
            get
            {
                var body = Body;
                float halfWidth = body != null ? body.HalfWidth : 0f;
                return halfWidth + Mathf.Max(0f, _ledgeCheckFwdOffset);
            }
        }
  • Step 5: 确认没有遗漏的旧引用

Grep 确认 _groundCheckColliderGroundColliderOrSelf 在整个项目已无引用:

grep -rn "_groundCheckCollider\|GroundColliderOrSelf" Assets/_Game/Scripts

预期:无输出。若编辑器脚本(如 EnemyLocomotionEditor)引用了它们,一并改为 Body

  • Step 6: 编译门 + 全量测试

MCPAssets/Refresh → 等 8 秒 → unity_get_compilation_errors(预期 0 错误) → unity_testing_run_tests({mode:"EditMode"})。预期:191 条全绿

  • Step 7: 提交
git add Assets/_Game/Scripts/Enemies/EnemyMovement.cs
git commit -m "refactor(enemy): 移动层探测与 EdgeSafeMargin 改走 Body 权威,删除重复的碰撞体解析"

Task 4: PlayMode 行为等价性冒烟验证

Files: 无(场景内验证)

因两个现存敌人的 _groundCheckCollider 实测均为 null,本次重构后行为应完全一致。 本 Task 用于确认没有引入回归。

  • Step 1: 确认编辑器无新报错

MCP unity_console_log({type:"error", count:30}):确认没有新增的 [EnemyBase] ... 找不到身体碰撞体 报错(ENM_CaoZhi 有根 BoxCollider2D,不该触发)。

  • Step 2: 进 Play 验证巡逻与夹停

MCP unity_play_mode({action:"play"}),等约 5 秒后用 unity_execute_code 读取状态:

var e = GameObject.Find("ENM_CaoZhi");
var mv = e.GetComponent<BaseGames.Enemies.EnemyMovement>();
var eb = e.GetComponent<BaseGames.Enemies.EnemyBase>();
return "grounded=" + mv.IsGrounded
     + "; halfWidth=" + eb.Body.HalfWidth.ToString("F3")
     + "; edgeSafeMargin=" + mv.EdgeSafeMargin.ToString("F3")
     + "; bodyCollider=" + (eb.Body.Collider != null ? eb.Body.Collider.GetType().Name : "NULL")
     + "; bounds=" + eb.Body.Bounds.ToString();

预期:bodyCollider=BoxCollider2D(非 NULL)、halfWidth>0edgeSafeMargin = halfWidth + 0.1grounded=True(敌人站在平台上)。

  • Step 3: 验证敌人仍会被崖边夹停、不会掉下平台

保持 Play,观察 ENM_CaoZhi 巡逻若干秒后再读一次:

var e = GameObject.Find("ENM_CaoZhi");
var mv = e.GetComponent<BaseGames.Enemies.EnemyMovement>();
return "pos=" + e.transform.position.ToString("F2")
     + "; grounded=" + mv.IsGrounded
     + "; wallAhead=" + mv.IsWallAhead
     + "; ledgeAhead=" + mv.IsLedgeAhead;

预期:grounded=True(未掉出平台);敌人位置在平台范围内。

  • Step 4: 退出 Play

MCP unity_play_mode({action:"stop"})。确认 Console 无新增异常。

  • Step 5: 记录结果

若全部通过,在提交信息或 Docs_Dev/Verification/ 记一行验证结论。无代码改动则无需提交。


自检记录(写计划时)

  • Spec 覆盖spec §3.1 权威源→Task 2;§3.2 接口 + §3.3 实现→Task 1;§3.4 消费端改造表(EnemyMovement 5 处 + EdgeSafeMargin + 删字段/方法)→Task 3;§3.5 分层原则→Task 3 Step 4 的 EdgeSafeMargin 注释体现; §4 边界与错误处理→Task 2 Step 2/3Awake LogError + OnValidate 警告 + 抑制机制)、Task 1 Step 3(碰撞体缺失退化值); §5 测试→Task 1(单测)、Task 3 Step 6(回归)、Task 4PlayMode);§6 迁移安全→Task 4 验证。 EnemyNavAgent 按 spec §3.4 明确不改,故无对应 Task。
  • 占位符扫描:无 TBD/TODO;每个改动步骤都给了完整代码。
  • 类型一致性IEnemyBody 成员(Collider/Bounds/HalfWidth/FrontEdgeX/RearEdgeX/HasPassed 与 static 纯函数(FrontEdge/RearEdge/BodyPassed)在 Task 1 定义,Task 2Body.Collider)、 Task 3Body.Bounds/Body.FrontEdgeX/Body.HalfWidth)与测试中的用法一致。
  • 测试基线:当前 177 → Task 1 后 191(新增 14 条);Task 2/3 不新增测试,仍应为 191。