using System; using UnityEngine; using System.Collections; using System.Collections.Generic; using TMPro; using UnityEngine.UI; public class NoteSpawner : MonoBehaviour { public event Action AllNotesSpawned; // invoked when all notes have been spawned public NotePool notePool; // 引用 NotePool public Transform[] spawnPoints; // 对应轨道的生成点 public GameObject[] notePrefabs; // 短音符预制体 public TextMeshProUGUI globalGameTime; // 长音符预制体数组(分别为 start, middle, end) public GameObject[] holdNoteStartPrefabs; public GameObject[] holdNoteMiddlePrefabs; public GameObject[] holdNoteEndPrefabs; public float spawnOffset = 0f; // 生成音符时的时间偏移量 public float bpm; [Header("Global timing adjustments")] [Tooltip("Global additional realtime offset (seconds) added to hit times for all notes. Use to test input latency or adjust judgement timing. Default 0. Can be negative to make notes arrive earlier.")] public float globalHitDelay = 0f; // 新增:视觉下落速度倍率(public,可在 Inspector 调整) // 默认 1.0 = 原始速度。>1 加速下落(视觉更快),<1 减速下落(视觉更慢)。 [Header("Visual speed settings")] [Tooltip("Multiplier applied to visual fall speed. Changing this will automatically adjust spawn timing so notes still arrive at their original beat times.")] [Range(0.8f, 1.25f)] public float speedMultiplier = 1f; [Header("Calibration")] [Tooltip("Tolerance (world units) for snapping middle segments to expected position after spawn.")] public float calibrateTolerance = 0.02f; [Tooltip("How many calibration checks to perform after spawn (spread over frames).")] public int calibrateChecks = 2; [Tooltip("Interval (seconds realtime) between calibration checks.")] public float calibrateInterval = 0.01f; private WaitForSecondsRealtime calibrateWait; private float calibrateWaitSeconds = float.NaN; // 控制是否启用“追赶偏移量(y-offset compensation)”计算,默认关闭 [Header("Early compensation (experimental)")] [Tooltip("When enabled, spawn positioning will compensate each segment's position based on its own activation time so newly spawned segments appear at their expected traveled position. Default: OFF.")] public bool enableYOffsetCompensation = false; [Header("Immediate Settlement")] [Tooltip("Optional. If set, this will be used to trigger settlement UI (JudgeManager.TriggerAllNotesJudged). If null, will fall back to JudgeManager.Instance.")] public JudgeManager judgeManager; [Tooltip("Optional UI Button. When clicked, will immediately stop spawning and enter settlement.")] public Button immediateSettlementButton; [Tooltip("Optional: the pause UI GameObject to disable when immediate settlement is triggered.")] public GameObject pausePanel; [Tooltip("Optional: animations GameObject to disable when entering settlement. Will be restored on Start().")] public GameObject animations; // optional: constants for runtime clamping (kept for internal use) private const float SpeedMultiplierMin = 0.8f; private const float SpeedMultiplierMax = 1.25f; public NoteJudgeConfig judgeConfig; // 判定区间配置,需在 Inspector 赋值 private Beatmap beatmap; private float startTime; // 存储歌曲开始时间 private bool isSpawning = false; // 生成唯一 id(改为自增计数器,避免随机冲突) private static int holdNoteIdCounter = 0; // map from beatmap note index -> assigned holdNoteId (for hold notes only) private Dictionary noteIndexToHoldId = new Dictionary(); private const string NoteSpeedPrefKey = "noteSpeedMultiplier"; private Coroutine spawnCoroutine; private Coroutine settlementCoroutine; // Cache for key bindings to reduce lookups private Dictionary _colorToKeyCache = new Dictionary(); // Guard to avoid double-trigger / deadlock private bool immediateSettlementTriggered = false; private void Awake() { // Load saved visual speed multiplier before any spawning logic uses it float saved = PlayerPrefs.GetFloat(NoteSpeedPrefKey, speedMultiplier); saved = Mathf.Clamp(saved, SpeedMultiplierMin, SpeedMultiplierMax); speedMultiplier = saved; if (JudgeManager.IsDebugEnabled) Debug.Log($"[NoteSpawner] Loaded speedMultiplier={speedMultiplier} from PlayerPrefs"); } private void OnEnable() { // 每次场景启用/加载时:清空立刻结算状态,避免进入场景即锁定 ResetImmediateSettlementState(); BindImmediateSettlementButton(); // subscribe to JudgeManager.AllNotesJudged so we disable animations on normal settlement as well TrySubscribeJudgeManager(); } private void Start() { // restore animations active state on Start if (animations != null) { try { animations.SetActive(true); } catch { } } // ensure subscription if JudgeManager.Instance was not ready during OnEnable TrySubscribeJudgeManager(); } private void OnDisable() { UnbindImmediateSettlementButton(); TryUnsubscribeJudgeManager(); } private void ResetImmediateSettlementState() { immediateSettlementTriggered = false; // 不强制设置 isSpawning,这个状态由 LoadBeatmap 驱动;这里只清锁 } private void BindImmediateSettlementButton() { if (immediateSettlementButton == null) return; try { immediateSettlementButton.onClick.RemoveListener(ForceImmediateSettlement); } catch { } immediateSettlementButton.onClick.AddListener(ForceImmediateSettlement); } private void UnbindImmediateSettlementButton() { if (immediateSettlementButton == null) return; try { immediateSettlementButton.onClick.RemoveListener(ForceImmediateSettlement); } catch { } } private void TrySubscribeJudgeManager() { var jm = judgeManager != null ? judgeManager : JudgeManager.Instance; if (jm != null) { try { jm.AllNotesJudged -= OnSettlementTriggered; } catch { } jm.AllNotesJudged += OnSettlementTriggered; } } private void TryUnsubscribeJudgeManager() { var jm = judgeManager != null ? judgeManager : JudgeManager.Instance; if (jm != null) { try { jm.AllNotesJudged -= OnSettlementTriggered; } catch { } } } private void OnSettlementTriggered() { // Disable animations when settlement begins if (animations != null) { try { animations.SetActive(false); } catch { } } } public void LoadBeatmap(Beatmap loadedBeatmap) { if (isSpawning) return; isSpawning = true; beatmap = loadedBeatmap; if (beatmap == null) { Debug.LogError("加载的谱面为空!"); return; } // initialize JudgeManager total note count (count each NoteData as one logical note; // long notes are counted once as a single logical note) int total = 0; if (beatmap.notes != null) { total = beatmap.notes.Length; } JudgeManager.Instance?.SetTotalNotes(total); bpm = beatmap.bpm; startTime = Time.time; if (JudgeManager.IsDebugEnabled) Debug.Log($"歌曲开始时间: {startTime}"); // keep reference so we can stop spawning when doing immediate settlement spawnCoroutine = StartCoroutine(SpawnNotes()); } private IEnumerator SpawnNotes() { if (beatmap == null || beatmap.notes == null) { Debug.LogError("谱面数据为空!"); isSpawning = false; yield break; } // Cache parameters that don't change within the loop float sm = Mathf.Clamp(speedMultiplier, SpeedMultiplierMin, SpeedMultiplierMax); float baseTravelTime = (60f / bpm) * 4f; float effectiveTravelTime = baseTravelTime / Mathf.Max(0.0001f, sm); float noteSpeed = CalculateSpeed(effectiveTravelTime); float segmentInterval = (60f / bpm) / 4f; // iterate with index so we can map hold notes to generated ids for (int i = 0; i < beatmap.notes.Length; i++) { // 允许外部在运行中打断生成(例如“立刻结算”) if (!isSpawning) yield break; NoteData note = beatmap.notes[i]; float spawnTime = note.time - effectiveTravelTime; float delay = spawnTime - (Time.time - startTime) + spawnOffset; if (delay > 0) { // Use scaled-time wait so spawning is paused while Time.timeScale==0 (PauseManager pause) float target = Time.time + delay; // Wait using frames so this loop respects Time.timeScale (Time.time won't advance when paused) while (Time.time < target) { yield return null; } } if (note.type == "hold") { // create the hold note once and record its id mapping int hid = SpawnHoldNote(note, sm, effectiveTravelTime, noteSpeed, segmentInterval); noteIndexToHoldId[i] = hid; } else { SpawnNote(note, sm, effectiveTravelTime, noteSpeed); } } isSpawning = false; spawnCoroutine = null; // Notify subscribers that all notes have been spawned AllNotesSpawned?.Invoke(); } private KeyCode GetCachedKeyCode(string color) { if (string.IsNullOrEmpty(color)) return KeyCode.None; if (!_colorToKeyCache.TryGetValue(color, out KeyCode key)) { key = KeyBindingManager.GetKeyForColor(color); _colorToKeyCache[color] = key; } return key; } // 生成短音符 public void SpawnNote(NoteData noteData, float sm, float effectiveTravelTime, float noteSpeed) { if (noteData.trackIndex < 0 || noteData.trackIndex >= spawnPoints.Length) { Debug.LogError("轨道索引超出范围!"); return; } KeyCode key = GetCachedKeyCode(noteData.color); if (key == KeyCode.None) { Debug.LogError($"未找到颜色 {noteData.color} 对应的按键!请检查 KeyBindingManager 是否正确初始化。"); return; } GameObject note = notePool.GetNote(noteData.color); if (note == null) { if (JudgeManager.IsDebugEnabled) Debug.LogError("对象池返回了一个空音符!"); return; } Transform spawnPoint = spawnPoints[noteData.trackIndex]; // Calculate hit time (realtime when note should be judged) float rawHit = startTime + noteData.time + globalHitDelay; float realtimeHit = Mathf.Max(0f, rawHit); // Calculate activation time (when note should start moving) float activationTime = realtimeHit - effectiveTravelTime; // Compute early compensation for short notes if enabled float noteYOffset = 0f; if (enableYOffsetCompensation) { float timeSinceNoteActivation = Time.time - activationTime; noteYOffset = Mathf.Max(0f, timeSinceNoteActivation * noteSpeed); } // Set initial position with offset compensation Vector3 initialPosition = spawnPoint.position + Vector3.down * noteYOffset; note.transform.position = initialPosition; note.transform.rotation = Quaternion.identity; Note noteScript = note.GetComponent(); NoteController noteController = note.GetComponent(); if (noteScript != null) { // Setup note script with timing parameters noteScript.Setup(key, noteData.trackIndex, noteSpeed, realtimeHit, noteData.color, judgeConfig, noteData); // Configure controller for absolute positioning (replaces relative Translate) if (noteController != null) { noteController.ConfigureAbsolutePositioning(initialPosition, realtimeHit, effectiveTravelTime, noteYOffset); } // Schedule calibration for short note to correct any drift StartCoroutine(CalibrateNoteAfterSpawn(noteController, initialPosition)); } else { Debug.LogError("音符预制体缺少 Note 组件!"); } } // Modified: return generated holdNoteId so callers can map notes to ids public int SpawnHoldNote(NoteData noteData, float sm, float effectiveTravelTime, float noteSpeed, float segmentInterval) { if (noteData.trackIndex < 0 || noteData.trackIndex >= spawnPoints.Length) { Debug.LogError("轨道索引超出范围!"); return -1; } KeyCode key = GetCachedKeyCode(noteData.color); if (key == KeyCode.None) { Debug.LogError($"未找到颜色 {noteData.color} 对应的按键!"); return -1; } if (string.IsNullOrEmpty(noteData.color)) { Debug.LogError("[NoteSpawner] noteData.color 为空,无法生成音符!"); return -1; } int segmentCount = Mathf.CeilToInt(noteData.length / segmentInterval); if (segmentCount < 1) segmentCount = 1; // 至少一个片段 // 如果用节拍计算的片段间隔与实际长度不整除,实际用于分段的间隔应当按实际长度均分, // 以确保尾部音符排在最后一个中段之后并且到达时间等于谱面结束时间。 float actualSegmentInterval = noteData.length / segmentCount; // 实际用于中段间隔 Transform spawnPoint = spawnPoints[noteData.trackIndex]; // scheduledEndTime 使用谱面时间(note.time + length),但转换为实时 float rawScheduledEnd = startTime + (noteData.time + noteData.length) + globalHitDelay; float scheduledEndTime = Mathf.Max(0f, rawScheduledEnd); // clamp to non-negative // 生成唯一 id(改为自增计数器,避免随机冲突) int holdNoteId = ++holdNoteIdCounter; // base realtime for hits float rawBase = startTime + noteData.time + globalHitDelay; float baseHit = Mathf.Max(0f, rawBase); // 生成 Start 段 GameObject startObj = notePool.GetStartNote(noteData.color); if (startObj == null) { Debug.LogError("对象池返回空 hold note start!"); return -1; } // Hold segments use absolute positioning; spawn at the lane origin. startObj.transform.position = spawnPoint.position; startObj.transform.rotation = Quaternion.identity; HoldNote holdNote = startObj.GetComponent(); if (holdNote != null) { // pass realtime hit time (startTime + note.time) and delay 0, include globalHitDelay holdNote.Setup(holdNoteId, noteData.trackIndex, noteSpeed, baseHit, 0f, false, scheduledEndTime, noteData.color, key, "start", judgeConfig, noteData); // compute visual scale for hold segments so pieces visually connect across speedMultiplier changes float visualScale = 0.9f * sm + 0.1f; // linear fit: f(1)=1, f(2)=1.9 holdNote.ApplyVisualScale(visualScale); // inform hold note of visual speed so it can adapt judgement windows if needed holdNote.visualSpeedMultiplier = sm; } else { Debug.LogError("长音符 start 部分缺少 HoldNote 组件!"); return -1; } // 生成 Middle 片段(1 .. segmentCount-1) for (int i = 1; i < segmentCount; i++) { // keep segment delays based on actualSegmentInterval (unscaled) so middle pieces are consecutive regardless of visual speed float segmentDelay = i * actualSegmentInterval; // 不按 speedMultiplier 缩放,保证中段连续 GameObject segObj = notePool.GetHoldNoteSegment(noteData.color); if (segObj == null) { Debug.LogError("对象池返回空 hold note 片段!"); continue; } segObj.transform.position = spawnPoint.position; segObj.transform.rotation = Quaternion.identity; HoldNote holdSeg = segObj.GetComponent(); if (holdSeg != null) { // 中段都标记为 middle,pass realtime base time and segmentDelay holdSeg.Setup(holdNoteId, noteData.trackIndex, noteSpeed, baseHit, segmentDelay, false, scheduledEndTime, noteData.color, key, "middle", judgeConfig, noteData); // apply same visual scale so middle pieces visually connect float visualScaleMid = 0.9f * sm + 0.1f; holdSeg.ApplyVisualScale(visualScaleMid); holdSeg.visualSpeedMultiplier = sm; // Immediately calibrate position and schedule additional checks to correct any offset Vector3 calibSpawnPos = spawnPoint.position; holdSeg.CalibratePosition(calibSpawnPos, calibrateTolerance); StartCoroutine(CalibrateAfterSpawn(holdSeg, calibSpawnPos)); } else { Debug.LogError("长音符片段缺少 HoldNote 组件!"); } } // 始终在中段之后生成一个明确的 End 段(尾部音符) GameObject endObj = notePool.GetHoldNoteEndSegment(noteData.color); if (endObj == null) { Debug.LogError("对象池返回空 hold note 片段(end)!"); return -1; } float endDelay = segmentCount * actualSegmentInterval; // 不按 speedMultiplier 缩放,使用谱面长度保证尾段紧随中段 endObj.transform.position = spawnPoint.position; endObj.transform.rotation = Quaternion.identity; HoldNote holdEnd = endObj.GetComponent(); if (holdEnd != null) { holdEnd.Setup(holdNoteId, noteData.trackIndex, noteSpeed, baseHit, endDelay, true, scheduledEndTime, noteData.color, key, "end", judgeConfig, noteData); // apply visual scale to end piece as well float visualScaleEnd = 0.9f * sm + 0.1f; holdEnd.ApplyVisualScale(visualScaleEnd); holdEnd.visualSpeedMultiplier = sm; // schedule calibration for end as well to be safe Vector3 calibEndPos = spawnPoint.position; holdEnd.CalibratePosition(calibEndPos, calibrateTolerance); StartCoroutine(CalibrateAfterSpawn(holdEnd, calibEndPos)); } else { Debug.LogError("长音符 end 部分缺少 HoldNote 组件!"); } return holdNoteId; } /// /// 立刻结算: /// - 停止继续生成音符(停止 SpawnNotes 协程) /// - 取消 NoteSpawner 自己的延迟结算协程(PostSpawnSettlementRoutine) /// - 立刻触发结算流程(JudgeManager.TriggerAllNotesJudged) /// 注意:该方法不会清理场上已生成的音符,仅停止后续生成并进入结算。 /// [ContextMenu("Force Immediate Settlement")] public void ForceImmediateSettlement() { if (immediateSettlementTriggered) { if (JudgeManager.IsDebugEnabled) Debug.LogWarning("[NoteSpawner] ForceImmediateSettlement ignored: already triggered."); return; } immediateSettlementTriggered = true; if (JudgeManager.IsDebugEnabled) Debug.LogWarning("[NoteSpawner] ForceImmediateSettlement called: stopping further note spawning and triggering settlement now."); // If a pause UI is assigned, disable it immediately to avoid stuck paused UI during settlement if (pausePanel != null) { try { pausePanel.SetActive(false); if (JudgeManager.IsDebugEnabled) Debug.Log("[NoteSpawner] pausePanel has been disabled by immediate settlement."); } catch (Exception ex) { if (JudgeManager.IsDebugEnabled) Debug.LogWarning("[NoteSpawner] Failed to disable pausePanel: " + ex.Message); } } // Also disable animations if assigned if (animations != null) { try { animations.SetActive(false); } catch { } } // Restore pause manager state if present, otherwise fallback to setting timeScale try { var pm = PauseManager.Instance; if (pm != null) { pm.Pause(false); if (JudgeManager.IsDebugEnabled) Debug.Log("[NoteSpawner] PauseManager.Pause(false) called to resume time."); } else { Time.timeScale = 1f; if (JudgeManager.IsDebugEnabled) Debug.Log("[NoteSpawner] PauseManager not found; Time.timeScale set to 1 as fallback."); } } catch (Exception ex) { try { Time.timeScale = 1f; } catch { } if (JudgeManager.IsDebugEnabled) Debug.LogWarning("[NoteSpawner] Exception while restoring time scale: " + ex.Message); } // stop spawning loop isSpawning = false; // cancel spawn coroutine if (spawnCoroutine != null) { try { StopCoroutine(spawnCoroutine); } catch { } spawnCoroutine = null; } // cancel delayed settlement coroutine if any if (settlementCoroutine != null) { try { StopCoroutine(settlementCoroutine); } catch { } settlementCoroutine = null; } // Disable animations when settlement begins if (animations != null) { try { animations.SetActive(false); } catch { } } // trigger settlement var jm = judgeManager != null ? judgeManager : JudgeManager.Instance; if (jm != null) { jm.TriggerAllNotesJudged(); } else { if (JudgeManager.IsDebugEnabled) Debug.LogError("[NoteSpawner] ForceImmediateSettlement failed: JudgeManager reference missing."); } } // --- end of duplicated methods removal --- private IEnumerator CalibrateAfterSpawn(HoldNote seg, Vector3 spawnPos) { if (seg == null) yield break; for (int i = 0; i < Mathf.Max(1, calibrateChecks); i++) { yield return GetCalibrateWait(); if (seg == null || !seg.gameObject.activeSelf) yield break; seg.CalibratePosition(spawnPos, calibrateTolerance); } } /// /// 校验短音符的位置,防止其因计时偏差而位移 /// private IEnumerator CalibrateNoteAfterSpawn(NoteController noteController, Vector3 expectedSpawnPos) { if (noteController == null) yield break; GameObject noteObj = noteController.gameObject; for (int i = 0; i < Mathf.Max(1, calibrateChecks); i++) { yield return GetCalibrateWait(); if (noteObj == null || !noteObj.activeSelf) yield break; // Check if note position deviates significantly from expected spawn position float distanceDeviation = Vector3.Distance(noteObj.transform.position, expectedSpawnPos); if (distanceDeviation > calibrateTolerance) { // Snap to expected position while allowing vertical movement Vector3 correctedPos = expectedSpawnPos; noteObj.transform.position = correctedPos; if (JudgeManager.IsDebugEnabled) Debug.Log($"[NoteSpawner] Calibrated short note position, deviation was {distanceDeviation:F4}"); } } } private WaitForSecondsRealtime GetCalibrateWait() { if (calibrateWait == null || calibrateWaitSeconds != calibrateInterval) { calibrateWaitSeconds = calibrateInterval; calibrateWait = new WaitForSecondsRealtime(calibrateInterval); } return calibrateWait; } private float CalculateSpeed(float noteTravelTime) { return 10.75f / noteTravelTime; } /// /// Public method to start the settlement routine. Called by GameManager when all notes have been spawned. /// public void StartSettlementRoutine() { if (JudgeManager.IsDebugEnabled) Debug.Log("[NoteSpawner] StartSettlementRoutine called - beginning settlement countdown"); // keep reference so it can be cancelled by ForceImmediateSettlement if (settlementCoroutine != null) StopCoroutine(settlementCoroutine); settlementCoroutine = StartCoroutine(PostSpawnSettlementRoutine(5)); } private IEnumerator PostSpawnSettlementRoutine(int lookback) { // Get relevant notes: last notes within 3 seconds before the last note time if (beatmap == null || beatmap.notes == null || beatmap.notes.Length == 0) yield break; NoteData lastNote = beatmap.notes[beatmap.notes.Length - 1]; float lastNoteTime = lastNote.time; float timeRangeStart = lastNoteTime - 3f; // 3 seconds window instead of 0.5 seconds // Collect notes within the range [timeRangeStart, lastNoteTime] List relevantNotes = new List(); for (int i = beatmap.notes.Length - 1; i >= 0; i--) { NoteData note = beatmap.notes[i]; if (note.time >= timeRangeStart) { relevantNotes.Add(note); } else { break; } } // Reverse to process in chronological order relevantNotes.Reverse(); if (JudgeManager.IsDebugEnabled) Debug.Log($"[NoteSpawner] Found {relevantNotes.Count} relevant notes in range [{timeRangeStart:F3}, {lastNoteTime:F3}]"); // Analyze notes: check if any are hold notes and find the latest ending note float maxEndTime = float.MinValue; NoteData maxEndNote = null; foreach (var note in relevantNotes) { float noteEndTime; if (note.type == "hold") { noteEndTime = note.time + note.length; } else { noteEndTime = note.time; } if (noteEndTime > maxEndTime) { maxEndTime = noteEndTime; maxEndNote = note; } if (JudgeManager.IsDebugEnabled) { Debug.Log($"[NoteSpawner] Note: type={note.type}, time={note.time:F3}, endTime={noteEndTime:F3}"); } } // Determine initial wait and type of max end note float initialWait = 0f; bool maxIsHold = false; if (maxEndNote != null) { if (maxEndNote.type == "hold") { maxIsHold = true; initialWait = maxEndNote.length; } else { maxIsHold = false; initialWait = 0f; } } if (JudgeManager.IsDebugEnabled) { Debug.Log($"[NoteSpawner] Max end note: type={maxEndNote?.type}, time={maxEndNote?.time:F3}, maxIsHold={maxIsHold}, initialWait={initialWait:F3}"); } // Log final chosen extension method and total extension (initial wait + final buffer) as an error for visibility float finalBuffer = 3f; // final buffer used by settlement routine (changed to 3s) float totalWait = initialWait + finalBuffer; string methodName = maxIsHold ? "hold" : "tap"; Debug.LogError($"[NoteSpawner] Settlement extension chosen: method={methodName}, initialWait={initialWait:F3}s, finalBuffer={finalBuffer:F3}s, totalWait={totalWait:F3}s"); // Clear the reference list to release memory after use relevantNotes.Clear(); relevantNotes = null; // Wait for initial period (hold length or 0) if (initialWait > 0f) { if (JudgeManager.IsDebugEnabled) Debug.Log($"[NoteSpawner] Waiting for initial hold length: {initialWait:F3}s"); float target = Time.time + initialWait; while (Time.time < target) yield return null; } // Final buffer: use finalBuffer variable if (JudgeManager.IsDebugEnabled) Debug.Log($"[NoteSpawner] Entering final {finalBuffer} s settlement buffer"); // Wait until 1s remaining, then clear input (clear at finalBuffer-1 seconds mark) float timeBeforeClear = Mathf.Max(0f, finalBuffer - 1f); if (timeBeforeClear > 0f) { float tBefore = Time.time + timeBeforeClear; while (Time.time < tBefore) yield return null; } // Force clear keyboard/input state at the 1s-before-end mark ForceClearInputState(null); // Wait the remaining 1s float tAfter = Time.time + 1f; while (Time.time < tAfter) yield return null; // Trigger settlement try { if (JudgeManager.Instance != null) { JudgeManager.Instance.OnAllNotesJudged(); } else { Debug.LogWarning("[NoteSpawner] JudgeManager.Instance is null when trying to trigger settlement"); } } catch (Exception ex) { Debug.LogWarning("[NoteSpawner] Exception while triggering settlement: " + ex); } } private void ForceClearInputState(List relevantNotes) { // Fire UI key release handlers: reset InputManager key indicator colors var im = InputManager.Instance; if (im != null) { var texts = im.trackKeyTexts; if (texts != null) { for (int ti = 0; ti < texts.Length; ti++) { if (texts[ti] != null) texts[ti].color = im.keyInactiveColor; } } } // Reset per-frame consumption and unlock any track locks if (TrackKeyManager.Instance != null) { TrackKeyManager.Instance.ResetConsumptionState(); TrackKeyManager.Instance.ClearAllLocks(); if (JudgeManager.IsDebugEnabled) Debug.Log("[NoteSpawner] TrackKeyManager consumption state reset and locks cleared"); } // Mark all relevant hold notes as released in JudgeManager // Only process if relevantNotes is provided (non-null) if (relevantNotes != null && JudgeManager.Instance != null && beatmap != null && beatmap.notes != null) { foreach (var note in relevantNotes) { if (note == null) continue; if (note.type == "hold") { // Find the index of this note in beatmap to get its hold ID for (int i = 0; i < beatmap.notes.Length; i++) { if (beatmap.notes[i] == note && noteIndexToHoldId.ContainsKey(i)) { int holdId = noteIndexToHoldId[i]; try { JudgeManager.Instance.RegisterNoteReleased(holdId.ToString(), true); } catch { } } } } } } } }