using System.Collections.Generic; using Cielonos.Core; using Lean.Pool; using Sirenix.OdinInspector; using SLSUtilities.General; using SLSUtilities.UI; using TMPro; using UnityEngine; using UnityEngine.UI; namespace Cielonos.MainGame { /// /// 屏幕下方的节拍滚动时间轴 UI 控制器。 /// 节拍标记从右向左移动,到达判定线时代表「现在」。 /// 支持 tag 优先级系统:当一个节拍有多个 tag 时,显示优先级最高且在 beatMarkerCollection 中有对应 Prefab 的 tag。 /// 无 Prefab 对应的 tag 节拍将被忽略不显示 /// public class BeatTimelineUI : UIElementBase { #region Constants /// /// 默认预览的未来节拍数量 /// private const int DEFAULT_PREVIEW_BEAT_COUNT = 4; /// /// 节拍标记通过判定线后多少秒回收(留出一点视觉余量) /// private const float RECYCLE_OFFSET_SECONDS = 0.3f; /// /// 判定反馈文本显示时长(秒) /// private const float JUDGEMENT_DISPLAY_DURATION = 0.6f; #endregion #region Serialized Fields [TitleGroup("Timeline References")] [Tooltip("判定线 Image,位于时间轴左侧")] [SerializeField] private RectTransform judgeLine; [TitleGroup("Timeline References")] [Tooltip("节拍标记的父容器(标记在此 RectTransform 内移动)")] [SerializeField] private RectTransform markerContainer; [TitleGroup("Timeline References")] [Tooltip("节拍标记的 Prefab 模板(用于 LeanPool Spawn)")] [SerializeField] private GameObject markerPrefab; [TitleGroup("Timeline References")] [Tooltip("判定结果反馈文本")] [SerializeField] private TextMeshProUGUI judgementText; [TitleGroup("Settings")] [Tooltip("预览的未来节拍数量")] [MinValue(1), MaxValue(8)] [SerializeField] private int previewBeatCount = DEFAULT_PREVIEW_BEAT_COUNT; [TitleGroup("Tag Priority")] [Tooltip("Tag 优先级列表,索引越小优先级越高。只有在 beatMarkerCollection 中有对应 Prefab 的 tag 才会被显示")] [SerializeField] private List tagPriorityOrder = new List(); #endregion #region Private State private MusicBeatSystem beatSystem; private float timelineWidth; private float previewDuration; private bool isInitialized; // Active markers tracked for position update and recycling private readonly List activeMarkers = new List(); // Track which beat indices are currently displayed to avoid duplicates private readonly HashSet displayedBeatIndices = new HashSet(); // Judgement display private float judgementDisplayTimer; // Cached collection reference private MainGameBaseCollection baseCollection; #endregion #region Public API /// /// 初始化时间轴 UI,订阅 MusicBeatSystem 事件。 /// 支持重复调用(会先取消旧订阅) /// public void Initialize(MusicBeatSystem system) { if (system == null) { Debug.LogError("[BeatTimelineUI] Initialize failed: system is null"); return; } // 防止重复订阅:先取消旧事件 if (beatSystem != null) { beatSystem.OnActivated -= OnSystemActivated; beatSystem.OnDeactivated -= OnSystemDeactivated; beatSystem.OnPlayerBeatJudged -= OnPlayerJudged; } beatSystem = system; baseCollection = MainGameBaseCollection.Instance; // Subscribe beatSystem.OnActivated += OnSystemActivated; beatSystem.OnDeactivated += OnSystemDeactivated; beatSystem.OnPlayerBeatJudged += OnPlayerJudged; isInitialized = true; Hide(); } /// /// 在判定线位置显示 Perfect/Good/Miss 反馈 /// public void ShowJudgement(BeatJudgement judgement) { if (judgementText == null) return; judgementText.gameObject.SetActive(true); judgementDisplayTimer = JUDGEMENT_DISPLAY_DURATION; switch (judgement.accuracy) { case BeatAccuracy.Perfect: judgementText.text = "Perfect!"; judgementText.color = new Color(1f, 0.9f, 0.2f); // Gold break; case BeatAccuracy.Good: judgementText.text = "Good"; judgementText.color = new Color(0.3f, 1f, 0.5f); // Green break; case BeatAccuracy.Miss: judgementText.text = "Miss"; judgementText.color = new Color(0.6f, 0.6f, 0.6f); // Gray break; } } /// /// 显示/隐藏时间轴 UI /// public void SetVisible(bool visible) { if (visible) Show(); else Hide(); } #endregion #region Lifecycle private void Update() { if (!isInitialized || beatSystem == null || !beatSystem.IsActive || !beatSystem.IsPlaying) return; // 每帧刷新 timelineWidth,防止首帧布局未完成导致宽度为 0 RefreshTimelineWidth(); UpdatePreviewDuration(); // 先生成新标记,再更新位置和回收旧标记 // 确保新生成的标记在同一帧内即可被定位和显示 SpawnUpcomingMarkers(); UpdateMarkers(); UpdateJudgementDisplay(); } private void OnDestroy() { if (beatSystem != null) { beatSystem.OnActivated -= OnSystemActivated; beatSystem.OnDeactivated -= OnSystemDeactivated; beatSystem.OnPlayerBeatJudged -= OnPlayerJudged; } } #endregion #region Event Handlers private void OnSystemActivated(MusicBeatData beatData) { DespawnAllMarkers(); displayedBeatIndices.Clear(); SetVisible(true); } private void OnSystemDeactivated() { DespawnAllMarkers(); displayedBeatIndices.Clear(); SetVisible(false); } private void OnPlayerJudged(BeatJudgement judgement) { ShowJudgement(judgement); } #endregion #region Core Update Logic /// /// 刷新时间轴宽度。RectTransform 在 SetActive(true) 后的首帧可能尚未完成布局, /// 因此每帧检查并更新,直到获取到有效值 /// private void RefreshTimelineWidth() { if (markerContainer == null) return; float width = markerContainer.rect.width; if (width > 0f) { timelineWidth = width; } } /// /// 根据当前 BPM 和预览拍数计算需要预览的时间范围 /// private void UpdatePreviewDuration() { if (beatSystem.CurrentBeatData == null) return; previewDuration = beatSystem.CurrentBeatData.BeatInterval * previewBeatCount; if (previewDuration <= 0f) previewDuration = 2f; } /// /// 更新所有活跃标记的位置,回收已过期的标记 /// private void UpdateMarkers() { if (timelineWidth <= 0f) return; float currentTime = beatSystem.CurrentSongTime; for (int i = activeMarkers.Count - 1; i >= 0; i--) { var marker = activeMarkers[i]; if (marker == null) { activeMarkers.RemoveAt(i); continue; } // BeatData 可能在 Despawn 时被清除,视为无效标记 if (marker.BeatData == null) { DespawnMarker(marker); activeMarkers.RemoveAt(i); continue; } float beatTime = marker.BeatData.time; float timeUntilBeat = beatTime - currentTime; // 已过判定线一段时间,回收 if (timeUntilBeat < -RECYCLE_OFFSET_SECONDS) { DespawnMarker(marker); activeMarkers.RemoveAt(i); continue; } // 归一化位置:0 = 判定线,1 = 时间轴最右端 float normalizedPos = Mathf.Clamp01(timeUntilBeat / previewDuration); marker.UpdatePosition(normalizedPos, timelineWidth); } } /// /// 生成即将到来的节拍标记 /// private void SpawnUpcomingMarkers() { if (beatSystem.CurrentBeatData == null) return; if (timelineWidth <= 0f) return; float currentTime = beatSystem.CurrentSongTime; float lookAheadEnd = currentTime + previewDuration; var markers = beatSystem.CurrentBeatData.beatMarkers; if (markers == null) return; for (int i = 0; i < markers.Count; i++) { // Skip already displayed if (displayedBeatIndices.Contains(i)) continue; var beat = markers[i]; // Skip beats already past recycle window if (beat.time < currentTime - RECYCLE_OFFSET_SECONDS) continue; // Beats are sorted by time; beyond preview range, stop if (beat.time > lookAheadEnd) break; // Resolve which pointer to use based on tag priority GameObject pointer = ResolvePointerForBeat(beat); //Debug.Log($"[BeatTimelineUI] Resolving pointer for beat at {beat.time:F2}s with tags [{string.Join(", ", beat.tags)}]: {(pointer != null ? pointer.name : "None")}"); // If no pointer found, skip this beat (e.g., enemy-only beats) if (pointer == null) continue; // Spawn via LeanPool var markerUI = LeanPool.Spawn(markerPrefab, markerContainer).GetComponent(); if (markerUI == null) { Debug.LogWarning("[BeatTimelineUI] LeanPool.Spawn returned null"); continue; } markerUI.Setup(beat, pointer); // 立即定位,避免闪烁 float timeUntilBeat = beat.time - currentTime; float normalizedPos = Mathf.Clamp01(timeUntilBeat / previewDuration); markerUI.UpdatePosition(normalizedPos, timelineWidth); activeMarkers.Add(markerUI); displayedBeatIndices.Add(i); } } /// /// 更新判定反馈文本的显示计时 /// private void UpdateJudgementDisplay() { if (judgementText == null || !judgementText.gameObject.activeSelf) return; judgementDisplayTimer -= Time.deltaTime; if (judgementDisplayTimer <= 0f) { judgementText.gameObject.SetActive(false); } else { // Fade out float alpha = Mathf.Clamp01(judgementDisplayTimer / JUDGEMENT_DISPLAY_DURATION); var color = judgementText.color; color.a = alpha; judgementText.color = color; } } #endregion #region Tag Priority Resolution /// /// 根据 tag 优先级解析节拍应使用的 Pointer Prefab。 /// 遍历节拍的 tags,按 tagPriorityOrder 的优先级排序, /// 返回第一个在 beatMarkerCollection 中有对应 Prefab 的 tag 的 Pointer。 /// 若无任何 tag 匹配,返回 null(该节拍不显示) /// private GameObject ResolvePointerForBeat(BeatMarker beat) { if (baseCollection == null || beat == null) { Debug.LogError("[BeatTimelineUI] ResolvePointerForBeat failed: baseCollection or beat is null"); return null; } SerializedDictionary markerCollection = baseCollection.beatMarkerCollection; // No tags: check if there's a default pointer (empty string key or "Default") if (beat.tags == null || beat.tags.Count == 0) { if (markerCollection.TryGetValue("Normal", out var defaultPointer)) return defaultPointer; return null; } // Single tag: fast path if (beat.tags.Count == 1) { if (markerCollection.TryGetValue(beat.tags[0], out var pointer)) return pointer; return null; } // Multiple tags: use priority order for (int p = 0; p < tagPriorityOrder.Count; p++) { string priorityTag = tagPriorityOrder[p]; if (beat.HasTag(priorityTag)) { if (markerCollection.TryGetValue(priorityTag, out var pointer)) return pointer; } } // Fallback: try any tag that has a pointer, in the order they appear on the beat for (int t = 0; t < beat.tags.Count; t++) { if (markerCollection.TryGetValue(beat.tags[t], out var pointer)) return pointer; } return null; } #endregion #region LeanPool Management /// /// 通过 LeanPool 回收单个标记 /// private void DespawnMarker(BeatMarkerUI marker) { if (marker == null) return; LeanPool.Despawn(marker); } /// /// 回收所有活跃标记 /// private void DespawnAllMarkers() { for (int i = activeMarkers.Count - 1; i >= 0; i--) { DespawnMarker(activeMarkers[i]); } activeMarkers.Clear(); } #endregion } }