Files
bansonic_beta_main/Assets/scripts/gamePlay_gameplay/TrackJudgeHitEffectController.cs
T
2026-07-30 23:15:58 +08:00

549 lines
18 KiB
C#

using System.Collections;
using System.Collections.Generic;
using SpriteGlow;
using UnityEngine;
public class TrackJudgeHitEffectController : MonoBehaviour
{
public static TrackJudgeHitEffectController Instance { get; private set; }
[Header("Track Sprite FX")]
[Tooltip("0-4 maps to track 1-5.")]
[SerializeField] private GameObject[] trackSpriteFxObjects = new GameObject[5];
[Header("Track Glow FX")]
[Tooltip("0-4 maps to track 1-5.")]
[SerializeField] private SpriteGlowEffect[] trackGlowEffects = new SpriteGlowEffect[5];
[SerializeField, Range(0f, 1f)] private float trackGlowSpriteAlpha = 0f;
[Header("Particle FX")]
[SerializeField] private GameObject trackParticlePrefab;
[SerializeField] private Vector3 trackParticleLocalPosition = new Vector3(0f, -5.29f, -0.13f);
[SerializeField] private Vector3 trackParticleLocalEulerAngles = new Vector3(70f, 0f, 0f);
[SerializeField] private Vector3 trackParticleLocalScale = Vector3.one;
[Header("Track Colors")]
[SerializeField] private Color[] trackColors = new Color[5]
{
new Color(1f, 0.44f, 0.42f, 1f),
new Color(0.22f, 0.96f, 0.79f, 1f),
new Color(1f, 0.88f, 0.63f, 1f),
new Color(0.95f, 0.51f, 0.89f, 1f),
new Color(0.48f, 0.98f, 1f, 1f)
};
[Header("Fade")]
[SerializeField, Range(0f, 1f)] private float initialAlpha = 10f / 255f;
[SerializeField] private float fadeDuration = 0.18f;
[SerializeField] private float glowFadeDuration = 0.18f;
[SerializeField] private bool useUnscaledTime = true;
private readonly Dictionary<int, Coroutine> activeFadeRoutines = new Dictionary<int, Coroutine>();
private readonly Dictionary<int, Coroutine> activeGlowFadeRoutines = new Dictionary<int, Coroutine>();
private readonly Queue<GameObject> particlePool = new Queue<GameObject>();
private Transform particlePoolRoot;
private readonly HashSet<GameObject> rentedParticles = new HashSet<GameObject>();
private readonly Dictionary<GameObject, int> particleRentIds = new Dictionary<GameObject, int>();
private int particleRentCounter;
// Per-instance cache: the particle systems and computed lifetime are resolved once
// when an instance is created, so the burst hot path never calls
// GetComponentsInChildren (an allocating call) per hit. Business behavior is
// unchanged: same systems, same tint, same lifetime.
private sealed class ParticleCacheEntry
{
public ParticleSystem[] Systems;
public float Lifetime;
}
private readonly Dictionary<GameObject, ParticleCacheEntry> particleCache =
new Dictionary<GameObject, ParticleCacheEntry>();
// 【性能】延迟回收改用 Update 轮询,替代每命中 StartCoroutine + new WaitForSecondsRealtime 的分配。
// dueUnscaledTime 用 Time.unscaledTime 记录到期时刻,与原 WaitForSecondsRealtime(realtime)语义一致;
// token 校验也原样保留,故回收时机与行为完全等效,仅消除每命中的协程/等待对象 GC。
private struct PendingParticleReturn
{
public GameObject Instance;
public ParticleCacheEntry Entry;
public int RentToken;
public float DueUnscaledTime;
}
private readonly List<PendingParticleReturn> pendingParticleReturns = new List<PendingParticleReturn>();
private void Awake()
{
if (Instance == null)
{
Instance = this;
ResetAllGlowAlpha();
ResetAllGlowSpriteAlpha();
}
else if (Instance != this)
{
Destroy(gameObject);
return;
}
}
private void OnDisable()
{
ReturnAllRentedParticles();
}
private void OnDestroy()
{
if (Instance == this)
{
Instance = null;
}
ReturnAllRentedParticles();
}
public static void PlayTrackHitFx(int trackIndex)
{
var controller = Instance != null ? Instance : SceneObjectLookupCache.FindAny<TrackJudgeHitEffectController>();
if (controller == null)
return;
controller.PlayTrackHitFxInternal(trackIndex);
}
private void PlayTrackHitFxInternal(int trackIndex)
{
if (trackIndex < 0 || trackIndex >= 5)
return;
GameObject trackFxObject = GetTrackFxObject(trackIndex);
if (trackFxObject == null)
return;
if (activeGlowFadeRoutines.TryGetValue(trackIndex, out Coroutine glowRoutine) && glowRoutine != null)
{
StopCoroutine(glowRoutine);
activeGlowFadeRoutines.Remove(trackIndex);
}
if (activeFadeRoutines.TryGetValue(trackIndex, out Coroutine fadeRoutine) && fadeRoutine != null)
{
StopCoroutine(fadeRoutine);
activeFadeRoutines.Remove(trackIndex);
}
trackFxObject.SetActive(true);
PlayGlowFx(trackIndex);
SpriteRenderer spriteRenderer = trackFxObject.GetComponent<SpriteRenderer>();
if (spriteRenderer == null)
{
spriteRenderer = trackFxObject.GetComponentInChildren<SpriteRenderer>(true);
}
if (spriteRenderer != null)
{
Color c = spriteRenderer.color;
c.a = initialAlpha;
spriteRenderer.color = c;
activeFadeRoutines[trackIndex] = StartCoroutine(FadeSpriteRoutine(trackIndex, spriteRenderer));
}
SpawnTrackParticle(trackIndex, trackFxObject.transform);
}
private void ResetAllGlowAlpha()
{
if (trackGlowEffects == null)
return;
for (int i = 0; i < trackGlowEffects.Length; i++)
{
SetGlowAlpha(trackGlowEffects[i], 0f);
}
}
private void ResetAllGlowSpriteAlpha()
{
if (trackGlowEffects == null)
return;
for (int i = 0; i < trackGlowEffects.Length; i++)
{
ResetGlowSpriteAlpha(trackGlowEffects[i]);
}
}
private GameObject GetTrackFxObject(int trackIndex)
{
if (trackSpriteFxObjects == null || trackIndex < 0 || trackIndex >= trackSpriteFxObjects.Length)
return null;
return trackSpriteFxObjects[trackIndex];
}
private SpriteGlowEffect GetTrackGlowEffect(int trackIndex)
{
if (trackGlowEffects == null || trackIndex < 0 || trackIndex >= trackGlowEffects.Length)
return null;
return trackGlowEffects[trackIndex];
}
private void ResetGlowSpriteAlpha(SpriteGlowEffect glowEffect)
{
if (glowEffect == null || glowEffect.Renderer == null)
return;
Color color = glowEffect.Renderer.color;
color.a = trackGlowSpriteAlpha;
glowEffect.Renderer.color = color;
}
private void PlayGlowFx(int trackIndex)
{
SpriteGlowEffect glowEffect = GetTrackGlowEffect(trackIndex);
if (glowEffect == null)
return;
ResetGlowSpriteAlpha(glowEffect);
SetGlowAlpha(glowEffect, 1f);
if (activeGlowFadeRoutines.TryGetValue(trackIndex, out Coroutine routine) && routine != null)
{
StopCoroutine(routine);
activeGlowFadeRoutines.Remove(trackIndex);
}
activeGlowFadeRoutines[trackIndex] = StartCoroutine(FadeGlowRoutine(trackIndex, glowEffect));
}
private void SetGlowAlpha(SpriteGlowEffect glowEffect, float alpha)
{
if (glowEffect == null)
return;
Color color = glowEffect.GlowColor;
color.a = alpha;
glowEffect.GlowColor = color;
}
private IEnumerator FadeSpriteRoutine(int trackIndex, SpriteRenderer spriteRenderer)
{
float duration = Mathf.Max(0.01f, fadeDuration);
float elapsed = 0f;
Color startColor = spriteRenderer != null ? spriteRenderer.color : Color.white;
Color endColor = startColor;
endColor.a = 0f;
while (elapsed < duration)
{
if (spriteRenderer == null)
yield break;
elapsed += useUnscaledTime ? Time.unscaledDeltaTime : Time.deltaTime;
float t = Mathf.Clamp01(elapsed / duration);
Color c = Color.LerpUnclamped(startColor, endColor, t);
spriteRenderer.color = c;
yield return null;
}
if (spriteRenderer != null)
{
Color c = spriteRenderer.color;
c.a = 0f;
spriteRenderer.color = c;
}
activeFadeRoutines.Remove(trackIndex);
}
private IEnumerator FadeGlowRoutine(int trackIndex, SpriteGlowEffect glowEffect)
{
float duration = Mathf.Max(0.01f, glowFadeDuration);
float elapsed = 0f;
Color startColor = glowEffect != null ? glowEffect.GlowColor : Color.white;
Color endColor = startColor;
endColor.a = 0f;
while (elapsed < duration)
{
if (glowEffect == null)
yield break;
elapsed += useUnscaledTime ? Time.unscaledDeltaTime : Time.deltaTime;
float t = Mathf.Clamp01(elapsed / duration);
Color c = Color.LerpUnclamped(startColor, endColor, t);
glowEffect.GlowColor = c;
yield return null;
}
if (glowEffect != null)
{
Color c = glowEffect.GlowColor;
c.a = 0f;
glowEffect.GlowColor = c;
}
activeGlowFadeRoutines.Remove(trackIndex);
}
private void SpawnTrackParticle(int trackIndex, Transform parent)
{
if (trackParticlePrefab == null || parent == null)
return;
GameObject instance = RentParticleInstance(out ParticleCacheEntry entry);
if (instance == null)
return;
Transform safeParent = parent != null && parent.gameObject.activeInHierarchy ? parent : transform;
instance.transform.SetParent(safeParent, false);
instance.transform.localPosition = trackParticleLocalPosition;
instance.transform.localRotation = Quaternion.Euler(trackParticleLocalEulerAngles);
instance.transform.localScale = trackParticleLocalScale;
instance.SetActive(true);
Color tint = GetTrackColor(trackIndex);
ApplyParticleColor(entry, tint);
// 入队延迟回收(Update 轮询),替代每命中 StartCoroutine + WaitForSecondsRealtime 分配。等效。
float delay = Mathf.Max(0.05f, entry.Lifetime);
pendingParticleReturns.Add(new PendingParticleReturn
{
Instance = instance,
Entry = entry,
RentToken = CurrentParticleRentToken(instance),
DueUnscaledTime = Time.unscaledTime + delay
});
}
private void Update()
{
if (pendingParticleReturns.Count == 0)
return;
float nowUnscaled = Time.unscaledTime;
for (int i = pendingParticleReturns.Count - 1; i >= 0; i--)
{
var pending = pendingParticleReturns[i];
if (nowUnscaled < pending.DueUnscaledTime)
continue;
pendingParticleReturns.RemoveAt(i);
// 与原 RecycleParticleRoutine 等效:实例已毁或已被重新租用(token 变了)则不回收。
if (pending.Instance == null)
continue;
if (CurrentParticleRentToken(pending.Instance) != pending.RentToken)
continue;
ReturnParticleInstance(pending.Instance, pending.Entry);
}
}
// Prewarm the track particle pool during load so the first judge does not pay
// the Instantiate + first-Play() cost on the hot path. Business behavior is
// unchanged: the same prefab is used, instances are just reused instead of
// being created and destroyed per hit.
public void PrewarmTrackParticles(int count)
{
if (trackParticlePrefab == null || count <= 0)
return;
EnsurePoolRoot();
for (int i = 0; i < count; i++)
{
GameObject instance = CreateParticleInstance();
if (instance == null)
continue;
instance.transform.SetParent(particlePoolRoot, false);
instance.SetActive(false);
particlePool.Enqueue(instance);
}
}
private void EnsurePoolRoot()
{
if (particlePoolRoot == null)
{
var root = new GameObject("TrackParticlePool");
root.transform.SetParent(transform, false);
root.SetActive(false);
particlePoolRoot = root.transform;
}
}
// Central instance factory: instantiates the prefab, resolves and caches its
// particle systems + computed lifetime once, and leaves it stopped/cleared.
private GameObject CreateParticleInstance()
{
if (trackParticlePrefab == null)
return null;
GameObject instance = Instantiate(trackParticlePrefab);
var systems = instance.GetComponentsInChildren<ParticleSystem>(true);
if (systems == null)
systems = new ParticleSystem[0];
var entry = new ParticleCacheEntry
{
Systems = systems,
Lifetime = ComputeLifetime(systems)
};
// Warm the play path once (forces emission buffer allocation and first-Play
// cost during load), then reset to a clean stopped state ready for reuse.
foreach (var ps in systems)
{
if (ps == null) continue;
ps.Play(true);
ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
ps.Clear(true);
}
particleCache[instance] = entry;
return instance;
}
private GameObject RentParticleInstance(out ParticleCacheEntry entry)
{
while (particlePool.Count > 0)
{
GameObject pooled = particlePool.Dequeue();
if (pooled != null && particleCache.TryGetValue(pooled, out entry))
{
pooled.transform.SetParent(null, false);
MarkParticleRented(pooled);
return pooled;
}
}
GameObject created = CreateParticleInstance();
if (created != null && particleCache.TryGetValue(created, out entry))
{
MarkParticleRented(created);
return created;
}
entry = null;
return created;
}
private int MarkParticleRented(GameObject instance)
{
if (instance == null)
return -1;
rentedParticles.Add(instance);
int token = ++particleRentCounter;
particleRentIds[instance] = token;
return token;
}
private int CurrentParticleRentToken(GameObject instance)
{
return instance != null && particleRentIds.TryGetValue(instance, out int token) ? token : -1;
}
// 说明:原 RecycleParticleRoutine 协程(每命中 StartCoroutine + new WaitForSecondsRealtime)已由
// Update 轮询的 pendingParticleReturns 替代,回收时机(unscaled/realtime)与 token 校验语义完全一致。
private void ReturnParticleInstance(GameObject instance, ParticleCacheEntry entry)
{
if (instance == null)
return;
if (!rentedParticles.Remove(instance))
return;
particleRentIds.Remove(instance);
if (entry != null && entry.Systems != null)
{
foreach (var ps in entry.Systems)
{
if (ps == null) continue;
ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
ps.Clear(true);
}
}
instance.SetActive(false);
EnsurePoolRoot();
instance.transform.SetParent(particlePoolRoot, false);
particlePool.Enqueue(instance);
}
private void ReturnAllRentedParticles()
{
var snapshot = new List<GameObject>(rentedParticles);
foreach (var instance in snapshot)
{
if (instance == null)
continue;
particleCache.TryGetValue(instance, out var entry);
ReturnParticleInstance(instance, entry);
}
// 所有租用粒子已强制回收,清空延迟回收队列避免残留项(与原协程在 OnDisable/OnDestroy 随之停止等效)。
pendingParticleReturns.Clear();
}
private Color GetTrackColor(int trackIndex)
{
if (trackColors != null && trackIndex >= 0 && trackIndex < trackColors.Length)
{
return trackColors[trackIndex];
}
return Color.white;
}
private void ApplyParticleColor(ParticleCacheEntry entry, Color tint)
{
if (entry == null || entry.Systems == null)
return;
foreach (var ps in entry.Systems)
{
if (ps == null) continue;
var main = ps.main;
main.startColor = tint;
if (!ps.isPlaying)
{
ps.Play(true);
}
}
}
private static float ComputeLifetime(ParticleSystem[] systems)
{
float maxLifetime = 0.5f;
if (systems == null)
return maxLifetime;
foreach (var ps in systems)
{
if (ps == null) continue;
var main = ps.main;
float startLifetime = 0.5f;
if (main.startLifetime.mode == ParticleSystemCurveMode.Constant)
startLifetime = main.startLifetime.constant;
else if (main.startLifetime.mode == ParticleSystemCurveMode.TwoConstants)
startLifetime = main.startLifetime.constantMax;
else
startLifetime = main.startLifetime.constantMax;
maxLifetime = Mathf.Max(maxLifetime, main.duration + startLifetime + 0.25f);
}
return maxLifetime;
}
}