68 KiB
05 · 玩家模块
命名空间
BaseGames.Player、BaseGames.Player.States
程序集BaseGames.Player、BaseGames.Player.States
路径Assets/Scripts/Player/
依赖BaseGames.Core、BaseGames.Input、BaseGames.Combat、BaseGames.Parry、Kybernetik.Animancer
目录
- Player Prefab 层级(完整)
- PlayerController(协调器)
- PlayerMovement
- PlayerStats
- PlayerCombat
- FormController
- WeaponManager
- SkillManager
- SpringSystem
- ParrySystem(见 CombatModule)
- FSM 状态基类与状态机
- 所有 State 类列表
- 关键 State 详细设计
- Animancer 动画系统
- PlayerMovementConfigSO
- PlayerStatsSO
- PlayerAnimationConfigSO
- FormConfigSO
- InputBuffer — 输入缓冲系统
- 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
// 路径: Assets/Scripts/Player/PlayerController.cs
[RequireComponent(typeof(InputBuffer))]
public class PlayerController : MonoBehaviour
{
// ──── Inspector 序列化引用(同 Prefab 内)────────────────────────────────
[Header("Sub Components")]
[SerializeField] private PlayerMovement _movement;
[SerializeField] private PlayerStats _stats;
[SerializeField] private PlayerCombat _combat;
[SerializeField] private FormController _formController;
[SerializeField] private WeaponManager _weaponManager;
[SerializeField] private SkillManager _skillManager;
[SerializeField] private SpringSystem _springSystem;
[SerializeField] private ParrySystem _parrySystem;
[SerializeField] private AnimancerComponent _animancer;
[SerializeField] private IFeedbackPlayer _feedback; // [Feedback] 子节点实现
[SerializeField] private HurtBox _hurtBox;
[SerializeField] private ShieldComponent _shield; // [Shield] 子节点,见 20_ShieldModule
[Header("Config SOs")]
[SerializeField] private InputReaderSO _inputReader;
[SerializeField] private PlayerMovementConfigSO _movementConfig;
[SerializeField] private PlayerAnimationConfigSO _animConfig;
[SerializeField] private PlayerStatsSO _statsConfig;
[SerializeField] private FormConfigSO _formConfig;
[Header("Event Channels - Raise")]
[SerializeField] private VoidEventChannelSO _onPlayerDied;
[SerializeField] private IntEventChannelSO _onHPChanged;
// ──── 运行时 FSM 状态机 ──────────────────────────────────────────────────
private PlayerStateBase _currentState;
// 预创建状态实例(避免 GC)
private IdleState _idleState;
private RunState _runState;
private JumpState _jumpState;
private FallState _fallState;
private DashState _dashState;
private AttackState _attackState;
private AirAttackState _airAttackState;
private HurtState _hurtState;
private DeadState _deadState;
private ParryState _parryState;
private WallSlideState _wallSlideState;
private SpringState _springState;
private SwimState _swimState; // 游泳状态(需 AbilityType.Swim 解锁)
// ... 更多状态
// ──── 公开只读属性(供 State 使用)────────────────────────────────────
public PlayerMovement Movement => _movement;
public PlayerStats Stats => _stats;
public PlayerCombat Combat => _combat;
public FormController Form => _formController;
public WeaponManager Weapon => _weaponManager;
public SkillManager Skill => _skillManager;
public SpringSystem Spring => _springSystem;
public InputReaderSO Input => _inputReader;
public InputBuffer Buffer => _inputBuffer; // GetComponent 缓存
public AnimancerComponent Animancer => _animancer;
public PlayerMovementConfigSO MovConfig => _movementConfig;
public PlayerAnimationConfigSO AnimConfig => _animConfig;
public bool IsGrounded => _movement.IsGrounded;
public int FacingDirection => _movement.FacingDirection;
public ShieldComponent Shield => _shield; // 供 UI / 技能判断护盾状态
### 2.1 State 上下文接口分组
> **架构决策(2026-05)**:PlayerController 将所有子系统统一暴露,任何状态都可访问任何子系统——这破坏了最小权限原则,并让 State 之间的隐式依赖难以追踪。改用接口分组后,每个 State 只声明它实际使用的接口,编译器强制隔离。PlayerController 同时实现所有接口,现有代码无需迁移。
```csharp
// 路径: Assets/Scripts/Player/StateContexts.cs
// 各状态按需依赖最小接口,PlayerController 统一实现
/// <summary>与移动相关的上下文:地面检测、朝向、移动驱动</summary>
public interface IMovementContext
{
PlayerMovement Movement { get; }
PlayerMovementConfigSO MovConfig { get; }
bool IsGrounded { get; }
int FacingDirection { get; }
}
/// <summary>与战斗相关的上下文:武器、技能、特殊机制</summary>
public interface ICombatContext
{
PlayerCombat Combat { get; }
WeaponManager Weapon { get; }
SkillManager Skill { get; }
SpringSystem Spring { get; }
ShieldComponent Shield { get; }
}
/// <summary>与动画相关的上下文:Animancer + AnimConfig</summary>
public interface IAnimationContext
{
AnimancerComponent Animancer { get; }
PlayerAnimationConfigSO AnimConfig { get; }
}
/// <summary>与输入相关的上下文:InputReader + InputBuffer</summary>
public interface IInputContext
{
InputReaderSO Input { get; }
InputBuffer Buffer { get; }
}
/// <summary>与玩家属性相关的上下文:stats + 能力查询</summary>
public interface IStatsContext
{
PlayerStats Stats { get; }
}
// PlayerController 统一实现所有接口(向后兼容,现有 State 通过 PlayerController 拿到任何子系统的路径仍有效)
public class PlayerController : MonoBehaviour,
IMovementContext, ICombatContext, IAnimationContext, IInputContext, IStatsContext
State 推荐依赖声明(在构造 / 注入时传入最小接口而非整个 PlayerController):
| State 类 | 实际使用接口 |
|---|---|
IdleState |
IMovementContext, IInputContext |
RunState |
IMovementContext, IInputContext, IAnimationContext |
JumpState |
IMovementContext, IInputContext, IAnimationContext, IStatsContext |
AttackState |
ICombatContext, IAnimationContext, IInputContext |
ParryState |
ICombatContext, IAnimationContext, IInputContext |
HurtState |
IMovementContext, IAnimationContext |
DashState |
IMovementContext, IStatsContext, IAnimationContext, IInputContext |
SpringState |
ICombatContext, IAnimationContext, IMovementContext |
SwimState |
IMovementContext, IInputContext, IAnimationContext |
渐进迁移策略:新增 State 时直接依赖接口;已有 State 可在重构时逐步将
PlayerController参数改为具体接口组合,不必一次全改。
// ──── 公开接口 ──────────────────────────────────────────────────────────
// 正常状态切换(各 State 调用,含优先级检查)
public void TryTransitionState(PlayerStateBase newState);
// 强制切换(HurtBox/ParrySystem 调用,无视当前状态)
public void ForceState(PlayerStateBase newState);
// 初始化(由 Awake 执行)
private void InitializeStates();
private void Awake()
{
// ... 其他初始化 ...
_hurtBox.SetShieldable(_shield); // 将护盾注入 HurtBox,拦截伤害管线
}
private void Update() => _currentState?.OnStateUpdate();
private void FixedUpdate() => _currentState?.OnStateFixedUpdate();
private void LateUpdate() => _movement.UpdateFacing();
}
---
## 3. PlayerMovement
```csharp
// 路径: Assets/Scripts/Player/PlayerMovement.cs
[RequireComponent(typeof(Rigidbody2D))]
public class PlayerMovement : MonoBehaviour
{
[SerializeField] private PlayerMovementConfigSO _config;
// ──── 内部状态 ──────────────────────────────────────────────────────────
private Rigidbody2D _rb;
private float _coyoteTimer;
private bool _isGrounded;
private bool _isWallLeft;
private bool _isWallRight;
private bool _onOneWayPlatform;
private int _facingDirection = 1; // +1 右, -1 左
// ──── 公开只读属性 ──────────────────────────────────────────────────────
public bool IsGrounded => _isGrounded;
public bool HasCoyoteTime => _coyoteTimer > 0;
public bool IsWallLeft => _isWallLeft;
public bool IsWallRight => _isWallRight;
public int FacingDirection => _facingDirection;
public Rigidbody2D Rb => _rb;
// ──── 物理接口(供各 State 调用)────────────────────────────────────────
public void Move(float speedX); // 设置水平速度,自动翻转 SpriteRenderer
public void Jump(bool isVariable = true); // 施加跳跃冲量(isVariable 允许 CutJump)
public void CutJump(); // 减半垂直速度(松开跳跃键调用)
public void Dash(Vector2 dir, float speed); // 施加冲刺速度(临时忽略重力)
public void ApplyKnockback(Vector2 dir, float force);
public void ZeroVelocity();
public void ZeroHorizontalVelocity();
public void SetGravityScale(float scale); // 下坠时增大重力用
public void DropThroughPlatform(); // 单向平台向下穿越(短暂忽略 OneWay Layer)
// ──── 检测(每帧 FixedUpdate 更新)──────────────────────────────────────
private void CheckGrounded(); // Physics2D.OverlapBox at feet
private void CheckWalls(); // 每侧发射两根射线(上/下偏移),均命中才算贴墙(防卡角)
// 由 PlayerWallDetector 组件封装,见 §3.1
public void UpdateFacing(); // 根据速度方向更新 _facingDirection + SpriteRenderer.flipX
// ──── 可取消帧窗口(Cancel Window)───────────────────────────────────────
// 由 PlayerAnimationEvents 的 CancelWindowOpen / CancelWindowClose 事件驱动
// (见 24_AnimEventModule §9)
private bool _cancelWindowOpen;
public bool CancelWindowOpen => _cancelWindowOpen;
public void SetCancelWindowOpen(bool open) => _cancelWindowOpen = open;
// ──── 地面材质(脚步系统)─────────────────────────────────────────
// 由 FootstepSystem 读取(见 24_AnimEventModule §8)
public SurfaceType CurrentSurface { get; private set; }
}
物理参数来自 PlayerMovementConfigSO(见 §15)。
4. PlayerStats
// 路径: Assets/Scripts/Player/PlayerStats.cs
public class PlayerStats : MonoBehaviour
{
[SerializeField] private PlayerStatsSO _config;
// Event 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
// 路径: Assets/Scripts/Player/PlayerCombat.cs
public class PlayerCombat : MonoBehaviour
{
[SerializeField] private WeaponManager _weaponManager;
[SerializeField] private SkillManager _skillManager;
// ── 玩家角色 Prefab 上的 HitBox(固定挂载,不依赖武器 Prefab)────────────
[SerializeField] private HitBox _hitBoxGround;
[SerializeField] private HitBox _hitBoxUp;
[SerializeField] private HitBox _hitBoxDown;
[SerializeField] private HitBox _hitBoxAir;
// ── 当前武器片段缓存(由 WeaponManager.OnWeaponChanged 触发刷新)─────────
private ClipTransition _attack1Clip, _attack2Clip, _attack3Clip;
private ClipTransition _airAttackClip, _upAttackClip, _downAttackClip;
void OnEnable()
{
_weaponManager.OnWeaponChanged += RefreshWeaponData;
if (_weaponManager.ActiveWeapon != null)
RefreshWeaponData(_weaponManager.ActiveWeapon);
}
void OnDisable() => _weaponManager.OnWeaponChanged -= RefreshWeaponData;
void RefreshWeaponData(WeaponSO weapon)
{
if (weapon == null) return;
// 缓存动画片段
_attack1Clip = weapon.attack1Clip;
_attack2Clip = weapon.attack2Clip;
_attack3Clip = weapon.attack3Clip;
_airAttackClip = weapon.airAttackClip;
_upAttackClip = weapon.upAttackClip;
_downAttackClip = weapon.downAttackClip;
// 刷新 HitBox DamageSource(初始段)
_hitBoxGround.SetDamageSource(weapon.attack1Source);
// 刷新 HitBox 尺寸(零向量 = 使用默认)
if (weapon.hitBoxSizeOverride != Vector2.zero)
_hitBoxGround.SetSizeOverride(weapon.hitBoxSizeOverride, weapon.hitBoxOffsetOverride);
else
_hitBoxGround.ClearSizeOverride();
ResetCombo();
}
// AttackState 在每段攻击开始时调用,切换当前段 DamageSource
public void SetComboSegmentSource(ComboState segment)
{
WeaponSO w = _weaponManager.ActiveWeapon;
DamageSourceSO src = segment switch
{
ComboState.Attack1 => w?.attack1Source,
ComboState.Attack2 => w?.attack2Source,
ComboState.Attack3 => w?.attack3Source,
ComboState.AirAttack => w?.airAttackSource,
ComboState.UpAttack => w?.upAttackSource,
ComboState.DownAttack => w?.downAttackSource,
_ => null,
};
if (src != null) _hitBoxGround.SetDamageSource(src);
}
// ── HitBox 激活(供 AttackState / AnimationEvent 调用)─────────────────
public void EnableWeaponHitBox(AttackDirection dir)
=> GetHitBox(dir)?.Activate(_weaponManager.ActiveWeapon?.GetSourceByDir(dir), transform);
public void DisableWeaponHitBox(AttackDirection dir)
=> GetHitBox(dir)?.Deactivate();
public void DisableAllWeaponHitBoxes()
{
_hitBoxGround?.Deactivate();
_hitBoxUp?.Deactivate();
_hitBoxDown?.Deactivate();
_hitBoxAir?.Deactivate();
}
// ── 技能 HitBox 由 SkillManager.ExecuteSkill() 直接控制(见 §8)──────────
// 命中回调 → 增加灵力
internal void OnHitConfirmed(DamageInfo info) { /* 增加灵力 */ }
private void ResetCombo() { /* 重置连击计数 */ }
private HitBox GetHitBox(AttackDirection dir) => dir switch
{
AttackDirection.Ground => _hitBoxGround,
AttackDirection.Up => _hitBoxUp,
AttackDirection.Down => _hitBoxDown,
AttackDirection.Air => _hitBoxAir,
_ => null,
};
}
public enum AttackDirection { Ground, Up, Down, Air }
/// <summary>连击段枚举(供 SetComboSegmentSource 使用)</summary>
public enum ComboState { Attack1, Attack2, Attack3, AirAttack, UpAttack, DownAttack }
6. FormController
参见 Design/53_WeaponSystem.md §4 — FormSO 结构说明
// 路径: Assets/Scripts/Player/FormController.cs
public class FormController : MonoBehaviour
{
[SerializeField] private FormConfigSO _config;
[SerializeField] private IntEventChannelSO _onFormChanged; // UI / Save 用(int 索引)
[SerializeField] private SkillManager _skillManager;
[SerializeField] private VoidEventChannelSO _onSkillSetChanged; // 通知 SkillHUD(见 09_ProgressionModule §11)
[SerializeField] private PaletteSwapSystem _paletteSwapSystem; // 见 18_VFXFeedbackModule §10
// ── 运行时状态 ────────────────────────────────────────────────────────
public FormSO CurrentForm { get; private set; }
/// <summary>
/// C# 事件:WeaponManager / SkillManager 订阅以响应形态切换。
/// 不直接注入这些系统——它们在 OnEnable 中自行订阅。
/// </summary>
public event Action OnFormChanged;
/// <summary>所有形态列表(来自 FormConfigSO.forms)。供 WeaponManager 枚举 override 时使用。</summary>
public IReadOnlyList<FormSO> AllForms => _config.forms;
// ── 公开 API ──────────────────────────────────────────────────────────
public void SwitchForm(FormSO newForm)
{
if (newForm == null || newForm == CurrentForm) return;
if (!IsFormUnlocked(newForm)) return;
CurrentForm = newForm;
// ① 发布事件频道(UI/Save 监听)
_onFormChanged.Raise(_config.GetFormIndex(newForm));
// ② 触发 C# 事件(WeaponManager / SkillManager 等系统监听)
OnFormChanged?.Invoke();
// ③ 形态过场(调色板、技能集变更通知)
_paletteSwapSystem?.ApplyPalette(newForm.formAccentColor);
_skillManager?.UpdateSkillSet(newForm.soulSkill, newForm.spiritSkill1, newForm.spiritSkill2);
_onSkillSetChanged?.Raise();
}
public bool IsFormUnlocked(FormSO form)
=> _config.IsUnlocked(form.formId);
}
FormSO(形态数据 SO)
// 路径: Assets/ScriptableObjects/Player/Forms/Form_{Name}.asset
[CreateAssetMenu(menuName = "Player/Form")]
public class FormSO : ScriptableObject
{
[Header("形态标识")]
public string formId; // "SkyForm" / "EarthForm" / "DeathForm"
public string displayName; // "天魂" / "地魂" / "命魂"
public Sprite formIcon;
public Color formAccentColor; // 天魂青色 / 地魂橙色 / 命魂紫色(拖尾/UI 染色)
[Header("默认武器")]
/// <summary>FormAttackConfig(旧内联结构体)已废弃,其所有字段迁移至 WeaponSO。</summary>
public WeaponSO defaultWeapon;
[Header("技能配置")]
public SoulSpellSO soulSkill;
public FormSkillSO spiritSkill1;
public FormSkillSO spiritSkill2;
}
7. WeaponManager
参见 Design/53_WeaponSystem.md §3 — 完整说明
HitBox 现挂载于角色 Prefab(PlayerCombat管理),不使用独立武器 Prefab。WeaponSO是纯数据 SO,不含 Prefab 引用。
// 路径: Assets/Scripts/Player/WeaponManager.cs
public class WeaponManager : MonoBehaviour
{
[SerializeField] private FormController _formController;
/// <summary>当前激活的武器 SO。PlayerCombat / AttackState 从此读取攻击数据。</summary>
public WeaponSO ActiveWeapon { get; private set; }
/// <summary>武器切换时广播(PlayerCombat / VFX 监听)</summary>
public event Action<WeaponSO> OnWeaponChanged;
// 护符注入的武器覆盖:Key = FormSO.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
// 路径: 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(护符效果,位于装备系统)
// 路径: Assets/Scripts/Equipment/CharmEffects/WeaponOverrideEffect.cs
// 见 Design/53_WeaponSystem.md §6 及 17_EquipmentSystem §3
namespace BaseGames.Equipment
{
[Serializable]
public class WeaponOverrideEffect : ICharmEffect
{
[Tooltip("目标形态 ID(留空 = 所有形态)")]
public string targetFormId;
[Tooltip("替换武器 SO")]
public WeaponSO replacementWeapon;
public void OnEquip(EquipmentContext ctx)
=> ctx.WeaponMgr.SetOverride(targetFormId, replacementWeapon);
public void OnUnequip(EquipmentContext ctx)
=> ctx.WeaponMgr.ClearOverride(targetFormId);
public string GetEffectDescription()
{
string formStr = string.IsNullOrEmpty(targetFormId) ? "所有形态" : targetFormId;
string wName = replacementWeapon != null ? replacementWeapon.displayName : "null";
return $"{formStr}的武器替换为 [{wName}]";
}
}
}
// EquipmentContext 需新增 WeaponManager WeaponMgr 字段(见 17_EquipmentSystem §3)
8. SkillManager
// 路径: Assets/Scripts/Player/SkillManager.cs
public class SkillManager : MonoBehaviour
{
[SerializeField] private InputReaderSO _inputReader;
[SerializeField] private PlayerStats _stats;
[SerializeField] private FormConfigSO _formConfig;
// 魂技能(消耗灵力)
private void OnSoulSkill(); // 根据 CurrentForm 从 FormConfigSO 获取 SoulSpellSO 并执行
// 魄技能(消耗魄元)
private void OnSpiritSkill1Started();
private void OnSpiritSkill1Cancelled();
private void OnSpiritSkill2Started();
private void OnSpiritSkill2Cancelled();
}
9. SpringSystem
// 路径: Assets/Scripts/Player/SpringSystem.cs
// 灵泉(快速回血物品,使用次数有限)
public class SpringSystem : MonoBehaviour
{
[SerializeField] private PlayerStats _stats;
[SerializeField] private PlayerController _controller;
[SerializeField] private VoidEventChannelSO _onSpringUsed; // 触发 SpringState
// 由 InputReader 触发
public void TryUseSpring(); // 检查 charges > 0 且 _stats.ConsumeSoulPower(触发消耗) → ForceState(SpringState)
}
10. ParrySystem
见 06_CombatModule.md §4。
11. FSM 状态基类与状态机
// 路径: Assets/Scripts/Player/States/PlayerStateBase.cs
public abstract class PlayerStateBase
{
protected PlayerController _owner;
// 构造时注入 Controller
protected PlayerStateBase(PlayerController owner) => _owner = owner;
public virtual void OnStateEnter() { }
public virtual void OnStateUpdate() { }
public virtual void OnStateFixedUpdate() { }
public virtual void OnStateExit() { }
// 便捷属性
protected InputReaderSO Input => _owner.Input;
protected InputBuffer Buffer => _owner.Buffer;
protected PlayerMovement Move => _owner.Movement;
protected PlayerStats Stats => _owner.Stats;
protected AnimancerComponent Anim => _owner.Animancer;
protected PlayerMovementConfigSO Cfg => _owner.MovConfig;
}
12. 所有 State 类列表
| 类名 | 命名空间 | 触发条件 | 退出条件 |
|---|---|---|---|
IdleState |
States | 落地 + 无输入 | 有移动输入 / 跳跃 / 攻击 / 冲刺 |
RunState |
States | 落地 + 水平输入 | 停止输入 / 跳跃 / 攻击 / 冲刺 |
JumpState |
States | 地面 Jump 输入(或 CoyoteTime 内) | 速度 ≤ 0(→ FallState) |
FallState |
States | 离地 + 下落 | 落地 / 跳跃缓冲(已有缓冲则跳跃) |
DashState |
States | Dash 输入(有冲刺解锁) | 冲刺时长结束 |
AerialDashState |
States | 空中 Dash 输入 | 冲刺时长结束 |
AttackState |
States | 地面 Attack 输入 | 动画完成 / 连击超时 |
AirAttackState |
States | 空中 Attack 输入 | 动画完成 |
UpAttackState |
States | 地面 UpAttack 输入 | 动画完成 |
DownAttackState |
States | 空中 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(连击链)
public class AttackState : PlayerStateBase
{
private int _comboStep = 0; // 当前连击步(0-based,最大 2 = 三段连击)
private float _comboTimer; // 连击窗口倒计时
private float _cancelWindowEnd; // 允许接受下一击输入的时间窗口
private bool _nextAttackBuffered;
// 最大连击段数由 WeaponSO 决定(若 attack3Clip 为 null 则最大 2 段)
private int MaxComboSteps => GetMaxCombo(_owner.Weapon.ActiveWeapon);
private static int GetMaxCombo(WeaponSO w)
{
if (w == null) return 1;
if (w.attack3Clip?.Clip != null) return 3;
if (w.attack2Clip?.Clip != null) return 2;
return 1;
}
public override void OnStateEnter()
{
var weapon = _owner.Weapon.ActiveWeapon;
// 根据当前连击段切换 DamageSource 并播放对应片段
var segment = _comboStep switch { 0 => ComboState.Attack1, 1 => ComboState.Attack2, _ => ComboState.Attack3 };
_owner.Combat.SetComboSegmentSource(segment);
var clip = _comboStep switch { 0 => weapon?.attack1Clip, 1 => weapon?.attack2Clip, _ => weapon?.attack3Clip };
if (clip != null) Anim.Play(clip);
_owner.Combat.EnableWeaponHitBox(AttackDirection.Ground); // AnimationEvent 控制开关更精确时改为 AnimEvent
var src = _comboStep switch
{
0 => weapon?.attack1Source, 1 => weapon?.attack2Source, _ => weapon?.attack3Source
};
_comboTimer = src?.ComboWindowDuration ?? 0.5f;
_cancelWindowEnd = src?.CancelWindowEnd ?? 0f;
}
public override void OnStateUpdate()
{
_comboTimer -= Time.deltaTime;
// 在取消窗口内检测下一击输入
if (Time.time < _cancelWindowEnd && Buffer.ConsumeAttack())
_nextAttackBuffered = true;
// 动画播放完毕
if (!Anim.IsPlaying)
{
if (_nextAttackBuffered && _comboStep < MaxComboSteps - 1)
{
_comboStep++;
_nextAttackBuffered = false;
OnStateEnter(); // 继续连击
}
else
{
_comboStep = 0;
_owner.TryTransitionState(_owner.IsGrounded ? (PlayerStateBase)_idleState : _fallState);
}
}
// 连击窗口超时
if (_comboTimer <= 0) _comboStep = 0;
}
public override void OnStateExit() => _owner.Combat.DisableAllWeaponHitBoxes();
}
HurtState
public class HurtState : PlayerStateBase
{
private float _stunTimer;
public void Configure(DamageInfo info) // ForceState 前由 HurtBox 调用
{
_stunTimer = info.HitStunDuration;
}
public override void OnStateEnter()
{
Move.ApplyKnockback(info.KnockbackDirection, info.KnockbackForce);
Move.ZeroHorizontalVelocity();
Stats.BeginInvincibility(Stats.InvincibilityDuration);
Anim.Play(_owner.AnimConfig.HurtClip);
}
public override void OnStateUpdate()
{
_stunTimer -= Time.deltaTime;
if (_stunTimer <= 0)
_owner.TryTransitionState(Move.IsGrounded ? (PlayerStateBase)_idleState : _fallState);
}
}
WallSlideState(贴墙下滑)
// 进入条件:空中 + 贴墙(PlayerWallDetector.IsTouchingWall)+ 下落速度 < 0
// 退出条件:离墙 / 跳跃输入 / 落地
public class WallSlideState : PlayerStateBase
{
private float _wallGrabY; // 进入贴墙时的 Y 坐标,用于防无限蹬墙
public override void OnStateEnter()
{
_wallGrabY = _owner.transform.position.y;
Move.SetGravityScale(0f); // 关闭重力,自行控制下滑速度
Anim.Play(_clips.WallSlide);
}
public override void OnStateUpdate()
{
// 防无限蹬墙:如果高度超出进入时超过阈值,立即离墙
float heightGain = _owner.transform.position.y - _wallGrabY;
if (heightGain > _config.WallGrabMaxHeightGain)
{
_owner.TryTransitionState(_fallState);
return;
}
// 贴墙下滑(固定向下速度)
Move.SetVelocityY(-_config.WallSlideSpeed);
// 跳跃输入 → WallJumpState
if (Input.Jump.WasPressedThisFrame())
{
_owner.TryTransitionState(_wallJumpState);
return;
}
// 离墙检查(PlayerWallDetector 刷新结果)
if (!_owner.WallDetector.IsTouchingWall || Move.IsGrounded)
_owner.TryTransitionState(_fallState);
}
public override void OnStateExit()
{
Move.SetGravityScale(_config.DefaultGravityScale); // 恢复重力
}
}
PlayerWallDetector(独立组件,保持 PlayerMovement 整洁):
// 路径: Assets/Scripts/Player/PlayerWallDetector.cs
// 每 FixedUpdate 发射双射线(TopRay + BottomRay);两根均命中才判定为贴墙
[RequireComponent(typeof(PlayerMovement))]
public class PlayerWallDetector : MonoBehaviour
{
[SerializeField] private PlayerMovementConfigSO _config;
public bool IsTouchingWall { get; private set; }
public int WallDirection { get; private set; } // +1 = 右墙,-1 = 左墙
private void FixedUpdate()
{
bool rightWall = CheckSide(Vector2.right);
bool leftWall = CheckSide(Vector2.left);
IsTouchingWall = rightWall || leftWall;
WallDirection = rightWall ? 1 : leftWall ? -1 : 0;
}
// 每侧发两根射线(TopRay + BottomRay),两根均命中才返回 true
private bool CheckSide(Vector2 dir)
{
Vector2 center = transform.position;
float len = _config.WallRayLength;
float oy = _config.WallRayOffsetY;
int layer = LayerMask.GetMask("Wall"); // Layer 10(见 06_CombatModule §12)
bool top = Physics2D.Raycast(center + Vector2.up * oy, dir, len, layer);
bool bot = Physics2D.Raycast(center + Vector2.down * oy, dir, len, layer);
return top && bot;
}
}
WallJumpState(蹬墙跳)
两种跳跃类型(根据玩家输入方向自动判断):
| 类型 | 条件 | 水平速度 | 垂直速度 | 说明 |
|---|---|---|---|---|
| 背墙跳(Wall-bounce) | 无水平输入 / 同向输入 | WallJumpBackForceX(同向) |
WallJumpForceY |
沿墙弹出,常用于小幅度调整 |
| 对墙跳(Wall-fly) | 反向输入 | WallJumpAwayForceX(反向) |
WallJumpAwayForceY |
快速飞离墙壁,有短暂输入锁定 |
public class WallJumpState : PlayerStateBase
{
private float _inputLockTimer; // 对墙跳后短暂锁定水平输入
private int _jumpDirection; // 起跳时的墙壁方向(-1/+1)
public override void OnStateEnter()
{
_jumpDirection = _owner.WallDetector.WallDirection;
float inputX = Input.Move.x;
bool isWallFly = (inputX != 0) && (Mathf.Sign(inputX) != Mathf.Sign(_jumpDirection));
if (isWallFly)
{
// 对墙跳:反向飞出 + 短暂水平输入锁
Move.SetVelocity(new Vector2(-_jumpDirection * _config.WallJumpAwayForceX,
_config.WallJumpAwayForceY));
_inputLockTimer = _config.WallJumpInputLockDuration;
}
else
{
// 背墙跳:同向弹出
Move.SetVelocity(new Vector2(-_jumpDirection * _config.WallJumpBackForceX,
_config.WallJumpForceY));
_inputLockTimer = 0f;
}
Anim.Play(_clips.Jump);
}
public override void OnStateUpdate()
{
_inputLockTimer -= Time.deltaTime;
bool canControl = _inputLockTimer <= 0f;
if (canControl)
{
float inputX = Input.Move.x;
Move.ApplyHorizontalAcceleration(inputX); // 恢复正常空中操控
}
// 松开跳跃键 → 截断跳跃
if (Input.Jump.WasReleasedThisFrame())
Move.CutJump();
// 转为下落状态
if (Move.Velocity.y < 0)
_owner.TryTransitionState(_fallState);
}
}
玩家使用 Animancer Pro 的 AnimancerComponent,采用双层动画模式:
| 层 | 作用 | 示例 |
|---|---|---|
| Base Layer(0) | 全身状态动画(移动/攻击/受伤/死亡) | Idle、Run、Attack_01 |
| Overlay Layer(1) | 上半身叠加层(边移动边施法) | SoulSpell、UseSpring |
每个 AnimancerState 对应一个 AnimationClip,由 PlayerAnimationConfigSO 统一配置。
15. PlayerMovementConfigSO
[CreateAssetMenu(menuName = "Player/MovementConfig")]
public class PlayerMovementConfigSO : ScriptableObject
{
[Header("Ground Movement")]
public float RunSpeed = 7f;
public float Acceleration = 50f;
public float Deceleration = 80f;
[Header("Jump")]
public float JumpForce = 18f;
public float CoyoteTime = 0.12f; // 秒
public float FallGravityMult = 2.5f; // 下落时额外重力倍率
public float MaxFallSpeed = 20f;
[Header("Dash")]
public float DashSpeed = 20f;
public float DashDuration = 0.18f; // 秒
public float DashCooldown = 0.4f; // 秒
public int MaxAerialDashes = 1; // 空中冲刺次数(升级后可增加)
[Header("Wall")]
public float WallSlideSpeed = 2f;
public float WallJumpForceX = 12f;
public float WallJumpForceY = 16f;
[Header("Wall — 检测")]
public float WallRayLength = 0.55f; // 墙壁射线长度
public float WallRayOffsetY = 0.2f; // 上/下两根射线相对中心的 Y 偏移(双射线检测)
[Header("Wall — WallGrab")]
public float WallGrabMaxHeightGain = 0.5f; // 允许的最大垂直高度增益(防无限蹬墙)
public float WallGrabReleaseDelay = 0.08f; // 离开墙面后的延迟释放(避免误判)
[Header("Wall — WallJump")]
public float WallJumpBackForceX = 14f; // 背墙跳(同向)水平速度
public float WallJumpAwayForceX = 10f; // 对墙跳(反向)水平速度
public float WallJumpAwayForceY = 18f; // 对墙跳垂直速度
public float WallJumpInputLockDuration = 0.15f; // 对墙跳后短暂锁定水平输入(防立即转向)
}
16. PlayerStatsSO
[CreateAssetMenu(menuName = "Player/Stats")]
public class PlayerStatsSO : ScriptableObject
{
[Header("HP")]
public int MaxHP = 5; // 初始心脏容器数 × 2(半格为1)
[Header("Soul Power")]
public int MaxSoulPower = 100;
[Header("Spirit Power")]
public int MaxSpiritPower = 100;
public int SpiritRegenRate = 5; // 每秒回复量
[Header("Spring")]
public int MaxSpringCharges = 3; // 初始灵泉次数上限
public int SpringHealAmount = 2; // 每次回复 HP 量(半格)
public int SpringKillThreshold = 4; // 增加 1 次灵泉所需击杀点数
[Header("Invincibility")]
public float InvincibilityDuration = 0.6f;
[Header("Geo")]
public int InitialGeo = 0;
}
17. PlayerAnimationConfigSO
[CreateAssetMenu(menuName = "Player/AnimationConfig")]
public class PlayerAnimationConfigSO : ScriptableObject
{
[Header("Base Layer Clips")]
public AnimationClip Idle;
public AnimationClip Run;
public AnimationClip Jump;
public AnimationClip Fall;
public AnimationClip Dash;
public AnimationClip WallSlide;
public AnimationClip Hurt;
public AnimationClip Dead;
public AnimationClip UseSpring;
[Header("Attack Combo Clips(按连击序索引)")]
public AnimationClip[] GroundAttacks; // [0] = combo1, [1] = combo2, [2] = combo3
public AnimationClip AirAttack;
public AnimationClip UpAttack;
public AnimationClip DownAttack; // Pogo
[Header("Parry")]
public AnimationClip ParryStart;
public AnimationClip ParrySuccess;
// 根据连击步获取动画
public AnimationClip GetAttackClip(int step) =>
step < GroundAttacks.Length ? GroundAttacks[step] : GroundAttacks[^1];
}
18. FormConfigSO
[CreateAssetMenu(menuName = "Player/FormConfig")]
public class FormConfigSO : ScriptableObject
{
[Header("形态列表(按解锁顺序排列)")]
// forms[0] = 天魂, forms[1] = 地魂, forms[2] = 命魂
// 每个 FormSO 包含:formId / displayName / formIcon / formAccentColor
// defaultWeapon / soulSkill / spiritSkill1 / spiritSkill2
public FormSO[] forms;
[Header("初始解锁状态")]
// 与 forms[] 对应:true = 初始解锁,false = 需要在游戏内解锁
public bool[] initialUnlocked; // initialUnlocked[0] = true(天魂初始解锁)
// ── 编辑器便利方法 ─────────────────────────────────────────────────────
public int GetFormIndex(FormSO form)
{
for (int i = 0; i < forms.Length; i++)
if (forms[i] == form) return i;
return -1;
}
public bool IsUnlocked(string formId)
{
for (int i = 0; i < forms.Length; i++)
if (forms[i].formId == formId) return i < initialUnlocked.Length && initialUnlocked[i];
return false;
}
}
旧字段(
SkyFormSoulSpell/EarthFormSoulSpell等独立字段)已迁移至各FormSO的soulSkill/spiritSkill1/spiritSkill2字段中。
19. InputBuffer — 输入缓冲系统
P2 优化:原
InputBuffer的缓冲窗口时长硬编码为0.1f,无障碍需求(高延迟玩家、手残模式)或竞技模式(精确 0 容忍)无法调整。将缓冲时长提取至AccessibilitySettingsSO,运行时可动态覆盖。
// 路径: Assets/Scripts/Player/Input/InputBuffer.cs
namespace BaseGames.Player
{
/// <summary>
/// 单帧输入缓冲系统:记录"提前输入的指令",在接受窗口开启时消费。
/// 典型用例:攻击收招前 0.1s 内按下跳跃 → 收招结束后立即起跳。
/// </summary>
public class InputBuffer : MonoBehaviour
{
// ── 配置 ──────────────────────────────────────────────────────────
/// <summary>
/// 缓冲窗口基准时长(秒)。由 AccessibilitySettingsSO 在初始化时注入,
/// 默认 0.1f,无障碍模式最高可扩展至 0.3f。
/// </summary>
[SerializeField, Min(0f), Tooltip("基准缓冲时长(秒),AccessibilitySettingsSO 会覆盖此值")]
private float _baseDuration = 0.1f;
/// <summary>运行时有效缓冲时长 = _baseDuration × AccessibilityMultiplier。</summary>
private float _effectiveDuration;
// ── 运行时状态 ────────────────────────────────────────────────────
private InputActionType _bufferedAction;
private float _bufferExpiry; // Time.time 过期时刻
// ── 初始化 ────────────────────────────────────────────────────────
public void Initialize(AccessibilitySettingsSO accessibility)
{
_effectiveDuration = _baseDuration *
(accessibility != null ? accessibility.InputBufferMultiplier : 1f);
}
// ── 写入(InputReaderSO 事件回调中调用)──────────────────────────
public void Buffer(InputActionType action)
{
_bufferedAction = action;
_bufferExpiry = Time.time + _effectiveDuration;
}
// ── 读取 & 消费(State.OnEnter / State.OnUpdate 中调用)──────────
/// <summary>若缓冲指令与 <paramref name="expected"/> 匹配且未过期,消费并返回 true。</summary>
public bool ConsumeIfMatch(InputActionType expected)
{
if (_bufferedAction == expected && Time.time <= _bufferExpiry)
{
_bufferedAction = InputActionType.None;
_bufferExpiry = 0f;
return true;
}
return false;
}
/// <summary>无条件清除缓冲(状态切换时调用,防止残留输入污染新状态)。</summary>
public void Clear()
{
_bufferedAction = InputActionType.None;
_bufferExpiry = 0f;
}
}
}
AccessibilitySettingsSO 相关字段:
// 路径: Assets/Scripts/Core/Settings/AccessibilitySettingsSO.cs(新增字段)
[CreateAssetMenu(menuName = "Settings/AccessibilitySettings")]
public class AccessibilitySettingsSO : ScriptableObject
{
[Header("输入辅助")]
[SerializeField, Range(0.5f, 3.0f),
Tooltip("输入缓冲时长倍率。1.0 = 标准;2.0 = 宽松模式(约 0.2s 缓冲);0.5 = 精确模式")]
public float InputBufferMultiplier = 1.0f;
}
初始化时机:PlayerController.Awake() 中调用 _inputBuffer.Initialize(_accessibilitySettings),_accessibilitySettings 通过 [SerializeField] 注入。
缓冲时长参考值:
| 模式 | InputBufferMultiplier |
有效缓冲时长 |
|---|---|---|
| 标准(默认) | 1.0 |
100ms |
| 无障碍宽松 | 2.0 |
200ms |
| 无障碍超宽松 | 3.0 |
300ms |
| 精确 / 竞技 | 0.5 |
50ms |
20. FSMTransitionTableSO — 数据驱动状态跳转
P1 优化:当前
PlayerStateMachine内各 State 的OnStateUpdate()里硬编码
if (condition) _sm.ChangeState(new XxxState(_owner))跳转逻辑,
增减状态时需修改多个类,设计师无法通过 Inspector 调整跳转优先级。
引入FSMTransitionTableSO,将跳转规则外化为可配置资产,
实现 "0 代码改动增加新跳转条件" 的设计师友好工作流。
20.1 数据结构
// 路径: Assets/Scripts/Player/FSM/FSMTransitionTableSO.cs
[CreateAssetMenu(menuName = "Player/FSM Transition Table")]
public class FSMTransitionTableSO : ScriptableObject
{
[System.Serializable]
public struct TransitionDefinition
{
[Tooltip("从哪个状态触发(空 = 任意状态)")]
public string FromState; // 类型名,如 "IdleState"
[Tooltip("跳转到哪个状态")]
public string ToState; // 类型名,如 "JumpState"
[Tooltip("触发条件(与条件注册表中的键对应)")]
public string Condition; // 如 "JumpPressed", "Grounded"
[Tooltip("优先级,数值越高越优先(默认 0,负数表示最低优先级)")]
public int Priority;
[Tooltip("是否覆盖同方向的硬编码跳转(true = 本 SO 优先;false = 仅作为补充)")]
public bool Override;
}
public TransitionDefinition[] Transitions;
}
20.2 条件注册表
// 路径: Assets/Scripts/Player/FSM/FSMConditionRegistry.cs
/// <summary>
/// 静态条件函数注册中心。
/// 条件函数在 PlayerController.Awake() 中注册,保证初始化顺序。
/// </summary>
public static class FSMConditionRegistry
{
// key = 条件名(与 TransitionDefinition.Condition 对应)
// value = 谓词,接收 PlayerController 上下文,返回是否满足
private static readonly Dictionary<string, Func<PlayerController, bool>> _conditions = new();
public static void Register(string key, Func<PlayerController, bool> predicate)
=> _conditions[key] = predicate;
public static bool Evaluate(string key, PlayerController ctx)
=> _conditions.TryGetValue(key, out var fn) && fn(ctx);
}
内置条件(PlayerController.Awake() 中注册):
// PlayerController.Awake() — 注册基础条件
void RegisterDefaultConditions()
{
FSMConditionRegistry.Register("JumpPressed", ctx => ctx.Buffer.WasPressed(InputAction.Jump));
FSMConditionRegistry.Register("Grounded", ctx => ctx.Movement.IsGrounded);
FSMConditionRegistry.Register("Airborne", ctx => !ctx.Movement.IsGrounded);
FSMConditionRegistry.Register("DashPressed", ctx => ctx.Buffer.WasPressed(InputAction.Dash));
FSMConditionRegistry.Register("AttackPressed", ctx => ctx.Buffer.WasPressed(InputAction.Attack));
FSMConditionRegistry.Register("MoveInputActive", ctx => Mathf.Abs(ctx.Input.Move.x) > 0.1f);
FSMConditionRegistry.Register("NoMoveInput", ctx => Mathf.Abs(ctx.Input.Move.x) <= 0.1f);
FSMConditionRegistry.Register("FallVelocity", ctx => ctx.Movement.Velocity.y < -0.5f);
// 扩展:技能解锁条件
FSMConditionRegistry.Register("WallSlideUnlocked",
ctx => ctx.Stats.HasAbility(AbilityType.WallSlide));
}
20.3 PlayerStateMachine 集成
// PlayerStateMachine.cs — 改动(最小侵入)
public class PlayerStateMachine
{
private readonly PlayerController _owner;
private FSMTransitionTableSO _transitionTable; // 可选,可为 null
private PlayerStateBase _currentState;
// 构造时可选注入 TransitionTable
public PlayerStateMachine(PlayerController owner, FSMTransitionTableSO table = null)
{
_owner = owner;
_transitionTable = table;
}
public void Tick()
{
// 1. 先检查 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 的自动注册
// 路径: Assets/Scripts/Player/FSM/FSMStateAttribute.cs
/// <summary>
/// 标记一个 PlayerStateBase 子类可被 FSMStateFactory 自动发现。
/// 参数为状态在 TransitionDefinition.ToState 中使用的字符串 key。
/// 示例:[FSMState("DashState")]
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class FSMStateAttribute : Attribute
{
public string Key { get; }
public FSMStateAttribute(string key) => Key = key;
}
// 路径: Assets/Scripts/Player/FSM/FSMStateFactory.cs
/// <summary>
/// 扫描程序集中所有标注了 [FSMState] 的 PlayerStateBase 子类,
/// 自动注册到工厂字典,新增状态只需添加 Attribute,无需修改本类。
/// </summary>
public static class FSMStateFactory
{
private static Dictionary<string, Func<PlayerController, PlayerStateBase>> _registry;
/// <summary>
/// 通过反射扫描 Assembly-CSharp 中所有 [FSMState] 标注类并注册。
/// 仅在 Initialize 时调用一次(无运行时反射开销)。
/// </summary>
public static void Initialize()
{
_registry = new();
var baseType = typeof(PlayerStateBase);
foreach (var type in typeof(FSMStateFactory).Assembly.GetTypes())
{
if (!type.IsClass || type.IsAbstract || !baseType.IsAssignableFrom(type)) continue;
var attr = type.GetCustomAttribute<FSMStateAttribute>();
if (attr == null) continue;
// 构造器缓存(避免重复反射)
var ctor = type.GetConstructor(new[] { typeof(PlayerController) });
if (ctor == null)
{
Debug.LogError($"[FSMStateFactory] {type.Name} 缺少 (PlayerController) 构造器");
continue;
}
var key = attr.Key;
_registry[key] = owner => (PlayerStateBase)ctor.Invoke(new object[] { owner });
}
#if UNITY_EDITOR
Debug.Log($"[FSMStateFactory] 已注册 {_registry.Count} 个状态: "
+ string.Join(", ", _registry.Keys));
#endif
}
public static PlayerStateBase Create(string stateName, PlayerController owner)
{
if (_registry == null) Initialize();
if (_registry.TryGetValue(stateName, out var factory)) return factory(owner);
Debug.LogError($"[FSMStateFactory] 未找到状态 '{stateName}',"
+ "请检查该 State 类是否添加了 [FSMState(\"" + stateName + "\")]。");
return null;
}
/// <summary>返回所有已注册的状态 key(供 PropertyDrawer 生成下拉列表)。</summary>
public static IReadOnlyCollection<string> GetRegisteredKeys()
{
if (_registry == null) Initialize();
return _registry.Keys;
}
}
标注示例(各 State 类添加一行):
[FSMState("IdleState")] public class IdleState : PlayerStateBase { ... }
[FSMState("RunState")] public class RunState : PlayerStateBase { ... }
[FSMState("JumpState")] public class JumpState : PlayerStateBase { ... }
[FSMState("FallState")] public class FallState : PlayerStateBase { ... }
[FSMState("DashState")] public class DashState : PlayerStateBase { ... }
// 新增状态只需加 [FSMState("XxxState")],工厂自动感知,无需修改 FSMStateFactory
20.4b FSMConditionPropertyDrawer — Inspector 下拉验证
// 路径: Assets/Scripts/Player/FSM/Editor/FSMConditionPropertyDrawer.cs
#if UNITY_EDITOR
using UnityEditor;
/// <summary>
/// 为 TransitionDefinition.Condition 字符串字段显示下拉列表,
/// 防止手填字符串拼写错误导致条件静默失败。
/// </summary>
[CustomPropertyDrawer(typeof(FSMConditionKeyAttribute))]
public class FSMConditionPropertyDrawer : PropertyDrawer
{
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
if (property.propertyType != SerializedPropertyType.String)
{
EditorGUI.PropertyField(position, property, label);
return;
}
var keys = new List<string>(FSMConditionRegistry.GetRegisteredKeys());
keys.Insert(0, "(none)");
int currentIndex = keys.IndexOf(property.stringValue);
if (currentIndex < 0) currentIndex = 0;
EditorGUI.BeginChangeCheck();
int selected = EditorGUI.Popup(position, label.text, currentIndex,
keys.Select(k => new GUIContent(k)).ToArray());
if (EditorGUI.EndChangeCheck())
property.stringValue = selected == 0 ? string.Empty : keys[selected];
}
}
#endif
// 配套 Attribute,标注 TransitionDefinition.Condition 字段
public sealed class FSMConditionKeyAttribute : PropertyAttribute { }
TransitionDefinition 更新(添加 Attribute):
[System.Serializable]
public struct TransitionDefinition
{
[Tooltip("从哪个状态触发(空 = 任意状态)")]
public string FromState;
[Tooltip("跳转到哪个状态")]
public string ToState;
// ✅ 加上 [FSMConditionKey] 后 Inspector 变为条件下拉列表
[FSMConditionKey]
[Tooltip("触发条件(与条件注册表中的键对应)")]
public string Condition;
[Tooltip("优先级,数值越高越优先(默认 0,负数表示最低优先级)")]
public int Priority;
[Tooltip("是否覆盖同方向的硬编码跳转(true = 本 SO 优先;false = 仅作为补充)")]
public bool Override;
}
FSMConditionRegistry 补充 GetRegisteredKeys()(供 PropertyDrawer 查询):
public static class FSMConditionRegistry
{
private static readonly Dictionary<string, Func<PlayerController, bool>> _conditions = new();
public static void Register(string key, Func<PlayerController, bool> predicate)
=> _conditions[key] = predicate;
public static bool Evaluate(string key, PlayerController ctx)
{
if (_conditions.TryGetValue(key, out var fn)) return fn(ctx);
Debug.LogWarning($"[FSMConditionRegistry] 条件 '{key}' 未注册,跳过。"
+ " 请检查 PlayerController.RegisterDefaultConditions() 或自定义注册。");
return false;
}
/// <summary>返回所有已注册条件的 key(供 PropertyDrawer 生成下拉列表)。</summary>
public static IReadOnlyCollection<string> GetRegisteredKeys() => _conditions.Keys;
}
20.5 设计师工作流
1. Project 窗口右键 → Create → Player → FSM Transition Table
→ 生成 "DefaultTransitionTable.asset"
2. Inspector 中展开 Transitions 数组,添加新条目例如:
From: "IdleState" To: "DashState"
Condition: "DashPressed" Priority: 10 Override: false
3. 将 asset 拖入 PlayerController 的 "Transition Table" 槽位
4. Play Mode 测试,无需修改任何 C# 代码
20.6 向后兼容保证
| 场景 | 行为 |
|---|---|
_transitionTable 为 null |
完全走原有硬编码路径,无影响 |
| 条目 Override=false | 仅在硬编码路径未触发跳转时才作为补充 |
| 条目 Override=true | 在 OnStateUpdate() 执行前拦截并优先跳转 |
| 条件名未注册 | FSMConditionRegistry.Evaluate 输出 Warning 并返回 false |
| 状态名未注册 | FSMStateFactory.Create 输出 Error 并返回 null,跳过本条目 |
| 新增状态类 | 只需添加 [FSMState("XxxState")],工厂自动发现,无需修改工厂 |
20.7 PlayerStateBase 依赖声明建议
设计意图(Beta 前重构建议):
PlayerStateBase当前构造器接受整个PlayerController,
状态可访问 Controller 所有属性。P1-3 建议逐步将各 State 类约束到最小接口集:
// 推荐做法:State 声明自己实际依赖的上下文接口(编译时约束)
// 例:DashState 只需要 IMovementContext + IInputContext
public class DashState : PlayerStateBase
{
private readonly IMovementContext _move;
private readonly IInputContext _input;
// 保持与 PlayerStateBase(PlayerController) 兼容,同时做接口分解
public DashState(PlayerController owner) : base(owner)
{
_move = owner; // PlayerController 实现 IMovementContext
_input = owner; // PlayerController 实现 IInputContext
}
// ... 内部只使用 _move / _input,不直接访问 _owner 其余属性
}
迁移原则:新增状态类直接按接口分组声明依赖;旧 State 类在需要维护时按需迁移,无需一次性重构。