using UnityEngine; namespace BaseGames.Player { /// /// 玩家物理移动组件。封装 Rigidbody2D 操作,提供跑动、跳跃、击退等接口。 /// // 执行顺序必须早于 PlayerController(-100),确保每帧 FixedUpdate // 开头能在状态机写入速度之前先应用"强制清零"标记。 [DefaultExecutionOrder(-200)] [RequireComponent(typeof(Rigidbody2D))] public class PlayerMovement : MonoBehaviour { [Header("配置")] [SerializeField] private PlayerMovementConfigSO _config; [Header("地面检测")] [SerializeField] private Transform _groundCheck; [SerializeField] private Vector2 _groundCheckSize = new Vector2(0.8f, 0.05f); [SerializeField] private LayerMask _groundLayer; [Header("朝向")] [SerializeField] private SpriteRenderer _spriteRenderer; // ── 运行时状态 ──────────────────────────────────────────────────────── private Rigidbody2D _rb; private float _coyoteTimer; private bool _isGrounded; // Update 中调用 ZeroHorizontalVelocity 后设置此标记; // 下一个 FixedUpdate(-200,先于状态机 -100)读取并清零, // 防止状态机用旧输入把速度重新写成非零值。 private bool _pendingHorizontalZero; private bool _isWallLeft; private bool _isWallRight; private bool _onOneWayPlatform; private int _facingDirection = 1; private bool _cancelWindowOpen; private SurfaceType _currentSurface = SurfaceType.Ground; private readonly Collider2D[] _groundBuffer = new Collider2D[4]; #if UNITY_EDITOR // ── 运行时调试(Inspector 中可见)─────────────────────────────── [Header("\u2500\u2500 \u8fd0\u884c\u65f6\u8c03\u8bd5 \u2500\u2500")] [SerializeField] private string _dbg_Position; [SerializeField] private float _dbg_VelocityX; [SerializeField] private float _dbg_VelocityY; [SerializeField] private bool _dbg_IsGrounded; [SerializeField] private bool _dbg_HasCoyoteTime; [SerializeField] private bool _dbg_IsWallLeft; [SerializeField] private bool _dbg_IsWallRight; [SerializeField] private bool _dbg_CancelWindowOpen; [SerializeField] private int _dbg_FacingDirection; #endif public bool IsGrounded => _isGrounded; public bool HasCoyoteTime => _coyoteTimer > 0f; public bool IsWallLeft => _isWallLeft; public bool IsWallRight => _isWallRight; public bool OnOneWayPlatform => _onOneWayPlatform; public int FacingDirection => _facingDirection; public Rigidbody2D Rb => _rb; public bool CancelWindowOpen => _cancelWindowOpen; public SurfaceType CurrentSurface => _currentSurface; /// 垂直速度大于零(处于上升弧段)。供 WallJumpState 判断起跳是否结束。 public bool IsRising => _rb.velocity.y > 0f; private void Awake() { Debug.Assert(_config != null, "[PlayerMovement] _config 未赋值,请在 Inspector 中指定 PlayerMovementConfigSO。", this); Debug.Assert(_groundCheck != null, "[PlayerMovement] GroundCheck 子节点未赋值,地面检测将无法工作,请在 Inspector 中指定 GroundCheck Transform。", this); _rb = GetComponent(); // 开启位置插值:在物理帧(50Hz)与渲染帧(60Hz+)之间平滑视觉位置,消除跳帧抖动。 // SpritePixelSnapper(LateUpdate +1000)在插值结果基础上吸附到像素网格, // 与 CameraPixelSnapper 同格对齐,消除亚像素模糊;停止时 ≤2 帧像素追赶不可感知。 _rb.interpolation = RigidbodyInterpolation2D.Interpolate; if (_spriteRenderer == null) _spriteRenderer = GetComponentInChildren(); } private void FixedUpdate() { // 优先处理来自 Update 的强制清零请求(在状态机 OnStateFixedUpdate 之前执行)。 if (_pendingHorizontalZero) { _rb.velocity = new Vector2(0f, _rb.velocity.y); _pendingHorizontalZero = false; } CheckGrounded(); CheckWalls(); if (_isGrounded) _coyoteTimer = _config.CoyoteTime; else _coyoteTimer = Mathf.Max(0f, _coyoteTimer - Time.fixedDeltaTime); #if UNITY_EDITOR _dbg_VelocityX = _rb.velocity.x; _dbg_VelocityY = _rb.velocity.y; _dbg_IsGrounded = _isGrounded; _dbg_HasCoyoteTime = _coyoteTimer > 0f; _dbg_IsWallLeft = _isWallLeft; _dbg_IsWallRight = _isWallRight; _dbg_CancelWindowOpen = _cancelWindowOpen; _dbg_FacingDirection = _facingDirection; _dbg_Position = $"({transform.position.x:F1}, {transform.position.y:F1})"; #endif } // ── 移动 ────────────────────────────────────────────────────────────── /// /// 直接赋予目标水平速度(按键即全速,松键即停,无加速过渡)。 /// 地面状态每帧直接到达全速;空中调用时同样即时,但配合 ApplyAirDrag /// 在无输入时自然减速,保留跳出时的动量。 /// public void Move(float speedX) { _rb.velocity = new Vector2(speedX, _rb.velocity.y); } /// /// 空中无输入时施加空气阻力:水平速度乘以 , /// 低于阈值时归零,避免速度无限趋近 0。 /// 在 FallState / JumpState / WallJumpState 的 OnStateFixedUpdate 中调用。 /// public void ApplyAirDrag(float factor) { float newX = _rb.velocity.x * factor; if (Mathf.Abs(newX) < 0.05f) newX = 0f; _rb.velocity = new Vector2(newX, _rb.velocity.y); } // ── 跳跃 ────────────────────────────────────────────────────────────── public void Jump(bool isVariable = true) { _rb.velocity = new Vector2(_rb.velocity.x, _config.JumpForce); _coyoteTimer = 0f; } public void CutJump() { if (_rb.velocity.y > 0f) _rb.velocity = new Vector2(_rb.velocity.x, _rb.velocity.y * _config.JumpCutMultiplier); } /// /// 二段跳。覆盖当前垂直速度为 DoubleJumpForce。 /// FallState / JumpState 在检测到 HasAbility(DoubleJump) && AirJumpsLeft > 0 时调用。 /// public void DoubleJump() { _rb.velocity = new Vector2(_rb.velocity.x, _config.DoubleJumpForce); _coyoteTimer = 0f; } // ── 重力 ────────────────────────────────────────────────────────────── public void SetGravityScale(float scale) => _rb.gravityScale = scale; // ── 击退 ────────────────────────────────────────────────────────────── public void ApplyKnockback(Vector2 direction, float force) => _rb.velocity = direction.normalized * force; // ── 速度控制 ────────────────────────────────────────────────────────── public void ZeroVelocity() => _rb.velocity = Vector2.zero; public void ZeroHorizontalVelocity() { _rb.velocity = new Vector2(0f, _rb.velocity.y); // 设置标记:下一个 FixedUpdate 开头再次强制清零, // 防止因读到旧输入而把速度重新写成非零值。 _pendingHorizontalZero = true; } // ── 朝向 ────────────────────────────────────────────────────────────── public void UpdateFacing() { float vx = _rb.velocity.x; if (Mathf.Abs(vx) < 0.1f) return; int dir = vx > 0f ? 1 : -1; if (dir == _facingDirection) return; _facingDirection = dir; if (_spriteRenderer != null) _spriteRenderer.flipX = dir < 0; } // ── 取消窗口 ────────────────────────────────────────────────────────── public void SetCancelWindowOpen(bool open) => _cancelWindowOpen = open; // ── 冲刺 ────────────────────────────────────────────────────────────── /// /// 施加冲刺速度(DashState/AerialDashState 调用)。 /// direction 应为归一化水平向量(±1, 0)。 /// 冲刺期间调用 SetGravityScale(0) 可关闭重力;结束后恢复默认重力。 /// public void Dash(Vector2 direction, float speed) { _rb.velocity = new Vector2(direction.x * speed, 0f); } /// /// 壁滑:将垂直速度限制为 -WallSlideSpeed(受限抓墙时向下缓慢滑动)。 /// WallSlideState.OnStateFixedUpdate 在受限模式下每帧调用。 /// public void ApplyWallSlide() { float targetY = -_config.WallSlideSpeed; if (_rb.velocity.y < targetY) _rb.velocity = new Vector2(_rb.velocity.x, targetY); } /// /// 背墙跳(Jump Away):远离墙壁斜上方弹出。 /// wallDir = +1 (右墙) 或 -1 (左墙),水平方向与之相反。 /// public void WallJumpAway(int wallDir) { _rb.velocity = new Vector2(-wallDir * _config.WallJumpAwayForceX, _config.WallJumpAwayForceY); _coyoteTimer = 0f; } /// /// 对墙跳(Jump Toward):沿墙壁方向偏向正上方弹出,水平分量较小。 /// wallDir = +1 (右墙) 或 -1 (左墙),水平方向与之相同。 /// public void WallJumpToward(int wallDir) { _rb.velocity = new Vector2(wallDir * _config.WallJumpTowardForceX, _config.WallJumpTowardForceY); _coyoteTimer = 0f; } /// 将垂直速度归零(抓墙悬挂时每帧调用,防止下滑)。 public void ZeroVerticalVelocity() { if (_rb.velocity.y < 0f) _rb.velocity = new Vector2(_rb.velocity.x, 0f); } /// 单向平台穿透(输入下行 + 跳跃键时触发)。 public void DropThroughPlatform() { } // ── Physics 检测 ────────────────────────────────────────────────────── private void CheckGrounded() { if (_groundCheck == null) return; bool wasGrounded = _isGrounded; _isGrounded = Physics2D.OverlapBoxNonAlloc(_groundCheck.position, _groundCheckSize, 0f, _groundBuffer, _groundLayer) > 0; if (_isGrounded && !wasGrounded) _coyoteTimer = _config.CoyoteTime; } private void CheckWalls() { float len = _config.WallRayLength; float offY = _config.WallRayOffsetY; Vector2 pos = (Vector2)transform.position + Vector2.up * offY; _isWallLeft = Physics2D.Raycast(pos, Vector2.left, len, _groundLayer); _isWallRight = Physics2D.Raycast(pos, Vector2.right, len, _groundLayer); } private void OnDrawGizmos() { #if UNITY_EDITOR // ── 1. 角色物理轮廓(浅绿,始终可见)──────────────────────────── Gizmos.color = new Color(0.4f, 1f, 0.4f, 0.65f); foreach (var col in GetComponents()) { if (col.isTrigger) continue; BaseGames.Combat.HitBox.DrawCollider2DWire(col); } // ── 2. 朝向箭头(金黄)────────────────────────────────────────── Vector3 center = transform.position; Vector3 arrowEnd = center + new Vector3(_facingDirection * 0.55f, 0f, 0f); DrawArrow2D(center, arrowEnd, new Color(1f, 0.85f, 0.1f, 0.95f)); // ── 3. 站立检测框(落地亮绿 / 未落地淡绿;未赋值时显示红色警告框)── if (_groundCheck == null) { Gizmos.color = new Color(1f, 0.1f, 0.1f, 0.9f); Gizmos.DrawWireSphere(transform.position, 0.25f); #if UNITY_EDITOR UnityEditor.Handles.color = new Color(1f, 0.1f, 0.1f, 1f); UnityEditor.Handles.Label(transform.position + Vector3.up * 0.6f, "GroundCheck 未赋值!"); #endif } else { bool grounded = Application.isPlaying && _isGrounded; Gizmos.color = grounded ? new Color(0.1f, 1f, 0.3f, 0.9f) : new Color(0.4f, 0.85f, 0.4f, 0.35f); BaseGames.Combat.HitBox.DrawWireRect2D(_groundCheck.position, _groundCheckSize); } // ── 4. 靠墙检测射线(碰墙橙色 / 未碰淡黄,带端点圆点)───────── if (_config == null) return; float wLen = _config.WallRayLength; float wOffY = _config.WallRayOffsetY; Vector2 wallPos = (Vector2)transform.position + Vector2.up * wOffY; bool lHit = Application.isPlaying && _isWallLeft; bool rHit = Application.isPlaying && _isWallRight; Gizmos.color = lHit ? new Color(1f, 0.45f, 0f, 1f) : new Color(1f, 0.85f, 0.3f, 0.4f); Gizmos.DrawRay(wallPos, Vector2.left * wLen); BaseGames.Combat.HitBox.DrawWireCircle2D((Vector3)(wallPos + Vector2.left * wLen), 0.04f, 8); Gizmos.color = rHit ? new Color(1f, 0.45f, 0f, 1f) : new Color(1f, 0.85f, 0.3f, 0.4f); Gizmos.DrawRay(wallPos, Vector2.right * wLen); BaseGames.Combat.HitBox.DrawWireCircle2D((Vector3)(wallPos + Vector2.right * wLen), 0.04f, 8); #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); // 将 -dir 分别旋转 ±35° 得到箭头两翼 float cos = 0.8192f; // Mathf.Cos(35° in radians) float sin = 0.5736f; // Mathf.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); } } /// 当前所在地面类型(用于脚步声等反馈)。 public enum SurfaceType { Ground, OneWayPlatform, Slope, Ice, } }