using UnityEngine; namespace BaseGames.Player.States { /// /// 跳跃状态。 /// - 一段跳 / 郊狼跳:OnStateEnter 时调用 Move.Jump()。 /// - 二段跳(二段跳能力解锁后可用):上升或下落途中再按跳跃且 AirJumpsLeft > 0, /// 调用 Move.DoubleJump(),重播跳跃动画,不离开本状态(保持速度截断逻辑)。 /// - 空中冲刺:上升途中按冲刺且 HasAbility(AirDash) → DashState。 /// - 变高跳:松开跳跃键触发 JumpCancelledEvent → CutJump()(系数 = JumpCutMultiplier)。 /// - _isDoubleJump:由 FallState 在转换前通过 SetDoubleJump(true) 预设, /// 使 OnStateEnter 对二段跳调用 Move.DoubleJump() 而非 Move.Jump()。 /// public class JumpState : PlayerStateBase { private bool _isDoubleJump; // 最小跳跃窗口:窗口内松键记录 _cutPending,窗口结束时统一执行 CutJump, // 保证短按始终在相同 vy 时截断 → 最小跳跃高度完全一致,消除帧级手感抖动。 private float _minJumpTimer; private bool _cutPending; public JumpState(PlayerController owner) : base(owner) { } /// /// 由 FallState 在转换到 JumpState 前调用,标记本次进入为二段跳, /// 以便 OnStateEnter 使用 DoubleJumpForce 而非 JumpForce。 /// public void SetDoubleJump(bool isDouble) => _isDoubleJump = isDouble; public override void OnStateEnter() { if (AnimCfg?.Jump != null) Anim.Play(AnimCfg.Jump); if (_isDoubleJump) { Move.DoubleJump(); // 优先播放专属空中跳跃动画,未配置时回退到普通跳跃动画 var airJumpClip = AnimCfg?.AirJump ?? AnimCfg?.Jump; if (airJumpClip != null) Anim?.Play(airJumpClip); } else Move.Jump(); _isDoubleJump = false; // 消耗标记 ResetMinJumpWindow(); Input.JumpCancelledEvent += OnJumpCancelled; // 开启上升阶段贴墙 vy 保护:防止物理摩擦降低跳跃最高点 Move.SetPreserveVyOnWallContact(true); } public override void OnStateUpdate() { // 上升结束时转为下落。 // 例外:按住朝墙方向且射线已检测到墙时,物理摩擦可能将 vy 瞬间压到 ≤ 0, // 此时不触发 FallState,让后续抓墙检测在 vy 稳定后接管状态转换, // 防止因一帧摩擦导致跳跃高度降低并错误进入 FallState。 if (Move.Rb.velocity.y <= 0f) { bool pressingTowardDetectedWall = false; if (Mathf.Abs(Input.MoveInput.x) > 0.01f) { int inputDir = Input.MoveInput.x > 0 ? 1 : -1; bool cwRay = inputDir > 0 ? Move.IsWallRight : Move.IsWallLeft; var wd0 = Owner.WallDetector; pressingTowardDetectedWall = cwRay || (wd0 != null && wd0.IsTouchingWall && wd0.WallDirection == inputDir); } if (!pressingTowardDetectedWall) { _owner.TransitionTo(_owner.GetState()); return; } } // ── 下冲刺(下 + 冲刺 → 向下冲刺,优先于普通冲刺)────────────────────── // 按住下方向 + 冲刺键,且已解锁 DownDash 能力、空中冲刺次数未耗尽 var dashState = Owner.GetState(); if (dashState != null && dashState.CanDashMidAir && Input.MoveInput.y < -0.5f && Stats != null && Stats.HasAbility(AbilityType.DownDash) && Buffer.ConsumeDash()) { _owner.TransitionTo(_owner.GetState()); return; } // 冲刺(地面/空中统一使用 DashState,空中限一次,优先于二段跳:冲刺可保存二段跳机会) // 先确认能力与冷却均满足,再消耗缓冲,避免无操作时静默吃掉输入 if (dashState != null && dashState.CanDashMidAir && Stats != null && Stats.HasAbility(AbilityType.Dash) && Buffer.ConsumeDash()) { _owner.TransitionTo(dashState); return; } // 二段跳:上升阶段即可触发(先确认有次数再消耗缓冲,避免静默消耗) if (Owner.AirJumpsLeft > 0 && Buffer.ConsumeJump()) { Owner.UseAirJump(); Move.DoubleJump(); ResetMinJumpWindow(); // 二段跳重置最小跳跃窗口 // 播放空中跳跃动画,未配置时回退到 Jump var airJumpClip = AnimCfg?.AirJump ?? AnimCfg?.Jump; if (airJumpClip != null) Anim?.Play(airJumpClip); return; } // 空中攻击:Move Y > 0 → 上劈;Y < -0.5 且解锁下劈 → 下劈;其余 → 空中攻击 if (Buffer.ConsumeAttack()) { if (Input.MoveInput.y > 0.5f) _owner.TransitionTo(_owner.GetState()); else if (Input.MoveInput.y < -0.5f && Stats != null && Stats.HasAbility(AbilityType.DownSlash)) _owner.TransitionTo(_owner.GetState()); else _owner.TransitionTo(_owner.GetState()); return; } // ── 抓墙:贴墙 + 朝向墙壁按键,或蹬墙跳后的自动抓墙────────────── // 仅在上升结束后(vy ≤ 0)才进入抓墙状态;上升阶段阻止转换以保留顶点重力缩减, // 避免贴墙按方向键导致跳跃最大高度降低。 var wd = Owner.WallDetector; if (wd != null && wd.IsTouchingWall && !Move.IsGrounded && !Move.IsRising) { int wallDir = wd.WallDirection; bool pressingTowardWall = Mathf.Abs(Input.MoveInput.x) > 0.01f && (int)Mathf.Sign(Input.MoveInput.x) == wallDir; if ((pressingTowardWall || Owner.IsPostWallJump) && Stats != null && Stats.HasAbility(AbilityType.WallCling)) { _owner.TransitionTo(_owner.GetState()); return; } } } public override void OnStateFixedUpdate() { // ── 最小跳跃窗口:窗口内松键不立即截断,窗口结束时统一处理 ───────────── // 保证短按(无论在窗口内哪帧松键)都在相同 vy 时执行 CutJump → 一致的最小跳跃高度。 if (_minJumpTimer > 0f) { _minJumpTimer -= Time.fixedDeltaTime; if (_minJumpTimer <= 0f) { // 窗口到期:已收到松键事件,或当前键未按住(InputBuffer 延迟导致进入时键已释放) if (_cutPending || !Input.IsJumpHeld) Move.CutJump(); _cutPending = false; } } // ── 顶点悬停:第一判断 |vy|,动态切换重力缩放系数 ──────────────── // |垂直速度| 低于顶点阈值时,重力缩减至 ApexGravityMultiplier 倍, // 产生角色在跳跃顶点起起“滒空”的手感,属于高重力平台游戏的标志性特征。 // 转入 FallState 时 OnStateExit 会恢复默认重力,不会漏到 FallState。 float absVY = Mathf.Abs(Move.Rb.velocity.y); if (absVY < Cfg.ApexThreshold) Move.SetGravityScale(Cfg.DefaultGravityScale * Cfg.ApexGravityMultiplier); else Move.SetGravityScale(Cfg.DefaultGravityScale); // ── 空中水平移动(朝向墙壁时停止施力,防止贴墙悬停)────────────── if (Mathf.Abs(Input.MoveInput.x) > 0.01f) { int inputDir = Input.MoveInput.x > 0 ? 1 : -1; var wd = Owner.WallDetector; // PlayerWallDetector(order 0)在状态机(order -100)之后执行, // 其 IsTouchingWall / HasPartialContact 对状态机而言是上一帧的结果。 // PlayerMovement.CheckWalls()(order -200)在状态机之前执行, // IsWallLeft / IsWallRight 是当帧最新结果,可提前一帧停止施力, // 防止角色以 RunSpeed 压入墙面后物理引擎的摩擦力降低上升速度。 bool currentFrameWall = inputDir > 0 ? Move.IsWallRight : Move.IsWallLeft; if (wd != null && wd.IsTouchingWall && wd.WallDirection == inputDir) { // 按下朝墙方向键 + 在墙上(且未著地)→ 进入抓墙状态 // 仅在上升结束后(vy ≤ 0)才进入抓墙状态;上升阶段只停止水平施力, // 保留顶点重力缩减逻辑,防止贴墙按键导致跳跃最大高度降低。 var wss = Owner.GetState(); if (wss != null && !Move.IsGrounded && !Move.IsRising) { wss.PrepareEnter(inputDir); Owner.TransitionTo(wss); return; } Move.ZeroHorizontalVelocity(); } else if (currentFrameWall || (wd != null && wd.HasPartialContact(inputDir))) { // 当帧单射线已检测到墙(PlayerMovement -200 先于状态机 -100 执行), // 或上一帧部分射线命中——停止施力,防止以 RunSpeed 压入墙面产生摩擦力。 Move.ZeroHorizontalVelocity(); } else Move.Move(Input.MoveInput.x * Cfg.RunSpeed); } else Move.ZeroHorizontalVelocity(); } public override void OnStateExit() { // 顶点悬停可能已降低重力,离开本状态时必须恢复; // 否则进入 FallState/DashState 等后续状态时重力仍低于默认值。 Move.SetGravityScale(Cfg.DefaultGravityScale); Move.SetPreserveVyOnWallContact(false); Input.JumpCancelledEvent -= OnJumpCancelled; } private void OnJumpCancelled() { if (_minJumpTimer > 0f) _cutPending = true; // 窗口内:推迟到窗口结束时执行,保证一致的截断时机 else Move.CutJump(); // 窗口已过(长按):立即截断 → 变高跳 } private void ResetMinJumpWindow() { _minJumpTimer = Cfg.MinJumpTime; _cutPending = false; } } }