Files
zeling_v2/Assets/_Game/Scripts/Player/PlayerMovement.cs

488 lines
24 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 BaseGames.Core;
using UnityEngine;
namespace BaseGames.Player
{
/// <summary>
/// 玩家物理移动组件。封装 Rigidbody2D 操作,提供跑动、跳跃、击退等接口。
/// </summary>
// 执行顺序必须早于 PlayerController-100确保每帧 FixedUpdate
// 开头能在状态机写入速度之前先应用"强制清零"标记。
[DefaultExecutionOrder(-200)]
[RequireComponent(typeof(Rigidbody2D))]
public class PlayerMovement : MonoBehaviour, IPassengerReceiver
{
[Header("配置")]
[SerializeField] private PlayerMovementConfigSO _config;
[Header("地面检测")]
[SerializeField] private Transform _groundCheck;
[SerializeField] private Vector2 _groundCheckSize = new Vector2(0.8f, 0.05f);
[SerializeField] private LayerMask _groundLayer;
// ── 运行时状态 ────────────────────────────────────────────────────────
private Rigidbody2D _rb;
private float _coyoteTimer;
private float _wallCoyoteTimer;
private int _wallCoyoteDir; // 离墙时记录的墙壁方向(+1 右 / -1 左)
private bool _isGrounded;
// Update 中调用 ZeroHorizontalVelocity 后设置此标记;
// 下一个 FixedUpdate-200先于状态机 -100读取并清零
// 防止状态机用旧输入把速度重新写成非零值。
private bool _pendingHorizontalZero;
// 移动平台速度双缓冲Platform(-300) 写入 _nextPlayer(-200) 将 _next 换入 _current 并清零;
// 状态机(-100) 的 Move() 将 _platformVelocity.x 叠加到水平速度,保持与平台同步。
// 使用速度(而非 _rb.position+=Kinematic 平台的 MovePosition 已通过物理接触推动 Dynamic 乘客;
// 若再手动写 position 会产生双重施加2× 速度)。速度方案仅覆盖 velocity无此问题。
private Vector2 _platformVelocity; // 本帧消费Move() 叠加到 velocity
private Vector2 _nextPlatformVelocity; // Platform(-300) 写入缓冲区
// 玩家自身输入水平速度(不含平台分量);由 Move() 写入UpdateFacing / ApplyAirDrag 读此值。
private float _inputVelocityX;
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];
private int _groundHitCount;
#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_OnOneWayPlatform;
[SerializeField] private bool _dbg_HasCoyoteTime;
[SerializeField] private bool _dbg_HasWallCoyoteTime;
[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 HasWallCoyoteTime => _wallCoyoteTimer > 0f;
public int WallCoyoteDir => _wallCoyoteDir;
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;
/// <summary>垂直速度大于零(处于上升弧段)。供 WallJumpState 判断起跳是否结束。</summary>
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<Rigidbody2D>();
// 开启位置插值在物理帧50Hz与渲染帧60Hz+)之间平滑视觉位置,消除跳帧抖动。
// SpritePixelSnapperLateUpdate +1000在插值结果基础上吸附到像素网格
// 与 CameraPixelSnapper 同格对齐,消除亚像素模糊;停止时 ≤2 帧像素追赶不可感知。
_rb.interpolation = RigidbodyInterpolation2D.Interpolate;
// 开启连续碰撞检测CCDKinematic 移动平台通过 MovePosition 将 Dynamic 角色推向墙体时,
// CCD 会沿移动路径追踪碰撞,确保角色在物理层被墙体表面拦截,而不是在离散步骤中穿透墙体。
_rb.collisionDetectionMode = CollisionDetectionMode2D.Continuous;
}
private void FixedUpdate()
{
// 平台速度双缓冲换入Platform(-300) 已写入 _next换入 _current 供 Move() 叠加。
_platformVelocity = _nextPlatformVelocity;
_nextPlatformVelocity = Vector2.zero;
// 优先处理来自 Update 的强制清零请求(在状态机 OnStateFixedUpdate 之前执行)。
if (_pendingHorizontalZero)
{
_inputVelocityX = 0f;
_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);
_wallCoyoteTimer = Mathf.Max(0f, _wallCoyoteTimer - Time.fixedDeltaTime);
#if UNITY_EDITOR
_dbg_VelocityX = _rb.velocity.x;
_dbg_VelocityY = _rb.velocity.y;
_dbg_IsGrounded = _isGrounded;
_dbg_OnOneWayPlatform = _onOneWayPlatform;
_dbg_HasCoyoteTime = _coyoteTimer > 0f;
_dbg_HasWallCoyoteTime = _wallCoyoteTimer > 0f;
_dbg_IsWallLeft = _isWallLeft;
_dbg_IsWallRight = _isWallRight;
_dbg_CancelWindowOpen = _cancelWindowOpen;
_dbg_FacingDirection = _facingDirection;
_dbg_Position = $"({transform.position.x:F1}, {transform.position.y:F1})";
#endif
}
// ── 移动 ──────────────────────────────────────────────────────────────
/// <summary>
/// 直接赋予目标水平速度(按键即全速,松键即停,无加速过渡)。
/// 地面状态每帧直接到达全速;空中调用时同样即时,但配合 ApplyAirDrag
/// 在无输入时自然减速,保留跳出时的动量。
/// 若当前站在移动平台上,自动叠加 <c>_platformVelocity.x</c> 保持同步;
/// <c>_inputVelocityX</c> 只记录玩家输入分量,供 UpdateFacing / ApplyAirDrag 使用。
/// 当平台速度方向与已贴墙方向相同时,不叠加平台水平速度,
/// 防止速度叠加把角色推入墙体(物理接触已被 CCD 拦截,但速度覆盖会继续施力)。
/// </summary>
public void Move(float speedX)
{
_inputVelocityX = speedX;
// 若平台将角色推向已贴墙一侧,忽略平台水平分量:
// 物理层CCD + 碰撞体)已阻止角色穿墙,速度层若继续施加同向力,
// 会在每帧产生累积穿透,导致角色卡入或被弹出墙体。
float platformX = _platformVelocity.x;
if ((platformX > 0f && _isWallRight) || (platformX < 0f && _isWallLeft))
platformX = 0f;
_rb.velocity = new Vector2(speedX + platformX, _rb.velocity.y);
}
/// <summary>
/// 空中无输入时施加空气阻力:水平速度乘以 <paramref name="factor"/>
/// 低于阈值时归零,避免速度无限趋近 0。
/// 在 FallState / JumpState / WallJumpState 的 OnStateFixedUpdate 中调用。
/// </summary>
public void ApplyAirDrag(float factor)
{
// 空中不在平台上_platformVelocity = 0velocity.x == _inputVelocityX同步写回保持一致。
float newX = _rb.velocity.x * factor;
if (Mathf.Abs(newX) < 0.05f) newX = 0f;
_inputVelocityX = newX;
_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);
}
/// <summary>
/// 二段跳。覆盖当前垂直速度为 DoubleJumpForce。
/// FallState / JumpState 在检测到 HasAbility(DoubleJump) && AirJumpsLeft > 0 时调用。
/// </summary>
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() { _inputVelocityX = 0f; _rb.velocity = Vector2.zero; }
public void ZeroHorizontalVelocity()
{
_inputVelocityX = 0f;
_rb.velocity = new Vector2(0f, _rb.velocity.y);
// 设置标记:下一个 FixedUpdate 开头再次强制清零,
// 防止因读到旧输入而把速度重新写成非零值。
_pendingHorizontalZero = true;
}
// ── 朝向 ──────────────────────────────────────────────────────────────
public void UpdateFacing()
{
// 读取玩家输入速度(不含平台分量),避免平台横向运动驱动朝向翻转。
float vx = _inputVelocityX;
if (Mathf.Abs(vx) < 0.1f) return;
int dir = vx > 0f ? 1 : -1;
if (dir == _facingDirection) return;
_facingDirection = dir;
// 翻转整个 Transform而非仅 SpriteRenderer.flipX
// 确保 WeaponSocket / SkillSocket 等所有子节点的 HitBox 及投射物生成点随朝向镜像。
transform.localScale = new Vector3(dir, 1f, 1f);
}
/// <summary>
/// 强制设定朝向,并同步清零速度,确保传送/出生后不被旧速度立即覆盖。
/// dir+1 = 朝右,-1 = 朝左。
/// </summary>
public void SetFacingImmediate(int dir)
{
if (dir == 0) return;
_facingDirection = dir;
transform.localScale = new Vector3(dir, 1f, 1f);
_rb.velocity = Vector2.zero;
}
/// <summary>
/// 仅翻转视觉朝向,不修改速度。用于蹬墙跳瞬间需要立即转向而不中断跳跃速度的场景。
/// dir+1 = 朝右,-1 = 朝左。
/// </summary>
public void FlipFacing(int dir)
{
if (dir == 0 || dir == _facingDirection) return;
_facingDirection = dir;
transform.localScale = new Vector3(dir, 1f, 1f);
}
// ── 取消窗口 ──────────────────────────────────────────────────────────
public void SetCancelWindowOpen(bool open) => _cancelWindowOpen = open;
// ── 墙壁土狼时间 ──────────────────────────────────────────────────────
/// <summary>
/// 由 WallSlideState 在切换到 FallState 时调用:启动墙壁土狼计时器。
/// 窗口内HasWallCoyoteTime == true按跳跃键仍可触发蹬墙跳。
/// wallDir+1 = 右墙 / -1 = 左墙。
/// </summary>
public void StartWallCoyote(int wallDir)
{
_wallCoyoteTimer = _config.WallCoyoteTime;
_wallCoyoteDir = wallDir;
}
/// <summary>消耗墙壁土狼时间,防止同一帧被多次触发。</summary>
public void ConsumeWallCoyote() => _wallCoyoteTimer = 0f;
// ── IPassengerReceiver ────────────────────────────────────────────────
/// <summary>
/// MovingPlatform.FixedUpdate(-300) 推送本帧平台期望位移。
/// 转换为速度delta / fixedDeltaTime写入下帧缓冲区 _next
/// 在本类 FixedUpdate(-200) 换入,由 Move() 叠加到水平速度。
/// </summary>
public void SetPlatformDelta(Vector2 delta)
=> _nextPlatformVelocity += delta / Time.fixedDeltaTime;
/// <summary>
/// 乘客离开平台时调用,传入平台当前帧速度。
/// 水平速度已由最后一次 Move() 自然携带(包含 _platformVelocity.x仅继承垂直分量。
/// </summary>
public void OnLeavePlatform(Vector2 platformVelocity)
=> _rb.velocity += new Vector2(0f, platformVelocity.y);
// ── 冲刺 ──────────────────────────────────────────────────────────────
/// <summary>
/// 施加冲刺速度DashState/DashState 调用)。
/// direction 应为归一化水平向量±1, 0
/// 冲刺期间调用 SetGravityScale(0) 可关闭重力;结束后恢复默认重力。
/// </summary>
public void Dash(Vector2 direction, float speed)
{
_rb.velocity = new Vector2(direction.x * speed, 0f);
}
/// <summary>
/// 壁滑:将垂直速度限制为 -speed每帧调用以约束最大下滑速度
/// WallSlideState.OnStateFixedUpdate 根据正常/受限模式传入不同速度。
/// </summary>
public void ApplyWallSlide(float speed)
{
float targetY = -speed;
if (_rb.velocity.y < targetY)
_rb.velocity = new Vector2(_rb.velocity.x, targetY);
}
/// <summary>
/// 背墙跳Jump Away远离墙壁斜上方弹出。
/// wallDir = +1 (右墙) 或 -1 (左墙),水平方向与之相反。
/// </summary>
public void WallJumpAway(int wallDir)
{
_rb.velocity = new Vector2(-wallDir * _config.WallJumpAwayForceX, _config.WallJumpAwayForceY);
_coyoteTimer = 0f;
}
/// <summary>
/// 对墙跳Jump Toward沿墙壁方向偏向正上方弹出水平分量较小。
/// wallDir = +1 (右墙) 或 -1 (左墙),水平方向与之相同。
/// </summary>
public void WallJumpToward(int wallDir)
{
_rb.velocity = new Vector2(wallDir * _config.WallJumpTowardForceX, _config.WallJumpTowardForceY);
_coyoteTimer = 0f;
}
/// <summary>将垂直速度归零(抓墙悬挂时每帧调用,防止下滑)。</summary>
public void ZeroVerticalVelocity()
{
if (_rb.velocity.y < 0f)
_rb.velocity = new Vector2(_rb.velocity.x, 0f);
}
/// <summary>
/// 单向平台穿落(↓ + 跳跃键触发)。
/// 找到脚下的 <see cref="IDropThrough"/> 并临时禁用其碰撞器;
/// 同帧清除 IsGrounded / OnOneWayPlatform状态机无需等到下一物理帧即可转换到 FallState。
/// </summary>
public void DropThroughPlatform()
{
if (!_onOneWayPlatform) return;
for (int i = 0; i < _groundHitCount; i++)
{
if (_groundBuffer[i] != null &&
_groundBuffer[i].TryGetComponent<IDropThrough>(out var platform))
{
platform.TriggerDropThrough();
// 立即清除着地状态,让状态机本帧即可切换到 FallState
_isGrounded = false;
_onOneWayPlatform = false;
_coyoteTimer = 0f;
break;
}
}
}
// ── Physics 检测 ──────────────────────────────────────────────────────
private void CheckGrounded()
{
if (_groundCheck == null) return;
_groundHitCount = Physics2D.OverlapBoxNonAlloc(
_groundCheck.position, _groundCheckSize, 0f, _groundBuffer, _groundLayer);
_isGrounded = _groundHitCount > 0;
// 检测是否站在单向平台(含 IDropThrough 组件的碰撞体)
_onOneWayPlatform = false;
for (int i = 0; i < _groundHitCount; i++)
{
if (_groundBuffer[i] != null &&
_groundBuffer[i].TryGetComponent<IDropThrough>(out _))
{
_onOneWayPlatform = true;
break;
}
}
_currentSurface = (_isGrounded && _onOneWayPlatform)
? SurfaceType.OneWayPlatform
: SurfaceType.Ground;
}
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<Collider2D>())
{
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);
}
}
/// <summary>当前所在地面类型(用于脚步声等反馈)。</summary>
public enum SurfaceType
{
Ground,
OneWayPlatform,
Slope,
Ice,
}
}