167
Assets/Scripts/UI/SongSelection/SongDotAnimationUI.cs
Normal file
167
Assets/Scripts/UI/SongSelection/SongDotAnimationUI.cs
Normal file
@@ -0,0 +1,167 @@
|
||||
using System.Collections.Generic;
|
||||
using Ichni.Menu;
|
||||
using UnityEngine;
|
||||
|
||||
public class SongDotAnimationUI : MonoBehaviour
|
||||
{
|
||||
[Header("引用")]
|
||||
public SongListControllerUI controllerUI;
|
||||
|
||||
[Header("预制体")]
|
||||
public GameObject DotPfab;
|
||||
|
||||
[Header("布局参数")]
|
||||
[SerializeField] private float innerOffset = 400f;
|
||||
[SerializeField] private float outerOffset = 600f;
|
||||
[SerializeField] [Min(1f)] private float visibleSongRadius = 2f;
|
||||
[SerializeField] [Min(1)] private int visibleDotCount = 5;
|
||||
[SerializeField] [Range(0f, 1f)] private float outerScale = 0f;
|
||||
[SerializeField] [Range(0f, 1f)] private float midScale = 0.2f;
|
||||
[SerializeField] [Range(0f, 1f)] private float outerAlpha = 0f;
|
||||
[SerializeField] [Range(0f, 1f)] private float midAlpha = 0.45f;
|
||||
|
||||
private readonly List<RectTransform> dots = new();
|
||||
private readonly List<CanvasGroup> dotCanvasGroups = new();
|
||||
private int displayedCount = -1;
|
||||
|
||||
public int Count => controllerUI != null ? controllerUI.SongCount : 0;
|
||||
public float ScrollProgress => controllerUI != null ? controllerUI.ScrollProgress : 0f;
|
||||
|
||||
void Start()
|
||||
{
|
||||
RebuildDotsIfNeeded();
|
||||
}
|
||||
|
||||
void LateUpdate()
|
||||
{
|
||||
RebuildDotsIfNeeded();
|
||||
|
||||
if (controllerUI == null || dots.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float centerIndex = controllerUI.ContinuousSongIndex;
|
||||
int firstVisibleSongIndex = Mathf.FloorToInt(centerIndex) - visibleDotCount / 2;
|
||||
for (int i = 0; i < dots.Count; i++)
|
||||
{
|
||||
RectTransform dot = dots[i];
|
||||
int songIndex = firstVisibleSongIndex + i;
|
||||
if (songIndex < 0 || songIndex >= Count)
|
||||
{
|
||||
SetDotVisible(i, false);
|
||||
continue;
|
||||
}
|
||||
|
||||
float relativeIndex = songIndex - centerIndex;
|
||||
float absoluteRelativeIndex = Mathf.Abs(relativeIndex);
|
||||
bool isVisible = absoluteRelativeIndex <= visibleSongRadius;
|
||||
|
||||
SetDotVisible(i, isVisible);
|
||||
if (!isVisible)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
float y = GetDotY(relativeIndex);
|
||||
dots[i].anchoredPosition = new Vector2(0f, y);
|
||||
|
||||
float scale = GetDotScale(absoluteRelativeIndex);
|
||||
dots[i].localScale = Vector3.one * Mathf.Clamp01(scale);
|
||||
|
||||
if (i < dotCanvasGroups.Count && dotCanvasGroups[i] != null)
|
||||
{
|
||||
dotCanvasGroups[i].alpha = GetDotAlpha(absoluteRelativeIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RebuildDotsIfNeeded()
|
||||
{
|
||||
int targetCount = Mathf.Min(Count, visibleDotCount);
|
||||
if (displayedCount == targetCount)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = dots.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (dots[i] != null)
|
||||
{
|
||||
Destroy(dots[i].gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
dots.Clear();
|
||||
dotCanvasGroups.Clear();
|
||||
|
||||
if (DotPfab == null)
|
||||
{
|
||||
displayedCount = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < targetCount; i++)
|
||||
{
|
||||
GameObject go = Instantiate(DotPfab, transform);
|
||||
dots.Add(go.GetComponent<RectTransform>());
|
||||
|
||||
CanvasGroup canvasGroup = go.GetComponent<CanvasGroup>();
|
||||
if (canvasGroup == null)
|
||||
{
|
||||
canvasGroup = go.AddComponent<CanvasGroup>();
|
||||
}
|
||||
|
||||
dotCanvasGroups.Add(canvasGroup);
|
||||
}
|
||||
|
||||
displayedCount = targetCount;
|
||||
}
|
||||
|
||||
private void SetDotVisible(int dotIndex, bool isVisible)
|
||||
{
|
||||
RectTransform dot = dots[dotIndex];
|
||||
if (dot.gameObject.activeSelf != isVisible)
|
||||
{
|
||||
dot.gameObject.SetActive(isVisible);
|
||||
}
|
||||
}
|
||||
|
||||
private float GetDotY(float relativeIndex)
|
||||
{
|
||||
float absRelativeIndex = Mathf.Abs(relativeIndex);
|
||||
if (absRelativeIndex <= 1f)
|
||||
{
|
||||
return -relativeIndex * innerOffset;
|
||||
}
|
||||
|
||||
float scaleRange = Mathf.Max(visibleSongRadius - 1f, 0.0001f);
|
||||
float outerT = Mathf.Clamp01((absRelativeIndex - 1f) / scaleRange);
|
||||
float distance = Mathf.Lerp(innerOffset, outerOffset, outerT);
|
||||
return -Mathf.Sign(relativeIndex) * distance;
|
||||
}
|
||||
|
||||
private float GetDotScale(float absoluteRelativeIndex)
|
||||
{
|
||||
if (absoluteRelativeIndex <= 1f)
|
||||
{
|
||||
return Mathf.Lerp(1f, midScale, absoluteRelativeIndex);
|
||||
}
|
||||
|
||||
float scaleRange = Mathf.Max(visibleSongRadius - 1f, 0.0001f);
|
||||
float outerT = Mathf.Clamp01((absoluteRelativeIndex - 1f) / scaleRange);
|
||||
return Mathf.Lerp(midScale, outerScale, outerT);
|
||||
}
|
||||
|
||||
private float GetDotAlpha(float absoluteRelativeIndex)
|
||||
{
|
||||
if (absoluteRelativeIndex <= 1f)
|
||||
{
|
||||
return Mathf.Lerp(1f, midAlpha, absoluteRelativeIndex);
|
||||
}
|
||||
|
||||
float scaleRange = Mathf.Max(visibleSongRadius - 1f, 0.0001f);
|
||||
float outerT = Mathf.Clamp01((absoluteRelativeIndex - 1f) / scaleRange);
|
||||
return Mathf.Lerp(midAlpha, outerAlpha, outerT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b6ebaa592d5a7fc4594d65a24bd0ec76
|
||||
@@ -10,34 +10,41 @@ using SLSUtilities.WwiseAssistance;
|
||||
|
||||
namespace Ichni.Menu
|
||||
{
|
||||
// 一个完全自定义的列表控制器,实现了拖拽、惯性、边界和吸附
|
||||
// 一个完全自定义的列表控制器,实现了拖拽、惯性、边界和吸附
|
||||
public partial class SongListControllerUI : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
|
||||
{
|
||||
[Title("核心组件")]
|
||||
[Title("核心组件")]
|
||||
[SerializeField] public RectTransform content;
|
||||
[SerializeField] public RectTransform viewport;
|
||||
|
||||
[Title("预制体")]
|
||||
[SerializeField]
|
||||
[Title("预制体")]
|
||||
[SerializeField]
|
||||
private GameObject songItemPrefab;
|
||||
|
||||
[Title("对齐与动画")]
|
||||
[Title("对齐与动画")]
|
||||
[SerializeField] public RectTransform centerPoint;
|
||||
[SerializeField] private float snapSpeed = 10f;
|
||||
[SerializeField] private float decelerationRate = 0.15f;
|
||||
|
||||
[Title("平滑度优化")]
|
||||
[SerializeField] [Range(1f, 20f)]
|
||||
[Title("平滑度优化")]
|
||||
[SerializeField]
|
||||
[Range(1f, 20f)]
|
||||
private float dragSmoothing = 16f; // 拖拽平滑度的阻尼值,可在Inspector中调节
|
||||
[SerializeField][Range(1f, 20f)]
|
||||
[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;
|
||||
@@ -47,6 +54,7 @@ namespace Ichni.Menu
|
||||
private Vector2 targetPosition; // 【新增】内容的目标位置
|
||||
private float targetX = 1500f;
|
||||
public bool isDuringSnap = false;
|
||||
private bool isSnapRequested = false;
|
||||
public RectTransform closestTab;
|
||||
|
||||
// 仅供 Story SongBlock 在“下一次初始化列表”时指定初始歌曲;不写入章节缓存,
|
||||
@@ -82,7 +90,7 @@ namespace Ichni.Menu
|
||||
_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高度的一半
|
||||
@@ -146,14 +154,14 @@ namespace Ichni.Menu
|
||||
|
||||
_initialSongSelectionOverride = null;
|
||||
}
|
||||
|
||||
|
||||
StartCoroutine(SnapToItem(songItems[songIndex], true));
|
||||
closestTab = songItems[songIndex];
|
||||
|
||||
|
||||
targetPosition = content.anchoredPosition;
|
||||
targetX = -1500f;
|
||||
}
|
||||
|
||||
|
||||
private void Update()
|
||||
{
|
||||
RectTransform closestItem = closestTab;
|
||||
@@ -167,7 +175,7 @@ namespace Ichni.Menu
|
||||
closestItem = item;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (closestItem != null && closestTab != closestItem)
|
||||
{
|
||||
closestTab = closestItem;
|
||||
@@ -181,21 +189,20 @@ namespace Ichni.Menu
|
||||
// 如果不在拖拽,则处理惯性
|
||||
// 注意:我们只在 isDragging 为 false 时处理惯性,
|
||||
// OnEndDrag中已经对速度进行了判断,所以这里的逻辑依然适用
|
||||
if (!isDragging && Mathf.Abs(velocity.y) > 0.1f)
|
||||
if (!isDragging && !isDuringSnap && Mathf.Abs(velocity.y) > 0.1f)
|
||||
{
|
||||
// 将速度施加于目标位置
|
||||
targetPosition.y += velocity.y * Time.deltaTime;
|
||||
// 速度衰减
|
||||
velocity *= (1 - decelerationRate);
|
||||
|
||||
// 当速度足够小时,停止惯性并触发吸附
|
||||
if (Mathf.Abs(velocity.y) <= 0.1f)
|
||||
// 惯性速度回落到吸附范围后,就请求吸附到最近歌曲。
|
||||
if (!isSnapRequested && Mathf.Abs(velocity.y) <= SnapThreshold)
|
||||
{
|
||||
velocity = Vector2.zero;
|
||||
StartCoroutine(SnapToClosest());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 无论何时,都将Content的实际位置平滑地Lerp到目标位置
|
||||
float damping = isDragging ? dragSmoothing : releaseSmoothing;
|
||||
Vector2 finalPosition = Vector2.Lerp(content.anchoredPosition, targetPosition, Time.deltaTime * damping);
|
||||
@@ -204,15 +211,17 @@ namespace Ichni.Menu
|
||||
// 每次更新后都施加边界限制
|
||||
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
|
||||
|
||||
@@ -228,7 +237,7 @@ namespace Ichni.Menu
|
||||
// 【核心修正 #1】计算速度时,只使用Y轴分量
|
||||
velocity = new Vector2(0, eventData.delta.y / Time.deltaTime);
|
||||
}
|
||||
|
||||
|
||||
public void OnEndDrag(PointerEventData eventData)
|
||||
{
|
||||
isDragging = false;
|
||||
@@ -244,7 +253,6 @@ namespace Ichni.Menu
|
||||
else
|
||||
{
|
||||
// 速度很小,是“慢速拖拽”,立即开始吸附
|
||||
velocity = Vector2.zero; // 清除残余速度
|
||||
StartCoroutine(SnapToClosest());
|
||||
}
|
||||
}
|
||||
@@ -255,7 +263,7 @@ namespace Ichni.Menu
|
||||
targetPosition.y = Mathf.Clamp(targetPosition.y, bottomBound, topBound);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public partial class SongListControllerUI
|
||||
{
|
||||
public void ClearSongTabs()
|
||||
@@ -276,8 +284,9 @@ namespace Ichni.Menu
|
||||
SnapCoroutine = null;
|
||||
targetPosition = content.anchoredPosition;
|
||||
isDuringSnap = false;
|
||||
isSnapRequested = false;
|
||||
}
|
||||
|
||||
|
||||
public void GenerateSongTabs()
|
||||
{
|
||||
ChapterSelectionUnit chapterUnit = ChapterSelectionManager.instance.currentChapter;
|
||||
@@ -315,7 +324,7 @@ namespace Ichni.Menu
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void GoToNextTab()
|
||||
{
|
||||
if (closestTab != null)
|
||||
@@ -332,18 +341,79 @@ namespace Ichni.Menu
|
||||
|
||||
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)
|
||||
{
|
||||
float distance = Mathf.Abs(item.position.y - centerPoint.position.y);
|
||||
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;
|
||||
@@ -351,24 +421,22 @@ namespace Ichni.Menu
|
||||
}
|
||||
}
|
||||
|
||||
if (closestItem != null)
|
||||
{
|
||||
yield return SnapToItem(closestItem, false);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -378,20 +446,20 @@ namespace Ichni.Menu
|
||||
{
|
||||
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);
|
||||
|
||||
@@ -419,11 +487,11 @@ namespace Ichni.Menu
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,10 +16,10 @@ namespace Ichni.Menu
|
||||
|
||||
[Tooltip("从选曲页进入当前章节剧情树的按钮。会优先定位当前选中歌曲对应的 SongBlock;未找到时显示剧情开头。")]
|
||||
public Button enterStoryButton;
|
||||
|
||||
|
||||
public Gradient2 backgroundFrames;
|
||||
public float framePositionOffset = -1;
|
||||
|
||||
|
||||
public SongListControllerUI songListController;
|
||||
public PlaySongUI playSongUI;
|
||||
public SongInfoUI songInfoUI;
|
||||
@@ -47,12 +47,12 @@ namespace Ichni.Menu
|
||||
MenuInputManager.OnMoveUp += songListController.GoToFormerTab;
|
||||
MenuInputManager.OnMoveDown += songListController.GoToNextTab;
|
||||
};
|
||||
|
||||
|
||||
backButton.onClick.AddListener(ReturnToChapterSelection);
|
||||
|
||||
if (enterStoryButton != null)
|
||||
enterStoryButton.onClick.AddListener(OpenStoryForSelectedSong);
|
||||
|
||||
|
||||
songInfoUI.SetUp();
|
||||
}
|
||||
|
||||
@@ -93,10 +93,10 @@ namespace Ichni.Menu
|
||||
private void Start()
|
||||
{
|
||||
Sequence framesSeq = DOTween.Sequence();
|
||||
framesSeq.Append(DOTween.To(() => framePositionOffset, x => framePositionOffset = x, 1f, 1f)
|
||||
framesSeq.Append(DOTween.To(() => framePositionOffset, x => framePositionOffset = x, 1f, 3f)
|
||||
.SetEase(Ease.Linear)
|
||||
.OnUpdate(() => backgroundFrames.Offset = framePositionOffset));
|
||||
framesSeq.AppendInterval(4f);
|
||||
framesSeq.AppendInterval(1f);
|
||||
framesSeq.SetLoops(-1, LoopType.Restart);
|
||||
framesSeq.Play();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user