Files
2026-07-30 23:15:58 +08:00

1052 lines
42 KiB
C#
Raw Permalink 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("Visual Spawn Lead")]
[Tooltip("Seconds to spawn notes earlier for visual readability only. Positive values make notes appear sooner without changing hit timing or judgement.")]
public float visualPreSpawnTime = 0f;
// Inspector 设定的视觉提前量基准;玩家 PlayerPrefs 视觉偏移在此之上叠加。
private float visualPreSpawnTimeBase = 0f;
private bool visualPreSpawnBaseCaptured = false;
[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, 3.25f)]
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.25; FlowSpeedGlobalMultiplier makes the real range 2-6.5.
private const float SpeedMultiplierMin = 1f;
private const float SpeedMultiplierMax = 3.25f;
// 全局流速系数:与用户可调流速(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();
}
// 计算最终 spawnOffset = 玩家校准量(PlayerPrefs) + 项目级定数(Inspector)。
// 必须在生成协程启动前调用;Start 与 LoadBeatmap 都调,保证不受生命周期顺序影响。
private void ApplySpawnOffsetFromPrefs()
{
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})");
ApplyVisualOffsetFromPrefs();
}
// 纯视觉偏移:仅影响音符出现时机(chartSpawnTime),不改变判定时间(chartHitTime)
// 因此对业务逻辑绝对等效。玩家值叠加在 Inspector 基准之上。
private void ApplyVisualOffsetFromPrefs()
{
if (!visualPreSpawnBaseCaptured)
{
visualPreSpawnTimeBase = visualPreSpawnTime;
visualPreSpawnBaseCaptured = true;
}
const string VISUAL_OFFSET_PREFS_KEY = "UserVisualOffsetSeconds";
float savedVisual = PlayerPrefs.GetFloat(VISUAL_OFFSET_PREFS_KEY, 0f);
visualPreSpawnTime = visualPreSpawnTimeBase + savedVisual;
if (JudgeManager.IsDebugEnabled) Debug.Log($"[NoteSpawner] Applied visualPreSpawnTime={visualPreSpawnTime} (Base={visualPreSpawnTimeBase} + Saved={savedVisual})");
}
private void Start()
{
// 读取 PlayerPrefs 中的延迟偏移值(秒)并应用
ApplySpawnOffsetFromPrefs();
// 读取 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();
// 谱面修饰符:每次加载谱面时重建映射表(Random 会固定一次洗牌结果)
ChartModifiers.InvalidateMapping();
// 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}");
// 关键:LoadBeatmap 可能早于本组件的 Start() 执行(GameManager 在启动流程里直接调本方法),
// 若只在 Start 里算 spawnOffset,生成协程会读到默认值 0,导致 Inspector 定数/玩家校准完全失效。
// 故在启动生成协程前再算一次,保证不受生命周期顺序影响。
ApplySpawnOffsetFromPrefs();
// 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;
// globalHitDelay 是输入/音画延迟补偿:它已进入判定时刻(chartHitTime/baseHit/scheduledEndTime)
// 因而音符 activationTime = hitTime - travelTime 也被整体前/后移。生成时刻必须同样加上它,
// 否则负补偿(音符提前到线)时 activationTime 提前、生成没提前,音符会一出生就弹到半路。
// 加上后 chartSpawnTime 恒等于 activationTime(再减去 visualPreSpawnTime 提前量),各取值下都从出生点自然下落。
float chartSpawnTime = startTime + spawnTime + spawnOffset + globalHitDelay - Mathf.Max(0f, visualPreSpawnTime);
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();
}
// Caches the pooled note's sync-note child so we can toggle it on/off per spawn instead of
// Destroy+Instantiate (plus a child-enumerator alloc) on every note. The child is created
// lazily the first time a note actually needs it and reused for the note's whole lifetime.
private sealed class SyncNoteChildRef : MonoBehaviour
{
public GameObject child;
}
private void ApplySyncNoteChild(GameObject note, bool isSync)
{
if (note == null || !enableSyncNotePrefab || syncNotePrefab == null)
{
return;
}
var refComp = note.GetComponent<SyncNoteChildRef>();
if (refComp == null)
{
refComp = note.AddComponent<SyncNoteChildRef>();
}
// Create the sync child once (only if this note has ever needed it).
if (refComp.child == null && isSync)
{
refComp.child = Instantiate(syncNotePrefab, note.transform);
}
if (refComp.child != null && refComp.child.activeSelf != isSync)
{
refComp.child.SetActive(isSync);
}
}
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;
}
// 应用谱面修饰符(Mirror/Random):重映射 trackIndex
int remappedTrackIndex = ChartModifiers.RemapTrackIndex(noteData.trackIndex);
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;
}
ApplySyncNoteChild(note, isSync);
Transform spawnPoint = spawnPoints[remappedTrackIndex];
// Calculate hit time (realtime when note should be judged)
// spawnOffset = 玩家在设置里校准的全局延迟量(PlayerPrefs UserGlobalDelaySeconds)
// 必须与生成时机(chartSpawnTime)一致地进入 hitTime,否则判定窗口不随校准移动、
// 且 activation=hitTime-travelTime 与生成时机错位导致音符出生即弹跳。
// 与谱面 globalDelaySeconds(作用于音频播放)彼此独立,不重复偏移。
float chartHitTime = Mathf.Max(0f, startTime + noteData.time + spawnOffset + 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 — 传入重映射后的 trackIndex
noteScript.Setup(key, remappedTrackIndex, 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;
}
// 应用谱面修饰符(Mirror/Random):重映射 trackIndex
int remappedTrackIndex = ChartModifiers.RemapTrackIndex(noteData.trackIndex);
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[remappedTrackIndex];
// Documentation text normalized.
float scheduledEndTime = Mathf.Max(0f, startTime + (noteData.time + noteData.length) + spawnOffset + globalHitDelay);
// Documentation text normalized.
int holdNoteId = ++holdNoteIdCounter;
// base realtime for hits
float baseHit = Mathf.Max(0f, startTime + noteData.time + spawnOffset + globalHitDelay);
GameObject startObj = notePool.GetStartNote(noteData.color);
if (startObj == null)
{
Debug.LogError("NotePool returned null hold start object.");
return -1;
}
ApplySyncNoteChild(startObj, isSync);
// 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 — 使用重映射后的 trackIndex
holdNote.Setup(holdNoteId, remappedTrackIndex, 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;
}
// 单条 Tiled body 取代原来的 N 个 middle 段密铺:一条 Tiled 长条覆盖 head→end 整段,
// 按绝对时间下落,end 越过判定线后回收。它纯视觉、不参与判定/计分,故 segmentCount /
// actualSegmentInterval 只再用于 end 段延迟(下方 endDelay),中段判定业务逻辑完全不变。
{
GameObject bodyObj = notePool.GetHoldNoteSegment(noteData.color);
if (bodyObj == null)
{
Debug.LogError("NotePool returned null hold body object.");
}
else
{
bodyObj.transform.position = spawnPoint.position;
bodyObj.transform.rotation = Quaternion.identity;
HoldNote holdBody = bodyObj.GetComponent<HoldNote>();
if (holdBody != null)
{
holdBody.visualSpeedMultiplier = sm;
holdBody.SetupBody(holdNoteId, remappedTrackIndex, noteSpeed, baseHit,
noteData.length, travelTime, noteData.color, spawnPoint.position);
}
else
{
Debug.LogError("Hold body 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, remappedTrackIndex, 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;
}
// 正向校准/延迟会把音符整体后移,结算需相应顺延以免最后一颗音符尚在判定就触发结算;
// 负向偏移不提前结算(钳 0),避免切掉仍在场的音符。postChartDelay 另有 2s 缓冲兜底。
chartEndTime += Mathf.Max(0f, spawnOffset + 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 { }
}
}
}
}
}
}
}