Files
zeling_v2/Assets/_Game/Scripts/Enemies/EnemyMovement.cs
Joywayer bcd8b0e90b feat: Update enemy AI and movement systems
- Enhanced Physics2D layer collision report with new interactions between Player and Enemy layers.
- Refactored BD_InvestigateLastKnown to streamline animation handling and improve readability.
- Simplified BD_MaintainCombatDistance by consolidating movement stop logic.
- Updated BD_MoveToPlayer to set AI phase on start.
- Improved BD_Patrol logic with better handling of stuck states and path failures.
- Enhanced BD_PatrolWaypoints to manage stuck conditions and retry logic more effectively.
- Refined BD_ReturnToHome to remove unnecessary animation calls.
- Updated BD_WalkRandom to ensure AI phase is set correctly on start.
- Improved EnemyAbilityBase to delegate target facing to the movement system.
- Enhanced EnemyBase with new movement methods for better control.
- Refactored EnemyMovement to introduce a new input system for handling movement and facing.
- Added EnemyMoveInput struct to encapsulate movement intentions.
- Updated Physics2DSettings to reflect new layer collision matrix.
- Introduced RTK CLI instructions for optimized command usage.
2026-05-29 17:01:59 +08:00

466 lines
20 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("导航跳跃能力INavLinkHandler")]
[Tooltip("可处理的最大跳跃垂直高度(超出则让 TBM 兜底)")]
[SerializeField] private float _navJumpMaxHeight = 6f;
[Tooltip("可处理的最大跳跃水平距离")]
[SerializeField] private float _navJumpMaxDist = 10f;
[Tooltip("地面检测射线长度(用于判断跳跃是否落地)")]
[SerializeField] private float _groundCheckDist = 0.35f;
[Tooltip("地面层 LayerMask")]
[SerializeField] private LayerMask _groundMask;
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>当前朝向1 = 右,-1 = 左。</summary>
public int FacingDirection => _facingDir;
/// <summary>当前是否正在播放转身动画(移动输入在此期间被屏蔽)。</summary>
public bool IsTurning => _isTurning;
#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_IsTurning;
[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 bool IsGroundedCheck() =>
Physics2D.Raycast(_rb.position, Vector2.down, _groundCheckDist, _groundMask);
private void Awake()
{
Debug.Assert(_config != null, "[EnemyMovement] _config 未赋值,请在 Prefab Inspector 中指定 EnemyStatsSO。", this);
_rb = GetComponent<Rigidbody2D>();
// 从 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;
_spriteRenderer.flipX = false; // 后续由 localScale 驱动,避免双重镜像
Vector3 s = transform.localScale;
transform.localScale = new Vector3(Mathf.Abs(s.x) * _facingDir, s.y, s.z);
}
else
{
_facingDir = transform.localScale.x >= 0f ? 1 : -1;
}
if (_enableTurnAnimation)
{
if (_animancer == null) _animancer = GetComponentInParent<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()
{
IsGrounded = IsGroundedCheck();
#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_IsTurning = _isTurning;
#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;
// ── 持久字段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;
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;
var vel = _rb.velocity;
vel.x = dir * speed;
_rb.velocity = vel;
UpdateFacing(dir);
}
/// <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();
// yield return stateAnimancer 的 AnimancerState 是 CustomYieldInstruction
// 等待动画自然播完,与 Layer/State 速度缩放无关,比手动计时更可靠。
var state = _animancer.Play(_animConfig.Turn);
yield return state;
ApplyFacingFlip(newDir);
_isTurning = false;
_turnCoroutine = null;
}
/// <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;
transform.localScale = new Vector3(Mathf.Abs(s.x) * newDir, 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);
Vector3 origin = transform.position;
Gizmos.DrawLine(origin, origin + Vector3.down * _groundCheckDist);
Gizmos.DrawWireSphere(origin + Vector3.down * _groundCheckDist, 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
}
// 在 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);
}
}
}