角色能力,存档
This commit is contained in:
@@ -15,8 +15,7 @@ namespace BaseGames.Player
|
||||
// ── 移动能力 ──────────────────────────────────────────────────────
|
||||
WallCling = 1u << 0, // 贴墙悬挂
|
||||
WallJump = 1u << 1, // 墙跳
|
||||
Dash = 1u << 2, // 地面冲刺
|
||||
AirDash = 1u << 3, // 空中冲刺(二段冲刺)
|
||||
Dash = 1u << 2, // 冲刺(地面与空中统一)
|
||||
DoubleJump = 1u << 4, // 二段跳
|
||||
SuperJump = 1u << 5, // 超级跳(聚气跳)
|
||||
Swim = 1u << 6, // 游泳(液体中自由移动)
|
||||
@@ -44,12 +43,12 @@ namespace BaseGames.Player
|
||||
/// <summary>
|
||||
/// 无敌冲刺强化(解锁后冲刺前段获得无敌窗口)。
|
||||
/// 仅持有 Dash 时:冲刺无无敌帧。
|
||||
/// 解锁 InvincibleDash 后:冲刺期间完全无敌(地面 DashState + 空中 AerialDashState)。
|
||||
/// 解锁 InvincibleDash 后:冲刺期间完全无敌。
|
||||
/// </summary>
|
||||
InvincibleDash = 1u << 18,
|
||||
|
||||
// ── 组合掩码 ─────────────────────────────────────────────────────────
|
||||
AllMovement = WallCling | WallJump | Dash | AirDash | DoubleJump | SuperJump | Swim | Dive | InvincibleDash,
|
||||
AllMovement = WallCling | WallJump | Dash | DoubleJump | SuperJump | Swim | Dive | InvincibleDash,
|
||||
AllSpells = Spell1 | Spell2 | Spell3,
|
||||
AllSpirit = SpiritForm | SpiritDash,
|
||||
}
|
||||
|
||||
62
Assets/_Game/Scripts/Player/CheckpointRespawnHandler.cs
Normal file
62
Assets/_Game/Scripts/Player/CheckpointRespawnHandler.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.Player
|
||||
{
|
||||
/// <summary>
|
||||
/// 监听 EVT_CheckpointRespawn,将玩家瞬移至最近检查点坐标。
|
||||
/// 通过淡出 → 移位 → 淡入实现无视觉跳变的传送。
|
||||
///
|
||||
/// 挂载在玩家根节点(与 Rigidbody2D 同一 GameObject)。
|
||||
/// </summary>
|
||||
public class CheckpointRespawnHandler : MonoBehaviour
|
||||
{
|
||||
[Header("事件 - 监听")]
|
||||
[Tooltip("EVT_CheckpointRespawn — 由 LethalTrap 在玩家存活且场景有检查点时触发")]
|
||||
[SerializeField] private VoidEventChannelSO _onCheckpointRespawn;
|
||||
|
||||
[Header("事件 - 触发")]
|
||||
[Tooltip("EVT_FadeOutRequest — 传送前淡出屏幕")]
|
||||
[SerializeField] private VoidEventChannelSO _onFadeOutRequest;
|
||||
|
||||
[Tooltip("EVT_FadeInRequest — 传送后淡入屏幕")]
|
||||
[SerializeField] private VoidEventChannelSO _onFadeInRequest;
|
||||
|
||||
[Header("配置")]
|
||||
[Tooltip("淡出结束到执行位移之间的等待时长(秒,不受 TimeScale 影响)")]
|
||||
[SerializeField] private float _fadeHalfDuration = 0.2f;
|
||||
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
private void OnEnable() => _onCheckpointRespawn?.Subscribe(OnCheckpointRespawn).AddTo(_subs);
|
||||
private void OnDisable() => _subs.Clear();
|
||||
|
||||
private void OnCheckpointRespawn() => StartCoroutine(RespawnCoroutine());
|
||||
|
||||
private IEnumerator RespawnCoroutine()
|
||||
{
|
||||
_onFadeOutRequest?.Raise();
|
||||
yield return new WaitForSecondsRealtime(_fadeHalfDuration);
|
||||
|
||||
var svc = ServiceLocator.GetOrDefault<ICheckpointService>();
|
||||
if (svc != null && svc.HasCheckpoint)
|
||||
{
|
||||
// 清零速度后移位,防止物理残留动量导致滑步
|
||||
var rb = GetComponent<Rigidbody2D>();
|
||||
if (rb != null)
|
||||
{
|
||||
rb.velocity = Vector2.zero;
|
||||
rb.position = svc.CheckpointPosition;
|
||||
}
|
||||
else
|
||||
{
|
||||
transform.position = svc.CheckpointPosition;
|
||||
}
|
||||
}
|
||||
|
||||
_onFadeInRequest?.Raise();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 38830e1649a9eb548b20540a737bdf09
|
||||
guid: 7ca41f67644b6b843ba7ef65e78b13e5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.Input;
|
||||
|
||||
namespace BaseGames.Player
|
||||
{
|
||||
@@ -16,6 +17,7 @@ namespace BaseGames.Player
|
||||
{
|
||||
[Header("配置")]
|
||||
[SerializeField] private FormConfigSO _config;
|
||||
[SerializeField] private InputReaderSO _input;
|
||||
|
||||
[Header("事件频道")]
|
||||
[SerializeField] private IntEventChannelSO _onFormChanged; // 广播当前形态索引(UI/Save)
|
||||
@@ -33,6 +35,22 @@ namespace BaseGames.Player
|
||||
Debug.Assert(_config != null, "[FormController] _config 未赋值,请在 Inspector 中指定 FormConfigSO。", this);
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_input == null) return;
|
||||
_input.SwitchSkyFormEvent += OnSwitchSky;
|
||||
_input.SwitchEarthFormEvent += OnSwitchEarth;
|
||||
_input.SwitchDeathFormEvent += OnSwitchDeath;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_input == null) return;
|
||||
_input.SwitchSkyFormEvent -= OnSwitchSky;
|
||||
_input.SwitchEarthFormEvent -= OnSwitchEarth;
|
||||
_input.SwitchDeathFormEvent -= OnSwitchDeath;
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (_config.forms != null && _config.forms.Length > 0)
|
||||
@@ -67,5 +85,10 @@ namespace BaseGames.Player
|
||||
if (form != null)
|
||||
SwitchForm(form.formType);
|
||||
}
|
||||
|
||||
// ── 内部输入处理 ────────────────────────────────────────────────────────
|
||||
private void OnSwitchSky() => SwitchForm(FormType.TianHun);
|
||||
private void OnSwitchEarth() => SwitchForm(FormType.DiHun);
|
||||
private void OnSwitchDeath() => SwitchForm(FormType.MingHun);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,20 @@ namespace BaseGames.Player
|
||||
public AnimationClip Idle;
|
||||
public AnimationClip Run;
|
||||
public AnimationClip Jump;
|
||||
[Tooltip("空中跳跃(二段跳)动画。留空则复用 Jump 动画。")]
|
||||
public AnimationClip AirJump;
|
||||
public AnimationClip Fall;
|
||||
[Tooltip("普通冲刺动画(无无敌帧,过程中受伤会被打断)。")]
|
||||
public AnimationClip Dash;
|
||||
[Tooltip("无敌冲刺动画(解锁 InvincibleDash 能力后使用)。留空则复用 Dash 动画。")]
|
||||
public AnimationClip DashInvincible;
|
||||
|
||||
[Header("墙")]
|
||||
public AnimationClip WallSlide;
|
||||
[Tooltip("背墙跳动画(无输入或反向输入时触发,远离墙壁斜上方弹出)。留空则复用 Jump 动画。")]
|
||||
public AnimationClip WallJumpAway;
|
||||
[Tooltip("对墙跳动画(朝向墙壁输入时触发,沿墙壁方向斜上方弹出)。留空则复用 Jump 动画。")]
|
||||
public AnimationClip WallJumpToward;
|
||||
|
||||
[Header("受伤 / 死亡")]
|
||||
public AnimationClip Hurt;
|
||||
@@ -24,28 +33,6 @@ namespace BaseGames.Player
|
||||
[Header("弹簧")]
|
||||
public AnimationClip UseSpring;
|
||||
|
||||
[Header("地面攻击(连招序列)")]
|
||||
public AnimationClip[] GroundAttacks;
|
||||
|
||||
/// <summary>每段连击的 HitBox 开启/关闭时间点(归一化 0-1),与 GroundAttacks 索引对应。</summary>
|
||||
[System.Serializable]
|
||||
public struct AttackTimings
|
||||
{
|
||||
[Tooltip("HitBox 开启时间点(归一化 0-1)")]
|
||||
[UnityEngine.Range(0f, 1f)] public float HitBoxEnter;
|
||||
[Tooltip("HitBox 关闭时间点(归一化 0-1)")]
|
||||
[UnityEngine.Range(0f, 1f)] public float HitBoxExit;
|
||||
}
|
||||
|
||||
[Header("地面攻击 HitBox 激活窗口(归一化时间 0-1)")]
|
||||
[Tooltip("每段连击 HitBox 开启/关闭时间点,与 GroundAttacks 索引对应")]
|
||||
public AttackTimings[] GroundAttackTimings = { new AttackTimings { HitBoxEnter = 0.3f, HitBoxExit = 0.6f } };
|
||||
|
||||
[Header("空中攻击")]
|
||||
public AnimationClip AirAttack;
|
||||
public AnimationClip UpAttack;
|
||||
public AnimationClip DownAttack; // 戳击 (Pogo)
|
||||
|
||||
[Header("弹反")]
|
||||
public AnimationClip ParryStart;
|
||||
public AnimationClip ParrySuccess;
|
||||
@@ -53,12 +40,5 @@ namespace BaseGames.Player
|
||||
[Header("游泳")]
|
||||
public AnimationClip SwimIdle;
|
||||
public AnimationClip SwimMove;
|
||||
|
||||
/// <summary>按连招步骤取地面攻击动画,越界自动取最后一个。</summary>
|
||||
public AnimationClip GetAttackClip(int step)
|
||||
{
|
||||
if (GroundAttacks == null || GroundAttacks.Length == 0) return null;
|
||||
return step < GroundAttacks.Length ? GroundAttacks[step] : GroundAttacks[^1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace BaseGames.Player
|
||||
[SerializeField] private WeaponManager _weaponManager;
|
||||
|
||||
private PlayerStats _stats;
|
||||
private PlayerMovement _movement;
|
||||
private WeaponHitBoxInstance _currentHitBoxInstance;
|
||||
|
||||
/// <summary>下劈 HitBox 命中确认事件(供 DownAttackState 订阅 pogo 弹跳逻辑)。</summary>
|
||||
@@ -19,7 +20,8 @@ namespace BaseGames.Player
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_stats = GetComponentInParent<PlayerStats>();
|
||||
_stats = GetComponentInParent<PlayerStats>();
|
||||
_movement = GetComponentInParent<PlayerMovement>();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
@@ -52,30 +54,17 @@ namespace BaseGames.Player
|
||||
|
||||
private void HandleDownHitConfirmed(DamageInfo info) => OnDownHitConfirmed?.Invoke(info);
|
||||
|
||||
// ── 连击段伤害来源切换 ────────────────────────────────────────────────
|
||||
/// <summary>
|
||||
/// 根据当前连招段切换 HitBox 的 DamageSource(由 AttackState 在每段开始时调用)。
|
||||
/// </summary>
|
||||
public void SetComboSegmentSource(int comboIndex)
|
||||
{
|
||||
WeaponSO w = _weaponManager?.ActiveWeapon;
|
||||
if (w == null) return;
|
||||
|
||||
DamageSourceSO src = comboIndex switch
|
||||
{
|
||||
0 => w.attack1Source,
|
||||
1 => w.attack2Source,
|
||||
2 => w.attack3Source,
|
||||
_ => w.attack1Source,
|
||||
};
|
||||
_weaponManager.ActiveHitBoxInstance?.SetDamageSource(AttackDirection.Ground, src);
|
||||
}
|
||||
|
||||
// ── HitBox 激活(由 State / AnimationEvent 调用)─────────────────────
|
||||
public void EnableWeaponHitBox(AttackDirection dir)
|
||||
/// <summary>
|
||||
/// 激活 HitBox。
|
||||
/// hitBoxId 非空时按 Id 精确激活 Prefab 中对应子节点;空 = 方向默认。
|
||||
/// source 为 null 时回退到 WeaponSO.GetSourceByDir(dir)(方向第 0 段)。
|
||||
/// </summary>
|
||||
public void EnableWeaponHitBox(AttackDirection dir,
|
||||
string hitBoxId = "", DamageSourceSO source = null)
|
||||
{
|
||||
var source = _weaponManager?.ActiveWeapon?.GetSourceByDir(dir);
|
||||
_weaponManager?.ActiveHitBoxInstance?.Activate(dir, source, transform);
|
||||
source ??= _weaponManager?.ActiveWeapon?.GetSourceByDir(dir);
|
||||
_weaponManager?.ActiveHitBoxInstance?.Activate(dir, source, transform, hitBoxId);
|
||||
}
|
||||
|
||||
public void DisableWeaponHitBox(AttackDirection dir)
|
||||
@@ -89,6 +78,12 @@ namespace BaseGames.Player
|
||||
{
|
||||
int gain = _weaponManager?.ActiveWeapon?.soulPowerGain ?? 10;
|
||||
_stats?.AddSoulPower(gain);
|
||||
|
||||
// 攻击命中反嵈:向攻击反方向施加微小后退冲量,增强打击感
|
||||
if (_movement?.Rb != null && info.KnockbackDirection.x != 0f)
|
||||
_movement.Rb.AddForce(
|
||||
new UnityEngine.Vector2(UnityEngine.Mathf.Sign(-info.KnockbackDirection.x) * 2f, 0f),
|
||||
UnityEngine.ForceMode2D.Impulse);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,20 @@ namespace BaseGames.Player
|
||||
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;
|
||||
@@ -53,10 +67,13 @@ namespace BaseGames.Player
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
Debug.Assert(_config != null, "[PlayerMovement] _config 未赋值,请在 Inspector 中指定 PlayerMovementConfigSO。", this);
|
||||
Debug.Assert(_config != null, "[PlayerMovement] _config 未赋值,请在 Inspector 中指定 PlayerMovementConfigSO。", this);
|
||||
Debug.Assert(_groundCheck != null, "[PlayerMovement] GroundCheck 子节点未赋值,地面检测将无法工作,请在 Inspector 中指定 GroundCheck Transform。", this);
|
||||
_rb = GetComponent<Rigidbody2D>();
|
||||
// 关闭位置插值:若开启插值,渲染位置会在速度清零后仍追赶 1~2 渲染帧,产生视觉滑行。
|
||||
_rb.interpolation = RigidbodyInterpolation2D.None;
|
||||
// 开启位置插值:在物理帧(50Hz)与渲染帧(60Hz+)之间平滑视觉位置,消除跳帧抖动。
|
||||
// SpritePixelSnapper(LateUpdate +1000)在插值结果基础上吸附到像素网格,
|
||||
// 与 CameraPixelSnapper 同格对齐,消除亚像素模糊;停止时 ≤2 帧像素追赶不可感知。
|
||||
_rb.interpolation = RigidbodyInterpolation2D.Interpolate;
|
||||
if (_spriteRenderer == null)
|
||||
_spriteRenderer = GetComponentInChildren<SpriteRenderer>();
|
||||
}
|
||||
@@ -77,6 +94,18 @@ namespace BaseGames.Player
|
||||
_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
|
||||
}
|
||||
|
||||
// ── 移动 ──────────────────────────────────────────────────────────────
|
||||
@@ -169,8 +198,8 @@ namespace BaseGames.Player
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 壁滑:将垂直速度限制为 -WallSlideSpeed(向下缓慢滑动)。
|
||||
/// WallSlideState.OnStateFixedUpdate 每帧调用。
|
||||
/// 壁滑:将垂直速度限制为 -WallSlideSpeed(受限抓墙时向下缓慢滑动)。
|
||||
/// WallSlideState.OnStateFixedUpdate 在受限模式下每帧调用。
|
||||
/// </summary>
|
||||
public void ApplyWallSlide()
|
||||
{
|
||||
@@ -180,29 +209,42 @@ namespace BaseGames.Player
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 蹬墙跳:对墙方向施加相反水平力 + 向上力。
|
||||
/// wallDir = +1 (右墙) 或 -1 (左墙),跳跃方向与之相反。
|
||||
/// 背墙跳(Jump Away):远离墙壁斜上方弹出。
|
||||
/// wallDir = +1 (右墙) 或 -1 (左墙),水平方向与之相反。
|
||||
/// </summary>
|
||||
public void WallJump(int wallDir)
|
||||
public void WallJumpAway(int wallDir)
|
||||
{
|
||||
float forceX = -wallDir * _config.WallJumpForceX;
|
||||
float forceY = _config.WallJumpForceY;
|
||||
_rb.velocity = new Vector2(forceX, forceY);
|
||||
_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>单向平台穿透(输入下行 + 跳跃键时触发)。</summary>
|
||||
public void DropThroughPlatform() { }
|
||||
|
||||
// ── Physics 检测 ──────────────────────────────────────────────────────
|
||||
private void CheckGrounded()
|
||||
{
|
||||
bool wasGrounded = _isGrounded;
|
||||
Vector2 origin = _groundCheck != null
|
||||
? (Vector2)_groundCheck.position
|
||||
: (Vector2)transform.position + Vector2.down * 0.5f;
|
||||
if (_groundCheck == null) return;
|
||||
|
||||
_isGrounded = Physics2D.OverlapBoxNonAlloc(origin, _groundCheckSize, 0f, _groundBuffer, _groundLayer) > 0;
|
||||
bool wasGrounded = _isGrounded;
|
||||
_isGrounded = Physics2D.OverlapBoxNonAlloc(_groundCheck.position, _groundCheckSize, 0f, _groundBuffer, _groundLayer) > 0;
|
||||
|
||||
if (_isGrounded && !wasGrounded)
|
||||
_coyoteTimer = _config.CoyoteTime;
|
||||
@@ -234,15 +276,24 @@ namespace BaseGames.Player
|
||||
Vector3 arrowEnd = center + new Vector3(_facingDirection * 0.55f, 0f, 0f);
|
||||
DrawArrow2D(center, arrowEnd, new Color(1f, 0.85f, 0.1f, 0.95f));
|
||||
|
||||
// ── 3. 站立检测框(落地亮绿 / 未落地淡绿)──────────────────────
|
||||
Vector2 gOrigin = _groundCheck != null
|
||||
? (Vector2)_groundCheck.position
|
||||
: (Vector2)transform.position + Vector2.down * 0.5f;
|
||||
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(gOrigin, _groundCheckSize);
|
||||
// ── 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;
|
||||
|
||||
@@ -23,12 +23,21 @@ namespace BaseGames.Player
|
||||
public float FallGravityMult = 3.5f;
|
||||
[Tooltip("最大下落速度(终端速度)。推荐 22。")]
|
||||
public float MaxFallSpeed = 22f;
|
||||
[Tooltip("松开跳跃键时速度保留比例(变高跳)。推荐 0.45,越小跳跃越低。")]
|
||||
[Tooltip("松开跳跃键时速度保留比例(变高跳)。推荐 0.35,越小跳跃越低。")]
|
||||
[Range(0f, 1f)]
|
||||
public float JumpCutMultiplier = 0.45f;
|
||||
public float JumpCutMultiplier = 0.35f;
|
||||
|
||||
[Header("二段跳")]
|
||||
[Tooltip("二段跳初速度。设为与 JumpForce 相同可获得等高二段跳。")]
|
||||
[Header("跳跃 — 顶点悬停")]
|
||||
[Tooltip("顶点悬停触发阈值(单位/秒)。当 |垂直速度| 低于此值时,重力缩减为 ApexGravityMultiplier 倍,\n产生\"滞空感\"。推荐 3。调高 → 悬停段更长;调低 → 悬停段更短乃至消失。")]
|
||||
public float ApexThreshold = 3f;
|
||||
[Tooltip("顶点区间内重力缩减比例(乘以 DefaultGravityScale)。推荐 0.3。\n0 = 完全无重力悬停;1 = 无悬停效果(等同于关闭此功能)。")]
|
||||
[Range(0f, 1f)]
|
||||
public float ApexGravityMultiplier = 0.3f;
|
||||
|
||||
[Header("空中跳跃(N 段跳)")]
|
||||
[Tooltip("腾空期间最多可追加的跳跃次数。1 = 二段跳,2 = 三段跳,以此类推。\n需同时在 PlayerStats 中解锁 DoubleJump 能力,否则此值无效。")]
|
||||
public int MaxAirJumps = 1;
|
||||
[Tooltip("空中追加跳跃的初速度。所有段数共用同一数值;设为与 JumpForce 相同可获得等高多段跳。")]
|
||||
public float DoubleJumpForce = 19f;
|
||||
|
||||
[Header("冲刺")]
|
||||
@@ -38,8 +47,6 @@ namespace BaseGames.Player
|
||||
public float DashDuration = 0.35f;
|
||||
[Tooltip("冲刺冷却时长(秒)。推荐 0.6s,落地后才可再次冲刺。")]
|
||||
public float DashCooldown = 0.6f;
|
||||
[Tooltip("每次腾空可使用的最大空中冲刺次数。通常设为 1(单次空中冲刺)。")]
|
||||
public int MaxAerialDashes = 1;
|
||||
|
||||
[Header("冲刺无敌帧(窗口 < 冲刺时长,且有独立 CD)")]
|
||||
[Tooltip("冲刺无敌窗口时长(秒)。仅为冲刺前段;窗口结束后即使仍在冲刺中也可受伤被打断(推荐 0.20s)。")]
|
||||
@@ -47,17 +54,28 @@ namespace BaseGames.Player
|
||||
[Tooltip("无敌的独立冷却(秒)。CD 内再次冲刺不会获得无敌帧,防止连冲变相持续无敌(推荐 0.9s)。")]
|
||||
public float DashInvincibilityCooldown = 0.9f;
|
||||
|
||||
[Header("蹬墙 / 壁滑")]
|
||||
[Header("抓墙 / 壁滑")]
|
||||
[Tooltip("受限抓墙时(高于 wallGrabY)的下滑速度(单位/秒)。推荐 2。")]
|
||||
public float WallSlideSpeed = 2f;
|
||||
public float WallJumpForceX = 12f;
|
||||
public float WallJumpForceY = 16f;
|
||||
public float WallRayLength = 0.55f;
|
||||
public float WallRayOffsetY = 0.2f;
|
||||
public float WallGrabMaxHeightGain = 0.5f;
|
||||
public float WallGrabReleaseDelay = 0.08f;
|
||||
public float WallJumpBackForceX = 14f;
|
||||
public float WallJumpAwayForceX = 10f;
|
||||
[Tooltip("抓墙高度容差:当前 Y 不超过 wallGrabY + 此值时视为未抬升,防止浮点抖动误判。")]
|
||||
public float WallGrabHeightTolerance = 0.05f;
|
||||
|
||||
[Header("蹬墙跳 — 背墙跳(Jump Away,远离墙壁斜上方)")]
|
||||
[Tooltip("背墙跳水平速度(远离墙壁方向)。推荐 14。")]
|
||||
public float WallJumpAwayForceX = 14f;
|
||||
[Tooltip("背墙跳垂直速度。推荐 18。")]
|
||||
public float WallJumpAwayForceY = 18f;
|
||||
|
||||
[Header("蹬墙跳 — 对墙跳(Jump Toward,沿墙壁斜上方)")]
|
||||
[Tooltip("对墙跳水平速度(朝向墙壁方向,较小)。推荐 6。")]
|
||||
public float WallJumpTowardForceX = 6f;
|
||||
[Tooltip("对墙跳垂直速度(偏向正上方)。推荐 18。")]
|
||||
public float WallJumpTowardForceY = 18f;
|
||||
|
||||
[Header("蹬墙跳 — 公共")]
|
||||
[Tooltip("蹬墙跳后水平输入锁定时长(秒)。防止玩家立即向原墙壁方向输入取消起跳。推荐 0.15。")]
|
||||
public float WallJumpInputLockDuration = 0.15f;
|
||||
|
||||
[Header("重力")]
|
||||
|
||||
@@ -46,6 +46,19 @@ namespace BaseGames.Player
|
||||
private bool _isGodMode;
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
#if UNITY_EDITOR
|
||||
// ── 运行时调试(Inspector 中可见)───────────────────────────────
|
||||
[Header("\u2500\u2500 \u8fd0\u884c\u65f6\u8c03\u8bd5 \u2500\u2500")]
|
||||
[SerializeField] private string _dbg_HP;
|
||||
[SerializeField] private string _dbg_Soul;
|
||||
[SerializeField] private string _dbg_Spirit;
|
||||
[SerializeField] private string _dbg_Spring;
|
||||
[SerializeField] private bool _dbg_IsInvincible;
|
||||
[SerializeField] private float _dbg_InvincibleTimer;
|
||||
[SerializeField] private bool _dbg_GodMode;
|
||||
[SerializeField] private string _dbg_Abilities;
|
||||
#endif
|
||||
|
||||
// ── 护符属性修改器 ─────────────────────────────────────────────────────────
|
||||
private readonly Dictionary<StatType, float> _flatModifiers = new();
|
||||
private readonly Dictionary<StatType, float> _percentModifiers = new();
|
||||
@@ -68,6 +81,7 @@ namespace BaseGames.Player
|
||||
MaxSpringCharges = _config.MaxSpringCharges;
|
||||
CurrentSpringCharges = MaxSpringCharges;
|
||||
CurrentLingZhu = _config.InitialLingZhu;
|
||||
_unlockedAbilities = _config.InitialAbilities;
|
||||
}
|
||||
|
||||
private void OnEnable() => _onDifficultyChanged?.Subscribe(HandleDifficultyChanged).AddTo(_subs);
|
||||
@@ -101,6 +115,17 @@ namespace BaseGames.Player
|
||||
AddSpiritPower(_config.SpiritRegenRate);
|
||||
}
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
_dbg_HP = $"{CurrentHP} / {MaxHP}";
|
||||
_dbg_Soul = $"{CurrentSoulPower} / {MaxSoulPower}";
|
||||
_dbg_Spirit = $"{CurrentSpiritPower} / {MaxSpiritPower}";
|
||||
_dbg_Spring = $"{CurrentSpringCharges} / {MaxSpringCharges}";
|
||||
_dbg_IsInvincible = IsInvincible;
|
||||
_dbg_InvincibleTimer = _invincibleTimer;
|
||||
_dbg_GodMode = _isGodMode;
|
||||
_dbg_Abilities = _unlockedAbilities == AbilityType.None ? "\u65e0" : _unlockedAbilities.ToString();
|
||||
#endif
|
||||
}
|
||||
// ── 护符修改器 API ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -25,5 +25,12 @@ namespace BaseGames.Player
|
||||
|
||||
[Header("初始货币")]
|
||||
public int InitialLingZhu = 0;
|
||||
|
||||
[Header("初始已解锁能力")]
|
||||
[Tooltip("角色出生时默认持有的能力([Flags] 位掩码)。\n\n" +
|
||||
"Dash:地面与空中冲刺统一控制,勾选后即可使用 DashState。\n\n" +
|
||||
"DoubleJump:追加次数由 PlayerMovementConfigSO.MaxAirJumps 决定。\n" +
|
||||
"落地或 Pogo 命中后次数自动重置。")]
|
||||
public AbilityType InitialAbilities = AbilityType.None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,24 +22,74 @@ namespace BaseGames.Player
|
||||
/// <summary>触碰到的墙壁方向:+1 = 右墙,-1 = 左墙,0 = 无墙。</summary>
|
||||
public int WallDirection { get; private set; }
|
||||
|
||||
// 每侧"任意一根射线命中 OR 物理接触点命中"的结果,用于防止下落时卡在矮墙边角
|
||||
private bool _anyRightContact;
|
||||
private bool _anyLeftContact;
|
||||
|
||||
// 物理接触点缓冲区(避免每帧 GC)
|
||||
private Rigidbody2D _rb;
|
||||
private static readonly ContactPoint2D[] _contactBuffer = new ContactPoint2D[8];
|
||||
|
||||
/// <summary>
|
||||
/// 指定方向上是否存在墙壁接触(射线命中 OR 物理接触点命中,任一为 true)。
|
||||
/// 用于在 FallState / JumpState 中防止角色卡在矮墙边角:<br/>
|
||||
/// • 射线检测覆盖"检测点略高于墙顶、仅一根射线命中"的情况;<br/>
|
||||
/// • 物理接触点覆盖"两根射线均高于墙顶,但碰撞体底角已卡在墙顶角"的极端情况。
|
||||
/// </summary>
|
||||
public bool HasPartialContact(int direction) =>
|
||||
direction > 0 ? _anyRightContact : _anyLeftContact;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
Debug.Assert(_config != null, "[PlayerWallDetector] _config 未赋值,请在 Inspector 中指定 PlayerMovementConfigSO。", this);
|
||||
_rb = GetComponent<Rigidbody2D>();
|
||||
}
|
||||
|
||||
private void FixedUpdate()
|
||||
{
|
||||
bool rightWall = CheckSide(Vector2.right);
|
||||
bool leftWall = CheckSide(Vector2.left);
|
||||
bool rightWall = CheckSide(Vector2.right, out bool anyRightRay);
|
||||
bool leftWall = CheckSide(Vector2.left, out bool anyLeftRay);
|
||||
|
||||
// 物理接触点兜底:两根射线都在墙顶以上时,仍可通过接触点检测到卡角
|
||||
bool physRight = CheckPhysicalContact(1);
|
||||
bool physLeft = CheckPhysicalContact(-1);
|
||||
|
||||
_anyRightContact = anyRightRay || physRight;
|
||||
_anyLeftContact = anyLeftRay || physLeft;
|
||||
|
||||
IsTouchingWall = rightWall || leftWall;
|
||||
WallDirection = rightWall ? 1 : (leftWall ? -1 : 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 每侧发两根射线(TopRay + BottomRay),两根均命中才返回 true。
|
||||
/// 通过物理接触点判断指定方向是否有墙壁(法线 X 分量超过 0.5 的水平接触)。
|
||||
/// direction = +1 检查右侧(接触法线指向左,normal.x < -0.5),
|
||||
/// direction = -1 检查左侧(接触法线指向右,normal.x > +0.5)。
|
||||
/// </summary>
|
||||
private bool CheckSide(Vector2 dir)
|
||||
private bool CheckPhysicalContact(int direction)
|
||||
{
|
||||
if (_rb == null) return false;
|
||||
LayerMask mask = _wallLayer != 0 ? _wallLayer : LayerMask.GetMask("Wall", "Ground");
|
||||
var filter = new ContactFilter2D();
|
||||
filter.SetLayerMask(mask);
|
||||
filter.useTriggers = false;
|
||||
|
||||
int count = _rb.GetContacts(filter, _contactBuffer);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
float nx = _contactBuffer[i].normal.x;
|
||||
// 右侧墙接触:法线指向左(nx < -0.5);左侧墙接触:法线指向右(nx > +0.5)
|
||||
if (direction > 0 && nx < -0.5f) return true;
|
||||
if (direction < 0 && nx > 0.5f) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 每侧发两根射线(TopRay + BottomRay),两根均命中才返回 true。
|
||||
/// <paramref name="anyContact"/> 在任意一根命中时为 true(用于防卡角判断)。
|
||||
/// </summary>
|
||||
private bool CheckSide(Vector2 dir, out bool anyContact)
|
||||
{
|
||||
Vector2 center = transform.position;
|
||||
float len = _config.WallRayLength;
|
||||
@@ -48,6 +98,7 @@ namespace BaseGames.Player
|
||||
|
||||
bool top = Physics2D.Raycast(center + Vector2.up * oy, dir, len, layer);
|
||||
bool bot = Physics2D.Raycast(center + Vector2.down * oy, dir, len, layer);
|
||||
anyContact = top || bot;
|
||||
return top && bot;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,43 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.Player
|
||||
{
|
||||
/// <summary>
|
||||
/// 治愈弹簧系统(架构 05_PlayerModule §7)。
|
||||
/// PlayerStats 中已预留 CurrentSpringCharges / MaxSpringCharges / SpringKillPoints 字段。
|
||||
/// TODO: 实现弹簧充能逻辑:
|
||||
/// - 击杀敌人时调用 PlayerStats.AddSpringKillPoint() 积累充能
|
||||
/// - 按下治愈键时消耗充能槽并恢复玩家 HP
|
||||
/// - 充能满格时触发特殊强化状态(视设计而定)
|
||||
/// 灵泉充能系统(架构 05_PlayerModule §7)。
|
||||
/// 订阅 EVT_EnemyDied 事件,每次击杀调用 PlayerStats.AddKillPoints();
|
||||
/// 积分达到阈值时由 PlayerStats 内部自动增加 SpringCharge 并重置积分。
|
||||
/// 使用灵泉(UseSpring)由 PlayerController 处理输入、SpringState 执行动画与消耗。
|
||||
/// </summary>
|
||||
public class SpringSystem : MonoBehaviour { }
|
||||
public class SpringSystem : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private PlayerStats _stats;
|
||||
[SerializeField] private StringEventChannelSO _onEnemyDied; // EVT_EnemyDied
|
||||
|
||||
private EventSubscription _sub;
|
||||
private bool _subscribed;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_onEnemyDied != null)
|
||||
{
|
||||
_sub = _onEnemyDied.Subscribe(OnEnemyDied);
|
||||
_subscribed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_subscribed)
|
||||
{
|
||||
_sub.Dispose();
|
||||
_subscribed = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEnemyDied(string _enemyId)
|
||||
{
|
||||
_stats?.AddKillPoints(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
// 无敌帧:与地面冲刺共享同一无敌 CD(DashState._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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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) && 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 已是本物理帧最新结果,不存在跨帧读到过期值的问题。
|
||||
// 落地检测放在 OnStateUpdate(Update 阶段)时,若低帧率导致同帧内多次 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;
|
||||
|
||||
@@ -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 中转换到 FallState,Update 调用的将是 FallState.OnStateUpdate()。
|
||||
if (!Move.IsGrounded)
|
||||
_owner.TransitionTo(_owner.GetState<FallState>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 Layer(Layer 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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,58 +5,82 @@ namespace BaseGames.Player
|
||||
{
|
||||
/// <summary>
|
||||
/// 武器 HitBox 实例。
|
||||
/// 由 WeaponManager 在切换武器时实例化到角色的 [WeaponSocket] 子节点下,
|
||||
/// 武器卸下(切换)时销毁。
|
||||
/// 由 WeaponManager 在切换武器时实例化到角色的 [WeaponSocket] 子节点下。
|
||||
///
|
||||
/// Prefab 内部层级示例:
|
||||
/// [WPN_SkyBlade_HitBox]
|
||||
/// ├── [HitBox_Ground] ← BoxCollider2D, Layer = PlayerHitBox
|
||||
/// ├── [HitBox_Up]
|
||||
/// ├── [HitBox_Down]
|
||||
/// └── [HitBox_Air]
|
||||
/// Prefab 层级示例(每段连击独立一个子节点,在 Prefab 内可视化编辑 Collider2D):
|
||||
/// [WPN_SkyBlade_HitBox] ← WeaponHitBoxInstance
|
||||
/// ├── [HitBox_Ground] Id="" ← 方向默认(ComboStepConfig.hitBoxId 为空时使用)
|
||||
/// ├── [HitBox_Ground_1] Id="g1" ← 第 1 段专属判定
|
||||
/// ├── [HitBox_Ground_2] Id="g2" ← 第 2 段专属判定
|
||||
/// ├── [HitBox_Up] Id="" ← 上劈默认
|
||||
/// ├── [HitBox_Down] Id="" ← 下劈默认
|
||||
/// └── [HitBox_Air] Id="" ← 空中默认
|
||||
///
|
||||
/// 命名规范:Assets/Prefabs/Weapons/WPN_{weaponId}_HitBox.prefab
|
||||
/// ComboStepConfig.hitBoxId 留空 → 用方向默认;填 Id → 精确激活对应子节点。
|
||||
/// </summary>
|
||||
public class WeaponHitBoxInstance : MonoBehaviour
|
||||
{
|
||||
[Header("方向默认 HitBox(hitBoxId 为空时使用)")]
|
||||
[SerializeField] private HitBox _hitBoxGround;
|
||||
[SerializeField] private HitBox _hitBoxUp;
|
||||
[SerializeField] private HitBox _hitBoxDown;
|
||||
[SerializeField] private HitBox _hitBoxAir;
|
||||
|
||||
/// <summary>下劈 HitBox 命中确认事件(供 PlayerCombat 转发给 DownAttackState pogo 逻辑)。</summary>
|
||||
private HitBox[] _allHitBoxes;
|
||||
private AttackDirection _activeDir;
|
||||
|
||||
/// <summary>下劈命中确认事件(供 DownAttackState Pogo 逻辑)。</summary>
|
||||
public event System.Action<DamageInfo> OnDownHitConfirmed;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (_hitBoxDown != null)
|
||||
_hitBoxDown.OnHitConfirmed += info => OnDownHitConfirmed?.Invoke(info);
|
||||
_allHitBoxes = GetComponentsInChildren<HitBox>(true);
|
||||
foreach (var hb in _allHitBoxes)
|
||||
hb.OnHitConfirmed += OnAnyHitConfirmed;
|
||||
}
|
||||
|
||||
private void OnAnyHitConfirmed(DamageInfo info)
|
||||
{
|
||||
if (_activeDir == AttackDirection.Down)
|
||||
OnDownHitConfirmed?.Invoke(info);
|
||||
}
|
||||
|
||||
// ── 公共 API ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>激活指定方向的 HitBox。</summary>
|
||||
public void Activate(AttackDirection dir, DamageSourceSO source, Transform attacker)
|
||||
=> GetHitBox(dir)?.Activate(source, attacker);
|
||||
|
||||
/// <summary>停用指定方向的 HitBox。</summary>
|
||||
public void Deactivate(AttackDirection dir)
|
||||
=> GetHitBox(dir)?.Deactivate();
|
||||
|
||||
/// <summary>停用所有方向的 HitBox。</summary>
|
||||
public void DeactivateAll()
|
||||
/// <summary>
|
||||
/// 激活 HitBox。
|
||||
/// hitBoxId 非空时按 Id 精确查找 Prefab 子节点;空 = 使用该方向的默认 HitBox。
|
||||
/// </summary>
|
||||
public void Activate(AttackDirection dir, DamageSourceSO source, Transform attacker,
|
||||
string hitBoxId = "")
|
||||
{
|
||||
_hitBoxGround?.Deactivate();
|
||||
_hitBoxUp?.Deactivate();
|
||||
_hitBoxDown?.Deactivate();
|
||||
_hitBoxAir?.Deactivate();
|
||||
_activeDir = dir;
|
||||
var hitBox = string.IsNullOrEmpty(hitBoxId)
|
||||
? GetHitBox(dir)
|
||||
: (GetHitBoxById(hitBoxId) ?? GetHitBox(dir));
|
||||
hitBox?.Activate(source, attacker);
|
||||
}
|
||||
|
||||
/// <summary>切换连击段伤害源(不改变激活状态,供 PlayerCombat.SetComboSegmentSource 调用)。</summary>
|
||||
public void SetDamageSource(AttackDirection dir, DamageSourceSO source)
|
||||
=> GetHitBox(dir)?.SetDamageSource(source);
|
||||
/// <summary>停用指定方向的默认 HitBox。</summary>
|
||||
public void Deactivate(AttackDirection dir) => GetHitBox(dir)?.Deactivate();
|
||||
|
||||
/// <summary>按方向查询对应 HitBox 组件。</summary>
|
||||
/// <summary>停用 Prefab 中所有 HitBox。</summary>
|
||||
public void DeactivateAll()
|
||||
{
|
||||
if (_allHitBoxes == null) return;
|
||||
foreach (var hb in _allHitBoxes) hb.Deactivate();
|
||||
}
|
||||
|
||||
/// <summary>按 HitBox.Id 查找子节点(未找到返回 null)。</summary>
|
||||
public HitBox GetHitBoxById(string id)
|
||||
{
|
||||
if (_allHitBoxes == null || string.IsNullOrEmpty(id)) return null;
|
||||
foreach (var hb in _allHitBoxes)
|
||||
if (hb.Id == id) return hb;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>按攻击方向返回默认 HitBox。</summary>
|
||||
public HitBox GetHitBox(AttackDirection dir) => dir switch
|
||||
{
|
||||
AttackDirection.Ground => _hitBoxGround,
|
||||
|
||||
@@ -20,21 +20,32 @@ namespace BaseGames.Player
|
||||
public Sprite icon;
|
||||
public WeaponType weaponType;
|
||||
|
||||
[Header("连击动画(Animancer ClipTransition)")]
|
||||
public ClipTransition attack1Clip;
|
||||
public ClipTransition attack2Clip;
|
||||
public ClipTransition attack3Clip;
|
||||
public ClipTransition airAttackClip;
|
||||
public ClipTransition upAttackClip;
|
||||
public ClipTransition downAttackClip;
|
||||
[Header("地面连击序列(任意段数)")]
|
||||
[Tooltip("每个元素对应一段连击,支持任意段数,无需修改代码。")]
|
||||
public ComboStepConfig[] groundComboSteps =
|
||||
{
|
||||
new ComboStepConfig
|
||||
{
|
||||
hitBoxEnter = 0.3f, hitBoxExit = 0.6f,
|
||||
comboInputOpen = 0.3f,
|
||||
cancelWindowOpen = 0.5f,
|
||||
recoveryTime = 0.05f,
|
||||
comboTimeout = 0.25f,
|
||||
}
|
||||
};
|
||||
|
||||
[Header("伤害来源(每段独立 DamageSourceSO)")]
|
||||
public DamageSourceSO attack1Source;
|
||||
public DamageSourceSO attack2Source;
|
||||
public DamageSourceSO attack3Source;
|
||||
public DamageSourceSO airAttackSource;
|
||||
public DamageSourceSO upAttackSource;
|
||||
public DamageSourceSO downAttackSource;
|
||||
[Header("空中攻击序列")]
|
||||
[Tooltip("空中攻击连击序列,通常只需 1 段。")]
|
||||
public ComboStepConfig[] airComboSteps =
|
||||
{
|
||||
new ComboStepConfig { hitBoxEnter = 0.1f, hitBoxExit = 0.8f, recoveryTime = 0.05f }
|
||||
};
|
||||
|
||||
[Header("上劈(固定单段)")]
|
||||
public ComboStepConfig upStep = new ComboStepConfig { hitBoxEnter = 0.2f, hitBoxExit = 0.7f, recoveryTime = 0.05f };
|
||||
|
||||
[Header("下劈(固定单段)")]
|
||||
public ComboStepConfig downStep = new ComboStepConfig { hitBoxEnter = 0.1f, hitBoxExit = 0.9f, recoveryTime = 0.05f };
|
||||
|
||||
[Header("HitBox Prefab")]
|
||||
[Tooltip("武器专属 HitBox Prefab,内含 WeaponHitBoxInstance。\nWeaponManager 在切换武器时实例化于 [WeaponSocket] 下,武器卸下时销毁。\n命名规范:Assets/Prefabs/Weapons/WPN_{weaponId}_HitBox.prefab")]
|
||||
@@ -48,31 +59,78 @@ namespace BaseGames.Player
|
||||
[Min(0)]
|
||||
public int soulPowerGain = 10;
|
||||
|
||||
// ── 方向查询 ──────────────────────────────────────────────────────────
|
||||
public DamageSourceSO GetSourceByDir(AttackDirection dir) => dir switch
|
||||
{
|
||||
AttackDirection.Ground => attack1Source,
|
||||
AttackDirection.Up => upAttackSource,
|
||||
AttackDirection.Down => downAttackSource,
|
||||
AttackDirection.Air => airAttackSource,
|
||||
_ => attack1Source,
|
||||
};
|
||||
// ── 查询 API ──────────────────────────────────────────────────────────
|
||||
|
||||
public ClipTransition GetClipByCombo(int comboIndex) => comboIndex switch
|
||||
/// <summary>取指定方向、指定段的完整配置,越界自动取最后一个。</summary>
|
||||
public ComboStepConfig GetDirStep(AttackDirection dir, int index = 0)
|
||||
{
|
||||
0 => attack1Clip,
|
||||
1 => attack2Clip,
|
||||
2 => attack3Clip,
|
||||
_ => attack1Clip,
|
||||
};
|
||||
if (dir == AttackDirection.Up) return upStep;
|
||||
if (dir == AttackDirection.Down) return downStep;
|
||||
|
||||
var arr = dir switch
|
||||
{
|
||||
AttackDirection.Ground => groundComboSteps,
|
||||
AttackDirection.Air => airComboSteps,
|
||||
_ => groundComboSteps,
|
||||
};
|
||||
if (arr == null || arr.Length == 0)
|
||||
return new ComboStepConfig { hitBoxEnter = 0.3f, hitBoxExit = 0.6f, recoveryTime = 0.05f, comboTimeout = 0.25f };
|
||||
int idx = index < arr.Length ? index : arr.Length - 1;
|
||||
return arr[idx];
|
||||
}
|
||||
|
||||
/// <summary>取指定方向第 0 段的 DamageSource(供 EnableWeaponHitBox 使用)。</summary>
|
||||
public DamageSourceSO GetSourceByDir(AttackDirection dir) => GetDirStep(dir, 0).damageSource;
|
||||
|
||||
// ── 地面连击便捷方法 ──────────────────────────────────────────────────
|
||||
public ComboStepConfig GetGroundStep(int index) => GetDirStep(AttackDirection.Ground, index);
|
||||
public ClipTransition GetClipByCombo(int index) => GetGroundStep(index).clip;
|
||||
|
||||
/// <summary>地面连击最大段数。</summary>
|
||||
public int GroundComboCount => groundComboSteps?.Length ?? 0;
|
||||
}
|
||||
|
||||
/// <summary>单段攻击的完整配置:动画、伤害、HitBox 时间、连击窗口、攻速缩放参数。</summary>
|
||||
[System.Serializable]
|
||||
public struct ComboStepConfig
|
||||
{
|
||||
[Header("动画 & 伤害")]
|
||||
public ClipTransition clip;
|
||||
public DamageSourceSO damageSource;
|
||||
|
||||
[Header("HitBox 激活窗口(归一化 0-1)")]
|
||||
[Tooltip("HitBox 开启时间点")]
|
||||
[UnityEngine.Range(0f, 1f)] public float hitBoxEnter;
|
||||
[Tooltip("HitBox 关闭时间点")]
|
||||
[UnityEngine.Range(0f, 1f)] public float hitBoxExit;
|
||||
|
||||
[Header("连击输入窗口(归一化 0-1,0 = 从动画开始就可输入)")]
|
||||
[Tooltip("从此时间点起接受下一段连击输入(0 = 动画开始即可)")]
|
||||
[UnityEngine.Range(0f, 1f)] public float comboInputOpen;
|
||||
[Tooltip("从此时间点起停止接受连击输入(0 = 持续到动画结束)")]
|
||||
[UnityEngine.Range(0f, 1f)] public float comboInputClose;
|
||||
|
||||
[Header("取消窗口(归一化 0-1,0 = 仅在动画结束后恢复期内开放)")]
|
||||
[Tooltip("从此时间点起允许跳跃/冲刺打断")]
|
||||
[UnityEngine.Range(0f, 1f)] public float cancelWindowOpen;
|
||||
|
||||
[Header("时间(秒,受攻速倍率缩放)")]
|
||||
[Tooltip("动画结束后的硬直时间,期间跳跃/冲刺无法打断")]
|
||||
[UnityEngine.Min(0f)] public float recoveryTime;
|
||||
[Tooltip("硬直结束后等待连击输入的时间,超时返回 Idle(0 = 立即返回)")]
|
||||
[UnityEngine.Min(0f)] public float comboTimeout;
|
||||
|
||||
[Header("HitBox 绑定(留空 = 使用该方向的默认 HitBox)")]
|
||||
[Tooltip("对应 Prefab 中 HitBox 组件的 Id 字段。\n留空 = 使用方向默认 HitBox;\n非空 = 精确激活该 Id 的 HitBox,可视化编辑其 Collider2D 即为本段的判定形状。")]
|
||||
public string hitBoxId;
|
||||
}
|
||||
|
||||
// ── 武器类型枚举(架构 05 §7)──────────────────────────────────────────────
|
||||
public enum WeaponType
|
||||
{
|
||||
SkyBlade, // 天魂:裂空刃(高频轻击)
|
||||
EarthHammer, // 地魂:地震锤(低频重击,范围大)
|
||||
LifeScythe, // 命魂:命镰(穿透,直线斩)
|
||||
TianHun, // 天魂:裂空刃(高频轻击)
|
||||
DiHun, // 地魂:地震锤(低频重击,范围大)
|
||||
MingHun, // 命魂:命镰(穿透,直线斩)
|
||||
Custom, // 护符替换或未来扩展武器
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user