Files
bansonic_beta_main/Assets/scripts/gamePlay_gameplay/HoldNote.cs
T
2026-03-03 14:07:39 +08:00

1481 lines
56 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;
private float releaseTime = 0f;
private bool hasReleased = true;
private bool hasEnteredLine = false;
private Coroutine autoReturnCoroutine;
private Coroutine scheduledReturnCoroutine;
private HoldNoteController controller;
private AnimationController anim;
// Queue id for TrackKeyManager (start segment only)
private string queueNoteId = null;
private bool isQueued = false;
public int id;
public int trackIndex;
public float speed;
public float startTime;
public float delay;
public bool isEnd;
public float scheduledEndTime;
public string color;
public KeyCode key;
public string type; // "start", "middle", or "end"
// Flags and runtime state
private bool isJudged = false; // whether this segment has been judged (true = evaluation done)
private bool isHoldActive = false; // whether the hold is currently active (player is holding)
// whether this middle segment was held from the start (used for logic checks)
private bool hasBeenHeldFromStart = false;
[Header("Judge configuration")]
public NoteJudgeConfig judgeConfig; // judge windows configuration
private NoteData noteData;
// store original transform scale so we can restore on reset
private Vector3 originalLocalScale = Vector3.one;
// visual transform (child) to scale instead of root to avoid affecting colliders
private Transform visualTransform = null;
private Vector3 originalVisualLocalScale = Vector3.one;
private List<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. >1 makes hold judgement more lenient (wider windows).")]
[Range(1f, 2f)]
public float holdWindowMultiplier = 1.3f;
[Header("Custom Animation")]
public GameObject holdAnimationPrefab;
private GameObject activeHoldAnimationInstance;
private Coroutine delayedHoldAnimationCoroutine;
public float visualSpeedMultiplier = 1f; // injected from NoteSpawner to adapt windows when visual speed changes
// track when the hold actually started (real-time) so we can compute held fraction
private float holdStartTime = -1f;
// Cache allies per track to avoid GameObject.Find on first judgement.
private static AllyCombatant[] allyCache;
private static BeatmapManager beatmapManagerCache;
private struct RendererMaterialCache
{
public SpriteRenderer spriteRenderer;
public UnityEngine.UI.Graphic uiGraphic;
public MeshRenderer meshRenderer;
public Material originalMaterial;
}
private List<RendererMaterialCache> materialCaches = new List<RendererMaterialCache>();
private static AllyCombatant GetAllyForTrackCached(int trackIndex)
{
if (trackIndex < 0) return null;
if (allyCache == null || allyCache.Length < 10) allyCache = new AllyCombatant[10];
if (allyCache[trackIndex] != null) return allyCache[trackIndex];
var allyGo = GameObject.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 = Object.FindAnyObjectByType<BeatmapManager>();
return beatmapManagerCache;
}
private static float JudgeToPmMultiplier(string judgeResult)
{
switch (judgeResult)
{
case "Perfect": return 1f;
case "Great": return 0.75f;
case "Good": return 0.5f;
case "Miss": return 0f;
default: return 0f;
}
}
private static int ComputePmDeltaFromJudge(string judgeResult, AllyCombatant ally)
{
int baseScore = 0;
var bmm = ally != null ? ally.bmm : null;
if (bmm == null) bmm = GetBeatmapManagerCached();
if (bmm != null) baseScore = Mathf.Max(0, bmm.perNoteScore);
if (baseScore <= 0 && ally != null) baseScore = Mathf.Max(0, ally.baseTrackScore);
if (baseScore <= 0) return 0;
float mult = JudgeToPmMultiplier(judgeResult);
return Mathf.FloorToInt(baseScore * mult);
}
private 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 = FindAnyObjectByType<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;
}
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 OnDestroy()
{
if (controller != null)
{
controller.OnJudgeZoneChanged -= OnJudgeZoneChanged;
}
if (JudgeManager.Instance != null)
{
JudgeManager.Instance.OnHoldAlphaSync -= HandleAlphaSync;
}
}
private void OnJudgeZoneChanged(bool inZone)
{
hasEnteredLine = inZone;
}
private void OnEnable()
{
ResetState();
}
private void PlayHitAnimation()
{
var controller = AnimationController.Global ?? anim;
if (controller != null)
{
controller.PlayDestroyAnimation(noteColor);
}
}
private void StartHoldEffects()
{
StopHoldEffects(); // cleanup any existing
if (gameObject.activeInHierarchy)
{
delayedHoldAnimationCoroutine = StartCoroutine(DelayedStartHoldEffects());
}
}
private void StopHoldEffects()
{
if (delayedHoldAnimationCoroutine != null)
{
StopCoroutine(delayedHoldAnimationCoroutine);
delayedHoldAnimationCoroutine = null;
}
if (activeHoldAnimationInstance != null)
{
Destroy(activeHoldAnimationInstance);
activeHoldAnimationInstance = null;
}
AnimationController.Global?.StopHoldParticles();
}
private IEnumerator DelayedStartHoldEffects()
{
yield return new WaitForSeconds(0.1f);
// Start standard particles
AnimationController.Global?.StartHoldParticles(noteColor);
// Instantiate custom prefab if assigned
if (holdAnimationPrefab != null)
{
// find spawn position: prefer the track effect position if available from AnimationController
Vector3 spawnPos = transform.position;
var ac = AnimationController.Global;
if (ac != null)
{
// Logic to find slot position (simplified: use ac's position or the note's)
// In AnimationController.PlayDestroyAnimation, it uses redEffect.transform.position etc.
// We don't have direct access to those private fields, but we can assume
// the note is at the judge line when this starts.
}
activeHoldAnimationInstance = Instantiate(holdAnimationPrefab, spawnPos, Quaternion.identity);
// Optionally parent to the note or track
// activeHoldAnimationInstance.transform.SetParent(this.transform, false);
}
}
private IEnumerator DelayedDisableAnimation(GameObject animationObject, float delay)
{
// No-op: we used to disable the shared AnimationController here which stopped coroutines.
// Keep as no-op to avoid side effects.
yield return null;
}
private float cachedTravelTime = 1.0f; // Cached travel time for distance optimization
public void Setup(int id, int trackIndex, float speed, float time, float delay,
bool isEnd, float scheduledEnd, string color, KeyCode key, string type, float travelTimeSeconds, NoteJudgeConfig judgeConfig, NoteData noteData)
{
this.id = id;
// Ensure singletons are up to date if they were re-initialized (e.g. on scene reload)
if (cachedJudgeManager == null) cachedJudgeManager = JudgeManager.Instance;
if (cachedTrackKeyManager == null) cachedTrackKeyManager = TrackKeyManager.Instance;
this.trackIndex = trackIndex;
// also set BaseNote.TrackIndex so other systems using TrackIndex property work consistently
this.speed = speed;
this.startTime = time;
this.delay = delay;
this.isEnd = isEnd;
this.scheduledEndTime = scheduledEnd;
this.color = color;
this.key = key;
this.type = type;
this.judgeConfig = judgeConfig;
this.noteID = id.ToString();
this.keyToPress = key;
this.noteColor = color;
// hitTime now uses real time base passed in as 'time' plus delay
this.hitTime = time + delay;
this.segment = (delay == 0f) ? NoteSegment.Start :
(isEnd ? NoteSegment.End : NoteSegment.Middle);
this.hasEnteredLine = false;
this.hasReleased = false;
if (judgeConfig == null)
{
if (JudgeManager.IsDebugEnabled) Debug.LogError($"HoldNoteJudgeConfig is null! judgeConfig not assigned. track={trackIndex}");
}
else
{
if (JudgeManager.IsDebugEnabled) Debug.Log($"HoldNoteJudgeConfig: perfect={judgeConfig.perfectRange}, great={judgeConfig.greatRange}, good={judgeConfig.goodRange}, miss={judgeConfig.missRange}");
}
this.noteData = noteData;
// cache queue id for TrackKeyManager (per pooled instance)
queueNoteId = gameObject.GetInstanceID().ToString();
isQueued = false;
// Debug info to help investigate end note visibility
if (JudgeManager.IsDebugEnabled)
{
Debug.Log($"[HoldNote.Setup] id={noteID} segment={segment} type={type} hitTime={hitTime:F2} scheduledEnd={scheduledEndTime:F2} color={color} track={trackIndex}");
Debug.Log($"[HoldNote.Setup] Controller present={(controller != null)}, AnimationController global={(AnimationController.Global != null)}, localAnim={(anim != null)}");
}
// Register initial judge state in JudgeManager
JudgeManager.Instance?.RegisterNoteReleased(noteID, false);
if (segment == NoteSegment.Start)
JudgeManager.Instance?.RegisterStartJudged(noteID, false); // initially mark start as not judged
if (segment == NoteSegment.End)
JudgeManager.Instance?.RegisterScheduledEndTime(noteID, scheduledEndTime);
controller?.SetSpeed(speed);
float travelTime = Mathf.Max(0f, travelTimeSeconds);
this.cachedTravelTime = travelTime;
if (controller != null)
{
// Keep all hold segments moving in absolute time so they stay visually connected.
float headActivationTime = time - travelTime;
float baseSpawnYOffset = -speed * delay;
controller.ConfigureAbsolutePositioning(transform.position, headActivationTime, baseSpawnYOffset);
}
else
{
// fallback
controller?.SetSegmentDelay(delay);
}
// Removed: CalibratePosition for delay==0f was causing Start segments to snap to incorrect positions
// if they started moving immediately after Setup (due to activationTime <= Time.time).
// Start segments should begin at spawnPoint and move from there naturally.
}
private void Update()
{
if (isJudged) return;
var jm = cachedJudgeManager;
var tkm = cachedTrackKeyManager;
bool debugEnabled = JudgeManager.IsDebugEnabled;
float now = Time.time;
// Optimization: Early exit if the note is far from judgment line and hasn't entered yet
if (!hasEnteredLine)
{
// For all segments, if they haven't triggered collision yet and are far away, skip input checks
// We use travelTime as a safe buffer. Only check input if note is within 0.5s of hitTime.
if (now < hitTime - 0.5f) return;
}
if (GameConfig.autoPlayEnabled)
{
UpdateAutoplay(now, jm, tkm, debugEnabled);
return;
}
bool keyHeld = Input.GetKey(keyToPress);
bool keyDown = Input.GetKeyDown(keyToPress);
bool keyUp = Input.GetKeyUp(keyToPress);
// If start was judged and player is holding key, start hold effects
if (!isHoldActive && jm != null && jm.IsStartJudged(noteID) && !jm.HasNoteReleased(noteID) && keyHeld)
{
isHoldActive = true;
StartHoldEffects();
if (debugEnabled) Debug.Log($"[HoldNote] Start particles started for {noteID} color={noteColor} at time={now:F3}");
}
// Auto-miss checks when inside judge line
if (hasEnteredLine)
{
float missRangeScaled = (judgeConfig?.missRange ?? 0.5f) * holdWindowMultiplier;
if (segment == NoteSegment.Start && now > hitTime + missRangeScaled)
{
if (debugEnabled) Debug.Log($"[HoldNote] START auto-Miss: {noteColor}");
// Register miss properly via EvaluateHoldEnd
EvaluateHoldEnd(now, true);
isJudged = true;
ScheduleReturnToPool(0.2f);
return;
}
else if (segment == NoteSegment.End && now > scheduledEndTime + missRangeScaled)
{
if (debugEnabled) Debug.Log($"[HoldNote] END auto-Miss: {noteColor}");
HandleEnd(true);
// HandleEnd/EvaluateHoldEnd will release lock as needed
isJudged = true;
return;
}
}
// Handle key release while holding
if (isHoldActive && keyUp)
{
isHoldActive = false;
StopHoldEffects();
if (debugEnabled) Debug.Log($"[HoldNote] KeyUp {keyToPress} detected. NoteID: {noteID}, Segment: {segment}, Type: {type}");
if (!hasReleased)
{
hasReleased = true;
releaseTime = now;
jm?.RegisterNoteReleased(noteID, true);
if (debugEnabled) Debug.Log($"[HoldNote] RegisterNoteReleased on KeyUp. NoteID: {noteID}, releaseTime={releaseTime:F2}");
EvaluateHoldEnd(releaseTime, false);
}
}
// Start judgement input handling
if (segment == NoteSegment.Start)
{
bool startResolved = jm != null && jm.IsStartJudged(noteID);
if (!startResolved)
{
// Ensure we are the front-most note in the judge queue (if any)
var headId = tkm?.GetCurrentNoteId(trackIndex);
if (headId != null && headId != queueNoteId)
{
return;
}
if (keyDown)
{
if (debugEnabled)
Debug.Log($"[HoldNote] KeyDown detected for {noteID} key={keyToPress} time={now:F3} hitTime={hitTime:F3} hasEnteredLine={hasEnteredLine}");
float pressTimeLocal = now;
float maxWindow = (judgeConfig?.missRange ?? 0.5f) * holdWindowMultiplier;
// Only consume the frame if this hold start is actually within the timing window.
if (Mathf.Abs(pressTimeLocal - hitTime) > maxWindow)
return;
if (tkm != null)
{
if (!tkm.IsBestCandidate(trackIndex, queueNoteId, pressTimeLocal, maxWindow))
return;
// Ensure one physical press only judges one note on this track per frame
if (!tkm.TryConsumeTrackForFrame(trackIndex))
return;
}
HandleStart(false);
// do not mark isJudged here; Start remains active for hold
}
}
}
else if (segment == NoteSegment.Middle
&& jm != null && jm.IsStartJudged(noteID)
&& isHoldActive
&& !jm.HasNoteReleased(noteID))
{
if (now >= hitTime)
{
if (hasEnteredLine)
{
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] Middle passed: {noteColor}");
PlayHitAnimation();
jm?.RegisterMiddlePassed(noteID);
ScheduleReturnToPool(0.2f);
isJudged = true;
}
}
}
else if (segment == NoteSegment.End
&& jm != null && jm.IsStartJudged(noteID)
&& !jm.HasNoteReleased(noteID))
{
if (keyUp)
{
// protect end handling with track lock so a single release doesn't trigger multiple ends
if (tkm != null && !tkm.TryLockTrackForJudge(trackIndex, noteID))
return;
HandleEnd();
isJudged = true;
}
}
}
private void UpdateAutoplay(float now, JudgeManager jm, TrackKeyManager tkm, bool debugEnabled)
{
// Keep the "hold active" state alive for middle/end segments once the start has been judged.
if (!isHoldActive && jm != null && jm.IsStartJudged(noteID) && !jm.HasNoteReleased(noteID))
{
isHoldActive = true;
}
if (segment == NoteSegment.Start)
{
bool startResolved = jm != null && jm.IsStartJudged(noteID);
if (!startResolved && now >= hitTime)
{
// Ensure we are the front-most note in the judge queue (if any)
var headId = tkm?.GetCurrentNoteId(trackIndex);
if (headId != null && headId != queueNoteId)
{
return;
}
float pressTimeLocal = hitTime; // force Perfect regardless of frame timing
float maxWindow = (judgeConfig?.missRange ?? 0.5f) * holdWindowMultiplier;
if (tkm != null && !tkm.IsBestCandidate(trackIndex, queueNoteId, pressTimeLocal, maxWindow))
{
return;
}
HandleStartAutoplayPerfect(pressTimeLocal);
}
return;
}
if (segment == NoteSegment.Middle)
{
if (jm != null && jm.IsStartJudged(noteID) && isHoldActive && !jm.HasNoteReleased(noteID))
{
if (now >= hitTime && hasEnteredLine)
{
if (debugEnabled) Debug.Log($"[HoldNote.AutoPlay] Middle passed: {noteColor}");
PlayHitAnimation();
jm?.RegisterMiddlePassed(noteID);
ScheduleReturnToPool(0.2f);
isJudged = true;
}
}
return;
}
if (segment == NoteSegment.End)
{
if (jm != null && jm.IsStartJudged(noteID) && !jm.HasNoteReleased(noteID))
{
if (now >= scheduledEndTime)
{
// Defensive: ensure pool info exists so EvaluateHoldEnd resolves to Perfect deterministically.
if (!HoldNoteJudgePool.TryGet(noteID, out _))
{
if (noteData != null) noteData.judgeOffsetMs = 0f;
HoldNoteJudgePool.RegisterStart(noteID, hitTime, hitTime, scheduledEndTime, noteColor, trackIndex, (object)noteData);
}
EvaluateHoldEnd(scheduledEndTime, false);
}
}
return;
}
}
private void HandleStartAutoplayPerfect(float pressTime)
{
// release from queue so following notes can become head
UnregisterQueueIfNeeded();
if (!JudgeManager.Instance.TryResolveStart(noteID))
{
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote.AutoPlay] START try-resolve failed: {noteColor}");
return;
}
const string result = "Perfect";
float rawOffsetMs = (hitTime - pressTime) * 1000f;
if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs;
JudgeManager.Instance.RegisterStartJudged(noteID, true);
isHoldActive = true;
PlayHitAnimation();
StartHoldEffects();
holdStartTime = pressTime;
HoldNoteJudgePool.RegisterStart(noteID, pressTime, hitTime, scheduledEndTime, noteColor, trackIndex, (object)noteData);
InputManager.Instance?.ShowJudgeResult(trackIndex, result);
teamUIController.Instance?.OnJudgeResult(result);
JudgeSoundManager.Instance?.PlayJudgeSound(result);
}
private void OnTriggerEnter2D(Collider2D collision)
{
if (!collision.CompareTag("JudgmentLine")) return;
hasEnteredLine = true;
if (segment == NoteSegment.Start)
{
RegisterQueueIfNeeded();
}
else if (segment == NoteSegment.Middle)
{
hasBeenHeldFromStart = Input.GetKey(keyToPress);
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] Middle enter: color={noteColor}, isStartJudged={JudgeManager.Instance.IsStartJudged(noteID)}, hasReleased={JudgeManager.Instance.HasNoteReleased(noteID)}, isKeyHeld={Input.GetKey(keyToPress)}, hasBeenHeldFromStart={hasBeenHeldFromStart}");
if (autoReturnCoroutine != null)
StopCoroutine(autoReturnCoroutine);
}
else if (segment == NoteSegment.End)
{
if (autoReturnCoroutine != null)
StopCoroutine(autoReturnCoroutine);
// If already released before reaching the line, handle end judgement immediately
if (JudgeManager.Instance.HasNoteReleased(noteID) && !isJudged)
{
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] End entered but note already released: {noteColor}");
HandleEnd();
isJudged = true;
return;
}
// If the key is still being held down when end segment enters judge zone, immediately judge.
// Autoplay must ignore physical input to guarantee Perfect at scheduledEndTime.
if (!GameConfig.autoPlayEnabled && Input.GetKey(keyToPress) && JudgeManager.Instance.IsStartJudged(noteID) && !isJudged)
{
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] End entered while key still held: {noteColor}, forcing Perfect");
// Record release time as current time (player is still holding)
releaseTime = Time.time;
hasReleased = true;
JudgeManager.Instance?.RegisterNoteReleased(noteID, true);
// Evaluate and judge the hold end - force Perfect since we reached the end while holding
EvaluateHoldEnd(releaseTime, false, true);
isJudged = true;
// Terminate input - mark key as released
isHoldActive = false;
StopHoldEffects();
return;
}
autoReturnCoroutine = StartCoroutine(DelayedAutoReturnCheck());
}
}
private void OnTriggerExit2D(Collider2D collision)
{
if (!collision.CompareTag("JudgmentLine")) return;
if (segment == NoteSegment.Start)
{
UnregisterQueueIfNeeded();
if (!JudgeManager.Instance.IsStartJudged(noteID))
{
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] Start left judge zone without being judged -> Miss: {noteColor}");
HandleStart(true);
isJudged = true;
ScheduleReturnToPool(0.2f);
}
}
else if (segment == NoteSegment.Middle)
{
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] Middle left judge zone: {noteColor}");
ScheduleReturnToPool(0.2f);
}
else if (segment == NoteSegment.End)
{
if (autoReturnCoroutine != null)
{
StopCoroutine(autoReturnCoroutine);
autoReturnCoroutine = null;
}
if (!isJudged)
{
if (!JudgeManager.Instance.IsStartJudged(noteID))
{
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] End left judge zone and start was not judged -> Miss: {noteColor}");
if (!JudgeManager.Instance.HasNoteReleased(noteID))
HandleEnd(true);
ScheduleReturnToPool(0.2f);
}
else
{
if (JudgeManager.Instance.HasNoteReleased(noteID))
{
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] End left judge zone and was released -> HandleEnd: {noteColor}");
HandleEnd();
isJudged = true;
ScheduleReturnToPool(0.2f);
}
}
}
else
{
ScheduleReturnToPool(0.2f);
}
}
}
private IEnumerator DelayedAutoReturnCheck()
{
// yield one frame to ensure any state changes are settled
yield return null;
if (segment == NoteSegment.End)
{
float timeToWait = Mathf.Max(0f, scheduledEndTime - Time.time);
if (timeToWait > 0f)
{
// use scaled time here so pause affects this wait
float target = Time.time + timeToWait;
while (Time.time < target)
{
yield return null;
}
}
// If the object was deactivated while waiting, bail out
if (!gameObject.activeInHierarchy)
{
yield break;
}
// wait a short buffer after arriving to judge line before returning to pool
ScheduleReturnToPool(0.2f);
}
}
// Schedules a return-to-pool after a short delay. Cancels any previously scheduled return.
private void ScheduleReturnToPool(float delay)
{
if (scheduledReturnCoroutine != null)
{
StopCoroutine(scheduledReturnCoroutine);
scheduledReturnCoroutine = null;
}
if (!gameObject.activeInHierarchy)
{
return;
}
scheduledReturnCoroutine = StartCoroutine(DelayedReturnToPool(delay));
}
private IEnumerator DelayedReturnToPool(float delay)
{
yield return new WaitForSeconds(delay);
if (gameObject.activeSelf)
{
ReturnToPool();
}
scheduledReturnCoroutine = null;
}
private void HandleStart(bool forceMiss = false)
{
// release from queue so following notes can become head
UnregisterQueueIfNeeded();
if (!JudgeManager.Instance.TryResolveStart(noteID))
{
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] START try-resolve failed: {noteColor}");
return;
}
// If forced miss (e.g. leaving judge zone without press), register miss immediately
if (forceMiss)
{
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] START forced Miss: {noteColor}");
// Use EvaluateHoldEnd to centralize miss logic and statistics
EvaluateHoldEnd(Time.time, true);
// Still need to show judge result and update combo for the head segment
InputManager.Instance?.ShowJudgeResult(trackIndex, "Miss");
JudgeSoundManager.Instance?.PlayJudgeSound("Miss");
// IMPORTANT: release lock immediately on miss
ReleaseTrackJudgeLockSafe();
// Return soon
ScheduleReturnToPool(0.2f);
return;
}
float pressTime = Time.time;
float rawOffsetMs = (hitTime - pressTime) * 1000f;
float offset = Mathf.Abs(pressTime - hitTime);
string result;
float visualScaleFactor = Mathf.Clamp(visualSpeedMultiplier, 0.5f, 2f);
float pRange = (judgeConfig?.perfectRange ?? 0.1f) * holdWindowMultiplier * visualScaleFactor;
float gRange = (judgeConfig?.greatRange ?? 0.2f) * holdWindowMultiplier * visualScaleFactor;
float gdRange = (judgeConfig?.goodRange ?? 0.3f) * holdWindowMultiplier * visualScaleFactor;
if (offset <= pRange)
{
result = "Perfect";
// Score statistics for Hold notes are now handled exclusively in EvaluateHoldEnd
// to ensure they only count as 1 note in the total sum.
if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs;
// RecordOffset removed from here to prevent double counting.
// It will be called once in EvaluateHoldEnd.
JudgeManager.Instance.RegisterStartJudged(noteID, true);
isHoldActive = true;
PlayHitAnimation();
StartHoldEffects();
// record when the hold started so we can compute held fraction later
holdStartTime = pressTime;
// store into pool for use on end
HoldNoteJudgePool.RegisterStart(noteID, pressTime, hitTime, scheduledEndTime, noteColor, trackIndex, (object)noteData);
}
else if (offset <= gRange)
{
result = "Great";
if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs;
// RecordOffset removed from here to prevent double counting
JudgeManager.Instance.RegisterStartJudged(noteID, true);
isHoldActive = true;
PlayHitAnimation();
StartHoldEffects();
holdStartTime = pressTime;
HoldNoteJudgePool.RegisterStart(noteID, pressTime, hitTime, scheduledEndTime, noteColor, trackIndex, (object)noteData);
}
else if (offset <= gdRange)
{
result = "Good";
if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs;
// RecordOffset removed from here to prevent double counting
JudgeManager.Instance.RegisterStartJudged(noteID, true);
isHoldActive = true;
PlayHitAnimation();
StartHoldEffects();
holdStartTime = pressTime;
HoldNoteJudgePool.RegisterStart(noteID, pressTime, hitTime, scheduledEndTime, noteColor, trackIndex, (object)noteData);
}
else
{
result = "Miss";
// For a Miss at the start, we immediately evaluate the end as a Miss too
// to ensure ScoreManager records exactly one Miss for this hold note.
JudgeManager.Instance.RegisterStartJudged(noteID, false);
isHoldActive = false;
EvaluateHoldEnd(Time.time, true);
try
{
bool triggered = SkillBuilder.Instance?.NotifyNoteHit(this.trackIndex, result, SkillDefinition.NoteTypeTrigger.Tap, this.noteID) ?? false;
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] START Miss NotifyNoteHit fired for slot {trackIndex}: {triggered}");
}
catch (System.Exception ex)
{
Debug.LogError($"[HoldNote] NotifyNoteHit(Start Miss) threw: {ex}");
}
// IMPORTANT: release lock immediately on miss
ReleaseTrackJudgeLockSafe();
}
// Lucky Chance: rewrite non-Miss judge to Perfect during active duration.
if (result != "Miss")
{
TryRewriteNonMissJudgeToPerfect(trackIndex, ref result);
}
// show judge result and update combo/UI like short notes
InputManager.Instance?.ShowJudgeResult(trackIndex, result);
// Only call OnJudgeResult on head if it's a hit, so it counts as 1 combo for the whole note.
// If it's a Miss, EvaluateHoldEnd(..., true) already handled the OnJudgeResult("Miss").
if (result != "Miss")
{
teamUIController.Instance?.OnJudgeResult(result);
}
// Play judge sound only for START segment
if (segment == NoteSegment.Start)
{
JudgeSoundManager.Instance?.PlayJudgeSound(result);
}
// Do not spawn the judgement animation prefab for Start (head) presses to avoid duplicated prefabs
if (segment != NoteSegment.Start)
{
Animation_GenerateJudgementSituationPrefab.Instance?.SpawnJudgePrefab(noteColor, result);
}
if (segment != NoteSegment.Start)
{
StartCoroutine(DelayedReturn());
}
// If start head was judged as Miss, ensure we return soon so it can't keep stale state
if (segment == NoteSegment.Start && result == "Miss")
{
ScheduleReturnToPool(0.2f);
}
}
private IEnumerator DelayedReturn()
{
yield return new WaitForSeconds(0.05f);
if (gameObject.activeSelf)
{
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] DelayedReturn: returning {noteID} to pool at time={Time.time:F3}");
ReturnToPool();
}
}
// central evaluation for hold end, given an actual releaseTime (real-time) or forceMiss
private void EvaluateHoldEnd(float actualReleaseTime, bool forceMiss, bool forcePerfect = false)
{
if (isJudged) return; // already evaluated
// ensure we only resolve once per note
if (!JudgeManager.Instance.TryResolveEnd(noteID))
{
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] EvaluateHoldEnd: TryResolveEnd failed for {noteID}");
return;
}
releaseTime = actualReleaseTime;
hasReleased = true;
JudgeManager.Instance.RegisterNoteReleased(noteID, true);
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] EvaluateHoldEnd: noteID={noteID} releaseTime={releaseTime:F3} scheduledEnd={scheduledEndTime:F3} forceMiss={forceMiss} forcePerfect={forcePerfect}");
string result;
if (forceMiss || !JudgeManager.Instance.IsStartJudged(noteID))
{
result = "Miss";
ScoreManager.Instance.countMiss += 1;
if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackMissCounts[trackIndex]++;
// Record a 0 offset for Miss to ensure total Offset Count matches note count
ScoreManager.Instance?.RecordOffset(0);
if (JudgeManager.IsDebugEnabled) Debug.LogWarning($"[HoldNote] END judged as Miss: {noteColor} reason={(forceMiss ? "forced Miss" : "start not judged")}");
// IMPORTANT: release lock on end miss too
ReleaseTrackJudgeLockSafe();
}
else
{
// try to use pool record for accurate press duration
if (HoldNoteJudgePool.TryGet(noteID, out var info))
{
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] Found pool info for {noteID}: pressTime={info.pressTime:F3} length={info.length:F3} scheduledEnd={info.scheduledEnd:F3}");
float playerHeld = Mathf.Clamp(releaseTime - info.pressTime, 0f, info.length);
float frac = info.length <= 0f ? 0f : (playerHeld / info.length);
if (forcePerfect || frac > 0.7f)
{
result = "Perfect";
ScoreManager.Instance.countPerfect += 1;
if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackPerfectCounts[trackIndex]++;
}
else if (frac > 0.4f)
{
result = "Great";
ScoreManager.Instance.countGreat += 1;
if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackGreatCounts[trackIndex]++;
}
else
{
result = "Good";
ScoreManager.Instance.countGood += 1;
if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackGoodCounts[trackIndex]++;
}
if (noteData != null && result != "Miss")
{
float offsetEnd = (info.scheduledEnd - releaseTime) * 1000f;
noteData.judgeOffsetMsEnd = offsetEnd;
// Record the head offset (stored during HandleStart) as the representative timing for this note
float headOffset = noteData.judgeOffsetMs;
if (float.IsNaN(headOffset)) headOffset = 0;
ScoreManager.Instance?.RecordOffset(headOffset);
}
// cleanup pool
HoldNoteJudgePool.Unregister(noteID);
}
else
{
if (JudgeManager.IsDebugEnabled) Debug.LogWarning($"[HoldNote] No pool info for {noteID}, falling back to local held fraction calculation");
// fallback logic if no pool info
float holdRequired = Mathf.Max(0.0001f, scheduledEndTime - hitTime);
float held = Mathf.Clamp(actualReleaseTime - hitTime, 0f, holdRequired);
float frac = holdRequired <= 0f ? 0f : (held / holdRequired);
if (forcePerfect || frac > 0.7f)
{
result = "Perfect";
ScoreManager.Instance.countPerfect += 1;
if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackPerfectCounts[trackIndex]++;
}
else if (frac > 0.4f)
{
result = "Great";
ScoreManager.Instance.countGreat += 1;
if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackGreatCounts[trackIndex]++;
}
else
{
result = "Good";
ScoreManager.Instance.countGood += 1;
if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackGoodCounts[trackIndex]++;
}
float rawOffsetMsEnd = (scheduledEndTime - actualReleaseTime) * 1000f;
if (noteData != null && result != "Miss")
{
noteData.judgeOffsetMsEnd = rawOffsetMsEnd;
// Fallback: use head offset if available, otherwise 0
float headOffset = noteData.judgeOffsetMs;
if (float.IsNaN(headOffset)) headOffset = 0;
ScoreManager.Instance?.RecordOffset(headOffset);
}
}
}
string originalEndJudge = result;
if (TryRewriteNonMissJudgeToPerfect(trackIndex, ref result))
{
AdjustCountsAfterRewriteToPerfect(trackIndex, originalEndJudge);
}
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] END result for {noteID}: {result} (color={noteColor})");
// show UI/audio
InputManager.Instance?.ShowJudgeResult(trackIndex, result);
// ONLY call OnJudgeResult on end if it's a Miss, to break the combo started at the head.
// If it's a hit, we don't call it again to avoid double-counting combo.
if (result == "Miss")
{
teamUIController.Instance?.OnJudgeResult(result);
}
// Ensure END always spawns judgement prefab (including Miss)
Animation_GenerateJudgementSituationPrefab.Instance?.SpawnJudgePrefab(noteColor, result);
// --- Add scoring for End like short notes ---
try
{
var ally = GetAllyForTrackCached(TrackIndex);
if (ally != null) ally.AddScoreForJudge(result);
int pmDelta = ComputePmDeltaFromJudge(result, ally);
float efficiency = (ally != null && !ally.IsDead) ? ally.scoreEfficiency : 0f;
ScoreManager.Instance?.AddPmScoreForTrack(TrackIndex, pmDelta, efficiency, false);
}
catch (System.Exception ex)
{
Debug.LogWarning($"[HoldNote] Failed to add per-track score for END: {ex}");
}
// Notify SkillBuilder so Hold notes can trigger skills configured for Hold
if (JudgeManager.Instance.TryTriggerSkill(noteID))
{
bool triggeredHold = false;
bool triggeredTap = false;
var candidates = new List<int>();
candidates.Add(this.trackIndex);
if (!candidates.Contains(this.TrackIndex)) candidates.Add(this.TrackIndex);
var ui = teamUIController.Instance;
if (ui != null)
{
for (int i = 0; i < ui.allySlotIds.Count; i++)
{
var slotObj = ui.GetAllyObjectBySlot(i);
if (slotObj == null) continue;
if (slotObj == this.gameObject || this.gameObject.transform.IsChildOf(slotObj.transform))
{
if (!candidates.Contains(i)) candidates.Add(i);
break;
}
}
}
foreach (var slot in candidates)
{
try
{
triggeredHold = SkillBuilder.Instance?.NotifyNoteHit(slot, result, SkillDefinition.NoteTypeTrigger.Hold, this.noteID) ?? false;
}
catch (System.Exception ex)
{
Debug.LogError($"[HoldNote] NotifyNoteHit(Hold) threw for slot {slot}: {ex}");
}
if (triggeredHold) break;
}
if (!triggeredHold)
{
foreach (var slot in candidates)
{
try
{
triggeredTap = SkillBuilder.Instance?.NotifyNoteHit(slot, result, SkillDefinition.NoteTypeTrigger.Tap, this.noteID) ?? false;
}
catch (System.Exception ex)
{
Debug.LogError($"[HoldNote] NotifyNoteHit(Tap) threw for slot {slot}: {ex}");
}
if (triggeredTap) break;
}
}
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] Hold end NotifyNoteHit called: TrackIndex={TrackIndex} trackIndexField={trackIndex} result={result} triggeredHold={triggeredHold} triggeredTap={triggeredTap}");
}
StopHoldEffects();
// Release the track lock immediately after end evaluation so the next hold can start on time
ReleaseTrackJudgeLockSafe();
isJudged = true;
JudgeManager.Instance?.SyncHoldAlpha(noteID, 0.5f);
// notify global judge manager that one final note (hold end) has been judged
JudgeManager.Instance?.NotifyNoteJudged();
ScheduleReturnToPool(0.2f);
}
private void ApplyJudgedMaterial()
{
if (JudgeManager.Instance == null || !JudgeManager.Instance.enableJudgedMaterial) return;
Material targetMat = JudgeManager.Instance.globalJudgedMaterial;
if (targetMat == null) return;
foreach (var cache in materialCaches)
{
if (cache.spriteRenderer != null)
{
cache.spriteRenderer.material = targetMat;
}
else if (cache.uiGraphic != null)
{
cache.uiGraphic.material = targetMat;
}
else if (cache.meshRenderer != null)
{
cache.meshRenderer.material = targetMat;
}
}
}
private void RestoreOriginalMaterials()
{
foreach (var cache in materialCaches)
{
if (cache.spriteRenderer != null)
{
cache.spriteRenderer.material = cache.originalMaterial;
}
else if (cache.uiGraphic != null)
{
cache.uiGraphic.material = cache.originalMaterial;
}
else if (cache.meshRenderer != null)
{
cache.meshRenderer.material = cache.originalMaterial;
}
}
}
private void HandleAlphaSync(string syncNoteID, float alpha)
{
if (this.noteID == syncNoteID)
{
// Instead of alpha, apply the judged material
ApplyJudgedMaterial();
}
}
private void HandleEnd(bool forceMiss = false)
{
// record release time and register release in JudgeManager
if (!hasReleased)
{
if (Input.GetKey(keyToPress))
{
releaseTime = scheduledEndTime;
}
else
{
releaseTime = Time.time;
}
hasReleased = true;
JudgeManager.Instance?.RegisterNoteReleased(noteID, true);
}
// central evaluation
EvaluateHoldEnd(releaseTime, forceMiss);
}
private void ReturnToPool()
{
if (!gameObject.activeSelf) return;
UnregisterQueueIfNeeded();
if (segment == NoteSegment.Start && !hasEnteredLine)
{
if (JudgeManager.IsDebugEnabled) Debug.LogWarning($"[HoldNote] Refusing to return a Start segment that never entered judge zone: {noteColor}");
return;
}
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] ReturnToPool called for {noteID} segment={segment} time={Time.time:F3}");
// CRITICAL: Always release lock before returning to pool, regardless of state
ReleaseTrackJudgeLockSafe();
gameObject.SetActive(false);
if (segment == NoteSegment.Start)
NotePool.Instance.ReturnStartNote(gameObject, noteColor);
else if (segment == NoteSegment.End)
NotePool.Instance.ReturnHoldNoteEndSegment(gameObject, noteColor);
else
NotePool.Instance.ReturnHoldNoteSegment(gameObject, noteColor);
}
public void ResetState()
{
// also release any stale lock from previous lifecycle
ReleaseTrackJudgeLockSafe();
UnregisterQueueIfNeeded();
// Restore materials before returning to pool/reusing
RestoreOriginalMaterials();
// Stop any active hold animations or delayed starts
StopHoldEffects();
hasReleased = false;
segment = NoteSegment.None;
keyToPress = KeyCode.None;
noteColor = string.Empty;
noteID = string.Empty;
hitTime = 0f;
scheduledEndTime = 0f;
hasEnteredLine = false;
isJudged = false;
isHoldActive = false;
hasBeenHeldFromStart = false;
holdStartTime = -1f;
if (autoReturnCoroutine != null)
{
StopCoroutine(autoReturnCoroutine);
autoReturnCoroutine = null;
}
controller?.StopMovement();
// restore visual scale
ResetVisualScale();
}
private void RegisterQueueIfNeeded()
{
if (isQueued) return;
if (TrackKeyManager.Instance == null || string.IsNullOrEmpty(queueNoteId)) return;
TrackKeyManager.Instance.RegisterKey(trackIndex, queueNoteId, "hold", hitTime);
isQueued = true;
}
private void UnregisterQueueIfNeeded()
{
if (!isQueued) return;
if (TrackKeyManager.Instance != null && !string.IsNullOrEmpty(queueNoteId))
{
TrackKeyManager.Instance.UnregisterKey(trackIndex, queueNoteId);
}
isQueued = false;
}
private void ReleaseTrackJudgeLockSafe()
{
// Defensive: any miss / pooling path should release the track lock.
if (TrackKeyManager.Instance != null && !string.IsNullOrEmpty(noteID))
{
TrackKeyManager.Instance.UnlockTrackForJudge(trackIndex, noteID);
}
}
public void ApplyVisualScale(float scaleY)
{
float s = Mathf.Clamp(scaleY, 0.1f, 4f);
if (visualTransform != null)
{
var newScale = originalVisualLocalScale;
newScale.y = originalVisualLocalScale.y * s;
visualTransform.localScale = newScale;
}
else
{
var newScale = originalLocalScale;
newScale.y = originalLocalScale.y * s;
transform.localScale = newScale;
}
}
public void ResetVisualScale()
{
if (visualTransform != null)
visualTransform.localScale = originalVisualLocalScale;
else
transform.localScale = originalLocalScale;
}
public void CalibratePosition(Vector3 spawnPointPosition, float tolerance = 0.02f)
{
if (controller == null) controller = GetComponent<HoldNoteController>();
if (controller == null) return;
float activation = controller.ActivationTime;
float s = controller.CurrentSpeed;
float elapsed = Time.time - activation;
Vector3 expected = spawnPointPosition;
if (controller.UsesAbsolutePositioning)
{
expected.y -= (controller.BaseSpawnYOffset + s * elapsed);
}
else
{
expected += Vector3.down * s * Mathf.Max(0f, elapsed);
}
if (Vector3.Distance(transform.position, expected) > tolerance)
{
transform.position = expected;
}
}
}