feat: Implement DownDash ability and related systems

- Added DownDash ability with cooldown and speed configuration.
- Introduced DownDashState to handle down dashing mechanics, including gravity manipulation and animation playback.
- Updated PlayerMovement to support DownDash functionality.
- Enhanced PlayerStats to manage spring charge consumption and healing.
- Modified PlayerCombat and WeaponHitBoxInstance to support new hit confirmation events.
- Updated AbilityType to include new form types for character abilities.
- Improved Gizmos for better visualization of enemy detection and attack ranges.
- Added feedback systems for form switching in PlayerFeedback and IFeedbackPlayer.
- Refactored combat and movement states to accommodate new abilities and ensure smooth transitions.
This commit is contained in:
2026-05-22 00:09:50 +08:00
parent 534de11e5d
commit 47bdc67cdf
27 changed files with 443 additions and 129 deletions

View File

@@ -47,9 +47,15 @@ namespace BaseGames.Player
/// </summary>
InvincibleDash = 1u << 18,
// ── 形态解锁(三魂形态)──────────────────────────────────────────────────
FormTianHun = 1u << 19, // 天魂形态(默认初始解锁)
FormDiHun = 1u << 20, // 地魂形态(游戏进程中解锁)
FormMingHun = 1u << 21, // 命魂形态(游戏进程中解锁)
// ── 组合掩码 ─────────────────────────────────────────────────────────
AllMovement = WallCling | WallJump | Dash | DoubleJump | SuperJump | Swim | DownDash | InvincibleDash,
AllSpells = Spell1 | Spell2 | Spell3,
AllSpirit = SpiritForm | SpiritDash,
AllForms = FormTianHun | FormDiHun | FormMingHun,
}
}

View File

@@ -13,6 +13,11 @@ namespace BaseGames.Player
[Header("形态列表 (forms[0]=Sky, forms[1]=Earth, forms[2]=Death)")]
public FormSO[] forms;
[Header("切换冷却")]
[Tooltip("形态切换冷却时长。CD 内重复按键不生效。推荐 0.5。")]
[Min(0f)]
public float SwitchCooldown = 0.5f;
/// <summary>按形态类型查找对应 FormSO找不到返回 null。</summary>
public FormSO GetFormByType(FormType type)
{

View File

@@ -3,6 +3,7 @@ using UnityEngine;
using BaseGames.Core;
using BaseGames.Core.Save;
using BaseGames.Core.Events;
using BaseGames.Feedback;
using BaseGames.Input;
namespace BaseGames.Player
@@ -21,6 +22,9 @@ namespace BaseGames.Player
[SerializeField] private FormConfigSO _config;
[SerializeField] private InputReaderSO _input;
[Header("依赖")]
[SerializeField] private PlayerStats _stats;
[Header("事件频道")]
[SerializeField] private IntEventChannelSO _onFormChanged; // 广播当前形态索引UI/Save
[SerializeField] private VoidEventChannelSO _onSkillSetChanged; // 通知 SkillHUD 刷新
@@ -29,12 +33,21 @@ namespace BaseGames.Player
public FormSO CurrentForm { get; private set; }
public FormSO[] AllForms => _config.forms;
/// <summary>当前切换 CD 剩余时间0 表示可切换。</summary>
public float SwitchCooldownRemaining => _switchCooldownTimer;
/// <summary>C# 事件WeaponManager 在 OnEnable 自订阅(架构 05 §6。</summary>
public event Action OnFormChanged;
private float _switchCooldownTimer;
private IFeedbackPlayer _feedback;
private void Awake()
{
Debug.Assert(_config != null, "[FormController] _config 未赋值,请在 Inspector 中指定 FormConfigSO。", this);
if (_stats == null)
_stats = GetComponent<PlayerStats>();
_feedback = GetComponentInChildren<IFeedbackPlayer>() ?? NullFeedbackPlayer.Instance;
}
private void OnEnable()
@@ -61,16 +74,43 @@ namespace BaseGames.Player
CurrentForm = _config.forms[0];
}
private void Update()
{
if (_switchCooldownTimer > 0f)
_switchCooldownTimer -= Time.deltaTime;
}
// ── 公共 API ────────────────────────────────────────────────────────────
/// <summary>切换到指定形态类型。若已在目标形态则不操作。</summary>
/// <summary>切换到指定形态类型。若已在目标形态、尚未解锁或 CD 未结束则不操作。</summary>
public void SwitchForm(FormType newFormType)
{
// CD 检查
if (_switchCooldownTimer > 0f) return;
// 检查对应形态的解锁标志
AbilityType required = newFormType switch
{
FormType.TianHun => AbilityType.FormTianHun,
FormType.DiHun => AbilityType.FormDiHun,
FormType.MingHun => AbilityType.FormMingHun,
_ => AbilityType.None,
};
if (required != AbilityType.None && _stats != null && !_stats.HasAbility(required))
return;
FormSO newForm = _config.GetFormByType(newFormType);
if (newForm == null || newForm == CurrentForm) return;
CurrentForm = newForm;
// 启动切换 CD
if (_config.SwitchCooldown > 0f)
_switchCooldownTimer = _config.SwitchCooldown;
// 播放对应形态切换反馈
_feedback.PlayFormSwitch((int)newFormType);
// 1. SO 事件广播索引UI/Save
_onFormChanged?.Raise(_config.GetFormIndex(newForm));

View File

@@ -33,6 +33,10 @@ namespace BaseGames.Player
[Header("弹簧")]
public AnimationClip UseSpring;
[Header("下冲刺")]
[Tooltip("向下冲刺动画。留空则复用 Fall 动画。")]
public AnimationClip DownDash;
[Header("弹反")]
public AnimationClip ParryStart;
public AnimationClip ParrySuccess;

View File

@@ -34,21 +34,25 @@ namespace BaseGames.Player
{
if (_weaponManager != null)
_weaponManager.OnWeaponChanged -= HandleWeaponChanged;
UnsubscribeDownHit();
UnsubscribeHitEvents();
}
private void HandleWeaponChanged(WeaponSO _)
{
UnsubscribeDownHit();
UnsubscribeHitEvents();
_currentHitBoxInstance = _weaponManager.ActiveHitBoxInstance;
if (_currentHitBoxInstance != null)
{
_currentHitBoxInstance.OnDownHitConfirmed += HandleDownHitConfirmed;
_currentHitBoxInstance.OnHitConfirmed += OnHitConfirmed;
}
}
private void UnsubscribeDownHit()
private void UnsubscribeHitEvents()
{
if (_currentHitBoxInstance == null) return;
_currentHitBoxInstance.OnDownHitConfirmed -= HandleDownHitConfirmed;
_currentHitBoxInstance.OnHitConfirmed -= OnHitConfirmed;
_currentHitBoxInstance = null;
}

View File

@@ -292,6 +292,15 @@ namespace BaseGames.Player
_rb.velocity = new Vector2(direction.x * speed, 0f);
}
/// <summary>
/// 施加向下冲刺速度DownDashState 调用)。
/// 冲刺期间调用 SetGravityScale(0) 可关闭重力;结束后恢复默认重力。
/// </summary>
public void DownDash(float speed)
{
_rb.velocity = new Vector2(0f, -speed);
}
/// <summary>
/// 壁滑:将垂直速度限制为 -speed每帧调用以约束最大下滑速度
/// WallSlideState.OnStateFixedUpdate 根据正常/受限模式传入不同速度。
@@ -422,7 +431,15 @@ namespace BaseGames.Player
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);
// 绘制地面检测矩形
{
Vector3 c = _groundCheck.position;
float hx = _groundCheckSize.x * 0.5f, hy = _groundCheckSize.y * 0.5f;
Gizmos.DrawLine(new Vector3(c.x - hx, c.y + hy), new Vector3(c.x + hx, c.y + hy));
Gizmos.DrawLine(new Vector3(c.x + hx, c.y + hy), new Vector3(c.x + hx, c.y - hy));
Gizmos.DrawLine(new Vector3(c.x + hx, c.y - hy), new Vector3(c.x - hx, c.y - hy));
Gizmos.DrawLine(new Vector3(c.x - hx, c.y - hy), new Vector3(c.x - hx, c.y + hy));
}
}
// ── 4. 靠墙检测射线(碰墙橙色 / 未碰淡黄,带端点圆点)─────────
@@ -436,11 +453,11 @@ namespace BaseGames.Player
Gizmos.color = lHit ? new Color(1f, 0.45f, 0f, 1f) : new Color(1f, 0.85f, 0.3f, 0.4f);
Gizmos.DrawRay(wallPos, Vector2.left * wLen);
BaseGames.Combat.HitBox.DrawWireCircle2D((Vector3)(wallPos + Vector2.left * wLen), 0.04f, 8);
Gizmos.DrawWireSphere((Vector3)(wallPos + Vector2.left * wLen), 0.04f);
Gizmos.color = rHit ? new Color(1f, 0.45f, 0f, 1f) : new Color(1f, 0.85f, 0.3f, 0.4f);
Gizmos.DrawRay(wallPos, Vector2.right * wLen);
BaseGames.Combat.HitBox.DrawWireCircle2D((Vector3)(wallPos + Vector2.right * wLen), 0.04f, 8);
Gizmos.DrawWireSphere((Vector3)(wallPos + Vector2.right * wLen), 0.04f);
#endif
}

View File

@@ -54,6 +54,12 @@ namespace BaseGames.Player
[Tooltip("无敌的独立冷却。CD 内再次冲刺不会获得无敌帧,防止连冲变相持续无敌(推荐 0.9s)。")]
public float DashInvincibilityCooldown = 0.9f;
[Header("下冲刺")]
[Tooltip("向下冲刺速度(单位/秒)。推荐 22快速向下穿透空间。")]
public float DownDashSpeed = 22f;
[Tooltip("向下冲刺持续时长(秒)。推荐 0.25s。")]
public float DownDashDuration = 0.25f;
[Header("抓墙 / 壁滑")]
[Tooltip("受限抓墙时(高于 wallGrabY的下滑速度单位/秒)。推荐 2。")]
public float WallSlideSpeed = 2f;

View File

@@ -257,12 +257,29 @@ namespace BaseGames.Player
}
// ── Spring ────────────────────────────────────────────────────────────
public bool UseSpring()
/// <summary>
/// 仅扣除灵泉充能SpringState 进入前摇时调用)。
/// 回血需前摇结束后调用 <see cref="ApplySpringHeal"/>。
/// </summary>
public bool ConsumeSpringCharge()
{
if (CurrentSpringCharges <= 0) return false;
CurrentSpringCharges--;
_onSpringChargesChanged?.Raise(CurrentSpringCharges);
HealHP(_config.SpringHealAmount);
return true;
}
/// <summary>
/// 执行灵泉回血SpringState 前摇动画结束时调用)。
/// </summary>
public void ApplySpringHeal() => HealHP(_config.SpringHealAmount);
/// <summary>扣除充能并立即回血(兼容旧调用路径)。</summary>
public bool UseSpring()
{
if (!ConsumeSpringCharge()) return false;
ApplySpringHeal();
return true;
}

View File

@@ -28,9 +28,10 @@ namespace BaseGames.Player
[Header("初始已解锁能力")]
[Tooltip("角色出生时默认持有的能力([Flags] \n\n" +
"FormTianHun天魂形态默认解锁请务必勾选。\n\n" +
"Dash地面与空中冲刺统一控制勾选后即可使用 DashState。\n\n" +
"DoubleJump追加次数由 PlayerMovementConfigSO.MaxAirJumps 决定。\n" +
"落地或 Pogo 命中后次数自动重置。")]
public AbilityType InitialAbilities = AbilityType.None;
public AbilityType InitialAbilities = AbilityType.FormTianHun;
}
}

View File

@@ -112,7 +112,10 @@ namespace BaseGames.Player.States
var animState = Anim.Play(step.clip);
animState.Speed *= spd; // 在 ClipTransition 自身速度基础上叠加攻速
// 每次重播同一 ClipTransition 会复用同一 AnimancerState
// 必须先清除旧事件再注册新事件,否则回调会累积叠加。
var events = animState.Events(this);
events.Clear();
events.OnEnd = OnClipEnd;
// HitBox 时间窗口capture step by value for closure safety
@@ -121,11 +124,24 @@ namespace BaseGames.Player.States
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);
{
events.Add(capturedStep.comboInputOpen, () =>
{
_comboWindowOpen = true;
// 窗口刚开时,补检查 InputBuffer——玩家可能在窗口前就提前按键
if (!_comboInputPending && Buffer.ConsumeAttack())
_comboInputPending = true;
});
}
else
_comboWindowOpen = true; // 0 = 立即开放
{
_comboWindowOpen = true;
if (!_comboInputPending && Buffer.ConsumeAttack())
_comboInputPending = true;
}
if (capturedStep.comboInputClose > 0f)
events.Add(capturedStep.comboInputClose, () => _comboWindowOpen = false);
@@ -141,11 +157,18 @@ namespace BaseGames.Player.States
_comboWindowOpen = false;
Move.SetCancelWindowOpen(false);
// 如果已有缓存输入,直接推进(零延迟连击)
// 有缓存连击输入且还不是最后一段 → 零延迟推进到下一段
if (_comboInputPending)
{
AdvanceCombo();
return;
_comboInputPending = false;
int maxCombo = Owner.Weapon?.ActiveWeapon?.GroundComboCount ?? 1;
if (_comboIndex < maxCombo - 1)
{
_comboIndex++;
PlayAttackClip();
return; // 新动画已开始,不进入等待阶段
}
// 已是最后一段:消耗掉多余输入,继续进入等待阶段(不 return
}
// 进入动画后等待阶段
@@ -169,7 +192,7 @@ namespace BaseGames.Player.States
}
else
{
// 已是最后一段,忽略多余输入,等待超时
// 已是最后一段,忽略多余输入,等待超时回 Idle
_comboInputPending = false;
}
}

View File

@@ -29,6 +29,9 @@ namespace BaseGames.Player.States
/// <summary>重置冲刺次数(落地或 Pogo 时由 IdleState/RunState/DownAttackState 调用)。</summary>
public void ResetDashCharge() => _dashChargeUsed = false;
/// <summary>消耗空中冲刺次数DownDashState 进入时调用,与普通空中冲刺共享次数上限)。</summary>
public void ConsumeAirDashCharge() => _dashChargeUsed = true;
/// <summary>
/// 无敌帧是否已冷却,即本次冲刺可以获得无敌。
/// </summary>

View File

@@ -0,0 +1,62 @@
using UnityEngine;
namespace BaseGames.Player.States
{
/// <summary>
/// 向下冲刺状态(空中下 + 冲刺触发)。
/// - 消耗空中冲刺次数(与普通冲刺共享,本次离地仅可用一次)。
/// - 关闭重力,施加向下速度,持续 DownDashDuration 秒。
/// - 提前着地或计时结束时退出。
/// - 需解锁 AbilityType.DownDash 才能进入FallState / JumpState 负责条件检查)。
/// </summary>
public class DownDashState : PlayerStateBase
{
private float _timer;
public DownDashState(PlayerController owner) : base(owner) { }
public override void OnStateEnter()
{
// 消耗空中冲刺次数(与普通空中冲刺互斥)
Owner.GetState<DashState>()?.ConsumeAirDashCharge();
_timer = Cfg.DownDashDuration;
// 关闭重力,施加向下速度
Move?.SetGravityScale(0f);
Move?.DownDash(Cfg.DownDashSpeed);
// 优先播放专属动画,未配置时回退到 Fall 动画
var clip = AnimCfg?.DownDash ?? AnimCfg?.Fall;
if (clip != null) Anim?.Play(clip);
}
public override void OnStateUpdate()
{
_timer -= Time.deltaTime;
if (_timer <= 0f || Move.IsGrounded)
EndDownDash();
}
public override void OnStateFixedUpdate()
{
// 持续保持向下速度(防止摩擦力减速)
if (_timer > 0f && !Move.IsGrounded)
Move?.DownDash(Cfg.DownDashSpeed);
}
public override void OnStateExit()
{
Move?.SetGravityScale(Cfg.DefaultGravityScale);
}
private void EndDownDash()
{
Move?.ZeroVelocity();
if (Move != null && Move.IsGrounded)
Owner.TransitionTo(Owner.GetState<IdleState>());
else
Owner.TransitionTo(Owner.GetState<FallState>());
}
}
}

View File

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

View File

@@ -6,6 +6,7 @@ namespace BaseGames.Player.States
/// 下落状态。
/// - 郊狼跳CoyoteTimer > 0 时按跳跃 → 一段跳JumpState使用 JumpForce
/// - 空中跳跃CoyoteTimer 耗尽后按跳跃且 AirJumpsLeft > 0 → JumpState使用 DoubleJumpForce
/// - 下冲刺HasAbility(DownDash) &amp;&amp; 下方向 + 冲刺键 → DownDashState优先于普通冲刺
/// - 冲刺HasAbility(Dash) &amp;&amp; DashState.CanDashMidAir → DashState地面与空中统一空中限一次
/// - 抓墙:贴墙时按下朝向墙壁的方向键 → WallSlideState。
/// - 增强下落重力FallGravityMult确保下落快于上升手感紧实。
@@ -53,9 +54,20 @@ namespace BaseGames.Player.States
return;
}
// ── 下冲刺(下 + 冲刺 → 向下冲刺,优先于普通冲刺)──────────────────────
// 按住下方向 + 冲刺键,且已解锁 DownDash 能力、空中冲刺次数未耗尽
var dashState = Owner.GetState<DashState>();
if (dashState != null && dashState.CanDashMidAir
&& Input.MoveInput.y < -0.5f
&& Stats != null && Stats.HasAbility(AbilityType.DownDash)
&& Buffer.ConsumeDash())
{
_owner.TransitionTo(_owner.GetState<DownDashState>());
return;
}
// ── 冲刺(地面/空中统一使用 DashState────────────────────────────
// 先确认能力与冷却均满足,再消耗缓冲,避免无操作时静默吃掉输入
var dashState = Owner.GetState<DashState>();
if (dashState != null && dashState.CanDashMidAir
&& Stats != null && Stats.HasAbility(AbilityType.Dash)
&& Buffer.ConsumeDash())

View File

@@ -52,9 +52,20 @@ namespace BaseGames.Player.States
return;
}
// ── 下冲刺(下 + 冲刺 → 向下冲刺,优先于普通冲刺)──────────────────────
// 按住下方向 + 冲刺键,且已解锁 DownDash 能力、空中冲刺次数未耗尽
var dashState = Owner.GetState<DashState>();
if (dashState != null && dashState.CanDashMidAir
&& Input.MoveInput.y < -0.5f
&& Stats != null && Stats.HasAbility(AbilityType.DownDash)
&& Buffer.ConsumeDash())
{
_owner.TransitionTo(_owner.GetState<DownDashState>());
return;
}
// 冲刺(地面/空中统一使用 DashState空中限一次优先于二段跳冲刺可保存二段跳机会
// 先确认能力与冷却均满足,再消耗缓冲,避免无操作时静默吃掉输入
var dashState = Owner.GetState<DashState>();
if (dashState != null && dashState.CanDashMidAir
&& Stats != null && Stats.HasAbility(AbilityType.Dash)
&& Buffer.ConsumeDash())

View File

@@ -350,6 +350,7 @@ namespace BaseGames.Player.States
_states[typeof(FallState)] = new FallState(this);
_states[typeof(AttackState)] = new AttackState(this);
_states[typeof(DashState)] = new DashState(this);
_states[typeof(DownDashState)] = new DownDashState(this);
_states[typeof(WallSlideState)] = new WallSlideState(this);
_states[typeof(WallJumpState)] = new WallJumpState(this);
_states[typeof(AirAttackState)] = new AirAttackState(this);

View File

@@ -14,11 +14,11 @@ namespace BaseGames.Player.States
public override void OnStateEnter()
{
// 消耗灵泉充能并治疗PlayerStats.UseSpring 内部回复 HP
bool used = Stats?.UseSpring() ?? false;
// 前摇开始时只扣除充能,不立即回血;回血在前摇结束后的 OnSpringEnd 中执行。
// 若前摇被打断(受伤 → HurtStateOnStateExit 被调用,充能已扣除但 OnSpringEnd 不会执行,回血失败。
bool used = Stats?.ConsumeSpringCharge() ?? false;
if (!used)
{
// 无充能时立即退出
Owner.TransitionTo(Owner.GetState<IdleState>());
return;
}
@@ -37,7 +37,7 @@ namespace BaseGames.Player.States
}
}
// 无动画则直接结束
// 无动画配置则直接结束(视为前摇瞬间完成)
OnSpringEnd();
}
@@ -49,6 +49,8 @@ namespace BaseGames.Player.States
private void OnSpringEnd()
{
// 前摇正常结束 → 执行回血
Stats?.ApplySpringHeal();
Owner.TransitionTo(Owner.GetState<IdleState>());
}
}

View File

@@ -32,6 +32,9 @@ namespace BaseGames.Player
/// <summary>下劈命中确认事件(供 DownAttackState Pogo 逻辑)。</summary>
public event System.Action<DamageInfo> OnDownHitConfirmed;
/// <summary>任意 HitBox 命中确认事件(供 PlayerCombat 订阅通用命中反馈)。</summary>
public event System.Action<DamageInfo> OnHitConfirmed;
private void Awake()
{
_allHitBoxes = GetComponentsInChildren<HitBox>(true);
@@ -41,6 +44,7 @@ namespace BaseGames.Player
private void OnAnyHitConfirmed(DamageInfo info)
{
OnHitConfirmed?.Invoke(info);
if (_activeDir == AttackDirection.Down)
OnDownHitConfirmed?.Invoke(info);
}

View File

@@ -27,6 +27,9 @@ namespace BaseGames.Player
// 护符注入的武器覆盖Key = FormSO.formIdValue = 替换武器(架构 05 §7
private readonly Dictionary<string, WeaponSO> _overrides = new();
// 对象池:避免每次切换形态时 Instantiate/DestroyKey = WeaponSOValue = 已创建实例)
private readonly Dictionary<WeaponSO, WeaponHitBoxInstance> _hitBoxPool = new();
private void Awake()
{
if (_formController != null && _formController.CurrentForm != null)
@@ -70,22 +73,33 @@ namespace BaseGames.Player
private void SetDirectWeapon(WeaponSO weapon)
{
var oldInstance = ActiveHitBoxInstance;
// 归还旧实例到池SetActive(false) 会触发 HitBox.OnDisable → Deactivate自动关闭 Collider2D
ActiveHitBoxInstance?.gameObject.SetActive(false);
ActiveWeapon = weapon;
ActiveHitBoxInstance = null;
if (weapon?.hitBoxPrefab != null && _weaponSocket != null)
{
var go = Instantiate(weapon.hitBoxPrefab, _weaponSocket);
ActiveHitBoxInstance = go.GetComponent<WeaponHitBoxInstance>();
if (!_hitBoxPool.TryGetValue(weapon, out var pooled) || pooled == null)
{
var go = Instantiate(weapon.hitBoxPrefab, _weaponSocket);
pooled = go.GetComponent<WeaponHitBoxInstance>();
_hitBoxPool[weapon] = pooled;
}
pooled.gameObject.SetActive(true);
ActiveHitBoxInstance = pooled;
}
// 通知订阅者(使其有机会取消旧实例事件订阅),再销毁旧实例
// 通知订阅者(PlayerCombat 取消旧实例事件订阅,订阅新实例
OnWeaponChanged?.Invoke(weapon);
}
if (oldInstance != null)
Destroy(oldInstance.gameObject);
private void OnDestroy()
{
foreach (var inst in _hitBoxPool.Values)
if (inst != null) Destroy(inst.gameObject);
_hitBoxPool.Clear();
}
// ── 护符 Override API由 WeaponOverrideEffect 调用,架构 05 §7──────