diff --git a/Assets/Tests/EditMode/Enemies/EnemyBodyTests.cs b/Assets/Tests/EditMode/Enemies/EnemyBodyTests.cs new file mode 100644 index 00000000..c8363595 --- /dev/null +++ b/Assets/Tests/EditMode/Enemies/EnemyBodyTests.cs @@ -0,0 +1,73 @@ +using NUnit.Framework; +using BaseGames.Enemies; + +namespace BaseGames.Tests.EditMode.Enemies +{ + /// + /// EnemyBody 方向相关纯函数测试(无场景依赖)。 + /// 这三个函数的左右符号最易写反,故对两个方向与边界相等情形都做断言。 + /// + 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)); + } +} diff --git a/Assets/_Game/Scripts/Enemies/EnemyBody.cs b/Assets/_Game/Scripts/Enemies/EnemyBody.cs new file mode 100644 index 00000000..d6dc1e07 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/EnemyBody.cs @@ -0,0 +1,97 @@ +using UnityEngine; + +namespace BaseGames.Enemies +{ + /// + /// 角色身体碰撞体的唯一权威查询口。 + /// 几何实时取自碰撞体 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; } + /// 脚底中点 (center.x, min.y)。 + Vector2 FootPoint { get; } + + /// 的前缘 X(dir≥0 取 max.x,否则 min.x)。 + float FrontEdgeX(float dir); + /// 的后缘 X(前缘的相反侧)。 + float RearEdgeX(float dir); + /// + /// 身体是否已**整体**越过世界 X 坐标: + /// dir≥0 时后缘(min.x) ≥ worldX;dir<0 时后缘(max.x) ≤ worldX。 + /// + bool HasPassed(float worldX, float dir); + } + + /// + /// 的实现(纯 C# 类,非 MonoBehaviour)。 + /// 懒解析碰撞体 → 编辑器模式(Gizmo / OnValidate,Awake 未执行)同样可用。 + /// + public sealed class EnemyBody : IEnemyBody + { + private readonly GameObject _owner; + private readonly Collider2D _explicitCollider; + private Collider2D _resolved; + + /// 宿主 GameObject(未显式指定碰撞体时从其上取 Collider2D)。 + /// 显式指定的身体碰撞体;可为 null。 + 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(); + return _resolved; + } + } + + /// + /// 碰撞体缺失时返回以宿主位置为中心的零尺寸包围盒。 + /// 该错误已由 在 Awake/OnValidate 显式报出,此处不重复每帧刷屏。 + /// + 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; + } +}