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; // 获取用于生成判定 prefab 的颜色。如果 noteColor 为空(初始化异常),从 trackIndex 推导兜底。 private string GetColorForPrefab() { if (!string.IsNullOrEmpty(noteColor)) return noteColor; if (trackIndex >= 0 && trackIndex < 5) { string[] trackColors = { "red", "green", "yellow", "purple", "blue" }; string fallback = trackColors[trackIndex]; if (JudgeManager.IsDebugEnabled) Debug.LogWarning($"[HoldNote] noteColor was null, using trackIndex {trackIndex} -> {fallback}"); return fallback; } Debug.LogError($"[HoldNote] Cannot derive color: noteColor is null and trackIndex {trackIndex} is invalid"); return null; } 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; // Cached instance-id string used by NotePool to unregister without re-allocating. // May be null if this segment never registered a queue id (e.g. non-start segment). public string GetQueueNoteId() => queueNoteId; public int id; public int trackIndex; // Reused by EvaluateHoldEnd's skill-trigger dispatch to avoid a per-hold-end List allocation. private readonly List _holdEndCandidatesBuffer = new List(4); 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; // Previous-frame held state for this track, used to derive key-down / key-up edges // from the platform-agnostic InputManager.IsTrackHeld table instead of polling // Input.GetKeyDown/Up directly (which touch cannot drive on Android). private bool prevTrackHeld = false; // Latest press/release dspTime captured from the Input System (via InputManager events) // for this note's track. When the polled keyDown/keyUp edge fires this frame, we convert // the captured dspTime → song time for sub-frame-accurate timing instead of reading the // frame-quantized NowSongTime. Falls back to NowSongTime if no event fired (e.g. touch // paths that don't carry a distinct timestamp still work via the AudioSettings.dspTime default). private double lastPressDspTime = double.NaN; private double lastReleaseDspTime = double.NaN; private void OnTrackPressedDsp(int track, double dspTime) { if (track == trackIndex) lastPressDspTime = dspTime; } private void OnTrackReleasedDsp(int track, double dspTime) { if (track == trackIndex) lastReleaseDspTime = dspTime; } // Convert the most-recent captured press dspTime to song time; fall back to NowSongTime. private float PressSongTime() { return double.IsNaN(lastPressDspTime) ? GameplayClock.NowSongTime : GameplayClock.SongTimeFromDsp(lastPressDspTime); } // Convert the most-recent captured release dspTime to song time; fall back to NowSongTime. private float ReleaseSongTime() { return double.IsNaN(lastReleaseDspTime) ? GameplayClock.NowSongTime : GameplayClock.SongTimeFromDsp(lastReleaseDspTime); } // Query the shared held-state table (keyboard OR touch). Falls back to legacy // Input.GetKey if the InputManager is somehow absent so editor/standalone still works. private bool IsHeld() { var im = InputManager.Instance; if (im != null) return im.IsTrackHeld(trackIndex); return Input.GetKey(keyToPress); } [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. Lower values make hold judgement stricter.")] [Range(0.1f, 2f)] public float holdWindowMultiplier = 0.8f; 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; // ==== 单条 Tiled 长音符 body(纯视觉,不参与任何判定/计分) ==== // 原本中段由 N 个 middle 段密铺组成;本 body 用一条 Tiled sprite 覆盖 head→end 的整段, // 按绝对时间下落,底边在判定线处被"消耗"(drain),end 到线时回收。 // 它绝不注册 JudgeManager/TrackKeyManager 状态,start/end 判定路径完全不受影响。 // 抗失真关键:Tiled 会按 sprite 原生像素在"局部空间"平铺,再乘 transform 世界缩放; // prefab 里烘焙的非等比 Y 缩放(如 3.75)会把每块拉长导致失真。故 body 实例把渲染器 // transform 的 Y 缩放归一为与 X 相同(等比),长度改由 SpriteRenderer.size.y 承载(世界单位)。 private bool bodyMode = false; private bool bodyReturning = false; private float bodyTravelTime; private float bodyHeadActivationTime; // head 发车的绝对歌曲时间(= baseHit - travelTime) private float bodyLengthSeconds; // 长音符时长(秒) private float bodySpeed; // 世界速度(单位/秒) private float bodySpawnX, bodySpawnZ; // 车道原点(x,z 固定) private float bodySpawnY; // 发车高度(head 在 activation 时刻的 Y) private SpriteRenderer[] bodyRenderers; private SpriteDrawMode[] bodyRendererOrigDrawMode; private Vector2[] bodyRendererOrigSize; private Vector3[] bodyRendererOrigLocalScale; // 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 = SceneObjectLookupCache.Find($"ally_0{trackIndex + 1}"); if (allyGo != null) { allyCache[trackIndex] = allyGo.GetComponent(); } return allyCache[trackIndex]; } private static BeatmapManager GetBeatmapManagerCached() { if (beatmapManagerCache != null) return beatmapManagerCache; beatmapManagerCache = SceneObjectLookupCache.FindAny(); return beatmapManagerCache; } public static void PrewarmRuntimeCaches() { GetBeatmapManagerCached(); if (allyCache == null || allyCache.Length < 10) allyCache = new AllyCombatant[10]; for (int i = 0; i < 5; i++) { GetAllyForTrackCached(i); } } private static float JudgeToPmMultiplier(string judgeResult) { switch (judgeResult) { case "Perfect": return 1f; case "Great": return 0.75f; case "Good": return 0.5f; case "Miss": return 0f; default: return 0f; } } private static int ComputePmDeltaFromJudge(string judgeResult, AllyCombatant ally) { int baseScore = 0; var bmm = ally != null ? ally.bmm : null; if (bmm == null) bmm = BeatmapManager.Instance; if (bmm == null) bmm = GetBeatmapManagerCached(); if (bmm != null) baseScore = Mathf.Max(0, bmm.perNoteScore); if (baseScore <= 0 && ally != null) baseScore = Mathf.Max(0, ally.baseTrackScore); if (baseScore <= 0) return 0; float mult = JudgeToPmMultiplier(judgeResult); return Mathf.FloorToInt(baseScore * mult); } private static bool TryRewriteNonMissJudgeToPerfect(int trackIndex, ref string judgeResult) { if (judgeResult != "Great" && judgeResult != "Good") return false; var ally = GetAllyForTrackCached(trackIndex); if (ally == null || ally.IsDead) return false; if (!ally.IsNonMissToPerfectRewriteActive()) return false; judgeResult = "Perfect"; return true; } private static void AdjustCountsAfterRewriteToPerfect(int trackIndex, string originalJudge) { var sm = ScoreManager.Instance; if (sm == null) return; if (originalJudge == "Great") { sm.countGreat = Mathf.Max(0, sm.countGreat - 1); if (trackIndex >= 0 && trackIndex < sm.trackGreatCounts.Length) sm.trackGreatCounts[trackIndex] = Mathf.Max(0, sm.trackGreatCounts[trackIndex] - 1); } else if (originalJudge == "Good") { sm.countGood = Mathf.Max(0, sm.countGood - 1); if (trackIndex >= 0 && trackIndex < sm.trackGoodCounts.Length) sm.trackGoodCounts[trackIndex] = Mathf.Max(0, sm.trackGoodCounts[trackIndex] - 1); } else { return; } sm.countPerfect += 1; if (trackIndex >= 0 && trackIndex < sm.trackPerfectCounts.Length) sm.trackPerfectCounts[trackIndex] += 1; } private 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 = SceneObjectLookupCache.FindAny(); } } 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; // 去物理判定:进入/离开判定区的分段逻辑由 controller 几何驱动回调触发(替代自身 OnTrigger)。 controller.OnZoneEnterGeometry += HandleZoneEnter; controller.OnZoneExitGeometry += HandleZoneExit; } 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 OnDisable() { prevTrackHeld = false; InputManager.OnTrackPressedWithDspTime -= OnTrackPressedDsp; InputManager.OnTrackReleasedWithDspTime -= OnTrackReleasedDsp; } private void OnDestroy() { if (controller != null) { controller.OnJudgeZoneChanged -= OnJudgeZoneChanged; controller.OnZoneEnterGeometry -= HandleZoneEnter; controller.OnZoneExitGeometry -= HandleZoneExit; } if (JudgeManager.Instance != null) { JudgeManager.Instance.OnHoldAlphaSync -= HandleAlphaSync; } } private void OnJudgeZoneChanged(bool inZone) { hasEnteredLine = inZone; } private void OnEnable() { ResetState(); InputManager.OnTrackPressedWithDspTime += OnTrackPressedDsp; InputManager.OnTrackReleasedWithDspTime += OnTrackReleasedDsp; } private void PlayHitAnimation() { var controller = GetAnimationController(); if (controller != null) { controller.PlayDestroyAnimation(noteColor); } } private void PlayMiddleSegmentHitFx() { PlayHitAnimation(); TrackJudgeHitEffectController.PlayTrackHitFx(trackIndex); } private void EnsureHoldParticleLoop() { if (!isHoldActive || string.IsNullOrEmpty(noteColor)) { return; } // 按长音符 id 记账,避免同轨道旧段回收误停当前长按特效。 GetAnimationController()?.StartHoldParticles(noteColor, id); } private AnimationController GetAnimationController() { if (AnimationController.Global != null) return AnimationController.Global; if (anim == null) { anim = SceneObjectLookupCache.FindAny(); } return anim; } private void StartHoldFxLoop() { GetAnimationController()?.StartHoldParticles(noteColor, id); } private void StopHoldFxLoop() { GetAnimationController()?.StopHoldParticles(id); } 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 private bool isSyncNote; 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, bool isSync = false) { 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.TrackIndex = trackIndex; 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.isSyncNote = isSync; 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: perfectE={judgeConfig.perfectEarlyRange} perfectL={judgeConfig.perfectLateRange}, greatE={judgeConfig.greatEarlyRange} greatL={judgeConfig.greatLateRange}, goodE={judgeConfig.goodEarlyRange} goodL={judgeConfig.goodLateRange}, 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 <= gameplay clock). // Start segments should begin at spawnPoint and move from there naturally. } private void Update() { // 纯视觉 body:只更新位置/尺寸(drain),不进入任何判定逻辑。 if (bodyMode) { UpdateBodyVisual(GameplayClock.NowSongTime); return; } if (isJudged) return; var jm = cachedJudgeManager; var tkm = cachedTrackKeyManager; bool debugEnabled = JudgeManager.IsDebugEnabled; float now = GameplayClock.NowSongTime; // Sample held state and derive key-down/up edges at the very top, BEFORE any // early return. Unity's Input.GetKeyDown/Up are global per-frame edges that // don't depend on whether we polled last frame; updating prevTrackHeld every // frame reproduces that. If we only sampled after the early exits below, a key // already held when the note enters range would produce a false keyDown. bool keyHeld = IsHeld(); bool keyDown = keyHeld && !prevTrackHeld; bool keyUp = !keyHeld && prevTrackHeld; prevTrackHeld = keyHeld; // 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; } // 池化泄漏兜底:不依赖 hasEnteredLine / 几何进入离开事件。 // 正常情况下 middle/start/end 由 HandleZoneEnter/Exit(几何驱动)或下方的 // in-zone auto-miss 负责回收;但当几何阈值未就绪、或帧尖峰导致音符 Y 未被采样到判定区内时, // 上述事件可能永不触发,音符会一直下落不回池,严重拖累性能(尤其 useMultiSegmentFill 使 // 中段数量随速度倍增)。此兜底在"正常判定窗口 + backstopBuffer"之后才触发,严格晚于所有正常 // 路径,因此对正常音符零行为改变——仅回收那些几何事件从未触发、否则永远泄漏的段。 // 【性能】本 block 放在上方"远距离早退"之后:兜底触发要求 now > hitTime(或 scheduledEndTime) // + missRange + 0.5,而早退只在 now < hitTime - 0.5 时发生,两条件互斥,故早退帧本就不会命中 // 任何兜底分支。移到早退后仅避免对数百个"离判定线尚远"的段每帧空算,行为与之前完全等效。 { float missRangeBackstop = (judgeConfig != null ? judgeConfig.missRange : 0.2f) * holdWindowMultiplier; const float backstopBuffer = 0.5f; bool startJudgedBs = jm != null && jm.IsStartJudged(noteID); if (segment == NoteSegment.End) { if (scheduledEndTime > 0f && now > scheduledEndTime + missRangeBackstop + backstopBuffer) { // 兜底触发时音符早已越过判定线,几何仅是漏采样。置 hasEnteredLine 让 ReturnToPool // 的 Start 守卫(拒收未入线 Start)不再阻挡回收。 hasEnteredLine = true; // 已按住到尾部的 End 早已在下方按 Perfect 自动完成并 return; // 走到这里说明几何事件从未触发。按原 HandleZoneExit(End) 的收尾语义处理。 bool releasedBs = jm != null && jm.HasNoteReleased(noteID); if (!startJudgedBs) { if (!releasedBs) HandleEnd(true); // 头段未判定 → End 记 Miss } else { HandleEnd(); // 头段已判定:按松手时间正常评估收尾 } isJudged = true; ScheduleReturnToPool(0.2f); return; } } else if (hitTime > 0f && now > hitTime + missRangeBackstop + backstopBuffer) { // 兜底触发时音符早已越过判定线,几何仅是漏采样。置 hasEnteredLine 让 ReturnToPool // 的 Start 守卫(拒收未入线 Start)不再阻挡回收。 hasEnteredLine = true; // Start 未判定 → 走正常 Miss;已判定的 start / middle → 静默回收 // (绝不 StopHoldParticles 杀掉长按持续特效)。 if (segment == NoteSegment.Start && !startJudgedBs) { EvaluateHoldEnd(now, true); } isJudged = true; ScheduleReturnToPool(0.2f); return; } } if (GameConfig.autoPlayEnabled) { UpdateAutoplay(now, jm, tkm, debugEnabled); return; } // 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; StartHoldFxLoop(); if (debugEnabled) Debug.Log($"[HoldNote] Start particles started for {noteID} color={noteColor} at time={now:F3}"); } if (isHoldActive && jm != null && jm.IsStartJudged(noteID) && !jm.HasNoteReleased(noteID) && keyHeld) { EnsureHoldParticleLoop(); } // Auto-miss checks when inside judge line if (hasEnteredLine) { float missRangeScaled = (judgeConfig != null ? judgeConfig.missRange : 0.2f) * holdWindowMultiplier; // 只对"尚未判定"的 start 段做超时 auto-Miss。已成功判定的 start 段(玩家已按下、 // 正持续按住)绝不能在此被误判 Miss——否则 EvaluateHoldEnd(forceMiss) 会调 StopHoldParticles, // 把长按持续特效杀掉。物理版时 collider 早已离开判定区不会走到这里;几何版 exit 更晚才暴露此隐患。 bool startAlreadyJudged = jm != null && jm.IsStartJudged(noteID); if (segment == NoteSegment.Start && now > hitTime + missRangeScaled) { if (!startAlreadyJudged) { if (debugEnabled) Debug.Log($"[HoldNote] START auto-Miss: {noteColor}"); // 未判定的 start 超时 → 正常 Miss。 EvaluateHoldEnd(now, true); isJudged = true; ScheduleReturnToPool(0.2f); return; } else { // 已成功判定的 start 段:其视觉/判定使命已完成(end 段负责收尾), // 静默回收即可,绝不能走 EvaluateHoldEnd(会 StopHoldParticles 杀掉长按持续特效)。 // isHoldActive 由 middle/end 段各自维持,不受本段回收影响。 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; StopHoldFxLoop(); if (debugEnabled) Debug.Log($"[HoldNote] KeyUp {keyToPress} detected. NoteID: {noteID}, Segment: {segment}, Type: {type}"); if (!hasReleased) { hasReleased = true; releaseTime = ReleaseSongTime(); 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 = PressSongTime(); float maxWindow = (judgeConfig != null ? Mathf.Max(judgeConfig.goodEarlyRange, judgeConfig.goodLateRange) : 0.3f) * 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) && keyHeld && !jm.HasNoteReleased(noteID)) { isHoldActive = true; EnsureHoldParticleLoop(); if (now >= hitTime) { if (hasEnteredLine) { if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] Middle passed: {noteColor}"); // 按住阶段粒子由 AnimationController 的固定 tick 统一生成。 // 中段经过只负责保持循环,不额外加打一组,避免长音符前半段因中段密集而粒子频率过高。 jm?.RegisterMiddlePassed(noteID); ScheduleReturnToPool(0.2f); isJudged = true; } } } else if (segment == NoteSegment.End && jm != null && jm.IsStartJudged(noteID) && !jm.HasNoteReleased(noteID)) { if (keyHeld && now >= scheduledEndTime) { if (debugEnabled) Debug.Log($"[HoldNote] END auto-complete on hold: {noteColor}, noteID={noteID}, now={now:F3}, scheduledEnd={scheduledEndTime:F3}"); releaseTime = scheduledEndTime; hasReleased = true; jm.RegisterNoteReleased(noteID, true); isHoldActive = false; StopHoldFxLoop(); EvaluateHoldEnd(scheduledEndTime, false, true); isJudged = true; return; } 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 (isHoldActive && jm != null && jm.IsStartJudged(noteID) && !jm.HasNoteReleased(noteID)) { EnsureHoldParticleLoop(); } 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 != null ? Mathf.Max(judgeConfig.goodEarlyRange, judgeConfig.goodLateRange) : 0.3f) * 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}"); // 按住阶段粒子由 AnimationController 的固定 tick 统一生成。 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(); StartHoldFxLoop(); 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); if (isSyncNote) { effectEventController.TryTriggerMultiNoteShake(); } globalNoteEffect.TryTrigger(noteData != null ? noteData.noteFunction : null); } // 去物理判定:原 OnTriggerEnter2D 的分段逻辑,改由 controller 几何驱动调用,逻辑完全一致。 private void HandleZoneEnter() { hasEnteredLine = true; if (segment == NoteSegment.Start) { RegisterQueueIfNeeded(); } else if (segment == NoteSegment.Middle) { hasBeenHeldFromStart = IsHeld(); if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] Middle enter: color={noteColor}, isStartJudged={JudgeManager.Instance.IsStartJudged(noteID)}, hasReleased={JudgeManager.Instance.HasNoteReleased(noteID)}, isKeyHeld={IsHeld()}, 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; } // END 段到达判定区时,如果头部已判定且玩家还没松手,立即自动结束(不等松手)。 // 让玩家"长按到尾部"即视为完成,无需精确松手时机——更符合直觉且降低难度。 // Autoplay 必须忽略物理输入,保证在 scheduledEndTime 精确判定 Perfect。 if (JudgeManager.Instance.IsStartJudged(noteID) && !JudgeManager.Instance.HasNoteReleased(noteID) && !isJudged) { if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] End entered while note not yet released: {noteColor}, auto-finishing hold as Perfect"); // 记录松手时间为当前时刻(玩家按到尾部视为完美松手) releaseTime = scheduledEndTime; hasReleased = true; JudgeManager.Instance?.RegisterNoteReleased(noteID, true); // 判定并生成 prefab——force Perfect(按到尾部=完美完成) EvaluateHoldEnd(scheduledEndTime, false, true); isJudged = true; // 终止输入:标记键已松开,停止持续特效 isHoldActive = false; StopHoldFxLoop(); return; } autoReturnCoroutine = StartCoroutine(DelayedAutoReturnCheck()); } } // 去物理判定:原 OnTriggerExit2D 的分段逻辑,改由 controller 几何驱动调用,逻辑完全一致。 private void HandleZoneExit() { 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 - GameplayClock.NowSongTime); if (timeToWait > 0f) { float target = GameplayClock.NowSongTime + timeToWait; while (GameplayClock.NowSongTime < 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 GameplayClock.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(GameplayClock.NowSongTime, 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 = PressSongTime(); float rawOffsetMs = (hitTime - pressTime) * 1000f; float offset = pressTime - hitTime; // positive = late, negative = early string result; float visualScaleFactor = Mathf.Clamp(visualSpeedMultiplier, 0.5f, 2f); float windowScale = holdWindowMultiplier * visualScaleFactor; float perfectEarly = (judgeConfig?.perfectEarlyRange ?? 0.1f) * windowScale; float perfectLate = (judgeConfig?.perfectLateRange ?? 0.1f) * windowScale; float greatEarly = (judgeConfig?.greatEarlyRange ?? 0.2f) * windowScale; float greatLate = (judgeConfig?.greatLateRange ?? 0.2f) * windowScale; float goodEarly = (judgeConfig?.goodEarlyRange ?? 0.3f) * windowScale; float goodLate = (judgeConfig?.goodLateRange ?? 0.3f) * windowScale; if (offset >= -perfectEarly && offset <= perfectLate) { 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(); StartHoldFxLoop(); // 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 >= -greatEarly && offset <= greatLate) { result = "Great"; if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs; // RecordOffset removed from here to prevent double counting JudgeManager.Instance.RegisterStartJudged(noteID, true); isHoldActive = true; PlayHitAnimation(); StartHoldFxLoop(); holdStartTime = pressTime; HoldNoteJudgePool.RegisterStart(noteID, pressTime, hitTime, scheduledEndTime, noteColor, trackIndex, (object)noteData); } else if (offset >= -goodEarly && offset <= goodLate) { result = "Good"; if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs; // RecordOffset removed from here to prevent double counting JudgeManager.Instance.RegisterStartJudged(noteID, true); isHoldActive = true; PlayHitAnimation(); StartHoldFxLoop(); 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(GameplayClock.NowSongTime, 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(); } // Lucky Chance: rewrite non-Miss judge to Perfect during active duration. if (result != "Miss") { TryRewriteNonMissJudgeToPerfect(trackIndex, ref result); GameplayLevelRuleEventBus.NotifyHoldStarted(trackIndex, result, pressTime, noteData); } // 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); } if (result != "Miss") { TrackJudgeHitEffectController.PlayTrackHitFx(trackIndex); } // Spawn judgement prefab: // - START 段:只有 Miss 时才生成(点中的 Perfect/Good 不生成,避免头尾都弹;但 Miss 必须立即反馈) // - END/BODY 段:总是生成(包括 Miss) if (segment != NoteSegment.Start || result == "Miss") { string colorForPrefab = GetColorForPrefab(); if (JudgeManager.IsDebugEnabled) { Debug.Log($"[HoldNote] Attempting to spawn prefab: segment={segment}, result={result}, noteColor={noteColor}, trackIndex={trackIndex}, derivedColor={colorForPrefab}, Instance={(Animation_GenerateJudgementSituationPrefab.Instance != null ? "EXISTS" : "NULL")}"); } if (string.IsNullOrEmpty(colorForPrefab)) { Debug.LogError($"[HoldNote] Cannot spawn prefab: colorForPrefab is null! noteColor={noteColor}, trackIndex={trackIndex}, segment={segment}, result={result}"); } else if (Animation_GenerateJudgementSituationPrefab.Instance == null) { Debug.LogError($"[HoldNote] Cannot spawn prefab: Animation_GenerateJudgementSituationPrefab.Instance is NULL!"); } else { Animation_GenerateJudgementSituationPrefab.Instance.SpawnJudgePrefab(colorForPrefab, result); if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] SpawnJudgePrefab called successfully for {colorForPrefab}, {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); } if (segment == NoteSegment.Start && isSyncNote && result != "Miss") { effectEventController.TryTriggerMultiNoteShake(); } if (segment == NoteSegment.Start && result != "Miss") { globalNoteEffect.TryTrigger(noteData != null ? noteData.noteFunction : null); } } private IEnumerator DelayedReturn() { yield return GameplayClock.WaitForSeconds(0.05f); if (gameObject.activeSelf) { if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] DelayedReturn: returning {noteID} to pool at time={GameplayClock.NowSongTime:F3}"); ReturnToPool(); } } // central evaluation for hold end, given an actual releaseTime (real-time) or forceMiss private void EvaluateHoldEnd(float actualReleaseTime, bool forceMiss, bool forcePerfect = false) { 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} forcePerfect={forcePerfect}"); 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 { // 松手时刻判定:以玩家松手时间(releaseTime)与预定结束时间(scheduledEndTime)的绝对时间差 // 评级,与头段"按下时刻判定"完全对称,不再依赖按住时长占比或几何进出判定。 // forcePerfect(按住到尾部自动完成)仍直接判 Perfect。 float endScheduled = scheduledEndTime; 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}"); endScheduled = info.scheduledEnd; HoldNoteJudgePool.Unregister(noteID); } else { if (JudgeManager.IsDebugEnabled) Debug.LogWarning($"[HoldNote] No pool info for {noteID}, using field scheduledEndTime for release timing"); } float endOffset = Mathf.Abs(releaseTime - endScheduled); result = GradeAndCountHoldEndByReleaseTiming(forcePerfect, endOffset); if (result == "Miss") { // 松手时刻严重偏离预定结束点 → 记 Miss(与头段 Miss 语义一致)。 if (JudgeManager.IsDebugEnabled) Debug.LogWarning($"[HoldNote] END judged as Miss by release timing: {noteColor} endOffset={endOffset:F3}"); ScoreManager.Instance?.RecordOffset(0); ReleaseTrackJudgeLockSafe(); } else if (noteData != null) { noteData.judgeOffsetMsEnd = (endScheduled - releaseTime) * 1000f; // 以头段偏移作为本音符的代表性时序记录(与原逻辑一致,保证每条长音符只记一次 offset)。 float headOffset = noteData.judgeOffsetMs; if (float.IsNaN(headOffset)) headOffset = 0; ScoreManager.Instance?.RecordOffset(headOffset); } } string originalEndJudge = result; if (TryRewriteNonMissJudgeToPerfect(trackIndex, ref result)) { AdjustCountsAfterRewriteToPerfect(trackIndex, originalEndJudge); } if (result == "Miss") { GameplayLevelRuleEventBus.NotifyHoldBroken(trackIndex, result, actualReleaseTime, noteData); } else { GameplayLevelRuleEventBus.NotifyHoldCompleted(trackIndex, result, actualReleaseTime, noteData); } 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); } teamUIController.Instance?.RefreshRealtimeJudgeStatsText(); if (result != "Miss") { TrackJudgeHitEffectController.PlayTrackHitFx(trackIndex); } // Ensure END always spawns judgement prefab (including Miss) string colorForPrefab = GetColorForPrefab(); if (JudgeManager.IsDebugEnabled) { Debug.Log($"[HoldNote.EvaluateHoldEnd] Attempting to spawn END prefab: result={result}, noteColor={noteColor}, trackIndex={trackIndex}, derivedColor={colorForPrefab}, Instance={(Animation_GenerateJudgementSituationPrefab.Instance != null ? "EXISTS" : "NULL")}"); } if (string.IsNullOrEmpty(colorForPrefab)) { Debug.LogError($"[HoldNote.EvaluateHoldEnd] Cannot spawn END prefab: colorForPrefab is null! noteColor={noteColor}, trackIndex={trackIndex}, result={result}"); } else if (Animation_GenerateJudgementSituationPrefab.Instance == null) { Debug.LogError($"[HoldNote.EvaluateHoldEnd] Cannot spawn END prefab: Animation_GenerateJudgementSituationPrefab.Instance is NULL!"); } else { Animation_GenerateJudgementSituationPrefab.Instance.SpawnJudgePrefab(colorForPrefab, result); if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote.EvaluateHoldEnd] SpawnJudgePrefab called successfully for {colorForPrefab}, {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 = _holdEndCandidatesBuffer; candidates.Clear(); 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}"); } // 只有命中(非 Miss)才播放打击特效;Miss 不该触发击打效果。 // 与上面 1253-1256 的 TrackJudgeHitEffectController 守卫保持一致。 if (result != "Miss") { PlayHitAnimation(); } // 关键修复(长条结束仍按住→持续特效不停):hold 已完整结算,标记该 note 为"已释放/已完结"。 // 否则玩家继续按住时,其它仍存活的段 Update 满足 (isHoldActive && IsStartJudged && !HasNoteReleased // && keyHeld) 会再次 EnsureHoldParticleLoop 重启持续 tick——即使 hold 逻辑上已经结束。 // 置 released 后,所有段的 Update 门槛 !HasNoteReleased 不再成立,持续循环不会被重启。 hasReleased = true; JudgeManager.Instance?.RegisterNoteReleased(noteID, true); StopHoldFxLoop(); // 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); } // 按"松手时刻偏移"评级并累加对应计数,返回评级字符串。与头段 HandleStart 的判定窗口 // 计算方式一致(perfect/great/good * holdWindowMultiplier * visualScaleFactor),超出 good 窗口判 Miss。 // forcePerfect(按住到尾部自动完成)直接判 Perfect。 private string GradeAndCountHoldEndByReleaseTiming(bool forcePerfect, float endOffset) { float visualScaleFactor = Mathf.Clamp(visualSpeedMultiplier, 0.5f, 2f); float windowScale = holdWindowMultiplier * visualScaleFactor; float perfectEarly = (judgeConfig?.perfectEarlyRange ?? 0.1f) * windowScale; float perfectLate = (judgeConfig?.perfectLateRange ?? 0.1f) * windowScale; float greatEarly = (judgeConfig?.greatEarlyRange ?? 0.2f) * windowScale; float greatLate = (judgeConfig?.greatLateRange ?? 0.2f) * windowScale; float goodEarly = (judgeConfig?.goodEarlyRange ?? 0.3f) * windowScale; float goodLate = (judgeConfig?.goodLateRange ?? 0.3f) * windowScale; // endOffset = releaseTime - scheduledEndTime, positive = late, negative = early string result; if (forcePerfect || (endOffset >= -perfectEarly && endOffset <= perfectLate)) { result = "Perfect"; ScoreManager.Instance.countPerfect += 1; if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackPerfectCounts[trackIndex]++; } else if (endOffset >= -greatEarly && endOffset <= greatLate) { result = "Great"; ScoreManager.Instance.countGreat += 1; if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackGreatCounts[trackIndex]++; } else if (endOffset >= -goodEarly && endOffset <= goodLate) { result = "Good"; ScoreManager.Instance.countGood += 1; if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackGoodCounts[trackIndex]++; } else { result = "Miss"; ScoreManager.Instance.countMiss += 1; if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackMissCounts[trackIndex]++; } return result; } 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 (IsHeld()) { releaseTime = scheduledEndTime; } else { releaseTime = GameplayClock.NowSongTime; } 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={GameplayClock.NowSongTime: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() { // 纯视觉 body 回收:还原渲染器到 prefab 原始状态,退出 body 模式,不走判定相关重置。 if (bodyMode || bodyReturning) { // body 与 head/end 共享 noteID,OnHoldAlphaSync 会让 body 也 ApplyJudgedMaterial // (与原中段行为一致)。回收时必须还原材质,否则换过的材质会残留到下次池化复用。 RestoreOriginalMaterials(); RestoreBodyRenderers(); bodyMode = false; bodyReturning = false; segment = NoteSegment.None; noteColor = string.Empty; noteID = string.Empty; type = string.Empty; if (controller != null) controller.DisableVisualBodyMode(); return; } // 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; // 普通 start/middle 段回收时,同一条长音符可能仍在持续按住,不能在这里停掉持续特效。 // 真正停止由 keyUp / end 判定路径负责;这里只兜底处理 end 段池化泄漏。 if (isHoldActive && segment == NoteSegment.End) { StopHoldFxLoop(); } isJudged = false; isHoldActive = false; hasBeenHeldFromStart = false; prevTrackHeld = false; lastPressDspTime = double.NaN; lastReleaseDspTime = double.NaN; // 去物理判定:池化复用时重置 controller 的几何判定状态(重新算阈值、清进入标志)。 if (controller != null) controller.ResetJudgeZoneGeometry(); 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); } } // 以纯视觉 body 身份初始化:一条 Tiled 长条覆盖 head→end 整段,按绝对时间下落, // end 越过判定线后回收(时机与原分段一致)。不注册任何判定/计分状态。 // baseHit=head 到达判定线的绝对歌曲时间;lengthSeconds=长音符时长;speed=世界速度; // travelTime=从发车到判定线的时间;spawnPoint=车道原点。 public void SetupBody(int holdNoteId, int trackIndexArg, float speed, float baseHit, float lengthSeconds, float travelTime, string color, Vector3 spawnPoint) { this.id = holdNoteId; this.noteID = holdNoteId.ToString(); this.trackIndex = trackIndexArg; this.TrackIndex = trackIndexArg; this.color = color; this.noteColor = color; this.type = "body"; this.segment = NoteSegment.None; // 不参与任何 segment 判定分支 bodyMode = true; bodyReturning = false; bodySpeed = Mathf.Max(0f, speed); bodyLengthSeconds = Mathf.Max(0f, lengthSeconds); bodyTravelTime = Mathf.Max(0f, travelTime); bodyHeadActivationTime = baseHit - bodyTravelTime; bodySpawnX = spawnPoint.x; bodySpawnZ = spawnPoint.z; bodySpawnY = spawnPoint.y; if (controller == null) controller = GetComponent(); controller?.EnableVisualBodyMode(); CaptureBodyRenderers(); UpdateBodyVisual(GameplayClock.NowSongTime); } // 缓存 body 上的所有 SpriteRenderer 并切到 Tiled。 // 不改 transform 缩放(保留 prefab 里 outer/inner 各自的构图比例),长度全部交给 size.y: // 世界高度 = size.y × lossyScale.y ⇒ 设 size.y = 目标世界高度 / lossyScale.y。 // 相比原来的 Simple+整体拉伸,Tiled 会沿 Y 重复贴图而非把单张越拉越长,消除拉伸失真。 private void CaptureBodyRenderers() { if (bodyRenderers == null) { bodyRenderers = GetComponentsInChildren(true); int n = bodyRenderers.Length; bodyRendererOrigDrawMode = new SpriteDrawMode[n]; bodyRendererOrigSize = new Vector2[n]; bodyRendererOrigLocalScale = new Vector3[n]; for (int i = 0; i < n; i++) { var sr = bodyRenderers[i]; if (sr == null) continue; bodyRendererOrigDrawMode[i] = sr.drawMode; bodyRendererOrigSize[i] = sr.size; bodyRendererOrigLocalScale[i] = sr.transform.localScale; } } for (int i = 0; i < bodyRenderers.Length; i++) { var sr = bodyRenderers[i]; if (sr == null) continue; sr.drawMode = SpriteDrawMode.Tiled; sr.tileMode = SpriteTileMode.Continuous; // 不改 transform 缩放(保留 outer/inner 各自构图比例)。宽度取一块 tile 宽, // 使世界宽度 = sprite 原生宽 × lossyScale.x,与原 Simple 模式一致,仅水平不平铺。 if (sr.sprite != null) { var sz = sr.size; sz.x = sr.sprite.bounds.size.x; sr.size = sz; } } } // 逐帧驱动 body 的位置(纯视觉,不触判定)。 // 复刻原 N 个 middle 段的整体行为:一条定高长条(全高 = speed×length)随 head 绝对时间下落、 // 穿过判定线继续下移,直到 end 越过判定线后(≈ scheduledEnd)再回收——与原分段"经过判定线 // 后 ~0.2s 回收"的滚动/消失时机一致,不做额外的底部裁剪(避免改变既有视觉行为)。 private void UpdateBodyVisual(float t) { if (!bodyMode) return; float scheduledEnd = bodyHeadActivationTime + bodyTravelTime + bodyLengthSeconds; if (t > scheduledEnd + 0.2f) { ReturnBodyToPool(); return; } float elapsed = t - bodyHeadActivationTime; float headY = bodySpawnY - bodySpeed * elapsed; // head(底端)世界 Y float fullHeight = bodySpeed * bodyLengthSeconds; // 定高(= end 高出 head 的量) float centerY = headY + fullHeight * 0.5f; transform.position = new Vector3(bodySpawnX, centerY, bodySpawnZ); ApplyBodyHeight(fullHeight); } // 世界高度 → 每个渲染器的 size.y(= 目标世界高度 / 该渲染器世界 Y 缩放)。 private void ApplyBodyHeight(float worldHeight) { if (bodyRenderers == null) return; for (int i = 0; i < bodyRenderers.Length; i++) { var sr = bodyRenderers[i]; if (sr == null) continue; float ly = Mathf.Abs(sr.transform.lossyScale.y); if (ly < 1e-4f) ly = 1f; var sz = sr.size; sz.y = Mathf.Max(0f, worldHeight / ly); sr.size = sz; } } private void ReturnBodyToPool() { if (bodyReturning) return; bodyReturning = true; if (!gameObject.activeSelf) return; gameObject.SetActive(false); if (NotePool.Instance != null) NotePool.Instance.ReturnHoldNoteSegment(gameObject, noteColor); } // 池化复用/回收时把 body 渲染器还原到 prefab 原始状态。 private void RestoreBodyRenderers() { if (bodyRenderers == null) return; for (int i = 0; i < bodyRenderers.Length; i++) { var sr = bodyRenderers[i]; if (sr == null) continue; sr.drawMode = bodyRendererOrigDrawMode[i]; sr.size = bodyRendererOrigSize[i]; sr.transform.localScale = bodyRendererOrigLocalScale[i]; } } 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 = GameplayClock.NowSongTime - 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; } } }