好多新内容
This commit is contained in:
@@ -40,8 +40,8 @@ public class AnimationController : MonoBehaviour
|
||||
private bool isHolding = false;
|
||||
private static bool loggedMissingHitParticleSource;
|
||||
|
||||
// Track active particle instances for immediate cleanup
|
||||
private List<GameObject> activeParticles = new List<GameObject>();
|
||||
// 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
|
||||
@@ -53,7 +53,18 @@ public class AnimationController : MonoBehaviour
|
||||
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 readonly Dictionary<GameObject, Queue<GameObject>> fxPools =
|
||||
@@ -73,6 +84,7 @@ public class AnimationController : MonoBehaviour
|
||||
private int fxRentCounter;
|
||||
private readonly Dictionary<GameObject, int> fxRentId = new Dictionary<GameObject, int>();
|
||||
private Transform fxPoolRoot;
|
||||
private string hitRingPathUnderParticleSource;
|
||||
|
||||
private void EnsureFxPoolRoot()
|
||||
{
|
||||
@@ -85,6 +97,71 @@ public class AnimationController : MonoBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -92,20 +169,55 @@ public class AnimationController : MonoBehaviour
|
||||
if (template == null)
|
||||
return null;
|
||||
|
||||
GameObject instance = Instantiate(template);
|
||||
var systems = instance.GetComponentsInChildren<ParticleSystem>(true);
|
||||
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,
|
||||
Lifetime = ComputeFxLifetime(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 { }
|
||||
@@ -130,6 +242,7 @@ public class AnimationController : MonoBehaviour
|
||||
GameObject pooled = pool.Dequeue();
|
||||
if (pooled != null && fxCache.TryGetValue(pooled, out entry))
|
||||
{
|
||||
pooled.transform.SetParent(null, false);
|
||||
rentToken = MarkRented(pooled);
|
||||
return pooled;
|
||||
}
|
||||
@@ -168,15 +281,34 @@ public class AnimationController : MonoBehaviour
|
||||
return;
|
||||
|
||||
fxRentId.Remove(instance);
|
||||
activeParticles.Remove(instance);
|
||||
activeHoldParticles.Remove(instance);
|
||||
|
||||
if (fxCache.TryGetValue(instance, out var entry) && entry.Systems != null)
|
||||
if (fxCache.TryGetValue(instance, out var entry))
|
||||
{
|
||||
foreach (var ps in entry.Systems)
|
||||
if (entry.Systems != null)
|
||||
{
|
||||
if (ps == null) continue;
|
||||
try { ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); } catch { }
|
||||
try { ps.Clear(true); } catch { }
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,7 +333,10 @@ public class AnimationController : MonoBehaviour
|
||||
|
||||
private IEnumerator RecycleFxRoutine(GameObject instance, int rentToken, float delay)
|
||||
{
|
||||
yield return GameplayClock.WaitForSeconds(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;
|
||||
|
||||
@@ -214,21 +349,124 @@ public class AnimationController : MonoBehaviour
|
||||
ReturnFxInstance(instance);
|
||||
}
|
||||
|
||||
private static float ComputeFxLifetime(ParticleSystem[] systems)
|
||||
private void ReturnAllRentedFx()
|
||||
{
|
||||
float maxLifetime = 0f;
|
||||
if (systems == null)
|
||||
return maxLifetime;
|
||||
|
||||
foreach (var ps in systems)
|
||||
var snapshot = new List<GameObject>(rentedFx);
|
||||
foreach (var instance in snapshot)
|
||||
{
|
||||
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;
|
||||
ReturnFxInstance(instance);
|
||||
}
|
||||
|
||||
activeHoldParticles.Clear();
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
holdActive = false;
|
||||
holdParticleCoroutine = null;
|
||||
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 { }
|
||||
}
|
||||
}
|
||||
|
||||
// 第三步:只对根系统 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;
|
||||
}
|
||||
|
||||
@@ -245,95 +483,140 @@ public class AnimationController : MonoBehaviour
|
||||
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)
|
||||
{
|
||||
// Prefer the Global controller if available so Start/Stop always affect the same instance
|
||||
StartHoldParticles(color, 0);
|
||||
}
|
||||
|
||||
public void StartHoldParticles(string color, int holdKey)
|
||||
{
|
||||
if (AnimationController.Global != null && AnimationController.Global != this)
|
||||
{
|
||||
AnimationController.Global.InternalStartHoldParticles(color);
|
||||
AnimationController.Global.InternalStartHoldParticles(color, holdKey);
|
||||
return;
|
||||
}
|
||||
|
||||
InternalStartHoldParticles(color);
|
||||
InternalStartHoldParticles(color, holdKey);
|
||||
}
|
||||
|
||||
public void StopHoldParticles()
|
||||
{
|
||||
StopHoldParticles(0);
|
||||
}
|
||||
|
||||
public void StopHoldParticles(int holdKey)
|
||||
{
|
||||
if (AnimationController.Global != null && AnimationController.Global != this)
|
||||
{
|
||||
AnimationController.Global.InternalStopHoldParticles();
|
||||
AnimationController.Global.InternalStopHoldParticles(holdKey);
|
||||
return;
|
||||
}
|
||||
|
||||
InternalStopHoldParticles();
|
||||
InternalStopHoldParticles(holdKey);
|
||||
}
|
||||
|
||||
// Internal implementations operate on this instance
|
||||
private void InternalStartHoldParticles(string color)
|
||||
private void InternalStartHoldParticles(string color, int holdKey)
|
||||
{
|
||||
if (holdActive) return;
|
||||
|
||||
holdActive = true;
|
||||
currentHoldColor = color;
|
||||
_activeHoldKeys.Add(holdKey);
|
||||
_holdKeyColor[holdKey] = color;
|
||||
|
||||
if (holdParticleCoroutine != null)
|
||||
// 只要有轨道在按住就保证 routine 在跑;重复调用不会重启(避免 tick 相位错乱)。
|
||||
holdActive = true;
|
||||
if (holdParticleCoroutine == null)
|
||||
{
|
||||
StopCoroutine(holdParticleCoroutine);
|
||||
holdParticleCoroutine = StartCoroutine(HoldParticleRoutine());
|
||||
}
|
||||
|
||||
holdParticleCoroutine = StartCoroutine(HoldParticleRoutine(color));
|
||||
}
|
||||
|
||||
private void InternalStopHoldParticles()
|
||||
private void InternalStopHoldParticles(int holdKey)
|
||||
{
|
||||
holdActive = false;
|
||||
// 本长音符结束:从活跃集合移除。仍有其它长音符按住时,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;
|
||||
}
|
||||
|
||||
// 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();
|
||||
activeHoldParticles.Clear();
|
||||
}
|
||||
|
||||
private IEnumerator HoldParticleRoutine(string color)
|
||||
private IEnumerator HoldParticleRoutine()
|
||||
{
|
||||
while (holdActive)
|
||||
float nextTickTime = Time.unscaledTime + Mathf.Max(0.01f, holdParticleInterval);
|
||||
|
||||
while (holdActive && _activeHoldKeys.Count > 0)
|
||||
{
|
||||
float waitSeconds = Mathf.Max(0.01f, holdParticleInterval);
|
||||
yield return GameplayClock.WaitForSeconds(waitSeconds);
|
||||
if (!holdActive) break;
|
||||
PlayDestroyAnimation(color);
|
||||
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()
|
||||
{
|
||||
if (isGlobalController)
|
||||
// 自动当选 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)
|
||||
{
|
||||
Debug.LogWarning("[AnimationController] Duplicate global detected, destroying self");
|
||||
Destroy(this);
|
||||
return;
|
||||
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");
|
||||
}
|
||||
|
||||
Global = this;
|
||||
DontDestroyOnLoad(gameObject);
|
||||
if (JudgeManager.IsDebugEnabled) Debug.Log("[AnimationController] Global AnimationController registered");
|
||||
}
|
||||
|
||||
// Animator cache
|
||||
@@ -378,9 +661,37 @@ public class AnimationController : MonoBehaviour
|
||||
"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)
|
||||
@@ -409,6 +720,8 @@ public class AnimationController : MonoBehaviour
|
||||
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);
|
||||
@@ -420,84 +733,88 @@ public class AnimationController : MonoBehaviour
|
||||
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)
|
||||
// 判定字(PERFECT/GREAT 等)统一且仅由 Animation_GenerateJudgementSituationPrefab 负责生成,
|
||||
// 本方法只负责打击粒子 + ring,不碰判定字。
|
||||
|
||||
// 【彻底去池化】前几轮所有"粒子变多/变少、ring 不激活、复用状态错乱"都源于对象池复用时
|
||||
// 子节点激活态/粒子播放态无法可靠复位。改回最朴素可靠的 Instantiate + 定时 Destroy:
|
||||
// 每次判定 new 一个全新干净实例(与 prefab 设计态完全一致),播放完销毁。无复用=无脏状态。
|
||||
// 这正是加池化之前本就正常工作的行为。
|
||||
|
||||
// 粒子本体:以场景源为模板实例化一份全新拷贝。
|
||||
SpawnOneShotFx(source, spawnPos, parentTransform, trackColor, true);
|
||||
|
||||
// Ring(叠加环):tap / hold 头尾 / hold 持续 tick 都按同一频率生成一次性实例。
|
||||
// 实例会按自身生命周期 Destroy,不会修改或启动场景里的模板对象。
|
||||
if (hit_ring_object != null)
|
||||
{
|
||||
Debug.LogError("Failed to instantiate hit_particular_object");
|
||||
TriggerAnimatorEffect(color);
|
||||
return;
|
||||
SpawnOneShotFx(hit_ring_object, spawnPos, parentTransform, trackColor, true);
|
||||
}
|
||||
}
|
||||
|
||||
// 实例化一个一次性特效(粒子/ring),播放后按自身生命周期 Destroy。无对象池、无复用。
|
||||
// applyTint=true 时把粒子 startColor 染成轨道色(粒子本体);ring 保留自身配色(false)。
|
||||
private void SpawnOneShotFx(GameObject template, Vector3 spawnPos, Transform parentTransform, Color tint, bool applyTint)
|
||||
{
|
||||
if (template == null) return;
|
||||
|
||||
GameObject instance = Instantiate(template);
|
||||
if (instance == null) return;
|
||||
|
||||
// 去除粒子本体克隆里"自带的嵌套 ring":hit_ring_object 本身是 hit_particular_object 的子物体,
|
||||
// Instantiate(粒子本体) 会连它一起克隆(原色,未染色) → 与我另外单独实例化的染色 ring 叠成双层。
|
||||
// 先 SetActive(false) 立即让它停播,并从下面的系统列表中排除,再销毁;只保留单独实例化的染色 ring。
|
||||
Transform nestedRing = null;
|
||||
if (template == hit_particular_object && !string.IsNullOrEmpty(hitRingPathUnderParticleSource))
|
||||
{
|
||||
nestedRing = instance.transform.Find(hitRingPathUnderParticleSource);
|
||||
if (nestedRing != null)
|
||||
{
|
||||
nestedRing.gameObject.SetActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
// 定位:跟随分轨效果对象(worldPositionStays=false 保留模板设计的本地朝向,如 ring 的 -90°X)。
|
||||
instance.transform.SetParent(parentTransform, false);
|
||||
instance.transform.position = spawnPos;
|
||||
instance.transform.localRotation = template.transform.localRotation;
|
||||
instance.SetActive(true);
|
||||
|
||||
var systems = particleEntry != null ? particleEntry.Systems : null;
|
||||
// 排除嵌套 ring 的粒子系统,避免把那层原色 ring 播出来。
|
||||
var systems = FilterParticleSystems(instance.GetComponentsInChildren<ParticleSystem>(true), nestedRing);
|
||||
if (nestedRing != null) Destroy(nestedRing.gameObject);
|
||||
float lifetime = 0.5f;
|
||||
if (systems != null && systems.Length > 0)
|
||||
{
|
||||
lifetime = ComputeFxLifetime(systems, null);
|
||||
// 关键修复(开局粒子多、之后变少的衰减):模板 hit_particular_object 是活动场景对象,
|
||||
// Instantiate 会连同模板粒子系统"当前缓冲里的活粒子"一起克隆——歌曲早期模板残留/预热粒子多
|
||||
// → 克隆多;随模板粒子消亡 → 克隆少。故必须把克隆继承的模拟状态彻底重置为"时间0、零粒子",
|
||||
// 再从头播放,保证每次一致、与模板当时状态无关。
|
||||
foreach (var ps in systems)
|
||||
{
|
||||
if (ps == null) continue;
|
||||
var main = ps.main;
|
||||
main.startColor = trackColor;
|
||||
ps.Play();
|
||||
if (applyTint) main.startColor = tint;
|
||||
try { ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); } catch { }
|
||||
try { ps.Clear(true); } catch { }
|
||||
// Simulate(0, withChildren:false, restart:true) 强制该系统回到时间 0 的干净态,
|
||||
// 抹掉 Instantiate 继承的任何已模拟粒子(单靠 Clear 有时不彻底)。
|
||||
try { ps.Simulate(0f, false, true); } catch { }
|
||||
try { ps.Clear(true); } catch { }
|
||||
}
|
||||
|
||||
// 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);
|
||||
// 全部重置后,只对根系统 Play(true) 级联播放,从零开始正常发射。
|
||||
var rootPs = instance.GetComponent<ParticleSystem>();
|
||||
if (rootPs == null) rootPs = systems[0];
|
||||
try { rootPs.Play(true); } catch { }
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogError("No ParticleSystem components found on hit_particular_object instance");
|
||||
ReturnFxInstance(particleInstance);
|
||||
TriggerAnimatorEffect(color);
|
||||
var animators = instance.GetComponentsInChildren<Animator>(true);
|
||||
lifetime = ComputeFxLifetime(null, animators);
|
||||
}
|
||||
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
Destroy(instance, Mathf.Max(0.5f, lifetime + 0.1f));
|
||||
}
|
||||
|
||||
// Documentation text normalized.
|
||||
@@ -556,65 +873,9 @@ public class AnimationController : MonoBehaviour
|
||||
break;
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// Documentation text normalized.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Documentation text normalized.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
// 注:判定字生成已完全移交给 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.
|
||||
|
||||
Reference in New Issue
Block a user