using System; using System.Collections.Generic; using UnityEngine; namespace BaseGames.Enemies.Perception { /// /// 敌人感知系统(自研纯物理实现)。 /// 每个 独立配置并独立运行,支持四种检测模式: /// • RangeCircle — Physics2D.OverlapCircleNonAlloc(可选 LOS 视线遮挡校验) /// • BatchLOS — 委托 EnemyBase.IsPlayerVisible()(BatchLOSSystem 批量射线) /// • FanCast — 以朝向为轴的扇形多射线视野(支持遮挡层) /// • BoxCast — 矩形区域重叠检测(X 偏移随 localScale.x 自动翻转) /// /// EnemyBase.Awake() 通过 GetComponentInChildren<IPerceptionSystem>() /// 自动发现本组件,无需修改 EnemyBase。 /// 槽位名称常量统一定义于 。 /// [DisallowMultipleComponent] public sealed class PhysicsPerceptionSystem : MonoBehaviour, IPerceptionSystem { // ── 槽位类型 ────────────────────────────────────────────────────────── public enum SlotType { /// Physics2D 圆形重叠检测 RangeCircle, /// 委托 EnemyBase.IsPlayerVisible()(BatchLOSSystem 批量射线视线检测) BatchLOS, /// 以朝向为轴的扇形射线视野,遮挡层阻断视线 FanCast, /// 矩形区域重叠检测,X 偏移随 localScale.x 自动翻转 BoxCast } // ── 槽位定义 ────────────────────────────────────────────────────────── [Serializable] public struct PerceptionSlot { [Tooltip("槽位名称,与 SensorSlotNames 常量保持一致\n(aggro / los / attack_melee / attack_range)")] public string slotName; [Tooltip("RangeCircle:Physics2D 圆形范围检测\nBatchLOS:视线射线检测(BatchLOSSystem)\nFanCast:以朝向为轴的扇形射线视野\nBoxCast:矩形区域重叠检测")] public SlotType type; [Min(0f)] [Tooltip("RangeCircle / FanCast:检测半径(米)\nBatchLOS:最大视线检测距离(0 = 不限制)\nBoxCast:忽略此值")] public float radius; [Tooltip("目标检测层(通常为 Player 层);BatchLOS 忽略此值")] public LayerMask detectLayer; [Tooltip("RangeCircle / BoxCast:基础重叠命中后额外校验视线(Physics2D.Raycast)\nFanCast:true = 射线被 losBlockMask 层遮挡;false = 穿透所有障碍物")] public bool requireLOS; [Tooltip("requireLOS = true / FanCast(requireLOS = true):视线遮挡检测层(通常为 Platform + Wall)\nFanCast 射线只在 requireLOS = true 时被此层遮挡")] public LayerMask losBlockMask; [Header("Origin")] [Tooltip("检测原点相对于 transform.position 的偏移(米)。\nX 分量随 localScale.x 朝向自动翻转;BatchLOS 仅影响 Gizmo,不影响实际射线。")] public Vector2 offset; [Header("FanCast")] [Tooltip("FanCast:扇形张角(度),以朝向为中轴左右均匀展开")] public float fanAngle; [Tooltip("FanCast:扇形内均匀分布的射线数量(建议 5–11 条)")] [Min(2)] public int fanRayCount; [Header("BoxCast")] [Tooltip("BoxCast:检测框尺寸 (宽, 高),单位米")] public Vector2 boxSize; [Tooltip("BoxCast:相对于感知中心的偏移,X 分量随 localScale.x 自动翻转")] public Vector2 boxOffset; [Header("Gizmos")] [Tooltip("Scene 视图 Gizmo 颜色。留空(全透明黑色)时自动使用紫色回退,以确保可见。")] public Color gizmoColor; } // ── 字段 ────────────────────────────────────────────────────────────── [SerializeField] private PerceptionSlot[] _slots; private readonly Dictionary> _detected = new Dictionary>(); private readonly Collider2D[] _overlapBuffer = new Collider2D[32]; private bool _suspended; private EnemyBase _owner; // ── Unity 生命周期 ──────────────────────────────────────────────────── private void Awake() { _owner = GetComponentInParent(); if (_slots == null) return; foreach (var slot in _slots) { if (!string.IsNullOrEmpty(slot.slotName) && !_detected.ContainsKey(slot.slotName)) _detected[slot.slotName] = new List(4); } } private void FixedUpdate() { if (_suspended || _slots == null) return; foreach (var slot in _slots) RefreshSlot(slot); } // ── 内部检测逻辑 ────────────────────────────────────────────────────── private void RefreshSlot(PerceptionSlot slot) { if (string.IsNullOrEmpty(slot.slotName)) return; if (!_detected.TryGetValue(slot.slotName, out var list)) return; list.Clear(); switch (slot.type) { case SlotType.BatchLOS: if (_owner != null && _owner.IsPlayerVisible() && _owner.PlayerTransform != null) { if (slot.radius > 0f) { float dist = Vector2.Distance( (Vector2)transform.position, (Vector2)_owner.PlayerTransform.position); if (dist > slot.radius) break; } list.Add(_owner.PlayerTransform.gameObject); } break; case SlotType.RangeCircle: RefreshRangeCircle(slot, list); break; case SlotType.FanCast: RefreshFanCast(slot, list); break; case SlotType.BoxCast: RefreshBoxCast(slot, list); break; } } private void RefreshRangeCircle(in PerceptionSlot slot, System.Collections.Generic.List list) { if (slot.radius <= 0f || slot.detectLayer == 0) return; float facingSign = transform.localScale.x < 0f ? -1f : 1f; Vector2 origin = (Vector2)transform.position + new Vector2(slot.offset.x * facingSign, slot.offset.y); int count = Physics2D.OverlapCircleNonAlloc(origin, slot.radius, _overlapBuffer, slot.detectLayer); for (int i = 0; i < count; i++) { var col = _overlapBuffer[i]; if (col == null) continue; if (slot.requireLOS && !HasLineOfSight(origin, col.gameObject, slot.losBlockMask)) continue; list.Add(col.gameObject); } } private void RefreshFanCast(in PerceptionSlot slot, System.Collections.Generic.List list) { if (slot.radius <= 0f || slot.detectLayer == 0 || slot.fanAngle <= 0f) return; float facingSign = transform.localScale.x < 0f ? -1f : 1f; var forward = new Vector2(facingSign, 0f); Vector2 origin = (Vector2)transform.position + new Vector2(slot.offset.x * facingSign, slot.offset.y); float halfAngle = slot.fanAngle * 0.5f; int rays = Mathf.Max(2, slot.fanRayCount); // requireLOS = true:射线被 losBlockMask 遮挡;false:仅检测 detectLayer(穿透障碍物) int castMask = slot.requireLOS ? ((int)slot.detectLayer | (int)slot.losBlockMask) : (int)slot.detectLayer; for (int r = 0; r < rays; r++) { float t = (float)r / (rays - 1); Vector2 dir = RotateVector(forward, Mathf.Lerp(-halfAngle, halfAngle, t)); RaycastHit2D hit = Physics2D.Raycast(origin, dir, slot.radius, castMask); if (hit.collider == null) continue; if (((1 << hit.collider.gameObject.layer) & (int)slot.detectLayer) == 0) continue; var go = hit.collider.gameObject; if (!list.Contains(go)) list.Add(go); } } private void RefreshBoxCast(in PerceptionSlot slot, System.Collections.Generic.List list) { if (slot.boxSize.x <= 0f || slot.boxSize.y <= 0f || slot.detectLayer == 0) return; float facingSign = transform.localScale.x < 0f ? -1f : 1f; Vector2 origin = (Vector2)transform.position + new Vector2(slot.offset.x * facingSign, slot.offset.y); Vector2 center = origin + new Vector2(slot.boxOffset.x * facingSign, slot.boxOffset.y); int count = Physics2D.OverlapBoxNonAlloc(center, slot.boxSize, 0f, _overlapBuffer, slot.detectLayer); for (int i = 0; i < count; i++) { var col = _overlapBuffer[i]; if (col == null) continue; if (slot.requireLOS && !HasLineOfSight(origin, col.gameObject, slot.losBlockMask)) continue; list.Add(col.gameObject); } } private bool HasLineOfSight(Vector2 origin, GameObject target, LayerMask blockMask) { // 优先使用碰撞体包围盒中心(比 transform.position 更准确,避免偏心轴误判) Vector2 targetPos; var col = target.GetComponent(); targetPos = col != null ? (Vector2)col.bounds.center : (Vector2)target.transform.position; var dir = targetPos - origin; float dist = dir.magnitude; if (dist <= 0f) return true; var hit = Physics2D.Raycast(origin, dir / dist, dist, blockMask); // 未命中任何障碍,或者第一个命中的就是目标自身(含子物体) return hit.collider == null || hit.collider.transform.IsChildOf(target.transform) || hit.collider.gameObject == target; } private static Vector2 RotateVector(Vector2 v, float angleDeg) { float rad = angleDeg * Mathf.Deg2Rad; float cos = Mathf.Cos(rad); float sin = Mathf.Sin(rad); return new Vector2(cos * v.x - sin * v.y, sin * v.x + cos * v.y); } // ── IPerceptionSystem ───────────────────────────────────────────────── public bool HasSlot(string slotName) { if (string.IsNullOrEmpty(slotName)) return false; // 运行时通过字典; 编辑器模式遍历数组 if (_detected.Count > 0) return _detected.ContainsKey(slotName); if (_slots == null) return false; foreach (var s in _slots) if (s.slotName == slotName) return true; return false; } public bool HasAnyDetection(string slotName) => _detected.TryGetValue(slotName, out var list) && list.Count > 0; public bool IsDetecting(string slotName, GameObject target) { if (!_detected.TryGetValue(slotName, out var list)) return false; for (int i = 0; i < list.Count; i++) if (list[i] == target) return true; return false; } public GameObject GetFirstDetection(string slotName) { if (!_detected.TryGetValue(slotName, out var list) || list.Count == 0) return null; return list[0]; } public float GetSensorRadius(string slotName) { if (string.IsNullOrEmpty(slotName) || _slots == null) return -1f; foreach (var s in _slots) if (s.slotName == slotName && s.type == SlotType.RangeCircle) return s.radius; return -1f; } public Vector2 GetSensorOffset(string slotName) { if (string.IsNullOrEmpty(slotName) || _slots == null) return Vector2.zero; float facingSign = transform.localScale.x < 0f ? -1f : 1f; foreach (var s in _slots) if (s.slotName == slotName) return new Vector2(s.offset.x * facingSign, s.offset.y); return Vector2.zero; } public void SetSuspended(bool suspended) { _suspended = suspended; if (suspended) foreach (var list in _detected.Values) list.Clear(); } // ── 编辑器 API(仅 UNITY_EDITOR 访问)──────────────────────────────── #if UNITY_EDITOR public PerceptionSlot[] EditorSlots => _slots; public IReadOnlyDictionary> EditorDetected => _detected; public EnemyBase EditorOwner => _owner; #endif } }