Files
zeling_v2/Assets/_Game/Scripts/Enemies/EnemyMovement.cs
Joywayer 06048c966a feat: Add HurtBoxOwnerGuard to prevent multiple damage registrations from the same HitBox activation
- Implemented HurtBoxOwnerGuard to ensure that multiple HurtBoxes on the same character do not register damage multiple times during a single HitBox activation.
- Added custom editor for HitBox to facilitate the creation of shape colliders with HitBoxColliderProxy.
- Introduced PhysicsPerceptionSystem for enemy perception, supporting multiple detection modes including RangeCircle, BatchLOS, FanCast, and BoxCast.
- Created EnemyPatrolZone to define patrol and chase areas for enemies, allowing for shared zones among multiple enemies.
- Added BD_IsOutsideZone conditional task for Behavior Designer to check if an enemy or player is outside a defined patrol zone.
2026-06-02 16:10:44 +08:00

725 lines
34 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections;
using Animancer;
using UnityEngine;
namespace BaseGames.Enemies
{
/// <summary>
/// 敌人移动组件(架构 07_EnemyModule §3
/// 实现:水平移动、面向目标、击退,以及导航连接段穿越(<see cref="INavLinkHandler"/>)。
///
/// 作为 <see cref="INavLinkHandler"/> 处理 Jump / Fall 两种 NavLink 类型:
/// - 跳跃连接Jump调用 <see cref="JumpToTarget"/> 施加物理冲量,等待落地后通知完成
/// - 下落连接Fall 水平对准目标X让重力自然下坠到达目标Y附近通知完成
/// 没有 EnemyMovement 组件(或 Jump 能力被移除)的敌人将无法通过跳跃连接,
/// 路径代价保持 TransformBasedMovement 兜底(仍可跳,但无自定义动画/物理)。
///
/// ⚠️ 使用 Rigidbody2D.velocityUnity 2022 LTS
/// </summary>
[RequireComponent(typeof(Rigidbody2D))]
public class EnemyMovement : MonoBehaviour, INavLinkHandler
{
[SerializeField] private EnemyStatsSO _config;
[SerializeField] private SpriteRenderer _spriteRenderer;
[Header("转身动画")]
[Tooltip("开启后,敌人翻转方向时播放转身动画并暂停水平移动,动画结束后完成翻转")]
[SerializeField] private bool _enableTurnAnimation = false;
[Tooltip("Animancer 组件引用;留空则在 Awake 时自动从父级查找")]
[SerializeField] private AnimancerComponent _animancer;
[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("用于确定射线起点宽度和底边的 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;
// ── 转身状态 ────────────────────────────────────────────────────────
private bool _isTurning;
private int _pendingFacingDir; // 转身目标方向,转身完成后 ApplyFacingFlip 使用
private Coroutine _turnCoroutine;
// ── 输入信号BD 任务在 Update 写入FixedUpdate 消费后自动清零)──
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──")]
[SerializeField] private int _dbg_FacingDirection;
[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;
[SerializeField] private bool _dbg_Input_WantStop;
[SerializeField] private bool _dbg_Input_WantFace;
[SerializeField] private Vector2 _dbg_Input_FaceTargetPos;
[SerializeField] private int _dbg_Input_FaceDir;
#endif
// ── INavLinkHandler ────────────────────────────────────────────
private static readonly NavLinkType[] _handledTypes =
new[] { NavLinkType.Jump, NavLinkType.Fall };
public NavLinkType[] HandledLinkTypes => _handledTypes;
public bool CanHandleLink(NavLinkType type, Vector2 linkStart, Vector2 linkEnd)
{
if (type == NavLinkType.Jump)
{
float dy = Mathf.Abs(linkEnd.y - linkStart.y);
float dx = Mathf.Abs(linkEnd.x - linkStart.x);
return dy <= _navJumpMaxHeight && dx <= _navJumpMaxDist;
}
return true; // Fall 总是可以处理
}
public void BeginLinkTraversal(NavLinkType type, Vector2 linkStart, Vector2 linkEnd, Action onComplete)
{
CancelTurn(); // 进入连接段前中止任何进行中的转身
if (_linkCoroutine != null) StopCoroutine(_linkCoroutine);
_linkCoroutine = type == NavLinkType.Jump
? StartCoroutine(JumpLinkCoroutine(linkStart, linkEnd, onComplete))
: StartCoroutine(FallLinkCoroutine(linkStart, linkEnd, onComplete));
}
public void AbortLinkTraversal()
{
if (_linkCoroutine != null) { StopCoroutine(_linkCoroutine); _linkCoroutine = null; }
CancelTurn();
StopHorizontal();
}
private IEnumerator JumpLinkCoroutine(Vector2 start, Vector2 end, Action onComplete)
{
JumpToTarget(end);
yield return null; // 等一帧让 velocity 生效
// 等待离地后落地(超时 3s 防死锁)
float timer = 0f;
bool leftGround = false;
while (timer < 3f)
{
timer += Time.fixedDeltaTime;
yield return new WaitForFixedUpdate();
if (!leftGround && !IsGroundedCheck()) { leftGround = true; }
if (leftGround && IsGroundedCheck()) break;
}
StopHorizontal();
_linkCoroutine = null;
onComplete?.Invoke();
}
private IEnumerator FallLinkCoroutine(Vector2 start, Vector2 end, Action onComplete)
{
// 水平对准目标
float dx = end.x - (float)transform.position.x;
if (Mathf.Abs(dx) > 0.15f) MoveHorizontal(Mathf.Sign(dx));
// 等待接近目标Y重力驱动下落
float timer = 0f;
while (timer < 3f)
{
timer += Time.fixedDeltaTime;
yield return new WaitForFixedUpdate();
if (IsGroundedCheck() && Mathf.Abs(_rb.position.y - end.y) < 0.6f) break;
}
StopHorizontal();
_linkCoroutine = null;
onComplete?.Invoke();
}
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。
// 三个信号均可能携带初始朝向信息,任意奇数个翻转表示实际方向与默认方向相反:
// 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;
// 归一化:清除所有翻转来源,仅保留 ROOT localScale.x 作为唯一翻转驱动。
if (_spriteRenderer != null)
_spriteRenderer.flipX = false;
if (_visualRoot != null && flippedByVisual)
{
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)
{
// AnimancerComponent 可能在 Visual 子节点上,用 GetComponentInChildren 兼容两种布局
if (_animancer == null) _animancer = GetComponentInChildren<AnimancerComponent>(true);
if (_animConfig == null)
{
var enemyBase = GetComponentInParent<EnemyBase>(true);
if (enemyBase != null) _animConfig = enemyBase.AnimConfig;
}
}
}
private void OnDisable()
{
// 持久信号在对象禁用时必须清零,防止重新启用时继承残留移动状态。
PendingInput = default;
StopHorizontal();
}
private void FixedUpdate()
{
// 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;
_dbg_Input_WantStop = PendingInput.WantStop;
_dbg_Input_WantFace = PendingInput.WantFace;
_dbg_Input_FaceTargetPos = PendingInput.FaceTargetPos;
_dbg_Input_FaceDir = PendingInput.FaceDir;
#endif
ConsumeInput();
#if UNITY_EDITOR
_dbg_FacingDirection = _facingDir;
_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
}
private void ConsumeInput()
{
// ── 一次性脉冲:消费后清零 ─────────────────────────────────────
// WantStop / WantFace 只需写一次,消费后自动清除,
// 避免 BD 任务每帧续写而产生的不必要开销。
bool wantStop = PendingInput.WantStop;
bool wantFace = PendingInput.WantFace;
int faceDir = PendingInput.FaceDir;
var facePosSnapshot = PendingInput.FaceTargetPos;
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 频率时的空帧问题:
// 两次 Update 之间如果 FixedUpdate 多执行一次,之前写入的 MoveDir
// 仍然有效,不会产生意外的 StopHorizontal。
if (wantStop)
{
PendingInput.MoveDir = 0f;
PendingInput.MoveSpeed = 0f;
StopHorizontal();
}
else if (PendingInput.MoveDir != 0f)
{
if (PendingInput.MoveSpeed > 0f)
MoveWithSpeed(PendingInput.MoveDir, PendingInput.MoveSpeed);
else
MoveHorizontal(PendingInput.MoveDir);
}
if (wantFace && !_isTurning)
{
if (faceDir != 0)
UpdateFacing(faceDir > 0 ? 1f : -1f);
else
FaceTarget(facePosSnapshot);
}
}
/// <summary>按 SO 配置速度水平移动。dir: +1 右 / -1 左 / 0 停止。转身动画期间调用无效。</summary>
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;
}
/// <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;
}
/// <summary>朝向指定世界坐标(通常传入玩家位置)。</summary>
public void FaceTarget(Vector2 targetPos)
{
float dir = targetPos.x < transform.position.x ? -1f : 1f;
UpdateFacing(dir);
}
/// <summary>
/// 直接指定朝向方向。dir: +1 = 右,-1 = 左。
/// 若启用转身动画且方向确实改变,会触发转身流程。
/// </summary>
public void FaceDirection(int dir)
{
if (dir == 0) return;
UpdateFacing(dir > 0 ? 1f : -1f);
}
/// <summary>朝向右方(+X。</summary>
public void FaceRight() => FaceDirection(1);
/// <summary>朝向左方(-X。</summary>
public void FaceLeft() => FaceDirection(-1);
public void ApplyKnockback(Vector2 dir, float force)
{
_rb.velocity = dir.normalized * force;
}
/// <summary>
/// 击飞冲量:向上 + 沿受击反方向水平。
/// sourceDir 为伤害来源朝向(通常是 DamageInfo.KnockbackDirection横向取其反方向。
/// </summary>
/// <param name="sourceDir">来袭方向(已归一化)</param>
/// <param name="horzForce">水平冲量大小</param>
/// <param name="upForce">纵向冲量大小</param>
public void LaunchKnockup(Vector2 sourceDir, float horzForce, float upForce)
{
if (_rb == null) return;
float horzSign = sourceDir.x >= 0f ? -1f : 1f; // 反方向弹飞
_rb.velocity = new Vector2(horzSign * horzForce, upForce);
}
public void StopHorizontal()
{
var vel = _rb.velocity;
vel.x = 0f;
_rb.velocity = vel;
}
/// <summary>
/// 向目标位置抖跃(抛物线累加填充)。
/// 计算初速使尔子到达目标,用 Impulse 施加力。
/// </summary>
public void JumpToTarget(Vector2 target)
{
if (_rb == null) return;
Vector2 delta = target - (Vector2)transform.position;
float gravMag = Mathf.Abs(Physics2D.gravity.y * _rb.gravityScale);
float timeAloft = Mathf.Max(0.1f, delta.x != 0f
? Mathf.Abs(delta.x) / _config.RunSpeed
: 0.5f);
float vy = (delta.y - 0.5f * (-gravMag) * timeAloft * timeAloft) / timeAloft;
float vx = delta.x / timeAloft;
_rb.velocity = new Vector2(vx, vy);
UpdateFacing(vx);
}
private void UpdateFacing(float dir)
{
if (Mathf.Approximately(dir, 0f)) return;
if (_isTurning) return; // 转身进行中,忽略新的朝向请求
int newDir = dir > 0f ? 1 : -1;
if (newDir == _facingDir) return;
if (_enableTurnAnimation && _animancer != null && _animConfig?.Turn != null)
{
// 启动转身协程:动画播完后再实际翻转
_pendingFacingDir = newDir;
if (_turnCoroutine != null) StopCoroutine(_turnCoroutine);
_turnCoroutine = StartCoroutine(TurnCoroutine(newDir));
}
else
{
ApplyFacingFlip(newDir);
}
}
/// <summary>转身动画协程:停止水平移动 → 播放 Turn 动画 → 翻转朝向 → 恢复移动动画。</summary>
private IEnumerator TurnCoroutine(int newDir)
{
_isTurning = true;
StopHorizontal();
// 用 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);
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>
/// 立即中止进行中的转身协程,并将朝向应用到待转方向。
/// 受击、死亡、NavLink 穿越等外部中断时调用。
/// </summary>
public void CancelTurn()
{
if (_turnCoroutine == null) return;
StopCoroutine(_turnCoroutine);
_turnCoroutine = null;
if (_isTurning)
{
ApplyFacingFlip(_pendingFacingDir);
_isTurning = false;
}
}
/// <summary>真正执行朝向翻转。始终用 localScale 翻转,子对象(传感器 RaySensor2D随之正确翻转。</summary>
private void ApplyFacingFlip(int newDir)
{
_facingDir = newDir;
// 若挂有 SpriteRenderer重置 flipX = falselocalScale 已负责镜像,避免双重翻转)。
if (_spriteRenderer != null)
_spriteRenderer.flipX = false;
Vector3 s = transform.localScale;
// 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()
{
#if UNITY_EDITOR
// ── 1. 敌人物理轮廓(珊瑚红,区别于玩家绿色)────────────────
Gizmos.color = new Color(1f, 0.45f, 0.35f, 0.65f);
foreach (var col in GetComponents<Collider2D>())
{
if (col.isTrigger) continue;
BaseGames.Combat.HitBox.DrawCollider2DWire(col);
}
// ── 2. 朝向箭头(橙色)──────────────────────────────────────
Vector3 center = transform.position;
DrawArrow2D(center, center + new Vector3(_facingDir * 0.5f, 0f, 0f),
new Color(1f, 0.6f, 0.1f, 0.9f));
// ── 3. 地面检测射线(接地亮绿 / 未接地暗绿)─────────────────
if (_groundCheckDist > 0f)
{
bool grounded = Application.isPlaying && IsGrounded;
Gizmos.color = grounded
? new Color(0.2f, 1f, 0.35f, 0.90f)
: new Color(0.4f, 0.75f, 0.4f, 0.40f);
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
}
private void OnDrawGizmosSelected()
{
#if UNITY_EDITOR
// 运行时:青色箭头显示速度向量(选中时)
if (!Application.isPlaying || _rb == null) return;
Vector2 vel = _rb.velocity;
if (vel.sqrMagnitude < 0.01f) return;
DrawArrow2D(transform.position, transform.position + (Vector3)(vel * 0.12f),
new Color(0.2f, 0.9f, 1f, 0.9f), 0.1f);
#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)
{
Vector3 dir = to - from;
if (dir.sqrMagnitude < 0.0001f) return;
dir = dir.normalized;
Gizmos.color = color;
Gizmos.DrawLine(from, to);
float cos = 0.8192f, sin = 0.5736f; // cos/sin 35°
float bx = -dir.x, by = -dir.y;
Vector3 wing1 = new Vector3(bx * cos - by * sin, bx * sin + by * cos, 0f) * headLen;
Vector3 wing2 = new Vector3(bx * cos + by * sin, -bx * sin + by * cos, 0f) * headLen;
Gizmos.DrawLine(to, to + wing1);
Gizmos.DrawLine(to, to + wing2);
}
}
}