Files
zeling_v2/Assets/_Game/Scripts/Enemies/Perception/PhysicsPerceptionSystem.cs
Joywayer d27ae9407d feat: Enhance Physics Perception System with new detection modes and performance optimizations
- 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.
2026-06-02 23:18:20 +08:00

623 lines
29 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 常量保持一致\naggro / patrol / alert / los / attack_melee / attack_range / sight")]
public string slotName;
[Tooltip("RangeCircle纯几何圆形范围\nBatchLOSOverlapCircle + 单射线遮挡(自包含 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 扇形分隔线数量(不影响检测,建议 39")]
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 专用] 01\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
}
}