- 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.
725 lines
34 KiB
C#
725 lines
34 KiB
C#
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.velocity(Unity 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 = true(0 = 禁用)")]
|
||
[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 = false(localScale 已负责镜像,避免双重翻转)。
|
||
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);
|
||
}
|
||
}
|
||
}
|