using UnityEngine; public class Note : BaseNote { private KeyCode keyToPress; private string noteColor; private AnimationController anim; private NoteController controller; private bool isJudged = false; private bool hasLock = false; // track whether we hold the track lock private NoteData noteData; [Header("Inspector")] public NoteJudgeConfig judgeConfig; // Documentation text normalized. // Cache allies per track to avoid GameObject.Find on every judge. private static AllyCombatant[] allyCache; private static BeatmapManager beatmapManagerCache; // Timeout handling - force miss if note isn't judged by this time private float missDeadlineTime = -1f; private static AllyCombatant GetAllyForTrackCached(int trackIndex) { if (trackIndex < 0) return null; if (allyCache == null || allyCache.Length < 10) allyCache = new AllyCombatant[10]; if (allyCache[trackIndex] != null) return allyCache[trackIndex]; var allyGo = GameObject.Find($"ally_0{trackIndex + 1}"); if (allyGo != null) { allyCache[trackIndex] = allyGo.GetComponent(); } return allyCache[trackIndex]; } private static BeatmapManager GetBeatmapManagerCached() { if (beatmapManagerCache != null) return beatmapManagerCache; beatmapManagerCache = Object.FindAnyObjectByType(); return beatmapManagerCache; } private static float JudgeToPmMultiplier(string judgeResult) { switch (judgeResult) { case "Perfect": return 1f; case "Great": return 0.75f; case "Good": return 0.5f; case "Miss": return 0f; default: return 0f; } } private static int ComputePmDeltaFromJudge(string judgeResult, AllyCombatant ally) { // If current character slot has no ally or is dead, then this corresponding track will also not get idol score if (ally == null || ally.IsDead) return 0; int baseScore = 0; var bmm = ally != null ? ally.bmm : null; if (bmm == null) bmm = GetBeatmapManagerCached(); if (bmm != null) baseScore = Mathf.Max(0, bmm.perNoteScore); if (baseScore <= 0 && ally != null) baseScore = Mathf.Max(0, ally.baseTrackScore); if (baseScore <= 0) return 0; float mult = JudgeToPmMultiplier(judgeResult); return Mathf.FloorToInt(baseScore * mult); } private static bool TryRewriteNonMissJudgeToPerfect(int trackIndex, ref string judgeResult) { if (judgeResult != "Great" && judgeResult != "Good") return false; var ally = GetAllyForTrackCached(trackIndex); if (ally == null || ally.IsDead) return false; if (!ally.IsNonMissToPerfectRewriteActive()) return false; judgeResult = "Perfect"; return true; } private static void AdjustCountsAfterRewriteToPerfect(int trackIndex, string originalJudge) { var sm = ScoreManager.Instance; if (sm == null) return; if (originalJudge == "Great") { sm.countGreat = Mathf.Max(0, sm.countGreat - 1); if (trackIndex >= 0 && trackIndex < sm.trackGreatCounts.Length) sm.trackGreatCounts[trackIndex] = Mathf.Max(0, sm.trackGreatCounts[trackIndex] - 1); } else if (originalJudge == "Good") { sm.countGood = Mathf.Max(0, sm.countGood - 1); if (trackIndex >= 0 && trackIndex < sm.trackGoodCounts.Length) sm.trackGoodCounts[trackIndex] = Mathf.Max(0, sm.trackGoodCounts[trackIndex] - 1); } else { return; } sm.countPerfect += 1; if (trackIndex >= 0 && trackIndex < sm.trackPerfectCounts.Length) sm.trackPerfectCounts[trackIndex] += 1; } private void Awake() { anim = GetComponent(); controller = GetComponent(); } public void Setup(KeyCode key, int trackIndex, float speed, float hitTime, string color, NoteJudgeConfig judgeConfig, NoteData data) { keyToPress = key; noteColor = color; TrackIndex = trackIndex; Speed = speed; this.hitTime = hitTime; isJudged = false; hasLock = false; this.judgeConfig = judgeConfig; this.noteData = data; // Set miss deadline: hitTime + missRange (the latest time to judge before auto-miss) float missRange = (judgeConfig?.missRange ?? 0.5f); this.missDeadlineTime = hitTime + missRange; if (controller != null) { controller.SetSpeed(speed); } InputManager.OnKeyPressed += HandlePress; if (judgeConfig == null) { if (JudgeManager.IsDebugEnabled) Debug.LogError($"NoteJudgeConfig is null! 判定配置未赋值,track={trackIndex}"); } else { if (JudgeManager.IsDebugEnabled) Debug.Log($"NoteJudgeConfig: perfect={judgeConfig.perfectRange}, great={judgeConfig.greatRange}, good={judgeConfig.goodRange}, miss={judgeConfig.missRange}"); } } private void OnDestroy() { InputManager.OnKeyPressed -= HandlePress; // Release lock if we still hold it if (hasLock) { TrackKeyManager.Instance?.UnlockTrackForJudge(TrackIndex, gameObject.GetInstanceID().ToString()); hasLock = false; } } private void Update() { // Autoplay: automatically judge notes as Perfect without user input. if (!isJudged && GameConfig.autoPlayEnabled && gameObject.activeSelf) { // Only judge once the note reaches its scheduled hit time. if (Time.time >= hitTime) { TryAutoJudgePerfect(); } return; } // Force miss if we've passed the deadline without being judged if (!isJudged && Time.time > missDeadlineTime && gameObject.activeSelf) { if (JudgeManager.IsDebugEnabled) Debug.Log($"[Note] {noteColor} on track {TrackIndex} exceeded miss deadline at time {Time.time:F3}, forcing Miss"); JudgeMiss(); } } private void TryAutoJudgePerfect() { if (isJudged) return; string myId = gameObject.GetInstanceID().ToString(); // Acquire the track lock to stay compatible with the existing single-judge-per-track invariants. bool locked = false; if (TrackKeyManager.Instance != null) { if (!TrackKeyManager.Instance.TryLockTrackForJudge(TrackIndex, myId)) return; locked = true; } hasLock = locked; float pressTime = hitTime; // force Perfect regardless of frame timing float maxWindow = (judgeConfig?.missRange ?? 0.5f); // If not in judge zone, still allow judgement (we're using scheduled timing). if (TrackKeyManager.Instance != null) { if (!TrackKeyManager.Instance.IsBestCandidate(TrackIndex, myId, pressTime, maxWindow)) { if (JudgeManager.IsDebugEnabled) Debug.Log($"[Note.AutoPlay] Ignored because another note is a better candidate on track {TrackIndex}"); ReleaseTrackLock(myId); return; } } // Record Perfect stats and offsets ScoreManager.Instance.countPerfect += 1; if (TrackIndex >= 0 && TrackIndex < 5) ScoreManager.Instance.trackPerfectCounts[TrackIndex]++; if (noteData != null) noteData.judgeOffsetMs = 0f; ScoreManager.Instance.RecordOffset(0f); const string judgeResult = "Perfect"; InputManager.Instance?.ShowJudgeResult(TrackIndex, judgeResult); JudgeSoundManager.Instance?.PlayJudgeSound(judgeResult); teamUIController.Instance?.OnJudgeResult(judgeResult); if (JudgeManager.IsDebugEnabled) Debug.Log($"[Note.AutoPlay] Spawning judge prefab: color={noteColor}, result={judgeResult}"); Animation_GenerateJudgementSituationPrefab.Instance?.SpawnJudgePrefab(noteColor, judgeResult); try { var ally = GetAllyForTrackCached(TrackIndex); if (ally != null) ally.AddScoreForJudge(judgeResult); int pmDelta = ComputePmDeltaFromJudge(judgeResult, ally); float efficiency = (ally != null && !ally.IsDead) ? ally.scoreEfficiency : 0f; ScoreManager.Instance?.AddPmScoreForTrack(TrackIndex, pmDelta, efficiency, false); } catch (System.Exception ex) { if (JudgeManager.IsDebugEnabled) Debug.LogWarning($"[Note.AutoPlay] Failed to add per-track score: {ex}"); } try { SkillBuilder.Instance?.NotifyNoteHit(TrackIndex, judgeResult, SkillDefinition.NoteTypeTrigger.Tap); } catch { } Judge(); } private void HandlePress(KeyCode key) { // Autoplay ignores player input to guarantee Perfect. if (GameConfig.autoPlayEnabled) return; // only proceed for matching key and not already judged if (isJudged || key != keyToPress) return; string myId = gameObject.GetInstanceID().ToString(); // Try to acquire lock for this note (prevents multiple notes on same track being judged at once) if (TrackKeyManager.Instance != null && !TrackKeyManager.Instance.TryLockTrackForJudge(TrackIndex, myId)) return; hasLock = true; float pressTime = Time.time; float maxWindow = (judgeConfig?.missRange ?? 0.5f); // If there is a controller and the note is not inside judge zone, only allow judgment // if the press time is within the allowed window. Otherwise ignore the press. if (controller != null && !controller.IsInJudgeZone()) { if (Mathf.Abs(pressTime - hitTime) > maxWindow) { // outside allowed window and not in judge zone -> release lock and ignore the press ReleaseTrackLock(myId); if (JudgeManager.IsDebugEnabled) Debug.Log($"[Note] {noteColor} press rejected: outside timing window and not in judge zone"); return; } // otherwise within window: fall through to normal judging } // Prefer the closest candidate in the judge zone for this press if (TrackKeyManager.Instance != null) { if (!TrackKeyManager.Instance.IsBestCandidate(TrackIndex, myId, pressTime, maxWindow)) { if (JudgeManager.IsDebugEnabled) Debug.Log($"[Note] Press ignored because another note is a better candidate on track {TrackIndex}"); ReleaseTrackLock(myId); return; } // Ensure only one note per track consumes this physical press per frame, // but only after we've confirmed this note is the best candidate. if (!TrackKeyManager.Instance.TryConsumeTrackForFrame(TrackIndex)) { if (JudgeManager.IsDebugEnabled) Debug.Log($"[Note] Press ignored due to frame consumption on track {TrackIndex}"); ReleaseTrackLock(myId); return; } } float timeDifference = Mathf.Abs(pressTime - hitTime); // raw offset ms: positive = note was early (hitTime > pressTime) float rawOffsetMs = (hitTime - pressTime) * 1000f; string judgeResult = null; if (judgeConfig != null) { if (timeDifference <= judgeConfig.perfectRange) { if (JudgeManager.IsDebugEnabled) Debug.Log($"{keyToPress}: Perfect"); judgeResult = "Perfect"; ScoreManager.Instance.countPerfect += 1; if (TrackIndex >= 0 && TrackIndex < 5) ScoreManager.Instance.trackPerfectCounts[TrackIndex]++; if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs; ScoreManager.Instance.RecordOffset(rawOffsetMs); } else if (timeDifference <= judgeConfig.greatRange) { if (JudgeManager.IsDebugEnabled) Debug.Log($"{keyToPress}: Great"); judgeResult = "Great"; ScoreManager.Instance.countGreat += 1; if (TrackIndex >= 0 && TrackIndex < 5) ScoreManager.Instance.trackGreatCounts[TrackIndex]++; if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs; ScoreManager.Instance.RecordOffset(rawOffsetMs); } else if (timeDifference <= judgeConfig.goodRange) { if (JudgeManager.IsDebugEnabled) Debug.Log($"{keyToPress}: Good"); judgeResult = "Good"; ScoreManager.Instance.countGood += 1; if (TrackIndex >= 0 && TrackIndex < 5) ScoreManager.Instance.trackGoodCounts[TrackIndex]++; if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs; ScoreManager.Instance.RecordOffset(rawOffsetMs); } else { JudgeMiss(); return; } } else { // fallback timing if (timeDifference <= 0.08f) { if (JudgeManager.IsDebugEnabled) Debug.Log($"{keyToPress}: Perfect"); judgeResult = "Perfect"; ScoreManager.Instance.countPerfect += 1; // Add missing offset record for fallback path too if possible ScoreManager.Instance.RecordOffset(0); } else if (timeDifference <= 0.15f) { if (JudgeManager.IsDebugEnabled) Debug.Log($"{keyToPress}: Great"); judgeResult = "Great"; ScoreManager.Instance.countGreat += 1; ScoreManager.Instance.RecordOffset(0); } else { JudgeMiss(); return; } } if (!string.IsNullOrEmpty(judgeResult)) { string originalJudge = judgeResult; if (TryRewriteNonMissJudgeToPerfect(TrackIndex, ref judgeResult)) { AdjustCountsAfterRewriteToPerfect(TrackIndex, originalJudge); } InputManager.Instance?.ShowJudgeResult(TrackIndex, judgeResult); JudgeSoundManager.Instance?.PlayJudgeSound(judgeResult); teamUIController.Instance?.OnJudgeResult(judgeResult); // Spawn judgment prefab - ensure noteColor is passed correctly if (JudgeManager.IsDebugEnabled) Debug.Log($"[Note] Spawning judge prefab: color={noteColor}, result={judgeResult}"); Animation_GenerateJudgementSituationPrefab.Instance?.SpawnJudgePrefab(noteColor, judgeResult); try { var ally = GetAllyForTrackCached(TrackIndex); if (ally != null) ally.AddScoreForJudge(judgeResult); int pmDelta = ComputePmDeltaFromJudge(judgeResult, ally); float efficiency = (ally != null && !ally.IsDead) ? ally.scoreEfficiency : 0f; ScoreManager.Instance?.AddPmScoreForTrack(TrackIndex, pmDelta, efficiency); } catch (System.Exception ex) { if (JudgeManager.IsDebugEnabled) Debug.LogWarning($"[Note] Failed to add per-track score: {ex}"); } try { SkillBuilder.Instance?.NotifyNoteHit(TrackIndex, judgeResult, SkillDefinition.NoteTypeTrigger.Tap); } catch { } } Judge(); } public bool IsJudged() { return isJudged; } public void Judge() { if (isJudged) return; isJudged = true; if (controller != null) { controller.StopMovement(); controller.PlayHitEffect(); } // Remove from per-track queue immediately so following notes become head string myId = gameObject.GetInstanceID().ToString(); TrackKeyManager.Instance?.UnregisterKey(TrackIndex, myId); // Release lock if we held it if (hasLock) { TrackKeyManager.Instance?.UnlockTrackForJudge(TrackIndex, myId); hasLock = false; } ReturnToPool(); if (anim != null) { anim.PlayDestroyAnimation(noteColor); } // notify global judge manager that this short note has been finally judged (hit) JudgeManager.Instance?.NotifyNoteJudged(); } public void JudgeMiss() { // idempotent: this can be called from multiple paths if (isJudged) return; isJudged = true; ScoreManager.Instance.countMiss += 1; if (TrackIndex >= 0 && TrackIndex < 5) ScoreManager.Instance.trackMissCounts[TrackIndex]++; // Record a 0 offset for Miss to ensure total Offset Count matches note count ScoreManager.Instance?.RecordOffset(0); if (JudgeManager.IsDebugEnabled) Debug.Log($"{keyToPress} Miss"); InputManager.Instance?.ShowJudgeResult(TrackIndex, "Miss"); JudgeSoundManager.Instance?.PlayJudgeSound("Miss"); teamUIController.Instance?.OnJudgeResult("Miss"); // Spawn judgment prefab for Miss - ensure noteColor is passed correctly if (JudgeManager.IsDebugEnabled) Debug.Log($"[Note.JudgeMiss] Spawning miss judge prefab: color={noteColor}"); Animation_GenerateJudgementSituationPrefab.Instance?.SpawnJudgePrefab(noteColor, "Miss"); try { var ally = GetAllyForTrackCached(TrackIndex); if (ally != null) ally.AddScoreForJudge("Miss"); int pmDelta = ComputePmDeltaFromJudge("Miss", ally); float efficiency = (ally != null && !ally.IsDead) ? ally.scoreEfficiency : 0f; ScoreManager.Instance?.AddPmScoreForTrack(TrackIndex, pmDelta, efficiency); } catch (System.Exception ex) { if (JudgeManager.IsDebugEnabled) Debug.LogWarning($"[Note] Failed to add per-track score for Miss: {ex}"); } try { SkillBuilder.Instance?.NotifyNoteHit(TrackIndex, "Miss", SkillDefinition.NoteTypeTrigger.Tap); } catch { } // notify global judge manager that this note has been finally judged (miss) JudgeManager.Instance?.NotifyNoteJudged(); ReturnToPool(); } private void ReleaseTrackLock(string myId) { if (hasLock) { TrackKeyManager.Instance?.UnlockTrackForJudge(TrackIndex, myId); hasLock = false; } } private void ReturnToPool() { InputManager.OnKeyPressed -= HandlePress; // CRITICAL: Always release lock and unregister before deactivating string myId = gameObject.GetInstanceID().ToString(); // Release lock if we still hold it ReleaseTrackLock(myId); // Remove from per-track queue to avoid stale entries TrackKeyManager.Instance?.UnregisterKey(TrackIndex, myId); if (controller != null) { controller.ResetState(); } gameObject.SetActive(false); NotePool.Instance.ReturnNote(gameObject, noteColor); } public int GetTrackIndex() { return TrackIndex; } public KeyCode GetKey() { return keyToPress; } /// /// From Controller notify Note whether it's inside judge zone /// public void SetJudgeZone(bool inZone) { // leaving judge zone if (!inZone) { // Only process if we haven't been judged yet if (!isJudged) { // IMPORTANT: Don't call JudgeMiss directly here because SetJudgeZone is called // from OnTriggerExit2D, which is in a physics callback. // Calling ReturnToPool (which deactivates the object) inside a physics callback // causes "GameObject is already being activated or deactivated" errors. // Instead, mark for miss and let Update handle it next frame. if (JudgeManager.IsDebugEnabled) Debug.Log($"[Note.SetJudgeZone] Note {noteColor} left judge zone on track {TrackIndex}, will force Miss next frame"); missDeadlineTime = Time.time; // Force deadline to now so Update will handle it } } } }