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

1715 lines
68 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 05 · 玩家模块
> **命名空间** `BaseGames.Player`、`BaseGames.Player.States`
> **程序集** `BaseGames.Player`、`BaseGames.Player.States`
> **路径** `Assets/Scripts/Player/`
> **依赖** `BaseGames.Core`、`BaseGames.Input`、`BaseGames.Combat`、`BaseGames.Parry`、`Kybernetik.Animancer`
---
## 目录
1. [Player Prefab 层级(完整)](#1-player-prefab-层级)
2. [PlayerController协调器](#2-playercontroller)
3. [PlayerMovement](#3-playermovement)
4. [PlayerStats](#4-playerstats)
5. [PlayerCombat](#5-playercombat)
6. [FormController](#6-formcontroller)
7. [WeaponManager](#7-weaponmanager)
8. [SkillManager](#8-skillmanager)
9. [SpringSystem](#9-springsystem)
10. [ParrySystem见 CombatModule](#10-parrysystem)
11. [FSM 状态基类与状态机](#11-fsm-状态基类与状态机)
12. [所有 State 类列表](#12-所有-state-类列表)
13. [关键 State 详细设计](#13-关键-state-详细设计)
14. [Animancer 动画系统](#14-animancer-动画系统)
15. [PlayerMovementConfigSO](#15-playermovementconfigso)
16. [PlayerStatsSO](#16-playerstatsso)
17. [PlayerAnimationConfigSO](#17-playeranimationconfigso)
18. [FormConfigSO](#18-formconfigso)
19. [InputBuffer — 输入缓冲系统](#19-inputbuffer--输入缓冲系统)
20. [FSMTransitionTableSO — 数据驱动状态跳转](#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
```csharp
// 路径: 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
```csharp
// 路径: 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
```csharp
// 路径: 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 结构说明
```csharp
// 路径: 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
```csharp
// 路径: 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 现挂载于**角色 Prefab**`PlayerCombat` 管理),**不使用独立武器 Prefab**。`WeaponSO` 是纯数据 SO不含 Prefab 引用。
```csharp
// 路径: 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
```csharp
// 路径: 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护符效果位于装备系统
```csharp
// 路径: 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
```csharp
// 路径: 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
```csharp
// 路径: 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](./06_CombatModule.md) §4。
---
## 11. FSM 状态基类与状态机
```csharp
// 路径: 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连击链
```csharp
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
```csharp
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贴墙下滑
```csharp
// 进入条件:空中 + 贴墙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 整洁):
```csharp
// 路径: 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` | 快速飞离墙壁,有短暂输入锁定 |
```csharp
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 Pro**`AnimancerComponent`,采用**双层动画**模式:
| 层 | 作用 | 示例 |
|----|------|------|
| Base Layer0 | 全身状态动画(移动/攻击/受伤/死亡) | Idle、Run、Attack_01 |
| Overlay Layer1 | 上半身叠加层(边移动边施法) | SoulSpell、UseSpring |
每个 AnimancerState 对应一个 `AnimationClip`,由 `PlayerAnimationConfigSO` 统一配置。
---
## 15. PlayerMovementConfigSO
```csharp
[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
```csharp
[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
```csharp
[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
```csharp
[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` 等独立字段)已迁移至各 `FormSO` 的 `soulSkill` / `spiritSkill1` / `spiritSkill2` 字段中。**
---
## 19. InputBuffer — 输入缓冲系统
> **P2 优化**:原 `InputBuffer` 的缓冲窗口时长硬编码为 `0.1f`,无障碍需求(高延迟玩家、手残模式)或竞技模式(精确 0 容忍)无法调整。将缓冲时长提取至 `AccessibilitySettingsSO`,运行时可动态覆盖。
```csharp
// 路径: 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` 相关字段**
```csharp
// 路径: 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 数据结构
```csharp
// 路径: 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 条件注册表
```csharp
// 路径: 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() 中注册):**
```csharp
// 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 集成
```csharp
// 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 的自动注册
```csharp
// 路径: 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 类添加一行):**
```csharp
[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 下拉验证
```csharp
// 路径: 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**
```csharp
[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 查询):**
```csharp
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 类约束到最小接口集:
```csharp
// 推荐做法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 类在需要维护时按需迁移**,无需一次性重构。