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.
This commit is contained in:
2026-06-02 16:10:44 +08:00
parent bcd8b0e90b
commit 06048c966a
47 changed files with 1912 additions and 1195 deletions

View File

@@ -1,89 +0,0 @@
using System.Collections.Generic;
using UnityEngine;
using Micosmo.SensorToolkit;
namespace BaseGames.Enemies.Perception
{
/// <summary>
/// 敌人感知 Hub架构 07_EnemyModule §9
/// 集中暴露挂载在敌人 Prefab 上的各种 SensorToolkit SensorBD 任务通过
/// 字符串槽位查询,避免在 BD 任务 Inspector 中拖具体 Sensor 引用。
///
/// 典型槽位命名约定:
/// - "aggro" : RangeSensor2D玩家入侵警戒圈
/// - "attack_melee" : RangeSensor2D近战触发距离
/// - "attack_range" : RangeSensor2D远程触发距离
/// - "los" : LOSSensor2D视线
/// - "wall_ahead" : RaySensor2D前方墙体检测
/// - "ledge" : RaySensor2D前方悬崖检测
/// </summary>
[DisallowMultipleComponent]
public sealed class EnemySensorHub : MonoBehaviour, IPerceptionSystem
{
[System.Serializable]
public struct SensorSlot
{
public string slotName;
public Sensor sensor;
}
[SerializeField] private SensorSlot[] _slots;
private Dictionary<string, Sensor> _map;
private void Awake()
{
_map = new Dictionary<string, Sensor>(_slots?.Length ?? 0);
if (_slots == null) return;
for (int i = 0; i < _slots.Length; i++)
{
var s = _slots[i];
if (s.sensor != null && !string.IsNullOrEmpty(s.slotName))
_map[s.slotName] = s.sensor;
}
}
public Sensor Get(string slotName)
{
if (_map == null || string.IsNullOrEmpty(slotName)) return null;
_map.TryGetValue(slotName, out var s);
return s;
}
public bool IsDetecting(string slotName, GameObject target)
{
var s = Get(slotName);
return s != null && target != null && s.IsDetected(target);
}
public bool HasAnyDetection(string slotName)
{
var s = Get(slotName);
if (s == null) return false;
foreach (var _ in s.Detections) return true;
return false;
}
public GameObject GetFirstDetection(string slotName)
{
var s = Get(slotName);
if (s == null) return null;
foreach (var go in s.Detections) return go;
return null;
}
/// <summary>
/// 暂停或恢复所有插槽的 Sensor。
/// 当敌人超出 QuotaManager 活跃范围时调用(关闭),归入活跃范围时恢复(开启)。
/// </summary>
public void SetSuspended(bool suspended)
{
if (_slots == null) return;
for (int i = 0; i < _slots.Length; i++)
{
var sensor = _slots[i].sensor;
if (sensor != null) sensor.enabled = !suspended;
}
}
}
}

View File

@@ -4,10 +4,15 @@ namespace BaseGames.Enemies.Perception
{
/// <summary>
/// 敌人感知系统接口。
/// EnemyBase 通过此接口与感知实现解耦,支持运行时替换SensorToolkit / 自定义实现)
/// EnemyBase 通过此接口与感知实现解耦,支持运行时替换。
/// 当前实现为 <see cref="PhysicsPerceptionSystem"/>(纯物理射线 / 圆形范围检测)。
/// 若未来替换底层传感器实现,只需重新实现此接口,上层代码无需改动。
/// </summary>
public interface IPerceptionSystem
{
/// <summary>指定槽位是否已配置(用于运行前的能力检测,避免无效查询)。</summary>
bool HasSlot(string slotName);
/// <summary>指定槽位是否检测到任意目标。</summary>
bool HasAnyDetection(string slotName);
@@ -17,6 +22,20 @@ namespace BaseGames.Enemies.Perception
/// <summary>返回指定槽位第一个检测到的对象,无检测则返回 null。</summary>
GameObject GetFirstDetection(string slotName);
/// <summary>
/// 返回指定槽位感知区域的半径(圆形区域)。
/// 槽位不存在、非圆形区域或实现不支持时返回 -1。
/// 主要供编辑器 Gizmos 绘制使用。
/// </summary>
float GetSensorRadius(string slotName);
/// <summary>
/// 返回指定槽位检测原点相对于感知组件 transform 的偏移X 分量已根据朝向翻转)。
/// 槽位不存在时返回 <see cref="Vector2.zero"/>。
/// 供 EnemyBase.OnDrawGizmos 定位各感知圆心使用,避免所有圆重叠在 transform.position。
/// </summary>
Vector2 GetSensorOffset(string slotName);
/// <summary>暂停或恢复感知系统LOD / 超出活跃范围时调用)。</summary>
void SetSuspended(bool suspended);
}

View File

@@ -0,0 +1,299 @@
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
}
}

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 58cb3ac0e49c151429cad39d3e164a3d
guid: c0026fe36cfaffc4e95698bccd0a8380
MonoImporter:
externalObjects: {}
serializedVersion: 2

View File

@@ -1,43 +1,33 @@
namespace BaseGames.Enemies.Perception
{
/// <summary>
/// <see cref="EnemySensorHub"/> 槽位名称常量。
/// <see cref="PhysicsPerceptionSystem"/> 槽位名称常量。
///
/// 统一定义字符串键,避免在 BD Task Inspector 和代码中散布魔法字符串。
/// Prefab 上 EnemySensorHub 组件的 slotName 字段必须与此处常量保持一致。
/// Prefab 上 <see cref="PhysicsPerceptionSystem"/> 组件的 slotName 字段必须与此处常量保持一致。
/// </summary>
public static class SensorSlotNames
{
/// <summary>
/// 警戒范围RangeSensor2D):玩家进入此圈触发 Alert 阶段。
/// 警戒范围RangeCircle):玩家进入此圈触发 Alert 阶段。
/// 通常半径大于攻击范围,小于视线检测范围。
/// </summary>
public const string Aggro = "aggro";
/// <summary>
/// 视线检测(LOSSensor2D):敌我之间无遮挡时持续为 true。
/// 视线检测(BatchLOS):敌我之间无遮挡时持续为 true。
/// 由 BatchLOSSystem 批量计算BD_IsPlayerVisible 读取结果。
/// </summary>
public const string LOS = "los";
/// <summary>
/// 近战攻击范围RangeSensor2D):玩家进入时触发近战攻击条件。
/// 近战攻击范围RangeCircle):玩家进入时触发近战攻击条件。
/// </summary>
public const string AttackMelee = "attack_melee";
/// <summary>
/// 远程攻击范围RangeSensor2D):玩家进入时触发远程攻击条件。
/// 远程攻击范围RangeCircle):玩家进入时触发远程攻击条件。
/// </summary>
public const string AttackRange = "attack_range";
/// <summary>
/// 前方墙体RaySensor2D水平方向检测用于巡逻转向。
/// </summary>
public const string WallAhead = "wall_ahead";
/// <summary>
/// 前方悬崖RaySensor2D斜向下检测地面是否存在用于巡逻转向。
/// </summary>
public const string Ledge = "ledge";
}
}