- Updated PhysicsPerceptionSystem to support seven detection modes: RangeCircle, BatchLOS, FanCast, BoxCast, Sight, RayCast, and TriggerZone. - Improved documentation for each detection mode, including performance optimization strategies. - Introduced PerceptionTriggerProxy for event-driven detection in TriggerZone slots. - Added SightBatchSystem to manage Sight slots efficiently, reducing CPU spikes during high enemy counts. - Updated SensorSlotNames to reflect new detection modes and their purposes. - Enhanced internal logic for detecting targets and managing detection events.
623 lines
29 KiB
C#
623 lines
29 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using UnityEngine;
|
||
|
||
namespace BaseGames.Enemies.Perception
|
||
{
|
||
/// <summary>
|
||
/// 敌人感知系统(自研纯物理实现,商业级设计)。
|
||
/// 每个 <see cref="PerceptionSlot"/> 独立配置并独立运行,支持七种检测模式:
|
||
/// • <b>RangeCircle</b> — OverlapCircle 纯几何范围检测(无遮挡)
|
||
/// • <b>BatchLOS</b> — OverlapCircle + 单次 Raycast 自研批量视线检测(低开销,有帧延迟)
|
||
/// • <b>FanCast</b> — OverlapCircle + 角度过滤扇形视野(纯几何,无遮挡)
|
||
/// • <b>BoxCast</b> — OverlapBox 矩形区域检测(纯几何,无遮挡)
|
||
/// • <b>Sight</b> — OverlapCircle + 可选扇形角度 + 强制多点 LOS 遮挡(专用视线传感器)
|
||
/// • <b>RayCast</b> — 单/多根方向射线传感器,支持扩散角和遮挡层
|
||
/// • <b>TriggerZone</b> — 物理触发器事件驱动(子节点 PerceptionTriggerProxy,零轮询)
|
||
///
|
||
/// <b>遮挡检测设计原则:</b>
|
||
/// RangeCircle / FanCast / BoxCast 是纯几何传感器,不做遮挡校验。
|
||
/// 需要视线遮挡判断时,使用 <b>Sight</b> 槽位(内置多点 LOS 采样,始终执行遮挡检测)。
|
||
/// 单向射线遮挡使用 <b>RayCast</b> 槽位(obstructLayer 控制阻断层)。
|
||
///
|
||
/// <b>性能优化:</b>
|
||
/// • <see cref="PerceptionSlot.tickInterval"/> — 每 N 帧刷新一次(错帧执行,分散开销)
|
||
/// • <see cref="PerceptionSlot.isDisabled"/> — 运行时动态禁用单个槽位
|
||
/// • <see cref="SightBatchSystem"/> — 场景单例,全局控制 Sight 射线预算
|
||
///
|
||
/// 槽位名称常量统一定义于 <see cref="SensorSlotNames"/>。
|
||
/// </summary>
|
||
[DisallowMultipleComponent]
|
||
public sealed class PhysicsPerceptionSystem : MonoBehaviour, IPerceptionSystem
|
||
{
|
||
// ── 槽位类型 ──────────────────────────────────────────────────────────
|
||
|
||
public enum SlotType
|
||
{
|
||
/// <summary>Physics2D.OverlapCircle 纯几何范围检测(无遮挡)</summary>
|
||
RangeCircle,
|
||
/// <summary>OverlapCircle + 单条遮挡射线(自包含 LOS 检测,低开销)</summary>
|
||
BatchLOS,
|
||
/// <summary>OverlapCircle + 角度过滤扇形视野(纯几何,无遮挡)</summary>
|
||
FanCast,
|
||
/// <summary>OverlapBox 矩形区域检测,X 偏移随 localScale.x 自动翻转(纯几何,无遮挡)</summary>
|
||
BoxCast,
|
||
/// <summary>OverlapCircle + 可选扇形角度过滤 + 强制多点 LOS 遮挡校验(专用视线传感器)</summary>
|
||
Sight,
|
||
/// <summary>单/多根方向射线检测,支持扩散角和遮挡层(对标 RaySensor2D)</summary>
|
||
RayCast,
|
||
/// <summary>物理触发器事件驱动,依赖子节点 PerceptionTriggerProxy(零轮询开销)</summary>
|
||
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;
|
||
}
|
||
|
||
// ── 事件 ─────────────────────────────────────────────────────────────
|
||
|
||
/// <summary>某槽位首次检测到目标时触发。参数:(slotName, target)。</summary>
|
||
public event Action<string, GameObject> OnEnterDetection;
|
||
|
||
/// <summary>某槽位失去对目标的检测时触发。参数:(slotName, target)。</summary>
|
||
public event Action<string, GameObject> OnExitDetection;
|
||
|
||
// ── 字段 ──────────────────────────────────────────────────────────────
|
||
|
||
[SerializeField] private PerceptionSlot[] _slots;
|
||
|
||
// 当前帧检测结果
|
||
private readonly Dictionary<string, List<GameObject>> _detected =
|
||
new Dictionary<string, List<GameObject>>();
|
||
|
||
// 上帧检测结果(Enter/Exit diff 用,HashSet 提供 O(1) Contains)
|
||
private readonly Dictionary<string, HashSet<GameObject>> _prevDetected =
|
||
new Dictionary<string, HashSet<GameObject>>();
|
||
|
||
// 实例缓冲区(单线程 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<GameObject> _enterBuf = new List<GameObject>(8);
|
||
private static readonly List<GameObject> _exitBuf = new List<GameObject>(8);
|
||
|
||
private int _fixedTick;
|
||
private bool _suspended;
|
||
private EnemyBase _owner;
|
||
|
||
// ── Unity 生命周期 ────────────────────────────────────────────────────
|
||
|
||
private void Awake()
|
||
{
|
||
_owner = GetComponentInParent<EnemyBase>();
|
||
|
||
if (_slots != null)
|
||
{
|
||
foreach (var slot in _slots)
|
||
if (!string.IsNullOrEmpty(slot.slotName))
|
||
EnsureSlotDict(slot.slotName);
|
||
}
|
||
|
||
// TriggerZone:发现所有子节点代理并绑定
|
||
var proxies = GetComponentsInChildren<PerceptionTriggerProxy>(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<PerceptionTriggerProxy>(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<GameObject>(4);
|
||
_prevDetected[name] = new HashSet<GameObject>();
|
||
}
|
||
}
|
||
|
||
// ── 内部检测逻辑 ──────────────────────────────────────────────────────
|
||
|
||
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<GameObject> 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<GameObject> 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<GameObject> 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<GameObject> 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);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 视线传感器:OverlapCircle + 可选扇形角度过滤 + 强制多点 LOS 遮挡校验。
|
||
/// 唯一内置遮挡检测的轮询槽位类型。
|
||
/// </summary>
|
||
private void RefreshSight(ref PerceptionSlot slot, List<GameObject> 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);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 方向射线传感器:单/多根射线,支持扩散角和遮挡阻断。
|
||
/// 射线方向在本地空间定义,X 分量随朝向自动翻转。
|
||
/// </summary>
|
||
private void RefreshRayCast(ref PerceptionSlot slot, List<GameObject> 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)─────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 由 <see cref="SightBatchSystem"/> 调用,执行本系统所有 Sight 槽位的刷新。
|
||
/// 批量系统负责频率控制,不参与 tickInterval 逻辑。
|
||
/// </summary>
|
||
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 工具 ──────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 多点采样 LOS 遮挡检测。
|
||
/// <para><paramref name="blockMask"/> = 0 时始终返回 true(无遮挡层配置 = 视线始终通过)。</para>
|
||
/// </summary>
|
||
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<Collider2D>();
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 将目标包围盒的 LOS 采样点写入 <see cref="_losPointsBuf"/>(80% 缩进避免边缘误判)。
|
||
/// </summary>
|
||
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<string, List<GameObject>> EditorDetected => _detected;
|
||
public EnemyBase EditorOwner => _owner;
|
||
#endif
|
||
}
|
||
}
|