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
}
}