Files
zeling_v2/Assets/_Game/Scripts/Enemies/Perception/PhysicsPerceptionSystem.cs
T
joywayerandClaude Opus 4.8 859d4e9bd6 feat(enemy): 感知系统新增 BoxLOS 槽位(横向矩形视野+遮挡)
BoxCast 是纯几何矩形无遮挡,BatchLOS/Sight 有遮挡但为圆形。
新增 BoxLOS 槽位:OverlapBox 矩形区域 + 从感知原点多点 LOS 遮挡校验,
用于走廊/平台等横向矩形视野带遮挡的场景。

- enum 末尾追加 BoxLOS(避免打乱既有序列化索引)
- 复用 boxSize/boxOffset 定义矩形、losBlockMask/losRayCount/losMinVisibility 定义遮挡
- 方向由 boxOffset.x 控制:0=前后双向对称,boxSize.x*0.5=仅前向
- Editor 补 Gizmo(矩形+眼点+实时检测连线)与条件字段

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 15:54:17 +08:00

655 lines
32 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>BoxLOS</b> — OverlapBox 矩形(横向)区域 + 多点 LOS 遮挡(矩形视野带遮挡)
///
/// <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,
/// <summary>OverlapBox 矩形(横向)区域 + 多点 LOS 遮挡校验(矩形视野带遮挡)</summary>
BoxLOS,
}
// ── 槽位定义 ──────────────────────────────────────────────────────────
[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 扇形分隔线数量(不影响检测,建议 3–9)")]
public int fanRayCount;
// ── BoxCast ────────────────────────────────────────────────────
[Tooltip("BoxCast / BoxLOS:检测框尺寸 (宽, 高),单位米")]
public Vector2 boxSize;
[Tooltip("BoxCast / BoxLOS:相对于感知中心的偏移,X 分量随 localScale.x 自动翻转。\n仅前向视野:X 设为 boxSize.x * 0.5(矩形整块推到前方);前后双向:X 设为 0(对称)。")]
public Vector2 boxOffset;
// ── LOSSight / BoxLOS 共用)────────────────────────────────
[Tooltip("[Sight / BoxLOS] 视线遮挡检测层(通常为 Platform + Wall + Ground)。\n 0 时视线始终通过(losBlockMask = 0 = 无遮挡层)。")]
public LayerMask losBlockMask;
[Range(1, 5)]
[Tooltip("[Sight / BoxLOS] LOS 多点采样数量:\n1 = 包围盒中心(最快)\n3 = 中心 + + 下(推荐)\n5 = 中心 + 上下左右(最稳健)")]
public int losRayCount;
[Range(0f, 1f)]
[Tooltip("[Sight / BoxLOS] 可见度阈值(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;
case SlotType.BoxLOS: RefreshBoxLOS(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>
/// 矩形视线传感器:OverlapBox(横向矩形区域)+ 多点 LOS 遮挡校验。
/// 形状同 <see cref="SlotType.BoxCast"/>,但对每个候选目标从感知原点做遮挡射线,
/// 用于"横向矩形视野带遮挡"的场景(如走廊 / 平台横向警戒)。
/// <para>矩形以 <see cref="PerceptionSlot.boxSize"/> / <see cref="PerceptionSlot.boxOffset"/> 定义;
/// 遮挡复用 <see cref="PerceptionSlot.losBlockMask"/> / losRayCount / losMinVisibility。
/// losBlockMask = 0 时视线始终通过(退化为纯几何 BoxCast)。</para>
/// </summary>
private void RefreshBoxLOS(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) continue;
// 遮挡校验:从感知原点(眼点)到目标;losBlockMask = 0 时始终通过
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>
/// 视线传感器: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
}
}