角色能力,存档

This commit is contained in:
2026-05-19 11:50:21 +08:00
parent d25f237e76
commit 2dcb7a961a
136 changed files with 36035 additions and 27551 deletions

View File

@@ -1,89 +0,0 @@
using UnityEngine;
namespace BaseGames.Player.States
{
/// <summary>
/// 空中冲刺状态(架构 05_PlayerModule §12
/// 与地面 DashState 独立,消耗 MaxAerialDashes 次数;
/// 冲刺方向在进入时锁定为当前朝向(进入时锁定朝向,冲刺期间不可通过输入改变方向)。
/// </summary>
public class AerialDashState : PlayerStateBase
{
private float _timer;
private int _aerialDashesLeft;
private int _facingDir;
public bool HasAerialDash => _aerialDashesLeft > 0;
// ── IsInvincible 不再在状态层硬编码,与 DashState 保持一致:
// 实际无敌用 Stats.BeginInvincibility(DashInvincibilityDuration) 面题。
// PlayerController.TakeDamage 已将 Stats.IsInvincible 纳入硬直判断。
public AerialDashState(PlayerController owner) : base(owner)
{
_aerialDashesLeft = 1;
}
public override void OnStateEnter()
{
_aerialDashesLeft = Mathf.Max(0, _aerialDashesLeft - 1);
_facingDir = Owner.FacingDirection;
_timer = Cfg.DashDuration;
// 无敌帧:与地面冲刺共享同一无敌 CDDashState._invincibilityCooldownTimer
// 条件 1已解锁 InvincibleDash
// 条件 2共享无敌冷却已就绪
var dashState = Owner.GetState<DashState>();
if (Stats != null && Stats.HasAbility(AbilityType.InvincibleDash)
&& dashState != null && dashState.CanGrantInvincibility)
{
Stats.BeginInvincibility(Cfg.DashInvincibilityDuration);
dashState.ResetInvincibilityCooldown(Cfg.DashInvincibilityCooldown);
}
// 关闭重力,施加冲刺速度(方向锁定为进入时朝向,不受输入影响)
Move?.SetGravityScale(0f);
Move?.Dash(new Vector2(_facingDir, 0f), Cfg.DashSpeed);
// 播放冲刺动画(复用地面冲刺动画)
if (AnimCfg?.Dash != null) Anim?.Play(AnimCfg.Dash);
}
public override void OnStateUpdate()
{
_timer -= Time.deltaTime;
if (_timer <= 0f)
{
Move?.SetGravityScale(Cfg.DefaultGravityScale);
Owner.TransitionTo(Owner.GetState<FallState>());
return;
}
// 撞墙立即终止冲刺(碰到实体墙立即中止,避免压墙卡住)
var wd = Owner.WallDetector;
if (wd != null && wd.IsTouchingWall && wd.WallDirection == _facingDir)
{
Move?.SetGravityScale(Cfg.DefaultGravityScale);
Owner.TransitionTo(Owner.GetState<FallState>());
}
}
public override void OnStateExit()
{
Move?.SetGravityScale(Cfg.DefaultGravityScale);
}
public override void OnStateFixedUpdate()
{
// 冲刺期间保持锁定方向速度(与 DashState 一致,使用 _facingDir
if (_timer > 0f)
Move?.Dash(new Vector2(_facingDir, 0f), Cfg.DashSpeed);
}
/// <summary>着地时重置空中冲刺次数(由 PlayerController 在着地时调用)。</summary>
public void ResetAerialDashes()
{
_aerialDashesLeft = Cfg.MaxAerialDashes;
}
}
}

View File

@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 38830e1649a9eb548b20540a737bdf09
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,34 +1,38 @@
using UnityEngine;
using BaseGames.Combat;
namespace BaseGames.Player.States
{
/// <summary>
/// 空中攻击状态(架构 05_PlayerModule §2
/// 由 FallState / JumpState 接收攻击输入后转入;
/// Animancer 帧事件驱动 HitBoxAir 激活;结束后回到 FallState。
/// HitBox 时间由 ComboStepConfig.hitBoxEnter/Exit 驱动;结束后按 recoveryTime 延迟取消窗口。
/// </summary>
public class AirAttackState : PlayerStateBase
{
private float _recoveryEndTime;
public AirAttackState(PlayerController owner) : base(owner) { }
public override void OnStateEnter()
{
Owner.Combat?.SetComboSegmentSource(0);
Owner.Combat?.EnableWeaponHitBox(AttackDirection.Air);
var step = Owner.Weapon?.ActiveWeapon?.GetDirStep(AttackDirection.Air);
float spd = Stats?.AnimatorSpeedMultiplier ?? 1f;
var clip = Owner.Weapon?.ActiveWeapon?.airAttackClip;
if (clip != null && clip.Clip != null)
if (step?.clip?.Clip != null)
{
var state = Anim.Play(clip);
state.Events(this).OnEnd = OnClipEnd;
}
else if (AnimCfg?.AirAttack != null)
{
var state = Anim.Play(AnimCfg.AirAttack);
state.Events(this).OnEnd = OnClipEnd;
var animState = Anim.Play(step.Value.clip);
animState.Speed *= spd;
var events = animState.Events(this);
events.OnEnd = OnClipEnd;
var s = step.Value;
events.Add(s.hitBoxEnter, () => Owner.Combat?.EnableWeaponHitBox(AttackDirection.Air, s.hitBoxId));
events.Add(s.hitBoxExit, () => Owner.Combat?.DisableAllWeaponHitBoxes());
}
else
{
Owner.Combat?.EnableWeaponHitBox(AttackDirection.Air);
OnClipEnd();
}
}
@@ -36,10 +40,24 @@ namespace BaseGames.Player.States
public override void OnStateExit()
{
Owner.Combat?.DisableAllWeaponHitBoxes();
Move?.SetCancelWindowOpen(false);
}
public override void OnStateUpdate()
{
if (Time.time >= _recoveryEndTime)
Move.SetCancelWindowOpen(true);
}
private void OnClipEnd()
{
Owner.Combat?.DisableAllWeaponHitBoxes();
var step = Owner.Weapon?.ActiveWeapon?.GetDirStep(AttackDirection.Air);
float spd = Stats?.AnimatorSpeedMultiplier ?? 1f;
float recovery = (step?.recoveryTime ?? 0.05f) / spd;
_recoveryEndTime = Time.time + recovery;
Move.SetCancelWindowOpen(false);
Owner.TransitionTo(Owner.GetState<FallState>());
}
}

View File

@@ -1,69 +1,193 @@
using UnityEngine;
using BaseGames.Combat;
namespace BaseGames.Player.States
{
/// <summary>
/// 地面攻击状态(3 段连击)。
/// 由 PlayerController 实例化AttackEvent 触发切换
/// 通过 Animancer 帧事件驱动 HitBox 激活/关闭。
/// 地面攻击状态(任意段连击)。
/// 攻速倍率Stats.AnimatorSpeedMultiplier缩放 clip 播放速度及 recoveryTime/comboTimeout
/// 状态机流程:
/// 播放动画 → [comboInputOpen] 接受输入 → [cancelWindowOpen] 允许跳跃/冲刺 →
/// 动画结束 → 硬直(recoveryTime) → 连击等待(comboTimeout) → 无输入则回 Idle
/// </summary>
public class AttackState : PlayerStateBase
{
private int _comboIndex;
private int _comboIndex;
private bool _comboInputPending; // 连击窗口内已收到攻击输入
private bool _comboWindowOpen; // 当前是否接受连击输入
// 动画结束后的两阶段计时
private bool _waitingAfterAnim; // 是否在动画结束后等待阶段
private float _recoveryEndTime; // 硬直结束时刻
private float _comboTimeoutEnd; // 连击等待结束时刻
public AttackState(PlayerController owner) : base(owner) { }
public override void OnStateEnter()
{
_comboIndex = 0;
_comboIndex = 0;
_comboInputPending = false;
_comboWindowOpen = false;
_waitingAfterAnim = false;
Input.AttackEvent += OnAttackInput;
PlayAttackClip();
Input.AttackEvent += OnAttackInput;
}
public override void OnStateExit()
{
Input.AttackEvent -= OnAttackInput;
Owner.Combat?.DisableAllWeaponHitBoxes();
Move?.SetCancelWindowOpen(false);
}
public override void OnStateUpdate()
{
if (!_waitingAfterAnim)
{
// ── 动画播放中:只处理取消窗口(跳跃/冲刺打断)──────────────
if (!Move.CancelWindowOpen) return;
TryConsumeCancelInput();
return;
}
// ── 动画结束后等待阶段 ─────────────────────────────────────────
float now = Time.time;
// 硬直结束后开放取消窗口
if (!Move.CancelWindowOpen && now >= _recoveryEndTime)
Move.SetCancelWindowOpen(true);
// 有缓存的连击输入 → 立即推进
if (_comboInputPending)
{
AdvanceCombo();
return;
}
// 连击超时 → 返回 Idle
if (now >= _comboTimeoutEnd)
{
Owner.TransitionTo(Owner.GetState<IdleState>());
return;
}
// 恢复期结束后仍可跳跃/冲刺取消
if (Move.CancelWindowOpen)
TryConsumeCancelInput();
}
public override void OnStateUpdate() { }
public override void OnStateFixedUpdate() { }
// ── 内部 ──────────────────────────────────────────────────────────────
private void PlayAttackClip()
{
// ⚠️ 字段名 GroundAttacks非 AttackChainClips
var clip = AnimCfg.GroundAttacks[_comboIndex];
var state = Anim.Play(clip);
var events = state.Events(this);
_waitingAfterAnim = false;
_comboWindowOpen = false;
Move.SetCancelWindowOpen(false);
var weapon = Owner.Weapon?.ActiveWeapon;
if (weapon == null)
{
UnityEngine.Debug.LogWarning("[AttackState] 未找到 ActiveWeapon请检查 WeaponManager 配置。");
Owner.TransitionTo(Owner.GetState<IdleState>());
return;
}
var step = weapon.GetGroundStep(_comboIndex);
if (step.clip == null || step.clip.Clip == null)
{
UnityEngine.Debug.LogWarning($"[AttackState] 连击段 {_comboIndex} 动画未配置,请检查 {weapon.weaponId}。");
Owner.TransitionTo(Owner.GetState<IdleState>());
return;
}
float spd = Stats?.AnimatorSpeedMultiplier ?? 1f;
var animState = Anim.Play(step.clip);
animState.Speed *= spd; // 在 ClipTransition 自身速度基础上叠加攻速
var events = animState.Events(this);
events.OnEnd = OnClipEnd;
// HitBox 由 Animancer 归一化时间事件驱动(时间点配置于 PlayerAnimationConfigSO
var timings = AnimCfg?.GroundAttackTimings;
float enterTime = timings != null && _comboIndex < timings.Length
? timings[_comboIndex].HitBoxEnter : 0.3f;
float exitTime = timings != null && _comboIndex < timings.Length
? timings[_comboIndex].HitBoxExit : 0.6f;
events.Add(enterTime, () => Owner.Combat?.EnableWeaponHitBox(AttackDirection.Ground));
events.Add(exitTime, () => Owner.Combat?.DisableAllWeaponHitBoxes());
// HitBox 时间窗口capture step by value for closure safety
var capturedStep = step;
events.Add(capturedStep.hitBoxEnter, () =>
Owner.Combat?.EnableWeaponHitBox(AttackDirection.Ground,
capturedStep.hitBoxId, capturedStep.damageSource));
events.Add(capturedStep.hitBoxExit, () => Owner.Combat?.DisableAllWeaponHitBoxes());
// 连击输入窗口
if (capturedStep.comboInputOpen > 0f)
events.Add(capturedStep.comboInputOpen, () => _comboWindowOpen = true);
else
_comboWindowOpen = true; // 0 = 立即开放
if (capturedStep.comboInputClose > 0f)
events.Add(capturedStep.comboInputClose, () => _comboWindowOpen = false);
// 取消窗口(跳跃/冲刺)
if (capturedStep.cancelWindowOpen > 0f)
events.Add(capturedStep.cancelWindowOpen, () => Move.SetCancelWindowOpen(true));
}
private void OnClipEnd()
{
Owner.Combat?.DisableAllWeaponHitBoxes();
Owner.TransitionTo(Owner.GetState<IdleState>());
_comboWindowOpen = false;
Move.SetCancelWindowOpen(false);
// 如果已有缓存输入,直接推进(零延迟连击)
if (_comboInputPending)
{
AdvanceCombo();
return;
}
// 进入动画后等待阶段
var step = Owner.Weapon?.ActiveWeapon?.GetGroundStep(_comboIndex) ?? default;
float spd = Stats?.AnimatorSpeedMultiplier ?? 1f;
float now = Time.time;
_waitingAfterAnim = true;
_recoveryEndTime = now + step.recoveryTime / spd;
_comboTimeoutEnd = _recoveryEndTime + step.comboTimeout / spd;
}
private void AdvanceCombo()
{
int maxCombo = Owner.Weapon?.ActiveWeapon?.GroundComboCount ?? 1;
if (_comboIndex < maxCombo - 1)
{
_comboIndex++;
_comboInputPending = false;
PlayAttackClip();
}
else
{
// 已是最后一段,忽略多余输入,等待超时
_comboInputPending = false;
}
}
private void TryConsumeCancelInput()
{
if (Buffer.ConsumeJump())
{
Owner.TransitionTo(Owner.GetState<JumpState>());
return;
}
var ds = Owner.GetState<DashState>();
if (ds != null && ds.CanDash
&& Stats != null && Stats.HasAbility(AbilityType.Dash)
&& Buffer.ConsumeDash())
{
Owner.TransitionTo(ds);
}
}
private void OnAttackInput()
{
// 连击段数从配置 SO 动态读取,无须修改代码即可支持任意段数连击
int maxCombo = AnimCfg?.GroundAttacks?.Length ?? 1;
if (_comboIndex < maxCombo - 1)
{
_comboIndex++;
PlayAttackClip();
}
if (_comboWindowOpen || _waitingAfterAnim)
_comboInputPending = true;
}
}
}

View File

@@ -19,7 +19,15 @@ namespace BaseGames.Player.States
private float _invincibilityCooldownTimer;
private int _facingDir;
public bool CanDash => _cooldownTimer <= 0f;
/// <summary>本次离地后是否已消耗过一次空中冲刺。落地或下劈命中Pogo时重置。</summary>
private bool _airDashUsed;
public bool CanDash => _cooldownTimer <= 0f;
/// <summary>空中冲刺可用条件:冷却就绪 且 本次离地内尚未冲刺过。</summary>
public bool CanAirDash => _cooldownTimer <= 0f && !_airDashUsed;
/// <summary>重置空中冲刺次数(落地或 Pogo 时由 IdleState/RunState/DownAttackState 调用)。</summary>
public void ResetAirDash() => _airDashUsed = false;
/// <summary>
/// 无敌帧是否已冷却,即本次冲刺可以获得无敌。
@@ -44,11 +52,20 @@ namespace BaseGames.Player.States
_facingDir = Owner.FacingDirection;
_timer = Cfg.DashDuration;
// 空中冲刺:记录本次离地已使用冲刺(地面冲刺不消耗,仅空中限制一次)
if (!Move.IsGrounded)
_airDashUsed = true;
// 无敌帧:
// 条件 1已解锁 InvincibleDash
// 条件 2无敌冷却已就绪防止 spam 冲刺连序无敌)
// 窗口时长 = DashInvincibilityDuration < DashDuration冲刺后段无保护
if (Stats != null && Stats.HasAbility(AbilityType.InvincibleDash) && CanGrantInvincibility)
// 在设置冷却计时器前捕获,供后续动画选择使用
bool isInvincibleDash = Stats != null
&& Stats.HasAbility(AbilityType.InvincibleDash)
&& CanGrantInvincibility;
if (isInvincibleDash)
{
Stats.BeginInvincibility(Cfg.DashInvincibilityDuration);
_invincibilityCooldownTimer = Cfg.DashInvincibilityCooldown;
@@ -58,8 +75,11 @@ namespace BaseGames.Player.States
Move?.SetGravityScale(0f);
Move?.Dash(new Vector2(_facingDir, 0f), Cfg.DashSpeed);
// 播放冲刺动画
if (AnimCfg?.Dash != null) Anim?.Play(AnimCfg.Dash);
// 播放冲刺动画:无敌冲刺使用专属 Clip留空时回退到普通冲刺 Clip
var dashClip = (isInvincibleDash && AnimCfg?.DashInvincible != null)
? AnimCfg.DashInvincible
: AnimCfg?.Dash;
if (dashClip != null) Anim?.Play(dashClip);
}
public override void OnStateUpdate()
@@ -71,10 +91,19 @@ namespace BaseGames.Player.States
return;
}
// 撞墙立即终止冲刺(碰到实体墙立即中止,避免压墙卡住)
var wd = Owner.WallDetector;
if (wd != null && wd.IsTouchingWall && wd.WallDirection == _facingDir)
EndDash();
// 跳跃可取消冲刺:冲刺期间按跳跃立即中断并起跳。
// 空中冲刺时若有剩余空中跳跃次数,消耗一次并使用二段跳力度。
if (Buffer.ConsumeJump())
{
if (!Move.IsGrounded && Owner.AirJumpsLeft > 0)
{
Owner.UseAirJump();
Owner.GetState<JumpState>()?.SetDoubleJump(true);
}
Owner.TransitionTo(Owner.GetState<JumpState>());
return;
}
// 注:碰墙时不中止冲刺,完成完整冲刺时长(物理阻止位移,但计时继续)
}
public override void OnStateExit()
@@ -93,6 +122,8 @@ namespace BaseGames.Player.States
private void EndDash()
{
// 双轴速度归零,防止冲刺结束时角色带着 DashSpeed 冲出平台边缘后继续向前飞行。
Move?.ZeroVelocity();
if (Move != null && Move.IsGrounded)
Owner.TransitionTo(Owner.GetState<IdleState>());
else

View File

@@ -23,21 +23,22 @@ namespace BaseGames.Player.States
if (Owner.Combat != null)
Owner.Combat.OnDownHitConfirmed += OnDownHitConfirmed;
Owner.Combat?.EnableWeaponHitBox(AttackDirection.Down);
var step = Owner.Weapon?.ActiveWeapon?.GetDirStep(AttackDirection.Down);
float spd = Stats?.AnimatorSpeedMultiplier ?? 1f;
var clip = Owner.Weapon?.ActiveWeapon?.downAttackClip;
if (clip != null && clip.Clip != null)
if (step?.clip?.Clip != null)
{
var state = Anim.Play(clip);
state.Events(this).OnEnd = OnClipEnd;
}
else if (AnimCfg?.DownAttack != null)
{
var state = Anim.Play(AnimCfg.DownAttack);
state.Events(this).OnEnd = OnClipEnd;
var animState = Anim.Play(step.Value.clip);
animState.Speed *= spd;
var events = animState.Events(this);
events.OnEnd = OnClipEnd;
var s = step.Value;
events.Add(s.hitBoxEnter, () => Owner.Combat?.EnableWeaponHitBox(AttackDirection.Down, s.hitBoxId));
events.Add(step.Value.hitBoxExit, () => Owner.Combat?.DisableAllWeaponHitBoxes());
}
else
{
Owner.Combat?.EnableWeaponHitBox(AttackDirection.Down);
OnClipEnd();
}
@@ -58,7 +59,9 @@ namespace BaseGames.Player.States
{
if (_hasHitEnemy) return;
_hasHitEnemy = true;
// Pogo 弹跳:命中敌人后向上弹起
// Pogo 弹跳:命中敌人后向上弹起,同时重置空中能力(等同落地效果)
Owner.ResetAirJumps();
Owner.GetState<DashState>()?.ResetAirDash();
Move.Jump();
}

View File

@@ -5,8 +5,9 @@ namespace BaseGames.Player.States
/// <summary>
/// 下落状态。
/// - 郊狼跳CoyoteTimer > 0 时按跳跃 → 一段跳JumpState使用 JumpForce
/// - 二段跳CoyoteTimer 耗尽后按跳跃且 AirJumpsLeft > 0 → JumpState使用 DoubleJumpForce
/// - 空中冲刺HasAbility(AirDash) && HasAerialDash → AerialDashState。
/// - 空中跳跃CoyoteTimer 耗尽后按跳跃且 AirJumpsLeft > 0 → JumpState使用 DoubleJumpForce
/// - 冲刺HasAbility(Dash) &amp;&amp; DashState.CanAirDash → DashState(地面与空中统一,空中限一次)
/// - 抓墙:贴墙时按下朝向墙壁的方向键 → WallSlideState。
/// - 增强下落重力FallGravityMult确保下落快于上升手感紧实。
/// </summary>
public class FallState : PlayerStateBase
@@ -22,7 +23,8 @@ namespace BaseGames.Player.States
public override void OnStateUpdate()
{
// ── 跳跃输入(郊狼跳 / 二段跳)────────────────────────────────────
if (Buffer.ConsumeJump())
// 先确认有可用跳跃机会,再消耗缓冲,避免无操作时静默吃掉输入
if ((Move.HasCoyoteTime || Owner.AirJumpsLeft > 0) && Buffer.ConsumeJump())
{
if (Move.HasCoyoteTime)
{
@@ -30,30 +32,40 @@ namespace BaseGames.Player.States
_owner.TransitionTo(_owner.GetState<JumpState>());
return;
}
if (Owner.AirJumpsLeft > 0)
{
// 二段跳:通过 SetDoubleJump 标记 JumpState 使用 DoubleJumpForce
Owner.UseAirJump();
Owner.GetState<JumpState>()?.SetDoubleJump(true);
_owner.TransitionTo(_owner.GetState<JumpState>());
return;
}
// 无跳跃机会:输入已消耗,静默忽略(无可用跳跃机会时静默消耗输入缓冲)
// 二段跳:通过 SetDoubleJump 标记 JumpState 使用 DoubleJumpForce
Owner.UseAirJump();
Owner.GetState<JumpState>()?.SetDoubleJump(true);
_owner.TransitionTo(_owner.GetState<JumpState>());
return;
}
// ── 空中冲刺────────────────────────────────────────────────────────
if (Buffer.ConsumeDash()
&& Stats != null && Stats.HasAbility(AbilityType.AirDash))
// ── 冲刺(地面/空中统一使用 DashState────────────────────────────
// 先确认能力与冷却均满足,再消耗缓冲,避免无操作时静默吃掉输入
var dashState = Owner.GetState<DashState>();
if (dashState != null && dashState.CanAirDash
&& Stats != null && Stats.HasAbility(AbilityType.Dash)
&& Buffer.ConsumeDash())
{
var aerialDash = Owner.GetState<AerialDashState>();
if (aerialDash != null && aerialDash.HasAerialDash)
{
_owner.TransitionTo(aerialDash);
return;
}
_owner.TransitionTo(dashState);
return;
}
// ── 着地──────────────────────────────────────────────────────────
// ── 空中攻击Move Y > 0 → 上劈Y < -0.5 且解锁下劈 → 下劈;其余 → 空中攻击 ──
if (Buffer.ConsumeAttack())
{
if (Input.MoveInput.y > 0.5f)
_owner.TransitionTo(_owner.GetState<UpAttackState>());
else if (Input.MoveInput.y < -0.5f
&& Stats != null && Stats.HasAbility(AbilityType.DownSlash))
_owner.TransitionTo(_owner.GetState<DownAttackState>());
else
_owner.TransitionTo(_owner.GetState<AirAttackState>());
return;
}
// ── 着地回退(主逻辑已移至 OnStateFixedUpdate此处仅作极端情况保险────────────
// 正常情况下状态在 FixedUpdate 中已转换Update 执行时 _currentState 已是 IdleState/RunState
// 此段不会被执行。仅在初始帧等 FixedUpdate 尚未运行时作补充保障。
if (Move.IsGrounded)
{
Move.ZeroVelocity();
@@ -68,13 +80,55 @@ namespace BaseGames.Player.States
public override void OnStateFixedUpdate()
{
// 空中水平移动:有输入时立即覆盖至目标速度;无输入时施加空气阻力保留动量
// ── 着地检测(在 FixedUpdate 内与 CheckGrounded 同帧执行)────────────────────
// PlayerMovement.FixedUpdate(-200) 先于 PlayerController.FixedUpdate(-100) 执行,
// 此处读到的 IsGrounded 已是本物理帧最新结果,不存在跨帧读到过期值的问题。
// 落地检测放在 OnStateUpdateUpdate 阶段)时,若低帧率导致同帧内多次 FixedUpdate
// 最后一次 FixedUpdate 的 depenetration 弹回可能使 _isGrounded 在 Update 时为 false
// 进而导致无法着地。
if (Move.IsGrounded)
{
Move.ZeroVelocity();
if (Mathf.Abs(Input.MoveInput.x) > 0.1f)
_owner.TransitionTo(_owner.GetState<RunState>());
else
_owner.TransitionTo(_owner.GetState<IdleState>());
return;
}
// 空中水平移动:有输入时立即覆盖至目标速度;无输入时水平速度立即归零。
// 朝向墙壁时停止施力,防止物理摩擦使角色在空中贴墙悬停。
if (Mathf.Abs(Input.MoveInput.x) > 0.01f)
Move.Move(Input.MoveInput.x * Cfg.RunSpeed);
{
int inputDir = Input.MoveInput.x > 0 ? 1 : -1;
var wd = Owner.WallDetector;
if (wd != null && wd.IsTouchingWall && wd.WallDirection == inputDir)
{
// 按下朝墙方向键 + 在墙上(且未著地)→ 进入抓墙状态
var wss = Owner.GetState<WallSlideState>();
if (wss != null && !Move.IsGrounded)
{
wss.PrepareEnter(inputDir);
Owner.TransitionTo(wss);
return;
}
Move.ZeroHorizontalVelocity();
}
else if (wd != null && wd.HasPartialContact(inputDir))
{
// 仅部分射线命中(如检测点高于矮墙顶部),停止施加朝墙方向的水平速度,
// 防止角色边角被卡在墙顶而无法继续下落。
Move.ZeroHorizontalVelocity();
}
else
Move.Move(Input.MoveInput.x * Cfg.RunSpeed);
}
else
Move.ApplyAirDrag(Cfg.AirDragFactor);
Move.ZeroHorizontalVelocity();
// 增强下落重力FallGravityMult下落比上升更快手感更紧实
// 着地时已由上方提前 return此处无需额外判断 IsGrounded
// 避免持续下压速度与 depenetration 形成振荡导致 IsGrounded 持续为 false
if (Move.Rb.velocity.y < 0f)
{
float extraGrav = Physics2D.gravity.y * (Cfg.FallGravityMult - 1f) * Time.fixedDeltaTime;

View File

@@ -13,8 +13,9 @@ namespace BaseGames.Player.States
Anim.Play(AnimCfg.Idle);
Move?.ZeroHorizontalVelocity();
// 落地时重置空中能力计数器
Owner.GetState<AerialDashState>()?.ResetAerialDashes();
Owner.ResetAirJumps();
Owner.GetState<DashState>()?.ResetAirDash();
Owner.GetState<WallSlideState>()?.ResetWallGrab();
}
public override void OnStateUpdate()
@@ -29,12 +30,22 @@ namespace BaseGames.Player.States
_owner.TransitionTo(_owner.GetState<JumpState>());
return;
}
// 地面冲刺:需解锁 Dash 能力,且冲刺不在冷却
if (Buffer.ConsumeDash()
&& Stats != null && Stats.HasAbility(AbilityType.Dash)
&& Owner.GetState<DashState>() is { CanDash: true } dashState)
// 地面攻击Move Y > 0 → 上劈;其余 → 普通连击
if (Buffer.ConsumeAttack())
{
_owner.TransitionTo(dashState);
if (Input.MoveInput.y > 0.5f)
_owner.TransitionTo(_owner.GetState<UpAttackState>());
else
_owner.TransitionTo(_owner.GetState<AttackState>());
return;
}
// 地面冲刺:先确认能力与冷却均满足,再消耗缓冲,避免无操作时静默吃掉输入
var ds = Owner.GetState<DashState>();
if (ds != null && ds.CanDash
&& Stats != null && Stats.HasAbility(AbilityType.Dash)
&& Buffer.ConsumeDash())
{
_owner.TransitionTo(ds);
return;
}
if (Mathf.Abs(Input.MoveInput.x) > 0.1f)
@@ -42,5 +53,14 @@ namespace BaseGames.Player.States
_owner.TransitionTo(_owner.GetState<RunState>());
}
}
public override void OnStateFixedUpdate()
{
// 离地检测(与 CheckGrounded 同帧在 FixedUpdate 中执行,立即响应离地事件)
// OnStateUpdate 中仍保留同样的检测作为跨帧补充,两者不会发生双重转换:
// 本帧若在 FixedUpdate 中转换到 FallStateUpdate 调用的将是 FallState.OnStateUpdate()。
if (!Move.IsGrounded)
_owner.TransitionTo(_owner.GetState<FallState>());
}
}
}

View File

@@ -30,7 +30,12 @@ namespace BaseGames.Player.States
Anim.Play(AnimCfg.Jump);
if (_isDoubleJump)
{
Move.DoubleJump();
// 优先播放专属空中跳跃动画,未配置时回退到普通跳跃动画
var airJumpClip = AnimCfg?.AirJump ?? AnimCfg?.Jump;
if (airJumpClip != null) Anim?.Play(airJumpClip);
}
else
Move.Jump();
@@ -47,40 +52,88 @@ namespace BaseGames.Player.States
return;
}
// 空中冲刺(优先于二段跳:冲刺可保存二段跳机会)
if (Buffer.ConsumeDash()
&& Stats != null && Stats.HasAbility(AbilityType.AirDash))
// 冲刺(地面/空中统一使用 DashState空中限一次优先于二段跳:冲刺可保存二段跳机会)
// 先确认能力与冷却均满足,再消耗缓冲,避免无操作时静默吃掉输入
var dashState = Owner.GetState<DashState>();
if (dashState != null && dashState.CanAirDash
&& Stats != null && Stats.HasAbility(AbilityType.Dash)
&& Buffer.ConsumeDash())
{
var aerialDash = Owner.GetState<AerialDashState>();
if (aerialDash != null && aerialDash.HasAerialDash)
{
_owner.TransitionTo(aerialDash);
return;
}
_owner.TransitionTo(dashState);
return;
}
// 二段跳:上升阶段即可触发(上升途中任意时刻可二段跳
if (Buffer.ConsumeJump() && Owner.AirJumpsLeft > 0)
// 二段跳:上升阶段即可触发(先确认有次数再消耗缓冲,避免静默消耗
if (Owner.AirJumpsLeft > 0 && Buffer.ConsumeJump())
{
Owner.UseAirJump();
Move.DoubleJump();
// 留在 JumpState速度截断JumpCancelledEvent和落地检测逻辑继续生效
if (AnimCfg?.Jump != null) Anim?.Play(AnimCfg.Jump);
// 播放空中跳跃动画,未配置时回退到 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<UpAttackState>());
else if (Input.MoveInput.y < -0.5f
&& Stats != null && Stats.HasAbility(AbilityType.DownSlash))
_owner.TransitionTo(_owner.GetState<DownAttackState>());
else
_owner.TransitionTo(_owner.GetState<AirAttackState>());
return;
}
}
public override void OnStateFixedUpdate()
{
// 空中水平移动:有输入时立即覆盖至目标速度;无输入时施加空气阻力保留动量
if (Mathf.Abs(Input.MoveInput.x) > 0.01f)
Move.Move(Input.MoveInput.x * Cfg.RunSpeed);
// ── 顶点悬停:第一判断 |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.ApplyAirDrag(Cfg.AirDragFactor);
Move.SetGravityScale(Cfg.DefaultGravityScale);
// ── 空中水平移动(朝向墙壁时停止施力,防止贴墙悬停)──────────────
if (Mathf.Abs(Input.MoveInput.x) > 0.01f)
{
int inputDir = Input.MoveInput.x > 0 ? 1 : -1;
var wd = Owner.WallDetector;
if (wd != null && wd.IsTouchingWall && wd.WallDirection == inputDir)
{
// 按下朝墙方向键 + 在墙上(且未著地)→ 进入抓墙状态
var wss = Owner.GetState<WallSlideState>();
if (wss != null && !Move.IsGrounded)
{
wss.PrepareEnter(inputDir);
Owner.TransitionTo(wss);
return;
}
Move.ZeroHorizontalVelocity();
}
else if (wd != null && wd.HasPartialContact(inputDir))
{
// 仅部分射线命中(如检测点高于矮墙顶部),停止施加朝墙方向的水平速度,
// 防止角色边角被卡在墙顶而无法继续下落。
Move.ZeroHorizontalVelocity();
}
else
Move.Move(Input.MoveInput.x * Cfg.RunSpeed);
}
else
Move.ZeroHorizontalVelocity();
}
public override void OnStateExit()
{
// 顶点悬停可能已降低重力,离开本状态时必须恢复;
// 否则进入 FallState/DashState 等后续状态时重力仍低于默认值。
Move.SetGravityScale(Cfg.DefaultGravityScale);
Input.JumpCancelledEvent -= OnJumpCancelled;
}

View File

@@ -66,6 +66,13 @@ namespace BaseGames.Player.States
#if UNITY_EDITOR
[Header("调试")]
[SerializeField] private bool _debugValidateTransitions = true;
[Header("── 运行时状态 ──")]
[SerializeField] private string _dbg_CurrentState;
[SerializeField] private bool _dbg_IsGrounded;
[SerializeField] private int _dbg_AirJumpsLeft;
[SerializeField] private bool _dbg_CanDash;
[SerializeField] private bool _dbg_IsInvincible;
#endif
// Overlay LayerLayer 1供 SpringState / SoulSkill 等叠加层动画使用
@@ -139,10 +146,12 @@ namespace BaseGames.Player.States
public void UseAirJump() => _airJumpsLeft = Mathf.Max(0, _airJumpsLeft - 1);
/// <summary>
/// 落地时重置空中跳跃次数(由 IdleState/RunState.OnStateEnter 调用)。
/// 若解锁 DoubleJump 则重置为 1,否则为 0。
/// 若解锁 DoubleJump 则重置为 MovConfig.MaxAirJumps,否则为 0。
/// </summary>
public void ResetAirJumps() =>
_airJumpsLeft = _stats != null && _stats.HasAbility(AbilityType.DoubleJump) ? 1 : 0;
_airJumpsLeft = (_stats != null && _stats.HasAbility(AbilityType.DoubleJump))
? (_movementConfig != null ? _movementConfig.MaxAirJumps : 1)
: 0;
// ── Overlay Layer API供 SpringState / SoulSkill 等叠加动画使用)─────
/// <summary>
@@ -192,6 +201,10 @@ namespace BaseGames.Player.States
_parrySystem.OnParryActivated += OnParryActivated;
_parrySystem.OnParryConsumed += OnParryConsumedHandler;
}
// 订阅灵泉使用输入
if (_inputReader != null)
_inputReader.UseSpringEvent += OnUseSpring;
}
private void OnDestroy()
@@ -201,6 +214,9 @@ namespace BaseGames.Player.States
_parrySystem.OnParryActivated -= OnParryActivated;
_parrySystem.OnParryConsumed -= OnParryConsumedHandler;
}
if (_inputReader != null)
_inputReader.UseSpringEvent -= OnUseSpring;
}
/// <summary>弹反输入激活时由 ParrySystem 触发 → 转换到 ParryState。</summary>
@@ -217,6 +233,15 @@ namespace BaseGames.Player.States
_shield?.OnParrySuccess();
}
/// <summary>灵泉输入:地面且有剩余充能时转入 SpringState 使用一次。</summary>
private void OnUseSpring()
{
if (_stats == null || _stats.CurrentSpringCharges <= 0) return;
if (_movement != null && !_movement.IsGrounded) return;
if (_states.ContainsKey(typeof(SpringState)))
TransitionTo(GetState<SpringState>());
}
private void Start()
{
if (!HasRequiredStateDependencies())
@@ -238,6 +263,14 @@ namespace BaseGames.Player.States
GetState<DashState>()?.TickCooldown(Time.deltaTime);
_currentState?.OnStateUpdate();
#if UNITY_EDITOR
_dbg_CurrentState = _currentState?.GetType().Name ?? "None";
_dbg_IsGrounded = _movement != null && _movement.IsGrounded;
_dbg_AirJumpsLeft = _airJumpsLeft;
_dbg_CanDash = GetState<DashState>()?.CanDash ?? false;
_dbg_IsInvincible = _stats != null && _stats.IsInvincible;
#endif
}
private void FixedUpdate()
@@ -276,7 +309,6 @@ namespace BaseGames.Player.States
_states[typeof(FallState)] = new FallState(this);
_states[typeof(AttackState)] = new AttackState(this);
_states[typeof(DashState)] = new DashState(this);
_states[typeof(AerialDashState)] = new AerialDashState(this);
_states[typeof(WallSlideState)] = new WallSlideState(this);
_states[typeof(WallJumpState)] = new WallJumpState(this);
_states[typeof(AirAttackState)] = new AirAttackState(this);

View File

@@ -12,8 +12,9 @@ namespace BaseGames.Player.States
if (AnimCfg?.Run != null)
Anim.Play(AnimCfg.Run);
// 落地时重置空中能力计数器(绝大多数情况被 IdleState 覆盖,但水平落地直接进入 RunState 时也需要)
Owner.GetState<AerialDashState>()?.ResetAerialDashes();
Owner.ResetAirJumps();
Owner.GetState<DashState>()?.ResetAirDash();
Owner.GetState<WallSlideState>()?.ResetWallGrab();
}
public override void OnStateUpdate()
@@ -28,12 +29,22 @@ namespace BaseGames.Player.States
_owner.TransitionTo(_owner.GetState<JumpState>());
return;
}
// 地面冲刺:需解锁 Dash 能力,且冲刺不在冷却
if (Buffer.ConsumeDash()
&& Stats != null && Stats.HasAbility(AbilityType.Dash)
&& Owner.GetState<DashState>() is { CanDash: true } dashState)
// 地面攻击Move Y > 0 → 上劈;其余 → 普通连击
if (Buffer.ConsumeAttack())
{
_owner.TransitionTo(dashState);
if (Input.MoveInput.y > 0.5f)
_owner.TransitionTo(_owner.GetState<UpAttackState>());
else
_owner.TransitionTo(_owner.GetState<AttackState>());
return;
}
// 地面冲刺:先确认能力与冷却均满足,再消耗缓冲,避免无操作时静默吃掉输入
var ds = Owner.GetState<DashState>();
if (ds != null && ds.CanDash
&& Stats != null && Stats.HasAbility(AbilityType.Dash)
&& Buffer.ConsumeDash())
{
_owner.TransitionTo(ds);
return;
}
if (Mathf.Abs(Input.MoveInput.x) < 0.1f)
@@ -46,6 +57,13 @@ namespace BaseGames.Player.States
public override void OnStateFixedUpdate()
{
// 离地检测(与 CheckGrounded 同帧在 FixedUpdate 中执行,立即响应离地事件)
if (!Move.IsGrounded)
{
_owner.TransitionTo(_owner.GetState<FallState>());
return;
}
float inputX = Input.MoveInput.x;
if (Mathf.Abs(inputX) > 0.1f)
Move.Move(inputX * Cfg.RunSpeed);

View File

@@ -4,7 +4,7 @@ namespace BaseGames.Player.States
{
/// <summary>
/// 上劈状态(架构 05_PlayerModule §2
/// 激活 HitBoxUp;结束后回到 Idle地面或 FallState空中
/// HitBox 由 ComboStepConfig 时间窗口驱动;结束后回到 Idle地面或 FallState空中
/// </summary>
public class UpAttackState : PlayerStateBase
{
@@ -12,21 +12,27 @@ namespace BaseGames.Player.States
public override void OnStateEnter()
{
Owner.Combat?.EnableWeaponHitBox(AttackDirection.Up);
var clip = Owner.Weapon?.ActiveWeapon?.upAttackClip;
if (clip != null && clip.Clip != null)
// 上劈反嵈:空中施加向下微小冲量,增强出招手感(地面无效)
if (!Move.IsGrounded && Move?.Rb != null)
Move.Rb.velocity = new UnityEngine.Vector2(Move.Rb.velocity.x, Move.Rb.velocity.y - 3f);
var step = Owner.Weapon?.ActiveWeapon?.GetDirStep(AttackDirection.Up);
float spd = Stats?.AnimatorSpeedMultiplier ?? 1f;
if (step?.clip?.Clip != null)
{
var state = Anim.Play(clip);
state.Events(this).OnEnd = OnClipEnd;
}
else if (AnimCfg?.UpAttack != null)
{
var state = Anim.Play(AnimCfg.UpAttack);
state.Events(this).OnEnd = OnClipEnd;
var animState = Anim.Play(step.Value.clip);
animState.Speed *= spd;
var events = animState.Events(this);
events.OnEnd = OnClipEnd;
var s = step.Value;
events.Add(s.hitBoxEnter, () => Owner.Combat?.EnableWeaponHitBox(AttackDirection.Up, s.hitBoxId));
events.Add(s.hitBoxExit, () => Owner.Combat?.DisableAllWeaponHitBoxes());
}
else
{
Owner.Combat?.EnableWeaponHitBox(AttackDirection.Up);
OnClipEnd();
}
}

View File

@@ -3,31 +3,56 @@ using UnityEngine;
namespace BaseGames.Player.States
{
/// <summary>
/// 蹬墙跳状态(架构 05_PlayerModule §2
/// 从 WallSlideState 进入;施加背墙方向的水平 + 垂直速度;
/// 短暂锁定水平输入后转为 FallState
/// 蹬墙跳状态(架构 05_PlayerModule §2,自定义蹬墙跳设计)。
///
/// 从 WallSlideState 进入(正常模式下按跳跃键触发)
/// 分为两种子类型,由 PrepareEnter 时的水平输入决定:
/// - 背墙跳Jump Away无输入或反方向输入 → 远离墙壁斜上方弹出WallJumpAwayForce
/// - 对墙跳Jump Toward朝向墙壁方向输入 → 沿墙壁方向斜上方弹出WallJumpTowardForce
///
/// 公共规则:
/// 视为第一段跳(不消耗空中跳跃次数);支持可变高度(提前松键截断);
/// 上升结束后转 FallState。
/// </summary>
public class WallJumpState : PlayerStateBase
{
private float _inputLockTimer;
private int _wallDir;
private bool _isAwayJump; // true = 背墙跳false = 对墙跳
public WallJumpState(PlayerController owner) : base(owner) { }
/// <summary>
/// 由 WallSlideState 在调用 TransitionTo 之前调用。
/// wallDir抓墙方向+1 右墙 / -1 左墙)。
/// moveInputX触发跳跃时的水平输入值。
/// </summary>
public void PrepareEnter(int wallDir, float moveInputX)
{
_wallDir = wallDir;
// 无输入或输入方向与墙壁反向 → 背墙跳;输入方向与墙壁同向 → 对墙跳
int inputDir = moveInputX > 0.1f ? 1 : (moveInputX < -0.1f ? -1 : 0);
_isAwayJump = (inputDir == 0 || inputDir != _wallDir);
}
public override void OnStateEnter()
{
// 记录墙壁方向(跳跃反向)
_wallDir = Owner.WallDetector != null ? Owner.WallDetector.WallDirection : 0;
if (_wallDir == 0) _wallDir = -Owner.FacingDirection;
// 施加对应类型的速度
if (_isAwayJump)
Move?.WallJumpAway(_wallDir);
else
Move?.WallJumpToward(_wallDir);
// 施加蹬墙跳速度
Move?.WallJump(_wallDir);
// 蹬墙跳视为第一段跳,重置空中跳跃次数(使二段跳可再次使用)
Owner.ResetAirJumps();
// 锁定水平输入
_inputLockTimer = Cfg.WallJumpInputLockDuration;
// 播放跳跃动画(复用跳跃动画
if (AnimCfg?.Jump != null) Anim?.Play(AnimCfg.Jump);
// 播放蹬墙跳动画:背墙跳/对墙跳使用各自专属 Clip留空时回退到 Jump 动画
var wallJumpClip = _isAwayJump
? (AnimCfg?.WallJumpAway ?? AnimCfg?.Jump)
: (AnimCfg?.WallJumpToward ?? AnimCfg?.Jump);
if (wallJumpClip != null) Anim?.Play(wallJumpClip);
Input.JumpCancelledEvent += OnJumpCancelled;
}
@@ -59,3 +84,4 @@ namespace BaseGames.Player.States
private void OnJumpCancelled() => Move?.CutJump();
}
}

View File

@@ -3,16 +3,58 @@ using UnityEngine;
namespace BaseGames.Player.States
{
/// <summary>
/// 壁滑状态(架构 05_PlayerModule §2
/// 需有 PlayerWallDetector.IsTouchingWall == true 才能进入;
/// 限制下落速度为 WallSlideSpeed按跳跃则切换到 WallJumpState
/// 抓墙状态(自定义设计,架构 05_PlayerModule §2
///
/// 触发条件:空中贴墙时玩家按下朝向墙壁的方向键(由 FallState/JumpState 检测并调用 PrepareEnter
/// 维持条件:进入后无需持续按键;主动按下反方向键或落地时解除。
///
/// 高度记忆机制(防止单面墙反复爬升):
/// - 首次抓墙(或切换到另一侧墙壁)时记录 _wallGrabY。
/// - 若当前 Y > _wallGrabY + 容差 → 受限模式:持续下滑,不可蹬墙跳。
/// - 若当前 Y ≤ _wallGrabY + 容差 → 正常模式:静止悬挂,可触发蹬墙跳。
/// - 重置时机:① 落地时(由 IdleState/RunState 调用 ResetWallGrab
/// ② 切换到另一侧墙壁时OnStateEnter 内自动判断)。
/// </summary>
public class WallSlideState : PlayerStateBase
{
// ── 运行时状态 ────────────────────────────────────────────────────────
/// <summary>首次抓住该侧墙壁时记录的 Y 坐标。</summary>
private float _wallGrabY = float.MinValue;
/// <summary>上次记录 wallGrabY 时的墙壁方向(+1 右墙 / -1 左墙)。</summary>
private int _lastGrabDir = 0;
/// <summary>本次进入时确认的墙壁方向(由 PrepareEnter 设置)。</summary>
private int _wallDir = 0;
/// <summary>当前是否处于正常模式可蹬墙跳。受限模式isRestricted时不可跳。</summary>
private bool _canJump = false;
public WallSlideState(PlayerController owner) : base(owner) { }
/// <summary>
/// 由 FallState / JumpState 在调用 TransitionTo 之前调用,传入已确认的墙壁方向。
/// </summary>
public void PrepareEnter(int wallDir) => _wallDir = wallDir;
/// <summary>
/// 落地时由 IdleState / RunState 调用,重置高度记忆,允许下次抓同侧墙时重新计算。
/// </summary>
public void ResetWallGrab()
{
_wallGrabY = float.MinValue;
_lastGrabDir = 0;
}
public override void OnStateEnter()
{
// 若切换到另一侧墙壁,重置高度记录(视为全新的墙壁)
if (_wallDir != _lastGrabDir)
{
_wallGrabY = Owner.transform.position.y;
_lastGrabDir = _wallDir;
}
// 计算当前是否处于正常模式
UpdateCanJump();
if (AnimCfg?.WallSlide != null)
Anim?.Play(AnimCfg.WallSlide);
@@ -26,8 +68,10 @@ namespace BaseGames.Player.States
public override void OnStateUpdate()
{
var wd = Owner.WallDetector;
// 离开墙壁 → 下落
if (Owner.WallDetector == null || !Owner.WallDetector.IsTouchingWall)
if (wd == null || !wd.IsTouchingWall)
{
Owner.TransitionTo(Owner.GetState<FallState>());
return;
@@ -39,17 +83,52 @@ namespace BaseGames.Player.States
Owner.TransitionTo(Owner.GetState<IdleState>());
return;
}
// 主动按反方向键 → 脱离(松墙下落)
float mx = Input.MoveInput.x;
if (Mathf.Abs(mx) > 0.1f)
{
int inputDir = mx > 0f ? 1 : -1;
if (inputDir != _wallDir)
{
Owner.TransitionTo(Owner.GetState<FallState>());
return;
}
}
// 每帧刷新正常/受限状态
UpdateCanJump();
}
public override void OnStateFixedUpdate()
{
// 限制下落速度(壁滑缓慢下落)
Move?.ApplyWallSlide();
if (_canJump)
// 正常模式:静止悬挂,阻止向下速度
Move?.ZeroVerticalVelocity();
else
// 受限模式:持续下滑
Move?.ApplyWallSlide();
}
// ── 内部 ──────────────────────────────────────────────────────────────
private void UpdateCanJump()
{
float tolerance = Cfg?.WallGrabHeightTolerance ?? 0.05f;
_canJump = Owner.transform.position.y <= _wallGrabY + tolerance;
}
private void OnJumpPressed()
{
Owner.TransitionTo(Owner.GetState<WallJumpState>());
// 受限模式禁止蹬墙跳
if (!_canJump) return;
var wjs = Owner.GetState<WallJumpState>();
if (wjs == null) return;
wjs.PrepareEnter(_wallDir, Input.MoveInput.x);
Owner.TransitionTo(wjs);
}
}
}