549 lines
18 KiB
C#
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;
|
|
}
|
|
}
|