# 18 · VFX 与反馈模块(VFX & Feedback Module)
> **命名空间** `BaseGames.VFX` / `BaseGames.Feedback`
> **程序集** `BaseGames.VFX`(`Assets/Scripts/VFX/`)
> **依赖** Feel v4.3 · `BaseGames.Core`(GlobalObjectPool)· `BaseGames.Combat`(HitFxType · DamageInfo)· `BaseGames.Camera`(CameraStateController)
> **Design 来源** [41_VFXArchitecture](../Design/41_VFXArchitecture.md) · [07_FeedbackSystem](../Design/07_FeedbackSystem.md)
---
## 目录
1. [模块职责](#1-模块职责)
2. [IFeedbackPlayer 接口](#2-ifeedbackplayer-接口)
3. [PlayerFeedback](#3-playerfeedback)
4. [EnemyFeedback](#4-enemyfeedback)
5. [FeedbackConfigSO](#5-feedbackconfigso)
6. [VFXPool — 粒子对象池](#6-vfxpool--粒子对象池)
7. [VFXCatalogSO — VFX 映射字典](#7-vfxcatalogso--vfx-映射字典)
8. [HitFXSpawner](#8-hitfxspawner)
9. [HurtFlashController](#9-hurtflashcontroller)
10. [PaletteSwapSystem](#10-paletteswapsystem)
11. [PostProcessManager](#11-postprocessmanager)
12. [RegionLightController](#12-regionlightcontroller)
13. [事件频道](#13-事件频道)
14. [VFX 分类与生命周期策略](#14-vfx-分类与生命周期策略)
15. [VFX 性能约束](#15-vfx-性能约束)
16. [MMF_Player 命名规范](#16-mmf_player-命名规范)
---
## 1. 模块职责
```
VFX & Feedback 模块职责:
├─ IFeedbackPlayer → 反馈抽象接口(GameLogic 依赖此接口,不依赖 Feel)
├─ PlayerFeedback → 玩家侧 MMF_Player 聚合,实现 IFeedbackPlayer
├─ EnemyFeedback → 敌人侧 MMF_Player 聚合
├─ FeedbackConfigSO → 全局反馈参数(冻帧时长、震屏强度等)
├─ VFXPool → ParticleSystem 专用对象池
├─ VFXCatalogSO → HitFxType → VFX Prefab AddressKey 映射
├─ HitFXSpawner → 监听 OnHitConfirmed,路由命中特效
├─ HurtFlashController → Shader 属性块驱动的受击白闪
└─ PaletteSwapSystem → 形态切换时精灵调色板替换
```
**零耦合**:`HitFXSpawner` 通过 `EVT_HitConfirmed` 事件频道获取命中信息,不直接引用战斗系统。
---
## 2. IFeedbackPlayer 接口
```csharp
namespace BaseGames.Feedback
{
///
/// 反馈执行器的抽象接口。
/// GameLogic(PlayerCombat / EnemyBase)依赖此接口,不依赖 Feel 资产。
/// 测试时可替换为 NullFeedbackPlayer。
///
public interface IFeedbackPlayer
{
void PlayHit(HitWeight weight); // 命中反馈(轻/中/重)
void PlayParrySuccess(); // 弹反成功
void PlayTakeHit(); // 玩家/敌人受击
void PlayDeath(); // 死亡演出
void PlayHeal(); // 治疗
void PlayLandImpact(); // 落地冲击
void PlayAttackWhoosh(); // 攻击挥动音效
void PlayJumpLaunch(); // 起跳
void TriggerPreset(string presetId); // 通过 ID 触发任意预设(AnimEvent 用)
void PlaySFXById(string sfxId); // 通过 ID 播放音效(AnimEvent 用)
}
/// 空实现,用于测试和无反馈需求场景。
public class NullFeedbackPlayer : IFeedbackPlayer
{
public void PlayHit(HitWeight w) { }
public void PlayParrySuccess() { }
public void PlayTakeHit() { }
public void PlayDeath() { }
public void PlayHeal() { }
public void PlayLandImpact() { }
public void PlayAttackWhoosh() { }
public void PlayJumpLaunch() { }
public void TriggerPreset(string id) { }
public void PlaySFXById(string id) { }
}
public enum HitWeight { Light, Medium, Heavy }
}
```
---
## 3. PlayerFeedback
```csharp
namespace BaseGames.Feedback
{
///
/// 挂在 Player Prefab 根节点下的 [Feedback] 子 GameObject 上。
/// 实现 IFeedbackPlayer,聚合所有玩家相关 MMF_Player。
///
public class PlayerFeedback : MonoBehaviour, IFeedbackPlayer
{
// ── MMF_Player 引用(Inspector 配置)────────────────
[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;
// ── Dictionary 预设(runtime,用于 TriggerPreset)───
Dictionary _presetMap;
void Awake()
{
_presetMap = new Dictionary
{
{ "HitLight", _onHitLight },
{ "HitMedium", _onHitMedium },
{ "HitHeavy", _onHitHeavy },
{ "ParrySuccess", _onParrySuccess },
{ "TakeHit", _onTakeHit },
{ "Death", _onDeath },
{ "LandImpact", _onLandImpact },
};
}
// ── IFeedbackPlayer 实现 ─────────────────────────────
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)
{
// ⚠️ AudioManager.PlaySFX 接受 AudioClip(非 string)(架构 11_AudioModule §2)
// sfxId → AudioClip 解析需通过 SFX 目录;此处留空实现待扩展
// 具体用法:持有 SFXCatalogSO 引用,查表后调用 AudioManager.Instance.PlaySFX(clip)
}
}
}
```
**Player Prefab 层级**:
```
[Player]
└── [Feedback]
├── PlayerFeedback.cs
├── MMF_Player_OnHitLight
├── MMF_Player_OnHitMedium
├── MMF_Player_OnHitHeavy
├── MMF_Player_OnParrySuccess
├── MMF_Player_OnTakeHit
├── MMF_Player_OnDeath
├── MMF_Player_OnHeal
├── MMF_Player_OnLandImpact
├── MMF_Player_OnAttackWhoosh
└── MMF_Player_OnJumpLaunch
```
---
## 4. EnemyFeedback
```csharp
namespace BaseGames.Feedback
{
///
/// 挂在 EnemyBase Prefab 下的 [Feedback] 子 GameObject 上。
/// EnemyBase 通过 [SerializeField] EnemyFeedback _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;
// IFeedbackPlayer 实现(敌人侧只需命中/受击/死亡)
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) { }
}
}
```
---
## 5. FeedbackConfigSO
```csharp
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;
}
}
```
**资产路径**:`Assets/ScriptableObjects/Feedback/Feedback_Config.asset`
---
## 6. VFXPool — 粒子对象池
```csharp
namespace BaseGames.VFX
{
///
/// ParticleSystem 专用对象池,挂在 Persistent 场景 [VFXPool] GameObject 上。
/// 粒子播放完成(或超过 MaxLifetime)后自动回池,调用方无需手动归还。
/// 不依赖 UniTask,使用 Coroutine 驱动回收。
///
public class VFXPool : MonoBehaviour
{
public static VFXPool Instance { get; private set; }
[SerializeField, Min(1f)] private float _globalMaxLifetime = 10f;
private readonly Dictionary> _pools
= new Dictionary>();
private void Awake() => Instance = this;
///
/// 在世界坐标播放一次特效(Fire-and-forget,Coroutine 自动回池)。
///
public void Play(AssetReferenceGameObject vfxRef,
Vector3 position,
Quaternion rotation = default,
float maxLifetime = 0f)
{
StartCoroutine(PlayCoroutine(vfxRef, position, rotation, maxLifetime));
}
/// 预热:预先创建若干实例避免首次播放卡顿。
public void Warmup(AssetReferenceGameObject vfxRef, int count)
{
StartCoroutine(WarmupCoroutine(vfxRef, count));
}
private IEnumerator PlayCoroutine(AssetReferenceGameObject vfxRef,
Vector3 position, Quaternion rotation, float maxLifetime)
{
ParticleSystem ps = null;
if (!TryDequeue(vfxRef, out ps))
{
var op = Addressables.InstantiateAsync(vfxRef, transform);
yield return op;
if (op.Result == null) { Debug.LogError($"[VFXPool] Failed: {vfxRef.RuntimeKey}"); yield break; }
ps = op.Result.GetComponent();
if (ps == null) { Addressables.ReleaseInstance(op.Result); yield break; }
ps.gameObject.SetActive(false);
}
ps.transform.SetPositionAndRotation(position, rotation);
ps.gameObject.SetActive(true);
ps.Play();
float limit = maxLifetime > 0f ? maxLifetime : _globalMaxLifetime;
float elapsed = 0f;
while (elapsed < limit && ps.IsAlive(true))
{
elapsed += Time.deltaTime;
yield return null;
}
if (ps.IsAlive(true))
{
ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
Debug.LogWarning($"[VFXPool] '{vfxRef.RuntimeKey}' 超过 {limit:F1}s 强制回收。");
}
ps.gameObject.SetActive(false);
Enqueue(vfxRef, ps);
}
private IEnumerator WarmupCoroutine(AssetReferenceGameObject vfxRef, int count)
{
for (int i = 0; i < count; i++)
{
var op = Addressables.InstantiateAsync(vfxRef, transform);
yield return op;
if (op.Result == null) continue;
var ps = op.Result.GetComponent();
if (ps == null) { Addressables.ReleaseInstance(op.Result); continue; }
ps.gameObject.SetActive(false);
Enqueue(vfxRef, ps);
}
}
private bool TryDequeue(AssetReferenceGameObject key, out ParticleSystem ps)
{
ps = null;
return _pools.TryGetValue(key, out var q) && q.Count > 0 && (ps = q.Dequeue()) != null;
}
private void Enqueue(AssetReferenceGameObject key, ParticleSystem ps)
{
if (!_pools.ContainsKey(key)) _pools[key] = new Queue();
_pools[key].Enqueue(ps);
}
}
}
```
---
## 7. VFXCatalogSO — VFX 映射字典
```csharp
namespace BaseGames.VFX
{
[CreateAssetMenu(menuName = "VFX/VFXCatalog")]
public class VFXCatalogSO : ScriptableObject
{
[Header("命中特效映射")]
public VFXEntry[] hitEffects; // HitFxType → VFX Prefab
[Header("预热配置")]
public VFXWarmupEntry[] warmups;
private Dictionary _map;
/// 在 GameManager.OnGameplayStarted 中调用,建立快速查表字典。
public void Initialize()
{
_map = new Dictionary();
foreach (var e in hitEffects) _map[e.type] = e.vfxRef;
}
/// 根据 HitFxType 查找对应的 VFX Prefab 引用。
public bool TryGetHitFX(HitFxType type, out AssetReferenceGameObject vfxRef)
{
if (_map != null) return _map.TryGetValue(type, out vfxRef);
// 未初始化时回退至线性查找
foreach (var e in hitEffects)
{
if (e.type == type) { vfxRef = e.vfxRef; return true; }
}
vfxRef = default;
return false;
}
}
[Serializable]
public struct VFXEntry
{
public HitFxType type;
public AssetReferenceGameObject vfxRef;
}
[Serializable]
public struct VFXWarmupEntry
{
public AssetReferenceGameObject vfxRef;
[Min(1)] public int warmupCount; // 建议 3~5
}
}
```
**资产路径**:`Assets/ScriptableObjects/VFX/VFX_Catalog.asset`
---
## 8. HitFXSpawner
```csharp
namespace BaseGames.VFX
{
///
/// 监听 EVT_HitConfirmed 事件频道,从 VFXPool 取粒子播放命中特效。
/// 挂在 Persistent 场景 [VFXSystem] GameObject 上。
///
public class HitFXSpawner : MonoBehaviour
{
[SerializeField] private HitConfirmedEventChannelSO _onHitConfirmed;
[SerializeField] private VFXCatalogSO _catalog;
private void Awake()
{
if (_catalog != null)
_catalog.Initialize();
}
private void OnEnable()
{
if (_onHitConfirmed != null)
_onHitConfirmed.OnEventRaised += HandleHit;
}
private void OnDisable()
{
if (_onHitConfirmed != null)
_onHitConfirmed.OnEventRaised -= HandleHit;
}
private void HandleHit(HitInfo info)
{
if (_catalog == null || VFXPool.Instance == null) return;
if (_catalog.TryGetHitFX(info.DamageInfo.FxType, out var vfxRef))
VFXPool.Instance.Play(vfxRef, info.HitPoint);
}
}
}
```
---
## 9. HurtFlashController
```csharp
namespace BaseGames.VFX
{
///
/// 受击白闪效果,通过 Material Property Block 修改 Shader 参数。
/// 挂在玩家/敌人的 SpriteRenderer 所在 GameObject 上。
/// 不复制 Material(避免 GC),通过 MaterialPropertyBlock 实现。
/// 若上一次闪白未结束则重置计时器(支持连击打断重置)。
///
public class HurtFlashController : MonoBehaviour
{
[SerializeField] private SpriteRenderer _renderer;
[SerializeField] private FeedbackConfigSO _config;
private static readonly int FlashColorID = Shader.PropertyToID("_FlashColor");
private static readonly int FlashAmountID = Shader.PropertyToID("_FlashAmount");
private MaterialPropertyBlock _block;
private Coroutine _flashCoroutine;
private void Awake()
{
if (_renderer == null)
_renderer = GetComponent();
_block = new MaterialPropertyBlock();
}
/// 触发一次受击白闪。若上一次未结束则重置计时器。
public void Flash()
{
if (_flashCoroutine != null)
StopCoroutine(_flashCoroutine);
_flashCoroutine = StartCoroutine(FlashCoroutine());
}
private IEnumerator FlashCoroutine()
{
SetFlash(1f);
yield return new WaitForSeconds(_config != null ? _config.HurtFlashDuration : 0.12f);
SetFlash(0f);
_flashCoroutine = null;
}
private void SetFlash(float amount)
{
_renderer.GetPropertyBlock(_block);
if (_config != null)
_block.SetColor(FlashColorID, _config.HurtFlashColor);
_block.SetFloat(FlashAmountID, amount);
_renderer.SetPropertyBlock(_block);
}
}
}
```
**Shader 要求**:`SpriteRenderer` 所用 Shader 需支持 `_FlashColor`(Color)和 `_FlashAmount`(float 0~1)属性(URP Sprite-Lit-Flash 变体)。
---
## 10. PaletteSwapSystem
```csharp
namespace BaseGames.VFX
{
///
/// 形态切换时替换玩家精灵调色板。
/// 通过 Texture2D 查找表(LUT)Shader 实现,不换 Sprite 资产。
/// 挂在玩家 SpriteRenderer 所在 GameObject 上。
/// 由 FormController 在切换形态时调用 ApplyPalette(FormType)。
///
public class PaletteSwapSystem : MonoBehaviour
{
[SerializeField] private SpriteRenderer _renderer;
[SerializeField] private PaletteCatalogSO _catalog;
private static readonly int PaletteTexID = Shader.PropertyToID("_PaletteTex");
private MaterialPropertyBlock _block;
private void Awake()
{
if (_renderer == null)
_renderer = GetComponent();
_block = new MaterialPropertyBlock();
}
/// 切换到指定形态的调色板。由 FormController.SwitchForm() 调用。
public void ApplyPalette(FormType form)
{
if (_catalog == null || _renderer == null) return;
if (!_catalog.TryGetPalette(form, out var tex)) return;
_renderer.GetPropertyBlock(_block);
_block.SetTexture(PaletteTexID, tex);
_renderer.SetPropertyBlock(_block);
}
}
[CreateAssetMenu(menuName = "VFX/PaletteCatalog")]
public class PaletteCatalogSO : ScriptableObject
{
[SerializeField] private PaletteEntry[] _entries;
public bool TryGetPalette(FormType form, out Texture2D tex)
{
if (_entries != null)
{
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)
}
}
```
---
## 11. PostProcessManager
```csharp
namespace BaseGames.VFX
{
///
/// 后处理 Volume 分区管理器,挂在 Persistent 场景 [PostProcess] GameObject 上。
/// 通过 Coroutine 平滑 blend Weight,监听游戏状态/Boss/死亡/胜利事件。
/// 注意:水下后处理由 UnderwaterPostProcessingController(World.Liquid)独立负责,此组件不处理。
///
public class PostProcessManager : MonoBehaviour
{
[Header("Volume 引用(Persistent 场景内)")]
[SerializeField] private Volume _bossArenaVolume; // Priority=10
[SerializeField] private Volume _deathVolume; // Priority=20
[SerializeField] private Volume _victoryVolume; // Priority=10
[Header("事件频道")]
[SerializeField] private VoidEventChannelSO _onBossFightStarted;
[SerializeField] private VoidEventChannelSO _onBossFightEnded;
[SerializeField] private VoidEventChannelSO _onPlayerDied;
[SerializeField] private VoidEventChannelSO _onPlayerRespawned;
[SerializeField] private VoidEventChannelSO _onBossDefeated;
[SerializeField] private float _blendDuration = 0.4f;
private Volume[] _managedVolumes;
private Coroutine _blendCoroutine;
private void Awake()
{
_managedVolumes = new[] { _bossArenaVolume, _deathVolume, _victoryVolume };
}
private void OnEnable()
{
if (_onBossFightStarted != null) _onBossFightStarted.OnEventRaised += HandleBossFightStarted;
if (_onBossFightEnded != null) _onBossFightEnded.OnEventRaised += HandleBossFightEnded;
if (_onPlayerDied != null) _onPlayerDied.OnEventRaised += HandlePlayerDied;
if (_onPlayerRespawned != null) _onPlayerRespawned.OnEventRaised += HandlePlayerRespawned;
if (_onBossDefeated != null) _onBossDefeated.OnEventRaised += HandleBossDefeated;
}
private void OnDisable()
{
if (_onBossFightStarted != null) _onBossFightStarted.OnEventRaised -= HandleBossFightStarted;
if (_onBossFightEnded != null) _onBossFightEnded.OnEventRaised -= HandleBossFightEnded;
if (_onPlayerDied != null) _onPlayerDied.OnEventRaised -= HandlePlayerDied;
if (_onPlayerRespawned != null) _onPlayerRespawned.OnEventRaised -= HandlePlayerRespawned;
if (_onBossDefeated != null) _onBossDefeated.OnEventRaised -= HandleBossDefeated;
}
private void HandleBossFightStarted() => BlendTo(_bossArenaVolume);
private void HandleBossFightEnded() => ResetAll();
private void HandlePlayerDied() => BlendTo(_deathVolume);
private void HandlePlayerRespawned() => ResetAll();
private void HandleBossDefeated() => BlendTo(_victoryVolume);
private void BlendTo(Volume target)
{
if (_blendCoroutine != null) StopCoroutine(_blendCoroutine);
_blendCoroutine = StartCoroutine(BlendCoroutine(target, 1f));
}
private void ResetAll()
{
if (_blendCoroutine != null) StopCoroutine(_blendCoroutine);
_blendCoroutine = StartCoroutine(ResetAllCoroutine());
}
private IEnumerator BlendCoroutine(Volume target, float targetWeight)
{
float elapsed = 0f;
float[] startWeights = new float[_managedVolumes.Length];
for (int i = 0; i < _managedVolumes.Length; i++)
startWeights[i] = _managedVolumes[i] != null ? _managedVolumes[i].weight : 0f;
while (elapsed < _blendDuration)
{
elapsed += Time.unscaledDeltaTime;
float t = Mathf.Clamp01(elapsed / _blendDuration);
for (int i = 0; i < _managedVolumes.Length; i++)
{
if (_managedVolumes[i] == null) continue;
float dest = _managedVolumes[i] == target ? targetWeight : 0f;
_managedVolumes[i].weight = Mathf.Lerp(startWeights[i], dest, t);
}
yield return null;
}
}
private IEnumerator ResetAllCoroutine()
{
float elapsed = 0f;
float[] startWeights = new float[_managedVolumes.Length];
for (int i = 0; i < _managedVolumes.Length; i++)
startWeights[i] = _managedVolumes[i] != null ? _managedVolumes[i].weight : 0f;
while (elapsed < _blendDuration)
{
elapsed += Time.unscaledDeltaTime;
float t = Mathf.Clamp01(elapsed / _blendDuration);
for (int i = 0; i < _managedVolumes.Length; i++)
{
if (_managedVolumes[i] == null) continue;
_managedVolumes[i].weight = Mathf.Lerp(startWeights[i], 0f, t);
}
yield return null;
}
}
}
}
```
> **⚠️ 实现说明**:
> - 使用 `Time.unscaledDeltaTime`,暂停期间 blend 不受 `Time.timeScale` 影响
> - 水下 Volume(`_underwaterVolume`)由 `World.Liquid` 模块中的 `UnderwaterPostProcessingController` 独立管理,不在此组件中
> - 不使用 DOTween;不使用 Lambda 订阅(Lambda 无法从事件中移除)
### Volume 结构与 Profile 参数
```
Persistent 场景 [PostProcess]:
├── Volume_Default Priority=0 Weight=1.0(始终生效基础 Profile)
├── Volume_Underwater Priority=10 Weight=0(由 UnderwaterPostProcessingController 管理)
├── 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)
```
---
## 12. RegionLightController
```csharp
namespace BaseGames.VFX
{
///
/// 区域进出时平滑切换 Global Light 2D 颜色和强度。
/// 挂在 Persistent 场景 [Lighting] GameObject 上,监听 OnRegionEntered 事件频道。
/// 使用 Coroutine 实现平滑过渡,不依赖 DOTween。
///
public class RegionLightController : MonoBehaviour
{
[SerializeField] private Light2D _globalLight;
[SerializeField] private RegionLightCatalogSO _catalog;
[SerializeField] private StringEventChannelSO _onRegionEntered;
[SerializeField] private float _transitionDuration = 1.5f;
private Coroutine _colorCoroutine;
private Coroutine _intensityCoroutine;
private void OnEnable()
{
if (_onRegionEntered != null)
_onRegionEntered.OnEventRaised += OnRegionEntered;
}
private void OnDisable()
{
if (_onRegionEntered != null)
_onRegionEntered.OnEventRaised -= OnRegionEntered;
}
private void OnRegionEntered(string regionId)
{
if (_catalog == null || _globalLight == null) return;
if (!_catalog.TryGet(regionId, out var config)) return;
if (_colorCoroutine != null) StopCoroutine(_colorCoroutine);
if (_intensityCoroutine != null) StopCoroutine(_intensityCoroutine);
_colorCoroutine = StartCoroutine(TweenColor(_globalLight.color, config.Color, _transitionDuration));
_intensityCoroutine = StartCoroutine(TweenIntensity(_globalLight.intensity, config.Intensity, _transitionDuration));
}
private IEnumerator TweenColor(Color from, Color to, float duration)
{
float elapsed = 0f;
while (elapsed < duration)
{
elapsed += Time.deltaTime;
_globalLight.color = Color.Lerp(from, to, Mathf.Clamp01(elapsed / duration));
yield return null;
}
_globalLight.color = to;
}
private IEnumerator TweenIntensity(float from, float to, float duration)
{
float elapsed = 0f;
while (elapsed < duration)
{
elapsed += Time.deltaTime;
_globalLight.intensity = Mathf.Lerp(from, to, Mathf.Clamp01(elapsed / duration));
yield return null;
}
_globalLight.intensity = to;
}
}
[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] private RegionLightConfig[] _entries;
public bool TryGet(string regionId, out RegionLightConfig cfg)
{
if (_entries != null)
{
foreach (var e in _entries)
{
if (e.regionId == regionId) { cfg = e; return true; }
}
}
cfg = default;
return false;
}
}
}
```
### URP 2D 光照层(Light Layer)规范
| Light Layer | 名称 | 用途 |
|------------|------|------|
| 0 | `Default` | 全局环境光(所有对象默认受光)|
| 1 | `Environment` | 蜡烛、火把、发光水晶等环境光源 |
| 2 | `PlayerLight` | 玩家携带光源(如灵力发光)|
| 3 | `EnemyLight` | Boss 爆炸闪光、特殊敌人发光体 |
| 4 | `FXOnly` | 仅特效 ParticleSystem 受此层光照 |
| 5–7 | _预留_ | 未来扩展 |
**规则**:地形 Tilemap 仅勾选 Layer 0;粒子特效勾选 Layer 0 + 4;敌人勾选 Layer 0 + 3。
### 区域 Global Light 2D 参数速查
| 区域 | 颜色 | 强度 |
|------|------|------|
| Forest(扎根森林)| `#C8E8D0`(淡绿)| 0.8 |
| Cave(腐蚀洞穴)| `#1A0A2E`(深紫)| 0.2 |
| Ruins(坍塌废墟)| `#3D3028`(暖褐)| 0.5 |
| Abyss(深渊裂隙)| `#000820`(极暗蓝)| 0.1 |
| Core(核心熔炉)| `#4A1800`(暗红橙)| 0.6 |
---
## 13. 事件频道
| 频道 SO | Payload | 发布者 | 订阅者 |
|--------|---------|--------|--------|
| `EVT_HitConfirmed` | `HitInfo` | `HurtBox.ReceiveDamage` | `HitFXSpawner`、`AudioManager`、`PlayerFeedback`(受击方) |
| `EVT_RegionEntered` | `string regionId` | `RegionTrigger` | `RegionLightController`、`AudioManager` |
> 注:`HurtFlashController.Flash()` 和 `CameraStateController.TriggerImpulse()` 均由 `IFeedbackPlayer`(`PlayerFeedback`)通过直接调用触发,不使用独立事件频道。
---
## 14. VFX 分类与生命周期策略
| 分类 | 示例 | 生命周期策略 | 说明 |
|------|------|------------|------|
| **命中特效** | 刀击火花、魔法爆炸 | 对象池(VFXPool)| 频繁触发,必须池化 |
| **状态特效** | 持续燃烧、中毒气泡 | Prefab 跟随目标 Transform | 随目标销毁,不需要池 |
| **环境特效** | 灰尘、落叶、水泡 | 场景内预放置,常驻播放 | 不产生运行时 Instantiate |
| **能力特效** | 冲刺残影、双跳光圈 | 对象池(VFXPool)| 中等频率,建议池化 |
| **死亡特效** | 敌人爆裂、Boss 死亡烟花 | Addressables.InstantiateAsync | 低频但复杂,不值得池化 |
| **UI 特效** | 升级闪光、货币飞入 | Timeline Signal / MMF_Player | 在 UI Canvas 子层用 UI Particle |
**原则**:帧内可能多次触发(命中、脚步等)→ 池化;每关卡最多触发数次 → Addressables 按需加载。
### HitFxType 枚举值速查
| 枚举值 | 特效 | 建议粒子数 |
|-------|------|----------|
| `Spark` | 金属碰撞火花 | ≤ 8 |
| `Blood` | 敌人受击血迹 | ≤ 12 |
| `Magic` | 法术命中光环 | ≤ 15 |
| `Crit` | 暴击大爆炸 | ≤ 20 |
| `Void` | 深渊伤害涟漪 | ≤ 10 |
| `Heal` | 治疗绿光粒子 | ≤ 8 |
| `Parry` | 弹反白光爆散 | ≤ 20 |
---
## 15. VFX 性能约束
| 约束项 | 限制 | 备注 |
|--------|------|------|
| 同屏活跃粒子总数 | ≤ 500 | 超过时 GPU 帧时上升 |
| 单次命中特效粒子数 | ≤ 20 | HitFxType 表格已约束 |
| 同屏 Light 2D 数量 | ≤ 16 | 超过时触发 URP 2D 光照降级 |
| Shadow Caster 2D 数量 | ≤ 32 | Composite 模式下 1 Tilemap = 1 |
| 新增 VFX Prefab | 必须通过 VFXPool 统一管理 | PR Review 清单检查项 |
| `Material.SetXxx()` 直接调用 | 禁止 | 一律用 MaterialPropertyBlock |
| `Instantiate(ParticleSystem)` 运行时调用 | 禁止 | 一律从 VFXPool.Play() 取 |
---
## 16. MMF_Player 命名规范
| 格式 | 示例 |
|------|------|
| `MMF_{Owner}_{EventName}` | `MMF_Player_OnTakeHit`、`MMF_Enemy_OnDeath`、`MMF_Boss_OnPhaseChange` |
**常用 Feedback 类型速查**:
| Feel Feedback | 用途 | 关键参数 |
|--------------|------|---------|
| `MMF_Flash` | Sprite 受击白闪 | `FlashColor`, `Duration` |
| `MMF_FreezeFrame` | 命中冻帧 | `FreezeDuration`(来自 FeedbackConfigSO) |
| `MMF_TimeScale` | 子弹时间 | `TimeScale`, `Duration` |
| `MMF_CinemachineImpulse` | 镜头震动 | `ImpulseSource`, `Velocity` |
| `MMF_Particles` | 命中粒子 | `ParticleSystem` 引用 |
| `MMF_AudioSource` | 音效 | `AudioClip`, `PitchVariance` |
| `MMF_TextMeshPro` | 伤害数字弹出 | 浮动文字动画 |