using System.Collections; using System.Collections.Generic; using UnityEngine; public enum NoteSegment { None, Start, Middle, End } [RequireComponent(typeof(HoldNoteController))] public class HoldNote : BaseNote { private KeyCode keyToPress = KeyCode.None; private string noteColor = string.Empty; private string noteID = string.Empty; private NoteSegment segment = NoteSegment.None; private float hitTime = 0f; private float releaseTime = 0f; private bool hasReleased = true; private bool hasEnteredLine = false; private Coroutine autoReturnCoroutine; private Coroutine scheduledReturnCoroutine; private HoldNoteController controller; private AnimationController anim; public int id; public int trackIndex; public float speed; public float startTime; public float delay; public bool isEnd; public float scheduledEndTime; public string color; public KeyCode key; public string type; // "start", "middle", or "end" // Flags and runtime state private bool isJudged = false; // whether this segment has been judged (true = evaluation done) private bool isHoldActive = false; // whether the hold is currently active (player is holding) // whether this middle segment was held from the start (used for logic checks) private bool hasBeenHeldFromStart = false; [Header("Judge configuration")] public NoteJudgeConfig judgeConfig; // judge windows configuration private NoteData noteData; // store original transform scale so we can restore on reset private Vector3 originalLocalScale = Vector3.one; // visual transform (child) to scale instead of root to avoid affecting colliders private Transform visualTransform = null; private Vector3 originalVisualLocalScale = Vector3.one; [Header("Hold judgement adjustments")] [Tooltip("Multiplier applied to judgement windows for hold notes. >1 makes hold judgement more lenient (wider windows).")] [Range(1f, 2f)] public float holdWindowMultiplier = 1.3f; public float visualSpeedMultiplier = 1f; // injected from NoteSpawner to adapt windows when visual speed changes // track when the hold actually started (real-time) so we can compute held fraction private float holdStartTime = -1f; // Cache allies per track to avoid GameObject.Find on first judgement. private static AllyCombatant[] allyCache; 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() { controller = GetComponent(); // Prefer the global shared AnimationController if available, otherwise fallback to local or scene instance anim = AnimationController.Global; if (anim == null) { anim = GetComponent(); if (anim == null) { anim = FindObjectOfType(); } } if (anim == null) { if (GameConfig.verboseLogs) Debug.LogWarning("HoldNote: No AnimationController found (Global/local/scene). Particle effects will be unavailable."); } if (controller != null) { controller.OnJudgeZoneChanged += OnJudgeZoneChanged; } // capture original local scale for this prefab instance originalLocalScale = transform.localScale; // find a dedicated visual child to scale (prefer child named "Visual") visualTransform = transform.Find("Visual"); if (visualTransform == null) { // fallback: try to find first child that has a SpriteRenderer, MeshRenderer or CanvasRenderer var sr = GetComponentInChildren(true); if (sr != null) visualTransform = sr.transform; else { var mr = GetComponentInChildren(true); if (mr != null) visualTransform = mr.transform; else { var ir = GetComponentInChildren(true); if (ir != null) visualTransform = ir.transform; } } } if (visualTransform != null) originalVisualLocalScale = visualTransform.localScale; else originalVisualLocalScale = originalLocalScale; } private void OnDestroy() { if (controller != null) { controller.OnJudgeZoneChanged -= OnJudgeZoneChanged; } } private void OnJudgeZoneChanged(bool inZone) { hasEnteredLine = inZone; } private void OnEnable() { ResetState(); } private void PlayHitAnimation() { var controller = AnimationController.Global ?? anim; if (controller != null) { controller.PlayDestroyAnimation(noteColor); } } private IEnumerator DelayedDisableAnimation(GameObject animationObject, float delay) { // No-op: we used to disable the shared AnimationController here which stopped coroutines. // Keep as no-op to avoid side effects. yield return null; } public void Setup(int id, int trackIndex, float speed, float time, float delay, bool isEnd, float scheduledEnd, string color, KeyCode key, string type, NoteJudgeConfig judgeConfig, NoteData noteData) { this.id = id; this.trackIndex = trackIndex; // also set BaseNote.TrackIndex so other systems using TrackIndex property work consistently this.speed = speed; this.startTime = time; this.delay = delay; this.isEnd = isEnd; this.scheduledEndTime = scheduledEnd; this.color = color; this.key = key; this.type = type; this.judgeConfig = judgeConfig; this.noteID = id.ToString(); this.keyToPress = key; this.noteColor = color; // hitTime now uses real time base passed in as 'time' plus delay this.hitTime = time + delay; this.segment = (delay == 0f) ? NoteSegment.Start : (isEnd ? NoteSegment.End : NoteSegment.Middle); this.hasEnteredLine = false; this.hasReleased = false; if (judgeConfig == null) { if (GameConfig.verboseLogs) Debug.LogError($"HoldNoteJudgeConfig is null! judgeConfig not assigned. track={trackIndex}"); } else { if (GameConfig.verboseLogs) Debug.Log($"HoldNoteJudgeConfig: perfect={judgeConfig.perfectRange}, great={judgeConfig.greatRange}, good={judgeConfig.goodRange}, miss={judgeConfig.missRange}"); } this.noteData = noteData; // Debug info to help investigate end note visibility if (GameConfig.verboseLogs) { Debug.Log($"[HoldNote.Setup] id={noteID} segment={segment} type={type} hitTime={hitTime:F2} scheduledEnd={scheduledEndTime:F2} color={color} track={trackIndex}"); Debug.Log($"[HoldNote.Setup] Controller present={(controller != null)}, AnimationController global={(AnimationController.Global != null)}, localAnim={(anim != null)}"); } // Register initial judge state in JudgeManager JudgeManager.Instance?.RegisterNoteReleased(noteID, false); if (segment == NoteSegment.Start) JudgeManager.Instance?.RegisterStartJudged(noteID, false); // initially mark start as not judged if (segment == NoteSegment.End) JudgeManager.Instance?.RegisterScheduledEndTime(noteID, scheduledEndTime); controller?.SetSpeed(speed); // IMPORTANT: configure timing using the same timebase as hitTime. // NoteSpawner/Setup passes 'time' as the base realtime hit point already (startTime + note.time + globalHitDelay). // We need travelTime so the segment starts moving at (hitTime - travelTime) rather than (Time.time + delay). float travelTime = 0f; if (speed > 0.0001f) { // NoteSpawner.CalculateSpeed uses: speed = 10.75f / travelTime, so travelTime = 10.75f / speed travelTime = 10.75f / speed; } if (controller != null) { // Keep all hold segments moving in absolute time so they stay visually connected. float headActivationTime = time - travelTime; float baseSpawnYOffset = -speed * delay; controller.ConfigureAbsolutePositioning(transform.position, headActivationTime, baseSpawnYOffset); } else { // fallback controller?.SetSegmentDelay(delay); } // Removed: CalibratePosition for delay==0f was causing Start segments to snap to incorrect positions // if they started moving immediately after Setup (due to activationTime <= Time.time). // Start segments should begin at spawnPoint and move from there naturally. } private void Update() { // If already judged, skip heavy logic if (isJudged) return; // If start was judged and player is holding key, start hold effects if (!isHoldActive && JudgeManager.Instance.IsStartJudged(noteID) && !JudgeManager.Instance.HasNoteReleased(noteID) && Input.GetKey(keyToPress)) { isHoldActive = true; AnimationController.Global?.StartHoldParticles(noteColor); if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] Start particles started for {noteID} color={noteColor} at time={Time.time:F3}"); } // Auto-miss checks when inside judge line if (hasEnteredLine && !isJudged) { float missRangeScaled = (judgeConfig?.missRange ?? 0.5f) * holdWindowMultiplier; if (segment == NoteSegment.Start && Time.time > hitTime + missRangeScaled) { if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] START auto-Miss: {noteColor}"); // Register miss properly via EvaluateHoldEnd EvaluateHoldEnd(Time.time, true); isJudged = true; ScheduleReturnToPool(0.2f); } else if (segment == NoteSegment.End && Time.time > scheduledEndTime + missRangeScaled) { if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] END auto-Miss: {noteColor}"); HandleEnd(true); // HandleEnd/EvaluateHoldEnd will release lock as needed isJudged = true; } } // Handle key release while holding if (isHoldActive && Input.GetKeyUp(keyToPress)) { isHoldActive = false; AnimationController.Global?.StopHoldParticles(); if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] KeyUp {keyToPress} detected. NoteID: {noteID}, Segment: {segment}, Type: {type}"); if (!hasReleased) { hasReleased = true; releaseTime = Time.time; JudgeManager.Instance?.RegisterNoteReleased(noteID, true); if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] RegisterNoteReleased on KeyUp. NoteID: {noteID}, releaseTime={releaseTime:F2}"); EvaluateHoldEnd(releaseTime, false); } } // Start judgement input handling if (segment == NoteSegment.Start) { if (Input.GetKeyDown(keyToPress)) { if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] KeyDown detected for {noteID} key={keyToPress} time={Time.time:F3} hitTime={hitTime:F3} hasEnteredLine={hasEnteredLine}"); float pressTimeLocal = Time.time; float maxWindow = (judgeConfig?.missRange ?? 0.5f) * holdWindowMultiplier; // prevent multiple notes on same track being judged by a single press if (TrackKeyManager.Instance != null && !TrackKeyManager.Instance.TryLockTrackForJudge(trackIndex, noteID)) return; if (Mathf.Abs(pressTimeLocal - hitTime) <= maxWindow) { HandleStart(false); // do not mark isJudged here; Start remains active for hold } else { // Press is outside window, release the lock if (TrackKeyManager.Instance != null) { TrackKeyManager.Instance.UnlockTrackForJudge(trackIndex, noteID); } } } } else if (segment == NoteSegment.Start && hasEnteredLine) { // legacy fallback if (Input.GetKeyDown(keyToPress)) { HandleStart(false); isJudged = true; } } else if (segment == NoteSegment.Middle && JudgeManager.Instance.IsStartJudged(noteID) && isHoldActive && !JudgeManager.Instance.HasNoteReleased(noteID)) { if (Time.time >= hitTime) { if (hasEnteredLine) { if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] Middle passed: {noteColor}"); PlayHitAnimation(); JudgeManager.Instance?.RegisterMiddlePassed(noteID); ScheduleReturnToPool(0.2f); isJudged = true; } } } else if (segment == NoteSegment.End && JudgeManager.Instance.IsStartJudged(noteID) && !JudgeManager.Instance.HasNoteReleased(noteID)) { if (Input.GetKeyUp(keyToPress)) { // protect end handling with track lock so a single release doesn't trigger multiple ends if (TrackKeyManager.Instance != null && !TrackKeyManager.Instance.TryLockTrackForJudge(trackIndex, noteID)) return; HandleEnd(); isJudged = true; } } } private void OnTriggerEnter2D(Collider2D collision) { if (!collision.CompareTag("JudgmentLine")) return; hasEnteredLine = true; if (segment == NoteSegment.Middle) { hasBeenHeldFromStart = Input.GetKey(keyToPress); if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] Middle enter: color={noteColor}, isStartJudged={JudgeManager.Instance.IsStartJudged(noteID)}, hasReleased={JudgeManager.Instance.HasNoteReleased(noteID)}, isKeyHeld={Input.GetKey(keyToPress)}, hasBeenHeldFromStart={hasBeenHeldFromStart}"); if (autoReturnCoroutine != null) StopCoroutine(autoReturnCoroutine); } else if (segment == NoteSegment.End) { if (autoReturnCoroutine != null) StopCoroutine(autoReturnCoroutine); // If already released before reaching the line, handle end judgement immediately if (JudgeManager.Instance.HasNoteReleased(noteID) && !isJudged) { if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] End entered but note already released: {noteColor}"); HandleEnd(); isJudged = true; return; } // NEW: If the key is still being held down when end segment enters judge zone, immediately judge if (Input.GetKey(keyToPress) && JudgeManager.Instance.IsStartJudged(noteID) && !isJudged) { if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] End entered while key still held: {noteColor}, calculating result immediately"); // Record release time as current time (player is still holding) releaseTime = Time.time; hasReleased = true; JudgeManager.Instance?.RegisterNoteReleased(noteID, true); // Evaluate and judge the hold end EvaluateHoldEnd(releaseTime, false); isJudged = true; // Terminate input - mark key as released isHoldActive = false; AnimationController.Global?.StopHoldParticles(); return; } autoReturnCoroutine = StartCoroutine(DelayedAutoReturnCheck()); } } private void OnTriggerExit2D(Collider2D collision) { if (!collision.CompareTag("JudgmentLine")) return; if (segment == NoteSegment.Start) { if (!JudgeManager.Instance.IsStartJudged(noteID)) { if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] Start left judge zone without being judged -> Miss: {noteColor}"); HandleStart(true); isJudged = true; ScheduleReturnToPool(0.2f); } } else if (segment == NoteSegment.Middle) { if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] Middle left judge zone: {noteColor}"); ScheduleReturnToPool(0.2f); } else if (segment == NoteSegment.End) { if (autoReturnCoroutine != null) { StopCoroutine(autoReturnCoroutine); autoReturnCoroutine = null; } if (!isJudged) { if (!JudgeManager.Instance.IsStartJudged(noteID)) { if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] End left judge zone and start was not judged -> Miss: {noteColor}"); if (!JudgeManager.Instance.HasNoteReleased(noteID)) HandleEnd(true); ScheduleReturnToPool(0.2f); } else { if (JudgeManager.Instance.HasNoteReleased(noteID)) { if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] End left judge zone and was released -> HandleEnd: {noteColor}"); HandleEnd(); isJudged = true; ScheduleReturnToPool(0.2f); } } } else { ScheduleReturnToPool(0.2f); } } } private IEnumerator DelayedAutoReturnCheck() { // yield one frame to ensure any state changes are settled yield return null; if (segment == NoteSegment.End) { float timeToWait = Mathf.Max(0f, scheduledEndTime - Time.time); if (timeToWait > 0f) { // use scaled time here so pause affects this wait float target = Time.time + timeToWait; while (Time.time < target) { yield return null; } } // If the object was deactivated while waiting, bail out if (!gameObject.activeInHierarchy) { yield break; } // wait a short buffer after arriving to judge line before returning to pool ScheduleReturnToPool(0.2f); } } // Schedules a return-to-pool after a short delay. Cancels any previously scheduled return. private void ScheduleReturnToPool(float delay) { if (scheduledReturnCoroutine != null) { StopCoroutine(scheduledReturnCoroutine); scheduledReturnCoroutine = null; } if (!gameObject.activeInHierarchy) { return; } scheduledReturnCoroutine = StartCoroutine(DelayedReturnToPool(delay)); } private IEnumerator DelayedReturnToPool(float delay) { yield return new WaitForSeconds(delay); if (gameObject.activeSelf) { ReturnToPool(); } scheduledReturnCoroutine = null; } private void HandleStart(bool forceMiss = false) { if (!JudgeManager.Instance.TryResolveStart(noteID)) { if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] START try-resolve failed: {noteColor}"); return; } // If forced miss (e.g. leaving judge zone without press), register miss immediately if (forceMiss) { if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] START forced Miss: {noteColor}"); // Use EvaluateHoldEnd to centralize miss logic and statistics EvaluateHoldEnd(Time.time, true); // Still need to show judge result and update combo for the head segment InputManager.Instance?.ShowJudgeResult(trackIndex, "Miss"); JudgeSoundManager.Instance?.PlayJudgeSound("Miss"); // IMPORTANT: release lock immediately on miss ReleaseTrackJudgeLockSafe(); // Return soon ScheduleReturnToPool(0.2f); return; } float pressTime = Time.time; float rawOffsetMs = (hitTime - pressTime) * 1000f; float offset = Mathf.Abs(pressTime - hitTime); string result; float visualScaleFactor = Mathf.Clamp(visualSpeedMultiplier, 0.5f, 2f); float pRange = (judgeConfig?.perfectRange ?? 0.1f) * holdWindowMultiplier * visualScaleFactor; float gRange = (judgeConfig?.greatRange ?? 0.2f) * holdWindowMultiplier * visualScaleFactor; float gdRange = (judgeConfig?.goodRange ?? 0.3f) * holdWindowMultiplier * visualScaleFactor; if (offset <= pRange) { result = "Perfect"; // Score statistics for Hold notes are now handled exclusively in EvaluateHoldEnd // to ensure they only count as 1 note in the total sum. if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs; // RecordOffset removed from here to prevent double counting. // It will be called once in EvaluateHoldEnd. JudgeManager.Instance.RegisterStartJudged(noteID, true); isHoldActive = true; PlayHitAnimation(); AnimationController.Global?.StartHoldParticles(noteColor); // record when the hold started so we can compute held fraction later holdStartTime = pressTime; // store into pool for use on end HoldNoteJudgePool.RegisterStart(noteID, pressTime, hitTime, scheduledEndTime, noteColor, trackIndex, (object)noteData); } else if (offset <= gRange) { result = "Great"; if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs; // RecordOffset removed from here to prevent double counting JudgeManager.Instance.RegisterStartJudged(noteID, true); isHoldActive = true; PlayHitAnimation(); AnimationController.Global?.StartHoldParticles(noteColor); holdStartTime = pressTime; HoldNoteJudgePool.RegisterStart(noteID, pressTime, hitTime, scheduledEndTime, noteColor, trackIndex, (object)noteData); } else if (offset <= gdRange) { result = "Good"; if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs; // RecordOffset removed from here to prevent double counting JudgeManager.Instance.RegisterStartJudged(noteID, true); isHoldActive = true; PlayHitAnimation(); AnimationController.Global?.StartHoldParticles(noteColor); holdStartTime = pressTime; HoldNoteJudgePool.RegisterStart(noteID, pressTime, hitTime, scheduledEndTime, noteColor, trackIndex, (object)noteData); } else { result = "Miss"; // For a Miss at the start, we immediately evaluate the end as a Miss too // to ensure ScoreManager records exactly one Miss for this hold note. JudgeManager.Instance.RegisterStartJudged(noteID, false); isHoldActive = false; EvaluateHoldEnd(Time.time, true); try { bool triggered = SkillBuilder.Instance?.NotifyNoteHit(this.trackIndex, result, SkillDefinition.NoteTypeTrigger.Tap, this.noteID) ?? false; if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] START Miss NotifyNoteHit fired for slot {trackIndex}: {triggered}"); } catch (System.Exception ex) { Debug.LogError($"[HoldNote] NotifyNoteHit(Start Miss) threw: {ex}"); } // IMPORTANT: release lock immediately on miss ReleaseTrackJudgeLockSafe(); } // show judge result and update combo/UI like short notes InputManager.Instance?.ShowJudgeResult(trackIndex, result); // Only call OnJudgeResult on head if it's a hit, so it counts as 1 combo for the whole note. // If it's a Miss, EvaluateHoldEnd(..., true) already handled the OnJudgeResult("Miss"). if (result != "Miss") { teamUIController.Instance?.OnJudgeResult(result); } // Play judge sound only for START segment if (segment == NoteSegment.Start) { JudgeSoundManager.Instance?.PlayJudgeSound(result); } // Do not spawn the judgement animation prefab for Start (head) presses to avoid duplicated prefabs if (segment != NoteSegment.Start) { Animation_GenerateJudgementSituationPrefab.Instance?.SpawnJudgePrefab(noteColor, result); } if (segment != NoteSegment.Start) { StartCoroutine(DelayedReturn()); } // If start head was judged as Miss, ensure we return soon so it can't keep stale state if (segment == NoteSegment.Start && result == "Miss") { ScheduleReturnToPool(0.2f); } } private IEnumerator DelayedReturn() { yield return new WaitForSeconds(0.05f); if (gameObject.activeSelf) { if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] DelayedReturn: returning {noteID} to pool at time={Time.time:F3}"); ReturnToPool(); } } // central evaluation for hold end, given an actual releaseTime (real-time) or forceMiss private void EvaluateHoldEnd(float actualReleaseTime, bool forceMiss) { if (isJudged) return; // already evaluated // ensure we only resolve once per note if (!JudgeManager.Instance.TryResolveEnd(noteID)) { if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] EvaluateHoldEnd: TryResolveEnd failed for {noteID}"); return; } releaseTime = actualReleaseTime; hasReleased = true; JudgeManager.Instance.RegisterNoteReleased(noteID, true); if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] EvaluateHoldEnd: noteID={noteID} releaseTime={releaseTime:F3} scheduledEnd={scheduledEndTime:F3} forceMiss={forceMiss}"); string result; if (forceMiss || !JudgeManager.Instance.IsStartJudged(noteID)) { result = "Miss"; 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.LogWarning($"[HoldNote] END judged as Miss: {noteColor} reason={(forceMiss ? "forced Miss" : "start not judged")}"); // IMPORTANT: release lock on end miss too ReleaseTrackJudgeLockSafe(); } else { // try to use pool record for accurate press duration if (HoldNoteJudgePool.TryGet(noteID, out var info)) { if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] Found pool info for {noteID}: pressTime={info.pressTime:F3} length={info.length:F3} scheduledEnd={info.scheduledEnd:F3}"); float playerHeld = Mathf.Clamp(releaseTime - info.pressTime, 0f, info.length); float frac = info.length <= 0f ? 0f : (playerHeld / info.length); if (frac > 0.8f) { result = "Perfect"; ScoreManager.Instance.countPerfect += 1; if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackPerfectCounts[trackIndex]++; } else if (frac > 0.5f) { result = "Great"; ScoreManager.Instance.countGreat += 1; if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackGreatCounts[trackIndex]++; } else { result = "Good"; ScoreManager.Instance.countGood += 1; if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackGoodCounts[trackIndex]++; } if (noteData != null && result != "Miss") { float offsetEnd = (info.scheduledEnd - releaseTime) * 1000f; noteData.judgeOffsetMsEnd = offsetEnd; // Record the head offset (stored during HandleStart) as the representative timing for this note float headOffset = noteData.judgeOffsetMs; if (float.IsNaN(headOffset)) headOffset = 0; ScoreManager.Instance?.RecordOffset(headOffset); } // cleanup pool HoldNoteJudgePool.Unregister(noteID); } else { if (GameConfig.verboseLogs) Debug.LogWarning($"[HoldNote] No pool info for {noteID}, falling back to local held fraction calculation"); // fallback logic if no pool info float holdRequired = Mathf.Max(0.0001f, scheduledEndTime - hitTime); float held = Mathf.Clamp(actualReleaseTime - hitTime, 0f, holdRequired); float frac = holdRequired <= 0f ? 0f : (held / holdRequired); if (frac > 0.8f) { result = "Perfect"; ScoreManager.Instance.countPerfect += 1; if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackPerfectCounts[trackIndex]++; } else if (frac > 0.5f) { result = "Great"; ScoreManager.Instance.countGreat += 1; if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackGreatCounts[trackIndex]++; } else { result = "Good"; ScoreManager.Instance.countGood += 1; if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackGoodCounts[trackIndex]++; } float rawOffsetMsEnd = (scheduledEndTime - actualReleaseTime) * 1000f; if (noteData != null && result != "Miss") { noteData.judgeOffsetMsEnd = rawOffsetMsEnd; // Fallback: use head offset if available, otherwise 0 float headOffset = noteData.judgeOffsetMs; if (float.IsNaN(headOffset)) headOffset = 0; ScoreManager.Instance?.RecordOffset(headOffset); } } } if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] END result for {noteID}: {result} (color={noteColor})"); // show UI/audio InputManager.Instance?.ShowJudgeResult(trackIndex, result); // ONLY call OnJudgeResult on end if it's a Miss, to break the combo started at the head. // If it's a hit, we don't call it again to avoid double-counting combo. if (result == "Miss") { teamUIController.Instance?.OnJudgeResult(result); } // Ensure END always spawns judgement prefab (including Miss) Animation_GenerateJudgementSituationPrefab.Instance?.SpawnJudgePrefab(noteColor, result); // --- Add scoring for End like short notes --- try { var ally = GetAllyForTrackCached(TrackIndex); if (ally != null) { int added = ally.AddScoreForJudge(result); float efficiency = ally.scoreEfficiency; ScoreManager.Instance?.AddPmScoreForTrack(TrackIndex, added, efficiency); } } catch (System.Exception ex) { Debug.LogWarning($"[HoldNote] Failed to add per-track score for END: {ex}"); } // Notify SkillBuilder so Hold notes can trigger skills configured for Hold if (JudgeManager.Instance.TryTriggerSkill(noteID)) { bool triggeredHold = false; bool triggeredTap = false; var candidates = new List(); candidates.Add(this.trackIndex); if (!candidates.Contains(this.TrackIndex)) candidates.Add(this.TrackIndex); var ui = teamUIController.Instance; if (ui != null) { for (int i = 0; i < ui.allySlotIds.Count; i++) { var slotObj = ui.GetAllyObjectBySlot(i); if (slotObj == null) continue; if (slotObj == this.gameObject || this.gameObject.transform.IsChildOf(slotObj.transform)) { if (!candidates.Contains(i)) candidates.Add(i); break; } } } foreach (var slot in candidates) { try { triggeredHold = SkillBuilder.Instance?.NotifyNoteHit(slot, result, SkillDefinition.NoteTypeTrigger.Hold, this.noteID) ?? false; } catch (System.Exception ex) { Debug.LogError($"[HoldNote] NotifyNoteHit(Hold) threw for slot {slot}: {ex}"); } if (triggeredHold) break; } if (!triggeredHold) { foreach (var slot in candidates) { try { triggeredTap = SkillBuilder.Instance?.NotifyNoteHit(slot, result, SkillDefinition.NoteTypeTrigger.Tap, this.noteID) ?? false; } catch (System.Exception ex) { Debug.LogError($"[HoldNote] NotifyNoteHit(Tap) threw for slot {slot}: {ex}"); } if (triggeredTap) break; } } if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] Hold end NotifyNoteHit called: TrackIndex={TrackIndex} trackIndexField={trackIndex} result={result} triggeredHold={triggeredHold} triggeredTap={triggeredTap}"); } AnimationController.Global?.StopHoldParticles(); isJudged = true; // notify global judge manager that one final note (hold end) has been judged JudgeManager.Instance?.NotifyNoteJudged(); ScheduleReturnToPool(0.2f); } private void HandleEnd(bool forceMiss = false) { // record release time and register release in JudgeManager if (!hasReleased) { if (Input.GetKey(keyToPress)) { releaseTime = scheduledEndTime; } else { releaseTime = Time.time; } hasReleased = true; JudgeManager.Instance?.RegisterNoteReleased(noteID, true); } // central evaluation EvaluateHoldEnd(releaseTime, forceMiss); } private void ReturnToPool() { if (!gameObject.activeSelf) return; if (segment == NoteSegment.Start && !hasEnteredLine) { if (GameConfig.verboseLogs) Debug.LogWarning($"[HoldNote] Refusing to return a Start segment that never entered judge zone: {noteColor}"); return; } if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] ReturnToPool called for {noteID} segment={segment} time={Time.time:F3}"); // CRITICAL: Always release lock before returning to pool, regardless of state ReleaseTrackJudgeLockSafe(); gameObject.SetActive(false); if (segment == NoteSegment.Start) NotePool.Instance.ReturnStartNote(gameObject, noteColor); else if (segment == NoteSegment.End) NotePool.Instance.ReturnHoldNoteEndSegment(gameObject, noteColor); else NotePool.Instance.ReturnHoldNoteSegment(gameObject, noteColor); } public void ResetState() { // also release any stale lock from previous lifecycle ReleaseTrackJudgeLockSafe(); hasReleased = false; segment = NoteSegment.None; keyToPress = KeyCode.None; noteColor = string.Empty; noteID = string.Empty; hitTime = 0f; scheduledEndTime = 0f; hasEnteredLine = false; isJudged = false; isHoldActive = false; hasBeenHeldFromStart = false; holdStartTime = -1f; if (autoReturnCoroutine != null) { StopCoroutine(autoReturnCoroutine); autoReturnCoroutine = null; } controller?.StopMovement(); // restore visual scale ResetVisualScale(); } private void ReleaseTrackJudgeLockSafe() { // Defensive: any miss / pooling path should release the track lock. if (TrackKeyManager.Instance != null && !string.IsNullOrEmpty(noteID)) { TrackKeyManager.Instance.UnlockTrackForJudge(trackIndex, noteID); } } public void ApplyVisualScale(float scaleY) { float s = Mathf.Clamp(scaleY, 0.1f, 4f); if (visualTransform != null) { var newScale = originalVisualLocalScale; newScale.y = originalVisualLocalScale.y * s; visualTransform.localScale = newScale; } else { var newScale = originalLocalScale; newScale.y = originalLocalScale.y * s; transform.localScale = newScale; } } public void ResetVisualScale() { if (visualTransform != null) visualTransform.localScale = originalVisualLocalScale; else transform.localScale = originalLocalScale; } public void CalibratePosition(Vector3 spawnPointPosition, float tolerance = 0.02f) { if (controller == null) controller = GetComponent(); if (controller == null) return; float activation = controller.ActivationTime; float s = controller.CurrentSpeed; float elapsed = Time.time - activation; Vector3 expected = spawnPointPosition; if (controller.UsesAbsolutePositioning) { expected.y -= (controller.BaseSpawnYOffset + s * elapsed); } else { expected += Vector3.down * s * Mathf.Max(0f, elapsed); } if (Vector3.Distance(transform.position, expected) > tolerance) { transform.position = expected; } } }