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 activeFadeRoutines = new Dictionary(); private readonly Dictionary activeGlowFadeRoutines = new Dictionary(); private readonly Queue particlePool = new Queue(); private Transform particlePoolRoot; private readonly HashSet rentedParticles = new HashSet(); private readonly Dictionary particleRentIds = new Dictionary(); 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 particleCache = new Dictionary(); // 【性能】延迟回收改用 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 pendingParticleReturns = new List(); 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(); 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(); if (spriteRenderer == null) { spriteRenderer = trackFxObject.GetComponentInChildren(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(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(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; } }