# 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 ← Rigidbody2D(Dynamic, 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` 管理四向 HitBox:Ground / 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 统一实现 /// 与移动相关的上下文:地面检测、朝向、移动驱动 public interface IMovementContext { PlayerMovement Movement { get; } PlayerMovementConfigSO MovConfig { get; } bool IsGrounded { get; } int FacingDirection { get; } } /// 与战斗相关的上下文:武器、技能、特殊机制 public interface ICombatContext { PlayerCombat Combat { get; } WeaponManager Weapon { get; } SkillManager Skill { get; } SpringSystem Spring { get; } ShieldComponent Shield { get; } } /// 与动画相关的上下文:Animancer + AnimConfig public interface IAnimationContext { AnimancerComponent Animancer { get; } PlayerAnimationConfigSO AnimConfig { get; } } /// 与输入相关的上下文:InputReader + InputBuffer public interface IInputContext { InputReaderSO Input { get; } InputBuffer Buffer { get; } } /// 与玩家属性相关的上下文:stats + 能力查询 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 Channels(Raise 方) [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 _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); // ──── 能力 ──────────────────────────────────────────────────────────── // 内部 bitmask(AbilityType [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 } /// 连击段枚举(供 SetComboSegmentSource 使用) 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; } /// /// C# 事件:WeaponManager / SkillManager 订阅以响应形态切换。 /// 不直接注入这些系统——它们在 OnEnable 中自行订阅。 /// public event Action OnFormChanged; /// 所有形态列表(来自 FormConfigSO.forms)。供 WeaponManager 枚举 override 时使用。 public IReadOnlyList 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("默认武器")] /// FormAttackConfig(旧内联结构体)已废弃,其所有字段迁移至 WeaponSO。 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; /// 当前激活的武器 SO。PlayerCombat / AttackState 从此读取攻击数据。 public WeaponSO ActiveWeapon { get; private set; } /// 武器切换时广播(PlayerCombat / VFX 监听) public event Action OnWeaponChanged; // 护符注入的武器覆盖:Key = FormSO.formId,Value = 替换武器 readonly Dictionary _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 调用)─────────── /// 为指定形态设置武器覆盖。formId 为空 = 覆盖所有形态。 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); } /// 移除覆盖,恢复默认武器。formId 为空 = 移除所有形态覆盖。 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 配置")] /// 零向量 = 使用 PlayerCombat 中的默认尺寸 public Vector2 hitBoxSizeOverride; public Vector2 hitBoxOffsetOverride; [Header("武器特效")] public WeaponVFXConfig vfxConfig; /// 根据攻击方向返回对应 DamageSourceSO(便捷方法) 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("武器挥斩拖尾 Prefab(null = 不显示拖尾)")] 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 | 空中 DownAttack(Pogo)输入 | 动画完成 | | `HurtState` | States | HurtBox.ReceiveDamage(ForceState) | 硬直时长结束 | | `DeadState` | States | PlayerStats.HP = 0(ForceState) | 永不自动退出 | | `ParryState` | States | Parry 输入 | 弹反窗口关闭 | | `WallSlideState` | States | 空中 + 贴墙 + 下落 | 离墙 / 跳跃 | | `WallJumpState` | States | WallSlide 中 Jump 输入 | 速度方向改变 | | `SpringState` | States | SpringSystem.TryUseSpring(ForceState) | 回血动画完成 | | `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 Layer(0) | 全身状态动画(移动/攻击/受伤/死亡) | Idle、Run、Attack_01 | | Overlay Layer(1) | 上半身叠加层(边移动边施法) | 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 { /// /// 单帧输入缓冲系统:记录"提前输入的指令",在接受窗口开启时消费。 /// 典型用例:攻击收招前 0.1s 内按下跳跃 → 收招结束后立即起跳。 /// public class InputBuffer : MonoBehaviour { // ── 配置 ────────────────────────────────────────────────────────── /// /// 缓冲窗口基准时长(秒)。由 AccessibilitySettingsSO 在初始化时注入, /// 默认 0.1f,无障碍模式最高可扩展至 0.3f。 /// [SerializeField, Min(0f), Tooltip("基准缓冲时长(秒),AccessibilitySettingsSO 会覆盖此值")] private float _baseDuration = 0.1f; /// 运行时有效缓冲时长 = _baseDuration × AccessibilityMultiplier。 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 中调用)────────── /// 若缓冲指令与 匹配且未过期,消费并返回 true。 public bool ConsumeIfMatch(InputActionType expected) { if (_bufferedAction == expected && Time.time <= _bufferExpiry) { _bufferedAction = InputActionType.None; _bufferExpiry = 0f; return true; } return false; } /// 无条件清除缓冲(状态切换时调用,防止残留输入污染新状态)。 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 /// /// 静态条件函数注册中心。 /// 条件函数在 PlayerController.Awake() 中注册,保证初始化顺序。 /// public static class FSMConditionRegistry { // key = 条件名(与 TransitionDefinition.Condition 对应) // value = 谓词,接收 PlayerController 上下文,返回是否满足 private static readonly Dictionary> _conditions = new(); public static void Register(string key, Func 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. 先检查 TransitionTable(Override 条目最优先) 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 /// /// 标记一个 PlayerStateBase 子类可被 FSMStateFactory 自动发现。 /// 参数为状态在 TransitionDefinition.ToState 中使用的字符串 key。 /// 示例:[FSMState("DashState")] /// [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 /// /// 扫描程序集中所有标注了 [FSMState] 的 PlayerStateBase 子类, /// 自动注册到工厂字典,新增状态只需添加 Attribute,无需修改本类。 /// public static class FSMStateFactory { private static Dictionary> _registry; /// /// 通过反射扫描 Assembly-CSharp 中所有 [FSMState] 标注类并注册。 /// 仅在 Initialize 时调用一次(无运行时反射开销)。 /// 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(); 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; } /// 返回所有已注册的状态 key(供 PropertyDrawer 生成下拉列表)。 public static IReadOnlyCollection 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; /// /// 为 TransitionDefinition.Condition 字符串字段显示下拉列表, /// 防止手填字符串拼写错误导致条件静默失败。 /// [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(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> _conditions = new(); public static void Register(string key, Func 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; } /// 返回所有已注册条件的 key(供 PropertyDrawer 生成下拉列表)。 public static IReadOnlyCollection 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 类在需要维护时按需迁移**,无需一次性重构。