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