using UnityEngine; using TMPro; using System.Collections; // Documentation text normalized. using System.Collections.Generic; // For List public class AnimationController : MonoBehaviour { [HideInInspector] public bool isGlobalController = false; public static AnimationController Global; public GameObject redEffect; // Documentation text normalized. public GameObject greenEffect; // Documentation text normalized. public GameObject yellowEffect; // Documentation text normalized. public GameObject purpleEffect; // Documentation text normalized. public GameObject blueEffect; // Documentation text normalized. private Animator redAnimator; private Animator greenAnimator; private Animator yellowAnimator; private Animator purpleAnimator; private Animator blueAnimator; // Scene object used as particle source (must be assigned in scene or found automatically) public GameObject hit_particular_object; public GameObject hit_ring_object; [Header("Inspector")] public string redColorHex = "#FF9390"; public string greenColorHex = "#38F6CA"; public string yellowColorHex = "#FFE1A2"; public string purpleColorHex = "#F083E4"; public string blueColorHex = "#7AF9FF"; [Header("Inspector")] public float holdParticleInterval = 0.2f; private Coroutine holdParticleCoroutine; private bool holdActive = false; private string currentHoldColor; private bool isHolding = false; private WaitForSeconds cachedHoldWait; private float cachedHoldWaitSeconds = -1f; private static bool loggedMissingHitParticleSource; // Track active particle instances for immediate cleanup private List activeParticles = new List(); // Per-hit FX pooling. PlayDestroyAnimation used to Instantiate + Destroy three // objects on every judge (hit particle, hit ring, judge text prefab). Under dense // note bursts that is the dominant GC + instantiation spike. We now reuse instances // from a pool keyed by template, and cache each instance's ParticleSystem[] + // computed lifetime so the hot path never calls GetComponentsInChildren per hit. // Business behavior is unchanged: same templates, same tint, same lifetime, same // parenting; instances are recycled instead of created and destroyed. private sealed class FxCacheEntry { public ParticleSystem[] Systems; public float Lifetime; } private readonly Dictionary> fxPools = new Dictionary>(); private readonly Dictionary fxCache = new Dictionary(); private readonly Dictionary fxInstanceTemplate = new Dictionary(); // Instances currently rented out. Return is guarded by this set so the timed // RecycleFxRoutine and an explicit StopHoldParticles cleanup can never return the // same instance twice (which would double-enqueue it and hand it out concurrently). private readonly HashSet rentedFx = new HashSet(); // Per-rent token. Each rent bumps a counter and records it for the instance. The // timed RecycleFxRoutine captures its token and only returns the instance if the // token still matches, so a stale timer from a previous rent cannot recycle an // instance that was already returned early (StopHoldParticles) and re-rented. private int fxRentCounter; private readonly Dictionary fxRentId = new Dictionary(); private Transform fxPoolRoot; private void EnsureFxPoolRoot() { if (fxPoolRoot == null) { var root = new GameObject("AnimationControllerFxPool"); root.transform.SetParent(transform, false); root.SetActive(false); fxPoolRoot = root.transform; } } // Creates one instance of the template, caches its particle systems + lifetime, // warms the play path once, and leaves it stopped/cleared ready for reuse. private GameObject CreateFxInstance(GameObject template) { if (template == null) return null; GameObject instance = Instantiate(template); var systems = instance.GetComponentsInChildren(true); if (systems == null) systems = new ParticleSystem[0]; var entry = new FxCacheEntry { Systems = systems, Lifetime = ComputeFxLifetime(systems) }; foreach (var ps in systems) { if (ps == null) continue; try { ps.Play(true); } catch { } try { ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); } catch { } try { ps.Clear(true); } catch { } } fxCache[instance] = entry; fxInstanceTemplate[instance] = template; return instance; } private GameObject RentFxInstance(GameObject template, out FxCacheEntry entry, out int rentToken) { entry = null; rentToken = -1; if (template == null) return null; if (fxPools.TryGetValue(template, out var pool)) { while (pool.Count > 0) { GameObject pooled = pool.Dequeue(); if (pooled != null && fxCache.TryGetValue(pooled, out entry)) { rentToken = MarkRented(pooled); return pooled; } } } GameObject created = CreateFxInstance(template); if (created != null) { fxCache.TryGetValue(created, out entry); rentToken = MarkRented(created); } return created; } private int CurrentRentToken(GameObject instance) { return fxRentId.TryGetValue(instance, out int token) ? token : -1; } private int MarkRented(GameObject instance) { rentedFx.Add(instance); int token = ++fxRentCounter; fxRentId[instance] = token; return token; } private void ReturnFxInstance(GameObject instance) { if (instance == null) return; // Idempotent: if this instance was already returned, ignore the second call. if (!rentedFx.Remove(instance)) return; fxRentId.Remove(instance); activeParticles.Remove(instance); if (fxCache.TryGetValue(instance, out var entry) && entry.Systems != null) { foreach (var ps in entry.Systems) { if (ps == null) continue; try { ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); } catch { } try { ps.Clear(true); } catch { } } } instance.SetActive(false); EnsureFxPoolRoot(); instance.transform.SetParent(fxPoolRoot, false); if (!fxInstanceTemplate.TryGetValue(instance, out var template) || template == null) { // Unknown origin (should not happen for pooled instances): drop it. Destroy(instance); return; } if (!fxPools.TryGetValue(template, out var pool)) { pool = new Queue(); fxPools[template] = pool; } pool.Enqueue(instance); } private IEnumerator RecycleFxRoutine(GameObject instance, int rentToken, float delay) { yield return new WaitForSeconds(delay); if (instance == null) yield break; // Only recycle if this instance is still on the same rent. If it was // returned early (StopHoldParticles) and re-rented in the meantime, its // token changed and this stale timer must not touch it. if (CurrentRentToken(instance) != rentToken) yield break; ReturnFxInstance(instance); } private static float ComputeFxLifetime(ParticleSystem[] systems) { float maxLifetime = 0f; if (systems == null) return maxLifetime; foreach (var ps in systems) { if (ps == null) continue; var main = ps.main; var lifetime = main.startLifetime; float life = lifetime.constantMax; if (life <= 0f) life = lifetime.constant; if (life > maxLifetime) maxLifetime = life; } return maxLifetime; } [Header("Inspector")] public GameObject perfect_judge_prefab; public GameObject great_judge_prefab; public GameObject good_judge_prefab; public GameObject miss_judge_prefab; [Header("Inspector")] // Documentation text normalized. public TextMeshProUGUI red_track_judgementText; public TextMeshProUGUI green_track_judgementText; public TextMeshProUGUI yellow_track_judgementText; public TextMeshProUGUI purple_track_judgementText; public TextMeshProUGUI blue_track_judgementText; public void StartHoldParticles(string color) { // Prefer the Global controller if available so Start/Stop always affect the same instance if (AnimationController.Global != null && AnimationController.Global != this) { AnimationController.Global.InternalStartHoldParticles(color); return; } InternalStartHoldParticles(color); } public void StopHoldParticles() { if (AnimationController.Global != null && AnimationController.Global != this) { AnimationController.Global.InternalStopHoldParticles(); return; } InternalStopHoldParticles(); } // Internal implementations operate on this instance private void InternalStartHoldParticles(string color) { if (holdActive) return; holdActive = true; currentHoldColor = color; if (holdParticleCoroutine != null) { StopCoroutine(holdParticleCoroutine); } holdParticleCoroutine = StartCoroutine(HoldParticleRoutine(color)); } private void InternalStopHoldParticles() { holdActive = false; if (holdParticleCoroutine != null) { try { StopCoroutine(holdParticleCoroutine); } catch { } holdParticleCoroutine = null; } // Immediately return all active pooled instances (was Destroy). Returning is // idempotent and token-guarded, so a later timed RecycleFxRoutine for the same // instance becomes a no-op. Iterate a snapshot because ReturnFxInstance removes // the instance from activeParticles (mutating the list mid-enumeration would throw). var snapshot = activeParticles.ToArray(); foreach (var p in snapshot) { if (p != null) { ReturnFxInstance(p); } } activeParticles.Clear(); } private IEnumerator HoldParticleRoutine(string color) { while (holdActive) { float waitSeconds = Mathf.Max(0.01f, holdParticleInterval); if (cachedHoldWait == null || !Mathf.Approximately(cachedHoldWaitSeconds, waitSeconds)) { cachedHoldWaitSeconds = waitSeconds; cachedHoldWait = new WaitForSeconds(waitSeconds); } yield return cachedHoldWait; if (!holdActive) break; PlayDestroyAnimation(color); } } private void Awake() { if (isGlobalController) { if (Global != null && Global != this) { Debug.LogWarning("[AnimationController] Duplicate global detected, destroying self"); Destroy(this); return; } Global = this; DontDestroyOnLoad(gameObject); if (JudgeManager.IsDebugEnabled) Debug.Log("[AnimationController] Global AnimationController registered"); } // Animator cache redAnimator = redEffect != null ? redEffect.GetComponent() : null; greenAnimator = greenEffect != null ? greenEffect.GetComponent() : null; yellowAnimator = yellowEffect != null ? yellowEffect.GetComponent() : null; purpleAnimator = purpleEffect != null ? purpleEffect.GetComponent() : null; blueAnimator = blueEffect != null ? blueEffect.GetComponent() : null; // If the scene object reference was not assigned on this instance (common when this script is on a prefab), // try to locate a scene object automatically so runtime instances can still use a shared particle source. if (hit_particular_object == null) { // First try a tag-based lookup. Designer should assign the scene particle source a tag "HitParticleSource". try { var byTag = GameObject.FindWithTag("HitParticleSource"); if (byTag != null) { hit_particular_object = byTag; if (JudgeManager.IsDebugEnabled) Debug.Log("AnimationController: assigned hit_particular_object via tag 'HitParticleSource'."); } } catch { } // Next try common names if tag not used if (hit_particular_object == null) { var byName = SceneObjectLookupCache.Find("HitParticleSource") ?? SceneObjectLookupCache.Find("hit_particular_object") ?? SceneObjectLookupCache.Find("Hit_Particle_Source"); if (byName != null) { hit_particular_object = byName; if (JudgeManager.IsDebugEnabled) Debug.Log("AnimationController: assigned hit_particular_object via GameObject.Find by name."); } } if (hit_particular_object == null) { if (JudgeManager.IsDebugEnabled) Debug.LogWarning("AnimationController: hit_particular_object not assigned in Inspector and automatic lookup failed.\n" + "You can either assign a scene object to 'hit_particular_object' on the prefab instance in the scene,\n" + "or tag the scene particle source with 'HitParticleSource', or place a GameObject named 'HitParticleSource' in the scene."); } } } public void PlayDestroyAnimation(string color) { // Only use the scene object as the template. No prefab fallback. if (hit_particular_object == null) { if (!loggedMissingHitParticleSource) { loggedMissingHitParticleSource = true; Debug.LogError("hit_particular_object is null. Assign a scene particle source to AnimationController.hit_particular_object or tag a GameObject 'HitParticleSource'."); } // As a fallback, trigger the Animator-based effects TriggerAnimatorEffect(color); return; } GameObject source = hit_particular_object; // choose spawn position: prefer per-color effect transform if available Vector3 spawnPos = transform.position; Transform parentTransform = null; switch (color) { case "red": if (redEffect != null) { spawnPos = redEffect.transform.position; parentTransform = redEffect.transform; } break; case "green": if (greenEffect != null) { spawnPos = greenEffect.transform.position; parentTransform = greenEffect.transform; } break; case "yellow": if (yellowEffect != null) { spawnPos = yellowEffect.transform.position; parentTransform = yellowEffect.transform; } break; case "purple": if (purpleEffect != null) { spawnPos = purpleEffect.transform.position; parentTransform = purpleEffect.transform; } break; case "blue": if (blueEffect != null) { spawnPos = blueEffect.transform.position; parentTransform = blueEffect.transform; } break; } // Documentation text normalized. Color trackColor; string hexCode = GetHexCodeForColor(color); // Documentation text normalized. if (!ColorUtility.TryParseHtmlString(hexCode, out trackColor)) { trackColor = Color.white; if (JudgeManager.IsDebugEnabled) Debug.LogWarning($"Failed to parse hex color for '{color}' ({hexCode}). Using white."); } SpawnJudgePrefabByTrackText(color); // Rent a pooled copy of the scene particle source (was Instantiate + Destroy per hit). GameObject particleInstance = RentFxInstance(source, out var particleEntry, out int particleToken); if (particleInstance == null) { Debug.LogError("Failed to instantiate hit_particular_object"); TriggerAnimatorEffect(color); return; } // Place at spawn position, then optionally parent under the effect object so it // moves with UI/slot (worldPositionStays=true keeps world pos at spawnPos). particleInstance.transform.SetParent(parentTransform, false); particleInstance.transform.position = spawnPos; particleInstance.transform.rotation = Quaternion.identity; particleInstance.SetActive(true); var systems = particleEntry != null ? particleEntry.Systems : null; if (systems != null && systems.Length > 0) { foreach (var ps in systems) { if (ps == null) continue; var main = ps.main; main.startColor = trackColor; ps.Play(); } // Recycle after cached lifetime + small buffer (token-guarded). StartCoroutine(RecycleFxRoutine(particleInstance, particleToken, Mathf.Max(0.5f, particleEntry.Lifetime + 0.1f))); // Track for immediate cleanup on hold stop. activeParticles.Add(particleInstance); } else { Debug.LogError("No ParticleSystem components found on hit_particular_object instance"); ReturnFxInstance(particleInstance); TriggerAnimatorEffect(color); } // Ring effect (overlay) if assigned. if (hit_ring_object != null) { GameObject ringInstance = RentFxInstance(hit_ring_object, out var ringEntry, out int ringToken); if (ringInstance != null) { // Parent before activation to avoid transform surprises when Simulation Space = Local. ringInstance.transform.SetParent(parentTransform, false); ringInstance.transform.position = spawnPos; ringInstance.transform.rotation = Quaternion.identity; ringInstance.SetActive(true); var ringSystems = ringEntry != null ? ringEntry.Systems : null; if (ringSystems != null && ringSystems.Length > 0) { foreach (var rps in ringSystems) { if (rps == null) continue; var rmain = rps.main; rmain.startColor = trackColor; rps.Play(); } StartCoroutine(RecycleFxRoutine(ringInstance, ringToken, Mathf.Max(0.5f, ringEntry.Lifetime + 0.1f))); activeParticles.Add(ringInstance); } else { if (JudgeManager.IsDebugEnabled) Debug.LogWarning("hit_ring_object has no ParticleSystem components"); ReturnFxInstance(ringInstance); } } else { Debug.LogError("Failed to instantiate hit_ring_object"); } } } // Documentation text normalized. private string GetHexCodeForColor(string color) { switch (color) { case "red": return redColorHex; case "green": return greenColorHex; case "yellow": return yellowColorHex; case "purple": return purpleColorHex; case "blue": return blueColorHex; default: return "#FFFFFF"; // Documentation text normalized. } } private void TriggerAnimatorEffect(string color) { // Fallback to original Animator triggers if particle source missing switch (color) { case "red": if (redAnimator != null) { redAnimator.ResetTrigger("PlayRedDestroy"); redAnimator.SetTrigger("PlayRedDestroy"); } break; case "green": if (greenAnimator != null) { greenAnimator.ResetTrigger("PlayGreenDestroy"); greenAnimator.SetTrigger("PlayGreenDestroy"); } break; case "yellow": if (yellowAnimator != null) { yellowAnimator.ResetTrigger("PlayYellowDestroy"); yellowAnimator.SetTrigger("PlayYellowDestroy"); } break; case "purple": if (purpleAnimator != null) { purpleAnimator.ResetTrigger("PlayPurpleDestroy"); purpleAnimator.SetTrigger("PlayPurpleDestroy"); } break; case "blue": if (blueAnimator != null) { blueAnimator.ResetTrigger("PlayBlueDestroy"); blueAnimator.SetTrigger("PlayBlueDestroy"); } break; } } /// /// Documentation text normalized. /// private void SpawnJudgePrefabByTrackText(string color) { TextMeshProUGUI targetText = null; Transform spawnPoint = null; // Documentation text normalized. switch (color) { case "red": targetText = red_track_judgementText; spawnPoint = redEffect != null ? redEffect.transform : null; break; case "green": targetText = green_track_judgementText; spawnPoint = greenEffect != null ? greenEffect.transform : null; break; case "yellow": targetText = yellow_track_judgementText; spawnPoint = yellowEffect != null ? yellowEffect.transform : null; break; case "purple": targetText = purple_track_judgementText; spawnPoint = purpleEffect != null ? purpleEffect.transform : null; break; case "blue": targetText = blue_track_judgementText; spawnPoint = blueEffect != null ? blueEffect.transform : null; break; } if (targetText != null && spawnPoint != null) { // Documentation text normalized. if (JudgeManager.IsDebugEnabled) Debug.Log($"判定 {color} 当前读取到的文字为 [{targetText.text}]"); } // Documentation text normalized. if (targetText != null && spawnPoint != null && !string.IsNullOrEmpty(targetText.text)) { DoExecutePrefabSpawn(targetText.text, spawnPoint); } } /// /// Documentation text normalized. /// private void DoExecutePrefabSpawn(string judgeResult, Transform spawnTransform) { GameObject prefabToUse = null; // Documentation text normalized. if (judgeResult.Contains("Perfect")) prefabToUse = perfect_judge_prefab; else if (judgeResult.Contains("Great")) prefabToUse = great_judge_prefab; else if (judgeResult.Contains("Good")) prefabToUse = good_judge_prefab; else if (judgeResult.Contains("Miss")) prefabToUse = miss_judge_prefab; if (prefabToUse != null) { // Rent a pooled copy of the judge-text prefab (was Instantiate + Destroy per hit). GameObject instance = RentFxInstance(prefabToUse, out _, out int judgeToken); if (instance == null) return; instance.transform.SetParent(null, false); instance.transform.position = spawnTransform.position; instance.transform.rotation = Quaternion.identity; instance.SetActive(true); // Same 1.0s visible lifetime as before, token-guarded recycle instead of Destroy. StartCoroutine(RecycleFxRoutine(instance, judgeToken, 1.0f)); } } /// /// Public API to prewarm particle instances. Call during loading/pause time to avoid hitch on first use. /// This will instantiate the configured scene particle sources (hit_particular_object / hit_ring_object) /// and briefly play their ParticleSystems to force any internal setup. /// public void PrewarmParticles(int perTemplate = 2) { // start coroutine to avoid blocking main thread with many instantiations StartCoroutine(PrewarmCoroutine(perTemplate)); } private IEnumerator PrewarmCoroutine(int perTemplate) { // Templates that flow through PlayDestroyAnimation on every judge. Seeding the // FX pool here (instead of instantiate-then-destroy) means the first real hits // rent a ready instance rather than paying Instantiate + first-Play on the hot // path. CreateFxInstance already warms each instance's play path once. var templates = new List(); if (hit_particular_object != null) templates.Add(hit_particular_object); if (hit_ring_object != null) templates.Add(hit_ring_object); if (perfect_judge_prefab != null) templates.Add(perfect_judge_prefab); if (great_judge_prefab != null) templates.Add(great_judge_prefab); if (good_judge_prefab != null) templates.Add(good_judge_prefab); if (miss_judge_prefab != null) templates.Add(miss_judge_prefab); EnsureFxPoolRoot(); for (int i = 0; i < templates.Count; i++) { var template = templates[i]; for (int j = 0; j < perTemplate; j++) { GameObject inst = null; try { inst = CreateFxInstance(template); if (inst != null) { inst.SetActive(false); inst.transform.SetParent(fxPoolRoot, false); if (!fxPools.TryGetValue(template, out var pool)) { pool = new Queue(); fxPools[template] = pool; } pool.Enqueue(inst); } } catch { } // spread work across frames to avoid a long load frame yield return null; } } yield break; } /// /// Public routine variant that callers can yield on to wait until prewarm completes. /// Use this when you need to ensure particle templates are fully instantiated and torn down /// before proceeding (to avoid hiccups at first real use). /// public IEnumerator PrewarmParticlesRoutine(int perTemplate = 2) { yield return StartCoroutine(PrewarmCoroutine(perTemplate)); } }