Files
bansonic_beta_main/Assets/scripts/gamePlay_gameplay/NoteSpawner.cs
T
2026-07-24 04:43:13 +08:00

1001 lines
39 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(1f, 3f)]
public float speedMultiplier = 2f;
[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)
// Player-facing speed is clamped to 1-3; FlowSpeedGlobalMultiplier makes the real range 2-6.
private const float SpeedMultiplierMin = 1f;
private const float SpeedMultiplierMax = 3f;
// 全局流速系数:与用户可调流速(speedMultiplier)相乘的独立系数,加快音符下落。
// 为什么用它而非 bpm 倍率:noteSpeed = 10.75 * sm * bpm / 240,若靠 ×bpm 提速,
// 长条音符的视觉长度由 ApplyVisualScale(sm) 决定(只随 sm 不随 bpm)、且分段数 segmentInterval=(60/bpm)/4
// 也会变,导致长条视觉与下落速度不匹配而错乱。故 bpm 方案不可行;sm 才是让 noteSpeed 与
// 长条视觉/粒子一致缩放的唯一安全杠杆。按用户指示设为 2。
// 判定不受影响:音符恒在 hitTime 抵达判定线,speed 仅改视觉下落快慢与出现时机,
// 判定链只依赖真实音符时间(hitTime ± range),与 speed 无共享变量。
private const float FlowSpeedGlobalMultiplier = 2.0f;
public float EffectiveSpeedMultiplier
{
get { return Mathf.Clamp(speedMultiplier, SpeedMultiplierMin, SpeedMultiplierMax) * FlowSpeedGlobalMultiplier; }
}
[Header("Note Fall Speed Source")]
[Tooltip("true=沿用谱面 BPM 决定下落速度(旧逻辑,高 BPM 歌音符更快);false=用固定参考 BPM" +
"下落速度不再随歌曲 BPM 变化,仅由流速 speedMultiplier 控制(全曲统一手感)。默认 false。")]
public bool useBpmForSpeed = false;
[Tooltip("useBpmForSpeed=false 时用作速度基准的固定参考 BPM。等价于“所有歌曲都按此 BPM 的手感下落”。" +
"建议 ≥60:地面粒子速度对 BPM 有 60 下限,低于 60 会使音符与粒子略微不同步。")]
public float referenceBpm = 120f;
// 速度/长条分段公式统一使用的“有效 BPM”。开=真实谱面 bpm;关=固定参考 bpm。
// 二者都同时喂给 noteSpeed 与 segmentInterval,故长条段间距(∝sm,bpm 相消)与地面粒子同步天然保持不变。
public float EffectiveBpm
{
get { return useBpmForSpeed ? bpm : Mathf.Max(1f, referenceBpm); }
}
[Header("Hold Note Body Fill")]
[Tooltip("true=新逻辑(默认):长条中段不拉伸(保持 prefab 原始大小),改为按流速生成更多中段密铺填满(流速越快段越多)。" +
"false=旧逻辑:中段数量固定,用 ApplyVisualScale(流速) 拉伸每段填满(流速越大越拉长)。" +
"两种方案下长条视觉长度一致、判定完全不变(中段纯视觉、end 段到达时间不变)。")]
public bool useMultiSegmentFill = true;
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 string NoteSpeedDefaultVersionKey = "noteSpeedMultiplierDefaultVersion";
private const int NoteSpeedDefaultVersion = 2;
private const float NoteSpeedDefault = 2f;
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()
{
// 去物理判定:清空上一场景/上一局缓存的判定线几何,重新捕获(判定线位置可能变)。
JudgeZoneGeometry.Reset();
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))
{
int version = PlayerPrefs.GetInt(NoteSpeedDefaultVersionKey, 0);
float saved = PlayerPrefs.GetFloat(NoteSpeedPrefKey, NoteSpeedDefault);
if (version < NoteSpeedDefaultVersion && Mathf.Approximately(saved, 3f))
{
saved = NoteSpeedDefault;
}
saved = Mathf.Clamp(saved, SpeedMultiplierMin, SpeedMultiplierMax);
PlayerPrefs.SetFloat(NoteSpeedPrefKey, saved);
PlayerPrefs.SetInt(NoteSpeedDefaultVersionKey, NoteSpeedDefaultVersion);
PlayerPrefs.Save();
return;
}
PlayerPrefs.SetFloat(NoteSpeedPrefKey, NoteSpeedDefault);
PlayerPrefs.SetInt(NoteSpeedDefaultVersionKey, NoteSpeedDefaultVersion);
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 = EffectiveSpeedMultiplier;
// 下落速度基准:useBpmForSpeed=true 用谱面 bpm(旧逻辑)false 用固定参考 bpm(速度不随歌曲变)。
// 判定时间不受影响:到达时刻只由 note.time 决定,speed 仅改视觉下落快慢与出现时机。
float speedBpm = EffectiveBpm;
float baseTravelTime = (60f / speedBpm) * 4f;
float baseNoteTravelTime = baseTravelTime / Mathf.Max(0.0001f, sm);
float noteSpeed = CalculateSpeed(baseNoteTravelTime);
float segmentInterval = (60f / speedBpm) / 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;
}
// 段间隔决定"每隔多少时间放一段"。段间距(世界坐标)= noteSpeed × 段间隔,而 noteSpeed ∝ 流速 sm。
// 旧逻辑(useMultiSegmentFill=false):段间隔不随 sm 变 → 段间距 ∝ sm,段数固定,
// 靠 ApplyVisualScale(sm) 把每段拉伸 sm 倍去填满变大的段间距(流速越大越拉长)。
// 新逻辑(useMultiSegmentFill=true):段间隔 ÷ sm → 段数 ∝ sm,而段间距 = noteSpeed × (段间隔/sm) = 恒定,
// 恰好等于不拉伸(scale=1)时 prefab 的原始段高,于是用等大的原始段密铺填满(流速越大段越多)。
// 两种方案下:end 段到达时间 = baseHit + length 完全不变(段数变但 actualSegmentInterval=length/count 抵消)
// 长条整体视觉长度一致;中段纯视觉、不参与计分/连击,故判定业务逻辑完全不受影响。
float effectiveSegmentInterval = segmentInterval;
if (useMultiSegmentFill)
{
effectiveSegmentInterval = segmentInterval / Mathf.Max(0.01f, sm);
}
int segmentCount = Mathf.CeilToInt(noteData.length / effectiveSegmentInterval);
if (segmentCount < 2) segmentCount = 2; // Force at least one middle segment between start and end.
float actualSegmentInterval = segmentCount > 0 ? noteData.length / segmentCount : effectiveSegmentInterval;
// 新逻辑下不拉伸(每段保持 prefab 原始大小);旧逻辑下按流速 sm 拉伸每段。
float holdVisualScale = useMultiSegmentFill ? 1f : sm;
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 拉伸;新逻辑(多段填充)=不拉伸(holdVisualScale=1)。
float visualScale = holdVisualScale;
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 = holdVisualScale;
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 = holdVisualScale;
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)
{
GameObject go = GameObject.FindGameObjectWithTag("JudgmentLine");
if (go != null) judgmentLine = go.transform;
}
// 去物理判定:捕获判定线 collider 的世界 Y 范围(只一次),供音符用几何阈值判进出判定区。
if (judgmentLine != null && !JudgeZoneGeometry.LineReady)
{
Collider2D lineCol = judgmentLine.GetComponent<Collider2D>();
if (lineCol == null) lineCol = judgmentLine.GetComponentInChildren<Collider2D>();
if (lineCol != null) JudgeZoneGeometry.CaptureLine(lineCol);
}
}
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 { }
}
}
}
}
}
}
}