Files
zeling_v2/Assets/_Game/Scripts/Enemies/Perception/PhysicsPerceptionSystem.cs
Joywayer 06048c966a feat: Add HurtBoxOwnerGuard to prevent multiple damage registrations from the same HitBox activation
- 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.
2026-06-02 16:10:44 +08:00

300 lines
14 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"/> 独立配置并独立运行,支持四种检测模式:
/// • RangeCircle — Physics2D.OverlapCircleNonAlloc可选 LOS 视线遮挡校验)
/// • BatchLOS — 委托 EnemyBase.IsPlayerVisible()BatchLOSSystem 批量射线)
/// • FanCast — 以朝向为轴的扇形多射线视野(支持遮挡层)
/// • BoxCast — 矩形区域重叠检测X 偏移随 localScale.x 自动翻转)
///
/// EnemyBase.Awake() 通过 GetComponentInChildren&lt;IPerceptionSystem&gt;()
/// 自动发现本组件,无需修改 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 常量保持一致\naggro / los / attack_melee / attack_range")]
public string slotName;
[Tooltip("RangeCirclePhysics2D 圆形范围检测\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\nFanCasttrue = 射线被 losBlockMask 层遮挡false = 穿透所有障碍物")]
public bool requireLOS;
[Tooltip("requireLOS = true / FanCastrequireLOS = 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扇形内均匀分布的射线数量建议 511 条)")]
[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
}
}