Files
zeling_v2/Docs/Architecture/05_PlayerModule.md
2026-05-08 11:04:00 +08:00

68 KiB
Raw Permalink Blame History

05 · 玩家模块

命名空间 BaseGames.PlayerBaseGames.Player.States
程序集 BaseGames.PlayerBaseGames.Player.States
路径 Assets/Scripts/Player/
依赖 BaseGames.CoreBaseGames.InputBaseGames.CombatBaseGames.ParryKybernetik.Animancer


目录

  1. Player Prefab 层级(完整)
  2. PlayerController协调器
  3. PlayerMovement
  4. PlayerStats
  5. PlayerCombat
  6. FormController
  7. WeaponManager
  8. SkillManager
  9. SpringSystem
  10. ParrySystem见 CombatModule
  11. FSM 状态基类与状态机
  12. 所有 State 类列表
  13. 关键 State 详细设计
  14. Animancer 动画系统
  15. PlayerMovementConfigSO
  16. PlayerStatsSO
  17. PlayerAnimationConfigSO
  18. FormConfigSO
  19. InputBuffer — 输入缓冲系统
  20. FSMTransitionTableSO — 数据驱动状态跳转

1. Player Prefab 层级

Assets/Prefabs/Player/PLY_Player.prefab
│
[PLY_Player]                    ← PlayerController + InputBuffer
├── PlayerMovement              ← Rigidbody2DDynamic, Freeze Z
├── PlayerStats
├── PlayerCombat
├── FormController
├── WeaponManager
├── SkillManager
├── SpringSystem
├── ParrySystem
├── AnimancerComponent          ← Animancer Pro
├── [HurtBox]                   ← BoxCollider2D, IsTrigger, Layer: PlayerHurtBox
│   └── HurtBox.cs
├── [WeaponSocket]              ← 空 Transform武器 Prefab 实例化后挂载于此
├── [SkillSocket]               ← 空 Transform技能 HitBox Prefab 实例化后挂载于此
├── [Shield]                    ← ShieldComponent.cs见 20_ShieldModule
├── [Animation]                 ← PlayerAnimationEvents.cs见 24_AnimEventModule
├── [Feedback]                  ← PlayerFeedback.cs实现 IFeedbackPlayer见 18_VFXFeedbackModule
└── SpriteRenderer

设计原则HitBox 固定挂载在 Player Prefab 上PlayerCombat 管理四向 HitBoxGround / Up / Down / Air
WeaponSO 是纯数据 SO不含 Prefab 引用),武器切换时 PlayerCombat.RefreshWeaponData() 自动
刷新 HitBox 的 DamageSource 与尺寸覆盖,确保碰撞盒伤害属性完全由武器数据驱动。
技能 HitBox 由 SkillManager.ExecuteSkill()[SkillSocket] 处动态实例化(独立 Prefab


2. PlayerController

// 路径: Assets/Scripts/Player/PlayerController.cs
[RequireComponent(typeof(InputBuffer))]
public class PlayerController : MonoBehaviour
{
    // ──── Inspector 序列化引用(同 Prefab 内)────────────────────────────────
    [Header("Sub Components")]
    [SerializeField] private PlayerMovement    _movement;
    [SerializeField] private PlayerStats       _stats;
    [SerializeField] private PlayerCombat      _combat;
    [SerializeField] private FormController    _formController;
    [SerializeField] private WeaponManager     _weaponManager;
    [SerializeField] private SkillManager      _skillManager;
    [SerializeField] private SpringSystem      _springSystem;
    [SerializeField] private ParrySystem       _parrySystem;
    [SerializeField] private AnimancerComponent _animancer;
    [SerializeField] private IFeedbackPlayer   _feedback;   // [Feedback] 子节点实现
    [SerializeField] private HurtBox           _hurtBox;
    [SerializeField] private ShieldComponent   _shield;     // [Shield] 子节点,见 20_ShieldModule

    [Header("Config SOs")]
    [SerializeField] private InputReaderSO          _inputReader;
    [SerializeField] private PlayerMovementConfigSO _movementConfig;
    [SerializeField] private PlayerAnimationConfigSO _animConfig;
    [SerializeField] private PlayerStatsSO          _statsConfig;
    [SerializeField] private FormConfigSO           _formConfig;

    [Header("Event Channels - Raise")]
    [SerializeField] private VoidEventChannelSO     _onPlayerDied;
    [SerializeField] private IntEventChannelSO      _onHPChanged;

    // ──── 运行时 FSM 状态机 ──────────────────────────────────────────────────
    private PlayerStateBase _currentState;

    // 预创建状态实例(避免 GC
    private IdleState    _idleState;
    private RunState     _runState;
    private JumpState    _jumpState;
    private FallState    _fallState;
    private DashState    _dashState;
    private AttackState  _attackState;
    private AirAttackState _airAttackState;
    private HurtState    _hurtState;
    private DeadState    _deadState;
    private ParryState   _parryState;
    private WallSlideState _wallSlideState;
    private SpringState  _springState;
    private SwimState    _swimState;      // 游泳状态(需 AbilityType.Swim 解锁)
    // ... 更多状态

    // ──── 公开只读属性(供 State 使用)────────────────────────────────────
    public PlayerMovement    Movement    => _movement;
    public PlayerStats       Stats       => _stats;
    public PlayerCombat      Combat      => _combat;
    public FormController    Form        => _formController;
    public WeaponManager     Weapon      => _weaponManager;
    public SkillManager      Skill       => _skillManager;
    public SpringSystem      Spring      => _springSystem;
    public InputReaderSO     Input       => _inputReader;
    public InputBuffer       Buffer      => _inputBuffer;   // GetComponent 缓存
    public AnimancerComponent Animancer  => _animancer;
    public PlayerMovementConfigSO MovConfig => _movementConfig;
    public PlayerAnimationConfigSO AnimConfig => _animConfig;
    public bool IsGrounded => _movement.IsGrounded;
    public int  FacingDirection => _movement.FacingDirection;
    public ShieldComponent Shield => _shield;                // 供 UI / 技能判断护盾状态

### 2.1 State 上下文接口分组

> **架构决策(2026-05**PlayerController 将所有子系统统一暴露,任何状态都可访问任何子系统——这破坏了最小权限原则,并让 State 之间的隐式依赖难以追踪。改用接口分组后,每个 State 只声明它实际使用的接口,编译器强制隔离。PlayerController 同时实现所有接口,现有代码无需迁移。

```csharp
// 路径: Assets/Scripts/Player/StateContexts.cs
// 各状态按需依赖最小接口PlayerController 统一实现

/// <summary>与移动相关的上下文:地面检测、朝向、移动驱动</summary>
public interface IMovementContext
{
    PlayerMovement         Movement       { get; }
    PlayerMovementConfigSO MovConfig      { get; }
    bool                   IsGrounded     { get; }
    int                    FacingDirection { get; }
}

/// <summary>与战斗相关的上下文:武器、技能、特殊机制</summary>
public interface ICombatContext
{
    PlayerCombat  Combat  { get; }
    WeaponManager Weapon  { get; }
    SkillManager  Skill   { get; }
    SpringSystem  Spring  { get; }
    ShieldComponent Shield { get; }
}

/// <summary>与动画相关的上下文Animancer + AnimConfig</summary>
public interface IAnimationContext
{
    AnimancerComponent       Animancer  { get; }
    PlayerAnimationConfigSO  AnimConfig { get; }
}

/// <summary>与输入相关的上下文InputReader + InputBuffer</summary>
public interface IInputContext
{
    InputReaderSO Input  { get; }
    InputBuffer   Buffer { get; }
}

/// <summary>与玩家属性相关的上下文stats + 能力查询</summary>
public interface IStatsContext
{
    PlayerStats Stats { get; }
}

// PlayerController 统一实现所有接口(向后兼容,现有 State 通过 PlayerController 拿到任何子系统的路径仍有效)
public class PlayerController : MonoBehaviour,
    IMovementContext, ICombatContext, IAnimationContext, IInputContext, IStatsContext

State 推荐依赖声明(在构造 / 注入时传入最小接口而非整个 PlayerController

State 类 实际使用接口
IdleState IMovementContext, IInputContext
RunState IMovementContext, IInputContext, IAnimationContext
JumpState IMovementContext, IInputContext, IAnimationContext, IStatsContext
AttackState ICombatContext, IAnimationContext, IInputContext
ParryState ICombatContext, IAnimationContext, IInputContext
HurtState IMovementContext, IAnimationContext
DashState IMovementContext, IStatsContext, IAnimationContext, IInputContext
SpringState ICombatContext, IAnimationContext, IMovementContext
SwimState IMovementContext, IInputContext, IAnimationContext

渐进迁移策略:新增 State 时直接依赖接口;已有 State 可在重构时逐步将 PlayerController 参数改为具体接口组合,不必一次全改。

// ──── 公开接口 ──────────────────────────────────────────────────────────
// 正常状态切换(各 State 调用,含优先级检查)
public void TryTransitionState(PlayerStateBase newState);

// 强制切换HurtBox/ParrySystem 调用,无视当前状态)
public void ForceState(PlayerStateBase newState);

// 初始化(由 Awake 执行)
private void InitializeStates();
private void Awake()
{
    // ... 其他初始化 ...
    _hurtBox.SetShieldable(_shield);   // 将护盾注入 HurtBox拦截伤害管线
}
private void Update()  => _currentState?.OnStateUpdate();
private void FixedUpdate() => _currentState?.OnStateFixedUpdate();
private void LateUpdate()  => _movement.UpdateFacing();

}


---

## 3. PlayerMovement

```csharp
// 路径: Assets/Scripts/Player/PlayerMovement.cs
[RequireComponent(typeof(Rigidbody2D))]
public class PlayerMovement : MonoBehaviour
{
    [SerializeField] private PlayerMovementConfigSO _config;

    // ──── 内部状态 ──────────────────────────────────────────────────────────
    private Rigidbody2D _rb;
    private float  _coyoteTimer;
    private bool   _isGrounded;
    private bool   _isWallLeft;
    private bool   _isWallRight;
    private bool   _onOneWayPlatform;
    private int    _facingDirection = 1;   // +1 右, -1 左

    // ──── 公开只读属性 ──────────────────────────────────────────────────────
    public bool IsGrounded      => _isGrounded;
    public bool HasCoyoteTime   => _coyoteTimer > 0;
    public bool IsWallLeft      => _isWallLeft;
    public bool IsWallRight     => _isWallRight;
    public int  FacingDirection => _facingDirection;
    public Rigidbody2D Rb       => _rb;

    // ──── 物理接口(供各 State 调用)────────────────────────────────────────
    public void Move(float speedX);             // 设置水平速度,自动翻转 SpriteRenderer
    public void Jump(bool isVariable = true);   // 施加跳跃冲量isVariable 允许 CutJump
    public void CutJump();                      // 减半垂直速度(松开跳跃键调用)
    public void Dash(Vector2 dir, float speed); // 施加冲刺速度(临时忽略重力)
    public void ApplyKnockback(Vector2 dir, float force);
    public void ZeroVelocity();
    public void ZeroHorizontalVelocity();
    public void SetGravityScale(float scale);  // 下坠时增大重力用
    public void DropThroughPlatform();         // 单向平台向下穿越(短暂忽略 OneWay Layer

    // ──── 检测(每帧 FixedUpdate 更新)──────────────────────────────────────
    private void CheckGrounded();   // Physics2D.OverlapBox at feet
    private void CheckWalls();      // 每侧发射两根射线(上/下偏移),均命中才算贴墙(防卡角)
                                    // 由 PlayerWallDetector 组件封装,见 §3.1
    public void UpdateFacing();     // 根据速度方向更新 _facingDirection + SpriteRenderer.flipX

    // ──── 可取消帧窗口Cancel Window───────────────────────────────────────
    // 由 PlayerAnimationEvents 的 CancelWindowOpen / CancelWindowClose 事件驱动
    // (见 24_AnimEventModule §9
    private bool _cancelWindowOpen;
    public bool CancelWindowOpen => _cancelWindowOpen;
    public void SetCancelWindowOpen(bool open) => _cancelWindowOpen = open;

    // ──── 地面材质(脚步系统)─────────────────────────────────────────
    // 由 FootstepSystem 读取(见 24_AnimEventModule §8
    public SurfaceType CurrentSurface { get; private set; }
}

物理参数来自 PlayerMovementConfigSO(见 §15


4. PlayerStats

// 路径: Assets/Scripts/Player/PlayerStats.cs
public class PlayerStats : MonoBehaviour
{
    [SerializeField] private PlayerStatsSO _config;

    // Event ChannelsRaise 方)
    [SerializeField] private IntEventChannelSO  _onHPChanged;
    [SerializeField] private IntEventChannelSO  _onMaxHPChanged;
    [SerializeField] private IntEventChannelSO  _onSoulPowerChanged;
    [SerializeField] private IntEventChannelSO  _onSpiritPowerChanged;
    [SerializeField] private IntEventChannelSO  _onSpringChargesChanged;
    [SerializeField] private IntEventChannelSO  _onGeoChanged;
    [SerializeField] private AbilityTypeEventChannelSO _onAbilityUnlocked;
    [SerializeField] private VoidEventChannelSO _onPlayerDied;

    // ──── 运行时属性 ──────────────────────────────────────────────────────
    public int   CurrentHP            { get; private set; }
    public int   MaxHP                { get; private set; }
    public int   CurrentSoulPower     { get; private set; }
    public int   MaxSoulPower         { get; private set; }  // = 100
    public int   CurrentSpiritPower   { get; private set; }
    public int   MaxSpiritPower       { get; private set; }
    public int   CurrentSpringCharges { get; private set; }
    public int   MaxSpringCharges     { get; private set; }
    public int   SpringKillPoints     { get; private set; }
    public int   CurrentGeo           { get; private set; }
    public bool  IsInvincible         => _invincibleTimer > 0;

    private float _invincibleTimer;
    private float _spiritRegenTimer;
    private HashSet<AbilityType> _unlockedAbilities = new();

    // ──── HP ──────────────────────────────────────────────────────────────
    public void TakeDamage(int amount);          // 扣血 → 发布 EVT_HPChanged → 若 = 0 发布 EVT_PlayerDied
    public void HealHP(int amount);              // 回血(上限 MaxHP→ 发布 EVT_HPChanged

    // ──── 能量资源 ─────────────────────────────────────────────────────────
    public void AddSoulPower(int amount);        // 灵力 +amount上限 MaxSoulPower
    public bool ConsumeSoulPower(int amount);    // 消耗灵力,返回是否足够
    public void AddSpiritPower(int amount);      // 魄元 +amount上限
    public bool ConsumeSpiritPower(int amount);  // 消耗魄元,返回是否足够

    // ──── 灵泉 ────────────────────────────────────────────────────────────
    public bool UseSpring();                     // 消耗 1 次返回是否成功charges > 0
    public void RestoreSpringCharges();          // 恢复至 MaxSpringCharges存档点触发
    public void AddKillPoints(int pts);         // 击杀积分 → 自动检查是否增加 SpringCharges

    // ──── 货币 ────────────────────────────────────────────────────────────
    public void AddGeo(int amount);
    public bool SpendGeo(int amount);            // 返回是否足够

    // ──── 无敌帧 ───────────────────────────────────────────────────────────
    public void BeginInvincibility(float duration);

    // ──── 能力 ────────────────────────────────────────────────────────────
    // 内部 bitmaskAbilityType [Flags] uint见 09_ProgressionModule §1
    private AbilityType _unlockedAbilities = AbilityType.None;

    public bool HasAbility(AbilityType ability) => (_unlockedAbilities & ability) != 0;
    public void UnlockAbility(AbilityType ability)
    {
        _unlockedAbilities |= ability;
        _onAbilityUnlocked.Raise(ability);  // AbilityTypeEventChannelSO触发 AbilityGate 等监听)
    }
    public void LockAbility(AbilityType ability) => _unlockedAbilities &= ~ability;  // NG+ 重置用

    // ──── 存档集成 ─────────────────────────────────────────────────────────
    public PlayerSaveData GetSaveData()
    {
        var d = new PlayerSaveData { /* ...其他字段... */ };
        d.AbilityFlags = (uint)_unlockedAbilities;   // 位掩码直接赋值,无 ToString/字典开销
        return d;
    }
    public void LoadSaveData(PlayerSaveData data)
    {
        _unlockedAbilities = (AbilityType)data.AbilityFlags;
        // ...其他字段加载...
    }

    // ──── Update ───────────────────────────────────────────────────────────
    private void Update()
    {
        // 无敌帧倒计时
        if (_invincibleTimer > 0) _invincibleTimer -= Time.deltaTime;
        // 魄元自动回复
        _spiritRegenTimer += Time.deltaTime;
        if (_spiritRegenTimer >= 1f)
        {
            _spiritRegenTimer = 0;
            AddSpiritPower(_config.SpiritRegenRate);
        }
    }
}

5. PlayerCombat

// 路径: Assets/Scripts/Player/PlayerCombat.cs
public class PlayerCombat : MonoBehaviour
{
    [SerializeField] private WeaponManager _weaponManager;
    [SerializeField] private SkillManager  _skillManager;

    // ── 玩家角色 Prefab 上的 HitBox固定挂载不依赖武器 Prefab────────────
    [SerializeField] private HitBox _hitBoxGround;
    [SerializeField] private HitBox _hitBoxUp;
    [SerializeField] private HitBox _hitBoxDown;
    [SerializeField] private HitBox _hitBoxAir;

    // ── 当前武器片段缓存(由 WeaponManager.OnWeaponChanged 触发刷新)─────────
    private ClipTransition _attack1Clip, _attack2Clip, _attack3Clip;
    private ClipTransition _airAttackClip, _upAttackClip, _downAttackClip;

    void OnEnable()
    {
        _weaponManager.OnWeaponChanged += RefreshWeaponData;
        if (_weaponManager.ActiveWeapon != null)
            RefreshWeaponData(_weaponManager.ActiveWeapon);
    }

    void OnDisable() => _weaponManager.OnWeaponChanged -= RefreshWeaponData;

    void RefreshWeaponData(WeaponSO weapon)
    {
        if (weapon == null) return;
        // 缓存动画片段
        _attack1Clip    = weapon.attack1Clip;
        _attack2Clip    = weapon.attack2Clip;
        _attack3Clip    = weapon.attack3Clip;
        _airAttackClip  = weapon.airAttackClip;
        _upAttackClip   = weapon.upAttackClip;
        _downAttackClip = weapon.downAttackClip;
        // 刷新 HitBox DamageSource初始段
        _hitBoxGround.SetDamageSource(weapon.attack1Source);
        // 刷新 HitBox 尺寸(零向量 = 使用默认)
        if (weapon.hitBoxSizeOverride != Vector2.zero)
            _hitBoxGround.SetSizeOverride(weapon.hitBoxSizeOverride, weapon.hitBoxOffsetOverride);
        else
            _hitBoxGround.ClearSizeOverride();
        ResetCombo();
    }

    // AttackState 在每段攻击开始时调用,切换当前段 DamageSource
    public void SetComboSegmentSource(ComboState segment)
    {
        WeaponSO w = _weaponManager.ActiveWeapon;
        DamageSourceSO src = segment switch
        {
            ComboState.Attack1    => w?.attack1Source,
            ComboState.Attack2    => w?.attack2Source,
            ComboState.Attack3    => w?.attack3Source,
            ComboState.AirAttack  => w?.airAttackSource,
            ComboState.UpAttack   => w?.upAttackSource,
            ComboState.DownAttack => w?.downAttackSource,
            _                     => null,
        };
        if (src != null) _hitBoxGround.SetDamageSource(src);
    }

    // ── HitBox 激活(供 AttackState / AnimationEvent 调用)─────────────────
    public void EnableWeaponHitBox(AttackDirection dir)
        => GetHitBox(dir)?.Activate(_weaponManager.ActiveWeapon?.GetSourceByDir(dir), transform);

    public void DisableWeaponHitBox(AttackDirection dir)
        => GetHitBox(dir)?.Deactivate();

    public void DisableAllWeaponHitBoxes()
    {
        _hitBoxGround?.Deactivate();
        _hitBoxUp?.Deactivate();
        _hitBoxDown?.Deactivate();
        _hitBoxAir?.Deactivate();
    }

    // ── 技能 HitBox 由 SkillManager.ExecuteSkill() 直接控制(见 §8──────────

    // 命中回调 → 增加灵力
    internal void OnHitConfirmed(DamageInfo info) { /* 增加灵力 */ }

    private void ResetCombo() { /* 重置连击计数 */ }

    private HitBox GetHitBox(AttackDirection dir) => dir switch
    {
        AttackDirection.Ground => _hitBoxGround,
        AttackDirection.Up     => _hitBoxUp,
        AttackDirection.Down   => _hitBoxDown,
        AttackDirection.Air    => _hitBoxAir,
        _                      => null,
    };
}

public enum AttackDirection { Ground, Up, Down, Air }

/// <summary>连击段枚举(供 SetComboSegmentSource 使用)</summary>
public enum ComboState { Attack1, Attack2, Attack3, AirAttack, UpAttack, DownAttack }

6. FormController

参见 Design/53_WeaponSystem.md §4 — FormSO 结构说明

// 路径: Assets/Scripts/Player/FormController.cs
public class FormController : MonoBehaviour
{
    [SerializeField] private FormConfigSO       _config;
    [SerializeField] private IntEventChannelSO  _onFormChanged;      // UI / Save 用int 索引)
    [SerializeField] private SkillManager       _skillManager;
    [SerializeField] private VoidEventChannelSO _onSkillSetChanged;  // 通知 SkillHUD见 09_ProgressionModule §11
    [SerializeField] private PaletteSwapSystem  _paletteSwapSystem;  // 见 18_VFXFeedbackModule §10

    // ── 运行时状态 ────────────────────────────────────────────────────────
    public FormSO CurrentForm { get; private set; }

    /// <summary>
    /// C# 事件WeaponManager / SkillManager 订阅以响应形态切换。
    /// 不直接注入这些系统——它们在 OnEnable 中自行订阅。
    /// </summary>
    public event Action OnFormChanged;

    /// <summary>所有形态列表(来自 FormConfigSO.forms。供 WeaponManager 枚举 override 时使用。</summary>
    public IReadOnlyList<FormSO> AllForms => _config.forms;

    // ── 公开 API ──────────────────────────────────────────────────────────
    public void SwitchForm(FormSO newForm)
    {
        if (newForm == null || newForm == CurrentForm) return;
        if (!IsFormUnlocked(newForm)) return;

        CurrentForm = newForm;
        // ① 发布事件频道UI/Save 监听)
        _onFormChanged.Raise(_config.GetFormIndex(newForm));
        // ② 触发 C# 事件WeaponManager / SkillManager 等系统监听)
        OnFormChanged?.Invoke();
        // ③ 形态过场(调色板、技能集变更通知)
        _paletteSwapSystem?.ApplyPalette(newForm.formAccentColor);
        _skillManager?.UpdateSkillSet(newForm.soulSkill, newForm.spiritSkill1, newForm.spiritSkill2);
        _onSkillSetChanged?.Raise();
    }

    public bool IsFormUnlocked(FormSO form)
        => _config.IsUnlocked(form.formId);
}

FormSO形态数据 SO

// 路径: Assets/ScriptableObjects/Player/Forms/Form_{Name}.asset
[CreateAssetMenu(menuName = "Player/Form")]
public class FormSO : ScriptableObject
{
    [Header("形态标识")]
    public string formId;          // "SkyForm" / "EarthForm" / "DeathForm"
    public string displayName;     // "天魂" / "地魂" / "命魂"
    public Sprite formIcon;
    public Color  formAccentColor; // 天魂青色 / 地魂橙色 / 命魂紫色(拖尾/UI 染色)

    [Header("默认武器")]
    /// <summary>FormAttackConfig旧内联结构体已废弃其所有字段迁移至 WeaponSO。</summary>
    public WeaponSO defaultWeapon;

    [Header("技能配置")]
    public SoulSpellSO  soulSkill;
    public FormSkillSO  spiritSkill1;
    public FormSkillSO  spiritSkill2;
}

7. WeaponManager

参见 Design/53_WeaponSystem.md §3 — 完整说明
HitBox 现挂载于角色 PrefabPlayerCombat 管理),不使用独立武器 PrefabWeaponSO 是纯数据 SO不含 Prefab 引用。

// 路径: Assets/Scripts/Player/WeaponManager.cs
public class WeaponManager : MonoBehaviour
{
    [SerializeField] private FormController _formController;

    /// <summary>当前激活的武器 SO。PlayerCombat / AttackState 从此读取攻击数据。</summary>
    public WeaponSO ActiveWeapon { get; private set; }

    /// <summary>武器切换时广播PlayerCombat / VFX 监听)</summary>
    public event Action<WeaponSO> OnWeaponChanged;

    // 护符注入的武器覆盖Key = FormSO.formIdValue = 替换武器
    readonly Dictionary<string, WeaponSO> _overrides = new();

    void Awake()
    {
        if (_formController.CurrentForm != null)
            ApplyWeapon(_formController.CurrentForm);
    }

    void OnEnable()  => _formController.OnFormChanged += HandleFormChanged;
    void OnDisable() => _formController.OnFormChanged -= HandleFormChanged;

    void HandleFormChanged() => ApplyWeapon(_formController.CurrentForm);

    void ApplyWeapon(FormSO form)
    {
        WeaponSO next = _overrides.TryGetValue(form.formId, out var ov)
            ? ov
            : form.defaultWeapon;

        if (next == ActiveWeapon) return;
        ActiveWeapon = next;
        OnWeaponChanged?.Invoke(next);
        next?.vfxConfig.onEquipFeedback?.PlayFeedbacks(gameObject);
    }

    // ─────────── 护符 Override API由 WeaponOverrideEffect 调用)───────────

    /// <summary>为指定形态设置武器覆盖。formId 为空 = 覆盖所有形态。</summary>
    public void SetOverride(string formId, WeaponSO weapon)
    {
        if (string.IsNullOrEmpty(formId))
            foreach (var f in _formController.AllForms) _overrides[f.formId] = weapon;
        else
            _overrides[formId] = weapon;
        ApplyWeapon(_formController.CurrentForm);
    }

    /// <summary>移除覆盖恢复默认武器。formId 为空 = 移除所有形态覆盖。</summary>
    public void ClearOverride(string formId)
    {
        if (string.IsNullOrEmpty(formId))
            foreach (var f in _formController.AllForms) _overrides.Remove(f.formId);
        else
            _overrides.Remove(formId);
        ApplyWeapon(_formController.CurrentForm);
    }
}

WeaponSO

// 路径: Assets/Scripts/Combat/WeaponSO.cs
// 资产路径: Assets/ScriptableObjects/Combat/Weapons/Weapon_{Name}.asset
[CreateAssetMenu(menuName = "Combat/Weapon")]
public class WeaponSO : ScriptableObject
{
    [Header("基础信息")]
    public string     weaponId;      // 全局唯一 ID如 "Weapon_SkyBlade"
    public string     displayName;   // "天裂刃"
    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("伤害来源(每段独立 DamageSourceSO")]
    public DamageSourceSO attack1Source;
    public DamageSourceSO attack2Source;
    public DamageSourceSO attack3Source;
    public DamageSourceSO airAttackSource;
    public DamageSourceSO upAttackSource;
    public DamageSourceSO downAttackSource;

    [Header("HitBox 配置")]
    /// <summary>零向量 = 使用 PlayerCombat 中的默认尺寸</summary>
    public Vector2 hitBoxSizeOverride;
    public Vector2 hitBoxOffsetOverride;

    [Header("武器特效")]
    public WeaponVFXConfig vfxConfig;

    /// <summary>根据攻击方向返回对应 DamageSourceSO便捷方法</summary>
    public DamageSourceSO GetSourceByDir(AttackDirection dir) => dir switch
    {
        AttackDirection.Ground => attack1Source,  // ground 当前段由 SetComboSegmentSource 动态切换
        AttackDirection.Up     => upAttackSource,
        AttackDirection.Down   => downAttackSource,
        AttackDirection.Air    => airAttackSource,
        _                      => null,
    };
}

public enum WeaponType
{
    SkyBlade,    // 天魂:裂空刃(高频轻击)
    EarthHammer, // 地魂:地震锤(低频重击,范围大)
    LifeScythe,  // 命魂:命镰(穿透,直线斩)
    Custom,      // 护符替换或未来扩展武器
}

[Serializable]
public class WeaponVFXConfig
{
    [Tooltip("切换到此武器时播放的特效(形态切换音效/粒子)")]
    public FeedbackPresetSO onEquipFeedback;

    [Tooltip("武器挥斩拖尾 Prefabnull = 不显示拖尾)")]
    public GameObject weaponTrailPrefab;

    public Color trailColor = Color.white;

    [Tooltip("命中特效类型覆盖null = 使用 DamageSourceSO.HitFxType")]
    public HitFxType? hitFxOverride;
}

WeaponOverrideEffect护符效果位于装备系统

// 路径: Assets/Scripts/Equipment/CharmEffects/WeaponOverrideEffect.cs
// 见 Design/53_WeaponSystem.md §6 及 17_EquipmentSystem §3
namespace BaseGames.Equipment
{
    [Serializable]
    public class WeaponOverrideEffect : ICharmEffect
    {
        [Tooltip("目标形态 ID留空 = 所有形态)")]
        public string   targetFormId;

        [Tooltip("替换武器 SO")]
        public WeaponSO replacementWeapon;

        public void OnEquip(EquipmentContext ctx)
            => ctx.WeaponMgr.SetOverride(targetFormId, replacementWeapon);

        public void OnUnequip(EquipmentContext ctx)
            => ctx.WeaponMgr.ClearOverride(targetFormId);

        public string GetEffectDescription()
        {
            string formStr = string.IsNullOrEmpty(targetFormId) ? "所有形态" : targetFormId;
            string wName   = replacementWeapon != null ? replacementWeapon.displayName : "null";
            return $"{formStr}的武器替换为 [{wName}]";
        }
    }
}
// EquipmentContext 需新增 WeaponManager WeaponMgr 字段(见 17_EquipmentSystem §3

8. SkillManager

// 路径: Assets/Scripts/Player/SkillManager.cs
public class SkillManager : MonoBehaviour
{
    [SerializeField] private InputReaderSO _inputReader;
    [SerializeField] private PlayerStats   _stats;
    [SerializeField] private FormConfigSO  _formConfig;

    // 魂技能(消耗灵力)
    private void OnSoulSkill();    // 根据 CurrentForm 从 FormConfigSO 获取 SoulSpellSO 并执行

    // 魄技能(消耗魄元)
    private void OnSpiritSkill1Started();
    private void OnSpiritSkill1Cancelled();
    private void OnSpiritSkill2Started();
    private void OnSpiritSkill2Cancelled();
}

9. SpringSystem

// 路径: Assets/Scripts/Player/SpringSystem.cs
// 灵泉(快速回血物品,使用次数有限)
public class SpringSystem : MonoBehaviour
{
    [SerializeField] private PlayerStats _stats;
    [SerializeField] private PlayerController _controller;
    [SerializeField] private VoidEventChannelSO _onSpringUsed;   // 触发 SpringState

    // 由 InputReader 触发
    public void TryUseSpring();   // 检查 charges > 0 且 _stats.ConsumeSoulPower(触发消耗) → ForceState(SpringState)
}

10. ParrySystem

06_CombatModule.md §4。


11. FSM 状态基类与状态机

// 路径: Assets/Scripts/Player/States/PlayerStateBase.cs
public abstract class PlayerStateBase
{
    protected PlayerController _owner;

    // 构造时注入 Controller
    protected PlayerStateBase(PlayerController owner) => _owner = owner;

    public virtual void OnStateEnter()  { }
    public virtual void OnStateUpdate() { }
    public virtual void OnStateFixedUpdate() { }
    public virtual void OnStateExit()   { }

    // 便捷属性
    protected InputReaderSO     Input   => _owner.Input;
    protected InputBuffer       Buffer  => _owner.Buffer;
    protected PlayerMovement    Move    => _owner.Movement;
    protected PlayerStats       Stats   => _owner.Stats;
    protected AnimancerComponent Anim   => _owner.Animancer;
    protected PlayerMovementConfigSO Cfg => _owner.MovConfig;
}

12. 所有 State 类列表

类名 命名空间 触发条件 退出条件
IdleState States 落地 + 无输入 有移动输入 / 跳跃 / 攻击 / 冲刺
RunState States 落地 + 水平输入 停止输入 / 跳跃 / 攻击 / 冲刺
JumpState States 地面 Jump 输入(或 CoyoteTime 内) 速度 ≤ 0→ FallState
FallState States 离地 + 下落 落地 / 跳跃缓冲(已有缓冲则跳跃)
DashState States Dash 输入(有冲刺解锁) 冲刺时长结束
AerialDashState States 空中 Dash 输入 冲刺时长结束
AttackState States 地面 Attack 输入 动画完成 / 连击超时
AirAttackState States 空中 Attack 输入 动画完成
UpAttackState States 地面 UpAttack 输入 动画完成
DownAttackState States 空中 DownAttackPogo输入 动画完成
HurtState States HurtBox.ReceiveDamageForceState 硬直时长结束
DeadState States PlayerStats.HP = 0ForceState 永不自动退出
ParryState States Parry 输入 弹反窗口关闭
WallSlideState States 空中 + 贴墙 + 下落 离墙 / 跳跃
WallJumpState States WallSlide 中 Jump 输入 速度方向改变
SpringState States SpringSystem.TryUseSpringForceState 回血动画完成
CutsceneState States CutsceneManager 触发 EVT_CutsceneEnded
SwimState States EVT_LiquidEntered + abilities.swim == true EVT_LiquidExited / 离开液体区域

13. 关键 State 详细设计

AttackState连击链

public class AttackState : PlayerStateBase
{
    private int   _comboStep = 0;           // 当前连击步0-based最大 2 = 三段连击)
    private float _comboTimer;              // 连击窗口倒计时
    private float _cancelWindowEnd;         // 允许接受下一击输入的时间窗口
    private bool  _nextAttackBuffered;

    // 最大连击段数由 WeaponSO 决定(若 attack3Clip 为 null 则最大 2 段)
    private int MaxComboSteps => GetMaxCombo(_owner.Weapon.ActiveWeapon);

    private static int GetMaxCombo(WeaponSO w)
    {
        if (w == null) return 1;
        if (w.attack3Clip?.Clip != null) return 3;
        if (w.attack2Clip?.Clip != null) return 2;
        return 1;
    }

    public override void OnStateEnter()
    {
        var weapon = _owner.Weapon.ActiveWeapon;
        // 根据当前连击段切换 DamageSource 并播放对应片段
        var segment = _comboStep switch { 0 => ComboState.Attack1, 1 => ComboState.Attack2, _ => ComboState.Attack3 };
        _owner.Combat.SetComboSegmentSource(segment);

        var clip = _comboStep switch { 0 => weapon?.attack1Clip, 1 => weapon?.attack2Clip, _ => weapon?.attack3Clip };
        if (clip != null) Anim.Play(clip);

        _owner.Combat.EnableWeaponHitBox(AttackDirection.Ground);  // AnimationEvent 控制开关更精确时改为 AnimEvent

        var src = _comboStep switch
        {
            0 => weapon?.attack1Source, 1 => weapon?.attack2Source, _ => weapon?.attack3Source
        };
        _comboTimer      = src?.ComboWindowDuration ?? 0.5f;
        _cancelWindowEnd = src?.CancelWindowEnd     ?? 0f;
    }

    public override void OnStateUpdate()
    {
        _comboTimer -= Time.deltaTime;

        // 在取消窗口内检测下一击输入
        if (Time.time < _cancelWindowEnd && Buffer.ConsumeAttack())
            _nextAttackBuffered = true;

        // 动画播放完毕
        if (!Anim.IsPlaying)
        {
            if (_nextAttackBuffered && _comboStep < MaxComboSteps - 1)
            {
                _comboStep++;
                _nextAttackBuffered = false;
                OnStateEnter();   // 继续连击
            }
            else
            {
                _comboStep = 0;
                _owner.TryTransitionState(_owner.IsGrounded ? (PlayerStateBase)_idleState : _fallState);
            }
        }

        // 连击窗口超时
        if (_comboTimer <= 0) _comboStep = 0;
    }

    public override void OnStateExit() => _owner.Combat.DisableAllWeaponHitBoxes();
}

HurtState

public class HurtState : PlayerStateBase
{
    private float _stunTimer;

    public void Configure(DamageInfo info)   // ForceState 前由 HurtBox 调用
    {
        _stunTimer = info.HitStunDuration;
    }

    public override void OnStateEnter()
    {
        Move.ApplyKnockback(info.KnockbackDirection, info.KnockbackForce);
        Move.ZeroHorizontalVelocity();
        Stats.BeginInvincibility(Stats.InvincibilityDuration);
        Anim.Play(_owner.AnimConfig.HurtClip);
    }

    public override void OnStateUpdate()
    {
        _stunTimer -= Time.deltaTime;
        if (_stunTimer <= 0)
            _owner.TryTransitionState(Move.IsGrounded ? (PlayerStateBase)_idleState : _fallState);
    }
}

WallSlideState贴墙下滑

// 进入条件:空中 + 贴墙PlayerWallDetector.IsTouchingWall+ 下落速度 < 0
// 退出条件:离墙 / 跳跃输入 / 落地
public class WallSlideState : PlayerStateBase
{
    private float _wallGrabY;           // 进入贴墙时的 Y 坐标,用于防无限蹬墙

    public override void OnStateEnter()
    {
        _wallGrabY = _owner.transform.position.y;
        Move.SetGravityScale(0f);                   // 关闭重力,自行控制下滑速度
        Anim.Play(_clips.WallSlide);
    }

    public override void OnStateUpdate()
    {
        // 防无限蹬墙:如果高度超出进入时超过阈值,立即离墙
        float heightGain = _owner.transform.position.y - _wallGrabY;
        if (heightGain > _config.WallGrabMaxHeightGain)
        {
            _owner.TryTransitionState(_fallState);
            return;
        }

        // 贴墙下滑(固定向下速度)
        Move.SetVelocityY(-_config.WallSlideSpeed);

        // 跳跃输入 → WallJumpState
        if (Input.Jump.WasPressedThisFrame())
        {
            _owner.TryTransitionState(_wallJumpState);
            return;
        }

        // 离墙检查PlayerWallDetector 刷新结果)
        if (!_owner.WallDetector.IsTouchingWall || Move.IsGrounded)
            _owner.TryTransitionState(_fallState);
    }

    public override void OnStateExit()
    {
        Move.SetGravityScale(_config.DefaultGravityScale); // 恢复重力
    }
}

PlayerWallDetector(独立组件,保持 PlayerMovement 整洁):

// 路径: Assets/Scripts/Player/PlayerWallDetector.cs
// 每 FixedUpdate 发射双射线TopRay + BottomRay两根均命中才判定为贴墙
[RequireComponent(typeof(PlayerMovement))]
public class PlayerWallDetector : MonoBehaviour
{
    [SerializeField] private PlayerMovementConfigSO _config;

    public bool IsTouchingWall  { get; private set; }
    public int  WallDirection   { get; private set; }   // +1 = 右墙,-1 = 左墙

    private void FixedUpdate()
    {
        bool rightWall = CheckSide(Vector2.right);
        bool leftWall  = CheckSide(Vector2.left);

        IsTouchingWall = rightWall || leftWall;
        WallDirection  = rightWall ? 1 : leftWall ? -1 : 0;
    }

    // 每侧发两根射线TopRay + BottomRay两根均命中才返回 true
    private bool CheckSide(Vector2 dir)
    {
        Vector2 center = transform.position;
        float   len    = _config.WallRayLength;
        float   oy     = _config.WallRayOffsetY;
        int     layer  = LayerMask.GetMask("Wall");   // Layer 10见 06_CombatModule §12

        bool top = Physics2D.Raycast(center + Vector2.up * oy,    dir, len, layer);
        bool bot = Physics2D.Raycast(center + Vector2.down * oy,  dir, len, layer);
        return top && bot;
    }
}

WallJumpState蹬墙跳

两种跳跃类型(根据玩家输入方向自动判断):

类型 条件 水平速度 垂直速度 说明
背墙跳Wall-bounce 无水平输入 / 同向输入 WallJumpBackForceX(同向) WallJumpForceY 沿墙弹出,常用于小幅度调整
对墙跳Wall-fly 反向输入 WallJumpAwayForceX(反向) WallJumpAwayForceY 快速飞离墙壁,有短暂输入锁定
public class WallJumpState : PlayerStateBase
{
    private float _inputLockTimer;   // 对墙跳后短暂锁定水平输入
    private int   _jumpDirection;    // 起跳时的墙壁方向(-1/+1

    public override void OnStateEnter()
    {
        _jumpDirection = _owner.WallDetector.WallDirection;
        float inputX   = Input.Move.x;

        bool isWallFly = (inputX != 0) && (Mathf.Sign(inputX) != Mathf.Sign(_jumpDirection));

        if (isWallFly)
        {
            // 对墙跳:反向飞出 + 短暂水平输入锁
            Move.SetVelocity(new Vector2(-_jumpDirection * _config.WallJumpAwayForceX,
                                         _config.WallJumpAwayForceY));
            _inputLockTimer = _config.WallJumpInputLockDuration;
        }
        else
        {
            // 背墙跳:同向弹出
            Move.SetVelocity(new Vector2(-_jumpDirection * _config.WallJumpBackForceX,
                                         _config.WallJumpForceY));
            _inputLockTimer = 0f;
        }

        Anim.Play(_clips.Jump);
    }

    public override void OnStateUpdate()
    {
        _inputLockTimer -= Time.deltaTime;
        bool canControl = _inputLockTimer <= 0f;

        if (canControl)
        {
            float inputX = Input.Move.x;
            Move.ApplyHorizontalAcceleration(inputX);   // 恢复正常空中操控
        }

        // 松开跳跃键 → 截断跳跃
        if (Input.Jump.WasReleasedThisFrame())
            Move.CutJump();

        // 转为下落状态
        if (Move.Velocity.y < 0)
            _owner.TryTransitionState(_fallState);
    }
}

玩家使用 Animancer ProAnimancerComponent,采用双层动画模式:

作用 示例
Base Layer0 全身状态动画(移动/攻击/受伤/死亡) Idle、Run、Attack_01
Overlay Layer1 上半身叠加层(边移动边施法) SoulSpell、UseSpring

每个 AnimancerState 对应一个 AnimationClip,由 PlayerAnimationConfigSO 统一配置。


15. PlayerMovementConfigSO

[CreateAssetMenu(menuName = "Player/MovementConfig")]
public class PlayerMovementConfigSO : ScriptableObject
{
    [Header("Ground Movement")]
    public float RunSpeed           = 7f;
    public float Acceleration       = 50f;
    public float Deceleration       = 80f;

    [Header("Jump")]
    public float JumpForce          = 18f;
    public float CoyoteTime         = 0.12f;    // 秒
    public float FallGravityMult    = 2.5f;     // 下落时额外重力倍率
    public float MaxFallSpeed       = 20f;

    [Header("Dash")]
    public float DashSpeed          = 20f;
    public float DashDuration       = 0.18f;    // 秒
    public float DashCooldown       = 0.4f;     // 秒
    public int   MaxAerialDashes    = 1;        // 空中冲刺次数(升级后可增加)

    [Header("Wall")]
    public float WallSlideSpeed     = 2f;
    public float WallJumpForceX     = 12f;
    public float WallJumpForceY     = 16f;

    [Header("Wall — 检测")]
    public float WallRayLength      = 0.55f;  // 墙壁射线长度
    public float WallRayOffsetY     = 0.2f;   // 上/下两根射线相对中心的 Y 偏移(双射线检测)

    [Header("Wall — WallGrab")]
    public float WallGrabMaxHeightGain = 0.5f; // 允许的最大垂直高度增益(防无限蹬墙)
    public float WallGrabReleaseDelay  = 0.08f; // 离开墙面后的延迟释放(避免误判)

    [Header("Wall — WallJump")]
    public float WallJumpBackForceX  = 14f;   // 背墙跳(同向)水平速度
    public float WallJumpAwayForceX  = 10f;   // 对墙跳(反向)水平速度
    public float WallJumpAwayForceY  = 18f;   // 对墙跳垂直速度
    public float WallJumpInputLockDuration = 0.15f; // 对墙跳后短暂锁定水平输入(防立即转向)
}

16. PlayerStatsSO

[CreateAssetMenu(menuName = "Player/Stats")]
public class PlayerStatsSO : ScriptableObject
{
    [Header("HP")]
    public int MaxHP             = 5;    // 初始心脏容器数 × 2半格为1

    [Header("Soul Power")]
    public int MaxSoulPower      = 100;

    [Header("Spirit Power")]
    public int MaxSpiritPower    = 100;
    public int SpiritRegenRate   = 5;    // 每秒回复量

    [Header("Spring")]
    public int MaxSpringCharges  = 3;    // 初始灵泉次数上限
    public int SpringHealAmount  = 2;    // 每次回复 HP 量(半格)
    public int SpringKillThreshold = 4; // 增加 1 次灵泉所需击杀点数

    [Header("Invincibility")]
    public float InvincibilityDuration = 0.6f;

    [Header("Geo")]
    public int InitialGeo        = 0;
}

17. PlayerAnimationConfigSO

[CreateAssetMenu(menuName = "Player/AnimationConfig")]
public class PlayerAnimationConfigSO : ScriptableObject
{
    [Header("Base Layer Clips")]
    public AnimationClip Idle;
    public AnimationClip Run;
    public AnimationClip Jump;
    public AnimationClip Fall;
    public AnimationClip Dash;
    public AnimationClip WallSlide;
    public AnimationClip Hurt;
    public AnimationClip Dead;
    public AnimationClip UseSpring;

    [Header("Attack Combo Clips按连击序索引")]
    public AnimationClip[] GroundAttacks;   // [0] = combo1, [1] = combo2, [2] = combo3
    public AnimationClip   AirAttack;
    public AnimationClip   UpAttack;
    public AnimationClip   DownAttack;      // Pogo

    [Header("Parry")]
    public AnimationClip ParryStart;
    public AnimationClip ParrySuccess;

    // 根据连击步获取动画
    public AnimationClip GetAttackClip(int step) =>
        step < GroundAttacks.Length ? GroundAttacks[step] : GroundAttacks[^1];
}

18. FormConfigSO

[CreateAssetMenu(menuName = "Player/FormConfig")]
public class FormConfigSO : ScriptableObject
{
    [Header("形态列表(按解锁顺序排列)")]
    // forms[0] = 天魂, forms[1] = 地魂, forms[2] = 命魂
    // 每个 FormSO 包含formId / displayName / formIcon / formAccentColor
    //                   defaultWeapon / soulSkill / spiritSkill1 / spiritSkill2
    public FormSO[] forms;

    [Header("初始解锁状态")]
    // 与 forms[] 对应true = 初始解锁false = 需要在游戏内解锁
    public bool[] initialUnlocked;   // initialUnlocked[0] = true天魂初始解锁

    // ── 编辑器便利方法 ─────────────────────────────────────────────────────
    public int GetFormIndex(FormSO form)
    {
        for (int i = 0; i < forms.Length; i++)
            if (forms[i] == form) return i;
        return -1;
    }

    public bool IsUnlocked(string formId)
    {
        for (int i = 0; i < forms.Length; i++)
            if (forms[i].formId == formId) return i < initialUnlocked.Length && initialUnlocked[i];
        return false;
    }
}

旧字段(SkyFormSoulSpell / EarthFormSoulSpell 等独立字段)已迁移至各 FormSOsoulSkill / spiritSkill1 / spiritSkill2 字段中。


19. InputBuffer — 输入缓冲系统

P2 优化:原 InputBuffer 的缓冲窗口时长硬编码为 0.1f,无障碍需求(高延迟玩家、手残模式)或竞技模式(精确 0 容忍)无法调整。将缓冲时长提取至 AccessibilitySettingsSO,运行时可动态覆盖。

// 路径: Assets/Scripts/Player/Input/InputBuffer.cs
namespace BaseGames.Player
{
    /// <summary>
    /// 单帧输入缓冲系统:记录"提前输入的指令",在接受窗口开启时消费。
    /// 典型用例:攻击收招前 0.1s 内按下跳跃 → 收招结束后立即起跳。
    /// </summary>
    public class InputBuffer : MonoBehaviour
    {
        // ── 配置 ──────────────────────────────────────────────────────────
        /// <summary>
        /// 缓冲窗口基准时长(秒)。由 AccessibilitySettingsSO 在初始化时注入,
        /// 默认 0.1f,无障碍模式最高可扩展至 0.3f。
        /// </summary>
        [SerializeField, Min(0f), Tooltip("基准缓冲时长AccessibilitySettingsSO 会覆盖此值")]
        private float _baseDuration = 0.1f;

        /// <summary>运行时有效缓冲时长 = _baseDuration × AccessibilityMultiplier。</summary>
        private float _effectiveDuration;

        // ── 运行时状态 ────────────────────────────────────────────────────
        private InputActionType _bufferedAction;
        private float           _bufferExpiry;   // Time.time 过期时刻

        // ── 初始化 ────────────────────────────────────────────────────────
        public void Initialize(AccessibilitySettingsSO accessibility)
        {
            _effectiveDuration = _baseDuration *
                (accessibility != null ? accessibility.InputBufferMultiplier : 1f);
        }

        // ── 写入InputReaderSO 事件回调中调用)──────────────────────────
        public void Buffer(InputActionType action)
        {
            _bufferedAction = action;
            _bufferExpiry   = Time.time + _effectiveDuration;
        }

        // ── 读取 & 消费State.OnEnter / State.OnUpdate 中调用)──────────
        /// <summary>若缓冲指令与 <paramref name="expected"/> 匹配且未过期,消费并返回 true。</summary>
        public bool ConsumeIfMatch(InputActionType expected)
        {
            if (_bufferedAction == expected && Time.time <= _bufferExpiry)
            {
                _bufferedAction = InputActionType.None;
                _bufferExpiry   = 0f;
                return true;
            }
            return false;
        }

        /// <summary>无条件清除缓冲(状态切换时调用,防止残留输入污染新状态)。</summary>
        public void Clear()
        {
            _bufferedAction = InputActionType.None;
            _bufferExpiry   = 0f;
        }
    }
}

AccessibilitySettingsSO 相关字段

// 路径: Assets/Scripts/Core/Settings/AccessibilitySettingsSO.cs新增字段
[CreateAssetMenu(menuName = "Settings/AccessibilitySettings")]
public class AccessibilitySettingsSO : ScriptableObject
{
    [Header("输入辅助")]
    [SerializeField, Range(0.5f, 3.0f),
     Tooltip("输入缓冲时长倍率。1.0 = 标准2.0 = 宽松模式(约 0.2s 缓冲0.5 = 精确模式")]
    public float InputBufferMultiplier = 1.0f;
}

初始化时机PlayerController.Awake() 中调用 _inputBuffer.Initialize(_accessibilitySettings)_accessibilitySettings 通过 [SerializeField] 注入。

缓冲时长参考值

模式 InputBufferMultiplier 有效缓冲时长
标准(默认) 1.0 100ms
无障碍宽松 2.0 200ms
无障碍超宽松 3.0 300ms
精确 / 竞技 0.5 50ms

20. FSMTransitionTableSO — 数据驱动状态跳转

P1 优化:当前 PlayerStateMachine 内各 State 的 OnStateUpdate() 里硬编码
if (condition) _sm.ChangeState(new XxxState(_owner)) 跳转逻辑,
增减状态时需修改多个类,设计师无法通过 Inspector 调整跳转优先级。
引入 FSMTransitionTableSO,将跳转规则外化为可配置资产,
实现 "0 代码改动增加新跳转条件" 的设计师友好工作流。

20.1 数据结构

// 路径: Assets/Scripts/Player/FSM/FSMTransitionTableSO.cs
[CreateAssetMenu(menuName = "Player/FSM Transition Table")]
public class FSMTransitionTableSO : ScriptableObject
{
    [System.Serializable]
    public struct TransitionDefinition
    {
        [Tooltip("从哪个状态触发(空 = 任意状态)")]
        public string       FromState;   // 类型名,如 "IdleState"

        [Tooltip("跳转到哪个状态")]
        public string       ToState;     // 类型名,如 "JumpState"

        [Tooltip("触发条件(与条件注册表中的键对应)")]
        public string       Condition;   // 如 "JumpPressed", "Grounded"

        [Tooltip("优先级,数值越高越优先(默认 0负数表示最低优先级")]
        public int          Priority;

        [Tooltip("是否覆盖同方向的硬编码跳转true = 本 SO 优先false = 仅作为补充)")]
        public bool         Override;
    }

    public TransitionDefinition[] Transitions;
}

20.2 条件注册表

// 路径: Assets/Scripts/Player/FSM/FSMConditionRegistry.cs
/// <summary>
/// 静态条件函数注册中心。
/// 条件函数在 PlayerController.Awake() 中注册,保证初始化顺序。
/// </summary>
public static class FSMConditionRegistry
{
    // key = 条件名(与 TransitionDefinition.Condition 对应)
    // value = 谓词,接收 PlayerController 上下文,返回是否满足
    private static readonly Dictionary<string, Func<PlayerController, bool>> _conditions = new();

    public static void Register(string key, Func<PlayerController, bool> predicate)
        => _conditions[key] = predicate;

    public static bool Evaluate(string key, PlayerController ctx)
        => _conditions.TryGetValue(key, out var fn) && fn(ctx);
}

内置条件PlayerController.Awake() 中注册):

// PlayerController.Awake() — 注册基础条件
void RegisterDefaultConditions()
{
    FSMConditionRegistry.Register("JumpPressed",    ctx => ctx.Buffer.WasPressed(InputAction.Jump));
    FSMConditionRegistry.Register("Grounded",       ctx => ctx.Movement.IsGrounded);
    FSMConditionRegistry.Register("Airborne",       ctx => !ctx.Movement.IsGrounded);
    FSMConditionRegistry.Register("DashPressed",    ctx => ctx.Buffer.WasPressed(InputAction.Dash));
    FSMConditionRegistry.Register("AttackPressed",  ctx => ctx.Buffer.WasPressed(InputAction.Attack));
    FSMConditionRegistry.Register("MoveInputActive", ctx => Mathf.Abs(ctx.Input.Move.x) > 0.1f);
    FSMConditionRegistry.Register("NoMoveInput",    ctx => Mathf.Abs(ctx.Input.Move.x) <= 0.1f);
    FSMConditionRegistry.Register("FallVelocity",   ctx => ctx.Movement.Velocity.y < -0.5f);
    // 扩展:技能解锁条件
    FSMConditionRegistry.Register("WallSlideUnlocked",
        ctx => ctx.Stats.HasAbility(AbilityType.WallSlide));
}

20.3 PlayerStateMachine 集成

// PlayerStateMachine.cs — 改动(最小侵入)
public class PlayerStateMachine
{
    private readonly PlayerController      _owner;
    private FSMTransitionTableSO           _transitionTable;  // 可选,可为 null
    private PlayerStateBase                _currentState;

    // 构造时可选注入 TransitionTable
    public PlayerStateMachine(PlayerController owner, FSMTransitionTableSO table = null)
    {
        _owner          = owner;
        _transitionTable = table;
    }

    public void Tick()
    {
        // 1. 先检查 TransitionTableOverride 条目最优先)
        if (TryTableTransition(overrideOnly: true)) return;

        // 2. 执行硬编码跳转(各 State.OnStateUpdate() 内部 ChangeState 调用)
        _currentState?.OnStateUpdate();

        // 3. 再检查 TransitionTable非 Override 条目作为补充)
        TryTableTransition(overrideOnly: false);
    }

    private bool TryTableTransition(bool overrideOnly)
    {
        if (_transitionTable == null) return false;
        string currentName = _currentState?.GetType().Name ?? "";

        // 按优先级排序(降序),取第一个满足的
        foreach (var def in _transitionTable.Transitions
                     .Where(d => d.Override == overrideOnly)
                     .Where(d => string.IsNullOrEmpty(d.FromState) || d.FromState == currentName)
                     .OrderByDescending(d => d.Priority))
        {
            if (FSMConditionRegistry.Evaluate(def.Condition, _owner))
            {
                var targetType = FSMStateFactory.Create(def.ToState, _owner);
                if (targetType != null)
                {
                    ChangeState(targetType);
                    return true;
                }
            }
        }
        return false;
    }

    public void ChangeState(PlayerStateBase newState)
    {
        _currentState?.OnStateExit();
        _currentState = newState;
        _currentState.OnStateEnter();
    }
}

20.4 FSMStateFactory — 基于 Attribute 的自动注册

// 路径: Assets/Scripts/Player/FSM/FSMStateAttribute.cs
/// <summary>
/// 标记一个 PlayerStateBase 子类可被 FSMStateFactory 自动发现。
/// 参数为状态在 TransitionDefinition.ToState 中使用的字符串 key。
/// 示例:[FSMState("DashState")]
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class FSMStateAttribute : Attribute
{
    public string Key { get; }
    public FSMStateAttribute(string key) => Key = key;
}

// 路径: Assets/Scripts/Player/FSM/FSMStateFactory.cs
/// <summary>
/// 扫描程序集中所有标注了 [FSMState] 的 PlayerStateBase 子类,
/// 自动注册到工厂字典,新增状态只需添加 Attribute无需修改本类。
/// </summary>
public static class FSMStateFactory
{
    private static Dictionary<string, Func<PlayerController, PlayerStateBase>> _registry;

    /// <summary>
    /// 通过反射扫描 Assembly-CSharp 中所有 [FSMState] 标注类并注册。
    /// 仅在 Initialize 时调用一次(无运行时反射开销)。
    /// </summary>
    public static void Initialize()
    {
        _registry = new();
        var baseType = typeof(PlayerStateBase);
        foreach (var type in typeof(FSMStateFactory).Assembly.GetTypes())
        {
            if (!type.IsClass || type.IsAbstract || !baseType.IsAssignableFrom(type)) continue;
            var attr = type.GetCustomAttribute<FSMStateAttribute>();
            if (attr == null) continue;

            // 构造器缓存(避免重复反射)
            var ctor = type.GetConstructor(new[] { typeof(PlayerController) });
            if (ctor == null)
            {
                Debug.LogError($"[FSMStateFactory] {type.Name} 缺少 (PlayerController) 构造器");
                continue;
            }
            var key = attr.Key;
            _registry[key] = owner => (PlayerStateBase)ctor.Invoke(new object[] { owner });
        }
#if UNITY_EDITOR
        Debug.Log($"[FSMStateFactory] 已注册 {_registry.Count} 个状态: "
            + string.Join(", ", _registry.Keys));
#endif
    }

    public static PlayerStateBase Create(string stateName, PlayerController owner)
    {
        if (_registry == null) Initialize();
        if (_registry.TryGetValue(stateName, out var factory)) return factory(owner);
        Debug.LogError($"[FSMStateFactory] 未找到状态 '{stateName}'"
            + "请检查该 State 类是否添加了 [FSMState(\"" + stateName + "\")]。");
        return null;
    }

    /// <summary>返回所有已注册的状态 key供 PropertyDrawer 生成下拉列表)。</summary>
    public static IReadOnlyCollection<string> GetRegisteredKeys()
    {
        if (_registry == null) Initialize();
        return _registry.Keys;
    }
}

标注示例(各 State 类添加一行):

[FSMState("IdleState")]  public class IdleState   : PlayerStateBase { ... }
[FSMState("RunState")]   public class RunState    : PlayerStateBase { ... }
[FSMState("JumpState")]  public class JumpState   : PlayerStateBase { ... }
[FSMState("FallState")]  public class FallState   : PlayerStateBase { ... }
[FSMState("DashState")]  public class DashState   : PlayerStateBase { ... }
// 新增状态只需加 [FSMState("XxxState")],工厂自动感知,无需修改 FSMStateFactory

20.4b FSMConditionPropertyDrawer — Inspector 下拉验证

// 路径: Assets/Scripts/Player/FSM/Editor/FSMConditionPropertyDrawer.cs
#if UNITY_EDITOR
using UnityEditor;

/// <summary>
/// 为 TransitionDefinition.Condition 字符串字段显示下拉列表,
/// 防止手填字符串拼写错误导致条件静默失败。
/// </summary>
[CustomPropertyDrawer(typeof(FSMConditionKeyAttribute))]
public class FSMConditionPropertyDrawer : PropertyDrawer
{
    public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {
        if (property.propertyType != SerializedPropertyType.String)
        {
            EditorGUI.PropertyField(position, property, label);
            return;
        }

        var keys = new List<string>(FSMConditionRegistry.GetRegisteredKeys());
        keys.Insert(0, "(none)");

        int currentIndex = keys.IndexOf(property.stringValue);
        if (currentIndex < 0) currentIndex = 0;

        EditorGUI.BeginChangeCheck();
        int selected = EditorGUI.Popup(position, label.text, currentIndex,
            keys.Select(k => new GUIContent(k)).ToArray());
        if (EditorGUI.EndChangeCheck())
            property.stringValue = selected == 0 ? string.Empty : keys[selected];
    }
}
#endif

// 配套 Attribute标注 TransitionDefinition.Condition 字段
public sealed class FSMConditionKeyAttribute : PropertyAttribute { }

TransitionDefinition 更新(添加 Attribute

    [System.Serializable]
    public struct TransitionDefinition
    {
        [Tooltip("从哪个状态触发(空 = 任意状态)")]
        public string  FromState;

        [Tooltip("跳转到哪个状态")]
        public string  ToState;

        // ✅ 加上 [FSMConditionKey] 后 Inspector 变为条件下拉列表
        [FSMConditionKey]
        [Tooltip("触发条件(与条件注册表中的键对应)")]
        public string  Condition;

        [Tooltip("优先级,数值越高越优先(默认 0负数表示最低优先级")]
        public int     Priority;

        [Tooltip("是否覆盖同方向的硬编码跳转true = 本 SO 优先false = 仅作为补充)")]
        public bool    Override;
    }

FSMConditionRegistry 补充 GetRegisteredKeys()(供 PropertyDrawer 查询):

public static class FSMConditionRegistry
{
    private static readonly Dictionary<string, Func<PlayerController, bool>> _conditions = new();

    public static void Register(string key, Func<PlayerController, bool> predicate)
        => _conditions[key] = predicate;

    public static bool Evaluate(string key, PlayerController ctx)
    {
        if (_conditions.TryGetValue(key, out var fn)) return fn(ctx);
        Debug.LogWarning($"[FSMConditionRegistry] 条件 '{key}' 未注册,跳过。"
            + " 请检查 PlayerController.RegisterDefaultConditions() 或自定义注册。");
        return false;
    }

    /// <summary>返回所有已注册条件的 key供 PropertyDrawer 生成下拉列表)。</summary>
    public static IReadOnlyCollection<string> GetRegisteredKeys() => _conditions.Keys;
}

20.5 设计师工作流

1. Project 窗口右键 → Create → Player → FSM Transition Table
   → 生成 "DefaultTransitionTable.asset"

2. Inspector 中展开 Transitions 数组,添加新条目例如:
   From: "IdleState"  To: "DashState"
   Condition: "DashPressed"  Priority: 10  Override: false

3. 将 asset 拖入 PlayerController 的 "Transition Table" 槽位

4. Play Mode 测试,无需修改任何 C# 代码

20.6 向后兼容保证

场景 行为
_transitionTable 为 null 完全走原有硬编码路径,无影响
条目 Override=false 仅在硬编码路径未触发跳转时才作为补充
条目 Override=true OnStateUpdate() 执行前拦截并优先跳转
条件名未注册 FSMConditionRegistry.Evaluate 输出 Warning 并返回 false
状态名未注册 FSMStateFactory.Create 输出 Error 并返回 null跳过本条目
新增状态类 只需添加 [FSMState("XxxState")],工厂自动发现,无需修改工厂

20.7 PlayerStateBase 依赖声明建议

设计意图Beta 前重构建议):PlayerStateBase 当前构造器接受整个 PlayerController
状态可访问 Controller 所有属性。P1-3 建议逐步将各 State 类约束到最小接口集:

// 推荐做法State 声明自己实际依赖的上下文接口(编译时约束)
// 例DashState 只需要 IMovementContext + IInputContext
public class DashState : PlayerStateBase
{
    private readonly IMovementContext _move;
    private readonly IInputContext    _input;

    // 保持与 PlayerStateBase(PlayerController) 兼容,同时做接口分解
    public DashState(PlayerController owner) : base(owner)
    {
        _move  = owner;   // PlayerController 实现 IMovementContext
        _input = owner;   // PlayerController 实现 IInputContext
    }
    // ... 内部只使用 _move / _input不直接访问 _owner 其余属性
}

迁移原则:新增状态类直接按接口分组声明依赖;旧 State 类在需要维护时按需迁移,无需一次性重构。