1715 lines
68 KiB
Markdown
1715 lines
68 KiB
Markdown
# 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 统一实现
|
||
|
||
/// <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 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<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);
|
||
|
||
// ──── 能力 ────────────────────────────────────────────────────────────
|
||
// 内部 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 }
|
||
|
||
/// <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.formId,Value = 替换武器
|
||
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("武器挥斩拖尾 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
|
||
{
|
||
/// <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. 先检查 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
|
||
/// <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 类在需要维护时按需迁移**,无需一次性重构。
|