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

34 KiB
Raw Permalink Blame History

18 · VFX 与反馈模块VFX & Feedback Module

命名空间 BaseGames.VFX / BaseGames.Feedback
程序集 BaseGames.VFXAssets/Scripts/VFX/
依赖 Feel v4.3 · BaseGames.CoreGlobalObjectPool· BaseGames.CombatHitFxType · DamageInfo· BaseGames.CameraCameraStateController
Design 来源 41_VFXArchitecture · 07_FeedbackSystem


目录

  1. 模块职责
  2. IFeedbackPlayer 接口
  3. PlayerFeedback
  4. EnemyFeedback
  5. FeedbackConfigSO
  6. VFXPool — 粒子对象池
  7. VFXCatalogSO — VFX 映射字典
  8. HitFXSpawner
  9. HurtFlashController
  10. PaletteSwapSystem
  11. PostProcessManager
  12. RegionLightController
  13. 事件频道
  14. VFX 分类与生命周期策略
  15. VFX 性能约束
  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 接口

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

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

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

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 — 粒子对象池

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 映射字典

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

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

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 需支持 _FlashColorColor_FlashAmountfloat 0~1属性URP Sprite-Lit-Flash 变体)。


10. PaletteSwapSystem

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

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

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 HitFXSpawnerAudioManagerPlayerFeedback(受击方)
EVT_RegionEntered string regionId RegionTrigger RegionLightControllerAudioManager

注:HurtFlashController.Flash()CameraStateController.TriggerImpulse() 均由 IFeedbackPlayerPlayerFeedback)通过直接调用触发,不使用独立事件频道。


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_OnTakeHitMMF_Enemy_OnDeathMMF_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 伤害数字弹出 浮动文字动画