- Implemented HurtBoxOwnerGuard to ensure that multiple HurtBoxes on the same character do not register damage multiple times during a single HitBox activation. - Added custom editor for HitBox to facilitate the creation of shape colliders with HitBoxColliderProxy. - Introduced PhysicsPerceptionSystem for enemy perception, supporting multiple detection modes including RangeCircle, BatchLOS, FanCast, and BoxCast. - Created EnemyPatrolZone to define patrol and chase areas for enemies, allowing for shared zones among multiple enemies. - Added BD_IsOutsideZone conditional task for Behavior Designer to check if an enemy or player is outside a defined patrol zone.
300 lines
14 KiB
C#
300 lines
14 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using UnityEngine;
|
||
|
||
namespace BaseGames.Enemies.Perception
|
||
{
|
||
/// <summary>
|
||
/// 敌人感知系统(自研纯物理实现)。
|
||
/// 每个 <see cref="PerceptionSlot"/> 独立配置并独立运行,支持四种检测模式:
|
||
/// • RangeCircle — Physics2D.OverlapCircleNonAlloc(可选 LOS 视线遮挡校验)
|
||
/// • BatchLOS — 委托 EnemyBase.IsPlayerVisible()(BatchLOSSystem 批量射线)
|
||
/// • FanCast — 以朝向为轴的扇形多射线视野(支持遮挡层)
|
||
/// • BoxCast — 矩形区域重叠检测(X 偏移随 localScale.x 自动翻转)
|
||
///
|
||
/// EnemyBase.Awake() 通过 GetComponentInChildren<IPerceptionSystem>()
|
||
/// 自动发现本组件,无需修改 EnemyBase。
|
||
/// 槽位名称常量统一定义于 <see cref="SensorSlotNames"/>。
|
||
/// </summary>
|
||
[DisallowMultipleComponent]
|
||
public sealed class PhysicsPerceptionSystem : MonoBehaviour, IPerceptionSystem
|
||
{
|
||
// ── 槽位类型 ──────────────────────────────────────────────────────────
|
||
|
||
public enum SlotType
|
||
{
|
||
/// <summary>Physics2D 圆形重叠检测</summary>
|
||
RangeCircle,
|
||
/// <summary>委托 EnemyBase.IsPlayerVisible()(BatchLOSSystem 批量射线视线检测)</summary>
|
||
BatchLOS,
|
||
/// <summary>以朝向为轴的扇形射线视野,遮挡层阻断视线</summary>
|
||
FanCast,
|
||
/// <summary>矩形区域重叠检测,X 偏移随 localScale.x 自动翻转</summary>
|
||
BoxCast
|
||
}
|
||
|
||
// ── 槽位定义 ──────────────────────────────────────────────────────────
|
||
|
||
[Serializable]
|
||
public struct PerceptionSlot
|
||
{
|
||
[Tooltip("槽位名称,与 SensorSlotNames 常量保持一致\n(aggro / los / attack_melee / attack_range)")]
|
||
public string slotName;
|
||
|
||
[Tooltip("RangeCircle:Physics2D 圆形范围检测\nBatchLOS:视线射线检测(BatchLOSSystem)\nFanCast:以朝向为轴的扇形射线视野\nBoxCast:矩形区域重叠检测")]
|
||
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,不影响实际射线。")]
|
||
public Vector2 offset;
|
||
|
||
[Header("FanCast")]
|
||
[Tooltip("FanCast:扇形张角(度),以朝向为中轴左右均匀展开")]
|
||
public float fanAngle;
|
||
|
||
[Tooltip("FanCast:扇形内均匀分布的射线数量(建议 5–11 条)")]
|
||
[Min(2)]
|
||
public int fanRayCount;
|
||
|
||
[Header("BoxCast")]
|
||
[Tooltip("BoxCast:检测框尺寸 (宽, 高),单位米")]
|
||
public Vector2 boxSize;
|
||
|
||
[Tooltip("BoxCast:相对于感知中心的偏移,X 分量随 localScale.x 自动翻转")]
|
||
public Vector2 boxOffset;
|
||
|
||
[Header("Gizmos")]
|
||
[Tooltip("Scene 视图 Gizmo 颜色。留空(全透明黑色)时自动使用紫色回退,以确保可见。")]
|
||
public Color gizmoColor;
|
||
}
|
||
|
||
// ── 字段 ──────────────────────────────────────────────────────────────
|
||
|
||
[SerializeField] private PerceptionSlot[] _slots;
|
||
|
||
private readonly Dictionary<string, List<GameObject>> _detected =
|
||
new Dictionary<string, List<GameObject>>();
|
||
private readonly Collider2D[] _overlapBuffer = new Collider2D[32];
|
||
private bool _suspended;
|
||
private EnemyBase _owner;
|
||
|
||
// ── Unity 生命周期 ────────────────────────────────────────────────────
|
||
|
||
private void Awake()
|
||
{
|
||
_owner = GetComponentInParent<EnemyBase>();
|
||
if (_slots == null) return;
|
||
foreach (var slot in _slots)
|
||
{
|
||
if (!string.IsNullOrEmpty(slot.slotName) && !_detected.ContainsKey(slot.slotName))
|
||
_detected[slot.slotName] = new List<GameObject>(4);
|
||
}
|
||
}
|
||
|
||
private void FixedUpdate()
|
||
{
|
||
if (_suspended || _slots == null) return;
|
||
foreach (var slot in _slots)
|
||
RefreshSlot(slot);
|
||
}
|
||
|
||
// ── 内部检测逻辑 ──────────────────────────────────────────────────────
|
||
|
||
private void RefreshSlot(PerceptionSlot slot)
|
||
{
|
||
if (string.IsNullOrEmpty(slot.slotName)) return;
|
||
if (!_detected.TryGetValue(slot.slotName, out var list)) return;
|
||
list.Clear();
|
||
|
||
switch (slot.type)
|
||
{
|
||
case SlotType.BatchLOS:
|
||
if (_owner != null && _owner.IsPlayerVisible() && _owner.PlayerTransform != null)
|
||
{
|
||
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);
|
||
}
|
||
break;
|
||
|
||
case SlotType.RangeCircle:
|
||
RefreshRangeCircle(slot, list);
|
||
break;
|
||
|
||
case SlotType.FanCast:
|
||
RefreshFanCast(slot, list);
|
||
break;
|
||
|
||
case SlotType.BoxCast:
|
||
RefreshBoxCast(slot, list);
|
||
break;
|
||
}
|
||
}
|
||
|
||
private void RefreshRangeCircle(in PerceptionSlot slot, System.Collections.Generic.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.requireLOS && !HasLineOfSight(origin, col.gameObject, slot.losBlockMask)) continue;
|
||
list.Add(col.gameObject);
|
||
}
|
||
}
|
||
|
||
private void RefreshFanCast(in PerceptionSlot slot, System.Collections.Generic.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);
|
||
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++)
|
||
{
|
||
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);
|
||
}
|
||
}
|
||
|
||
private void RefreshBoxCast(in PerceptionSlot slot, System.Collections.Generic.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;
|
||
if (slot.requireLOS && !HasLineOfSight(origin, col.gameObject, slot.losBlockMask)) continue;
|
||
list.Add(col.gameObject);
|
||
}
|
||
}
|
||
|
||
private bool HasLineOfSight(Vector2 origin, GameObject target, LayerMask blockMask)
|
||
{
|
||
// 优先使用碰撞体包围盒中心(比 transform.position 更准确,避免偏心轴误判)
|
||
Vector2 targetPos;
|
||
var col = target.GetComponent<Collider2D>();
|
||
targetPos = col != null ? (Vector2)col.bounds.center : (Vector2)target.transform.position;
|
||
|
||
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;
|
||
}
|
||
|
||
private static Vector2 RotateVector(Vector2 v, float angleDeg)
|
||
{
|
||
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);
|
||
}
|
||
|
||
// ── 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)
|
||
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
|
||
}
|
||
}
|