# Phase 2 · 核心玩法扩展
> **周期**:4–5 周(Week 5–9)
> **前置条件**:Phase 1 全部完成标准通过(第三方库协作验证无误)
> **核心目标**:完整的玩家战斗/移动系统、护盾/弹反、状态效果、难度系统、AudioMixer 快照、敌人扩展
> **产出物**:玩家拥有所有移动能力(冲刺/登墙/游泳)、完整连击链、弹反机制;2–3 种敌人类型;AudioMixer 快照需求已整合
---
## 目录
1. [实施顺序总览](#1-实施顺序总览)
2. [Week 5:玩家 FSM 完整扩展](#2-week-5玩家-fsm-完整扩展)
3. [Week 6:护盾 + 弹反 + 战斗深化](#3-week-6护盾--弹反--战斗深化)
4. [Week 7:状态效果 + 动画事件 + 完整 VFX](#4-week-7状态效果--动画事件--完整-vfx)
5. [Week 8:难度系统 + AudioMixer 快照](#5-week-8难度系统--audiomixer-快照)
6. [Week 9:敌人扩展 + BossBase 骨架](#6-week-9敌人扩展--bossbase-骨架)
7. [完成标准检查清单](#7-完成标准检查清单)
---
## 1. 实施顺序总览
```
Week 5: DashState → WallSlideState → AirAttackState → DownAttack/UpAttack
↓
FormController → WeaponManager → PlayerCombat 完整(含 SoulSkill 触发)
↓
PlayerStats 完整(SoulPower/SpiritPower/Geo/Ability 系统)
Week 6: ShieldComponent → HurtBox 修正(护盾管线)
↓
ParrySystem → ParryConfigSO → ParryState
↓
PoiseSystem(霸体)→ HurtState 更新(考虑霸体打断)
↓
连击链完整(3 段地面 + 2 段空中 + 下劈 + 上劈)
Week 7: AnimationEventType 枚举 → IAnimationEventHandler(接口,架构 24 §4)
↓
StatusEffect(抽象基类,架构 06 §11,非 StatusEffectBase)→ Poison / Burn / Stagger 具体实现
↓
StatusEffectManager → HurtBox 集成
↓
完整 VFX(ParryFlash / FormSwitch / StatusEffect 粒子)
Week 8: AudioMixer 快照配置(BGM形态切换/战斗/平静快照)
↓
DifficultyManager → DifficultyScalerSO × 4 → PlayerStats/EnemyStats 注入钩子
Week 9: EnemyBase 子类:RangedEnemy(远程)+ FlyingEnemy(飞行巡逻)
↓
LootResolver + LootTableSO
↓
BossBase 骨架(HP 分段、Phase 切换、Arena 锁定)
```
---
## 2. Week 5:玩家 FSM 完整扩展 ✅ 完成(2026-05-12)
**参考文档**:`05_PlayerModule.md §2`
### 2.1 新增 State 文件列表
```
Assets/Scripts/Player/States/
├── DashState.cs ← 地面冲刺(位移 + 无敌帧)
├── AerialDashState.cs ← 空中冲刺(架构 05 §12,独立状态,消耗 MaxAerialDashes 次数)⚠️ 与 DashState 分离
├── WallSlideState.cs ← 蹬墙(下滑 + 蹬墙跳触发)
├── WallJumpState.cs ← 蹬墙跳
├── AirAttackState.cs ← 空中攻击
├── DownAttackState.cs ← 下劈(Physics2D.OverlapPoint 踩踏检测)
├── UpAttackState.cs ← 上劈
├── HurtState.cs ← 受击硬直
├── DeadState.cs ← 死亡(冻结物理)
├── SpringState.cs ← 使用灵泉(治疗动画)
└── ParryState.cs ← 弹反预备(Week 6)
```
### 2.2 DashState 实现要点
```csharp
public class DashState : PlayerStateBase
{
public override void OnStateEnter()
{
// 1. 消耗冲刺次数(Phase 2 先不加多段冲刺计数,一次冲刺)
// 2. 开启无敌帧 BeginInvincibility(_config.DashInvincibleDuration)
// 3. 施加冲刺速度 _player.Movement.Dash(facingDir, _config.DashSpeed)
// 4. 启动持续时间计时器 → 到期进入 Fall 或 Idle
// 5. 播放冲刺动画 + _player.Feedback.PlayDash()
}
// 冲刺期间忽略重力(SetGravityScale(0)),结束时恢复
}
```
### 2.3 FormController + WeaponManager
按 `05_PlayerModule.md §6/§7` 实现:
```
FormConfigSO.asset × 1 ← 单一资产,forms[0..2] = Sky/Earth/Death(Assets/Data/Player/Forms/) ⚠️ 非 3 个 FormConfigSO
FormSO.asset × 3 ← Sky / Earth / Death(存于 FormConfigSO.forms[] 数组中,架构 05 §18)
WeaponSO.asset × 3 ← 三形态对应武器数据(Assets/Data/Combat/Weapons/)
// ⚠️ 无 WeaponInstance Prefab;HitBox 直接挂载在 Player Prefab 上(PlayerCombat._hitBoxGround/Up/Down/Air),架构 05 §5/§7
```
`FormController.SwitchForm()` 实现(架构 05_PlayerModule §6):
1. 更新 `CurrentForm`
2. 发布事件频道 `_onFormChanged.Raise(_config.GetFormIndex(newForm))`(UI / Save 用)
3. 触发 C# 事件 `OnFormChanged?.Invoke()`(⚠️ WeaponManager 在 OnEnable 自订阅,非直接调用 WeaponManager)
4. 调用 `_paletteSwapSystem?.ApplyPalette(newForm.formType)`(⚠️ 传 `FormType` 枚举值,架构 18_VFXFeedbackModule §10 定义 `ApplyPalette(FormType form)` 接受 FormType;架构 05 §6 代码片段错误地传了 `newForm.formAccentColor`(Color)与架构 18 §10 不符,以架构 18 §10 为准;`FormSO` 需补充 `public FormType formType` 字段,架构 05 §18 FormSO 定义中遗漏此字段)
5. 调用 `_skillManager?.UpdateSkillSet(newForm)`(⚠️ 传 FormSO 1 个参数,非 3 个 FormSkillSO,架构 05 §6)
6. 发布 `_onSkillSetChanged?.Raise()`(EVT_SkillSetChanged,通知 SkillHUD 刷新,架构 09_ProgressionModule §11)
**PlayerCombat**(按 `05_PlayerModule.md §5` 完整实现,Phase 1 §3.4 简化版补全):
```csharp
// Assets/Scripts/Player/PlayerCombat.cs
// ⚠️ 架构 05_PlayerModule §5:HitBox 直接挂在 Player Prefab 上,不经过 WeaponInstance(无此类)
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;
void OnEnable()
{
_weaponManager.OnWeaponChanged += RefreshWeaponData;
if (_weaponManager.ActiveWeapon != null)
RefreshWeaponData(_weaponManager.ActiveWeapon);
}
void OnDisable() => _weaponManager.OnWeaponChanged -= RefreshWeaponData;
void RefreshWeaponData(WeaponSO weapon) { /* 缓存动画片段,刷新 HitBox 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();
}
// 命中回调 → 增加灵力
internal void OnHitConfirmed(DamageInfo info) { /* 增加灵力 */ }
private HitBox GetHitBox(AttackDirection dir) => dir switch
{
AttackDirection.Ground => _hitBoxGround,
AttackDirection.Up => _hitBoxUp,
AttackDirection.Down => _hitBoxDown,
AttackDirection.Air => _hitBoxAir,
_ => null,
};
}
```
**WeaponSO**(按 `05_PlayerModule.md §7` 完整实现):
```csharp
// Assets/Scripts/Combat/WeaponSO.cs
// ⚠️ 纯数据 SO(不含 Prefab),架构 05 §7;HitBox 固定在 Player Prefab 上
[CreateAssetMenu(menuName = "Combat/Weapon")]
public class WeaponSO : ScriptableObject
{
[Header("基础信息")]
public string weaponId; // 全局唯一 ID,如 "Weapon_SkyBlade"
public string displayName; // "天裂刃"
public Sprite icon;
public WeaponType weaponType; // ⚠️ WeaponType 枚举(架构 05 §7)
[Header("连击动画(Animancer ClipTransition)")]
public ClipTransition attack1Clip;
public ClipTransition attack2Clip;
public ClipTransition attack3Clip;
public ClipTransition airAttackClip;
public ClipTransition upAttackClip;
public ClipTransition downAttackClip;
[Header("伤害来源(每段独立 DamageSourceSO)")]
public DamageSourceSO attack1Source;
public DamageSourceSO attack2Source;
public DamageSourceSO attack3Source;
public DamageSourceSO airAttackSource;
public DamageSourceSO upAttackSource;
public DamageSourceSO downAttackSource;
[Header("HitBox 配置")]
/// 零向量 = 使用 PlayerCombat 中的默认尺寸
public Vector2 hitBoxSizeOverride;
public Vector2 hitBoxOffsetOverride;
[Header("武器特效")]
public WeaponVFXConfig vfxConfig; // ⚠️ 类型为 WeaponVFXConfig(非 MMF_Player),架构 05 §7
public DamageSourceSO GetSourceByDir(AttackDirection dir) => dir switch
{
AttackDirection.Ground => attack1Source,
AttackDirection.Up => upAttackSource,
AttackDirection.Down => downAttackSource,
AttackDirection.Air => airAttackSource,
_ => null,
};
}
// ⚠️ WeaponType 枚举(架构 05 §7)
public enum WeaponType
{
SkyBlade, // 天魂:裂空刃(高频轻击)
EarthHammer, // 地魂:地震锤(低频重击,范围大)
LifeScythe, // 命魂:命镰(穿透,直线斩)
Custom, // 护符替换或未来扩展武器
}
// ⚠️ WeaponVFXConfig:内嵌类([Serializable]),架构 05 §7;包含 FeedbackPresetSO 而非 MMF_Player
[Serializable]
public class WeaponVFXConfig
{
[Tooltip("切换到此武器时播放的特效(形态切换音效/粒子)")]
public FeedbackPresetSO onEquipFeedback; // ⚠️ FeedbackPresetSO(非 MMF_Player),架构 05 §7
[Tooltip("武器挥斩拖尾 Prefab(null = 不显示拖尾)")]
public GameObject weaponTrailPrefab;
public Color trailColor = Color.white;
[Tooltip("命中特效类型覆盖(null = 使用 DamageSourceSO.HitFxType)")]
public HitFxType? hitFxOverride;
}
```
**WeaponManager**(按 `05_PlayerModule.md §7` 完整实现,含护符 Override API):
```csharp
// Assets/Scripts/Player/WeaponManager.cs
// ⚠️ 架构 05 §7:含护符替换武器的 Override 字典(_overrides),非简单直接切换
public class WeaponManager : MonoBehaviour
{
[SerializeField] private FormController _formController;
public WeaponSO ActiveWeapon { get; private set; }
public event Action OnWeaponChanged;
// ⚠️ 护符注入的武器覆盖:Key = FormSO.formId,Value = 替换武器(架构 05 §7)
readonly Dictionary _overrides = new();
void Awake()
{
if (_formController.CurrentForm != null)
ApplyWeapon(_formController.CurrentForm);
}
void OnEnable() => _formController.OnFormChanged += HandleFormChanged;
void OnDisable() => _formController.OnFormChanged -= HandleFormChanged;
void HandleFormChanged() => ApplyWeapon(_formController.CurrentForm);
void ApplyWeapon(FormSO form)
{
WeaponSO next = _overrides.TryGetValue(form.formId, out var ov)
? ov
: form.defaultWeapon;
if (next == ActiveWeapon) return;
ActiveWeapon = next;
OnWeaponChanged?.Invoke(next);
// ⚠️ vfxConfig.onEquipFeedback 为 FeedbackPresetSO(架构 05 §7),非 MMF_Player
next?.vfxConfig.onEquipFeedback?.PlayFeedbacks(gameObject);
}
// ─────────── 护符 Override API(由 WeaponOverrideEffect 调用,架构 05 §7)───────────
/// 为指定形态设置武器覆盖。formId 为空 = 覆盖所有形态。
public void SetOverride(string formId, WeaponSO weapon)
{
if (string.IsNullOrEmpty(formId))
foreach (var f in _formController.AllForms) _overrides[f.formId] = weapon;
else
_overrides[formId] = weapon;
ApplyWeapon(_formController.CurrentForm);
}
/// 移除覆盖,恢复默认武器。formId 为空 = 移除所有形态覆盖。
public void ClearOverride(string formId)
{
if (string.IsNullOrEmpty(formId))
foreach (var f in _formController.AllForms) _overrides.Remove(f.formId);
else
_overrides.Remove(formId);
ApplyWeapon(_formController.CurrentForm);
}
}
```
**WeaponOverrideEffect**(护符效果实现,按 `05_PlayerModule.md §7`):
```csharp
// Assets/Scripts/Equipment/CharmEffects/WeaponOverrideEffect.cs
// ⚠️ 实现 ICharmEffect 接口(架构 05 §7),非直接继承 MonoBehaviour
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)
```
### 2.4 PlayerStats 完整实现
补充 Phase 1 未实现的部分:
| 功能 | 实现要点 |
|------|---------|
| `SoulPower` | 击杀/命中增加;使用魂技能消耗;上限 100 |
| `SpiritPower` | 时间自动回复(`SpiritRegenRate`/秒);使用魄技能消耗 |
| `SpringCharges` | 击杀积分达阈值增加;存档点恢复满值 |
| `Geo` | 击杀掉落收集;购买消耗 |
| `UnlockedAbilities` | `HashSet` 持久存储;`HasAbility` 供状态检查 |
> **⚠️ `AddSoul` vs `AddSoulPower` 命名不一致**:`PlayerStats.cs` API(架构 05 §4)定义 `AddSoulPower(int amount)`,但 `ParrySystem.HandleSuccessfulParry`(架构 06 §8)调用 `_playerStats.AddSoul(soulGain)`。**以架构 06 §8 为准**,即 `AddSoul` 为正确方法名(可能是 SO 配置文件的旧名称);实现 `PlayerStats.cs` 时须确保两个调用名称一致。
**PlayerStatsSO 完整定义**(按 `05_PlayerModule.md §16`):
```csharp
// Assets/Scripts/Player/PlayerStatsSO.cs
[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 量(半格),架构 05 §16
public int SpringKillThreshold = 4; // ⚠️ 增加 1 次灵泉所需击杀点数,架构 05 §16
[Header("Invincibility")]
public float InvincibilityDuration = 0.6f;
[Header("Geo")]
public int InitialGeo = 0;
}
```
**PlayerMovementConfigSO 完整定义**(按 `05_PlayerModule.md §15`,含所有 Wall 参数):
```csharp
// Assets/Scripts/Player/PlayerMovementConfigSO.cs
[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; // ⚠️ 空中冲刺次数(AerialDashState 消耗此计数)
[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; // ⚠️ 允许的最大垂直高度增益(防无限蹬墙),架构 05 §15
public float WallGrabReleaseDelay = 0.08f; // ⚠️ 离开墙面后的延迟释放(避免误判),架构 05 §15
[Header("Wall — WallJump")]
public float WallJumpBackForceX = 14f; // ⚠️ 背墙跳(同向)水平速度,架构 05 §15
public float WallJumpAwayForceX = 10f; // ⚠️ 对墙跳(反向)水平速度,架构 05 §15
public float WallJumpAwayForceY = 18f; // ⚠️ 对墙跳垂直速度,架构 05 §15
public float WallJumpInputLockDuration = 0.15f; // ⚠️ 对墙跳后短暂锁定水平输入,架构 05 §15
[Header("General")]
public float DefaultGravityScale = 3f;
}
```
### 2.5 PlayerWallDetector + PlayerController IPoiseSource 扩展
**PlayerWallDetector**(独立组件,架构 `05_PlayerModule.md §13`):
```csharp
// Assets/Scripts/Player/PlayerWallDetector.cs
// ⚠️ 独立组件,不嵌入 PlayerMovement(架构 05 §13)
// WallSlideState 通过 _owner.WallDetector.IsTouchingWall 访问
[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;
}
}
```
**PlayerController 补充**(架构 `05_PlayerModule.md §13` + `06_CombatModule §13`):
```csharp
// PlayerController.cs 追加以下内容(与 Phase 1 骨架 merge)
// ⚠️ 须实现 IPoiseSource 接口(架构 06_CombatModule §13)
public partial class PlayerController : MonoBehaviour, IPoiseSource
{
[Header("Wall Detection")]
[SerializeField] private PlayerWallDetector _wallDetector; // ⚠️ 独立组件引用(架构 05 §13)
public PlayerWallDetector WallDetector => _wallDetector; // WallSlideState / WallJumpState 使用
// ── IPoiseSource 实现(架构 06_CombatModule §13)────────────────────────
private PoiseWindowConfig _currentPoiseWindow;
private AnimancerState _activeState; // Animancer 提供当前动画状态
public void SetPoiseWindow(PoiseWindowConfig window) // 由 AttackState / SkillState.OnStateEnter() 调用
=> _currentPoiseWindow = window;
public void ClearPoiseWindow() // 由 OnStateExit() 调用
=> _currentPoiseWindow = default;
/// IPoiseSource 实现:每帧查询当前霸体等级(攻击动画期间)
public PoiseLevel GetCurrentPoiseLevel()
{
if (_currentPoiseWindow.Level == PoiseLevel.None) return PoiseLevel.None;
if (_activeState == null) return PoiseLevel.None;
float t = _activeState.NormalizedTime % 1f;
bool inWindow = t >= _currentPoiseWindow.NormalizedStart
&& t <= _currentPoiseWindow.NormalizedEnd;
return inWindow ? _currentPoiseWindow.Level : PoiseLevel.None;
}
}
// 资产:Player Prefab Inspector 需挂载 PlayerWallDetector 组件并赋值到 _wallDetector
```
---
## 3. Week 6:护盾 + 弹反 + 战斗深化 ✅ 完成(2026-05-10)
**参考文档**:`06_CombatModule.md §8-13`、`20_ShieldModule.md`
> **实现摘要**(与原计划的实际偏差):
> - `ShieldConfigSO` 放在 `BaseGames.Combat` 命名空间(非 `BaseGames.Player.Shield`),统一管理
> - `ShieldComponent` 新增 `FullRecharge()` / `OnParrySuccess()` / `_brokenPenaltyTimer` / 可选 `ShieldConfigSO` 覆盖
> - `ParrySystem` 使用 **C# 事件**(`OnParryActivated` / `OnParryConsumed`)替代 `ForceState(PlayerStateType.Parry)` 强制转换,避免 Parry 程序集引用 Player.States 程序集
> - `ParryInfo` 结构体仅含 `IsPerfect` + `SoulGained`(去掉 `DamageInfo OriginalDamage` 字段,保持 Parry 不引用 Combat 的程序集约束)
> - `PlayerController.Awake()` 新增订阅 `ParrySystem.OnParryActivated`(转 ParryState)和 `OnParryConsumed`(发放灵力+恢复护盾),`OnDestroy()` 解订阅
> - `BaseGames.Parry.asmdef` 新增引用:`BaseGames.Input`、`BaseGames.Core.Events`
**完成文件清单**:
```
新建:Assets/Scripts/Combat/ShieldConfigSO.cs
新建:Assets/Scripts/Combat/PoiseWindowConfig.cs
新建:Assets/Scripts/Parry/ParryConfigSO.cs
新建:Assets/Scripts/Parry/ParryInfo.cs
新建:Assets/Scripts/Parry/ParryInfoEventChannelSO.cs
新建:Assets/Scripts/Enemies/EnemyPoiseComponent.cs
修改:Assets/Scripts/Combat/ShieldComponent.cs(FullRecharge/OnParrySuccess/破碎惩罚/ShieldConfigSO)
修改:Assets/Scripts/Parry/ParrySystem.cs(5 阶段状态机完整重写)
修改:Assets/Scripts/Player/States/PlayerController.cs(订阅 ParrySystem 事件)
修改:Assets/Scripts/Parry/BaseGames.Parry.asmdef(新增 Input + Core.Events 引用)
```
### 3.0 护盾数据层 SO 与接口
```csharp
// Assets/Scripts/Player/Shield/ShieldConfigSO.cs
// ⚠️ 按 Architecture 20_ShieldModule §3 实现
[CreateAssetMenu(menuName = "Player/ShieldConfig")]
public class ShieldConfigSO : ScriptableObject
{
[Header("基础参数")]
[Min(1)]
public int MaxShieldHP = 60;
[Range(0f, 1f)]
public float DamageAbsorptionRatio = 1.0f;
[Header("恢复")]
[Min(0f)]
public float RechargeDelay = 2.5f; // 最后受击后静默时间(秒)
[Min(0f)]
public float RechargeRate = 20f; // 每秒恢复量
[Header("破碎惩罚")]
[Min(0f)]
public float BrokenPenaltyDuration = 3.0f; // 护盾破碎后无法恢复的时间
[Header("存档点全量恢复")]
public bool FullRechargeOnSavePoint = true;
[Header("弹反加成(P1)")]
[Range(0f, 1f)]
public float ParryRestoreRatio = 0.3f; // 成功格挡时恢复护盾耐久比例
}
// Assets/Scripts/Player/Shield/IShieldable.cs
// ⚠️ 按 Architecture 20_ShieldModule §5 实现
namespace BaseGames.Player.Shield
{
public interface IShieldable
{
bool HasShield { get; }
int CurrentShieldHP { get; }
int MaxShieldHP { get; }
int AbsorbDamage(int incomingDamage); // ⚠️ 返回穿透伤害剩余量:0 = 全部被吸收,>0 = 穿透量(架构 20_ShieldModule §5)
void FullRecharge();
void OnParrySuccess();
}
}
```
### 3.1 ShieldComponent
按 `20_ShieldModule.md` 实现完整护盾系统:
```csharp
// 关键实现点
public class ShieldComponent : MonoBehaviour, IShieldable
{
public bool HasShield => _currentShieldHP > 0 && !_isBroken; // ⚠️ 字段名 _currentShieldHP(架构 20_ShieldModule §4)
public int AbsorbDamage(int incomingDamage)
{
_timeSinceLastHit = 0f; // ⚠️ 重置静默计时(架构 20_ShieldModule §4)
int absorbed = Mathf.RoundToInt(incomingDamage * _config.DamageAbsorptionRatio);
int passThrough = incomingDamage - absorbed;
_currentShieldHP -= absorbed;
if (_currentShieldHP <= 0)
{
// 护盾破碎:多余伤害穿透(⚠️ 架构 20_ShieldModule §4)
passThrough += Mathf.Abs(_currentShieldHP);
_currentShieldHP = 0;
_isBroken = true;
_brokenTimer = 0f;
_onShieldBroken.Raise(); // ⚠️ Raise(),非 RaiseEvent()(架构 02 §2)
}
_onShieldHPChanged.Raise(_currentShieldHP); // ⚠️ 更新 ShieldBarUI(架构 20_ShieldModule §4)
return passThrough;
}
// ⚠️ 存档加载时恢复护盾状态,由 PlayerController.LoadFromSaveData() 调用(架构 20_ShieldModule §4)
public void SetShieldHP(int hp, bool isBroken)
{
_currentShieldHP = Mathf.Clamp(hp, 0, _config.MaxShieldHP);
_isBroken = isBroken;
_brokenTimer = 0f;
_timeSinceLastHit = 0f;
}
private void Update()
{
// 破碎冷却计时(递增到 BrokenPenaltyDuration)
if (_isBroken)
{
_brokenTimer += Time.deltaTime;
if (_brokenTimer >= _config.BrokenPenaltyDuration) // ⚠️ BrokenPenaltyDuration,非 BreakPenaltyDuration
{
_isBroken = false;
_brokenTimer = 0f;
}
return;
}
// 延迟恢复(_timeSinceLastHit 累计受击后静默时间,架构 20_ShieldModule §4)
if (_currentShieldHP < _config.MaxShieldHP)
{
_timeSinceLastHit += Time.deltaTime;
if (_timeSinceLastHit >= _config.RechargeDelay)
_currentShieldHP = Mathf.Min(
_config.MaxShieldHP,
_currentShieldHP + Mathf.RoundToInt(_config.RechargeRate * Time.deltaTime)
);
}
}
}
```
### 3.2 ParrySystem
按 `06_CombatModule.md §8` 实现 **5 阶段状态机**(Inactive → Startup → Active → EndLag/CounterWindow → Inactive):
```
Inactive → [按弹反键] → Startup(0.05s) → Active(0.28s) → [命中可弹反攻击] → ParrySuccess
↓(Startup/Active 超时) ↓
EndLag(0.10s) → Inactive CounterWindow(0.5s) → Inactive
```
```csharp
// Assets/Scripts/Parry/ParrySystem.cs
// ⚠️ 5 阶段状态机;事件频道为 ParryInfoEventChannelSO(非 VoidEventChannelSO),架构 06 §8
/// 弹反成功时通过 OnParrySuccess 频道广播的载荷(架构 06 §8)
public struct ParryInfo
{
public DamageInfo OriginalDamage; // 被弹反的原始攻击信息
public bool IsPerfect; // 是否为完美弹反
public Projectile HitProjectile; // 若弹反了投射物,此字段非 null
public DamageSourceSO ReflectDamageSource; // 反击伤害来源(ParryCounterMultiplier 倍率已应用)
}
/// 弹反阶段枚举(架构 06 §8)
public enum ParryPhase { Inactive, Startup, Active, EndLag, CounterWindow }
public class ParrySystem : MonoBehaviour
{
[SerializeField] private InputReaderSO _inputReader;
[SerializeField] private PlayerController _controller;
[SerializeField] private ParryConfigSO _config;
[SerializeField] private HurtBox _playerHurtBox;
[SerializeField] private PlayerStats _playerStats;
[Header("Event Channels - Raise")]
// ⚠️ ParryInfoEventChannelSO 携带 ParryInfo 载荷(非 VoidEventChannelSO),架构 06 §8
[SerializeField] private ParryInfoEventChannelSO _onParrySuccess;
// ── 运行时状态 ────────────────────────────────────────────────
private ParryPhase _phase = ParryPhase.Inactive;
private float _phaseTimer;
private bool _isInCounterWindow;
public ParryPhase CurrentPhase => _phase;
public bool IsInCounterWindow => _isInCounterWindow;
private void OnEnable() => _inputReader.ParryEvent += TryActivateParry;
private void OnDisable() => _inputReader.ParryEvent -= TryActivateParry;
private void Update()
{
if (_phase == ParryPhase.Inactive) return;
// ⚠️ 使用 unscaledDeltaTime — 子弹时间期间计时不受影响(架构 06 §8)
_phaseTimer -= Time.unscaledDeltaTime;
if (_phaseTimer <= 0f) AdvancePhase(success: false);
}
// ── 外部调用入口 ──────────────────────────────────────────────
/// 玩家按下弹反键时由 InputReader 事件触发
private void TryActivateParry()
{
if (_phase != ParryPhase.Inactive) return;
if (!_playerStats.HasAbility(AbilityType.Parry)) return;
EnterPhase(ParryPhase.Startup, _config.StartupDuration);
_controller.ForceState(PlayerStateType.Parry);
}
///
/// HurtBox.ReceiveDamage 在 Active 阶段调用此方法判断是否弹反成功。
/// 返回 true 表示弹反成功,调用方应跳过普通受击流程。(架构 06 §8)
///
public bool TryParryDamage(DamageInfo info)
{
if (_phase != ParryPhase.Active) return false;
if (info.Flags.HasFlag(DamageFlags.Unblockable)) return false;
if (!info.Flags.HasFlag(DamageFlags.CanBeParried)) return false;
if (info.Flags.HasFlag(DamageFlags.PerfectParryOnly) && !IsInPerfectWindow()) return false;
bool isPerfect = IsInPerfectWindow();
HandleSuccessfulParry(info, isPerfect);
return true;
}
// ── 私有实现 ──────────────────────────────────────────────────
private bool IsInPerfectWindow()
{
// Active 阶段开始后的前 PerfectParryThreshold 秒内命中为完美弹反
float activeElapsed = _config.WindowDuration - _phaseTimer;
return activeElapsed <= _config.PerfectParryThreshold;
}
/// 弹反成功的统一处理入口(TryParryDamage 和 ParryableProjectile 均调用此处)
public void HandleSuccessfulParry(DamageInfo originalDamage, bool isPerfect,
Projectile hitProjectile = null)
{
// ⚠️ _playerStats.AddSoul()(架构 06 §8 使用 AddSoul,非 AddSoulPower)
int soulGain = isPerfect
? _config.SoulGainOnParry + _config.SoulGainOnPerfect
: _config.SoulGainOnParry;
_playerStats.AddSoul(soulGain);
// 护盾恢复
if (_controller.TryGetComponent(out var shield))
shield.OnParrySuccess();
// 子弹时间(仅完美弹反)
if (isPerfect)
StartCoroutine(ApplyBulletTime());
// ⚠️ _onParrySuccess 为 ParryInfoEventChannelSO,携带 ParryInfo 载荷
var parryInfo = new ParryInfo
{
OriginalDamage = originalDamage,
IsPerfect = isPerfect,
HitProjectile = hitProjectile,
ReflectDamageSource = null, // 实际项目中设为预配置的 ReflectDamageSourceSO
};
_onParrySuccess.Raise(parryInfo);
// 进入 CounterWindow
EnterPhase(ParryPhase.CounterWindow, _config.CounterWindowDuration);
_isInCounterWindow = true;
}
private void AdvancePhase(bool success)
{
switch (_phase)
{
case ParryPhase.Startup:
EnterPhase(ParryPhase.Active, _config.WindowDuration);
break;
case ParryPhase.Active:
EnterPhase(ParryPhase.EndLag, _config.EndlagDuration);
break;
case ParryPhase.EndLag:
case ParryPhase.CounterWindow:
EnterPhase(ParryPhase.Inactive, 0f);
_isInCounterWindow = false;
break;
}
}
private void EnterPhase(ParryPhase phase, float duration)
{
_phase = phase;
_phaseTimer = duration;
}
private System.Collections.IEnumerator ApplyBulletTime()
{
Time.timeScale = _config.BulletTimeScale;
yield return new WaitForSecondsRealtime(_config.BulletTimeDuration);
Time.timeScale = 1f;
}
}
```
**ParryConfigSO**(按 `06_CombatModule.md §9` 完整字段):
```csharp
// Assets/ScriptableObjects/Combat/ParryConfig.asset
[CreateAssetMenu(menuName = "Combat/ParryConfig")]
public class ParryConfigSO : ScriptableObject
{
[Header("阶段时长")]
public float StartupDuration = 0.05f; // ⚠️ 前摇(Startup)时长(秒),架构 06 §9
public float WindowDuration = 0.28f; // ⚠️ 弹反有效窗口(Active)时长(秒)
public float EndlagDuration = 0.10f; // ⚠️ 后摇(EndLag)时长(秒)
public float CounterWindowDuration = 0.5f; // ⚠️ 弹反成功后反击窗口时长(秒)
[Header("完美弹反判定")]
public float PerfectParryThreshold = 0.05f; // ⚠️ Active 阶段开始后的完美弹反窗口(秒)
[Header("冷却")]
public float ParryCooldown = 0.3f; // 弹反动作冷却(秒)
[Header("灵力奖励")]
public int SoulGainOnParry = 33; // ⚠️ 普通弹反获得灵力(架构 06 §9)
public int SoulGainOnPerfect = 50; // ⚠️ 完美弹反额外获得灵力(累计 +83)
[Header("反击伤害")]
public float ParryCounterMultiplier = 3.0f; // ⚠️ 弹反反击伤害倍率
[Header("子弹时间(完美弹反)")]
public float BulletTimeScale = 0.25f; // ⚠️ 完美弹反触发时的时间缩放
public float BulletTimeDuration = 0.2f; // ⚠️ 子弹时间持续时长(秒,实际时间)
[Header("硬直")]
public float StaggerDuration = 0.8f; // ⚠️ 被弹反敌人的受击硬直时长(秒)
}
```
`ParryState` 实现要点:
- 进入:`EnterPhase(Startup)` 已在 `TryActivateParry()` 完成,State 播放弹反预备动画
- Active 阶段:HurtBox 调用 `TryParryDamage(info)` — 5 阶段自动推进
- 成功:`HandleSuccessfulParry()` → CounterWindow → 可切换到 AttackState(反击)
- 超时未命中:EndLag → Inactive → 返回 `IdleState`
### 3.3 PoiseSystem(霸体)
> **架构核心机制**(`06_CombatModule.md §13`):霸体使用**等级比较**,而非数值耐久条。
> - 攻击方:`DamageInfo.Break`(`BreakLevel` 枚举)——此次攻击能打断多高的霸体
> - 承受方:`IPoiseSource.GetCurrentPoiseLevel()`(`PoiseLevel` 枚举)——当前帧拥有多高的霸体
> - 判定公式:`(int)info.Break >= (int)currentPoise` → 打断成功
> - **玩家和敌人均可拥有霸体**(玩家在攻击/技能动画的特定帧,敌人在超甲状态)
```csharp
// 实体实现此接口以提供当前霸体等级(玩家侧由 PlayerController 实现,敌人侧由 EnemyPoiseComponent 实现)
public interface IPoiseSource
{
/// 返回当前帧的霸体等级(受时间窗口/状态机控制)
PoiseLevel GetCurrentPoiseLevel();
}
// 地址: Assets/Scripts/Combat/PoiseWindowConfig.cs
// 描述某个状态/技能在特定动画时间段内的霸体等级
[System.Serializable]
public struct PoiseWindowConfig
{
public PoiseLevel Level; // 此窗口期间的霸体等级
public float NormalizedStart; // 动画归一化时间起点(0~1)
public float NormalizedEnd; // 动画归一化时间终点(0~1)
}
// 地址: Assets/Scripts/Enemies/EnemyPoiseComponent.cs
// 敌人霸体组件:Awake() 自动注入到同节点 HurtBox(架构 06 §13)
// ⚠️ [RequireComponent(typeof(EnemyBase))](架构 06 §13)
[RequireComponent(typeof(EnemyBase))]
public class EnemyPoiseComponent : MonoBehaviour, IPoiseSource
{
[SerializeField] private PoiseLevel _defaultPoiseLevel = PoiseLevel.None;
private PoiseLevel _currentPoiseLevel;
private void Awake()
{
_currentPoiseLevel = _defaultPoiseLevel;
// ⚠️ Awake 自动注入到同节点 HurtBox(架构 06 §13)
if (TryGetComponent(out var hurtBox))
hurtBox.SetPoiseSource(this);
}
// 超甲状态由 EnemyBase 或 BehaviorDesigner 任务直接调用
public void SetPoiseLevel(PoiseLevel level) => _currentPoiseLevel = level;
public void ResetPoiseLevel() => _currentPoiseLevel = _defaultPoiseLevel;
// IPoiseSource 实现
public PoiseLevel GetCurrentPoiseLevel() => _currentPoiseLevel;
}
```
**HurtBox 霸体判定逻辑**(已在 `06_CombatModule §5` HurtBox.ReceiveDamage 中实现):
```csharp
// 3. 霸体检查(BreakLevel vs PoiseLevel)
if (!info.Flags.HasFlag(DamageFlags.ForceBreak) && _poiseSource != null)
{
PoiseLevel curPoise = _poiseSource.GetCurrentPoiseLevel();
if (curPoise != PoiseLevel.None && curPoise == PoiseLevel.Unbreakable) return; // 完全无法打断
if ((int)info.Break < (int)curPoise)
{
// 打断等级不足:触发受击 VFX 但跳过伤害
_onHitConfirmed.Raise(new HitInfo { DamageInfo = info, HitPoint = transform.position });
return;
}
}
```
**PoiseOverrideTableSO**(精细控制资产,按 `06_CombatModule.md §13`):
```csharp
// Assets/ScriptableObjects/Combat/PoiseOverrideTable.asset
// ⚠️ 用于特殊规则:某个特定 sourceId 对某类目标的打断等级覆盖(架构 06 §13)
[CreateAssetMenu(menuName = "Combat/PoiseOverrideTable")]
public class PoiseOverrideTableSO : ScriptableObject
{
[Serializable]
public struct OverrideEntry
{
public string SourceId; // DamageSourceSO.sourceId(攻击来源)
public string TargetTag; // 目标 GameObject Tag(如 "Boss")
public BreakLevel OverrideBreak; // 覆盖使用的打断等级(忽略 DamageInfo.Break)
}
public List Entries;
public bool TryGetOverride(string sourceId, string targetTag, out BreakLevel result)
{
foreach (var e in Entries)
{
if (e.SourceId == sourceId && e.TargetTag == targetTag)
{ result = e.OverrideBreak; return true; }
}
result = default;
return false;
}
}
```
### 3.4 完整连击链
| 输入 | 状态 | 说明 |
|------|------|------|
| 攻击 × 3(地面) | AttackState (combo 0/1/2) | 3段地面连击链 |
| 攻击(空中) | AirAttackState | 1段空中基础攻击 |
| 下 + 攻击(空中) | DownAttackState | 下劈(踩踏判定)|
| 上 + 攻击 | UpAttackState | 上劈(向上击飞)|
| 形态技能键 | SoulSkillState | 消耗灵力的魂技能(Phase 2)|
### 3.5 ClashResolver — 拼刀系统
**参考文档**:`06_CombatModule.md §15`
> 当玩家与敌人的近战 HitBox 同时激活并相互重叠时触发拼刀:双方武器碰撞,均不扣血,各自弹开,播放拼刀特效与音效。仅携带 `CanClash` 标记(`DamageFlags.CanClash`)的 HitBox 才参与拼刀检测。
```csharp
// Assets/Scripts/Combat/ClashResolver.cs
// ⚠️ 单例服务,常驻 Persistent 场景(由 GameManager 持有),架构 06 §15
[DefaultExecutionOrder(-500)]
public class ClashResolver : MonoBehaviour
{
public static ClashResolver Instance { get; private set; }
[SerializeField] VoidEventChannelSO _onNailClash;
[SerializeField] ClashConfigSO _config;
// 防止同一碰撞在同帧被双方 HitBox 各触发一次(去重)
readonly HashSet _processedThisFrame = new();
void Awake()
{
if (Instance != null) { Destroy(gameObject); return; }
Instance = this;
}
void LateUpdate() => _processedThisFrame.Clear();
public void ResolveClash(HitBox playerHitBox, HitBox enemyHitBox)
{
int key = playerHitBox.GetInstanceID() ^ enemyHitBox.GetInstanceID();
if (!_processedThisFrame.Add(key)) return; // 本帧已处理,去重
// 1. 拼刀 HitStop
HitStopManager.FreezeFrames(_config.ClashFreezeFrames);
// 2. 双方弹开
ApplyClashKnockback(playerHitBox.OwnerRigidbody, enemyHitBox.transform.position);
ApplyClashKnockback(enemyHitBox.OwnerRigidbody, playerHitBox.transform.position);
// 3. 广播事件(VFX / Audio / CameraImpulse 订阅)
_onNailClash?.Raise();
}
void ApplyClashKnockback(Rigidbody2D rb, Vector2 oppositePos)
{
if (rb == null) return;
var dir = ((Vector2)rb.transform.position - oppositePos).normalized;
rb.AddForce(dir * _config.ClashKnockbackForce, ForceMode2D.Impulse);
}
}
// Assets/ScriptableObjects/Config/Combat/ClashConfig.asset
[CreateAssetMenu(menuName = "Combat/ClashConfig")]
public class ClashConfigSO : ScriptableObject
{
[Header("HitStop")]
public int ClashFreezeFrames = 1; // 拼刀冻帧(比命中的 2 帧更短)
[Header("弹开")]
public float ClashKnockbackForce = 6.0f; // 拼刀弹开力度
[Header("Camera Impulse")]
public float ClashImpulseStrength = 0.3f; // Cinemachine Impulse 强度(轻微)
}
```
**拼刀判定规则**:
| 情况 | 结果 |
|------|------|
| 双方 HitBox 均激活 + 均 `CanClash = true` | 触发拼刀,双方弹开,不扣血 |
| 仅玩家 HitBox 激活,敌人 HitBox 未激活 | 正常命中敌人 HurtBox |
| 敌人攻击标记为 `CanClash = false`(如 Boss 重击)| 不触发拼刀,正常伤害玩家 |
| 同帧多次碰撞 | `HashSet` 去重,每对 HitBox 每帧只触发一次 |
### 3.6 BreakConditionSO + IBreakable + BreakableProp — 机关/障碍物交互
**参考文档**:`06_CombatModule.md §14`
> 游戏中某些机关(晶石、封印门、毒液容器……)**只能被特定类别/标签的攻击击碎**。这类物体实现 `IBreakable` 而非 `IDamageable`,`HitBox.OnTriggerEnter2D` 会把 `DamageInfo` 传给 `IBreakable.TryInteract(info)` 由物体本身决定是否响应。
```csharp
// Assets/Scripts/Combat/BreakConditionSO.cs
// ⚠️ 数据驱动机关响应条件(架构 06 §14)
[CreateAssetMenu(menuName = "Combat/BreakCondition")]
public class BreakConditionSO : ScriptableObject
{
[Header("Category 白名单(空 = 不限类别)")]
public DamageCategory[] AllowedCategories;
[Header("Tags 必须位(AND 逻辑)")]
public DamageTags RequiredTags;
[Header("Tags 禁止位(AND NOT 逻辑)")]
public DamageTags ForbiddenTags;
[Header("DamageType 白名单(空 = 不限元素)")]
public DamageType[] AllowedTypes;
[Header("技能 ID 白名单(空 = 不限技能)")]
public string[] AllowedSkillIds;
public bool Evaluate(in DamageInfo info)
{
if (AllowedCategories is { Length: > 0 }
&& System.Array.IndexOf(AllowedCategories, info.Category) < 0) return false;
if (RequiredTags != DamageTags.None && (info.Tags & RequiredTags) != RequiredTags) return false;
if (ForbiddenTags != DamageTags.None && (info.Tags & ForbiddenTags) != 0) return false;
if (AllowedTypes is { Length: > 0 }
&& System.Array.IndexOf(AllowedTypes, info.Type) < 0) return false;
if (AllowedSkillIds is { Length: > 0 }
&& System.Array.IndexOf(AllowedSkillIds, info.SkillId) < 0) return false;
return true;
}
}
// Assets/Scripts/Combat/IBreakable.cs
// ⚠️ 机关、障碍物实现此接口(非 IDamageable),架构 06 §14
public interface IBreakable
{
bool TryInteract(in DamageInfo info);
}
// Assets/Scripts/Combat/BreakableProp.cs
// ⚠️ 通用可破坏/可交互物体(挂在机关 Prefab 上),架构 06 §14
public class BreakableProp : MonoBehaviour, IBreakable
{
[SerializeField] private BreakConditionSO _condition;
[SerializeField] private int _maxHp = 1; // 默认单次即碎
[SerializeField] private VoidEventChannelSO _onBroken; // 全局广播(开门、切换场景等)
[SerializeField] private FeedbackPresetSO _hitFeedback;
[SerializeField] private FeedbackPresetSO _breakFeedback;
[SerializeField] private FeedbackPresetSO _rejectFeedback; // 提示玩家"需要特定能力"
private int _currentHp;
private void Awake() => _currentHp = _maxHp;
public bool TryInteract(in DamageInfo info)
{
if (!_condition.Evaluate(info))
{
_rejectFeedback?.Play(transform.position);
return false;
}
_currentHp -= info.FinalDamage > 0 ? info.FinalDamage : 1;
_hitFeedback?.Play(transform.position);
if (_currentHp <= 0)
{
_breakFeedback?.Play(transform.position);
_onBroken?.Raise();
gameObject.SetActive(false);
}
return true;
}
}
```
**机关配置示例**:
| 机关 | AllowedCategories | RequiredTags | AllowedTypes | 说明 |
|------|-------------------|--------------|--------------|------|
| 毒液晶石 | — | `ElementPoison` | `Poison` | 任何毒属性攻击 |
| 封印门 | `SoulSkill` | `SkyFormOnly` | — | 仅天形魂技能 |
| 弱点晶球 | `NormalAttack`, `SoulSkill` | `AfterParry` | — | 弹反后才能击碎 |
| 普通木箱 | `NormalAttack`, `SoulSkill`, `SpiritSkill` | `MeleeHit` | — | 任意近战 |
---
## 4. Week 7:状态效果 + 动画事件 + 完整 VFX ✅ 代码完成(2026-05-13,VFX 资产填充仅需 Unity 编辑器)
**当前状态**:✅ 完成(2026-05-11)
**参考文档**:`06_CombatModule.md §10-11`、`16_AnimationModule.md`、`18_VFXFeedbackModule.md`
**已实施内容**:
1. ✅ **动画事件骨架**:`AnimationEventType.cs`(**21 种事件类型**)、`IAnimationEventHandler.cs`(接口)、`AnimationEventConfigSO.cs`(SO 资产 + 时间线编辑支持)、`AnimationEventBinder.cs`(Animancer SetCallback 注入)
2. ✅ **状态效果系统**:`StatusEffectType.cs`(枚举,含 Stagger 扩展项)、`StatusEffect.cs`(抽象基类,非 StatusEffectBase)、`FireEffect.cs`(DoT,不可叠加,**3s/0.5s/1dmg True**,含 `_FireGlow` Shader)、`PoisonEffect.cs`(DoT,3层叠加,**5s/1s/StackCount dmg True**,含 `_PoisonGlow` Shader)、`StaggerEffect.cs`(硬直,架构扩展)、`StatusEffectEventChannelSO.cs`(SO 事件频道)
3. ✅ **StatusEffectManager 完整重写**:双结构(List + Dictionary)、O(1) 查找、`ApplyDirectDamage(DamageInfo)` via IDamageable、`SetShaderParam(string,float)` via MaterialPropertyBlock、`CleanseEffect`/`CleanseAll`、`HasEffect` 查询、SO 事件广播、SpriteRenderer + MaterialPropertyBlock Awake 初始化
4. ✅ **动画事件接线**:`PlayerAnimationEvents.cs`(含 HitBox.Id 按名精确激活、ParrySystem.OpenParryWindow/CloseParryWindow、CancelWindowOpen、IFrame、Feedback)、`EnemyAnimationEvents.cs`(含 SpawnProjectile、SetRoaring、TriggerPhaseTwo、OnAnimationComplete)
5. ✅ **编辑器工具**:`EventConfigEditor.cs`(时间线色块预览 + 归一化时间验证 + Clip 长度漂移检测 + 排序按钮)
6. ✅ **依赖修改**:`BaseGames.Animation.asmdef` 添加 Combat/Parry/Feedback/Player/Enemies 引用;`BaseGames.Combat.StatusEffects.asmdef` 添加 Core.Events 引用;`BaseGames.Editor.asmdef` 添加 Animation 引用
7. ✅ **HitBox.Id** 属性新增(`[SerializeField] private string _id`)
8. ✅ **EnemyBase** 虚方法:`SpawnProjectile`、`TriggerPhaseTwo`、`OnAnimationComplete`、`SetRoaring`
**注**:FootstepSystem(计划 §4.1.2)为 Footstep 事件留了占位(不调用任何实现),待音频模块阶段(`FootstepCatalogSO` + Addressables 异步加载)完整实现。
**架构差异注记(已验证 2026-05-11)**:
- **ParrySystem 方法名**:架构 24 §5 代码示例写的是 `OpenWindow()`/`CloseWindow()`,实际 ParrySystem API(06 §8)为 `OpenParryWindow()`/`CloseParryWindow()`,代码已按正确 API 实现
- **StatusEffectType 枚举**:架构 §11 定义 4 值 {Fire, Poison, Freeze, Stun},实现额外添加 `Stagger` 作为硬直扩展
- **StatusEffectEventChannelSO**:架构期望广播 `StatusEffectType` 直接值,实现扩展为包含 StackCount/RemainingDuration 的 `StatusEffectEvent` struct(UI 更方便)
- **EnemyAnimationEvents RoarStart/RoarEnd**:架构展示 `_enemy.Blackboard.SetVariableValue()` 直接调用,实现抽象为 `EnemyBase.SetRoaring(bool)` 虚方法(避免 Animation 程序集依赖 BehaviorDesigner)
**实施优先级**(建议顺序):
1. **动画事件骨架**:`AnimationEventType` 枚举 → `AnimationEventConfigSO` → `AnimationEventBinder` → `IAnimationEventHandler`
2. **状态效果系统**:`StatusEffect` 抽象基类 → `FireEffect` / `PoisonEffect` / `StaggerEffect` → `StatusEffectManager`
3. **HurtBox 集成**:步骤 8 `_statusEffectable.ApplyStatusEffect(info.Type)` 连接 `StatusEffectManager`
4. **动画事件接线**:`PlayerAnimationEvents` + `EnemyAnimationEvents`(含 HitBox 激活时机改为 AnimEvent 驱动)
5. **编辑器工具**:`EventConfigEditor`(时间轴可视化 + Clip 漂移检测)
> **汇编约束**:`BaseGames.Animation` 程序集需引用 `BaseGames.Combat`(PlayerAnimationEvents 访问 HitBox/HurtBox)和 `BaseGames.Parry`(AnimEvent 驱动 ParrySystem.OpenParryWindow/CloseParryWindow;注意架构文档示例写的是 OpenWindow/CloseWindow,以实际 API 为准)。
### 4.1 AnimationEventType 枚举 + AnimationEventBinder
**参考文档**:`24_AnimEventModule.md §2-4`
> **⚠️ 架构约束**:
> - **禁止**使用 Unity 传统 `AnimationEvent`(字符串反射)
> - **必须**使用 Animancer `ClipTransition.Events.SetCallback(normalizedTime, callback)`
> - 所有时机值由 `AnimationEventConfigSO` 管理(设计师可在 Inspector 调整)
**文件**:
```
Assets/Scripts/Animation/AnimationEventType.cs ← 枚举(与架构完全一致)
Assets/Scripts/Animation/AnimationEventConfigSO.cs ← SO 配置(每个 Clip 的事件时机)
Assets/Scripts/Animation/AnimationEventBinder.cs ← 静态工具类(注册回调)
Assets/Scripts/Animation/IAnimationEventHandler.cs ← 接口(⚠️ IAnimationEventHandler,架构 24 §4)
Assets/Scripts/Animation/PlayerAnimationEvents.cs ← 玩家侧实现
Assets/Scripts/Animation/EnemyAnimationEvents.cs ← 敌人侧实现
```
```csharp
// ✅ 正确 — 完整枚举定义(对应架构 24_AnimEventModule §2)
namespace BaseGames.Animation
{
public enum AnimationEventType
{
// 战斗 - HitBox
EnableHitBox, // 开启 HitBox(payload 字段携带 HitBox 编号或名称)
DisableHitBox, // 关闭 HitBox
AttackImpact, // 攻击命中反馈(音效/特效时机)
// 战斗 - 弹反窗口(由 ParrySystem 监听)
EnableParryWindow, // 开启可弹反时间窗(ParrySystem.OpenWindow())
DisableParryWindow, // 关闭可弹反时间窗(ParrySystem.CloseWindow())
// 玩家
EnableIFrame, // 开启无敌帧(翻滚/受击恢复)
DisableIFrame, // 关闭无敌帧
Footstep, // 脚步落地(⚠️ 合并 FootstepLeft/Right,由 payload 区分面,架构 24 §2)
LandImpact, // 落地震动(落地音效/特效)
JumpLaunch, // 起跳(跳跃音效/特效)
EnableInteract, // 动画帧触发互动(如 NPC 握手时机)
// 反馈派发
TriggerFeedback, // 触发 MMF_Player 预设(payload = Feedback 名称)
PlaySFX, // 播放音效(payload = AudioEventSO Address key)
// 敌人
SpawnProjectile, // 生成弹幕(⚠️ SpawnProjectile,非 SummonProjectile,架构 24 §2)
RoarStart, // 怒吼开始(AI 警觉)
RoarEnd, // 怒吼结束
PhaseTwoStart, // 二阶段开始(Boss 过渡)
// 通用
CancelWindowOpen, // 可取消帧窗口开始(连击/取消窗口)
CancelWindowClose, // 可取消帧窗口结束
StateTransition, // 动画驱动状态机转移(payload = 目标状态名)
AnimationComplete, // 动画播完(一次性动画通知)
}
}
```
```csharp
// AnimationEventConfigSO.cs — 每个 ClipTransition 的事件时机配置(设计师可在 Inspector 调整)
namespace BaseGames.Animation
{
[CreateAssetMenu(menuName = "Animation/EventConfig")]
public class AnimationEventConfigSO : ScriptableObject
{
[Serializable]
public struct EventEntry
{
public AnimationEventType eventType;
[Range(0f, 1f)]
public float normalizedTime; // 触发帧在整个动画中的归一化位置
[Tooltip("附加数据(可空):如 HitBox 编号、音频 key 等")]
public string data;
}
[Header("绑定的动画片段(类型安全引用,替代旧 clipName 字符串)")]
public AnimationClip targetClip; // ⚠️ AnimationClip(非 string clipName),架构 24_AnimEventModule §3
[Header("事件时机列表")]
public EventEntry[] events;
/// 按时机顺序排序,方便 Binder 批量注册。
public IEnumerable SortedEvents =>
events.OrderBy(e => e.normalizedTime);
/// 查询指定事件类型的触发帧(工具/编辑器用)。⚠️ 架构 24 §3 定义
public float GetNormalizedTime(AnimationEventType eventType)
{
foreach (var e in events)
if (e.eventType == eventType) return e.normalizedTime;
return -1f; // 未找到
}
}
}
```
```csharp
// AnimationEventBinder.cs — 静态工具,将 SO 配置绑定到 ClipTransition
namespace BaseGames.Animation
{
public static class AnimationEventBinder
{
public static void Bind(
ClipTransition clip,
AnimationEventConfigSO config,
IAnimationEventHandler receiver) // ⚠️ IAnimationEventHandler(架构 24 §4)
{
if (config == null) return;
foreach (var entry in config.SortedEvents)
{
var captured = entry;
clip.Events.SetCallback(captured.normalizedTime, () =>
receiver.HandleEvent(captured.eventType, captured.data)); // ⚠️ HandleEvent(架构 24 §4)
}
}
}
// ⚠️ 接口名为 IAnimationEventHandler,方法名为 HandleEvent(架构 24 §4,非 IAnimEventReceiver/OnAnimationEvent)
public interface IAnimationEventHandler
{
void HandleEvent(AnimationEventType type, string payload);
}
}
```
```csharp
// PlayerAnimationEvents.cs — 挂在 [Animation] 子节点,是玩家所有动画事件的唯一派发入口
// ⚠️ 字段以架构 24_AnimEventModule §5 为准:独立组件引用,非 PlayerController 聚合
namespace BaseGames.Animation
{
// ⚠️ 实现 IAnimationEventHandler(架构 24 §5),方法名 HandleEvent
public class PlayerAnimationEvents : MonoBehaviour, IAnimationEventHandler
{
[Header("战斗组件")]
[SerializeField] HitBox[] _hitBoxes; // 玩家身上所有 HitBox
[SerializeField] HurtBox _hurtBox;
[Header("能力组件")]
[SerializeField] PlayerStats _playerStats;
[SerializeField] PlayerMovement _mover; // ⚠️ 类型为 PlayerMovement(架构 05_PlayerModule §3),非 PlayerMover
[SerializeField] ParrySystem _parrySystem; // ⚠️ 弹反窗口控制(架构 24 §5 / 20_ShieldModule §1)
[Header("特效/音效")]
[SerializeField] IFeedbackPlayer _feedback; // 通过 GetComponentInParent 注入
[Header("事件配置(与 ClipTransition 一一对应)")]
[SerializeField] EventBinding[] _bindings;
[Serializable]
struct EventBinding
{
public ClipTransition clip;
public AnimationEventConfigSO config;
}
void Awake()
{
_feedback = GetComponentInParent();
foreach (var b in _bindings)
AnimationEventBinder.Bind(b.clip, b.config, this);
}
public void HandleEvent(AnimationEventType type, string payload) // ⚠️ HandleEvent + payload(架构 24 §4)
{
switch (type)
{
case AnimationEventType.EnableHitBox:
EnableHitBoxById(payload); break;
case AnimationEventType.DisableHitBox:
DisableHitBoxById(payload); break;
case AnimationEventType.AttackImpact:
_feedback?.PlayAttackWhoosh(); break;
case AnimationEventType.EnableIFrame:
_hurtBox.SetInvincible(true); break; // ⚠️ HurtBox.SetInvincible,非 PlayerStats.BeginInvincibility
case AnimationEventType.DisableIFrame:
_hurtBox.SetInvincible(false); break;
case AnimationEventType.Footstep: // ⚠️ Footstep(合并左右脚,payload 区分)
FootstepSystem.Play(_mover.CurrentSurface, transform.position); break;
case AnimationEventType.LandImpact:
_feedback?.PlayLandImpact(); break;
case AnimationEventType.JumpLaunch:
_feedback?.PlayJumpLaunch(); break;
case AnimationEventType.EnableParryWindow:
_parrySystem?.OpenWindow(); break; // ⚠️ 架构 24 §5:ParrySystem.OpenWindow()
case AnimationEventType.DisableParryWindow:
_parrySystem?.CloseWindow(); break; // ⚠️ 架构 24 §5:ParrySystem.CloseWindow()
case AnimationEventType.CancelWindowOpen:
_mover.SetCancelWindowOpen(true); break; // ⚠️ PlayerMovement.SetCancelWindowOpen(架构 05_PlayerModule §3),非 AttackState 方法
case AnimationEventType.CancelWindowClose:
_mover.SetCancelWindowOpen(false); break;
case AnimationEventType.TriggerFeedback:
_feedback?.TriggerPreset(payload); break; // ⚠️ TriggerPreset(架构 18 §2 IFeedbackPlayer 接口,非 PlayFeedbackByName)
case AnimationEventType.PlaySFX:
_feedback?.PlaySFXById(payload); break; // ⚠️ PlaySFXById(架构 18 §2 IFeedbackPlayer 接口;AudioManager 无 PlaySFXByKey 方法)
}
}
void EnableHitBoxById(string id)
{
foreach (var hb in _hitBoxes)
if (hb.Id == id || string.IsNullOrEmpty(id)) hb.Activate(); // ⚠️ HitBox.Activate(),无参数(架构 06 §4)
}
void DisableHitBoxById(string id)
{
foreach (var hb in _hitBoxes)
if (hb.Id == id || string.IsNullOrEmpty(id)) hb.Deactivate(); // ⚠️ HitBox.Deactivate()(架构 06 §4)
}
}
}
```
```csharp
// EnemyAnimationEvents.cs — 挂在 EnemyBase(或其 [Animation] 子节点),敌人侧动画事件派发入口
// ⚠️ 与 PlayerAnimationEvents 结构相同(见架构 24_AnimEventModule §6)
namespace BaseGames.Animation
{
public class EnemyAnimationEvents : MonoBehaviour, IAnimationEventHandler // ⚠️ IAnimationEventHandler(架构 24 §4,非 IAnimEventReceiver)
{
[SerializeField] HitBox[] _hitBoxes;
[SerializeField] EnemyFeedback _feedback;
[SerializeField] EnemyBase _enemy;
[SerializeField] EventBinding[] _bindings;
[Serializable]
struct EventBinding
{
public ClipTransition clip;
public AnimationEventConfigSO config;
}
void Awake()
{
foreach (var b in _bindings)
AnimationEventBinder.Bind(b.clip, b.config, this);
}
public void HandleEvent(AnimationEventType type, string payload) // ⚠️ HandleEvent(架构 24 §6,非 OnAnimationEvent)
{
switch (type)
{
case AnimationEventType.EnableHitBox:
foreach (var hb in _hitBoxes)
if (hb.Id == payload || string.IsNullOrEmpty(payload)) hb.Activate(); // ⚠️ HitBox.Activate()(架构 06 §4)
break;
case AnimationEventType.DisableHitBox:
foreach (var hb in _hitBoxes)
if (hb.Id == payload || string.IsNullOrEmpty(payload)) hb.Deactivate(); // ⚠️ HitBox.Deactivate()(架构 06 §4)
break;
case AnimationEventType.SpawnProjectile: // ⚠️ SpawnProjectile(架构 24 §2,非旧版 SummonProjectile)
_enemy.SpawnProjectile(payload); break;
case AnimationEventType.RoarStart:
case AnimationEventType.RoarEnd:
_enemy.Blackboard.SetVariableValue("IsRoaring",
type == AnimationEventType.RoarStart);
break;
case AnimationEventType.PhaseTwoStart:
_enemy.TriggerPhaseTwo(); break;
case AnimationEventType.AnimationComplete:
_enemy.OnAnimationComplete(payload); break;
}
}
}
}
```
**配置工作流**:
1. 为每个攻击动画创建 `AnimationEventConfigSO`(Inspector 设置 normalizedTime)
2. 在 `PlayerAnimationEvents.Awake` 中调用 `AnimationEventBinder.Bind(clip, config, this)`
3. 设计师调整时机无需修改代码,只改 SO 资产
### 4.1.1 EventConfigEditor — 动画事件时间轴可视化(⚠️ 架构 24 §10,Phase 2 优化)
> 路径:`Assets/Editor/Animation/EventConfigEditor.cs`。`[CustomEditor(typeof(AnimationEventConfigSO))]`,设计师可在 Inspector 中直观查看/调整所有 normalizedTime 标记点,无需手动核对帧序号。
```csharp
// Assets/Editor/Animation/EventConfigEditor.cs
// 需在 AnimationEventConfigSO 补充隐藏字段:
// [HideInInspector] public float ExpectedClipLength = -1f; // Auto-detect 写入
[CustomEditor(typeof(AnimationEventConfigSO))]
public class EventConfigEditor : UnityEditor.Editor
{
// 事件类型颜色规则(架构 24 §10):
// HitBox=红 IFrame=绿 Footstep/SFX=蓝 ParryWindow=黄 TriggerFeedback=紫 CancelWindow=橙
public override void OnInspectorGUI()
{
var config = (AnimationEventConfigSO)target;
// ① 时间轴背景条(全宽 24px 高)+ 各事件 normalizedTime 竖线标记(按类型着色)
DrawTimeline(config);
// ② 默认 Inspector 字段
DrawDefaultInspector();
// ③ 越界保护:normalizedTime < 0 || > 1 → HelpBox Error
ValidateNormalizedTime(config);
// ④ Clip 漂移检测:|currentLen - ExpectedClipLength| > 5帧 → HelpBox Warning
if (config.ExpectedClipLength > 0 && config.TargetClip != null)
{
float frameDrift = Mathf.Abs(config.TargetClip.length - config.ExpectedClipLength)
* config.TargetClip.frameRate;
if (frameDrift > 5f)
EditorGUILayout.HelpBox(
$"Clip 长度偏差 {frameDrift:F1} 帧,请重新校验 normalizedTime 或点击 Auto-detect 更新基准。",
MessageType.Warning);
}
// ⑤ Auto-detect 按钮:将当前 Clip 长度写入 ExpectedClipLength
if (GUILayout.Button("Auto-detect Clip Length"))
{
if (config.TargetClip != null)
{
config.ExpectedClipLength = config.TargetClip.length;
EditorUtility.SetDirty(config);
}
}
}
private void DrawTimeline(AnimationEventConfigSO config) { /* ... */ }
private void ValidateNormalizedTime(AnimationEventConfigSO config) { /* ... */ }
}
```
### 4.1.2 FootstepSystem + FootstepCatalogSO + SurfaceType
**参考文档**:`24_AnimEventModule.md §8`
```csharp
// Assets/Scripts/Animation/FootstepSystem.cs
// ⚠️ 静态工具类;_catalog 通过 Addressables 异步加载,禁止 Resources.Load
// BootstrapLoader 在游戏启动时调用 FootstepSystem.InitAsync()
namespace BaseGames.Animation
{
public static class FootstepSystem
{
static FootstepCatalogSO _catalog;
public static async UniTask InitAsync()
{
_catalog = await Addressables
.LoadAssetAsync(AddressKeys.SO_FootstepCatalog).Task;
// AddressKeys.SO_FootstepCatalog = "Config/FootstepCatalog"
}
public static void Play(SurfaceType surface, Vector3 position)
{
var entry = _catalog.GetEntry(surface);
if (entry == null) return;
// ⚠️ entry.audioClip 类型为 AudioClip(非 string key),对应架构 11_AudioModule §2 的 PlaySFXAtPosition(AudioClip, Vector2)
AudioManager.Instance.PlaySFXAtPosition(entry.audioClip, position);
if (entry.dustParticlePrefab.RuntimeKeyIsValid())
VFXPool.Instance.Play(entry.dustParticlePrefab, position);
}
}
// Assets/Scripts/Animation/FootstepCatalogSO.cs
[CreateAssetMenu(menuName = "Audio/FootstepCatalog")]
public class FootstepCatalogSO : ScriptableObject
{
[Serializable]
public struct FootstepEntry
{
public SurfaceType surface;
public AudioClip audioClip; // ⚠️ AudioClip 直接引用,非 string key
public AssetReferenceGameObject dustParticlePrefab;
}
[SerializeField] FootstepEntry[] _entries;
public FootstepEntry? GetEntry(SurfaceType surface)
{
foreach (var e in _entries)
if (e.surface == surface) return e;
return null;
}
}
// Assets/Scripts/Animation/SurfaceType.cs
public enum SurfaceType { Stone, Wood, Dirt, Water, Metal, Grass }
}
```
### 4.1.2 PlayerMovement Phase 2 扩展字段
**参考文档**:`05_PlayerModule.md §3`、`24_AnimEventModule.md §9`
> **Week 7 补充**:在 PlayerMovement 已有方法基础上,追加以下字段供 AnimEvent 系统使用。
```csharp
// PlayerMovement.cs 中新增(Phase 2 Week 7,对应架构 05_PlayerModule §3)
// ── 可取消帧窗口 ────────────────────────────────────────────────────
private bool _cancelWindowOpen = false;
public bool CancelWindowOpen => _cancelWindowOpen;
public void SetCancelWindowOpen(bool open) => _cancelWindowOpen = open;
// 由 PlayerAnimationEvents.OnAnimationEvent(CancelWindowOpen/CancelWindowClose) 驱动
// ── 当前地面材质 ─────────────────────────────────────────────────────
public SurfaceType CurrentSurface { get; private set; } // private set,由 CheckGrounded() 检测 Tile 材质后赋值
// FootstepSystem.Play(_mover.CurrentSurface, ...) 依赖此属性
```
### 4.1.3 AttackState.GetNextState() CancelWindow 检查
**参考文档**:`24_AnimEventModule.md §9`
> **Week 7 补充**:在 Plan 02 §3.5 AttackState 基础上,Phase 2 Week 7 追加 `GetNextState()` 重写,
> 确保只有在 `CancelWindowOpen` 为 true 时才允许 FSM 接受新输入并切换状态。
```csharp
// AttackState.cs 中追加(Phase 2 Week 7,对应架构 24_AnimEventModule §9)
// ⚠️ Phase 1 §3.5 使用 inline Events.SetCallback 的简化版;Week 7 改为由 PlayerAnimationEvents 驱动
public override PlayerStateBase GetNextState()
{
// 只有在 CancelWindowOpen 时才接受新输入转换状态
if (!_player.Movement.CancelWindowOpen) return null;
if (_player.Input.AttackPressed) return _nextAttackState;
if (_player.Input.DashPressed) return _player.DashState;
if (_player.Input.JumpPressed) return _player.JumpState;
return null;
}
```
**流程**(`24_AnimEventModule.md §9` 时序):
```
AttackClip 播放
├─ NormalizedTime = 0.6 → AnimationEventType.CancelWindowOpen → PlayerMovement._cancelWindowOpen = true
├─ 玩家可输入 → FSM.GetNextState() 允许取消进入其他状态
└─ NormalizedTime = 0.9 → AnimationEventType.CancelWindowClose → PlayerMovement._cancelWindowOpen = false
```
### 4.2 StatusEffect + 具体状态效果
```csharp
// Assets/Scripts/Combat/StatusEffects/StatusEffect.cs
// ⚠️ 类名为 StatusEffect(非 StatusEffectBase),架构 06_CombatModule §11
namespace BaseGames.Combat.StatusEffects
{
public enum StatusEffectType { Fire, Poison, Freeze, Stun } // ⚠️ 枚举标识符,非 string EffectId
public abstract class StatusEffect
{
public abstract StatusEffectType EffectType { get; } // ⚠️ EffectType(非 EffectId string)
public abstract int MaxStacks { get; } // 最大叠加层数(1 = 不可叠加)
public int StackCount { get; protected set; } = 1; // ⚠️ 必须定义 StackCount(架构 06 §11)
public float Duration { get; protected set; } // 当前剩余持续时间
public float TickInterval { get; protected set; } // 每次 Tick 的间隔秒数
float _tickTimer;
protected StatusEffectManager Owner; // 宿主(由 OnApply 注入)
// ⚠️ OnApply 参数为 StatusEffectManager owner(非 IDamageable)
public virtual void OnApply(StatusEffectManager owner)
{
Owner = owner;
Duration = GetBaseDuration();
}
// ⚠️ OnStack(同类型再次施加时叠层/刷新)
public virtual void OnStack()
{
Duration = GetBaseDuration();
StackCount = Mathf.Min(StackCount + 1, MaxStacks);
}
// ⚠️ OnTick/OnExpire 无参数(用 Owner 字段引用宿主)
public virtual void OnTick() { }
public virtual void OnExpire() { } // ⚠️ OnExpire(非 OnRemove)
public virtual bool IsExpired => Duration <= 0f;
// ⚠️ Update(float delta)(非 Tick(IDamageable, float))
public void Update(float delta)
{
Duration -= delta;
_tickTimer += delta;
if (_tickTimer >= TickInterval)
{
_tickTimer -= TickInterval;
OnTick();
}
}
protected abstract float GetBaseDuration();
public abstract string GetDisplayName();
}
}
```
// Phase 2 实现以下 3 种(其余 Phase 3 补充)
// Assets/Scripts/Combat/StatusEffects/FireEffect.cs — 燃烧(DOT,不可叠加,触发则刷新)
// Assets/Scripts/Combat/StatusEffects/PoisonEffect.cs — 中毒(DOT,最多 3 层叠加)
// Assets/Scripts/Combat/StatusEffects/StaggerEffect.cs — 硬直(无法行动 N 秒)
### 4.3 StatusEffectManager
```csharp
// Assets/Scripts/Combat/StatusEffects/StatusEffectManager.cs
// ⚠️ [RequireComponent(typeof(SpriteRenderer))] — 需要 SpriteRenderer 支持 Shader 参数(架构 06 §10)
namespace BaseGames.Combat.StatusEffects
{
[RequireComponent(typeof(SpriteRenderer))]
public class StatusEffectManager : MonoBehaviour
{
[Header("事件频道")]
// ⚠️ StatusEffectEventChannelSO(非 VoidEventChannelSO),携带 StatusEffectType 载荷(架构 06 §10)
[SerializeField] StatusEffectEventChannelSO _onStatusEffectApplied;
[SerializeField] StatusEffectEventChannelSO _onStatusEffectExpired;
// ⚠️ 双结构:List 用于有序 Update 遍历;Dictionary 用于 O(1) 查找(架构 06 §10)
readonly List _activeList = new();
readonly Dictionary _activeIndex = new();
SpriteRenderer _renderer;
MaterialPropertyBlock _propBlock;
void Awake()
{
_renderer = GetComponent();
_propBlock = new MaterialPropertyBlock();
}
void Update()
{
for (int i = _activeList.Count - 1; i >= 0; i--)
{
var effect = _activeList[i];
effect.Update(Time.deltaTime); // ⚠️ Update(float)(非 Tick(IDamageable, float))
if (effect.IsExpired)
{
effect.OnExpire(); // ⚠️ OnExpire()(非 OnRemove(target))
_activeIndex.Remove(effect.EffectType);
_activeList.RemoveAt(i);
_onStatusEffectExpired?.Raise(effect.EffectType);
}
}
}
/// 施加状态效果。已有相同类型时叠层/刷新(O(1) 查找)。
public void ApplyEffect(StatusEffect newEffect)
{
// ⚠️ 用 Dictionary 做 O(1) 查找(非 List.Find),架构 06 §10
if (_activeIndex.TryGetValue(newEffect.EffectType, out var existing))
{
existing.OnStack(); // ⚠️ OnStack()叠层或刷新
}
else
{
newEffect.OnApply(this); // ⚠️ 传 StatusEffectManager(非 IDamageable)
_activeList.Add(newEffect);
_activeIndex[newEffect.EffectType] = newEffect;
_onStatusEffectApplied?.Raise(newEffect.EffectType);
}
}
/// 净化指定类型的状态效果(O(1) 查找)。⚠️ 方法名为 CleanseEffect(非 RemoveEffect),架构 06 §10
public void CleanseEffect(StatusEffectType type)
{
if (!_activeIndex.TryGetValue(type, out var effect)) return;
effect.OnExpire();
_activeIndex.Remove(type);
_activeList.Remove(effect);
_onStatusEffectExpired?.Raise(type);
}
/// 净化所有状态效果。⚠️ 存档点激活时调用(架构 06 §10)
public void CleanseAll()
{
foreach (var e in _activeList) e.OnExpire();
_activeList.Clear();
_activeIndex.Clear();
}
/// 由状态效果调用,直接扣 HP(True 伤害,绕过 HurtBox)。⚠️ 架构 06 §10
public void ApplyDirectDamage(DamageInfo info)
=> GetComponent()?.TakeDamage(info);
/// 由状态效果调用,设置 Shader 参数(MaterialPropertyBlock)。⚠️ 架构 06 §10
public void SetShaderParam(string param, float value)
{
_renderer.GetPropertyBlock(_propBlock);
_propBlock.SetFloat(param, value);
_renderer.SetPropertyBlock(_propBlock);
}
/// 检查是否有指定类型的状态效果。⚠️ 架构 06 §10
public bool HasEffect(StatusEffectType type) => _activeIndex.ContainsKey(type);
}
}
```
HurtBox 收到含 `DamageType.Poison/Fire` 的 DamageInfo 时,自动调用 `StatusEffectManager.ApplyEffect`。
净化方式:进入水域调用 `CleanseEffect(Fire)`;装备解毒护符自动净化 Poison;存档点调用 `CleanseAll()`(架构 06 §11.2)。
### 4.4 完整 VFX 反馈模块
**参考文档**:`18_VFXFeedbackModule.md §2–§12`
#### 4.4.1 FeedbackConfigSO
```csharp
// Assets/ScriptableObjects/Feedback/Feedback_Config.asset
// ⚠️ menuName = "Feedback/FeedbackConfig"(架构 18_VFXFeedbackModule §5)
namespace BaseGames.Feedback
{
[CreateAssetMenu(menuName = "Feedback/FeedbackConfig")]
public class FeedbackConfigSO : ScriptableObject
{
[Header("冻帧")]
[Range(0f, 0.2f)]
public float FreezeFrameDuration = 0.033f; // 默认 2 帧(60fps)
[Range(0f, 0.2f)]
public float ParryFreezeFrameDuration = 0.067f; // 弹反冻帧更长
[Header("子弹时间")]
[Range(0.01f, 1f)]
public float BulletTimeScale = 0.15f;
[Range(0f, 1f)]
public float BulletTimeDuration = 0.3f;
[Header("镜头震动强度")]
public float ShakeLightForce = 0.2f;
public float ShakeMediumForce = 0.5f;
public float ShakeHeavyForce = 1.0f;
public float ShakeParryForce = 0.8f;
[Header("受击白闪")]
public Color HurtFlashColor = Color.white;
[Range(0f, 0.5f)]
public float HurtFlashDuration = 0.15f;
}
}
```
#### 4.4.2 PlayerFeedback(完整实现)
```csharp
// Assets/Scripts/Feedback/PlayerFeedback.cs
// ⚠️ 挂在 Player Prefab 根节点下的 [Feedback] 子 GameObject 上(架构 18_VFXFeedbackModule §3)
// Player Prefab 层级:[Player] → [Feedback] ← PlayerFeedback.cs + MMF_Player × 10
namespace BaseGames.Feedback
{
public class PlayerFeedback : MonoBehaviour, IFeedbackPlayer
{
[Header("命中反馈")]
[SerializeField] MMF_Player _onHitLight;
[SerializeField] MMF_Player _onHitMedium;
[SerializeField] MMF_Player _onHitHeavy;
[Header("战斗反馈")]
[SerializeField] MMF_Player _onParrySuccess;
[SerializeField] MMF_Player _onTakeHit;
[SerializeField] MMF_Player _onDeath;
[Header("移动反馈")]
[SerializeField] MMF_Player _onHeal;
[SerializeField] MMF_Player _onLandImpact;
[SerializeField] MMF_Player _onAttackWhoosh;
[SerializeField] MMF_Player _onJumpLaunch;
[Header("配置")]
[SerializeField] FeedbackConfigSO _config;
// 预设字典(runtime,用于 TriggerPreset)
Dictionary _presetMap;
void Awake()
{
_presetMap = new Dictionary
{
{ "HitLight", _onHitLight },
{ "HitMedium", _onHitMedium },
{ "HitHeavy", _onHitHeavy },
{ "ParrySuccess", _onParrySuccess },
{ "TakeHit", _onTakeHit },
{ "Death", _onDeath },
{ "LandImpact", _onLandImpact },
};
}
public void PlayHit(HitWeight weight)
{
switch (weight)
{
case HitWeight.Light: _onHitLight?.PlayFeedbacks(); break;
case HitWeight.Medium: _onHitMedium?.PlayFeedbacks(); break;
case HitWeight.Heavy: _onHitHeavy?.PlayFeedbacks(); break;
}
}
public void PlayParrySuccess() => _onParrySuccess?.PlayFeedbacks();
public void PlayTakeHit() => _onTakeHit?.PlayFeedbacks();
public void PlayDeath() => _onDeath?.PlayFeedbacks();
public void PlayHeal() => _onHeal?.PlayFeedbacks();
public void PlayLandImpact() => _onLandImpact?.PlayFeedbacks();
public void PlayAttackWhoosh() => _onAttackWhoosh?.PlayFeedbacks();
public void PlayJumpLaunch() => _onJumpLaunch?.PlayFeedbacks();
public void TriggerPreset(string presetId)
{
if (_presetMap.TryGetValue(presetId, out var player))
player?.PlayFeedbacks();
}
public void PlaySFXById(string sfxId)
{
// sfxId → AudioClip 解析通过 SFXCatalogSO 查表后调用 AudioManager.Instance.PlaySFX(clip)
}
}
}
```
#### 4.4.3 EnemyFeedback(完整实现)
```csharp
// Assets/Scripts/Feedback/EnemyFeedback.cs
// ⚠️ 挂在 EnemyBase Prefab 下的 [Feedback] 子 GameObject 上(架构 18_VFXFeedbackModule §4)
// EnemyBase 通过 [SerializeField] EnemyFeedback _feedback 引用
namespace BaseGames.Feedback
{
public class EnemyFeedback : MonoBehaviour, IFeedbackPlayer
{
[SerializeField] MMF_Player _onTakeHit;
[SerializeField] MMF_Player _onDeath;
[SerializeField] MMF_Player _onHitLight;
[SerializeField] MMF_Player _onHitMedium;
[SerializeField] MMF_Player _onHitHeavy;
public void PlayHit(HitWeight weight)
{
switch (weight)
{
case HitWeight.Light: _onHitLight?.PlayFeedbacks(); break;
case HitWeight.Medium: _onHitMedium?.PlayFeedbacks(); break;
case HitWeight.Heavy: _onHitHeavy?.PlayFeedbacks(); break;
}
}
public void PlayTakeHit() => _onTakeHit?.PlayFeedbacks();
public void PlayDeath() => _onDeath?.PlayFeedbacks();
// 敌人未使用的接口方法(空实现)
public void PlayParrySuccess() { }
public void PlayHeal() { }
public void PlayLandImpact() { }
public void PlayAttackWhoosh() { }
public void PlayJumpLaunch() { }
public void TriggerPreset(string id) { }
public void PlaySFXById(string id) { }
}
}
```
#### 4.4.4 PaletteSwapSystem + PaletteCatalogSO
```csharp
// Assets/Scripts/VFX/PaletteSwapSystem.cs
// ⚠️ ApplyPalette(FormType form) 接受 FormType 枚举(架构 18_VFXFeedbackModule §10)
// FormController.SwitchForm 调用:_paletteSwapSystem?.ApplyPalette(newForm.formType) ← 正确(FormSO.formType 字段,架构 05 §18 遗漏,以架构 18 §10 为准)
namespace BaseGames.VFX
{
public class PaletteSwapSystem : MonoBehaviour
{
[SerializeField] SpriteRenderer _renderer;
[SerializeField] PaletteCatalogSO _catalog;
static readonly int PaletteTexID = Shader.PropertyToID("_PaletteTex");
MaterialPropertyBlock _block;
void Awake() => _block = new MaterialPropertyBlock();
/// 切换到指定形态的调色板。由 FormController 调用。
public void ApplyPalette(FormType form) // ⚠️ 参数为 FormType 枚举(非 Color,非 FormSO)
{
if (!_catalog.TryGetPalette(form, out var tex)) return;
_renderer.GetPropertyBlock(_block);
_block.SetTexture(PaletteTexID, tex);
_renderer.SetPropertyBlock(_block);
}
}
// Assets/Scripts/VFX/PaletteCatalogSO.cs
[CreateAssetMenu(menuName = "VFX/PaletteCatalog")]
public class PaletteCatalogSO : ScriptableObject
{
public PaletteEntry[] entries;
public bool TryGetPalette(FormType form, out Texture2D tex)
{
foreach (var e in entries)
if (e.form == form) { tex = e.paletteLUT; return true; }
tex = null;
return false;
}
}
[Serializable]
public struct PaletteEntry
{
public FormType form;
public Texture2D paletteLUT; // 1D 查找表纹理(256×1 px)
}
}
```
#### 4.4.5 PostProcessManager
```csharp
// Assets/Scripts/VFX/PostProcessManager.cs
// ⚠️ 挂在 Persistent 场景 [PostProcess] GameObject(架构 18_VFXFeedbackModule §11)
// ⚠️ DOTween 规范:.SetAutoKill(true).SetLink(gameObject)(架构 §11)
namespace BaseGames.VFX
{
public class PostProcessManager : MonoBehaviour
{
[Header("Volume 引用(Persistent 场景内)")]
[SerializeField] Volume _underwaterVolume; // Priority=10
[SerializeField] Volume _bossArenaVolume; // Priority=10
[SerializeField] Volume _deathVolume; // Priority=20
[SerializeField] Volume _victoryVolume; // Priority=10
[Header("事件频道")]
[SerializeField] VoidEventChannelSO _onLiquidEntered;
[SerializeField] VoidEventChannelSO _onLiquidExited;
[SerializeField] VoidEventChannelSO _onBossFightStarted;
[SerializeField] VoidEventChannelSO _onBossFightEnded;
[SerializeField] VoidEventChannelSO _onPlayerDied;
[SerializeField] VoidEventChannelSO _onPlayerRespawned;
[SerializeField] VoidEventChannelSO _onBossDefeated;
[SerializeField] float _blendDuration = 0.4f;
private Volume[] _nonDefaultVolumes;
private void Awake()
{
_nonDefaultVolumes = new[] { _underwaterVolume, _bossArenaVolume, _deathVolume, _victoryVolume };
}
private void OnEnable()
{
_onLiquidEntered.OnEventRaised += () => BlendTo(_underwaterVolume);
_onLiquidExited.OnEventRaised += ResetAll;
_onBossFightStarted.OnEventRaised += () => BlendTo(_bossArenaVolume);
_onBossFightEnded.OnEventRaised += ResetAll;
_onPlayerDied.OnEventRaised += () => BlendTo(_deathVolume);
_onPlayerRespawned.OnEventRaised += ResetAll;
_onBossDefeated.OnEventRaised += () => BlendTo(_victoryVolume);
}
private void OnDisable()
{
_onLiquidEntered.OnEventRaised -= () => BlendTo(_underwaterVolume);
_onLiquidExited.OnEventRaised -= ResetAll;
_onBossFightStarted.OnEventRaised -= () => BlendTo(_bossArenaVolume);
_onBossFightEnded.OnEventRaised -= ResetAll;
_onPlayerDied.OnEventRaised -= () => BlendTo(_deathVolume);
_onPlayerRespawned.OnEventRaised -= ResetAll;
_onBossDefeated.OnEventRaised -= () => BlendTo(_victoryVolume);
}
private void BlendTo(Volume target)
{
foreach (var v in _nonDefaultVolumes)
DOTween.To(() => v.weight, x => v.weight = x, 0f, _blendDuration)
.SetAutoKill(true).SetLink(gameObject);
DOTween.To(() => target.weight, x => target.weight = x, 1f, _blendDuration)
.SetAutoKill(true).SetLink(gameObject);
}
private void ResetAll()
{
foreach (var v in _nonDefaultVolumes)
DOTween.To(() => v.weight, x => v.weight = x, 0f, _blendDuration)
.SetAutoKill(true).SetLink(gameObject);
}
}
}
```
**Persistent 场景 [PostProcess] 子 Volume 层级**:
```
[PostProcess]
├── Volume_Default Priority=0 Weight=1.0(始终生效)
├── Volume_Underwater Priority=10 Weight=0(进水时 blend 到 1.0)
├── Volume_BossArena Priority=10 Weight=0(Boss 战开始时 blend 到 1.0)
├── Volume_Death Priority=20 Weight=0(玩家死亡时 blend 到 1.0)
└── Volume_Victory Priority=10 Weight=0(Boss 击败时 blend 到 1.0)
```
| Volume | Bloom | Color Grading | Vignette | Chromatic Aberration |
|--------|-------|--------------|----------|---------------------|
| Default | 0.3 | 正常 | 0.2 | 关闭 |
| Underwater | 0.1 | 青绿 Filter -0.3 | 0.45 | 0.4 |
| BossArena | 0.5 | 饱和度 +20% | 0.35 | 0.15 |
| Death | 0 | 去饱和度 -80% | 0.7(黑色)| 0.8 |
| Victory | 0.8(白色)| 亮度 +0.4 | 0 | 0 |
#### 4.4.6 RegionLightController + RegionLightCatalogSO
```csharp
// Assets/Scripts/VFX/RegionLightController.cs
// ⚠️ 挂在 Persistent 场景 [Lighting] GameObject(架构 18_VFXFeedbackModule §12)
// 监听 EVT_RegionEntered(StringEventChannelSO regionId)平滑切换 Global Light 2D
namespace BaseGames.VFX
{
public class RegionLightController : MonoBehaviour
{
[SerializeField] Light2D _globalLight;
[SerializeField] RegionLightCatalogSO _catalog;
[SerializeField] StringEventChannelSO _onRegionEntered;
[SerializeField] float _transitionDuration = 1.5f;
private void OnEnable() => _onRegionEntered.OnEventRaised += OnRegionEntered;
private void OnDisable() => _onRegionEntered.OnEventRaised -= OnRegionEntered;
private void OnRegionEntered(string regionId)
{
if (!_catalog.TryGet(regionId, out var config)) return;
DOTween.To(() => _globalLight.color,
x => _globalLight.color = x,
config.Color, _transitionDuration)
.SetAutoKill(true).SetLink(gameObject);
DOTween.To(() => _globalLight.intensity,
x => _globalLight.intensity = x,
config.Intensity, _transitionDuration)
.SetAutoKill(true).SetLink(gameObject);
}
}
// Assets/Scripts/VFX/RegionLightCatalogSO.cs
[CreateAssetMenu(menuName = "VFX/RegionLightCatalog")]
public class RegionLightCatalogSO : ScriptableObject
{
[Serializable]
public struct RegionLightConfig
{
public string regionId;
public Color Color;
[Range(0f, 1f)] public float Intensity;
}
[SerializeField] RegionLightConfig[] _entries;
public bool TryGet(string regionId, out RegionLightConfig cfg)
{
foreach (var e in _entries)
if (e.regionId == regionId) { cfg = e; return true; }
cfg = default;
return false;
}
}
}
```
**区域 Global Light 2D 参数速查**:
| 区域 | 颜色 | 强度 |
|------|------|------|
| Forest(扎根森林)| `#C8E8D0`(淡绿)| 0.8 |
| Cave(腐蚀洞穴)| `#1A0A2E`(深紫)| 0.2 |
| Ruins(坍塌废墟)| `#3D3028`(暖褐)| 0.5 |
| Abyss(深渊裂隙)| `#000820`(极暗蓝)| 0.1 |
| Core(核心熔炉)| `#4A1800`(暗红橙)| 0.6 |
#### 4.4.7 Phase 2 追加的 VFX 资产
| VFX Key | 触发时机 |
|---------|---------|
| `VFX_ParryFlash` | 弹反成功瞬间 |
| `VFX_ShieldBreak` | 护盾破碎 |
| `VFX_FormSwitch_Sky` | 切换天魂形态 |
| `VFX_FormSwitch_Earth` | 切换地魂形态 |
| `VFX_FormSwitch_Death` | 切换命魂形态 |
| `VFX_Poison_Tick` | 中毒每秒跳动 |
| `VFX_Burn_Tick` | 燃烧 DOT |
| `VFX_DashTrail` | 冲刺残影 |
| `VFX_WallSlide_Dust` | 蹬墙粉尘 |
| `VFX_DownAttack_Land` | 下劈落地冲击 |
---
## 5. Week 8:难度系统 + AudioMixer 快照 ✅ 完成(2026-05-10)
**参考文档**:`11_AudioModule.md §3-5`、`19_DifficultyModule.md §4`
> **⚠️ 架构说明**:Phase 2 音频工作是完善 Unity AudioMixer 快照和 BGM 管理;架构无 FMOD 依赖,**不需要 IAudioBackend 接口层**。Phase 1 实现的 AudioManager 无需重构。
### 5.1 AudioMixer 快照系统
**四个快照**(在 `Assets/Audio/MainMixer.mixer` 中已创建):
| 快照 | 触发条件 | BGM 变化 |
|------|---------|---------|
| `Default` | 正常游戏 | 正常音量 |
| `Paused` | PauseMenu 打开 | BGM 降低 + 低通滤波 |
| `Dead` | 玩家死亡 | BGM 淡出 + 环境音压低 |
| `BossFight` | Boss 战开始 | BGM 切换 + SFX 提升 |
**AudioManager 快照 API 完善**(Week 8 补充到 Phase 1 的 `AudioManager.cs`):
```csharp
// 已在 Phase 1 AudioManager 骨架中预留,Week 8 正式实现:
public void TransitionToSnapshot(string snapshotName, float transitionTime = 0.5f) // ⚠️ TransitionToSnapshot(架构 11 §2)
{
var snapshot = _mixer.FindSnapshot(snapshotName);
snapshot?.TransitionTo(transitionTime);
}
```
**GameManager 事件绑定**:
- `EVT_PauseRequested` → `TransitionToSnapshot("Paused", 0.3f)`
- `EVT_BossFightToggled`(true) → `TransitionToSnapshot("BossFight", 1.0f)` + BGM 切换
- `EVT_PlayerDied` → `TransitionToSnapshot("Dead", 0.5f)`
### 5.1.1 AudioEventSO
**参考文档**:`11_AudioModule.md §5`
```csharp
// Assets/Scripts/Audio/AudioEventSO.cs
// ⚠️ 字段全大写(Clips, VolumeMin/Max, PitchMin/Max),方法为 Play/PlayOneShot(架构 11 §5)
[CreateAssetMenu(menuName = "Audio/AudioEvent")]
public class AudioEventSO : ScriptableObject
{
public AudioClip[] Clips; // 随机挑选一个播放
[Range(0f, 1f)]
public float VolumeMin = 0.9f;
[Range(0f, 1f)]
public float VolumeMax = 1.0f;
[Range(0.5f, 2f)]
public float PitchMin = 0.95f;
[Range(0.5f, 2f)]
public float PitchMax = 1.05f;
public AudioMixerGroup MixerGroup; // ⚠️ 路由到子混音组(如 SFX_Player),架构 11 §5
public void Play(AudioSource source)
{
if (Clips == null || Clips.Length == 0) return;
source.outputAudioMixerGroup = MixerGroup; // ⚠️ 架构 11 §5
source.clip = Clips[Random.Range(0, Clips.Length)];
source.volume = Random.Range(VolumeMin, VolumeMax);
source.pitch = Random.Range(PitchMin, PitchMax);
source.Play();
}
public void PlayOneShot(AudioSource source)
{
if (Clips == null || Clips.Length == 0) return;
var clip = Clips[Random.Range(0, Clips.Length)];
source.outputAudioMixerGroup = MixerGroup; // ⚠️ 架构 11 §5
source.PlayOneShot(clip, Random.Range(VolumeMin, VolumeMax));
}
}
// 资产路径:Assets/ScriptableObjects/Audio/
// 命名规范:AUD_{Category}_{Name}.asset(例 AUD_Player_SwordSlash.asset)
```
### 5.1.2 GlobalSFXPlayer
**参考文档**:`11_AudioModule.md §6`
```csharp
// Assets/Scripts/Audio/GlobalSFXPlayer.cs
// ⚠️ 静态入口 Play(AudioEventSO, Vector2?);Singleton 为 _instance(非 Instance,架构 11 §6)
public class GlobalSFXPlayer : MonoBehaviour
{
private static GlobalSFXPlayer _instance;
[SerializeField] private AudioMixerGroup _sfxGroup;
[SerializeField] private AudioSource _globalSFXSource;
private void Awake()
{
if (_instance != null) { Destroy(gameObject); return; }
_instance = this;
}
/// 在任意地方播放 SFX。worldPos != null 时使用对象池创建空间音源。
public static void Play(AudioEventSO audioEvent, Vector2? worldPos = null)
{
if (worldPos.HasValue)
{
// 使用对象池获取临时 AudioSource GO,播放后自动归还
// (Phase 2 简化实现:直接 PlaySFXAtPosition 即可)
AudioManager.Instance.PlaySFXAtPosition(
audioEvent.Clips[Random.Range(0, audioEvent.Clips.Length)],
worldPos.Value,
Random.Range(audioEvent.VolumeMin, audioEvent.VolumeMax));
}
else
{
audioEvent.Play(_instance._globalSFXSource);
}
}
}
```
### 5.1.3 AudioConfigSO
**参考文档**:`11_AudioModule.md §7`
> **⚠️ Phase 1 的 BGMController 已引用 `_config.VictoryStingBGM` / `GetBossBGM()` / `GetZoneBGM()` 等,此处补全类定义(P2-8 任务)。**
```csharp
// Assets/Scripts/Audio/AudioConfigSO.cs — 程序集 BaseGames.Audio
[CreateAssetMenu(menuName = "Audio/AudioConfig")]
public class AudioConfigSO : ScriptableObject
{
[System.Serializable]
public struct ZoneBGMEntry
{
public string ZoneId;
public AudioClip BGMClip;
public float FadeDuration;
}
[System.Serializable]
public struct BossBGMEntry
{
public string BossId;
public AudioClip BGMClip;
}
public ZoneBGMEntry[] ZoneBGMs;
public BossBGMEntry[] BossBGMs;
public AudioClip MainMenuBGM;
public AudioClip GameOverSting; // 死亡时短音乐片段
public AudioClip VictoryStingBGM; // Boss 击败后胜利音乐片段
public float VictoryStingDuration = 4f; // 胜利音乐播放时长(秒)
public AudioClip GetZoneBGM(string zoneId)
{
foreach (var e in ZoneBGMs)
if (e.ZoneId == zoneId) return e.BGMClip;
return null;
}
public AudioClip GetBossBGM(string bossId)
{
foreach (var e in BossBGMs)
if (e.BossId == bossId) return e.BGMClip;
return null;
}
}
// 资产路径:Assets/ScriptableObjects/Audio/AUD_Config.asset
```
> **⚠️ Footstep + Underwater 音频**:架构 11 §9-10 定义了 `FootstepMaterial` enum、`FootstepAudioConfigSO`、`FootstepMaterialMarker` 和 `UnderwaterAudioController`;这两个系统与玩家游泳(Phase 3 LiquidZone)及地面脚步相关,留至 Phase 3 Week 11(SwimState 完整实现时)集成实现。
按 `19_DifficultyModule.md §3-4` 实现:
```csharp
// Assets/Scripts/Core/Difficulty/DifficultyScalerSO.cs
namespace BaseGames.Core
{
[CreateAssetMenu(menuName = "Core/DifficultyScaler")]
public class DifficultyScalerSO : ScriptableObject
{
[Header("标识")]
public DifficultyLevel level;
[Header("玩家属性缩放")]
[Range(0.1f, 3.0f)] public float PlayerMaxHPMultiplier = 1.0f;
[Range(0.1f, 3.0f)] public float PlayerDamageMultiplier = 1.0f;
[Range(0.0f, 2.0f)] public float InvincibilityFrameScale = 1.0f;
[Header("敌人属性缩放")]
[Range(0.1f, 3.0f)] public float EnemyDamageMultiplier = 1.0f;
[Range(0.1f, 3.0f)] public float EnemyHPMultiplier = 1.0f;
[Range(0.1f, 3.0f)] public float BossDamageMultiplier = 1.0f;
[Range(0.1f, 3.0f)] public float BossHPMultiplier = 1.0f;
[Header("商店价格")]
[Range(0.5f, 2.0f)] public float ShopPriceMultiplier = 1.0f;
[Header("游戏规则")]
public bool CanReviveWithGeoLoss = true;
public bool InstantDeathOnZeroHP = false; // SteelSoul 专用:HP 归零清档
public bool GeoPenaltyOnDeath = true; // false = Easy 无 Geo 损失
[Header("AI 行为(Behavior Designer 黑板变量名)")]
public float EnemyAttackIntervalScale = 1.0f; // 攻击间隔倍率(Hard < 1 = 更频繁)
public float EnemyAggroRangeScale = 1.0f; // 感知范围倍率
[Range(0.3f, 2.0f)]
public float EnemyReactionTimeScale = 1.0f; // ⚠️ 反应时间倍率(>1 = 更慢 = 更简单),架构 19 §3
[Range(0, 5)]
public int EnemyAggressionLevel = 2; // ⚠️ 0=被动 … 5=全力出击(影响 BT 决策权重),架构 19 §3
[Header("掉落与奖励")]
[Range(0.0f, 3.0f)]
public float GeoDropMultiplier = 1.0f; // ⚠️ Geo 掉落量倍率(Easy 可给更多),架构 19 §3
}
}
```
```csharp
// Assets/Scripts/Core/Difficulty/DifficultyManager.cs
namespace BaseGames.Core
{
///
/// 全局难度管理器,挂在 Persistent 场景 [GameManagers] 下。
/// 持有当前难度 ScalerSO,提供静态访问入口,广播难度变更事件。
///
[DefaultExecutionOrder(-900)] // ⚠️ 架构 19_DifficultyModule §4:早于 PlayerStats/EnemyStats(-800) Awake 执行
public class DifficultyManager : MonoBehaviour
{
// ── Inspector ────────────────────────────────────────
[SerializeField] DifficultyScalerSO[] _allScalers; // 4 档资产
[SerializeField] DifficultyChangedEventChannel _onDifficultyChanged;
// ── Singleton ────────────────────────────────────────
public static DifficultyManager Instance { get; private set; }
// ── Runtime State ────────────────────────────────────
public DifficultyLevel CurrentLevel { get; private set; } = DifficultyLevel.Normal;
public DifficultyScalerSO CurrentScaler { get; private set; }
void Awake()
{
Instance = this;
// SaveData 加载后由 GameManager 调用 Apply,此处初始化为 Normal
Apply(DifficultyLevel.Normal);
}
///
/// 应用难度。新游戏开始/读档时由 GameManager 调用。
///
public void Apply(DifficultyLevel level)
{
// SteelSoul 不可降级
if (CurrentLevel == DifficultyLevel.SteelSoul && level != DifficultyLevel.SteelSoul)
{
Debug.LogWarning("[DifficultyManager] SteelSoul 模式不可降级");
return;
}
CurrentLevel = level;
CurrentScaler = GetScaler(level);
_onDifficultyChanged.Raise(CurrentScaler);
}
public DifficultyScalerSO GetScaler(DifficultyLevel level)
{
foreach (var s in _allScalers)
if (s.level == level) return s;
Debug.LogError($"[DifficultyManager] 找不到 {level} 的 ScalerSO");
return _allScalers[0]; // fallback
}
///
/// 游戏进行中切换难度(仅允许 Easy ↔ Normal ↔ Hard,不可切换到 SteelSoul)。
///
public void ChangeDifficulty(DifficultyLevel newLevel)
{
if (newLevel == DifficultyLevel.SteelSoul)
{
Debug.LogWarning("[DifficultyManager] 游戏进行中不可切换到 SteelSoul");
return;
}
Apply(newLevel);
}
}
}
```
**四份 DifficultyScalerSO 资产**(`Assets/ScriptableObjects/Core/Difficulty/`):
- `Difficulty_Easy.asset`:玩家 HP ×1.5,敌人伤害 ×0.7,`GeoPenaltyOnDeath = false`,`GeoDropMultiplier = 1.2`,`EnemyReactionTimeScale = 1.4`,`EnemyAggressionLevel = 1`
- `Difficulty_Normal.asset`:全部 ×1.0(默认),`EnemyAggressionLevel = 2`
- `Difficulty_Hard.asset`:玩家 HP ×0.75,敌人伤害 ×1.3,敌人 HP ×1.2,`EnemyAttackIntervalScale = 0.8`,`EnemyReactionTimeScale = 0.7`,`EnemyAggressionLevel = 3`
- `Difficulty_SteelSoul.asset`:玩家 HP ×1.2,敌人伤害 ×1.5,`InstantDeathOnZeroHP = true`,`EnemyReactionTimeScale = 0.6`,`EnemyAggressionLevel = 4`
> **⚠️ 架构 19_DifficultyModule §3** 完整字段见 DifficultyScalerSO 上方的资产预设表,以上为关键差异项。
**各系统注入**:
- `PlayerStats` — 难度由 `DifficultyManager._onDifficultyChanged` 事件驱动(无 Initialize 参数)
- `EnemyStats.Initialize(so)` — 仅传入 `EnemyStatsSO`(架构 07_EnemyModule §2);难度缩放由 `DifficultyManager.Apply` 广播 `DifficultyChangedEventChannel` 统一驱动
---
## 6. Week 9:敌人扩展 + BossBase 骨架 ✅ 完成(2026-05-10)
**参考文档**:`07_EnemyModule.md §3-5`
### 6.1 新增敌人类型
**RangedEnemy**(远程攻击):
- 继承 `EnemyBase`
- BD BT:在攻击距离内静止 → `BeginAttack(AttackType.Ranged)` → 生成 `Projectile`
- `Projectile` 飞行中持有 `HitBox`,命中玩家 `HurtBox` 后归还对象池
**FlyingEnemy**(飞行巡逻):
- 继承 `EnemyBase`
- 不使用 PathBerserker2d(空中无需 NavSurface)
- 使用 `Vector2.MoveTowards` 直线追踪玩家
- 碰撞攻击(`OnTriggerStay2D`)
### 6.1.1 Projectile 弹射物系统
**参考文档**:`06_CombatModule.md §7`
```csharp
// Assets/Scripts/Combat/ProjectileConfigSO.cs
// ⚠️ 弹射物静态配置(数据与运行时实例分离),架构 06 §7
[CreateAssetMenu(menuName = "Combat/ProjectileConfig")]
public class ProjectileConfigSO : ScriptableObject
{
public DamageSourceSO DamageSource; // 伤害来源
[Header("运动")]
public float speed = 12f; // 飞行速度 (m/s)
public float lifetime = 5f; // 生存时间 (s)
public float launchAngleDeg = 45f; // ArcProjectile 发射角(度)
public float gravityScale = 1f; // ArcProjectile 重力缩放
public float homingStrength = 4f; // HomingProjectile 追踪角速度(弧度/秒)
[Header("对象池")]
public string poolKey; // AddressKeys 常量,用于 GlobalObjectPool(⚠️ 类名为 GlobalObjectPool,非 ObjectPoolManager,架构 13_AssetPoolModule §3)
[Header("弹反")]
public float parrySpeedMultiplier = 1.2f; // 弹反后速度倍率
public float parryDamageMultiplier = 2.0f; // 弹反伤害倍率
}
// Assets/Scripts/Combat/Projectile.cs(基类)
// ⚠️ 直线 LinearProjectile、抛物线 ArcProjectile、追踪 HomingProjectile 均继承此基类(架构 06 §7)
[RequireComponent(typeof(Rigidbody2D), typeof(HitBox))]
public class Projectile : MonoBehaviour
{
[HideInInspector] public DamageInfo DamageInfo; // 由发射方注入
[HideInInspector] public Vector2 Direction; // 归一化发射方向
protected ProjectileConfigSO _config;
protected Rigidbody2D _rb;
protected HitBox _hitBox;
protected float _aliveTimer;
public virtual void Initialize(ProjectileConfigSO config, DamageInfo damageInfo, Vector2 direction)
{
_config = config;
DamageInfo = damageInfo;
Direction = direction.normalized;
_aliveTimer = 0f;
_rb = GetComponent();
_hitBox = GetComponent();
OnInitialized();
}
protected virtual void OnInitialized() { }
protected virtual void Update()
{
_aliveTimer += Time.deltaTime;
if (_aliveTimer >= _config.lifetime) ReturnToPool();
}
protected void ReturnToPool()
{
gameObject.SetActive(false);
GlobalObjectPool.Instance.Despawn(_config.poolKey, gameObject); // ⚠️ GlobalObjectPool(非 ObjectPoolManager),架构 13_AssetPoolModule §3
}
}
// ── 抛物线弹射物(投石、毒液弹)────────────────────────────────────────────────
// ⚠️ ArcProjectile 继承 Projectile(架构 06 §7)
public class ArcProjectile : Projectile
{
protected override void OnInitialized()
{
float angle = _config.launchAngleDeg * Mathf.Deg2Rad;
_rb.velocity = new Vector2(
Direction.x * _config.speed * Mathf.Cos(angle),
_config.speed * Mathf.Sin(angle)
);
_rb.gravityScale = _config.gravityScale;
}
}
// ── 追踪弹射物(Boss 阶段特殊弹、追踪蜂群)────────────────────────────────────
// ⚠️ HomingProjectile 通过 TransformEventChannelSO 注入目标(零耦合),架构 06 §7
public class HomingProjectile : Projectile
{
[SerializeField] TransformEventChannelSO _onPlayerSpawned;
Transform _target;
void OnEnable() => _onPlayerSpawned.OnEventRaised += t => _target = t;
void OnDisable() => _onPlayerSpawned.OnEventRaised -= t => _target = t;
public void SetTarget(Transform t) => _target = t;
protected override void OnInitialized() => _rb.velocity = Direction * _config.speed;
protected override void Update()
{
base.Update();
if (_target == null) return;
Vector2 toTarget = ((Vector2)_target.position - (Vector2)transform.position).normalized;
Vector2 newVel = Vector2.MoveTowards(
_rb.velocity.normalized, toTarget,
_config.homingStrength * Time.deltaTime) * _config.speed;
_rb.velocity = newVel;
}
}
// ── ProjectileManager(追踪弹辅助缓存,常驻 Persistent 场景)─────────────────
// ⚠️ 架构 06 §7;在发射追踪弹时注入已缓存的玩家 Transform
public class ProjectileManager : MonoBehaviour
{
[SerializeField] TransformEventChannelSO _onPlayerSpawned;
Transform _playerTransform;
void OnEnable() => _onPlayerSpawned.OnEventRaised += t => _playerTransform = t;
void OnDisable() => _onPlayerSpawned.OnEventRaised -= t => _playerTransform = t;
public Transform PlayerTransform => _playerTransform;
public void LaunchHoming(HomingProjectile proj, Vector2 origin, Vector2 direction,
ProjectileConfigSO config, DamageInfo damageInfo)
{
proj.Initialize(config, damageInfo, direction);
proj.SetTarget(_playerTransform); // 直接注入缓存值
}
}
```
```csharp
// ⚠️ 字段名以架构 07_EnemyModule §14 为准(AddressKey→ItemId,DropChance→BaseWeight,GeoRange→GuaranteedGeoMin/Max)
[CreateAssetMenu(menuName = "Enemies/LootTable")]
public class LootTableSO : ScriptableObject
{
[Serializable]
public struct LootEntry
{
public string ItemId; // ⚠️ 非 AddressKey;"" 表示纯 Geo 掉落
public int GeoAmount; // ItemId 为空时使用
public float BaseWeight; // ⚠️ 非 DropChance;加权随机权重(相对值)
public bool ScaleWithDifficulty; // true → Hard 难度时权重 × 1.5
}
public LootEntry[] Entries;
public int GuaranteedGeoMin; // ⚠️ 非 GeoRange;死亡必掉最低 Geo
public int GuaranteedGeoMax;
}
public static class LootResolver
{
public static void Resolve(LootTableSO table, Vector2 worldPosition)
{
if (table == null) return;
// 1. 保底 Geo
int geo = Random.Range(table.GuaranteedGeoMin, table.GuaranteedGeoMax + 1);
if (geo > 0) CollectibleSpawner.SpawnGeo(worldPosition, geo);
// 2. 按 BaseWeight 加权随机(Hard 难度时 ScaleWithDifficulty=true 的条目权重 × 1.5)
float totalWeight = 0f;
var difficulty = DifficultyManager.Instance.CurrentLevel;
foreach (var entry in table.Entries)
{
float w = entry.BaseWeight;
if (entry.ScaleWithDifficulty && difficulty >= DifficultyLevel.Hard) w *= 1.5f;
totalWeight += w;
}
float roll = Random.Range(0f, totalWeight);
float cumulative = 0f;
foreach (var entry in table.Entries)
{
float w = entry.BaseWeight;
if (entry.ScaleWithDifficulty && difficulty >= DifficultyLevel.Hard) w *= 1.5f;
cumulative += w;
if (roll <= cumulative)
{
if (!string.IsNullOrEmpty(entry.ItemId))
CollectibleSpawner.SpawnItem(worldPosition, entry.ItemId);
break;
}
}
}
}
```
### 6.3 BossBase 骨架
```csharp
// Assets/Scripts/Enemies/Boss/BossBase.cs
public class BossBase : EnemyBase
{
[SerializeField] protected BossOrchestrator _orchestrator; // ⚠️ 架构 07_EnemyModule §10
[SerializeField] protected TelegraphSystem _telegraph; // ⚠️ 架构 07_EnemyModule §10
[Header("Boss 识别")]
[SerializeField] private string _bossId; // ⚠️ 用于事件 payload(无 BossConfigSO)
[Header("Event Channels")]
[SerializeField] private BoolEventChannelSO _onBossFightEnded; // ⚠️ Raise 方:BossBase.Die()(架构 02 §4)
[SerializeField] private BossPhaseEventChannelSO _onBossPhaseChanged; // ⚠️ Raise 方:BossBase.EnterPhase()
public string BossId => _bossId;
protected int _currentPhase = 0;
// Boss 专属接口(由 BD_EnterPhase 任务调用)
// ⚠️ 方法名为 EnterPhase,非 TransitionToPhase(架构 07_EnemyModule §9)
public virtual void EnterPhase(int phase)
{
_currentPhase = phase;
// 1. 短暂无敌
// 2. 播放 Phase 过渡演出动画
_onBossPhaseChanged.Raise(new BossPhaseEvent { BossId = _bossId, Phase = phase });
}
// HP 阈值检查(BD_IsHPBelow 任务使用,无 CheckPhaseTransition MonoBehaviour 方法)
public bool IsHPBelow(float ratio) => (float)_stats.CurrentHP / _stats.MaxHP < ratio;
public override void Die()
{
base.Die();
_onBossFightEnded.Raise(true); // 广播胜利
// 触发死亡过场(EventChain)
}
}
```
### 6.3.1 CameraStateController 完整实现(补充 Boss/Death 相机)
> **参考文档**:`17_CameraModule.md §3`
> Phase 1 仅创建了基础骨架(A/B 双机 + SwitchRoom + RegisterRoomCamera + TriggerImpulse)。Phase 2 添加 BossBase 时须同步完善 `CameraStateController`,加入以下字段和处理方法:
```csharp
// 补充到 CameraStateController(在 Phase 1 骨架基础上追加)
[Header("特殊状态相机(⚠️ 架构 17_CameraModule §3 要求)")]
[SerializeField] CinemachineCamera _vcamBoss; // Priority 30(Boss 战激活)
[SerializeField] CinemachineCamera _vcamDeath; // Priority 50(死亡时激活,1.0s EaseIn)
[SerializeField] CinemachineCamera _vcamCutscene; // Priority 40(Phase 4 Cutscene 时激活)
[Header("Phase 2 事件频道")]
[SerializeField] BoolEventChannelSO _onBossFightToggled; // true=开始 false=结束(⚠️ 与 BGMController 共用同一 SO)
[SerializeField] VoidEventChannelSO _onPlayerDied; // 死亡相机接管
[SerializeField] VoidEventChannelSO _onPlayerRespawned; // 死亡相机释放
// 补充事件订阅(在 OnEnable/OnDisable 中追加)
_onBossFightToggled.OnEventRaised += OnBossFightToggled;
_onPlayerDied.OnEventRaised += OnPlayerDied;
_onPlayerRespawned.OnEventRaised += OnPlayerRespawned;
// 补充处理方法
private void OnBossFightToggled(bool started)
{
_vcamBoss.Priority = started ? 30 : 0;
if (started) _brain.DefaultBlend =
new CinemachineBlendDefinition(CinemachineBlendDefinition.Style.EaseIn, 0.8f);
else _brain.DefaultBlend = new CinemachineBlendDefinition(
_defaultConfig.DefaultBlendStyle, _defaultConfig.DefaultBlendDuration);
}
private void OnPlayerDied()
{
_vcamDeath.Priority = 50; // 死亡相机接管,1.0s EaseIn
_brain.DefaultBlend =
new CinemachineBlendDefinition(CinemachineBlendDefinition.Style.EaseIn, 1.0f);
}
private void OnPlayerRespawned()
{
_vcamDeath.Priority = 0;
_brain.DefaultBlend = new CinemachineBlendDefinition(
_defaultConfig.DefaultBlendStyle, _defaultConfig.DefaultBlendDuration);
}
```
> **Persistent 场景结构更新**:在 `[CameraRig]` 下新增 `[SpecialCameras]` 子组,包含 `VCam_Boss`(Priority 0)、`VCam_Death`(Priority 0)、`VCam_Cutscene`(Priority 0)三个 CinemachineCamera。`VCam_Cutscene` Phase 4 才正式使用,但 Phase 2 可先创建 GO 留空。
---
### 6.4 DeathShade(死亡遗骸)
**文件**:`Assets/Scripts/World/DeathShade.cs`(架构 07_EnemyModule §12)
> 玩家死亡时由 `GameManager.HandlePlayerDied()` 在最后位置生成;与之交互可回收玩家死亡时持有的 Geo。
```csharp
// ⚠️ 完整实现以架构 07_EnemyModule §12 为准
// ⚠️ IInteractable 定义在 BaseGames.World 命名空间(Architecture 08 §7 / 14 §1)
public class DeathShade : MonoBehaviour, IInteractable
{
[Header("Event Channel")]
[SerializeField] private StringEventChannelSO _onGeoRecovered; // ⚠️ payload: sceneId(通知存档)
private int _storedGeo;
private string _sceneId;
private Vector2 _worldPosition;
public bool CanInteract => true;
public string InteractPrompt => "回收遗骸";
/// 由 GameManager(或 DeathShadeManager)在玩家死亡时调用。
public void Initialize(int geo, string sceneId, Vector2 pos)
{
_storedGeo = geo;
_sceneId = sceneId;
_worldPosition = pos;
transform.position = pos;
}
public void Interact(Transform player)
{
// ⚠️ PlayerController 无 Instance(Architecture 05 §2);通过 player 参数获取
var pc = player.GetComponent();
if (pc != null) pc.Stats.AddGeo(_storedGeo);
_onGeoRecovered.Raise(_sceneId);
Destroy(gameObject); // 或归还对象池
}
public void OnPlayerEnterRange(Transform player) { }
public void OnPlayerExitRange() { }
// 存档集成(世界状态持久化,架构 07_EnemyModule §12)
public DeathShadeSaveData GetSaveData();
public void LoadSaveData(DeathShadeSaveData data);
}
```
**GameManager 集成**(Plan 02 §4.4 `HandlePlayerDied` 补充):
- `HandlePlayerDied()` 在切入 `GameState.Dead` 后,通过 `[SerializeField] DeathShade _deadShadePrefab` + `Instantiate` 在玩家当前位置生成 DeathShade,并调用 `Initialize(currentGeo, currentSceneName, playerPos)`
- 玩家死后 Geo 清零;新死亡覆盖旧遗骸(旧 DeathShade 自动销毁)
---
## 7. 完成标准检查清单
> **图例**:`☑` = 代码实现完成(待 Unity 运行时验证)|`□` = 尚未实现
```
☑ 冲刺:DashState 实现(施加冲刺力 + 无敌帧)——待 Unity 内验证落地切换 Idle
☑ 蹬墙:WallSlideState + WallJumpState 实现——待 Unity 内验证墙面检测与弹跳方向
☑ 下劈:DownAttackState 实现(向下速度 + 着地切换 Idle)——待 Unity 内验证踩踏弹跳
☑ 上劈:UpAttackState 实现——待 Unity 内验证命中后进入 FallState
☑ ParryState 开启弹反窗口——待 ParrySystem 完整实现后验证成功/失败流程
☑ ShieldComponent 护盾吸收逻辑实现——待 HurtBox 护盾管道接入 + UI 耐久条
□ 状态效果:Poison 每秒掉血 + StatusEffectManager 正确 Tick + 到期自动移除
□ AnimationEventType:攻击动画 HitBox 激活时机与动画帧完全同步
□ EventConfigEditor:AnimationEventConfigSO Inspector 时间轴可视化正常渲染,Clip 漂移警告触发(偏差>5帧)
☑ FormController 三形态切换代码实现——待 Unity 内验证调色板切换与武器对应
☑ AudioMixer 快照 API(TransitionToSnapshot)实现——待 Unity 内配置 MainMixer 快照并验证过渡
☑ AudioEventSO + GlobalSFXPlayer 代码实现——待 Unity 内填充音效资产并连接 SFX 钩子
☑ DifficultyManager + DifficultyScalerSO 代码实现——待 Unity 内创建 4 份 SO 资产并验证 Hard 模式缩放
☑ BoolEventChannelSO 实现——供 BossBase._onBossFightEnded 使用
☑ RangedEnemy:SpawnProjectile 重写实现——待 Unity 内挂载 ProjectileConfigSO 并验证 Projectile 命中
☑ FlyingEnemy:Rigidbody2D MoveTowards 追击 + 接触伤害实现——待 Unity 内验证导航行为
☑ BossBase:EnterPhase + IsHPBelow + Die 广播实现——待 Unity 内验证 Phase 切换演出触发
☑ LootTableSO + LootResolver:加权随机掉落 + 难度缩放实现——待 Unity 内配置掉落表并验证
☑ ProjectileConfigSO + Projectile(Linear/Arc/Homing/Parryable) + ProjectileManager 实现——待对象池预热后验证
☑ DeathShade:IInteractable + Geo 回收事件实现——待 Unity 内验证交互流程
□ Console 无 Error(Unity 编辑器内编译验证)
```
### Week 5 已完成实现(2026-05-09)
| 文件 | 状态 | 说明 |
|------|------|------|
| `FormType.cs` | ✅ | Sky/Earth/Death 枚举 |
| `FormSO.cs` | ✅ | 单形态数据 SO |
| `FormConfigSO.cs` | ✅ | 三形态统一容器 |
| `FormController.cs` | ✅ | 形态切换 + C# 事件 + SO 事件频道 |
| `WeaponSO.cs` | ✅ | 完整扩展:6 方向动画片段 + DamageSource + VFX |
| `WeaponManager.cs` | ✅ | FormController 联动 + 护符 Override 字典 |
| `PlayerCombat.cs` | ✅ | HitBox 四向激活 + SetComboSegmentSource + Soul 增益 |
| `PlayerStats.cs` | ✅ | AddSoul 别名 + 现有完整 API |
| `PlayerMovement.cs` | ✅ | Dash / WallSlide / WallJump 方法 |
| `PlayerMovementConfigSO.cs` | ✅ | 补充 DefaultGravityScale = 3f |
| `PlayerWallDetector.cs` | ✅ | 独立组件,双射线每侧检测 |
| `HitBox.cs` | ✅ | SetDamageSource 方法 |
| `DashState.cs` | ✅ | 无敌帧 + 重力置零 + 冷却 |
| `AerialDashState.cs` | ✅ | 空中冲刺计数 + 落地重置 |
| `WallSlideState.cs` | ✅ | 下滑限速 + 蹬墙跳触发 |
| `WallJumpState.cs` | ✅ | 蹬墙跳 + 输入锁定 |
| `AirAttackState.cs` | ✅ | 空中攻击 + HitBoxAir |
| `DownAttackState.cs` | ✅ | 下劈冲击 + 着地检测 |
| `UpAttackState.cs` | ✅ | 上劈 + 结束分流 |
| `HurtState.cs` | ✅ | 受击 + 击退 + 无敌帧 |
| `DeadState.cs` | ✅ | 物理冻结 + HurtBox 关闭 |
| `SpringState.cs` | ✅ | 灵泉治疗动画 |
| `ParryState.cs` | ✅ | 弹反窗口开关 |
| `PlayerController.cs` | ✅ | Phase 2 完整扩展(IPoiseSource + 11 状态 + TakeDamage 路由)|
| `ShieldComponent.cs` | ✅ | Phase 2 完整实现(吸收 + 再生 + 破盾事件)|
### Week 8 已完成实现(2026-05-10)
| 文件 | 状态 | 说明 |
|------|------|------|
| `BoolEventChannelSO.cs` | ✅ | `BaseEventChannelSO` 频道,BossBase 依赖 |
| `DifficultyScalerSO.cs` | ✅ | 难度缩放参数 SO,含 AI/玩家/敌人/经济多维度字段 |
| `DifficultyManager.cs` | ✅ | 单例,`DefaultExecutionOrder(-900)`,SteelSoul 不可降级 |
| `AudioEventSO.cs` | ✅ | 随机音效 SO,支持 Play / PlayOneShot |
| `GlobalSFXPlayer.cs` | ✅ | 静态 SFX 入口,支持 2D 和世界坐标 3D 播放 |
| `EnemyBase._playerTransform` | ✅ | 改为 `protected`,子类(RangedEnemy/FlyingEnemy)可访问 |
| `BaseGames.Combat.asmdef` | ✅ | 新增 `BaseGames.Core` 引用,供 GlobalObjectPool 使用 |
### Week 9 已完成实现(2026-05-10)
| 文件 | 状态 | 说明 |
|------|------|------|
| `ProjectileConfigSO.cs` | ✅ | 抛射物配置 SO(速度/生命周期/弹反倍率/对象池键) |
| `Projectile.cs` | ✅ | 抽象基类,Initialize + ReturnToPool,依赖 PooledObject |
| `LinearProjectile.cs` | ✅ | 直线飞行,固定速度 |
| `ArcProjectile.cs` | ✅ | 抛物线,LaunchAngleDeg + GravityScale |
| `HomingProjectile.cs` | ✅ | 追踪弹,每帧向目标转向 |
| `ParryableProjectile.cs` | ✅ | 可弹反,手动 OnTriggerEnter2D 检测 ParrySystem |
| `ProjectileManager.cs` | ✅ | 单例,缓存 PlayerTransform,辅助 Homing 注入目标 |
| `RangedEnemy.cs` | ✅ | 重写 SpawnProjectile,GlobalObjectPool.Spawn + Initialize |
| `FlyingEnemy.cs` | ✅ | Rigidbody2D MoveTowards 追击 + OnTriggerStay2D 接触伤害 |
| `BossBase.cs` | ✅ | EnterPhase(int) + IsHPBelow(ratio) + Die 广播战斗结束 |
| `LootTableSO.cs` | ✅ | 战利品表 SO,LootEntry 含 BaseWeight + ScaleWithDifficulty |
| `LootResolver.cs` | ✅ | 加权随机掉落 + DifficultyManager 缩放保底 Geo |
| `DeathShade.cs` | ✅ | IInteractable,IntEventChannelSO 零耦合返还 Geo |
### P2-7 补充实现(2026-05-12)
| 文件 | 状态 | 说明 |
|------|------|------|
| `ILOSRequester.cs` | ✅ | 视线检测请求接口(`LOSOrigin`/`LOSTarget`/`LOSBlockingMask`/`ReceiveLOSResult`);命名空间 `BaseGames.Enemies.AI` |
| `BatchLOSSystem.cs` | ✅ | 批量视线检测,`[DefaultExecutionOrder(-200)]`,round-robin Raycast2D(最多 8/帧),`Register`/`Unregister` |
| `TelegraphSystem.cs` | ✅ | Boss 预警系统;`ShowTelegraph(vfxKey, duration, pos)` Coroutine;`GlobalObjectPool.Instance.Spawn`;`PooledObject.ReturnToPool()` |
| `EnemyQuotaManager.cs` | ✅ | BT 配额管理;最多 12 棵 BT 启用;每 10 帧按玩家距离排序重排 |
| `EnemyBase.cs` | ✅ | 扩展:实现 `ILOSRequester`;新增 `Nav`/`Movement`/`Stats`/`Animancer`/`AnimConfig`/`BehaviorTree`(`#if GRAPH_DESIGNER`)属性;`SetAggroTickRate`;`JumpTo` |
| `EnemyStatsSO.cs` | ✅ | 追加 `EyeOffset`(`Vector2(0,0.8f)`)+ `LOSBlockingMask`(LayerMask)字段 |
| `EnemyMovement.cs` | ✅ | 追加 `JumpToTarget(Vector2)` 抛物线跳跃方法 |
| `EnemyAnimationConfigSO.cs` | ✅ | 追加 `GetClipByName(string)` 方法(switch 路由 Idle/Walk/Run/Attack/Hurt/Death) |
| `BD_TeleportTo.cs` | ✅ | Action;单帧 `transform.position = Target.Value`,返回 Success |
| `BD_SummonMinions.cs` | ✅ | Action;`GlobalObjectPool.Instance.Spawn(key, pos, Quaternion.identity)`;随机角度偏移 |
| `BD_EnterPhase.cs` | ✅ | Action;`GetComponent()?.EnterPhase(PhaseIndex.Value)` |
| `BD_IsPlayerVisible.cs` | ✅ | Conditional;调用 `_enemy.IsPlayerVisible()` |
| `BD_CanAttack.cs` | ✅ | Conditional;调用 `_enemy.CanAttack()` |
| `BD_IsHPBelow.cs` | ✅ | Conditional;SharedFloat 阈值(0–1);`CurrentHP/MaxHP <= threshold` |
| `BD_IsGrounded.cs` | ✅ | Conditional;`_enemy.Movement?.IsGrounded` |
| `BD_IsNearEdge.cs` | ✅ | Conditional;`_enemy.Nav?.IsNearEdge()` |
| `BD_IsStateMatch.cs` | ✅ | Conditional;SharedInt TargetState;`(int)_enemy.CurrentState == TargetState.Value` |
### P2-8 补充实现(2026-05-13)
| 文件 | 状态 | 说明 |
|------|------|------|
| `ShieldComponent.cs` | ✅ | P2-2 VFX 钩子:新增 `_onShieldBrokenChannel`(VoidEventChannelSO)和 `_onShieldRestoredChannel`(VoidEventChannelSO);破碎时 Raise broken,破碎惩罚结束/FullRecharge 时 Raise restored |
| `PostProcessManager.cs` | ✅ | P2-5 VFX:后处理 Volume 分区管理器;Coroutine 渐变 Boss/Death/Victory Volume 权重;订阅 BossFightStarted/Ended、PlayerDied/Respawned、BossDefeated 事件频道 |
| `RegionLightController.cs` | ✅ | P2-5 VFX:区域灯光切换;订阅 `StringEventChannelSO _onRegionEntered`;Coroutine 渐变 Global Light 2D 颜色和强度 |
| `RegionLightCatalogSO.cs` | ✅ | P2-5 VFX:与 RegionLightController 同文件;regionId → Color + Intensity 映射 |
| `PaletteSwapSystem.cs` | ✅ | P2-5 VFX:形态调色板切换;`ApplyPalette(FormType)` 通过 MaterialPropertyBlock 设置 `_PaletteTex` |
| `PaletteCatalogSO.cs` | ✅ | P2-5 VFX:与 PaletteSwapSystem 同文件;FormType → Texture2D(LUT 1D 256×1 px)映射 |
| `BaseGames.VFX.asmdef` | ✅ | 新增 `BaseGames.Player` 引用(PaletteSwapSystem 需要 FormType) |
| `CombatSFXController.cs` | ✅ | P2-8 SFX 钩子:所有字段由 `AudioClip` 升级为 `AudioEventSO`;播放改为 `GlobalSFXPlayer.Play(sfx, pos)`,支持随机音量/音调/多片段 |