using System; using System.Collections.Generic; using UnityEngine; namespace BaseGames.Enemies.Perception { /// /// 敌人感知系统(自研纯物理实现,商业级设计)。 /// 每个 独立配置并独立运行,支持七种检测模式: /// • RangeCircle — OverlapCircle 纯几何范围检测(无遮挡) /// • BatchLOS — OverlapCircle + 单次 Raycast 自研批量视线检测(低开销,有帧延迟) /// • FanCast — OverlapCircle + 角度过滤扇形视野(纯几何,无遮挡) /// • BoxCast — OverlapBox 矩形区域检测(纯几何,无遮挡) /// • Sight — OverlapCircle + 可选扇形角度 + 强制多点 LOS 遮挡(专用视线传感器) /// • RayCast — 单/多根方向射线传感器,支持扩散角和遮挡层 /// • TriggerZone — 物理触发器事件驱动(子节点 PerceptionTriggerProxy,零轮询) /// /// 遮挡检测设计原则: /// RangeCircle / FanCast / BoxCast 是纯几何传感器,不做遮挡校验。 /// 需要视线遮挡判断时,使用 Sight 槽位(内置多点 LOS 采样,始终执行遮挡检测)。 /// 单向射线遮挡使用 RayCast 槽位(obstructLayer 控制阻断层)。 /// /// 性能优化: /// • — 每 N 帧刷新一次(错帧执行,分散开销) /// • — 运行时动态禁用单个槽位 /// • — 场景单例,全局控制 Sight 射线预算 /// /// 槽位名称常量统一定义于 。 /// [DisallowMultipleComponent] public sealed class PhysicsPerceptionSystem : MonoBehaviour, IPerceptionSystem { // ── 槽位类型 ────────────────────────────────────────────────────────── public enum SlotType { /// Physics2D.OverlapCircle 纯几何范围检测(无遮挡) RangeCircle, /// OverlapCircle + 单条遮挡射线(自包含 LOS 检测,低开销) BatchLOS, /// OverlapCircle + 角度过滤扇形视野(纯几何,无遮挡) FanCast, /// OverlapBox 矩形区域检测,X 偏移随 localScale.x 自动翻转(纯几何,无遮挡) BoxCast, /// OverlapCircle + 可选扇形角度过滤 + 强制多点 LOS 遮挡校验(专用视线传感器) Sight, /// 单/多根方向射线检测,支持扩散角和遮挡层(对标 RaySensor2D) RayCast, /// 物理触发器事件驱动,依赖子节点 PerceptionTriggerProxy(零轮询开销) TriggerZone, } // ── 槽位定义 ────────────────────────────────────────────────────────── [Serializable] public struct PerceptionSlot { // ── 通用 ─────────────────────────────────────────────────────── [Tooltip("槽位名称,与 SensorSlotNames 常量保持一致\n(aggro / patrol / alert / los / attack_melee / attack_range / sight)")] public string slotName; [Tooltip("RangeCircle:纯几何圆形范围\nBatchLOS:OverlapCircle + 单射线遮挡(自包含 LOS)\nFanCast:扇形视野(纯几何)\nBoxCast:矩形区域(纯几何)\nSight:视线传感器(多射线遮挡)\nRayCast:方向射线传感器\nTriggerZone:触发器事件驱动(需子节点 PerceptionTriggerProxy)")] public SlotType type; [Tooltip("检测原点相对于 transform.position 的偏移(米)。\nX 分量随 localScale.x 朝向自动翻转。")] public Vector2 offset; [Tooltip("Scene 视图 Gizmo 颜色。留空(全透明黑色)时自动使用紫色回退。")] public Color gizmoColor; [Tooltip("勾选后禁用本槽位检测(可运行时动态切换)。\n注:tickInterval = 0 时本字段无效(历史兼容模式:始终启用)。")] public bool isDisabled; [Min(0)] [Tooltip("每 N 个 FixedUpdate 帧刷新一次。\n0 = 历史兼容(每帧刷新,isDisabled 无效)\n1 = 每帧刷新\n3 = 每 3 帧刷新(Sight 推荐)\n多个相同间隔的槽位自动错帧执行。")] public int tickInterval; // ── 范围(RangeCircle / FanCast / BatchLOS / Sight)─────────── [Min(0f)] [Tooltip("RangeCircle / FanCast / Sight:检测半径(米)\nBatchLOS:最大视线距离(0 = 不限)\nBoxCast / RayCast / TriggerZone:忽略")] public float radius; // ── 检测层(RangeCircle / FanCast / BoxCast / Sight / RayCast) [Tooltip("目标检测层(通常为 Player 层);BatchLOS / TriggerZone 忽略此值")] public LayerMask detectLayer; // ── FanCast & Sight ──────────────────────────────────────────── [Tooltip("FanCast:扇形张角(度),以朝向为中轴左右均匀展开\nSight:视野锥角(度),设为 0 或 ≥ 360 表示全向 360°")] public float fanAngle; [Min(2)] [Tooltip("FanCast 专用:Scene 视图 Gizmo 扇形分隔线数量(不影响检测,建议 3–9)")] public int fanRayCount; // ── BoxCast ──────────────────────────────────────────────────── [Tooltip("BoxCast:检测框尺寸 (宽, 高),单位米")] public Vector2 boxSize; [Tooltip("BoxCast:相对于感知中心的偏移,X 分量随 localScale.x 自动翻转")] public Vector2 boxOffset; // ── Sight LOS(仅 Sight 类型使用)──────────────────────────── [Tooltip("[Sight 专用] 视线遮挡检测层(通常为 Platform + Wall + Ground)。\n⚠ 留 0 时视线始终通过(losBlockMask = 0 = 无遮挡层)。")] public LayerMask losBlockMask; [Range(1, 5)] [Tooltip("[Sight 专用] LOS 多点采样数量:\n1 = 包围盒中心(最快)\n3 = 中心 + 上 + 下(推荐)\n5 = 中心 + 上下左右(最稳健)")] public int losRayCount; [Range(0f, 1f)] [Tooltip("[Sight 专用] 可见度阈值(0–1):\n0 = 任意 1 条通过即可(宽松)\n0.5 = 50% 通过\n1 = 全部通过(严格)")] public float losMinVisibility; // ── RayCast ─────────────────────────────────────────────────── [Tooltip("[RayCast 专用] 射线方向(本地空间),X 分量随朝向自动翻转。\n向量长度不需要为 1,运行时自动归一化。\n示例:(1,0)=正前方 (0,-1)=正下方 (1,-1)=右前下45°")] public Vector2 rayDirection; [Min(0f)] [Tooltip("[RayCast 专用] 射线长度(米)")] public float rayLength; [Range(0f, 180f)] [Tooltip("[RayCast 专用] 多射线扩散角(度):\n0 = 单根射线\n> 0 时在此角度范围内均匀分布 rayCount 根射线")] public float raySpread; [Range(1, 9)] [Tooltip("[RayCast 专用] 射线根数(raySpread > 0 时生效;= 1 时始终单根)")] public int rayCount; [Tooltip("[RayCast 专用] 遮挡层(射线碰到此层后停止,不再检测后续目标);0 = 不检测遮挡,射线穿透所有物体")] public LayerMask obstructLayer; } // ── 事件 ───────────────────────────────────────────────────────────── /// 某槽位首次检测到目标时触发。参数:(slotName, target)。 public event Action OnEnterDetection; /// 某槽位失去对目标的检测时触发。参数:(slotName, target)。 public event Action OnExitDetection; // ── 字段 ────────────────────────────────────────────────────────────── [SerializeField] private PerceptionSlot[] _slots; // 当前帧检测结果 private readonly Dictionary> _detected = new Dictionary>(); // 上帧检测结果(Enter/Exit diff 用,HashSet 提供 O(1) Contains) private readonly Dictionary> _prevDetected = new Dictionary>(); // 实例缓冲区(单线程 FixedUpdate 安全) private readonly Collider2D[] _overlapBuffer = new Collider2D[32]; // 静态共享缓冲区(零 GC,全局单线程安全) private static readonly Vector2[] _losPointsBuf = new Vector2[5]; private static readonly RaycastHit2D[] _rayHitBuf = new RaycastHit2D[16]; private static readonly List _enterBuf = new List(8); private static readonly List _exitBuf = new List(8); private int _fixedTick; private bool _suspended; private EnemyBase _owner; // ── Unity 生命周期 ──────────────────────────────────────────────────── private void Awake() { _owner = GetComponentInParent(); if (_slots != null) { foreach (var slot in _slots) if (!string.IsNullOrEmpty(slot.slotName)) EnsureSlotDict(slot.slotName); } // TriggerZone:发现所有子节点代理并绑定 var proxies = GetComponentsInChildren(true); foreach (var proxy in proxies) { if (string.IsNullOrEmpty(proxy.slotName)) continue; EnsureSlotDict(proxy.slotName); proxy.ParentSystem = this; } // 注册到 SightBatchSystem(如存在) if (SightBatchSystem.Instance != null) SightBatchSystem.Instance.Register(this); } private void OnDestroy() { if (SightBatchSystem.Instance != null) SightBatchSystem.Instance.Unregister(this); var proxies = GetComponentsInChildren(true); foreach (var proxy in proxies) if (proxy.ParentSystem == this) proxy.ParentSystem = null; } private void FixedUpdate() { if (_suspended || _slots == null) return; _fixedTick++; bool hasSightBatch = SightBatchSystem.Instance != null; for (int i = 0; i < _slots.Length; i++) { ref var slot = ref _slots[i]; if (string.IsNullOrEmpty(slot.slotName)) continue; // TriggerZone 完全事件驱动,不轮询 if (slot.type == SlotType.TriggerZone) continue; // Sight 槽由 SightBatchSystem 统一调度,此处跳过 if (slot.type == SlotType.Sight && hasSightBatch) continue; // tickInterval = 0:历史兼容模式(每帧刷新,isDisabled 无效) if (slot.tickInterval == 0) { RefreshSlot(ref slot, i); continue; } // 新行为:isDisabled + 错帧节流 if (slot.isDisabled) continue; if ((_fixedTick + i) % slot.tickInterval != 0) continue; RefreshSlot(ref slot, i); } } private void EnsureSlotDict(string name) { if (!_detected.ContainsKey(name)) { _detected[name] = new List(4); _prevDetected[name] = new HashSet(); } } // ── 内部检测逻辑 ────────────────────────────────────────────────────── private void RefreshSlot(ref PerceptionSlot slot, int slotIndex) { if (!_detected.TryGetValue(slot.slotName, out var list)) return; // Enter/Exit diff:保存上帧结果 if ((OnEnterDetection != null || OnExitDetection != null) && _prevDetected.TryGetValue(slot.slotName, out var prev)) { prev.Clear(); foreach (var go in list) prev.Add(go); } list.Clear(); switch (slot.type) { case SlotType.BatchLOS: RefreshBatchLOS(ref slot, list); break; case SlotType.RangeCircle: RefreshRangeCircle(ref slot, list); break; case SlotType.FanCast: RefreshFanCast(ref slot, list); break; case SlotType.BoxCast: RefreshBoxCast(ref slot, list); break; case SlotType.Sight: RefreshSight(ref slot, list); break; case SlotType.RayCast: RefreshRayCast(ref slot, list); break; } FireDiffEvents(slot.slotName); } private void FireDiffEvents(string slotName) { if (OnEnterDetection == null && OnExitDetection == null) return; if (!_prevDetected.TryGetValue(slotName, out var prev)) return; if (!_detected.TryGetValue(slotName, out var curr)) return; _enterBuf.Clear(); _exitBuf.Clear(); foreach (var go in curr) if (!prev.Contains(go)) _enterBuf.Add(go); foreach (var go in prev) { bool stillDetected = false; for (int i = 0; i < curr.Count; i++) if (curr[i] == go) { stillDetected = true; break; } if (!stillDetected) _exitBuf.Add(go); } foreach (var go in _enterBuf) OnEnterDetection?.Invoke(slotName, go); foreach (var go in _exitBuf) OnExitDetection?.Invoke(slotName, go); } private void RefreshBatchLOS(ref PerceptionSlot slot, 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.losBlockMask != 0) { Vector2 targetCenter = col.bounds.center; Vector2 dir = targetCenter - origin; float dist = dir.magnitude; if (dist > 0.001f) { var hit = Physics2D.Raycast(origin, dir / dist, dist, slot.losBlockMask); if (hit.collider != null) continue; // 被遮挡 } } list.Add(col.gameObject); } } private void RefreshRangeCircle(ref PerceptionSlot slot, 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) list.Add(col.gameObject); } } private void RefreshFanCast(ref PerceptionSlot slot, List list) { if (slot.radius <= 0f || slot.detectLayer == 0 || slot.fanAngle <= 0f) return; float facingSign = transform.localScale.x < 0f ? -1f : 1f; Vector2 origin = (Vector2)transform.position + new Vector2(slot.offset.x * facingSign, slot.offset.y); var forward = new Vector2(facingSign, 0f); float halfAngle = slot.fanAngle * 0.5f; 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 (Vector2.Angle(forward, (Vector2)col.bounds.center - origin) > halfAngle) continue; if (!list.Contains(col.gameObject)) list.Add(col.gameObject); } } private void RefreshBoxCast(ref PerceptionSlot slot, 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) list.Add(col.gameObject); } } /// /// 视线传感器:OverlapCircle + 可选扇形角度过滤 + 强制多点 LOS 遮挡校验。 /// 唯一内置遮挡检测的轮询槽位类型。 /// private void RefreshSight(ref PerceptionSlot slot, 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); Vector2 forward = new Vector2(facingSign, 0f); float halfAngle = slot.fanAngle * 0.5f; bool limitAngle = slot.fanAngle > 0f && slot.fanAngle < 360f; 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 (limitAngle && Vector2.Angle(forward, (Vector2)col.bounds.center - origin) > halfAngle) continue; if (!HasLineOfSight(origin, col.gameObject, slot.losBlockMask, Mathf.Max(1, slot.losRayCount), slot.losMinVisibility)) continue; if (!list.Contains(col.gameObject)) list.Add(col.gameObject); } } /// /// 方向射线传感器:单/多根射线,支持扩散角和遮挡阻断。 /// 射线方向在本地空间定义,X 分量随朝向自动翻转。 /// private void RefreshRayCast(ref PerceptionSlot slot, List list) { if (slot.rayLength <= 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); // 射线基准方向(本地空间,X 随朝向翻转,fallback = 正前方) Vector2 baseDir = new Vector2(slot.rayDirection.x * facingSign, slot.rayDirection.y); if (baseDir.sqrMagnitude < 0.0001f) baseDir = new Vector2(facingSign, 0f); baseDir.Normalize(); int rayCount = Mathf.Max(1, slot.rayCount); float spread = slot.raySpread; LayerMask combinedMask = slot.detectLayer | slot.obstructLayer; for (int r = 0; r < rayCount; r++) { Vector2 dir; if (rayCount == 1 || spread <= 0f) { dir = baseDir; } else { float t = (float)r / (rayCount - 1); float angDeg = Mathf.Lerp(-spread * 0.5f, spread * 0.5f, t); float rad = angDeg * Mathf.Deg2Rad; float cos = Mathf.Cos(rad); float sin = Mathf.Sin(rad); dir = new Vector2(cos * baseDir.x - sin * baseDir.y, sin * baseDir.x + cos * baseDir.y); } int hits = Physics2D.RaycastNonAlloc(origin, dir, _rayHitBuf, slot.rayLength, combinedMask); for (int j = 0; j < hits; j++) { var hit = _rayHitBuf[j]; if (hit.collider == null) continue; int layer = hit.collider.gameObject.layer; // 遮挡层:阻断当前射线(不检测后续目标) if (slot.obstructLayer != 0 && ((1 << layer) & (int)slot.obstructLayer) != 0) break; // 检测层:加入结果 if (((1 << layer) & (int)slot.detectLayer) != 0) { var go = hit.collider.gameObject; if (!list.Contains(go)) list.Add(go); } } } } // ── TriggerZone 回调(由 PerceptionTriggerProxy 调用)──────────────── internal void OnTriggerZoneEnter(string slotName, GameObject go) { if (!_detected.TryGetValue(slotName, out var list)) return; if (list.Contains(go)) return; list.Add(go); OnEnterDetection?.Invoke(slotName, go); } internal void OnTriggerZoneExit(string slotName, GameObject go) { if (!_detected.TryGetValue(slotName, out var list)) return; if (!list.Remove(go)) return; OnExitDetection?.Invoke(slotName, go); } // ── SightBatchSystem 调用入口(内部 API)───────────────────────────── /// /// 由 调用,执行本系统所有 Sight 槽位的刷新。 /// 批量系统负责频率控制,不参与 tickInterval 逻辑。 /// internal void ExecuteSightSlots() { if (_suspended || _slots == null) return; for (int i = 0; i < _slots.Length; i++) { ref var slot = ref _slots[i]; if (slot.type != SlotType.Sight) continue; if (slot.isDisabled && slot.tickInterval > 0) continue; if (string.IsNullOrEmpty(slot.slotName)) continue; RefreshSlot(ref slot, i); } } // ── LOS 工具 ────────────────────────────────────────────────────────── /// /// 多点采样 LOS 遮挡检测。 /// = 0 时始终返回 true(无遮挡层配置 = 视线始终通过)。 /// private static bool HasLineOfSight(Vector2 origin, GameObject target, LayerMask blockMask, int rayCount = 1, float minVisibility = 0f) { if (blockMask == 0) return true; var col = target.GetComponent(); int n = FillLOSPoints(col, target.transform, Mathf.Clamp(rayCount, 1, 5)); if (n == 0) return true; int passed = 0; for (int i = 0; i < n; i++) { var dir = _losPointsBuf[i] - origin; float dist = dir.magnitude; if (dist <= 0f) { passed++; continue; } var hit = Physics2D.Raycast(origin, dir / dist, dist, blockMask); bool clear = hit.collider == null || hit.collider.transform.IsChildOf(target.transform) || hit.collider.gameObject == target; if (clear) passed++; } float visibility = (float)passed / n; return minVisibility <= 0f ? passed > 0 : visibility >= minVisibility; } /// /// 将目标包围盒的 LOS 采样点写入 (80% 缩进避免边缘误判)。 /// private static int FillLOSPoints(Collider2D col, Transform fallback, int count) { if (col != null) { var b = col.bounds; var center = (Vector2)b.center; var ext = (Vector2)b.extents * 0.8f; _losPointsBuf[0] = center; if (count >= 2) _losPointsBuf[1] = center + new Vector2(0f, ext.y); if (count >= 3) _losPointsBuf[2] = center + new Vector2(0f, -ext.y); if (count >= 4) _losPointsBuf[3] = center + new Vector2( ext.x, 0f); if (count >= 5) _losPointsBuf[4] = center + new Vector2(-ext.x, 0f); return count; } _losPointsBuf[0] = (Vector2)fallback.position; return 1; } // ── 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 || s.type == SlotType.Sight)) 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 } }