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

@@ -39,6 +39,10 @@ namespace BaseGames.Enemies
[SerializeField] protected EnemyFeedback _feedback;
[SerializeField] protected HurtBox _hurtBox;
[Header("区域(可选)")]
[Tooltip("地图固定巡逻/追击区域;配置后 BD_ChasePlayer 以区域边界替代 MaxChaseDistanceBD_ReturnToHome 归位至区域中心。留空则沿用出生点 + MaxChaseDistance 旧逻辑。")]
[SerializeField] private EnemyPatrolZone _patrolZone;
[Header("事件频道")]
[SerializeField] private BaseGames.Core.Events.StringEventChannelSO _onEnemyDied;
/// <summary>
@@ -101,6 +105,13 @@ namespace BaseGames.Enemies
/// </summary>
public Vector2 LastKnownPlayerPosition { get; set; }
/// <summary>
/// 地图固定巡逻/追击区域(可选)。
/// 配置后 BD_ChasePlayer 以区域边界为追击上限BD_ReturnToHome 归位至区域中心。
/// 未配置时退回旧逻辑HomePosition + MaxChaseDistance
/// </summary>
public EnemyPatrolZone PatrolZone => _patrolZone;
#if UNITY_EDITOR
[Header("── 运行时调试(仅 Editor──")]
[SerializeField] private EnemyStateType _dbg_CurrentState;
@@ -212,9 +223,9 @@ namespace BaseGames.Enemies
private readonly EnemyAbilityRegistry _abilities = new EnemyAbilityRegistry();
/// <summary>由 _onPlayerSpawned 事件缓存的玩家 Transform供 BD 任务读取。</summary>
public Transform PlayerTransform => _playerTransform;
/// <summary>感知 HubSensorToolkit;供 QuotaManager 暂停/恢复 Sensor 使用。</summary>
/// <summary>感知 Hub BD 任务及 QuotaManager 暂停/恢复感知使用。</summary>
public Perception.IPerceptionSystem SensorHub => _sensorHub;
private Perception.EnemySensorHub _sensorHub;
private Perception.IPerceptionSystem _sensorHub;
/// <summary>威胁评估器(可选):为原始 LOS 结果叠加反应延迟,使感知更自然。</summary>
public Perception.EnemyThreatAssessor ThreatAssessor => _threatAssessor;
private Perception.EnemyThreatAssessor _threatAssessor;
@@ -275,26 +286,6 @@ namespace BaseGames.Enemies
public virtual bool IsPlayerInRange(float range)
=> _stats != null && _stats.SqrDistanceToPlayer <= range * range;
/// <summary>
/// 检查玩家是否在感知范围内。若 <see cref="EnemyStatsSO.DetectAngleDeg"/> > 0
/// 同时验证玩家是否在自身朝向的扇形角度内。
/// </summary>
public virtual bool IsPlayerInDetectRange()
{
if (_stats == null || _playerTransform == null) return false;
float detectRange = _statsSO != null ? _statsSO.DetectRange : 6f;
if (_stats.SqrDistanceToPlayer > detectRange * detectRange) return false;
float angleDeg = _statsSO?.DetectAngleDeg ?? 0f;
if (angleDeg <= 0f) return true; // 0 = 关闭方向限制
Vector2 toPlayer = ((Vector2)_playerTransform.position - (Vector2)transform.position).normalized;
float facingDir = _movement != null ? _movement.FacingDirection : 1f;
var forward = new Vector2(facingDir, 0f);
float angle = Vector2.Angle(forward, toPlayer);
return angle <= angleDeg;
}
/// <summary>原始视线检测结果BatchLOSSystem 写入,无感知延迟修正)。</summary>
public bool HasLineOfSight => _losResult;
@@ -309,6 +300,23 @@ namespace BaseGames.Enemies
_movement.PendingInput.FaceDir = 0;
}
/// <summary>朝向世界坐标点(通过输入信号,下一 FixedUpdate 消费)。</summary>
public void FaceTarget(Vector2 worldPos)
{
if (_movement == null) return;
_movement.PendingInput.WantFace = true;
_movement.PendingInput.FaceTargetPos = worldPos;
_movement.PendingInput.FaceDir = 0;
}
/// <summary>直接指定朝向方向(+1 右 / -1 左,通过输入信号)。</summary>
public void FaceDirection(int dir)
{
if (_movement == null) return;
_movement.PendingInput.WantFace = true;
_movement.PendingInput.FaceDir = dir;
}
/// <summary>
/// 搜查"环顾"子步骤:停止移动,播放原地环顾动画。
/// 由搜查行为触发;动画细节由角色自己决定,外部无需感知 AnimConfig。
@@ -542,7 +550,7 @@ namespace BaseGames.Enemies
_nav = GetComponent<IPathAgent>() ?? new NullPathAgent();
if (_movement == null) _movement = GetComponent<EnemyMovement>();
_poiseSource = GetComponent<IPoiseSource>();
_sensorHub = GetComponentInChildren<Perception.EnemySensorHub>();
_sensorHub = GetComponentInChildren<Perception.IPerceptionSystem>();
_statusEffects = GetComponent<StatusEffects.EnemyStatusEffectManager>();
_threatAssessor = GetComponent<Perception.EnemyThreatAssessor>();
_pooledObject = GetComponent<PooledObject>();
@@ -782,43 +790,8 @@ namespace BaseGames.Enemies
#if UNITY_EDITOR
if (_statsSO == null) return;
// ── 侦测范围(淡橙;若配置扇形角则绘制扇形弧)+ 攻击范围(淡红圆)────
{
var c = new Vector3(transform.position.x, transform.position.y, 0f);
var prevM = UnityEditor.Handles.matrix;
UnityEditor.Handles.matrix = Matrix4x4.identity;
// 攻击范围(全圆)
UnityEditor.Handles.color = new Color(1f, 0.15f, 0.15f, 0.15f);
UnityEditor.Handles.DrawSolidDisc(c, Vector3.back, _statsSO.AttackRange);
UnityEditor.Handles.color = new Color(1f, 0.15f, 0.15f, 0.55f);
UnityEditor.Handles.DrawWireDisc(c, Vector3.back, _statsSO.AttackRange);
// 侦测范围
float angleDeg = _statsSO.DetectAngleDeg;
if (angleDeg > 0f)
{
// 扇形感知:绘制弧形扇区
float facing = Application.isPlaying && _movement != null ? _movement.FacingDirection : 1f;
Vector3 forward3 = new Vector3(facing, 0f, 0f);
UnityEditor.Handles.color = new Color(1f, 0.6f, 0.1f, 0.12f);
UnityEditor.Handles.DrawSolidArc(c, Vector3.back, forward3, angleDeg, _statsSO.DetectRange);
UnityEditor.Handles.DrawSolidArc(c, Vector3.back, forward3, -angleDeg, _statsSO.DetectRange);
UnityEditor.Handles.color = new Color(1f, 0.6f, 0.1f, 0.6f);
UnityEditor.Handles.DrawWireArc(c, Vector3.back, forward3, angleDeg, _statsSO.DetectRange);
UnityEditor.Handles.DrawWireArc(c, Vector3.back, forward3, -angleDeg, _statsSO.DetectRange);
}
else
{
// 全圆感知
UnityEditor.Handles.color = new Color(1f, 0.6f, 0.1f, 0.15f);
UnityEditor.Handles.DrawSolidDisc(c, Vector3.back, _statsSO.DetectRange);
UnityEditor.Handles.color = new Color(1f, 0.6f, 0.1f, 0.55f);
UnityEditor.Handles.DrawWireDisc(c, Vector3.back, _statsSO.DetectRange);
}
UnityEditor.Handles.matrix = prevM;
}
// 感知范围圆形 Gizmo 由 PhysicsPerceptionSystemEditor [DrawGizmo] 统一绘制,
// 此处不重复绘制,避免叠加覆盖导致 gizmoColor 设置无效。
// ── 运行时AI 状态标签(常态可见,无需选中)────────────────
if (Application.isPlaying)
@@ -843,10 +816,14 @@ namespace BaseGames.Enemies
// ── 运行时LOS 连线 ────────────────────────────────────────
if (!Application.isPlaying || _playerTransform == null) return;
float drawDetectRange = _sensorHub != null
? _sensorHub.GetSensorRadius(Perception.SensorSlotNames.Aggro)
: -1f;
Vector3 eyeWorld = transform.position + new Vector3(_statsSO.EyeOffset.x, _statsSO.EyeOffset.y, 0f);
Vector3 playerPos = _playerTransform.position;
float sqrDist = (playerPos - transform.position).sqrMagnitude;
bool inRange = sqrDist <= _statsSO.DetectRange * _statsSO.DetectRange;
bool inRange = drawDetectRange >= 0f && sqrDist <= drawDetectRange * drawDetectRange;
// 眼睛位置小圆点(金黄)
Gizmos.color = new Color(1f, 0.9f, 0.2f, 0.85f);
@@ -867,24 +844,8 @@ namespace BaseGames.Enemies
#if UNITY_EDITOR
if (_statsSO == null) return;
// 选中时加亮范围圆
{
var c = new Vector3(transform.position.x, transform.position.y, 0f);
var prevM = UnityEditor.Handles.matrix;
UnityEditor.Handles.matrix = Matrix4x4.identity;
UnityEditor.Handles.color = new Color(1f, 0.6f, 0.1f, 0.25f);
UnityEditor.Handles.DrawSolidDisc(c, Vector3.back, _statsSO.DetectRange);
UnityEditor.Handles.color = new Color(1f, 0.6f, 0.1f, 0.90f);
UnityEditor.Handles.DrawWireDisc(c, Vector3.back, _statsSO.DetectRange);
UnityEditor.Handles.color = new Color(1f, 0.2f, 0.2f, 0.25f);
UnityEditor.Handles.DrawSolidDisc(c, Vector3.back, _statsSO.AttackRange);
UnityEditor.Handles.color = new Color(1f, 0.2f, 0.2f, 0.90f);
UnityEditor.Handles.DrawWireDisc(c, Vector3.back, _statsSO.AttackRange);
UnityEditor.Handles.matrix = prevM;
}
// 感知范围圆形 Gizmo 由 PhysicsPerceptionSystemEditor [DrawGizmo] 统一绘制,
// 此处不重复绘制。
// 运行时:选中时绘制 AiPhase 彩色外圆(突出显示当前状态)
if (Application.isPlaying)