Files
2026-07-30 23:15:58 +08:00

735 lines
29 KiB
C#

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<AllyCombatant>();
}
return allyCache[trackIndex];
}
private static BeatmapManager GetBeatmapManagerCached()
{
if (beatmapManagerCache != null) return beatmapManagerCache;
beatmapManagerCache = SceneObjectLookupCache.FindAny<BeatmapManager>();
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<AnimationController>();
}
controller = GetComponent<NoteController>();
}
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;
}
/// <summary>
/// From Controller notify Note whether it's inside judge zone
/// </summary>
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
}
}
}
}