Files
2026-07-30 23:15:58 +08:00

1948 lines
79 KiB
C#
Raw Permalink 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;
// 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<int> allocation.
private readonly List<int> _holdEndCandidatesBuffer = new List<int>(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<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;
// ==== 单条 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<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;
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<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: 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 共享 noteIDOnHoldAlphaSync 会让 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<HoldNoteController>();
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<SpriteRenderer>(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<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;
}
}
}