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

@@ -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)
{