1948 lines
79 KiB
C#
1948 lines
79 KiB
C#
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 共享 noteID,OnHoldAlphaSync 会让 body 也 ApplyJudgedMaterial
|
||
// (与原中段行为一致)。回收时必须还原材质,否则换过的材质会残留到下次池化复用。
|
||
RestoreOriginalMaterials();
|
||
RestoreBodyRenderers();
|
||
bodyMode = false;
|
||
bodyReturning = false;
|
||
segment = NoteSegment.None;
|
||
noteColor = string.Empty;
|
||
noteID = string.Empty;
|
||
type = string.Empty;
|
||
if (controller != null) controller.DisableVisualBodyMode();
|
||
return;
|
||
}
|
||
|
||
// also release any stale lock from previous lifecycle
|
||
ReleaseTrackJudgeLockSafe();
|
||
|
||
UnregisterQueueIfNeeded();
|
||
|
||
// Restore materials before returning to pool/reusing
|
||
RestoreOriginalMaterials();
|
||
|
||
hasReleased = false;
|
||
segment = NoteSegment.None;
|
||
keyToPress = KeyCode.None;
|
||
noteColor = string.Empty;
|
||
noteID = string.Empty;
|
||
hitTime = 0f;
|
||
scheduledEndTime = 0f;
|
||
hasEnteredLine = false;
|
||
// 普通 start/middle 段回收时,同一条长音符可能仍在持续按住,不能在这里停掉持续特效。
|
||
// 真正停止由 keyUp / end 判定路径负责;这里只兜底处理 end 段池化泄漏。
|
||
if (isHoldActive && segment == NoteSegment.End)
|
||
{
|
||
StopHoldFxLoop();
|
||
}
|
||
isJudged = false;
|
||
isHoldActive = false;
|
||
hasBeenHeldFromStart = false;
|
||
prevTrackHeld = false;
|
||
lastPressDspTime = double.NaN;
|
||
lastReleaseDspTime = double.NaN;
|
||
// 去物理判定:池化复用时重置 controller 的几何判定状态(重新算阈值、清进入标志)。
|
||
if (controller != null) controller.ResetJudgeZoneGeometry();
|
||
|
||
holdStartTime = -1f;
|
||
|
||
if (autoReturnCoroutine != null)
|
||
{
|
||
StopCoroutine(autoReturnCoroutine);
|
||
autoReturnCoroutine = null;
|
||
}
|
||
|
||
controller?.StopMovement();
|
||
|
||
// restore visual scale
|
||
ResetVisualScale();
|
||
}
|
||
|
||
private void RegisterQueueIfNeeded()
|
||
{
|
||
if (isQueued) return;
|
||
if (TrackKeyManager.Instance == null || string.IsNullOrEmpty(queueNoteId)) return;
|
||
TrackKeyManager.Instance.RegisterKey(trackIndex, queueNoteId, "hold", hitTime);
|
||
isQueued = true;
|
||
}
|
||
|
||
private void UnregisterQueueIfNeeded()
|
||
{
|
||
if (!isQueued) return;
|
||
if (TrackKeyManager.Instance != null && !string.IsNullOrEmpty(queueNoteId))
|
||
{
|
||
TrackKeyManager.Instance.UnregisterKey(trackIndex, queueNoteId);
|
||
}
|
||
isQueued = false;
|
||
}
|
||
|
||
private void ReleaseTrackJudgeLockSafe()
|
||
{
|
||
// Defensive: any miss / pooling path should release the track lock.
|
||
if (TrackKeyManager.Instance != null && !string.IsNullOrEmpty(noteID))
|
||
{
|
||
TrackKeyManager.Instance.UnlockTrackForJudge(trackIndex, noteID);
|
||
}
|
||
}
|
||
|
||
// 以纯视觉 body 身份初始化:一条 Tiled 长条覆盖 head→end 整段,按绝对时间下落,
|
||
// end 越过判定线后回收(时机与原分段一致)。不注册任何判定/计分状态。
|
||
// baseHit=head 到达判定线的绝对歌曲时间;lengthSeconds=长音符时长;speed=世界速度;
|
||
// travelTime=从发车到判定线的时间;spawnPoint=车道原点。
|
||
public void SetupBody(int holdNoteId, int trackIndexArg, float speed, float baseHit,
|
||
float lengthSeconds, float travelTime, string color, Vector3 spawnPoint)
|
||
{
|
||
this.id = holdNoteId;
|
||
this.noteID = holdNoteId.ToString();
|
||
this.trackIndex = trackIndexArg;
|
||
this.TrackIndex = trackIndexArg;
|
||
this.color = color;
|
||
this.noteColor = color;
|
||
this.type = "body";
|
||
this.segment = NoteSegment.None; // 不参与任何 segment 判定分支
|
||
|
||
bodyMode = true;
|
||
bodyReturning = false;
|
||
bodySpeed = Mathf.Max(0f, speed);
|
||
bodyLengthSeconds = Mathf.Max(0f, lengthSeconds);
|
||
bodyTravelTime = Mathf.Max(0f, travelTime);
|
||
bodyHeadActivationTime = baseHit - bodyTravelTime;
|
||
bodySpawnX = spawnPoint.x;
|
||
bodySpawnZ = spawnPoint.z;
|
||
bodySpawnY = spawnPoint.y;
|
||
|
||
if (controller == null) controller = GetComponent<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;
|
||
}
|
||
}
|
||
}
|