优化一些动画效果

Signed-off-by: TRADER_FOER <lhf190@outlook.com>
This commit is contained in:
2026-07-26 12:12:45 +08:00
parent a34461d31f
commit a00046128d
84 changed files with 8241 additions and 1681 deletions

View 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);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b6ebaa592d5a7fc4594d65a24bd0ec76

View File

@@ -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;
}
}

View File

@@ -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();
}