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 static bool loggedMissingHitParticleSource; // Track active hold-loop particle instances for immediate cleanup on hold release. private readonly List activeHoldParticles = 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 Animator[] Animators; public float Lifetime; // 模板(prefab/场景源)的设计本地旋转。特效作者常给 ring 之类加了朝向(如 -90°X), // 播放时必须沿用,否则扁平粒子侧向对相机几乎不可见。 public Quaternion LocalRotation; // 根粒子系统:只对它 Play(true)(withChildren 级联到激活的子系统),避免对每个子系统重复触发 burst。 public ParticleSystem RootSystem; // clone 时每个子物体的原始 activeSelf:复用时恢复到这个初始态。 // 关键:特效里有默认 inactive 的备用子层(如 CFXR3 的 "Ring"、trackStar 的 Spikes/Aura), // 绝不能被复用逻辑盲目激活(会粒子变多);而 stopAction=Disable 误关的该显示层要恢复回来。 public Transform[] ChildTransforms; public bool[] ChildInitialActive; } private struct PendingFxReturn { public GameObject Instance; public int RentToken; public float ReturnTime; } 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 readonly List pendingFxReturns = new List(64); private Transform fxPoolRoot; private string hitRingPathUnderParticleSource; private void EnsureFxPoolRoot() { if (fxPoolRoot == null) { var root = new GameObject("AnimationControllerFxPool"); root.transform.SetParent(transform, false); root.SetActive(false); fxPoolRoot = root.transform; } } private static Transform GetVisibleFxParent(Transform candidate) { return candidate != null && candidate.gameObject.activeInHierarchy ? candidate : null; } private static string BuildRelativePath(Transform root, Transform child) { if (root == null || child == null || child == root || !child.IsChildOf(root)) return null; var names = new List(); Transform current = child; while (current != null && current != root) { names.Add(current.name); current = current.parent; } names.Reverse(); return names.Count > 0 ? string.Join("/", names.ToArray()) : null; } private void CacheNestedRingPath() { hitRingPathUnderParticleSource = null; if (hit_particular_object == null || hit_ring_object == null) return; Transform particleRoot = hit_particular_object.transform; Transform ringTransform = hit_ring_object.transform; if (ringTransform != particleRoot && ringTransform.IsChildOf(particleRoot)) { hitRingPathUnderParticleSource = BuildRelativePath(particleRoot, ringTransform); } } private Transform GetNestedRingCloneForParticleTemplate(GameObject template, GameObject instance) { if (template != hit_particular_object || instance == null || string.IsNullOrEmpty(hitRingPathUnderParticleSource)) return null; return instance.transform.Find(hitRingPathUnderParticleSource); } private static ParticleSystem[] FilterParticleSystems(ParticleSystem[] systems, Transform excludedRoot) { if (systems == null || systems.Length == 0 || excludedRoot == null) return systems ?? new ParticleSystem[0]; var filtered = new List(systems.Length); foreach (var ps in systems) { if (ps == null) continue; Transform t = ps.transform; if (t == excludedRoot || t.IsChildOf(excludedRoot)) continue; filtered.Add(ps); } return filtered.ToArray(); } // 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; EnsureFxPoolRoot(); GameObject instance = Instantiate(template, fxPoolRoot, false); instance.SetActive(false); Transform excludedNestedRing = GetNestedRingCloneForParticleTemplate(template, instance); if (excludedNestedRing != null) { excludedNestedRing.gameObject.SetActive(false); } var systems = FilterParticleSystems(instance.GetComponentsInChildren(true), excludedNestedRing); if (systems == null) systems = new ParticleSystem[0]; var animators = instance.GetComponentsInChildren(true); if (animators == null) animators = new Animator[0]; // 根粒子系统(实例根上的 PS,或第一个未被排除的 PS)。只对它 Play(true) 级联播放。 ParticleSystem rootPs = instance.GetComponent(); if (rootPs == null && systems.Length > 0) rootPs = systems[0]; // 记录每个子物体(含自身外的所有后代)的初始 activeSelf——此刻已处理完嵌套 ring 的 SetActive(false), // 即为"设计初始态"。复用时据此恢复,既不激活默认关闭的备用层,也能修好被 stopAction=Disable 误关的层。 Transform[] allChildren = instance.GetComponentsInChildren(true); var childList = new List(allChildren.Length); var childActive = new List(allChildren.Length); foreach (var ct in allChildren) { if (ct == null || ct == instance.transform) continue; // 跳过根(根由 SetActive 单独控制) childList.Add(ct); childActive.Add(ct.gameObject.activeSelf); } var entry = new FxCacheEntry { Systems = systems, Animators = animators, Lifetime = ComputeFxLifetime(systems, animators), // 记录模板设计本地旋转(clone 自 template,此时 localRotation 即模板设计朝向)。 LocalRotation = template.transform.localRotation, RootSystem = rootPs, ChildTransforms = childList.ToArray(), ChildInitialActive = childActive.ToArray() }; foreach (var ps in systems) { if (ps == null) continue; var main = ps.main; main.stopAction = ParticleSystemStopAction.None; 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)) { pooled.transform.SetParent(null, false); 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); activeHoldParticles.Remove(instance); if (fxCache.TryGetValue(instance, out var entry)) { if (entry.Systems != null) { foreach (var ps in entry.Systems) { if (ps == null) continue; var main = ps.main; main.stopAction = ParticleSystemStopAction.None; try { ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); } catch { } try { ps.Clear(true); } catch { } } } // 回收时把子物体激活态恢复到 clone 初始态:stopAction=Disable 的层播放完被 SetActive(false), // 若不恢复,下次复用就少一层(粒子变少/ring 不出现)。默认 inactive 的备用层仍保持关闭。 if (entry.ChildTransforms != null && entry.ChildInitialActive != null) { for (int i = 0; i < entry.ChildTransforms.Length; i++) { var ct = entry.ChildTransforms[i]; if (ct == null) continue; bool want = entry.ChildInitialActive[i]; if (ct.gameObject.activeSelf != want) ct.gameObject.SetActive(want); } } } 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) { // Visual FX lifetimes must not depend on the DSP/song clock. If the chart // clock pauses, resets, or is not initialized, pooled particles still need // to return or the pool eventually runs out of reusable visible instances. yield return new WaitForSecondsRealtime(Mathf.Max(0f, 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 void ScheduleFxReturn(GameObject instance, int rentToken, float delay) { if (instance == null) return; pendingFxReturns.Add(new PendingFxReturn { Instance = instance, RentToken = rentToken, ReturnTime = Time.unscaledTime + Mathf.Max(0f, delay) }); } private void Update() { if (pendingFxReturns.Count == 0) return; float now = Time.unscaledTime; for (int i = pendingFxReturns.Count - 1; i >= 0; i--) { PendingFxReturn pending = pendingFxReturns[i]; if (pending.Instance == null) { pendingFxReturns.RemoveAt(i); continue; } if (now < pending.ReturnTime) continue; pendingFxReturns.RemoveAt(i); if (CurrentRentToken(pending.Instance) != pending.RentToken) continue; ReturnFxInstance(pending.Instance); } } private void ReturnAllRentedFx() { var snapshot = new List(rentedFx); foreach (var instance in snapshot) { ReturnFxInstance(instance); } activeHoldParticles.Clear(); } private void OnDisable() { holdActive = false; holdParticleCoroutine = null; pendingFxReturns.Clear(); ReturnAllRentedFx(); } private void OnDestroy() { if (Global == this) { Global = null; } ReturnAllRentedFx(); } private static void RestartFxPlayback(FxCacheEntry entry, Color tint) { if (entry == null) return; // 第一步:把所有子物体的激活态恢复到 clone 初始态。 // 这修复"粒子逐渐变少":stopAction=Disable 的粒子层播放完会被 Unity SetActive(false), // 回收/复用时若不恢复就少一层。同时不会激活默认 inactive 的备用层(它们初始态就是 false)。 if (entry.ChildTransforms != null && entry.ChildInitialActive != null) { for (int i = 0; i < entry.ChildTransforms.Length; i++) { var ct = entry.ChildTransforms[i]; if (ct == null) continue; bool want = entry.ChildInitialActive[i]; if (ct.gameObject.activeSelf != want) ct.gameObject.SetActive(want); } } // 第二步:停止并清空所有粒子系统的残留粒子,设 stopAction=None 防止播放结束再次自我禁用, // 并刷新 startColor。此处只 Stop/Clear,不逐个 Play。 if (entry.Systems != null) { foreach (var ps in entry.Systems) { if (ps == null) continue; var main = ps.main; main.stopAction = ParticleSystemStopAction.None; main.startColor = tint; try { ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); } catch { } try { ps.Clear(true); } catch { } // Force pooled FX back to time 0 before replaying, matching a fresh Instantiate // without paying allocation/destruction cost on every hit. try { ps.Simulate(0f, false, true); } catch { } try { ps.Clear(true); } catch { } } } // 第三步:只对根系统 Play(true)——withChildren 会级联播放当前处于激活态的子系统, // 不会触发默认 inactive 备用层。绝不逐个子系统 Play(那会对同层重复 burst=粒子变多)。 if (entry.RootSystem != null) { try { entry.RootSystem.Play(true); } catch { } } else if (entry.Systems != null && entry.Systems.Length > 0 && entry.Systems[0] != null) { try { entry.Systems[0].Play(true); } catch { } } // 这两个打击特效无 Animator;保留极简 Rebind 仅对"本就处于激活态"的 Animator(其它含动画的特效复用兜底), // 绝不为此激活任何子节点(那正是上一版把隐藏备用层强开导致粒子变多的错误)。 if (entry.Animators != null) { foreach (var animator in entry.Animators) { if (animator == null || !animator.isActiveAndEnabled) continue; try { animator.Rebind(); animator.Update(0f); } catch { } } } } private static float ComputeFxLifetime(ParticleSystem[] systems, Animator[] animators) { float maxLifetime = 0.5f; if (systems != null) { 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; maxLifetime = Mathf.Max(maxLifetime, main.duration + life + 0.25f); } } if (animators != null) { foreach (var animator in animators) { if (animator == null || animator.runtimeAnimatorController == null) continue; foreach (var clip in animator.runtimeAnimatorController.animationClips) { if (clip != null) maxLifetime = Mathf.Max(maxLifetime, clip.length + 0.1f); } } } 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; // 当前正在按住的长音符集合(按 hold note id)。不能按 trackIndex 记账: // 同一轨道上的旧段回收/下一条长音符会互相 Stop,导致长按后半段特效频率越来越低。 private readonly HashSet _activeHoldKeys = new HashSet(); // 各长音符当前颜色,供 routine 逐条播放对应色。 private readonly Dictionary _holdKeyColor = new Dictionary(); public void StartHoldParticles(string color) { StartHoldParticles(color, 0); } public void StartHoldParticles(string color, int holdKey) { if (AnimationController.Global != null && AnimationController.Global != this) { AnimationController.Global.InternalStartHoldParticles(color, holdKey); return; } InternalStartHoldParticles(color, holdKey); } public void StopHoldParticles() { StopHoldParticles(0); } public void StopHoldParticles(int holdKey) { if (AnimationController.Global != null && AnimationController.Global != this) { AnimationController.Global.InternalStopHoldParticles(holdKey); return; } InternalStopHoldParticles(holdKey); } // Internal implementations operate on this instance private void InternalStartHoldParticles(string color, int holdKey) { currentHoldColor = color; _activeHoldKeys.Add(holdKey); _holdKeyColor[holdKey] = color; // 只要有轨道在按住就保证 routine 在跑;重复调用不会重启(避免 tick 相位错乱)。 holdActive = true; if (holdParticleCoroutine == null) { holdParticleCoroutine = StartCoroutine(HoldParticleRoutine()); } } private void InternalStopHoldParticles(int holdKey) { // 本长音符结束:从活跃集合移除。仍有其它长音符按住时,routine 继续跑。 _activeHoldKeys.Remove(holdKey); _holdKeyColor.Remove(holdKey); if (_activeHoldKeys.Count > 0) { return; // 还有别的长音符在按,不停整体循环。 } // 全部松手:只停止后续 tick。已经发出的命中特效是 one-shot, // 让它按自身 lifetime 自然回收,避免刚生成就被清掉造成后段频率变低。 holdActive = false; if (holdParticleCoroutine != null) { try { StopCoroutine(holdParticleCoroutine); } catch { } holdParticleCoroutine = null; } activeHoldParticles.Clear(); } private IEnumerator HoldParticleRoutine() { float nextTickTime = Time.unscaledTime + Mathf.Max(0.01f, holdParticleInterval); while (holdActive && _activeHoldKeys.Count > 0) { float waitSeconds = Mathf.Max(0.01f, holdParticleInterval); while (holdActive && _activeHoldKeys.Count > 0 && Time.unscaledTime < nextTickTime) { yield return null; } if (!holdActive || _activeHoldKeys.Count == 0) break; nextTickTime += waitSeconds; if (Time.unscaledTime - nextTickTime > waitSeconds) { nextTickTime = Time.unscaledTime + waitSeconds; } // 为当前所有按住的轨道各播一次持续粒子(逐轨对应色)。 try { // 复制到临时数组避免迭代中集合被改。 _holdTickBuffer.Clear(); foreach (var kv in _holdKeyColor) _holdTickBuffer.Add(kv.Value); for (int i = 0; i < _holdTickBuffer.Count; i++) { PlayDestroyAnimation(_holdTickBuffer[i], true); } } catch (System.Exception ex) { Debug.LogWarning($"[AnimationController] Hold particle tick failed: {ex.Message}"); } } holdParticleCoroutine = null; } private readonly List _holdTickBuffer = new List(); private void Awake() { // 自动当选 Global:场景里没有实例勾选 isGlobalController(全为 false),导致 Global 恒 null, // tap/长条判定的 PlayDestroyAnimation(Global ?? anim) 从不执行、hold 状态在多个 note 实例间错乱。 // 判据:真正配置了特效源(hit_particular_object + hit_ring_object)的那个场景实例才是全局控制器; // note 预制体上挂的 20 个 AnimationController 这两个字段为空,不参与竞选。 bool isConfiguredController = isGlobalController || (hit_particular_object != null && hit_ring_object != null); if (isConfiguredController) { if (Global != null && Global != this) { if (JudgeManager.IsDebugEnabled) Debug.LogWarning("[AnimationController] Duplicate global detected, ignoring self"); // 不 Destroy(this):该组件可能挂在还承载其它引用的场景对象上,仅放弃全局身份即可。 } else { Global = this; 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."); } } CacheNestedRingPath(); // 从根上消除"开局克隆继承模板活粒子":把作为特效模板的场景源(hit_particular_object / hit_ring_object) // 自身的粒子系统停播并清空,使其成为纯静态模板、永不持有活粒子。这样任何时刻 Instantiate 出来的 // 克隆都从零粒子开始,开局与后期完全一致(不再前多后少)。 StopAndClearTemplate(hit_particular_object); StopAndClearTemplate(hit_ring_object); } private static void StopAndClearTemplate(GameObject template) { if (template == null) return; var systems = template.GetComponentsInChildren(true); if (systems == null) return; foreach (var ps in systems) { if (ps == null) continue; var main = ps.main; main.playOnAwake = false; // 模板绝不自动播放 try { ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); } catch { } try { ps.Clear(true); } catch { } } } public void PlayDestroyAnimation(string color) { PlayDestroyAnimation(color, false); } private void PlayDestroyAnimation(string color, bool trackAsHoldParticle) { // 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; } parentTransform = GetVisibleFxParent(parentTransform); // 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."); } // 判定字(PERFECT/GREAT 等)统一且仅由 Animation_GenerateJudgementSituationPrefab 负责生成, // 本方法只负责打击粒子 + ring,不碰判定字。 // 粒子本体:走对象池,避免高频判定时 Instantiate/Destroy 造成移动端掉帧。 SpawnOneShotFx(source, spawnPos, parentTransform, trackColor, true, trackAsHoldParticle); // Ring(叠加环):tap / hold 头尾 / hold 持续 tick 都按同一频率生成一次性实例。 // 实例会按自身生命周期回池,不会修改或启动场景里的模板对象。 if (hit_ring_object != null) { SpawnOneShotFx(hit_ring_object, spawnPos, parentTransform, trackColor, true, trackAsHoldParticle); } } // 播放一个一次性特效(粒子/ring),播放后按自身生命周期回池。 // applyTint=true 时把粒子 startColor 染成轨道色;当前调用保持旧行为,粒子和 ring 都染色。 private void SpawnOneShotFx(GameObject template, Vector3 spawnPos, Transform parentTransform, Color tint, bool applyTint, bool trackAsHoldParticle) { if (template == null) return; GameObject instance = RentFxInstance(template, out FxCacheEntry entry, out int rentToken); if (instance == null) return; // 定位:跟随分轨效果对象(worldPositionStays=false 保留模板设计的本地朝向,如 ring 的 -90°X)。 instance.transform.SetParent(parentTransform, false); instance.transform.position = spawnPos; instance.transform.localRotation = entry != null ? entry.LocalRotation : template.transform.localRotation; instance.SetActive(true); if (applyTint) { RestartFxPlayback(entry, tint); } else { RestartFxPlayback(entry, Color.white); } if (trackAsHoldParticle) { activeHoldParticles.Add(instance); } float lifetime = entry != null ? entry.Lifetime : 0.5f; ScheduleFxReturn(instance, rentToken, Mathf.Max(0.5f, lifetime + 0.1f)); } // 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; } } // 注:判定字生成已完全移交给 Animation_GenerateJudgementSituationPrefab。 // 原 SpawnJudgePrefabByTrackText / DoExecutePrefabSpawn 是重复的第二套判定字来源,已删除, // 避免同一次判定出现两套判定字重叠(一大一小)。judge prefab 字段仅供 prewarm 预热池使用。 /// /// 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)); } }