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

@@ -79,6 +79,11 @@ namespace BaseGames.Enemies.AI
Vector2 playerPos = _enemy.PlayerTransform.position;
// 若配置了巡逻区域,且玩家超出追击边界 → 放弃追击(优先级高于纯距离限制)
var zone = _enemy.PatrolZone;
if (zone != null && !zone.ContainsChase(playerPos))
return TaskStatus.Failure;
if (_enemy.IsPlayerVisible())
{
// 视线恢复Searching → Tracking恢复奔跑速度

View File

@@ -11,7 +11,7 @@ namespace BaseGames.Enemies.AI
/// </summary>
[TaskName("Is Near Edge?")]
[TaskCategory("BaseGames/Enemy/State")]
[TaskDescription("检查前方是否有悬崖边缘(基于 SensorToolkit 或 Raycast")]
[TaskDescription("检查前方是否有悬崖边缘(基于 EnemyNavAgent Raycast 检测")]
public class BD_IsNearEdge : Conditional
{
private EnemyBase _enemy;

View File

@@ -0,0 +1,61 @@
#if GRAPH_DESIGNER
using UnityEngine;
using Opsive.BehaviorDesigner.Runtime.Tasks;
using Opsive.BehaviorDesigner.Runtime.Tasks.Conditionals;
using BaseGames.Enemies;
namespace BaseGames.Enemies.AI
{
/// <summary>
/// BD Conditional判断目标坐标是否超出指定区域边界。
///
/// <list type="bullet">
/// <item>未配置 PatrolZone 时返回 Failure表示"无限制",等同于不超界)。</item>
/// <item>超界 → Success区域内 → Failure。</item>
/// </list>
///
/// 典型用法:在 Patrol BT 子树中用 BD_IsOutsideZone 检查敌人坐标,
/// 超出巡逻区域时触发归位序列。
/// </summary>
[TaskName("Is Outside Zone")]
[TaskCategory("BaseGames/Enemy/Zone")]
[TaskDescription("判断敌人/玩家坐标是否超出巡逻或追击区域;无 Zone 时返回 Failure不限制")]
public sealed class BD_IsOutsideZone : Conditional
{
[Tooltip("true = 检查追击区域false = 检查巡逻区域")]
[SerializeField] private bool m_CheckChaseZone = false;
[Tooltip("true = 检查敌人自身坐标false = 检查玩家坐标")]
[SerializeField] private bool m_CheckEnemy = true;
private EnemyBase _enemy;
public override void OnAwake() => _enemy = gameObject.GetComponent<EnemyBase>();
public override TaskStatus OnUpdate()
{
if (_enemy == null) return TaskStatus.Failure;
var zone = _enemy.PatrolZone;
if (zone == null) return TaskStatus.Failure; // 无区域 = 不限制
Vector2 pos;
if (m_CheckEnemy)
{
pos = _enemy.transform.position;
}
else
{
if (_enemy.PlayerTransform == null) return TaskStatus.Failure;
pos = _enemy.PlayerTransform.position;
}
bool inside = m_CheckChaseZone
? zone.ContainsChase(pos)
: zone.ContainsPatrol(pos);
return inside ? TaskStatus.Failure : TaskStatus.Success;
}
}
}
#endif

View File

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

View File

@@ -7,23 +7,23 @@ using BaseGames.Enemies.Perception;
namespace BaseGames.Enemies.AI
{
/// <summary>
/// 条件:EnemySensorHub 中名为 slotName 的 Sensor 是否检测到目标。
/// 条件:<see cref="IPerceptionSystem"/> 中名为 slotName 的槽位是否检测到目标。
/// 若 m_Target 为空则使用 EnemyBase.PlayerTransform。
/// </summary>
[TaskName("Is Sensor Detecting?")]
[TaskCategory("BaseGames/Enemy/Perception")]
[TaskDescription("检查 EnemySensorHub 中指定 Sensor 槽是否检测到目标")]
[TaskDescription("检查 PhysicsPerceptionSystem 中指定 Sensor 槽是否检测到目标")]
public sealed class BD_IsSensorDetecting : Conditional
{
[SerializeField] private string m_SlotName = "aggro";
[SerializeField] private bool m_AnyTarget = false;
private EnemySensorHub _hub;
private EnemyBase _enemy;
private IPerceptionSystem _hub;
private EnemyBase _enemy;
public override void OnAwake()
{
_hub = gameObject.GetComponent<EnemySensorHub>();
_hub = gameObject.GetComponent<IPerceptionSystem>();
_enemy = gameObject.GetComponent<EnemyBase>();
}

View File

@@ -3,44 +3,38 @@ using UnityEngine;
using Opsive.BehaviorDesigner.Runtime.Tasks;
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
using BaseGames.Enemies;
using BaseGames.Enemies.Perception;
namespace BaseGames.Enemies.AI
{
/// <summary>
/// BD Action来回踱步巡逻——持续向当前方向移动遇墙或悬崖时自动翻转方向。
/// 转向检测依赖 EnemySensorHub 的 "wall_ahead" / "ledge" 槽SensorToolkit
/// 转向检测通过 <see cref="EnemyMovement.IsWallAhead"/> / <see cref="EnemyMovement.IsLedgeAhead"/>
/// 进行;这两项是 EnemyMovement 内置的物理射线检测不属于感知系统PhysicsPerceptionSystem
///
/// 若需要按预设路点顺序巡逻,请使用 <see cref="BD_PatrolWaypoints"/>(支持 Transform 引用和内联坐标)。
/// </summary>
[TaskName("Patrol (Pace)")]
[TaskCategory("BaseGames/Enemy/Movement")]
[TaskDescription("来回踱步巡逻:遇墙或悬崖自动翻转方向(需配置 EnemySensorHub wall_ahead / ledge 槽)")]
[TaskDescription("来回踱步巡逻:遇墙或悬崖自动翻转方向(通过 EnemyMovement 物理射线检测,无需感知槽)")]
public class BD_Patrol : Action
{
private EnemyBase _enemy;
private EnemySensorHub _hub;
private float _dir = 1f;
private float _flipCooldown; // 翻转后短暂冷却,等待 RaySensor2D 刷新到新朝向
// 缓存SensorHub 中对应槽位是否已配置Awake 时查询一次,避免每帧 Dictionary 查找)
private bool _hasWallSensor;
private bool _hasEdgeSensor;
private EnemyBase _enemy;
private EnemyMovement _movement;
private float _dir = 1f;
private float _flipCooldown; // 翻转后短暂冷却,等待射线刷新到新朝向
public override void OnAwake()
{
_enemy = GetComponent<EnemyBase>();
_hub = GetComponent<EnemySensorHub>();
_hasWallSensor = _hub != null && _hub.Get(SensorSlotNames.WallAhead) != null;
_hasEdgeSensor = _hub != null && _hub.Get(SensorSlotNames.Ledge) != null;
_enemy = GetComponent<EnemyBase>();
_movement = _enemy?.Movement;
}
public override void OnStart()
{
_enemy?.SetAiPhase(AiPhase.Patrol);
// 与敌人实际朝向同步,防止任务重入时 _dir 与朝向不符(如战斗后朝向已改变)
if (_enemy?.Movement != null)
_dir = _enemy.Movement.FacingDirection;
if (_movement != null)
_dir = _movement.FacingDirection;
_flipCooldown = 0f;
}
@@ -48,13 +42,13 @@ namespace BaseGames.Enemies.AI
{
if (_enemy == null) return TaskStatus.Failure;
// 翻转冷却期间跳过传感器检测(等待 RaySensor2D 在新朝向完成刷新)
// 翻转冷却期间跳过物理检测(等待射线在新朝向完成刷新)
if (_flipCooldown > 0f)
_flipCooldown -= Time.deltaTime;
else if (ShouldFlip())
{
_dir = -_dir;
_flipCooldown = 0.1f; // ~6 帧缓冲60 fps防止传感器残留信号导致抖动
_flipCooldown = 0.1f; // ~6 帧缓冲60 fps防止射线残留信号导致抖动
}
_enemy.MoveInDirection(_dir);
@@ -65,11 +59,9 @@ namespace BaseGames.Enemies.AI
private bool ShouldFlip()
{
// 转身进行中时不重复检测,防止 _dir 在转身期间被传感器残留信号反复翻转
if (_enemy.Movement != null && _enemy.Movement.IsTurning) return false;
bool wallHit = _hasWallSensor && _hub.HasAnyDetection(SensorSlotNames.WallAhead);
bool edgeHit = _hasEdgeSensor && _hub.HasAnyDetection(SensorSlotNames.Ledge);
return wallHit || edgeHit;
// 转身进行中时不重复检测,防止 _dir 在转身期间被残留信号反复翻转
if (_movement == null || _movement.IsTurning) return false;
return _movement.IsWallAhead || _movement.IsLedgeAhead;
}
}
}

View File

@@ -35,6 +35,7 @@ namespace BaseGames.Enemies.AI
private bool _reached;
private bool _pathFailed;
private bool _subscribed;
private Vector2 _returnTarget;
public override void OnAwake() => _enemy = gameObject.GetComponent<EnemyBase>();
@@ -54,7 +55,12 @@ namespace BaseGames.Enemies.AI
// 切换为行走速度
float walkSpeed = _enemy.Stats?.WalkSpeed ?? 2f;
_enemy.Nav?.SetSpeed(walkSpeed);
_enemy.MoveTo(_enemy.HomePosition);
// 优先归位到巡逻区域中心;无区域时退回出生点
_returnTarget = _enemy.PatrolZone != null
? _enemy.PatrolZone.PatrolCenter
: _enemy.HomePosition;
_enemy.MoveTo(_returnTarget);
}
public override TaskStatus OnUpdate()
@@ -66,7 +72,7 @@ namespace BaseGames.Enemies.AI
// 兜底距离判断(事件可能因帧序问题延迟一帧)
float radius = m_ArriveRadius > 0f ? m_ArriveRadius : (_enemy.Stats?.HomeRadius ?? 0.5f);
float sqr = ((Vector2)_enemy.transform.position - _enemy.HomePosition).sqrMagnitude;
float sqr = ((Vector2)_enemy.transform.position - _returnTarget).sqrMagnitude;
if (sqr <= radius * radius)
return CompleteReturn();

View File

@@ -13,8 +13,7 @@
"BaseGames.Enemies",
"BaseGames.Enemies.Boss.Patterns",
"Opsive.BehaviorDesigner.Runtime",
"Kybernetik.Animancer",
"Micosmo.SensorToolkit"
"Kybernetik.Animancer"
],
"autoReferenced": true,
"overrideReferences": false,

View File

@@ -1,7 +1,6 @@
using System.Collections;
using Animancer;
using UnityEngine;
using BaseGames.Enemies.Perception;
namespace BaseGames.Enemies.Abilities
{
@@ -18,7 +17,6 @@ namespace BaseGames.Enemies.Abilities
[Header("感知与接触伤害")]
[SerializeField] private BodyContactDamage _contactDamage;
[SerializeField] private EnemySensorHub _sensorHub;
[Tooltip("用于追击感知判断的传感器槽位名,通常为 \"aggro\"")]
[SerializeField] private string _aggroSlotName = "aggro";
@@ -36,7 +34,7 @@ namespace BaseGames.Enemies.Abilities
while (true)
{
if (_enemy.PlayerTransform == null) break;
if (_sensorHub != null && !_sensorHub.IsDetecting(_aggroSlotName, _enemy.PlayerTransform.gameObject))
if (_enemy.SensorHub != null && !_enemy.SensorHub.IsDetecting(_aggroSlotName, _enemy.PlayerTransform.gameObject))
break;
_enemy.MoveTo(_enemy.PlayerTransform.position);
yield return null;

View File

@@ -149,11 +149,11 @@ namespace BaseGames.Enemies.Abilities
protected virtual void OnInterrupted(InterruptReason reason) { }
/// <summary>子类辅助:朝向目标。委托给 EnemyMovement.FaceTarget 以保持转身动画系统一致。</summary>
/// <summary>子类辅助:朝向目标(写入输入信号,下一 FixedUpdate 由 EnemyMovement 消费)。</summary>
protected void FaceTarget(Transform target)
{
if (target == null || _enemy?.Movement == null) return;
_enemy.Movement.FaceTarget(target.position);
if (target == null || _enemy == null) return;
_enemy.FaceTarget(target.position);
}
}

View File

@@ -31,7 +31,7 @@ namespace BaseGames.Enemies.Abilities
{
Phase = AbilityRunState.Active;
_enemy.Movement?.FaceTarget(_enemy.PlayerTransform.position);
_enemy.FacePlayer();
if (_faceClip.Clip == null) yield break;

View File

@@ -12,7 +12,6 @@
"Opsive.BehaviorDesigner.Runtime",
"Unity.Addressables",
"Unity.ResourceManager",
"Micosmo.SensorToolkit",
"BaseGames.Parry"
],
"includePlatforms": [],

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)

View File

@@ -79,6 +79,7 @@ namespace BaseGames.Enemies
private System.Collections.Generic.List<string> BuildLines()
{
var list = new System.Collections.Generic.List<string>(8);
// 名称

View File

@@ -31,16 +31,36 @@ namespace BaseGames.Enemies
[Tooltip("动画配置 SO留空则在 Awake 时自动从 EnemyBase 读取")]
[SerializeField] private EnemyAnimationConfigSO _animConfig;
[Header("视觉节点")]
[Tooltip("包含 SpriteRenderer / AnimancerComponent 的子节点Visual设置后 Awake 自动将其 localPosition 对齐到 Collider2D offset使视觉中心与碰撞体中心重合。留空则不做偏移处理。")]
[SerializeField] private Transform _visualRoot;
[Tooltip("精灵资源本身的默认朝向1 = 右localScale.x 为正时面朝右),-1 = 左localScale.x 为正时面朝左)。如果美术资源绘制方向朝左,此值填 -1朝右填 1。大多数 Unity 项目美术朝右,默认值为 1。")]
[SerializeField] private int _spriteDefaultFacingDir = 1;
[Header("导航跳跃能力INavLinkHandler")]
[Tooltip("可处理的最大跳跃垂直高度(超出则让 TBM 兜底)")]
[SerializeField] private float _navJumpMaxHeight = 6f;
[Tooltip("可处理的最大跳跃水平距离")]
[SerializeField] private float _navJumpMaxDist = 10f;
[Tooltip("地面检测射线长度(用于判断跳跃是否落地)")]
[SerializeField] private float _groundCheckDist = 0.35f;
[Tooltip("用于确定射线起点宽度和底边的 Collider2D留空则 Awake 时自动查找")]
[SerializeField] private Collider2D _groundCheckCollider;
[Tooltip("从碰撞体底边向下的射线检测距离")]
[SerializeField] private float _groundCheckDist = 0.15f;
[Tooltip("射线数量1 = 仅中心,>1 时沿碰撞体底边均匀分布)")]
[SerializeField] [Min(1)] private int _groundCheckCount = 3;
[Tooltip("地面层 LayerMask")]
[SerializeField] private LayerMask _groundMask;
[Header("墙体 / 悬崖检测")]
[Tooltip("从碰撞体朝向前边缘水平发射的墙体检测距离0 = 禁用)")]
[SerializeField] private float _wallCheckDist = 0.2f;
[Tooltip("悬崖检测:从碰撞体前下角再向前偏移此距离后向下发射射线(用于检测脚边是否有地面)")]
[SerializeField] private float _ledgeCheckFwdOffset = 0.1f;
[Tooltip("悬崖检测:向下的射线长度;射线未命中地面则 IsLedgeAhead = true0 = 禁用)")]
[SerializeField] private float _ledgeCheckDownDist = 0.4f;
[Tooltip("墙体层 LayerMask留空时复用地面 LayerMask")]
[SerializeField] private LayerMask _wallMask;
private Rigidbody2D _rb;
private int _facingDir = 1;
private Coroutine _linkCoroutine;
@@ -54,10 +74,20 @@ namespace BaseGames.Enemies
public EnemyMoveInput PendingInput;
public bool IsGrounded { get; private set; }
/// <summary>前方是否有墙体。在 FixedUpdate 中更新,仅当 _wallCheckDist > 0 时有效。</summary>
public bool IsWallAhead { get; private set; }
/// <summary>前方是否有悬崖(脚边地面缺失)。在 FixedUpdate 中更新,仅当 _ledgeCheckDownDist > 0 时有效。</summary>
public bool IsLedgeAhead { get; private set; }
/// <summary>当前朝向1 = 右,-1 = 左。</summary>
public int FacingDirection => _facingDir;
/// <summary>当前是否正在播放转身动画(移动输入在此期间被屏蔽)。</summary>
public bool IsTurning => _isTurning;
/// <summary>
/// 当 PathBerserker2d TransformBasedMovement 正在直接驱动 transform.position 时由
/// <see cref="Navigation.EnemyNavAgent"/> 设为 true。
/// 此时 MoveHorizontal/MoveWithSpeed 仅更新朝向,不写 rb.velocity防止双重驱动冲突。
/// </summary>
public bool NavDriving { get; set; }
#if UNITY_EDITOR
[Header("── 运行时调试(仅 Editor──")]
@@ -65,7 +95,10 @@ namespace BaseGames.Enemies
[SerializeField] private float _dbg_VelocityX;
[SerializeField] private float _dbg_VelocityY;
[SerializeField] private bool _dbg_IsGrounded;
[SerializeField] private bool _dbg_IsWallAhead;
[SerializeField] private bool _dbg_IsLedgeAhead;
[SerializeField] private bool _dbg_IsTurning;
[SerializeField] private bool _dbg_NavDriving;
[Header("── 输入信号(仅 Editor──")]
[SerializeField] private float _dbg_Input_MoveDir;
[SerializeField] private float _dbg_Input_MoveSpeed;
@@ -147,36 +180,111 @@ namespace BaseGames.Enemies
onComplete?.Invoke();
}
private bool IsGroundedCheck() =>
Physics2D.Raycast(_rb.position, Vector2.down, _groundCheckDist, _groundMask);
private Vector2 GetGroundRayOrigin(int index)
{
// 优先用序列化字段,编辑器模式下 Awake 未执行时也能直接 GetComponent
var col = _groundCheckCollider != null ? _groundCheckCollider : GetComponent<Collider2D>();
if (col == null)
return (Vector2)transform.position;
Bounds b = col.bounds;
float x = _groundCheckCount <= 1
? b.center.x
: Mathf.Lerp(b.min.x, b.max.x, (float)index / (_groundCheckCount - 1));
return new Vector2(x, b.min.y);
}
private bool IsGroundedCheck()
{
for (int i = 0; i < _groundCheckCount; i++)
{
if (Physics2D.Raycast(GetGroundRayOrigin(i), Vector2.down, _groundCheckDist, _groundMask))
return true;
}
return false;
}
// 墙体射线起点:碰撞体朝向侧边缘中心高度
private Vector2 GetWallRayOrigin()
{
var col = _groundCheckCollider != null ? _groundCheckCollider : GetComponent<Collider2D>();
if (col == null) return (Vector2)transform.position;
Bounds b = col.bounds;
float x = _facingDir >= 0 ? b.max.x : b.min.x;
return new Vector2(x, b.center.y);
}
// 悬崖射线起点:碰撞体前下角再向前偏移 _ledgeCheckFwdOffset
private Vector2 GetLedgeRayOrigin()
{
var col = _groundCheckCollider != null ? _groundCheckCollider : GetComponent<Collider2D>();
if (col == null) return (Vector2)transform.position;
Bounds b = col.bounds;
float x = _facingDir >= 0
? b.max.x + _ledgeCheckFwdOffset
: b.min.x - _ledgeCheckFwdOffset;
return new Vector2(x, b.min.y);
}
private void WallAndLedgeCheck()
{
LayerMask wallLayer = (_wallMask.value != 0) ? _wallMask : _groundMask;
if (_wallCheckDist > 0f)
IsWallAhead = Physics2D.Raycast(
GetWallRayOrigin(),
new Vector2(_facingDir, 0f),
_wallCheckDist,
wallLayer);
if (_ledgeCheckDownDist > 0f)
IsLedgeAhead = !Physics2D.Raycast(
GetLedgeRayOrigin(),
Vector2.down,
_ledgeCheckDownDist,
_groundMask);
}
private void Awake()
{
Debug.Assert(_config != null, "[EnemyMovement] _config 未赋值,请在 Prefab Inspector 中指定 EnemyStatsSO。", this);
_rb = GetComponent<Rigidbody2D>();
if (_groundCheckCollider == null)
_groundCheckCollider = GetComponent<Collider2D>();
// 从 Sprite 或 localScale 的初始状态推断朝向,并统一切换为 localScale 翻转。
// 这样子对象(含 RaySensor2D会随 localScale 正确翻转,不再依赖 flipX。
if (_spriteRenderer != null)
{
// 两个信号均可能携带初始朝向信息flipX 或 localScale.x < 0
// XOR 组合:恰好一个翻转 → 面左;两个都翻(互相抵消)→ 面右。
bool flippedBySprite = _spriteRenderer.flipX;
bool flippedByScale = transform.localScale.x < 0f;
_facingDir = (flippedBySprite ^ flippedByScale) ? -1 : 1;
// 三个信号均可能携带初始朝向信息,任意奇数个翻转表示实际方向与默认方向相反:
// flippedBySprite : SpriteRenderer.flipX
// flippedByScale : ROOT localScale.x < 0
// flippedByVisual : _visualRoot.localScale.x < 0需归一化否则与 ROOT 产生双重翻转)
bool flippedBySprite = _spriteRenderer != null && _spriteRenderer.flipX;
bool flippedByScale = transform.localScale.x < 0f;
bool flippedByVisual = _visualRoot != null && _visualRoot.localScale.x < 0f;
_facingDir = (flippedBySprite ^ flippedByScale ^ flippedByVisual)
? -_spriteDefaultFacingDir
: _spriteDefaultFacingDir;
_spriteRenderer.flipX = false; // 后续由 localScale 驱动,避免双重镜像
Vector3 s = transform.localScale;
transform.localScale = new Vector3(Mathf.Abs(s.x) * _facingDir, s.y, s.z);
}
else
// 归一化:清除所有翻转来源,仅保留 ROOT localScale.x 作为唯一翻转驱动。
if (_spriteRenderer != null)
_spriteRenderer.flipX = false;
if (_visualRoot != null && flippedByVisual)
{
_facingDir = transform.localScale.x >= 0f ? 1 : -1;
var vs = _visualRoot.localScale;
_visualRoot.localScale = new Vector3(Mathf.Abs(vs.x), vs.y, vs.z);
}
Vector3 s = transform.localScale;
float signX = (_facingDir == _spriteDefaultFacingDir) ? Mathf.Abs(s.x) : -Mathf.Abs(s.x);
transform.localScale = new Vector3(signX, s.y, s.z);
// 将 Visual 子节点的 localPosition 对齐到 Collider2D offset使视觉中心与碰撞体中心重合
if (_visualRoot != null && _groundCheckCollider != null)
_visualRoot.localPosition = _groundCheckCollider.offset;
if (_enableTurnAnimation)
{
if (_animancer == null) _animancer = GetComponentInParent<AnimancerComponent>(true);
// AnimancerComponent 可能在 Visual 子节点上,用 GetComponentInChildren 兼容两种布局
if (_animancer == null) _animancer = GetComponentInChildren<AnimancerComponent>(true);
if (_animConfig == null)
{
var enemyBase = GetComponentInParent<EnemyBase>(true);
@@ -194,7 +302,16 @@ namespace BaseGames.Enemies
private void FixedUpdate()
{
IsGrounded = IsGroundedCheck();
// localScale.x 为正 → 精灵以 _spriteDefaultFacingDir 方向显示;为负则相反。
if (!_isTurning)
_facingDir = transform.localScale.x >= 0f ? _spriteDefaultFacingDir : -_spriteDefaultFacingDir;
// NavDriving: TBM 直接写 transform.position零速防止物理重力积累和双重驱动冲突。
if (NavDriving)
_rb.velocity = Vector2.zero;
IsGrounded = IsGroundedCheck();
WallAndLedgeCheck();
#if UNITY_EDITOR
_dbg_Input_MoveDir = PendingInput.MoveDir;
_dbg_Input_MoveSpeed = PendingInput.MoveSpeed;
@@ -209,7 +326,10 @@ namespace BaseGames.Enemies
_dbg_VelocityX = _rb != null ? _rb.velocity.x : 0f;
_dbg_VelocityY = _rb != null ? _rb.velocity.y : 0f;
_dbg_IsGrounded = IsGrounded;
_dbg_IsWallAhead = IsWallAhead;
_dbg_IsLedgeAhead = IsLedgeAhead;
_dbg_IsTurning = _isTurning;
_dbg_NavDriving = NavDriving;
#endif
}
@@ -222,8 +342,10 @@ namespace BaseGames.Enemies
bool wantFace = PendingInput.WantFace;
int faceDir = PendingInput.FaceDir;
var facePosSnapshot = PendingInput.FaceTargetPos;
PendingInput.WantStop = false;
PendingInput.WantFace = false;
PendingInput.WantStop = false;
PendingInput.WantFace = false;
PendingInput.FaceDir = 0; // clear to prevent stale Inspector display
PendingInput.FaceTargetPos = default; // clear to prevent stale Inspector display
// ── 持久字段MoveDir / MoveSpeed 不清零 ─────────────────────
// 解决 FixedUpdate 频率 > Update 频率时的空帧问题:
@@ -256,20 +378,22 @@ namespace BaseGames.Enemies
public void MoveHorizontal(float dir)
{
if (_isTurning) return;
UpdateFacing(dir);
if (NavDriving) return; // TBM 驱动位置,仅更新朝向
var vel = _rb.velocity;
vel.x = dir * _config.WalkSpeed;
_rb.velocity = vel;
UpdateFacing(dir);
}
/// <summary>显式指定速度BD 追击任务调用)。转身动画期间调用无效。</summary>
public void MoveWithSpeed(float dir, float speed)
{
if (_isTurning) return;
UpdateFacing(dir);
if (NavDriving) return; // TBM 驱动位置,仅更新朝向
var vel = _rb.velocity;
vel.x = dir * speed;
_rb.velocity = vel;
UpdateFacing(dir);
}
/// <summary>朝向指定世界坐标(通常传入玩家位置)。</summary>
@@ -361,20 +485,54 @@ namespace BaseGames.Enemies
}
}
/// <summary>转身动画协程:停止水平移动 → 播放 Turn 动画 → 翻转朝向 → 恢复。</summary>
/// <summary>转身动画协程:停止水平移动 → 播放 Turn 动画 → 翻转朝向 → 恢复移动动画。</summary>
private IEnumerator TurnCoroutine(int newDir)
{
_isTurning = true;
StopHorizontal();
// yield return stateAnimancer 的 AnimancerState 是 CustomYieldInstruction
// 等待动画自然播完,与 Layer/State 速度缩放无关,比手动计时更可靠。
// 用 WaitForSeconds 代替 "yield return state"
// AnimancerState.IsLooping 是只读属性(反映 clip 自身设置),无法强制单次播放;
// 若 Turn clip 被误配为 Loop"yield return state" 的 keepWaiting 永远为 true
// 导致 _isTurning 卡住、走路/攻击动画无法播放。
// WaitForSeconds(Length / Speed) 精确等待一个周期,与 clip 的 Loop 设置无关。
var state = _animancer.Play(_animConfig.Turn);
yield return state;
float waitSec = state.Length > 0f
? state.Length / Mathf.Max(0.001f, Mathf.Abs(state.EffectiveSpeed))
: 0.3f;
yield return new WaitForSeconds(waitSec);
ApplyFacingFlip(newDir);
_isTurning = false;
_turnCoroutine = null;
// 转身完成后恢复运动动画Turn 覆盖了之前的 Walk/Run
// 上层EnemyBase.SetAiPhase只在阶段切换时播放一次动画不会在此处重播。
ResumeMovementAnimation();
}
/// <summary>
/// 根据当前输入状态恢复合适的移动动画Walk / Run / Idle
/// 转身协程结束、CancelTurn 时调用,避免动画停留在 Turn 最后一帧。
/// </summary>
private void ResumeMovementAnimation()
{
if (_animancer == null || _animConfig == null) return;
if (PendingInput.WantStop || Mathf.Approximately(PendingInput.MoveDir, 0f))
{
if (_animConfig.Idle != null) _animancer.Play(_animConfig.Idle);
return;
}
// 有速度且明显超过步行速度 → 跑步动画
float spd = PendingInput.MoveSpeed > 0f ? PendingInput.MoveSpeed : 0f;
if (_animConfig.Run != null && _config != null && spd > _config.WalkSpeed + 0.05f)
_animancer.Play(_animConfig.Run);
else if (_animConfig.Walk != null)
_animancer.Play(_animConfig.Walk);
else if (_animConfig.Idle != null)
_animancer.Play(_animConfig.Idle);
}
/// <summary>
@@ -401,7 +559,9 @@ namespace BaseGames.Enemies
if (_spriteRenderer != null)
_spriteRenderer.flipX = false;
Vector3 s = transform.localScale;
transform.localScale = new Vector3(Mathf.Abs(s.x) * newDir, s.y, s.z);
// newDir 与精灵默认方向一致 → 正比例(不翻转),否则取反(翻转)。
float signX = (newDir == _spriteDefaultFacingDir) ? Mathf.Abs(s.x) : -Mathf.Abs(s.x);
transform.localScale = new Vector3(signX, s.y, s.z);
}
private void OnDrawGizmos()
@@ -427,9 +587,38 @@ namespace BaseGames.Enemies
Gizmos.color = grounded
? new Color(0.2f, 1f, 0.35f, 0.90f)
: new Color(0.4f, 0.75f, 0.4f, 0.40f);
Vector3 origin = transform.position;
Gizmos.DrawLine(origin, origin + Vector3.down * _groundCheckDist);
Gizmos.DrawWireSphere(origin + Vector3.down * _groundCheckDist, 0.04f);
for (int i = 0; i < _groundCheckCount; i++)
{
Vector3 origin = GetGroundRayOrigin(i);
Gizmos.DrawLine(origin, origin + Vector3.down * _groundCheckDist);
Gizmos.DrawWireSphere(origin + Vector3.down * _groundCheckDist, 0.04f);
}
}
// ── 4. 墙体检测射线(命中红色 / 无命中青色)─────────────────
if (_wallCheckDist > 0f)
{
bool hit = Application.isPlaying && IsWallAhead;
Gizmos.color = hit
? new Color(1f, 0.2f, 0.2f, 0.90f)
: new Color(0.2f, 0.9f, 1f, 0.50f);
Vector3 wallOrigin = GetWallRayOrigin();
Vector3 wallEnd = wallOrigin + new Vector3(_facingDir * _wallCheckDist, 0f, 0f);
Gizmos.DrawLine(wallOrigin, wallEnd);
Gizmos.DrawWireSphere(wallEnd, 0.04f);
}
// ── 5. 悬崖检测射线(无地面橙色 / 有地面灰色)───────────────
if (_ledgeCheckDownDist > 0f)
{
bool ledge = Application.isPlaying && IsLedgeAhead;
Gizmos.color = ledge
? new Color(1f, 0.65f, 0.1f, 0.90f)
: new Color(0.6f, 0.6f, 0.6f, 0.40f);
Vector3 ledgeOrigin = GetLedgeRayOrigin();
Vector3 ledgeEnd = ledgeOrigin + Vector3.down * _ledgeCheckDownDist;
Gizmos.DrawLine(ledgeOrigin, ledgeEnd);
Gizmos.DrawWireSphere(ledgeEnd, 0.04f);
}
#endif
}
@@ -446,6 +635,76 @@ namespace BaseGames.Enemies
#endif
}
#if UNITY_EDITOR
/// <summary>
/// 一键在 Enemy Prefab 上创建 Visual 子节点,将 SpriteRenderer / AnimancerComponent
/// 迁移到该子节点,并自动将 _visualRoot / _spriteRenderer / EnemyBase._animancer 引用指向新节点。
/// 在 Inspector 右键菜单或 Component Header 菜单中调用。
/// ⚠️ 请在 Prefab 编辑模式(或 Prefab Stage中执行以便变更能正确保存。
/// </summary>
[ContextMenu("Setup Visual Node")]
public void SetupVisualNode()
{
// 1. 找或创建 Visual 子节点
Transform visual = transform.Find("Visual");
if (visual == null)
{
var go = new GameObject("Visual");
UnityEditor.Undo.RegisterCreatedObjectUndo(go, "Create Enemy Visual Node");
go.transform.SetParent(transform, false);
visual = go.transform;
}
// 2. 对齐 localPosition 到 Collider2D offset
var col = _groundCheckCollider != null ? _groundCheckCollider : GetComponent<Collider2D>();
if (col != null)
{
UnityEditor.Undo.RecordObject(visual, "Set Visual LocalPosition");
visual.localPosition = col.offset;
}
// 3. 迁移 SpriteRenderer仅在 Visual 上尚无 SpriteRenderer 时执行)
var sr = GetComponent<SpriteRenderer>();
if (sr != null && visual.GetComponent<SpriteRenderer>() == null)
{
UnityEditorInternal.ComponentUtility.CopyComponent(sr);
UnityEditorInternal.ComponentUtility.PasteComponentAsNew(visual.gameObject);
UnityEditor.Undo.DestroyObjectImmediate(sr);
}
// 4. 迁移 AnimancerComponent
var anim = GetComponent<AnimancerComponent>();
if (anim != null && visual.GetComponent<AnimancerComponent>() == null)
{
UnityEditorInternal.ComponentUtility.CopyComponent(anim);
UnityEditorInternal.ComponentUtility.PasteComponentAsNew(visual.gameObject);
UnityEditor.Undo.DestroyObjectImmediate(anim);
}
// 5. 更新 EnemyMovement 字段引用
var movSO = new UnityEditor.SerializedObject(this);
movSO.FindProperty("_visualRoot").objectReferenceValue = visual;
movSO.FindProperty("_spriteRenderer").objectReferenceValue = visual.GetComponent<SpriteRenderer>();
movSO.FindProperty("_animancer").objectReferenceValue = visual.GetComponent<AnimancerComponent>();
movSO.ApplyModifiedProperties();
// 6. 更新 EnemyBase._animancer 引用
var enemyBase = GetComponent<EnemyBase>();
if (enemyBase != null)
{
var baseSO = new UnityEditor.SerializedObject(enemyBase);
baseSO.FindProperty("_animancer").objectReferenceValue = visual.GetComponent<AnimancerComponent>();
baseSO.ApplyModifiedProperties();
}
UnityEditor.EditorUtility.SetDirty(gameObject);
Debug.Log($"[EnemyMovement] Visual node setup complete on '{gameObject.name}'.\n" +
$"Visual.localPosition = {visual.localPosition}\n" +
$"请在 Prefab 编辑器中手动保存Ctrl+S。", this);
}
#endif
// 在 Gizmos 空间绘制带箭头的 2D 有向线段
private static void DrawArrow2D(Vector3 from, Vector3 to, Color color, float headLen = 0.15f)
{

View File

@@ -0,0 +1,128 @@
using UnityEngine;
namespace BaseGames.Enemies
{
/// <summary>
/// 地图固定巡逻/追击区域(矩形)。
///
/// 放置于场景中(非敌人子节点),通过 EnemyBase Inspector 的 _patrolZone 字段引用。
/// 同一区域可被多个敌人共享(哨兵组、竞技场等)。
///
/// 区域层级:
/// <list type="bullet">
/// <item><b>巡逻区域Patrol</b>:绿色矩形,巡逻点限制范围。</item>
/// <item><b>追击区域Chase</b>:橙色矩形 = 巡逻区域 + <see cref="ChaseExpandPadding"/>
/// 玩家进入/出此区域触发追击开始/放弃。</item>
/// </list>
/// </summary>
public class EnemyPatrolZone : MonoBehaviour
{
[Header("巡逻区域")]
[Tooltip("巡逻区域中心相对 transform.position 的偏移(局部偏移,方便在 Scene 中移动节点)")]
public Vector2 PatrolOffset = Vector2.zero;
[Tooltip("巡逻区域尺寸(宽 × 高,单位 m")]
public Vector2 PatrolSize = new Vector2(10f, 4f);
[Header("追击区域")]
[Tooltip("追击区域向四周扩展的边距m追击区域 = 巡逻区域各边 + 此值。0 = 不扩展(追击 = 巡逻)")]
[Min(0f)]
public float ChaseExpandPadding = 8f;
[Tooltip("自定义追击区域尺寸(宽×高);保持 Vector2.zero 时使用巡逻区域 + ChaseExpandPadding 自动计算")]
public Vector2 CustomChaseSize = Vector2.zero;
// ── 计算属性 ──────────────────────────────────────────────────────
/// <summary>巡逻区域中心(世界坐标)。</summary>
public Vector2 PatrolCenter => (Vector2)transform.position + PatrolOffset;
/// <summary>追击区域中心与巡逻区域共享。</summary>
public Vector2 ChaseCenter => PatrolCenter;
/// <summary>有效追击区域尺寸:自定义 > 0 时使用自定义,否则巡逻区域 + 边距。</summary>
public Vector2 EffectiveChaseSize
{
get
{
if (CustomChaseSize.sqrMagnitude > 0f) return CustomChaseSize;
return PatrolSize + Vector2.one * (ChaseExpandPadding * 2f);
}
}
// ── 空间查询 ──────────────────────────────────────────────────────
/// <summary>判断世界坐标 <paramref name="worldPos"/> 是否在巡逻区域内。</summary>
public bool ContainsPatrol(Vector2 worldPos)
{
Vector2 delta = worldPos - PatrolCenter;
Vector2 half = PatrolSize * 0.5f;
return Mathf.Abs(delta.x) <= half.x && Mathf.Abs(delta.y) <= half.y;
}
/// <summary>判断世界坐标 <paramref name="worldPos"/> 是否在追击区域内。</summary>
public bool ContainsChase(Vector2 worldPos)
{
Vector2 delta = worldPos - ChaseCenter;
Vector2 half = EffectiveChaseSize * 0.5f;
return Mathf.Abs(delta.x) <= half.x && Mathf.Abs(delta.y) <= half.y;
}
/// <summary>将 <paramref name="worldPos"/> 夹紧到巡逻区域内最近点(用于归位目标)。</summary>
public Vector2 ClampToPatrol(Vector2 worldPos)
{
Vector2 center = PatrolCenter;
Vector2 half = PatrolSize * 0.5f;
return new Vector2(
Mathf.Clamp(worldPos.x, center.x - half.x, center.x + half.x),
Mathf.Clamp(worldPos.y, center.y - half.y, center.y + half.y)
);
}
// ── Gizmos ────────────────────────────────────────────────────────
#if UNITY_EDITOR
private void OnDrawGizmos()
{
Vector2 patrol = PatrolCenter;
Vector2 chase = ChaseCenter;
// 追击区域(橙色,后绘,垫在巡逻区域下方)
Vector2 chaseSize = EffectiveChaseSize;
Gizmos.color = new Color(1f, 0.55f, 0.1f, 0.07f);
Gizmos.DrawCube(chase, chaseSize);
Gizmos.color = new Color(1f, 0.55f, 0.1f, 0.7f);
Gizmos.DrawWireCube(chase, chaseSize);
// 巡逻区域(绿色,前绘,覆盖在追击区域上方)
Gizmos.color = new Color(0.2f, 0.9f, 0.2f, 0.12f);
Gizmos.DrawCube(patrol, PatrolSize);
Gizmos.color = new Color(0.2f, 0.9f, 0.2f, 0.85f);
Gizmos.DrawWireCube(patrol, PatrolSize);
// 中心点
Gizmos.color = new Color(0.2f, 0.9f, 0.2f, 1f);
Gizmos.DrawSphere(patrol, 0.15f);
}
private void OnDrawGizmosSelected()
{
// 选中时用更鲜艳的线框突出显示
Vector2 patrol = PatrolCenter;
Gizmos.color = Color.green;
Gizmos.DrawWireCube(patrol, PatrolSize);
Gizmos.color = new Color(1f, 0.7f, 0f);
Gizmos.DrawWireCube(ChaseCenter, EffectiveChaseSize);
// 标注尺寸(仅在 Selected 时绘制)
UnityEditor.Handles.color = Color.green;
UnityEditor.Handles.Label(patrol + Vector2.right * PatrolSize.x * 0.5f,
$"Patrol {PatrolSize.x:F1}×{PatrolSize.y:F1}");
UnityEditor.Handles.color = new Color(1f, 0.7f, 0f);
Vector2 cs = EffectiveChaseSize;
UnityEditor.Handles.Label(ChaseCenter + Vector2.right * cs.x * 0.5f,
$"Chase {cs.x:F1}×{cs.y:F1}");
}
#endif
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6eaf6ff027820884daab5fa51164c899
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -19,8 +19,8 @@ namespace BaseGames.Enemies
{
_currentPoiseLevel = _defaultPoiseLevel;
// 自动注入到节点 HurtBox架构 06 §13
if (TryGetComponent<HurtBox>(out var hurtBox))
// 自动注入到所有子节点 HurtBox支持多形状受击区
foreach (var hurtBox in GetComponentsInChildren<HurtBox>(true))
hurtBox.SetPoiseSource(this);
}

View File

@@ -113,7 +113,7 @@ namespace BaseGames.Enemies
if (bt != null && bt.enabled != active)
{
bt.enabled = active;
// 同步暂停/恢复 SensorToolkit Sensor,避免远处敌人无效 tick
// 同步暂停/恢复感知系统,避免远处敌人无效 tick
enemy.SensorHub?.SetSuspended(!active);
}
}

View File

@@ -45,6 +45,7 @@ namespace BaseGames.Enemies.Navigation
// ── 私有 ────────────────────────────────────────────────────────
private NavAgent _navAgent;
private TransformBasedMovement _movement;
private EnemyMovement _enemyMovement;
// 能力 handler 注册表NavLinkType → INavLinkHandler
private readonly Dictionary<NavLinkType, INavLinkHandler> _handlers
@@ -53,6 +54,7 @@ namespace BaseGames.Enemies.Navigation
// 连接段状态缓存
private NavLinkType _currentLinkType = NavLinkType.None;
private bool _wasNavOnSegment;
private Vector2 _currentLinkStart;
private Vector2 _currentLinkEnd;
@@ -61,6 +63,7 @@ namespace BaseGames.Enemies.Navigation
{
_navAgent = GetComponent<NavAgent>();
_movement = GetComponent<TransformBasedMovement>();
_enemyMovement = GetComponent<EnemyMovement>();
// 自动发现 INavLinkHandler 组件并注册(包含子对象)
foreach (var handler in GetComponentsInChildren<INavLinkHandler>(true))
@@ -196,6 +199,32 @@ namespace BaseGames.Enemies.Navigation
private void HandlePathFailed(NavAgent _) => OnNavPathFailed?.Invoke();
private void HandleGoalReached(NavAgent _) => OnGoalReached?.Invoke();
// ── NavDriving 信号桥 ───────────────────────────────────────────
/// <summary>
/// 每物理帧向 <see cref="EnemyMovement"/> 写入导航方向信号,并设置 NavDriving 标志。
/// NavDriving=true 时 EnemyMovement 只更新朝向TBM 保留对 transform.position 的控制权。
/// </summary>
private void FixedUpdate()
{
if (_enemyMovement == null || _navAgent == null) return;
bool onSegment = _navAgent.IsMovingOnSegment;
_enemyMovement.NavDriving = onSegment;
if (onSegment)
{
float dx = _navAgent.PathSubGoal.x - transform.position.x;
_enemyMovement.PendingInput.MoveDir = Mathf.Abs(dx) > 0.01f ? Mathf.Sign(dx) : 0f;
}
else if (_wasNavOnSegment && !_navAgent.IsFollowingAPath)
{
// 导航刚结束(到达目标 / 路径失败)→ 清除残留 MoveDir
_enemyMovement.PendingInput.WantStop = true;
}
_wasNavOnSegment = onSegment;
}
// ── 工具 ───────────────────────────────────────────────────────
private static NavLinkType ParseLinkType(string name) => name switch
{

View File

@@ -1,5 +1,6 @@
using System;
using UnityEngine;
using BaseGames.Enemies;
namespace BaseGames.Enemies.Navigation
{
@@ -48,10 +49,11 @@ namespace BaseGames.Enemies.Navigation
public event Action OnGoalReached;
// ── 状态 ───────────────────────────────────────────────────────
private Rigidbody2D _rb;
private Vector2? _destination;
private bool _isMoving;
private bool _goalFired;
private Rigidbody2D _rb;
private EnemyMovement _movement;
private Vector2? _destination;
private bool _isMoving;
private bool _goalFired;
private float _hoverTimer;
private float _hoverFlipTimer;
@@ -67,7 +69,8 @@ namespace BaseGames.Enemies.Navigation
// ── Unity 生命周期 ─────────────────────────────────────────────
private void Awake()
{
_rb = GetComponent<Rigidbody2D>();
_rb = GetComponent<Rigidbody2D>();
_movement = GetComponent<EnemyMovement>();
_rb.gravityScale = 0f;
_rb.constraints = RigidbodyConstraints2D.FreezeRotation;
}
@@ -139,13 +142,23 @@ namespace BaseGames.Enemies.Navigation
Vector2 newPos = Vector2.MoveTowards(myPos, target, _moveSpeed * Time.fixedDeltaTime);
_rb.MovePosition(newPos);
// 面向移动方向
// 面向移动方向(通过 EnemyMovement 输入信号,保持 _facingDir 与动画系统同步)
float dx = target.x - myPos.x;
if (Mathf.Abs(dx) > 0.05f)
{
var s = transform.localScale;
s.x = Mathf.Abs(s.x) * Mathf.Sign(dx);
transform.localScale = s;
int dir = dx > 0f ? 1 : -1;
if (_movement != null)
{
_movement.PendingInput.WantFace = true;
_movement.PendingInput.FaceDir = dir;
}
else
{
// 降级:没有 EnemyMovement 时直接翻转(独立飞行单位)
var s = transform.localScale;
s.x = Mathf.Abs(s.x) * dir;
transform.localScale = s;
}
}
}

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

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c0026fe36cfaffc4e95698bccd0a8380
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

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";
}
}