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.
This commit is contained in:
@@ -7,7 +7,7 @@ namespace BaseGames.Enemies.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// BD Conditional:玩家是否可见(LOS 检测)。
|
||||
/// 读取 EnemyBase._losResult 字段(由 BatchLOSSystem 每帧写入,或降级 3 帧节流 Raycast)。
|
||||
/// 读取 EnemyBase.IsPlayerVisible(),结果来自 PhysicsPerceptionSystem(LOS / Sight 槽位)。
|
||||
/// </summary>
|
||||
[TaskName("Is Player Visible?")]
|
||||
[TaskCategory("BaseGames/Enemy/Perception")]
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using Unity.Collections;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Jobs;
|
||||
using BaseGames.Core;
|
||||
|
||||
namespace BaseGames.Enemies.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// 批量视线检测系统(架构 07_EnemyModule §12)。
|
||||
/// 每 FixedUpdate:
|
||||
/// 1. 读取上一帧 RaycastHit2D 结果,回调各注册者。
|
||||
/// 2. 重新构建本帧 RaycastCommand 批次,使用 Physics2D.RaycastAll 同步模式执行。
|
||||
///
|
||||
/// ⚠️ Unity 2022.3 中 Physics2D 批量命令(RaycastCommand2D)尚未稳定,
|
||||
/// 此实现使用 FixedUpdate 内顺序 Raycast2D(节流),确保零 GC 分配。
|
||||
/// 当敌人数量 > 20 时建议切换到 Job System RaycastCommand。
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(-200)]
|
||||
public class BatchLOSSystem : MonoBehaviour
|
||||
{
|
||||
[SerializeField, Min(1)] private int _maxRequestersPerFrame = 8;
|
||||
|
||||
private void Awake() => ServiceLocator.Register<BatchLOSSystem>(this);
|
||||
private void OnDestroy() => ServiceLocator.Unregister<BatchLOSSystem>(this);
|
||||
|
||||
private readonly List<ILOSRequester> _requesters = new();
|
||||
private readonly HashSet<ILOSRequester> _requesterSet = new(); // O(1) 包含查询
|
||||
// _indexMap 记录每个 requester 在 _requesters 中的下标,供 Unregister 实现 O(1) 删除
|
||||
private readonly Dictionary<ILOSRequester, int> _indexMap = new();
|
||||
private int _currentOffset = 0;
|
||||
|
||||
// ── 注册 ──────────────────────────────────────────────────────────
|
||||
public void Register(ILOSRequester requester)
|
||||
{
|
||||
if (_requesterSet.Add(requester))
|
||||
{
|
||||
_indexMap[requester] = _requesters.Count;
|
||||
_requesters.Add(requester);
|
||||
}
|
||||
}
|
||||
|
||||
public void Unregister(ILOSRequester requester)
|
||||
{
|
||||
if (!_requesterSet.Remove(requester)) return;
|
||||
|
||||
int idx = _indexMap[requester];
|
||||
int last = _requesters.Count - 1;
|
||||
|
||||
// Swap-and-pop:将末尾元素移动到被删除的位置,避免 O(n) 搬移
|
||||
if (idx != last)
|
||||
{
|
||||
var moved = _requesters[last];
|
||||
_requesters[idx] = moved;
|
||||
_indexMap[moved] = idx;
|
||||
}
|
||||
|
||||
_requesters.RemoveAt(last);
|
||||
_indexMap.Remove(requester);
|
||||
|
||||
// 修正偏移量,防止越界
|
||||
if (_currentOffset >= _requesters.Count) _currentOffset = 0;
|
||||
}
|
||||
|
||||
// ── FixedUpdate ───────────────────────────────────────────────────
|
||||
private void FixedUpdate()
|
||||
{
|
||||
if (_requesters.Count == 0) return;
|
||||
|
||||
// 每帧轮询部分请求者(均匀分配,避免单帧全量射线)
|
||||
int count = Mathf.Min(_maxRequestersPerFrame, _requesters.Count);
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
int idx = (_currentOffset + i) % _requesters.Count;
|
||||
var requester = _requesters[idx];
|
||||
|
||||
bool hasLOS = false;
|
||||
if (requester != null)
|
||||
{
|
||||
Vector2 origin = requester.LOSOrigin;
|
||||
Vector2 target = requester.LOSTarget;
|
||||
Vector2 direction = target - origin;
|
||||
float distance = direction.magnitude;
|
||||
|
||||
if (distance > 0.01f)
|
||||
{
|
||||
// direction / distance == direction.normalized,避免重复开方
|
||||
var hit = Physics2D.Raycast(origin, direction / distance, distance, requester.LOSBlockingMask);
|
||||
// 若无遮挡物(hit.collider == null),则视线畅通
|
||||
hasLOS = hit.collider == null;
|
||||
}
|
||||
|
||||
|
||||
requester.ReceiveLOSResult(hasLOS);
|
||||
}
|
||||
}
|
||||
|
||||
_currentOffset = (_currentOffset + count) % Mathf.Max(1, _requesters.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ namespace BaseGames.Enemies
|
||||
/// ⚠️ _nav 字段类型为 IPathAgent(在 BaseGames.Enemies.Navigation 中实现具体类)。
|
||||
/// 实现 IPoolable:配合 PooledObject 支持对象池复用,避免频繁 Destroy/Instantiate。
|
||||
/// </summary>
|
||||
public class EnemyBase : MonoBehaviour, IDamageable, ILOSRequester, IPoolable
|
||||
public class EnemyBase : MonoBehaviour, IDamageable, IPoolable
|
||||
{
|
||||
[Header("标识")]
|
||||
[SerializeField] private string _enemyId; // 任务系统 / Boss 进程追踪用,如 "Enemy_SpiderGuard"
|
||||
@@ -286,11 +286,14 @@ namespace BaseGames.Enemies
|
||||
public virtual bool IsPlayerInRange(float range)
|
||||
=> _stats != null && _stats.SqrDistanceToPlayer <= range * range;
|
||||
|
||||
/// <summary>原始视线检测结果(BatchLOSSystem 写入,无感知延迟修正)。</summary>
|
||||
public bool HasLineOfSight => _losResult;
|
||||
/// <summary>视线检测结果(由 PhysicsPerceptionSystem 的 LOS / Sight slot 提供)。</summary>
|
||||
public bool HasLineOfSight =>
|
||||
_sensorHub != null &&
|
||||
(_sensorHub.HasAnyDetection(Perception.SensorSlotNames.LOS) ||
|
||||
_sensorHub.HasAnyDetection(Perception.SensorSlotNames.Sight));
|
||||
|
||||
public virtual bool IsPlayerVisible()
|
||||
=> _threatAssessor != null ? _threatAssessor.IsThreatDetected : _losResult;
|
||||
=> _threatAssessor != null ? _threatAssessor.IsThreatDetected : HasLineOfSight;
|
||||
|
||||
public virtual void FacePlayer()
|
||||
{
|
||||
@@ -644,31 +647,16 @@ namespace BaseGames.Enemies
|
||||
protected virtual void OnEnable()
|
||||
{
|
||||
_onPlayerSpawned?.Subscribe(SetPlayerTransform).AddTo(_subs);
|
||||
Core.ServiceLocator.GetOrDefault<AI.BatchLOSSystem>()?.Register(this);
|
||||
}
|
||||
|
||||
protected virtual void OnDisable()
|
||||
{
|
||||
Core.ServiceLocator.GetOrDefault<AI.BatchLOSSystem>()?.Unregister(this);
|
||||
_subs.Clear();
|
||||
}
|
||||
|
||||
protected virtual void OnDestroy() { }
|
||||
|
||||
// LOS 缓存(BatchLOSSystem 写入;降级时由 3 帧节流 Raycast 写入)
|
||||
private bool _losResult;
|
||||
|
||||
// ── ILOSRequester ──────────────────────────────────────────────────
|
||||
public Vector2 LOSOrigin => (Vector2)transform.position + _statsSO.EyeOffset;
|
||||
public Vector2 LOSTarget => _playerTransform != null
|
||||
? (Vector2)_playerTransform.position
|
||||
: (Vector2)transform.position;
|
||||
public LayerMask LOSBlockingMask => _statsSO.LOSBlockingMask;
|
||||
|
||||
public void ReceiveLOSResult(bool hasLineOfSight)
|
||||
{
|
||||
_losResult = hasLineOfSight;
|
||||
}
|
||||
|
||||
// BehaviorTree 引用(#if GRAPH_DESIGNER 保护,避免空引用)
|
||||
#if GRAPH_DESIGNER
|
||||
@@ -831,9 +819,9 @@ namespace BaseGames.Enemies
|
||||
Gizmos.color = new Color(1f, 0.9f, 0.2f, 0.85f);
|
||||
Gizmos.DrawWireSphere(eyeWorld, 0.07f);
|
||||
|
||||
if (inRange || _losResult)
|
||||
if (inRange || HasLineOfSight)
|
||||
{
|
||||
Gizmos.color = _losResult
|
||||
Gizmos.color = HasLineOfSight
|
||||
? new Color(1f, 0.5f, 0f, 0.85f)
|
||||
: new Color(0.6f, 0.6f, 0.6f, 0.25f);
|
||||
Gizmos.DrawLine(eyeWorld, playerPos);
|
||||
|
||||
@@ -64,7 +64,7 @@ namespace BaseGames.Enemies
|
||||
[Tooltip("Stagger / KnockUp 伤害阈值及击飞参数")]
|
||||
public HitTierConfig HitTiers;
|
||||
|
||||
[Header("视线检测(BatchLOSSystem)")]
|
||||
[Header("视线检测(遗留配置,已迁移到 PhysicsPerceptionSystem 各 Slot 的 losBlockMask)")]
|
||||
[Tooltip("相对 transform.position 的眼睛偏移量")]
|
||||
public Vector2 EyeOffset = new Vector2(0f, 0.8f);
|
||||
[Tooltip("遮挡 LOS 的物理图层")]
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Enemies
|
||||
{
|
||||
/// <summary>
|
||||
/// BatchLOSSystem 的注册接口(架构 07_EnemyModule §12)。
|
||||
/// 实现此接口的 EnemyBase 子类可以注册到 BatchLOSSystem,
|
||||
/// 以批处理方式接收 LOS(Line of Sight)检测结果。
|
||||
/// </summary>
|
||||
public interface ILOSRequester
|
||||
{
|
||||
/// <summary>射线起点(通常是眼部位置)。</summary>
|
||||
Vector2 LOSOrigin { get; }
|
||||
|
||||
/// <summary>射线终点(通常是玩家位置)。</summary>
|
||||
Vector2 LOSTarget { get; }
|
||||
|
||||
/// <summary>遮挡 LOS 的物理图层。</summary>
|
||||
LayerMask LOSBlockingMask { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 接收 LOS 检测结果。
|
||||
/// <paramref name="hasLineOfSight"/>: true = 有视线,false = 被遮挡。
|
||||
/// </summary>
|
||||
void ReceiveLOSResult(bool hasLineOfSight);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ namespace BaseGames.Enemies.Perception
|
||||
///
|
||||
/// 工作原理:
|
||||
/// <list type="bullet">
|
||||
/// <item>读取 <see cref="EnemyBase.HasLineOfSight"/>(原始 LOS,BatchLOSSystem 写入)。</item>
|
||||
/// <item>读取 <see cref="EnemyBase.HasLineOfSight"/>(由 PhysicsPerceptionSystem 的 LOS/Sight 槽位驱动)。</item>
|
||||
/// <item>连续感知到玩家超过 <see cref="reactionDelay"/> 秒后,<see cref="IsThreatDetected"/> 才变为 true。</item>
|
||||
/// <item>一旦丢失 LOS,<see cref="IsThreatDetected"/> 立即重置为 false(保持对"躲起来"的快速响应)。</item>
|
||||
/// </list>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Enemies.Perception
|
||||
{
|
||||
/// <summary>
|
||||
/// 挂载在 PhysicsPerceptionSystem 子节点上的触发器代理组件。
|
||||
/// 与 <see cref="PhysicsPerceptionSystem"/> 的 <c>TriggerZone</c> 槽位配合使用:
|
||||
/// 当物理触发器产生 Enter / Exit 事件时,通知父系统的感知字典,
|
||||
/// 同时触发 <see cref="PhysicsPerceptionSystem.OnEnterDetection"/> /
|
||||
/// <see cref="PhysicsPerceptionSystem.OnExitDetection"/> 委托。
|
||||
///
|
||||
/// <b>使用步骤:</b>
|
||||
/// 1. 在 PhysicsPerceptionSystem 的子 GameObject 上添加本组件。
|
||||
/// 2. 确保同一 GameObject 或子节点上有 Collider2D,且 <c>isTrigger = true</c>。
|
||||
/// 3. 填写 <see cref="slotName"/>(与父系统中同名 TriggerZone 槽位对应)。
|
||||
/// 4. 设置 <see cref="detectLayer"/>(只有命中该层的碰撞体才会触发通知)。
|
||||
/// 5. <see cref="ParentSystem"/> 由父系统 Awake() 自动赋值,无需手动操作。
|
||||
/// </summary>
|
||||
[AddComponentMenu("BaseGames/Enemies/Perception Trigger Proxy")]
|
||||
[RequireComponent(typeof(Collider2D))]
|
||||
public sealed class PerceptionTriggerProxy : MonoBehaviour
|
||||
{
|
||||
[Tooltip("对应 PhysicsPerceptionSystem 中 TriggerZone 槽位的 slotName")]
|
||||
public string slotName;
|
||||
|
||||
[Tooltip("目标检测层,只有命中此层的碰撞体才通知父系统")]
|
||||
public LayerMask detectLayer;
|
||||
|
||||
/// <summary>由父 PhysicsPerceptionSystem 在 Awake() 中自动注入,无需手动赋值。</summary>
|
||||
internal PhysicsPerceptionSystem ParentSystem { get; set; }
|
||||
|
||||
// ── Unity 生命周期 ────────────────────────────────────────────────────
|
||||
|
||||
private void OnValidate()
|
||||
{
|
||||
var col = GetComponent<Collider2D>();
|
||||
if (col != null && !col.isTrigger)
|
||||
Debug.LogWarning(
|
||||
$"[PerceptionTriggerProxy] '{name}' 上的 Collider2D.isTrigger 未勾选," +
|
||||
"TriggerZone 槽位将无法工作。", this);
|
||||
}
|
||||
|
||||
private void OnTriggerEnter2D(Collider2D other)
|
||||
{
|
||||
if (ParentSystem == null || string.IsNullOrEmpty(slotName)) return;
|
||||
if (detectLayer != 0 && ((1 << other.gameObject.layer) & (int)detectLayer) == 0) return;
|
||||
ParentSystem.OnTriggerZoneEnter(slotName, other.gameObject);
|
||||
}
|
||||
|
||||
private void OnTriggerExit2D(Collider2D other)
|
||||
{
|
||||
if (ParentSystem == null || string.IsNullOrEmpty(slotName)) return;
|
||||
if (detectLayer != 0 && ((1 << other.gameObject.layer) & (int)detectLayer) == 0) return;
|
||||
ParentSystem.OnTriggerZoneExit(slotName, other.gameObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a482b11f99a870f4ea28cd36b716a69b
|
||||
guid: e8052de08fa173e479e190f652a1c04d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
@@ -5,15 +5,26 @@ using UnityEngine;
|
||||
namespace BaseGames.Enemies.Perception
|
||||
{
|
||||
/// <summary>
|
||||
/// 敌人感知系统(自研纯物理实现)。
|
||||
/// 每个 <see cref="PerceptionSlot"/> 独立配置并独立运行,支持四种检测模式:
|
||||
/// • RangeCircle — Physics2D.OverlapCircleNonAlloc(可选 LOS 视线遮挡校验)
|
||||
/// • BatchLOS — 委托 EnemyBase.IsPlayerVisible()(BatchLOSSystem 批量射线)
|
||||
/// • FanCast — 以朝向为轴的扇形多射线视野(支持遮挡层)
|
||||
/// • BoxCast — 矩形区域重叠检测(X 偏移随 localScale.x 自动翻转)
|
||||
/// 敌人感知系统(自研纯物理实现,商业级设计)。
|
||||
/// 每个 <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 射线预算
|
||||
///
|
||||
/// EnemyBase.Awake() 通过 GetComponentInChildren<IPerceptionSystem>()
|
||||
/// 自动发现本组件,无需修改 EnemyBase。
|
||||
/// 槽位名称常量统一定义于 <see cref="SensorSlotNames"/>。
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
@@ -23,14 +34,20 @@ namespace BaseGames.Enemies.Perception
|
||||
|
||||
public enum SlotType
|
||||
{
|
||||
/// <summary>Physics2D 圆形重叠检测</summary>
|
||||
/// <summary>Physics2D.OverlapCircle 纯几何范围检测(无遮挡)</summary>
|
||||
RangeCircle,
|
||||
/// <summary>委托 EnemyBase.IsPlayerVisible()(BatchLOSSystem 批量射线视线检测)</summary>
|
||||
/// <summary>OverlapCircle + 单条遮挡射线(自包含 LOS 检测,低开销)</summary>
|
||||
BatchLOS,
|
||||
/// <summary>以朝向为轴的扇形射线视野,遮挡层阻断视线</summary>
|
||||
/// <summary>OverlapCircle + 角度过滤扇形视野(纯几何,无遮挡)</summary>
|
||||
FanCast,
|
||||
/// <summary>矩形区域重叠检测,X 偏移随 localScale.x 自动翻转</summary>
|
||||
BoxCast
|
||||
/// <summary>OverlapBox 矩形区域检测,X 偏移随 localScale.x 自动翻转(纯几何,无遮挡)</summary>
|
||||
BoxCast,
|
||||
/// <summary>OverlapCircle + 可选扇形角度过滤 + 强制多点 LOS 遮挡校验(专用视线传感器)</summary>
|
||||
Sight,
|
||||
/// <summary>单/多根方向射线检测,支持扩散角和遮挡层(对标 RaySensor2D)</summary>
|
||||
RayCast,
|
||||
/// <summary>物理触发器事件驱动,依赖子节点 PerceptionTriggerProxy(零轮询开销)</summary>
|
||||
TriggerZone,
|
||||
}
|
||||
|
||||
// ── 槽位定义 ──────────────────────────────────────────────────────────
|
||||
@@ -38,56 +55,119 @@ namespace BaseGames.Enemies.Perception
|
||||
[Serializable]
|
||||
public struct PerceptionSlot
|
||||
{
|
||||
[Tooltip("槽位名称,与 SensorSlotNames 常量保持一致\n(aggro / los / attack_melee / attack_range)")]
|
||||
// ── 通用 ───────────────────────────────────────────────────────
|
||||
|
||||
[Tooltip("槽位名称,与 SensorSlotNames 常量保持一致\n(aggro / patrol / alert / los / attack_melee / attack_range / sight)")]
|
||||
public string slotName;
|
||||
|
||||
[Tooltip("RangeCircle:Physics2D 圆形范围检测\nBatchLOS:视线射线检测(BatchLOSSystem)\nFanCast:以朝向为轴的扇形射线视野\nBoxCast:矩形区域重叠检测")]
|
||||
[Tooltip("RangeCircle:纯几何圆形范围\nBatchLOS:OverlapCircle + 单射线遮挡(自包含 LOS)\nFanCast:扇形视野(纯几何)\nBoxCast:矩形区域(纯几何)\nSight:视线传感器(多射线遮挡)\nRayCast:方向射线传感器\nTriggerZone:触发器事件驱动(需子节点 PerceptionTriggerProxy)")]
|
||||
public SlotType type;
|
||||
|
||||
[Min(0f)]
|
||||
[Tooltip("RangeCircle / FanCast:检测半径(米)\nBatchLOS:最大视线检测距离(0 = 不限制)\nBoxCast:忽略此值")]
|
||||
public float radius;
|
||||
|
||||
[Tooltip("目标检测层(通常为 Player 层);BatchLOS 忽略此值")]
|
||||
public LayerMask detectLayer;
|
||||
|
||||
[Tooltip("RangeCircle / BoxCast:基础重叠命中后额外校验视线(Physics2D.Raycast)\nFanCast:true = 射线被 losBlockMask 层遮挡;false = 穿透所有障碍物")]
|
||||
public bool requireLOS;
|
||||
|
||||
[Tooltip("requireLOS = true / FanCast(requireLOS = true):视线遮挡检测层(通常为 Platform + Wall)\nFanCast 射线只在 requireLOS = true 时被此层遮挡")]
|
||||
public LayerMask losBlockMask;
|
||||
|
||||
[Header("Origin")]
|
||||
[Tooltip("检测原点相对于 transform.position 的偏移(米)。\nX 分量随 localScale.x 朝向自动翻转;BatchLOS 仅影响 Gizmo,不影响实际射线。")]
|
||||
[Tooltip("检测原点相对于 transform.position 的偏移(米)。\nX 分量随 localScale.x 朝向自动翻转。")]
|
||||
public Vector2 offset;
|
||||
|
||||
[Header("FanCast")]
|
||||
[Tooltip("FanCast:扇形张角(度),以朝向为中轴左右均匀展开")]
|
||||
[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;
|
||||
|
||||
[Tooltip("FanCast:扇形内均匀分布的射线数量(建议 5–11 条)")]
|
||||
[Min(2)]
|
||||
[Tooltip("FanCast 专用:Scene 视图 Gizmo 扇形分隔线数量(不影响检测,建议 3–9)")]
|
||||
public int fanRayCount;
|
||||
|
||||
[Header("BoxCast")]
|
||||
// ── BoxCast ────────────────────────────────────────────────────
|
||||
|
||||
[Tooltip("BoxCast:检测框尺寸 (宽, 高),单位米")]
|
||||
public Vector2 boxSize;
|
||||
|
||||
[Tooltip("BoxCast:相对于感知中心的偏移,X 分量随 localScale.x 自动翻转")]
|
||||
public Vector2 boxOffset;
|
||||
|
||||
[Header("Gizmos")]
|
||||
[Tooltip("Scene 视图 Gizmo 颜色。留空(全透明黑色)时自动使用紫色回退,以确保可见。")]
|
||||
public Color gizmoColor;
|
||||
// ── 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;
|
||||
|
||||
@@ -96,59 +176,163 @@ namespace BaseGames.Enemies.Perception
|
||||
private void Awake()
|
||||
{
|
||||
_owner = GetComponentInParent<EnemyBase>();
|
||||
if (_slots == null) return;
|
||||
foreach (var slot in _slots)
|
||||
|
||||
if (_slots != null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(slot.slotName) && !_detected.ContainsKey(slot.slotName))
|
||||
_detected[slot.slotName] = new List<GameObject>(4);
|
||||
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;
|
||||
foreach (var slot in _slots)
|
||||
RefreshSlot(slot);
|
||||
_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(PerceptionSlot slot)
|
||||
private void RefreshSlot(ref PerceptionSlot slot, int slotIndex)
|
||||
{
|
||||
if (string.IsNullOrEmpty(slot.slotName)) return;
|
||||
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:
|
||||
if (_owner != null && _owner.IsPlayerVisible() && _owner.PlayerTransform != null)
|
||||
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)
|
||||
{
|
||||
if (slot.radius > 0f)
|
||||
{
|
||||
float dist = Vector2.Distance(
|
||||
(Vector2)transform.position, (Vector2)_owner.PlayerTransform.position);
|
||||
if (dist > slot.radius) break;
|
||||
}
|
||||
list.Add(_owner.PlayerTransform.gameObject);
|
||||
var hit = Physics2D.Raycast(origin, dir / dist, dist, slot.losBlockMask);
|
||||
if (hit.collider != null) continue; // 被遮挡
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case SlotType.RangeCircle:
|
||||
RefreshRangeCircle(slot, list);
|
||||
break;
|
||||
|
||||
case SlotType.FanCast:
|
||||
RefreshFanCast(slot, list);
|
||||
break;
|
||||
|
||||
case SlotType.BoxCast:
|
||||
RefreshBoxCast(slot, list);
|
||||
break;
|
||||
list.Add(col.gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshRangeCircle(in PerceptionSlot slot, System.Collections.Generic.List<GameObject> list)
|
||||
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;
|
||||
@@ -157,40 +341,30 @@ namespace BaseGames.Enemies.Perception
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var col = _overlapBuffer[i];
|
||||
if (col == null) continue;
|
||||
if (slot.requireLOS && !HasLineOfSight(origin, col.gameObject, slot.losBlockMask)) continue;
|
||||
list.Add(col.gameObject);
|
||||
if (col != null) list.Add(col.gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshFanCast(in PerceptionSlot slot, System.Collections.Generic.List<GameObject> list)
|
||||
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;
|
||||
var forward = new Vector2(facingSign, 0f);
|
||||
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 rays = Mathf.Max(2, slot.fanRayCount);
|
||||
// requireLOS = true:射线被 losBlockMask 遮挡;false:仅检测 detectLayer(穿透障碍物)
|
||||
int castMask = slot.requireLOS
|
||||
? ((int)slot.detectLayer | (int)slot.losBlockMask)
|
||||
: (int)slot.detectLayer;
|
||||
|
||||
for (int r = 0; r < rays; r++)
|
||||
int count = Physics2D.OverlapCircleNonAlloc(origin, slot.radius, _overlapBuffer, slot.detectLayer);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
float t = (float)r / (rays - 1);
|
||||
Vector2 dir = RotateVector(forward, Mathf.Lerp(-halfAngle, halfAngle, t));
|
||||
RaycastHit2D hit = Physics2D.Raycast(origin, dir, slot.radius, castMask);
|
||||
if (hit.collider == null) continue;
|
||||
if (((1 << hit.collider.gameObject.layer) & (int)slot.detectLayer) == 0) continue;
|
||||
|
||||
var go = hit.collider.gameObject;
|
||||
if (!list.Contains(go)) list.Add(go);
|
||||
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(in PerceptionSlot slot, System.Collections.Generic.List<GameObject> list)
|
||||
private void RefreshBoxCast(ref PerceptionSlot slot, List<GameObject> list)
|
||||
{
|
||||
if (slot.boxSize.x <= 0f || slot.boxSize.y <= 0f || slot.detectLayer == 0) return;
|
||||
|
||||
@@ -202,33 +376,183 @@ namespace BaseGames.Enemies.Perception
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var col = _overlapBuffer[i];
|
||||
if (col == null) continue;
|
||||
if (slot.requireLOS && !HasLineOfSight(origin, col.gameObject, slot.losBlockMask)) continue;
|
||||
list.Add(col.gameObject);
|
||||
if (col != null) list.Add(col.gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
private bool HasLineOfSight(Vector2 origin, GameObject target, LayerMask blockMask)
|
||||
/// <summary>
|
||||
/// 视线传感器:OverlapCircle + 可选扇形角度过滤 + 强制多点 LOS 遮挡校验。
|
||||
/// 唯一内置遮挡检测的轮询槽位类型。
|
||||
/// </summary>
|
||||
private void RefreshSight(ref PerceptionSlot slot, List<GameObject> list)
|
||||
{
|
||||
// 优先使用碰撞体包围盒中心(比 transform.position 更准确,避免偏心轴误判)
|
||||
Vector2 targetPos;
|
||||
var col = target.GetComponent<Collider2D>();
|
||||
targetPos = col != null ? (Vector2)col.bounds.center : (Vector2)target.transform.position;
|
||||
if (slot.radius <= 0f || slot.detectLayer == 0) return;
|
||||
|
||||
var dir = targetPos - origin;
|
||||
float dist = dir.magnitude;
|
||||
if (dist <= 0f) return true;
|
||||
var hit = Physics2D.Raycast(origin, dir / dist, dist, blockMask);
|
||||
// 未命中任何障碍,或者第一个命中的就是目标自身(含子物体)
|
||||
return hit.collider == null || hit.collider.transform.IsChildOf(target.transform) || hit.collider.gameObject == target;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private static Vector2 RotateVector(Vector2 v, float angleDeg)
|
||||
/// <summary>
|
||||
/// 方向射线传感器:单/多根射线,支持扩散角和遮挡阻断。
|
||||
/// 射线方向在本地空间定义,X 分量随朝向自动翻转。
|
||||
/// </summary>
|
||||
private void RefreshRayCast(ref PerceptionSlot slot, List<GameObject> list)
|
||||
{
|
||||
float rad = angleDeg * Mathf.Deg2Rad;
|
||||
float cos = Mathf.Cos(rad);
|
||||
float sin = Mathf.Sin(rad);
|
||||
return new Vector2(cos * v.x - sin * v.y, sin * v.x + cos * v.y);
|
||||
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 ─────────────────────────────────────────────────
|
||||
@@ -236,7 +560,6 @@ namespace BaseGames.Enemies.Perception
|
||||
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)
|
||||
@@ -265,7 +588,7 @@ namespace BaseGames.Enemies.Perception
|
||||
{
|
||||
if (string.IsNullOrEmpty(slotName) || _slots == null) return -1f;
|
||||
foreach (var s in _slots)
|
||||
if (s.slotName == slotName && s.type == SlotType.RangeCircle)
|
||||
if (s.slotName == slotName && (s.type == SlotType.RangeCircle || s.type == SlotType.Sight))
|
||||
return s.radius;
|
||||
return -1f;
|
||||
}
|
||||
@@ -291,9 +614,9 @@ namespace BaseGames.Enemies.Perception
|
||||
// ── 编辑器 API(仅 UNITY_EDITOR 访问)────────────────────────────────
|
||||
|
||||
#if UNITY_EDITOR
|
||||
public PerceptionSlot[] EditorSlots => _slots;
|
||||
public PerceptionSlot[] EditorSlots => _slots;
|
||||
public IReadOnlyDictionary<string, List<GameObject>> EditorDetected => _detected;
|
||||
public EnemyBase EditorOwner => _owner;
|
||||
public EnemyBase EditorOwner => _owner;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace BaseGames.Enemies.Perception
|
||||
|
||||
/// <summary>
|
||||
/// 视线检测(BatchLOS):敌我之间无遮挡时持续为 true。
|
||||
/// 由 BatchLOSSystem 批量计算,BD_IsPlayerVisible 读取结果。
|
||||
/// 由 PhysicsPerceptionSystem 自研批量计算(OverlapCircle + 单次 Raycast),BD_IsPlayerVisible 读取结果。
|
||||
/// </summary>
|
||||
public const string LOS = "los";
|
||||
|
||||
@@ -29,5 +29,23 @@ namespace BaseGames.Enemies.Perception
|
||||
/// 远程攻击范围(RangeCircle):玩家进入时触发远程攻击条件。
|
||||
/// </summary>
|
||||
public const string AttackRange = "attack_range";
|
||||
|
||||
/// <summary>
|
||||
/// 巡逻范围(RangeCircle):定义敌人允许巡逻的地图固定区域半径。
|
||||
/// 超出此范围时触发"返回巡逻点"逻辑。
|
||||
/// </summary>
|
||||
public const string Patrol = "patrol";
|
||||
|
||||
/// <summary>
|
||||
/// 警觉半径(RangeCircle):进入此圈时敌人从待机/巡逻切换到 Alert 状态,
|
||||
/// 通常比 Aggro 小,用于区分"察觉"和"追击"两个阶段。
|
||||
/// </summary>
|
||||
public const string Alert = "alert";
|
||||
|
||||
/// <summary>
|
||||
/// 视线感知(Sight):带强制 LOS 遮挡检测的视锥传感器。
|
||||
/// 仅当目标在视野锥内且无障碍物遮挡时才触发,是"看见玩家"的核心传感器。
|
||||
/// </summary>
|
||||
public const string Sight = "sight";
|
||||
}
|
||||
}
|
||||
|
||||
107
Assets/_Game/Scripts/Enemies/Perception/SightBatchSystem.cs
Normal file
107
Assets/_Game/Scripts/Enemies/Perception/SightBatchSystem.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Enemies.Perception
|
||||
{
|
||||
/// <summary>
|
||||
/// 场景级 Sight 槽位批量调度器(Phase 3 性能优化)。
|
||||
///
|
||||
/// <b>设计意图:</b>
|
||||
/// Sight 槽位内置多点 LOS 射线检测,当场景中存在大量敌人时(10+ 个),
|
||||
/// 每帧同步刷新所有 Sight 槽位会产生明显的 CPU 峰值。
|
||||
/// 本组件将全局 Sight 更新量限制在 <see cref="maxSystemsPerFrame"/> 个/帧,
|
||||
/// 通过轮询方式公平分配每帧的 Sight 预算,把瞬时峰值摊平到多帧。
|
||||
///
|
||||
/// <b>使用方式:</b>
|
||||
/// 1. 在场景中添加一个空 GameObject,挂载本组件(建议放在 Managers 节点下)。
|
||||
/// 2. 本组件自动在 <c>DefaultExecutionOrder(-50)</c> 执行,比默认 PhysicsPerceptionSystem 早。
|
||||
/// 3. 当本组件存在时,PhysicsPerceptionSystem.FixedUpdate() 会跳过所有 Sight 槽位的更新,
|
||||
/// 由本组件统一调度(优雅降级:本组件不存在时 Sight 每帧正常更新)。
|
||||
///
|
||||
/// <b>性能参考(测试用):</b>
|
||||
/// • maxSystemsPerFrame = 4,60fps,20 个敌人 → 平均每帧 4 × losRayCount 次射线,
|
||||
/// 每个敌人 Sight 刷新周期约 5 帧(~83ms),适合慢速视线感应。
|
||||
/// • 增大此值可减少延迟,减小此值可进一步降低每帧开销。
|
||||
/// </summary>
|
||||
[AddComponentMenu("BaseGames/Enemies/Sight Batch System")]
|
||||
[DefaultExecutionOrder(-50)]
|
||||
public sealed class SightBatchSystem : MonoBehaviour
|
||||
{
|
||||
// ── 单例 ──────────────────────────────────────────────────────────────
|
||||
|
||||
public static SightBatchSystem Instance { get; private set; }
|
||||
|
||||
// ── 配置 ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Min(1)]
|
||||
[Tooltip("每帧最多更新多少个 PhysicsPerceptionSystem 的 Sight 槽位。\n" +
|
||||
"建议值:场景敌人数 / 5(使平均刷新延迟 ≈ 5 帧)。\n" +
|
||||
"值越大 = 延迟越低但 CPU 峰值越高;值越小反之。")]
|
||||
[SerializeField] private int maxSystemsPerFrame = 4;
|
||||
|
||||
// ── 内部状态 ──────────────────────────────────────────────────────────
|
||||
|
||||
private readonly List<PhysicsPerceptionSystem> _registrants =
|
||||
new List<PhysicsPerceptionSystem>(32);
|
||||
|
||||
private int _offset;
|
||||
|
||||
// ── 单例生命周期 ──────────────────────────────────────────────────────
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this)
|
||||
{
|
||||
Debug.LogWarning("[SightBatchSystem] 场景中存在多个 SightBatchSystem,将销毁多余实例。", this);
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (Instance == this) Instance = null;
|
||||
}
|
||||
|
||||
// ── 注册 / 注销 ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>PhysicsPerceptionSystem.Awake() 自动调用。</summary>
|
||||
public void Register(PhysicsPerceptionSystem system)
|
||||
{
|
||||
if (system == null || _registrants.Contains(system)) return;
|
||||
_registrants.Add(system);
|
||||
}
|
||||
|
||||
/// <summary>PhysicsPerceptionSystem.OnDestroy() 自动调用。O(1) 交换删除。</summary>
|
||||
public void Unregister(PhysicsPerceptionSystem system)
|
||||
{
|
||||
int idx = _registrants.IndexOf(system);
|
||||
if (idx < 0) return;
|
||||
int last = _registrants.Count - 1;
|
||||
_registrants[idx] = _registrants[last];
|
||||
_registrants.RemoveAt(last);
|
||||
|
||||
// 防止 _offset 越界
|
||||
if (_offset >= _registrants.Count)
|
||||
_offset = 0;
|
||||
}
|
||||
|
||||
// ── 调度 ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void FixedUpdate()
|
||||
{
|
||||
int total = _registrants.Count;
|
||||
if (total == 0) return;
|
||||
|
||||
int budget = Mathf.Min(maxSystemsPerFrame, total);
|
||||
for (int i = 0; i < budget; i++)
|
||||
{
|
||||
int idx = (_offset + i) % total;
|
||||
_registrants[idx].ExecuteSightSlots();
|
||||
}
|
||||
|
||||
_offset = (_offset + budget) % total;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 89145f6fbc97f53419fa3ce81fcb6342
|
||||
guid: c4da817926ac7c741a2c33ec552b249e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
Reference in New Issue
Block a user