角色能力,存档

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

@@ -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,
}

View 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();
}
}
}

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 38830e1649a9eb548b20540a737bdf09
guid: 7ca41f67644b6b843ba7ef65e78b13e5
MonoImporter:
externalObjects: {}
serializedVersion: 2

View File

@@ -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);
}
}

View File

@@ -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];
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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+)之间平滑视觉位置,消除跳帧抖动
// SpritePixelSnapperLateUpdate +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;

View File

@@ -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("重力")]

View File

@@ -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 ─────────────────────────────────────────────────────

View File

@@ -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;
}
}

View File

@@ -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 &lt; -0.5
/// direction = -1 检查左侧接触法线指向右normal.x &gt; +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;
}

View File

@@ -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);
}
}
}

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,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);
}
}
}

View File

@@ -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("方向默认 HitBoxhitBoxId 为空时使用)")]
[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,

View File

@@ -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-10 = 从动画开始就可输入)")]
[Tooltip("从此时间点起接受下一段连击输入0 = 动画开始即可)")]
[UnityEngine.Range(0f, 1f)] public float comboInputOpen;
[Tooltip("从此时间点起停止接受连击输入0 = 持续到动画结束)")]
[UnityEngine.Range(0f, 1f)] public float comboInputClose;
[Header("取消窗口(归一化 0-10 = 仅在动画结束后恢复期内开放)")]
[Tooltip("从此时间点起允许跳跃/冲刺打断")]
[UnityEngine.Range(0f, 1f)] public float cancelWindowOpen;
[Header("时间(秒,受攻速倍率缩放)")]
[Tooltip("动画结束后的硬直时间,期间跳跃/冲刺无法打断")]
[UnityEngine.Min(0f)] public float recoveryTime;
[Tooltip("硬直结束后等待连击输入的时间,超时返回 Idle0 = 立即返回)")]
[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, // 护符替换或未来扩展武器
}