Files
ichni_Official/Assets/Scripts/UI/SongSelection/SongListControllerUI.cs
2026-07-26 12:12:45 +08:00

499 lines
20 KiB
C#
Raw 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 UnityEngine.UI;
using UnityEngine.EventSystems;
using System.Collections;
using System.Collections.Generic;
using DG.Tweening;
using Sirenix.OdinInspector;
using SLSUtilities.WwiseAssistance;
namespace Ichni.Menu
{
// 一个完全自定义的列表控制器,实现了拖拽、惯性、边界和吸附
public partial class SongListControllerUI : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{
[Title("核心组件")]
[SerializeField] public RectTransform content;
[SerializeField] public RectTransform viewport;
[Title("预制体")]
[SerializeField]
private GameObject songItemPrefab;
[Title("对齐与动画")]
[SerializeField] public RectTransform centerPoint;
[SerializeField] private float snapSpeed = 10f;
[SerializeField] private float decelerationRate = 0.15f;
[Title("平滑度优化")]
[SerializeField]
[Range(1f, 20f)]
private float dragSmoothing = 16f; // 拖拽平滑度的阻尼值可在Inspector中调节
[SerializeField]
[Range(1f, 20f)]
private float releaseSmoothing = 4f; // 松手后平滑度的阻尼值可在Inspector中调节
[Title("甩动判定")]
[Tooltip("当松手时的速度大于此值,才会被判定为一次“甩动”并产生惯性")]
[SerializeField] private float flickThreshold = 50f;
[Title("吸附判定")]
[Tooltip("当甩动惯性低于这玩意的时候就吸附")]
[SerializeField] private float SnapThreshold = 10f;
[Tooltip("吸附选项时按当前速度预判的时间,值越大越容易顺着滑动方向吸附到下一首")]
[SerializeField] [Min(0f)] private float snapVelocityProjectionTime = 0.12f;
public SongSelectionTab selectedTab;
// 内部变量
public List<RectTransform> songItems = new List<RectTransform>();
private Vector2 velocity;
private bool isDragging = false;
public float topBound;
public float bottomBound = 0f;
private Vector2 targetPosition; // 【新增】内容的目标位置
private float targetX = 1500f;
public bool isDuringSnap = false;
private bool isSnapRequested = false;
public RectTransform closestTab;
// 仅供 Story SongBlock 在“下一次初始化列表”时指定初始歌曲;不写入章节缓存,
// 以免剧情入口覆盖玩家平时在该章节记录的歌曲/难度选择。
private SongItemData _initialSongSelectionOverride;
/// <summary>
/// 请求下一次 <see cref="InitializeList"/> 自动选中指定歌曲。
/// 此请求是一次性的:无论目标是否仍存在,初始化完成后都会清除。
/// </summary>
public void RequestInitialSongSelection(SongItemData song)
{
_initialSongSelectionOverride = song;
}
public void InitializeList()
{
// FadeOut 通常已经清理过旧 Tab但这里仍保持幂等防止重复初始化时残留旧引用或重复 UI。
ClearSongTabs();
GenerateSongTabs();
if (songItems.Count == 0)
{
// 空 Chapter 是内容异常;运行时只进入安全状态,不访问 songItems[0],也不允许进入游戏。
selectedTab = null;
closestTab = null;
topBound = content.anchoredPosition.y;
bottomBound = content.anchoredPosition.y;
targetPosition = content.anchoredPosition;
MenuManager.instance.songSelectionUIPage.selectedSong = null;
MenuManager.instance.songSelectionUIPage.selectedSave = null;
MenuManager.instance.songSelectionUIPage.difficultySelectionContainer.SetUp(null);
_initialSongSelectionOverride = null;
return;
}
Canvas.ForceUpdateCanvases();
topBound = (songItems.Count * 144f + (songItems.Count - 1) * 60f) - 72f; //topBound中144为tab高度60为tab间距72为tab高度的一半
bottomBound = 72f; //bottomBound中72为tab高度的一半
int songIndex = 0;
int preferredDifficultyIndex = 0;
ChapterSelectionUnit currentChapter = ChapterSelectionManager.instance.currentChapter;
MenuInformationRecorder menuInformationRecorder = MenuInformationRecorder.instance;
if (menuInformationRecorder.TryGetRecordOfThisChapter(out SongSelectionRecord record))
{
preferredDifficultyIndex = record.difficultyListIndex;
SongItemData cachedSong = record.song;
bool cacheNeedsRepair = false;
// 优先按对象引用恢复;若缓存来自内容热更新/重建,再按 songName 进行兼容匹配。
songIndex = songItems.FindIndex(item => item != null && item.GetComponent<SongSelectionTab>()?.connectedSong == cachedSong);
if (songIndex < 0 && !string.IsNullOrEmpty(cachedSong?.songName))
{
songIndex = songItems.FindIndex(item => item != null &&
item.GetComponent<SongSelectionTab>()?.connectedSong?.songName == cachedSong.songName);
cacheNeedsRepair = songIndex >= 0;
}
// 缓存歌曲已经被删除或改名时,立即修正缓存;难度由 SnapToItem 内的统一回退机制继续解析。
if (songIndex < 0)
{
songIndex = 0;
cacheNeedsRepair = true;
}
if (cacheNeedsRepair)
{
SongItemData resolvedSong = songItems[songIndex].GetComponent<SongSelectionTab>().connectedSong;
menuInformationRecorder.SetRecordForChapter(currentChapter, resolvedSong, preferredDifficultyIndex);
}
}
// Story SongBlock 的目标歌曲优先于常规章节缓存,但只影响本次页面打开。
// 先比对对象引用,再按稳定 songName 兼容热更新或重建后的 SongItemData 实例。
if (_initialSongSelectionOverride != null)
{
int overrideIndex = songItems.FindIndex(item => item != null &&
item.GetComponent<SongSelectionTab>()?.connectedSong == _initialSongSelectionOverride);
if (overrideIndex < 0 && !string.IsNullOrEmpty(_initialSongSelectionOverride.songName))
{
overrideIndex = songItems.FindIndex(item => item != null &&
item.GetComponent<SongSelectionTab>()?.connectedSong?.songName ==
_initialSongSelectionOverride.songName);
}
if (overrideIndex >= 0)
{
songIndex = overrideIndex;
}
else
{
Debug.LogWarning($"[SongListControllerUI] 请求自动选中的歌曲 '{_initialSongSelectionOverride.songName}' 不在当前章节列表中,已保留常规缓存选择。");
}
_initialSongSelectionOverride = null;
}
StartCoroutine(SnapToItem(songItems[songIndex], true));
closestTab = songItems[songIndex];
targetPosition = content.anchoredPosition;
targetX = -1500f;
}
private void Update()
{
RectTransform closestItem = closestTab;
float cloesestY = Mathf.Infinity;
foreach (RectTransform item in songItems)
{
float distance = Mathf.Abs(item.position.y - centerPoint.position.y);
if (distance < cloesestY)
{
cloesestY = distance;
closestItem = item;
}
}
if (closestItem != null && closestTab != closestItem)
{
closestTab = closestItem;
AudioManager.Post(AK.EVENTS.SWITCHTAB);
}
}
// 使用LateUpdate来处理所有位置更新防止抖动
void LateUpdate()
{
// 如果不在拖拽,则处理惯性
// 注意:我们只在 isDragging 为 false 时处理惯性,
// OnEndDrag中已经对速度进行了判断所以这里的逻辑依然适用
if (!isDragging && !isDuringSnap && Mathf.Abs(velocity.y) > 0.1f)
{
// 将速度施加于目标位置
targetPosition.y += velocity.y * Time.deltaTime;
// 速度衰减
velocity *= (1 - decelerationRate);
// 惯性速度回落到吸附范围后,就请求吸附到最近歌曲。
if (!isSnapRequested && Mathf.Abs(velocity.y) <= SnapThreshold)
{
StartCoroutine(SnapToClosest());
}
}
// 无论何时都将Content的实际位置平滑地Lerp到目标位置
float damping = isDragging ? dragSmoothing : releaseSmoothing;
Vector2 finalPosition = Vector2.Lerp(content.anchoredPosition, targetPosition, Time.deltaTime * damping);
finalPosition.x = targetX;
content.anchoredPosition = finalPosition;
// 每次更新后都施加边界限制
ClampContentPosition();
}
public void OnBeginDrag(PointerEventData eventData)
{
isDragging = true;
velocity = Vector2.zero;
StopAllCoroutines();
isSnapRequested = false;
isDuringSnap = false;
// 开始拖拽时,将目标位置与当前位置同步
targetPosition = content.anchoredPosition;
selectedTab?.SetSelection(false);
selectedTab = null; // 清除当前选中的Tab
//DOTween.To(x=>targetX = x, targetX, -1550f, 0.2f).SetEase(Ease.OutQuad).Play();
//songItems.ForEach(item => item.DOScale(1.1f,0.2f).SetEase(Ease.OutQuad).Play());
}
public void OnDrag(PointerEventData eventData)
{
// 拖拽时只更新目标位置而不是直接移动Content
targetPosition += new Vector2(0, eventData.delta.y);
// 【核心修正 #1】计算速度时只使用Y轴分量
velocity = new Vector2(0, eventData.delta.y / Time.deltaTime);
}
public void OnEndDrag(PointerEventData eventData)
{
isDragging = false;
//DOTween.To(x => targetX = x, targetX, -1500f, 0.2f).SetEase(Ease.OutQuad).Play();
//songItems.ForEach(item => item.DOScale(1,0.2f).SetEase(Ease.OutQuad).Play());
// 【核心修正】在这里根据速度决定下一步做什么
if (Mathf.Abs(velocity.y) > flickThreshold)
{
// 速度足够大,是“甩动”,让 LateUpdate 中的惯性逻辑接管
// 我们什么都不用做LateUpdate会自动处理
}
else
{
// 速度很小,是“慢速拖拽”,立即开始吸附
StartCoroutine(SnapToClosest());
}
}
private void ClampContentPosition()
{
// 【核心修正 #2】现在我们限制的是目标位置让Lerp去处理实际位置
targetPosition.y = Mathf.Clamp(targetPosition.y, bottomBound, topBound);
}
}
public partial class SongListControllerUI
{
public void ClearSongTabs()
{
StopAllCoroutines();
foreach (RectTransform item in songItems)
{
if (item != null)
{
Destroy(item.gameObject);
}
}
songItems.Clear();
selectedTab = null;
closestTab = null;
velocity = Vector2.zero;
isDragging = false;
SnapCoroutine = null;
targetPosition = content.anchoredPosition;
isDuringSnap = false;
isSnapRequested = false;
}
public void GenerateSongTabs()
{
ChapterSelectionUnit chapterUnit = ChapterSelectionManager.instance.currentChapter;
if (chapterUnit == null || chapterUnit.songs == null)
{
return;
}
foreach (SongItemData song in chapterUnit.songs)
{
if (song == null)
{
Debug.LogWarning("Skipped an empty song entry while generating song selection tabs.");
continue;
}
SongSelectionTab tab = Instantiate(songItemPrefab, content).GetComponent<SongSelectionTab>();
songItems.Add(tab.GetComponent<RectTransform>());
tab.SetUpTab(song);
}
}
}
public partial class SongListControllerUI
{
public void GoToFormerTab()
{
if (closestTab != null)
{
int currentIndex = songItems.IndexOf(closestTab);
if (currentIndex > 0)
{
SongSelectionTab formerTab = songItems[currentIndex - 1].GetComponent<SongSelectionTab>();
StartCoroutine(SnapToTab(formerTab));
}
}
}
public void GoToNextTab()
{
if (closestTab != null)
{
int currentIndex = songItems.IndexOf(closestTab);
if (currentIndex < songItems.Count - 1)
{
SongSelectionTab nextTab = songItems[currentIndex + 1].GetComponent<SongSelectionTab>();
StartCoroutine(SnapToTab(nextTab));
}
}
}
}
public partial class SongListControllerUI
{
/// <summary>
/// 列表滚动进度0 = 第一首1 = 最后一首。
/// 基于 content.anchoredPosition.y 在 bottomBound ~ topBound 区间归一化。
/// </summary>
public float ScrollProgress =>
songItems.Count <= 1
? 0f
: Mathf.InverseLerp(bottomBound, topBound, content.anchoredPosition.y);
/// <summary>
/// 当前列表的连续歌曲索引0 = 第一首1 = 第二首。
/// 拖动或吸附动画中会返回小数,供外部 UI 跟随歌曲滑动。
/// </summary>
public float ContinuousSongIndex
{
get
{
if (songItems.Count <= 1)
{
return 0f;
}
return Mathf.Clamp(ScrollProgress * (songItems.Count - 1), 0f, songItems.Count - 1);
}
}
/// <summary>
/// 当前列表中的歌曲总数。
/// </summary>
public int SongCount => songItems.Count;
private IEnumerator SnapCoroutine;
// SnapToClosest 和 SnapToItem 这两个协程也需要微调,以确保它们能正确地与新的平滑系统协作
private IEnumerator SnapToClosest()
{
if (isSnapRequested)
{
yield break;
}
isSnapRequested = true;
// ... (寻找最近项的逻辑不变) ...
yield return new WaitForEndOfFrame();
float projectedContentY = Mathf.Clamp(
targetPosition.y + velocity.y * snapVelocityProjectionTime,
bottomBound,
topBound);
RectTransform closestItem = FindClosestItemToContentY(projectedContentY);
velocity = Vector2.zero;
if (closestItem != null)
{
yield return SnapToItem(closestItem, false);
}
isSnapRequested = false;
}
private RectTransform FindClosestItemToContentY(float contentY)
{
float minDistance = float.MaxValue;
RectTransform closestItem = null;
foreach (RectTransform item in songItems)
{
Vector3 itemLocalPos = viewport.InverseTransformPoint(item.position);
Vector3 centerLocalPos = viewport.InverseTransformPoint(centerPoint.position);
float localOffsetY = centerLocalPos.y - itemLocalPos.y;
float itemTargetY = Mathf.Clamp(content.anchoredPosition.y + localOffsetY, bottomBound, topBound);
float distance = Mathf.Abs(itemTargetY - contentY);
if (distance < minDistance)
{
minDistance = distance;
closestItem = item;
}
}
return closestItem;
}
public IEnumerator SnapToTab(SongSelectionTab tab)
{
selectedTab?.SetSelection(false);
selectedTab = null; // 清除当前选中的Tab
isSnapRequested = false;
if (isDuringSnap && SnapCoroutine != null)
{
StopCoroutine(SnapCoroutine);
}
SnapCoroutine = SnapToItem(tab.GetComponent<RectTransform>(), false);
yield return StartCoroutine(SnapCoroutine);
}
private IEnumerator SnapToItem(RectTransform targetItem, bool immediate)
{
if (!immediate)
{
yield return new WaitForEndOfFrame();
}
Debug.Log("开始对齐到: " + targetItem.name);
selectedTab = targetItem.GetComponent<SongSelectionTab>();
selectedTab.SetSelection(true);
SongItemData connectedSong = selectedTab.connectedSong;
MenuManager.instance.songSelectionUIPage.selectedSong = connectedSong;
MenuManager.instance.songSelectionUIPage.selectedSave = GameSaveManager.instance.SongSaveModule.GetSongStatusSave(connectedSong.songName);
Vector3 closestItemLocalPos = viewport.InverseTransformPoint(targetItem.position);
Vector3 centerPointLocalPos = viewport.InverseTransformPoint(centerPoint.position);
float localOffsetY = centerPointLocalPos.y - closestItemLocalPos.y;
Vector2 finalTargetPosition = content.anchoredPosition + new Vector2(0, localOffsetY);
finalTargetPosition.y = Mathf.Clamp(finalTargetPosition.y, bottomBound, topBound);
if (immediate)
{
// 立即模式直接设置Content和Target
targetPosition = finalTargetPosition;
content.anchoredPosition = finalTargetPosition;
}
else
{
isDuringSnap = true;
// 动画模式只更新Target让LateUpdate中的Lerp来完成动画
targetPosition = finalTargetPosition;
// 我们也可以在这里保留一个独立的Lerp循环以使用不同的snapSpeed
velocity = Vector2.zero;
while (Mathf.Abs(content.anchoredPosition.y - targetPosition.y) > 1f)
{
content.anchoredPosition = Vector2.Lerp(content.anchoredPosition, targetPosition, Time.deltaTime * snapSpeed);
yield return null;
}
content.anchoredPosition = targetPosition;
}
Debug.Log($"已对齐到: {targetItem.GetComponent<SongSelectionTab>().songNameText.text}");
SongSelectionManager.instance.SetPreview(connectedSong, selectedTab.isLocked);
MenuManager.instance.songSelectionUIPage.difficultySelectionContainer.SetUp(connectedSong.difficultyDataList);
MenuManager.instance.songSelectionUIPage.songInfoUI.SetIllustration(connectedSong.illustration, connectedSong.illustratorName);
isDuringSnap = false;
}
}
}