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 releaseTime = 0f; private bool hasReleased = true; private bool hasEnteredLine = false; private Coroutine autoReturnCoroutine; private Coroutine scheduledReturnCoroutine; private HoldNoteController controller; private AnimationController anim; // Queue id for TrackKeyManager (start segment only) private string queueNoteId = null; private bool isQueued = false; 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; private List cachedSpriteRenderers = new List(); private List cachedUIComponents = new List(); [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 BeatmapManager beatmapManagerCache; private struct RendererMaterialCache { public SpriteRenderer spriteRenderer; public UnityEngine.UI.Graphic uiGraphic; public MeshRenderer meshRenderer; public Material originalMaterial; } private List materialCaches = new List(); 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 static BeatmapManager GetBeatmapManagerCached() { if (beatmapManagerCache != null) return beatmapManagerCache; beatmapManagerCache = Object.FindAnyObjectByType(); return beatmapManagerCache; } 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 = 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 JudgeManager cachedJudgeManager; private TrackKeyManager cachedTrackKeyManager; 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 = FindAnyObjectByType(); } } if (anim == null) { if (JudgeManager.IsDebugEnabled) Debug.LogWarning("HoldNote: No AnimationController found (Global/local/scene). Particle effects will be unavailable."); } if (controller != null) { controller.OnJudgeZoneChanged += OnJudgeZoneChanged; } cachedJudgeManager = JudgeManager.Instance; cachedTrackKeyManager = TrackKeyManager.Instance; if (cachedJudgeManager != null) { cachedJudgeManager.OnHoldAlphaSync += HandleAlphaSync; } // 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; // Cache all renderers for alpha control cachedSpriteRenderers.AddRange(GetComponentsInChildren(true)); cachedUIComponents.AddRange(GetComponentsInChildren(true)); // Initialize material caches materialCaches.Clear(); foreach (var sr in cachedSpriteRenderers) { if (sr != null && sr.gameObject.activeInHierarchy && sr.enabled && sr.color.a > 0.1f) { // Only affect renderers under visualTransform to avoid affecting logic/debug objects if (visualTransform != null && !sr.transform.IsChildOf(visualTransform)) continue; // Skip internal parts that should stay hidden or are connectors string n = sr.gameObject.name.ToLower(); if (n.Contains("body") || n.Contains("connect") || n.Contains("line") || n.Contains("mask") || n.Contains("debug") || n.Contains("bg") || n.Contains("back") || n.Contains("overlay") || n.Contains("shadow") || n.Contains("lane") || n.Contains("glow") || n.Contains("effect") || n.Contains("area") || n.Contains("fill") || n.Contains("slider") || n.Contains("long")) continue; materialCaches.Add(new RendererMaterialCache { spriteRenderer = sr, originalMaterial = sr.sharedMaterial }); } } foreach (var ui in cachedUIComponents) { if (ui != null && ui.gameObject.activeInHierarchy && ui.enabled && ui.color.a > 0.1f) { if (visualTransform != null && !ui.transform.IsChildOf(visualTransform)) continue; string n = ui.gameObject.name.ToLower(); if (n.Contains("body") || n.Contains("connect") || n.Contains("line") || n.Contains("mask") || n.Contains("debug") || n.Contains("bg") || n.Contains("back") || n.Contains("overlay") || n.Contains("shadow") || n.Contains("lane") || n.Contains("glow") || n.Contains("effect") || n.Contains("area") || n.Contains("fill") || n.Contains("slider") || n.Contains("long")) continue; materialCaches.Add(new RendererMaterialCache { uiGraphic = ui, originalMaterial = ui.material }); } } var meshRenderers = GetComponentsInChildren(true); foreach (var mr in meshRenderers) { if (mr != null && mr.gameObject.activeInHierarchy && mr.enabled) { if (visualTransform != null && !mr.transform.IsChildOf(visualTransform)) continue; string n = mr.gameObject.name.ToLower(); if (n.Contains("body") || n.Contains("connect") || n.Contains("line") || n.Contains("mask") || n.Contains("debug") || n.Contains("bg") || n.Contains("back") || n.Contains("overlay") || n.Contains("shadow") || n.Contains("lane") || n.Contains("glow") || n.Contains("effect") || n.Contains("area") || n.Contains("fill") || n.Contains("slider") || n.Contains("long")) continue; materialCaches.Add(new RendererMaterialCache { meshRenderer = mr, originalMaterial = mr.sharedMaterial }); } } } private void OnDestroy() { if (controller != null) { controller.OnJudgeZoneChanged -= OnJudgeZoneChanged; } if (JudgeManager.Instance != null) { JudgeManager.Instance.OnHoldAlphaSync -= HandleAlphaSync; } } 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; } private float cachedTravelTime = 1.0f; // Cached travel time for distance optimization public void Setup(int id, int trackIndex, float speed, float time, float delay, bool isEnd, float scheduledEnd, string color, KeyCode key, string type, float travelTimeSeconds, NoteJudgeConfig judgeConfig, NoteData noteData) { this.id = id; // Ensure singletons are up to date if they were re-initialized (e.g. on scene reload) if (cachedJudgeManager == null) cachedJudgeManager = JudgeManager.Instance; if (cachedTrackKeyManager == null) cachedTrackKeyManager = TrackKeyManager.Instance; 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 (JudgeManager.IsDebugEnabled) Debug.LogError($"HoldNoteJudgeConfig is null! judgeConfig not assigned. track={trackIndex}"); } else { if (JudgeManager.IsDebugEnabled) Debug.Log($"HoldNoteJudgeConfig: perfect={judgeConfig.perfectRange}, great={judgeConfig.greatRange}, good={judgeConfig.goodRange}, miss={judgeConfig.missRange}"); } this.noteData = noteData; // cache queue id for TrackKeyManager (per pooled instance) queueNoteId = gameObject.GetInstanceID().ToString(); isQueued = false; // Debug info to help investigate end note visibility if (JudgeManager.IsDebugEnabled) { 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); float travelTime = Mathf.Max(0f, travelTimeSeconds); this.cachedTravelTime = travelTime; 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 (isJudged) return; var jm = cachedJudgeManager; var tkm = cachedTrackKeyManager; bool debugEnabled = JudgeManager.IsDebugEnabled; float now = Time.time; // Optimization: Early exit if the note is far from judgment line and hasn't entered yet if (!hasEnteredLine) { // For all segments, if they haven't triggered collision yet and are far away, skip input checks // We use travelTime as a safe buffer. Only check input if note is within 0.5s of hitTime. if (now < hitTime - 0.5f) return; } if (GameConfig.autoPlayEnabled) { UpdateAutoplay(now, jm, tkm, debugEnabled); return; } bool keyHeld = Input.GetKey(keyToPress); bool keyDown = Input.GetKeyDown(keyToPress); bool keyUp = Input.GetKeyUp(keyToPress); // If start was judged and player is holding key, start hold effects if (!isHoldActive && jm != null && jm.IsStartJudged(noteID) && !jm.HasNoteReleased(noteID) && keyHeld) { isHoldActive = true; AnimationController.Global?.StartHoldParticles(noteColor); if (debugEnabled) Debug.Log($"[HoldNote] Start particles started for {noteID} color={noteColor} at time={now:F3}"); } // Auto-miss checks when inside judge line if (hasEnteredLine) { float missRangeScaled = (judgeConfig?.missRange ?? 0.5f) * holdWindowMultiplier; if (segment == NoteSegment.Start && now > hitTime + missRangeScaled) { if (debugEnabled) Debug.Log($"[HoldNote] START auto-Miss: {noteColor}"); // Register miss properly via EvaluateHoldEnd EvaluateHoldEnd(now, true); isJudged = true; ScheduleReturnToPool(0.2f); return; } else if (segment == NoteSegment.End && now > scheduledEndTime + missRangeScaled) { if (debugEnabled) Debug.Log($"[HoldNote] END auto-Miss: {noteColor}"); HandleEnd(true); // HandleEnd/EvaluateHoldEnd will release lock as needed isJudged = true; return; } } // Handle key release while holding if (isHoldActive && keyUp) { isHoldActive = false; AnimationController.Global?.StopHoldParticles(); if (debugEnabled) Debug.Log($"[HoldNote] KeyUp {keyToPress} detected. NoteID: {noteID}, Segment: {segment}, Type: {type}"); if (!hasReleased) { hasReleased = true; releaseTime = now; jm?.RegisterNoteReleased(noteID, true); if (debugEnabled) Debug.Log($"[HoldNote] RegisterNoteReleased on KeyUp. NoteID: {noteID}, releaseTime={releaseTime:F2}"); EvaluateHoldEnd(releaseTime, false); } } // Start judgement input handling if (segment == NoteSegment.Start) { bool startResolved = jm != null && jm.IsStartJudged(noteID); if (!startResolved) { // Ensure we are the front-most note in the judge queue (if any) var headId = tkm?.GetCurrentNoteId(trackIndex); if (headId != null && headId != queueNoteId) { return; } if (keyDown) { if (debugEnabled) Debug.Log($"[HoldNote] KeyDown detected for {noteID} key={keyToPress} time={now:F3} hitTime={hitTime:F3} hasEnteredLine={hasEnteredLine}"); float pressTimeLocal = now; float maxWindow = (judgeConfig?.missRange ?? 0.5f) * holdWindowMultiplier; // Only consume the frame if this hold start is actually within the timing window. if (Mathf.Abs(pressTimeLocal - hitTime) > maxWindow) return; if (tkm != null) { if (!tkm.IsBestCandidate(trackIndex, queueNoteId, pressTimeLocal, maxWindow)) return; // Ensure one physical press only judges one note on this track per frame if (!tkm.TryConsumeTrackForFrame(trackIndex)) return; } HandleStart(false); // do not mark isJudged here; Start remains active for hold } } } else if (segment == NoteSegment.Middle && jm != null && jm.IsStartJudged(noteID) && isHoldActive && !jm.HasNoteReleased(noteID)) { if (now >= hitTime) { if (hasEnteredLine) { if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] Middle passed: {noteColor}"); PlayHitAnimation(); jm?.RegisterMiddlePassed(noteID); ScheduleReturnToPool(0.2f); isJudged = true; } } } else if (segment == NoteSegment.End && jm != null && jm.IsStartJudged(noteID) && !jm.HasNoteReleased(noteID)) { if (keyUp) { // protect end handling with track lock so a single release doesn't trigger multiple ends if (tkm != null && !tkm.TryLockTrackForJudge(trackIndex, noteID)) return; HandleEnd(); isJudged = true; } } } private void UpdateAutoplay(float now, JudgeManager jm, TrackKeyManager tkm, bool debugEnabled) { // Keep the "hold active" state alive for middle/end segments once the start has been judged. if (!isHoldActive && jm != null && jm.IsStartJudged(noteID) && !jm.HasNoteReleased(noteID)) { isHoldActive = true; } if (segment == NoteSegment.Start) { bool startResolved = jm != null && jm.IsStartJudged(noteID); if (!startResolved && now >= hitTime) { // Ensure we are the front-most note in the judge queue (if any) var headId = tkm?.GetCurrentNoteId(trackIndex); if (headId != null && headId != queueNoteId) { return; } float pressTimeLocal = hitTime; // force Perfect regardless of frame timing float maxWindow = (judgeConfig?.missRange ?? 0.5f) * holdWindowMultiplier; if (tkm != null && !tkm.IsBestCandidate(trackIndex, queueNoteId, pressTimeLocal, maxWindow)) { return; } HandleStartAutoplayPerfect(pressTimeLocal); } return; } if (segment == NoteSegment.Middle) { if (jm != null && jm.IsStartJudged(noteID) && isHoldActive && !jm.HasNoteReleased(noteID)) { if (now >= hitTime && hasEnteredLine) { if (debugEnabled) Debug.Log($"[HoldNote.AutoPlay] Middle passed: {noteColor}"); PlayHitAnimation(); jm?.RegisterMiddlePassed(noteID); ScheduleReturnToPool(0.2f); isJudged = true; } } return; } if (segment == NoteSegment.End) { if (jm != null && jm.IsStartJudged(noteID) && !jm.HasNoteReleased(noteID)) { if (now >= scheduledEndTime) { // Defensive: ensure pool info exists so EvaluateHoldEnd resolves to Perfect deterministically. if (!HoldNoteJudgePool.TryGet(noteID, out _)) { if (noteData != null) noteData.judgeOffsetMs = 0f; HoldNoteJudgePool.RegisterStart(noteID, hitTime, hitTime, scheduledEndTime, noteColor, trackIndex, (object)noteData); } EvaluateHoldEnd(scheduledEndTime, false); } } return; } } private void HandleStartAutoplayPerfect(float pressTime) { // release from queue so following notes can become head UnregisterQueueIfNeeded(); if (!JudgeManager.Instance.TryResolveStart(noteID)) { if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote.AutoPlay] START try-resolve failed: {noteColor}"); return; } const string result = "Perfect"; float rawOffsetMs = (hitTime - pressTime) * 1000f; if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs; JudgeManager.Instance.RegisterStartJudged(noteID, true); isHoldActive = true; PlayHitAnimation(); AnimationController.Global?.StartHoldParticles(noteColor); holdStartTime = pressTime; HoldNoteJudgePool.RegisterStart(noteID, pressTime, hitTime, scheduledEndTime, noteColor, trackIndex, (object)noteData); InputManager.Instance?.ShowJudgeResult(trackIndex, result); teamUIController.Instance?.OnJudgeResult(result); JudgeSoundManager.Instance?.PlayJudgeSound(result); } private void OnTriggerEnter2D(Collider2D collision) { if (!collision.CompareTag("JudgmentLine")) return; hasEnteredLine = true; if (segment == NoteSegment.Start) { RegisterQueueIfNeeded(); } else if (segment == NoteSegment.Middle) { hasBeenHeldFromStart = Input.GetKey(keyToPress); if (JudgeManager.IsDebugEnabled) 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 (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] End entered but note already released: {noteColor}"); HandleEnd(); isJudged = true; return; } // If the key is still being held down when end segment enters judge zone, immediately judge. // Autoplay must ignore physical input to guarantee Perfect at scheduledEndTime. if (!GameConfig.autoPlayEnabled && Input.GetKey(keyToPress) && JudgeManager.Instance.IsStartJudged(noteID) && !isJudged) { if (JudgeManager.IsDebugEnabled) 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) { UnregisterQueueIfNeeded(); if (!JudgeManager.Instance.IsStartJudged(noteID)) { if (JudgeManager.IsDebugEnabled) 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 (JudgeManager.IsDebugEnabled) 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 (JudgeManager.IsDebugEnabled) 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 (JudgeManager.IsDebugEnabled) 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) { // release from queue so following notes can become head UnregisterQueueIfNeeded(); if (!JudgeManager.Instance.TryResolveStart(noteID)) { if (JudgeManager.IsDebugEnabled) 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 (JudgeManager.IsDebugEnabled) 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 (JudgeManager.IsDebugEnabled) 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 (JudgeManager.IsDebugEnabled) 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 (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] EvaluateHoldEnd: TryResolveEnd failed for {noteID}"); return; } releaseTime = actualReleaseTime; hasReleased = true; JudgeManager.Instance.RegisterNoteReleased(noteID, true); if (JudgeManager.IsDebugEnabled) 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 (JudgeManager.IsDebugEnabled) 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 (JudgeManager.IsDebugEnabled) 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 (JudgeManager.IsDebugEnabled) 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 (JudgeManager.IsDebugEnabled) 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) ally.AddScoreForJudge(result); int pmDelta = ComputePmDeltaFromJudge(result, ally); float efficiency = (ally != null && !ally.IsDead) ? ally.scoreEfficiency : 0f; ScoreManager.Instance?.AddPmScoreForTrack(TrackIndex, pmDelta, efficiency, false); } 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 (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] Hold end NotifyNoteHit called: TrackIndex={TrackIndex} trackIndexField={trackIndex} result={result} triggeredHold={triggeredHold} triggeredTap={triggeredTap}"); } AnimationController.Global?.StopHoldParticles(); // Release the track lock immediately after end evaluation so the next hold can start on time ReleaseTrackJudgeLockSafe(); isJudged = true; JudgeManager.Instance?.SyncHoldAlpha(noteID, 0.5f); // notify global judge manager that one final note (hold end) has been judged JudgeManager.Instance?.NotifyNoteJudged(); ScheduleReturnToPool(0.2f); } private void ApplyJudgedMaterial() { if (JudgeManager.Instance == null || !JudgeManager.Instance.enableJudgedMaterial) return; Material targetMat = JudgeManager.Instance.globalJudgedMaterial; if (targetMat == null) return; foreach (var cache in materialCaches) { if (cache.spriteRenderer != null) { cache.spriteRenderer.material = targetMat; } else if (cache.uiGraphic != null) { cache.uiGraphic.material = targetMat; } else if (cache.meshRenderer != null) { cache.meshRenderer.material = targetMat; } } } private void RestoreOriginalMaterials() { foreach (var cache in materialCaches) { if (cache.spriteRenderer != null) { cache.spriteRenderer.material = cache.originalMaterial; } else if (cache.uiGraphic != null) { cache.uiGraphic.material = cache.originalMaterial; } else if (cache.meshRenderer != null) { cache.meshRenderer.material = cache.originalMaterial; } } } private void HandleAlphaSync(string syncNoteID, float alpha) { if (this.noteID == syncNoteID) { // Instead of alpha, apply the judged material ApplyJudgedMaterial(); } } 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; UnregisterQueueIfNeeded(); if (segment == NoteSegment.Start && !hasEnteredLine) { if (JudgeManager.IsDebugEnabled) Debug.LogWarning($"[HoldNote] Refusing to return a Start segment that never entered judge zone: {noteColor}"); return; } if (JudgeManager.IsDebugEnabled) 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(); UnregisterQueueIfNeeded(); // Restore materials before returning to pool/reusing RestoreOriginalMaterials(); 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 RegisterQueueIfNeeded() { if (isQueued) return; if (TrackKeyManager.Instance == null || string.IsNullOrEmpty(queueNoteId)) return; TrackKeyManager.Instance.RegisterKey(trackIndex, queueNoteId, "hold", hitTime); isQueued = true; } private void UnregisterQueueIfNeeded() { if (!isQueued) return; if (TrackKeyManager.Instance != null && !string.IsNullOrEmpty(queueNoteId)) { TrackKeyManager.Instance.UnregisterKey(trackIndex, queueNoteId); } isQueued = false; } 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; } } }