using UnityEngine; using TMPro; using UnityEngine.UI; using DG.Tweening; public class ScoreManager : MonoBehaviour { public static ScoreManager Instance { get; private set; } public int totalScore = 0; // Gameplay UI: per-track chart progress keys under bgSPRITE (Scale.y: 0%->0, 100%->2). private readonly Transform[] _idolscoreKeys = new Transform[5]; private readonly Vector3[] _idolscoreKeyBaseScales = new Vector3[5]; private readonly bool[] _idolscoreKeyBaseScaleCached = new bool[5]; private readonly float[] _idolscoreDisplayY = new float[5]; private readonly float[] _idolscoreTargetY = new float[5]; private readonly float[] _idolscoreLastTargetY = new float[5]; private readonly float[] _idolscorePulseAmplitude = new float[5]; private readonly float[] _idolscorePulseSeed = new float[5]; // True once a key has fully converged to its target (no wobble, displayY == targetY). // While settled we skip the per-frame noise + localScale write; cleared when target changes. private readonly bool[] _idolscoreSettled = new bool[5]; // per-track pm score sums (red, green, yellow, purple, blue) public int red_pmScore_sum = 0; public int green_pmScore_sum = 0; public int yellow_pmScore_sum = 0; public int purple_pmScore_sum = 0; public int blue_pmScore_sum = 0; // aggregate of the five track pm sums public int allSum_pmScore = 0; private int[] pmScoreSums = new int[5]; // per-track idol score sums (red, green, yellow, purple, blue) public int red_idolScore_sum = 0; public int green_idolScore_sum = 0; public int yellow_idolScore_sum = 0; public int purple_idolScore_sum = 0; public int blue_idolScore_sum = 0; // aggregate of the five track idol sums public int allSum_idolScore = 0; private int[] idolScoreSums = new int[5]; private readonly int[] tmpCurrents = new int[5]; private readonly int[] tmpMaxes = new int[5]; private readonly GameObject[] allyObjects = new GameObject[5]; private readonly AllyCombatant[] allyCombatants = new AllyCombatant[5]; private int lastAllyCacheFrame = -9999; private readonly TextMeshProUGUI[] slotTmpFallback = new TextMeshProUGUI[5]; private readonly Text[] slotLegacyFallback = new Text[5]; private readonly bool[] slotFallbackResolved = new bool[5]; private float nextIdolscoreKeyLookupTime = 0f; private const float IdolscoreKeyLookupInterval = 0.5f; // 性能:缓存 currentTotalScore 的 TMP/Text 组件,避免每次判定 GetComponent。 private TextMeshProUGUI _cachedTotalTmp; private Text _cachedTotalLegacy; private GameObject _cachedTotalGo; // 性能:记录上次写入 UI 的字符串,值未变则跳过 .text 赋值(避免 TMP 无谓网格重建)。 private string _lastTotalText; // 性能:per-track pm/idol 合计上次写入值(12 项),值未变跳过 .text,避免每判定重写全部槽位触发网格重建。 // 索引:0-5 = pm(red,green,yellow,purple,blue,all),6-11 = idol(同序)。int.MinValue 表示尚未写入。 private readonly int[] _lastSumWritten = new int[12] { int.MinValue, int.MinValue, int.MinValue, int.MinValue, int.MinValue, int.MinValue, int.MinValue, int.MinValue, int.MinValue, int.MinValue, int.MinValue, int.MinValue }; // 性能:RecalculateTotal 里 5 个分槽 "cur/max" 上次写入的字符串及其目标 Text 对象。 // 仅当"数值(cur/max)变化"或"目标对象变化(换局/换场景导致 UI 重建)"时才写 .text, // 避免每判定对 5 个槽位无条件赋值触发 TMP 网格重建。写入逻辑与原先完全一致,只是跳过重复写。 private readonly string[] _lastSlotScoreText = new string[5]; private readonly UnityEngine.Object[] _lastSlotScoreTarget = new UnityEngine.Object[5]; // 仅当数值变化时写 TMP.text(避免相同字符串触发无谓网格重建)。 private void SetSumTextIfChanged(TextMeshProUGUI field, int value, int cacheIndex) { if (field == null) return; if (_lastSumWritten[cacheIndex] == value) return; _lastSumWritten[cacheIndex] = value; field.text = value.ToString(); } private JudgeManager hookedJudgeManager; private bool perfectBonusHooked = false; private int perfectClearBonusPm = 0; private bool perfectClearBonusApplied = false; private int comboRecentPlusAmount = 0; private int displayedRecentPlusAmount = 0; private int lastKnownCombo = 0; private float lastRecentPlusScoreTime = float.NegativeInfinity; private Tween recentPlusAmountValueTween; private const float RecentPlusResetDelaySeconds = 5f; [Header("Judgement Statistics")] public int countPerfect = 0; public int countGreat = 0; public int countGood = 0; public int countMiss = 0; [Header("Per-Track Judgement Statistics")] public int[] trackPerfectCounts = new int[5]; public int[] trackGreatCounts = new int[5]; public int[] trackGoodCounts = new int[5]; public int[] trackMissCounts = new int[5]; [Header("Timing Statistics")] public int countEarly = 0; public int countLate = 0; public float totalOffsetMs = 0f; public int offsetCount = 0; // Raw per-hit samples (ms) for median/suggestion. Miss (0) not included. private readonly System.Collections.Generic.List offsetSamples = new System.Collections.Generic.List(); /// /// 只读访问判定偏移样本列表(供直方图等外部组件读取)。 /// public System.Collections.Generic.IReadOnlyList OffsetSamples => offsetSamples; public void ResetStatistics() { countPerfect = countGreat = countGood = countMiss = 0; perfectClearBonusPm = 0; perfectClearBonusApplied = false; for (int i = 0; i < 5; i++) { trackPerfectCounts[i] = 0; trackGreatCounts[i] = 0; trackGoodCounts[i] = 0; trackMissCounts[i] = 0; } countEarly = countLate = 0; totalOffsetMs = 0f; offsetCount = 0; offsetSamples.Clear(); } /// /// Fired whenever a timing offset is recorded (Positive = Early, Negative = Late). /// Display-only hook (e.g. realtime offset bar); does not affect scoring/judgement. /// public static event System.Action OnOffsetRecorded; /// /// Records a timing offset in milliseconds. Positive = Early, Negative = Late. /// public void RecordOffset(float offsetMs) { if (float.IsNaN(offsetMs)) return; totalOffsetMs += offsetMs; offsetCount++; offsetSamples.Add(offsetMs); if (offsetMs > 0) countEarly++; else if (offsetMs < 0) countLate++; OnOffsetRecorded?.Invoke(offsetMs); } /// /// Returns median offset (ms) across all recorded hits. More robust than mean for /// calibration suggestions — single outlier taps don't skew it. /// public float GetMedianOffsetMs() { int n = offsetSamples.Count; if (n == 0) return 0f; var sorted = new System.Collections.Generic.List(offsetSamples); sorted.Sort(); int mid = n / 2; return (n % 2 == 1) ? sorted[mid] : 0.5f * (sorted[mid - 1] + sorted[mid]); } private void Awake() { if (Instance == null) Instance = this; else Destroy(gameObject); for (int i = 0; i < pmScoreSums.Length; i++) pmScoreSums[i] = 0; for (int i = 0; i < idolScoreSums.Length; i++) idolScoreSums[i] = 0; for (int i = 0; i < 5; i++) { _idolscoreDisplayY[i] = 0f; _idolscoreTargetY[i] = 0f; _idolscoreLastTargetY[i] = 0f; _idolscorePulseAmplitude[i] = 0f; _idolscorePulseSeed[i] = UnityEngine.Random.Range(0.01f, 999f); } ResetStatistics(); comboRecentPlusAmount = 0; displayedRecentPlusAmount = 0; lastKnownCombo = 0; lastRecentPlusScoreTime = float.NegativeInfinity; } private void OnEnable() { TryHookPerfectBonusEvent(); } private void OnDisable() { UnhookPerfectBonusEvent(); } private void OnDestroy() { KillRecentPlusAmountValueTween(); UnhookPerfectBonusEvent(); } private void Start() { // Ensure the progress keys start at 0. UpdateIdolscoreKeyScales(); TryHookPerfectBonusEvent(); RefreshAllScoreUi(); } private void Update() { EnsureIdolscoreKeys(); UpdateRecentPlusAmountTimeout(); float dt = Time.unscaledDeltaTime; if (dt <= 0f) return; for (int i = 0; i < 5; i++) { Transform t = _idolscoreKeys[i]; if (t == null) continue; // Settled: display converged to target and wobble decayed. Nothing changes frame to // frame, so skip the noise sample and localScale write until the target moves again. if (_idolscoreSettled[i]) continue; Vector3 baseScale = _idolscoreKeyBaseScaleCached[i] ? _idolscoreKeyBaseScales[i] : t.localScale; float targetY = Mathf.Max(0f, _idolscoreTargetY[i]); // Fast rise, slow settle. float smooth = 1f - Mathf.Exp(-7.5f * dt); _idolscoreDisplayY[i] = Mathf.Lerp(_idolscoreDisplayY[i], targetY, smooth); // Random growth wobble when target updates. _idolscorePulseAmplitude[i] = Mathf.Lerp(_idolscorePulseAmplitude[i], 0f, 4.8f * dt); float noise = (Mathf.PerlinNoise(_idolscorePulseSeed[i], Time.unscaledTime * 7f) - 0.5f) * 2f; float y = Mathf.Max(0f, _idolscoreDisplayY[i] + noise * _idolscorePulseAmplitude[i]); t.localScale = new Vector3(baseScale.x, y, baseScale.z); // Mark settled once the smoothed value has essentially reached the target and the // wobble amplitude has decayed to zero, so the next frame can early-out. if (Mathf.Abs(_idolscoreDisplayY[i] - targetY) < 0.0005f && _idolscorePulseAmplitude[i] < 0.0005f) { _idolscoreDisplayY[i] = targetY; _idolscorePulseAmplitude[i] = 0f; t.localScale = new Vector3(baseScale.x, targetY, baseScale.z); _idolscoreSettled[i] = true; } } } private void TryHookPerfectBonusEvent() { if (!Application.isPlaying) return; if (perfectBonusHooked) { if (hookedJudgeManager != null) return; // previous JudgeManager got destroyed across scene switch perfectBonusHooked = false; } var jm = JudgeManager.Instance ?? SceneObjectLookupCache.FindAny(); if (jm == null) return; jm.AllNotesJudged -= OnAllNotesJudgedForPerfectBonus; jm.AllNotesJudged += OnAllNotesJudgedForPerfectBonus; hookedJudgeManager = jm; perfectBonusHooked = true; } private void UnhookPerfectBonusEvent() { if (!perfectBonusHooked) return; if (hookedJudgeManager != null) { hookedJudgeManager.AllNotesJudged -= OnAllNotesJudgedForPerfectBonus; } hookedJudgeManager = null; perfectBonusHooked = false; } private int GetCurrentChartLogicalNoteCount() { var bmm = SceneObjectLookupCache.FindAny(); if (bmm != null) { if (bmm.beatmap != null && bmm.beatmap.notes != null && bmm.beatmap.notes.Length > 0) return bmm.beatmap.notes.Length; if (bmm.parsedNoteAmount > 0) return bmm.parsedNoteAmount; } // Fallback to runtime judged count if beatmap reference is unavailable. return countPerfect + countGreat + countGood + countMiss; } private int GetCurrentChartLeftoverScore() { var bmm = SceneObjectLookupCache.FindAny(); if (bmm == null) return 0; return Mathf.Max(0, bmm.leftoverScore); } private void OnAllNotesJudgedForPerfectBonus() { if (perfectClearBonusApplied) return; int noteCount = Mathf.Max(0, GetCurrentChartLogicalNoteCount()); bool allPerfect = noteCount > 0 && countGreat == 0 && countGood == 0 && countMiss == 0 && countPerfect >= noteCount; if (allPerfect) { perfectClearBonusPm = GetCurrentChartLeftoverScore(); } else { perfectClearBonusPm = 0; } perfectClearBonusApplied = true; long aggPm = 0; long aggIdol = 0; for (int i = 0; i < pmScoreSums.Length; i++) aggPm += pmScoreSums[i]; for (int i = 0; i < idolScoreSums.Length; i++) aggIdol += idolScoreSums[i]; long aggPmWithBonus = aggPm + (long)perfectClearBonusPm; allSum_pmScore = (int)Mathf.Min((float)aggPmWithBonus, (float)int.MaxValue - 1f); allSum_idolScore = (int)Mathf.Min((float)aggIdol, (float)int.MaxValue - 1f); var ui = teamUIController.Instance; if (ui != null) { if (ui.allSum_pmScore != null) ui.allSum_pmScore.text = allSum_pmScore.ToString(); if (ui.allSum_idolScore != null) ui.allSum_idolScore.text = allSum_idolScore.ToString(); } RecalculateTotal(); GameplaySkillLogger.RecordScoreDelta( "PerfectClearBonus", -1, perfectClearBonusPm, perfectClearBonusPm, 0, 0, float.NaN, false, -1, -1, allSum_pmScore, allSum_idolScore, totalScore); } private void EnsureIdolscoreKeys() { bool hasMissingKey = false; for (int i = 0; i < _idolscoreKeys.Length; i++) { if (_idolscoreKeys[i] == null) { hasMissingKey = true; break; } } // Avoid scene lookup every frame when one of these optional key objects is absent. if (hasMissingKey && Time.unscaledTime >= nextIdolscoreKeyLookupTime) { nextIdolscoreKeyLookupTime = Time.unscaledTime + IdolscoreKeyLookupInterval; if (_idolscoreKeys[0] == null) _idolscoreKeys[0] = SceneObjectLookupCache.Find("idolscoreKey_red")?.transform; if (_idolscoreKeys[1] == null) _idolscoreKeys[1] = SceneObjectLookupCache.Find("idolscoreKey_green")?.transform; if (_idolscoreKeys[2] == null) _idolscoreKeys[2] = SceneObjectLookupCache.Find("idolscoreKey_yellow")?.transform; if (_idolscoreKeys[3] == null) _idolscoreKeys[3] = SceneObjectLookupCache.Find("idolscoreKey_purple")?.transform; if (_idolscoreKeys[4] == null) _idolscoreKeys[4] = SceneObjectLookupCache.Find("idolscoreKey_blue")?.transform; } for (int i = 0; i < 5; i++) { var t = _idolscoreKeys[i]; if (t == null) continue; if (_idolscoreKeyBaseScaleCached[i]) continue; _idolscoreKeyBaseScales[i] = t.localScale; _idolscoreKeyBaseScaleCached[i] = true; } } private void UpdateIdolscoreKeyScales() { EnsureIdolscoreKeys(); EnsureAllyCache(); for (int i = 0; i < 5; i++) { var t = _idolscoreKeys[i]; if (t == null) continue; int maxScore = 0; var ally = allyCombatants[i]; if (ally != null) maxScore = Mathf.Max(0, ally.maxTrackScore); int curScore = pmScoreSums[i]; float ratio = maxScore > 0 ? (float)curScore / maxScore : 0f; ratio = Mathf.Clamp01(ratio); float targetY = ratio * 2f; _idolscoreTargetY[i] = targetY; if (Mathf.Abs(_idolscoreLastTargetY[i] - targetY) > 0.0001f) { _idolscoreLastTargetY[i] = targetY; _idolscorePulseAmplitude[i] = UnityEngine.Random.Range(0.08f, 0.24f); // Target moved (and/or wobble re-seeded): resume per-frame animation. _idolscoreSettled[i] = false; } if (targetY <= 0.001f) _idolscoreDisplayY[i] = 0f; } } private void EnsureAllyCache() { int frame = Time.frameCount; if (frame - lastAllyCacheFrame < 30) return; lastAllyCacheFrame = frame; for (int i = 0; i < 5; i++) { var current = allyCombatants[i]; if (current != null) continue; var go = allyObjects[i]; if (go == null) { go = SceneObjectLookupCache.Find($"ally_0{i + 1}"); allyObjects[i] = go; } if (go != null) allyCombatants[i] = go.GetComponent(); } } private void ResolveSlotFallbackUi(int slotIndex, GameObject parent) { if (slotIndex < 0 || slotIndex >= 5) return; if (slotFallbackResolved[slotIndex]) return; if (parent == null) return; slotTmpFallback[slotIndex] = parent.GetComponentInChildren(true); if (slotTmpFallback[slotIndex] == null) { slotLegacyFallback[slotIndex] = parent.GetComponentInChildren(true); } slotFallbackResolved[slotIndex] = true; } /// /// Add a per-note pm score delta to the specified track (0..4) and update named fields + aggregate. /// Also calculates idol score as pmDelta * scoreEfficiency. /// public void AddPmScoreForTrack(int trackIndex, int pmDelta, float scoreEfficiency, bool isSkillRelated = false) { TryHookPerfectBonusEvent(); if (trackIndex < 0 || trackIndex >= pmScoreSums.Length) return; if (pmDelta == 0) return; int pmBefore = pmScoreSums[trackIndex]; pmScoreSums[trackIndex] += pmDelta; // clamp at int.MaxValue-1 to avoid overflow if (pmScoreSums[trackIndex] < 0) pmScoreSums[trackIndex] = 0; if (pmScoreSums[trackIndex] > int.MaxValue - 1) pmScoreSums[trackIndex] = int.MaxValue - 1; int pmActualDelta = pmScoreSums[trackIndex] - pmBefore; int idolBefore = idolScoreSums[trackIndex]; int idolDelta = Mathf.FloorToInt(pmDelta * scoreEfficiency); idolScoreSums[trackIndex] += idolDelta; if (idolScoreSums[trackIndex] < 0) idolScoreSums[trackIndex] = 0; if (idolScoreSums[trackIndex] > int.MaxValue - 1) idolScoreSums[trackIndex] = int.MaxValue - 1; int idolActualDelta = idolScoreSums[trackIndex] - idolBefore; // Spawn floating number popup for idol score change if (idolActualDelta != 0) { var type = idolActualDelta > 0 ? iNumberPrefabController.InstantNumberType.IscorePlus : iNumberPrefabController.InstantNumberType.IscoreMinus; iNumberPrefabController.SpawnForAllyStatic(trackIndex, type, idolActualDelta); } // update named fields for easy access red_pmScore_sum = pmScoreSums[0]; green_pmScore_sum = pmScoreSums[1]; yellow_pmScore_sum = pmScoreSums[2]; purple_pmScore_sum = pmScoreSums[3]; blue_pmScore_sum = pmScoreSums[4]; red_idolScore_sum = idolScoreSums[0]; green_idolScore_sum = idolScoreSums[1]; yellow_idolScore_sum = idolScoreSums[2]; purple_idolScore_sum = idolScoreSums[3]; blue_idolScore_sum = idolScoreSums[4]; // recompute aggregates long aggPm = 0; long aggIdol = 0; for (int i = 0; i < pmScoreSums.Length; i++) aggPm += pmScoreSums[i]; for (int i = 0; i < idolScoreSums.Length; i++) aggIdol += idolScoreSums[i]; long aggPmWithBonus = aggPm + (long)perfectClearBonusPm; allSum_pmScore = (int)Mathf.Min((float)aggPmWithBonus, (float)int.MaxValue - 1f); allSum_idolScore = (int)Mathf.Min((float)aggIdol, (float)int.MaxValue - 1f); if (JudgeManager.IsDebugEnabled) Debug.Log($"[ScoreManager] Added {pmDelta} to track {trackIndex} pm sum, idol delta {idolDelta}. New pm sums: R{red_pmScore_sum} G{green_pmScore_sum} Y{yellow_pmScore_sum} P{purple_pmScore_sum} B{blue_pmScore_sum} -> allSumPm={allSum_pmScore}. Idol sums: R{red_idolScore_sum} G{green_idolScore_sum} Y{yellow_idolScore_sum} P{purple_idolScore_sum} B{blue_idolScore_sum} -> allSumIdol={allSum_idolScore}"); UpdateRecentPlusAmountUi(pmActualDelta + idolActualDelta); // Update UI in teamUIController if available (per-track pm sums + aggregate) var ui = teamUIController.Instance; if (ui != null) { // Legacy Text fields on teamUIController // 性能:仅在数值变化时写(SetSumTextIfChanged),避免每判定重写 12 个槽位触发 TMP 网格重建。 try { SetSumTextIfChanged(ui.red_pmScore_sum, red_pmScore_sum, 0); SetSumTextIfChanged(ui.green_pmScore_sum, green_pmScore_sum, 1); SetSumTextIfChanged(ui.yellow_pmScore_sum, yellow_pmScore_sum, 2); SetSumTextIfChanged(ui.purple_pmScore_sum, purple_pmScore_sum, 3); SetSumTextIfChanged(ui.blue_pmScore_sum, blue_pmScore_sum, 4); SetSumTextIfChanged(ui.allSum_pmScore, allSum_pmScore, 5); SetSumTextIfChanged(ui.red_idolScore_sum, red_idolScore_sum, 6); SetSumTextIfChanged(ui.green_idolScore_sum, green_idolScore_sum, 7); SetSumTextIfChanged(ui.yellow_idolScore_sum, yellow_idolScore_sum, 8); SetSumTextIfChanged(ui.purple_idolScore_sum, purple_idolScore_sum, 9); SetSumTextIfChanged(ui.blue_idolScore_sum, blue_idolScore_sum, 10); SetSumTextIfChanged(ui.allSum_idolScore, allSum_idolScore, 11); } catch (System.Exception ex) { if (JudgeManager.IsDebugEnabled) Debug.LogWarning($"[ScoreManager] Failed to write sums to teamUIController fields: {ex}"); } } // Optionally update any UI for pm sums here if teamUIController exposes fields (not implemented by default) // Ensure total is recalculated when pm/idol aggregates change so totalScore reflects pm + idol RecalculateTotal(); // Update per-track progress keys (bgSPRITE/idolscoreKey_*). UpdateIdolscoreKeyScales(); GameplaySkillLogger.RecordScoreDelta( "AddPmScoreForTrack", trackIndex, pmDelta, pmActualDelta, idolDelta, idolActualDelta, scoreEfficiency, isSkillRelated, pmScoreSums[trackIndex], idolScoreSums[trackIndex], allSum_pmScore, allSum_idolScore, totalScore); } /// /// Add idol score directly to a track (0..4) without changing pm score. /// Used by skills that grant/consume idol score independently from note judgement pm. /// public void AddIdolScoreForTrack(int trackIndex, int idolDelta) { TryHookPerfectBonusEvent(); if (trackIndex < 0 || trackIndex >= idolScoreSums.Length) return; if (idolDelta == 0) return; int idolBefore = idolScoreSums[trackIndex]; idolScoreSums[trackIndex] += idolDelta; if (idolScoreSums[trackIndex] < 0) idolScoreSums[trackIndex] = 0; if (idolScoreSums[trackIndex] > int.MaxValue - 1) idolScoreSums[trackIndex] = int.MaxValue - 1; int actual = idolScoreSums[trackIndex] - idolBefore; if (actual != 0) { var type = actual > 0 ? iNumberPrefabController.InstantNumberType.IscorePlus : iNumberPrefabController.InstantNumberType.IscoreMinus; iNumberPrefabController.SpawnForAllyStatic(trackIndex, type, actual); } UpdateRecentPlusAmountUi(actual); red_idolScore_sum = idolScoreSums[0]; green_idolScore_sum = idolScoreSums[1]; yellow_idolScore_sum = idolScoreSums[2]; purple_idolScore_sum = idolScoreSums[3]; blue_idolScore_sum = idolScoreSums[4]; long aggPm = 0; long aggIdol = 0; for (int i = 0; i < pmScoreSums.Length; i++) aggPm += pmScoreSums[i]; for (int i = 0; i < idolScoreSums.Length; i++) aggIdol += idolScoreSums[i]; long aggPmWithBonus = aggPm + (long)perfectClearBonusPm; allSum_pmScore = (int)Mathf.Min((float)aggPmWithBonus, (float)int.MaxValue - 1f); allSum_idolScore = (int)Mathf.Min((float)aggIdol, (float)int.MaxValue - 1f); var ui = teamUIController.Instance; if (ui != null) { try { if (ui.red_idolScore_sum != null) ui.red_idolScore_sum.text = red_idolScore_sum.ToString(); if (ui.green_idolScore_sum != null) ui.green_idolScore_sum.text = green_idolScore_sum.ToString(); if (ui.yellow_idolScore_sum != null) ui.yellow_idolScore_sum.text = yellow_idolScore_sum.ToString(); if (ui.purple_idolScore_sum != null) ui.purple_idolScore_sum.text = purple_idolScore_sum.ToString(); if (ui.blue_idolScore_sum != null) ui.blue_idolScore_sum.text = blue_idolScore_sum.ToString(); if (ui.allSum_idolScore != null) ui.allSum_idolScore.text = allSum_idolScore.ToString(); } catch (System.Exception ex) { if (JudgeManager.IsDebugEnabled) Debug.LogWarning($"[ScoreManager] Failed to write idol sums to teamUIController fields: {ex}"); } } RecalculateTotal(); GameplaySkillLogger.RecordScoreDelta( "AddIdolScoreForTrack", trackIndex, 0, 0, idolDelta, actual, float.NaN, true, pmScoreSums[trackIndex], idolScoreSums[trackIndex], allSum_pmScore, allSum_idolScore, totalScore); } public void RecalculateTotal() { EnsureAllyCache(); for (int i = 0; i < 5; i++) { var ally = allyCombatants[i]; if (ally != null) { tmpCurrents[i] = ally.currentScore; tmpMaxes[i] = ally.maxTrackScore; } else { tmpCurrents[i] = 0; tmpMaxes[i] = 0; } } // totalScore should include both pm (per-note) and idol aggregates maintained by ScoreManager long combined = (long)allSum_pmScore + (long)allSum_idolScore; // clamp to int range totalScore = (int)Mathf.Min((float)combined, (float)int.MaxValue - 1f); // update UI if available if (teamUIController.Instance != null) { var ui = teamUIController.Instance; // helper lambda to set text on TMP or legacy Text under a parent fallback void SetScoreText(TextMeshProUGUI assignedTmp, GameObject parent, int cur, int max, string slotName, int slotIdx) { string value = cur + "/" + max; // 【性能】值变门控:仅当 (目标 Text 对象, value) 与该槽位上次写入不同才赋值 .text。 // 目标对象或数值任一变化都会写(换局/换场景使 UI 重建时目标对象变,必写一次), // 因此可见文本与原先逐次无条件赋值完全一致,只是跳过"同对象同值"的重复写。 // 用纯判断函数(非闭包 lambda)避免额外 GC:返回 true 表示需要写并已更新缓存。 bool ShouldWriteSlot(UnityEngine.Object target) { if (slotIdx >= 0 && slotIdx < 5 && ReferenceEquals(_lastSlotScoreTarget[slotIdx], target) && _lastSlotScoreText[slotIdx] == value) { return false; // 同对象同值,已是该文本,跳过写入(等效)。 } if (slotIdx >= 0 && slotIdx < 5) { _lastSlotScoreTarget[slotIdx] = target; _lastSlotScoreText[slotIdx] = value; } return true; } if (assignedTmp != null) { if (ShouldWriteSlot(assignedTmp)) { assignedTmp.text = value; if (JudgeManager.IsDebugEnabled) Debug.Log($"[ScoreManager] Wrote to assigned TMP for {slotName}: '{value}' -> {assignedTmp.gameObject.name}"); } return; } if (parent != null) { int idx = slotName == "teammate01" ? 0 : slotName == "teammate02" ? 1 : slotName == "teammate03" ? 2 : slotName == "teammate04" ? 3 : slotName == "teammate05" ? 4 : -1; if (idx >= 0) { ResolveSlotFallbackUi(idx, parent); var tmp = slotTmpFallback[idx]; if (tmp != null) { if (ShouldWriteSlot(tmp)) { tmp.text = value; if (JudgeManager.IsDebugEnabled) Debug.Log($"[ScoreManager] Wrote to TMP in parent {parent.name} for {slotName}: '{value}' -> {tmp.gameObject.name}"); } return; } var legacy = slotLegacyFallback[idx]; if (legacy != null) { if (ShouldWriteSlot(legacy)) { legacy.text = value; if (JudgeManager.IsDebugEnabled) Debug.Log($"[ScoreManager] Wrote to legacy Text in parent {parent.name} for {slotName}: '{value}' -> {legacy.gameObject.name}"); } return; } } else { var tmp = parent.GetComponentInChildren(true); if (tmp != null) { if (ShouldWriteSlot(tmp)) { tmp.text = value; if (JudgeManager.IsDebugEnabled) Debug.Log($"[ScoreManager] Wrote to TMP in parent {parent.name} for {slotName}: '{value}' -> {tmp.gameObject.name}"); } return; } var legacy = parent.GetComponentInChildren(true); if (legacy != null) { if (ShouldWriteSlot(legacy)) { legacy.text = value; if (JudgeManager.IsDebugEnabled) Debug.Log($"[ScoreManager] Wrote to legacy Text in parent {parent.name} for {slotName}: '{value}' -> {legacy.gameObject.name}"); } return; } } } if (JudgeManager.IsDebugEnabled) Debug.LogWarning($"[ScoreManager] Could not find UI to write score for {slotName}. assignedTmp is null and parent '{parent?.name}' has no TMP/Text children."); } // Update each slot: use the explicit TMP fields if set; otherwise try parent object fields SetScoreText(ui.teammate01_current_scoreText, ui.objectFather_ally01, tmpCurrents[0], tmpMaxes[0], "teammate01", 0); SetScoreText(ui.teammate02_current_scoreText, ui.objectFather_ally02, tmpCurrents[1], tmpMaxes[1], "teammate02", 1); SetScoreText(ui.teammate03_current_scoreText, ui.objectFather_ally03, tmpCurrents[2], tmpMaxes[2], "teammate03", 2); SetScoreText(ui.teammate04_current_scoreText, ui.objectFather_ally04, tmpCurrents[3], tmpMaxes[3], "teammate04", 3); SetScoreText(ui.teammate05_current_scoreText, ui.objectFather_ally05, tmpCurrents[4], tmpMaxes[4], "teammate05", 4); // update total score (try TMP first, then legacy Text) if (ui.currentTotalScore != null) { var go = ui.currentTotalScore.gameObject; if (go != null) { // 性能:仅在目标物体变化时 GetComponent(缓存),避免每次判定重复反射查找。 if (_cachedTotalGo != go) { _cachedTotalGo = go; _cachedTotalTmp = go.GetComponent(); _cachedTotalLegacy = _cachedTotalTmp == null ? go.GetComponent() : null; _lastTotalText = null; // 目标物体变了(如新一局/换场景),强制写一次 } string totalText = totalScore.ToString(); // 性能:值未变则跳过 .text 赋值,避免 TMP 无谓网格重建。 if (totalText != _lastTotalText) { _lastTotalText = totalText; if (_cachedTotalTmp != null) { _cachedTotalTmp.text = totalText; if (JudgeManager.IsDebugEnabled) Debug.Log($"[ScoreManager] Wrote total to TMP: {totalScore} -> {_cachedTotalTmp.gameObject.name}"); } else if (_cachedTotalLegacy != null) { _cachedTotalLegacy.text = totalText; if (JudgeManager.IsDebugEnabled) Debug.Log($"[ScoreManager] Wrote total to legacy Text: {totalScore} -> {_cachedTotalLegacy.gameObject.name}"); } else { ui.currentTotalScore.text = totalText; // fallback if (JudgeManager.IsDebugEnabled) Debug.Log($"[ScoreManager] Wrote total to currentTotalScore field fallback: {totalScore}"); } } } else { ui.currentTotalScore.text = totalScore.ToString(); if (JudgeManager.IsDebugEnabled) Debug.Log($"[ScoreManager] Wrote total to currentTotalScore fallback (no go): {totalScore}"); } } else { if (JudgeManager.IsDebugEnabled) Debug.LogWarning("[ScoreManager] teamUIController.currentTotalScore is null; cannot display total score."); } if (ui.currentScoreRankImage != null && ui.currentScoreRankConfig != null) { ui.currentScoreRankImage.sprite = ui.currentScoreRankConfig.GetRankSprite(totalScore); } } else { if (JudgeManager.IsDebugEnabled) Debug.LogWarning("[ScoreManager] teamUIController.Instance is null; cannot update UI."); } } public void ApplyExternalTotalScoreDelta(int delta) { if (delta == 0) { return; } long next = (long)totalScore + delta; if (next < 0) next = 0; if (next > int.MaxValue - 1L) next = int.MaxValue - 1L; totalScore = (int)next; UpdateRecentPlusAmountUi(delta); RefreshAllScoreUi(); } private void UpdateRecentPlusAmountUi(int scoreDelta) { var ui = teamUIController.Instance; if (ui == null || ui.recentPlusAmountText == null) { return; } int comboNow = ui.CurrentCombo; bool comboBroken = comboNow <= 0; bool comboRestarted = comboNow > 0 && lastKnownCombo <= 0; if (comboBroken) { ResetRecentPlusAmountState(ui, true); lastKnownCombo = comboNow; return; } if (comboRestarted) { ResetRecentPlusAmountState(ui, false); } if (scoreDelta != 0) { comboRecentPlusAmount += scoreDelta; lastRecentPlusScoreTime = Time.unscaledTime; } ui.ResetRecentPlusAmountVisual(); AnimateRecentPlusAmountText(ui, comboRecentPlusAmount); lastKnownCombo = comboNow; } public void RefreshAllScoreUi() { var ui = teamUIController.Instance; if (ui == null) { return; } if (ui.currentTotalScore != null) { ui.currentTotalScore.text = totalScore.ToString(); } if (ui.currentScoreRankImage != null && ui.currentScoreRankConfig != null) { ui.currentScoreRankImage.sprite = ui.currentScoreRankConfig.GetRankSprite(totalScore); } } private void AnimateRecentPlusAmountText(teamUIController ui, int targetValue) { if (ui == null || ui.recentPlusAmountText == null) { return; } KillRecentPlusAmountValueTween(); int startValue = displayedRecentPlusAmount; if (startValue == targetValue) { ui.recentPlusAmountText.text = FormatSignedScoreDelta(targetValue); return; } recentPlusAmountValueTween = DOTween .To(() => startValue, value => { startValue = value; displayedRecentPlusAmount = value; ui.recentPlusAmountText.text = FormatSignedScoreDelta(value); }, targetValue, 0.2f) .SetEase(Ease.OutQuad) .SetUpdate(true) .OnComplete(() => { displayedRecentPlusAmount = targetValue; if (ui.recentPlusAmountText != null) { ui.recentPlusAmountText.text = FormatSignedScoreDelta(targetValue); } recentPlusAmountValueTween = null; }); } private void KillRecentPlusAmountValueTween() { if (recentPlusAmountValueTween == null) { return; } recentPlusAmountValueTween.Kill(); recentPlusAmountValueTween = null; } private void UpdateRecentPlusAmountTimeout() { if (comboRecentPlusAmount == 0) { return; } if (Time.unscaledTime - lastRecentPlusScoreTime < RecentPlusResetDelaySeconds) { return; } ResetRecentPlusAmountState(teamUIController.Instance, false); } private void ResetRecentPlusAmountState(teamUIController ui, bool killTweenOnly) { comboRecentPlusAmount = 0; displayedRecentPlusAmount = 0; lastRecentPlusScoreTime = float.NegativeInfinity; KillRecentPlusAmountValueTween(); if (ui == null || ui.recentPlusAmountText == null) { return; } ui.recentPlusAmountText.text = FormatSignedScoreDelta(0); if (killTweenOnly) { return; } } private static string FormatSignedScoreDelta(int value) { return value >= 0 ? $"+{value}" : value.ToString(); } }