927 lines
35 KiB
C#
927 lines
35 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 hitTime = 0f;
|
|
private float releaseTime = 0f;
|
|
private bool hasReleased = true;
|
|
private bool hasEnteredLine = false;
|
|
|
|
private Coroutine autoReturnCoroutine;
|
|
private Coroutine scheduledReturnCoroutine;
|
|
private HoldNoteController controller;
|
|
private AnimationController anim;
|
|
|
|
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;
|
|
|
|
[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;
|
|
|
|
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 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 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 = FindObjectOfType<AnimationController>();
|
|
}
|
|
}
|
|
|
|
if (anim == null)
|
|
{
|
|
if (GameConfig.verboseLogs) Debug.LogWarning("HoldNote: No AnimationController found (Global/local/scene). Particle effects will be unavailable.");
|
|
}
|
|
|
|
if (controller != null)
|
|
{
|
|
controller.OnJudgeZoneChanged += OnJudgeZoneChanged;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
private void OnDestroy()
|
|
{
|
|
if (controller != null)
|
|
{
|
|
controller.OnJudgeZoneChanged -= OnJudgeZoneChanged;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
public void Setup(int id, int trackIndex, float speed, float time, float delay,
|
|
bool isEnd, float scheduledEnd, string color, KeyCode key, string type, NoteJudgeConfig judgeConfig, NoteData noteData)
|
|
{
|
|
this.id = id;
|
|
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 (GameConfig.verboseLogs) Debug.LogError($"HoldNoteJudgeConfig is null! judgeConfig not assigned. track={trackIndex}");
|
|
}
|
|
else
|
|
{
|
|
if (GameConfig.verboseLogs) Debug.Log($"HoldNoteJudgeConfig: perfect={judgeConfig.perfectRange}, great={judgeConfig.greatRange}, good={judgeConfig.goodRange}, miss={judgeConfig.missRange}");
|
|
}
|
|
|
|
this.noteData = noteData;
|
|
|
|
// Debug info to help investigate end note visibility
|
|
if (GameConfig.verboseLogs)
|
|
{
|
|
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);
|
|
|
|
// IMPORTANT: configure timing using the same timebase as hitTime.
|
|
// NoteSpawner/Setup passes 'time' as the base realtime hit point already (startTime + note.time + globalHitDelay).
|
|
// We need travelTime so the segment starts moving at (hitTime - travelTime) rather than (Time.time + delay).
|
|
float travelTime = 0f;
|
|
if (speed > 0.0001f)
|
|
{
|
|
// NoteSpawner.CalculateSpeed uses: speed = 10.75f / travelTime, so travelTime = 10.75f / speed
|
|
travelTime = 10.75f / speed;
|
|
}
|
|
|
|
if (controller != null)
|
|
{
|
|
controller.ConfigureTiming(time, delay, travelTime);
|
|
|
|
// If the activation time is already in the past at the moment of Setup,
|
|
// the segment should have already traveled some distance. In that case
|
|
// snap its position forward so it appears at the correct location.
|
|
// Do this only when activation happened before now to avoid snapping
|
|
// freshly spawned segments that haven't started moving yet.
|
|
if (controller.ActivationTime < Time.time - 0.0001f)
|
|
{
|
|
// Use current transform.position as the spawn origin (NoteSpawner sets this before Setup).
|
|
CalibratePosition(transform.position, 0f);
|
|
}
|
|
}
|
|
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 already judged, skip heavy logic
|
|
if (isJudged) return;
|
|
|
|
// If start was judged and player is holding key, start hold effects
|
|
if (!isHoldActive && JudgeManager.Instance.IsStartJudged(noteID) && !JudgeManager.Instance.HasNoteReleased(noteID) && Input.GetKey(keyToPress))
|
|
{
|
|
isHoldActive = true;
|
|
AnimationController.Global?.StartHoldParticles(noteColor);
|
|
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] Start particles started for {noteID} color={noteColor} at time={Time.time:F3}");
|
|
}
|
|
|
|
// Auto-miss checks when inside judge line
|
|
if (hasEnteredLine && !isJudged)
|
|
{
|
|
float missRangeScaled = (judgeConfig?.missRange ?? 0.5f) * holdWindowMultiplier;
|
|
if (segment == NoteSegment.Start && Time.time > hitTime + missRangeScaled)
|
|
{
|
|
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] START auto-Miss: {noteColor}");
|
|
|
|
// register as missed
|
|
JudgeManager.Instance.RegisterStartJudged(noteID, false);
|
|
|
|
InputManager.Instance?.ShowJudgeResult(trackIndex, "Miss");
|
|
JudgeSoundManager.Instance?.PlayJudgeSound("Miss");
|
|
teamUIController.Instance?.OnJudgeResult("Miss");
|
|
|
|
// Show judgement prefab for auto-miss as well (consistent with other notes)
|
|
Animation_GenerateJudgementSituationPrefab.Instance?.SpawnJudgePrefab(noteColor, "Miss");
|
|
|
|
isJudged = true;
|
|
ScheduleReturnToPool(0.2f);
|
|
}
|
|
|
|
else if (segment == NoteSegment.End && Time.time > scheduledEndTime + missRangeScaled)
|
|
{
|
|
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] END auto-Miss: {noteColor}");
|
|
HandleEnd(true);
|
|
isJudged = true;
|
|
}
|
|
}
|
|
|
|
// Handle key release while holding
|
|
if (isHoldActive && Input.GetKeyUp(keyToPress))
|
|
{
|
|
isHoldActive = false;
|
|
AnimationController.Global?.StopHoldParticles();
|
|
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] KeyUp {keyToPress} detected. NoteID: {noteID}, Segment: {segment}, Type: {type}");
|
|
|
|
if (!hasReleased)
|
|
{
|
|
hasReleased = true;
|
|
releaseTime = Time.time;
|
|
JudgeManager.Instance?.RegisterNoteReleased(noteID, true);
|
|
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] RegisterNoteReleased on KeyUp. NoteID: {noteID}, releaseTime={releaseTime:F2}");
|
|
|
|
EvaluateHoldEnd(releaseTime, false);
|
|
}
|
|
}
|
|
|
|
// Start judgement input handling
|
|
if (segment == NoteSegment.Start)
|
|
{
|
|
if (Input.GetKeyDown(keyToPress))
|
|
{
|
|
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] KeyDown detected for {noteID} key={keyToPress} time={Time.time:F3} hitTime={hitTime:F3} hasEnteredLine={hasEnteredLine}");
|
|
float pressTimeLocal = Time.time;
|
|
float maxWindow = (judgeConfig?.missRange ?? 0.5f) * holdWindowMultiplier;
|
|
if (Mathf.Abs(pressTimeLocal - hitTime) <= maxWindow)
|
|
{
|
|
HandleStart(false);
|
|
// do not mark isJudged here; Start remains active for hold
|
|
}
|
|
}
|
|
}
|
|
else if (segment == NoteSegment.Start && hasEnteredLine)
|
|
{
|
|
// legacy fallback
|
|
if (Input.GetKeyDown(keyToPress))
|
|
{
|
|
HandleStart(false);
|
|
isJudged = true;
|
|
}
|
|
}
|
|
else if (segment == NoteSegment.Middle
|
|
&& JudgeManager.Instance.IsStartJudged(noteID)
|
|
&& isHoldActive
|
|
&& !JudgeManager.Instance.HasNoteReleased(noteID))
|
|
{
|
|
if (Time.time >= hitTime)
|
|
{
|
|
if (hasEnteredLine)
|
|
{
|
|
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] Middle passed: {noteColor}");
|
|
PlayHitAnimation();
|
|
JudgeManager.Instance?.RegisterMiddlePassed(noteID);
|
|
ScheduleReturnToPool(0.2f);
|
|
isJudged = true;
|
|
}
|
|
}
|
|
}
|
|
else if (segment == NoteSegment.End
|
|
&& JudgeManager.Instance.IsStartJudged(noteID)
|
|
&& !JudgeManager.Instance.HasNoteReleased(noteID))
|
|
{
|
|
if (Input.GetKeyUp(keyToPress))
|
|
{
|
|
HandleEnd();
|
|
isJudged = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
private void OnTriggerEnter2D(Collider2D collision)
|
|
{
|
|
if (!collision.CompareTag("JudgmentLine")) return;
|
|
|
|
hasEnteredLine = true;
|
|
|
|
if (segment == NoteSegment.Middle)
|
|
{
|
|
hasBeenHeldFromStart = Input.GetKey(keyToPress);
|
|
if (GameConfig.verboseLogs) 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 (GameConfig.verboseLogs) Debug.Log($"[HoldNote] End entered but note already released: {noteColor}");
|
|
HandleEnd();
|
|
isJudged = true;
|
|
return;
|
|
}
|
|
|
|
autoReturnCoroutine = StartCoroutine(DelayedAutoReturnCheck());
|
|
}
|
|
}
|
|
|
|
private void OnTriggerExit2D(Collider2D collision)
|
|
{
|
|
if (!collision.CompareTag("JudgmentLine")) return;
|
|
|
|
if (segment == NoteSegment.Start)
|
|
{
|
|
if (!JudgeManager.Instance.IsStartJudged(noteID))
|
|
{
|
|
if (GameConfig.verboseLogs) 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 (GameConfig.verboseLogs) 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 (GameConfig.verboseLogs) 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 (GameConfig.verboseLogs) 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)
|
|
{
|
|
if (!JudgeManager.Instance.TryResolveStart(noteID))
|
|
{
|
|
if (GameConfig.verboseLogs) 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 (GameConfig.verboseLogs) Debug.Log($"[HoldNote] START forced Miss: {noteColor}");
|
|
ScoreManager.Instance.countMiss += 1;
|
|
JudgeManager.Instance.RegisterStartJudged(noteID, false);
|
|
isHoldActive = false;
|
|
InputManager.Instance?.ShowJudgeResult(trackIndex, "Miss");
|
|
JudgeSoundManager.Instance?.PlayJudgeSound("Miss");
|
|
teamUIController.Instance?.OnJudgeResult("Miss");
|
|
|
|
// Also show judgement prefab for forced miss on start
|
|
Animation_GenerateJudgementSituationPrefab.Instance?.SpawnJudgePrefab(noteColor, "Miss");
|
|
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";
|
|
if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs;
|
|
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;
|
|
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;
|
|
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";
|
|
ScoreManager.Instance.countMiss += 1;
|
|
JudgeManager.Instance.RegisterStartJudged(noteID, false);
|
|
isHoldActive = false;
|
|
try
|
|
{
|
|
bool triggered = SkillBuilder.Instance?.NotifyNoteHit(this.trackIndex, result, SkillDefinition.NoteTypeTrigger.Tap, this.noteID) ?? false;
|
|
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] START Miss NotifyNoteHit fired for slot {trackIndex}: {triggered}");
|
|
}
|
|
catch (System.Exception ex)
|
|
{
|
|
Debug.LogError($"[HoldNote] NotifyNoteHit(Start Miss) threw: {ex}");
|
|
}
|
|
}
|
|
|
|
// show judge result and update combo/UI like short notes
|
|
InputManager.Instance?.ShowJudgeResult(trackIndex, result);
|
|
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());
|
|
}
|
|
}
|
|
|
|
private IEnumerator DelayedReturn()
|
|
{
|
|
yield return new WaitForSeconds(0.05f);
|
|
if (gameObject.activeSelf)
|
|
{
|
|
if (GameConfig.verboseLogs) 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)
|
|
{
|
|
if (isJudged) return; // already evaluated
|
|
|
|
// ensure we only resolve once per note
|
|
if (!JudgeManager.Instance.TryResolveEnd(noteID))
|
|
{
|
|
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] EvaluateHoldEnd: TryResolveEnd failed for {noteID}");
|
|
return;
|
|
}
|
|
|
|
releaseTime = actualReleaseTime;
|
|
hasReleased = true;
|
|
JudgeManager.Instance.RegisterNoteReleased(noteID, true);
|
|
|
|
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] EvaluateHoldEnd: noteID={noteID} releaseTime={releaseTime:F3} scheduledEnd={scheduledEndTime:F3} forceMiss={forceMiss}");
|
|
|
|
string result;
|
|
|
|
if (forceMiss || !JudgeManager.Instance.IsStartJudged(noteID))
|
|
{
|
|
result = "Miss";
|
|
ScoreManager.Instance.countMiss += 1;
|
|
if (GameConfig.verboseLogs) Debug.LogWarning($"[HoldNote] END judged as Miss: {noteColor} reason={(forceMiss ? "forced Miss" : "start not judged")}");
|
|
}
|
|
else
|
|
{
|
|
// try to use pool record for accurate press duration
|
|
if (HoldNoteJudgePool.TryGet(noteID, out var info))
|
|
{
|
|
if (GameConfig.verboseLogs) 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 (frac > 0.8f)
|
|
{
|
|
result = "Perfect";
|
|
ScoreManager.Instance.countPerfect += 1;
|
|
}
|
|
else if (frac > 0.5f)
|
|
{
|
|
result = "Great";
|
|
ScoreManager.Instance.countGreat += 1;
|
|
}
|
|
else
|
|
{
|
|
result = "Good";
|
|
ScoreManager.Instance.countGood += 1;
|
|
}
|
|
|
|
if (noteData != null && result != "Miss")
|
|
{
|
|
noteData.judgeOffsetMsEnd = (info.scheduledEnd - releaseTime) * 1000f;
|
|
}
|
|
|
|
// cleanup pool
|
|
HoldNoteJudgePool.Unregister(noteID);
|
|
}
|
|
else
|
|
{
|
|
if (GameConfig.verboseLogs) 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 (frac > 0.8f)
|
|
{
|
|
result = "Perfect";
|
|
ScoreManager.Instance.countPerfect += 1;
|
|
}
|
|
else if (frac > 0.5f)
|
|
{
|
|
result = "Great";
|
|
ScoreManager.Instance.countGreat += 1;
|
|
}
|
|
else
|
|
{
|
|
result = "Good";
|
|
ScoreManager.Instance.countGood += 1;
|
|
}
|
|
|
|
float rawOffsetMsEnd = (scheduledEndTime - actualReleaseTime) * 1000f;
|
|
if (noteData != null && result != "Miss")
|
|
{
|
|
noteData.judgeOffsetMsEnd = rawOffsetMsEnd;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] END result for {noteID}: {result} (color={noteColor})");
|
|
|
|
// show UI/audio and combo
|
|
InputManager.Instance?.ShowJudgeResult(trackIndex, result);
|
|
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)
|
|
{
|
|
int added = ally.AddScoreForJudge(result);
|
|
float efficiency = ally.scoreEfficiency;
|
|
ScoreManager.Instance?.AddPmScoreForTrack(TrackIndex, added, efficiency);
|
|
}
|
|
}
|
|
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 (GameConfig.verboseLogs) Debug.Log($"[HoldNote] Hold end NotifyNoteHit called: TrackIndex={TrackIndex} trackIndexField={trackIndex} result={result} triggeredHold={triggeredHold} triggeredTap={triggeredTap}");
|
|
}
|
|
|
|
AnimationController.Global?.StopHoldParticles();
|
|
|
|
isJudged = true;
|
|
ScheduleReturnToPool(0.2f);
|
|
}
|
|
|
|
private void HandleEnd(bool forceMiss = false)
|
|
{
|
|
if (!JudgeManager.Instance.TryResolveEnd(noteID))
|
|
{
|
|
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] END try-resolve failed: {noteColor}");
|
|
return;
|
|
}
|
|
// 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;
|
|
|
|
if (segment == NoteSegment.Start && !hasEnteredLine)
|
|
{
|
|
if (GameConfig.verboseLogs) Debug.LogWarning($"[HoldNote] Refusing to return a Start segment that never entered judge zone: {noteColor}");
|
|
return;
|
|
}
|
|
|
|
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] ReturnToPool called for {noteID} segment={segment} time={Time.time:F3}");
|
|
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()
|
|
{
|
|
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();
|
|
}
|
|
|
|
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 = Mathf.Max(0f, Time.time - activation);
|
|
Vector3 expected = spawnPointPosition + Vector3.down * s * elapsed;
|
|
|
|
if (Vector3.Distance(transform.position, expected) > tolerance)
|
|
{
|
|
transform.position = expected;
|
|
}
|
|
}
|
|
} |