960 lines
38 KiB
C#
960 lines
38 KiB
C#
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));
|
||
}
|
||
}
|