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("判定配置")] public NoteJudgeConfig judgeConfig; // 判定窗口配置,包含判定时间范围 // Cache allies per track to avoid GameObject.Find on every judge. private static AllyCombatant[] allyCache; // 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 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 (GameConfig.verboseLogs) Debug.LogError($"NoteJudgeConfig is null! 判定配置未赋值!track={trackIndex}"); } else { if (GameConfig.verboseLogs) 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() { // Force miss if we've passed the deadline without being judged if (!isJudged && Time.time > missDeadlineTime && gameObject.activeSelf) { if (GameConfig.verboseLogs) Debug.Log($"[Note] {noteColor} on track {TrackIndex} exceeded miss deadline at time {Time.time:F3}, forcing Miss"); JudgeMiss(); } } private void HandlePress(KeyCode key) { // only proceed for matching key and not already judged if (isJudged || key != keyToPress) return; string myId = gameObject.GetInstanceID().ToString(); // Ensure only one note per track consumes this physical press per frame if (TrackKeyManager.Instance != null && !TrackKeyManager.Instance.TryConsumeTrackForFrame(TrackIndex)) { if (GameConfig.verboseLogs) Debug.Log($"[Note] Press ignored due to frame consumption on track {TrackIndex}"); return; } // Ensure we are the front-most note on this track. If not, ignore this press so later press can hit the next note. var headId = TrackKeyManager.Instance?.GetCurrentNoteId(TrackIndex); if (headId != null && headId != myId) { if (GameConfig.verboseLogs) Debug.Log($"[Note] Press ignored because another note is ahead on track {TrackIndex}: head={headId} me={myId}"); return; } // 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 (GameConfig.verboseLogs) Debug.Log($"[Note] {noteColor} press rejected: outside timing window and not in judge zone"); return; } // otherwise within window: fall through to normal judging } 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 (GameConfig.verboseLogs) 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 (GameConfig.verboseLogs) 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 (GameConfig.verboseLogs) 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 (GameConfig.verboseLogs) 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 (GameConfig.verboseLogs) Debug.Log($"{keyToPress}: Great"); judgeResult = "Great"; ScoreManager.Instance.countGreat += 1; ScoreManager.Instance.RecordOffset(0); } else { JudgeMiss(); return; } } if (!string.IsNullOrEmpty(judgeResult)) { InputManager.Instance?.ShowJudgeResult(TrackIndex, judgeResult); JudgeSoundManager.Instance?.PlayJudgeSound(judgeResult); teamUIController.Instance?.OnJudgeResult(judgeResult); // Spawn judgment prefab - ensure noteColor is passed correctly if (GameConfig.verboseLogs) Debug.Log($"[Note] Spawning judge prefab: color={noteColor}, result={judgeResult}"); Animation_GenerateJudgementSituationPrefab.Instance?.SpawnJudgePrefab(noteColor, judgeResult); try { var ally = GetAllyForTrackCached(TrackIndex); if (ally != null) { int added = ally.AddScoreForJudge(judgeResult); float efficiency = ally.scoreEfficiency; ScoreManager.Instance?.AddPmScoreForTrack(TrackIndex, added, efficiency); } } catch (System.Exception ex) { 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 (GameConfig.verboseLogs) 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 (GameConfig.verboseLogs) Debug.Log($"[Note.JudgeMiss] Spawning miss judge prefab: color={noteColor}"); Animation_GenerateJudgementSituationPrefab.Instance?.SpawnJudgePrefab(noteColor, "Miss"); try { var ally = GetAllyForTrackCached(TrackIndex); if (ally != null) { int added = ally.AddScoreForJudge("Miss"); float efficiency = ally.scoreEfficiency; ScoreManager.Instance?.AddPmScoreForTrack(TrackIndex, added, efficiency); } } catch (System.Exception ex) { 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 (GameConfig.verboseLogs) 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 } } } }