MusicBeat
This commit is contained in:
@@ -0,0 +1,452 @@
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// 屏幕下方的节拍滚动时间轴 UI 控制器。
|
||||
/// 节拍标记从右向左移动,到达判定线时代表「现在」。
|
||||
/// 支持 tag 优先级系统:当一个节拍有多个 tag 时,显示优先级最高且在 beatMarkerCollection 中有对应 Prefab 的 tag。
|
||||
/// 无 Prefab 对应的 tag 节拍将被忽略不显示
|
||||
/// </summary>
|
||||
public class BeatTimelineUI : UIElementBase
|
||||
{
|
||||
#region Constants
|
||||
|
||||
/// <summary>
|
||||
/// 默认预览的未来节拍数量
|
||||
/// </summary>
|
||||
private const int DEFAULT_PREVIEW_BEAT_COUNT = 4;
|
||||
|
||||
/// <summary>
|
||||
/// 节拍标记通过判定线后多少秒回收(留出一点视觉余量)
|
||||
/// </summary>
|
||||
private const float RECYCLE_OFFSET_SECONDS = 0.3f;
|
||||
|
||||
/// <summary>
|
||||
/// 判定反馈文本显示时长(秒)
|
||||
/// </summary>
|
||||
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<string> tagPriorityOrder = new List<string>();
|
||||
|
||||
#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<BeatMarkerUI> activeMarkers = new List<BeatMarkerUI>();
|
||||
|
||||
// Track which beat indices are currently displayed to avoid duplicates
|
||||
private readonly HashSet<int> displayedBeatIndices = new HashSet<int>();
|
||||
|
||||
// Judgement display
|
||||
private float judgementDisplayTimer;
|
||||
|
||||
// Cached collection reference
|
||||
private MainGameBaseCollection baseCollection;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Public API
|
||||
|
||||
/// <summary>
|
||||
/// 初始化时间轴 UI,订阅 MusicBeatSystem 事件。
|
||||
/// 支持重复调用(会先取消旧订阅)
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在判定线位置显示 Perfect/Good/Miss 反馈
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 显示/隐藏时间轴 UI
|
||||
/// </summary>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// 刷新时间轴宽度。RectTransform 在 SetActive(true) 后的首帧可能尚未完成布局,
|
||||
/// 因此每帧检查并更新,直到获取到有效值
|
||||
/// </summary>
|
||||
private void RefreshTimelineWidth()
|
||||
{
|
||||
if (markerContainer == null) return;
|
||||
|
||||
float width = markerContainer.rect.width;
|
||||
if (width > 0f)
|
||||
{
|
||||
timelineWidth = width;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据当前 BPM 和预览拍数计算需要预览的时间范围
|
||||
/// </summary>
|
||||
private void UpdatePreviewDuration()
|
||||
{
|
||||
if (beatSystem.CurrentBeatData == null) return;
|
||||
previewDuration = beatSystem.CurrentBeatData.BeatInterval * previewBeatCount;
|
||||
if (previewDuration <= 0f) previewDuration = 2f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新所有活跃标记的位置,回收已过期的标记
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成即将到来的节拍标记
|
||||
/// </summary>
|
||||
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<BeatMarkerUI>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新判定反馈文本的显示计时
|
||||
/// </summary>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// 根据 tag 优先级解析节拍应使用的 Pointer Prefab。
|
||||
/// 遍历节拍的 tags,按 tagPriorityOrder 的优先级排序,
|
||||
/// 返回第一个在 beatMarkerCollection 中有对应 Prefab 的 tag 的 Pointer。
|
||||
/// 若无任何 tag 匹配,返回 null(该节拍不显示)
|
||||
/// </summary>
|
||||
private GameObject ResolvePointerForBeat(BeatMarker beat)
|
||||
{
|
||||
if (baseCollection == null || beat == null)
|
||||
{
|
||||
Debug.LogError("[BeatTimelineUI] ResolvePointerForBeat failed: baseCollection or beat is null");
|
||||
return null;
|
||||
}
|
||||
|
||||
SerializedDictionary<string, GameObject> 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
|
||||
|
||||
/// <summary>
|
||||
/// 通过 LeanPool 回收单个标记
|
||||
/// </summary>
|
||||
private void DespawnMarker(BeatMarkerUI marker)
|
||||
{
|
||||
if (marker == null) return;
|
||||
LeanPool.Despawn(marker);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 回收所有活跃标记
|
||||
/// </summary>
|
||||
private void DespawnAllMarkers()
|
||||
{
|
||||
for (int i = activeMarkers.Count - 1; i >= 0; i--)
|
||||
{
|
||||
DespawnMarker(activeMarkers[i]);
|
||||
}
|
||||
|
||||
activeMarkers.Clear();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user