Files
SoulliesOfficial b5cb6152ff MusicBeat
2026-05-26 00:21:27 -04:00

453 lines
15 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.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
}
}