Files
2026-07-26 03:44:27 +08:00

960 lines
38 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<GameObject> activeHoldParticles = new List<GameObject>();
// 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<GameObject, Queue<GameObject>> fxPools =
new Dictionary<GameObject, Queue<GameObject>>();
private readonly Dictionary<GameObject, FxCacheEntry> fxCache =
new Dictionary<GameObject, FxCacheEntry>();
private readonly Dictionary<GameObject, GameObject> fxInstanceTemplate =
new Dictionary<GameObject, GameObject>();
// 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<GameObject> rentedFx = new HashSet<GameObject>();
// 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<GameObject, int> fxRentId = new Dictionary<GameObject, int>();
private readonly List<PendingFxReturn> pendingFxReturns = new List<PendingFxReturn>(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<string>();
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<ParticleSystem>(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<ParticleSystem>(true), excludedNestedRing);
if (systems == null)
systems = new ParticleSystem[0];
var animators = instance.GetComponentsInChildren<Animator>(true);
if (animators == null)
animators = new Animator[0];
// 根粒子系统(实例根上的 PS,或第一个未被排除的 PS)。只对它 Play(true) 级联播放。
ParticleSystem rootPs = instance.GetComponent<ParticleSystem>();
if (rootPs == null && systems.Length > 0) rootPs = systems[0];
// 记录每个子物体(含自身外的所有后代)的初始 activeSelf——此刻已处理完嵌套 ring 的 SetActive(false)
// 即为"设计初始态"。复用时据此恢复,既不激活默认关闭的备用层,也能修好被 stopAction=Disable 误关的层。
Transform[] allChildren = instance.GetComponentsInChildren<Transform>(true);
var childList = new List<Transform>(allChildren.Length);
var childActive = new List<bool>(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<GameObject>();
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<GameObject>(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<int> _activeHoldKeys = new HashSet<int>();
// 各长音符当前颜色,供 routine 逐条播放对应色。
private readonly Dictionary<int, string> _holdKeyColor = new Dictionary<int, string>();
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<string> _holdTickBuffer = new List<string>();
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<Animator>() : null;
greenAnimator = greenEffect != null ? greenEffect.GetComponent<Animator>() : null;
yellowAnimator = yellowEffect != null ? yellowEffect.GetComponent<Animator>() : null;
purpleAnimator = purpleEffect != null ? purpleEffect.GetComponent<Animator>() : null;
blueAnimator = blueEffect != null ? blueEffect.GetComponent<Animator>() : 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<ParticleSystem>(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 预热池使用。
/// <summary>
/// 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.
/// </summary>
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<GameObject>();
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<GameObject>();
fxPools[template] = pool;
}
pool.Enqueue(inst);
}
}
catch { }
// spread work across frames to avoid a long load frame
yield return null;
}
}
yield break;
}
/// <summary>
/// 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).
/// </summary>
public IEnumerator PrewarmParticlesRoutine(int perTemplate = 2)
{
yield return StartCoroutine(PrewarmCoroutine(perTemplate));
}
}