922 lines
33 KiB
C#
922 lines
33 KiB
C#
using System;
|
|
using UnityEngine;
|
|
using System.Collections;
|
|
using System.Collections.Generic;
|
|
using TMPro;
|
|
using UnityEngine.UI;
|
|
|
|
public class NoteSpawner : MonoBehaviour
|
|
{
|
|
public event Action AllNotesSpawned; // invoked when all notes have been spawned
|
|
|
|
public NotePool notePool; // Documentation text normalized.
|
|
public Transform[] spawnPoints; // Documentation text normalized.
|
|
public Transform judgmentLine;
|
|
public GameObject[] notePrefabs; // Documentation text normalized.
|
|
|
|
public TextMeshProUGUI globalGameTime;
|
|
|
|
// Documentation text normalized.
|
|
public GameObject[] holdNoteMiddlePrefabs;
|
|
public GameObject[] holdNoteEndPrefabs;
|
|
|
|
public float spawnOffset = 0f; // Documentation text normalized.
|
|
public float static_value_add_to_spawnoffset = 0f;
|
|
|
|
[Header("Global timing adjustments")]
|
|
[Tooltip("Global additional realtime offset (seconds) added to hit times for all notes. Use to test input latency or adjust judgement timing. Default 0. Can be negative to make notes arrive earlier.")]
|
|
public float globalHitDelay = 0f;
|
|
|
|
[Header("Sync Note Detection")]
|
|
[Tooltip("Whether to spawn a special prefab for notes that appear within 0.05s of each other.")]
|
|
public bool enableSyncNotePrefab = false;
|
|
[Tooltip("The prefab to instantiate under the note when a sync is detected.")]
|
|
public GameObject syncNotePrefab;
|
|
|
|
// Documentation text normalized.
|
|
[Tooltip("Multiplier applied to visual fall speed. Changing this will automatically adjust spawn timing so notes still arrive at their original beat times.")]
|
|
[Range(0.5f, 2f)]
|
|
public float speedMultiplier = 1f;
|
|
|
|
[Header("Calibration")]
|
|
[Tooltip("Tolerance (world units) for snapping middle segments to expected position after spawn.")]
|
|
public float calibrateTolerance = 0.02f;
|
|
[Tooltip("How many calibration checks to perform after spawn (spread over frames).")]
|
|
public int calibrateChecks = 2;
|
|
[Tooltip("Interval (seconds realtime) between calibration checks.")]
|
|
public float calibrateInterval = 0.01f;
|
|
private class NoteCalibrationJob
|
|
{
|
|
public NoteController controller;
|
|
public GameObject obj;
|
|
public int remaining;
|
|
public float nextTime;
|
|
}
|
|
|
|
private class HoldCalibrationJob
|
|
{
|
|
public HoldNote hold;
|
|
public Vector3 spawnPos;
|
|
public int remaining;
|
|
public float nextTime;
|
|
}
|
|
|
|
private readonly List<NoteCalibrationJob> noteCalibrationJobs = new List<NoteCalibrationJob>();
|
|
private readonly List<HoldCalibrationJob> holdCalibrationJobs = new List<HoldCalibrationJob>();
|
|
private Coroutine calibrationRunner;
|
|
|
|
// Documentation text normalized.
|
|
[Header("Early compensation (experimental)")]
|
|
[Tooltip("When enabled, spawn positioning will compensate each segment's position based on its own activation time so newly spawned segments appear at their expected traveled position. Default: OFF.")]
|
|
public bool enableYOffsetCompensation = false;
|
|
|
|
[Header("Immediate Settlement")]
|
|
[Tooltip("Optional. If set, this will be used to trigger settlement UI (JudgeManager.TriggerAllNotesJudged). If null, will fall back to JudgeManager.Instance.")]
|
|
public JudgeManager judgeManager;
|
|
|
|
[Tooltip("Optional UI Button. When clicked, will immediately stop spawning and enter settlement.")]
|
|
public Button immediateSettlementButton;
|
|
|
|
[Tooltip("Optional: the pause UI GameObject to disable when immediate settlement is triggered.")]
|
|
public GameObject pausePanel;
|
|
|
|
[Tooltip("Optional: animations GameObject to disable when entering settlement. Will be restored on Start().")]
|
|
public GameObject animations;
|
|
|
|
// optional: constants for runtime clamping (kept for internal use)
|
|
private const float SpeedMultiplierMin = 0.5f;
|
|
private const float SpeedMultiplierMax = 2f;
|
|
|
|
public NoteJudgeConfig judgeConfig; // Documentation text normalized.
|
|
private Beatmap beatmap;
|
|
private float startTime; // Absolute chart time anchor.
|
|
private float bpm = 120f;
|
|
private bool isSpawning = false;
|
|
|
|
// Documentation text normalized.
|
|
private static int holdNoteIdCounter = 0;
|
|
|
|
// map from beatmap note index -> assigned holdNoteId (for hold notes only)
|
|
private Dictionary<int, int> noteIndexToHoldId = new Dictionary<int, int>();
|
|
|
|
private const string NoteSpeedPrefKey = "noteSpeedMultiplier";
|
|
private const float NoteSpeedDefault = 1f;
|
|
private const float BaseTravelDistance = 10.75f;
|
|
|
|
private Coroutine spawnCoroutine;
|
|
private Coroutine settlementCoroutine;
|
|
|
|
// Cache for key bindings to reduce lookups
|
|
private Dictionary<string, KeyCode> _colorToKeyCache = new Dictionary<string, KeyCode>();
|
|
|
|
// Guard to avoid double-trigger / deadlock
|
|
private bool immediateSettlementTriggered = false;
|
|
|
|
public bool IsImmediateSettlementTriggered
|
|
{
|
|
get { return immediateSettlementTriggered; }
|
|
}
|
|
|
|
private void Awake()
|
|
{
|
|
EnsureDefaultNoteSpeedPreference();
|
|
// Load saved visual speed multiplier before any spawning logic uses it
|
|
float saved = PlayerPrefs.GetFloat(NoteSpeedPrefKey, NoteSpeedDefault);
|
|
saved = Mathf.Clamp(saved, SpeedMultiplierMin, SpeedMultiplierMax);
|
|
speedMultiplier = saved;
|
|
if (JudgeManager.IsDebugEnabled) Debug.Log($"[NoteSpawner] Loaded speedMultiplier={speedMultiplier} from PlayerPrefs");
|
|
}
|
|
|
|
private static void EnsureDefaultNoteSpeedPreference()
|
|
{
|
|
if (PlayerPrefs.HasKey(NoteSpeedPrefKey))
|
|
{
|
|
return;
|
|
}
|
|
|
|
PlayerPrefs.SetFloat(NoteSpeedPrefKey, NoteSpeedDefault);
|
|
PlayerPrefs.Save();
|
|
}
|
|
|
|
private void OnEnable()
|
|
{
|
|
// Documentation text normalized.
|
|
BindImmediateSettlementButton();
|
|
|
|
// subscribe to JudgeManager.AllNotesJudged so we disable animations on normal settlement as well
|
|
TrySubscribeJudgeManager();
|
|
}
|
|
|
|
private void Start()
|
|
{
|
|
// 读取 PlayerPrefs 中的延迟偏移值(秒)并应用
|
|
const string DELAY_PREFS_KEY = "UserGlobalDelaySeconds";
|
|
float savedDelay = PlayerPrefs.GetFloat(DELAY_PREFS_KEY, 0f);
|
|
spawnOffset = savedDelay + static_value_add_to_spawnoffset;
|
|
if (JudgeManager.IsDebugEnabled) Debug.Log($"[NoteSpawner] Applied spawnOffset={spawnOffset} (Saved={savedDelay} + Static={static_value_add_to_spawnoffset})");
|
|
|
|
// 读取 PlayerPrefs 中的同步音符开关状态
|
|
enableSyncNotePrefab = PlayerPrefs.GetInt("EnableSyncNotePrefab", 1) == 1;
|
|
if (JudgeManager.IsDebugEnabled) Debug.Log($"[NoteSpawner] Applied enableSyncNotePrefab={enableSyncNotePrefab}");
|
|
|
|
// restore animations active state on Start
|
|
if (animations != null)
|
|
{
|
|
try { animations.SetActive(true); }
|
|
catch { }
|
|
}
|
|
|
|
// ensure subscription if JudgeManager.Instance was not ready during OnEnable
|
|
TrySubscribeJudgeManager();
|
|
}
|
|
|
|
private void OnDisable()
|
|
{
|
|
UnbindImmediateSettlementButton();
|
|
TryUnsubscribeJudgeManager();
|
|
}
|
|
|
|
private void ResetImmediateSettlementState()
|
|
{
|
|
immediateSettlementTriggered = false;
|
|
// Documentation text normalized.
|
|
}
|
|
|
|
private void BindImmediateSettlementButton()
|
|
{
|
|
if (immediateSettlementButton == null) return;
|
|
try { immediateSettlementButton.onClick.RemoveListener(ForceImmediateSettlement); } catch { }
|
|
immediateSettlementButton.onClick.AddListener(ForceImmediateSettlement);
|
|
}
|
|
|
|
private void UnbindImmediateSettlementButton()
|
|
{
|
|
if (immediateSettlementButton == null) return;
|
|
try { immediateSettlementButton.onClick.RemoveListener(ForceImmediateSettlement); } catch { }
|
|
}
|
|
|
|
private void TrySubscribeJudgeManager()
|
|
{
|
|
var jm = judgeManager != null ? judgeManager : JudgeManager.Instance;
|
|
if (jm != null)
|
|
{
|
|
try { jm.AllNotesJudged -= OnSettlementTriggered; } catch { }
|
|
jm.AllNotesJudged += OnSettlementTriggered;
|
|
}
|
|
}
|
|
|
|
private void TryUnsubscribeJudgeManager()
|
|
{
|
|
var jm = judgeManager != null ? judgeManager : JudgeManager.Instance;
|
|
if (jm != null)
|
|
{
|
|
try { jm.AllNotesJudged -= OnSettlementTriggered; } catch { }
|
|
}
|
|
}
|
|
|
|
private void OnSettlementTriggered()
|
|
{
|
|
// Disable animations when settlement begins
|
|
if (animations != null)
|
|
{
|
|
try { animations.SetActive(false); }
|
|
catch { }
|
|
}
|
|
}
|
|
|
|
public void LoadBeatmap(Beatmap loadedBeatmap)
|
|
{
|
|
if (isSpawning) return;
|
|
isSpawning = true;
|
|
|
|
beatmap = loadedBeatmap;
|
|
if (beatmap == null)
|
|
{
|
|
Debug.LogError("加载的谱面为空!");
|
|
return;
|
|
}
|
|
noteIndexToHoldId.Clear();
|
|
|
|
// initialize JudgeManager total note count (count each NoteData as one logical note;
|
|
// long notes are counted once as a single logical note)
|
|
int total = 0;
|
|
if (beatmap.notes != null)
|
|
{
|
|
total = beatmap.notes.Length;
|
|
}
|
|
JudgeManager.Instance?.SetTotalNotes(total);
|
|
|
|
bpm = beatmap.bpm;
|
|
startTime = GameplayClock.ChartStartSongTime;
|
|
if (JudgeManager.IsDebugEnabled) Debug.Log($"歌曲开始时间: {startTime}");
|
|
|
|
// keep reference so we can stop spawning when doing immediate settlement
|
|
spawnCoroutine = StartCoroutine(SpawnNotes());
|
|
}
|
|
|
|
private IEnumerator SpawnNotes()
|
|
{
|
|
if (beatmap == null || beatmap.notes == null)
|
|
{
|
|
Debug.LogError("Beatmap note data is null.");
|
|
isSpawning = false;
|
|
yield break;
|
|
}
|
|
|
|
// Cache parameters that don't change within the loop
|
|
float sm = Mathf.Clamp(speedMultiplier, SpeedMultiplierMin, SpeedMultiplierMax);
|
|
float baseTravelTime = (60f / bpm) * 4f;
|
|
float baseNoteTravelTime = baseTravelTime / Mathf.Max(0.0001f, sm);
|
|
float noteSpeed = CalculateSpeed(baseNoteTravelTime);
|
|
float segmentInterval = (60f / bpm) / 4f;
|
|
|
|
EnsureJudgmentLine();
|
|
float[] laneTravelTimes = BuildLaneTravelTimes(noteSpeed);
|
|
|
|
// iterate with index so we can map hold notes to generated ids
|
|
for (int i = 0; i < beatmap.notes.Length; i++)
|
|
{
|
|
// Documentation text normalized.
|
|
if (!isSpawning)
|
|
yield break;
|
|
|
|
NoteData note = beatmap.notes[i];
|
|
|
|
// Check if this is a sync note (within 0.05s of any other note)
|
|
bool isSync = false;
|
|
if (enableSyncNotePrefab)
|
|
{
|
|
// Check previous notes (optimized assuming sorted beatmap)
|
|
for (int j = i - 1; j >= 0; j--)
|
|
{
|
|
if (Mathf.Abs(note.time - beatmap.notes[j].time) <= 0.05f) { isSync = true; break; }
|
|
if (note.time - beatmap.notes[j].time > 0.05f) break;
|
|
}
|
|
if (!isSync)
|
|
{
|
|
// Check next notes (optimized assuming sorted beatmap)
|
|
for (int j = i + 1; j < beatmap.notes.Length; j++)
|
|
{
|
|
if (Mathf.Abs(note.time - beatmap.notes[j].time) <= 0.05f) { isSync = true; break; }
|
|
if (beatmap.notes[j].time - note.time > 0.05f) break;
|
|
}
|
|
}
|
|
}
|
|
|
|
float travelTime = GetLaneTravelTimeSeconds(laneTravelTimes, note.trackIndex);
|
|
float spawnTime = note.time - travelTime;
|
|
float chartSpawnTime = startTime + spawnTime + spawnOffset;
|
|
float delay = chartSpawnTime - GameplayClock.NowSongTime;
|
|
|
|
if (delay > 0)
|
|
{
|
|
while (GameplayClock.NowSongTime < chartSpawnTime)
|
|
{
|
|
yield return null;
|
|
}
|
|
}
|
|
|
|
if (note.type == "hold")
|
|
{
|
|
// create the hold note once and record its id mapping
|
|
int hid = SpawnHoldNote(note, sm, travelTime, noteSpeed, segmentInterval, isSync);
|
|
noteIndexToHoldId[i] = hid;
|
|
}
|
|
else
|
|
{
|
|
SpawnNote(note, sm, travelTime, noteSpeed, isSync);
|
|
}
|
|
}
|
|
|
|
isSpawning = false;
|
|
spawnCoroutine = null;
|
|
|
|
// Notify subscribers that all notes have been spawned
|
|
AllNotesSpawned?.Invoke();
|
|
}
|
|
|
|
private KeyCode GetCachedKeyCode(string color)
|
|
{
|
|
if (string.IsNullOrEmpty(color)) return KeyCode.None;
|
|
if (!_colorToKeyCache.TryGetValue(color, out KeyCode key))
|
|
{
|
|
key = KeyBindingManager.GetKeyForColor(color);
|
|
_colorToKeyCache[color] = key;
|
|
}
|
|
return key;
|
|
}
|
|
|
|
public void SpawnNote(NoteData noteData, float sm, float travelTime, float noteSpeed, bool isSync = false)
|
|
{
|
|
if (noteData.trackIndex < 0 || noteData.trackIndex >= spawnPoints.Length)
|
|
{
|
|
Debug.LogError("Track index out of range.");
|
|
return;
|
|
}
|
|
|
|
KeyCode key = GetCachedKeyCode(noteData.color);
|
|
if (key == KeyCode.None)
|
|
{
|
|
Debug.LogError($"No key binding found for note color '{noteData.color}'.");
|
|
return;
|
|
}
|
|
|
|
GameObject note = notePool.GetNote(noteData.color);
|
|
if (note == null)
|
|
{
|
|
if (JudgeManager.IsDebugEnabled) Debug.LogError("NotePool returned a null short note object.");
|
|
return;
|
|
}
|
|
|
|
if (enableSyncNotePrefab && syncNotePrefab != null)
|
|
{
|
|
// Clean up any existing sync prefab from previous use in pool
|
|
foreach (Transform child in note.transform)
|
|
{
|
|
if (child.name.StartsWith(syncNotePrefab.name))
|
|
{
|
|
Destroy(child.gameObject);
|
|
}
|
|
}
|
|
|
|
if (isSync)
|
|
{
|
|
Instantiate(syncNotePrefab, note.transform);
|
|
}
|
|
}
|
|
|
|
Transform spawnPoint = spawnPoints[noteData.trackIndex];
|
|
|
|
// Calculate hit time (realtime when note should be judged)
|
|
float chartHitTime = Mathf.Max(0f, startTime + noteData.time + globalHitDelay);
|
|
|
|
Vector3 initialPosition = spawnPoint.position;
|
|
note.transform.position = initialPosition;
|
|
note.transform.rotation = Quaternion.identity;
|
|
|
|
Note noteScript = note.GetComponent<Note>();
|
|
NoteController noteController = note.GetComponent<NoteController>();
|
|
|
|
if (noteScript != null)
|
|
{
|
|
// Setup note script with timing parameters
|
|
noteScript.Setup(key, noteData.trackIndex, noteSpeed, chartHitTime, noteData.color, judgeConfig, noteData, isSync);
|
|
|
|
// Configure controller for absolute positioning (replaces relative Translate)
|
|
if (noteController != null)
|
|
{
|
|
noteController.ConfigureAbsolutePositioning(initialPosition, chartHitTime, travelTime, 0f);
|
|
}
|
|
|
|
EnqueueNoteCalibration(noteController);
|
|
}
|
|
else
|
|
{
|
|
Debug.LogError("Note prefab is missing Note component.");
|
|
}
|
|
}
|
|
|
|
// Modified: return generated holdNoteId so callers can map notes to ids
|
|
public int SpawnHoldNote(NoteData noteData, float sm, float travelTime, float noteSpeed, float segmentInterval, bool isSync = false)
|
|
{
|
|
if (noteData.trackIndex < 0 || noteData.trackIndex >= spawnPoints.Length)
|
|
{
|
|
Debug.LogError("Track index out of range.");
|
|
return -1;
|
|
}
|
|
|
|
KeyCode key = GetCachedKeyCode(noteData.color);
|
|
if (key == KeyCode.None)
|
|
{
|
|
Debug.LogError($"未找到颜色 {noteData.color} 对应的按键!");
|
|
return -1;
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(noteData.color))
|
|
{
|
|
Debug.LogError("[NoteSpawner] noteData.color 为空,无法生成音符!");
|
|
return -1;
|
|
}
|
|
|
|
int segmentCount = Mathf.CeilToInt(noteData.length / segmentInterval);
|
|
if (segmentCount < 2) segmentCount = 2; // Force at least one middle segment between start and end.
|
|
float actualSegmentInterval = segmentCount > 0 ? noteData.length / segmentCount : segmentInterval;
|
|
|
|
Transform spawnPoint = spawnPoints[noteData.trackIndex];
|
|
// Documentation text normalized.
|
|
float scheduledEndTime = Mathf.Max(0f, startTime + (noteData.time + noteData.length) + globalHitDelay);
|
|
|
|
// Documentation text normalized.
|
|
int holdNoteId = ++holdNoteIdCounter;
|
|
|
|
// base realtime for hits
|
|
float baseHit = Mathf.Max(0f, startTime + noteData.time + globalHitDelay);
|
|
|
|
GameObject startObj = notePool.GetStartNote(noteData.color);
|
|
if (startObj == null)
|
|
{
|
|
Debug.LogError("NotePool returned null hold start object.");
|
|
return -1;
|
|
}
|
|
|
|
if (enableSyncNotePrefab && syncNotePrefab != null)
|
|
{
|
|
// Clean up any existing sync prefab from previous use in pool
|
|
foreach (Transform child in startObj.transform)
|
|
{
|
|
if (child.name.StartsWith(syncNotePrefab.name))
|
|
{
|
|
Destroy(child.gameObject);
|
|
}
|
|
}
|
|
|
|
if (isSync)
|
|
{
|
|
Instantiate(syncNotePrefab, startObj.transform);
|
|
}
|
|
}
|
|
|
|
// Hold segments use absolute positioning; spawn at the lane origin.
|
|
startObj.transform.position = spawnPoint.position;
|
|
startObj.transform.rotation = Quaternion.identity;
|
|
|
|
HoldNote holdNote = startObj.GetComponent<HoldNote>();
|
|
if (holdNote != null)
|
|
{
|
|
// pass realtime hit time (startTime + note.time) and delay 0, include globalHitDelay
|
|
holdNote.Setup(holdNoteId, noteData.trackIndex, noteSpeed, baseHit, 0f, false, scheduledEndTime, noteData.color, key, "start", travelTime, judgeConfig, noteData, isSync);
|
|
|
|
// 使用流速倍率 sm 直接进行缩放,确保长条音符在 0.5-2.0 范围内依然能完美衔接
|
|
float visualScale = sm;
|
|
holdNote.ApplyVisualScale(visualScale);
|
|
// inform hold note of visual speed so it can adapt judgement windows if needed
|
|
holdNote.visualSpeedMultiplier = sm;
|
|
}
|
|
else
|
|
{
|
|
Debug.LogError("Hold start object is missing HoldNote component.");
|
|
return -1;
|
|
}
|
|
|
|
for (int i = 1; i < segmentCount; i++)
|
|
{
|
|
// keep segment delays based on actualSegmentInterval (unscaled) so middle pieces are consecutive regardless of visual speed
|
|
float segmentDelay = i * actualSegmentInterval; // Documentation text normalized.
|
|
GameObject segObj = notePool.GetHoldNoteSegment(noteData.color);
|
|
if (segObj == null)
|
|
{
|
|
Debug.LogError("NotePool returned null hold segment object.");
|
|
continue;
|
|
}
|
|
|
|
segObj.transform.position = spawnPoint.position;
|
|
segObj.transform.rotation = Quaternion.identity;
|
|
|
|
HoldNote holdSeg = segObj.GetComponent<HoldNote>();
|
|
if (holdSeg != null)
|
|
{
|
|
// Documentation text normalized.
|
|
holdSeg.Setup(holdNoteId, noteData.trackIndex, noteSpeed, baseHit, segmentDelay, false, scheduledEndTime, noteData.color, key, "middle", travelTime, judgeConfig, noteData, false);
|
|
|
|
// 应用与开始段一致的流速缩放
|
|
float visualScaleMid = sm;
|
|
holdSeg.ApplyVisualScale(visualScaleMid);
|
|
holdSeg.visualSpeedMultiplier = sm;
|
|
|
|
// Immediately calibrate position and schedule additional checks to correct any offset
|
|
Vector3 calibSpawnPos = spawnPoint.position;
|
|
holdSeg.CalibratePosition(calibSpawnPos, calibrateTolerance);
|
|
EnqueueHoldCalibration(holdSeg, calibSpawnPos);
|
|
}
|
|
else
|
|
{
|
|
Debug.LogError("Hold segment object is missing HoldNote component.");
|
|
}
|
|
}
|
|
|
|
GameObject endObj = notePool.GetHoldNoteEndSegment(noteData.color);
|
|
if (endObj == null)
|
|
{
|
|
Debug.LogError("对象池返回空 hold note 片段(end)!");
|
|
return -1;
|
|
}
|
|
|
|
float endDelay = segmentCount * actualSegmentInterval; // Documentation text normalized.
|
|
endObj.transform.position = spawnPoint.position;
|
|
endObj.transform.rotation = Quaternion.identity;
|
|
|
|
HoldNote holdEnd = endObj.GetComponent<HoldNote>();
|
|
if (holdEnd != null)
|
|
{
|
|
holdEnd.Setup(holdNoteId, noteData.trackIndex, noteSpeed, baseHit, endDelay, true, scheduledEndTime, noteData.color, key, "end", travelTime, judgeConfig, noteData, false);
|
|
|
|
// 同样应用流速缩放
|
|
float visualScaleEnd = sm;
|
|
holdEnd.ApplyVisualScale(visualScaleEnd);
|
|
holdEnd.visualSpeedMultiplier = sm;
|
|
|
|
// schedule calibration for end as well to be safe
|
|
Vector3 calibEndPos = spawnPoint.position;
|
|
holdEnd.CalibratePosition(calibEndPos, calibrateTolerance);
|
|
EnqueueHoldCalibration(holdEnd, calibEndPos);
|
|
}
|
|
else
|
|
{
|
|
Debug.LogError("Hold end object is missing HoldNote component.");
|
|
}
|
|
|
|
return holdNoteId;
|
|
}
|
|
|
|
private void EnsureJudgmentLine()
|
|
{
|
|
if (judgmentLine != null) return;
|
|
GameObject go = GameObject.FindGameObjectWithTag("JudgmentLine");
|
|
if (go != null) judgmentLine = go.transform;
|
|
}
|
|
|
|
private float[] BuildLaneTravelTimes(float noteSpeed)
|
|
{
|
|
int lanes = spawnPoints != null ? spawnPoints.Length : 0;
|
|
float[] times = new float[lanes];
|
|
float safeSpeed = Mathf.Max(0.0001f, noteSpeed);
|
|
|
|
for (int i = 0; i < lanes; i++)
|
|
{
|
|
float distance = GetLaneDistance(i);
|
|
times[i] = distance / safeSpeed;
|
|
}
|
|
|
|
return times;
|
|
}
|
|
|
|
private float GetLaneDistance(int trackIndex)
|
|
{
|
|
if (spawnPoints == null || trackIndex < 0 || trackIndex >= spawnPoints.Length) return BaseTravelDistance;
|
|
Transform sp = spawnPoints[trackIndex];
|
|
if (sp == null) return BaseTravelDistance;
|
|
if (judgmentLine == null) return BaseTravelDistance;
|
|
|
|
return Mathf.Abs(sp.position.y - judgmentLine.position.y);
|
|
}
|
|
|
|
private float GetLaneTravelTimeSeconds(float[] laneTravelTimes, int trackIndex)
|
|
{
|
|
if (laneTravelTimes == null || laneTravelTimes.Length == 0) return 0f;
|
|
if (trackIndex < 0 || trackIndex >= laneTravelTimes.Length) return laneTravelTimes[0];
|
|
return laneTravelTimes[trackIndex];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Documentation text normalized.
|
|
[ContextMenu("Force Immediate Settlement")]
|
|
public void ForceImmediateSettlement()
|
|
{
|
|
if (immediateSettlementTriggered)
|
|
{
|
|
if (JudgeManager.IsDebugEnabled) Debug.LogWarning("[NoteSpawner] ForceImmediateSettlement ignored: already triggered.");
|
|
return;
|
|
}
|
|
immediateSettlementTriggered = true;
|
|
|
|
if (JudgeManager.IsDebugEnabled) Debug.LogWarning("[NoteSpawner] ForceImmediateSettlement called: stopping further note spawning and triggering settlement now.");
|
|
|
|
// If a pause UI is assigned, disable it immediately to avoid stuck paused UI during settlement
|
|
if (pausePanel != null)
|
|
{
|
|
try
|
|
{
|
|
pausePanel.SetActive(false);
|
|
if (JudgeManager.IsDebugEnabled) Debug.Log("[NoteSpawner] pausePanel has been disabled by immediate settlement.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
if (JudgeManager.IsDebugEnabled) Debug.LogWarning("[NoteSpawner] Failed to disable pausePanel: " + ex.Message);
|
|
}
|
|
}
|
|
|
|
// Also disable animations if assigned
|
|
if (animations != null)
|
|
{
|
|
try { animations.SetActive(false); }
|
|
catch { }
|
|
}
|
|
|
|
// Restore pause manager state if present, otherwise fallback to setting timeScale
|
|
try
|
|
{
|
|
var pm = PauseManager.Instance;
|
|
if (pm != null)
|
|
{
|
|
pm.Pause(false);
|
|
if (JudgeManager.IsDebugEnabled) Debug.Log("[NoteSpawner] PauseManager.Pause(false) called to resume time.");
|
|
}
|
|
else
|
|
{
|
|
Time.timeScale = 1f;
|
|
if (JudgeManager.IsDebugEnabled) Debug.Log("[NoteSpawner] PauseManager not found; Time.timeScale set to 1 as fallback.");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
try { Time.timeScale = 1f; } catch { }
|
|
if (JudgeManager.IsDebugEnabled) Debug.LogWarning("[NoteSpawner] Exception while restoring time scale: " + ex.Message);
|
|
}
|
|
|
|
// stop spawning loop
|
|
isSpawning = false;
|
|
|
|
// cancel spawn coroutine
|
|
if (spawnCoroutine != null)
|
|
{
|
|
try { StopCoroutine(spawnCoroutine); } catch { }
|
|
spawnCoroutine = null;
|
|
}
|
|
|
|
// cancel delayed settlement coroutine if any
|
|
if (settlementCoroutine != null)
|
|
{
|
|
try { StopCoroutine(settlementCoroutine); } catch { }
|
|
settlementCoroutine = null;
|
|
}
|
|
|
|
// Disable animations when settlement begins
|
|
if (animations != null)
|
|
{
|
|
try { animations.SetActive(false); }
|
|
catch { }
|
|
}
|
|
|
|
// trigger settlement
|
|
var jm = judgeManager != null ? judgeManager : JudgeManager.Instance;
|
|
if (jm != null)
|
|
{
|
|
jm.TriggerAllNotesJudged();
|
|
}
|
|
else
|
|
{
|
|
if (JudgeManager.IsDebugEnabled) Debug.LogError("[NoteSpawner] ForceImmediateSettlement failed: JudgeManager reference missing.");
|
|
}
|
|
}
|
|
|
|
// --- end of duplicated methods removal ---
|
|
private void EnqueueNoteCalibration(NoteController noteController)
|
|
{
|
|
if (noteController == null) return;
|
|
int checks = Mathf.Max(1, calibrateChecks);
|
|
if (checks <= 0) return;
|
|
GameObject obj = noteController.gameObject;
|
|
noteCalibrationJobs.Add(new NoteCalibrationJob
|
|
{
|
|
controller = noteController,
|
|
obj = obj,
|
|
remaining = checks,
|
|
nextTime = Time.unscaledTime + Mathf.Max(0f, calibrateInterval)
|
|
});
|
|
StartCalibrationRunner();
|
|
}
|
|
|
|
private void EnqueueHoldCalibration(HoldNote hold, Vector3 spawnPos)
|
|
{
|
|
if (hold == null) return;
|
|
int checks = Mathf.Max(1, calibrateChecks);
|
|
if (checks <= 0) return;
|
|
holdCalibrationJobs.Add(new HoldCalibrationJob
|
|
{
|
|
hold = hold,
|
|
spawnPos = spawnPos,
|
|
remaining = checks,
|
|
nextTime = Time.unscaledTime + Mathf.Max(0f, calibrateInterval)
|
|
});
|
|
StartCalibrationRunner();
|
|
}
|
|
|
|
private void StartCalibrationRunner()
|
|
{
|
|
if (calibrationRunner != null) return;
|
|
calibrationRunner = StartCoroutine(CalibrationRunner());
|
|
}
|
|
|
|
private IEnumerator CalibrationRunner()
|
|
{
|
|
while (noteCalibrationJobs.Count > 0 || holdCalibrationJobs.Count > 0)
|
|
{
|
|
float now = Time.unscaledTime;
|
|
|
|
for (int i = noteCalibrationJobs.Count - 1; i >= 0; i--)
|
|
{
|
|
var job = noteCalibrationJobs[i];
|
|
if (job == null || job.controller == null || job.obj == null || !job.obj.activeSelf)
|
|
{
|
|
noteCalibrationJobs.RemoveAt(i);
|
|
continue;
|
|
}
|
|
|
|
if (now >= job.nextTime)
|
|
{
|
|
Vector3 expected = job.controller.GetExpectedPosition(GameplayClock.NowSongTime);
|
|
float distanceDeviation = Vector3.Distance(job.obj.transform.position, expected);
|
|
if (distanceDeviation > calibrateTolerance)
|
|
{
|
|
job.obj.transform.position = expected;
|
|
if (JudgeManager.IsDebugEnabled) Debug.Log($"[NoteSpawner] Calibrated short note position, deviation was {distanceDeviation:F4}");
|
|
}
|
|
|
|
job.remaining--;
|
|
if (job.remaining <= 0)
|
|
{
|
|
noteCalibrationJobs.RemoveAt(i);
|
|
}
|
|
else
|
|
{
|
|
job.nextTime = now + Mathf.Max(0f, calibrateInterval);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (int i = holdCalibrationJobs.Count - 1; i >= 0; i--)
|
|
{
|
|
var job = holdCalibrationJobs[i];
|
|
if (job == null || job.hold == null || !job.hold.gameObject.activeSelf)
|
|
{
|
|
holdCalibrationJobs.RemoveAt(i);
|
|
continue;
|
|
}
|
|
|
|
if (now >= job.nextTime)
|
|
{
|
|
job.hold.CalibratePosition(job.spawnPos, calibrateTolerance);
|
|
job.remaining--;
|
|
if (job.remaining <= 0)
|
|
{
|
|
holdCalibrationJobs.RemoveAt(i);
|
|
}
|
|
else
|
|
{
|
|
job.nextTime = now + Mathf.Max(0f, calibrateInterval);
|
|
}
|
|
}
|
|
}
|
|
|
|
yield return null;
|
|
}
|
|
|
|
calibrationRunner = null;
|
|
}
|
|
|
|
private float CalculateSpeed(float noteTravelTime)
|
|
{
|
|
return BaseTravelDistance / noteTravelTime;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Public method to start the settlement routine. Called by GameManager when all notes have been spawned.
|
|
/// </summary>
|
|
public void StartSettlementRoutine()
|
|
{
|
|
if (JudgeManager.IsDebugEnabled) Debug.Log("[NoteSpawner] StartSettlementRoutine called - beginning settlement countdown");
|
|
// keep reference so it can be cancelled by ForceImmediateSettlement
|
|
if (settlementCoroutine != null) StopCoroutine(settlementCoroutine);
|
|
settlementCoroutine = StartCoroutine(PostSpawnSettlementRoutine());
|
|
}
|
|
|
|
private IEnumerator PostSpawnSettlementRoutine()
|
|
{
|
|
if (beatmap == null || beatmap.notes == null || beatmap.notes.Length == 0)
|
|
yield break;
|
|
|
|
float chartEndTime = 0f;
|
|
for (int i = 0; i < beatmap.notes.Length; i++)
|
|
{
|
|
NoteData note = beatmap.notes[i];
|
|
if (note == null) continue;
|
|
|
|
float end = note.type == "hold" ? (note.time + note.length) : note.time;
|
|
if (end > chartEndTime) chartEndTime = end;
|
|
}
|
|
chartEndTime += Mathf.Max(0f, globalHitDelay);
|
|
|
|
const float postChartDelay = 2f;
|
|
float settlementTime = startTime + chartEndTime + postChartDelay;
|
|
|
|
if (JudgeManager.IsDebugEnabled)
|
|
{
|
|
Debug.Log($"[NoteSpawner] Settlement scheduled at chartEnd+{postChartDelay:F1}s " +
|
|
$"(chartEnd={chartEndTime:F3}, startTime={startTime:F3}, now={GameplayClock.NowSongTime:F3})");
|
|
}
|
|
|
|
while (GameplayClock.NowSongTime < settlementTime)
|
|
yield return null;
|
|
|
|
ForceClearInputState(null);
|
|
|
|
// Trigger settlement
|
|
try
|
|
{
|
|
if (JudgeManager.Instance != null)
|
|
{
|
|
JudgeManager.Instance.OnAllNotesJudged();
|
|
}
|
|
else
|
|
{
|
|
Debug.LogWarning("[NoteSpawner] JudgeManager.Instance is null when trying to trigger settlement");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Debug.LogWarning("[NoteSpawner] Exception while triggering settlement: " + ex);
|
|
}
|
|
}
|
|
|
|
private void ForceClearInputState(List<NoteData> relevantNotes)
|
|
{
|
|
// Fire UI key release handlers: reset InputManager key indicator colors
|
|
var im = InputManager.Instance;
|
|
if (im != null)
|
|
{
|
|
var texts = im.trackKeyTexts;
|
|
if (texts != null)
|
|
{
|
|
for (int ti = 0; ti < texts.Length; ti++)
|
|
{
|
|
if (texts[ti] != null)
|
|
texts[ti].color = im.keyInactiveColor;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reset per-frame consumption and unlock any track locks
|
|
if (TrackKeyManager.Instance != null)
|
|
{
|
|
TrackKeyManager.Instance.ResetConsumptionState();
|
|
TrackKeyManager.Instance.ClearAllLocks();
|
|
if (JudgeManager.IsDebugEnabled) Debug.Log("[NoteSpawner] TrackKeyManager consumption state reset and locks cleared");
|
|
}
|
|
|
|
// Mark all relevant hold notes as released in JudgeManager
|
|
// Only process if relevantNotes is provided (non-null)
|
|
if (relevantNotes != null && JudgeManager.Instance != null && beatmap != null && beatmap.notes != null)
|
|
{
|
|
foreach (var note in relevantNotes)
|
|
{
|
|
if (note == null) continue;
|
|
if (note.type == "hold")
|
|
{
|
|
// Find the index of this note in beatmap to get its hold ID
|
|
for (int i = 0; i < beatmap.notes.Length; i++)
|
|
{
|
|
if (beatmap.notes[i] == note && noteIndexToHoldId.ContainsKey(i))
|
|
{
|
|
int holdId = noteIndexToHoldId[i];
|
|
try { JudgeManager.Instance.RegisterNoteReleased(holdId.ToString(), true); }
|
|
catch { }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
}
|