824 lines
30 KiB
Markdown
824 lines
30 KiB
Markdown
# 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>
|
||
/// 反馈执行器的抽象接口。
|
||
/// GameLogic(PlayerCombat / 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)后自动回池,调用方无需手动归还。
|
||
/// </summary>
|
||
public class VFXPool : MonoBehaviour
|
||
{
|
||
public static VFXPool Instance { get; private set; }
|
||
|
||
/// <summary>
|
||
/// 全局兜底超时(秒)。单个特效可在 Prefab 的 VFXPoolEntry 组件上覆盖。
|
||
/// 防止循环粒子或异常长时间特效永不回池导致内存膨胀。
|
||
/// </summary>
|
||
[SerializeField, Min(1f)] private float _globalMaxLifetime = 10f;
|
||
|
||
readonly Dictionary<AssetReferenceGameObject, Queue<ParticleSystem>> _pools = new();
|
||
|
||
void Awake() => Instance = this;
|
||
|
||
/// <summary>
|
||
/// 在世界坐标播放一次特效。Fire-and-forget(UniTask 自动回池)。
|
||
/// </summary>
|
||
/// <param name="maxLifetime">
|
||
/// > 0 时覆盖全局超时;≤ 0 时使用 <see cref="_globalMaxLifetime"/>。
|
||
/// </param>
|
||
public async UniTaskVoid Play(AssetReferenceGameObject vfxRef,
|
||
Vector3 position,
|
||
Quaternion rotation = default,
|
||
float maxLifetime = 0f)
|
||
{
|
||
var ps = await GetOrCreateAsync(vfxRef);
|
||
float limit = maxLifetime > 0f ? maxLifetime : _globalMaxLifetime;
|
||
|
||
ps.transform.SetPositionAndRotation(position, rotation);
|
||
ps.gameObject.SetActive(true);
|
||
ps.Play();
|
||
|
||
// 双重退出条件:粒子自然结束 OR 超时强制回收
|
||
using var cts = new System.Threading.CancellationTokenSource(
|
||
System.TimeSpan.FromSeconds(limit));
|
||
try
|
||
{
|
||
await UniTask.WaitUntil(() => !ps.IsAlive(true),
|
||
cancellationToken: cts.Token);
|
||
}
|
||
catch (System.OperationCanceledException)
|
||
{
|
||
// 超时:强制停止
|
||
ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
|
||
Debug.LogWarning(
|
||
$"[VFXPool] '{vfxRef.RuntimeKey}' 超过 {limit:F1}s 强制回收。" +
|
||
"请检查粒子是否设为 Loop 或 Duration 过长。");
|
||
}
|
||
|
||
ps.gameObject.SetActive(false);
|
||
_pools[vfxRef].Enqueue(ps);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 预热:预先创建若干实例避免首次播放卡顿。
|
||
/// </summary>
|
||
public async UniTask WarmupAsync(AssetReferenceGameObject vfxRef, int count)
|
||
{
|
||
for (int i = 0; i < count; i++)
|
||
{
|
||
var go = await Addressables.InstantiateAsync(vfxRef, transform).Task;
|
||
var ps = go.GetComponent<ParticleSystem>();
|
||
ps.gameObject.SetActive(false);
|
||
if (!_pools.ContainsKey(vfxRef)) _pools[vfxRef] = new Queue<ParticleSystem>();
|
||
_pools[vfxRef].Enqueue(ps);
|
||
}
|
||
}
|
||
|
||
async UniTask<ParticleSystem> GetOrCreateAsync(AssetReferenceGameObject vfxRef)
|
||
{
|
||
if (_pools.TryGetValue(vfxRef, out var q) && q.Count > 0)
|
||
return q.Dequeue();
|
||
|
||
// 池不存在时初始化
|
||
if (!_pools.ContainsKey(vfxRef))
|
||
_pools[vfxRef] = new Queue<ParticleSystem>();
|
||
|
||
var go = await Addressables.InstantiateAsync(vfxRef, transform).Task;
|
||
return go.GetComponent<ParticleSystem>();
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**`maxLifetime` 使用指南**:
|
||
|
||
| 特效类型 | 建议 `maxLifetime` | 说明 |
|
||
|----------|-------------------|------|
|
||
| 命中火花 / 数字 | 默认(3s) | 时长已知,无需覆盖 |
|
||
| 爆炸 / 大范围 | `5f` | 稍长,粒子散逸需时间 |
|
||
| Boss 相位特效 | `15f` | 长动画,须显式指定 |
|
||
| 环境氛围循环粒子 | **不使用 VFXPool** | Loop 粒子应手动管理生命周期 |
|
||
|
||
---
|
||
|
||
## 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] HitConfirmedEventChannelSO _onHitConfirmed;
|
||
[SerializeField] VFXCatalogSO _catalog;
|
||
|
||
void OnEnable() => _onHitConfirmed.OnEventRaised += HandleHit;
|
||
void OnDisable() => _onHitConfirmed.OnEventRaised -= HandleHit;
|
||
|
||
void HandleHit(HitInfo info)
|
||
{
|
||
if (_catalog.TryGetHitFX(info.DamageInfo.HitFxType, out var vfxRef))
|
||
VFXPool.Instance.Play(vfxRef, info.HitPoint).Forget();
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 9. HurtFlashController
|
||
|
||
```csharp
|
||
namespace BaseGames.VFX
|
||
{
|
||
/// <summary>
|
||
/// 受击白闪效果,通过 Material Property Block 修改 Shader 参数。
|
||
/// 挂在玩家/敌人的 SpriteRenderer 所在 GameObject 上。
|
||
/// 不复制 Material(避免 GC),通过 MaterialPropertyBlock 实现。
|
||
/// </summary>
|
||
public class HurtFlashController : MonoBehaviour
|
||
{
|
||
[SerializeField] SpriteRenderer _renderer;
|
||
[SerializeField] FeedbackConfigSO _config;
|
||
|
||
static readonly int FlashColorID = Shader.PropertyToID("_FlashColor");
|
||
static readonly int FlashAmountID = Shader.PropertyToID("_FlashAmount");
|
||
|
||
MaterialPropertyBlock _block;
|
||
|
||
void Awake() => _block = new MaterialPropertyBlock();
|
||
|
||
/// <summary>触发一次受击白闪(由 IFeedbackPlayer.PlayTakeHit 间接调用)。</summary>
|
||
public async UniTaskVoid Flash(CancellationToken ct = default)
|
||
{
|
||
_renderer.GetPropertyBlock(_block);
|
||
_block.SetColor(FlashColorID, _config.HurtFlashColor);
|
||
_block.SetFloat(FlashAmountID, 1f);
|
||
_renderer.SetPropertyBlock(_block);
|
||
|
||
await UniTask.Delay(
|
||
TimeSpan.FromSeconds(_config.HurtFlashDuration),
|
||
cancellationToken: ct);
|
||
|
||
_block.SetFloat(FlashAmountID, 0f);
|
||
_renderer.SetPropertyBlock(_block);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Shader 要求**:`SpriteRenderer` 所用 Shader 需支持 `_FlashColor`(Color)和 `_FlashAmount`(float 0~1)属性(URP Sprite-Lit-Flash 变体)。
|
||
|
||
---
|
||
|
||
## 10. PaletteSwapSystem
|
||
|
||
```csharp
|
||
namespace BaseGames.VFX
|
||
{
|
||
/// <summary>
|
||
/// 形态切换时替换玩家精灵调色板。
|
||
/// 通过 Texture2D 查找表(LUT)Shader 实现,不换 Sprite 资产。
|
||
/// 挂在玩家 SpriteRenderer 所在 GameObject 上。
|
||
/// </summary>
|
||
public class PaletteSwapSystem : MonoBehaviour
|
||
{
|
||
[SerializeField] SpriteRenderer _renderer;
|
||
[SerializeField] PaletteCatalogSO _catalog;
|
||
|
||
static readonly int PaletteTexID = Shader.PropertyToID("_PaletteTex");
|
||
|
||
MaterialPropertyBlock _block;
|
||
|
||
void Awake() => _block = new MaterialPropertyBlock();
|
||
|
||
/// <summary>切换到指定形态的调色板。由 FormController 调用。</summary>
|
||
public void ApplyPalette(FormType form)
|
||
{
|
||
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
|
||
{
|
||
public PaletteEntry[] entries;
|
||
|
||
public bool TryGetPalette(FormType form, out Texture2D tex)
|
||
{
|
||
foreach (var e in entries)
|
||
{
|
||
if (e.form == form) { tex = e.paletteLUT; return true; }
|
||
}
|
||
tex = null;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
[Serializable]
|
||
public struct PaletteEntry
|
||
{
|
||
public FormType form;
|
||
public Texture2D paletteLUT; // 1D 查找表纹理(256×1 px)
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 11. PostProcessManager
|
||
|
||
```csharp
|
||
namespace BaseGames.VFX
|
||
{
|
||
/// <summary>
|
||
/// 后处理 Volume 分区管理器,挂在 Persistent 场景 [PostProcess] GameObject 上。
|
||
/// 通过 DOTween 平滑 blend Weight,监听游戏状态事件。
|
||
/// </summary>
|
||
public class PostProcessManager : MonoBehaviour
|
||
{
|
||
[Header("Volume 引用(Persistent 场景内)")]
|
||
[SerializeField] Volume _underwaterVolume; // Priority=10
|
||
[SerializeField] Volume _bossArenaVolume; // Priority=10
|
||
[SerializeField] Volume _deathVolume; // Priority=20
|
||
[SerializeField] Volume _victoryVolume; // Priority=10
|
||
|
||
[Header("事件频道")]
|
||
[SerializeField] VoidEventChannelSO _onLiquidEntered;
|
||
[SerializeField] VoidEventChannelSO _onLiquidExited;
|
||
[SerializeField] VoidEventChannelSO _onBossFightStarted;
|
||
[SerializeField] VoidEventChannelSO _onBossFightEnded;
|
||
[SerializeField] VoidEventChannelSO _onPlayerDied;
|
||
[SerializeField] VoidEventChannelSO _onPlayerRespawned;
|
||
[SerializeField] VoidEventChannelSO _onBossDefeated;
|
||
|
||
[SerializeField] float _blendDuration = 0.4f;
|
||
|
||
private Volume[] _nonDefaultVolumes;
|
||
|
||
private void Awake()
|
||
{
|
||
_nonDefaultVolumes = new[] { _underwaterVolume, _bossArenaVolume, _deathVolume, _victoryVolume };
|
||
}
|
||
|
||
private void OnEnable()
|
||
{
|
||
_onLiquidEntered.OnEventRaised += () => BlendTo(_underwaterVolume);
|
||
_onLiquidExited.OnEventRaised += ResetAll;
|
||
_onBossFightStarted.OnEventRaised += () => BlendTo(_bossArenaVolume);
|
||
_onBossFightEnded.OnEventRaised += ResetAll;
|
||
_onPlayerDied.OnEventRaised += () => BlendTo(_deathVolume);
|
||
_onPlayerRespawned.OnEventRaised += ResetAll;
|
||
_onBossDefeated.OnEventRaised += () => BlendTo(_victoryVolume);
|
||
}
|
||
|
||
private void OnDisable()
|
||
{
|
||
_onLiquidEntered.OnEventRaised -= () => BlendTo(_underwaterVolume);
|
||
_onLiquidExited.OnEventRaised -= ResetAll;
|
||
_onBossFightStarted.OnEventRaised -= () => BlendTo(_bossArenaVolume);
|
||
_onBossFightEnded.OnEventRaised -= ResetAll;
|
||
_onPlayerDied.OnEventRaised -= () => BlendTo(_deathVolume);
|
||
_onPlayerRespawned.OnEventRaised -= ResetAll;
|
||
_onBossDefeated.OnEventRaised -= () => BlendTo(_victoryVolume);
|
||
}
|
||
|
||
private void BlendTo(Volume target)
|
||
{
|
||
foreach (var v in _nonDefaultVolumes)
|
||
DOTween.To(() => v.weight, x => v.weight = x, 0f, _blendDuration)
|
||
.SetAutoKill(true).SetLink(gameObject);
|
||
|
||
DOTween.To(() => target.weight, x => target.weight = x, 1f, _blendDuration)
|
||
.SetAutoKill(true).SetLink(gameObject);
|
||
}
|
||
|
||
private void ResetAll()
|
||
{
|
||
foreach (var v in _nonDefaultVolumes)
|
||
DOTween.To(() => v.weight, x => v.weight = x, 0f, _blendDuration)
|
||
.SetAutoKill(true).SetLink(gameObject);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### Volume 结构与 Profile 参数
|
||
|
||
```
|
||
Persistent 场景 [PostProcess]:
|
||
├── Volume_Default Priority=0 Weight=1.0(始终生效基础 Profile)
|
||
├── Volume_Underwater Priority=10 Weight=0(进水时 blend 到 1.0)
|
||
├── Volume_BossArena Priority=10 Weight=0(Boss 战开始时 blend 到 1.0)
|
||
├── Volume_Death Priority=20 Weight=0(玩家死亡时 blend 到 1.0)
|
||
└── Volume_Victory Priority=10 Weight=0(Boss 击败时 blend 到 1.0)
|
||
```
|
||
|
||
| Volume | Bloom | Color Grading | Vignette | Chromatic Aberration |
|
||
|--------|-------|--------------|----------|---------------------|
|
||
| Default | Intensity 0.3 | 正常 | 0.2 | 关闭 |
|
||
| Underwater | 0.1 | 青绿 Filter -0.3 | 0.45 | 0.4 |
|
||
| BossArena | 0.5 | 饱和度 +20% | 0.35 | 0.15 |
|
||
| Death | 0 | 去饱和度 -80% | 0.7(黑色)| 0.8 |
|
||
| Victory | 0.8(白色)| 亮度 +0.4 | 0 | 0 |
|
||
|
||
> **DOTween 规范**:所有 `DOTween.To()` 必须链式调用 `.SetAutoKill(true).SetLink(gameObject)`;禁止使用 `DOTween.KillAll()`。
|
||
|
||
---
|
||
|
||
## 12. RegionLightController
|
||
|
||
```csharp
|
||
namespace BaseGames.VFX
|
||
{
|
||
/// <summary>
|
||
/// 区域进出时平滑切换 Global Light 2D 颜色和强度。
|
||
/// 挂在 Persistent 场景 [Lighting] GameObject 上,监听 OnRegionEntered 事件频道。
|
||
/// </summary>
|
||
public class RegionLightController : MonoBehaviour
|
||
{
|
||
[SerializeField] Light2D _globalLight;
|
||
[SerializeField] RegionLightCatalogSO _catalog; // RegionId → 颜色 + 强度
|
||
[SerializeField] StringEventChannelSO _onRegionEntered;
|
||
[SerializeField] float _transitionDuration = 1.5f;
|
||
|
||
private void OnEnable() => _onRegionEntered.OnEventRaised += OnRegionEntered;
|
||
private void OnDisable() => _onRegionEntered.OnEventRaised -= OnRegionEntered;
|
||
|
||
private void OnRegionEntered(string regionId)
|
||
{
|
||
if (!_catalog.TryGet(regionId, out var config)) return;
|
||
DOTween.To(() => _globalLight.color,
|
||
x => _globalLight.color = x,
|
||
config.Color, _transitionDuration)
|
||
.SetAutoKill(true).SetLink(gameObject);
|
||
DOTween.To(() => _globalLight.intensity,
|
||
x => _globalLight.intensity = x,
|
||
config.Intensity, _transitionDuration)
|
||
.SetAutoKill(true).SetLink(gameObject);
|
||
}
|
||
}
|
||
|
||
[CreateAssetMenu(menuName = "VFX/RegionLightCatalog")]
|
||
public class RegionLightCatalogSO : ScriptableObject
|
||
{
|
||
[Serializable]
|
||
public struct RegionLightConfig
|
||
{
|
||
public string regionId;
|
||
public Color Color;
|
||
[Range(0f, 1f)] public float Intensity;
|
||
}
|
||
|
||
[SerializeField] RegionLightConfig[] _entries;
|
||
|
||
public bool TryGet(string regionId, out RegionLightConfig cfg)
|
||
{
|
||
foreach (var e in _entries)
|
||
{
|
||
if (e.regionId == regionId) { cfg = e; return true; }
|
||
}
|
||
cfg = default;
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 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` | 伤害数字弹出 | 浮动文字动画 |
|