好多新内容

This commit is contained in:
2026-07-22 05:35:53 +08:00
parent b5d1cdcbc5
commit 15649d02f8
128 changed files with 5956 additions and 673 deletions
@@ -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.