Files
bansonic_beta_main/Assets/scripts/gamePlay_gameplay/HoldNote.cs
T

1489 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;
// Previous-frame held state for this track, used to derive key-down / key-up edges
// from the platform-agnostic InputManager.IsTrackHeld table instead of polling
// Input.GetKeyDown/Up directly (which touch cannot drive on Android).
private bool prevTrackHeld = false;
// Query the shared held-state table (keyboard OR touch). Falls back to legacy
// Input.GetKey if the InputManager is somehow absent so editor/standalone still works.
private bool IsHeld()
{
var im = InputManager.Instance;
if (im != null) return im.IsTrackHeld(trackIndex);
return Input.GetKey(keyToPress);
}
[Header("Judge configuration")]
public NoteJudgeConfig judgeConfig; // judge windows configuration
private NoteData noteData;
// store original transform scale so we can restore on reset
private Vector3 originalLocalScale = Vector3.one;
// visual transform (child) to scale instead of root to avoid affecting colliders
private Transform visualTransform = null;
private Vector3 originalVisualLocalScale = Vector3.one;
private List<SpriteRenderer> cachedSpriteRenderers = new List<SpriteRenderer>();
private List<UnityEngine.UI.Graphic> cachedUIComponents = new List<UnityEngine.UI.Graphic>();
[Header("Hold judgement adjustments")]
[Tooltip("Multiplier applied to judgement windows for hold notes. Lower values make hold judgement stricter.")]
[Range(0.1f, 2f)]
public float holdWindowMultiplier = 0.8f;
public float visualSpeedMultiplier = 1f; // injected from NoteSpawner to adapt windows when visual speed changes
// track when the hold actually started (real-time) so we can compute held fraction
private float holdStartTime = -1f;
// Cache allies per track to avoid GameObject.Find on first judgement.
private static AllyCombatant[] allyCache;
private static BeatmapManager beatmapManagerCache;
private struct RendererMaterialCache
{
public SpriteRenderer spriteRenderer;
public UnityEngine.UI.Graphic uiGraphic;
public MeshRenderer meshRenderer;
public Material originalMaterial;
}
private List<RendererMaterialCache> materialCaches = new List<RendererMaterialCache>();
private static AllyCombatant GetAllyForTrackCached(int trackIndex)
{
if (trackIndex < 0) return null;
if (allyCache == null || allyCache.Length < 10) allyCache = new AllyCombatant[10];
if (allyCache[trackIndex] != null) return allyCache[trackIndex];
var allyGo = SceneObjectLookupCache.Find($"ally_0{trackIndex + 1}");
if (allyGo != null)
{
allyCache[trackIndex] = allyGo.GetComponent<AllyCombatant>();
}
return allyCache[trackIndex];
}
private static BeatmapManager GetBeatmapManagerCached()
{
if (beatmapManagerCache != null) return beatmapManagerCache;
beatmapManagerCache = SceneObjectLookupCache.FindAny<BeatmapManager>();
return beatmapManagerCache;
}
public static void PrewarmRuntimeCaches()
{
GetBeatmapManagerCached();
if (allyCache == null || allyCache.Length < 10)
allyCache = new AllyCombatant[10];
for (int i = 0; i < 5; i++)
{
GetAllyForTrackCached(i);
}
}
private static float JudgeToPmMultiplier(string judgeResult)
{
switch (judgeResult)
{
case "Perfect": return 1f;
case "Great": return 0.75f;
case "Good": return 0.5f;
case "Miss": return 0f;
default: return 0f;
}
}
private static int ComputePmDeltaFromJudge(string judgeResult, AllyCombatant ally)
{
int baseScore = 0;
var bmm = ally != null ? ally.bmm : null;
if (bmm == null) bmm = BeatmapManager.Instance;
if (bmm == null) bmm = GetBeatmapManagerCached();
if (bmm != null) baseScore = Mathf.Max(0, bmm.perNoteScore);
if (baseScore <= 0 && ally != null) baseScore = Mathf.Max(0, ally.baseTrackScore);
if (baseScore <= 0) return 0;
float mult = JudgeToPmMultiplier(judgeResult);
return Mathf.FloorToInt(baseScore * mult);
}
private static bool TryRewriteNonMissJudgeToPerfect(int trackIndex, ref string judgeResult)
{
if (judgeResult != "Great" && judgeResult != "Good") return false;
var ally = GetAllyForTrackCached(trackIndex);
if (ally == null || ally.IsDead) return false;
if (!ally.IsNonMissToPerfectRewriteActive()) return false;
judgeResult = "Perfect";
return true;
}
private static void AdjustCountsAfterRewriteToPerfect(int trackIndex, string originalJudge)
{
var sm = ScoreManager.Instance;
if (sm == null) return;
if (originalJudge == "Great")
{
sm.countGreat = Mathf.Max(0, sm.countGreat - 1);
if (trackIndex >= 0 && trackIndex < sm.trackGreatCounts.Length)
sm.trackGreatCounts[trackIndex] = Mathf.Max(0, sm.trackGreatCounts[trackIndex] - 1);
}
else if (originalJudge == "Good")
{
sm.countGood = Mathf.Max(0, sm.countGood - 1);
if (trackIndex >= 0 && trackIndex < sm.trackGoodCounts.Length)
sm.trackGoodCounts[trackIndex] = Mathf.Max(0, sm.trackGoodCounts[trackIndex] - 1);
}
else
{
return;
}
sm.countPerfect += 1;
if (trackIndex >= 0 && trackIndex < sm.trackPerfectCounts.Length)
sm.trackPerfectCounts[trackIndex] += 1;
}
private JudgeManager cachedJudgeManager;
private TrackKeyManager cachedTrackKeyManager;
private void Awake()
{
controller = GetComponent<HoldNoteController>();
// Prefer the global shared AnimationController if available, otherwise fallback to local or scene instance
anim = AnimationController.Global;
if (anim == null)
{
anim = GetComponent<AnimationController>();
if (anim == null)
{
anim = SceneObjectLookupCache.FindAny<AnimationController>();
}
}
if (anim == null)
{
if (JudgeManager.IsDebugEnabled) Debug.LogWarning("HoldNote: No AnimationController found (Global/local/scene). Particle effects will be unavailable.");
}
if (controller != null)
{
controller.OnJudgeZoneChanged += OnJudgeZoneChanged;
}
cachedJudgeManager = JudgeManager.Instance;
cachedTrackKeyManager = TrackKeyManager.Instance;
if (cachedJudgeManager != null)
{
cachedJudgeManager.OnHoldAlphaSync += HandleAlphaSync;
}
// capture original local scale for this prefab instance
originalLocalScale = transform.localScale;
// find a dedicated visual child to scale (prefer child named "Visual")
visualTransform = transform.Find("Visual");
if (visualTransform == null)
{
// fallback: try to find first child that has a SpriteRenderer, MeshRenderer or CanvasRenderer
var sr = GetComponentInChildren<SpriteRenderer>(true);
if (sr != null) visualTransform = sr.transform;
else
{
var mr = GetComponentInChildren<MeshRenderer>(true);
if (mr != null) visualTransform = mr.transform;
else
{
var ir = GetComponentInChildren<UnityEngine.UI.Graphic>(true);
if (ir != null) visualTransform = ir.transform;
}
}
}
if (visualTransform != null)
originalVisualLocalScale = visualTransform.localScale;
else
originalVisualLocalScale = originalLocalScale;
// Cache all renderers for alpha control
cachedSpriteRenderers.AddRange(GetComponentsInChildren<SpriteRenderer>(true));
cachedUIComponents.AddRange(GetComponentsInChildren<UnityEngine.UI.Graphic>(true));
// Initialize material caches
materialCaches.Clear();
foreach (var sr in cachedSpriteRenderers)
{
if (sr != null && sr.gameObject.activeInHierarchy && sr.enabled && sr.color.a > 0.1f)
{
// Only affect renderers under visualTransform to avoid affecting logic/debug objects
if (visualTransform != null && !sr.transform.IsChildOf(visualTransform))
continue;
// Skip internal parts that should stay hidden or are connectors
string n = sr.gameObject.name.ToLower();
if (n.Contains("body") || n.Contains("connect") || n.Contains("line") || n.Contains("mask") || n.Contains("debug") ||
n.Contains("bg") || n.Contains("back") || n.Contains("overlay") || n.Contains("shadow") || n.Contains("lane") ||
n.Contains("glow") || n.Contains("effect") || n.Contains("area") || n.Contains("fill") || n.Contains("slider") || n.Contains("long"))
continue;
materialCaches.Add(new RendererMaterialCache { spriteRenderer = sr, originalMaterial = sr.sharedMaterial });
}
}
foreach (var ui in cachedUIComponents)
{
if (ui != null && ui.gameObject.activeInHierarchy && ui.enabled && ui.color.a > 0.1f)
{
if (visualTransform != null && !ui.transform.IsChildOf(visualTransform))
continue;
string n = ui.gameObject.name.ToLower();
if (n.Contains("body") || n.Contains("connect") || n.Contains("line") || n.Contains("mask") || n.Contains("debug") ||
n.Contains("bg") || n.Contains("back") || n.Contains("overlay") || n.Contains("shadow") || n.Contains("lane") ||
n.Contains("glow") || n.Contains("effect") || n.Contains("area") || n.Contains("fill") || n.Contains("slider") || n.Contains("long"))
continue;
materialCaches.Add(new RendererMaterialCache { uiGraphic = ui, originalMaterial = ui.material });
}
}
var meshRenderers = GetComponentsInChildren<MeshRenderer>(true);
foreach (var mr in meshRenderers)
{
if (mr != null && mr.gameObject.activeInHierarchy && mr.enabled)
{
if (visualTransform != null && !mr.transform.IsChildOf(visualTransform))
continue;
string n = mr.gameObject.name.ToLower();
if (n.Contains("body") || n.Contains("connect") || n.Contains("line") || n.Contains("mask") || n.Contains("debug") ||
n.Contains("bg") || n.Contains("back") || n.Contains("overlay") || n.Contains("shadow") || n.Contains("lane") ||
n.Contains("glow") || n.Contains("effect") || n.Contains("area") || n.Contains("fill") || n.Contains("slider") || n.Contains("long"))
continue;
materialCaches.Add(new RendererMaterialCache { meshRenderer = mr, originalMaterial = mr.sharedMaterial });
}
}
}
private void OnDisable()
{
prevTrackHeld = false;
}
private void OnDestroy()
{
if (controller != null)
{
controller.OnJudgeZoneChanged -= OnJudgeZoneChanged;
}
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 IEnumerator DelayedDisableAnimation(GameObject animationObject, float delay)
{
// No-op: we used to disable the shared AnimationController here which stopped coroutines.
// Keep as no-op to avoid side effects.
yield return null;
}
private float cachedTravelTime = 1.0f; // Cached travel time for distance optimization
private bool isSyncNote;
public void Setup(int id, int trackIndex, float speed, float time, float delay,
bool isEnd, float scheduledEnd, string color, KeyCode key, string type, float travelTimeSeconds, NoteJudgeConfig judgeConfig, NoteData noteData, bool isSync = false)
{
this.id = id;
// Ensure singletons are up to date if they were re-initialized (e.g. on scene reload)
if (cachedJudgeManager == null) cachedJudgeManager = JudgeManager.Instance;
if (cachedTrackKeyManager == null) cachedTrackKeyManager = TrackKeyManager.Instance;
this.trackIndex = trackIndex;
// also set BaseNote.TrackIndex so other systems using TrackIndex property work consistently
this.TrackIndex = trackIndex;
this.speed = speed;
this.startTime = time;
this.delay = delay;
this.isEnd = isEnd;
this.scheduledEndTime = scheduledEnd;
this.color = color;
this.key = key;
this.type = type;
this.judgeConfig = judgeConfig;
this.isSyncNote = isSync;
this.noteID = id.ToString();
this.keyToPress = key;
this.noteColor = color;
// hitTime now uses real time base passed in as 'time' plus delay
this.hitTime = time + delay;
this.segment = (delay == 0f) ? NoteSegment.Start :
(isEnd ? NoteSegment.End : NoteSegment.Middle);
this.hasEnteredLine = false;
this.hasReleased = false;
if (judgeConfig == null)
{
if (JudgeManager.IsDebugEnabled) Debug.LogError($"HoldNoteJudgeConfig is null! judgeConfig not assigned. track={trackIndex}");
}
else
{
if (JudgeManager.IsDebugEnabled) Debug.Log($"HoldNoteJudgeConfig: perfect={judgeConfig.perfectRange}, great={judgeConfig.greatRange}, good={judgeConfig.goodRange}, miss={judgeConfig.missRange}");
}
this.noteData = noteData;
// cache queue id for TrackKeyManager (per pooled instance)
queueNoteId = gameObject.GetInstanceID().ToString();
isQueued = false;
// Debug info to help investigate end note visibility
if (JudgeManager.IsDebugEnabled)
{
Debug.Log($"[HoldNote.Setup] id={noteID} segment={segment} type={type} hitTime={hitTime:F2} scheduledEnd={scheduledEndTime:F2} color={color} track={trackIndex}");
Debug.Log($"[HoldNote.Setup] Controller present={(controller != null)}, AnimationController global={(AnimationController.Global != null)}, localAnim={(anim != null)}");
}
// Register initial judge state in JudgeManager
JudgeManager.Instance?.RegisterNoteReleased(noteID, false);
if (segment == NoteSegment.Start)
JudgeManager.Instance?.RegisterStartJudged(noteID, false); // initially mark start as not judged
if (segment == NoteSegment.End)
JudgeManager.Instance?.RegisterScheduledEndTime(noteID, scheduledEndTime);
controller?.SetSpeed(speed);
float travelTime = Mathf.Max(0f, travelTimeSeconds);
this.cachedTravelTime = travelTime;
if (controller != null)
{
// Keep all hold segments moving in absolute time so they stay visually connected.
float headActivationTime = time - travelTime;
float baseSpawnYOffset = -speed * delay;
controller.ConfigureAbsolutePositioning(transform.position, headActivationTime, baseSpawnYOffset);
}
else
{
// fallback
controller?.SetSegmentDelay(delay);
}
// Removed: CalibratePosition for delay==0f was causing Start segments to snap to incorrect positions
// if they started moving immediately after Setup (due to activationTime <= gameplay clock).
// Start segments should begin at spawnPoint and move from there naturally.
}
private void Update()
{
if (isJudged) return;
var jm = cachedJudgeManager;
var tkm = cachedTrackKeyManager;
bool debugEnabled = JudgeManager.IsDebugEnabled;
float now = GameplayClock.NowSongTime;
// Sample held state and derive key-down/up edges at the very top, BEFORE any
// early return. Unity's Input.GetKeyDown/Up are global per-frame edges that
// don't depend on whether we polled last frame; updating prevTrackHeld every
// frame reproduces that. If we only sampled after the early exits below, a key
// already held when the note enters range would produce a false keyDown.
bool keyHeld = IsHeld();
bool keyDown = keyHeld && !prevTrackHeld;
bool keyUp = !keyHeld && prevTrackHeld;
prevTrackHeld = keyHeld;
// Optimization: Early exit if the note is far from judgment line and hasn't entered yet
if (!hasEnteredLine)
{
// For all segments, if they haven't triggered collision yet and are far away, skip input checks
// We use travelTime as a safe buffer. Only check input if note is within 0.5s of hitTime.
if (now < hitTime - 0.5f) return;
}
if (GameConfig.autoPlayEnabled)
{
UpdateAutoplay(now, jm, tkm, debugEnabled);
return;
}
// If start was judged and player is holding key, start hold effects
if (!isHoldActive && jm != null && jm.IsStartJudged(noteID) && !jm.HasNoteReleased(noteID) && keyHeld)
{
isHoldActive = true;
AnimationController.Global?.StartHoldParticles(noteColor);
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;
AnimationController.Global?.StopHoldParticles();
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();
AnimationController.Global?.StartHoldParticles(noteColor);
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);
}
private void OnTriggerEnter2D(Collider2D collision)
{
if (!collision.CompareTag("JudgmentLine")) return;
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;
}
// 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 && IsHeld() && 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 = GameplayClock.NowSongTime;
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;
AnimationController.Global?.StopHoldParticles();
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 - GameplayClock.NowSongTime);
if (timeToWait > 0f)
{
float target = GameplayClock.NowSongTime + timeToWait;
while (GameplayClock.NowSongTime < target)
{
yield return null;
}
}
// If the object was deactivated while waiting, bail out
if (!gameObject.activeInHierarchy)
{
yield break;
}
// wait a short buffer after arriving to judge line before returning to pool
ScheduleReturnToPool(0.2f);
}
}
// Schedules a return-to-pool after a short delay. Cancels any previously scheduled return.
private void ScheduleReturnToPool(float delay)
{
if (scheduledReturnCoroutine != null)
{
StopCoroutine(scheduledReturnCoroutine);
scheduledReturnCoroutine = null;
}
if (!gameObject.activeInHierarchy)
{
return;
}
scheduledReturnCoroutine = StartCoroutine(DelayedReturnToPool(delay));
}
private IEnumerator DelayedReturnToPool(float delay)
{
yield return GameplayClock.WaitForSeconds(delay);
if (gameObject.activeSelf)
{
ReturnToPool();
}
scheduledReturnCoroutine = null;
}
private void HandleStart(bool forceMiss = false)
{
// release from queue so following notes can become head
UnregisterQueueIfNeeded();
if (!JudgeManager.Instance.TryResolveStart(noteID))
{
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] START try-resolve failed: {noteColor}");
return;
}
// If forced miss (e.g. leaving judge zone without press), register miss immediately
if (forceMiss)
{
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] START forced Miss: {noteColor}");
// Use EvaluateHoldEnd to centralize miss logic and statistics
EvaluateHoldEnd(GameplayClock.NowSongTime, true);
// Still need to show judge result and update combo for the head segment
InputManager.Instance?.ShowJudgeResult(trackIndex, "Miss");
JudgeSoundManager.Instance?.PlayJudgeSound("Miss");
// IMPORTANT: release lock immediately on miss
ReleaseTrackJudgeLockSafe();
// Return soon
ScheduleReturnToPool(0.2f);
return;
}
float pressTime = GameplayClock.NowSongTime;
float rawOffsetMs = (hitTime - pressTime) * 1000f;
float offset = Mathf.Abs(pressTime - hitTime);
string result;
float visualScaleFactor = Mathf.Clamp(visualSpeedMultiplier, 0.5f, 2f);
float pRange = (judgeConfig?.perfectRange ?? 0.1f) * holdWindowMultiplier * visualScaleFactor;
float gRange = (judgeConfig?.greatRange ?? 0.2f) * holdWindowMultiplier * visualScaleFactor;
float gdRange = (judgeConfig?.goodRange ?? 0.3f) * holdWindowMultiplier * visualScaleFactor;
if (offset <= pRange)
{
result = "Perfect";
// Score statistics for Hold notes are now handled exclusively in EvaluateHoldEnd
// to ensure they only count as 1 note in the total sum.
if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs;
// RecordOffset removed from here to prevent double counting.
// It will be called once in EvaluateHoldEnd.
JudgeManager.Instance.RegisterStartJudged(noteID, true);
isHoldActive = true;
PlayHitAnimation();
AnimationController.Global?.StartHoldParticles(noteColor);
// 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();
AnimationController.Global?.StartHoldParticles(noteColor);
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();
AnimationController.Global?.StartHoldParticles(noteColor);
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);
}
// 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);
}
// 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);
}
if (segment == NoteSegment.Start && isSyncNote && result != "Miss")
{
effectEventController.TryTriggerMultiNoteShake();
}
if (segment == NoteSegment.Start && result != "Miss")
{
globalNoteEffect.TryTrigger(noteData != null ? noteData.noteFunction : null);
}
}
private IEnumerator DelayedReturn()
{
yield return GameplayClock.WaitForSeconds(0.05f);
if (gameObject.activeSelf)
{
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] DelayedReturn: returning {noteID} to pool at time={GameplayClock.NowSongTime:F3}");
ReturnToPool();
}
}
// central evaluation for hold end, given an actual releaseTime (real-time) or forceMiss
private void EvaluateHoldEnd(float actualReleaseTime, bool forceMiss, bool forcePerfect = false)
{
if (isJudged) return; // already evaluated
// ensure we only resolve once per note
if (!JudgeManager.Instance.TryResolveEnd(noteID))
{
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] EvaluateHoldEnd: TryResolveEnd failed for {noteID}");
return;
}
releaseTime = actualReleaseTime;
hasReleased = true;
JudgeManager.Instance.RegisterNoteReleased(noteID, true);
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] EvaluateHoldEnd: noteID={noteID} releaseTime={releaseTime:F3} scheduledEnd={scheduledEndTime:F3} forceMiss={forceMiss} forcePerfect={forcePerfect}");
string result;
if (forceMiss || !JudgeManager.Instance.IsStartJudged(noteID))
{
result = "Miss";
ScoreManager.Instance.countMiss += 1;
if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackMissCounts[trackIndex]++;
// Record a 0 offset for Miss to ensure total Offset Count matches note count
ScoreManager.Instance?.RecordOffset(0);
if (JudgeManager.IsDebugEnabled) Debug.LogWarning($"[HoldNote] END judged as Miss: {noteColor} reason={(forceMiss ? "forced Miss" : "start not judged")}");
// IMPORTANT: release lock on end miss too
ReleaseTrackJudgeLockSafe();
}
else
{
// try to use pool record for accurate press duration
if (HoldNoteJudgePool.TryGet(noteID, out var info))
{
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] Found pool info for {noteID}: pressTime={info.pressTime:F3} length={info.length:F3} scheduledEnd={info.scheduledEnd:F3}");
float playerHeld = Mathf.Clamp(releaseTime - info.pressTime, 0f, info.length);
float frac = info.length <= 0f ? 0f : (playerHeld / info.length);
if (forcePerfect || frac > 0.7f)
{
result = "Perfect";
ScoreManager.Instance.countPerfect += 1;
if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackPerfectCounts[trackIndex]++;
}
else if (frac > 0.4f)
{
result = "Great";
ScoreManager.Instance.countGreat += 1;
if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackGreatCounts[trackIndex]++;
}
else
{
result = "Good";
ScoreManager.Instance.countGood += 1;
if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackGoodCounts[trackIndex]++;
}
if (noteData != null && result != "Miss")
{
float offsetEnd = (info.scheduledEnd - releaseTime) * 1000f;
noteData.judgeOffsetMsEnd = offsetEnd;
// Record the head offset (stored during HandleStart) as the representative timing for this note
float headOffset = noteData.judgeOffsetMs;
if (float.IsNaN(headOffset)) headOffset = 0;
ScoreManager.Instance?.RecordOffset(headOffset);
}
// cleanup pool
HoldNoteJudgePool.Unregister(noteID);
}
else
{
if (JudgeManager.IsDebugEnabled) Debug.LogWarning($"[HoldNote] No pool info for {noteID}, falling back to local held fraction calculation");
// fallback logic if no pool info
float holdRequired = Mathf.Max(0.0001f, scheduledEndTime - hitTime);
float held = Mathf.Clamp(actualReleaseTime - hitTime, 0f, holdRequired);
float frac = holdRequired <= 0f ? 0f : (held / holdRequired);
if (forcePerfect || frac > 0.7f)
{
result = "Perfect";
ScoreManager.Instance.countPerfect += 1;
if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackPerfectCounts[trackIndex]++;
}
else if (frac > 0.4f)
{
result = "Great";
ScoreManager.Instance.countGreat += 1;
if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackGreatCounts[trackIndex]++;
}
else
{
result = "Good";
ScoreManager.Instance.countGood += 1;
if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackGoodCounts[trackIndex]++;
}
float rawOffsetMsEnd = (scheduledEndTime - actualReleaseTime) * 1000f;
if (noteData != null && result != "Miss")
{
noteData.judgeOffsetMsEnd = rawOffsetMsEnd;
// Fallback: use head offset if available, otherwise 0
float headOffset = noteData.judgeOffsetMs;
if (float.IsNaN(headOffset)) headOffset = 0;
ScoreManager.Instance?.RecordOffset(headOffset);
}
}
}
string originalEndJudge = result;
if (TryRewriteNonMissJudgeToPerfect(trackIndex, ref result))
{
AdjustCountsAfterRewriteToPerfect(trackIndex, originalEndJudge);
}
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] END result for {noteID}: {result} (color={noteColor})");
// show UI/audio
InputManager.Instance?.ShowJudgeResult(trackIndex, result);
// ONLY call OnJudgeResult on end if it's a Miss, to break the combo started at the head.
// If it's a hit, we don't call it again to avoid double-counting combo.
if (result == "Miss")
{
teamUIController.Instance?.OnJudgeResult(result);
}
if (result != "Miss")
{
TrackJudgeHitEffectController.PlayTrackHitFx(trackIndex);
}
// Ensure END always spawns judgement prefab (including Miss)
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}");
}
AnimationController.Global?.StopHoldParticles();
// Release the track lock immediately after end evaluation so the next hold can start on time
ReleaseTrackJudgeLockSafe();
isJudged = true;
JudgeManager.Instance?.SyncHoldAlpha(noteID, 0.5f);
// notify global judge manager that one final note (hold end) has been judged
JudgeManager.Instance?.NotifyNoteJudged();
ScheduleReturnToPool(0.2f);
}
private void ApplyJudgedMaterial()
{
if (JudgeManager.Instance == null || !JudgeManager.Instance.enableJudgedMaterial) return;
Material targetMat = JudgeManager.Instance.globalJudgedMaterial;
if (targetMat == null) return;
foreach (var cache in materialCaches)
{
if (cache.spriteRenderer != null)
{
cache.spriteRenderer.material = targetMat;
}
else if (cache.uiGraphic != null)
{
cache.uiGraphic.material = targetMat;
}
else if (cache.meshRenderer != null)
{
cache.meshRenderer.material = targetMat;
}
}
}
private void RestoreOriginalMaterials()
{
foreach (var cache in materialCaches)
{
if (cache.spriteRenderer != null)
{
cache.spriteRenderer.material = cache.originalMaterial;
}
else if (cache.uiGraphic != null)
{
cache.uiGraphic.material = cache.originalMaterial;
}
else if (cache.meshRenderer != null)
{
cache.meshRenderer.material = cache.originalMaterial;
}
}
}
private void HandleAlphaSync(string syncNoteID, float alpha)
{
if (this.noteID == syncNoteID)
{
// Instead of alpha, apply the judged material
ApplyJudgedMaterial();
}
}
private void HandleEnd(bool forceMiss = false)
{
// record release time and register release in JudgeManager
if (!hasReleased)
{
if (IsHeld())
{
releaseTime = scheduledEndTime;
}
else
{
releaseTime = GameplayClock.NowSongTime;
}
hasReleased = true;
JudgeManager.Instance?.RegisterNoteReleased(noteID, true);
}
// central evaluation
EvaluateHoldEnd(releaseTime, forceMiss);
}
private void ReturnToPool()
{
if (!gameObject.activeSelf) return;
UnregisterQueueIfNeeded();
if (segment == NoteSegment.Start && !hasEnteredLine)
{
if (JudgeManager.IsDebugEnabled) Debug.LogWarning($"[HoldNote] Refusing to return a Start segment that never entered judge zone: {noteColor}");
return;
}
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] ReturnToPool called for {noteID} segment={segment} time={GameplayClock.NowSongTime:F3}");
// CRITICAL: Always release lock before returning to pool, regardless of state
ReleaseTrackJudgeLockSafe();
gameObject.SetActive(false);
if (segment == NoteSegment.Start)
NotePool.Instance.ReturnStartNote(gameObject, noteColor);
else if (segment == NoteSegment.End)
NotePool.Instance.ReturnHoldNoteEndSegment(gameObject, noteColor);
else
NotePool.Instance.ReturnHoldNoteSegment(gameObject, noteColor);
}
public void ResetState()
{
// also release any stale lock from previous lifecycle
ReleaseTrackJudgeLockSafe();
UnregisterQueueIfNeeded();
// Restore materials before returning to pool/reusing
RestoreOriginalMaterials();
hasReleased = false;
segment = NoteSegment.None;
keyToPress = KeyCode.None;
noteColor = string.Empty;
noteID = string.Empty;
hitTime = 0f;
scheduledEndTime = 0f;
hasEnteredLine = false;
isJudged = false;
isHoldActive = false;
hasBeenHeldFromStart = false;
prevTrackHeld = 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 = 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;
}
}
}