# 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)`,支持随机音量/音调/多片段 |