Files
bansonic_beta_main/Assets/scripts/gamePlay_gameplay/HoldNote.cs
T
2026-07-24 04:43:13 +08:00

1647 lines
64 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
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;
// 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;
// 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<SpriteRenderer> cachedSpriteRenderers = new List<SpriteRenderer>();
private List<UnityEngine.UI.Graphic> cachedUIComponents = new List<UnityEngine.UI.Graphic>();
[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;
// 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<RendererMaterialCache> materialCaches = new List<RendererMaterialCache>();
private static AllyCombatant GetAllyForTrackCached(int trackIndex)
{
if (trackIndex < 0) return null;
if (allyCache == null || allyCache.Length < 10) allyCache = new AllyCombatant[10];
if (allyCache[trackIndex] != null) return allyCache[trackIndex];
var allyGo = SceneObjectLookupCache.Find($"ally_0{trackIndex + 1}");
if (allyGo != null)
{
allyCache[trackIndex] = allyGo.GetComponent<AllyCombatant>();
}
return allyCache[trackIndex];
}
private static BeatmapManager GetBeatmapManagerCached()
{
if (beatmapManagerCache != null) return beatmapManagerCache;
beatmapManagerCache = SceneObjectLookupCache.FindAny<BeatmapManager>();
return beatmapManagerCache;
}
public static void PrewarmRuntimeCaches()
{
GetBeatmapManagerCached();
if (allyCache == null || allyCache.Length < 10)
allyCache = new AllyCombatant[10];
for (int i = 0; i < 5; i++)
{
GetAllyForTrackCached(i);
}
}
private static float JudgeToPmMultiplier(string judgeResult)
{
switch (judgeResult)
{
case "Perfect": return 1f;
case "Great": return 0.75f;
case "Good": return 0.5f;
case "Miss": return 0f;
default: return 0f;
}
}
private static int ComputePmDeltaFromJudge(string judgeResult, AllyCombatant ally)
{
int baseScore = 0;
var bmm = ally != null ? ally.bmm : null;
if (bmm == null) bmm = BeatmapManager.Instance;
if (bmm == null) bmm = GetBeatmapManagerCached();
if (bmm != null) baseScore = Mathf.Max(0, bmm.perNoteScore);
if (baseScore <= 0 && ally != null) baseScore = Mathf.Max(0, ally.baseTrackScore);
if (baseScore <= 0) return 0;
float mult = JudgeToPmMultiplier(judgeResult);
return Mathf.FloorToInt(baseScore * mult);
}
private static bool TryRewriteNonMissJudgeToPerfect(int trackIndex, ref string judgeResult)
{
if (judgeResult != "Great" && judgeResult != "Good") return false;
var ally = GetAllyForTrackCached(trackIndex);
if (ally == null || ally.IsDead) return false;
if (!ally.IsNonMissToPerfectRewriteActive()) return false;
judgeResult = "Perfect";
return true;
}
private static void AdjustCountsAfterRewriteToPerfect(int trackIndex, string originalJudge)
{
var sm = ScoreManager.Instance;
if (sm == null) return;
if (originalJudge == "Great")
{
sm.countGreat = Mathf.Max(0, sm.countGreat - 1);
if (trackIndex >= 0 && trackIndex < sm.trackGreatCounts.Length)
sm.trackGreatCounts[trackIndex] = Mathf.Max(0, sm.trackGreatCounts[trackIndex] - 1);
}
else if (originalJudge == "Good")
{
sm.countGood = Mathf.Max(0, sm.countGood - 1);
if (trackIndex >= 0 && trackIndex < sm.trackGoodCounts.Length)
sm.trackGoodCounts[trackIndex] = Mathf.Max(0, sm.trackGoodCounts[trackIndex] - 1);
}
else
{
return;
}
sm.countPerfect += 1;
if (trackIndex >= 0 && trackIndex < sm.trackPerfectCounts.Length)
sm.trackPerfectCounts[trackIndex] += 1;
}
private JudgeManager cachedJudgeManager;
private TrackKeyManager cachedTrackKeyManager;
private void Awake()
{
controller = GetComponent<HoldNoteController>();
// Prefer the global shared AnimationController if available, otherwise fallback to local or scene instance
anim = AnimationController.Global;
if (anim == null)
{
anim = GetComponent<AnimationController>();
if (anim == null)
{
anim = SceneObjectLookupCache.FindAny<AnimationController>();
}
}
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<SpriteRenderer>(true);
if (sr != null) visualTransform = sr.transform;
else
{
var mr = GetComponentInChildren<MeshRenderer>(true);
if (mr != null) visualTransform = mr.transform;
else
{
var ir = GetComponentInChildren<UnityEngine.UI.Graphic>(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<SpriteRenderer>(true));
cachedUIComponents.AddRange(GetComponentsInChildren<UnityEngine.UI.Graphic>(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<MeshRenderer>(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;
}
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();
}
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<AnimationController>();
}
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: 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 <= gameplay clock).
// 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 = 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;
}
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?.missRange ?? 0.5f) * 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 = 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)
&& 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 (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?.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}");
// 按住阶段粒子由 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 = GameplayClock.NowSongTime;
hasReleased = true;
JudgeManager.Instance?.RegisterNoteReleased(noteID, true);
// 判定并生成 prefab——force Perfect(按到尾部=完美完成)
EvaluateHoldEnd(releaseTime, 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 = GameplayClock.NowSongTime;
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();
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 <= 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();
StartHoldFxLoop();
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();
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
{
// 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 (forcePerfect || frac > 0.7f)
{
result = "Perfect";
ScoreManager.Instance.countPerfect += 1;
if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackPerfectCounts[trackIndex]++;
}
else if (frac > 0.4f)
{
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 (forcePerfect || frac > 0.7f)
{
result = "Perfect";
ScoreManager.Instance.countPerfect += 1;
if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackPerfectCounts[trackIndex]++;
}
else if (frac > 0.4f)
{
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);
}
}
}
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);
}
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 = new List<int>();
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);
}
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()
{
// 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;
// 去物理判定:池化复用时重置 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);
}
}
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<HoldNoteController>();
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;
}
}
}