Files
zeling_v2/Docs/Architecture/18_VFXFeedbackModule.md
2026-05-12 15:34:08 +08:00

938 lines
34 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
{
/// <summary>
/// 反馈执行器的抽象接口。
/// GameLogicPlayerCombat / EnemyBase依赖此接口不依赖 Feel 资产。
/// 测试时可替换为 NullFeedbackPlayer。
/// </summary>
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 用)
}
/// <summary>空实现,用于测试和无反馈需求场景。</summary>
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
{
/// <summary>
/// 挂在 Player Prefab 根节点下的 [Feedback] 子 GameObject 上。
/// 实现 IFeedbackPlayer聚合所有玩家相关 MMF_Player。
/// </summary>
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<string, MMF_Player> _presetMap;
void Awake()
{
_presetMap = new Dictionary<string, MMF_Player>
{
{ "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
{
/// <summary>
/// 挂在 EnemyBase Prefab 下的 [Feedback] 子 GameObject 上。
/// EnemyBase 通过 [SerializeField] EnemyFeedback _feedback 引用。
/// </summary>
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
{
/// <summary>
/// ParticleSystem 专用对象池,挂在 Persistent 场景 [VFXPool] GameObject 上。
/// 粒子播放完成(或超过 MaxLifetime后自动回池调用方无需手动归还。
/// 不依赖 UniTask使用 Coroutine 驱动回收。
/// </summary>
public class VFXPool : MonoBehaviour
{
public static VFXPool Instance { get; private set; }
[SerializeField, Min(1f)] private float _globalMaxLifetime = 10f;
private readonly Dictionary<AssetReferenceGameObject, Queue<ParticleSystem>> _pools
= new Dictionary<AssetReferenceGameObject, Queue<ParticleSystem>>();
private void Awake() => Instance = this;
/// <summary>
/// 在世界坐标播放一次特效Fire-and-forgetCoroutine 自动回池)。
/// </summary>
public void Play(AssetReferenceGameObject vfxRef,
Vector3 position,
Quaternion rotation = default,
float maxLifetime = 0f)
{
StartCoroutine(PlayCoroutine(vfxRef, position, rotation, maxLifetime));
}
/// <summary>预热:预先创建若干实例避免首次播放卡顿。</summary>
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<ParticleSystem>();
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<ParticleSystem>();
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<ParticleSystem>();
_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<HitFxType, AssetReferenceGameObject> _map;
/// <summary>在 GameManager.OnGameplayStarted 中调用,建立快速查表字典。</summary>
public void Initialize()
{
_map = new Dictionary<HitFxType, AssetReferenceGameObject>();
foreach (var e in hitEffects) _map[e.type] = e.vfxRef;
}
/// <summary>根据 HitFxType 查找对应的 VFX Prefab 引用。</summary>
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
{
/// <summary>
/// 监听 EVT_HitConfirmed 事件频道,从 VFXPool 取粒子播放命中特效。
/// 挂在 Persistent 场景 [VFXSystem] GameObject 上。
/// </summary>
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
{
/// <summary>
/// 受击白闪效果,通过 Material Property Block 修改 Shader 参数。
/// 挂在玩家/敌人的 SpriteRenderer 所在 GameObject 上。
/// 不复制 Material避免 GC通过 MaterialPropertyBlock 实现。
/// 若上一次闪白未结束则重置计时器(支持连击打断重置)。
/// </summary>
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<SpriteRenderer>();
_block = new MaterialPropertyBlock();
}
/// <summary>触发一次受击白闪。若上一次未结束则重置计时器。</summary>
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
{
/// <summary>
/// 形态切换时替换玩家精灵调色板。
/// 通过 Texture2D 查找表LUTShader 实现,不换 Sprite 资产。
/// 挂在玩家 SpriteRenderer 所在 GameObject 上。
/// 由 FormController 在切换形态时调用 ApplyPalette(FormType)。
/// </summary>
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<SpriteRenderer>();
_block = new MaterialPropertyBlock();
}
/// <summary>切换到指定形态的调色板。由 FormController.SwitchForm() 调用。</summary>
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
{
/// <summary>
/// 后处理 Volume 分区管理器,挂在 Persistent 场景 [PostProcess] GameObject 上。
/// 通过 Coroutine 平滑 blend Weight监听游戏状态/Boss/死亡/胜利事件。
/// 注意:水下后处理由 UnderwaterPostProcessingControllerWorld.Liquid独立负责此组件不处理。
/// </summary>
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=0Boss 战开始时 blend 到 1.0
├── Volume_Death Priority=20 Weight=0玩家死亡时 blend 到 1.0
└── Volume_Victory Priority=10 Weight=0Boss 击败时 blend 到 1.0
```
---
## 12. RegionLightController
```csharp
namespace BaseGames.VFX
{
/// <summary>
/// 区域进出时平滑切换 Global Light 2D 颜色和强度。
/// 挂在 Persistent 场景 [Lighting] GameObject 上,监听 OnRegionEntered 事件频道。
/// 使用 Coroutine 实现平滑过渡,不依赖 DOTween。
/// </summary>
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 受此层光照 |
| 57 | _预留_ | 未来扩展 |
**规则**:地形 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` | 伤害数字弹出 | 浮动文字动画 |