using System.Globalization; using UnityEngine; public class Note : BaseNote { private KeyCode keyToPress; private string noteColor; // 获取用于生成判定 prefab 的颜色。如果 noteColor 为空(初始化异常),从 TrackIndex 推导兜底。 private string GetColorForPrefab() { if (!string.IsNullOrEmpty(noteColor)) return noteColor; if (TrackIndex >= 0 && TrackIndex < 5) { string[] trackColors = { "red", "green", "yellow", "purple", "blue" }; string fallback = trackColors[TrackIndex]; if (JudgeManager.IsDebugEnabled) Debug.LogWarning($"[Note] noteColor was null, using trackIndex {TrackIndex} -> {fallback}"); return fallback; } Debug.LogError($"[Note] Cannot derive color: noteColor is null and TrackIndex {TrackIndex} is invalid"); return null; } private AnimationController anim; private NoteController controller; private bool isJudged = false; private bool hasLock = false; // track whether we hold the track lock private NoteData noteData; private bool isSyncNote; [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 = SceneObjectLookupCache.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 = SceneObjectLookupCache.FindAny(); return beatmapManagerCache; } public static void PrewarmRuntimeCaches() { GetBeatmapManagerCached(); if (allyCache == null || allyCache.Length < 10) allyCache = new AllyCombatant[10]; for (int i = 0; i < 5; i++) { GetAllyForTrackCached(i); } } 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) { int baseScore = 0; var bmm = ally != null ? ally.bmm : null; if (bmm == null) bmm = BeatmapManager.Instance; 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 string BuildNoteLogId() { if (noteData != null) { return "tap:" + TrackIndex + ":" + noteData.time.ToString("0.###", CultureInfo.InvariantCulture) + ":" + gameObject.GetInstanceID(); } return "tap:" + TrackIndex + ":" + hitTime.ToString("0.###", CultureInfo.InvariantCulture) + ":" + gameObject.GetInstanceID(); } private void LogTapJudge(string judgeResult, float rawOffsetMs, bool rewrittenToPerfect, bool autoplay, float actionTime) { GameplaySkillLogger.RecordJudgeResult( "Tap", "Single", TrackIndex, BuildNoteLogId(), judgeResult, rawOffsetMs, rewrittenToPerfect, autoplay, actionTime, hitTime, float.NaN); } // 缓存本音符的实例ID字符串:GameObject 实例ID终身不变(池化复用也不变), // 缓存后避免每次判定/进出判定区都 GetInstanceID().ToString() 分配字符串(热路径 GC)。 private string _cachedId; private string CachedId => _cachedId ?? (_cachedId = gameObject.GetInstanceID().ToString()); // 供 NoteController 等复用同一缓存id(避免它们各自再 ToString 分配)。 public string GetCachedInstanceId() => CachedId; private void Awake() { anim = AnimationController.Global; if (anim == null) { anim = GetComponent(); } controller = GetComponent(); } public void Setup(KeyCode key, int trackIndex, float speed, float hitTime, string color, NoteJudgeConfig judgeConfig, NoteData data, bool isSync = false) { keyToPress = key; noteColor = color; TrackIndex = trackIndex; Speed = speed; this.hitTime = hitTime; isJudged = false; hasLock = false; this.judgeConfig = judgeConfig; this.noteData = data; this.isSyncNote = isSync; // Set miss deadline: hitTime + missRange (the latest time to judge before auto-miss) float missRange = judgeConfig != null ? judgeConfig.missRange : 0.2f; this.missDeadlineTime = hitTime + missRange; if (controller != null) { controller.SetSpeed(speed); } InputManager.OnTrackPressedWithDspTime += HandlePress; if (judgeConfig == null) { if (JudgeManager.IsDebugEnabled) Debug.LogError($"NoteJudgeConfig is null! 判定配置未赋值,track={trackIndex}"); } else { if (JudgeManager.IsDebugEnabled) Debug.Log($"NoteJudgeConfig: perfectE={judgeConfig.perfectEarlyRange} perfectL={judgeConfig.perfectLateRange}, greatE={judgeConfig.greatEarlyRange} greatL={judgeConfig.greatLateRange}, goodE={judgeConfig.goodEarlyRange} goodL={judgeConfig.goodLateRange}, miss={judgeConfig.missRange}"); } } private void OnDestroy() { InputManager.OnTrackPressedWithDspTime -= HandlePress; // Release lock if we still hold it if (hasLock) { TrackKeyManager.Instance?.UnlockTrackForJudge(TrackIndex, CachedId); hasLock = false; } } private void Update() { // 【性能】NowSongTime 每次访问都读 native AudioSettings.dspTime;帧内其值恒定, // 缓存到局部只读一次,与原先多次读取同值,行为等效。 float now = GameplayClock.NowSongTime; // 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 (now >= hitTime) { TryAutoJudgePerfect(); } return; } // Force miss if we've passed the deadline without being judged if (!isJudged && gameObject.activeSelf) { if (now >= missDeadlineTime) { if (JudgeManager.IsDebugEnabled) Debug.Log($"[Note.Update] Forcing miss for {noteColor} on track {TrackIndex}: now={now:F3}, deadline={missDeadlineTime:F3}"); JudgeMiss(); } } } private void TryAutoJudgePerfect() { if (isJudged) return; string myId = CachedId; // 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 != null ? Mathf.Max(judgeConfig.goodEarlyRange, judgeConfig.goodLateRange) : 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"; if (noteData != null) noteData.judgeResult = judgeResult; InputManager.Instance?.ShowJudgeResult(TrackIndex, judgeResult); JudgeSoundManager.Instance?.PlayJudgeSound(judgeResult); teamUIController.Instance?.OnJudgeResult(judgeResult); string colorForPrefab = GetColorForPrefab(); if (JudgeManager.IsDebugEnabled) Debug.Log($"[Note.AutoPlay] Spawning judge prefab: color={colorForPrefab}, result={judgeResult}"); if (!string.IsNullOrEmpty(colorForPrefab)) { Animation_GenerateJudgementSituationPrefab.Instance?.SpawnJudgePrefab(colorForPrefab, 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 { } LogTapJudge(judgeResult, 0f, false, true, pressTime); GameplayLevelRuleEventBus.NotifyTapJudged(TrackIndex, judgeResult, pressTime, noteData); if (isSyncNote) { effectEventController.TryTriggerMultiNoteShake(); } if (judgeResult != "Miss") { TrackJudgeHitEffectController.PlayTrackHitFx(TrackIndex); } globalNoteEffect.TryTrigger(noteData != null ? noteData.noteFunction : null); Judge(); } private void HandlePress(int trackIndex, double pressDspTime) { // Autoplay ignores player input to guarantee Perfect. if (GameConfig.autoPlayEnabled) return; // only proceed for matching track and not already judged if (isJudged || trackIndex != TrackIndex) return; string myId = CachedId; // 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; // Convert the Input System press timestamp (dspTime) into song time for sub-frame-accurate // judgement. Equivalent to the old NowSongTime read but captured at the exact input moment. float pressTime = GameplayClock.SongTimeFromDsp(pressDspTime); float maxWindow = judgeConfig != null ? Mathf.Max(judgeConfig.goodEarlyRange, judgeConfig.goodLateRange) : 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; } } // raw offset ms: positive = note was early (hitTime > pressTime) float rawOffsetMs = (hitTime - pressTime) * 1000f; float offset = pressTime - hitTime; // positive = late, negative = early string judgeResult = null; if (judgeConfig != null) { float earlyTolerance, lateTolerance; // Perfect window earlyTolerance = judgeConfig.perfectEarlyRange; lateTolerance = judgeConfig.perfectLateRange; if (offset >= -earlyTolerance && offset <= lateTolerance) { 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); } // Great window else if (offset >= -(earlyTolerance = judgeConfig.greatEarlyRange) && offset <= (lateTolerance = judgeConfig.greatLateRange)) { 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); } // Good window else if (offset >= -(earlyTolerance = judgeConfig.goodEarlyRange) && offset <= (lateTolerance = judgeConfig.goodLateRange)) { 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 { // "提前按下"不再判 Miss:音符尚未到达判定线(pressTime < hitTime)且超出 good 窗口, // 视为过早误触 → 无任何反应,释放锁并保持未判定,音符继续下落等待玩家在正确时机再次击打。 // 仅"过晚"(pressTime >= hitTime,音符已越过判定点)才照常判 Miss; // 完全没接住的情况由 Update 中 missDeadlineTime 的兜底 auto-Miss 处理(属"过晚没接住")。 if (pressTime < hitTime) { ReleaseTrackLock(myId); if (JudgeManager.IsDebugEnabled) Debug.Log($"[Note] {noteColor} early press ignored (no miss): pressTime={pressTime:F3}, hitTime={hitTime:F3}"); return; } JudgeMiss(); return; } } else { // fallback timing (symmetric, legacy) float timeDifference = Mathf.Abs(pressTime - hitTime); 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(rawOffsetMs); } else if (timeDifference <= 0.15f) { if (JudgeManager.IsDebugEnabled) Debug.Log($"{keyToPress}: Great"); judgeResult = "Great"; ScoreManager.Instance.countGreat += 1; ScoreManager.Instance.RecordOffset(rawOffsetMs); } else { // 同上:提前误触不判 Miss,仅过晚才判 Miss。 if (pressTime < hitTime) { ReleaseTrackLock(myId); if (JudgeManager.IsDebugEnabled) Debug.Log($"[Note] {noteColor} early press ignored (no miss, fallback): pressTime={pressTime:F3}, hitTime={hitTime:F3}"); return; } JudgeMiss(); return; } } if (!string.IsNullOrEmpty(judgeResult)) { string originalJudge = judgeResult; bool rewrittenToPerfect = false; if (TryRewriteNonMissJudgeToPerfect(TrackIndex, ref judgeResult)) { rewrittenToPerfect = true; AdjustCountsAfterRewriteToPerfect(TrackIndex, originalJudge); } if (noteData != null) noteData.judgeResult = judgeResult; InputManager.Instance?.ShowJudgeResult(TrackIndex, judgeResult); JudgeSoundManager.Instance?.PlayJudgeSound(judgeResult); teamUIController.Instance?.OnJudgeResult(judgeResult); // Spawn judgment prefab string colorForPrefab = GetColorForPrefab(); if (JudgeManager.IsDebugEnabled) Debug.Log($"[Note] Spawning judge prefab: color={colorForPrefab}, result={judgeResult}"); if (!string.IsNullOrEmpty(colorForPrefab)) { Animation_GenerateJudgementSituationPrefab.Instance?.SpawnJudgePrefab(colorForPrefab, 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 { } LogTapJudge(judgeResult, rawOffsetMs, rewrittenToPerfect, false, pressTime); GameplayLevelRuleEventBus.NotifyTapJudged(TrackIndex, judgeResult, pressTime, noteData); // FAST/SLOW 预埋反馈:offset = pressTime - hitTime,正=晚(SLOW)/负=早(FAST)。 // 默认无订阅者时为 no-op,不影响任何判定/计分逻辑。 JudgeFeedback.Report(TrackIndex, judgeResult, offset * 1000f); if (isSyncNote && judgeResult != "Miss") { effectEventController.TryTriggerMultiNoteShake(); } if (judgeResult != "Miss") { TrackJudgeHitEffectController.PlayTrackHitFx(TrackIndex); } globalNoteEffect.TryTrigger(noteData != null ? noteData.noteFunction : null); } 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 = CachedId; TrackKeyManager.Instance?.UnregisterKey(TrackIndex, myId); // Release lock if we held it if (hasLock) { TrackKeyManager.Instance?.UnlockTrackForJudge(TrackIndex, myId); hasLock = false; } var animationController = AnimationController.Global ?? anim; if (animationController != null) { animationController.PlayDestroyAnimation(noteColor); } ReturnToPool(); // notify global judge manager that this short note has been finally judged (hit) JudgeManager.Instance?.NotifyNoteJudged(); } public void JudgeMiss() { if (JudgeManager.IsDebugEnabled) Debug.Log($"[Note.JudgeMiss] Called for {noteColor} on track {TrackIndex}, isJudged={isJudged}"); // idempotent: this can be called from multiple paths if (isJudged) { if (JudgeManager.IsDebugEnabled) Debug.Log($"[Note.JudgeMiss] Already judged, skipping"); 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"); if (noteData != null) noteData.judgeResult = "Miss"; InputManager.Instance?.ShowJudgeResult(TrackIndex, "Miss"); JudgeSoundManager.Instance?.PlayJudgeSound("Miss"); teamUIController.Instance?.OnJudgeResult("Miss"); // 生成 Miss 判定 prefab(调试日志无条件输出,便于排查问题) string colorForPrefab = GetColorForPrefab(); if (JudgeManager.IsDebugEnabled) Debug.Log($"[Note.JudgeMiss] Attempting to spawn miss prefab: noteColor={noteColor}, TrackIndex={TrackIndex}, derivedColor={colorForPrefab}, Instance={(Animation_GenerateJudgementSituationPrefab.Instance != null ? "EXISTS" : "NULL")}"); if (string.IsNullOrEmpty(colorForPrefab)) { if (JudgeManager.IsDebugEnabled) Debug.LogError($"[Note.JudgeMiss] Cannot spawn miss prefab: colorForPrefab is null! noteColor={noteColor}, TrackIndex={TrackIndex}"); } else if (Animation_GenerateJudgementSituationPrefab.Instance == null) { if (JudgeManager.IsDebugEnabled) Debug.LogError($"[Note.JudgeMiss] Cannot spawn miss prefab: Animation_GenerateJudgementSituationPrefab.Instance is NULL!"); } else { Animation_GenerateJudgementSituationPrefab.Instance.SpawnJudgePrefab(colorForPrefab, "Miss"); if (JudgeManager.IsDebugEnabled) Debug.Log($"[Note.JudgeMiss] SpawnJudgePrefab called successfully for {colorForPrefab}"); } 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 { } LogTapJudge("Miss", 0f, false, false, GameplayClock.NowSongTime); GameplayLevelRuleEventBus.NotifyTapJudged(TrackIndex, "Miss", GameplayClock.NowSongTime, noteData); // 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.OnTrackPressedWithDspTime -= HandlePress; // CRITICAL: Always release lock and unregister before deactivating string myId = CachedId; // 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) { if (JudgeManager.IsDebugEnabled) Debug.Log($"[Note.SetJudgeZone] {noteColor} on track {TrackIndex}: inZone={inZone}, isJudged={isJudged}"); // 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}, setting deadline to now"); missDeadlineTime = GameplayClock.NowSongTime; // Force deadline to now so Update will handle it } } } }