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

759 lines
28 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.
[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;
// 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.8f, 1.25f)]
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 WaitForSecondsRealtime calibrateWait;
private float calibrateWaitSeconds = float.NaN;
// 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.8f;
private const float SpeedMultiplierMax = 1.25f;
public NoteJudgeConfig judgeConfig; // Documentation text normalized.
private Beatmap beatmap;
private float startTime; // Documentation text normalized.
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 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;
private void Awake()
{
// Load saved visual speed multiplier before any spawning logic uses it
float saved = PlayerPrefs.GetFloat(NoteSpeedPrefKey, speedMultiplier);
saved = Mathf.Clamp(saved, SpeedMultiplierMin, SpeedMultiplierMax);
speedMultiplier = saved;
if (JudgeManager.IsDebugEnabled) Debug.Log($"[NoteSpawner] Loaded speedMultiplier={speedMultiplier} from PlayerPrefs");
}
private void OnEnable()
{
// Documentation text normalized.
BindImmediateSettlementButton();
// subscribe to JudgeManager.AllNotesJudged so we disable animations on normal settlement as well
TrySubscribeJudgeManager();
}
private void Start()
{
// 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;
}
// 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 = Time.time;
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];
float travelTime = GetLaneTravelTimeSeconds(laneTravelTimes, note.trackIndex);
float spawnTime = note.time - travelTime;
float delay = spawnTime - (Time.time - startTime) + spawnOffset;
if (delay > 0)
{
// Use scaled-time wait so spawning is paused while Time.timeScale==0 (PauseManager pause)
float target = Time.time + delay;
// Wait using frames so this loop respects Time.timeScale (Time.time won't advance when paused)
while (Time.time < target)
{
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);
noteIndexToHoldId[i] = hid;
}
else
{
SpawnNote(note, sm, travelTime, noteSpeed);
}
}
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)
{
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;
}
Transform spawnPoint = spawnPoints[noteData.trackIndex];
// Calculate hit time (realtime when note should be judged)
float rawHit = startTime + noteData.time + globalHitDelay;
float realtimeHit = Mathf.Max(0f, rawHit);
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, realtimeHit, noteData.color, judgeConfig, noteData);
// Configure controller for absolute positioning (replaces relative Translate)
if (noteController != null)
{
noteController.ConfigureAbsolutePositioning(initialPosition, realtimeHit, travelTime, 0f);
}
// Schedule calibration for short note to correct any drift
StartCoroutine(CalibrateNoteAfterSpawn(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)
{
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 < 1) segmentCount = 1; // Documentation text normalized.
float actualSegmentInterval = segmentCount > 0 ? noteData.length / segmentCount : segmentInterval;
Transform spawnPoint = spawnPoints[noteData.trackIndex];
// Documentation text normalized.
float rawScheduledEnd = startTime + (noteData.time + noteData.length) + globalHitDelay;
float scheduledEndTime = Mathf.Max(0f, rawScheduledEnd); // clamp to non-negative
// Documentation text normalized.
int holdNoteId = ++holdNoteIdCounter;
// base realtime for hits
float rawBase = startTime + noteData.time + globalHitDelay;
float baseHit = Mathf.Max(0f, rawBase);
GameObject startObj = notePool.GetStartNote(noteData.color);
if (startObj == null)
{
Debug.LogError("NotePool returned null hold start object.");
return -1;
}
// 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);
// compute visual scale for hold segments so pieces visually connect across speedMultiplier changes
float visualScale = 0.9f * sm + 0.1f; // linear fit: f(1)=1, f(2)=1.9
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);
// apply same visual scale so middle pieces visually connect
float visualScaleMid = 0.9f * sm + 0.1f;
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);
StartCoroutine(CalibrateAfterSpawn(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);
// apply visual scale to end piece as well
float visualScaleEnd = 0.9f * sm + 0.1f;
holdEnd.ApplyVisualScale(visualScaleEnd);
holdEnd.visualSpeedMultiplier = sm;
// schedule calibration for end as well to be safe
Vector3 calibEndPos = spawnPoint.position;
holdEnd.CalibratePosition(calibEndPos, calibrateTolerance);
StartCoroutine(CalibrateAfterSpawn(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 IEnumerator CalibrateAfterSpawn(HoldNote seg, Vector3 spawnPos)
{
if (seg == null) yield break;
for (int i = 0; i < Mathf.Max(1, calibrateChecks); i++)
{
yield return GetCalibrateWait();
if (seg == null || !seg.gameObject.activeSelf) yield break;
seg.CalibratePosition(spawnPos, calibrateTolerance);
}
}
/// <summary>
/// Documentation text normalized.
private IEnumerator CalibrateNoteAfterSpawn(NoteController noteController)
{
if (noteController == null) yield break;
GameObject noteObj = noteController.gameObject;
for (int i = 0; i < Mathf.Max(1, calibrateChecks); i++)
{
yield return GetCalibrateWait();
if (noteObj == null || !noteObj.activeSelf) yield break;
Vector3 expected = noteController.GetExpectedPosition(Time.time);
float distanceDeviation = Vector3.Distance(noteObj.transform.position, expected);
if (distanceDeviation > calibrateTolerance)
{
noteObj.transform.position = expected;
if (JudgeManager.IsDebugEnabled) Debug.Log($"[NoteSpawner] Calibrated short note position, deviation was {distanceDeviation:F4}");
}
}
}
private WaitForSecondsRealtime GetCalibrateWait()
{
if (calibrateWait == null || calibrateWaitSeconds != calibrateInterval)
{
calibrateWaitSeconds = calibrateInterval;
calibrateWait = new WaitForSecondsRealtime(calibrateInterval);
}
return calibrateWait;
}
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={Time.time:F3})");
}
while (Time.time < 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 { }
}
}
}
}
}
}
}